Sprawdzone metody dotyczące współprogramów na Androidzie

Na tej stronie znajdziesz kilka sprawdzonych metod, które pozytywnie wpływają na skalowalność i testowalność aplikacji korzystającej ze współprogramów.

Wprowadzanie dyspozytorów

Podczas tworzenia nowych współprogramów lub wywoływania funkcji withContext nie należy na stałe kodować dyspozytorów 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) { /* ... */ }
}

Ten wzorzec wstrzykiwania zależności ułatwia testowanie, ponieważ w testach jednostkowych i testach instrumentacji można zastąpić te dyspozytory dyspozytorem testowym, aby testy były bardziej deterministyczne.

Funkcje zawieszające powinny być bezpieczne do wywoływania z wątku głównego

Funkcje zawieszające powinny być bezpieczne dla wątku głównego, co oznacza, że można je bezpiecznie wywoływać z wątku głównego. Jeśli klasa wykonuje długotrwałe operacje blokujące we współprogramie, jest odpowiedzialna za przeniesienie wykonania z wątku głównego za pomocą funkcji withContext. Dotyczy to wszystkich klas w aplikacji, niezależnie od części architektury, w której się znajdują.

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) { /* ... implementation ... */ }
    }
}

// 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(): Result<List<ArticleWithAuthor>> {
        val news = newsRepository.fetchLatestNews()

        val response = mutableListOf<ArticleWithAuthor>()
        for (article in news) {
            val author = authorsRepository.getAuthor(article.author)
            response.add(ArticleWithAuthor(article, author))
        }
        return Result.Success(response)
    }
}

Ten wzorzec zwiększa skalowalność aplikacji, ponieważ klasy wywołujące funkcje zawieszające nie muszą się martwić, jakiego dyspozytora Dispatcher użyć do danego typu pracy. Odpowiedzialność za to spoczywa na klasie, która wykonuje pracę.

Współprogramy powinny być tworzone przez ViewModel

ViewModel klasy powinny preferować tworzenie współprogramów zamiast udostępniać funkcje zawieszające do wykonywania logiki biznesowej. Funkcje zawieszające w ViewModel mogą być przydatne, jeśli zamiast udostępniać stan za pomocą strumienia danych, trzeba wyemitować tylko jedną wartość.

// DO create coroutines in the ViewModel
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)
        }
    }
}

// Prefer observable state rather than suspend functions from the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {
    // DO NOT do this. News would probably need to be refreshed as well.
    // Instead of exposing a single value with a suspend function, news should
    // be exposed using a stream of data as in the code snippet above.
    suspend fun loadNews() = getLatestNewsWithAuthors()
}

Widoki nie powinny bezpośrednio wywoływać żadnych współprogramów do wykonywania logiki biznesowej. Zamiast tego należy przekazać tę odpowiedzialność do ViewModel. Ułatwia to testowanie logiki biznesowej, ponieważ obiekty ViewModel można testować jednostkowo, zamiast używać testów instrumentacji, które są wymagane do testowania widoków.

Ponadto współprogramy automatycznie przetrwają zmiany konfiguracji, jeśli praca zostanie rozpoczęta w viewModelScope. Jeśli utworzysz współprogramy za pomocą lifecycleScope, musisz to zrobić ręcznie. Jeśli współprogram ma działać dłużej niż zakres ViewModel, zapoznaj się z sekcją Tworzenie współprogramów w warstwie biznesowej i warstwie danych.

Nie udostępniaj typów modyfikowalnych

W przypadku innych klas preferuj udostępnianie typów niezmiennych. Dzięki temu wszystkie zmiany w typie modyfikowalnym są scentralizowane w jednej klasie, co ułatwia debugowanie w przypadku wystąpienia problemu.

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

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

    /* ... */
}

class LatestNewsViewModel : ViewModel() {

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

    /* ... */
}

Warstwa danych i warstwa biznesowa powinny udostępniać funkcje zawieszające i przepływy

Klasy w warstwie danych i warstwie biznesowej zazwyczaj udostępniają funkcje do wykonywania jednorazowych wywołań lub do otrzymywania powiadomień o zmianach danych w czasie. Klasy w tych warstwach powinny udostępniać funkcje zawieszające do jednorazowych wywołań i przepływ do powiadamiania o zmianach danych.

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

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

Ta sprawdzona metoda umożliwia wywołującemu, zwykle warstwie prezentacji, kontrolowanie wykonania i cyklu życia pracy wykonywanej w tych warstwach oraz anulowanie jej w razie potrzeby.

Tworzenie współprogramów w warstwie biznesowej i warstwie danych

W przypadku klas w warstwie danych lub warstwie biznesowej, które muszą tworzyć współprogramy z różnych powodów, dostępne są różne opcje.

Jeśli praca, która ma być wykonana w tych współprogramach, jest istotna tylko wtedy, gdy użytkownik jest na bieżącym ekranie, powinna ona być zgodna z cyklem życia wywołującego. W większości przypadków wywołującym będzie ViewModel, a wywołanie zostanie anulowane, gdy użytkownik opuści ekran, a ViewModel zostanie wyczyszczony. W takim przypadku, coroutineScope lub supervisorScope należy użyć.

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

Jeśli praca, która ma być wykonana, jest istotna tak długo, jak aplikacja jest otwarta, i nie jest powiązana z konkretnym ekranem, powinna ona trwać dłużej niż cykl życia wywołującego. W tym przypadku należy użyć zewnętrznego CoroutineScope, jak opisano w poście na blogu Współprogramy i wzorce dotyczące pracy, która nie powinna zostać anulowana.

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

externalScope powinna być tworzona i zarządzana przez klasę, która działa dłużej niż bieżący ekran. Może być zarządzana przez klasę Application lub ViewModel w zakresie wykresu nawigacji.

Wprowadzanie TestDispatchers w testach

W testach do klas należy wprowadzić instancję TestDispatcher W bibliotece kotlinx-coroutines-test dostępne są 2 implementacje:

  • StandardTestDispatcher: kolejkuje współprogramy uruchomione na nim za pomocą harmonogramu i wykonuje je, gdy wątek testowy nie jest zajęty. Możesz zawiesić wątek testowy, aby umożliwić uruchomienie innych współprogramów w kolejce, za pomocą metod takich jak advanceUntilIdle.

  • UnconfinedTestDispatcher: uruchamia nowe współprogramy w sposób blokujący. Zazwyczaj ułatwia to pisanie testów, ale daje mniejszą kontrolę nad sposobem wykonywania współprogramów podczas testu.

Więcej informacji znajdziesz w dokumentacji każdej implementacji dyspozytora.

Do testowania współprogramów użyj runTest konstruktora współprogramów. runTest używa TestCoroutineScheduler aby pomijać opóźnienia w testach i umożliwiać kontrolowanie czasu wirtualnego. Możesz też użyć tego harmonogramu, aby w razie potrzeby utworzyć dodatkowe dyspozytory testowe.

class ArticlesRepositoryTest {

    @Test
    fun testBookmarkArticle() = runTest {
        // Pass the testScheduler provided by runTest's coroutine scope to
        // the test dispatcher
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)

        val articlesDataSource = FakeArticlesDataSource()
        val repository = ArticlesRepository(
            articlesDataSource,
            defaultDispatcher = testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

Wszystkie TestDispatchers powinny korzystać z tego samego harmonogramu. Dzięki temu możesz uruchamiać cały kod współprogramu w jednym wątku testowym, aby testy były deterministyczne. runTest poczeka na zakończenie wszystkich współprogramów, które są w tym samym harmonogramie lub są elementami podrzędnymi współprogramu testowego, zanim zwróci wynik.

Unikaj GlobalScope

Jest to podobne do sprawdzonej metody Wprowadzanie dyspozytorów. Używając GlobalScope, na stałe kodujesz CoroutineScope, którego używa klasa, co ma pewne wady z tym związane:

  • Promuje na stałe kodowanie wartości. Jeśli na stałe zakodujesz GlobalScope, możesz też na stałe zakodować Dispatchers.

  • Utrudnia testowanie, ponieważ kod jest wykonywany w niekontrolowanym zakresie, więc nie będziesz mieć kontroli nad jego wykonaniem.

  • Nie możesz mieć wspólnego CoroutineContext, który będzie wykonywany we wszystkich współprogramach wbudowanych w sam zakres.

Zamiast tego rozważ wprowadzenie CoroutineScope w przypadku pracy, która ma trwać dłużej niż bieżący zakres. Więcej informacji na ten temat znajdziesz w sekcji Tworzenie współprogramów w warstwie biznesowej i warstwie danych.

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

Więcej informacji o GlobalScope i jego alternatywach znajdziesz w poście na blogu Współprogramy i wzorce dotyczące pracy, która nie powinna zostać anulowana.

Umożliwianie anulowania współprogramu

Anulowanie we współprogramach jest kooperatywne, co oznacza, że gdy Job współprogramu zostanie anulowany, współprogram nie zostanie anulowany, dopóki nie zostanie zawieszony lub nie sprawdzi, czy został anulowany. Jeśli wykonujesz operacje blokujące we współprogramie, upewnij się, że współprogram jest możliwy do anulowania.

Jeśli na przykład odczytujesz wiele plików z dysku, przed rozpoczęciem odczytu każdego pliku sprawdź, czy współprogram został anulowany. Jednym ze sposobów sprawdzenia, czy współprogram został anulowany, jest wywołanie funkcji ensureActive.

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

Wszystkie funkcje zawieszające z kotlinx.coroutines, takie jak withContext i delay, można anulować. Jeśli współprogram je wywołuje, nie musisz wykonywać żadnych dodatkowych czynności.

Więcej informacji o anulowaniu we współprogramach znajdziesz w poście na blogu Anulowanie we współprogramach.

Uważaj na wyjątki

Nieobsłużone wyjątki zgłaszane we współprogramach mogą spowodować awarię aplikacji. Jeśli istnieje prawdopodobieństwo wystąpienia wyjątków, przechwytuj je w treści współprogramów utworzonych za pomocą viewModelScope lub lifecycleScope.

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

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

Więcej informacji znajdziesz w poście na blogu Wyjątki we współprogramach, lub obsługi wyjątków we współprogramach w dokumentacji Kotlin.

Więcej informacji o współprogramach

Więcej informacji o współprogramach znajdziesz na stronie Dodatkowe materiały dotyczące współprogramów i przepływów Kotlin.