Kotlin Multiplatform Mobile — совместное управление состоянием пользовательского интерфейса

от автора

В своей предыдущей статье я рассказал о том, почему считаю, что мы можем значительно улучшить управление UI State (состояние пользовательского интерфейса) между View (представление) и ViewModel (модель представления) в Android, используя архитектуру Model-View-Intent (модель-представление-намерение) (MVI) с помощью Finite State Machine (машина с конечным числом состояний. конечный автомат) (FSM).

В этой статье я подскажу вам шаги, необходимые для модернизации этого решения до уровня Kotlin Multiplatform Mobile (KMM), где можно воспользоваться общим исходным кодом, содержащим MVI+FSM, так что обе платформы — Android и iOS — могут унаследовать его преимущества, отвечая только за платформозависимые реализации: UI/UX.

Перед тем как мы начнем, я предположу, что читатель имеет базовые знания о KMM, о том, как настроить проект, как создать общий код, как запросить имплементации, соответствующие платформе (expect/actual (ожидаемые/фактические)), и прочитал мою предыдущую статью.

Необходимые условия для платформы

Android:

Jetpack Compose и Flow (Job).

iOS:

SwiftUI и Combine (Publishers и Subscription).

Общие предварительные условия

После создания нового проекта KMM нам необходимо убедиться, что мы можем использовать наши имплементации FSM и MVI.

FSM:

State Machine от Tinder еще не прошла апгрейд для использования в качестве мультиплатформенной библиотеки, но, к счастью, существует пулл-реквест (PR) с такой имплементацией, которая на самом деле довольно проста. Пока этот PR не будет принят и опубликован, один из вариантов — скопировать StateMachine.kt и добавить его в наш проект в shared (общий) модуль.

Примечание: Если вам интересно, почему мы не можем воспользоваться сервисом JitPack, то по этому поводу имеется соответствующий материал.

MVI:

Библиотека Orbit Multiplatform — можно догадаться по названию — уже является мультиплатформенной. Orbit также предоставляет нам swift-gradle-plugin для генерации хелпер-классов .swift, так что нам не нужно беспокоиться о том, как все работает под капотом. Для прослушивания изменений состояния мы просто используем ObservableObject внутри View, а коммуникации Combine/Flow и жизненные циклы автоматически управляются за нас.

Примечание: на момент написания статьи авторы находятся в процессе обновления для новых версий Kotlin. Сейчас он не работает с версиями, начиная с 1.6.0.

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

ViewModel:

Чтобы воспользоваться преимуществами общей области жизненного цикла — для запуска корутины, которая будет прервана при очистке ViewModel, я буду использовать ViewModel библиотеки IceRock moko-mvvm (dev.icerock.moko:mvvm-core:$ {latest-version}) в качестве родительского класса нашей общей ViewModel (вместо Android’s).

Кто следующий?

Для иллюстрации этого путешествия я буду использовать тот же проект, что и в предыдущей статье. Как вы можете увидеть ниже, результат будет одинаковым, и это потому, что мы используем преимущества одной и той же бизнес-логики и архитектурной реализации — написанной, протестированной и валидированной один раз — оставляя только имплементацию пользовательского интерфейса для каждой платформы. В этом и заключается вся прелесть KMM.

Android
Android
iOS (запись экрана с помощью симулятора отстает от анимации)
iOS (запись экрана с помощью симулятора отстает от анимации)

Миграция

Пользовательский интерфейс Android уже готов, нам не нужно его менять.

Следующие шаги таковы:

  1. Совместное использование архитектуры FSM+MVI и ViewModel;

  2. Обработка жизненных циклов Flow и Publisher;

  3. Использование изменений состояния на iOS.

Весь код FSM и MVI будет перемещен в папку commonMain внутри модуля shared:

модуль shared 
модуль shared 

Совместное использование ViewModel

Koin поможет нам справиться с этой задачей. Сначала нам нужно создать класс, в котором мы определим expect (ожидаемые) «правила» :

expect fun platformModule(): Module  object DependencyInjection {     fun initKoin(appDeclaration: KoinAppDeclaration) {         startKoin {             appDeclaration()             modules(commonModule(), platformModule())         }     }      internal fun commonModule() = module { ... } }

commonMain

Этот класс находится в папке commonMain и содержит логику инициирования внедрения зависимостей. Далее нам нужно создать по одному классу для каждой платформы с их actual (фактической) имплементацией:

actual fun platformModule() = module {     viewModel { TimerViewModel() } }

androidMain

actual fun platformModule() = module {     factory { TimerViewModel() } }  object ViewModels : KoinComponent {     fun timerViewModel() = get<TimerViewModel>() }

iosMain

Они очень похожи, но в случае имплементации на iOS нам нужно раскрыть геттер для ViewModel. На Android Koin предлагает удобные расширения getViewModel.

Архитектура в общем доступе.

Выставление Job для Publisher

Для использования эмиссий состояния из нашего общего кода мы используем Kotlin Flows, но нам нужно навести мосты между ними и Swift Combine Publishers. Следующий код был составлен на основе очень познавательной статьи Джона О’Рейли. Он поможет нам этого достичь и обработать жизненный цикл Publisher на iOS.

Начнем с создания функции расширения, которая возвращает фоновый Job, заданный Flow:

fun Flow<*>.subscribe(     onEach: (item: Any) -> Unit,     onComplete: () -> Unit,     onThrow: (error: Throwable) -> Unit ): Job =     this.onEach { onEach(it as Any) }         .catch { onThrow(it) }         .onCompletion { onComplete() }         .launchIn(CoroutineScope(Job() + Dispatchers.Main))

iosMain

Далее внутри iosApp нам нужно создать Subscription, которая будет содержать экземпляры Flow и Job, чтобы управлять для нас логикой subscribe и cancel :

import Combine import shared  struct FlowPublisher<T: Any>: Publisher {     public typealias Output = T     public typealias Failure = Never     private let flow: Kotlinx_coroutines_coreFlow          public init(flow: Kotlinx_coroutines_coreFlow) {         self.flow = flow     }          public func receive<S: Subscriber>(subscriber: S) where S.Input == T, S.Failure == Failure {         subscriber.receive(subscription: FlowSubscription(flow: flow, subscriber: subscriber))     }      final class FlowSubscription<S: Subscriber>: Subscription where S.Input == T, S.Failure == Failure {         private var subscriber: S?         private var job: Kotlinx_coroutines_coreJob?         private let flow: Kotlinx_coroutines_coreFlow         init(flow: Kotlinx_coroutines_coreFlow, subscriber: S) {             self.flow = flow             self.subscriber = subscriber             job = SubscribeKt.subscribe(                 flow,                 onEach: { item in if let item = item as? T { _ = subscriber.receive(item) }},                 onComplete: { subscriber.receive(completion: .finished) },                 onThrow: { error in debugPrint(error) }             )         }          func cancel() {             subscriber = nil             job?.cancel(cause: nil)         }     } } 

Мост между Flow и Publisher

Все элементы, полученные Flow, будут переданы subscriber. Кроме того, при вызове функции Flow onComplete, subscriber также будет завершен. Следовательно, будет вызвана функция cancel(), которая очистит subscriber и отменит job.

Если вы помните, наша архитектура MVI привязана к viewModelScope, что означает, что когда ViewModel очищается, то очищается и Flow, и Publisher.

Жизненный цикл обработан.

Прежде чем перейти к следующему шагу, давайте добавим это удобное расширение:

public extension Kotlinx_coroutines_coreFlow {     func asPublisher<T: AnyObject>() -> AnyPublisher<T, Never> {         (FlowPublisher(flow: self) as FlowPublisher<T>).eraseToAnyPublisher()     } }

Объект ObservableObject

Последним шагом этой миграции является раскрытие UI State как Published переменной. Для этого мы создадим класс-обертку, соответствующий протоколу ObservableObject. Этот класс будет содержать экземпляр из общей ViewModel, чтобы раскрыть его состояние и публичные методы:

import SwiftUI import Combine import shared  public class TimerViewModelObservableObject : ObservableObject {          private var wrapped: TimerViewModel     @Published private(set) var state: TimerUiState          init(wrapped: TimerViewModel) {         self.wrapped = wrapped         state = wrapped.stateFlow.value as! TimerUiState         (wrapped.stateFlow.asPublisher() as AnyPublisher<TimerUiState, Never>)             .receive(on: RunLoop.main)             .assign(to: &$state)     }              deinit {         wrapped.onCleared()     }        func settingTime() {         wrapped.settingTime()     }          func setTime(seconds: Int32) {         wrapped.setTime(seconds: seconds)     }          //ramaining public functions...     }

Обертка ObservableObject

Следующее расширение также будет весьма кстати:

public extension TimerViewModel {     func asObservableObject() -> TimerViewModelObservableObject {         return TimerViewModelObservableObject(wrapped: self)     } }

И использовать состояния в View:

import SwiftUI import shared  struct TimerView: View {       @StateObject private var viewModel = ViewModels().timerViewModel().asObservableObject()     @State private var currentProgress: Float = 0.0      var body: some View {         ZStack {             //...             CircularProgressView(progress: $currentProgress)             if(viewModel.state.isRestarting) {                 //...             }         }         .onReceive(viewModel.$state, perform: { new in             currentProgress = new.progress         })     } }  struct CircularProgressView: View {       @Binding var progress: Float     //... }

Теперь, когда у нас в распоряжении @Published var state для последующего использования, мы можем выбрать, как это сделать — как StateObject или ObservedObject. Этот пример также иллюстрирует два случая применения, когда мы можем запрашивать свойства состояния напрямую по viewModel.state.something или через @State var, когда нам нужно, чтобы это свойство вело себя как State.

iOS использует изменения состояния.

Последний шаг миграции завершен.

Выводы

В этой статье мы узнали, как осуществить миграцию рабочей архитектуры платформы в проект KMM, чтобы воспользоваться преимуществами философии совместного использования кода. Мы также глубоко погрузились внутрь библиотеки Orbit Multiplatform swift-gradle-plugin и поняли, какие классы генерируются, их назначение и как они работают вместе.


Скоро в OTUS состоится открытое занятие по теме «Одновременная реализация фич на iOS + Android. Необходимый tool-set». На вебинаре обсудим мультиплатформенную разработку для iOS и Android, а также рассмотрим технологию Kotlin-Multiplatform с точки зрения Swift разработчика. Регистрация для всех желающих доступна по ссылке.


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/666164/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *