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/


Интересные статьи

Интересные статьи

Huawei поставляет Android-смартфоны без сервисов Google и привычного магазина приложений Google Play, создав аналоги: Huawei Services и AppGallery. Для нас, разработчиков, это 420 миллионов активных п...
На свете существует много хороших библиотек, фреймворков, операционных систем — всего того, из чего разработчики конструируют свои продукты. Иногда в этом разнообразии можно просто утонуть, особенно е...
Android-разработчики и продакты всей галактики ломают голову над одним важным вопросом — “Нужно ли делать интеграцию HMS?”. В это статьей мы расскажем, как у нас получилось затащить поддержку Huawei M...
В этой статье описан мой опыт по написанию плагина для компилятора Kotlin. Моей главной целью было создание плагина под iOS (Kotlin/Native), аналогичного kotlin-parc...
Большинство компиляторов C позволяют получить доступ к массиву extern с неопределёнными границами, например: extern int external_array[]; int array_get (long int index) { return external_ar...