نمط MVI المعماري في Kotlin Multiplatform ، الجزء 2





هذه هي المقالة الثانية من ثلاث مقالات حول تطبيق النمط المعماري MVI في Kotlin Multiplatform. في المقالة الأولى ، تذكرنا ما هو MVI وقمنا بتطبيقه لكتابة كود مشترك لنظامي التشغيل iOS و Android. قدمنا ​​ملخصات بسيطة مثل المتجر والعرض وبعض فئات المساعدة واستخدمناها لإنشاء وحدة مشتركة.



الغرض من هذه الوحدة هو تنزيل الروابط إلى الصور من الويب وربط منطق الأعمال بواجهة مستخدم ممثلة كواجهة Kotlin ، والتي يجب تنفيذها أصلاً على كل نظام أساسي. هذا ما سنفعله في هذه المقالة.



سنقوم بتنفيذ أجزاء خاصة بالنظام الأساسي للوحدة النمطية المشتركة ودمجها في تطبيقات iOS و Android. كما كان من قبل ، أفترض أن القارئ لديه بالفعل معرفة أساسية بـ Kotlin Multiplatform ، لذلك لن أتحدث عن تكوينات المشروع وأشياء أخرى لا تتعلق بـ MVI في Kotlin Multiplatform.



مشروع عينة محدث متاح على GitHub .



خطة



في المقالة الأولى ، حددنا واجهة KittenDataSource في وحدة Kotlin العامة. مصدر البيانات هذا مسؤول عن تنزيل الروابط إلى الصور من الويب. حان الوقت الآن لتنفيذه لنظامي التشغيل iOS و Android. للقيام بذلك ، سنستخدم ميزة Kotlin متعددة المنصات كما هو متوقع / فعلي . ثم ندمج وحدة Kittens العامة في تطبيقات iOS و Android. بالنسبة لنظام التشغيل iOS ، نستخدم SwiftUI ، وبالنسبة لنظام Android ، نستخدم طرق عرض Android العادية.



لذا فإن الخطة على النحو التالي:



  • تنفيذ جانب KittenDataSource

    • لنظام التشغيل iOS
    • لأجهزة الأندرويد
  • دمج وحدة القطط في تطبيق iOS

    • تنفيذ KittenView باستخدام SwiftUI
    • دمج KittenComponent في عرض SwiftUI
  • دمج وحدة القطط في تطبيق Android

    • تنفيذ KittenView باستخدام Android Views
    • دمج KittenComponent في جزء Android




تنفيذ KittenDataSource



لنتذكر أولاً كيف تبدو هذه الواجهة:



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


وهنا عنوان وظيفة المصنع الذي سنقوم بتطبيقه:



internal expect fun KittenDataSource(): KittenDataSource


يتم الإعلان عن كل من الواجهة ووظيفة المصنع الداخلية وهي تفاصيل تنفيذ لوحدة Kittens. باستخدام توقع / الفعلي ، يمكننا الوصول إلى API لكل منصة.



KittenDataSource لنظام iOS



دعنا ننفذ مصدر بيانات لنظام iOS أولاً. للوصول إلى واجهة برمجة تطبيقات iOS ، نحتاج إلى وضع رمزنا في مجموعة مصادر "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. إنه غير متزامن ، لذلك لا يلزم تبديل الخيط. نحن فقط نلف المكالمة ربما ونضيف الرد والخطأ ومعالجة الإلغاء.



وهنا يتم تنفيذ وظيفة المصنع:



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


في هذه المرحلة ، يمكننا تجميع الوحدة النمطية المشتركة لدينا لـ iosX64 و iosArm64.



KittenDataSource لنظام Android



للوصول إلى 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 دون استخدام مكتبات الطرف الثالث. تم حظر واجهة برمجة التطبيقات هذه ، لذلك نحتاج إلى التبديل إلى سلسلة المحادثات الخلفية باستخدام عامل الاشتراك SubscribeOn.



يتطابق تنفيذ وظيفة المصنع لنظام Android مع ذلك المستخدم لنظام التشغيل iOS:



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


الآن يمكننا تجميع وحدتنا المشتركة للأندرويد.



دمج وحدة القطط في تطبيق iOS



هذا هو الجزء الأصعب (والأكثر إثارة للاهتمام) من الوظيفة. لنفترض أننا جمعنا وحدتنا كما هو موضح في تطبيق iOS README . قمنا أيضًا بإنشاء مشروع SwiftUI أساسي في Xcode وأضفنا إطار عمل 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. يتعرض KittenViewModel باستخدام خاصية @ المنشورة للنموذج ، بحيث يمكن عرض SwiftUI الخاص بنا. استخدمنا فئة AbstractMviView التي أنشأناها في المقالة السابقة. لا يتعين علينا التفاعل مع مكتبة Reaktive - يمكننا استخدام طريقة الإرسال لإرسال الأحداث.



لماذا نتجنب مكتبات Reaktive (أو coroutines / Flow) في Swift؟ لأن توافق Kotlin-Swift له عدة قيود. على سبيل المثال ، لا يتم تصدير المعلمات العامة للواجهات (البروتوكولات) ، ولا يمكن استدعاء وظائف التمديد بالطريقة المعتادة ، وما إلى ذلك. يرجع معظم القيود إلى حقيقة أن توافق Kotlin-Swift يتم من خلال Objective-C (يمكنك العثور على جميع القيود هنا). أيضًا ، نظرًا لنموذج ذاكرة Kotlin / Native الصعب ، أعتقد أنه من الأفضل أن يكون لديك أقل قدر ممكن من تفاعل Kotlin-iOS.



حان الوقت الآن لتقديم عرض SwiftUI. لنبدأ بإنشاء هيكل عظمي:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
}


لقد أعلنا عن طريقة عرض SwiftUI التي تعتمد على KittenViewProxy. تشترك خاصية الوكيل التي تم وضع علامة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())
                    }
                }
            }
        }
    }
}


الجزء الرئيسي هنا هو المحتوى. نأخذ النموذج الحالي من الوكيل ونعرض أحد الخيارات الثلاثة: لا شيء (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 صحيحة ؛ خلاف ذلك ، يتم عرض قائمة بالصور.



التكامل KittenComponent



الآن لدينا KittenSwiftView ، حان الوقت لاستخدام KittenComponent. لا يوجد لدى SwiftUI سوى View ، لذا سيتعين علينا التفاف KittenSwiftView و KittenComponent في عرض SwiftUI منفصل.



تتكون دورة حياة عرض SwiftUI من حدثين فقط: onAppear و onDisappear. يتم تشغيل الأول عند عرض المنظر على الشاشة ، والثاني يتم تشغيله عندما يكون مخفيًا. لا يوجد إشعار صريح بتدمير التقديم. لذلك ، نستخدم كتلة "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 حالة. يتم إعادة إنشاء هياكل العرض في كل مرة يتم فيها تحديث واجهة المستخدم ، ولكن يتم تمييز الخصائص على أنهاحالةيتم حفظها.



الباقي بسيط جدا. نحن نستخدم 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. بالنسبة لهذه المقالة ، سأستخدم مقتطفات AndroidX التي يعرفها جميع مطوري Android. ولكن أوصي بفحص RIBs لدينا ، شوكة من RIBs من Uber. هذا بديل أكثر قوة وأمانًا للشظايا.



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 داخلية مسؤولة عن تحميل عناوين URL للصور من الويب. استخدمنا NSURLSession لنظام iOS و HttpURLConnection لنظام Android. ثم قمنا بدمج KittenComponent في مشروع iOS باستخدام SwiftUI وفي مشروع Android باستخدام طرق عرض Android العادية.



على Android ، كان دمج KittenComponent بسيطًا جدًا. أنشأنا تخطيطًا بسيطًا باستخدام RecyclerView و SwipeRefreshLayout ونفذنا واجهة KittenView من خلال توسيع فئة AbstractMviView. بعد ذلك ، استخدمنا KittenComponent في جزء: لقد أنشأنا للتو مثيلًا وأطلقنا عليه أساليب دورة الحياة.



مع iOS ، كانت الأمور أكثر تعقيدًا. أجبرتنا ميزات SwiftUI على كتابة بعض الفصول الإضافية:



  • KittenViewProxy: هذه الفئة هي KittenView و ObservableObject في نفس الوقت ؛ لا يعرض نموذج العرض مباشرة ، لكنه يعرضه عبر نموذج الملكية @ المنشور ؛
  • ComponentHolder: تحتوي هذه الفئة على مثيل لـ KittenComponent وتستدعي أسلوب onDestroy عند إزالته من الذاكرة.


في المقالة الثالثة (والأخيرة) في هذه السلسلة ، سأوضح لك مدى قابلية هذا النهج للاختبار من خلال توضيح كيفية كتابة اختبارات الوحدة والتكامل.



تابعني على Twitter وابق على اتصال!



All Articles