Android

Android - Coroutines best practices

Sara.H 2024. 5. 15. 22:17

공식문서에서 제시한 순서대로 나열

 

1. Inject Disptachers

새로운 코루틴을 만들거나, withContext 를 호출할때 디스패처 하드코딩 피해라. 테스트 할때 TestDispatcher 주입하는게 더 쉬워진다. 

Note: The viewModelScope property of ViewModel classes is hardcoded to Dispatchers.Main. Replace it in tests by calling Dispatchers.setMain and passing in a test dispatcher.

 

2. Suspend functions should be safe to call from the main thread 

suspend function 을 호출하는 호출부에서 어떤 Dispatcher 를 써야할지 고민하지 않도록 해야한다. 

Repository 내부에서 fetchLatestNews() 라는 suspend 함수가 있다면, 해당 함수 내부적으로 withContext(Dispatchers.IO) 있는게 이상적이다. 

 

3. The ViewModel should create coroutines 

ViewModel 이 suspend 함수를 노출하는 것이 아니라, coroutine 을 생성하는 주체가 되도록 하자. 

View 는 직접적으로 코루틴을 실행시켜서 비즈니스 로직을 실행하지 않아야 한다. 

대신, 그 책임을 뷰모델에 위임하자. 

이는 뷰모델의 비즈니스 로직을 테스트 하기 쉽게 한다. 

또한 코루틴이 configuration change 에 따라 적절히 관리 될 수 있다. - viewModelScope 

만약에 lifecycleScope 을 이용해서 코루틴을 만든다고 하면, 수동으로 관리해줘야. 

 

Note: Views should trigger coroutines for UI-related logic. For example, fetching an image from the Internet or formatting a String.

 

4. Don't expose mutable types 

val uiState = MutableStateFlow(...) 하지 말고, StateFlow 타입으로 내보내자. 

뮤터블 타입에 대한 변경이 하나의 클래스에 집중되어 있는 편이 낫다. 

 

5. The data and business layer should expose suspend functions and Flows 

// Classes in the data and business layer expose
// either suspend functions or Flows
class ExampleRepository {
    suspend fun makeNetworkRequest() { /* ... */ }

    fun getExamples(): Flow<Example> { /* ... */ }
}

Classes in those layers should expose suspend functions for one-shot calls and Flow to notify about data changes. This best practice makes the caller, generally the presentation layer, able to control the execution and lifecycle of the work happening in those layers, and cancel when needed.

 

5-1. creating coroutines in the business and data layer 

 

상요자가 현재 화면에 머물러 있는 동안에만 코루틴이 실행되면 될 경우에 아래와 같은 선택지가 있다. 

getxxx 는 호출자 (대게의 경우 ViewModel) 의 라이프사이클을 따르게 된다. 

콜은 ViewModel 이 clear 될 때 캔슬될 것. 

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
) {
    suspend fun getBookAndAuthors(): BookAndAuthors {
        // In parallel, fetch books and authors and return when both requests
        // complete and the data is ready
        return coroutineScope {
            val books = async { booksRepository.getAllBooks() }
            val authors = async { authorsRepository.getAllAuthors() }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

 

한편 아래처럼 스코프를 따로 주입해주어야 하는 경우가 있을수도 있는데. 

코루틴의 실행이 하나의 스크린에 bound 되지 않은 경우에 적용 가능. 

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
) {
    // 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 { articlesDataSource.bookmarkArticle(article) }
            .join() // Wait for the coroutine to complete
    }
}

https://medium.com/androiddevelopers/coroutines-patterns-for-work-that-shouldnt-be-cancelled-e26c40f142ad

 

Coroutines & Patterns for work that shouldn’t be cancelled

Cancellation and Exceptions in Coroutines (Part 4)

medium.com

추가 정보는 위 포스트에서 확인하기 

 

6. Inject TestDispatchers in tests 

TestDispatcher 를 테스트에 주입하자 

두가지 선택지 

- StandardTestDispatcher : Queues up coroutines started on it with a scheduler. 

- UnconfinedTestDispatcher : 

 

7. Avoid GlobalScope 

Dispatcher하드코딩 하지 말자는 것과 비슷한 얘기. 

GlobalScope 를 하드코딩 한다는 것은 Dispatcher 를 하드코딩 할 가능성도 있다는 것. 

테스팅이 control 되지 않은 스코프라서 힘들다 

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

비교해보자 

 

8. Make your coroutine cancellable 

코루틴의 cancellation 은 협력적이다. - 즉, 코루틴 Job 이 캔슬될 경우 코루틴은 suspend 되거나 혹은 cancellation 을 확인하기 전까지 캔슬되지 않는다. 만약 코루틴 내부에서 blocking operation 을 한다면 코루틴이 cancellable 한지 확인하라. 

someScope.launch {
    for(file in files) {
        ensureActive() // Check for cancellation
        readFile(file)
    }
}

- 자원을 많이 사용하는 작업 전에 cancel 되었는지 확인한 후 실행하면 불필요한 작업 방지할 수 있음. 

 

 

9. Watch out for exceptions 

class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {

    fun login(username: String, token: String) {
        viewModelScope.launch {
            try {
                loginRepository.login(username, token)
                // Notify view user logged in successfully
            } catch (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

Caution: To enable coroutine cancellation, don't consume exceptions of type CancellationException (don't catch them, or always rethrow them if caught). Prefer catching specific exception types like IOException over generic types like Exception or Throwable.

Coroutine exceptions handling

 

Coroutine exceptions handling | Kotlin

 

kotlinlang.org

Exceptions in coroutines

 

Exceptions in coroutines

Cancellation and Exceptions in Coroutines (Part 3) — Gotta catch ’em all!

medium.com