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 dispatcher pengujian 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. Dalam sebagian besar kasus, pemanggil akan menjadi ViewModel, dan panggilan akan dibatalkan saat pengguna keluar dari layar dan ViewModel dihapus. Dalam hal ini, coroutineScope atau supervisorScope harus digunakan.

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

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,
) {
    // 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 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 TestDispatchers dalam pengujian

Instance TestDispatcher harus dimasukkan ke dalam class Anda dalam pengujian. Ada dua implementasi yang tersedia di library kotlinx-coroutines-test:

  • StandardTestDispatcher: Mengantrekan coroutine yang dimulai dengan scheduler, dan menjalankannya saat thread pengujian tidak sibuk. Anda dapat menangguhkan thread pengujian untuk mengizinkan coroutine lainnya yang diantrekan berjalan menggunakan metode seperti advanceUntilIdle.

  • UnconfinedTestDispatcher: Menjalankan coroutine baru dengan segera, dengan cara memblokir. Hal ini umumnya mempermudah penulisan pengujian, tetapi memberi Anda lebih sedikit kontrol atas cara coroutine dijalankan selama pengujian.

Lihat dokumentasi setiap implementasi dispatcher untuk detail tambahan.

Untuk menguji coroutine, gunakan builder coroutine runTest. runTest menggunakan TestCoroutineScheduler untuk melewati penundaan dalam pengujian dan memungkinkan Anda mengontrol waktu virtual. Anda juga dapat menggunakan scheduler ini untuk membuat dispatcher pengujian tambahan sesuai kebutuhan.

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

Semua TestDispatchers harus memiliki scheduler yang sama. Hal ini memungkinkan Anda menjalankan semua kode coroutine pada satu thread pengujian agar pengujian Anda menjadi deterministik. runTest akan menunggu semua coroutine yang ada di scheduler yang sama atau merupakan turunan dari coroutine pengujian yang telah selesai sebelum kembali.

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.

Memperhatikan pengecualian

Pengecualian yang tidak ditangani yang ditampilkan dalam 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 (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

Untuk informasi selengkapnya, lihat postingan blog Pengecualian di coroutine, atau Penanganan pengecualian coroutine dalam dokumentasi Kotlin.

Pelajari coroutine lebih lanjut

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