company_banner

Архитектурный шаблон MVI в Kotlin Multiplatform, часть 2



    Это вторая из трёх статей о применении архитектурного шаблона MVI в Kotlin Multiplatform. В первой статье мы вспомнили, что такое MVI, и применили его для написания общего для iOS и Android кода. Мы ввели простые абстракции, такие как Store и View, а также некоторые вспомогательные классы и использовали их для создания общего модуля.

    Задача этого модуля — загружать ссылки на изображения из Сети и связывать бизнес-логику с пользовательским интерфейсом, представленным в виде Kotlin-интерфейса, который должен быть реализован нативно на каждой платформе. Именно этим мы и займёмся в этой статье.

    Мы будем реализовывать специфичные для платформы части общего модуля и интегрировать их в iOS- и Android-приложения. Как и прежде, я предполагаю, что читатель уже имеет базовые знания о Kotlin Multiplatform, поэтому не буду рассказывать о конфигурациях проектов и других вещах, не связанных с MVI в Kotlin Multiplatform.

    Обновлённый пример проекта доступен на нашем GitHub.

    План


    В первой статье мы определили интерфейс KittenDataSource в нашем общем модуле Kotlin. Этот источник данных отвечает за загрузку ссылок на изображения из Сети. Теперь пришло время реализовать его для iOS и Android. Для этого мы воспользуемся такой особенностью Kotlin Multiplatform, как expect/actual. После этого мы интегрируем наш общий модуль Kittens в iOS- и Android-приложения. Для iOS мы используем SwiftUI, а для Android — обычные Android Views.

    Итак, план следующий:

    • Реализация KittenDataSource на стороне
      • Для iOS
      • Для Android
    • Интеграция модуля Kittens в iOS-приложение
      • Реализация KittenView с использованием SwiftUI
      • Интеграция KittenComponent в SwiftUI View
    • Интеграция модуля Kittens в Android-приложение
      • Реализация KittenView с использованием Android Views
      • Интеграция KittenComponent в Android Fragment


    Реализация KittenDataSource


    Давайте сначала вспомним, как выглядит этот интерфейс:

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

    А вот заголовок его фабричной функции, которую мы собираемся реализовать:

    internal expect fun KittenDataSource(): KittenDataSource

    И интерфейс и его фабричная функция объявлены как internal и являются деталями реализации модуля Kittens. Используя expect/actual, мы можем получить доступ к API каждой платформы.

    KittenDataSource для iOS


    Давайте сначала реализуем источник данных для iOS. Чтобы получить доступ к iOS API, нам нужно поместить наш код в набор исходного кода (source set) “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.

    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 без применения сторонних библиотек. Этот API блокирующий, поэтому нам необходимо переключиться на фоновый поток, используя оператор subscribeOn.

    Реализация фабричной функции для Android идентична той, что используется для iOS:

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

    Теперь мы можем скомпилировать наш общий модуль под Android.

    Интеграция модуля Kittens в iOS-приложение


    Это самая трудная (и самая интересная) часть работы. Предположим, что мы скомпилировали наш модуль, как описано в README к iOS-приложению. Также мы создали в 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. Модель KittenViewModel выдаётся при помощи @Published-свойства model, поэтому наше SwiftUI-представление сможет подписаться на него. Мы использовали класс AbstractMviView, который создали в предыдущей статье. Нам не придётся взаимодействовать с библиотекой Reaktive — для отправки событий мы можем использовать метод dispatch.

    Почему мы избегаем библиотеки Reaktive (или корутин/Flow) в Swift? Потому что совместимость Kotlin-Swift имеет несколько ограничений. Например, generic-параметры не экспортируются для интерфейсов (протоколов), extension-функции нельзя вызывать привычным способом и т. д. Большинство ограничений связано с тем, что совместимость Kotlin-Swift выполняется через Objective-C (вы можете найти все ограничения здесь). Кроме того, из-за хитрой модели памяти Kotlin/Native я считаю, что лучше иметь как можно меньше взаимодействия Kotlin-iOS.

    Теперь пришло время сделать SwiftUI-представление. Начнём с создания скелета:

    struct KittenSwiftView: View {
        @ObservedObject var proxy: KittenViewProxy
    
        var body: some View {
        }
    }

    Мы объявили наше SwiftUI-представление, которое зависит от KittenViewProxy. Свойство proxy, помеченное как @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())
                        }
                    }
                }
            }
        }
    }
    

    Основной частью здесь является content. Мы берём текущую модель из прокси и отображаем один из трёх вариантов: ничего (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
        }
    }
    

    Мы показываем content внутри NavigationView, добавляя заголовок, индикатор загрузки (loader) и кнопку для обновления.

    При каждом изменении модели представление будет автоматически обновляться. Индикатор загрузки отображается, когда для флага 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()
        }
    }
    

    Наконец, давайте реализуем наше основное представление 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 помечены как State. Структуры представлений пересоздаются при каждом обновлении пользовательского интерфейса, но свойства, помеченные как State, сохраняются.

    Всё остальное довольно просто. Мы используем KittenSwiftView. Когда вызывается onAppear, мы передаём KittenViewProxy (который реализует протокол KittenView) в KittenComponent и запускаем компонент, вызывая onStart. Когда срабатывает onDisappear, мы вызываем противоположные методы жизненного цикла компонента. KittenComponent продолжит работать до тех пор, пока не будет удалён из памяти, даже если мы перейдём к другому представлению.

    Вот так выглядит приложение для iOS:

    Интеграция модуля Kittens в 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 отправляется при обновлении свайпом. При возникновении ошибки показывается Snackbar. 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 Views.

    В Android интеграция KittenComponent была очень простой. Мы создали простой макет с RecyclerView и SwipeRefreshLayout и реализовали интерфейс KittenView, расширив класс AbstractMviView. После этого мы использовали KittenComponent во фрагменте: просто создали экземпляр и вызвали его методы жизненного цикла.

    С iOS всё было немного сложнее. Особенности SwiftUI заставили нас написать несколько дополнительных классов:

    • KittenViewProxy: этот класс одновременно является и KittenView, и ObservableObject; он не отображает модель представления напрямую, а предоставляет её через @Published-свойство model;
    • ComponentHolder: этот класс хранит экземпляр KittenComponent и вызывает его метод onDestroy, когда удаляется из памяти.

    В третьей (и последней) статье из этой серии я покажу, насколько тестируемым является описанный подход, продемонстрировав написание модульных и интеграционных тестов.

    Подписывайтесь на меня в Twitter и оставайтесь на связи!
    Badoo
    Big Dating

    Комментарии 9

      +1

      1) А почему не использовали скажем Ktor для получения данных? Зачем разнесли в expect/actual?


      Почему мы избегаем библиотеки Reaktive (или корутин/Flow) в Swift?

      2) Новая инфа) А разве не в этом был смысл Reaktive? Вы её используете только в рамках jvm/андроид таргетов?

        0

        Спасибо за вопросы!


        1. Здесь несколько причин:
          • Прежде всего Ktor немного выходит за рамки статьи
          • Ktor знаком меньшему количеству разработчиков, нежели HttpUrlConnection
          • Хотелось лишний раз продемонстрировать возможности expect/actual
        2. Здесь речь именно про экспорт асинхронных фреймворков как API в Swift (или в Xcode). Из-за описанных ограничений их неудобно (и местами опасно) использовать. По-этому намного удобнее закрыть удобным и простым фасадом, а всю логику сделать деталями реализации общего модуля. Но использовать Reaktive для написания общего Kotlin кода — одно удовольствие. :-)
          0

          2) Тут конечно философский вопрос. В моей удобной вселенной — реактивщина и должна давать возможность проброса "примитивов"(Flow, Flowable,..) куда-то ещё, где их будешь использовать. Я понимаю, что сейчас это опасно скажем из-за native. Но почему менее удобно? В jvm/android ведь никого это особо не смущает когда rx идёт от сетевого уровня к UI..

            0

            Я с Вами полностью согласен, и в переведённом примере Rx (или это могли быть корутины+Flow) — во всех слоях, кроме прямо самого UI. И так получается, что UI можно сделать практически единственным, с чем придётся взаимодейстовать пользователям модуля. Это отлично ложится на имеющиеся ограничения Kotlin/Native. Да и просто, чем меньше пользователям модуля надо делать работы, тем лучше — на много проще вызвать метод onDestroy, чем заботится об утечках и отменах самому.

              0
              Там просто со Swift не удобно все это дергать, я конечно, только игрался с ним. Но дискомфорт как-то сразу виден, если например мы из Android кода без проблем дергаем suspend функции, то через Swift приходится использовать обычные callback-и.
        0

        Как бы на основе текущего примера реализовали скажем: тап на котика — показ диалога и пусть с получением какого-либо результата с диалога.
        ?

          0

          Показ диалогов можно делать также через состояние, а можно доработать "фреймворк" и добавить туда подобие News в MVICore. Если через состояние, то порядок примерно следующий:


          • По нажатию на котика производится Event.ItemClicked, который преобразуется в Intent.DoSomething
          • Intent.DoSomething поступает в Store
          • Store выставляет некий флаг State.isWaitingForData
          • State преобразуется во ViewModel, где проставляется флаг ViewModel.isRequestDataDialogVisible
          • При успешном закрытии диалога выдаётся Event.RequestDataDialogConfirmed(data)
          • При отмене диалога выдаётся Event.RequestDataDialogCancelled
          • Store получается соответствующие намерения, убирает флаг State.isWaitingForData и выполняет нужное действие

          Если делать через News, то вместо фалага в состоянии Store будет выдавать одноразовый News.WaitingForData. Получив этот News представление показывает диалог.

            0

            Ага, спасибо. А Store был бы один на сам экран и диалог?
            Что если диалог сложный и интерактивный? Тогда для него следовало бы сделать отдельный Store? Т.е. вопрос больше о том где граница когда фичу/компонент пора выделять?

              0

              Мы стараемся следовать SRP. По-этому да, если диалог сложный или может быть переиспользован, то его можно вынести в отдельную сущность (в статье это Component, или DialogFragment, или в нашем случае это был бы отдельный RIB). Там был бы свой Store. Тогда показ и скрытие лучше делать через News.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое