After successfully implementing the basic Kotlin multiplatform app in our last blog, we will now focus on following a simple architecture with use cases, dependency injection, Shared View Model etc.

We will build on what we built in the first part of the blog. We make the code structured and better state management.

App Demonstration

You can access the code, here on the branch feature/koin-vm

We will be using these dependencies:

We will start off by adding the required dependencies to the shared module in build.gradle[Preview]. Make sure to use the Koin 3.2.0 version, there was some compatibility issue with the latest version.

cocoapods {
.....
framework {
baseName = "MultiPlatformLibrary" // changed this from `shared`
export("dev.icerock.moko:mvvm-core:0.16.1")
export("dev.icerock.moko:mvvm-flow:0.16.1")
}
}

//Common Dependencies with go under commonMain
val commonMain by getting {
dependencies {
....
api("dev.icerock.moko:mvvm-core:0.16.1")
api("dev.icerock.moko:mvvm-flow:0.16.1")
implementation("io.insert-koin:koin-core:3.2.0")
}
}

//Adding Android Specific dependency
val androidMain by getting {
dependencies {
implementation("io.insert-koin:koin-android:3.2.0")
}
}

Adding dependencies to androidApp in build.gradle(:andriodApp) [Preview]

dependencies {
.....
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")

val koin = "3.5.0"
implementation("io.insert-koin:koin-android:${koin}")
implementation("io.insert-koin:koin-androidx-compose:${koin}")
}

Adding pod to iosApp in Podfile(:iosApp) [Preview]

target 'iosApp' do
...
pod 'mokoMvvmFlowSwiftUI', :podspec => 'https://raw.githubusercontent.com/icerockdev/moko-mvvm/release/0.16.1/mokoMvvmFlowSwiftUI.podspec'
end

Firstly we can see the attached image, we have divided it into model, repository, usecase, di, utils.

Application Architectural Structure

Model — consists of the data class which is serialized from the response in shared/src/commonMain/kotlin/com/debanshu/animax/data/model

Utils — is a simple example of using different dispatchers based on the platform.

/*
File Name: Dispatcher.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/utils/Dispatcher.kt
*/
internal interface Dispatcher {
val io:CoroutineDispatcher
}

internal expect fun provideDispatcher():Dispatcher

/*
File Name: Dispatcher.kt
Loaction: shared/src/iosMain/kotlin/com/debanshu/animax/utils/Dispatcher.kt
*/
internal class IosDispatcher:Dispatcher {
override val io: CoroutineDispatcher
get() = Dispatchers.Default
}

internal actual fun provideDispatcher():Dispatcher = IosDispatcher()

/*
File Name: Dispatcher.kt
Loaction: shared/src/androidMain/kotlin/com/debanshu/animax/utils/Dispatcher.kt
*/
internal class AndroidDispatcher:Dispatcher {
override val io: CoroutineDispatcher
get() = Dispatchers.IO
}

internal actual fun provideDispatcher():Dispatcher = AndroidDispatcher()

Repository — module consists of RemoteDataRepository which makes the actual API call. networkClient is coming from NetworkClient which we have previously discussed in our previous blog.

/*
File Name: RemoteDataRepository.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/data/repository/RemoteDataRepository.kt
*/
import com.debanshu.animax.data.model.TopAnimeResponse
import com.debanshu.animax.data.networkClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.url

internal class RemoteDataRepository {
suspend fun getTopAnimeList(): TopAnimeResponse = networkClient.get {
url("https://api.jikan.moe/v4/top/anime")
}.body()
}

//TopAnimeResponse is the serialized data class from the model

Usecase — To make it extendable we will make an abstract BaseUsecase and then we can implement it as GetTopAnimeUsecase. We will also put model cases here TopAnimeResponse is a model class inside shared/src/commonMain/kotlin/com.debanshu.animax/model[code preview]. We are also using KoinComponent and by inject(), A class implementation KoinComponent is similar to a Spring @Component. It has a link to the global Koininstance and serves as an entry point to the object tree encoded in the modules. by inject()lazily inject instance from Koin. In this case, we will be injecting remoteRepository & dispatcher to our use case.

/*
File Name: BaseUseCase.kt
Loaction: shared/src/iosMain/kotlin/com.debanshu.animax/usecase
*/
abstract class BaseUseCase<REQUEST,RESPONSE> {
@Throws(Exception::class)
abstract suspend fun execute(request: REQUEST):RESPONSE
}
/*
File Name: GetTopAnimeUseCase.kt
Loaction: shared/src/iosMain/kotlin/com.debanshu.animax/usecase
*/
class GetTopAnimeUseCase : BaseUseCase<Unit, TopAnimeResponse>(), KoinComponent {
private val remoteDataRepository: RemoteDataRepository by inject()
private val dispatcher: Dispatcher by inject()
override suspend fun execute(request: Unit): TopAnimeResponse = withContext(dispatcher.io) {
remoteDataRepository.getTopAnimeList()
}
}

ViewModel — We will be using moko-viewModel which provides us with ViewModel as we use it in Android. There is no specific difference between the MVVM approach we follow. We will be consuming this ViewModel in our view, while we will be emitting AnimeListState from ViewModel. We will be injecting the GetTopAnimeUseCase to ViewModel and consuming and triggering in our loadMovies()

/*
File Name: AppViewModel.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/data/AppViewModel.kt
*/

class AppViewModel(private val getTopAnimeUseCase: GetTopAnimeUseCase) : ViewModel() {
private val animeMutable =
MutableStateFlow<AnimeListState>(AnimeListState.Uninitialized)
val animeState = animeMutable.asStateFlow().cStateFlow()

init {
loadMovies()
}

private fun loadMovies() {
animeMutable.value = AnimeListState.Loading
viewModelScope.launch {
try {
animeMutable.value = AnimeListState.Success(getTopAnimeUseCase.execute(Unit).data)
} catch (e: Exception) {
e.printStackTrace()
animeMutable.value = AnimeListState.Error(e.message.orEmpty())
}
}
}
override fun onCleared() {
viewModelScope.cancel()
super.onCleared()
}
}

sealed interface AnimeListState {
data class Success(val data: List<Anime>) : AnimeListState
data class Error(val exceptionMessage: String) : AnimeListState
data object Loading : AnimeListState
data object Uninitialized : AnimeListState
}

There is no specific difference between the usage of Koin in KMP from our Native Android development. But to give iOS support the shared module KoinHelper.kt needs to be added. We need to start Koin with our iOS application. In the Kotlin shared code, we have a function to let us configure Koin. We will be discussing ⁣getSharedModules() this is a bit later.

/*
File Name: KoinHelper.kt
Loaction: shared/src/iosMain/kotlin/com/debanshu/animax/utils/KoinHelper.kt
*/

import com.debanshu.animax.data.AppViewModel
import com.debanshu.animax.di.getSharedModules
import org.koin.core.component.KoinComponent
import org.koin.core.context.startKoin
import org.koin.core.component.get
import org.koin.dsl.module

fun initKoin() {
startKoin {
modules(getSharedModules())
}
}

class KoinHelper: KoinComponent {
fun getAppViewModel() = get<AppViewModel>()
}

In the iOS main entry, we can call the KoinHelperKt.doInitKoin() function that is calling our helper function above.

/*
File Name: iOSApp.swift
Loaction: iosApp/iosApp/iOSApp.swift
*/
import SwiftUI
import MultiPlatformLibrary

@main
struct iOSApp: App {
init(){
KoinHelperKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
GridViewAnime()
}
}
}

In the Android main entry, We need to start Koin with our Android application. Just call the startKoin() function in the application’s main entry point, our AnimaxApplication class. Make sure to add AnimaxApplication to your AndroidManifest.xml.

/*
File Name: AnimaxApplication.kt
Loaction: androidApp/src/main/java/com/debanshu/animax/android/AnimaxApplication.kt
*/
import android.app.Application
import com.debanshu.animax.di.getSharedModules
import org.koin.core.context.startKoin

class AnimaxApplication:Application() {
override fun onCreate() {
super.onCreate()
startKoin {
modules(getSharedModules())
}
}
}

We will also set up getViewModelByPlatfrom() which will be actual/expect function.

/*
File Name: viewModelModules.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/di/viewModelModules.kt
*/
import org.koin.core.module.Module

internal expect fun getViewModelByPlatform(): Module

/*
File Name: viewModelModules.kt
Loaction: shared/src/androidMain/kotlin/com/debanshu/animax/di/viewModelModules.kt
*/
import com.debanshu.animax.data.AppViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module

actual fun getViewModelByPlatform() = module {
viewModel {
AppViewModel(get())
}
}

/*
File Name: viewModelModules.kt
Loaction: shared/src/iosMain/kotlin/com/debanshu/animax/di/viewModelModules.kt
*/
import com.debanshu.animax.data.AppViewModel
import org.koin.dsl.module
actual fun getViewModelByPlatform() = module {
single {
AppViewModel(get())
}
}

Now, let’s set up the dependencies RemoteDataRepository, GetTopAnimeUseCases. To set up the dispatchers from the utils we discussed previously provideDispatcher(). Finally, we will put getViewModelByPlatform() the above to use the specific implementation of the platform.

/*
File Name: sharedModules.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/di/sharedModules.kt
*/
import com.debanshu.animax.data.repository.RemoteDataRepository
import com.debanshu.animax.data.usecase.GetTopAnimeUseCase
import com.debanshu.animax.utils.provideDispatcher
import org.koin.dsl.module

private val dataModule = module {
single { RemoteDataRepository() }
factory { GetTopAnimeUseCase() }
}

private val utilityModule = module {
factory { provideDispatcher() }
}

private val sharedModules = listOf(dataModule, utilityModule, getViewModelByPlatform())

fun getSharedModules() = sharedModules

Consuming the ViewModel data is something similar to what we follow in Android development.

Android Implementation

/*
File Name: MainActivity.kt
Loaction: androidApp/src/main/java/com/debanshu/animax/android/MainActivity.kt
*/
@Composable
fun GridViewAnime(viewModel: AppViewModel = getViewModel()) {
val animeState by viewModel.animeState.collectAsStateWithLifecycle()

Scaffold(topBar = {
TopAppBar {
Row(modifier = Modifier.padding(10.dp)) {
Text(text = "Animax")
}
}
}) { defaultPadding ->
when (animeState) {
is AnimeListState.Loading -> {}
is AnimeListState.Success -> {
// This is the Success UI
}
is AnimeListState.Error-> {}
is AnimeListState.Uninitialized->{}
}
}
}

iOS Implementation

/*
File Name: GridViewAnime.swift
Loaction: iosApp/iosApp/GridViewAnime.swift
*/
import SwiftUI
import MultiPlatformLibrary
import mokoMvvmFlowSwiftUI

struct GridViewAnime: View {
@ObservedObject var viewModel: AppViewModel = KoinHelper().getAppViewModel()
@State var uiState: AnimeListState = AnimeListStateUninitialized()

private let adaptaiveColumns = [
GridItem(.adaptive(minimum: 170))
]

var body: some View {
let appUiState = viewModel.animeState
NavigationView{
VStack{
switch(uiState){
case is AnimeListStateLoading:
LoadingView()
case let successState as AnimeListStateSuccess:
// This is the Success UI
case is AnimeListStateError:
ErrorView()
default:
ErrorView()
}
}
.padding([.horizontal])
.navigationTitle("Animax")
}.onAppear {
appUiState.subscribe { state in
self.uiState = state!
}
}
}
}

Finally, we are done with a basic Architecture Example of Kotlin Multiplatform Mobile✨.

For any doubts and suggestions, you can reach out on my Instagram, or LinkedIn. Follow me for Kotlin content and more. Happy Coding!

I will well appreciate one of these 👏

Source link