Przygotuj swój kod na Androida na przyszłość, część 2: Funkcjonalne programowanie reaktywne w działaniu
Opublikowany: 2022-09-08Funkcjonalne 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:
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 podstawieEvent
. Podstawowa implementacja uruchamia nowy współprogram dla każdego wydarzenia. -
stateVal(): S
: Zwraca aktualny stan. -
updateState((S) -> S): S
Aktualizuje stanViewModel
.
Przyjrzyjmy się teraz kilku metodom związanym z komponowaniem funkcji:
-
then
: Składa razem dwie funkcje. -
asParamTo
: Tworzy funkcjęg() = f(t)
zf(T)
i wartośćt
(typuT
). -
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.
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 kodemonEvent: (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.