MVVM
Выбор подхода
Когда мы начинали внедрять Kotlin Multiplatform в разработку проектов мы стремились максимально избавиться от дублирования между платформами, но не вредя конечному UI и UX (оставляя его полностью нативным и привычным пользователям). Проведя некоторое исследование решили, что паттерн Model-View-ViewModel, который мы уже активно применяли на Android, наиболее хорошо подойдет для переиспользования между платформами.
На тот момент декларативного UI в виде SwiftUI и Jetpack Compose еще не было, поэтому рассматривалось удобство и надежность интеграции с обычными View
.
В результате, мы имеем в общей Kotlin Multiplatform библиотеке:
- Для каждого экрана ViewModel с логикой работы
- Работа с сетью
- Работа с базой данных
- Процессинг данных, преобразования, расчеты
И остается на стороне платформы - верстка UI, привязка к общим ViewModel и навигация.
info
С самим подходом MVVM вы уже знакомились в разделе Android. Для освежения памяти особо полезно будет перечитать статью Единый стейт экрана
moko-mvvm
Для использования MVVM мы реализовали библиотеку moko-mvvm. Главное, что мы стремились достичь при ее реализации, это использование оригинальных классов JetPack ViewModel
и LiveData
со стороны Android, чтобы продолжить использовать существующие в Android интеграции с данными классами (включая логику хранения ViewModel
в ViewModelStore
чтобы переживать смену конфигурации). Для iOS стороны (и других платформ тоже) классы ViewModel
и LiveData
были реализованы нами, в более простом виде чем в Android (так как только в Android есть сложный жизненный цикл компонентов с пересозданием). По сути классы ViewModel
и LiveData
являются expect классами с разными actual реализациями на платформах.
Для знакомства с библиотекой посмотрите материалы на странице в базе знаний - moko-mvvm.
Привязка LiveData к UI
В библиотеке также содержатся готовые методы для привязки LiveData
к UI элементам, по аналогии с методами, которые были использованы нами в статье про State. Данные методы доступны и для Android и для iOS, а поэтому в большинстве случаев вам не потребуется писать вручную привязку каждого типа данных к каждому UI элементу.
Привязкой UI к LiveData
называется binding, и основано на использовании метода bind
:
Для Android нам доступны например:
fun EditText.bindTextTwoWay(
lifecycleOwner: LifecycleOwner,
liveData: MutableLiveData<String>
): Closeable
fun TextView.bindText(
lifecycleOwner: LifecycleOwner,
liveData: LiveData<String>
): Closeable
fun View.bindVisibleOrGone(
lifecycleOwner: LifecycleOwner,
liveData: LiveData<Boolean>
): Closeable
И для iOS соответственно:
extension UITextField {
@discardableResult
func bindTextTwoWay(liveData: MutableLiveData<NSString>) -> Closeable
}
extension UILabel {
@discardableResult
func bindText<T : NSString>(liveData: LiveData<T>) -> Closeable
}
extension UIView {
@discardableResult
func bindHidden(liveData: LiveData<KotlinBoolean>) -> Closeable
}
Пример
shared code:
class SimpleViewModel : ViewModel() {
private val _counter: MutableLiveData<Int> = MutableLiveData(0)
val counter: LiveData<String> = _counter.map { it.toString() }
fun onCounterButtonPressed() {
_counter.value += 1
}
}
android app:
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() }
}
}
ios app:
class SimpleViewController: UIViewController {
@IBOutlet private var counterLabel: UILabel!
private var viewModel: SimpleViewModel!
override func viewDidLoad() {
super.viewDidLoad()
viewModel = SimpleViewModel()
counterLabel.bindText(liveData: viewModel.counter)
}
@IBAction func onCounterButtonPressed() {
viewModel.onCounterButtonPressed()
}
}
Добавление своих расширений
Если в moko-mvvm
не оказалось нужной вам функции биндинга для iOS
или Android
, вы можете добавить свой extension
к LiveData
.
Например, добавим функцию bindToMenuItemVisible
для связи LiveData<Boolean>
и MenuItem
на Android
:
internal fun LiveData<Boolean>.bindToMenuItemVisible(
lifecycleOwner: LifecycleOwner,
menuItem: MenuItem
): Closeable {
return bindNotNull(lifecycleOwner) { value ->
menuItem.isVisible = value
}
}
Для iOS
добавим функцию bindToUIToolbarVisible
для связи UIToolbar
c LiveData<KotlinBoolean>
(на iOS
из общего кода вместо Boolean
приходит KotlinBoolean
) вот как это будет выглядеть:
extension UIToolbar {
func bindToUIToolbarVisible(liveData: LiveData<KotlinBoolean>) -> Closeable {
return liveData.addCloseableObserver { [weak self] value in
let kotlinBool = value as! KotlinBoolean
self?.isHidden = kotlinBool.boolValue
}
}
}
Важно, в методах биндинга должна быть только привязка liveData
к объекту UI
, никакой логики быть не должно!
Вся логика должна быть во ViewModel
, если нужно как-то преобразовать значение liveData
, делайте это там.
MvvmActivity и MvvmFragment
В moko-mvvm реализованы абстрактные классы MvvmFragment и MvvmActivity, наследуясь от которых вы:
- автоматически получите доступ к
binding
иviewModel
Пример фрагмента, наследника обычного Fragment:
@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()
}
}
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
}
}
А теперь тот же самый фрагмент, но наследник MvvmFragment:
@AndroidEntryPoint
class TestFragment : MvvmFragment<TestFragmentBinding, AuthViewModel>() {
@Inject
lateinit var testFactory: TestFactory
override val viewModelClass: Class<AuthViewModel>
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
Для начала, освежите в памяти что такое события/действия, для чего они нужны и как реализуются на Android.
За всю логику в приложении, в том числе и за принятие решения, когда нужно перейти на другой экран отвечает ViewModel
. Поэтому ViewModel
должна как-то сообщать Fragment
-у или UIViewController
-y, что нужно выполнить какое-то действие (Action
).
Разберем несколько подходов для передачи событий от ViewModel
на UI:
- используя
Flow
- используя
EventsDispatcher
из moko-mvvm - используя
Flow
вместе с moko-kswift
Flow
В статье про состояния и события вы уже ознакомились с передачей событий на Android используя Flow APIs.
Однако, теперь нам нужно отправлять такие действия из общего кода, который потом подключится к iOS и Android приложениям.
Первая проблема заключается в том, что на iOS не удастся использовать Flow APIs, потому что Flow
- это interface с generic типом, который после компиляции Kotlin/Native со стороны Swift generic тип исчезнет и будет просто protocol Flow
.
Вторая проблема - sealed interface
нельзя использовать в switch
на iOS также, как мы используем его в when
на Kotlin. Чтобы использовать его в switch
нужно чтобы он был enum
-ом.
Рассмотрим на примере:
Допустим, у нас для переходов между экранами во ViewModel
объявлен вот такой sealed interface
:
sealed interface Action {
object RouteToMainScreen : Action
object RouteToAuthScreen : Action
object RouteToSettingsScreen : Action
}
При необходимости перейти на другой экран ViewModel
помещает во Flow
объекты Action
. Fragment
и UIViewController
подписываются на этот Flow
, и, когда он получает новый объект, определяют по нему на какой экран переходить.
Представим, что мы подписались на Flow
в Fragment
: каждый новый объект обрабатывается when
-ом. Если все объекты из sealed interface
-а обработаны в when
, то на Android ветка else
не потребуется.
В iOS же, sealed interface
не преобразуется в enum
, из-за чего даже при переборе всех объектов в switch
, нужно будет добавить ветку else
.
Теперь, предположим, что нам понадобилось добавить еще один объект в Action
для событий, которые кидает ViewModel
.
В Kotlin-мире мы получим ошибку при компиляции, надо будет добавить в when
обработку еще одного объекта - нового, который только что добавили во ViewModel
.
А на iOS компилятор нам ничего не подскажет, потому что новый объект будет обрабатываться в ветке else
. Из-за этого, логика перехода на iOS нарушится. Поиск ошибки может занять некоторое время, в зависимости от знаний разработчика.
Чтобы не сталкиваться с этим на практике мы долгое время использовали другой подход - с помощью EventsDispatcher
из moko-mvvm. Разберемся, как он работает.
EventsDispatcher
С этим подходом нужно разобраться, потому что на многих наших проектах сейчас используется именно он.
EventDispatcher
- это класс с одной единственной задачей - гарантировать доставку события и вызов его обработчика на UI, после сигнала от ViewModel
.
Во ViewModel
объявляется интерфейс с методами, реализация которых ей нужна на платформе, например, метод для перехода на какой-нибудь экран:
interface EventsListener {
fun routeToMainPage()
}
Далее, все что остается сделать, чтобы вызвать событие на UI
- это получить во ViewModel
объект eventsDispatcher
и, когда пора переходить на главный экран, послать платформе это событие простым вызовом метода:
class EventsViewModel(
val eventsDispatcher: EventsDispatcher<EventsListener>
) : ViewModel() {
fun onButtonPressed() {
eventsDispatcher.dispatchEvent { routeToMainPage() }
}
interface EventsListener {
fun routeToMainPage()
}
}
На платформах Fragment
и UIViewController
реализуют этот интерфейс.
Пример реализации на Android:
class EventsFragment: Fragment(R.layout.fragment_simple), EventsViewModel.EventsListener {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val viewModel: EventsViewModel = getViewModel {
EventsViewModel(eventsDispatcherOnMain())
}
viewModel.eventsDispatcher.bind(lifecycleOwner = this, listener = this)
}
override fun routeToMainPage() {
TODO("some routing")
}
}
Пример на iOS:
class EventsViewController: UIViewController {
private var viewModel: EventsViewModel!
override func viewDidLoad() {
super.viewDidLoad()
viewModel = EventsViewModel(
eventsDispatcher: EventsDispatcher(listener: self)
)
}
}
extension EventsViewController: EventsViewModelEventsListener {
func routeToMainPage() {
fatalError("some routing")
}
}
За счет интерфейса обе платформы знают, какой набор действий должны поддерживать.
Если во ViewModel
нужно будет добавить еще одно событие, и мы забудем реализовать его на какой-нибудь из платформ, компилятор выделит, что отсутствует реализация метода интерфейса.
warning
В dispatchEvent
нельзя передавать лямбду из общего кода, например, для установки действия по кнопке в AlertDialog. Нельзя этого делать потому, что на Android мы не сможем ее никуда сохранить, поэтому при пересоздании экрана она пропадет.
Если вам нужно установить чему-либо на платформе действие - делайте соответствующий метод во ViewModel
.
Flow c moko-kswift
Мы уже рассмотрели, с какими проблемами мы столкнулись бы, если бы использовали Flow
в общем коде.
Разберем теперь, как можно решить эти проблемы, начнем с отсутствия типов у Flow
на iOS.
Мы будем использовать классы-обертки CFlow
и CStateFlow
из moko-mvvm, а также функции, позволяющие преобразовать в них Flow
и StateFlow
.
CFlow
и CStateFlow
- это те же самые Flow
и StateFlow
, только в виде классов. Сделаны они были для того, чтобы использовать именно классы, потому что для классов в Swift generic типы доступны.
В common-коде мы будем использовать CFlow
и CStateFlow
только для public API, а в внутренней реализации общего кода нет нужды использовать классы вместо интерфейсов - можно будет использовать обычное Flow
API.
Таким образом, мы решили первую проблему - отсутствие типов у Flow
на iOS.
Теперь разберемся со второй проблемой - преобразованием sealed interface
к enum
в Swift.
Используя плагин moko-kswift, мы можем получать автоматически генерируемые Swift enum
, соответствующие sealed-interface
-ам общего кода, а после работать с ними в switch
.
Для более полного понимания проблемы и её решения, изучите страницу плагина в базе знаний.
Удобное public api общего кода
Благодаря переносу всей логики приложения в общий код мы получаем более удобное и простое API библиотеки для интеграции на платформы. Мы знаем что есть, например, ряд ViewModel
-ей, в которых есть LiveData
на которые нужно подписаться и EventsDispatcher
события от которого нужно обрабатывать. Все передаваемые на UI данные уже подготовлены к отображению и не требуют дополнительной обработки.
Вот некоторый список преимуществ, которые мы получаем за счет использования ViewModel
-ей в общем коде:
- Вся обработка ошибок (
Exception
) внутри Kotlin кода, на UI привязываются строки с текстом - нам не надо на Swift пытаться распознать что такоеKotlinException
; suspend
функции все внутри Kotlin кода.
Практическое задание
- Используйте проект, готовый после раздела Внедрение зависимостей
- Подключите библиотеку
moko-mvvm
- Подключите плагин
moko-kswift
- Добавьте в ваши фичи репозиторий, необходимые классы и вьюмодели, которые вы делали в третьем блоке для Android, все вьюмодели наследуйте от
ViewModel
изmoko-mvvm
- Ориентируйтесь на классы из практики 3его блока и диаграмму классов mpp-library
- Используйте
CFlow
иCStateFlow
для дляstate
public API
- Добавьте необходимые фрагменты в Android-приложение, фрагменты наследуйте от
MvvmFragment
изmoko-mvvm
, ориентируйтесь на практику 3 блока - Добавьте необходимые
ViewController
-ы в iOS приложение, всеViewController
-ы наследуйте отMVVMController
изmoko-mvvm
- Подключите вьюмодели к iOS и Android
- Настройте Android и iOS приложения - их логикой, кроме управления списков, должен управлять общий код
- Приложения должны запускаться