오늘도 더 나은 코드를 작성하였습니까?

Android의 코루틴 권장사항 본문

Coroutine

Android의 코루틴 권장사항

hik14 2021. 4. 7. 18:10

코루틴을 사용할 때 앱의 확장성과 테스트 가능성을 높여 긍정적인 영향을 미치는 권장사항을 알아보자!

 

디스패처 삽입

새 코루틴을 만들거나 withContext를 호출할 때 Dispatchers를 하드코딩하지 마세요.

// DO inject Dispatchers
class NewsRepository(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}


// DO NOT hardcode Dispatchers
class NewsRepository {
    // DO NOT use Dispatchers.Default directly, inject it instead
    suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}

이 종속 항목 삽입 패턴을 사용하면 단위 테스트와 계측 테스트의 디스패처를 TestCoroutineDispatcher로 교체하여 테스트가 더 확정적이 될 수 있으므로 테스트가 더욱 쉬워집니다. 애플리케이션에서 디스패처를 삽입하여 테스트를 더 확정적으로 만들어야 합니다

 

 

suspend 함수는 main 스레드에서 호출하기에 안전해야 한다.

suspend 함수는 main 스레드에서 호출하기에 안전한 기본 안전 함수여야 합니다.

클래스가 코루틴에서 장기 실행 차단 작업을 실행하는 경우 withContext를 사용하여 기본 스레드에서 실행을 이동하는 역할을 합니다.

이 사항은 클래스가 있는 아키텍처의 부분과 관계없이 앱의 모든 클래스에 적용됩니다.

 

class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {

    // 서버에서 뉴스 데이터를 받아온다
    // 실행을 안전함을 위해 ioDispatcher에서 해야된다. 
    suspend fun fetchLatestNews(): List<Article> {
        withContext(ioDispatcher) { /* ... implementation ... */ }
    }
}



// 최신 기사화 연관된 글쓴이를 가져온다.
class GetLatestNewsWithAuthorsUseCase(
    private val newsRepository: NewsRepository,
    private val authorsRepository: AuthorsRepository
) {
	// 뉴스 레포지터리가 main-safe하기 때문에 코루틴의 실행이 다른 스레드로 넘어가는 것이 문제되지 않는다.
	// 코루틴에서 실행하는 일은 list를 생성하고 원소를 담는 일로 간단하다. 
  
    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)
    }
}

위 패턴을 통해 앱의 확장 가능성이 높아집니다.

suspend 함수를 호출하는 클래스가 작업 유형에 어떤 Dispatcher를 사용할지 걱정할 필요가 없기 때문입니다.

이 책임은 작업을 실행하는 클래스에 있습니다.

 

ViewModel은 코루틴을 만들어야 한다.

 

ViewModel 클래스를 사용하면 비즈니스 로직을 실행하기 위해 suspend 함수를 노출하는 대신 코루틴을 만들어야 한다.

데이터 스트림을 사용하여 상태를 노출하는 대신 하나의 값만 방출해야 하는 경우 ViewModel의 suspend 함수가 유용합니다

// 뷰모델에서 코루틴 만들기.
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun loadNews() {
        viewModelScope.launch {
            val latestNewsWithAuthors = getLatestNewsWithAuthors()
            _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors)
        }
    }
}

// 뷰모델에서는 suspend function 보다 옵져버블한 상태를 유지해야된다. 
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {

    // 이렇게 하면안된다. 뉴스는 refreshed 되어야한다. 
    // suspend function을 외부에 노출하는것 대신에 
    // 위에 코드처럼 데이터 스트림을 이용해서 뉴스가 가 노출되어야 한다.
    
    suspend fun loadNews() = getLatestNewsWithAuthors()
}

View는 코루틴을 직접 트리거하여 비즈니스 로직을 실행하면 안 됩니다.

이 책임은 ViewModel에서 한다.

 

이렇게 하면 비즈니스 로직을 더 쉽게 테스트할 수 있습니다.

뷰 테스트에 필요한 계측 테스트를 사용하는 대신 ViewModel 객체를 단위 테스트할 수 있기 때문입니다.

 

또한 Job이 viewModelScope에서 시작되면 코루틴이 구성 변경에도 자동으로 유지됩니다.

대신 lifecycleScope를 사용하여 코루틴을 만들면 수동으로 처리해야 합니다.

코루틴이 ViewModel의 범위를 벗어나야 하면 비즈니스 및 데이터 영역에서 코루틴 만들기 섹션을 확인

 

변경 가능한 유형 노출하지 않음

// DO expose immutable types
class LatestNewsViewModel : ViewModel() {

    val _uiState = MutableStateFlow(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    /* ... */
}

class LatestNewsViewModel : ViewModel() {

    // DO NOT expose mutable types
    val uiState = MutableStateFlow(LatestNewsUiState.Loading)

    /* ... */
}

 

데이터 및 비즈니스 레이어는 정지 함수와 흐름을 노출해야 한다.

 

데이터 및 비즈니스 레이어의 클래스는 일반적으로 일회성 호출을 실행하거나 시간 경과에 따른 데이터 변경사항에 관해 알림을 받는 함수를 노출합니다. 이러한 레이어의 클래스는 일회성 호출용 정지 함수 데이터 변경사항에 관해 알리는 흐름을 노출해야 합니다.

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

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

 

데이터 및 비즈니스 레이어에서 코루틴 만들기

여러 가지 이유로 코루틴을 만들어야 하는 데이터 또는 비즈니스 레이어의 클래스에는 다양한 옵션이 있습니다.

코루틴에서 실행할 작업이 사용자가 현재 화면에 있을 때만 관련이 있으면 호출자의 수명 주기를 따라야 한다. 대부분의 경우 호출자는 ViewModel입니다. 이 경우 coroutineScope나 supervisorScope를 사용해야 합니다.

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    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(defaultDispatcher) {
                booksRepository.getAllBooks()
            }
            val authors = async(defaultDispatcher) {
                authorsRepository.getAllAuthors()
            }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

앱이 실행되고 있을때 작업이 관련되어 있고 작업이 특정 화면(Activity, Fragment)에 연결되어 있지 안으면 작업은 호출자의 수명 주기보다 오래 지속되어야 합니다.

이 시나리오의 경우 취소하면 안 되는 작업의 코루틴 및 패턴 블로그 게시물에 설명된 대로 외부 CoroutineScope를 사용해야 합니다.

 

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
	// 북마크를 해야된다면,
	// 사용자가 화면을 떠났음에도 불구하고 외부 스코프를 받아서 새로운 코루틴을 만들어 작업을 완료함.
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch(defaultDispatcher) {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

externalScope는 현재 화면보다 오래 지속되는 클래스에서 만들고 관리해야 합니다

Application 클래스 또는 탐색 그래프로 범위가 지정된 ViewModel에서 관리할 수도 있습니다.

 

GlobalScope 피하기

. GlobalScope를 사용하면 클래스에서 사용하는 CoroutineScope를 하드코딩하게 되고 다음과 같은 단점이 발생합니다.

  • 하드코딩 값을 승격합니다. GlobalScope를 하드코딩하면 Dispatchers를 하드코딩할 수도 있습니다.
  • 제어되지 않는 범위에서 코드가 실행되므로 테스트가 매우 어려워지고 실행을 제어할 수 없습니다.
  • 범위 자체에 빌드된 모든 코루틴에서 실행할 공통 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
    }
}

코루틴을 취소 가능하게 만들기

코루틴의 취소는 협력적으로 이루어진다. 

즉, 코루틴의 Job이 취소될 때 코루틴은 suspend 되거나 취소를 확인할 때까지 취소되지 않습니다.

코루틴에서 차단 작업을 실행하는 경우 코루틴이 취소 가능한지 확인합니다.

 

예를 들어 디스크에서 여러 파일을 읽는 경우 각 파일을 읽기 전에 코루틴이 취소되었는지 확인합니다. 취소를 확인하는 방법 중 하나는 ensureActive 함수를 호출하는 것입니다.

 

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

 

withContext  delay와 같은 kotlinx.coroutines의 모든 suspend 함수가 취소 가능합니다.

코루틴에서 이러한 함수를 호출하면 추가 작업을 실행하지 않아도 됩니다.

코루틴의 취소에 관한 자세한 내용은 코루틴의 취소 블로그 게시물을 참고하세요

 

예외에 주의

코루틴에서 발생하는 예외를 잘못 처리하면 앱이 비정상 종료될 수 있습니다.

예외가 발생할 수 있으면 viewModelScope 또는 lifecycleScope를 사용하여 만든 코루틴의 본문에서 예외를 포착합니다.

 

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 (error: Throwable) {
               
// Notify view login attempt failed
           
}
       
}
   
}
}

 

CoroutineExceptionHandler의 사용 및 기타 시나리오에 관한 자세한 내용은 코루틴의 예외 블로그 게시물을 참고하세요.

'Coroutine' 카테고리의 다른 글

코루틴 시작  (0) 2021.04.07
Kotlin 코루틴으로 앱 성능 향상  (0) 2021.04.07
Android의 Kotlin Coroutine  (0) 2021.04.07