Ein moderner Ansatz für den Wettbewerb in Android: Corotins bei Kotlin

Hallo Habr!

Wir erinnern Sie daran, dass wir bereits eine Vorbestellung für das lang erwartete Buch über die Kotlin-Sprache aus der berühmten Big Nerd Ranch Guides-Reihe haben. Heute haben wir beschlossen, Sie auf eine Übersetzung eines Artikels aufmerksam zu machen, der über Kotlin-Coroutinen und die korrekte Arbeit mit Streams in Android berichtet. Das Thema wird sehr aktiv diskutiert. Der Vollständigkeit halber empfehlen wir Ihnen daher, diesen Artikel von Habr und diesen ausführlichen Beitrag aus dem Axmor Software-Blog zu lesen.

Das moderne Wettbewerbs-Framework in Java / Android verursacht Rückrufe und führt zu Blockierungszuständen, da Android keine relativ einfache Möglichkeit bietet, die Thread-Sicherheit zu gewährleisten.

Kotlin Coroutinen sind ein sehr effektives und vollständiges Toolkit, das die Verwaltung des Wettbewerbs viel einfacher und produktiver macht.

Pause und Block: Was ist der Unterschied?

Coroutinen ersetzen keine Threads, sondern bieten einen Rahmen für deren Verwaltung. Die Philosophie von corutin besteht darin, einen Kontext zu definieren, in dem Sie warten können, bis Hintergrundvorgänge abgeschlossen sind, ohne den Hauptthread zu blockieren.

Corutins Ziel ist es in diesem Fall, auf Rückrufe zu verzichten und den Wettbewerb zu vereinfachen.

Einfachstes Beispiel

Nehmen wir zunächst das einfachste Beispiel: Führen Sie coroutine im Kontext von Main (Hauptthread) aus. Darin extrahieren wir das Bild aus dem IO Stream und senden dieses Bild zur Verarbeitung zurück an Main .

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

Der Code ist als Single-Thread-Funktion einfach. Während getImage im zugewiesenen Pool von getImage Threads ausgeführt wird, ist der Hauptthread außerdem frei und kann jede andere Aufgabe übernehmen! Die Funktion withContext hält die aktuelle Coroutine an, während ihre Aktion ausgeführt wird ( getImage() ). Sobald getImage() zurückkehrt und der looper aus dem Hauptthread verfügbar wird, nimmt imageView.setImageBitmap(image) die Arbeit im Hauptthread wieder auf und ruft imageView.setImageBitmap(image) .

Das zweite Beispiel: Jetzt müssen zwei Hintergrundaufgaben erledigt werden, damit sie verwendet werden können. Wir werden das Duett async / await verwenden, damit diese beiden Aufgaben parallel ausgeführt werden, und ihr Ergebnis im Hauptthread verwenden, sobald beide Aufgaben fertig sind:

 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 ähnelt dem launch , gibt jedoch eine deferred Rückgabe zurück (eine Kotlin-Entität, die Future ), sodass das Ergebnis mit await() abgerufen werden kann. Wenn es ohne Parameter aufgerufen wird, funktioniert es im Standardkontext für den aktuellen Bereich.

Auch hier bleibt der Haupt-Thread frei, während wir auf unsere 2 Werte warten.
Wie Sie sehen können, gibt die Startfunktion Job , mit dem gewartet werden kann, bis der Vorgang abgeschlossen ist. Dies erfolgt über die Funktion join() . Es funktioniert wie in jeder anderen Sprache, mit der Einschränkung, dass es einfach die Coroutine suspendiert und den Fluss nicht blockiert .

Versand

Dispatching ist ein Schlüsselkonzept bei der Arbeit mit Coroutinen. Mit dieser Aktion können Sie von einem Thread zum anderen "springen".

Überlegen Sie, wie das Äquivalent für den Versand in Main in Java aussieht.

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

Die Handler für Android ist ein Handler basierter Dispatcher. Das ist also in der Tat eine sehr geeignete Implementierung:

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

launch(Dispatchers.Main) sendet Runnable an Handler , sodass der Code nicht sofort ausgeführt wird.

launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) führt seinen Lambda-Ausdruck sofort im aktuellen Thread aus.

Dispatchers.Main stellt sicher, dass Coroutine, wenn die Arbeit wieder aufgenommen wird, an den Haupt-Thread weitergeleitet wird . Darüber hinaus wird Handler hier als native Android-Implementierung zum Senden an die Anwendungsereignisschleife verwendet.

Die genaue Implementierung sieht folgendermaßen aus:

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

Hier ist ein guter Artikel, der Ihnen hilft, die Feinheiten des Versands in Android zu verstehen:
Grundlegendes zu Android Core: Looper, Handler und HandlerThread .

Coroutine-Kontext

Der Coroutine-Kontext (auch als Coroutine-Manager bezeichnet) bestimmt, in welchem ​​Thread sein Code ausgeführt wird, was zu tun ist, wenn eine Ausnahme ausgelöst wird, und verweist auf den übergeordneten Kontext, um die Stornierung zu verbreiten.

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

job.cancel() alle Coroutinen ab, deren übergeordnetes job.cancel() job . Ein Ausnahmehandler erhält alle Ausnahmen, die in diesen Coroutinen ausgelöst werden.

Geltungsbereich

Die coroutineScope Schnittstelle vereinfacht die Fehlerbehandlung:
Wenn eine der Tochter-Coroutinen ausfällt, werden auch der gesamte Bereich und alle untergeordneten Coroutinen storniert.

Wenn es im async Beispiel nicht möglich war, den Wert zu extrahieren, während eine andere Aufgabe weiter funktionierte, haben wir einen beschädigten Zustand, und damit muss etwas getan werden.

Bei der Arbeit mit useValues wird die Funktion useValues nur aufgerufen, wenn beide Werte erfolgreich extrahiert wurden. Wenn deferred2 fehlschlägt, wird deferred1 abgebrochen.

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

Sie können auch eine ganze Klasse in den Bereich CoroutineContext , um einen Standard- CoroutineContext dafür CoroutineContext und zu verwenden.

Eine Beispielklasse, die die CoroutineScope Schnittstelle implementiert:

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

Ausführen von Corutin in CoroutineScope :

Der Standardstart- oder async Manager wird jetzt zum aktuellen Bereichsmanager.

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

Autonomer Start von Coroutine (außerhalb eines CoroutineScope):

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

Sie können den Bereich für eine Anwendung sogar definieren, indem Sie den Standard- Main festlegen:

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

Bemerkungen

  • Coroutinen beschränken die Interoperabilität mit Java
  • Begrenzen Sie die Veränderlichkeit, um Sperren zu vermeiden
  • Coroutinen sollen warten, nicht Threads organisieren
  • Vermeiden Sie E / A in Dispatchers.Default (und Main ...) - dafür ist Dispatchers.IO gedacht
  • Streams sind ressourcenintensiv, daher werden Single-Threaded-Kontexte verwendet
  • Dispatchers.Default basiert auf ForkJoinPool , das in Android 5+ eingeführt wurde
  • Coroutinen können über Kanäle verwendet werden

Sperren und Rückrufe über Kanäle beseitigen

Kanaldefinition aus der JetBrains-Dokumentation:

Kanal Channel BlockingQueue konzeptionell sehr ähnlich. Der Hauptunterschied besteht darin, dass der Put-Vorgang nicht blockiert wird, ein Suspend- send (oder ein nicht blockierendes offer ) vorgesehen ist und statt des Take-Vorgangs ein Suspend- receive .


Schauspieler

Stellen Sie sich ein einfaches Werkzeug für die Arbeit mit Kanälen vor: Actor .

Actor ist wiederum Handler sehr ähnlich: Wir definieren den Kontext der Coroutine (dh den Thread, in dem wir Aktionen ausführen werden) und arbeiten in einer sequentiellen Reihenfolge damit.

Der Unterschied besteht natürlich darin, dass hier Corutine verwendet werden; Sie können die Leistung und die ausgeführte Codepause angeben .

Im Prinzip leitet der actor jeden Befehl an den Coroutine-Kanal weiter. Es garantiert die Ausführung eines Befehls und schränkt Operationen in seinem Kontext ein . Dieser Ansatz hilft perfekt dabei, synchronize Anrufe loszuwerden und alle Threads frei zu halten!

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

In diesem Beispiel verwenden wir die versiegelten Kotlin-Klassen und wählen aus, welche Aktion ausgeführt werden soll.

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

Darüber hinaus werden alle diese Aktionen in die Warteschlange gestellt und niemals parallel ausgeführt. Dies ist ein bequemer Weg, um Variabilitätsgrenzen zu erreichen.

Android Lebenszyklus + Coroutinen

Akteure können auch sehr nützlich sein, um die Android-Benutzeroberfläche zu steuern, das Abbrechen von Aufgaben zu vereinfachen und eine Überlastung des Hauptthreads zu verhindern.
Lassen Sie uns dies implementieren und job.cancel() aufrufen, wenn die Aktivität zerstört wird.

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

Die SupervisorJob Klasse ähnelt dem regulären Job mit der einzigen Ausnahme, dass sich die Stornierung nur in der Downstream-Richtung erstreckt.

Daher brechen wir nicht alle Coroutinen in einer Activity wenn eine davon fehlschlägt.

Mit einer Erweiterungsfunktion , mit der Sie von jeder View in CoroutineScope aus auf diesen CoroutineContext zugreifen können, CoroutineContext es etwas besser aus.

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

Jetzt können wir all dies setOnClick Funktion setOnClick erstellt einen kombinierten Akteur, um seine onClick Aktionen zu steuern. Bei mehreren Abgriffen werden Zwischenaktionen ignoriert, wodurch ANR-Fehler beseitigt werden (die Anwendung reagiert nicht), und diese Aktionen werden im Rahmen der Activity . Wenn die Aktivität zerstört wird, wird dies alles abgebrochen.

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

In diesem Beispiel setzen wir den Channel auf " Conflated , damit einige Ereignisse ignoriert werden, wenn zu viele vorhanden sind. Sie können es durch Channel.UNLIMITED ersetzen, wenn Sie Ereignisse lieber in die Warteschlange stellen möchten, ohne eines davon zu verlieren, die Anwendung jedoch vor ANR-Fehlern schützen möchten.

Sie können auch die Coroutinen- und Lifecycle-Frameworks kombinieren, um das Abbrechen von Aufgaben im Zusammenhang mit der Benutzeroberfläche zu automatisieren:

 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) { /*    ! */ } 

Vereinfachen Sie die Situation mit Rückrufen (Teil 1)

Hier erfahren Sie, wie Sie die Verwendung von Callback-basierten APIs mit Channel transformieren.

Die API funktioniert folgendermaßen:

  1. requestBrowsing(url, listener) analysiert den Ordner unter url .
  2. Der listener empfängt onMediaAdded(media: Media) für jede in diesem Ordner gefundene Mediendatei.
  3. listener.onBrowseEnd() wird beim Parsen des Ordners aufgerufen

Hier ist die alte refresh im Inhaltsanbieter für den VLC-Browser:

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

Wie kann man es verbessern?

Erstellen Sie einen Kanal, der in der refresh . Jetzt leiten Browser-Rückrufe die Medien nur noch auf diesen Kanal und schließen ihn dann.

Jetzt ist die refresh klarer geworden. Sie erstellt einen Kanal, ruft den VLC-Browser auf, erstellt eine Liste mit Mediendateien und verarbeitet diese.

Anstelle der consumeEach oder consumeEach Sie for das Warten auf das Medium verwenden. Diese Schleife wird browserChannel sobald der browserChannel geschlossen wird.

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

Vereinfachung der Situation durch Rückrufe (Teil 2): ​​Nachrüstung

Der zweite Ansatz: Wir verwenden überhaupt keine Kotlinx-Coroutinen, sondern ein Coroutine-Kern-Framework.

Sehen Sie, wie Coroutinen tatsächlich funktionieren!

Die Funktion retrofitSuspendCall umschließt eine Retrofit Call Anforderung, um sie zu einer suspend Funktion zu machen.

Mit suspendCoroutine rufen wir die Call.enqueue Methode auf und halten die Coroutine an. Der auf diese Weise bereitgestellte Rückruf ruft continuation.resume(response) auf, um die Coroutine mit einer Antwort vom Server fortzusetzen, sobald sie empfangen wird.

Als nächstes müssen wir nur unsere Retrofit-Funktionen in retrofitSuspendCall kombinieren, um Abfrageergebnisse mit ihnen zurückzugeben.

 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) 

Somit erfolgt der Anruf, der das Netzwerk blockiert, im dedizierten Retrofit-Thread, die Coroutine ist hier und wartet auf eine Antwort vom Server, und es gibt keinen Ort, an dem sie in der Anwendung verwendet werden kann!

Diese Implementierung ist von der Gildor / Kotlin-Coroutines-Retrofit-Bibliothek inspiriert.

Es gibt auch einen JakeWharton / retrofit2-kotlin-coroutines-Adapter mit einer anderen Implementierung, die ein ähnliches Ergebnis liefert .

Nachwort

Channel kann auf viele andere Arten verwendet werden. In BroadcastChannel finden Sie leistungsfähigere Implementierungen, die Sie möglicherweise nützlich finden.

Sie können Kanäle auch mit der Funktion Produzieren erstellen.

Schließlich ist es unter Verwendung von Kanälen bequem, die Kommunikation zwischen den Komponenten der Benutzeroberfläche zu organisieren: Der Adapter kann Klickereignisse über Channel oder beispielsweise über Actor an sein Fragment / seine Aktivität übertragen.

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


All Articles