Архитектура общего модуля и shared ViewModel

Как разложить общий модуль по слоям и где провести границу с нативным UI.

Shared ViewModel — расположенный в общем коде класс presentation-слоя, который держит состояние экрана (через StateFlow) и обрабатывает действия, отдавая UI обеим платформам один источник истины.

Слои общего модуля

Зрелый KMP-модуль строят по Clean Architecture: data (API, БД, репозитории), domain (модели и use-case'ы бизнес-логики), presentation (ViewModel, состояние экрана). UI — вне общего модуля, на каждой платформе свой. Чем больше слоёв в общем коде, тем меньше дублирования.

[ Нативный UI: Compose / SwiftUI ]   ← на каждой платформе
            |
[ presentation: shared ViewModel ]   ← общий код
            |
[ domain: use-cases, модели ]        ← общий код
            |
[ data: repository, API, БД ]        ← общий код

Общий ViewModel со StateFlow

ViewModel держит состояние как StateFlow — наблюдаемый поток, на который подписывается UI:

// commonMain
class OrderListViewModel(private val repo: OrderRepository) {
    private val _state = MutableStateFlow(OrderListState.Loading)
    val state: StateFlow<OrderListState> = _state

    fun load(scope: CoroutineScope) {
        scope.launch {
            _state.value = OrderListState.Loading
            _state.value = runCatching { repo.refresh() }
                .fold({ OrderListState.Data(it) }, { OrderListState.Error })
        }
    }
}

На Android Compose читает state как обычно. На iOS Swift подписывается на тот же поток. Логика экрана — одна.

Граница с UI

Граница проходит ровно по presentation: общий код отдаёт состояние и принимает действия, но ничего не рисует. Это и есть формула «общая логика, нативный UI» на уровне архитектуры. Где-то нужна тонкая прослойка-обёртка для iOS (например, чтобы StateFlow удобно читался в SwiftUI) — её пишут на стороне iOS.

Как работает под капотом

StateFlow — это холодный наблюдаемый держатель значения: подписчик всегда получает текущее состояние и затем обновления. Поскольку kotlinx.coroutines мультиплатформенна, тот же StateFlow работает на iOS. Тонкость в том, что Swift не умеет «из коробки» подписываться на Kotlin Flow идиоматично — поэтому используют обёртки (вручную или библиотеки вроде SKIE/KMP-NativeCoroutines), превращающие Flow в нечто удобное для Swift Combine/async. Это и есть граница интеропа, которой посвящён отдельный раздел.

Частые ошибки

Затащить в общий ViewModel платформенные вещи (Android Context, навигацию) — тогда он перестанет быть общим. ViewModel должна работать с абстракциями. Вторая ошибка — рисовать в общем коде хоть что-то от UI; общий слой заканчивается на состоянии. Третья — не дать iOS удобную обёртку над Flow и мучиться с колбэками вручную.

Итоги

  • Общий модуль разбивают на data/domain/presentation; UI остаётся нативным.
  • Shared ViewModel держит состояние через StateFlow — один источник истины.
  • Граница с UI проходит по presentation: отдаём состояние, принимаем действия.
  • Для удобной подписки на Flow из Swift нужны обёртки интеропа.
Проверьте себя
1. Что отдаёт shared ViewModel нативному UI?
AГотовые отрисованные экраны
BСостояние (через StateFlow) и приём действий, но не рисует UI
CHTML-разметку
DПлатформенный Context
2. Почему для подписки на Kotlin Flow из Swift используют обёртки?
AFlow не работает на iOS
BSwift не умеет идиоматично подписываться на Kotlin Flow без обёрток (SKIE и т.п.)
CОбёртки ускоряют сеть
DЭто требование Apple