Android에서 사용하는 CoroutineScope을 알아보자
Kotlin coroutines은 기존 Java Thread, 안드로이드에서 제공하는 AsyncTask, ReactiveX 패러다임을 일부 구현한 RxJava를 대신할 수 있는 Asynchronous/Non-blocking programming을 제공한다.
참고로 기존에 작성하였던 글에서 Kotlin Coroutines을 알아보고, 안드로이드에 library 적용하기 Java Thread/AsyncTask/RxJava 활용에 대한 내용이 포함되어 있다.
coroutines은 사용하기 쉽고, 적용하기도 쉽다.
우선 안드로이드 환경에서 코틀린을 적용하는 방법은 간단하다.
Github의 kotlinx.coroutines에서 Apache License, Version 2.0으로 공개되어 있는 coroutines 소스를 확인할 수 있다.
build.gradle 파일에 아래 coroutines-android를 적용함으로써 코루틴의 사용 준비는 끝이 난다.
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
사이트에 나와있지만 당연히 Kotlin 개발 환경에서 위 dependencies 적용이 가능하다.
Coroutine 접근하는 방법
필자가 작성한 앞선 글에서도 확인할 수 있지만, 코루틴은 두 가지 접근 방법을 통해 사용이 가능하다.
코루틴을 활용하는 데 있어 CoroutineScope이 interface로 정의되어 있다. 이 interface 정의를 통해 매번 원하는 형태의 CoroutineContext를 정의할 수 있고, Coroutines의 생명 주기를 관리할 수 있다.
CoroutineScope.kt
파일 안에는 이러한 interface 정의되어 있으며, CoroutineScope을 상속 받아 CoroutineScope과 GlobalScope 등에서 이를 활용하고 있다.
public interface CoroutineScope {
/**
* Context of this scope.
*/
public val coroutineContext: CoroutineContext
}
어플리케이션이 동작하는 동안 별도의 생명 주기를 관리하지 않고 사용할 수 있는 GlobalScope이 있다. 이는 안드로이드 앱이 처음 시작부터 종료 할때까지 하나의 CoroutineContext 안에서 동작하도록 할 수 있다.
이러한 CoroutineScope과 GlobalScope을 각각 알아보도록 한다.
필요할 때 선언하고, 종료하자
필요할 때 선언하고, 종료하는 게 필요하다. 예를 들면 아래와 같을 수 있겠다.
현 재 화면을 벗어나면 더 이상 통신을 할 필요가 없다.
- 리스트를 갱신하기 위한 최신 데이터를 불러온다.
- 리스트를 불러오는 중 서버 응답이 느려 답답해진 사용자는 창을 닫아버린다.
이 경우는 백그라운드에서 계속 다운로드할 필요가 없어진다.(캐싱을 하는 앱이라면 이후 처리가 필요하므로 별도의 작업을 하겠지만) 이 경우 굳이 GlobalScope을 활용하여 이 작업을 살려둘 필요가 없어진다.
이 경우에 매번 새롭게 생성하는 CoroutineScope을 활용함으로써 효율을 높일 수 있다.
이러한 CoroutineScope의 내부 초기화 코드는 아래와 같은데, 이는 fun으로 시작해서 함수이다.
@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())
그래서 내부에 정의되어 있는 클래스는 ContextScope이라는 클래스를 초기화해서 사용하고 있음을 알 수 있다.
internal class ContextScope(context: CoroutineContext) : CoroutineScope {
override val coroutineContext: CoroutineContext = context
}
결국 CoroutineScope은 CoroutineContext(Main(안드로이드에서는 UI thread)/IO thread)를 통해 원하는 형태로 초기화하는 하나의 Block을 만들어 사용함을 알 수 있다.
이를 사용할때는 아래와 같다.
앱이 동작하는 동안 사용해보자.
이번엔 앱이 동작하는 동안 사용할 수 있는 GlobalScope을 알아보자.
당연히 앱을 사용하면서 장시간 동작해야 할 thread가 필요하다면 매번 생성하는 CoroutineScope보다는 최초 접근 시 만들어지는 GlobalScope이 효율적이다.
이러한 GlobalScope을 사용하더라도, 안드로이드 환경에서는 백그라운드 잡이 또 필요하여 WorkManager을 활용해야 한다.
GlobalScope + WorkManager 활용 예)
다만, GlobalScope은 Job을 컨트롤하기에 접합하지 않음에 주의해야 하는데, 하나하나의 launch/actor 등에 CoroutineContext와 Job을 함께 사용하지 않으면 제어가 쉽지 않다.
이러한 GlobalScope은 interface CoroutineScope을 상속받아 구현되어있는데, 기본 CoroutineContext를 EmptyCoroutineContext을 활용하고 있다.
public object GlobalScope : CoroutineScope {
/**
* Returns [EmptyCoroutineContext].
*/
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}
GlobalScope을 안드로이드의 테스트 코드에서 동작하면 아래와 같은 ThreadType을 확인할 수 있는데, 별도로 지정하지 않은 상태에서는 IO가 기본으로 동작한다.
Thread[DefaultDispatcher-worker-1 @coroutine#2,5,main]
이런 GlobalScope의 좋은 예는 API 상으로 아래와 같이 소개하고 있다.
fun ReceiveChannel<Int>.sqrt(): ReceiveChannel<Double> = GlobalScope.produce(Dispatchers.Unconfined) {
for (number in this) {
send(Math.sqrt(number))
}
}
Android 환경에서의 CoroutineScope 활용하기
Android 환경에서는 CoroutineScope을 활용하기 위해서 CoroutineScope을 상속받아고, 이를 Android Lifecycle에 맞게 사용하는 걸 권장하고 있고 Activity가 완전히 내려가는 onDestroy
에서 Job을 종료하는 걸 추천하고있다.
참고로 필자가 구현한 방법은 CoroutineScope을 Activity/Fragment/ViewModel/LifecycleObserver 등에서 활용할 수 있도록 하다 보니 Kotlin delegation을 활용하여 구현하여 일부 코드를 여기에 붙였다.
여기에 나오는 코드들은 아래 링크를 통해 확인이 가능하다.
CoroutineScope을 interface로 정의
interface BaseCoroutineScope : CoroutineScope {
val job: Job
/**
* Coroutine job cancel
*/
fun releaseCoroutine()
}
이 코드에서 중요한 부분은 Job 부분이다. CoroutineScope을 상속받아 사용할 경우에는 CoroutineScope의 동작을 제어할 녀석이 필요하다. 이를 Job을 이용하여 할 수 있다.
이러한 Job은 안드로이드 상에서는 Lifecycle을 활용할 수 있도록 도와주는데 아래와 같은 job의 동작 방법을 알 수 있다.
Job에 대해서는 다음 글에서 좀 더 자세히 다루어보겠다.
BaseCoroutineScope을 상속받아 CoroutineScope을 구현
필자는 Delegation 패턴을 활용하기 위해서 미리 UICoroutineScope을 구현해보았다.
UICoroutineScope에서는 Job을 추하였고, CoroutineContext를 Main 스케줄러를 활용하도록 초기화하였다.
class UICoroutineScope(private val dispatchers: CoroutineContext = DispatchersProvider.main) : BaseCoroutineScope {
override val job: Job = Job()
override val coroutineContext: CoroutineContext
get() = dispatchers + job
override fun releaseCoroutine() {
if (DEBUG) {
Log.d("UICoroutineScope", "onRelease coroutine")
}
job.cancel()
}
}
UICoroutineScope을 delegation으로 적용하면 위 코드를 그대로 가져가 job과 CoroutineContext을 함께 활용하게 된다.
Activity에서 상속 구현
BaseCoroutineScope을 바로 변수 scope인 UICoroutineScope을 활용하도록 만들었다.
abstract class CoroutineScopeActivity @JvmOverloads constructor(scope: BaseCoroutineScope = UICoroutineScope())
: AppCompatActivity(), BaseCoroutineScope by scope {
override fun onDestroy() {
super.onDestroy()
releaseCoroutine()
}
}
앞으로 CoroutineScopeActivity을 활용하는 경우에는 항상 Android Main thread를 동반하고, launch에 Job이 포함되어 하위 잡들까지 컨트롤이 가능한 형태를 만들었다.
원래 형태는
위 코드는 필자가 필요로 인해서 간단하게 구현한 베이스에 해당한다. 그렇다면 위 코드를 모두 합쳐서 사용하면 어떻게 해야 할까?
간단하게 아래와 같이 CoroutineScope을 상속받아, 직접 구현해주면 되겠다.
좀 더 쉽게 사용하기 위해서 Delegation 패턴을 활용하였을 뿐 크게 다른 부분은 없다.
class MyActivity : AppCompatActivity(), CoroutineScope {
// Job을 등록할 수 있도록 초기화
lateinit var job: Job
// 기본 Main Thread 정의와 job을 함께 초기화
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
job = Job()
}
// 작업 중이던 모든 job을 종 children을 종료 처리
override fun onDestroy() {
super.onDestroy()
job.cancel() // Cancel job on activity destroy. After destroy all children jobs will be cancelled automatically
}
}
그럼 샘플은?
위와 같이 만들었다면 별도의 CoroutineScope을 초기화 할 필요 없이 아래와 같이 launch {}
로 바로 사용이 가능하다. 필요하다면 launch()
에 thread를 변경할 수 있다.
/*
* Note how coroutine builders are scoped: if activity is destroyed or any of the launched coroutines
* in this method throws an exception, then all nested coroutines are cancelled.
*/
fun loadDataFromUI() = launch { // <- extension on current activity, launched in the main thread
val ioData = async(Dispatchers.IO) { // <- extension on launch scope, launched in IO dispatcher
// blocking I/O operation
}
// do something else concurrently with I/O
val data = ioData.await() // wait for result of I/O
draw(data) // can draw in the main thread
}
코루틴의 스레드 형태를 어떻게 가져갈지 정의(Dispatchers.Main, Dispatchers.Default
)
앞에서도 이야기하지 않은 Dispatches라는 게 있다. 이는 코루틴의 스레드를 어떠한 형태로 가져갈지를 지정할 수 있다.
일반적으로 IO 스레드와 Main 스레드가 있다. 안드로이드 환경에서는 IO는 백그라운드 잡을 말하고, Main은 UI thread를 뜻한다.
사용방법은 간단한데 CoroutineScope을 정의할 때 아래와 같이 사용 가능하다.
CoroutineScope(Dispatches.IO).launch(Dispatches.Main) {
}
이 코드에서는 CoroutineScope은 IO thread에서 동작하도록 지정해주었고, 이어서 launch에서 Main으로 변경해버렸다. 실제 동작은 IO가 아닌 Main에서 동작하게 된다.
IO로 글로벌하게 잡아두고, 특정 영역에서만 Main으로 교체하고 싶다면 아래와 같이 할 수 있다.
CoroutineScope(Dispatches.IO).launch {
launch(Dispatches.Main) {
// UI Thread
}
launch {
// IO Thread
}
// IO Thread
}
Dispatchers.Main
Dispatchers.Main은 안드로이드 용으로 제공하는 thread. Java Handler가 기본으로 초기화되어 사용된다.
Dispatches execution onto Android main thread and provides native [delay] support.
Dispatchers.Default
Dispatchers.Default은 모든 launch, async, etc에서 사용하는데, ContinuationInterceptor을 지정하지 않을 경우 기본으로 사용하는 CoroutineDispatcher이다.
마무리
기존에 작성했던 코루틴 관련 글 중에 최신화 부분을 다시 정리하였다. 다음 글에서 Job 부분을 위해서 좀 더 심도 있게 정리해보았다.