Android

LifecycleScope, ViewModelScope의 내부 구조

seokzoo 2022. 9. 14. 00:03
반응형

어느날 안드로이드 개발 단톡방에 이런 글이 올라왔다.

CoroutineScope를 이용해 코루틴을 이용할 수 있지만, 위의 사진 처럼 Activity, ViewModel에 따라 각각의 Lifecycle에 맞추어 onCleared()시에 Coroutine의 작업을 취소시켜줄 수 있다.
이처럼 Coroutine의 Scope에는 상황에 맞는 Scope가 있는데, 이중 ViewModelScope, LifecycleScope에 대해서 차근차근 알아보자.

1. LifecycleScope

먼저 모듈수준의 build.gradle에 추가를 해준다.

androidx.lifecycle:lifecycle-runtime-ktx:2.2.0 // 또는 그이상의 버전을 사용

  • LifecycleScope의 선언부이다. 설명을 읽어보면 LifecycleOwner의 Lifecycle에 CoroutineScope가 연결되어 있다고 나온다. 또한 Dispatchers.Main에 바인딩되어 있으며, Lifecycle이 파괴되면 이 스코프는 취소된다. 여기서 Dispatchers.Main은 UI 스레드로 스레드를 메인으로 사용하는 곳을 의미한다. lifecycleScope를 이용해 사용 가능하며, 커스텀 getter를 통해 lifecycle.coroutineScope로 값을 얻어온다(사용할 때마다 얻어옴).
public val Lifecycle.coroutineScope: LifecycleCoroutineScope
    get() {
        while (true) {
            val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl?
            if (existing != null) {
                return existing
            }
            val newScope = LifecycleCoroutineScopeImpl(
                this,
                SupervisorJob() + Dispatchers.Main.immediate
            )
            if (mInternalScopeRef.compareAndSet(null, newScope)) {
                newScope.register()
                return newScope
            }
        }
    }
  • 상단의 get()을 통해 얻어올 때를 보면, existing을 통해 객체가 초기화가 되어있다면 즉시 반환하고, exisiting이 null이라면 newScope에서 LifecycleCoroutineScopeImpl을 통해 해당 Lifecycle에 대한 정보를 얻는다. LifecycleCoroutineScopeImpl는 internal 가시성의 클래스로 만들어져있으며 internal은 같은 모듈 내에서 볼 수 있다.
internal class LifecycleCoroutineScopeImpl(
    override val lifecycle: Lifecycle,
    override val coroutineContext: CoroutineContext
) : LifecycleCoroutineScope(), LifecycleEventObserver {
    init {
        // in case we are initialized on a non-main thread, make a best effort check before
        // we return the scope. This is not sync but if developer is launching on a non-main
        // dispatcher, they cannot be 100% sure anyways.
        if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
            coroutineContext.cancel()
        }
    }
    fun register() {
        launch(Dispatchers.Main.immediate) {
            if (lifecycle.currentState >= Lifecycle.State.INITIALIZED) {
                lifecycle.addObserver(this@LifecycleCoroutineScopeImpl)
            } else {
                coroutineContext.cancel()
            }
        }
    }

    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
        if (lifecycle.currentState <= Lifecycle.State.DESTROYED) {
            lifecycle.removeObserver(this)
            coroutineContext.cancel()
        }
    }

클래스의 파라미터로 Lifecycle 객체와 CoroutineContext를 넘겨준다.

위에서 SupervisorJob과 Dispatchers.Main.immediate를 넘겨주는 것을 확인할 수 있다.

위의 init부터 하나씩 내려가며 확인해보면,

  • lifecycle.currentState가 DESTROYED일 경우 코루틴의 객체를 cancel해준다.
  • register()에서는 currentState가 INITIALIZED상태 이후인 경우 옵저버를 추가해준다. else문에 들어갈 수 있는경우(DESTROYED 인경우), 코루틴 객체를 cancel해준다.
    여기서 위의 register()를 호출하는 부분을 봐야하는데, 위의 커스텀 get()에서 newScope.register() 를 호출하는 if문을 보자!
    mInternalScopeRef.compareAndSet(null,newScope)를 알아보면, compareAndSet은 AtomicBoolean에 선언된 함수로 synchronized와 같게 동시성을 보장해준다.
    현재 값과 파라미터 왼쪽 값이 같다면, 파라미터 오른쪽 값으로 변경해준 뒤 true를 리턴한다.
    즉, mInternalScopeRef가 null이라면 newScope의 값으로 변경해준 뒤 true를 반환한다. 이로 인해 if문이 true가 되어 register의 함수가 실행되고, 후에 newScope는 addObserver를 통해 Observer를 가질 수 있게 된다.
  • 정리해보면, 커스텀 getter에서 mInternalScopeRef는 Lifecycle라는 추상클래스에 저장된 변수인데, mInternalScopeRef가 null이라면 job과 dispatchers의 정보를 담아 초기화 시키고, register() 메소드를 통해 lifecycle에 addObserver를 이용, 옵저버를 달아준다.
    그리고 lifecycle의 State를 감시하며 State가 DESTROYED일 때 coroutineContext를 cancel해준다!
  • onStateChanged()에서는 DESTROYED 보다 작거나 같으면 옵저버를 제거하고 코루틴을 cancel해주는데, DESTROYED는 enum const로 선언된 0이다. 0보다 작거나 같은, 즉 DESTROYED인 상태인 경우 옵저버를 제거하고 코루틴 객체를 cancel해준다.

이외에도 추상클래스로 구현된 LifecycleCoroutineScope를 통해 Lifecycle의 State에 따른 처리도 가능하다.

이는 순서대로 Lifecycle.State.CREATED, Lifecycle.State.RESUMED, Lifecycle.State.STARTED 상태에 있을 때부터 코루틴을 시작할 수 있도록 해준다.

사용법

class MyFragment: Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        lifecycleScope.launch {
            ... 어떤 작업...
        }
    }
}

이런 방법으로 사용해주면 Activity, Fragment의 수명주기에 맞게 작업이 취소되게 만들 수 있다.


2. ViewModelScope

  • ViewModelScope는 ViewModel ktx를 이용하여 사용이 가능한 extension이다.
  • CoroutineScope를 상속받고 있으며 뷰모델의 생명주기에 코루틴 작업을 맞춰줄 수 있다.
  • ViewModel.onCleared()에서 작업중인 Coroutine을 취소해준다.
androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0 // 또는 그 이상의 버전을 사용

구글에서 권장하는 ViewModelScope의 사용 방법이다.

class MyViewModel: ViewModel() {
    init {
        viewModelScope.launch {
            // Coroutine that will be canceled when the ViewModel is cleared.
        }
    }
}

한번 내부의 구조를 통해서 알아보자.
viewModelScope도 lifecycleScope와 비슷하게, 커스텀 게터를통해 접근할 때마다 인스턴스를 검사하는데, 여기선 scope를 getTag를 통해 초기화가 됐는지 확인한다. 검사 후 null이 아니면 그대로 scope를 반환하고, 초기화가 되지 않았다면 setTagIfAbsent를 통해 초기화를 진행한다.

/**
 * [CoroutineScope] tied to this [ViewModel].
 * This scope will be canceled when ViewModel will be cleared, i.e [ViewModel.onCleared] is called
 *
 * This scope is bound to
 * [Dispatchers.Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate]
 */
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()
    }
}
  1. scope의 값이 null일 경우 setTagIfAbsent를 통해 JOB_KEY와 CloseableCoroutineScope 인스턴스를 인자로 넘겨 ViewModel에 HashMap에 코루틴 객체를 넘겨준다.(이는 위의 lifecycleScope에서 scope를 얻을때와 유사하다)
  2. null이 아닐 경우 그대로 scope를 반환해준다.
//ViewModel.java

<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) {
            // It is possible that we'll call close() multiple times on the same object, but
            // Closeable interface requires close method to be idempotent:
            // "if the stream is already closed then invoking this method has no effect." (c)
            closeWithRuntimeException(result);
        }
        return result;
    }

@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) {
            synchronized (mBagOfTags) {
                for (Object value : mBagOfTags.values()) {
                    // see comment for the similar call in setTagIfAbsent
                    closeWithRuntimeException(value);
                }
            }
        }
        onCleared();
    }

private static void closeWithRuntimeException(Object obj) {
        if (obj instanceof Closeable) {
            try {
                ((Closeable) obj).close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }

setTagIfAbsent() 메소드의 내부를 보면 넘겨준 key와 CoroutineContext를 이용해 synchronized 블록을 실행하는데, mBagOfTags는 ViewModel 내부의 해시맵이다. 여기서 get()함수를 통해 key로 value를 찾아내는데, 내부에 저장된적이 없어 초기화가 되지 않았다면 null을 반환하고, previous가 null인 경우 put() 메소드를 통해 초기화를 진행한다.

후에 result에 previous가 null이면 newValue를, null이 아니면 previous를 저장해준다!

이전에 ViewModel에서 ViewModel이 해제될 때 clear()함수가 호출되는 것을 이야기한 적이 있다. 이때 mCleared값이 True가 되고, setTagIfAbsent 함수에서 closeWithRuntimeException에 result값을 보내준다.

clear()함수에서는 if(mBagOfTags != null)문에서 for문을 보면 mBagOfTags.values를 통해 해시맵의 값을 가져오는데, 이는 위에서 Closeable을 구현해주었고, setTagIfAbsent로 보내줬던 값으로 코루틴 객체가 Closeable의 인스턴스이므로 if문의 closeWithRuntimeException에서 close를 호출함으로써 ViewModel.viewModelScope에 있는 close()함수를 호출하면서 coroutineContext.cancel()을 실행하고, 이는 코루틴 객체인 Job을 취소(Cancel)해주게 된다.

정리

  1. ViewModelScope에서 생성시에 mBagOfTags에 CloseableCoroutineScope를 통해 해당 CoroutineContext를 put해준다.
  2. Lifecycle == DESTROY일 때 clear() 함수가 호출된다.
  3. clear()에서 for문을 통해 모든 mBagOfTags의 값들을 closeWithRuntimeException()에 던져준다
  4. closeWithRuntimeException()로 넘어온 value는 Closable의 인스턴스이므로, close()해준다.
  5. 위에 만들어줬던 CloseableCoroutineScope안의 close() 안에는 Coroutinecontext를 cancel해주어 모든 코루틴을 중지시킨다.

진짜 정리!

lifecycleScope, viewModelScope는 모두 각각 coroutineScope를 가지고 있고, lifecycleScope는 activity나 fragment의 lifecycle에 맞게, viewModelScope는 viewModel이 clear되는 시점에 맞게 각각의 couroutine들을 cancel() 해주어 개발자가 신경써서 coroutine을 취소해주지 않아도 된다!

출처

https://developer.android.com/topic/libraries/architecture/coroutines
https://thdev.tech/kotlin/2020/12/29/kotlin_effective_17/
https://thdev.tech/android/2019/07/14/Android-Kotlin/
https://tourspace.tistory.com/267
https://underdog11.tistory.com/entry/Kotlin-%EC%BD%94%EB%A3%A8%ED%8B%B4-Coroutine-async%EA%B3%BC-await-LifecycleScope%EA%B3%BC-ViewModelScope-3%ED%8E%B8
https://www.netguru.com/blog/android-coroutines-%EF%B8%8Fin-2021
https://codechacha.com/ko/java-atomic-types/

반응형