Przygotuj swój kod na Androida na przyszłość, część 2: Funkcjonalne programowanie reaktywne w działaniu

Opublikowany: 2022-09-08

Funkcjonalne programowanie reaktywne (FRP) to paradygmat, który łączy reaktywność z programowania reaktywnego z deklaratywną kompozycją funkcji z programowania funkcjonalnego. Upraszcza złożone zadania, tworzy eleganckie interfejsy użytkownika i płynnie zarządza stanem. Ze względu na te i wiele innych wyraźnych korzyści, korzystanie z FRP staje się głównym nurtem w tworzeniu aplikacji mobilnych i internetowych.

Nie oznacza to, że zrozumienie tego paradygmatu programowania jest łatwe — nawet doświadczeni programiści mogą się zastanawiać: „Czym dokładnie jest FRP?” W części 1 tego samouczka zdefiniowaliśmy podstawowe koncepcje FRP: programowanie funkcjonalne i programowanie reaktywne. Ta część przygotuje Cię do jej zastosowania, z przeglądem przydatnych bibliotek i szczegółową przykładową implementacją.

Ten artykuł został napisany z myślą o programistach Androida, ale koncepcje są istotne i przydatne dla każdego programisty z doświadczeniem w ogólnych językach programowania.

Pierwsze kroki z FRP: Projektowanie systemu

Paradygmat FRP to niekończący się cykl stanów i zdarzeń: State -> Event -> State' -> Event' -> State'' -> … . (Przypominamy, ' , wymawiane jako „prim”, oznacza nową wersję tej samej zmiennej.) Każdy program FRP rozpoczyna się od stanu początkowego, który będzie aktualizowany po każdym otrzymanym zdarzeniu. Ten program zawiera te same elementy, co w programie reaktywnym:

  • Państwo
  • Wydarzenie
  • Potok deklaratywny (wskazywany jako FRPViewModel function )
  • Obserwowalny (oznaczony jako StateFlow )

Tutaj zastąpiliśmy ogólne elementy reaktywne prawdziwymi komponentami i bibliotekami Androida:

Dwa główne niebieskie pola „StateFlow” i „State” mają między sobą dwie główne ścieżki. Pierwszy to „Obserwuje (nasłuchuje zmian)”. Drugi to „Powiadomienia (o najnowszym stanie)” do niebieskiego pola „@Komponowanie (JetpackCompose)”, które przechodzi przez „Przekształca dane wprowadzone przez użytkownika do” do niebieskiego pola „Zdarzenie”, które przechodzi przez „Wyzwalacze” do niebieskiego pola „ FRPViewModel” i wreszcie przez „Produces (nowy stan)”. „Stan” następnie łączy się z powrotem z „funkcją FRPViewModel” poprzez „Działa jako dane wejściowe dla”.
Funkcjonalny cykl programowania reaktywnego w systemie Android.

Odkrywanie bibliotek i narzędzi FRP

Istnieje wiele bibliotek i narzędzi systemu Android, które mogą pomóc w rozpoczęciu pracy z FRP, a także są istotne w programowaniu funkcjonalnym:

  • Ivy FRP : To jest biblioteka, którą napisałem, która będzie używana do celów edukacyjnych w tym samouczku. Jest to punkt wyjścia do podejścia do FRP, ale nie jest przeznaczony do użytku produkcyjnego, ponieważ nie ma odpowiedniego wsparcia. (Obecnie jestem jedynym inżynierem, który go obsługuje.)
  • Arrow : Jest to jedna z najlepszych i najpopularniejszych bibliotek Kotlina dla FP, z której będziemy również korzystać w naszej przykładowej aplikacji. Zapewnia prawie wszystko, czego potrzebujesz, aby funkcjonować w Kotlinie, pozostając stosunkowo lekkim.
  • Jetpack Compose : Jest to obecny zestaw narzędzi programistycznych Androida do tworzenia natywnego interfejsu użytkownika i jest trzecią biblioteką, z której będziemy korzystać dzisiaj. Jest to niezbędne dla współczesnych programistów Androida — polecam nauczyć się go, a nawet przeprowadzić migrację interfejsu użytkownika, jeśli jeszcze tego nie zrobiłeś.
  • Flow : To jest asynchroniczne reaktywne API strumienia danych Kotlina; chociaż nie pracujemy z nim w tym samouczku, jest on kompatybilny z wieloma popularnymi bibliotekami Androida, takimi jak RoomDB, Retrofit i Jetpack. Flow bezproblemowo współpracuje z współprogramami i zapewnia reaktywność. Na przykład w połączeniu z RoomDB Flow zapewnia, że ​​Twoja aplikacja będzie zawsze działać z najnowszymi danymi. Jeśli nastąpi zmiana w tabeli, przepływy zależne od tej tabeli natychmiast otrzymają nową wartość.
  • Kotest : Ta platforma testowa oferuje wsparcie testowania opartego na właściwościach, odpowiadającego czystemu kodowi domeny FP.

Implementacja przykładowej aplikacji do konwersji stóp/metrów

Zobaczmy przykład działania FRP w aplikacji na Androida. Stworzymy prostą aplikację, która konwertuje wartości między metrami (m) a stopami (ft).

Na potrzeby tego samouczka omawiam tylko fragmenty kodu niezbędne do zrozumienia FRP, zmodyfikowane dla uproszczenia z mojej pełnej przykładowej aplikacji konwertera. Jeśli chcesz śledzić w Android Studio, stwórz swój projekt za pomocą działania Jetpack Compose i zainstaluj Arrow i Ivy FRP. Będziesz potrzebować wersji minSdk 28 lub wyższej oraz wersji językowej Kotlin 1.6+.

Państwo

Zacznijmy od zdefiniowania stanu naszej aplikacji.

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

Nasza klasa państwowa jest dość oczywista:

  • conversion : typ opisujący, między czym dokonujemy konwersji — stopy na metry lub metry na stopy.
  • value : liczba zmiennoprzecinkowa wprowadzona przez użytkownika, którą później przekonwertujemy.
  • result : opcjonalny wynik reprezentujący udaną konwersję.

Następnie musimy potraktować dane wejściowe użytkownika jako zdarzenie.

Wydarzenie

Zdefiniowaliśmy ConvEvent jako zapieczętowaną klasę reprezentującą dane wejściowe użytkownika:

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

Przyjrzyjmy się celom jego członków:

  • SetConversionType : określa, czy konwertujemy ze stóp na metry, czy z metrów na stopy.
  • SetValue : Ustawia wartości liczbowe, które będą używane do konwersji.
  • Convert : Wykonuje konwersję wprowadzonej wartości przy użyciu typu konwersji.

Teraz będziemy kontynuować nasz model widoku.

Potok deklaratywny: obsługa zdarzeń i kompozycja funkcji

Model widoku zawiera nasz kod obsługi zdarzeń i skład funkcji (deklaratywny potok):

 // 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 } // ... }

Zanim przeanalizujemy implementację, rozbijmy kilka obiektów specyficznych dla biblioteki Ivy FRP.

FRPViewModel<S,E> to abstrakcyjna podstawa modelu widoku, która implementuje architekturę FRP. W naszym kodzie musimy zaimplementować następujące metody:

  • val _state : Definiuje początkową wartość stanu (Ivy FRP używa Flow jako reaktywnego strumienia danych).
  • handleEvent(Event): suspend () -> S : Generuje następny stan asynchronicznie na podstawie Event . Podstawowa implementacja uruchamia nowy współprogram dla każdego wydarzenia.
  • stateVal(): S : Zwraca aktualny stan.
  • updateState((S) -> S): S Aktualizuje stan ViewModel .

Przyjrzyjmy się teraz kilku metodom związanym z komponowaniem funkcji:

  • then : Składa razem dwie funkcje.
  • asParamTo : Tworzy funkcję g() = f(t) z f(T) i wartość t (typu T ).
  • thenInvokeAfter : Składa dwie funkcje, a następnie je wywołuje.

updateState i thenInvokeAfter to metody pomocnicze pokazane w następnym fragmencie kodu; zostaną one użyte w naszym pozostałym kodzie modelu widoku.

Potok deklaratywny: dodatkowe implementacje funkcji

Nasz model widoku zawiera również implementacje funkcji do ustawiania typu i wartości konwersji, wykonywania rzeczywistych konwersji i formatowania wyniku końcowego:

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

Dzięki zrozumieniu naszych funkcji pomocniczych Ivy FRP jesteśmy gotowi do analizy kodu. Zacznijmy od podstawowej funkcjonalności: convert . convert akceptuje stan ( ConvState ) jako dane wejściowe i tworzy funkcję, która wyprowadza nowy stan zawierający wynik przekonwertowanych danych wejściowych. W pseudokodzie możemy to podsumować jako: State (ConvState) -> Value (Float) -> Converted value (Float) -> Result (Option<String>) .

Obsługa zdarzeń Event.SetValue jest prosta; po prostu aktualizuje stan o wartość ze zdarzenia (tj. użytkownik wprowadza liczbę do przekonwertowania). Jednak obsługa zdarzenia Event.SetConversionType jest nieco bardziej interesująca, ponieważ robi dwie rzeczy:

  • Aktualizuje stan o wybrany typ konwersji ( ConvType ).
  • Używa convert do konwersji bieżącej wartości na podstawie wybranego typu konwersji.

Korzystając z potęgi kompozycji, możemy użyć funkcji convert: State -> State jako danych wejściowych dla innych kompozycji. Być może zauważyłeś, że przedstawiony powyżej kod nie jest czysty: protected abstract val _state: MutableStateFlow<S> w FRPViewModel , co skutkuje efektami ubocznymi za każdym razem, gdy używamy updateState {} . Całkowicie czysty kod FP dla Androida w Kotlinie nie jest możliwy.

Ponieważ tworzenie funkcji, które nie są czyste, może prowadzić do nieprzewidywalnych rezultatów, podejście hybrydowe jest najbardziej praktyczne: używaj w większości czystych funkcji i upewnij się, że wszelkie nieczyste funkcje mają kontrolowane skutki uboczne. Dokładnie to zrobiliśmy powyżej.

Obserwowalny i interfejs użytkownika

Naszym ostatnim krokiem jest zdefiniowanie interfejsu użytkownika naszej aplikacji i ożywienie naszego konwertera.

Duży szary prostokąt z czterema strzałkami wskazującymi go z prawej strony. Od góry do dołu pierwsza strzałka z napisem „Przyciski” wskazuje dwa mniejsze prostokąty: ciemnoniebieski lewy prostokąt z tekstem pisanym wielkimi literami „Metry do stóp” i jasnoniebieski prawy prostokąt z tekstem „Stopy na metry”. Druga strzałka, oznaczona „TextField”, wskazuje biały prostokąt z tekstem wyrównanym do lewej „100.0”. Trzecia strzałka, oznaczona „Przycisk”, wskazuje wyrównany do lewej zielony prostokąt z tekstem „Konwertuj”. Ostatnia strzałka, oznaczona „Tekst”, wskazuje wyrównany do lewej niebieski tekst: „Wynik: 328,08 stopy”.
Makieta interfejsu użytkownika aplikacji.

Interfejs użytkownika naszej aplikacji będzie nieco „brzydki”, ale celem tego przykładu jest zademonstrowanie FRP, a nie zbudowanie pięknego projektu za pomocą Jetpack Compose.

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

Nasz kod interfejsu użytkownika wykorzystuje podstawowe zasady Jetpack Compose w najmniejszej możliwej liczbie wierszy kodu. Warto jednak wspomnieć o jednej interesującej funkcji: FRP<ConvState, ConvEvent, ConverterViewModel> . FRP to funkcja komponowania z frameworka Ivy FRP, która robi kilka rzeczy:

  • Tworzy wystąpienie modelu widoku przy użyciu @HiltViewModel .
  • Obserwuje State modelu widoku za pomocą Flow.
  • Propaguje zdarzenia do ViewModel z kodem onEvent: (Event) -> Unit) .
  • Zapewnia funkcję @Composable wyższego rzędu, która wykonuje propagację zdarzeń i otrzymuje najnowszy stan.
  • Opcjonalnie zapewnia sposób przekazywania initialEvent , który jest wywoływany po uruchomieniu aplikacji.

Oto jak funkcja FRP jest zaimplementowana w bibliotece 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) }

Pełny kod przykładowego konwertera można znaleźć w serwisie GitHub, a cały kod interfejsu użytkownika można znaleźć w funkcji UI pliku ConverterScreen.kt . Jeśli chcesz poeksperymentować z aplikacją lub kodem, możesz sklonować repozytorium Ivy FRP i uruchomić sample aplikację w Android Studio. Twój emulator może potrzebować zwiększonej pamięci, zanim aplikacja będzie mogła działać.

Czystsza architektura Androida z FRP

Dzięki silnemu, fundamentalnemu zrozumieniu programowania funkcjonalnego, programowania reaktywnego i wreszcie funkcjonalnego programowania reaktywnego, jesteś gotowy, aby czerpać korzyści z FRP i budować czystszą i łatwiejszą w utrzymaniu architekturę Androida.

Blog Toptal Engineering wyraża wdzięczność Tarunowi Goyalowi za przejrzenie próbek kodu przedstawionych w tym artykule.