ViewModel + Kotlin Multiplatform. Trying a native solution

Hi all! Anna Zharkova, head of the mobile development group at Usetech, is in touch. Google announced their interest in Koltin Multiplatform at the last Google I/O 2023. Next, the development direction of existing Jetpack architectural library solutions to support KMP was outlined. Just a few hours ago, Google published a new product expected by many, namely ViewModels from the Lifecycle library with support for the Kotlin Multiplatform API. And now we will check how convenient it is, what is already ready, and what needs to be improved.

First, let's refresh what we worked with before ViewModels from Lifecycle.
The ViewModel itself as part of the MVVM pattern in relation to cross-platform solutions is not a new idea. Many have long used their own implementation, also combining them with platform architectures.


For KMP, ViewModel is not only part of the overall architecture, but also a component where you can conveniently encapsulate the logic of working with general multithreading:

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

Just 1.5 years ago, implementing asynchrony in the general part of KMP applications required serious effort, which I wrote a lot about. Now we don't even need to use expect/actual to create our coroutine managers. Let's just declare it in commonMain in the file:

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

In fact, expect/actual remains, but now the library developers have implemented all the logic for us. We just need to contact through common entry points. At least in the case of iOS and Android targets this will work.

We use the resulting class as a base one, and use coroutine managers to call their logic. For example, a network client:

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)
            }
        }
    }
}

This ViewModel can then be used directly in our native applications:

//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
    }()

Or in DI solutions:

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

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

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

So this is how it works now. Now let's try the actual solution from Google.

developer.android.com/jetpack/androidx/releases/lifecycle?s=09#2.8.0-alpha03. This is exactly the same package androidx.lifecycle:lifecycle-*.

Let's first try to add all the solutions included in the package. Copy and paste into the dependencies section. We look forward to and launch Gradle Sync. And we get… nothing at all, or rather an error in the console:

Beware of errors

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')

We, of course, swung ourselves. Most of this functionality is currently only for the JVM and Android. Let's re-read the instructions carefully and install only lifecycle-viewmodel:

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

Now let's create a new base class for all our ViewModels:

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

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

As in the traditional ViewModel for Android and JVM, we have access to the built-in 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()

As we can see from lock, viewModelScope is thread safe.

The createViewModelScope() function under the hood creates its own coroutine scope, where Dispatchers.Main is substituted:

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())
}

If we are dealing with a target that does not have its own implementation of Dispatchers.Main, for example, Linux, then we will receive an exception

EmptyCoroutineContext

. Therefore, we will not be able to use viewModelScope.

Another innovation in the ViewModels API, the ability to override viewModelScope and pass scopes as a ViewModel parameter:

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)
}

Well, apart from the work done to optimize coroutine managers and encapsulate work with multithreading, it may seem like they just took our code and placed it in a shared library.

Let's replace the base class in our ViewModel and call the request via 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())
        }
    }
}

Let's check. Everything is working.

Also, the cross-platform library API includes: ViewModelStore, ViewModelStoreOwner And ViewModelProvider. ViewModelProvider now supports requesting instances by type not only as java.lang.Class, but also kotlin.reflect.KClass. ViewModelProvider.NewInstanceFactory And ViewModelProvider.AndroidViewModelFactory are available only for Android and JVM, and using them on other targets will generate an error: UnsupportedOperationException.

For all non-JVM targets, you now need to implement your own factories based on ViewModelProvider.Factory with overriding the create method:

class CustomFactory: ViewModelProvider.Factory {

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

To query and create ViewModel instances via DI, nothing changes.

If you do it in Compose Multiplatform, then everything will be even simpler.

Summarize. We've been given an official API that gives us a native ViewModel implementation. All the routine is now done for us. But we can also redefine scopes to our taste.
KMP is becoming more and more convenient.

Keep in touch)

Useful links:

developer.android.com/jetpack/androidx/releases/lifecycle?s=09#2.8.0-alpha03
Sources:

github.com/anioutkazharkova/kmp_news_sample

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *