A prova di futuro il tuo codice Android, parte 2: programmazione reattiva funzionale in azione

Pubblicato: 2022-09-08

La programmazione reattiva funzionale (FRP) è un paradigma che combina la reattività della programmazione reattiva con la composizione della funzione dichiarativa della programmazione funzionale. Semplifica attività complesse, crea eleganti interfacce utente e gestisce lo stato senza intoppi. A causa di questi e di molti altri chiari vantaggi, l'uso di FRP sta diventando mainstream nello sviluppo mobile e web.

Ciò non significa che comprendere questo paradigma di programmazione sia facile, anche gli sviluppatori esperti potrebbero chiedersi: " Cos'è esattamente FRP?" Nella parte 1 di questo tutorial, abbiamo definito i concetti fondamentali di FRP: programmazione funzionale e programmazione reattiva. Questa puntata ti preparerà ad applicarlo, con una panoramica delle librerie utili e un'implementazione di esempio dettagliata.

Questo articolo è stato scritto pensando agli sviluppatori Android, ma i concetti sono rilevanti e vantaggiosi per qualsiasi sviluppatore con esperienza nei linguaggi di programmazione generali.

Guida introduttiva a FRP: progettazione del sistema

Il paradigma FRP è un ciclo infinito di stati ed eventi: State -> Event -> State' -> Event' -> State'' -> … . (Ricordiamo ' , pronunciato "prime", indica una nuova versione della stessa variabile.) Ogni programma FRP inizia con uno stato iniziale che verrà aggiornato ad ogni evento ricevuto. Questo programma include gli stessi elementi di quelli di un programma reattivo:

  • Stato
  • Evento
  • La pipeline dichiarativa (indicata come FRPViewModel function )
  • Osservabile (indicato come StateFlow )

Qui, abbiamo sostituito gli elementi reattivi generali con componenti e librerie Android reali:

Due caselle blu principali, "StateFlow" e "State", hanno due percorsi principali tra di loro. Il primo è tramite "Osserva (ascolta le modifiche)". Il secondo è tramite "Notifica (dell'ultimo stato)" nella casella blu "@Composable (JetpackCompose)," che va tramite "Trasforma l'input dell'utente in" nella casella blu "Evento", che va tramite "Trigger" nella casella blu " funzione FRPViewModel" e infine tramite "Produce (nuovo stato)." "Stato" si ricollega quindi anche alla "funzione FRPViewModel" tramite "Agisce come input per".
Il ciclo di programmazione reattiva funzionale in Android.

Esplorazione di librerie e strumenti FRP

Esistono numerose librerie e strumenti Android che possono aiutarti a iniziare con FRP e che sono rilevanti anche per la programmazione funzionale:

  • Ivy FRP : questa è una libreria che ho scritto che verrà utilizzata per scopi didattici in questo tutorial. È inteso come punto di partenza per il tuo approccio a FRP, ma non è destinato all'uso in produzione poiché manca un supporto adeguato. (Al momento sono l'unico ingegnere che lo mantiene.)
  • Freccia : questa è una delle migliori e più popolari librerie Kotlin per FP, che useremo anche nella nostra app di esempio. Fornisce quasi tutto il necessario per funzionare in Kotlin pur rimanendo relativamente leggero.
  • Jetpack Compose : questo è l'attuale toolkit di sviluppo di Android per la creazione di un'interfaccia utente nativa ed è la terza libreria che useremo oggi. È essenziale per i moderni sviluppatori Android: consiglierei di impararlo e persino di migrare l'interfaccia utente se non l'hai già fatto.
  • Flusso : questa è l'API del flusso di dati reattiva asincrona di Kotlin; sebbene non ci stiamo lavorando in questo tutorial, è compatibile con molte librerie Android comuni come RoomDB, Retrofit e Jetpack. Flow funziona perfettamente con le coroutine e fornisce reattività. Se utilizzato con RoomDB, ad esempio, Flow garantisce che la tua app funzioni sempre con i dati più recenti. Se si verifica una modifica in una tabella, i flussi dipendenti da questa tabella riceveranno immediatamente il nuovo valore.
  • Kotest : questa piattaforma di test offre supporto per test basati su proprietà rilevanti per il puro codice di dominio FP.

Implementazione di un'app di conversione piedi/metri di esempio

Vediamo un esempio di FRP al lavoro in un'app Android. Creeremo una semplice app che converte i valori tra metri (m) e piedi (ft).

Ai fini di questo tutorial, tratterò solo le parti di codice vitali per comprendere FRP, modificate per semplicità dalla mia app di esempio del convertitore completo. Se vuoi seguire Android Studio, crea il tuo progetto con un'attività Jetpack Compose e installa Arrow e Ivy FRP. Avrai bisogno di una versione minSdk di 28 o superiore e una versione in lingua di Kotlin 1.6+.

Stato

Iniziamo definendo lo stato della nostra app.

 // ConvState.kt enum class ConvType { METERS_TO_FEET, FEET_TO_METERS } data class ConvState( val conversion: ConvType, val value: Float, val result: Option<String> )

La nostra classe di stato è abbastanza autoesplicativa:

  • conversion : un tipo che descrive ciò che stiamo convertendo: piedi in metri o metri in piedi.
  • value : Il float che l'utente immette, che convertiremo in seguito.
  • result : un risultato facoltativo che rappresenta una conversione riuscita.

Successivamente, dobbiamo gestire l'input dell'utente come un evento.

Evento

Abbiamo definito ConvEvent come una classe sigillata per rappresentare l'input dell'utente:

 // ConvEvent.kt sealed class ConvEvent { data class SetConversionType(val conversion: ConvType) : ConvEvent() data class SetValue(val value: Float) : ConvEvent() object Convert : ConvEvent() }

Esaminiamo le finalità dei suoi membri:

  • SetConversionType : Sceglie se stiamo convertendo da piedi a metri o da metri a piedi.
  • SetValue : imposta i valori numerici, che verranno utilizzati per la conversione.
  • Convert : esegue la conversione del valore immesso utilizzando il tipo di conversione.

Ora continueremo con il nostro modello di visualizzazione.

La pipeline dichiarativa: gestore di eventi e composizione della funzione

Il modello di visualizzazione contiene il nostro gestore di eventi e il codice di composizione della funzione (conduttura dichiarativa):

 // ConverterViewModel.kt @HiltViewModel class ConverterViewModel @Inject constructor() : FRPViewModel<ConvState, ConvEvent>() { companion object { const val METERS_FEET_CONST = 3.28084f } // set initial state override val _state: MutableStateFlow<ConvState> = MutableStateFlow( ConvState( conversion = ConvType.METERS_TO_FEET, value = 1f, result = None ) ) override suspend fun handleEvent(event: ConvEvent): suspend () -> ConvState = when (event) { is ConvEvent.SetConversionType -> event asParamTo ::setConversion then ::convert is ConvEvent.SetValue -> event asParamTo ::setValue is ConvEvent.Convert -> stateVal() asParamTo ::convert } // ... }

Prima di analizzare l'implementazione, analizziamo alcuni oggetti specifici della libreria Ivy FRP.

FRPViewModel<S,E> è una base del modello di visualizzazione astratta che implementa l'architettura FRP. Nel nostro codice, dobbiamo implementare i seguenti metodi:

  • val _state : definisce il valore iniziale dello stato (Ivy FRP utilizza Flow come flusso di dati reattivo).
  • handleEvent(Event): suspend () -> S : produce lo stato successivo in modo asincrono dato un Event . L'implementazione sottostante lancia una nuova coroutine per ogni evento.
  • stateVal(): S : Restituisce lo stato corrente.
  • updateState((S) -> S): S Aggiorna lo stato di ViewModel .

Ora, diamo un'occhiata ad alcuni metodi relativi alla composizione delle funzioni:

  • then : compone due funzioni insieme.
  • asParamTo : produce una funzione g() = f(t) da f(T) e un valore t (di tipo T ).
  • thenInvokeAfter : compone due funzioni e quindi le richiama.

updateState e thenInvokeAfter sono metodi di supporto mostrati nel frammento di codice successivo; verranno utilizzati nel codice del modello di visualizzazione rimanente.

La pipeline dichiarativa: implementazioni di funzioni aggiuntive

Il nostro modello di visualizzazione contiene anche implementazioni di funzioni per impostare il nostro tipo e valore di conversione, eseguire le conversioni effettive e formattare il nostro risultato finale:

 // ConverterViewModel.kt @HiltViewModel class ConverterViewModel @Inject constructor() : FRPViewModel<ConvState, ConvEvent>() { // ... private suspend fun setConversion(event: ConvEvent.SetConversionType) = updateState { it.copy(conversion = event.conversion) } private suspend fun setValue(event: ConvEvent.SetValue) = updateState { it.copy(value = event.value) } private suspend fun convert( state: ConvState ) = state.value asParamTo when (stateVal().conversion) { ConvType.METERS_TO_FEET -> ::convertMetersToFeet ConvType.FEET_TO_METERS -> ::convertFeetToMeters } then ::formatResult thenInvokeAfter { result -> updateState { it.copy(result = Some(result)) } } private fun convertMetersToFeet(meters: Float): Float = meters * METERS_FEET_CONST private fun convertFeetToMeters(ft: Float): Float = ft / METERS_FEET_CONST private fun formatResult(result: Float): String = DecimalFormat("###,###.##").format(result) }

Con la comprensione delle nostre funzioni di supporto Ivy FRP, siamo pronti per analizzare il codice. Iniziamo con la funzionalità principale: convert . convert accetta lo stato ( ConvState ) come input e produce una funzione che restituisce un nuovo stato contenente il risultato dell'input convertito. In pseudocodice, possiamo riassumerlo come: State (ConvState) -> Value (Float) -> Converted value (Float) -> Result (Option<String>) .

La gestione dell'evento Event.SetValue è semplice; aggiorna semplicemente lo stato con il valore dell'evento (cioè, l'utente immette un numero da convertire). Tuttavia, la gestione dell'evento Event.SetConversionType è un po' più interessante perché fa due cose:

  • Aggiorna lo stato con il tipo di conversione selezionato ( ConvType ).
  • Utilizza convert per convertire il valore corrente in base al tipo di conversione selezionato.

Usando il potere di composizione, possiamo usare la funzione convert: State -> State come input per altre composizioni. Potresti aver notato che il codice dimostrato sopra non è puro: stiamo mutando protected abstract val _state: MutableStateFlow<S> in FRPViewModel , con conseguenti effetti collaterali ogni volta che utilizziamo updateState {} . Il codice FP completamente puro per Android in Kotlin non è fattibile.

Poiché la composizione di funzioni che non sono pure può portare a risultati imprevedibili, un approccio ibrido è il più pratico: utilizzare per la maggior parte funzioni pure e assicurarsi che tutte le funzioni impure abbiano effetti collaterali controllati. Questo è esattamente ciò che abbiamo fatto sopra.

Osservabile e UI

Il nostro ultimo passaggio è definire l'interfaccia utente della nostra app e dare vita al nostro convertitore.

Un grande rettangolo grigio con quattro frecce che puntano ad esso da destra. Dall'alto verso il basso, la prima freccia, denominata "Pulsanti", punta a due rettangoli più piccoli: un rettangolo sinistro blu scuro con il testo in maiuscolo "Metri ai piedi" e un rettangolo blu chiaro a destra con il testo "Piedi ai metri". La seconda freccia, denominata "TextField", punta a un rettangolo bianco con testo allineato a sinistra, "100.0". La terza freccia, denominata "Pulsante", punta a un rettangolo verde allineato a sinistra con il testo "Converti". L'ultima freccia, denominata "Testo", indica il testo blu allineato a sinistra: "Risultato: 328,08 piedi".
Un mock-up dell'interfaccia utente dell'app.

L'interfaccia utente della nostra app sarà un po' "brutta", ma l'obiettivo di questo esempio è dimostrare FRP, non creare un bel design utilizzando Jetpack Compose.

 // ConverterScreen.kt @Composable fun BoxWithConstraintsScope.ConverterScreen(screen: ConverterScreen) { FRP<ConvState, ConvEvent, ConverterViewModel> { state, onEvent -> UI(state, onEvent) } }

Il nostro codice dell'interfaccia utente utilizza i principi di base di Jetpack Compose nel minor numero di righe di codice possibile. Tuttavia, c'è una funzione interessante degna di nota: FRP<ConvState, ConvEvent, ConverterViewModel> . FRP è una funzione componibile dal framework Ivy FRP, che fa diverse cose:

  • Crea un'istanza del modello di visualizzazione utilizzando @HiltViewModel .
  • Osserva lo State del modello di visualizzazione utilizzando Flow.
  • Propaga gli eventi al ViewModel con il codice onEvent: (Event) -> Unit) .
  • Fornisce una funzione di ordine superiore @Composable che esegue la propagazione degli eventi e riceve lo stato più recente.
  • Facoltativamente fornisce un modo per passare initialEvent , che viene chiamato all'avvio dell'app.

Ecco come viene implementata la funzione FRP nella libreria Ivy FRP:

 @Composable inline fun <S, E, reified VM : FRPViewModel<S, E>> BoxWithConstraintsScope.FRP( initialEvent: E? = null, UI: @Composable BoxWithConstraintsScope.( state: S, onEvent: (E) -> Unit ) -> Unit ) { val viewModel: VM = viewModel() val state by viewModel.state().collectAsState() if (initialEvent != null) { onScreenStart { viewModel.onEvent(initialEvent) } } UI(state, viewModel::onEvent) }

Puoi trovare il codice completo dell'esempio del convertitore in GitHub e l'intero codice dell'interfaccia utente può essere trovato nella funzione UI del file ConverterScreen.kt . Se desideri sperimentare l'app o il codice, puoi clonare il repository Ivy FRP ed eseguire l'app di sample in Android Studio. L'emulatore potrebbe aver bisogno di maggiore spazio di archiviazione prima che l'app possa essere eseguita.

Architettura Android più pulita con FRP

Con una solida conoscenza di base della programmazione funzionale, della programmazione reattiva e, infine, della programmazione reattiva funzionale, sei pronto a raccogliere i vantaggi di FRP e creare un'architettura Android più pulita e manutenibile.

Il Toptal Engineering Blog estende la sua gratitudine a Tarun Goyal per aver esaminato gli esempi di codice presentati in questo articolo.