
В своей предыдущей статье я рассказал о том, почему считаю, что мы можем значительно улучшить управление 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 уже готов, нам не нужно его менять.
Следующие шаги таковы:
Совместное использование архитектуры FSM+MVI и ViewModel;
Обработка жизненных циклов Flow и Publisher;
Использование изменений состояния на iOS.
Весь код FSM и MVI будет перемещен в папку commonMain внутри модуля 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 разработчика. Регистрация для всех желающих доступна по ссылке.
