From 34d37c5916b0ce91a0cf25cbbd5e85d557404c15 Mon Sep 17 00:00:00 2001 From: Kuzmin <«vkuzmin@icerockdev.com»> Date: Fri, 22 May 2026 15:07:26 +0300 Subject: [PATCH 1/5] update MVVM --- university/4-icerock-basics/mvvm.md | 399 +++++++++--------------- university/4-icerock-basics/practice.md | 149 ++++++--- 2 files changed, 244 insertions(+), 304 deletions(-) diff --git a/university/4-icerock-basics/mvvm.md b/university/4-icerock-basics/mvvm.md index 94c508a92..59d6242a7 100644 --- a/university/4-icerock-basics/mvvm.md +++ b/university/4-icerock-basics/mvvm.md @@ -6,341 +6,236 @@ sidebar_position: 6 ## Выбор подхода -Когда мы начинали внедрять Kotlin Multiplatform в разработку проектов мы стремились максимально избавиться от дублирования между платформами, но не вредя конечному UI и UX (оставляя его полностью нативным и привычным пользователям). Проведя некоторое исследование решили, что паттерн **Model-View-ViewModel**, который мы уже активно применяли на Android, **наиболее хорошо подойдет для переиспользования** между платформами. +При разработке на Kotlin Multiplatform мы стремимся максимально перенести логику в общий код, оставляя на стороне платформы только верстку UI и навигацию. Паттерн **Model-View-ViewModel** оптимально подходит для переиспользования между платформами. -*На тот момент декларативного UI в виде SwiftUI и Jetpack Compose еще не было, поэтому рассматривалось удобство и надежность интеграции с обычными `View`.* +В общем коде (shared) содержится: +- ViewModel для каждого экрана с бизнес-логикой +- Работа с сетью, БД, процессинг данных +- Преобразование данных в формат, готовый к отображению -В результате, мы имеем в общей Kotlin Multiplatform библиотеке: -- Для каждого экрана ViewModel с логикой работы -- Работа с сетью -- Работа с базой данных -- Процессинг данных, преобразования, расчеты - -И остается на стороне платформы - верстка UI, привязка к общим ViewModel и навигация. +На стороне платформы остаётся: +- Верстка UI (Compose на Android, SwiftUI на iOS) +- Привязка UI к ViewModel +- Навигация :::info -С самим подходом MVVM вы уже знакомились в разделе Android. Для освежения памяти особо полезно будет перечитать статью [Единый стейт экрана](../../learning/state) +С самим подходом MVVM вы уже знакомились в разделе Android. Для освежения памяти полезно перечитать статью [Единый стейт экрана](../../learning/state). ::: ## moko-mvvm -Для использования MVVM мы реализовали библиотеку [moko-mvvm](https://github.com/icerockdev/moko-mvvm). Главное, что мы стремились достичь при ее реализации, это использование оригинальных классов JetPack `ViewModel` и `StateFlow` со стороны Android, чтобы продолжить использовать существующие в Android интеграции с данными классами (включая логику хранения `ViewModel` в `ViewModelStore` чтобы переживать смену конфигурации). Для iOS стороны (и других платформ тоже) классы `ViewModel` и `StateFlow` были реализованы нами, в более простом виде чем в Android (так как только в Android есть сложный жизненный цикл компонентов с пересозданием). По сути классы `ViewModel` и `StateFlow` являются expect классами с разными actual реализациями на платформах. +Для реализации MVVM в KMP мы используем библиотеку [moko-mvvm](https://github.com/icerockdev/moko-mvvm). Она предоставляет базовый класс `ViewModel` с `viewModelScope`, классы-обёртки `CFlow`/`CStateFlow`/`CMutableStateFlow` для совместимости с iOS, а также готовые интеграции с Compose и SwiftUI. -Для знакомства с библиотекой посмотрите материалы на странице в базе знаний - [moko-mvvm](../../learning/libraries/moko/moko-mvvm). +Подробное описание подключения и API — на странице [moko-mvvm](../../learning/libraries/moko/moko-mvvm) в базе знаний. -### Привязка StateFlow к UI +## ViewModel в общем коде -В библиотеке также содержатся готовые методы для привязки `StateFlow` к UI элементам, по аналогии с методами, которые были использованы нами в [статье про State](../../learning/state). Данные методы доступны и для Android и для iOS, а поэтому в большинстве случаев вам не потребуется писать вручную привязку каждого типа данных к каждому UI элементу. +Каждая ViewModel наследуется от `ViewModel` из `moko-mvvm`, использует `viewModelScope` для корутин и хранит: -Привязкой UI к `StateFlow` называется binding, и основано на использовании метода `bind`: -- [для Android](https://github.com/icerockdev/moko-mvvm/blob/master/mvvm-flow/src/androidMain/kotlin/dev/icerock/moko/mvvm/flow/binding/BindingBase.kt) -- [для iOS](https://github.com/icerockdev/moko-mvvm/blob/master/mvvm-flow/src/iosMain/kotlin/dev/icerock/moko/mvvm/flow/binding/BindingBase.kt) +- **Состояние** — `CStateFlow` / `CMutableStateFlow` для данных, отображаемых в UI +- **Одноразовые события** — `Channel`, обёрнутый в `CFlow`, для действий (навигация, snackbar, диалоги) -Для Android нам доступны например: -```kotlin -fun EditText.bindTextTwoWay( - lifecycleOwner: LifecycleOwner, - flow: MutableStateFlow -): DisposableHandle - -fun TextView.bindText( - lifecycleOwner: LifecycleOwner, - flow: StateFlow -): DisposableHandle - -fun View.bindVisibleOrGone( - lifecycleOwner: LifecycleOwner, - flow: StateFlow -): DisposableHandle -``` - -И для iOS соответственно: -```swift -extension UITextField { - @discardableResult - func bindTextTwoWay(flow: CMutableStateFlow) -> DisposableHandle -} - -extension UILabel { - @discardableResult - func bindText(flow: CStateFlow) -> DisposableHandle -} - -extension UIView { - @discardableResult - func bindHidden(flow: CStateFlow) -> DisposableHandle -} -``` - -#### Пример - -shared code: ```kotlin class SimpleViewModel : ViewModel() { - private val _counter: MutableStateFlow = MutableStateFlow(0) - val counter: CStateFlow = _counter.map { it.toString() }.cStateFlow() + private val _counter: CMutableStateFlow = + MutableStateFlow(0).cMutableStateFlow() + val counter: CStateFlow = + _counter.map { it.toString() } + .stateIn(viewModelScope, SharingStarted.Lazily, "0") + .cStateFlow() + + private val _actions: Channel = Channel() + val actions: CFlow = _actions.receiveAsFlow().cFlow() fun onCounterButtonPressed() { _counter.value += 1 } -} -``` -android app: -```kotlin -class SimpleFragment: Fragment(R.layout.fragment_simple) { - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val viewModel: SimpleViewModel = getViewModel { SimpleViewModel() } - - val binding = FragmentSimpleBinding.bind(view) - binding.counterText.bindText(viewLifecycleOwner, viewModel.counter) - binding.incrementButton.setOnClickListener { viewModel.onCounterButtonPressed() } + fun onNavigatePressed() { + viewModelScope.launch { + _actions.send(Actions.RouteToMain) + } } -} -``` -ios app: -```swift -class SimpleViewController: UIViewController { - @IBOutlet private var counterLabel: UILabel! - - private var viewModel: SimpleViewModel! - - override func viewDidLoad() { - super.viewDidLoad() - - viewModel = SimpleViewModel() - - counterLabel.bindText(flow: viewModel.counter) - } - - @IBAction func onCounterButtonPressed() { - viewModel.onCounterButtonPressed() + sealed interface Actions { + data object RouteToMain : Actions } } ``` -#### Добавление своих расширений +### DI-регистрация + +ViewModel регистрируется в Koin как фабрика: -Если в `moko-mvvm` не оказалось нужной вам функции биндинга для `iOS` или `Android`, вы можете добавить свой `extension` к `CStateFlow`. -Например, добавим функцию `bindToMenuItemVisible` для связи `CStateFlow` и `MenuItem` на `Android`: ```kotlin -internal fun CStateFlow.bindToMenuItemVisible( - lifecycleOwner: LifecycleOwner, - menuItem: MenuItem -): DisposableHandle { - return bind(lifecycleOwner) { value -> - menuItem.isVisible = value - } +val featureModule: Module = module { + factoryOf(::SimpleViewModel) } -``` -Для `iOS` добавим функцию `bindToUIToolbarVisible` для связи `UIToolbar` c `CStateFlow` (на `iOS` из общего кода вместо `Boolean` приходит `KotlinBoolean`) вот как это будет выглядеть: -```swift -extension UIToolbar { - func bindToUIToolbarVisible(flow: CStateFlow) -> DisposableHandle { - return flow.subscribe { [weak self] value in - let kotlinBool = value as! KotlinBoolean - self?.isHidden = kotlinBool.boolValue - } - } +fun Koin.getSimpleViewModel(): SimpleViewModel { + return get() } ``` -Важно, в методах биндинга должна быть только привязка `flow` к объекту `UI`, никакой логики быть не должно! -Вся логика должна быть во `ViewModel`, если нужно как-то преобразовать значение `flow`, делайте это там. +## Подключение ViewModel на Android -### MvvmActivity и MvvmFragment -В moko-mvvm реализованы абстрактные классы [MvvmFragment](https://github.com/icerockdev/moko-mvvm/blob/b4b2ed1a86451bd303aa0733ecd776be96c6f455/mvvm-viewbinding/src/main/kotlin/dev/icerock/moko/mvvm/viewbinding/MvvmEventsFragment.kt) и [MvvmActivity](https://github.com/icerockdev/moko-mvvm/blob/b6f2630df03bbd405e5659d85ea7df03f38e5dc7/mvvm-viewbinding/src/main/kotlin/dev/icerock/moko/mvvm/viewbinding/MvvmActivity.kt), наследуясь от которых вы: -- автоматически получите доступ к `binding` и `viewModel` +На Android используется Jetpack Compose. ViewModel получается через `koinViewModel` или `getViewModel` с сохранением в `ViewModelStoreOwner` (переживает смену конфигурации). -Пример фрагмента, наследника обычного Fragment: ```kotlin -@AndroidEntryPoint -class TestFragment : Fragment() { - private var _binding: TestFragmentBinding? = null - - private val binding - get() = _binding!! - - @Inject - lateinit var testFactory: TestFactory - - private lateinit var viewModel: TestViewModel - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - viewModel = getViewModel { - testFactory.createTestViewModel() +@Composable +fun CounterScreen( + viewModel: SimpleViewModel = koinViewModel() +) { + val counter: String by viewModel.counter.collectAsState() + + viewModel.actions.observeAsActions { action -> + when (action) { + SimpleViewModel.Actions.RouteToMain -> { /* навигация */ } } } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = TestFragmentBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.testLayout.bindFormField(viewLifecycleOwner, viewModel.testText) - } - - override fun onDestroyView() { - super.onDestroyView() - - _binding = null + Column { + Text(text = counter) + Button(onClick = { viewModel.onCounterButtonPressed() }) { + Text("Нажать") + } } } ``` -А теперь тот же самый фрагмент, но наследник MvvmFragment: -```kotlin -@AndroidEntryPoint -class TestFragment : MvvmFragment() { - @Inject - lateinit var testFactory: TestFactory - - override val viewModelClass: Class - get() = AuthViewModel::class.java - - override fun viewBindingInflate( - inflater: LayoutInflater, - container: ViewGroup? - ): TestFragmentBinding = TestFragmentBinding - .inflate(inflater, container, false) - - override fun viewModelFactory(): ViewModelProvider.Factory = ViewModelFactory { - testFactory.createTestViewModel() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.testLayout.bindFormField(viewLifecycleOwner, viewModel.testText) - } -} -``` - -### Передача событий из ViewModel на UI - -Для начала, освежите в памяти что такое [события/действия](../../learning/state#событие-действие), для чего они нужны и как реализуются на Android. -За всю логику в приложении, в том числе и за принятие решения, когда нужно перейти на другой экран отвечает `ViewModel`. Поэтому `ViewModel` должна как-то сообщать `Fragment`-у или `UIViewController`-y, что нужно выполнить какое-то действие (`Action`). - -Разберем несколько подходов для передачи событий от `ViewModel` на UI: -- используя `Flow` -- используя `Flow` вместе с [moko-kswift](https://github.com/icerockdev/moko-kswift) +`observeAsActions` из `moko-mvvm-flow-compose` автоматически учитывает жизненный цикл и гарантирует однократную обработку каждого события. -#### Flow +## Подключение ViewModel на iOS -В [статье](../../learning/state) про состояния и события вы уже ознакомились с передачей событий на Android используя Flow APIs. -Однако, теперь нам нужно отправлять такие действия из общего кода, который потом подключится к iOS и Android приложениям. +На iOS используется SwiftUI. Для управления временем жизни ViewModel применяется `@ViewModelWrapper` — он вызывает `viewModel.onCleared()` при удалении экрана. -**Первая проблема** заключается в том, что на iOS не удастся использовать Flow APIs, потому что `Flow` - это interface с generic типом, который после компиляции Kotlin/Native со стороны Swift generic тип исчезнет и будет просто protocol `Flow`. - -**Вторая проблема** - `sealed interface` нельзя использовать в `switch` на iOS также, как мы используем его в `when` на Kotlin. Чтобы использовать его в `switch` нужно чтобы он был `enum`-ом. +```swift +extension ViewModel: ObservableObject {} -Рассмотрим на примере: +struct CounterView: View { + @ViewModelWrapper private var viewModel: SimpleViewModel = + Koin.instance.getSimpleViewModel() -Допустим, у нас для переходов между экранами во `ViewModel` объявлен вот такой `sealed interface`: -```kotlin -sealed interface Action { - object RouteToMainScreen : Action - object RouteToAuthScreen : Action - object RouteToSettingsScreen : Action + var body: some View { + VStack { + Text(viewModel.state(\.counter)) + Button("Нажать") { viewModel.onCounterButtonPressed() } + } + } } ``` -При необходимости перейти на другой экран `ViewModel` помещает во `Flow` объекты `Action`. `Fragment` и `UIViewController` подписываются на этот `Flow`, и, когда он получает новый объект, определяют по нему на какой экран переходить. -Представим, что мы подписались на `Flow` в `Fragment`: каждый новый объект обрабатывается `when`-ом. Если все объекты из `sealed interface`-а обработаны в `when`, то на Android ветка `else` не потребуется. +Метод `state(\.counter)` — расширение `ViewModelState`, которое подписывается на `CStateFlow` и уведомляет SwiftUI об изменениях. -В iOS же, `sealed interface` не преобразуется в `enum`, из-за чего даже при переборе всех объектов в `switch`, нужно будет добавить ветку `else`. -Теперь, предположим, что нам понадобилось добавить еще один объект в `Action` для событий, которые кидает `ViewModel`. -В Kotlin-мире мы получим ошибку при компиляции, надо будет добавить в `when` обработку еще одного объекта - нового, который только что добавили во `ViewModel`. -А на iOS компилятор нам ничего не подскажет, потому что новый объект будет обрабатываться в ветке `else`. Из-за этого, логика перехода на iOS нарушится. Поиск ошибки может занять некоторое время, в зависимости от знаний разработчика. +Для событий (CFlow) используется `toPublisher()` из Combine: -Чтобы не сталкиваться с этим на практике мы используем другой подход - с помощью `Channel`. Разберемся, как он работает +```swift +viewModel.actions.toPublisher() + .sink { [weak self] action in + guard let action else { return } + // обработка действия + } + .store(in: &cancellables) +``` -#### Передача Action с помощью Channel +## Передача событий из ViewModel на UI -Во view model добавляем канал и события для передачи: +ViewModel принимает решения о навигации, показах сообщений и других действиях. Для передачи одноразовых событий используется паттерн **Channel + CFlow**. + +**ViewModel:** ```kotlin private val _actions: Channel = Channel() val actions: CFlow = _actions.receiveAsFlow().cFlow() -... + sealed interface Actions { - data class ShowMessage(val messageText: StringDesc) : Actions + data class ShowMessage(val message: StringDesc) : Actions data object RouteToBack : Actions } ``` -В Android подписываемся на события. В экране на Compose UI это выглядит так: + +**Android (Compose):** ```kotlin viewModel.actions.observeAsActions { action -> when (action) { - is Actions.ShowMessage -> { - ... - } - - Actions.RouteToBack -> { - ... - } + is Actions.ShowMessage -> { /* показать сообщение */ } + Actions.RouteToBack -> { /* навигация назад */ } } } ``` -В iOS подписка выглядит так: + +**iOS (SwiftUI):** ```swift -viewModel.actions.subscribe { [weak self] action in - guard let self = self, - let action = action else { return } - let actionKs = SimpleViewModelActionKs(action) - switch actionKs { - case .showMessage(let data): - ... - case .routeToBack: - ... +viewModel.actions.toPublisher() + .sink { [weak self] action in + guard let action else { return } + switch action { + case is Actions.ShowMessage: break + case is Actions.RouteToBack: break + default: break + } } -} + .store(in: &cancellables) ``` -#### Flow c moko-kswift -Мы уже рассмотрели, с какими проблемами мы столкнулись бы, если бы использовали `Flow` в общем коде. -Разберем теперь, как можно решить эти проблемы, начнем с отсутствия типов у `Flow` на iOS. +### Sealed interface на iOS + +На iOS sealed interface из Kotlin не преобразуется в enum Swift. При использовании `switch` требуется ветка `default`, что снижает типобезопасность — при добавлении нового типа Actions ошибки компиляции на iOS не возникнет. + +Плагин [SKIE](https://skie.touchlab.co/) решает часть этих проблем, генерируя Swift-friendly обёртки для Kotlin Flow и suspend-функций. Подробнее — в [статье про SKIE](../../learning/kotlin-multiplatform/mobile-highlights). -Мы будем использовать классы-обертки `CFlow` и `CStateFlow` из [moko-mvvm](https://github.com/icerockdev/moko-mvvm), а также функции, позволяющие преобразовать в них `Flow` и `StateFlow`. +## ResourceState — состояния экрана + +Типовой sealed class для описания состояния экрана: + +```kotlin +sealed class ResourceState { + class Loading : ResourceState() + data class Success(val data: T) : ResourceState() + class Empty : ResourceState() + data class Failed(val error: E) : ResourceState() +} +``` -`CFlow` и `CStateFlow` - это те же самые `Flow` и `StateFlow`, только в виде классов. Сделаны они были для того, чтобы использовать именно классы, потому что для классов в Swift generic типы доступны. -В common-коде мы будем использовать `CFlow` и `CStateFlow` только для public API, а в внутренней реализации общего кода нет нужды использовать классы вместо интерфейсов - можно будет использовать обычное `Flow` API. -Таким образом, мы решили первую проблему - отсутствие типов у `Flow` на iOS. +В ViewModel: -Теперь разберемся со второй проблемой - преобразованием `sealed interface` к `enum` в Swift. +```kotlin +val state: CStateFlow, StringDesc>> = ... -Используя плагин [moko-kswift](https://github.com/icerockdev/moko-kswift), мы можем получать автоматически генерируемые Swift `enum`, соответствующие `sealed-interface`-ам общего кода, а после работать с ними в `switch`. -Для более полного понимания проблемы и её решения, изучите [страницу](../../learning/libraries/moko/moko-kswift) плагина в базе знаний. +fun loadBooks() { + viewModelScope.launch { + _state.value = ResourceState.Loading + val result = repository.getBooks() + _state.value = ResourceState.Success(result) + } +} +``` -## Удобное public api общего кода +В Compose: -Благодаря переносу всей логики приложения в общий код мы получаем более удобное и простое API библиотеки для интеграции на платформы. Мы знаем что есть, например, ряд `ViewModel`-ей, в которых есть `StateFlow` на которые нужно подписаться и события, которые нужно обрабатывать. Все передаваемые на UI данные уже подготовлены к отображению и не требуют дополнительной обработки. +```kotlin +when (val s = state.collectAsState().value) { + is ResourceState.Loading -> LoadingState() + is ResourceState.Success -> BookList(s.data) + is ResourceState.Empty -> EmptyState() + is ResourceState.Failed -> ErrorState(s.error) +} +``` -Вот некоторый список преимуществ, которые мы получаем за счет использования `ViewModel`-ей в общем коде: +## Удобное public API общего кода -- Вся обработка ошибок (`Exception`) внутри Kotlin кода, на UI привязываются строки с текстом - нам не надо на Swift пытаться распознать что такое `KotlinException`; -- `suspend` функции все внутри Kotlin кода. +Вся логика обработки ошибок скрыта внутри Kotlin-кода — на UI приходят готовые строки (через `StringDesc`). Все `suspend`-функции инкапсулированы в ViewModel. Платформенный код работает только с `CStateFlow` и `CFlow`. ## Практическое задание + - Используйте проект, готовый после раздела [Внедрение зависимостей](./di#практическое-задание) -- Подключите библиотеку `moko-mvvm` -- Подключите плагин `moko-kswift` -- Добавьте в ваши фичи репозиторий, необходимые классы и вьюмодели, которые вы делали в третьем блоке для Android, все вьюмодели наследуйте от `ViewModel` из `moko-mvvm` - - Ориентируйтесь на классы из практики 3его блока и [диаграмму классов mpp-library](./practice#классы-приложения) - - Используйте `CFlow` и `CStateFlow` для для `state` public API -- Добавьте необходимые фрагменты в Android-приложение, фрагменты наследуйте от `MvvmFragment` из `moko-mvvm`, ориентируйтесь на практику 3 блока -- Добавьте необходимые `ViewController`-ы в iOS приложение, все `ViewController`-ы наследуйте от `MVVMController` из `moko-mvvm` -- Подключите вьюмодели к iOS и Android -- Настройте Android и iOS приложения - их логикой, кроме управления списков, должен управлять общий код +- Подключите библиотеку `moko-mvvm` (настройка описана в [базе знаний](../../learning/libraries/moko/moko-mvvm)) +- Добавьте в ваши фичи ViewModel, наследуя их от `ViewModel` из `moko-mvvm` + - Для стейта используйте `CStateFlow` / `CMutableStateFlow` + - Для одноразовых событий используйте `Channel` + `CFlow` +- Зарегистрируйте ViewModel в Koin через `factoryOf` +- На Android реализуйте экраны на Jetpack Compose, получайте ViewModel через `koinViewModel` +- На iOS реализуйте экраны на SwiftUI, используйте `@ViewModelWrapper` для интеграции +- Ориентируйтесь на классы из практики третьего блока и [диаграмму классов mpp-library](./practice#классы-приложения) - Приложения должны запускаться diff --git a/university/4-icerock-basics/practice.md b/university/4-icerock-basics/practice.md index 43a231c3f..12b957c56 100644 --- a/university/4-icerock-basics/practice.md +++ b/university/4-icerock-basics/practice.md @@ -44,6 +44,7 @@ sidebar_position: 15 21. Локализовать проект используя `sheets-localizations-generator` - обеспечьте поддержку русского и английского языков 22. Обеспечить поддержку iOS 13.0 +23. UI на Android реализовать на Jetpack Compose, на iOS — на SwiftUI ## Классы приложения @@ -85,24 +86,28 @@ class KeyValueStorage { ### mpp-library-feature-auth ```kotlin -class AuthViewModel { - val token: MutableStateFlow - val state: StateFlow - val actions: Flow +class AuthViewModel( + private val repository: AppRepository, +) : ViewModel() { + val token: CMutableStateFlow = MutableStateFlow("").cMutableStateFlow() + val state: CStateFlow // TODO: инициализация с начальным состоянием + + private val _actions: Channel = Channel() + val actions: CFlow = _actions.receiveAsFlow().cFlow() fun onSignButtonPressed() { // TODO: } sealed interface State { - object Idle : State - object Loading : State - data class InvalidInput(val reason: String) : State + data object Idle : State + data object Loading : State + data class InvalidInput(val reason: StringDesc) : State } - sealed interface Action { - data class ShowError(val message: String) : Action - object RouteToMain : Action + sealed interface Actions { + data class ShowError(val message: StringDesc) : Actions + data object RouteToMain : Actions } // TODO: @@ -111,12 +116,14 @@ class AuthViewModel { ### mpp-library-feature-repo ```kotlin -class RepositoryInfoViewModel { - val state: StateFlow +class RepositoryInfoViewModel( + private val repository: AppRepository, +) : ViewModel() { + val state: CStateFlow // TODO: инициализация sealed interface State { - object Loading : State - data class Error(val error: String) : State + data object Loading : State + data class Error(val error: StringDesc) : State data class Loaded( val githubRepo: Repo, @@ -125,23 +132,25 @@ class RepositoryInfoViewModel { } sealed interface ReadmeState { - object Loading : ReadmeState - object Empty : ReadmeState - data class Error(val error: String) : ReadmeState + data object Loading : ReadmeState + data object Empty : ReadmeState + data class Error(val error: StringDesc) : ReadmeState data class Loaded(val markdown: String) : ReadmeState } // TODO: } -class RepositoriesListViewModel { - val state: StateFlow +class RepositoriesListViewModel( + private val repository: AppRepository, +) : ViewModel() { + val state: CStateFlow // TODO: инициализация sealed interface State { - object Loading : State + data object Loading : State data class Loaded(val repos: List) : State - data class Error(val error: String) : State - object Empty : State + data class Error(val error: StringDesc) : State + data object Empty : State } // TODO: @@ -150,35 +159,71 @@ class RepositoriesListViewModel { ### android-app ```kotlin -class MainActivity: AppCompatActivity { - // TODO: +@AndroidEntryPoint +class MainActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AppNavHost() + } + } } -class AuthFragment: Fragment { - // TODO: +@Composable +fun AuthScreen( + viewModel: AuthViewModel = koinViewModel() +) { + // TODO: экран авторизации } -class RepositoriesListFragment: Fragment { - // TODO: +@Composable +fun RepositoriesListScreen( + viewModel: RepositoriesListViewModel = koinViewModel() +) { + // TODO: список репозиториев } -class DetailInfoFragment: Fragment { - // TODO: +@Composable +fun DetailInfoScreen( + viewModel: RepositoryInfoViewModel = koinViewModel() +) { + // TODO: детальная информация о репозитории } ``` ### ios-app ```swift -class RepositoriesListViewController: UIViewController { - // TODO: +@main +struct MobileApp: App { + var body: some Scene { + WindowGroup { + AuthView() + } + } } -class RepositoryDetailInfoViewController: UIViewController { - // TODO: +struct AuthView: View { + @ViewModelWrapper private var viewModel: AuthViewModel = Koin.instance.getAuthViewModel(params: ...) + + var body: some View { + // TODO: экран авторизации + } } -class AuthViewController: UIViewController { - // TODO: +struct RepositoriesListView: View { + @ViewModelWrapper private var viewModel: RepositoriesListViewModel = Koin.instance.getRepositoriesListViewModel() + + var body: some View { + // TODO: список репозиториев + } +} + +struct DetailInfoView: View { + @ViewModelWrapper private var viewModel: RepositoryInfoViewModel = Koin.instance.getRepositoryInfoViewModel(params: ...) + + var body: some View { + // TODO: детальная информация о репозитории + } } ``` @@ -199,27 +244,27 @@ class GitHubRepoRepository:::common class KeyValueStorage:::common class MainActivity:::android -class RepositoriesListFragment:::android -class DetailInfoFragment:::android -class AuthFragment:::android -class RepositoriesListViewController:::ios -class RepositoryDetailInfoViewController:::ios -class AuthViewController:::ios - -MainActivity --> AuthFragment -MainActivity --> RepositoriesListFragment -MainActivity --> DetailInfoFragment -RepositoriesListFragment --> RepositoriesListViewModel -DetailInfoFragment --> RepositoryInfoViewModel -AuthFragment --> AuthViewModel +class AuthScreen:::android +class RepositoriesListScreen:::android +class DetailInfoScreen:::android +class AuthView:::ios +class RepositoriesListView:::ios +class DetailInfoView:::ios + +MainActivity --> AuthScreen +MainActivity --> RepositoriesListScreen +MainActivity --> DetailInfoScreen +AuthScreen --> AuthViewModel +RepositoriesListScreen --> RepositoriesListViewModel +DetailInfoScreen --> RepositoryInfoViewModel RepositoriesListViewModel --> GitHubRepoRepository AuthViewModel --> GitHubRepoRepository RepositoryInfoViewModel --> GitHubRepoRepository -RepositoriesListViewController --> RepositoriesListViewModel -RepositoryDetailInfoViewController --> RepositoryInfoViewModel -AuthViewController --> AuthViewModel +AuthView --> AuthViewModel +RepositoriesListView --> RepositoriesListViewModel +DetailInfoView --> RepositoryInfoViewModel GitHubRepoRepository --> KeyValueStorage ``` From e4aa03860295cde707ae4bd7dae261fe410b0cc4 Mon Sep 17 00:00:00 2001 From: Kuzmin <«vkuzmin@icerockdev.com»> Date: Fri, 22 May 2026 15:07:58 +0300 Subject: [PATCH 2/5] remove kswift info --- learning/legacy/state.md | 9 ++- learning/libraries/moko/moko-kswift.md | 82 -------------------------- onboarding/moko.md | 2 +- 3 files changed, 9 insertions(+), 84 deletions(-) delete mode 100644 learning/libraries/moko/moko-kswift.md diff --git a/learning/legacy/state.md b/learning/legacy/state.md index daa7e8128..f9e6dcc64 100644 --- a/learning/legacy/state.md +++ b/learning/legacy/state.md @@ -222,9 +222,16 @@ viewModel.state.observe(viewLifecycleOwner) { state -> Теперь, для каждого элемента на основе значения стейта мы устанавливаем значение всего один раз, в одном единственном месте. Отлаживать и изменять такой код будет гораздо легче. ### Обработка на iOS + +:::caution + +Раздел описывает устаревший подход с **moko-kswift** и UIKit. В актуальных проектах используется **SKIE** для Swift-friendly API и SwiftUI для UI. Подробнее — в [статье про SKIE](../../learning/kotlin-multiplatform/mobile-highlights#skie--swift-friendly-api-из-коробки). + +::: + #### moko-kswift -За счет [moko-kswift](/learning/libraries/moko/moko-kswift) у нас есть возможность использовать `sealed interface` для `State` и `Actions` из общего кода в виде `enum` в Swift, чтобы можно было обрабатывать объекты в `switch` без ветки `default`. +За счет [moko-kswift](/learning/legacy/moko-kswift) у нас есть возможность использовать `sealed interface` для `State` и `Actions` из общего кода в виде `enum` в Swift, чтобы можно было обрабатывать объекты в `switch` без ветки `default`. Это очень полезно для обработки `Actions`, потому что при появлении нового `Action` в общем коде iOS приложение не скомпилируется из-за того, что не все объекты `enum` будут обработаны. diff --git a/learning/libraries/moko/moko-kswift.md b/learning/libraries/moko/moko-kswift.md deleted file mode 100644 index e0c07b43a..000000000 --- a/learning/libraries/moko/moko-kswift.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -sidebar_position: 10 ---- - -# moko-kswift - -## moko-kswift - -[moko-kswift](https://github.com/icerockdev/moko-kswift) - этот плагин, позволяет автоматически генерировать Swift-friendly API из общего кода: -- `enum`-ы, соответствующие `sealed-interface`-ам из общего кода, чтобы использовать их в `switch` без ветки `default` -- `extensions` к платформенным классам (`UILabel` и тд) и интерфейсам - -Детали подключения плагина вы можете узнать из его [README](https://github.com/icerockdev/moko-kswift#readme) и [статьи](https://medium.com/icerock/how-to-implement-swift-friendly-api-with-kotlin-multiplatform-mobile-e68521a63b6d). - -## Способы подключения -### Cocapods -При подключении плагина `moko-kswift` он добавит следующие таски в gradle. Имя таски генерируется по принципу: `kSwift` + `framework name` + `Podspec`, находиться они будут в группе `cocoapods`. -- `:mpp-library:kSwiftMultiplatformLibraryPodspec` - если подключен наш плагин [dev.icerock.mobile.multiplatform.ios-framework](https://github.com/icerockdev/mobile-multiplatform-gradle-plugin) -- `:mpp-library-pods:kSwiftmpp_library_podsPodspec` - если подключен плагин [cocoapods](https://kotlinlang.org/docs/native-cocoapods.html) от JetBrains - -Обе эти таски генерируют `.podspec` файл. Его имя будет: `framework_name` + `Swift.podspec`, например `mpp-library/MultiplatformLibrarySwift.podspec`. - -После этого нужно просто подключить его `pod MultiplatformLibrarySwift` в `iosApp/Podfile` и использовать `import MultiplatformLibrarySwift` в нужном файле. - -***Этот вариант более предпочтителен к использованию, потому что*** -- Меньше конфликтов с именами, сгенерированные классы и методы доступны только там, где мы его подключили -- Лучше воспроизводимость - `buildPhase` на сборку Kotlin-кода всегда происходит при компиляции пода. Реже будет происходить непонятная ошибка из-за не скомпилированного заранее Kotlin модуля. - -Однако, сгенерированные файлы можно подключить и вручную, генерируются они по пути `../framework_name/build/cocoapods/framework/framework_nameSwift/..` - -### Напрямую -Если фреймворк общего кода подключен к iOS-проекту напрямую, то сгенерированные файлы подключить при помощи `cocoapods` не получится, потому что `pod MultiplatformLibrarySwift` внутри себя имеет зависимость от основного фреймворка - `MultiplatformLibrary`. -Чтобы сгенерированные файлы всегда находились в одном месте, можно добавить таску в `shared_module/build.gradle`, которая переместит сгенерированные файлы в `build/generated/swift`. После этого нужно просто подключить их вручную к iOS проекту. -```kotlin -tasks.withType().matching { - it.binary is org.jetbrains.kotlin.gradle.plugin.mpp.Framework -}.configureEach { - doLast { - val swiftDirectory = File(destinationDir, "${binary.baseName}Swift") - val xcodeSwiftDirectory = File(buildDir, "generated/swift") - swiftDirectory.copyRecursively(xcodeSwiftDirectory, overwrite = true) - } -} -``` - -При самой первой сборке iOS проекта, без предварительной сборки Kotlin, Xcode будет ругаться, что у него нет сгенерированных файлов. Поэтому нужно вручную предварительно компилировать Kotlin Framework и только потом собирать iOS в Xcode. - -## mvvm-livedata, mvvm-flow и moko-kswift в одном проекте - -### Проблема -Начиная с [moko-mvvm-0.13.0](https://github.com/icerockdev/moko-mvvm/releases/tag/release%2F0.13.0) появилась поддержка декларативного UI - `Jetpack Compose` и `SwiftUI`. Она основана на `mvvm-flow`, без `mvvm-livedata`. - -Однако, на момент написания, часть библиотек еще использовала модуль `mvvm-livedata` -- `moko-paging:0.7.1` -- `moko-fields:0.9.0` -- ... - -Поэтому, пока все библиотеки не обновятся до поддержки и `mvvm-flow` и `mvvm-livedata`, нам иногда придется подключать оба этих модуля. В этом случае возникает проблема с генерацией `extensions` для `iOS`. - -В `mvvm-livedata` и в `mvvm-flow` есть экстеншены с одинаковыми именами, для биндинга UI элемента к `State`. -Компилятор `Kotlin/Native` видит конфликты имен, чтобы их избежать он создаст эти экстеншены с `_`. -Однако, плагин `moko-kswift` ничего не знает про новые измененные названия `extensions` c `_`, он ожидает экстеншены с такими же именами, какие были в `Kotlin`, поэтому сгенерированный код окажется некорректным (будет использовать не верные имена функций). - -### Решение -В `mpp-library/src/iosMain/...` создаем файл со всеми экстеншенами из `mvvm-flow` или `mvvm-livedata`, которые понадобятся нам на платформах, например: -```kotlin -import dev.icerock.moko.mvvm.livedata.bindTextTwoWay -import dev.icerock.moko.mvvm.flow.bindTextTwoWay -// ... - -fun UITextField.bindTextTwoWay(livedata: MutableLiveData) = bindTextTwoWay(livedata) - -fun UITextField.bindTextTwoWay(flow: CStateFlow) = bindTextTwoWay(flow) -``` -Оригинальные модули библиотек нужно будет добавить в [исключения](https://github.com/icerockdev/moko-kswift#how-to-exclude-generation-of-entries-from-some-libraries) для `moko-kswift`, чтобы генерация происходила только на основе файла из `iosMain`. -```kotlin -kswift { - excludeLibrary("mvvm-livedata") - excludeLibrary("mvvm-flow") -} -``` -На основе этого `moko-kswift` успешно сгенерирует файл, где будут все эти экстеншены, но с нормальными именами. diff --git a/onboarding/moko.md b/onboarding/moko.md index a412d3b37..8357e8cae 100644 --- a/onboarding/moko.md +++ b/onboarding/moko.md @@ -15,7 +15,7 @@ MOKO — это наши мультиплатформенные open-source би Изучите библиотеку [MOKO resources](https://kmm.icerock.dev/university/icerock-basics/resources-in-common#библиотека-moko-resources), [дополнение](../learning/libraries/moko/moko-resources). Также ознакомьтесь с: -- использованием классов-оберток [CFlow и CStateFlow](https://kmm.icerock.dev/university/icerock-basics/mvvm#flow-c-moko-kswift) из [MOKO MVVM](../learning/libraries/moko/moko-mvvm) +- использованием классов-оберток [CFlow и CStateFlow](https://kmm.icerock.dev/university/icerock-basics/mvvm) из [MOKO MVVM](../learning/libraries/moko/moko-mvvm) - использованием [mvvm-state](https://kmm.icerock.dev/university/lists/moko-paging#moko-mvvm-state) из MOKO MVVM - зачем нужна библиотека [МОКО Network](https://kmm.icerock.dev/learning/libraries/moko/moko-network) - видео [о MOKO paging](https://kmm.icerock.dev/learning/libraries/moko/moko-paging), конкретные примеры текущего применения стоит смотреть в текущих проектах. From 34781bd4d97567d3fda37d3b13c620d680712d15 Mon Sep 17 00:00:00 2001 From: Kuzmin <«vkuzmin@icerockdev.com»> Date: Fri, 22 May 2026 15:12:17 +0300 Subject: [PATCH 3/5] add kswift info --- learning/legacy/moko-kswift.md | 88 ++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 learning/legacy/moko-kswift.md diff --git a/learning/legacy/moko-kswift.md b/learning/legacy/moko-kswift.md new file mode 100644 index 000000000..c55b02209 --- /dev/null +++ b/learning/legacy/moko-kswift.md @@ -0,0 +1,88 @@ +--- +sidebar_position: 10 +--- + +# moko-kswift (legacy) + +:::caution + +Плагин **moko-kswift** больше не используется в новых проектах. Вместо него применяется **SKIE** — см. [статью про SKIE](../../learning/kotlin-multiplatform/mobile-highlights#skie--swift-friendly-api-из-коробки). + +::: + +## moko-kswift + +[moko-kswift](https://github.com/icerockdev/moko-kswift) - этот плагин, позволяет автоматически генерировать Swift-friendly API из общего кода: +- `enum`-ы, соответствующие `sealed-interface`-ам из общего кода, чтобы использовать их в `switch` без ветки `default` +- `extensions` к платформенным классам (`UILabel` и тд) и интерфейсам + +Детали подключения плагина вы можете узнать из его [README](https://github.com/icerockdev/moko-kswift#readme) и [статьи](https://medium.com/icerock/how-to-implement-swift-friendly-api-with-kotlin-multiplatform-mobile-e68521a63b6d). + +## Способы подключения +### Cocapods +При подключении плагина `moko-kswift` он добавит следующие таски в gradle. Имя таски генерируется по принципу: `kSwift` + `framework name` + `Podspec`, находиться они будут в группе `cocoapods`. +- `:mpp-library:kSwiftMultiplatformLibraryPodspec` - если подключен наш плагин [dev.icerock.mobile.multiplatform.ios-framework](https://github.com/icerockdev/mobile-multiplatform-gradle-plugin) +- `:mpp-library-pods:kSwiftmpp_library_podsPodspec` - если подключен плагин [cocoapods](https://kotlinlang.org/docs/native-cocoapods.html) от JetBrains + +Обе эти таски генерируют `.podspec` файл. Его имя будет: `framework_name` + `Swift.podspec`, например `mpp-library/MultiplatformLibrarySwift.podspec`. + +После этого нужно просто подключить его `pod MultiplatformLibrarySwift` в `iosApp/Podfile` и использовать `import MultiplatformLibrarySwift` в нужном файле. + +***Этот вариант более предпочтителен к использованию, потому что*** +- Меньше конфликтов с именами, сгенерированные классы и методы доступны только там, где мы его подключили +- Лучше воспроизводимость - `buildPhase` на сборку Kotlin-кода всегда происходит при компиляции пода. Реже будет происходить непонятная ошибка из-за не скомпилированного заранее Kotlin модуля. + +Однако, сгенерированные файлы можно подключить и вручную, генерируются они по пути `../framework_name/build/cocoapods/framework/framework_nameSwift/..` + +### Напрямую +Если фреймворк общего кода подключен к iOS-проекту напрямую, то сгенерированные файлы подключить при помощи `cocoapods` не получится, потому что `pod MultiplatformLibrarySwift` внутри себя имеет зависимость от основного фреймворка - `MultiplatformLibrary`. +Чтобы сгенерированные файлы всегда находились в одном месте, можно добавить таску в `shared_module/build.gradle`, которая переместит сгенерированные файлы в `build/generated/swift`. После этого нужно просто подключить их вручную к iOS проекту. +```kotlin +tasks.withType().matching { + it.binary is org.jetbrains.kotlin.gradle.plugin.mpp.Framework +}.configureEach { + doLast { + val swiftDirectory = File(destinationDir, "${binary.baseName}Swift") + val xcodeSwiftDirectory = File(buildDir, "generated/swift") + swiftDirectory.copyRecursively(xcodeSwiftDirectory, overwrite = true) + } +} +``` + +При самой первой сборке iOS проекта, без предварительной сборки Kotlin, Xcode будет ругаться, что у него нет сгенерированных файлов. Поэтому нужно вручную предварительно компилировать Kotlin Framework и только потом собирать iOS в Xcode. + +## mvvm-livedata, mvvm-flow и moko-kswift в одном проекте + +### Проблема +Начиная с [moko-mvvm-0.13.0](https://github.com/icerockdev/moko-mvvm/releases/tag/release%2F0.13.0) появилась поддержка декларативного UI - `Jetpack Compose` и `SwiftUI`. Она основана на `mvvm-flow`, без `mvvm-livedata`. + +Однако, на момент написания, часть библиотек еще использовала модуль `mvvm-livedata` +- `moko-paging:0.7.1` +- `moko-fields:0.9.0` +- ... + +Поэтому, пока все библиотеки не обновятся до поддержки и `mvvm-flow` и `mvvm-livedata`, нам иногда придется подключать оба этих модуля. В этом случае возникает проблема с генерацией `extensions` для `iOS`. + +В `mvvm-livedata` и в `mvvm-flow` есть экстеншены с одинаковыми именами, для биндинга UI элемента к `State`. +Компилятор `Kotlin/Native` видит конфликты имен, чтобы их избежать он создаст эти экстеншены с `_`. +Однако, плагин `moko-kswift` ничего не знает про новые измененные названия `extensions` c `_`, он ожидает экстеншены с такими же именами, какие были в `Kotlin`, поэтому сгенерированный код окажется некорректным (будет использовать не верные имена функций). + +### Решение +В `mpp-library/src/iosMain/...` создаем файл со всеми экстеншенами из `mvvm-flow` или `mvvm-livedata`, которые понадобятся нам на платформах, например: +```kotlin +import dev.icerock.moko.mvvm.livedata.bindTextTwoWay +import dev.icerock.moko.mvvm.flow.bindTextTwoWay +// ... + +fun UITextField.bindTextTwoWay(livedata: MutableLiveData) = bindTextTwoWay(livedata) + +fun UITextField.bindTextTwoWay(flow: CStateFlow) = bindTextTwoWay(flow) +``` +Оригинальные модули библиотек нужно будет добавить в [исключения](https://github.com/icerockdev/moko-kswift#how-to-exclude-generation-of-entries-from-some-libraries) для `moko-kswift`, чтобы генерация происходила только на основе файла из `iosMain`. +```kotlin +kswift { + excludeLibrary("mvvm-livedata") + excludeLibrary("mvvm-flow") +} +``` +На основе этого `moko-kswift` успешно сгенерирует файл, где будут все эти экстеншены, но с нормальными именами. From 5f178f4d7a53cdaac49396f42e64a46128e17b7b Mon Sep 17 00:00:00 2001 From: Kuzmin <«vkuzmin@icerockdev.com»> Date: Fri, 22 May 2026 15:35:58 +0300 Subject: [PATCH 4/5] update resources-in-common --- .../4-icerock-basics/resources-in-common.md | 149 ++++++++++++------ 1 file changed, 105 insertions(+), 44 deletions(-) diff --git a/university/4-icerock-basics/resources-in-common.md b/university/4-icerock-basics/resources-in-common.md index b209b1e4c..cd30da8ae 100644 --- a/university/4-icerock-basics/resources-in-common.md +++ b/university/4-icerock-basics/resources-in-common.md @@ -4,7 +4,8 @@ sidebar_position: 7 # Ресурсы в общем коде ## Введение -Разберем задачу: допустим, мы получаем от сервера `enum` - тип транспорта, и хотим отобразить локализированную строку с его названием. + +Разберем задачу: допустим, мы получаем от сервера `enum` — тип транспорта, и хотим отобразить локализованную строку с его названием. ```kotlin enum class VehicleType { @@ -18,85 +19,145 @@ class MainViewModel : ViewModel() { get() = ... } ``` -Далее, на платформах нам нужно сделать маппинг - определить какие строки из ресурсов соответствуют каждому типу транспорта, чтобы использовать локализованные строки на `UI`. -`Android`: +Без инструментов общего кода маппинг на каждую платформу пришлось бы писать отдельно: + +**Android:** ```kotlin val vehicleTitle = when (viewModel.vehicleType) { VehicleType.BOAT -> R.string.boatTitle VehicleType.CAR -> R.string.carTitle VehicleType.PLANE -> R.string.planeTitle - } +} ``` -`iOS`: +**iOS:** ```swift let vehicleTitle: String switch(viewModel.vehicleType) { case VehicleType.boat: - vehicleTitle = NSLocalizedString("boatTitle", comment: "") - break + vehicleTitle = NSLocalizedString("boatTitle", comment: "") case VehicleType.car: - vehicleTitle = NSLocalizedString("carTitle", comment: "") - break + vehicleTitle = NSLocalizedString("carTitle", comment: "") case VehicleType.plane: - vehicleTitle = NSLocalizedString("planeTitle", comment: "") - break + vehicleTitle = NSLocalizedString("planeTitle", comment: "") } ``` -Писать такой маппинг на обеих платформах для всех строк - рутина, еще и обновлять строки нужно будет на обеих платформах, что может приводить к багам из-за невнимательности. + +Такой дублированный маппинг на обеих платформах — рутина, а синхронизировать изменения легко забыть, что приводит к багам. ## Библиотека moko-resources -### Описание -Эта библиотека позволяет хранить и использовать общие для платформ ресурсы (строки локализации, плюралы, шрифты, изображения, цвета) в `common` коде. +Библиотека [moko-resources](https://github.com/icerockdev/moko-resources) позволяет хранить и использовать общие ресурсы (строки локализации, плюралы, шрифты, изображения, цвета) в `common` коде. + +Базовый тип для работы со строками — **`StringDesc`**. Это контейнер, который хранит ссылку на ресурс, но не преобразует его в строку до момента, когда доступен platform context. Преобразование происходит на платформе — через `toString(context)` на Android или `.localized()` на iOS. + +Благодаря `StringDesc` маппинг из примера выше можно сделать прямо во ViewModel: -Используя `moko-resources` мы можем сделать маппинг из примера выше в самой вьюмодели, а на платформу будет предоставляться объекты `StringDesc`, из которого каждая платформа сможет получить необходимый ей ресурс: ```kotlin class MainViewModel : ViewModel() { - private val vehicleType: VehicleType get() = ... - val vehicleTypeString: StringDesc - get() = when (vehicleType) { - VehicleType.BOAT -> MR.strings.boatTitle.desc() - VehicleType.CAR -> MR.strings.carTitle.desc() - VehicleType.PLANE -> MR.strings.planeTitle.desc() - } + private val vehicleType: VehicleType get() = ... + val vehicleTypeString: StringDesc + get() = when (vehicleType) { + VehicleType.BOAT -> MR.strings.boatTitle.desc() + VehicleType.CAR -> MR.strings.carTitle.desc() + VehicleType.PLANE -> MR.strings.planeTitle.desc() + } } ``` +На платформу приходит уже готовая ссылка на ресурс — остаётся только получить строку. + :::info Важно! -В общем коде должны находиться только те ресурсы, которые им и управляются, как из примера выше. -Все остальные ресурсы должны находиться на платформе, не нужно с платформы обращаться к ресурсам из `MR`. + +В общем коде должны находиться только те ресурсы, которые им и управляются, как в примере выше. +Все остальные ресурсы должны оставаться на платформе. Не нужно с платформы обращаться к ресурсам из `MR`. + ::: -Ознакомьтесь детальнее с ней по материалам на странице [moko-resources](../../learning/libraries/moko/moko-resources). +Подробное описание подключения и API — на странице [moko-resources](../../learning/libraries/moko/moko-resources) в базе знаний. + +## Получение строки на платформе из StringDesc + +**Android (Compose):** +```kotlin +import dev.icerock.moko.resources.compose.localized + +Text(text = viewModel.vehicleTypeString.localized()) +``` + +**iOS (SwiftUI):** +```swift +Text(viewModel.vehicleTypeString.localized()) +``` -### Подключение и настройка: +:::info -Выполните следующие шаги, ориентируясь на [README](https://github.com/icerockdev/moko-resources#installation) библиотеки: -- Настройте `root build.gradle`: - - подключите `resources-generator` плагин -- Настройте `build.gradle` для каждого модуля: - - подключите плагин `"dev.icerock.mobile.multiplatform-resources"` - - подключите зависимость `"dev.icerock.moko:resources:$MOKO_RESOURCES_VERSION"` - - добавьте и настройте блок `multiplatformResources` -- Настройка `iOS`: - - добавьте и настройте `Localizations` в `infoPlist` - - добавьте [Build Phase](https://github.com/icerockdev/moko-resources#static-kotlin-frameworks-support), не забудьте изменить `yourframeworkproject` -- [пример](https://github.com/icerockdev/moko-resources#usage) добавления строк +На iOS `.localized()` — это extension из `moko-resources`, доступный для `StringDesc`. В связке с `moko-mvvm` можно автоматически преобразовывать `CStateFlow` в `String` через расширение `state()`, и `.localized()` будет вызываться в маппере автоматически. -## Использование Google Sheets для генерации строк -Для строк локализации мы используем интеграцию с [Google Sheets](https://www.google.com/intl/ru_ru/sheets/about/). -Строки локализации описываются в таблицах и на их основе генерируются в проект. Делается это при помощи плагина [sheets-localizations-generator](https://gitlab.icerockdev.com/scl/sheets-localizations-generator). +::: + +## Compose Multiplatform + +Если подключен модуль `resources-compose`, ресурсы доступны напрямую в `commonMain` без `StringDesc`: + +```kotlin +// Строки +Text(text = stringResource(MR.strings.hello_world)) -В проектах, созданных на основе [шаблона](https://gitlab.icerockdev.com/scl/boilerplate/mobile-moko-boilerplate) есть файл [master.sh](https://gitlab.icerockdev.com/scl/boilerplate/mobile-moko-boilerplate/-/blob/master/master.sh), в нем находится скрипт `localize`, который и генерирует строки на основе таблицы. -Для использования скрипта, в нем необходимо заменить `GSHEET_ID_HERE` на `ID` реальной таблицы. После добавления строк в таблицу, выполните скрипт `localize` в терминале: `./master.sh localize` и строки добавятся в проект. +// Плюралы +Text(text = pluralStringResource(MR.plurals.chars_count, counter, counter)) + +// Цвета +Text(color = colorResource(MR.colors.textColor), text = "Привет") + +// Изображения +Image(painter = painterResource(MR.images.moko_logo), contentDescription = null) + +// Шрифты +Text(fontFamily = fontFamilyResource(MR.fonts.cormorant_italic), text = "Привет") + +// Файлы +val content: String? by MR.files.some_file_txt.readTextAsState() +``` + +## Изображения + +`ImageResource` из `moko-resources` можно использовать в общем коде (например, для иконок ошибок): + +```kotlin +data class ErrorBundle( + val title: StringDesc, + val message: StringDesc, + val icon: ImageResource, +) +``` + +На платформе изображение преобразуется в зависимости от UI-фреймворка: + +**Android (Compose):** +```kotlin +Icon(painter = painterResource(error.icon), contentDescription = null) +``` + +**iOS (SwiftUI):** +```swift +Image(error.icon.assetImageName, bundle: error.icon.bundle) +``` + +## Google Sheets для генерации строк + +Для строк локализации мы используем интеграцию с [Google Sheets](https://www.google.com/intl/ru_ru/sheets/about/). Строки описываются в таблицах и на их основе генерируются в проект через плагин [sheets-localizations-generator](https://gitlab.icerockdev.com/scl/sheets-localizations-generator). + +В проектах, созданных на основе [шаблона](https://gitlab.icerockdev.com/scl/boilerplate/mobile-moko-boilerplate), есть файл [master.sh](https://gitlab.icerockdev.com/scl/boilerplate/mobile-moko-boilerplate/-/blob/master/master.sh), в нём находится скрипт `localize`, который генерирует строки на основе таблицы. +Для использования замените `GSHEET_ID_HERE` на `ID` реальной таблицы и выполните: `./master.sh localize`. ## Практическое задание + - Используйте проект, готовый после раздела [MVVM](./mvvm#практическое-задание) -- Подключите `moko-resources` +- Подключите `moko-resources` (настройка описана в [базе знаний](../../learning/libraries/moko/moko-resources)) - `MR` подключайте к `mpp-library` -- Вынесите в `MR` только те ресурсы, управление которыми происходит из общего кода +- Вынесите в `MR` только те ресурсы, управление которыми происходит из общего кода - Используйте `sheets-localizations-generator` для доступа к строкам локализации из `Google Sheets` - Настройте проброс ресурсов из `mpp-library` во вьюмодели фичей - Обеспечьте поддержку русского и английского языков From ff80aa9ade9863fc7c24b51b9445a3e8e364bab4 Mon Sep 17 00:00:00 2001 From: Kuzmin <«vkuzmin@icerockdev.com»> Date: Fri, 22 May 2026 16:12:37 +0300 Subject: [PATCH 5/5] update filling-fields --- university/5-filling-fields/intro.md | 2 +- university/5-filling-fields/moko-fields.md | 2 +- university/5-filling-fields/moko-network.md | 4 ++-- university/5-filling-fields/practice.md | 5 +++-- university/5-filling-fields/problems.md | 10 +++++----- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/university/5-filling-fields/intro.md b/university/5-filling-fields/intro.md index df126ebaa..873aae61f 100644 --- a/university/5-filling-fields/intro.md +++ b/university/5-filling-fields/intro.md @@ -7,7 +7,7 @@ sidebar_position: 0 Поздравляем! Вы прошли первые 4 блока, теперь вы не только знаете, как применять KMP на практике, но также знакомы с основными подходами разработки, используемыми в IceRock. Начиная с этого блока вы будете изучать решение типовых задач, с которыми обязательно предстоит столкнуться на проектах. Также, вы будете добавлять новый и обновлять старый функционал в своем GitHub-приложении. -В этом блоке вы разберетесь с реализацией форм ввода - элемента, без которого не обходится практически ни одно приложение, а также +В этом блоке вы разберетесь с реализацией форм ввода — элемента, без которого не обходится практически ни одно приложение, а также узнаете: - Что стоит учитывать при реализации форм ввода - Использование библиотеки moko-fields, для упрощения реализации форм ввода - Использование библиотеки moko-errors, для отображения пользователю информации об ошибке diff --git a/university/5-filling-fields/moko-fields.md b/university/5-filling-fields/moko-fields.md index ee4b1a4ec..fdf95ac7d 100644 --- a/university/5-filling-fields/moko-fields.md +++ b/university/5-filling-fields/moko-fields.md @@ -3,7 +3,7 @@ sidebar_position: 2 --- # moko-fields -Познакомьтесь с библиотекой изучив ее страницу в [базе знаний](../../learning/libraries/moko/moko-fields). +Познакомьтесь с библиотекой, изучив ее страницу в [базе знаний](../../learning/libraries/moko/moko-fields). ## Практическое задание Обновите ваше GitHub-приложение, на экране авторизации переделайте поле ввода токена, используя moko-fields, поведение приложения должно остаться прежним. diff --git a/university/5-filling-fields/moko-network.md b/university/5-filling-fields/moko-network.md index fba5efa52..6f8cf0192 100644 --- a/university/5-filling-fields/moko-network.md +++ b/university/5-filling-fields/moko-network.md @@ -11,11 +11,11 @@ sidebar_position: 4 ## ExceptionFactory -Для создания объекта [ExceptionFactory](https://github.com/icerockdev/moko-network/blob/26fd7bbf10da6b09f1a543f316155c6c3880023e/network/src/commonMain/kotlin/dev/icerock/moko/network/exceptionfactory/HttpExceptionFactory.kt) нам необходимо указать два параметра, а именно `defaultParser` и `customParsers`. +Для создания объекта [ExceptionFactory](https://github.com/icerockdev/moko-network/blob/master/network/src/commonMain/kotlin/dev/icerock/moko/network/exceptionfactory/HttpExceptionFactory.kt) нам необходимо указать два параметра, а именно `defaultParser` и `customParsers`. - `defaultParser` - парсер для всех ошибок от сервера, за исключением тех, обработку которых мы захотим сделать самостоятельно. - `customParsers` - набор парсеров, привязанных к конкретному коду ошибки. -Например, для ошибки валидации и конкретного JSON объекта от сервера в библиотеке уже реализован [ValidationExceptionParser](https://github.com/icerockdev/moko-network/blob/0f8459ff2d51c6b7cade0cadd6d11066b7a55d60/network/src/commonMain/kotlin/dev/icerock/moko/network/exceptionfactory/parser/ValidationExceptionParser.kt). +Например, для ошибки валидации и конкретного JSON объекта от сервера в библиотеке уже реализован [ValidationExceptionParser](https://github.com/icerockdev/moko-network/blob/master/network/src/commonMain/kotlin/dev/icerock/moko/network/exceptionfactory/parser/ValidationExceptionParser.kt). Из его кода видно, что парсер подходит для JSON объектов, выглядящих следующим образом: ```json diff --git a/university/5-filling-fields/practice.md b/university/5-filling-fields/practice.md index f60047a07..112047133 100644 --- a/university/5-filling-fields/practice.md +++ b/university/5-filling-fields/practice.md @@ -3,7 +3,7 @@ sidebar_position: 5 --- # Практическое задание -Необходимо добавить новый функционал в ваше приложение, готовое после 4ого блока. +Необходимо добавить новый функционал в ваше приложение, готовое после 4-го блока. Предлагаем сделать возможность создавать issue к репозиторию, с экрана детальной информации. Во время работы над практическим заданием настоятельно рекомендуем обращаться к разделу [Памятки для разработчика](../../university/memos/best-practices) @@ -21,7 +21,8 @@ sidebar_position: 5 1. Использовать `moko-fields` для реализации всех форм ввода 1. Использовать `moko-errors` для отображения пользователю информации об ошибке 1. Использовать `moko-network` для обработки ошибок валидации от сервера -1. Реализовать кастомный парсер ошибок валидации, для обработки ответа +1. Реализовать кастомный парсер ошибок валидации, для обработки ответа +1. UI на Android реализовать на Jetpack Compose, на iOS — на SwiftUI ## Материалы 1. [GitHub Issues API](https://docs.github.com/en/rest/issues/issues#about-the-issues-api) diff --git a/university/5-filling-fields/problems.md b/university/5-filling-fields/problems.md index 0d666fa2e..0b3ffbd11 100644 --- a/university/5-filling-fields/problems.md +++ b/university/5-filling-fields/problems.md @@ -11,10 +11,10 @@ sidebar_position: 1 Ниже описаны тезисы из видео: - Данные, введенные пользователем, должны валидироваться - Поле не должно валидироваться, пока юзер не закончил его ввод, чтобы не пугать преждевременными ошибками - - Если поле одно - первая валидация должна произойти после ввода данных в поле и нажатия кнопки-триггера, после этого валидация происходит в момент заоплнения поля + - Если поле одно - первая валидация должна произойти после ввода данных в поле и нажатия кнопки-триггера, после этого валидация происходит в момент заполнения поля - Если полей несколько - валидация происходит при переходе с одного поля на другое (когда невалидное поле осталось одно - в момент его заполнения наверное?) -- В клавиатуре должны быть настроены вспомогательные клавиши - переход фокуса на следующее поле, отправка данных на последнем поле (вызов дейтсвия по триггер-кнопке) -- Должны быть правильно настроены клавиатуры и подсказки (предложение автозаполнения типа? пароль/email/телефон) для разных типов вводимых данных +- В клавиатуре должны быть настроены вспомогательные клавиши - переход фокуса на следующее поле, отправка данных на последнем поле (вызов действия по триггер-кнопке) +- Должны быть правильно настроены клавиатуры и подсказки (предложение автозаполнения: пароль/email/телефон) для разных типов вводимых данных - email - текст(латиница), цифры, символы - клава только с латиницей, без других языков - текст - просто обычная клава - номер телефона: цифирная клава без символов @@ -24,7 +24,7 @@ sidebar_position: 1 - надо брать текущее время, и сравнивать его миллисекундами с временем запуска таймера - тогда не поломается - Если на форме есть связанные поля - например пароль и подтверждение пароля, то надо чтобы их валидация зависела друг от друга и чтобы она срабатывала при изменении любого из полей. - Пример: ввели пароль, потом ввели повторение пароля - видим, что пароли не совпадают. Поняли, что первый раз забыли одну букву - вернулись и добавили пропущенную букву. После этого валидация должна пройти успешно -- Если сервер сообщает об ошибке в данных, полученных из поля ввода - нужно выводить эту ошибку у этого поля. Локальная проверка полей ввода также должна отображать ошибки валидации у конкретного поля +- Если сервер сообщает об ошибке в данных, полученных из поля ввода - нужно отображать эту ошибку рядом с этим полем. Локальная проверка полей ввода также должна отображать ошибки валидации у конкретного поля - Примеры ошибок валидации, о которых может сообщить сервер: - email уже используется - email не найден @@ -37,7 +37,7 @@ sidebar_position: 1 - Если форма длинная, мы все заполнили и проскроллили вниз до триггер-кнопки, нажали кнопку и нам вернулась ошибка в поле, которое не видно - надо скроллить к первому полю с ошибкой - Должна быть корректно проставлена информация о контенте поля для автозаполнения (email, телефон, пароль, файл) - Обязательные поля должны быть как-то выделены, например звездочкой, юзер не должен гадать какие поля нужно заполнять -- Те данные, которые приложение может заполнить автоматически, должны заполняться автоматически () +- Те данные, которые приложение может заполнить автоматически, должны заполняться автоматически - Многострочные поля ввода должны растягиваться при появлении множества строк, но до ограниченной высоты - дальше должен включаться скролл для просмотра контента - Пробелы в начале и конце введенного текста должны обрезаться - При выборе даты должны быть проставлены соответствующие границы возможного выбора (нельзя выбрать в прошлом или будущем)