해당 글은 코드스쿼드 첫 번째, 두 번째 그룹 프로젝트를 진행하면서 중요하다고 생각한 내용과 피드백받았던 내용들을 공부하기 위해 정리한 글입니다.
📌 LiveData에 데이터를 넣는 방법 (setValue, postValue) 이해하기
LiveData는 Android Architecture Components(AAC)에서 제공하는 라이브러리 중 하나
LiveData는 Observer 패턴을 따르며 데이터의 변경이 일어날 때마다 콜백을 받아 원하는 동작을 실행할 수 있다. 이때 LiveData의 값을 변경하게 해주는 함수가 바로 setValue()와 postValue()이다.
setValue()
setValue()는 메인 스레드에서 LiveData의 값을 변경해준다. LiveData를 구독하고 있는 옵저버가 있는 상태에서 setValue()를 통해 값을 변경시킨 다면 메인 스레드에서 바로 값을 변경해준다. 때문에 setValue() 함수를 호출한 뒤 바로 밑에서 getValue() 함수로 값을 읽어오면 변경된 값을 가져올 수도 있다.
이때, setValue()는 메인 스레드에서 값을 dispatch 하기 때문에 백그라운드에서 setValue()를 호출한다면 오류가 발생한다. 만약 setValue()가 동작하지 않는다면, 해당 함수가 호출되는 스레드가 메인 스레드인지 체크해봐야 한다.
postValue()
백그라운드 thread인 상황에서 LiveData 값을 set 하고 싶을 때가 있다. 그럴 때 사용하는 메서드이다. 내부적으로
new Handler(Looper.mainLooper()). post(() -> setValue())
이런 코드가 실행된다. 즉 setting 하고 싶은 값을 main lopper로 보내기 때문에 결국 메인 스레드에서 값을 변경하게 된다.
공식문서에 따르면 postValue를 연속으로 여러 번 호출할 모두 변경이 발생하지는 않고 가장 최신의 값만 1번 set 될 확률이 크다.
If you called this method multiple times before a main thread executed a posted task, only the last value would be dispatched.
📌 LiveData에 직접 데이터를 add 시키는 경우 Observer가 인식하지 못하는 이슈
private val _todoTask = MutableLiveData<MutableList<TaskDetailResponse>>()
val todoTask: LiveData<MutableList<TaskDetailResponse>>
get() = _todoTask
...
...
val tasks = taskRemoteRepository.loadTask()
_todoTask.value?.add(tasks.data.todo) // MutableLiveData에 데이터를 직접 넣어줌
미션을 수행하다 서버를 통해 DataLayer에서 가져온 데이터를 ViewModel에서 LiveData에 add를 통해 직접 집어넣어주었는데 앱을 실행하면 해당 UI가 LiveData의 변화를 Observe하지 못하는 이슈가 발생했다.
해당 문제는 https://stackoverflow.com/questions/47941537/notify-observer-when-item-is-added-to-list-of-livedata 를 통해 해결할 수 있었다.
Internally, LiveData keeps track of each change as a version number (simple counter stored as an int). Calling setValue() increments this version and updates any observers with the new data (only if the observer's version number is less than the LiveData's version).
It appears the only way to start this process is by calling setValue() or postValue(). The side-effect is if the LiveData's underlying data structure has changed (such as adding an element to a Collection), nothing will happen to communicate this to the observers.
간단히 말하면 setValue() 혹은 postValue()는 내부적으로 observe version을 올려주면서 새로운 데이터들을 observe 하는데 반해 add 같은 side-effect들을 이러한 observe를 하지 않는다는 내용이다.
실제로 setValue 내부 구현 코드를 뜯어보니
@MainThread
protected void setValue(T value) {
assertMainThread("setValue");
mVersion++;
mData = value;
dispatchingValue(null);
}
다음과 같이 setValue함수 안에는 version을 올려주며 dispatchingValue (아마 값의 변화를 dispatch해주는 부분으로 추정된다.) 해주는 코드가 있는 것을 볼 수 있었다.
그래서 이럴 땐 어떻게 해결해야할까?
1. 외부에 LiveData 타입과 동일한 List를 만들어 주어 그 List에 데이터를 추가 한 뒤 LiveData와 동기화
private var todoItem: MutableList<TaskDetailResponse> = mutableListOf() // -> 동기화를 위한 List
private val _todoTask = MutableLiveData<MutableList<TaskDetailResponse>>()
val todoTask: LiveData<MutableList<TaskDetailResponse>>
get() = _todoTask
private fun loadTasks() {
todoItem.add(tasks.data.todo)
_todoTask.value = todoItem
}
2. MutableLiveData에 데이터를 add 하는 확장함수를 만들어 준다.
private fun <E> MutableLiveData<MutableList<E>>.setList(element: E?) {
val tempList: MutableList<E> = mutableListOf()
this.value.let {
if (it != null) {
tempList.addAll(it)
}
}
if (element != null) {
tempList.add(element)
}
this.value = tempList
}
내부적으로 임시의 tempList를 만들어 주고 MutableLiveData에 있는 MutableList의 데이터를 모두 tempList에 추가해 준 뒤 그 tempList에 add 하고자 하는 element를 추가해주고 다시 this.value = tempList를 통해 동기화 시켜 주는 것이다.
사실상 1번 로직을 그대로 이용해 확장함수로 만든 것이다.
📌 공통으로 사용되는 header OkHttpClient로 설정하여 중복 코드 제거
기존에 서버와의 통신을 위한 Service 인터페이스를 구현할 때 전달한 요청 HTTP 메시지 내용이 json 형식으로 되어 있다는 것을 알려주기 위해
@Headers("Content-Type: application/json")
@GET("products?categoryId=1")
suspend fun getMainMenu(): Response<MenuData>
@Headers("Content-Type: application/json")
@GET("products?categoryId=2")
suspend fun getSoupMenu(): Response<MenuData>
@Headers("Content-Type: application/json")
@GET("products?categoryId=3")
suspend fun getSideDish(): Response<MenuData>
@Headers("Content-Type: application/json")
@GET("products/{productId}")
suspend fun getProductDetail(@Path("productId") productId: Int): Response<ProductDetail>
@Headers("Content-Type: application/json")
@POST("orders")
suspend fun orderProduct(@Body postRequest: PostRequest): Response<Error>
다음과 같이 모든 suspend 함수의 @Headers로 Content-Type을 설정해 주었다.
하지만 이렇게 모든 API 메서드에 공통으로 적용되는 Header라면 OkHttpClient에 header를 설정하는 방법으로 모든 API에 적용되도록 할 수 있다.
Network 패키지의 RetrofitAPI object에 정의되어 있는 okHttpClient에
private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}).addInterceptor { chain ->
val request = chain.request()
.newBuilder()
.addHeader("Content-Type", "application/json")
.build()
chain.proceed(request)
}
.build()
}
다음과 같이 addInterceptor를 추가해 chain을 설정해준다.
📌 코루틴으로 API 병렬 처리로 수정하기
기존코드
private fun loadMenu() {
viewModelScope.launch {
mainRepository.loadMainMenu().let {
_mainMenu.value = it?.products
}
mainRepository.loadSoupMenu().let {
_soupMenu.value = it?.products
}
mainRepository.loadSideDish().let {
_sideDish.value = it?.products
}
3개의 API에서 각각 데이터를 불러와 ViewModel에서 LiveData로 값을 넣어주는 부분에서 코루틴을 사용해보았지만 해당 코드는
mainRepository.loadMainMenu(),
mainRepository.loadSoupMenu(),
mainRepository.loadSideDish()
이 순차적으로 즉 동기적으로 진행되는 코드임을 알았다. 풀어 말하면 위 코드에서는 mainRepository.loadMainMenu() API 응답이 온 후에, mainRepository.loadSoupMenu() 요청이 시작되게 된다.
1차 수정
private fun loadMenu() {
viewModelScope.launch {
CoroutineScope(Dispatchers.IO).launch {
mainRepository.loadMainMenu().let {
_mainMenu.postValue(it?.products)
}
}
CoroutineScope(Dispatchers.IO).launch {
mainRepository.loadSoupMenu().let {
_soupMenu.postValue(it?.products)
}
}
CoroutineScope(Dispatchers.IO).launch {
mainRepository.loadSideDish().let {
_sideDish.postValue(it?.products)
}
}
}
}
피드백을 받고 1차로 viewModelScope안에 3개의 CoroutineScope를 launch 하여 수행하게 만들었다. 하지만 이렇게 launch를 할 때 새로운 scope로 하게 되면 화면 진입 후 API 호출하는 도중에 해당 화면을 이탈하면, 진행 중인 API 호출이 취소가 되는 viewModelScope를 사용하는 효과를 누릴 수 없다.
2차 수정
private fun loadMenu() {
viewModelScope.launch {
launch(Dispatchers.IO) {
mainRepository.loadMainMenu().let {
_mainMenu.postValue(it?.products)
}
}
launch(Dispatchers.IO) {
mainRepository.loadSoupMenu().let {
_soupMenu.postValue(it?.products)
}
}
launch(Dispatchers.IO) {
mainRepository.loadSideDish().let {
_sideDish.postValue(it?.products)
}
}
}
}
이런 식으로 바로 launch를 사용해야 viewModelScope 자식 스코프가 돼서, 자식 스코프 중 하나만 종료되어도 모든 스코프가 다 같이 종료되는 효과를 볼 수 있다.
📌 CoroutineExceptoinHandler로 예외처리해보기
Retrofit과 Coroutine에서 다양한 네트워크 오류에 대응하기 위한 예외처리는 여러 가지 방법으로 수행할 수 있다.
📒 try.. catch 블록
fun updateProfile(file: File) = viewModelScope.launch(Dispatchers.IO) {
try{
repository.updateProfile(file)
}catch(e: Exception){
//에러 처리 코드
}
}
가장 흔하고 간단한 방법이지만 한 앱에서 여러 개의 네트워킹 처리 함수가 존재할 텐데, 각 함수 내부에 모두 try... catch를 사용하여 보일러 플레이트가 늘어나고, 코드가 비대해질 수 있다는 단점이 있다.
📒 sealed class 안에 Success와 Error로 처리
간단하게 말하자면 통신 응답을 Result 클래스로 해놓으면 통신에 성공할 시 Result.Success <T>를 반환하고 실패할 시 Result.Error를 반환하게 된다.
sealed class Result<out T: Any> {
data class Success<out T : Any>(val data: T) : Result<T>()
data class Error(val exception: String) : Result<Nothing>()
}
suspend fun loadMenu(): Result<ProductDetail> {
val response = productDetailRomoteDataSource.loadProductsDetail()
response?.let {
return Result.Success(it)
}
return Result.Error("error")
}
리뷰어 분이 말씀하시길 CoroutineExceptoinHandler과 함께 위 방법도 많이 쓰이는 방법이라고 한다.
📒 CoroutineExceptoinHandler을 이용한 예외처리
마지막으로 CoroutineExceptoinHandler(이하 CEH)를 이용한 예외처리이다.
루트 코루틴(부모가 없는 최상위 코루틴)에 CoroutineExceptionHandler 컨텍스트 요소를 설정하면 이 핸들러는 이 루트 코루틴 및 모든 자식 코루틴들을 위한 범용적인 catch 블록과 같이 사용된다. CoroutineExceptionHandler를 이용하여 예외 상황을 복구할 수는 없다. 예외 핸들러가 호출된 이미 발생한 예외로 인해 종료되게 된다.
data class CEHModel(
val throwable: Throwable,
val errorMessage: String?
)
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
throwable.stackTrace
when (throwable) {
is SocketException -> _error.value = CEHModel(throwable, "소켓관련 오류입니다.")
is HttpException -> _error.value = CEHModel(throwable, "Http 관련 오류입니다")
is UnknownHostException -> _error.value = CEHModel(throwable, "UnknownHost 오류입니다.")
else -> _error.value = CEHModel(throwable, "알 수 없는 오류입니다.")
}
}
private fun loadMenu() {
viewModelScope.launch(exceptionHandler) {
launch(Dispatchers.Main) {
mainRepository.loadMainMenu().let {
_mainMenu.value = it?.products
}
}
launch(Dispatchers.Main) {
mainRepository.loadSoupMenu().let {
_soupMenu.value = it?.products
}
}
launch(Dispatchers.Main) {
mainRepository.loadSideDish().let {
_sideDish.value = it?.products
}
}
}
}
Http 통신 중 발생할 수 있는 exception들을 알아본 다음 해당 throwable과 state를 CEHModel로 model로 만들어 핸들링하였다.
HttpException의 경우 서버에서 상태 코드를 200 ~ 299가 아닌 값을 내려줄 때 발생되는 예외이다. 우리는 이 경우 백엔드팀에서 약속한 errorMessage를 내려주었다. 이 경우 메시지를 토스트 메시지로 사용자에게 알려주는 방식으로 예외처리를 하였다.
'Android' 카테고리의 다른 글
안드로이드 - Fragment Lifecycle (프래그먼트 생명주기) (0) | 2022.05.18 |
---|---|
안드로이드 - Activity Lifecycle (액티비티 생명주기) (0) | 2022.05.05 |
안드로이드 [Kotlin] - Retrofit, Gson을 이용한 회원가입 API 통신 (0) | 2022.04.17 |
안드로이드 [Kotlin] - TextInputLayout 및 정규식을 이용하여 회원가입 UI 구현 (2) | 2022.04.16 |
안드로이드 [Kotlin] - Retrofit, Moshi 를 이용하여 다운받은 코로나 선별 진료소 Json 데이터를 RecyclerView에 표시하기 (3) | 2022.03.29 |