Blog Infos
Author
Published
Topics
,
Published
Topics
,

In my previous story, I’ve talked about why I believe we can strongly improve the UI State management between the View and ViewModel on Android, by using aModel-View-Intent (MVI) architecture with the help of a Finite State Machine (FSM):

Managing the UI State by using a Finite State Machine and MVI architecture

In this story, I’ll guide you through the steps needed to upgrade this solution to the Kotlin Multiplatform Mobile (KMM) universe, where one can benefit from a common source, containing MVI+FSM, so that both platforms – Android and iOS -, can inherit its benefits making them only responsible for platform dependent implementations: the UI/UX.

Before we start, I’ll assume the reader has basic knowledge about KMM, how to setup a project, how to create common code, how to request platform specific implementations (expect/actual), and have read my previous article.

Platform prerequisites
Android:

Jetpack Compose and Flow (Job).

iOS:

SwiftUI and Combine (Publishers & Subscription).

Common prerequisites

After creating a new KMM project, we need to make sure we can use our FSM and MVI implementations.

FSM:

Tinder’s State Machine is not yet upgraded to be used as a multiplatform library, but luckily there’s a pull request (PR) with that implementation, which is quite simple actually. Until this PR is accepted and published, one option is to copy StateMachine.kt and add it to our project in the shared module.

note: If you wonder why can’t we take advantage of JitPack service, there’s an issue about it.

MVI:

Orbit Multiplatform library – you can guess it by its name -, is already multiplatform-ready. Orbit also provides us with a swift-gradle-plugin to generate .swift helper classes so we don’t have to worry about how things work under the hood. To listen for state changes we just consume an ObservableObject inside a View and the Combine/Flow communications and lifecycles are automatically managed for us.

note: at the time of writing, the authors are in the process of updating it for newer Kotlin versions. Right now it doesn’t work with versions starting from 1.6.0.

This plugin does the code generation heavy-lifting for us, but I believe we gain from knowing what’s happening behind the curtains, that’s why I’ll guide you through the logic of creating those classes. In the end, we’re not strictly dependent on it.

ViewModel:

To take advantage of a shared lifecycle scope – to launch a coroutine that will be cancelled when the ViewModel is cleared -, I’ll use IceRock’s moko-mvvm library ViewModel (dev.icerock.moko:mvvm-core:${latest-version}) as the parent class of our shared ViewModel (instead of the Android’s one).

Who’s Next?!

I’ll be using the same project I’ve used in the previous article to illustrate this journey. As you can see below, the output is the same, and that’s because we’re taking advantage of the same business logic and architectural implementation – written, tested and validated once – leaving only the UI creation for each platform to implement. The beauty of KMM.

Android

1

iOS (screen recording with the simulator lags the animations)

Migration

Android UI’s it’s already done, we don’t need to change it.

The next steps are:

  1. Sharing the FSM+MVI architecture and the ViewModel;
  2. Handle Flow’s and Publisher’s lifecycles;
  3. Consume state changes on iOS.

All the FSM and MVI code will be moved to the commonMain folder inside the shared module:

shared module

Sharing the ViewModel

Koin will help us with this task. First, we need to create a class where we’ll define the expect “rules”:

expect fun platformModule(): Module
object DependencyInjection {
fun initKoin(appDeclaration: KoinAppDeclaration) {
startKoin {
appDeclaration()
modules(commonModule(), platformModule())
}
}
internal fun commonModule() = module { ... }
}

commonMain

 

This class lives inside commonMain’s folder and it also contains the dependency injection initiation logic. Next, we need to create one for each platform with its actual implementation:

actual fun platformModule() = module {
viewModel { TimerViewModel() }
}

androidMain

actual fun platformModule() = module {
factory { TimerViewModel() }
}
object ViewModels : KoinComponent {
fun timerViewModel() = get<TimerViewModel>()
}

iosMain

 

They are very similar, but on iOS’s implementation, we need to expose a getter for the ViewModel. On Android, Koin offers handy getViewModel extensions.

Architecture shared ✅

Job Offers

Job Offers


    Android Software Engineer (f/m/d)

    Paradox Cat GmbH
    Munich
    • Full Time
    apply now

    Senior Android Software Engineer (f/m/d)

    Paradox Cat GmbH
    Munich
    • Full Time
    apply now

    Mobile Engineer

    OLX Group
    Remote, Portugal, Spain, Romania, Poland
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

,

Overview of UI testing in Compose

Jetpack Compose has no views, so how do we test it? Come learn about the basics of UI testing in Jetpack Compose.
Watch Video

Overview of UI testing in Compose

Michael Bailey
Distinguished Engineer
American Express

Overview of UI testing in Compose

Michael Bailey
Distinguished Engine ...
American Express

Overview of UI testing in Compose

Michael Bailey
Distinguished Engineer
American Express

Jobs

Exposing a Job for a Publisher

To consume state emissions from our shared code we use Kotlin Flows, but we need to bridge the gap between them and the Swift Combine Publishers. The following code was based on the very enlightening article from John O’Reilly. It will help us achieve it and handle the Publisher’s lifecycle on the iOS side.

We start by creating an extension function that returns a background Job given a Flow:

fun Flow<*>.subscribe(
onEach: (item: Any) -> Unit,
onComplete: () -> Unit,
onThrow: (error: Throwable) -> Unit
): Job =
this.onEach { onEach(it as Any) }
.catch { onThrow(it) }
.onCompletion { onComplete() }
.launchIn(CoroutineScope(Job() + Dispatchers.Main))
view raw Subscribe.kt hosted with ❤ by GitHub

iosMain

 

Next, inside iosApp, we need to create a Subscription that will hold the Flow and Job instances to manage the subscribe and cancel logic for us:

import Combine
import shared
struct FlowPublisher<T: Any>: Publisher {
public typealias Output = T
public typealias Failure = Never
private let flow: Kotlinx_coroutines_coreFlow
public init(flow: Kotlinx_coroutines_coreFlow) {
self.flow = flow
}
public func receive<S: Subscriber>(subscriber: S) where S.Input == T, S.Failure == Failure {
subscriber.receive(subscription: FlowSubscription(flow: flow, subscriber: subscriber))
}
final class FlowSubscription<S: Subscriber>: Subscription where S.Input == T, S.Failure == Failure {
private var subscriber: S?
private var job: Kotlinx_coroutines_coreJob?
private let flow: Kotlinx_coroutines_coreFlow
init(flow: Kotlinx_coroutines_coreFlow, subscriber: S) {
self.flow = flow
self.subscriber = subscriber
job = SubscribeKt.subscribe(
flow,
onEach: { item in if let item = item as? T { _ = subscriber.receive(item) }},
onComplete: { subscriber.receive(completion: .finished) },
onThrow: { error in debugPrint(error) }
)
}
func cancel() {
subscriber = nil
job?.cancel(cause: nil)
}
}
}
view raw Publisher.swift hosted with ❤ by GitHub

Bridge between Flow and Publisher

 

All items received by the Flow will be forward to the subscriber. Also, when Flow‘s onComplete is called the subscriber will also complete. Consequently cancel() will be invoked and it will clear the subscriber and cancel the job.

If you remember, our MVI architecture is tied to the viewModelScope which means that when the ViewModel is cleared so it will the Flow and the Publisher.

Lifecycle handled ✅

Before continue to the next step, let’s add this handy extension:

public extension Kotlinx_coroutines_coreFlow {
func asPublisher<T: AnyObject>() -> AnyPublisher<T, Never> {
(FlowPublisher(flow: self) as FlowPublisher<T>).eraseToAnyPublisher()
}
}
The ObservableObject

The final step of this migration is exposing the UI State as a Published variable. To do so we’ll create a wrapper class that conforms with the ObservableObjectprotocol. That class will contain an instance from the shared ViewModel to expose it’s state and public methods:

import SwiftUI
import Combine
import shared
public class TimerViewModelObservableObject : ObservableObject {
private var wrapped: TimerViewModel
@Published private(set) var state: TimerUiState
init(wrapped: TimerViewModel) {
self.wrapped = wrapped
state = wrapped.stateFlow.value as! TimerUiState
(wrapped.stateFlow.asPublisher() as AnyPublisher<TimerUiState, Never>)
.receive(on: RunLoop.main)
.assign(to: &$state)
}
deinit {
wrapped.onCleared()
}
func settingTime() {
wrapped.settingTime()
}
func setTime(seconds: Int32) {
wrapped.setTime(seconds: seconds)
}
//ramaining public functions...
}

ObservableObject wrapper

 

The following extension will become also quite handy:

public extension TimerViewModel {
func asObservableObject() -> TimerViewModelObservableObject {
return TimerViewModelObservableObject(wrapped: self)
}
}

ObservableObject extension

 

And to consume states in the View:

import SwiftUI
import shared
struct TimerView: View {
@StateObject private var viewModel = ViewModels().timerViewModel().asObservableObject()
@State private var currentProgress: Float = 0.0
var body: some View {
ZStack {
//...
CircularProgressView(progress: $currentProgress)
if(viewModel.state.isRestarting) {
//...
}
}
.onReceive(viewModel.$state, perform: { new in
currentProgress = new.progress
})
}
}
struct CircularProgressView: View {
@Binding var progress: Float
//...
}
view raw TimerView.swift hosted with ❤ by GitHub

Now that we have the @Published var state at our disposal to be consumed we can choose to do it as a StateObject or ObservedObject. This example also illustrates two use-cases where we can query the state properties directly by viewModel.state.something or through a @State var when we need that property to behave like a State.

iOS consuming state changes ✅

The final step of this migration is completed.

Conclusions

In this article, we’ve learned how to migrate a platform working architecture into a KMM project in order to take advantage of its code-sharing philosophy. We’ve also deep-dived inside Orbit Multiplatform’s library swift-gradle-plugin and understood what classes are being generated, their purpose and how they work together.

I won’t be sharing Who’s Next!? project for now, but rest assured, I’ve created Expressus a Kotlin Multiplatform Coffee Machine:

This project contains all the logic discussed in both of my articles and includes a little bonus 😉, take a look for yourself.

As always, I hope you find this article useful, thanks for reading.

This article was originally published on proandroiddev.com on April 19, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
In this part of the series, we will plan our first screen in Jetpack…
READ MORE
blog
We’ll be selecting a time whenever a user presses a number key. Following points…
READ MORE
blog
Ask yourself a fairly standard question for any interview to fill an Android position:…
READ MORE
blog
This is part of a multi-part series about learning to use Jetpack Compose through…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu