面向未來的 Android 代碼,第 2 部分:功能響應式編程實戰

已發表: 2022-09-08

函數式反應式編程 (FRP) 是一種將反應式編程的反應性與函數式編程的聲明式函數組合相結合的範式。 它簡化了複雜的任務,創建了優雅的用戶界面,並順利地管理狀態。 由於這些以及許多其他明顯的好處,FRP 的使用正在成為移動和 Web 開發的主流。

這並不意味著理解這種編程範式很容易——即使是經驗豐富的開發人員也可能會想:“FRP 到底什麼?” 在本教程的第 1 部分中,我們定義了 FRP 的基本概念:函數式編程和反應式編程。 本期文章將為您準備好應用它,並概述有用的庫和詳細的示例實現。

本文是為 Android 開發人員編寫的,但這些概念對任何具有通用編程語言經驗的開發人員都是相關且有益的。

FRP 入門:系統設計

FRP 範式是狀態和事件的無限循環: State -> Event -> State' -> Event' -> State'' -> … 。 (提醒一下, ' ,發音為“prime”,表示同一變量的新版本。)每個 FRP 程序都以初始狀態開始,該初始狀態將隨著它接收到的每個事件而更新。 該程序包含與響應式程序中相同的元素:

  • 狀態
  • 事件
  • 聲明式管道(表示為FRPViewModel function
  • 可觀察的(表示為StateFlow

在這裡,我們用真正的 Android 組件和庫替換了一般的反應元素:

兩個主要的藍色框“StateFlow”和“State”在它們之間有兩條主要路徑。第一個是通過“觀察(監聽變化)”。第二個是通過“通知(最新狀態)”到藍色框“@Composable(JetpackCompose)”,通過“將用戶輸入轉換為”到藍色框“事件”,通過“觸發器”到藍色框“ FRPViewModel 函數”,最後通過“Produces(新狀態)”。然後“狀態”還通過“充當輸入”連接回“FRPViewModel 功能”。
Android 中的函數式反應式編程循環。

探索 FRP 庫和工具

有多種 Android 庫和工具可以幫助您開始使用 FRP,並且也與函數式編程相關:

  • Ivy FRP :這是我編寫的一個庫,將在本教程中用於教育目的。 它旨在作為您使用 FRP 方法的起點,但由於缺乏適當的支持,因此不適用於生產用途。 (我是目前唯一維護它的工程師。)
  • Arrow :這是用於 FP 的最好和最受歡迎的 Kotlin 庫之一,我們也將在示例應用程序中使用它。 它提供了在 Kotlin 中運行所需的幾乎所有內容,同時保持相對輕量級。
  • Jetpack Compose :這是 Android 當前用於構建原生 UI 的開發工具包,也是我們今天將使用的第三個庫。 它對於現代 Android 開發人員來說是必不可少的——如果您還沒有的話,我建議您學習它,甚至遷移您的 UI。
  • Flow :這是 Kotlin 的異步響應式數據流 API; 儘管我們在本教程中沒有使用它,但它與許多常見的 Android 庫兼容,例如 RoomDB、Retrofit 和 Jetpack。 Flow 與協程無縫協作並提供響應性。 例如,當與 RoomDB 一起使用時,Flow 可確保您的應用程序始終使用最新數據。 如果表中發生更改,則依賴於該表的流將立即收到新值。
  • Kotest :該測試平台提供與純 FP 域代碼相關的基於屬性的測試支持。

實現示例英尺/米轉換應用程序

讓我們看一個在 Android 應用程序中工作的 FRP 示例。 我們將創建一個簡單的應用程序來轉換米 (m) 和英尺 (ft) 之間的值。

出於本教程的目的,我僅涵蓋對理解 FRP 至關重要的代碼部分,為簡單起見,我從完整的轉換器示例應用程序中對其進行了修改。 如果您想在 Android Studio 中進行操作,請使用 Jetpack Compose 活動創建您的項目,並安裝 Arrow 和 Ivy FRP。 您將需要 28 或更高版本的minSdk和 Kotlin 1.6+ 的語言版本。

狀態

讓我們從定義應用程序的狀態開始。

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

我們的狀態類是不言自明的:

  • convert :一種描述我們在什麼之間conversion的類型——英尺到米或米到英尺。
  • value :用戶輸入的浮點數,我們稍後會轉換。
  • result :表示成功轉換的可選結果。

接下來,我們需要將用戶輸入作為事件處理。

事件

我們將ConvEvent定義為一個密封類來表示用戶輸入:

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

讓我們檢查一下其成員的目的:

  • SetConversionType :選擇我們是從英尺轉換為米還是從米轉換為英尺。
  • SetValue :設置將用於轉換的數值。
  • Convert :使用轉換類型執行輸入值的轉換。

現在,我們將繼續我們的視圖模型。

聲明式管道:事件處理程序和函數組合

視圖模型包含我們的事件處理程序和函數組合(聲明性管道)代碼:

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

在分析實現之前,讓我們分解一些特定於 Ivy FRP 庫的對象。

FRPViewModel<S,E>是實現 FRP 架構的抽象視圖模型庫。 在我們的代碼中,我們需要實現以下方法:

  • val _state :定義狀態的初始值(Ivy FRP 使用 Flow 作為反應數據流)。
  • handleEvent(Event): suspend () -> S : 在給定Event的情況下異步產生下一個狀態。 底層實現為每個事件啟動一個新的協程。
  • stateVal(): S :返回當前狀態。
  • updateState((S) -> S): S更新ViewModel的狀態。

現在,我們來看幾個與函數組合相關的方法:

  • then :將兩個函數組合在一起。
  • asParamTo :從f(T)和值t (類型T )生成函數g() = f(t) )。
  • thenInvokeAfter :組合兩個函數,然後調用它們。

updateStatethenInvokeAfter是下一個代碼片段中顯示的輔助方法; 它們將在我們剩餘的視圖模型代碼中使用。

聲明式管道:附加功能實現

我們的視圖模型還包含用於設置轉換類型和值、執行實際轉換以及格式化最終結果的函數實現:

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

了解了我們的 Ivy FRP 輔助函數後,我們就可以分析代碼了。 讓我們從核心功能開始: convertconvert接受狀態 ( ConvState ) 作為輸入,並生成一個函數,該函數輸出一個包含轉換後輸入結果的新狀態。 在偽代碼中,我們可以總結為: State (ConvState) -> Value (Float) -> Converted value (Float) -> Result (Option<String>)

Event.SetValue事件處理很簡單; 它只是用來自事件的值更新狀態(即,用戶輸入要轉換的數字)。 但是,處理Event.SetConversionType事件更有趣,因為它做了兩件事:

  • 使用選定的轉換類型 ( ConvType ) 更新狀態。
  • 使用convert根據選定的轉換類型轉換當前值。

利用組合的力量,我們可以使用convert: State -> State函數作為其他組合的輸入。 您可能已經註意到,上面演示的代碼並不純粹:我們在 FRPViewModel 中改變protected abstract val _state: MutableStateFlow<S> FRPViewModel每當我們使用updateState {}時都會產生副作用。 在 Kotlin 中用於 Android 的完全純 FP 代碼是不可行的。

由於組合非純函數會導致不可預測的結果,因此混合方法是最實用的:大多數情況下使用純函數,並確保任何不純函數具有可控的副作用。 這正是我們上面所做的。

可觀察的和 UI

我們的最後一步是定義我們應用程序的 UI 並使我們的轉換器栩栩如生。

一個灰色的大矩形,四個箭頭從右側指向它。從上到下,標有“按鈕”的第一個箭頭指向兩個較小的矩形:一個帶有大寫文本“米到英尺”的深藍色左側矩形和一個帶有“英尺到米”文本的淺藍色右側矩形。第二個箭頭,標記為“TextField”,指向一個帶有左對齊文本的白色矩形,“100.0”。第三個箭頭,標有“按鈕”,指向一個左對齊的綠色矩形,上面寫著“轉換”。最後一個標記為“文本”的箭頭指向左對齊的藍色文本:“結果:328.08 英尺”。
應用程序 UI 的模型。

我們應用程序的 UI 會有點“醜陋”,但本示例的目的是演示 FRP,而不是使用 Jetpack Compose 構建漂亮的設計。

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

我們的 UI 代碼以盡可能少的代碼行使用基本的 Jetpack Compose 原則。 但是,有一個有趣的函數值得一提: FRP<ConvState, ConvEvent, ConverterViewModel>FRP是 Ivy FRP 框架中的一個可組合函數,它做了幾件事:

  • 使用@HiltViewModel實例化視圖模型。
  • 使用 Flow 觀察視圖模型的State
  • 使用代碼onEvent: (Event) -> Unit)將事件傳播到ViewModel
  • 提供一個執行事件傳播並接收最新狀態的@Composable高階函數。
  • 可選地提供一種傳遞initialEvent的方法,該方法在應用程序啟動時調用。

以下是 Ivy FRP 庫中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) }

您可以在 GitHub 中找到轉換器示例的完整代碼,整個 UI 代碼可以在ConverterScreen.kt文件的UI函數中找到。 如果您想試驗應用程序或代碼,可以克隆 Ivy FRP 存儲庫並在 Android Studio 中運行sample應用程序。 您的模擬器可能需要增加存儲空間才能運行應用程序。

使用 FRP 的更清潔的 Android 架構

憑藉對函數式編程、反應式編程以及最終的函數式反應式編程的深入了解,您已準備好獲得 FRP 的好處並構建更清潔、更可維護的 Android 架構。

Toptal 工程博客對 Tarun Goyal 對本文中提供的代碼示例的審閱表示感謝。