Всем привет! На связи Анна Жаркова, руководитель группы мобильной разработки в компании 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 становится все более и более удобным.

Остаемся на связи :-)

Полезные ссылки

Исходники