Building a Compose Multiplatform app with an Architectural pattern. Ft. PreCompose MVVM-Navigation, Koin Dependency Injection & Apollo Kotlin for GraphQL 🔥

Nitheesh Ag
6 min readAug 31, 2023

--

Back in the day, with Kotlin being my favourite language, I always wondered if I could do cross-platform with Kotlin.

Fast forward to the present. Now we’ve got Kotlin Multiplatform and its subset, Compose Multiplatform. In this article, we go through building a cross-platform iOS and Android app using the Jetpack Compose API with a single codebase that implements almost all the basic things like a proper architecture, navigation, DI, theme, etc.

The project

The explanations in this article are based on how I put things together in a project that I’ve already built.

Setting up the project

We can use a template provided by Jetbrains to start developing our own Compose Multiplatform mobile application targeting Android and iOS. With this, we can omit manually setting up the project. Following the documentation provided in the template will get our Compose Multiplatform app up and running.

Theming

We can theme our app just like we do in Android Jetpack Compose. We can create a theme based out of Material theme and wrap it around our main UI entry point.

@Composable
fun EarthTheme(
content: @Composable () -> Unit
) {
MaterialTheme(
colors = if (isSystemInDarkTheme())
darkColors(
...
)
else lightColors(
...
),
typography = Typography,
shapes = Shapes(...)
) {
content()
}
}

And we can use like:

@Composable
fun App() {
EarthTheme {
...
}
}

Setting up typography is little different from what we usually do in an Android Compose project. The below article describes beautifully how that deal can be done.

Setting up Apollo Kotlin for GraphQL

First we need to add the Apollo Gradle plugin in build.gradle.kts

plugins {
...
id("com.apollographql.apollo3") version "3.8.2" apply false
}

Then in shared/build.gradle.kts

plugins {
...
id("com.apollographql.apollo3")
}
val commonMain by getting {
dependencies {
...
api("com.apollographql.apollo3:apollo-runtime:3.8.2")
api("com.apollographql.apollo3:apollo-normalized-cache:3.8.2")
// add more apollo libraries here
}
}

And finally we need to set the packageName in shared/build.gradle.kts

apollo {
service("service") {
packageName.set("dev.imn.earth")
}
}

Now, we need to add the schema file at src/commonMain/graphql/dev/imn/earth/schema.graphqls

We can download the schema using:

./gradlew downloadApolloSchema --endpoint='https://countries.trevorblades.com/graphql' --schema=shared/src/commonMain/graphql/dev/imn/earth/schema.graphqls

In this example, I’ve used a free GraphQL API:

Now, we can write queries and mutations inside shared/src/commonMain/graphql/dev/imn/earth.

Now, Rebuild the project for the necessary Apollo codegen.

Setting up Koin & PreCompose

PreCompose is used for Compose Multiplatform Navigation & ViewModel, inspired by Jetpack Lifecycle, ViewModel, LiveData and Navigation, PreCompose provides similar (or even the same) components but in Kotlin, and it’s Kotlin Multiplatform project.

First, we need to add the libraries at shared/build.gradle.kts

    val commonMain by getting {
dependencies {
...

api("com.apollographql.apollo3:apollo-runtime:3.8.2")
api("com.apollographql.apollo3:apollo-normalized-cache:3.8.2")

api("io.insert-koin:koin-core:3.4.3")
api("io.insert-koin:koin-compose:1.0.4")

api("moe.tlaster:precompose:1.5.0")
api("moe.tlaster:precompose-viewmodel:1.5.0")
api("moe.tlaster:precompose-koin:1.5.0")
}
}

Now, we need to implement the startKoin function, which is main entry point to launch Koin container.

We can write a function like:

fun initKoin() {
startKoin {
modules(
module {
single {...}
factory {...}
}
)
}
}

We can declare the modules later. The above function is written in a file called DIHelper.kt.

We need to call initKoin in androidApp/src/androidMain/kotlin/dev/imn/earth/MainActivity.kt

class MainActivity : PreComposeActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

initKoin()

setContent {
MainView()
}
}
}

Inorder to use PreCompose, change the Activity’s parent class to moe.tlaster.precompose.lifecycle.PreComposeActivity and use moe.tlaster.precompose.lifecycle.setContent for setting compose content.

Similary, we can init it in our iOS Main app entry. I.e iosApp/iosApp/iOSApp.swift

import shared

@main
struct iOSApp: App {

init() {
DIHelperKt.doInitKoin()
}

var body: some Scene {
WindowGroup {
ContentView()
}
}
}

For PreCompose, we need to set the UIWindow.rootViewController to PreComposeApplication. This can be done in shared/src/iosMain/kotlin/main.ios.kt

fun MainViewController() = PreComposeApplication { App() }

Now, PreCompose and Koin is all set 🔥.

Setting up MVVM

First we need API interface and the API implementation class.

interface CountriesAPI {

suspend fun continentsQuery(): ApolloCall<ContinentsQuery.Data>

suspend fun continentQuery(id: String): ApolloCall<ContinentQuery.Data>

suspend fun countryQuery(code: String): ApolloCall<CountryQuery.Data>

}
class CountriesAPIImpl(private val apolloClient: ApolloClient) : CountriesAPI {
override suspend fun continentsQuery(): ApolloCall<ContinentsQuery.Data> {
return apolloClient.query(ContinentsQuery())
}

override suspend fun continentQuery(id: String): ApolloCall<ContinentQuery.Data> {
return apolloClient.query(ContinentQuery(id))
}

override suspend fun countryQuery(code: String): ApolloCall<CountryQuery.Data> {
return apolloClient.query(CountryQuery(code))
}
}

We need to create an instance of ApolloClient in order to use the CountriesAPI. Creating instance is handled at the startKoin function and that is mentioned below.

Now, we can write a Repository and ViewModel class for a Screen.

class ContinentsRepository(private val countriesAPI: CountriesAPI) {
suspend fun continentsQuery() = countriesAPI.continentsQuery()
}
class ContinentsViewModel(
private val continentsRepository: ContinentsRepository
) : ViewModel() {

fun attemptContinentsQuery() = viewModelScope.launch {
...
continentsRepository.continentsQuery().toFlow().catch { exceptions ->
...
}.collectLatest { res ->
if (!res.hasErrors()) {
val continents = res.data?.continents
// we will get the data here and it can be show in the UI
} else {
...
}
}
}
}

We can use StateFlow as a state holder.

private val _uiState = MutableStateFlow(UIState())
val uiState = _uiState.asStateFlow()

And, we can update the _uiState based on the state of API call.

_uiState.update { state ->
state.copy(
data = continents
)
}

We can now the observe uiState in a Composable function as:

val uiState by viewModel.uiState.collectAsState()

Text(uiState.somedata)

Now that ContinentsRepository, ContinentsViewModel, and CountriesAPI are ready, we can now add modules to the startKoin function:

fun initKoin() {
startKoin {
modules(
module {
single {
ApolloClient.Builder()
.serverUrl("https://countries.trevorblades.com/graphql")
.normalizedCache(MemoryCacheFactory(maxSizeBytes = 10 * 1024 * 1024))
.build()
}
single<CountriesAPI> { CountriesAPIImpl(get()) }
single { ContinentsRepository(get()) }
...
factory { ContinentsViewModel(get()) }
...
}
)
}
}

Resolving & injecting dependencies​

ApolloClient instance is created here. CountriesAPIImpl has a dependency on ApolloClient, which is provided by Koin. Similary, dependency Resolving & injecting is done for ContinentsRepository and ContinentsViewModel by Koin.

Navigation with PreCompose đź“Ť

Navigation is similar to Jetpack Compose Navigation. There is NavHost composable function to define the navigation graph like what we’ve done in Jetpack Navigation, and it behaves like what Jetpack Navigation provides. NavHost provides back stack management and stack lifecycle and viewModel management.

In our case, we can implement our navigation like:

@Composable
fun Nav() {
// Define a navigator, which is a replacement for Jetpack Navigation's NavController
val navigator = rememberNavigator()
NavHost(
// Assign the navigator to the NavHost
navigator = navigator,
// Navigation transition for the scenes in this NavHost, this is optional
navTransition = NavTransition(),
// The start destination
initialRoute = "/continents",
) {
// Define a scene to the navigation graph
scene(
// Scene's route path
route = "/continents",
// Navigation transition for this scene, this is optional
navTransition = NavTransition(),
) {
val vm = koinViewModel(ContinentsViewModel::class)
ContinentsScreen(vm) {
navigator.navigate("/countries/$it")
}
}

scene(route = "/countries/{id}?") { backStackEntry ->
val vm = koinViewModel(CountriesViewModel::class)
val code: String? = backStackEntry.path<String>("id")
CountriesScreen(
code = code,
viewModel = vm,
onCountryClick = {
navigator.navigate("/country/$it")
},
onBackPress = {
navigator.goBack()
}
)
}

}
}

We can get our ViewModel instance by:

val vm = koinViewModel(ContinentsViewModel::class)

There is an additional screen called CountriesScreen with route /countries/{id}?. This means it accepts a variable id.

We can navigate to CountriesScreen using:

navigator.navigate("/countries/some_id")

Now, in CountriesScreen, we can access the code by:

val code: String? = backStackEntry.path<String>("id")

Putting things all together now our app is up for running.

Conclusion

So, Kotlin is fun, and so is Compose Multiplatform. I hope this bunch of lines concludes with a conceptualization.

Please go through the project. So, you’ll get a better understanding.

Happy coding!

References

https://insert-koin.io/docs/reference/koin-mp/kmp/

https://insert-koin.io/docs/reference/koin-android/compose/

https://www.apollographql.com/docs/kotlin/tutorial/01-configure-project

https://tlaster.github.io/PreCompose/setup.html

https://tlaster.github.io/PreCompose/component/navigation.html

Thank you for reading. 🙏

--

--

Nitheesh Ag

👨‍💻 #SelfTaught Developer who is in a pursuit of learning new things.