AAC와 코루틴을 함께 사용 할 때 쓸 수 있는 유용한 built-in coroutine scopes를 알아보자. 구글에서 ViewModel, Lifecycle, LiveData에 적용할 수 있는 확장함수들을 만들어놨다.

  • ViewModelScope
  • LifecycleScope
  • LiveData

이번 글에서는 이 중에서 ViewModelScope, LiveData Builder의 사용법을 정리한다.

 


안드로이드에서 코루틴 사용하기

build.gradledependencies에 라이브러리를 추가한다. 이 글을 작성하는 시점에서 코루틴 최신 버전은 1.3.4다.

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_version"

 

ViewModel에서 코루틴 사용하기

ViewModel은 UI에서 보여줄 데이터를 관리하기 위해 사용한다. ViewModel의 주요 특징 중 하나는 생성-해제 주기가 Activity나 Fragment 같은 컴포넌트의 lifecycle를 따라간다는 점이다. 컴포넌트가 destroy 되기 전까지는 데이터를 쭉 가지고 있다가, destroy 됐을 때 onCleared()를 호출하며 해제된다.

공식 문서에 첨부된 생명주기 그림

ViewModel은 앱의 비즈니스 로직을 가지고 있기 때문에 내부에서 비싼 작업을 처리해야할 경우가 있다. onCleared()가 호출되고 ViewModel이 메모리에서 해제되면 ViewModel에서 실행 중이던 비동기 작업들은 더 이상 필요가 없어진다. 필요가 없어진 작업을 처리하는 비용을 절약하기 위해 이 작업들은 모두 멈추는 것이 좋다.

 

만약 CoroutineScope를 사용했다면 onCleared()을 오버라이딩 해서 모든 코루틴을 중지시키면 된다.

코드로 옮겨보면 아래와 같은 형태가 될것이다.

class SampleViewModel : ViewModel() {
    private val job = Job()
    private val uiScope = CoroutineScope(Dispatchers.Main + job)

    private val _toastMsg = MutableLiveData<String>()
    val toastMsg: LiveData<String> get() = _toastMsg

    fun loadData() = uiScope.launch {
        _toastMsg.value = "오래걸리는 작업 시작!" // 메인쓰레드 작업

        executeHeavyTask() // 백그라운드 작업

        _toastMsg.value = "처리 완료!" // 메인쓰레드 작업
    }

    private suspend fun executeHeavyTask() = withContext(Dispatchers.Default) {
        delay(5000L)
    }

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

(여기서 delay(5000L)는 비용이 큰 작업을 대체하는 목적으로 쓰였다.)

이 예제를 실행해보면 처음에 "오래걸리는 작업 시작!"이 뜨고, 5초 후 "작업 처리 완료!"가 표시된다.

 

ViewModelScope

ViewModelScope를 사용하면 이런 작업 취소 처리를 생략할 수 있다.

사용하려면 먼저 dependency에 ktx extension을 추가해야 한다.

implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-alpha01"

 

viewModelScope를 사용하면 lifecycle을 인식하는 CoroutineScope를 만들 수 있다. viewModelScope 블록에서 실행되는 작업은 별도의 처리를 하지 않아도 ViewModel이 clear 되는 순간 자동으로 취소된다.

class MyViewModel: ViewModel() {
    init {
        viewModelScope.launch {
            // ...
        }
    }
}

viewModelScope를 적용해 위의 예제를 수정해보자.

class SampleViewModel : ViewModel() {
    private val _toastMsg = MutableLiveData<String>()
    val toastMsg: LiveData<String> get() = _toastMsg

    fun loadData() = viewModelScope.launch {
        _toastMsg.value = "오래걸리는 작업 시작!" // 메인쓰레드 작업

        executeHeavyTask() // 백그라운드 작업

        _toastMsg.value = "처리 완료!" // 메인쓰레드 작업
    }

    private suspend fun executeHeavyTask() = withContext(Dispatchers.Default) {
        delay(5000L)
    }
}

 

viewModelScope는 어떻게 만들어졌을까?

viewModelScope의 소스코드를 잠시 살펴보자.

Closeable interface를 구현한 CloseableCoroutineScope 클래스가 만들어져 있다.

setTagIfAbsent()에 CloseableCoroutineScope 인스턴스를 인자로 넘기면, ViewModel 필드에 있는 HashMap에 코루틴 객체가 추가된다. 여기서 Closeable이 등장한 이유는 ViewModel의 close() 메서드 내부를 확인해보면 알 수 있다.

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

 

ViewModel이 해제될 때가 되면 clear()라는 함수가 호출된다.

우리가 ViewModel에서 재정의 했던 onCleared()는 clear()라는 함수 내부에서 호출되는 메서드다.

public abstract class ViewModel {
    @Nullable
    private final Map<String, Object> mBagOfTags = new HashMap<>();
    
    // ...
    
    @MainThread
    final void clear() {
        mCleared = true;

        if (mBagOfTags != null) {
            synchronized (mBagOfTags) {
                for (Object value : mBagOfTags.values()) {
                    closeWithRuntimeException(value);
                }
            }
        }
        onCleared();
    }
    
    // ...

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

ViewModel에서 관리하던 HashMap 객체들을 모두 Closeable로 변환한 후 close()를 호출함으로써 생명주기가 끝났을 때의 처리를 하고 있다.

 

 

viewModelScope means less boilerplate code

 

Android developers 블로그에서 viewModelScope의 소개를 찾아보면 이런 소제목이 들어가 있다.

이 말 그대로 CoroutineScope 클래스를 하나 만들어서 기존에 반복적으로 하던 작업을 미리 정의해둔 것이다.

 


LiveData builder

LiveData의 value을 비동기적으로 설정해야 한다면 liveData builder를 사용할 수 있다.

livedata builder 내부에서 suspend 메서드를 호출해 그 결과를 LiveData object에 넘겨줄 수 있다. { } 블럭 안의 코드는 LiveData가 active가 됐을 때 실행되고, inactive가 되면 자동으로 취소된다. 만약 작업이 완료되기 전에 취소됐다면, inactive -> active로 바꼈을 때 다시 작업을 시작한다.

 

아래는 간단한 적용 예제다. loadMovieData는 suspend 메서드고, 실행 결과를 LiveData observer에게 제공하기 위해 emit()과 emitSource() 메서드를 사용한다.

private val _movieData: LiveData<List<Movie>> = liveData {
    val data = loadMovieData() // call suspending function
    emit(data) // emit the result
}

 

확장 함수의 원형은 다음과 같다.

 * * @param context The CoroutineContext to run the given block in. Defaults to
 * [EmptyCoroutineContext] combined with
 * [Dispatchers.Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate].
 * @param timeout The timeout duration before cancelling the block if there are no active observers
 * ([LiveData.hasActiveObservers].
 * @param block The block to run when the [LiveData] has active observers.
 */
@RequiresApi(Build.VERSION_CODES.O)
@UseExperimental(ExperimentalTypeInference::class)
fun <T> liveData(
    context: CoroutineContext = EmptyCoroutineContext,
    timeout: Duration,
    @BuilderInference block: suspend LiveDataScope<T>.() -> Unit
): LiveData<T> = CoroutineLiveData(context, timeout.toMillis(), block)

생성자의 인자로 CoroutineContext와 Timeout Duration을 설정할 수 있다.

  • CoroutineContext : 따로 설정하지 않는다면 블록은 기본적으로 Main Thread에서 실행된다. 백그라운드에서 실행하고 싶으면 liveData(Dispatchers.Default) {} 이런식으로 Dispatcher를 지정해줘야 한다.
  • timeout : 위에서 LiveData가 inactive 상태가 됐을 때 block 실행이 취소된다는 얘기를 했다. 이 변수로 실행이 취소하기 전 대기 시간을 설정할 수 있다.
    timeout은 화면 회전 등의 동작이 발생했을 때 유용하게 사용할 수 있다. 취소되기 전 잠시 유예 시간을 둠으로써 active -> inactive -> active 요런 식으로 빠르게 상태가 변할 때 코드 실행이 멈추는 일을 막을 수 있다.

 

liveData()의 실행 결과로 CoroutineLiveData 객체가 반환된다. CoroutineLiveData 안에서 onActive, onInactive를 재정의 해 코루틴을 일시정지하거나 재시작 한다는 것을 알 수 있다.

    override fun onActive() {
        super.onActive()
        blockRunner?.maybeRun()
    }

    override fun onInactive() {
        super.onInactive()
        blockRunner?.cancel()
    }

 


참고 자료