Categories
Apps Architecture Kotlin Multiplatform Mobile Uncategorized

Kotlin Multiplatform app architectures

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.

LayerRecommendation on sharing
Business logicYes
Platform accessYes/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.
Best practices for the KMM app architecture

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()
Official Kotlin Multiplatform Mobile example app

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 nameShared business logicShared presenterDeclarative UIShared UI code
MVVM with separate view models++
MVVM with Moko-MVVM++
MVI with D-KMP+++
Redux from RSS Reader sample+++
Architecture comparison

Leave a Reply