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

idx2jf4rojletkvgtsx0gfrccwy.png

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

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


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

7k50bqkmovfun9vvkpk2uyvwvja.png

Также 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  create(modelClass: KClass, extras: CreationExtras): T {
        return super.create(modelClass, extras)
    }
}


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

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

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

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

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

Исходники

© Habrahabr.ru