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