본문으로 바로가기

Easy Coroutines in Android: viewModelScope

category 개발이야기/Android 2019. 11. 14. 14:05
반응형

안드로이드 Summit 2019를 보던중 Testing coroutine section에 갑자기 viewModelScope이란게 튀어 나옵니다.

..

분명 coroutineScope이긴 할텐데, ViewModel에 한정시킨 버전인듯한데..

라고는 가늠해 보지만 세미나를 진행해야 하는 입장이라 명확한 viewModelScope에 대한 글을 정리했습니다.

이 글은 하기 링크를 번역했습니다.

https://medium.com/androiddevelopers/easy-coroutines-in-android-viewmodelscope-25bffb605471


Coroutines의 취소는 단조롭고 번거로운 작업으로 심지어 까먹기도 쉽습니다.

viewModelScope은 extension property를 ViewModel에 추가하여 ViewModel이 destroy될 때 자식 coroutine들을 자동으로 취소하는 structured concurrency를 제공합니다.

structured concurrency에 대한 내용은 아래 링크를 확인하세요

https://tourspace.tistory.com/150


Scope in ViewModels

Coroutine scope은 기본적으로 내부에서 생성하는 coroutine들을 전부 tracking합니다.

따라서 scope 자체를 취소하면 내부에서 생성된 모든 coroutine들이 같이 취소합니다.

이게 ViewModel에서는 특히 중요합니다.

(사실 Activity 같이 생성과 소멸의 life cycle을 갖는것들에게는 특히나 유용합니다.)

보통 ViewModel이 destroy되면 이에 딸려있던 비동기 작업들은 전부 중지 되어야 합니다.

그렇지 않는다면 memory가 leak 나거나, 사용하지도 않는 background 작업이 돌게되니 resource 낭비가 발생합니다.

만약, ViewModel이 중지되었는데도 계속 유지되어야 하는 비동기 작업이라면 ViewModel에 있어야 하는게 아니라 app 구조상 좀더 lower layer에 위치해야 겠죠?


일반적으로 coroutine을 안드로이드에 적용할 때 SuperviorJob을 이용하여 life cycle과 맞물려 사용합니다.

ViewModel에 CoroutineScope을 추가한다면, onCleard() 함수에서 scope의 취소 하도록 구현하면 됩니다.

따라서 ViewModle의 life cycle에 의하여 coroutine도 생성하고, 소멸되도록 아래와 같이 구현할 수 있습니다.

class MyViewModel : ViewModel() {

    /**
     * This is the job for all coroutines started by this ViewModel.
     * Cancelling this job will cancel all coroutines started by this ViewModel.
     */
    private val viewModelJob = SupervisorJob()
    
    /**
     * This is the main scope for all coroutines launched by MainViewModel.
     * Since we pass viewModelJob, you can cancel all coroutines 
     * launched by uiScope by calling viewModelJob.cancel()
     */
    private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)
    
    /**
     * Cancel all coroutines when the ViewModel is cleared
     */
    override fun onCleared() {
        super.onCleared()
        viewModelJob.cancel()
    }
    
    /**
     * Heavy operation that cannot be done in the Main Thread
     */
    fun launchDataLoad() {
        uiScope.launch {
            sortList()
            // Modify UI
        }
    }
    
    suspend fun sortList() = withContext(Dispatchers.Default) {
        // Heavy work
    }
}

위 코드에서 uiScope을 이용하여 heavy한 background 작업을 수행하기 때문에 ViewModel 자체가 destroy되면 해당 작업 역시 취소 됩니다.

어디서 많이 보던 pattern 입니다.

바로 coroutine 가이드에서 제공하는 android activity에 코루틴 적용관련된 구조와 동일하네요

상세한 내용은 요기를 참고하시면 됩니다.

https://tourspace.tistory.com/153


하여튼 이런 코드들을 모든 ViewModel에 포함해서 쓰기에는 귀찮습니다. (클래스 하나 만들어서 상속해서 쓰면 될텐데..원문에서는 왜 boilerplate 라고 표현했을까용..??)

그래서 viewModelScope라는걸 아예 지원합니다. ㅎㅎㅎ


viewModelScope means less boilerplate code

AndroidX lifeCycle v2.1.0  에서 ViewModel class에 viewModelScope이라는 extention property를 제공합니다.

여기서 제공되는 ViewModel을 사용한다면 위 코드는 아래와 같이 바뀝니다.

class MyViewModel : ViewModel() {
  
    /**
     * Heavy operation that cannot be done in the Main Thread
     */
    fun launchDataLoad() {
        viewModelScope.launch {
            sortList()
            // Modify UI
        }
    }
  
    suspend fun sortList() = withContext(Dispatchers.Default) {
        // Heavy work
    }
}

CoroutineScope의 setup과 취소부분이 ViewModel class 내부에 확장되어 추가되어 빠졌습니다.

Job을 만들고 cancel을 override 하는 코드들이 ViewModel 자체에 포함되었기 때문에 실제 필요한 로직 코드만 남는거죠~

이 ViewModel을 사용하기 위해서는 build.gradle에 아래의 lib을 import 시켜야 합니다.

implementation "androidx.lifecycle.lifecycle-viewmodel-ktx$lifecycle_version"


Digging into viewModelScope

ViewModel 내부에 viewModelScope은 아래와 같이 구현되어 있습니다.
private const val JOB_KEY = "androidx.lifecycle.ViewModelCoroutineScope.JOB_KEY"

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))
    }

ViewModel class는 어떤 object 타입도 저장 가능한 ConcurrentHashSet을 속성으로 가지고 있습니다.

코드에서 보듯이 CoroutineScope을 여기에 저장하며 getTag(JOB_KEY)로 꺼내서 사용합니다.

만약 처음이라 생성된 CouroutineScope이 없다면 생성하여 ConcurrentHashSet에 저장후 사용합니다.


ViewModel이 clear되면 onCleared()가 호출되기전에 clear()가 수행됩니다.

clear() 함수에는 viewModelScope을 취소하는 코드가 들어 있습니다.

@MainThread
final void clear() {
    mCleared = true;
    // Since clear() is final, this method is still called on mock 
    // objects and in those cases, mBagOfTags is null. It'll always 
    // be empty though because setTagIfAbsent and getTag are not 
    // final so we can skip clearing it
    if (mBagOfTags != null) {
        for (Object value : mBagOfTags.values()) {
            // see comment for the similar call in setTagIfAbsent
            closeWithRuntimeException(value);
        }
    }
    onCleared();
}
흠..

필요한 부분만 발췌한거라고는 하나, 어디서 취소하는 건지 보이지 않습니다.

bag(ConcurrentHashSet)의 모든 값에 대해서 closeWithRuntimeException()을 호출 시키는 코드밖에는 아직 보이지 않네요.


closeWithRuntimeException() 함수는 넘겨받은 object가 Closeable하다면 colse 시켜주는 함수 입니다.

따라서 close가 호출시 coroutineScope이 cancel되도록 하면 되므로 viewModelScope을 생성할때 CloseableCoroutineScope을 사용하여 생성합니다.

internal class CloseableCoroutineScope(
    context: CoroutineContext
) : Closeable, CoroutineScope {
  
    override val coroutineContext: CoroutineContext = context
  
    override fun close() {
        coroutineContext.cancel()
    }
}

충분히 예상 가능한대로 CloseableCoroutineScope은 위와같이 Closeable interface를 상속받아 close() 함수를 구현하고 있습니다.

그리고 close 안에서 context 자체를 cancel() 하는 작업을 하고 있네요.


Dispatchers.Main as default

val scope = CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main)

viewModelScope을 만들때 dispatcher로는 Dispather.Main을 사용합니다.

ViewModel은 UI를 종종 update 해주기위한 컨셉의 class이기 때문에  Dispather.Main을 사용한다는건 매우 당연해 보입니다.

또한 suspende function의 특성상 특정 thread에 종속되어 동작하므로 ViewModel의 성격에 따라 동작하는 coroutine이라면 Main dispatcher를 사용하게 자연스러워 보입니다.


Unit Testing viewModelScope

Dispathers.Main은 Android의 Looper.getMainLooper() 함수를 사용하여 code를 UI thread에서 수행 시킵니다.
따라서 Unit test에서는 사용할수 없고 Instrumented Android test를 사용해야합니다.

따라서 Unit test에서 coroutine unit 테스트를 사용하기 위해서는 Dispathers.MainTestCoroutineDispatcher로 변경해야 합니다.
TestCoroutineDispatcherorg.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version에 포함되어 있으며, 이 library에서 제공하는 Dispatchers.setMain(dispatcher: coroutineDispatcher) api를 통해서 변경할 수 있습니다.

TestCoroutineDispatcher는 코루틴 v1.2.1에 포함된 experimental API로, dispatcher가 어떻게 동작할지, pause/resume, virtual clock를 control할수 있습니다.

만약 Dispatchers.UnconfiedDispatcher.Main을 대신해서 사용하면 Main 동작에 대한 모든 가정과 타이밍이 깨집니다.
두개의 동작 차이점은 아래 링크에서 확인 가능합니다.

Unit test를 isolation 시키고 side effect이 없도록 실행하기 위해 끝나는 cleanUP 시점에 Dispatcher.resetMain()을 호출하는것이 좋습니다.
@ExperimentalCoroutinesApi
class CoroutinesTestRule(
        val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {

    override fun starting(description: Description?) {
        super.starting(description)
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description?) {
        super.finished(description)
        Dispatchers.resetMain()
        testDispatcher.cleanupTestCoroutines()
    }
}
위와 같이 JUnitRule을 설정합니다.

그리고 나서 실제 Test를 진행하는 코드는 위 Rule을 이용하여 간단하게 작성 가능합니다.

class MainViewModelUnitTest {
  
    @get:Rule
    var coroutinesTestRule = CoroutinesTestRule()
  
    @Test
    fun test() {
        ...
    }
}


Testing coroutines using Mockito

Mockito를 사용하고 객체와 상호작용이 발생하는지에 대해서 확인 할 때 Mockito의 verify 메서드를 사용하는것은 unit test code에서 추천되는 방법은 아닙니다.
객체간 상호 작용이 발생하는 부분을 확인하기 보단 각 앱에서 사용하는 요소별 로직을 확인하는 용도로 사용하는것이 좋습니다.

객체간 상호 작용을 확인하기 전에 먼저 코루틴 작업이 완료 되었는지 체크해야 합니다.
class MainViewModel(private val dependency: Any): ViewModel {
  
  fun sampleMethod() {
    viewModelScope.launch {
      val hashCode = dependency.hashCode()
      // TODO: do something with hashCode
  }
}


테스트 코드

class MainViewModelUnitTest {

  // Mockito setup goes here
  ...
  
  @get:Rule
  var coroutinesTestRule = CoroutinesTestRule()
  
  @Test
  fun test() = coroutinesTestRule.testDispatcher.runBlockingTest {
    val subject = MainViewModel(mockObject)
    subject.sampleMethod()
    // Checks mockObject called the hashCode method that is expected from the coroutine created in sampleMethod
    verify(mockObject).hashCode()
  }
}


위 테스트 코드에서는 TestRule로 생성된 testDispatcher에서 제공하는 runBlockingTest scope을 사용합니다.

rule에 의해서 Dispatchers.MainTestCoroutineDispatcher로 override 되므로 코드에 존재하는 launch 역시 TestCoroutineDispatcher에서 동작합니다.

runBlockingTest는 코루틴이 Synchronously하게 동작하도록 만들어 줍니다.

원 코드에 launch 시키도록 되어있어 비동기로 동작하는 코드도 이 block안에 synchronouse한 동작을 보장 하므로 테스트 코드의 마지막 줄에 있는 Mockito의 verfiy는, 코루틴 작업이 다 끝난 이후에 호출되어 정상적으로 테스트 결과를 체크 할 수 있습니다.


AAC가 나오고 나서 부터 안드로이드의 일부 부분에 있어서 패러다임이 바뀌어 가는 모습입니다.

(MVVM으로??)

또한 AAC에서 제공하는 컴포넌트들이 코루틴을 지원하기 위해 내부적으로 여러 작업을 추가하고 있는것이 보입니다.

ViewModel 내부에서 coroutine scope을 제공하는 것도 그렇고 Room의 dao에 suspend function을 기본적으로 선언하여 만들수 있는것도  코루틴을 적극적으로 지원하기 위한 점진적인 작업이 아닌가 싶습니다.

이렇게 안드로이드에서 신나게 밀어주고 있는데, L OS 이하에서는 쓰기 어렵다는게 아쉽네요.

제가 프로젝트에 코루틴 적용했다가 혼구멍이난 경험이 있어, 개인적으로는 안드로이드에서 밀어주지만 쓰지 못하는 아쉬움이 있네요.

혹시라도 L OS에서 왜 못쓰는지에 궁금하시면 아래 링크에서 확인하시면 됩니다.

https://tourspace.tistory.com/188

반응형