Launch & Async
- launch - 새로운 Coroutine을 시작하며 호출자에게 결과를 반환하지 않는다.
- async - 새로운 Coroutine을 시작하며 await라는 suspend 함수로 결과를 반환할 수 있다.
async는 Coroutine 내부에서만 사용하거나 suspend 함수 내에서 작업을 병렬적으로 처리할 때 사용한다.
suspend 함수 내에서 시작되는 Coroutine은 함수가 반환되면 중지되어야 한다.
그러므로 반환 전에 Coroutine이 완료되도록 보장해야 한다.
이를 위해 여러 Coroutine을 실행할 수 있는 coroutineScope를 정의할 수 있다.
그리고 await() , awaitAll() 을 사용하여 함수가 반환되기 전에 Coroutine이 완료되도록 보장할 수 있다.
아래 코드는 async를 통해 시작한 Coroutine을 await 함수를 사용하여, 실행된 Coroutine이 결과를 반환할 때 까지 기다린다.
그러나 await 함수를 사용하지 않았더라도 coroutineScope Builder는 모든 Coroutine이 완료될 때 까지 기다린다.
또한 coroutineScope는 Coroutine이 발생시키는 Exception(예외)를 호출자에게 전파한다.
await()를 사용한 코드는 다음과 같다.
suspend fun fetchTwoDocs() =
coroutineScope {
val deferredOne = async { fetchDoc(1) }
val deferredTwo = async { fetchDoc(2) }
deferredOne.await()
deferredTwo.await()
}
awaitAll()을 사용한 코드는 다음과 같다.
// called on any Dispatcher (any thread, possibly Main)
suspend fun fetchTwoDocs() =
coroutineScope {
// fetch two docs at the same time
val deferreds = listOf(
// async returns a result for the first doc
async { fetchDoc(1) },
// async returns a result for the second doc
async { fetchDoc(2) }
)
// use awaitAll to wait for both network requests
deferreds.awaitAll()
}
CoroutineScope
CoroutineScope는 launch 혹은 async로 생성된 Coroutine을 추적한다.
실행 중인 Coroutine은 scope.cancel() 을 호출하여 취소할 수 있다.
ViewModel이나 Lifecycle은 자체 CoroutineScope를 제공한다.
ViewModel은 viewModelScope가 있고, Lifecycle에는 lifecycleScope가 있다.
그러나 CoroutineScope는 Coroutine을 실행하진 않는다.
scope.cancel() 에 의해 Coroutine이 한 번 취소되면 다시는 해당 scope에서 Coroutine을 생성할 수 없다.
즉, scope.cancel() 은 해당 클래스가 destroy된 경우에만 사용해야 한다.
ViewModel의 경우 onCleared() 메서드에서 자동으로 취소된다.
class ExampleClass {
// Job and Dispatcher are combined into a CoroutineContext which
// will be discussed shortly
val scope = CoroutineScope(Job() + Dispatchers.Main)
fun exampleMethod() {
// Starts a new coroutine within the scope
scope.launch {
// New coroutine that can call suspend functions
fetchDocs()
}
}
fun cleanUp() {
// Cancel the scope to cancel ongoing coroutines work
scope.cancel()
}
}
Job
Job은 Coroutine을 다루기 위해 사용한다.
launch나 async로 만들어진 Coroutine은 Coroutine을 고유하게 식별하고 lifecycle을 관리하는 Job 인스턴스를 반환한다.
다음 예시 코드와 같이 Job을 CoroutineScope에 전달하여 lifecycler을 관리할 수 있다.
class ExampleClass {
...
fun exampleMethod() {
// Handle to the coroutine, you can control its lifecycle
val job = scope.launch {
// New coroutine
}
if (...) {
// Cancel the coroutine started above,
// this doesn't affect the scope
// this coroutine was launched in
job.cancel()
}
}
}
CoroutineContext
CoroutineContext는 다음 요소를 사용하여 Coroutine의 동작을 정의한다.
- Job: Coroutine의 lifecycle 제어
- CoroutineDispatcher: 적절한 Thread에 작업 전달
- CoroutineName: Coroutine의 이름. 디버깅 시 사용
- CoroutineExceptionHandler: uncaught Exception 처리
scope 내에서 만들어진 Coroutine은 새로운 Job 인스턴스가 새 Coroutine에 할당되고 CoroutineContext 요소는 해당 scope에서 상속된다.
class ExampleClass {
val scope = CoroutineScope(Job() + Dispatchers.Main)
fun exampleMethod() {
// Starts a new coroutine on Dispatchers.Main
// as it's the scope's default
val job1 = scope.launch {
// New coroutine with CoroutineName = "coroutine" (default)
}
// Starts a new coroutine on Dispatchers.Default
val job2 = scope.launch(
Dispatchers.Default + CoroutineName("BackgroundCoroutine")
) {
// New coroutine with CoroutineName
// = "BackgroundCoroutine" (overridden)
}
}
}
Recommendation
Inject Dispatcher
새 Coroutine을 만들거나 withContext를 호출할 때 Dispatchers를 하드코딩 하지 않는 것이 좋다.
하드코딩 시 테스트가 어렵기 때문이다.
안전한 suspend 함수
suspend 함수는 메인 Thread에서 호출하기 때문에 main-safe 해야 한다.
class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {
// As this operation is manually retrieving the news from the server
// using a blocking HttpURLConnection, it needs to move the execution
// to an IO dispatcher to make it main-safe
suspend fun fetchLatestNews(): List<Article> {
withContext(ioDispatcher) { }
}
}
// This use case fetches the latest news and the associated author.
class GetLatestNewsWithAuthorsUseCase(
private val newsRepository: NewsRepository,
private val authorsRepository: AuthorsRepository
) {
// This method doesn't need to worry about moving the execution of the
// coroutine to a different thread as newsRepository is main-safe.
// The work done in the coroutine is lightweight as it only creates
// a list and add elements to it
suspend operator fun invoke(): List<ArticleWithAuthor> {
val news = newsRepository.fetchLatestNews()
val response: List<ArticleWithAuthor> = mutableEmptyList()
for (article in news) {
val author = authorsRepository.getAuthor(article.author)
response.add(ArticleWithAuthor(article, author))
}
return Result.Success(response)
}
}
ViewModel에서의 Coroutine 생성
View에서 Coroutine을 만들고 ViewModel에서 suspend 함수를 사용하지 말아야 한다.
이러한 경우 테스트가 어렵고 configuration 변경에 수동으로 처리해야 한다.
ViewModel에서 Coroutine을 생성한다면 단위 테스트를 진행할 수 있고, viewModelScope에서 실행된 작업은 configuration 변경에도 자동으로 유지된다.
GlobalScope 금지
GlobalScope는 어느 Job에도 종속되지 않고, Application의 lifecycle을 따른다.
그러나 테스트가 어렵고 실행을 제어할 수 없으며 공통 CoroutineContext를 가질 수 없다.
// DO inject an external scope instead of using GlobalScope.
// GlobalScope can be used indirectly. Here as a default parameter makes sense.
class ArticlesRepository(
private val articlesDataSource: ArticlesDataSource,
private val externalScope: CoroutineScope = GlobalScope,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
// As we want to complete bookmarking the article even if the user moves
// away from the screen, the work is done creating a new coroutine
// from an external scope
suspend fun bookmarkArticle(article: Article) {
externalScope.launch(defaultDispatcher) {
articlesDataSource.bookmarkArticle(article)
}
.join() // Wait for the coroutine to complete
}
}
// DO NOT use GlobalScope directly
class ArticlesRepository(
private val articlesDataSource: ArticlesDataSource,
) {
// As we want to complete bookmarking the article
// even if the user moves away from the screen,
// the work is done creating a new coroutine with GlobalScope
suspend fun bookmarkArticle(article: Article) {
GlobalScope.launch {
articlesDataSource.bookmarkArticle(article)
}
.join() // Wait for the coroutine to complete
}
}