вернуться назад

ViewModel + Kotlin Multiplatform. Пробуем нативное решение

Habr.ru
Анна Жаркова
Ведущий мобильный разработчик

Всем привет! На связи Анна Жаркова, руководитель группы мобильной разработки в компании Usetech. Компания Google объявили о своем интересе к Koltin Multiplatform на прошлом Google I/O 2023. Следом был обозначен вектор развития имеющихся решений архитектурных библиотек Jetpack для поддержки KMP. Буквально считанные часы назад компания Google опубликовали ожидаемую многими новинку, а именно ViewModels из библиотеки Lifecycle с поддержкой API Kotlin Multiplatform. И сейчас мы с вами проверим, насколько это удобно, что уже готово, а что нужно доработать.

Для начала освежим, с чем же мы работали до ViewModels из Lifecycle. 

Сами по себе ViewModel как часть паттерна MVVM применительно к кросс-платформенным решениям идея не новая. Многие давно использовали собственную реализацию, совмещая также с платформенными архитектурами.

Для KMP ViewModel — это не только часть общей архитектуры, но и компонент, где можно удобно инкапсулировать логику работы с общей многопоточностью:

open class ViewModel{
val job = SupervisorJob()
protected var scope: CoroutineScope = CoroutineScope(uiDispatcher + job)
}

Еще 1.5 года назад реализация асинхронности в общей части KMP приложений требовала серьезных усилий, о чем я много писала. Сейчас нам даже не нужно использовать expect/actual для создания своих диспетчеров корутин. Просто объявим в commonMain в файле:

val ioDispatcher = Dispatchers.IO
val uiDispatcher = Dispatchers.Main

На самом деле, expect/actual остался, но теперь всю логику за нас реализовали разработчики библиотеки. Нам достаточно просто обратиться через общие входные точки. По крайней мере, в случае iOS и Android таргетов это будет работать.

Полученный класс используем как базовый, а диспетчеры корутин для вызова своей логики. Например, сетевого клиента:

class NewsViewModel(private val useCase: NewsUseCase) : ViewModel() {
var newsFlow = MutableStateFlow<NewsList?>(null)

fun loadNews() {
scope.launch {
val result = withContext(ioDispatcher) {
useCase.invoke(Unit)
}
result.getOrNull()?.let {
newsFlow.tryEmit(it)
}
}
}
}

Далее такую ViewModel можно использовать напрямую в наших нативных приложениях:

//Android
class NewsActivity : PreComposeActivity() {
val vm: NewsViewMode = NewsViewModel()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_news)
setContent {
NewsListScreen(viewModel = vm)
}
}
}

//IOS
class NewsListModel : ObservableObject {
private lazy var vm: NewsViewModel? = {
let vm = NewsViewModel()
vm?.newsFlow.collect(collector: itemsCollector, completionHandler: {_ in})
return vm
}()

Или в DI-решениях:

class KoinDI : KoinComponent {
//...
val vmModule = module {
factory<NewsViewModel> { NewsViewModel(get()) }
}

fun start() = startKoin {
modules(listOf(vmModule))
}
}

//Подключение
private val vm: NewsViewModel? = KoinDIFactory.resolve(NewsViewModel::class)

Итак, это то, как работает сейчас. А теперь попробуем собственно решение от Google. 
developer.android.com/jetpack/androidx/releases/lifecycle?s=09#2.8.0-alpha03. Это ровно тот же пакет androidx.lifecycle:lifecycle-*. 

Попробуем сначала добавить себе все решения из входящих в пакет. Копируем, вставляем в секцию dependencies. Предвкушаем и запускаем Gradle Sync. И получаем… целое ничего, вернее, ошибку в консоли:

Осторожно, ошибки
Мы, конечно, размахнулись. Большая часть этого функционала пока только для JVM и Android. Перечитаем инструкцию внимательно и установим только lifecycle-viewmodel:

val commonMain by getting {
dependencies {
val lifecycle_version = "2.8.0-alpha03"
implementation("androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version")
}
}

Теперь создадим новый базовый класс для всех наших ViewModel:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope

open class BaseViewModel : ViewModel(){
val scope = this.viewModelScope
}

Как и в традиционной ViewModel для Android и JVM, нам доступен встроенный viewModelScope:

public val ViewModel.viewModelScope: CoroutineScope
get() = viewModelScopeLock.withLock {
getCloseable(VIEW_MODEL_SCOPE_KEY)
?: createViewModelScope().also { scope -> addCloseable(VIEW_MODEL_SCOPE_KEY, scope) }
}

private val viewModelScopeLock = Lock()

Как мы видим по lock, viewModelScope потокобезопасен.

Функция createViewModelScope() под капотом создает собственный скоуп корутин, куда подставляется Dispatchers.Main:

internal fun createViewModelScope(): CloseableCoroutineScope {
val dispatcher = try {
Dispatchers.Main.immediate
} catch (_: NotImplementedError) {
// In platforms where `Dispatchers.Main` is not available, Kotlin Multiplatform will throw
// a `NotImplementedError`. Since there's no direct functional alternative, we use
// `EmptyCoroutineContext` to ensure a `launch` will run in the same context as the caller.
EmptyCoroutineContext
}
return CloseableCoroutineScope(coroutineContext = dispatcher + SupervisorJob())
}

Если мы имеем дело с таргетом, который не имеет своей реализации Dispatchers.Main, например, Linux, то мы получим исключение EmptyCoroutineContext. Следовательно, viewModelScope мы использовать не сможем.

Еще одно новшество API ViewModels, возможность переопределять viewModelScope и передача скоупов как параметр ViewModel:

class MyViewModel(
// Make Dispatchers.Main the default, rather than Dispatchers.Main.immediate
viewModelScope: CoroutineScope = Dispatchers.Main + SupervisorJob()
) : ViewModel(viewModelScope) {
// Use viewModelScope as before, without any code changes
}

// Allows overriding the viewModelScope in a test
fun Test() = runTest {
val viewModel = MyViewModel(backgroundScope)
}

Что ж, если не считать проделанной работы по оптимизации диспетчеров корутин, инкапсуляции работы с многопоточностью, то может создасться впечатление, что просто взяли наш код и поместили его в общую библиотеку.

Заменим базовый класс в своей ViewModel и вызовем запрос через viewModelScope:

class NewsViewModel() : BaseViewModel() {
var newsFlow = MutableStateFlow<NewsList?>(null)
private val newsService = DI.newsService

fun loadNews() {
viewModelScope.launch {
val result = withContext(ioDispatcher) {
newsService.loadNews()
}
newsItems.tryEmit(result.getOrNull()?.articles.orEmpty())
}
}
}

Проверяем. Все работает.

Также API библиотеки для кросс-платформы включает в себя: ViewModelStore, ViewModelStoreOwner и ViewModelProvider. ViewModelProvider поддерживает теперь запрос инстансов по типу не только как java.lang.Class, но и kotlin.reflect.KClass. ViewModelProvider.NewInstanceFactory и ViewModelProvider.AndroidViewModelFactoryдоступны только для Android и JVM, и использование их на других таргетах выдаст ошибку: UnsupportedOperationException

Для всех не-JVM таргетов теперь надо реализовывать свои собственные фабрики на основе ViewModelProvider.Factory с переопределением метода create:

class CustomFactory: ViewModelProvider.Factory {

override fun <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T {
return super.create(modelClass, extras)
}
}

Для запроса и создания инстансов ViewModel через DI не меняется ничего. 

Если делать в Compose Multiplatform, то все будет еще проще.

Подведем итог. Нам дали официальный API, который дает нам нативную реализацию ViewModel. Всю рутину теперь делают за нас. Но также мы можем переопределять скоупы на свой вкус.
KMP становится все более и более удобным.

Остаемся на связи 🙂

Loading

Мы обрабатываем файлы cookie для корректной работы сайта и персонализации сервисов, в т.ч. с использованием метрических программ и систем аналитик. Продолжая пользоваться сайтом, Вы соглашаетесь с использованием файлов cookie. Если вы не хотите, чтобы ваши данные обрабатывались, пожалуйста, ограничьте использование файлов cookie в настройках браузера. Более подробная информация на странице Политика конфиденциальности.
Принять
Мы обрабатываем файлы cookie для корректной работы сайта и персонализации сервисов, в т.ч. с использованием метрических программ и систем аналитик. Продолжая пользоваться сайтом, Вы соглашаетесь с использованием файлов cookie. Если вы не хотите, чтобы ваши данные обрабатывались, пожалуйста, ограничьте использование файлов cookie в настройках браузера. Более подробная информация на странице Политика конфиденциальности.
Принять