Kotlin Multiplatform中的MVI建筑模式,第2部分





这是关于在Kotlin Multiplatform中应用MVI体系结构模式的三篇文章的第二篇。在第一篇文章中,我们记得什么是MVI,并将其应用于编写iOS和Android的通用代码。我们介绍了诸如Store和View之类的简单抽象以及一些帮助程序类,并使用它们来创建一个通用模块。



该模块的目的是从Web下载图像的链接,并将业务逻辑与表示为Kotlin界面的用户界面相关联,该界面必须在每个平台上本机实现。这就是我们在本文中要做的。



我们将实现通用模块的特定于平台的部分,并将它们集成到iOS和Android应用程序中。和以前一样,我假设读者已经具有有关Kotlin Multiplatform的基本知识,因此,我不会谈论项目配置以及Kotlin Multiplatform中与MVI不相关的其他事项。



在我们的GitHub上有更新的示例项目



计划



在第一篇文章中,我们在通用Kotlin模块中定义了KittenDataSource接口。该数据源负责从Web下载图像的链接。现在是时候在iOS和Android上实现它了。为此,我们将使用Expect / Actual这样的Kotlin Multiplatform功能然后,我们将通用的小猫模块集成到iOS和Android应用中。对于iOS,我们使用SwiftUI,对于Android,我们使用常规的Android视图。



因此该计划如下:



  • KittenDataSource端实现

    • 对于iOS
    • 对于Android
  • 将小猫模块集成到iOS App

    • 使用SwiftUI的KittenView实现
    • 将KittenComponent集成到SwiftUI视图中
  • 将小猫模块集成到Android应用中

    • 使用Android Views的KittenView实现
    • 将KittenComponent集成到Android Fragment




KittenDataSource实现



首先让我们记住该界面的外观:



internal interface KittenDataSource {
    fun load(limit: Int, offset: Int): Maybe<String>
}


这是我们将要实现的工厂函数的标题:



internal expect fun KittenDataSource(): KittenDataSource


接口及其工厂功能都声明为内部,并且是Kittens模块的实现详细信息。通过使用期望/实际,我们可以访问每个平台的API。



适用于iOS的KittenDataSource



首先让我们为iOS实现数据源。要访问iOS API,我们需要将代码放入“ iosCommonMain”源集中。它被配置为依赖commonMain。源代码的目标集(iosX64Main和iosArm64Main)又取决于iosCommonMain。您可以在此处找到完整的配置



这是数据源实现:




internal class KittenDataSourceImpl : KittenDataSource {
    override fun load(limit: Int, offset: Int): Maybe<String> =
        maybe<String> { emitter ->
            val callback: (NSData?, NSURLResponse?, NSError?) -> Unit =
                { data: NSData?, _, error: NSError? ->
                    if (data != null) {
                        emitter.onSuccess(NSString.create(data, NSUTF8StringEncoding).toString())
                    } else {
                        emitter.onComplete()
                    }
                }

            val task =
                NSURLSession.sharedSession.dataTaskWithURL(
                    NSURL(string = makeKittenEndpointUrl(limit = limit, offset = offset)),
                    callback.freeze()
                )
            task.resume()
            emitter.setDisposable(Disposable(task::cancel))
        }
            .onErrorComplete()
}



使用NSURLSession是在iOS中从网络下载数据的主要方法。它是异步的,因此不需要线程切换。我们只是将通话包装在Maybe中,并添加响应,错误和取消处理。



这是工厂功能的实现:



internal actual fun KittenDataSource(): KittenDataSource = KittenDataSourceImpl()


至此,我们可以为iosX64和iosArm64编译通用模块。



适用于Android的KittenDataSource



要访问Android API,我们需要将代码放入androidMain源代码集中。数据源实现如下所示:



internal class KittenDataSourceImpl : KittenDataSource {
    override fun load(limit: Int, offset: Int): Maybe<String> =
        maybeFromFunction {
            val url = URL(makeKittenEndpointUrl(limit = limit, offset = offset))
            val connection = url.openConnection() as HttpURLConnection

            connection
                .inputStream
                .bufferedReader()
                .use(BufferedReader::readText)
        }
            .subscribeOn(ioScheduler)
            .onErrorComplete()
}


对于Android,我们已实现HttpURLConnection。同样,这是在不使用第三方库的情况下在Android中加载数据的常用方法。该API处于阻塞状态,因此我们需要使用subscriptionOn运算符切换到后台线程。



Android的工厂功能的实现与iOS的实现相同:



internal actual fun KittenDataSource(): KittenDataSource = KittenDataSourceImpl()


现在,我们可以编译我们用于Android的通用模块。



将小猫模块集成到iOS App



这是工作中最困难(也是最有趣)的部分。假设我们已经按照iOS应用程序README描述编译了模块我们还使用Xcode创建了一个基本的SwiftUI项目并向其中添加了小猫框架。现在是时候将KittenComponent集成到您的iOS应用程序中了。



KittenView实施



让我们从实现KittenView开始。首先,让我们记住其接口在Kotlin中的外观:



interface KittenView : MviView<Model, Event> {
    data class Model(
        val isLoading: Boolean,
        val isError: Boolean,
        val imageUrls: List<String>
    )

    sealed class Event {
        object RefreshTriggered : Event()
    }
}


因此,我们的KittenView会获取模型并触发事件。要在SwiftUI中渲染模型,我们必须创建一个简单的代理:



import Kittens

class KittenViewProxy : AbstractMviView<KittenViewModel, KittenViewEvent>, KittenView, ObservableObject {
    @Published var model: KittenViewModel?
    
    override func render(model: KittenViewModel) {
        self.model = model
    }
}


代理实现两个接口(协议):KittenView和ObservableObject。 KittenViewModel使用模型的@ Published属性公开,因此我们的SwiftUI视图可以订阅它。我们使用了上一篇文章中创建的AbstractMviView类。我们不必与Reaktive库进行交互-我们可以使用dispatch方法来调度事件。



为什么我们要避免在Swift中使用Reaktive(或协程/流程)库?因为Kotlin-Swift的兼容性有几个限制。例如,未为接口(协议)导出通用参数,无法以通常的方式调用扩展功能,等等。大多数限制是由于Kotlin-Swift兼容性是通过Objective-C完成的(您可以在此处找到所有限制))。另外,由于棘手的Kotlin /本机内存模型,我认为最好尽量减少Kotlin-iOS的交互。



现在是时候制作一个SwiftUI视图了。让我们开始创建一个骨架:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
}


我们已经声明了SwiftUI视图,该视图取决于KittenViewProxy。标记为@ObservedObject的代理属性订阅了ObservableObject(KittenViewProxy)。每当KittenViewProxy更改时,我们的KittenSwiftView将自动更新。



现在让我们开始实现视图:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
    
    private var content: some View {
        let model: KittenViewModel! = self.proxy.model

        return Group {
            if (model == nil) {
                EmptyView()
            } else if (model.isError) {
                Text("Error loading kittens :-(")
            } else {
                List {
                    ForEach(model.imageUrls) { item in
                        RemoteImage(url: item)
                            .listRowInsets(EdgeInsets())
                    }
                }
            }
        }
    }
}


这里的主要部分是内容。我们从代理中获取当前模型,并显示以下三个选项之一:无(EmptyView),错误消息或图像列表。



视图的主体可能如下所示:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
        NavigationView {
            content
            .navigationBarTitle("Kittens KMP Sample")
            .navigationBarItems(
                leading: ActivityIndicator(isAnimating: self.proxy.model?.isLoading ?? false, style: .medium),
                trailing: Button("Refresh") {
                    self.proxy.dispatch(event: KittenViewEvent.RefreshTriggered())
                }
            )
        }
    }
    
    private var content: some View {
        // Omitted code
    }
}


我们通过添加标题,加载器和刷新按钮来在NavigationView中显示内容。



每次更改模型时,视图都会自动更新。将isLoading标志设置为true时,将显示一个加载指示器。单击刷新按钮时,将调度RefreshTriggered事件。如果isError标志为true,则会显示一条错误消息;否则,将显示错误消息。否则,将显示图像列表。



KittenComponent整合



现在我们有了KittenSwiftView,是时候使用我们的KittenComponent了。SwiftUI除了View之外什么都没有,因此我们必须将KittenSwiftView和KittenComponent包装在单独的SwiftUI视图中。



SwiftUI视图生命周期仅包含两个事件:onAppear和onDisappear。当视图在屏幕上显示时,第一个被激发,而第二个被隐藏时,被激发。没有明确破坏提交内容的通知。因此,我们使用“ deinit”块,该块在释放对象占用的内存时被调用。



不幸的是,Swift结构不能包含deinit块,因此我们必须将KittenComponent包装在一个类中:



private class ComponentHolder {
    let component = KittenComponent()
    
    deinit {
        component.onDestroy()
    }
}


最后,让我们实现主小猫视图:



struct Kittens: View {
    @State private var holder: ComponentHolder?
    @State private var proxy = KittenViewProxy()

    var body: some View {
        KittenSwiftView(proxy: proxy)
            .onAppear(perform: onAppear)
            .onDisappear(perform: onDisappear)
    }

    private func onAppear() {
        if (self.holder == nil) {
            self.holder = ComponentHolder()
        }
        self.holder?.component.onViewCreated(view: self.proxy)
        self.holder?.component.onStart()
    }

    private func onDisappear() {
        self.holder?.component.onViewDestroyed()
        self.holder?.component.onStop()
    }
}


这里重要的是ComponentHolder和KittenViewProxy都标记为 ... 每次刷新UI时都会重新创建视图结构,但是属性标记为被保存。



其余的非常简单。我们正在使用KittenSwiftView。调用onAppear时,我们将KittenViewProxy(实现KittenView协议)传递给KittenComponent,并通过调用onStart启动组件。当onDisappear触发时,我们调用组件生命周期的相反方法。即使我们切换到其他视图,KittenComponent仍将继续工作,直到将其从内存中删除为止。



这是iOS应用程序的外观:



将小猫模块集成到Android应用中



此任务比iOS容易得多。再次假设我们已经创建了一个基本的Android应用程序模块让我们从实现KittenView开始。



布局没有什么特别的-只是SwipeRefreshLayout和RecyclerView:



<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/swype_refresh"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:contentDescription="@null"
        android:orientation="vertical"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>


KittenView实施:



internal class KittenViewImpl(root: View) : AbstractMviView<Model, Event>(), KittenView {
    private val swipeRefreshLayout = root.findViewById<SwipeRefreshLayout>(R.id.swype_refresh)
    private val adapter = KittenAdapter()
    private val snackbar = Snackbar.make(root, R.string.error_loading_kittens, Snackbar.LENGTH_INDEFINITE)

    init {
        root.findViewById<RecyclerView>(R.id.recycler).adapter = adapter

        swipeRefreshLayout.setOnRefreshListener {
            dispatch(Event.RefreshTriggered)
        }
    }

    override fun render(model: Model) {
        swipeRefreshLayout.isRefreshing = model.isLoading
        adapter.setUrls(model.imageUrls)

        if (model.isError) {
            snackbar.show()
        } else {
            snackbar.dismiss()
        }
    }
}


与iOS中一样,我们使用AbstractMviView类简化实现。刷卡更新时将调度RefreshTriggered事件。发生错误时,将显示小吃栏。 KittenAdapter会显示图像并在模型更改时进行更新。适配器内部使用DiffUtil来防止不必要的列表更新。完整的KittenAdapter代码可在此处找到



是时候使用KittenComponent了。在本文中,我将使用所有Android开发人员都熟悉的AndroidX代码段。但是我建议您查看我们的RIB,这是Uber的RIB分支。这是片段的更强大,更安全的替代方法。



class MainFragment : Fragment(R.layout.main_fragment) {
    private lateinit var component: KittenComponent

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        component = KittenComponent()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        component.onViewCreated(KittenViewImpl(view))
    }

    override fun onStart() {
        super.onStart()
        component.onStart()
    }

    override fun onStop() {
        component.onStop()
        super.onStop()
    }

    override fun onDestroyView() {
        component.onViewDestroyed()
        super.onDestroyView()
    }

    override fun onDestroy() {
        component.onDestroy()
        super.onDestroy()
    }
}


实现非常简单。我们实例化KittenComponent并在适当的时间调用其生命周期方法。



这是一个Android应用程序的外观:



结论



在本文中,我们已将Kittens通用模块集成到iOS和Android应用程序中。首先,我们实现了一个内部KittensDataSource接口,该接口负责从Web加载图像URL。我们使用iOS的NSURLSession和Android的HttpURLConnection。然后,我们使用SwiftUI将KittenComponent集成到iOS项目中,并使用常规的Android Views将其集成到Android项目中。



在Android上,KittenComponent集成非常简单。我们使用RecyclerView和SwipeRefreshLayout创建了一个简单的布局,并通过扩展AbstractMviView类实现了KittenView接口。之后,我们在片段中使用了KittenComponent:我们只是创建了一个实例,并调用了它的生命周期方法。



使用iOS,事情要复杂一些。SwiftUI功能迫使我们编写其他一些类:



  • KittenViewProxy:此类同时是KittenView和ObservableObject。它不会直接显示视图模型,而是通过@ Published属性模型公开它;
  • ComponentHolder:此类包含KittenComponent的实例,并在从内存中删除时调用其onDestroy方法。


在本系列的第三篇(也是最后一篇)中,我将通过演示如何编写单元测试和集成测试来向您展示这种方法的可测试性。



Twitter上关注我并保持联系!



All Articles