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

Na tej stronie znajdziesz kilka sprawdzonych metod, które pomogą Ci zwiększyć skalowalność aplikacji i łatwość jej testowania w przypadku współprogramowania.

Dyspozytorzy wstrzykiwań

Nie koduj na stałe w Dispatchers podczas tworzenia nowych współprogramów ani wywoływania funkcji withContext.

// 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ż możesz zastąpić dyspozytorów w testach jednostkowych i z instrumentacją dyspozytorem testów, aby wyniki były bardziej deterministyczne.

Funkcje zawieszania powinny być bezpiecznie wywoływane z wątku głównego

Funkcje zawieszania powinny być bezpieczne dla głównych funkcji, czyli można je bezpiecznie wywoływać z wątku głównego. Jeśli klasa wykonuje długotrwałe operacje blokujące w korutynie, odpowiada 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ę ona należy.

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

Ten wzorzec zwiększa skalowalność aplikacji, ponieważ klasy wywołujące funkcje zawieszania nie muszą martwić się o to, czego użyć w Dispatcher w danym typie pracy. Ta odpowiedzialność spoczywa na klasie, która wykonuje daną pracę.

Model ViewModel powinien utworzyć współprogramy

W klasach ViewModel powinny być preferowane tworzenie koordynacji, a nie udostępnianie funkcji zawieszania funkcji do obsługi logiki biznesowej. Funkcje zawieszania w ViewModel mogą być przydatne, jeśli zamiast ujawniania stanu za pomocą strumienia danych wystarczy 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()
}

Wyświetlenia nie powinny bezpośrednio uruchamiać żadnych współprogramów do działania logiki biznesowej. Zamiast tego przenieś tę odpowiedzialność na ViewModel. Ułatwia to testowanie logiki biznesowej, ponieważ można testować jednostkowe obiekty ViewModel, zamiast korzystać z testów narzędzi wymaganych do testowania widoków.

Dodatkowo współprogramy będą automatycznie przetrwać zmiany konfiguracji, jeśli praca zostanie uruchomiona w viewModelScope. Jeśli zamiast tego tworzysz współprogramy za pomocą polecenia lifecycleScope, musisz to robić ręcznie. Jeśli współpraca musi wykraczać poza zakres ViewModel, zapoznaj się z sekcją Tworzenie współuczestników w sekcji dotyczącej firmy i warstwy danych.

Nie ujawniaj typów zmiennych

Wolę ujawnianie typów stałych innym klasom. W ten sposób wszystkie zmiany w typie zmiennym są scentralizowane w jednej klasie, co ułatwia debugowanie w razie niepowodzenia.

// 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 biznesu powinna udostępnić funkcje zawieszania i przepływy

Klasy w warstwach danych i biznesowych zwykle dają funkcje do wykonywania wywołań jednorazowych lub powiadamiania o zmianach danych w miarę upływu czasu. Klasy w tych warstwach powinny udostępniać zawieszanie funkcji w przypadku wywołań jednoshotowych oraz Flow, aby powiadamiać 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 sprawia, że element wywołujący, czyli zwykle warstwa prezentacji, może kontrolować wykonywanie i cykl życia zadań wykonywanych w tych warstwach oraz w razie potrzeby je anulować.

Tworzenie koordynatorów w warstwie biznesowej i danych

W przypadku klas w warstwie danych lub firm, które z różnych powodów muszą tworzyć współprogramy, masz do wyboru różne opcje.

Jeśli praca do wykonania w tych systemach ma znaczenie tylko wtedy, gdy użytkownik jest obecny na bieżącym ekranie, powinna być zgodna z cyklem życia rozmówcy. W większości przypadków obiektem wywołującym jest ViewModel, a wywołanie to jest anulowane, gdy użytkownik opuści ekran, a model ViewModel zostanie wyczyszczony. W takim przypadku należy użyć właściwości coroutineScope lub supervisorScope.

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 do wykonania jest istotna, dopóki aplikacja jest otwarta, a praca nie jest powiązana z konkretnym ekranem, praca powinna przetrwać cykl życia rozmówcy. W takim przypadku należy użyć zewnętrznego elementu CoroutineScope, jak wyjaśniamy w sekcji Korekty i wzorce dla pracy, której nie należy anulować w poście na blogu.

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

Klasa externalScope powinna być utworzona 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 ograniczony do wykresu nawigacyjnego.

Wstrzykiwanie elementów TestDispatcher do testów

Wystąpienie TestDispatcher powinno być wstrzykiwane do klas w testach. W bibliotece kotlinx-coroutines-test dostępne są 2 implementacje:

  • StandardTestDispatcher: Kolejkowanie uruchamia się od niego za pomocą algorytmu szeregowania i uruchamia je, gdy wątek testowy nie jest zajęty. Możesz zawiesić wątek testowy, aby umożliwić uruchamianie innych w kolejce koordynatorów za pomocą takich metod jak advanceUntilIdle.

  • UnconfinedTestDispatcher: Niecierpliwie uruchamia nowe współprogramy, blokując je. Zwykle ułatwia to testowanie pisania, ale daje mniejszą kontrolę nad sposobem wykonywania współuczestników podczas testu.

Więcej informacji znajdziesz w dokumentacji poszczególnych implementacji dyspozytorskich.

Aby przetestować współprogramy, użyj konstruktora współuczestników runTest. runTest używa TestCoroutineScheduler, aby pomijać opóźnienia w testach i umożliwiać Ci kontrolowanie czasu wirtualnego. W razie potrzeby możesz też użyć tego algorytmu szeregowania, aby utworzyć dodatkowych dyspozytorów testowych.

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,
            testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

Wszystkie TestDispatchers powinny mieć ten sam algorytm szeregowania. Dzięki temu możesz uruchomić cały kod kodeksu w jednym wątku testowym, co sprawi, że testy będą deterministyczne. Przed zwróceniem runTest będzie czekać na zakończenie wszystkich współuczestników, które są w tym samym harmonogramie lub są podrzędne wobec niej.

Unikaj globalnego zakresu

Jest to podobne do sprawdzonej metody dyspozytorów wprowadzania. Używając GlobalScope, na stałe zakodujesz CoroutineScope, którego klasa używa, co wiąże się z jego wadami:

  • Promuje wartości wpisane na stałe w kodzie. Jeśli zakodujesz na stałe GlobalScope, być może zajmujesz też kodowanie Dispatchers.

  • Bardzo utrudnia testowanie, ponieważ kod jest wykonany w niekontrolowanym zakresie i nie można kontrolować jego wykonania.

  • Nie może mieć wspólnego żądania CoroutineContext do wykonania w przypadku wszystkich współprogramów wbudowanych w sam zakres.

Zamiast tego rozważ wstrzyknięcie elementu CoroutineScope do zadań, które muszą przetrwać obecny zakres. Więcej informacji na ten temat znajdziesz w sekcji dotyczącej tworzenia koordynatorów w warstwie biznesowej i warstwowej 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 usłudze GlobalScope i jej alternatywach znajdziesz w poście na temat korekty i wzorców w pracy, których nie należy anulować na blogu.

Umożliwienie anulowania współużytkowania

Anulowanie koderów ma charakter kooperatywny, co oznacza, że po anulowaniu wtyczki Job współpraca nie zostanie anulowana, dopóki nie zawiesi się lub nie sprawdzi jej anulowania. Jeśli blokujesz operacje w współprogramie, upewnij się, że można ją anulować.

Jeśli na przykład czytasz wiele plików z dysku, przed rozpoczęciem odczytywania każdego z nich sprawdź, czy współpraca została anulowana. Jednym ze sposobów sprawdzenia, czy usługa została anulowana, jest wywołanie funkcji ensureActive.

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

Wszystkie funkcje zawieszania z kotlinx.coroutines, takie jak withContext i delay, można anulować. Nie musisz wykonywać żadnych dodatkowych czynności, jeśli współtwórca je wywołuje.

Więcej informacji o anulowaniu programów znajdziesz w poście na blogu na temat anulowania w kodach.

Uważaj na wyjątki

Nieobsłużone wyjątki wprowadzone w komentinach mogą spowodować awarię aplikacji. Jeśli zdarzają się wyjątki, wychwyć je w treści współużytkowanych współprogramów utworzonych za pomocą funkcji viewModelScope lub 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 (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

Więcej informacji znajdziesz w poście na blogu na temat wyjątków w korektywach i obsługi wyjątków od reguł w dokumentacji Kotlin.

Więcej informacji o współprogramach

Więcej zasobów dotyczących współprogramów znajdziesz na stronie Dodatkowe materiały dotyczące współprogramów i przepływu pracy Kotlin.