
Всем привет! На связи Анна Жаркова, руководитель группы мобильной разработки в компании 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. И получаем… целое ничего, вернее, ошибку в консоли:
Осторожно, ошибки
Execution failed for task ':shared:transformIosMainCInteropDependenciesMetadataForIde'.
> Could not resolve all files for configuration ':shared:iosX64CompilationDependenciesMetadata'.
> Could not resolve androidx.lifecycle:lifecycle-runtime-ktx:2.8.0-alpha03.
Required by:
project :shared
project :shared > androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0-alpha03
project :shared > androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0-alpha03 > androidx.lifecycle:lifecycle-viewmodel:2.8.0-alpha03 > androidx.lifecycle:lifecycle-viewmodel-iosx64:2.8.0-alpha03
> No matching variant of androidx.lifecycle:lifecycle-runtime-ktx:2.8.0-alpha03 was found. The consumer was configured to find a library for use during 'kotlin-metadata', preferably optimized for non-jvm, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native', attribute 'org.jetbrains.kotlin.native.target' with value 'ios_x64' but:
- Variant 'androidxSourcesElements' capability androidx.lifecycle:lifecycle-runtime-ktx:2.8.0-alpha03:
- Incompatible because this component declares documentation for use during 'androidx-multiplatform-docs' and the consumer needed a library for use during 'kotlin-metadata'
- Other compatible attributes:
- Doesn't say anything about its target Java environment (preferred optimized for non-jvm)
- Doesn't say anything about org.jetbrains.kotlin.native.target (required 'ios_x64')
- Doesn't say anything about org.jetbrains.kotlin.platform.type (required 'native')
- Variant 'libraryVersionMetadata' capability androidx.lifecycle:lifecycle-runtime-ktx:2.8.0-alpha03:
- Incompatible because this component declares documentation for use during 'library-version-metadata' and the consumer needed a library for use during 'kotlin-metadata'
- Other compatible attributes:
- Doesn't say anything about its target Java environment (preferred optimized for non-jvm)
- Doesn't say anything about org.jetbrains.kotlin.native.target (required 'ios_x64')
- Doesn't say anything about org.jetbrains.kotlin.platform.type (required 'native')
- Variant 'metadataApiElements' capability androidx.lifecycle:lifecycle-runtime-ktx:2.8.0-alpha03 declares a library for use during 'kotlin-metadata':
- Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'common' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
- Other compatible attributes:
- Doesn't say anything about its target Java environment (preferred optimized for non-jvm)
- Doesn't say anything about org.jetbrains.kotlin.native.target (required 'ios_x64')
- Variant 'metadataSourcesElements' capability androidx.lifecycle:lifecycle-runtime-ktx:2.8.0-alpha03:
- Incompatible because this component declares documentation for use during 'kotlin-runtime', as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'common' and the consumer needed a library for use during 'kotlin-metadata', as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
- Other compatible attributes:
- Doesn't say anything about its target Java environment (preferred optimized for non-jvm)
- Doesn't say anything about org.jetbrains.kotlin.native.target (required 'ios_x64')
- Variant 'releaseApiElements-published' capability androidx.lifecycle:lifecycle-runtime-ktx:2.8.0-alpha03 declares a library for use during compile-time:
- Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'androidJvm' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
- Other compatible attributes:
- Doesn't say anything about its target Java environment (preferred optimized for non-jvm)
- Doesn't say anything about org.jetbrains.kotlin.native.target (required 'ios_x64')
- Variant 'releaseRuntimeElements-published' capability androidx.lifecycle:lifecycle-runtime-ktx:2.8.0-alpha03 declares a library for use during runtime:
- Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'androidJvm' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
- Other compatible attributes:
- Doesn't say anything about its target Java environment (preferred optimized for non-jvm)
- Doesn't say anything about org.jetbrains.kotlin.native.target (required 'ios_x64')
- Variant 'releaseSourcesElements-published' capability androidx.lifecycle:lifecycle-runtime-ktx:2.8.0-alpha03 declares a component for use during runtime:
- Incompatible because this component declares documentation, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'androidJvm' and the consumer needed a library, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
- Other compatible attributes:
- Doesn't say anything about its target Java environment (preferred optimized for non-jvm)
- Doesn't say anything about org.jetbrains.kotlin.native.target (required 'ios_x64')
> Could not resolve org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3.
Required by:
project :shared > androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0-alpha03
> No matching variant of org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 was found. The consumer was configured to find a library for use during 'kotlin-metadata', preferably optimized for non-jvm, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native', attribute 'org.jetbrains.kotlin.native.target' with value 'ios_x64' but:
- Variant 'apiElements' capability org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 declares a library for use during compile-time, preferably optimized for standard JVMs:
- Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'jvm' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
- Other compatible attribute:
- Doesn't say anything about org.jetbrains.kotlin.native.target (required 'ios_x64')
- Variant 'runtimeElements' capability org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 declares a library for use during runtime, preferably optimized for standard JVMs:
- Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'jvm' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
- Other compatible attribute:
- Doesn't say anything about org.jetbrains.kotlin.native.target (required 'ios_x64')Мы, конечно, размахнулись. Большая часть этого функционала пока только для 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 становится все более и более удобным.
Остаемся на связи :-)
Полезные ссылки
Исходники
