Praktik terbaik untuk coroutine di Android

Halaman ini menyajikan beberapa praktik terbaik yang memiliki dampak positif dengan membuat aplikasi Anda lebih skalabel dan dapat diuji saat menggunakan coroutine.

Memasukkan Dispatcher

Jangan meng-hardcode Dispatchers saat membuat coroutine baru atau memanggil 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) { /* ... */ }
}

Pola injeksi dependensi ini mempermudah pengujian karena Anda dapat mengganti dispatcher tersebut dalam uji unit dan instrumentasi dengan TestCoroutineDispatcher untuk membuat pengujian Anda menjadi lebih deterministik.

Fungsi penangguhan harus aman untuk dipanggil dari thread utama

Fungsi penangguhan harus main-safe, artinya aman untuk dipanggil dari thread utama. Jika suatu class melakukan operasi pemblokiran yang berjalan lama di coroutine, fungsi penangguhan bertugas memindahkan eksekusi dari thread utama menggunakan withContext. Ini berlaku untuk semua class di aplikasi Anda, terlepas dari bagian arsitektur tempat class tersebut berada.

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

Pola ini membuat aplikasi Anda lebih skalabel, karena class yang memanggil fungsi penangguhan tidak perlu mengkhawatirkan Dispatcher yang akan digunakan untuk suatu jenis pekerjaan. Tanggung jawab ini berada di class yang mengerjakan pekerjaan.

ViewModel harus membuat coroutine

Class ViewModel sebaiknya memilih membuat coroutine daripada mengekspos fungsi penangguhan untuk menjalankan logika bisnis. Fungsi penangguhan di ViewModel dapat berguna jika bukan mengekspos status menggunakan aliran data, hanya satu nilai yang perlu dimunculkan.

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

Tampilan tidak boleh langsung memicu coroutine untuk menjalankan logika bisnis. Sebagai gantinya, tangguhkan tanggung jawab tersebut ke ViewModel. Hal ini membuat logika bisnis lebih mudah diuji karena objek ViewModel dapat diuji unitnya, bukan menggunakan uji instrumentasi yang diperlukan untuk menguji tampilan.

Selain itu, coroutine Anda akan tetap berfungsi meski konfigurasi otomatis berubah jika pekerjaan dimulai di viewModelScope. Jika Anda membuat coroutine menggunakan lifecycleScope, Anda harus menanganinya secara manual. Jika coroutine perlu aktif lebih lama dari cakupan ViewModel, lihat Bagian membuat coroutine di lapisan bisnis dan data.

Jangan mengekspos jenis yang dapat diubah

Pilih mengekspos jenis yang tidak dapat diubah ke class lain. Dengan demikian, semua perubahan pada jenis yang dapat diubah terpusat di satu class akan memudahkan proses debug saat terjadi kesalahan.

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

    /* ... */
}

Lapisan data dan bisnis harus mengekspos fungsi penangguhan dan Alur

Class di lapisan data dan bisnis umumnya mengekspos fungsi untuk melakukan panggilan satu kali atau agar diberi tahu tentang perubahan data dari waktu ke waktu. Class di lapisan tersebut harus mengekspos fungsi penangguhan untuk panggilan satu kali dan Alur untuk memberi tahu tentang perubahan data.

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

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

Praktik terbaik ini membuat pemanggil, biasanya lapisan presentasi, dapat mengontrol eksekusi dan siklus proses pekerjaan yang terjadi dalam lapisan tersebut, dan membatalkannya jika diperlukan.

Membuat coroutine di lapisan bisnis dan data

Untuk class di lapisan data atau bisnis yang perlu membuat coroutine karena alasan yang berbeda, terdapat opsi yang berbeda pula.

Jika pekerjaan yang akan dilakukan di coroutine tersebut hanya relevan saat pengguna ada di layar saat ini, pekerjaan tersebut harus mengikuti siklus proses pemanggil. Biasanya, pemanggil akan menjadi ViewModel. Dalam hal ini, coroutineScope atau supervisorScope harus digunakan.

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

Jika pekerjaan yang akan dilakukan relevan selama aplikasi dibuka, dan pekerjaan tidak terikat ke layar tertentu, pekerjaan tersebut harus aktif lebih lama dibandingkan siklus proses pemanggil. Untuk skenario ini, CoroutineScope eksternal harus digunakan seperti yang dijelaskan dalam Postingan blog Coroutine & Pola untuk pekerjaan yang tidak boleh dibatalkan.

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
    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
    }
}

externalScope harus dibuat dan dikelola oleh class yang aktif lebih lama dari layar saat ini. Ini dapat dikelola oleh class Application atau ViewModel yang tercakup dalam grafik navigasi.

Memasukkan TestCoroutineDispatcher dalam pengujian

Instance TestCoroutineDispatcher harus dimasukkan ke dalam class Anda dalam pengujian. TestCoroutineDispatcher segera mengeksekusi tugas dan memberi Anda kontrol atas waktu eksekusi coroutine dalam pengujian.

Gunakan runBlockingTest TestCoroutineDispatcher dalam isi pengujian untuk menunggu semua coroutine yang menggunakan dispatcher tersebut selesai.

class ArticlesRepositoryTest {

    private val testDispatcher = TestCoroutineDispatcher()

    @Test
    fun testBookmarkArticle() {
        // Execute all coroutines that use this Dispatcher immediately
        testDispatcher.runBlockingTest {
            val articlesDataSource = FakeArticlesDataSource()
            val repository = ArticlesRepository(
                articlesDataSource,
                // Make the CoroutineScope use the same dispatcher
                // that we use for runBlockingTest
                CoroutineScope(testDispatcher),
                testDispatcher
            )
            val article = Article()
            repository.bookmarkArticle(article)
            assertThat(articlesDataSource.isBookmarked(article)).isTrue()
        }
        // make sure nothing else is scheduled to be executed
        testDispatcher.cleanupTestCoroutines()
    }
}

Karena semua coroutine yang dibuat oleh class yang sedang diuji menggunakan TestCoroutineDispatcher yang sama, dan isi pengujian menunggu untuk dieksekusi menggunakan runBlockingTest, pengujian Anda akan menjadi deterministik dan tidak akan mengalami kondisi race.

Menghindari GlobalScope

Ini mirip dengan praktik terbaik Memasukkan Dispatcher. Dengan menggunakan GlobalScope, meng-hardcode CoroutineScope yang digunakan class akan membawa beberapa kelemahan:

  • Mempromosikan nilai hard code. Jika Anda meng-hardcode GlobalScope, Anda juga mungkin meng-hardcode Dispatchers.

  • Membuat pengujian menjadi sangat sulit karena kode dieksekusi dalam cakupan yang tidak terkontrol, Anda tidak akan dapat mengontrol eksekusinya.

  • Anda tidak dapat memiliki CoroutineContext umum untuk dieksekusi bagi semua coroutine yang dibuat ke dalam cakupan itu sendiri.

Sebagai gantinya, sebaiknya masukkan CoroutineScope untuk pekerjaan yang perlu aktif lebih lama daripada cakupan saat ini. Lihat Bagian membuat coroutine di lapisan bisnis dan data untuk mempelajari topik ini lebih lanjut.

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

Pelajari GlobalScope dan alternatifnya lebih lanjut di Postingan blog Coroutine & Pola untuk pekerjaan yang tidak boleh dibatalkan.

Membuat coroutine Anda dapat dibatalkan

Pembatalan di coroutine bersifat kooperatif, yang berarti bahwa ketika Job coroutine dibatalkan, coroutine tidak akan dibatalkan sampai terjadi penangguhan atau pemeriksaan pembatalan. Jika Anda melakukan operasi pemblokiran di coroutine, pastikan coroutine dapat dibatalkan.

Misalnya, jika Anda membaca beberapa file dari disk, sebelum mulai membaca setiap file, periksa apakah coroutine dibatalkan. Salah satu cara untuk memeriksa pembatalan adalah dengan memanggil fungsi ensureActive.

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

Semua fungsi penangguhan dari kotlinx.coroutines seperti withContext dan delay dapat dibatalkan. Jika coroutine Anda memanggil fungsi, Anda tidak perlu melakukan pekerjaan tambahan.

Untuk informasi selengkapnya tentang pembatalan di coroutine, lihat Postingan blog pembatalan di coroutine.

Perhatikan pengecualian

Pengecualian yang ditangani dengan tidak semestinya di coroutine dapat membuat aplikasi Anda error. Jika pengecualian cenderung terjadi, tarik pengecualian dalam isi coroutine yang dibuat dengan viewModelScope atau 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
            }
        }
    }
}

Untuk informasi tentang penggunaan dan skenario lainnya untuk CoroutineExceptionHandler, lihat Postingan blog pengecualian di coroutine.

Pelajari coroutine lebih lanjut

Untuk mendapatkan referensi coroutine lainnya, lihat halaman Referensi tambahan untuk coroutine dan alur Kotlin.