Pola arsitektur MVI di Kotlin Multiplatform, bagian 2





Ini adalah artikel kedua dari tiga tentang penerapan pola arsitektur MVI di Kotlin Multiplatform. Pada artikel pertama, kami ingat apa MVI itu dan menerapkannya untuk menulis kode umum untuk iOS dan Android. Kami memperkenalkan abstraksi sederhana seperti Store and View, serta beberapa kelas pembantu, dan menggunakannya untuk membuat modul umum.



Tujuan modul ini adalah untuk mengunduh tautan ke gambar dari Web dan mengaitkan logika bisnis dengan antarmuka pengguna yang direpresentasikan sebagai antarmuka Kotlin, yang harus diterapkan secara asli di setiap platform. Inilah yang akan kita lakukan di artikel ini.



Kami akan mengimplementasikan bagian platform tertentu dari modul umum dan mengintegrasikannya ke dalam aplikasi iOS dan Android. Seperti sebelumnya, saya berasumsi bahwa pembaca sudah memiliki pengetahuan dasar tentang Kotlin Multiplatform, jadi saya tidak akan berbicara tentang konfigurasi proyek dan hal-hal lain yang tidak terkait dengan MVI di Kotlin Multiplatform.



Proyek sampel yang diperbarui tersedia di GitHub kami .



Rencana



Pada artikel pertama, kami mendefinisikan antarmuka KittenDataSource dalam modul Kotlin umum kami. Sumber data ini bertanggung jawab untuk mengunduh tautan ke gambar dari web. Sekarang saatnya mengimplementasikannya untuk iOS dan Android. Untuk melakukan ini, kami akan menggunakan fitur Multiplatform Kotlin seperti yang diharapkan / aktual . Kami kemudian mengintegrasikan modul generik Anak Kucing kami ke dalam aplikasi iOS dan Android. Untuk iOS, kami menggunakan SwiftUI, dan untuk Android, kami menggunakan Tampilan Android biasa.



Jadi rencananya adalah sebagai berikut:



  • Implementasi sisi KittenDataSource

    • Untuk iOS
    • Untuk Android
  • Mengintegrasikan Modul Anak Kucing ke dalam Aplikasi iOS

    • Implementasi KittenView menggunakan SwiftUI
    • Mengintegrasikan KittenComponent ke dalam Tampilan SwiftUI
  • Mengintegrasikan Modul Anak Kucing ke dalam Aplikasi Android

    • Implementasi KittenView menggunakan Android Views
    • Mengintegrasikan KittenComponent ke dalam Android Fragment




Implementasi KittenDataSource



Pertama-tama mari kita ingat seperti apa antarmuka ini:



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


Dan di sini adalah tajuk fungsi pabrik yang akan kita implementasikan:



internal expect fun KittenDataSource(): KittenDataSource


Baik antarmuka maupun fungsi pabriknya dinyatakan internal dan merupakan detail implementasi dari modul Kittens. Dengan menggunakan ekspektasi / aktual, kita dapat mengakses API dari setiap platform.



KittenDataSource untuk iOS



Mari kita mengimplementasikan sumber data untuk iOS terlebih dahulu. Untuk mengakses iOS API, kita perlu memasukkan kode kita di set sumber "iosCommonMain". Ini dikonfigurasi untuk bergantung pada commonMain. Set target kode sumber (iosX64Main dan iosArm64Main), pada gilirannya, tergantung pada iosCommonMain. Anda dapat menemukan konfigurasi lengkap di sini .



Berikut ini adalah implementasi sumber data:




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



Menggunakan NSURLSession adalah cara utama untuk mengunduh data dari web di iOS. Ini asinkron, jadi tidak perlu beralih ulir. Kami hanya membungkus panggilan dalam Mungkin dan menambahkan respons, kesalahan, dan penanganan pembatalan.



Dan di sini adalah implementasi dari fungsi pabrik:



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


Pada titik ini, kita dapat mengkompilasi modul umum kita untuk iosX64 dan iosArm64.



KittenDataSource untuk Android



Untuk mengakses Android API, kita perlu meletakkan kode kita di set kode sumber AndroidMain. Ini adalah bagaimana implementasi sumber data terlihat seperti:



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


Untuk Android, kami telah mengimplementasikan HttpURLConnection. Sekali lagi, ini adalah cara yang populer untuk memuat data di Android tanpa menggunakan perpustakaan pihak ketiga. API ini memblokir, jadi kita perlu beralih ke utas latar belakang menggunakan operator berlangganan.



Implementasi fungsi pabrik untuk Android identik dengan yang digunakan untuk iOS:



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


Sekarang kita dapat menyusun modul umum kita untuk Android.



Mengintegrasikan Modul Anak Kucing ke dalam Aplikasi iOS



Ini adalah bagian pekerjaan yang paling sulit (dan paling menarik). Katakanlah kita telah menyusun modul kita seperti yang dijelaskan dalam README aplikasi iOS. Kami juga membuat proyek dasar SwiftUI di Xcode dan menambahkan kerangka anak kucing kami ke dalamnya. Saatnya untuk mengintegrasikan KittenComponent ke dalam aplikasi iOS Anda.



Implementasi KittenView



Mari kita mulai dengan mengimplementasikan KittenView. Pertama, mari kita ingat seperti apa tampilannya di 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()
    }
}


Jadi KittenView kami mengambil model dan memadamkan acara. Untuk merender model di SwiftUI, kita harus membuat proxy sederhana:



import Kittens

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


Proxy mengimplementasikan dua antarmuka (protokol): KittenView dan ObservableObject. KittenViewModel diekspos menggunakan properti model @ Published, sehingga tampilan SwiftUI kami dapat berlangganan. Kami menggunakan kelas AbstractMviView yang kami buat di artikel sebelumnya. Kami tidak harus berinteraksi dengan perpustakaan Reaktive - kami dapat menggunakan metode pengiriman untuk mengirim acara.



Mengapa kita menghindari perpustakaan Reaktive (atau coroutines / Flow) di Swift? Karena kompatibilitas Kotlin-Swift memiliki beberapa keterbatasan. Misalnya, parameter umum tidak diekspor untuk antarmuka (protokol), fungsi ekstensi tidak dapat dipanggil dengan cara biasa, dll. Sebagian besar keterbatasan disebabkan oleh kenyataan bahwa kompatibilitas Kotlin-Swift dilakukan melalui Objective-C (Anda dapat menemukan semua batasan di sini). Selain itu, karena model memori Kotlin / Native yang rumit, saya pikir sebaiknya interaksi Kotlin-iOS sesedikit mungkin.



Sekarang saatnya membuat tampilan SwiftUI. Mari kita mulai dengan membuat kerangka:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
}


Kami telah mendeklarasikan tampilan SwiftUI kami, yang tergantung pada KittenViewProxy. Properti proxy bertanda @ObservedObject berlangganan ObservableObject (KittenViewProxy). KittenSwiftView kami akan secara otomatis diperbarui setiap kali KittenViewProxy berubah.



Sekarang mari kita mulai mengimplementasikan view:



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


Bagian utama di sini adalah konten. Kami mengambil model saat ini dari proxy dan menampilkan salah satu dari tiga opsi: tidak ada (EmptyView), pesan kesalahan, atau daftar gambar.



Tubuh tampilan mungkin terlihat seperti ini:



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


Kami menampilkan konten di dalam NavigationView dengan menambahkan judul, pemuat, dan tombol untuk menyegarkan.



Setiap kali model berubah, tampilan akan diperbarui secara otomatis. Indikator memuat ditampilkan ketika bendera isLoading diatur ke true. Acara RefreshTriggered dikirim ketika tombol refresh diklik. Pesan kesalahan ditampilkan jika bendera isError benar; jika tidak, daftar gambar ditampilkan.



Integrasi KittenComponent



Sekarang kami memiliki KittenSwiftView, saatnya menggunakan KittenComponent kami. SwiftUI tidak memiliki apa-apa selain View, jadi kita harus membungkus KittenSwiftView dan KittenComponent dalam tampilan SwiftUI yang terpisah.



Siklus hidup tampilan SwiftUI hanya terdiri dari dua peristiwa: onAppear dan onDisappear. Yang pertama dipecat saat tampilan ditampilkan di layar, dan yang kedua dipecat saat disembunyikan. Tidak ada pemberitahuan eksplisit tentang penghancuran pengajuan. Oleh karena itu, kami menggunakan blok "deinit", yang disebut ketika memori yang ditempati oleh objek dibebaskan.



Sayangnya, struktur Swift tidak dapat berisi blok deinit, jadi kita harus membungkus KittenComponent kita di kelas:



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


Akhirnya, mari kita terapkan tampilan anak kucing utama kami:



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


Yang penting di sini adalah ComponentHolder dan KittenViewProxy ditandai sebagai Negara... Struktur tampilan dibuat kembali setiap kali UI di-refresh, tetapi properti ditandai sebagaiNegaradiselamatkan.



Sisanya cukup sederhana. Kami menggunakan KittenSwiftView. Ketika onAppear dipanggil, kami meneruskan KittenViewProxy (yang mengimplementasikan protokol KittenView) ke KittenComponent dan memulai komponen dengan memanggil onStart. Ketika onDisappear menyala, kami memanggil metode yang berlawanan dari siklus hidup komponen. KittenComponent akan terus bekerja sampai dihapus dari memori, bahkan jika kita beralih ke tampilan yang berbeda.



Seperti inilah tampilan aplikasi iOS:



Mengintegrasikan Modul Anak Kucing ke dalam Aplikasi Android



Tugas ini jauh lebih mudah daripada dengan iOS. Misalkan lagi kita telah membuat modul aplikasi Android dasar . Mari kita mulai dengan mengimplementasikan KittenView.



Tidak ada yang istimewa dengan tata letak - hanya SwipeRefreshLayout dan 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>


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


Seperti di iOS, kami menggunakan kelas AbstractMviView untuk menyederhanakan implementasi. Acara RefreshTriggered dikirim ketika memperbarui dengan babatan. Ketika kesalahan terjadi, Snackbar ditampilkan. KittenAdapter menampilkan gambar dan diperbarui setiap kali model berubah. DiffUtil digunakan di dalam adaptor untuk mencegah pembaruan daftar yang tidak perlu. Kode KittenAdapter yang lengkap dapat ditemukan di sini .



Saatnya menggunakan KittenComponent. Untuk artikel ini, saya akan menggunakan cuplikan AndroidX yang sudah dikenal semua pengembang Android. Tapi saya sarankan memeriksa RIB kami , garpu RIB dari Uber. Ini adalah alternatif yang lebih kuat dan lebih aman untuk fragmen.



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


Implementasinya sangat sederhana. Kami instantiate KittenComponent dan memanggil metode siklus hidupnya pada waktu yang tepat.



Dan inilah tampilan aplikasi Android:



Kesimpulan



Pada artikel ini, kami telah mengintegrasikan modul generik Anak Kucing ke dalam aplikasi iOS dan Android. Pertama, kami menerapkan antarmuka KittensDataSource internal yang bertanggung jawab untuk memuat URL gambar dari web. Kami menggunakan NSURLSession untuk iOS dan HttpURLConnection untuk Android. Kami kemudian mengintegrasikan KittenComponent ke proyek iOS menggunakan SwiftUI dan ke proyek Android menggunakan Tampilan Android biasa.



Di Android, integrasi KittenComponent sangat sederhana. Kami membuat tata letak sederhana dengan RecyclerView dan SwipeRefreshLayout dan mengimplementasikan antarmuka KittenView dengan memperluas kelas AbstractMviView. Setelah itu, kami menggunakan KittenComponent dalam sebuah fragmen: kami baru saja membuat sebuah instance dan memanggil metode siklus hidupnya.



Dengan iOS, segalanya menjadi sedikit lebih rumit. Fitur SwiftUI memaksa kami untuk menulis beberapa kelas tambahan:



  • KittenViewProxy: Kelas ini adalah KittenView dan ObservableObject secara bersamaan; itu tidak menampilkan model tampilan secara langsung, tetapi mengeksposnya melalui model properti @ Published;
  • ComponentHolder: Kelas ini menyimpan instance KittenComponent dan memanggil metode onDestroy ketika dihapus dari memori.


Dalam artikel ketiga (dan terakhir) dalam seri ini, saya akan menunjukkan kepada Anda seberapa dapat diujinya pendekatan ini dengan menunjukkan cara menulis unit dan tes integrasi.



Ikuti saya di Twitter dan tetap terhubung!



All Articles