Na tej stronie znajdziesz kilka sprawdzonych metod, które pozytywnie wpływają na skalowalność i testowalność aplikacji korzystających z korutyn.
Wstrzykiwanie dyspozytorów
Podczas tworzenia nowych korutyn lub wywoływania funkcji withContext nie koduj na stałe wartości 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 integracyjnych możesz zastąpić te dyspozytory dyspozytorem testowym, aby testy były bardziej deterministyczne.
Funkcje zawieszania 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 w korutynie, odpowiada za przeniesienie wykonania poza wątek główny za pomocą 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 Dispatcher użyć do jakiego rodzaju pracy. Odpowiedzialność za to spoczywa na klasie, która wykonuje pracę.
ViewModel powinien tworzyć coroutines
Klasy ViewModel powinny tworzyć korutyny zamiast udostępniać funkcje zawieszające do wykonywania logiki biznesowej. Funkcje zawieszania w ViewModel mogą być przydatne, jeśli zamiast udostępniać stan 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() }
Widoki nie powinny bezpośrednio wywoływać żadnych współprogramów do wykonywania logiki biznesowej.
Zamiast tego przekaż tę odpowiedzialność na ViewModel. Ułatwia to testowanie logiki biznesowej, ponieważ obiekty ViewModel można testować jednostkowo, zamiast używać testów instrumentacyjnych, które są wymagane do testowania widoków.
Dodatkowo Twoje korutyny automatycznie przetrwają zmiany konfiguracji, jeśli praca zostanie rozpoczęta w viewModelScope. Jeśli utworzysz korutyny 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
Preferuj udostępnianie innym klasom typów niezmiennych. W ten sposób wszystkie zmiany typu modyfikowalnego są scentralizowane w jednej klasie, co ułatwia debugowanie w przypadku wystąpienia problemów.
// 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 zawieszania i przepływy.
Klasy w warstwach danych i biznesowej zwykle 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 zawieszania dla jednorazowych wywołań i Flow 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, czyli zwykle warstwie prezentacji, sterowanie wykonywaniem i cyklem życia pracy w tych warstwach oraz anulowanie w razie potrzeby.
Tworzenie korutyn w warstwie biznesowej i warstwie danych
W przypadku klas w warstwie danych lub biznesowej, które muszą tworzyć korutyny z różnych powodów, istnieją różne opcje.
Jeśli praca do wykonania w tych korutynach jest istotna tylko wtedy, gdy użytkownik jest na bieżącym ekranie, powinna 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 należy użyć atrybutu 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, i nie jest powiązana z określonym ekranem, powinna trwać dłużej niż cykl życia wywołującego. W tym scenariuszu należy użyć zewnętrznego CoroutineScope, jak wyjaśniono w tym artykule na blogu o współprogramach i wzorcach w przypadku zadań, których nie należy anulować.
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 istnieje dłużej niż bieżący ekran. Może być zarządzana przez klasę Application lub ViewModel w zakresie wykresu nawigacji.
Wstrzykiwanie obiektów TestDispatchers w testach
W testach do klas należy wstrzykiwać instancję TestDispatcher. W kotlinx-coroutines-testbibliotece dostępne są 2 implementacje:
StandardTestDispatcher: kolejkuje uruchomione na nim korutyny 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, używając metod takich jakadvanceUntilIdle.UnconfinedTestDispatcher: uruchamia nowe korutyny od razu w sposób blokujący. Zwykle ułatwia to pisanie testów, ale daje mniejszą kontrolę nad sposobem wykonywania w nich korutyn.
Więcej informacji znajdziesz w dokumentacji poszczególnych implementacji modułu wysyłającego.
Do testowania współprogramów używaj konstruktora współprogramów runTest. runTest używa TestCoroutineScheduler, aby pomijać opóźnienia w testach i umożliwiać kontrolowanie czasu wirtualnego. Za pomocą tego harmonogramu możesz też w razie potrzeby tworzyć 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, 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ółprogramów na jednym wątku testowym, aby testy były deterministyczne. runTest poczeka na zakończenie wszystkich współprogramów, które znajdują się w tym samym harmonogramie lub są współprogramami podrzędnymi współprogramu testowego, zanim zwróci wartość.
Unikaj GlobalScope
Jest to podobne do sprawdzonej metody Wstrzykiwanie dyspozytorów. Używając
GlobalScope,
CoroutineScope
Promuje zakodowane na stałe 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 możesz kontrolować jego wykonania.
Nie możesz mieć wspólnego
CoroutineContextdo wykonywania wszystkich korutyn wbudowanych w sam zakres.
Zamiast tego rozważ wstawienie CoroutineScope w przypadku pracy, która ma wykraczać poza bieżący zakres. Więcej informacji na ten temat znajdziesz w sekcji Tworzenie korutyn 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 Korutyny i wzorce do zadań, których nie należy anulować.
Umożliwianie anulowania korutyny
Anulowanie we współprogramach jest kooperatywne, co oznacza, że gdy współprogram
Job zostanie anulowany, nie zostanie anulowany, dopóki nie zostanie zawieszony lub nie sprawdzi
, czy został anulowany. Jeśli wykonujesz operacje blokujące w korutynie, upewnij się, że można ją anulować.
Jeśli na przykład odczytujesz z dysku wiele plików, przed rozpoczęciem odczytu każdego z nich sprawdź, czy współprogram został anulowany. Jednym ze sposobów sprawdzenia anulowania jest wywołanie funkcji ensureActive.
someScope.launch { for (file in files) { ensureActive() // Check for cancellation readFile(file) } }
Wszystkie funkcje zawieszenia z kotlinx.coroutines, takie jak withContext i delay, można anulować. Jeśli Twój współprogram je wywołuje, nie musisz wykonywać żadnych dodatkowych działań.
Więcej informacji o anulowaniu w korutynach znajdziesz w poście na blogu na ten temat.
Uważaj na wyjątki
Nieobsłużone wyjątki zgłaszane w korutynach mogą powodować awarie aplikacji. Jeśli istnieje prawdopodobieństwo wystąpienia wyjątków, przechwyć je w treści wszystkich korutyn 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) // Update UI, user logged in successfully } catch (exception: IOException) { // Update UI, login attempt failed } } } }
Więcej informacji znajdziesz w poście na blogu Exceptions in coroutines (Wyjątki w korutynach) lub w artykule Coroutine exceptions handling (Obsługa wyjątków w korutynach) w dokumentacji języka Kotlin.
Więcej informacji o korutynach
Więcej informacji o korutynach znajdziesz w przewodniku po korutynach w dokumentacji Kotlin.