Pendekatan Modern untuk Persaingan di Android: Corotins at Kotlin

Halo, Habr!

Kami mengingatkan Anda bahwa kami telah memiliki pemesanan di muka untuk buku yang telah lama ditunggu-tunggu tentang bahasa Kotlin dari seri Big Nerd Ranch Guides yang terkenal. Hari ini kami memutuskan untuk membawa kepada Anda terjemahan dari sebuah artikel yang menceritakan tentang Kotlin coroutine dan tentang pekerjaan yang benar dengan stream di Android. Topik sedang dibahas dengan sangat aktif, oleh karena itu, untuk kelengkapan, kami juga menyarankan agar Anda melihat artikel ini dari Habr dan pos terperinci ini dari blog Axmor Software.

Kerangka kerja kompetitif modern di Java / Android menimbulkan neraka pada panggilan balik dan mengarah ke pemblokiran negara, karena Android tidak memiliki cara yang cukup sederhana untuk menjamin keamanan utas.

Corinthine Kotlin adalah toolkit yang sangat efektif dan lengkap yang membuat mengelola kompetisi menjadi lebih mudah dan lebih produktif.

Jeda dan blokir: apa bedanya

Coroutine tidak mengganti utas, tetapi menyediakan kerangka kerja untuk mengelola mereka. Filosofi dari corutin adalah untuk mendefinisikan konteks yang memungkinkan Anda untuk menunggu operasi latar belakang untuk menyelesaikan tanpa memblokir utas utama.

Tujuan Corutin dalam hal ini adalah untuk menghilangkan panggilan balik dan menyederhanakan persaingan.

Contoh paling sederhana

Untuk memulainya, mari kita ambil contoh paling sederhana: jalankan coroutine dalam konteks Main (utas utama). Di dalamnya, kami akan mengekstrak gambar dari aliran IO dan mengirim gambar ini untuk diproses kembali ke Main .

 launch(Dispatchers.Main) { val image = withContext(Dispatchers.IO) { getImage() } //    IO imageView.setImageBitmap(image) //     } 

Kode ini sederhana sebagai fungsi single-threaded. Selain itu, sementara getImage dieksekusi di kumpulan thread yang dialokasikan IO , utas utama gratis dan dapat mengambil tugas lain! Fungsi withContext menjeda coroutine saat ini saat aksinya berjalan ( getImage() ). Segera setelah getImage() kembali dan looper dari utas utama tersedia, coroutine akan melanjutkan pekerjaan di utas utama dan memanggil imageView.setImageBitmap(image) .

Contoh kedua: sekarang kita perlu menyelesaikan 2 tugas latar belakang agar bisa digunakan. Kami akan menggunakan duet async / await sehingga kedua tugas ini dilakukan secara paralel, dan menggunakan hasilnya di utas utama segera setelah kedua tugas siap:

 val job = launch(Dispatchers.Main) { val deferred1 = async(Dispatchers.Default) { getFirstValue() } val deferred2 = async(Dispatchers.IO) { getSecondValue() } useValues(deferred1.await(), deferred2.await()) } job.join() //    ,      

async mirip dengan launch , tetapi mengembalikan deferred (entitas Kotlin setara dengan Future ), sehingga hasilnya dapat diperoleh menggunakan await() . Ketika dipanggil tanpa parameter, ia berfungsi dalam konteks default untuk lingkup saat ini.

Sekali lagi, utas utama tetap bebas sementara kami menunggu 2 nilai kami.
Seperti yang Anda lihat, fungsi launch mengembalikan Job , yang dapat digunakan untuk menunggu sampai operasi selesai - ini dilakukan menggunakan fungsi join() . Ini bekerja seperti dalam bahasa lain, dengan peringatan bahwa itu hanya menunda coroutine, dan tidak menghalangi aliran .

Pengiriman

Pengiriman adalah konsep kunci ketika bekerja dengan coroutine. Tindakan ini memungkinkan Anda untuk "melompat" dari satu utas ke utas lainnya.

Pertimbangkan seperti apa persamaan untuk pengiriman di Main seperti di java, yaitu,

 runOnUiThread: public final void runOnUiThread(Runnable action) { if (Thread.currentThread() != mUiThread) { mHandler.post(action); //  } else { action.run(); //   } } 

Implementasi Konteks Main untuk Android adalah dispatcher berbasis Handler . Jadi ini memang implementasi yang sangat cocok:

 launch(Dispatchers.Main) { ... } vs launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) { ... } //   kotlinx 0.26: launch(Dispatchers.Main.immediate) { ... } 

launch(Dispatchers.Main) mengirimkan Runnable ke Handler , sehingga kodenya tidak segera dieksekusi.

launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) akan segera menjalankan ekspresi lambda di utas saat ini.

Dispatchers.Main memastikan bahwa ketika coroutine melanjutkan bekerja, itu akan diarahkan ke utas utama ; selain itu, Handler digunakan di sini sebagai implementasi Android asli untuk mengirim ke loop acara aplikasi.

Implementasi yang tepat terlihat seperti ini:

 val Main: HandlerDispatcher = HandlerContext(mainHandler, "Main") 

Berikut ini adalah artikel yang bagus untuk membantu Anda memahami seluk-beluk pengiriman di Android:
Memahami Android Core: Looper, Handler, dan HandlerThread .

Konteks Coroutine

Konteks coroutine (juga dikenal sebagai manajer coroutine) menentukan di mana utas kode akan dieksekusi, apa yang harus dilakukan jika pengecualian dilemparkan, dan merujuk ke konteks induk untuk menyebarkan pembatalan.

 val job = Job() val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable -> whatever(throwable) } launch(Disaptchers.Default+exceptionHandler+job) { ... } 

job.cancel() akan membatalkan semua coroutine yang orang tuanya adalah job . Pengecualian Handler akan menerima semua pengecualian yang dilemparkan ke coroutine ini.

Lingkup

Antarmuka coroutineScope menyederhanakan penanganan kesalahan:
Jika salah satu coroutine putrinya gagal, maka seluruh ruang lingkup dan semua coroutine anak juga akan dibatalkan.

Dalam contoh async , jika tidak mungkin untuk mengekstraksi nilai, sementara tugas lain terus bekerja, kami memiliki keadaan yang rusak, dan kami perlu melakukan sesuatu tentang hal itu.

Saat bekerja dengan coroutineScope , fungsi useValues akan dipanggil hanya jika ekstraksi kedua nilai berhasil. Juga, jika deferred2 gagal, deferred1 akan dibatalkan.

 coroutineScope { val deferred1 = async(Dispatchers.Default) { getFirstValue() } val deferred2 = async(Dispatchers.IO) { getSecondValue() } useValues(deferred1.await(), deferred2.await()) } 

Anda juga dapat “memasukkan ruang lingkup” seluruh kelas untuk mengatur CoroutineContext default untuk itu dan menggunakannya.

Kelas contoh yang mengimplementasikan antarmuka CoroutineScope :

 open class ScopedViewModel : ViewModel(), CoroutineScope { protected val job = Job() override val coroutineContext = Dispatchers.Main+job override fun onCleared() { super.onCleared() job.cancel() } } 

Menjalankan Corutin di CoroutineScope :

launch default atau manajer async sekarang menjadi manajer lingkup saat ini.

 launch { val foo = withContext(Dispatchers.IO) { … } // -    CoroutineContext   … } launch(Dispatchers.Default) { // -        … } 

Peluncuran otonom coroutine (di luar CoroutineScope apa pun):

 GlobalScope.launch(Dispatchers.Main) { // -    . … } 

Anda bahkan dapat menentukan ruang lingkup aplikasi dengan menetapkan operator Main :

 object AppScope : CoroutineScope by GlobalScope { override val coroutineContext = Dispatchers.Main.immediate } 

Komentar

  • Coroutines membatasi interoperabilitas dengan Java
  • Batasi mutabilitas untuk menghindari kunci
  • Coroutine dirancang untuk menunggu, bukan untuk mengatur utas
  • Hindari I / O di Dispatchers.Default (dan Main ...) - inilah gunanya Dispatchers.IO
  • Streaming memakan sumber daya, sehingga konteks single-threaded digunakan
  • Dispatchers.Default didasarkan pada ForkJoinPool , diperkenalkan di Android 5+
  • Coroutine dapat digunakan melalui saluran

Menyingkirkan kunci dan panggilan balik menggunakan saluran

Definisi saluran dari dokumentasi JetBrains:

Saluran Channel konseptual sangat mirip dengan BlockingQueue . Perbedaan utama adalah bahwa ia tidak memblokir operasi put, ia menyediakan untuk send ditangguhkan (atau penawaran yang tidak menghalangi), dan alih-alih memblokir operasi take, ia menyediakan untuk receive penundaan.


Aktor

Pertimbangkan alat sederhana untuk bekerja dengan saluran: Actor .

Actor , sekali lagi, sangat mirip dengan Handler : kita mendefinisikan konteks coroutine (yaitu, thread di mana kita akan melakukan tindakan) dan bekerja dengannya secara berurutan.

Perbedaannya, tentu saja, adalah bahwa corutin digunakan di sini; Anda dapat menentukan kekuatan, dan kode yang dijalankan - jeda .

Pada prinsipnya, actor akan mengarahkan ulang perintah apa pun ke saluran coroutine. Ini menjamin eksekusi perintah dan membatasi operasi dalam konteksnya . Pendekatan ini sangat membantu menghilangkan synchronize panggilan dan menjaga semua utas gratis!

 protected val updateActor by lazy { actor<Update>(capacity = Channel.UNLIMITED) { for (update in channel) when (update) { Refresh -> updateList() is Filter -> filter.filter(update.query) is MediaUpdate -> updateItems(update.mediaList as List<T>) is MediaAddition -> addMedia(update.media as T) is MediaListAddition -> addMedia(update.mediaList as List<T>) is MediaRemoval -> removeMedia(update.media as T) } } } //  fun filter(query: String?) = updateActor.offer(Filter(query)) //  suspend fun filter(query: String?) = updateActor.send(Filter(query)) 

Dalam contoh ini, kami menggunakan kelas Kotlin tersegel, memilih tindakan mana yang harus dilakukan.

 sealed class Update object Refresh : Update() class Filter(val query: String?) : Update() class MediaAddition(val media: Media) : Update() 

Selain itu, semua tindakan ini akan antri, mereka tidak akan pernah dieksekusi secara paralel. Ini adalah cara yang mudah untuk mencapai batas variabilitas .

Android Life Cycle + Coroutines

Aktor juga dapat sangat berguna untuk mengendalikan antarmuka pengguna Android, menyederhanakan pembatalan tugas, dan mencegah kelebihan utas.
Mari kita implementasikan ini dan panggil job.cancel() ketika aktivitas dihancurkan.

 class MyActivity : AppCompatActivity(), CoroutineScope { protected val job = SupervisorJob() //  Job    override val coroutineContext = Dispatchers.Main.immediate+job override fun onDestroy() { super.onDestroy() job.cancel() //      } } 

Kelas SupervisorJob mirip dengan Job reguler dengan satu pengecualian bahwa pembatalan hanya meluas ke arah hilir.

Karenanya, kami tidak membatalkan semua coroutine dalam suatu Activity ketika salah satu dari mereka gagal.

Hal-hal sedikit lebih baik dengan fungsi ekstensi yang memungkinkan Anda untuk mengakses CoroutineContext ini dari View apa pun di CoroutineScope .

 val View.coroutineContext: CoroutineContext? get() = (context as? CoroutineScope)?.coroutineContext 

Sekarang kita bisa menggabungkan semua ini, fungsi setOnClick membuat aktor gabungan untuk mengontrol tindakan onClick -nya. Dalam hal beberapa ketukan, tindakan lanjutan akan diabaikan, sehingga menghilangkan kesalahan PPA (aplikasi tidak merespons), dan tindakan ini akan dilakukan dalam lingkup Activity . Karena itu, ketika aktivitas dihancurkan, semua ini akan dibatalkan.

 fun View.setOnClick(action: suspend () -> Unit) { //         val scope = (context as? CoroutineScope)?: AppScope val eventActor = scope.actor<Unit>(capacity = Channel.CONFLATED) { for (event in channel) action() } //       setOnClickListener { eventActor.offer(Unit) } } 

Dalam contoh ini, kami mengatur Channel ke Conflated sehingga mengabaikan beberapa peristiwa jika ada terlalu banyak. Anda dapat menggantinya dengan Channel.UNLIMITED jika Anda lebih memilih untuk mengantri acara tanpa kehilangan salah satunya, tetapi tetap ingin melindungi aplikasi dari kesalahan ANR.

Anda juga dapat menggabungkan kerangka kerja coroutine dan Siklus Hidup untuk mengotomatiskan pembatalan tugas yang terkait dengan antarmuka pengguna:

 val LifecycleOwner.untilDestroy: Job get() { val job = Job() lifecycle.addObserver(object: LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) fun onDestroy() { job.cancel() } }) return job } //  GlobalScope.launch(Dispatchers.Main, parent = untilDestroy) { /*    ! */ } 

Sederhanakan situasi dengan panggilan balik (bagian 1)

Berikut cara mengubah penggunaan API berbasis panggilan balik dengan Channel .

API berfungsi seperti ini:

  1. requestBrowsing(url, listener) mem-parsing folder yang terletak di url .
  2. listener menerima onMediaAdded(media: Media) untuk semua file media yang ditemukan di folder ini.
  3. listener.onBrowseEnd() dipanggil saat parsing folder

Ini adalah fungsi refresh lama di penyedia konten untuk browser VLC:

 private val refreshList = mutableListOf<Media>() fun refresh() = requestBrowsing(url, refreshListener) private val refreshListener = object : EventListener{ override fun onMediaAdded(media: Media) { refreshList.add(media)) } override fun onBrowseEnd() { val list = refreshList.toMutableList() refreshList.clear() launch { dataset.value = list parseSubDirectories() } } } 

Bagaimana cara memperbaikinya?

Buat saluran yang akan dijalankan dalam refresh . Sekarang panggilan balik browser hanya akan mengarahkan media ke saluran ini, dan kemudian menutupnya.

Sekarang fungsi refresh menjadi lebih jelas. Dia membuat saluran, memanggil browser VLC, lalu membentuk daftar file media dan memprosesnya.

Alih-alih select atau consumeEach Anda dapat menggunakan for menunggu media, dan loop ini akan pecah segera setelah browserChannel ditutup.

 private lateinit var browserChannel : Channel<Media> override fun onMediaAdded(media: Media) { browserChannel.offer(media) } override fun onBrowseEnd() { browserChannel.close() } suspend fun refresh() { browserChannel = Channel(Channel.UNLIMITED) val refreshList = mutableListOf<Media>() requestBrowsing(url) //        for (media in browserChannel) refreshList.add(media) //   dataset.value = refreshList parseSubDirectories() } 

Menyederhanakan situasi dengan callback (bagian 2): Retrofit

Pendekatan kedua: kami sama sekali tidak menggunakan kotlinx coroutine, tetapi kami menggunakan kerangka inti coroutine.

Lihat bagaimana sebenarnya coroutine bekerja!

Fungsi retrofitSuspendCall membungkus permintaan Retrofit Call untuk menjadikannya fungsi suspend .

Menggunakan suspendCoroutine kami memanggil metode Call.enqueue dan menjeda coroutine. Callback yang disediakan dengan cara ini akan memanggil continuation.resume(response) untuk melanjutkan coroutine dengan respons dari server segera setelah diterima.

Selanjutnya, kita hanya perlu menggabungkan fungsi Retrofit kita ke retrofitSuspendCall untuk mengembalikan hasil kueri menggunakannya.

 suspend inline fun <reified T> retrofitSuspendCall(request: () -> Call <T> ) : Response <T> = suspendCoroutine { continuation -> request.invoke().enqueue(object : Callback<T> { override fun onResponse(call: Call<T>, response: Response<T>) { continuation.resume(response) } override fun onFailure(call: Call<T>, t: Throwable) { continuation.resumeWithException(t) } }) } suspend fun browse(path: String?) = retrofitSuspendCall { ApiClient.browse(path) } //  (   Main) livedata.value = Repo.browse(path) 

Jadi, panggilan yang memblokir jaringan dibuat dalam utas Retrofit khusus, coroutine ada di sini, menunggu respons dari server, dan tidak ada tempat untuk menggunakannya dalam aplikasi!

Implementasi ini terinspirasi oleh perpustakaan gildor / kotlin-coroutines-retrofit .

Ada juga JakeWharton / retrofit2-kotlin-coroutines-adapter dengan implementasi lain yang memberikan hasil yang serupa.

Epilog

Channel dapat digunakan dengan banyak cara lain; Lihat BroadcastChannel untuk implementasi yang lebih kuat yang mungkin berguna bagi Anda.

Anda juga dapat membuat saluran menggunakan fungsi Produce .

Akhirnya, menggunakan saluran akan lebih mudah untuk mengatur komunikasi antara komponen-komponen UI: adaptor dapat mengirimkan peristiwa klik ke fragmen / aktivitasnya melalui Channel atau, misalnya, melalui Actor .

Source: https://habr.com/ru/post/id457224/


All Articles