面向未来的 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 对本文中提供的代码示例的审阅表示感谢。