Questa pagina illustra diverse best practice che hanno un impatto positivo rendendo la tua app più scalabile e testabile quando utilizzi le coroutine.
Inietta supervisori
Non impostare come hardcoded Dispatchers
quando crei nuove coroutine o chiami
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) { /* ... */ }
}
Questo pattern di inserimento delle dipendenze semplifica i test poiché è possibile sostituire i mittenti nei test di unità e strumentazione con un supervisore di test per rendere i test più deterministici.
Le funzioni di sospensione dovrebbero essere in sicurezza per poter chiamare dal thread principale
Le funzioni di sospensione devono essere sicure per l'account principale, ovvero poterle chiamare in sicurezza dal thread principale. Se una classe esegue operazioni di blocco a lunga esecuzione in una
coroutine, deve spostare l'esecuzione dal thread principale utilizzando
withContext
. Questo si applica a tutte le classi nella tua app, indipendentemente dalla parte
dell'architettura in cui si trova la classe.
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)
}
}
Questo pattern rende la tua app più scalabile, poiché le classi che chiamano funzioni di sospensione non devono preoccuparsi di quale Dispatcher
utilizzare per quale tipo di lavoro. Questa responsabilità spetta alla classe che si occupa del lavoro.
ViewModel deve creare coroutine
Le classi ViewModel
dovrebbero preferire
creare coroutine anziché esporre le funzioni di sospensione per eseguire la logica di
business. La sospensione delle funzioni in ViewModel
può essere utile se, invece di esporre lo stato utilizzando un flusso di dati, deve essere emesso un solo valore.
// 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()
}
Le visualizzazioni non devono attivare direttamente alcuna coroutine per eseguire la logica di business.
Rimanda invece questa responsabilità all'ViewModel
. In questo modo la logica di business è più semplice da testare, poiché gli oggetti ViewModel
possono essere testati sulle unità, anziché utilizzare i test di strumentazione necessari per testare le viste.
Inoltre, le coroutine sopravviveranno automaticamente alle modifiche di configurazione se il lavoro viene avviato in viewModelScope
. Se crei
coroutine utilizzando invece lifecycleScope
, dovrai gestire questa operazione manualmente.
Se la coroutine deve superare l'ambito di ViewModel
, consulta la sezione
Creazione di coroutine nella sezione livello dati e aziendale.
Non esporre tipi modificabili
Preferisci esporre tipi immutabili ad altre classi. In questo modo, tutte le modifiche al tipo modificabile sono centralizzate in un'unica classe, in modo da poter eseguire più facilmente il debug quando qualcosa va storto.
// 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)
/* ... */
}
I livelli dati e aziendali devono esporre le funzioni di sospensione e i flussi
Le classi nei livelli dati e aziendali in genere espongono le funzioni per eseguire chiamate one-shot o per ricevere notifiche sulle modifiche ai dati nel tempo. Le classi in questi livelli dovrebbero esporre le funzioni di sospensione per le chiamate one-shot e Flusso per notificare modifiche ai dati.
// Classes in the data and business layer expose
// either suspend functions or Flows
class ExampleRepository {
suspend fun makeNetworkRequest() { /* ... */ }
fun getExamples(): Flow<Example> { /* ... */ }
}
Questa best practice consente al chiamante, in genere al livello di presentazione, di controllare l'esecuzione e il ciclo di vita del lavoro svolto nei livelli in questione e di annullare quando necessario.
Creazione di coroutine nel livello aziendale e dati
Esistono diverse opzioni per le classi nel livello dati o aziendale che devono creare coroutine per motivi diversi.
Se il lavoro da svolgere in queste coroutine è pertinente solo quando l'utente è
presente nella schermata corrente, deve seguire il ciclo di vita del chiamante. Nella maggior parte dei casi, il chiamante sarà ViewModel e la chiamata verrà annullata quando l'utente uscirà dallo schermo e il ViewModel viene cancellato. In questo caso, è necessario utilizzare coroutineScope
o 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())
}
}
}
Se il lavoro da svolgere è rilevante fintanto che l'app è aperta e non è vincolata a una schermata particolare, dovrebbe durare più del ciclo di vita del chiamante. Per questo scenario, è necessario utilizzare un CoroutineScope
esterno come
spiegato nel post del blog Coroutine e motivi per il lavoro che non dovrebbe essere annullato.
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
dovrebbe essere creato e gestito da una classe che dura più a lungo della
schermata corrente, potrebbe essere gestito dalla classe Application
o da
ViewModel
con l'ambito di un grafico di navigazione.
Inserisci TestDispatchers nei test
Un'istanza di
TestDispatcher
deve essere inserita nelle tue classi nei test. Nella libreria kotlinx-coroutines-test
sono disponibili due implementazioni:
StandardTestDispatcher
: mette in coda le coroutine avviate con uno scheduler e le esegue quando il thread di test non è occupato. Puoi sospendere il thread di test per consentire l'esecuzione di altre coroutine in coda utilizzando metodi comeadvanceUntilIdle
.UnconfinedTestDispatcher
: esegue con impazienza nuove coroutine, in modo da bloccarlo. In genere questo semplifica i test di scrittura, ma offre un minore controllo sul modo in cui vengono eseguite le coroutine durante il test.
Per ulteriori dettagli, consulta la documentazione di ciascuna implementazione dei committenti.
Per testare le coroutine, utilizza lo strumento per la creazione di coroutine
runTest
. runTest
utilizza un
TestCoroutineScheduler
per saltare i ritardi nei test e consentirti di controllare il tempo virtuale. Puoi anche
utilizzare questo scheduler per creare ulteriori supervisori di test in base alle tue esigenze.
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()
}
}
Tutti gli elementi TestDispatchers
devono condividere lo stesso scheduler. In questo modo puoi eseguire tutto il codice della coroutine sul singolo thread di test per rendere i test deterministici. runTest
attenderà che tutte le coroutine che si trovano sullo stesso
programmatore o che sono secondarie della coroutine di test siano completate prima di tornare.
Evita GlobalScope
Questa procedura è simile alla best practice per Inserisci committenti. Con GlobalScope
, esegui l'hardcoding del CoroutineScope
utilizzato da una classe con alcuni svantaggi:
Promuove valori hardcoded. Se imposti come hardcoded
GlobalScope
, potresti anche eseguire l'hard-coding diDispatchers
.Rende molto difficile eseguire test poiché il codice viene eseguito in un ambito non controllato, quindi non potrai controllarne l'esecuzione.
Non puoi avere un
CoroutineContext
comune da eseguire per tutte le coroutine integrate nell'ambito stesso.
In alternativa, valuta la possibilità di inserire un CoroutineScope
per un lavoro che deve durare più
nell'ambito attuale. Consulta la sezione sulla creazione di coroutine nella sezione livello dati e aziendale per saperne di più su questo argomento.
// 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
}
}
Scopri di più su GlobalScope
e sulle sue alternative nel
post del blog "Coroutine e motivi per il lavoro che non dovrebbe essere annullato".
Rendere annullabile la coroutine
L'annullamento nelle coroutine è cooperativo, il che significa che quando una coroutine
Job
viene annullata, la coroutine non viene annullata fino a quando non viene sospesa o verificata
l'annullamento. Se blocchi le operazioni in una coroutine, assicurati che sia annullabile.
Ad esempio, se stai leggendo più file dal disco, prima di iniziare a leggere ogni file controlla se la coroutine è stata annullata. Un modo per
verificare l'annullamento è chiamare la
funzione ensureActive
.
someScope.launch {
for(file in files) {
ensureActive() // Check for cancellation
readFile(file)
}
}
Tutte le funzioni di sospensione di kotlinx.coroutines
, come withContext
e
delay
, sono annullabili. Se la tua coroutine li chiama, non
dovresti fare altro.
Per ulteriori informazioni sull'annullamento nelle coroutine, leggi il post del blog sull'annullamento nelle coroutine.
Fare attenzione alle eccezioni
Le eccezioni non gestite generate nelle coroutine possono causare l'arresto anomalo dell'app. Se è probabile che si verifichino delle eccezioni, inseriscile nel corpo di eventuali coroutine create con viewModelScope
o 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
}
}
}
}
Per ulteriori informazioni, consulta il post del blog Eccezioni nelle coroutine o Gestione delle eccezioni di corona nella documentazione di Kotlin.
Scopri di più sulle coroutine
Per ulteriori risorse sulle coroutine, consulta la pagina Risorse aggiuntive per coroutine e flusso Kotlin.