Архитектура общего модуля и 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 нужны обёртки интеропа.