본문 바로가기

안드로이드 Android

안드로이드 개발 (30) viewModelScope

Android 는 현재 집중적으로 Coroutine 을 밀고 있습니다. Android에서 Coroutine을 응용한 api와 Coroutine 관련된 코드 스니펫 등등이 등장하면서 앞으로 Android에서 Coroutine을 활용할일이 많아지고 있습니다. 그래서 과거 비동기 관련 코드들을 Coroutine 으로 마이그레이션을 진행중이거나 완료한 소식들을 간혹 접하기도 했습니다. 그만큼 Coroutine에 대한 열풍이 뜨겁다고 느껴집니다. 

1. ViewModelScope 란? 

 

특히 Jetpack Library 를 많이 사용하는데 Jetpack에 viewModelScope 라는 Coroutine Scope를 지원해줍니다. 

viewModelScope는 ViewModel에서 onCleared() 호출 할때 직접 coroutine context를 명시적으로 취소를 하지않아도 자동적으로 onCleared() 호출 될때 coroutine 작업을 취소합니다.

 

잠시 예제를 보자면 아래와 같습니다. 

// ViewModelScope 비사용시..
class SampleViewModel : ViewModel() {
    private val job = SupervisorJob()
    private val uiScope = CoroutineScope(Dispatchers.Main + job)
        ...
    fun loadData() = uiScope.launch {
        ...
    }

    override fun onCleared() {
        super.onCleared()
        job.cancel()
    }
}

viewModelScope를 사용하지 않고 ViewModel에서 Coroutine을 사용한다면  onCleared()에서 직접 job.cancel()를 통해서 Coroutine 작업을 취소를 해야했었습니다. 그래야 ViewModel에서 CLEARED가 호출이 될때 Coroutine 작업을 취소해서 메모리 누수를 방지할 수 있습니다.

 

위 방식은 항상 개발자가 명시적으로 표시해줘야한다는 점이 있어서 불편함을 느끼게합니다. 

다음은 viewModelScope를 사용했을 때의 경우입니다. 

//ViewModel를 사용했을 때
class SampleViewModel : ViewModel() {
    fun loadData() = viewModelScope.launch {
        ...
    }
}

상당히 코드가 깔끔해졌습니다. viewModelScope는 구글 Android팀에서 ViewModel의 onCleard가 호출된다면 자동적으로 viewModelScope의 작업을 취소하도록 만들어놨습니다.

 

즉 개발자가 일일이 onCleared()에서 job.cancle()를 명시적으로 지정하지 않아도 되서 개발자가 실수할 확률이 줄어듭니다.

 

2. ViewModelScope 내부 원리

private const val JOB_KEY = "androidx.lifecycle.ViewModelCoroutineScope.JOB_KEY"

//뷰모델 스코프 구현 코드
public val ViewModel.viewModelScope: CoroutineScope
    get() {
        val scope: CoroutineScope? = this.getTag(JOB_KEY)
        if (scope != null) {
            return scope
        }
        return setTagIfAbsent(
            JOB_KEY,
            CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
        )
    }

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context

    override fun close() {
        coroutineContext.cancel()
    }
}

viewModelScope는 kotlin 언어로 작성되었습니다. ViewModel의 확장 프로퍼티로 사용이 가능합니다.

viewModelScope 변수는 getter로 CoroutineScope를 return 해줍니다. 

 

getter 에서 CloseableCoroutineScope() 클래스를 생성할 때 매개변수로 SupervisorJob() + Dispatchers.Main.immediate를 넘겨줍니다. viewModelScope는 기본적으로 Main thread로 작업을 하는것임을 알수가 있습니다.

 

return 하는 setTagIfAbsent() 함수는 아래와 같습니다.

 

....
@Nullable
private final Map<String, Object> mBagOfTags = new HashMap<>();

<T> T setTagIfAbsent(String key, T newValue) {
        T previous;
        synchronized (mBagOfTags) {
            previous = (T) mBagOfTags.get(key);
            if (previous == null) {
                mBagOfTags.put(key, newValue);
            }
        }
        T result = previous == null ? newValue : previous;
        if (mCleared) {
            closeWithRuntimeException(result);
        }
        return result;
    }

 

setTagIfAbsent는 ViewModel의 mBagOfTags 라는 HashMap 에 중복되는 key가없다면 인자로 받아온

key,newValue를 mBagOfTags에 put 하고 난 후 매개변수로 받은 newValue를 return 해줍니다.

 

onCleared() 함수는 실은 ViewModel의 clear() 라는 함수로 인해 호출이 됩니다.

  @MainThread
    final void clear() {
        mCleared = true;
        if (mBagOfTags != null) {
            synchronized (mBagOfTags) {
                for (Object value : mBagOfTags.values()) {
                    closeWithRuntimeException(value);
                }
            }
        }
        onCleared();
    }
//위 예제 CloseableCoroutineScope 클래스에서 구현한 인터페이스 

public interface Closeable extends AutoCloseable {
    public void close() throws IOException;
}

clear() 함수가 호출되면 mBagOfTags에 있는 모든 value를 close() 합니다. 그 다음 onCleared()를 호출합니다.

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context

    override fun close() {
        coroutineContext.cancel()
    }
}

다시 viewModelScope에서 사용되었던 CloseableCoroutineScope코드를 다시보자면, Closeable 인터페이스를 상속 받아 close 함수에 coroutinecontext 객체가 cancel() 되는것을 알 수 있습니다.

 

clear 메서드는 오직 AndroidX 내부에서 호출되기 위해서 만들어졌으며 프레임워크에서 사용되는 함수입니다.

Activity 혹은 Fragment의 LifceCycle이 DESTORY 가 호출될 때 사용됩니다. 

public class ComponentActivity ... {
    ...
    getLifecycle().addObserver(new LifecycleEventObserver() {
      @Override
      public void onStateChanged(@NonNull LifecycleOwner source, 
        @NonNull Lifecycle.Event event) {
       ...
        if (event == Lifecycle.Event.ON_DESTROY) {                   
                   ...
          if (!isChangingConfigurations()) {
          // ViewModel.clear()를 ViewModelStore의 clear() 함수 안에서 사용함       
            getViewModelStore().clear(); 
          }
       }

그래서 아래와 같은 순서가 됩니다.

 

1) viewModelScope 안에서 mBagOfTags에 CloseableCoroutineScope를 통해서 해당 CoroutineContext를 put 

2) Lifecycle == DESTORY 일때 clear 함수가 호출됨

3) clear 함수에서 mBagOfTags의 값들을 모두 close() 처리

4) CloseableCoroutineScope class 에서 정의한 close() 의 내용이  coroutineContext.cancel() 임으로, 해당 작업을 취소함 

 

위와 같은 과정으로 LifeCycle DESTORY 일때 알아서 CoroutineContext가 취소 되기 때문에 viewModelScope를 사용하면 안드로이드 개발자가 뷰모델에서 편하게 Coroutine을 사용할 수 있습니다.

 

3. ViewModelScope와 Retrofit2 사용시

ViewModelScope의 Dispatchers를 바꾸지 않아도 되는 이유

 

  fun getProduct() {
        viewModelScope.launch {
            _productList.value = productRepo.getProduct()
        }
    }

위 설명대로 ViewModelScope는 withContext를 통해 Dispatchers 전환이 없다면 기본적으로 Main Thread 로 작업을 합니다.

그런데 신기한 점은 ViewModelScope 에서 Retrofit2를 사용할 때 IO Thread가 아니더라도 문제없이 Retrofit2를 사용할 수 있습니다. 그 이유는 아래와 같습니다.

 

우선 Retrofit2를 사용할 때 Coroutine을 응용해서 사용한다면 보통 아래와 같이 사용이 됩니다.

 @GET("product")
 suspend fun getProduct(): Product

기본적으로 Call<Product>를 사용했던 것과 달리 CoroutineScope 에서 실행되는 suspend 함수로 지정하면 Retrofit Package를 이용하는 개발자는 return type에 Retrofit2에서 제공하는 Call<T>를 사용하지 않고 사용합니다.

 

위 같은 문법이 가능한 이유는 

https://github.com/square/retrofit/blob/master/retrofit/src/main/java/retrofit2/KotlinExtensions.kt

 

GitHub - square/retrofit: A type-safe HTTP client for Android and the JVM

A type-safe HTTP client for Android and the JVM. Contribute to square/retrofit development by creating an account on GitHub.

github.com

suspend fun <T : Any> Call<T>.await(): T {
  return suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation {
      cancel()
    }
    enqueue(object : Callback<T> {
      override fun onResponse(call: Call<T>, response: Response<T>) {
        if (response.isSuccessful) {
          val body = response.body()
          if (body == null) {
            val invocation = call.request().tag(Invocation::class.java)!!
            val method = invocation.method()
            val e = KotlinNullPointerException("Response from " +
                method.declaringClass.name +
                '.' +
                method.name +
                " was null but response body type was declared as non-null")
            continuation.resumeWithException(e)
          } else {
            continuation.resume(body)
          }
        } else {
          continuation.resumeWithException(HttpException(response))
        }
      }

      override fun onFailure(call: Call<T>, t: Throwable) {
        continuation.resumeWithException(t)
      }
    })
  }
}

Retrofit를 만든 square에서 Coroutine 사용을 돕기 위한 kotlinExtensions를 지원해주기 때문입니다. 그래서 Coroutine과 Retorift2를 같이 사용하게 되면 Call를 내부적으로 호출하게 됩니다. 위 코드를 보면 애초에 enqueue를 통해서 내부적으로 통신을 통한 결과값을 받아서 통신이 성공/실패 여부를 알려주고 있었습니다. 

 

즉, 내부적으로 이미 통신을 해서 responseBody를 알려주고 enqueue로 결과 값을 활용하고 있었습니다. 이 얘기는 이미 백그라운드 작업을 한 후에 개발자가 결과값을 사용하는 상황입니다.

 

그래서 아래 코드는 내부적으로 통신을 통해 받은 값을 활용중이라서 IO Thread가 반드시 필요한 상황은 이미 지난 상황이 되는 것입니다.

  

  fun getProduct() {
        viewModelScope.launch {
            _productList.value = productRepo.getProduct()
        }
    }

순수 return 받는 값을 사용하기 때문에 withContext()를 사용하지 않아도 됩니다.

 

이상 viewModelScope에 대해 알아봤습니다.