Dieser Artikel soll zeigen, wie Kotlin Coroutines verwendet und Reaxtive eXtensions (Rx) entfernt werden .
Vorteile
Betrachten wir zunächst vier Vorteile von Coroutines gegenüber Rx:
Federung über Blockierung
Um nicht blockierenden Code mit Rx auszuführen, schreiben Sie Folgendes:
Observable.interval(1, TimeUnit.SECONDS) .subscribe { textView.text = "$it seconds have passed" }
Welches schafft effektiv einen neuen Thread. Threads sind schwere Objekte in Bezug auf Speicher und Leistung.
Beides ist in der mobilen Entwicklungswelt von entscheidender Bedeutung.
Sie können dasselbe Verhalten mit dem folgenden Snippet erzielen:
launch { var i = 0 while (true){ textView.text = "${it++} seconds have passed" delay(1000) } }
Coroutinen sind im Wesentlichen leichte Threads, aber wir erstellen keinen echten Thread.
Hier verwenden wir die nicht blockierende delay () -Funktion, eine spezielle Suspending-Funktion, die keinen Thread blockiert, sondern die Coroutine suspendiert.
Natürlicher Umgang mit Gegendruck über manuell
Gegendruck ist, wenn Observable Gegenstände schneller produzieren, als ihre Beobachter sie konsumieren.
Während Sie Rx verwenden, müssen Sie explizit angeben, wie Sie mit Gegendruck umgehen.
Es gibt zwei grundlegende Ansätze:
- Verwenden Sie Drossel-, Puffer- oder Fensteroperatoren
- Das reaktive Pull-Modell
Während Coroutinen suspendieren können, bieten sie eine natürliche Antwort auf den Umgang mit Gegendruck.
Somit sind keine zusätzlichen Aktionen erforderlich.
Synchronisieren Sie den Codestil über asynchron
Die grundlegende Natur einer mobilen App besteht darin, auf Benutzeraktionen zu reagieren. Deshalb wäre Reactive eXtensions eine gute Wahl.
Sie müssen jedoch einen Code in einem funktionalen Stil schreiben. Wenn Sie früher im imperativen Stil geschrieben haben, könnte es etwas schwierig sein.
Mit Coroutines können Sie asynchronen Code so schreiben, als wären es übliche Synchronisierungsfunktionen. Zum Beispiel
suspend fun showTextFromRemote() { val text = remote.getText() textView.text = text }
Selbst wenn ich lange Zeit mit funktionalem Stil arbeite, ist es immer noch einfacher, einen zwingenden Code zu lesen und zu debuggen.
Native über Drittanbieter lib
Coroutinen sind eine native integrierte Funktion von Kotlin.
Sie müssen keine zusätzlichen Abhängigkeiten hinzufügen. Derzeit könnten sich alle Hauptbibliotheken mit Coroutinen befassen.
Zum Beispiel
Nachrüstung
interface Api { @Get("users") suspend fun loadUsers() : List<User> }
Zimmer
interface Dao { @Update suspend fun update(user: UserEntity) }
Sie können also eine App erstellen, die vollständig angehalten wird - die Benutzeroberfläche wird über die Domäne gestartet und endet in der Datenschicht.
Die App
Gehen wir zur Sache. Wir werden eine klassische Master-Detail-App erstellen.
Die erste Seite würde eine unendliche Liste von Lieferungen enthalten.
Beim Klicken auf einen Artikel öffnen wir eine Detailseite.
Außerdem unterstützen wir den Offline-Modus - alle Daten werden zwischengespeichert.
Außerdem werde ich die MVVM-Architektur verwenden, bei der die ViewModel-Rolle von Fragment anstelle von ViewModel von AAC gespielt wird. Es gibt mehrere Gründe:
Fragmente sind normalerweise sehr kahl - binden Sie viewModel einfach an XML.
Funktionen wie das Festlegen der Farbe der Statusleiste konnten in AAC ViewModel nicht ausgeführt werden. Sie müssen die Fragmentmethode auslösen. Die Verwendung von Fragment als ViewModel würde es uns ermöglichen, alle zugehörigen Funktionen (Verwaltung eines bestimmten Bildschirms) in einer Klasse zu speichern.
Zuerst erstellen wir BaseViewModel:
abstract class BaseViewModel<B : BaseBindings, V : ViewDataBinding> : Fragment(), CoroutineScope by CoroutineScope(Dispatchers.IO){ protected abstract val layoutId: Int protected abstract val bindings: B protected lateinit var viewBinding: V override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) retainInstance = true } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { viewBinding = DataBindingUtil.inflate(inflater, layoutId, container, false) return viewBinding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewBinding.lifecycleOwner = viewLifecycleOwner viewBinding.setVariable(BR.bindings, bindings) } override fun onDestroy() { cancel() super.onDestroy() } }
Wir markieren unser ViewModel als CoroutineScope, damit wir Coroutinen in Ansichtsmodellen starten können und alle gestarteten Coroutinen auf den Lebenszyklus eines Fragments beschränkt sind.
Wir müssen explizit das Ende der Lifecycle-Aufrufmethode cancel()
des Bereichs angeben, um alle laufenden Anforderungen abzubrechen und Speicherlecks zu vermeiden.
Wir setzen retainInstance = true
damit bei Konfigurationsänderungen das Fragment nicht neu erstellt wird, damit wir alle lang laufenden Anforderungen retainInstance = true
können.
Außerdem müssen wir lifecycleOwner auf Bindung setzen, um die bidirektionale Datenbindung zu aktivieren .
Ausnahmebehandlung
Laut Coroutines- Dokumentation :
Coroutine builders come in two flavors: propagating exceptions automatically (launch and actor) or exposing them to users (async and produce). The former treat exceptions as unhandled, similar to Java's Thread.uncaughtExceptionHandler
Da wir in den meisten Fällen den Launch Builder verwenden, müssen wir CoroutineExceptionHandler angeben
CoroutineExceptionHandler ist CoroutineContext.Element, mit dem mit dem Operator plus ein Coroutine-Kontext erstellt werden kann.
Ich werde den statischen Handler wie folgt deklarieren:
val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Timber.e(throwable) }
Und ändern Sie BaseViewModel:
abstract class BaseViewModel<B : BaseBindings, V : ViewDataBinding> : Fragment(), CoroutineScope by CoroutineScope(Dispatchers.IO + exceptionHandler)
Von hier an würde jede Ausnahme, die in der gestarteten Coroutine im ViewModel-Bereich aufgetreten ist, an einen bestimmten Handler geliefert.
Als nächstes muss ich meine API und DAO deklarieren:
interface DeliveriesApi { @GET("deliveries") suspend fun getDeliveries(@Query("offset") offset: Int, @Query("limit") limit: Int): List<DeliveryResponse> } @Dao interface DeliveryDao { @Query("SELECT * FROM ${DeliveryEntity.TABLE_NAME}") fun getAll(): DataSource.Factory<Int, DeliveryEntity> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(delivery: DeliveryEntity) }
Wie Sie sehen, habe ich Methoden als ausgesetzt markiert, damit wir nur erwartete Antwortobjekte deklarieren können. Darüber hinaus wird durch die Stornierung der übergeordneten Coroutine auch der Netzwerkanruf abgebrochen.
Gleiches gilt für DAO.
Der einzige Unterschied besteht darin, dass wir die Möglichkeit bieten möchten, die Datenbank zu beobachten.
Am einfachsten ist die integrierte Live-Datenunterstützung. Wenn wir jedoch getAll () als angehalten markieren würden, würde dies einen Kompilierungsfehler verursachen
Fehler:
Not sure how to convert a Cursor to this method's return type ...
Hier müssen wir nicht aussetzen, weil:
- Datenbankanforderungen werden standardmäßig im Hintergrund ausgeführt
- Das Ergebnis von LiveData ist lebenszyklusabhängig, sodass wir es nicht manuell abbrechen müssen
Wir müssen irgendwie entfernte und lokale Datenquellen kombinieren.
Es lohnt sich, sich daran zu erinnern - es sollte nur einen einzigen Punkt der Wahrheit geben.
Nach dem Offline-First-Design wäre es lokaler Speicher. Wir würden also den Datenbankstatus beobachten. Wenn nichts abzurufen ist, werden Daten von der Fernbedienung abgefragt und in die Datenbank eingefügt.
Wir werden die Listing-Klasse vorstellen
data class Listing<T>( val pagedList: LiveData<PagedList<T>>, val dataState: LiveData<DataState>, val refreshState: LiveData<DataState>, val refresh: () -> Unit, val retry: () -> Unit )
Lass uns Val für Val gehen:
- pagedList - Die Hauptdaten, die als PagedList erstellt wurden, um ein unendliches Scrollen zu ermöglichen, und mit LiveData umschlossen sind, um das Beobachten von Daten zu ermöglichen
- dataState - einer von drei Zuständen, in denen unsere Daten sein können: Erfolg, Ausführen, Fehler. Wird auch in LiveData eingeschlossen, um Änderungen zu beobachten
- refreshState - Wenn wir eine Datenaktualisierung durch Wischen zum Aktualisieren auslösen, benötigen wir ein Tool, mit dem wir zwischen dem Feedback der Aktualisierungsanforderung und dem Feedback der nächsten Seite unterscheiden können. Für das erstere möchten wir einen Fehler am Ende der Liste anzeigen, aber für einen Aktualisierungsfehler möchten wir eine Toastnachricht anzeigen und einen Loader ausblenden.
- refresh () - Rückruf, der beim Wischen zum Aktualisieren ausgelöst wird
retry () - Rückruf zum Auslösen eines Ladefehlers bei pagedList
Als nächstes Listenansichtsmodell:
class DeliveryListViewModel : BaseViewModel<DeliveryListBindings, DeliveryListBinding>(), DeliveryListBindings, DeliveryListItemBindings, DeliveryListErrorBindings { override val layoutId: Int = R.layout.delivery_list override val bindings: DeliveryListBindings = this private val deliveryGateway: DeliveryGateway by inject { parametersOf(this) } private val listing = deliveryGateway.getDeliveries() override val dataState = listing.dataState override val isRefreshing = Transformations.switchMap(listing.refreshState) { MutableLiveData(it == DataState.Loading) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupList() setupRefresh() } private fun setupList() { val adapter = DeliveriesAdapter(this, this) viewBinding.deliveries.adapter = adapter viewBinding.deliveries.setHasFixedSize(true) listing.pagedList.observe(viewLifecycleOwner, Observer { adapter.submitList(it) }) listing.dataState.observe(viewLifecycleOwner, Observer { adapter.updateDataState(it) }) } private fun setupRefresh() { listing.refreshState.observe(viewLifecycleOwner, Observer { if (it is DataState.Error) { Toast.makeText(context, it.message, LENGTH_SHORT).show() } }) } override fun refresh() { listing.refresh() } override fun onDeliveryClicked(delivery: Delivery) { view?.findNavController()?.navigate(DeliveryListViewModelDirections.toDetails(delivery)) } override fun onRetryClicked() { listing.retry() } }
Beginnen wir mit der Klassendeklaration.
Zunächst DeliveryListBindings und DeliveryListBinding. Das erste ist unsere deklarierte Schnittstelle zum Kleben des Ansichtsmodells mit der XML-Ansicht. Zweitens ist die automatisch generierte Klasse basierend auf XML. Wir benötigen die zweite, um unsere Bindungsschnittstelle und unseren Lebenszyklus auf XML einzustellen.
Darüber hinaus ist es empfehlenswert, Ansichten unter Verwendung dieser autogenerierten Bindung zu referenzieren, anstatt Kotlins Synthetik zu verwenden.
Es kann vorkommen, dass in der aktuellen Ansicht nicht über eine synthetische Ansicht verwiesen wird. Mit der Datenbindung scheitern Sie auch in der Kompilierungsphase schnell.
Als nächstes drei Schnittstellen: DeliveryListBindings, DeliveryListItemBindings, DeliveryListErrorBindings.
- DeliveryListBindings - Bindungen für den Bildschirm selbst. Beispielsweise enthält es die Methode refresh (), die beim vertikalen Wischen aufgerufen wird.
- DeliveryListItemBindings - Bindungen für ein Element in der Liste. Zum Beispiel onClicked ()
- DeliveryListErrorBindings - Bindungen für die Fehleransicht, die auch das Listenelement ist, das im Fehlerstatus angezeigt wird. Zum Beispiel enthält es die Methode retry ()
Daher behandeln wir alles im Einzelansichtsmodell, da es sich um einen einzelnen Bildschirm handelt, aber auch dem Prinzip der Schnittstellentrennung folgt
Wenden wir uns besonders dieser Zeile zu:
private val deliveryGateway: DeliveryGateway by inject { parametersOf(this) }
DeliveryGateway muss Anforderungen außerhalb des Hauptthreads ausführen. Daher müssen entweder Methoden als ausgesetzt oder CoroutineScope deklariert werden, um neue Coroutinen in diesem Bereich zu starten. Wir würden den zweiten Ansatz wählen, da wir unsere LiveData von Anfang an benötigen, und dann würden wir nur auf Aktualisierungen warten. Es ist dem Abonnieren der liveData-Instanz sehr ähnlich, wenn wir lifecycleOwner übergeben (was häufig auf 'this' verweist). Hier ist auf die gleiche Weise, wie wir "dies" als CoroutineScope übergeben
Die CoroutineScope-Schnittstelle besteht aus einem einzigen Feld - CoroutineContext. Im Wesentlichen sind ein Bereich und ein Kontext dasselbe. Der Unterschied zwischen einem Kontext und einem Bereich liegt in ihrem beabsichtigten Zweck.
Um mehr darüber zu erfahren, würde ich einen Artikel von Roman Elizarov empfehlen. Wenn Sie DeliveryGateway also einen Bereich zur Verfügung stellen, wird auch derselbe Kontext verwendet. Speziell Thread-, Job- und Ausnahmehandler.
Schauen wir uns nun DeliveryGateway selbst an:
class DeliveryBoundGateway( private val db: DataBase, private val api: DeliveriesApi, private val deliveryDao: DeliveryDao, private val coroutineScope: CoroutineScope ) : DeliveryGateway { private val boundaryCallback = DeliveriesBoundaryCallback( api = api, coroutineScope = coroutineScope, handleResponse = { insertIntoDatabase(it) } ) @MainThread override fun getDeliveries(): Listing<Delivery> { val refreshTrigger = MutableLiveData<Unit>() val refreshState = Transformations.switchMap(refreshTrigger) { refresh() } val pagingConfig = Config( initialLoadSizeHint = PAGE_SIZE, pageSize = PAGE_SIZE, prefetchDistance = PAGE_SIZE ) val deliveries = deliveryDao.getAll() .toLiveData( config = pagingConfig, boundaryCallback = boundaryCallback ) return Listing( pagedList = deliveries, dataState = boundaryCallback.dataState, retry = { boundaryCallback.helper.retryAllFailed() }, refresh = { refreshTrigger.value = null }, refreshState = refreshState ) } @MainThread private fun refresh(): LiveData<DataState> { boundaryCallback.refresh() val dataState = MutableLiveData<DataState>() dataState.value = DataState.Loading coroutineScope.launch { try { val deliveries = api.getDeliveries(0, PAGE_SIZE) db.withTransaction { deliveryDao.clear() insertIntoDatabase(deliveries) } dataState.postValue(DataState.Loaded) } catch (throwable: Throwable) { Timber.w(throwable) dataState.postValue(DataState.Error(throwable.message)) } } return dataState } private suspend fun insertIntoDatabase(deliveries: List<DeliveryResponse>) { deliveries.forEach { delivery -> val entity = deliveryConverter.fromNetwork(delivery) deliveryDao.insert(entity) } } companion object { const val PAGE_SIZE = 20 } }
Hier erstellen wir von Anfang an eine LiveData-Struktur und laden dann mithilfe von Coroutinen Daten und veröffentlichen sie in den LiveData. Außerdem verwenden wir die Implementierung von PagedList.BoundaryCallback (), um die lokale Datenbank und die Remote-API zu verkleben. Wenn wir das Ende der ausgelagerten Liste erreichen, wird borderCallback ausgelöst und lädt den nächsten Datenblock.
Wie Sie sehen, verwenden wir coroutineScope, um neue Coroutinen zu starten.
Da dieser Bereich dem Lebenszyklus des Fragments entspricht, werden alle ausstehenden Anforderungen beim onDestroy()
Rückruf des Fragments abgebrochen.
Die Seite mit den Lieferdetails ist recht einfach: Wir übergeben einfach ein Lieferobjekt als Paket vom Hauptbildschirm mithilfe des Plugins zum Speichern der Argumente der Navigationskomponente. Auf dem Detailbildschirm binden Sie einfach ein bestimmtes Objekt an ein XML.
class DeliveryViewModel : BaseViewModel<DeliveryBindings, DeliveryBinding>(), DeliveryBindings { override val layoutId: Int = R.layout.delivery override val bindings: DeliveryBindings = this private val args: DeliveryViewModelArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewBinding.delivery = args.delivery viewBinding.image.clipToOutline = true } }
Hier ist der Link zum Github-Quellcode.
Sie können gerne Kommentare und offene Fragen hinterlassen.