The recommended architecture for Android development is MVVM. However, when using Kotlin Multiplatform Mobile(KMM), the Jetpack’s ViewModel is not available. This means a different dependency or architecture needs to be used. There is no official way to go about this, but thanks to the Kotlin community, different approaches have surfaced.
Separation of concerns
The purpose of Kotlin Multiplatform Mobile (KMM) is to share all the business and possibly some UI logic between the iOS/Android apps. This reduces code duplication while retaining access to the native features like Services in Android or Widgets in iOS.
Architecture success metrics
According to the official documentation, there is no clear definition about what code should be shared.
Layer | Recommendation on sharing |
---|---|
Business logic | Yes |
Platform access | Yes/no. You’ll still need to use platform-specific APIs, but you can share the behavior. |
Frontend behavior (reaction to inputs & communication with the backend) | Yes/no. Consider these architectural patterns. |
User interface (including animations & transitions) | No. It needs to be platform-specific. |
This means that it is up to the user to choose what suits best for her. Let’s compare some of the available architectures and evaluate their feasibility by looking at their features.
MVVM with separate ViewModels
In the Kotlin example project, there is no shared ViewModel. Developer needs to use the shared singleton object, and derive a ViewModel out of that. In SwiftUI, this object can then be bind to the view via the @ObservableObject.
class ViewModel: ObservableObject {
let sdk: SpaceXSDK
@Published var launches = LoadableLaunches.loading
init(sdk: SpaceXSDK) {
self.sdk = sdk
self.loadLaunches(forceReload: false)
}
func loadLaunches(forceReload: Bool) {
In Android, the Space launcher array is retrieved straight from the shared code and used in a simple RecyclerView RecyclerView adapter.
private fun displayLaunches(needReload: Boolean) {
progressBarView.isVisible = true
mainScope.launch {
kotlin.runCatching {
sdk.getLaunches(needReload)
}.onSuccess {
launchesRvAdapter.launches = it
launchesRvAdapter.notifyDataSetChanged()
Ease of use
The shared business logic can be written easily. From that module, asynchronous data is returned to native platforms. However, both the Android and iOS targets need a separate presentation logic. That logic also needs to be independently tested.
There are no extra dependencies, so setup is very easy. New ViewModels can be added as desired, so the project is expendable
Summary
Separate ViewModel approach can be considered for developers who want to quickly write small apps, or want an architecture that is proven to work and is expandable. The presentation logic is not shared, so that code needs to be written and tested twice.
MVVM with shared ViewModel
The Moko-MVVM library provides a shared ViewModel object. However, it is not available for SwiftUI or Jetpack Compose. This means, in iOS, a ViewController needs to be used:
class SimpleViewController: UIViewController {
@IBOutlet private var counterLabel: UILabel!
private var viewModel: SimpleViewModel!
override func viewDidLoad() {
super.viewDidLoad()
viewModel = SimpleViewModel()
counterLabel.bindText(liveData: viewModel.counter)
}
The same goes for Android – Instead of Compose, a Data binding set in the Activity’s layout file needs to be used.
<data>
<variable
name="viewModel"
type="com.icerockdev.library.sample1.SimpleViewModel" />
</data>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{viewModel.counter.ld}" />
This approach is very clean, and with a shared presentation logic. But since it doesn’t follow the latest programming patterns, like declarative UI, it might not be preferred.
Read more about moko-MVVM in this blog post or from their Github repository.
MVI
MVI is the the evolution of MVP pattern and is designed for declarative UI frameworks. It is therefore recommended to be used with SwiftUI and Jetpack Compose.
The data flow is unidirectional, meaning View updates itself according to changes to the model. The user can then send actions to the Model, to which’s changes the View is subscribed.
View does not contain any logic, thus state management can be delegated to the shared KMM module.
D-KMP
D-KMP is a suggested solution to share the model and user actions in KMM. It is not a library, but a proposed structure of code. Straight off the bat, it comes with many limitations.
Requires considerable amount of boilerplate code
Classes for each view
- Events
- Init
- State
Classes for each data points
- A file for each of the repository’s functions
- Files for each of the datasources
- A file for each of the data object
and so on
For a simple demo app, other patterns get away with less than half of the classes.
Bad UI
The iOS app doesn’t follow the native UI practices. View back/forth navigation animation is not working.
Instead, it uses a drag gesture
.gesture(
DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded({ value in
if value.translation.width > 0 { // RIGHT SWIPE
if (!self.only1ScreenInBackstack) {
self.exitScreen()
}
}
})
)
to simulate navigating from the view stack. exitScreen() triggers the view recomposition. This is not the expected behaviour in a iOS app.
Summary
With D-KMP, presenter logic is shared and declarative UI can be used. However, it introduces new View behaviour, which needs to be implemented in SwiftUI and doesn’t look and feel native. The app state is more testable, but it defeats the purpose of KMM where native UI elements should be used.
Redux pattern
Simplified Redux pattern is used in the official Kotlin example to share the app state in Kotlin. The redux pattern is implemented manually in the shared module.
The whole state of the app is stored in an object tree inside a single Store
object. The only way to change the state tree is to emit an action. Store produces effects that the UI subscribes to.
State update from KMM to SwiftUI
In iOS, an ObservableObject wrapper around the shared module is required to receive updates.
The Store
object dispatches events if there are updates to data source, or view state has changed. It can be the result of either user or async task.
is FeedAction.Data -> {
if (oldState.progress) {
val selected = oldState.selectedFeed?.let {
if (action.feeds.contains(it)) it else null
}
**FeedState(false, action.feeds, selected)**
} else {
launch { sideEffect.emit(FeedSideEffect.Error(Exception("Unexpected action"))) }
oldState
}
}
In iOS, relevant views observe this Store
class’s FeedState
via ObservableObject
,
class ObservableFeedStore: ObservableObject {
@Published public var state: FeedState = FeedState(progress: false, feeds: [], selectedFeed: nil)
@Published public var sideEffect: FeedSideEffect?
var stateWatcher : Closeable?
init(store: FeedStore) {
and can update the list of Feed when there are updates.
func body(props: Props) -> some View {
List {
ForEach(props.defaultFeeds) { FeedRow(feed: $0) }
ForEach(props.userFeeds) { FeedRow(feed: $0) }
.onDelete( perform: { set in
set.map { props.userFeeds[$0] }.forEach { props.onRemove($0.sourceUrl) }
})
}
View update from SwiftUI
If there is a button press, a FeedAction.Refresh
event is dispatched
return Props(loading: state.progress,
items: state.mainFeedPosts(),
feedOptions: [.all] + state.feeds.map { FeedPickerOption.feed($0)},
selectedFeedOption: selectedFeedOption,
onReloadFeed: { reload in
**dispatch(FeedAction.Refresh(forceLoad: reload))**
},
onSelectFeed: { feed in
dispatch(FeedAction.SelectFeed(feed: feed))
})
Which in turn is calls straight into KMM code, where new View State is created, and dispatched back to SwiftUI
val newState = when (action) {
is FeedAction.Refresh -> {
if (oldState.progress) {
launch { sideEffect.emit(FeedSideEffect.Error(Exception("In progress"))) }
oldState
} else {
launch { loadAllFeeds(action.forceLoad) }
oldState.copy(progress = true)
}
}
Native UI
Opposed to D-KMP, native UI elements are used.
Redux summary
Simplified redux pattern from can be used to share the App state. Developer needs to be aware that it uses StateFlow to publish state updates, which currently might not be desirable. The user also needs to copy/implement their own redux pattern. It is not part of a library.
Similarly to D-KMP, learning new concepts and adding boilerplate code is needed. The boilerplate however seems considerably less, and the pattern is more well defined.
Conclusion
While KMM being relatively young, developers have proposed different solutions for app architecture. Depending on your preferences, MVVM with separate ViewModel can be used for easier setup and use. It comes with the limitation that UI presentation logic is not shared.
There is currently no well defined architecture to share presentation code. Kotlin’s StateFlow and custom architecture patterns, following either MVVM or MVI, can be considered.
There are even more patterns available, and anyone can put together their own. AAKira Architecture list has some more examples.
Architecture name | Shared business logic | Shared presenter | Declarative UI | Shared UI code |
---|---|---|---|---|
MVVM with separate view models | + | – | + | – |
MVVM with Moko-MVVM | + | + | – | – |
MVI with D-KMP | + | + | + | – |
Redux from RSS Reader sample | + | + | + | – |
Comparison of a few KMM app architectureshttps://t.co/fUe3jCSiRq#kotlin #multiplatform #SwiftUI
— Tõnis Tiganik (@tonisives) June 1, 2021