Photo by Mark Basarab on Unsplash
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:
Dependencies Setup
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
Shared Module Structure Setup
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.
Job Offers
/* 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 Koin
instance 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 }
Dependency with Koin in KMP
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 ViewModel In UI
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 👏
This article was previously published on proandroiddev.com