The future of apps:
Declarative UIs with Kotlin MultiPlatform (D-KMP) — Part 3/3

Daniele Baroncelli
9 min readNov 17, 2020

A 3-part article explaining the new D-KMP architecture, based on DeclarativeUIs, Kotlin MultiPlatform and MVI pattern.

- Part 1: The D-KMP architecture and the Declarative UIs
- Part 2: Kotlin MultiPlatform and the MVI pattern
- Part 3: D-KMP layers and Team organization

Latest Update: May 25, 2021

The D-KMP ViewModel class

In our D-KMP architecture, the ViewModel is a Kotlin MultiPlatform class:

class DKMPViewModel(repo: Repository) {    val stateFlow: StateFlow<AppState>
get() = stateManager.mutableStateFlow
private val stateManager by lazy { StateManager(repo) }
val navigation by
lazy { Navigation(stateManager) }
}

The StateFlow

The StateFlow of our ViewModel is the component responsible for triggering a new UI layer recomposition, which happens each time its value changes.
As you can see from its definition above, its value type is a data class called AppState, which is defined as simply holding a recompositionIndex property:

data class AppState (
val recompositionIndex : Int = 0
)

The StateFlow is a read only component, which gets its value (via a computed property getter) from its read/write version, the MutableStateFlow, defined as a property of the StateManager component.

The StateManager

The StateManager is the core class of our ViewModel. It manages UI screen states and coroutine scopes. It also holds the MutableStateFlow, which is responsible for changing the StateFlow’s AppState value and hence triggering a UI recomposition.

class StateManager(repo: Repository) {  internal val mutableStateFlow = MutableStateFlow(AppState())  val screenStatesMap : MutableMap<ScreenIdentifier, ScreenState> = mutableMapOf()
val screenScopesMap : MutableMap<ScreenIdentifier,CoroutineScope> = mutableMapOf()

internal val dataRepository by lazy { repo }
fun triggerRecomposition() {
mutableStateFlow.value = AppState(mutableStateFlow.value.recompositionIndex+1)
}
}

The Navigation

The Navigation is shared among all platforms, which creates a very desirable consistency across all apps.
It is defined as a class holding the StateProviders and the Events, which are conveniently passed to the UI screens.

class Navigation(stateManager: StateManager) {  val stateProvider by lazy { StateProvider(stateManager) }
val events by
lazy { Events(stateManager) }
}

On each platform, UI screens can be defined with almost the same syntax. Here below we report the screens definition for our sample master/detail app (the link to the GitHub repo is at the end of this article).

On Compose (in Kotlin):

On SwiftUI (in Swift):

The StateProvider

The StateProvider provides the state to each screen. It‘s called at each UI recomposition, and retrieves the data from the StateManager’s screenStatesMap.

...
Screen.CountriesList -> CountriesListScreen (
countriesListState = stateProviders.get(screenIdentifier)
...
)
...

The Events

Events are functions we define in the shared code, which can be called by the UI layer on each platform. They typically perform actions that change the state of the app, and hence trigger a new UI recomposition.

...
Screen.CountriesList -> CountriesListScreen (
...
onFavoriteIconClick = { events.selectFavorite(countryName = it) }
)
...

You can understand all these components better by running the sample app, which you can get from the GitHub repository linked at the bottom of this article.

ViewModel files organization

In the shared code, we define a viewmodel folder, where we specify the navigation settings (inside NavigationSettings.kt) as well as the list of all screen in the app (inside ScreenEnum.kt).

For each screen, we also define these 3 files:

screenEvents.kt, with the event functions for that screen

screenInit.kt, with the initializiation settings for that screen

screenState.kt, with the data class for the state of that screen

All other files in the root of the “viewmodel” folder stay the same for any app, and don’t need to be modified.

The platform-specific code

On our platform specific code, we need first of all to create an instance of the DKMPViewModel, via platform-specific factory methods.

On Android, we pass the ApplicationContext as a parameter, as the Android framework needs it for things such as working with sqlite databases.

DKMPViewModel.Factory.getAndroidInstance(context)

On iOS, we don’t need to pass any parameter:

DKMPViewModel.Factory.getIosInstance()

Collecting the StateFlow

A very important part of our architecture is collecting the StateFlow (our platform-independent observable), which enables the UI layer recompositions, by listening to the AppState changes.

Let’s remember that in our architecture, the AppState is just made of the recompositionIndex value, which is incremented by 1 each time the StateManager’s triggerRecomposition() function is called.

On each recomposition, the UI screens get the new state via the StateProviders.

Let’s now see how we can configure the StateFlow on each platform.

on Android:

Collecting the StateFlow value on Android is really straightforward. It’s just one line! This is because the Android team has implemented a special StateFlow method collectAsState() as part of the JetpackCompose library.

class DKMPApp : Application() {
lateinit var model: DKMPViewModel
override fun onCreate() {
super.onCreate()
model = DKMPViewModel.Factory.getAndroidInstance(this)
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val model = (application as DKMPApp).model
setContent {
MyTheme {
MainComposable(model)
}
}
}
}
@Composable
fun MainComposable(model: DKMPViewModel) {
val appState by model.stateFlow.collectAsState()
val dkmpNav = appState.getNavigation(model)
dkmpNav.Router()
}

on iOS:

Collecting the StateFlow value on iOS currently requires a little boilerplate code, but we are hoping that JetBrains will soon make it easier. We have already opened an issue here. Please upvote it.
At the moment, we need to initialize a “listener” on the platform-specific ViewModel:

class AppObservableObject: ObservableObject {
let model : DKMPViewModel = DKMPViewModel.Factory().getIosInstance()
var dkmpNav : Navigation {
return self.appState.getNavigation(model: self.model)
}
@Published var appState : AppState = AppState()
init() {
model.onChange { newState in
self.appState = newState
}
}

}
@main
struct iosApp: App {
@StateObject var appObj = AppObservableObject()
var body: some Scene {
WindowGroup {
MainView(appObj: appObj)
}
}
}
struct MainView: View {
@ObservedObject var appObj: AppObservableObject
var body: some View {
let dkmpNav = appObj.dkmpNav
dkmpNav.router()
}
}

where onChange is an extension function we need to add to our KMP shared ViewModel, only needed by iOS:

fun DKMPViewModel.onChange(provideNewState: ((AppState) -> Unit)) : Closeable {
val job = Job()
stateFlow.onEach {
provideNewState(it)
}.launchIn(
CoroutineScope(Dispatchers.Main + job)
)
return object : Closeable {
override fun close() {
job.cancel()
}
}
}

The D-KMP DataLayer

In our D-KMP architecture, we are aiming for a very clear separation between the ViewModel and the DataLayer.

The DataLayer consists of a Repository collecting data from multiple sources, which typically are: webservices, runtime objects, platform settings, platform services, sqlite databases, realtime databases, local files, etc.

The Repository also manages any suitable caching mechanism, which can be extremely smart, given the opportunity to be written once and applied to all platforms. Any complexity doesn’t need to be replicated for every platform.

The Repository’s role is to process any data and provide it unformatted to the ViewModel, which is where all data formatting happens.

The ViewModel doesn’t need to be aware of the data source. It’s entirely up to the Repository to manage the sourcing/caching mechanism.

KMP libraries

On KMP, we clearly cannot use platform-specific libraries. But we don’t need to worry, as the new KMP libraries are even better! In some cases they are entirely written in Kotlin, in others they wrap the native platform-specific libraries under the hood. In any case, you don’t need to care about their implementation.

Here is a table, summarizing the libraries for each key area:

We already know everything about Declarative UIs, StateFlow and Coroutines. Let’s quickly mention the other ones:
- Ktor Http Client, developed by JetBrains. It’s the best KMP networking library, wrapping the native HTTP clients on each platform.
- Serialization, developed by JetBrains. It provides a very simple way to serialize data. It’s typically used in conjunction with Ktor Http Client, to parse Json data.
- SqlDelight, developed by Square. It provides multi-platform support to local SQLite databases.
- MultiPlatform Settings, developed by Russell Wolf (TouchLab). It’s wrapping SharedPreferences on Android, NSUserDefaults on iOS, Storage on Javascript.

Beside the existing KMP libraries, anyone can build its own custom KMP libraries, using the expect/actual mechanism, which allows you to wrap platform-specific code.

Some KMP libraries we expect coming very soon would be:
- Location, wrapping Android and iOS location services/APIs
- Bluetooth, wrapping Android and iOS bluetooth services/APIs (UPDATE: since the release of this article, a KMP library for BLE is now available!)
- In-App-Purchases, wrapping GooglePlay and AppStore in-app-purchases APIs
- Firebase, an official KMP implementation of services like Analytics, Firestore, Authentication (but no worries, there is already a third party KMP library wrapping the full Firebase suite)

Team organization

Let’s now talk about the new way of organizing a development team around the D-KMP architecture, which is quite different than traditional app development.
We have 4 main roles:

  • Declarative UI developer
    (JetpackCompose and SwiftUI)
  • ViewModel developer
    (KMP)
  • DataLayer developer
    (KMP)
  • Backend developer (Kotlin/JVM? Golang?)

Declarative UI developer
We believe that in a D-KMP team, the UI developer should be multi-platform, managing both JetpackCompose and SwiftUI implementations.
Considering the simplicity of the DeclarativeUIs frameworks, it’s totally feasible (and fun!) for the same developer to manage both. Keeping the focus on both frameworks, helps the developer to have a better understanding of the UI trends and to build the best user experience.

ViewModel developer
This role is the most crucial, and managerial. It’s the one at the center of the development, which requires a full understanding of the project.
The ViewModel developer needs to engage with the UI developer for defining all screen state objects, and with the DataLayer developer for defining the required data.
The ViewModel developer also needs to organize text localizations.

DataLayer developer
This is the most demanding role in terms of technical knowledge.
The DataLayer developer has to deal with anything that is data, including the implementation of the caching mechanisms.
He/she needs to organize all data sources, including platform-specific ones if needed: for example in case of location or bluetooth services.
This role requires a full understanding of Kotlin MultiPlatform, including the capability of writing custom KMP libraries.

BackEnd developer
In a D-KMP team, this role is still important, but less than in platform-specific development, where webservices had to be coordinated with all app teams (Android, iOS, Web).
In D-KMP, the BackEnd developer is coordinated directly by the DataLayer developer, who is the one responsible to define which is the data required by the app. The webservices don’t need to have an understanding of what’s happening in the app.
Webservices could be written in any language of choice (for example Golang), but if you are interested in a Kotlin full-stack approach you can also consider writing them in Kotlin/JVM, via the Ktor framework. Using Kotlin on the server-side allows you to share data class definitions between the client’s DataLayer and the webservices.

This is the end of the article. Thanks for reading!

You can find the sample app on GitHub:

https://github.com/dbaroncelli/D-KMP-sample

If you have any question, feel free to contact me or leave your comments!

For updates on the D-KMP Architecture, please follow me on Twitter @dbaroncellimob

Special thanks! (I owe very much to the knowledge shared by these guys)

  • Jim Sproach (@jimsproach): the progenitor of Jetpack Compose
  • Roman Elizarov (@relizarov): the man behind the evolution of Kotlin
  • Hannes Dorfmann (@sockeqwe): for his inspiring articles on MVI
  • Kevin Galligan (@kpgalligan): for his work on Kotlin MultiPlatform
  • Leland Richardson (@intelligibabble): for his articles on Declarative UIs
  • John O’Reilly (@joreilly): for his samples with Declarative UIs and KMP
  • Siamak Ashrafi (@biocodes): for his thoughts on MVI and Declarative UIs
  • Jake Wharton (@jakewharton): for his expert feedbacks on Kotlin

--

--

Daniele Baroncelli

Mobile architect (client/server), with over 15 years experience. Focusing on Android, iOS, Kotlin, Golang, Compose, SwiftUI, KMP, MVI.