Kotlin MultiplatformのMVIアーキテクチャパターン、パート2





これは、Kotlin MultiplatformでのMVIアーキテクチャパターンの適用に関する3つの記事の2番目です。では最初の記事、私たちは、MVIが何であるかを思い出したとiOSとAndroidのための一般的なコードを書くためにそれを適用します。 StoreやViewなどの単純な抽象化といくつかのヘルパークラスを導入し、それらを使用して共通モジュールを作成しました。



このモジュールの目的は、Webから画像へのリンクをダウンロードし、ビジネスロジックをKotlinインターフェイスとして表されるユーザーインターフェイスに関連付けることです。これは、各プラットフォームにネイティブに実装する必要があります。これがこの記事で行うことです。



共通モジュールのプラットフォーム固有の部分を実装し、iOSおよびAndroidアプリケーションに統合します。以前と同様に、読者はKotlin Multiplatformの基本的な知識をすでに持っていると想定しているため、Kotlin MultiplatformのMVIに関連しないプロジェクト構成やその他のことについては説明しません。



更新されたサンプルプロジェクトは、GitHubで入手できます



予定



最初の記事では、汎用のKotlinモジュールでKittenDataSourceインターフェイスを定義しました。このデータソースは、ウェブから画像へのリンクをダウンロードする責任があります。iOSとAndroidに実装する時が来ました。これを行うには、expect / actualのようなKotlinマルチプラットフォーム機能を使用します次に、汎用のKittensモジュールをiOSおよびAndroidアプリに統合します。iOSの場合はSwiftUIを使用し、Androidの場合は通常のAndroidビューを使用します。



したがって、計画は次のとおりです。



  • KittenDataSource側の実装

    • iOSの場合
    • アンドロイド用
  • Kittens ModuleをiOSアプリに統合する

    • SwiftUIを使用したKittenViewの実装
    • KittenComponentをSwiftUIビューに統合する
  • Kittens ModuleをAndroidアプリに統合する

    • Androidビューを使用したKittenViewの実装
    • KittenComponentをAndroidフラグメントに統合する




KittenDataSourceの実装



このインターフェースがどのように見えるかを最初に覚えましょう:



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


そして、これが実装するファクトリ関数のヘッダーです:



internal expect fun KittenDataSource(): KittenDataSource


インターフェースとそのファクトリー関数はどちらも内部で宣言されており、Kittensモジュールの実装の詳細です。expect / actualを使用することで、各プラットフォームの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でWebからデータをダウンロードするための主要な方法です。これは非同期であるため、スレッドの切り替えは必要ありません。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はブロックしているため、subscribeOn演算子を使用してバックグラウンドスレッドに切り替える必要があります。



Androidのファクトリー関数の実装は、iOSで使用されるものと同じです。



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


これで、Android用の共通モジュールをコンパイルできます。



Kittens ModuleをiOSアプリに統合する



これは、仕事の最も難しい(そして最も興味深い)部分です。iOSアプリのREADMEで説明されいるようにモジュールをコンパイルしたとしましょうまた、Xcodeで基本的なSwiftUIプロジェクトを作成し、それに Kittensフレームワークを追加しました。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の2つのインターフェース(プロトコル)を実装します。 KittenViewModelは、モデルの@ Publishedプロパティを使用して公開されるため、SwiftUIビューがサブスクライブできます。前の記事で作成したAbstractMviViewクラスを使用しました。 Reaktiveライブラリと対話する必要はありません-dispatchメソッドを使用してイベントをディスパッチできます。



SwiftでReaktive(またはコルーチン/ Flow)ライブラリを使用しないのはなぜですか? Kotlin-Swiftの互換性にはいくつかの制限があるためです。たとえば、ジェネリックパラメーターはインターフェイス(プロトコル)に対してエクスポートされません。拡張関数は通常の方法で呼び出すことができません。ほとんどの制限は、Kotlin-Swift互換性がObjective-Cによって行われるためです(すべての制限はここにあります))。また、トリッキーなKotlin /ネイティブメモリモデルのため、 KotlinとiOSの相互作用をできるだけ少なくするのが最善だと思います。



次に、SwiftUIビューを作成します。スケルトンを作成することから始めましょう:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
}


KittenViewProxyに依存するSwiftUIビューを宣言しました。@ObservedObjectとマークされたプロキシプロパティは、ObservableObject(KittenViewProxy)をサブスクライブします。KittenSwiftViewは、KittenViewProxyが変更されるたびに自動的に更新されます。



次に、ビューの実装を開始します。



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


ここの主要部分はコンテンツです。プロキシから現在のモデルを取得し、3つのオプションの1つを表示します:なし(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の場合、エラーメッセージが表示されます。それ以外の場合は、画像のリストが表示されます。



子猫コンポーネントの統合



KittenSwiftViewができたので、今度はKittenComponentを使用します。SwiftUIにはViewしかないので、KittenSwiftViewとKittenComponentを別のSwiftUIビューにラップする必要があります。



SwiftUIビューのライフサイクルは、onAppearとonDisappearの2つのイベントのみで構成されています。1つ目はビューが画面に表示されたときに発生し、2つ目は非表示のときに発生します。提出物の破棄に関する明確な通知はありません。そのため、オブジェクトが占有していたメモリが解放されたときに呼び出される「deinit」ブロックを使用します。



残念ながら、Swift構造にはdeinitブロックを含めることができないため、KittenComponentをクラスでラップする必要があります。



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


最後に、メインのKittensビューを実装しましょう。



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アプリは次のようになります。



Kittens Moduleを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スニペットを使用します。しかし、私はUberからのRIBのフォークである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アプリは次のようになります。



結論



この記事では、iOSおよびAndroidアプリにKittens汎用モジュールを統合しました。まず、ウェブから画像のURLをロードする内部のKittensDataSourceインターフェースを実装しました。iOSではNSURLSession、AndroidではHttpURLConnectionを使用しました。次に、SwiftUIを使用してKittenComponentをiOSプロジェクトに統合し、通常のAndroidビューを使用してAndroidプロジェクトに統合しました。



Androidでは、KittenComponentの統合は非常に簡単でした。RecyclerViewとSwipeRefreshLayoutを使用してシンプルなレイアウトを作成し、AbstractMviViewクラスを拡張してKittenViewインターフェースを実装しました。その後、フラグメントでKittenComponentを使用しました。インスタンスを作成し、そのライフサイクルメソッドを呼び出しました。



iOSでは、物事はもう少し複雑でした。SwiftUI機能により、いくつかの追加クラスを作成する必要がありました。



  • KittenViewProxy:このクラスは、同時にKittenViewとObservableObjectの両方です。ビューモデルを直接表示するのではなく、@ Publishedプロパティモデルを介して公開します。
  • ComponentHolder:このクラスはKittenComponentのインスタンスを保持し、メモリから削除されるとそのonDestroyメソッドを呼び出します。


このシリーズの3番目(および最後)の記事では、単体テストと統合テストの作成方法を示すことで、このアプローチがどれほどテスト可能かを示します。Twitter



私に従ってください



All Articles