Pubblicato da Chris Banes

Un esempio elaborato dall'app Tivi

Illustrazione di Virginia Poltrack

Questo post sul blog è il secondo dei due post che mostrano come le coroutine consentano di scrivere complesse operazioni UI asincrone in modo molto più semplice. Il primo post descrive la teoria, mentre questo mostra come è possibile risolvere un problema specifico.
Se vuoi consultare il primo post, puoi trovarlo qui:
Sospensione sulle viste
Prendiamo ciò che abbiamo appreso nel post precedente e applichiamolo a un caso di utilizzo di app nel mondo reale.

Il problema

Qui abbiamo la UI dei dettagli dei programmi TV dell'app di esempio Tivi. Oltre alle informazioni sui programmi, elenca le stagioni e gli episodi. Quando l'utente fa clic su uno degli episodi, i dettagli dell'episodio vengono visualizzati utilizzando un'animazione che espande l'elemento selezionato:



Espansione episodio (velocità 20%)

L'app utilizza la libreria InboxRecyclerView per gestire l'animazione di espansione precedente:
fun onEpisodeItemClicked(view: View, episode: Episode) {
    // Tell InboxRecyclerView to expand the episode item. 
    // We pass it the ID of the item to expand
    recyclerView.expandItem(episode.id)
}


InboxRecyclerView funziona fornendo l'ID elemento della vista da espandere. Quindi trova la vista corrispondente dagli elementi di RecyclerView ed esegue l'animazione su di essa.
Ora diamo un'occhiata al problema che stiamo cercando di risolvere. Nella parte superiore della stessa UI è presente un elemento diverso, che mostra all'utente il prossimo episodio da guardare. Utilizza lo stesso tipo di vista dell'elemento del singolo episodio mostrato sopra, ma ha un ID elemento diverso.
Vista la mia pigrizia, per facilitare lo sviluppo ho usato lo stesso onEpisodeItemClicked() per questo elemento. Sfortunatamente questo ha portato a un'interruzione dell'animazione al momento del clic.



Espansione elemento errata (velocità 20%)

Invece di espandere l'elemento cliccato, la libreria espande un elemento apparentemente casuale in alto. Questo non è l'effetto desiderato ed è causato da alcuni problemi sottostanti:
  1. L'ID che utilizziamo nel listener di clic viene preso direttamente dalla classe Episode. Questo ID è associato all'elemento dell'episodio singolo nell'elenco delle stagioni.
  2. L'elemento dell'episodio potrebbe non essere collegato a RecyclerView. Affinché la vista esista in RecyclerView, l'utente dovrebbe aver allargato la stagione e scorso in modo tale che l'elemento sia nell'area visibile.
A causa di questi problemi, la libreria espande il primo elemento.

Soluzione ideale

Quindi qual è il comportamento previsto? Idealmente avremmo qualcosa del genere (rallentato:



Il risultato ideale (velocità 20%)

In pseudo-codice potrebbe apparire così:
fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) {
    // Tell the ViewModel to include the season’s episodes in the
    // RecyclerView data set. This will trigger a database fetch, and update
    // the view state
    viewModel.expandSeason(nextEpisodeToWatch.seasonId)

    // Scroll the RecyclerView so that the episode is displayed
    recyclerView.scrollToItemId(nextEpisodeToWatch.id)

    // Expand the item like before
    recyclerView.expandItem(nextEpisodeToWatch.id)
}

In realtà, però, dovrebbe assomigliare di più a questo:
fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) {
    // Tell the ViewModel to include the season’s episodes in the
    // RecyclerView data set. This will trigger a database fetch
    viewModel.expandSeason(nextEpisodeToWatch.seasonId)

    // TODO wait for new state dispatch from the ViewModel
    // TODO wait for RecyclerView adapter to diff new data set
    // TODO wait for RecyclerView to layout any new items

    // Scroll the RecyclerView so that the episode is displayed
    recyclerView.scrollToItemId(nextEpisodeToWatch.id)

    // TODO wait for RecyclerView scroller to finish

    // Expand the item like before
    recyclerView.expandItem(nextEpisodeToWatch.id)
}

Come puoi vedere, bisogna aspettare un bel po' per l'esecuzione di attività asincrone! ⏳
Lo pseudo-codice qui non sembra troppo complesso, ma quando inizi a implementarlo ti ritrovi presto impantanato in un mare di callback. Ecco un tentativo di scrivere una soluzione scheletro usando callback concatenati:
fun expandEpisodeItem(itemId: Long) {
    recyclerView.expandItem(itemId)
}

fun scrollToEpisodeItem(position: Int) {
   recyclerView.smoothScrollToPosition(position)
  
   // Add a scroll listener, and wait for the RV to be become idle
   recyclerView.addOnScrollListener(object : OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                expandEpisodeItem(episode.id)
            }
        }
    })
}

fun waitForEpisodeItemInAdapter() {
    // We need to wait for the adapter to contain the item id
    val position = adapter.findItemIdPosition(itemId)
    if (position != RecyclerView.NO_POSITION) {
        // The item ID is in the adapter, now we can scroll to it
        scrollToEpisodeItem(itemId))
    } else {
        // Otherwise we wait for new items to be added to the adapter and try again
       adapter.registerAdapterDataObserver(object : AdapterDataObserver() {
            override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
                waitForEpisodeItemInAdapter()
            }
        })
    }
}

// And tell the ViewModel to give us the expanded season data set
viewModel.expandSeason(nextEpisodeToWatch.seasonId)
// Now need to wait for the new data
waitForEpisodeItemInAdapter()

Questo codice non è particolarmente ben scritto e probabilmente non funziona, ma lo scopo è mostrare come le callback possano rendere la programmazione della UI davvero complessa. In genere, questo codice presenta alcuni problemi:

Strettamente accoppiate

Dal momento che dobbiamo scrivere la nostra transizione usando i callback, ogni "animazione" deve essere consapevole di cosa chiamare successivamente: Il callback n. 1 chiama l'animazione 2, il callback n. 2 chiama l'animazione n. 3 e così via. Queste animazioni non hanno alcuna relazione l'una con l'altra, ma siamo stati costretti ad accoppiarle insieme.

Difficile da mantenere/aggiornare

Due mesi dopo aver scritto questo codice, il tuo motion designer ti chiede di aggiungere una transizione di dissolvenza nel mezzo. Dovrai tracciare la transizione, passando attraverso ogni callback per trovare il callback corretto in cui attivare la nuova animazione. Quindi dovrai provarla...

Test

Testare le animazioni è sempre difficile, ma barcamenarsi in questo mare di callback lo rende ancora più complesso. Il test deve riguardare tutti i diversi tipi di animazione e tutti i callback per verificare che tutto funzioni correttamente. In questo articolo non analizziamo i test in dettaglio, ma è un'attività che le coroutine rendono molto più facile.

Coroutine in soccorso 

Nel primo post abbiamo imparato come includere un'API di callback in una funzione di sospensione. Usiamo questa conoscenza per trasformare il nostro terribile codice di callback in questo:
viewLifecycleOwner.lifecycleScope.launch {    
    // await until the adapter contains the episode item ID
    adapter.awaitItemIdExists(episode.id)
    // Find the position of the season item
    val seasonItemPosition = adapter.findItemIdPosition(episode.seasonId)

    // Scroll the RecyclerView so that the season item is at the
    // top of the viewport
    recyclerView.smoothScrollToPosition(seasonItemPosition)
    // ...and await that scroll to finish
    recyclerView.awaitScrollEnd()

    // Finally, expand the episode item to show the episode details
    recyclerView.expandItem(episode.id)
}

Non è decisamente più leggibile?
Le nuove funzioni di sospensione dell'attesa nascondono tutta la complessità, determinando un elenco sequenziale di chiamate di funzione. Entriamo più nei dettagli...

MotionLayout.awaitTransitionComplete()

Al momento non ci sono estensioni MotionLayout ktx disponibili e, inoltre, MotionLayout attualmente non ha la possibilità di aggiungere più di un listener alla volta (richiesta di funzionalità). Ciò significa che l'implementazione della funzione awaitTransitionComplete() è un po' più complicata rispetto ad alcune delle altre funzioni.
Usiamo una sottoclasse di MotionLayout che aggiunge il supporto per più listener: MultiListenerMotionLayout.
La nostra funzione waititTransitionComplete() viene quindi definita come:
/**
 * Wait for the transition to complete so that the given [transitionId] is fully displayed.
 * 
 * @param transitionId The transition set to await the completion of
 * @param timeout Timeout for the transition to take place. Defaults to 5 seconds.
 */
suspend fun MultiListenerMotionLayout.awaitTransitionComplete(transitionId: Int, timeout: Long = 5000L) {
    // If we're already at the specified state, return now
    if (currentState == transitionId) return

    var listener: MotionLayout.TransitionListener? = null

    try {
        withTimeout(timeout) {
            suspendCancellableCoroutine<Unit> { continuation ->
                val l = object : TransitionAdapter() {
                    override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) {
                        if (currentId == transitionId) {
                            removeTransitionListener(this)
                            continuation.resume(Unit)
                        }
                    }
                }
                // If the coroutine is cancelled, remove the listener
                continuation.invokeOnCancellation {
                    removeTransitionListener(l)
                }
                // And finally add the listener
                addTransitionListener(l)
                listener = l
            }
        }
    } catch (tex: TimeoutCancellationException) {
        // Transition didn't happen in time. Remove our listener and throw a cancellation
        // exception to let the coroutine know 
        listener?.let(::removeTransitionListener)
        throw CancellationException("Transition to state with id: $transitionId did not" +
                " complete in timeout.", tex)
    }
}

Adapter.awaitItemIdExists()

Questa funzione è probabilmente piuttosto di nicchia, ma è molto utile. Nell'esempio dei programmi TV precedenti, in realtà gestisce alcuni stati asincroni diversi:
// Make sure that the season is expanded, with the episode attached
viewModel.expandSeason(nextEpisodeToWatch.seasonId)
// 1. Wait for new data dispatch
// 2. Wait for RecyclerView adapter to diff new data set
// Scroll the RecyclerView so that the episode is displayed
recyclerView.scrollToItemId(nextEpisodeToWatch.id)
La funzione viene implementata usando AdapterDataObserver di RecyclerView, che viene chiamato ogni volta che cambia il set di dati dell'adattatore:
/**
 * Await an item in the data set with the given [itemId], and return its adapter position.
 */
suspend fun <VH : RecyclerView.ViewHolder> RecyclerView.Adapter<VH>.awaitItemIdExists(itemId: Long): Int {
    val currentPos = findItemIdPosition(itemId)
    // If the item is already in the data set, return the position now
    if (currentPos >= 0) return currentPos

    // Otherwise we register a data set observer and wait for the item ID to be added
    return suspendCancellableCoroutine { continuation ->
        val observer = object : RecyclerView.AdapterDataObserver() {
            override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
                (positionStart until positionStart + itemCount).forEach { position ->
                    // Iterate through the new items and check if any have our itemId
                    if (getItemId(position) == itemId) {
                        // Remove this observer so we don't leak the coroutine
                        unregisterAdapterDataObserver(this)
                        // And resume the coroutine
                        continuation.resume(position)
                    }
                }
            }
        }
        // If the coroutine is cancelled, remove the observer
        continuation.invokeOnCancellation {
            unregisterAdapterDataObserver(observer)
        }
        // And finally register the observer
        registerAdapterDataObserver(observer)
    }
}

RecyclerView.awaitScrollEnd()

L'ultima funzione da evidenziare è RecyclerView.awaitScrollEnd(), che attende che lo scorrimento sia terminato:
suspend fun RecyclerView.awaitScrollEnd() {
    // If a smooth scroll has just been started, it won't actually start until the next
    // animation frame, so we'll await that first
    awaitAnimationFrame()
    // Now we can check if we're actually idle. If so, return now
    if (scrollState == RecyclerView.SCROLL_STATE_IDLE) return

    suspendCancellableCoroutine<Unit> { continuation ->
        continuation.invokeOnCancellation {
            // If the coroutine is cancelled, remove the scroll listener
            recyclerView.removeOnScrollListener(this)
            // We could also stop the scroll here if desired
        }

        addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                    // Make sure we remove the listener so we don't leak the
                    // coroutine continuation
                    recyclerView.removeOnScrollListener(this)
                    // Finally, resume the coroutine
                    continuation.resume(Unit)
                }
            }
        })
    }
}

Spero che ora questo codice risulti abbastanza comprensibile. Il problema con questa funzione è la necessità di utilizzare awaitAnimationFrame() prima di eseguire il controllo fail-fast. Come menzionato nei commenti, ciò è dovuto al fatto che uno SmoothScroller inizia al successivo frame di animazione, quindi bisogna attendere che ciò accada prima di controllare lo stato di scorrimento.
awaitAnimationFrame() è un wrapper su postOnAnimation (), che ci consente di attendere il successivo step temporale dell'animazione, che in genere si verifica al rendering della visualizzazione successiva. È implementato come nell'esempio doOnNextLayout() dal primo post:
suspend fun View.awaitAnimationFrame() = suspendCancellableCoroutine<Unit> { continuation ->
    val runnable = Runnable {
        continuation.resume(Unit)
    }
    // If the coroutine is cancelled, remove the callback
    continuation.invokeOnCancellation { removeCallbacks(runnable) }
    // And finally post the runnable
    postOnAnimation(runnable)
}

Risultato finale

Alla fine, la sequenza delle operazioni è simile alla seguente:



Soluzione, suddivisa in passaggi (velocità 20%)

Rompere le catene dei callback ⛓️

Passando alle coroutine, il nostro codice è in grado di eliminare lunghissime catene di callback, che sono difficili da mantenere e testare.
La ricetta per racchiudere un'API callback/listener/observer in una funzione di sospensione è sostanzialmente la stessa per tutte le API. Spero che le funzioni mostrate in questo post siano ora di facile comprensione. Quindi non ti resta che liberare il codice della tua UI dalle catene dei callback!