Kotlin多平台中的MVI建筑模板,第1部分



大约一年前,我对新的Kotlin Multiplatform技术感兴趣。它允许您编写通用代码并针对不同平台进行编译,同时可以访问它们的API。从那时起,我一直在这一领域进行积极的试验,并在我们公司中推广该工具。例如,一个结果就是我们的Reaktive-Kotlin Multiplatform的Reactive Extensions。

在用于Android开发的Badoo和Bumble应用程序中,我们使用MVI架构模板(有关我们的架构的更多详细信息,请参见Zsolt Kocsi的文章:“ 基于现代Kotlin的MVI架构”)。在从事各种项目时,我非常喜欢这种方法。当然,我不能错过在Kotlin Multiplatform中尝试MVI的机会。而且,这种情况是合适的:我们需要为Reaktive库编写示例。经过我的这些实验,我受到了MVI的更多启发。

我始终关注开发人员如何使用Kotlin Multiplatform以及如何构建此类项目的体系结构。根据我的观察,普通的Kotlin Multiplatform开发人员实际上是一名Android开发人员,他在工作中使用MVVM模板只是因为他已经习惯了。有些还采用“清洁架构”。但是,我认为MVI最适合Kotlin Multiplatform,“干净的体系结构”是不必要的麻烦。

因此,我决定就以下主题写这三篇文章系列:

  1. MVI模板的简要说明,问题说明以及使用Kotlin Multiplatform创建通用模块。
  2. 在iOS和Android应用程序中集成通用模块。
  3. 单元和集成测试。

以下是该系列的第一篇文章。对于已经使用或打算使用Kotlin Multiplatform的每个人,它都会很感兴趣。

我马上注意到,本文的目的不是要教您如何使用Kotlin Multiplatform本身。如果您认为您在这方面的知识不足,建议您先熟悉一下介绍和文档(尤其是“ 并发 ”和“ 不变性部分,以了解Kotlin /本机内存模型的功能)。在本文中,我将不介绍项目的配置,模块以及与该主题无关的其他内容。

MVI


首先,让我们回顾一下MVI是什么。该缩写代表Model-View-Intent。系统中只有两个主要组件:

  • 模型-逻辑和数据层(模型还存储系统的当前状态);
  • (View) — UI-, (states) (intents).

下图可能已经为许多人所熟悉:



因此,我们看到了那些非常基本的组件:模型和表示。其他所有内容都是在它们之间传播的数据。

很容易看到数据仅在一个方向上移动。状态来自模型,进入视图进行显示,意图来自视图,进入模型进行处理。这种循环称为单向数据流。

在实践中,模型通常由称为Store的实体表示(从Redux借用)。但是,这并不总是发生。例如,在我们的MVICore库中,该模型称为Feature。

还值得注意的是,MVI与反应性密切相关。使用反应式编程库可以很方便地实现数据流及其转换的显示以及订阅的生命周期管理。现在有相当多的代码可供使用,但是,在Kotlin Multiplatform中编写通用代码时,我们只能使用多平台库。我们需要对数据流进行抽象,我们需要具有连接和断开其输入和输出以及进行转换的能力。目前,我知道两个这样的库:

  • 我们的Reaktive-在Kotlin Multiplatform上实现Reactive Extensions;
  • 协程流程 -使用Kotlin协程实施冷流。

问题的提法


本文的目的是展示如何在Kotlin Multiplatform中使用MVI模板以及该方法的优点和缺点。因此,我不会迷恋任何特定的MVI实现。但是,我将使用Reaktive,因为仍然需要数据流。如果需要,在理解了这个想法之后,可以用协程和Flow代替Reaktive。通常,我将尝试使我们的MVI尽可能简单,没有不必要的复杂性。

为了演示MVI,我将尝试实现满足以下要求的最简单的项目:

  • 支持Android和iOS;
  • 演示异步操作(输入输出,数据处理等);
  • 尽可能多的通用代码;
  • 使用每个平台的本机工具进行UI实施;
  • 平台端缺少Rx(因此您不必将对Rx的依赖关系指定为“ api”)。

举个例子,我选择了一个非常简单的应用程序:一个带有按钮的屏幕,单击可下载并显示带有猫的任意图像的列表。要上传图像,我将使用开放的API:https : //thecatapi.com因为您必须从Web下载列表并解析JSON文件,所以这将满足您异步操作的要求。

您可以在我们的GitHub上找到该项目的所有源代码

入门:MVI的抽象


首先,我们需要为MVI引入一些抽象。我们将需要非常基本的组件-模型和视图-以及一些类型别名。

类型别名


为了处理意图,我们引入一个actor(Actor)-一个接受意图和当前状态并返回结果流(Effect)的函数:

typealias Actor < 状态意图效果 > =状态意图- > 可观察的 < 效果 >

我们还需要一个reducer(Reducer)-一个使当前状态生效并返回新状态的函数:

typealias Reducer < 状态效果 > =状态效果- > 状态

商店


商店将展示MVI的模型。他必须接受意图并发出一连串的状态。订阅状态流时,应发布当前状态。

让我们介绍适当的接口:

接口 Store < in Intent 任意输出 状态 Any > Consumer < Intent >,可观察的 < State >,一次性

因此,我们的商店具有以下属性:

  • 有两个通用参数:输入意图和输出状态;
  • 是意图的消费者(消费者<Intent>);
  • 是状态流(Observable <State>);
  • 它是可破坏的(一次性的)。

由于每次实现这样的接口都不太方便,因此我们需要一个助手:

StoreHelper < 意图 任何 国家 任何 效果 在任何 >(
initialState 状态
私人 val 演员 演员 < 状态目的效果 >,
private val reducer: Reducer<State, Effect>
) : Observable<State>, DisposableScope by DisposableScope() {
init {
ensureNeverFrozen()
}
private val subject = BehaviorSubject(initialState)
fun onIntent(intent: Intent) {
actor(subject.value, intent).subscribeScoped(isThreadLocal = true, onNext = ::onEffect)
}
fun onEffect效果 Effect){
subject.onNext(归约者(subject.value,effect))
}
覆盖 有趣的 订阅观察者 ObservableObserver < 状态 >){
subject.subscribe(观察者)
}
}


StoreHelper是一个小类,它将使我们可以更轻松地创建商店。它具有以下属性:

  • 具有三个通用参数:输入意图和效果以及输出状态;
  • 通过构造函数,演员和变速箱接受初始状态;
  • 是一连串的国家;
  • 可破坏的(一次性的);
  • 非冻结的(这样订户也不会冻结);
  • 实现DisposableScope(Reaktive的接口,用于管理订阅);
  • 接受并处理意图和效果。

查看我们商店的示意图。其中



的参与者和变速箱是实现细节:让我们更详细地考虑onIntent方法:

  • 接受意图作为论点;
  • 调用演员并将意图和当前状态传递给演员;
  • 订阅演员返回的效果流;
  • 将所有效果定向到onEffect方法;
  • 订阅效果是使用isThreadLocal标志执行的(这避免了在Kotlin / Native中冻结)。

现在,让我们仔细看一下onEffect方法:

  • 以效果为论据;
  • 调用变速箱并将效果和当前状态传输到变速箱;
  • 将新状态传递给BehaviorSubject,该行为导致所有订阅者都收到新状态。

视图


现在让我们进入演示。它应该接受用于显示的模型并发出事件流。我们还将创建一个单独的界面:

MviView 接口 < 输入 模型 任何输出 事件 任何 > {
val事件 可观察 < 事件 >
有趣的 渲染模型 Model
}


视图具有以下属性:

  • 有两个通用参数:输入模型和输出事件;
  • 接受使用render方法显示的模型;
  • 使用events属性调度事件流。

我在MviView名称中添加了Mvi前缀,以避免与Android View混淆。另外,我没有扩展Consumer和Observable接口,只是使用了属性和方法。这样一来,您可以将演示界面设置为要实施的平台(Android或iOS),而无需将Rx导出为“ api”依赖项。诀窍在于,客户端不会直接与“事件”属性进行交互,而是将实现MviView接口,从而扩展抽象类。

立即添加此抽象类以表示:

abstract class AbstractMviView<in Model : Any, Event : Any> : MviView<Model, Event> {
private val subject = PublishSubject<Event>()
override val events: Observable<Event> = subject
protected fun dispatch(event: Event) {
subject.onNext(event)
}
}

该类将帮助我们发布事件,并避免平台与Rx交互。

这是一个显示其工作方式的图:



存储生成状态,这些状态被转换为模型并由视图显示。后者会产生事件,这些事件会转换为意图并交付给商店进行处理。这种方法消除了存储和表示之间的一致性。但是在简单的情况下,视图可以直接与状态和意图一起使用。

这就是我们实现MVI所需要的。让我们编写通用代码。

通用代码


计划


  1. 我们将制作一个通用模块,其任务是下载并显示猫的图像列表。
  2. UI我们将接口抽象化,然后将其实现转移到外部。
  3. 我们将把实现隐藏在方便的外观后面。

小猫商店


让我们从最主要的东西开始-创建一个KittenStore,它将加载图像列表:

内部 接口 KittenStore 存储 < IntentState > {
密封 意图 {
对象重载 意图()
}
数据 状态
val isLoading 布尔值 = false
val data 数据 = 数据图片()
){
密封 数据 {
数据 图像val urls List < String > = emptyList()) Data()
对象错误 数据()
}
}
}


我们已经使用意图类型和状态类型扩展了Store接口。请注意:该接口被声明为内部接口。我们的KittenStore是模块的实现细节。我们的意图只有一个-重新加载,它会导致加载图像列表。但是该条件值得更详细地考虑:

  • isLoading标志指示当前是否正在进行下载;
  • data属性可以采用以下两个选项之一:
    • 图片-图片链接列表;
    • 错误-表示已发生错误。

现在开始执行。我们将分阶段进行。首先,创建一个空的KittenStoreImpl类,该类将实现KittenStore接口:

内部 KittenStoreImpl
KittenStoreDisposableScope通过DisposableScope(){
覆盖 fun onNext Intent){
}
覆盖 有趣的 订阅观察者 ObservableObserver < 状态 >){
}
}


我们还实现了熟悉的DisposableScope接口。这对于方便的订阅管理是必需的。

我们将需要从Web下载图像列表并解析JSON文件。声明相应的依赖项:

内部 KittenStoreImpl
私有 val 网络 网络
私有 val 解析器 解析器
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
fun parsejson String 也许 < List < String >>
}
}

Network将从网络下载JSON文件的文本,而Parser将解析JSON文件并返回图像链接列表。发生错误时,也许会简单地结束而没有结果。在本文中,我们对错误的类型不感兴趣。

现在声明效果和减速器:

内部 KittenStoreImpl
私有 val 网络 网络
私有 val 解析器 解析器
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
private fun reduce(state: State, effect: Effect): State =
when (effect) {
is Effect.LoadingStarted -> state.copy(isLoading = true)
is Effect.LoadingFinished -> state.copy(isLoading = false, data = State.Data.Images(urls = effect.imageUrls))
is Effect.LoadingFailed -> state.copy(isLoading = false, data = State.Data.Error)
}
private sealed class Effect {
object LoadingStarted : Effect()
data class LoadingFinished(val imageUrls: List<String>) : Effect()
object LoadingFailed : Effect()
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
fun parsejson String 也许 < List < String >>
}
}

在开始下载之前,我们给出LoadingStarted效果,该效果将导致设置isLoading标志。下载完成后,我们发出LoadingFinished或LoadingFailed。在第一种情况下,我们清除isLoading标志并应用图像列表,在第二种情况下,我们也清除标志并应用错误状态。请注意,效果是KittenStore的专用API。

现在我们实现下载本身:

内部 KittenStoreImpl
private val network: Network,
private val parser: Parser
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
private fun reload(network: Network, parser: Parser): Observable<Effect> =
network
.load()
.flatMap(parser::parse)
.map(Effect::LoadingFinished)
.observeOn(mainScheduler)
.asObservable()
.defaultIfEmpty(Effect.LoadingFailed)
.startWithValue(Effect.LoadingStarted)
private fun reduce(state: State, effect: Effect): State =
// Omitted code
private sealed class Effect {
// Omitted code
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
fun parse(json: String): Maybe<List<String>>
}
}

在这里,值得注意的是,尽管我们已经将Network和Parser传递给了重载函数,但事实上它们已经可以作为构造函数的属性使用。这样做是为了避免对此进行引用,并因此冻结整个KittenStore。

好了,最后,使用StoreHelper并完成KittenStore的实现:

内部 KittenStoreImpl
私有 val 网络 网络
私有 val 解析器 解析器
KittenStoreDisposableScope通过DisposableScope(){
private val helper = StoreHelper(State(), ::handleIntent, ::reduce).scope()
override fun onNext(value: Intent) {
helper.onIntent(value)
}
override fun subscribe(observer: ObservableObserver<State>) {
helper.subscribe(observer)
}
private fun handleIntent(state: State, intent: Intent): Observable<Effect> =
when (intent) {
is Intent.Reload -> reload(network, parser)
}
private fun reload(network: Network, parser: Parser): Observable<Effect> =
// Omitted code
private fun reduce(state: State, effect: Effect): State =
// Omitted code
private sealed class Effect {
// Omitted code
}
interface Network {
fun load() 也许 < String >
}
接口 解析器 {
fun parsejson String 也许 < List < String >>
}
}

我们的KittenStore准备好了!我们进行介绍。

小猫咪


声明以下界面:

KittenView 界面 MviView < 模型事件 > {
资料 类别 Model
val isLoading 布尔值
val isError 布尔值
val imageUrls 列表 < 字符串 >
密封 事件 {
RefreshTriggered 对象 事件()
}
}

我们发布了一个带有加载和错误标志以及图像链接列表的视图模型。我们只有一个事件-RefreshTriggered。每次用户调用更新时都会发布。KittenView是我们模块的公共API。

KittenDataSource


该数据源的任务是从Web下载JSON文件的文本。与往常一样,声明接口:

内部 接口 KittenDataSource {
有趣的 负载限制 Int偏移量 Int 也许 < String >
}

数据源实现将针对每个平台分别进行。因此,我们可以使用Expect / Actual声明工厂方法:

内部 期望 有趣的 KittenDataSource() KittenDataSource

下一部分将讨论数据源的实现,我们将在其中实现iOS和Android的应用程序。

积分


最后阶段是所有组件的集成。

网络接口实现:

内部 KittenStoreNetwork
私有 val dataSource KittenDataSource
KittenStoreImpl网络 {
重写 fun load() 也许 < String > = dataSource.load(limit = 50,offset = 0
}


解析器接口实现:

内部 对象 KittenStoreParser KittenStoreImpl解析器 {
覆盖 有趣的 解析json String 也许 < List < String >> =
mayFromFunction {
JsonJsonConfiguration稳定的
.parseJson(json)
.jsonArray
.map {it.jsonObject.getPrimitive( url ).content}
}
.subscribeOn(computationScheduler)
.onErrorComplete()
}

在这里,我们使用了kotlinx.serialization解析是在计算调度程序上执行的,以避免阻塞主线程。

将状态转换为视图模型:

内部 乐趣的状态。toModel() 模型 =
型号
isLoading = isLoading,
isError = whendata){
国家数据图片 - > 错误
国家数据错误 - > true
},
imageUrls = whendata){
国家数据图片 - > data .urls
国家数据错误 - > emptyList()
}

将事件转换为意图:

内部 娱乐活动toIntent() 意图 =
何时this){
EventRefreshTriggered - > 意图重装
}

立面准备:

KittenComponent {
有趣的 onViewCreatedview KittenView){
}
有趣的 onStart(){
}
有趣的 onStop(){
}
有趣的 onViewDestroyed(){
}
有趣的 onDestroy(){
}
}

熟悉许多Android开发人员的生命周期。它非常适合iOS,甚至JavaScript。外墙生命周期状态之间的过渡图如下所示:我将


在此处简要说明发生的情况:

  • 首先,调用onCreate方法,之后调用onViewCreated,然后调用onStart:这将使外观处于工作状态(已启动);
  • 在此之后的某个时刻,将调用onStop方法:这会将立面置于停止状态(已停止);
  • 在停止状态下,可以调用以下两种方法之一:onStart或onViewDestroyed,即可以再次启动外观或破坏其外观;或者
  • 销毁视图时,可以再次创建它(onViewCreated),也可以销毁整个外观(onDestroy)。

Facade实现可能如下所示:

KittenComponent {
私人 val商店=
KittenStoreImpl
网络= KittenStoreNetwork(数据源= KittenDataSource()),
解析器= KittenStoreParser
私人 var视图 KittenView? =
private var startStopScope DisposableScope吗? =
有趣的 onViewCreatedview KittenView){
这个 .view =视图
}
fun onStart() {
val view = requireNotNull(view)
startStopScope = disposableScope {
store.subscribeScoped(onNext = view::render)
view.events.map(Event::toIntent).subscribeScoped(onNext = store::onNext)
}
}
fun onStop() {
startStopScope?.dispose()
}
fun onViewDestroyed() {
view = null
}
fun onDestroy() {
store.dispose()
}
}

怎么运行的:

  • 首先,我们创建一个KittenStore实例。
  • 在onViewCreated方法中,我们记得到KittenView的链接;
  • 在onStart中,我们将KittenStore和KittenView彼此签名;
  • 在onStop中,我们彼此镜像;
  • 在onViewDestroyed中,我们清除了指向视图的链接;
  • 在onDestroy中,销毁KittenStore。

结论


这是我在Kotlin Multiplatform上的MVI系列文章中的第一篇。在其中:

  • 记得什么是MVI及其运作方式;
  • 使用Reaktive库在Kotlin Multiplatform上实现了最简单的MVI实现;
  • 创建了一个通用模块,用于使用MVI加载图像列表。

注意我们的通用模块最重要的属性:

  • 我们设法将除UI代码之外的所有代码放入multiplatform模块中;所有逻辑,以及逻辑和UI之间的连接和转换都是通用的;
  • 逻辑和UI完全无关;
  • UI的实现非常简单:您只需要显示传入的视图模型并抛出事件即可;
  • 模块集成也很简单:您需要做的是:
    • 实现KittenView接口(协议);
    • 创建一个KittenComponent实例;
    • 在适当的时间调用他的生命周期方法;
  • 这种方法避免了Rx(或协程)“流入”平台,这意味着我们不必在应用程序级别管理任何订阅;
  • 所有重要的类都通过接口抽象并经过测试。

在下一部分中,我将在实践中展示KittenComponent集成在iOS和Android应用程序中的外观。

Twitter上关注我并保持联系!

All Articles