Building Reactive Cross-Platform Native Apps in Kotlin

Aubrey Goodman
Granular Engineering
12 min readAug 9, 2021

Over the last few years, Granular has made a significant investment in cross-platform solutions using the Kotlin Multiplatform (KMP) framework. Many of the features of our Insights mobile app have been built from the ground up using KMP, including sophisticated components like image overlays on maps. In fact, we have found novel techniques for leveraging pure Kotlin components across all areas of the app. We even developed solutions to drive UI view model behavior using reactive flow-based data streams. In order to achieve this, we built upon an existing open source library developed in 2018 by engineers at Motiv, called PMVP. The library was introduced in an engineering blog post (link) before being published as a Swift CocoaPod in early 2019 (link).

The goal of this framework was to standardize the mechanisms mobile app developers use to synchronize data between mobile devices and cloud resources. Whereas the original Swift implementation was designed to be open and flexible, the new Kotlin version was designed to be opinionated. This is partially due to lessons learned along the way. It became clear we could leverage existing frameworks, such as ktor and sqldelight, for networking and data persistence, respectively. This eliminated the need for platform components to be injected into a common domain-agnostic interface. Our original intentions of injecting Room and CoreData components into the KMP code with wrappers and interfaces proved to be more trouble than it was worth in the end. We could achieve the same level of performance and durability from a pure Kotlin sqldelight solution, which can easily be shared between platforms.

Data Persistence

After porting the foundation pieces from Swift to Kotlin as pmvp-library, we built out boilerplate implementations of the ktor and sqldelight concerns in separate dedicated modules as pmvp-ktor and pmvp-sqldelight, respectively. Many of the interfaces defined in PMVP rely on constrained generics to achieve complex tasks. Here, we use a mix of composition and inheritance to accomplish these tasks. We find most domain models involve collections of objects, indexed by primary keys and related by foreign keys. The lowest level of the library defines storage interfaces as follows:

interface Proxy<K> {
val key: K
}
interface Storage<K, T: Proxy<K>>
interface ForeignKeyStorage<FK, K, T: Proxy<K>>

These interfaces define standard persistence methods for querying generic domain objects, adopting an upsert paradigm. As a result, there are methods like the following:

fun objects(): Flow<List<T>>
fun objectFor(key: K): Flow<T?>
fun updateObject(model: T): Flow<T>
fun destroyObject(model: T): Flow<T>

Similar to these methods, there is a corresponding set for foreign key relationships, such as the following:

fun objects(foreignKey: FK): Flow<List<T>>
fun objectFor(foreignKey: FK, key: K): Flow<T?>
fun updateObject(foreignKey: FK, model: T): Flow<T>
fun destroyObject(foreignKey: FK, model: T): Flow<T>

Using these building blocks, developers can compose a wide variety of persistence solutions. Domain model components can be easily and quickly synthesized using the sqldelight and ktor components to interact with local and remote storage subsystems, respectively. If your domain needs a Playlist object, you can easily compose a persistence solution with the tools in the toolkit, like this:

class PlaylistLocalStorage: SqlDelightStorage<Int, PlaylistModel, Playlists>(…)

In this example, the playlist domain object is represented by the PlaylistModel class, which has a key property with the Int type. The Playlists class is autogenerated by sqldelight based on a schema descriptor. All the developer needs to build in order to persist data is the sqldelight schema and a simple Kotlin class to represent the metadata. That’s it! Similarly, we could implement a component to request data from a web service endpoint with something like this:

class PlaylistRemoteStorage: KtorStorage<Int, PlaylistModel>(…)

With just a small amount of code, we can implement a convenient flow-based component for accessing web service contents. We’re glossing over some of the details here, of course. Developers would be required to inject some lambda for the custom bits. If you needed to use HTTP GET to retrieve a collection of data from a specific endpoint, it might look like this:

class PlaylistRemoteStorage(
private val baseUrl: String
): KtorStorage<Int, PlaylistModel>(
collectionRequest = { token, client ->
client.get<String> {
url(“$baseUrl/api/v2/playlists.json”)
accept(ContentType.Application.Json)
addAuthTokenHeader(token)
}
},

)

By injecting custom behavior into these standardized lambda expressions, developers can take advantage of the boilerplate logic baked into the KtorStorage class without sacrificing extensibility. So, this allows for local and remote persistence using standard interfaces to be built quickly. Now, let’s review the provider classes, which delegate to local and remote storage components to achieve a robust data management layer.

Rather than access data directly from the storage components, we introduce a class to act as the primary surface for accessing domain objects. Each domain object has a dedicated provider class and relies on a single local storage and a single remote storage for coordinating synchronization concerns between mobile device and cloud. Following the above metaphor, we might have a provider like this:

class PlaylistProvider(
localStorage: Storage<Int, PlaylistModel>,
remoteStorage: Storage<Int, PlaylistModel>
): Provider<Int, PlaylistModel>(localStorage, remoteStorage)

With this class, we can now perform a wide variety of data access tasks, all using a standard set of convenient flow-based methods. As an example, we might want to fetch a single playlist by its primary key. This would likely look something like the following:

val playlist: Flow<PlaylistModel?> = playlistProvider.objectFor(key)

Observant readers will note the returned object is a Flow, not an instance of the domain object directly. This is done for many reasons. First, it offers a natural environment for error handling. Also, flows are easy to compose with other flows to achieve complex behavior. As we’ll see later in view models, this can be extremely powerful. Most importantly, this is a hot flow; if the underlying data in the local storage changes, the flow will emit a new value with the latest version of the model. This is especially useful in situations where a sync operation results in an update to on-screen data. The view model can be simply configured to react to the latest model using a hot flow observer.

Before we get into the view model architecture, let’s first review the sync components. So far, we’ve covered the local and remote storage concerns and wrapped them in a convenient provider. Now we’ll see how the provider works with a sync orchestrator to coordinate interactions between local and remote storage. PMVP offers an interface for this, as well as implementations for a small number of standard cases. The orchestration is managed by this interface:

interface SyncOrchestrator<K, T: Proxy<K>> {
fun register(local: Storage<K, T>, remote: Storage<K, T>)
fun perform(): Flow<Float>
}

This interface enables the provider and storage components to work together to facilitate some custom sync process without exposing the storage components themselves. As a result, it’s impossible for developers to access the local or remote storage directly given a reference to a provider object. They must use the provider’s public methods. This helps prevent engineers from introducing bugs, while supporting the orchestration process smoothly.

For simple remote collections, it’s often sufficient to fetch all the remote objects and compare them to the local objects in memory before deciding to update local data. For this scenario, we have a CollectionDifferenceSyncOrchestrator class and its foreign key counterpart. Continuing the playlist metaphor, we might have a class like this:

class PlaylistSyncOrchestrator: CollectionDifferenceSyncOrchestrator<Int, PlaylistModel>()

This class can now be used to coordinate the sync for our Playlist domain model. We can easily manage a sync process by invoking a method on the provider and passing in the sync orchestrator, like this:

val progressFlow: Flow<Float> =
playlistProvider.syncUsing(playlistSyncOrchestrator)

At this point, we can zoom out a bit and consider how this domain object is related to others. If we’re building a module with a narrow scope, we may wish to compose a simple mechanism where we can invoke a sync operation and be able to sync multiple domain objects together. We may alternatively need to wait for one domain object to finish syncing before we attempt to sync another. For all these scenarios, we have designed a simple interface, as follows:

interface Progressable {
fun perform(): Flow<Float>
}
interface SyncEngine: Progressable

With this final puzzle piece, we can compose rich components by implementing the SyncEngine interface and delegating to other components. We can even compose solutions where one engine delegates to another. This supports both serial and parallel execution in any arbitrary composition, allowing a wide range of options to the developer. Here is a simple example for the playlist sync engine:

class PlaylistSyncEngine(
private val playlistProvider: Provider<Int, PlaylistModel>,
private val playlistSyncOrchestrator: SyncOrchestrator<Int, PlaylistModel>
) : SyncEngine {
override fun perform(): Flow<Float> =
playlistProvider.syncUsing(playlistSyncOrchestrator)
}

User Interaction

In an effort to standardize the tooling used to achieve a cross-platform solution in KMP, we agreed to adopt the MVI architecture, using unidirectional data flow and user intent as a founding principle. This fits very well with Kotlin Flow, as we can define view models with hot flow properties for state and event streams and offer a single method for conveying intents into the view model. This enables developers to define presentation layer logic and behavior in a KMP view model. The value of this capability can not be understated. View models can be unit tested once, rather than duplicating test logic for both platforms.

The Android platform needs no additional configuration to consume hot flows from view models. iOS simply required the introduction of RxSwift in order to allow reactive observers to subscribe to flows. The following sections describe the techniques used to connect a view model into platform-specific UI components.

View Model Example

class PlaylistEditViewModel : 
ViewStateProducer<PlaylistEditState>,
ViewIntentConsumer<PlaylistEditState> {
private val stateFlow = MutableStateFlow(PlaylistEditState())
override val viewState: Flow<PlaylistEditState> = stateFlow
override fun onIntent(intent: PlaylistEditIntent) {
when(intent) {
is SetName -> stateFlow.tryEmit(
stateFlow.value.copy(name = intent.name)
)
}
}
}
data class PlaylistEditState(val name: String = “”) : ViewStatesealed class PlaylistEditIntent : ViewIntent {
data class SetName(val name: String): PlaylistEditIntent()
}
sealed class PlaylistEditEvent : ViewEvent {
object Exit : PlaylistEditEvent()
}

Android Example

The following is an excerpt of an example fragment, showing how the state and event streams are used to drive the view:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
playlistEditViewModel.viewState
.onEach { render(it) }
.launchIn(lifecycle.coroutineScope)
playlistEditViewModel.viewEvents
.onEach { handle(it) }
.launchIn(lifecycle.coroutineScope)
}
private fun render(state: PlaylistEditState) {
// use state details to update view properties
}
private fun handle(event: PlaylistEditEvent) {
// use event details to trigger navigation if needed
}

iOS Example

The following is an excerpt of an example view controller, showing how the state and event streams are used to drive the view:

// in a Kotlin extension
val PlaylistEditViewModel.viewStateConsumer
get() = FlowConsumer(MainScope(), viewState)
val PlaylistEditViewModel.viewEventsConsumer
get() = FlowConsumer(MainScope(), viewEvents)
// in a RxSwift extension
extension PlaylistEditViewModel {
var stateObservable: Observable<PlaylistEditState> {
return wrapObservable(viewStateConsumer)
}
var eventsObservable: Observable<PlaylistEditEvent> {
return wrapObservable(viewEventsConsumer)
}
}
// in a Swift UIViewController
override func viewWillAppear(animated: Bool) {
playlistEditViewModel.viewStateObservable
.bind(to: Binder(self) { vc, state in vc.render(state) })
.disposed(by: disposeBag)
playlistEditViewModel.viewEventsObservable
.bind(to: Binder(self) { vc, event in vc.handle(event) })
.disposed(by: disposeBag)
}
override func viewDidDisappear(animated: Bool) {
disposeBag = DisposeBag()
}
private func render(_ state: PlaylistEditState) {
// use state details to update view properties
}
private func handle(_ event: PlaylistEditEvent) {
// use event details to trigger navigation if needed
}

Generalized Presenter

In both Android and iOS cases, we see the same pattern for receiving the state from a view model. This represents the Presenter behavior, which completes the cycle between data layer and user. We can formalize this by introducing a new interface, which both the platforms can implement to propagate state details to view objects, such as labels and buttons. Similarly, we define a parallel interface for receiving events from the view model as well. Together, they look like this:

interface ViewStateConsumer<T: ViewState> {
fun render(state: T)
}
interface ViewEventConsumer<T: ViewEvent> {
fun handle(event: T)
}

While this is freely available to Android at any level of the app, these interfaces are not available in the Swift code directly. Instead, we would need to define an interface to include the concrete types, like this:

interface PlaylistEditStateConsumer: ViewStateConsumer<PlaylistEditState>

There is one quick note about Swift integration of KMP components we must acknowledge. Occasionally, there will be some minor type erasure when using constrained generics. In this specific example, the Swift interpretation of the consumer interface will use the wrong type in the method signature. It will instead look like this:

func render(state: ViewState) {
guard let playlistEditState = state as? PlaylistEditState else { return }

}

Here, we have a type-safe technique for accessing the domain-specific state received from the view model. This brings us almost to the finish line. We have all the parts we need to compose any arbitrary user experience, but we don’t have a mechanism for injecting various components to connect all the pieces together.

Dependency Injection

In order to guarantee all the KMP classes are configured correctly and accessible universally within the runtime environment, we built a simple and powerful tool to facilitate dependency injection. The system relies on a simple empty interface implemented by all components responsible for configuring dependencies.

interface CommonComponent

This interface acts as a binding for defining any DI interface used throughout the ecosystem. Arbitrary components can be composed easily by extending this common interface. We use zero argument methods by design to force the DI code to define all the dependencies internally, rather than having them provided externally. A component responsible for delivering the data components for the playlist metaphor might look like this:

interface PlaylistDataComponent : CommonComponent {
fun playlistLocalStorage(): Storage<Int, PlaylistModel>
fun playlistRemoteStorage(): Storage<Int, PlaylistModel>
fun playlistProvider(): PlaylistProvider
}

Such an interface might be implemented two different ways — one for standard operation and one for automation testing. An example of what the standard component implementation might look like is as follows:

class PlaylistDataComponentImpl(
private val database: PlaylistDb
) : PlaylistDataComponent {
private val playlistLocal: PlaylistLocalStorage by lazy {
PlaylistLocalStorage(database)
}
override fun playlistLocalStorage() : Storage<Int, PlaylistModel> = playlistLocal

}

At the top level of the app, we inject a single class implementing the common interfaces used in the KMP code. This relies on a special accessor designed to enable quick and easy access to components. The single class is constructed and injected as follows:

val appComponent: CommonComponent = AppComponentImpl(options)
Components.set(appComponent)

Any time after invocation of this method, other components in the runtime environment can access a component by using a reified type method. This might look something like the following:

val playlistProvider: PlaylistProvider = Components<PlaylistDataComponent>().playlistProvider()

This connects into the rest of the ecosystem in the form of zero argument constructors, typically found in view models. The Kotlin/Native bridge does not support default values. This causes an issue with iOS where the parameters must be provided even if the Kotlin code has default values defined. As a result, this manifests in component lookups in convenience constructors like the following:

class PlaylistEditViewModel(
private val playlistProvider: PlaylistProvider
) {
constructor() : this(
playlistProvider = Components<PlaylistDataComponent>().playlistProvider()
)

}

Automation Testing

In order to support a rich feature set in UI automation testing, we use a fake data component with built-in data describing a set of specific predefined scenarios. This allows us to select from an available list of options to control the scenario selection. We can do this as part of the UI test suite to enable engineers to define automation tests based on tailored scenarios.

We might have a simple scenario with specific predefined playlists with known names and keys. We can define this data in a fake component implementation, similar to the one described above. All we need is to define a class conforming to the same component interface, and we can use any arbitrary implementation internally to the fake wrapper. To facilitate this, we built several classes to achieve various strategies. The following is a short list of such classes:

class StaticStorage(val data: Map<K,T>): Storage<K, T>
class InMemoryStorage(var data: MutableMap<K, T>): Storage<K, T>
class JsonStaticStorage(val jsonData: String): Storage<K, T>

With these simple tools, we can compose fake data scenarios with arbitrary behaviors. Static components can be used to support read-only scenarios, such as presenting a list of options to select. In-memory components can be used for dynamic situations, such as the edit view model example above. Unsurprisingly, the JSON static components can be used for decoding existing JSON data stored in a file. All these options can be invaluable in the construction of UI automation tests for both iOS and Android platforms.

By defining the fake component implementation in KMP as well, we can share test scenarios between platforms and gain additional platform parity with low cost. These fakes can then be used in XCUITest or Espresso tests to exercise the desired UI behavior in a sandbox setting. By swapping out the dependency injection component at the top-most level, we avoid the common problem of testing singleton components with built-in assumptions.

For iOS testing, we leverage the command line arguments system to inject string values as arguments to configure our test scenarios. This can also be used to inject options into the test suite as follows:

let app = XCUIApplication()
app.launchArguments = [“UseFakeData”, “UsePlaylists”]
app.launch()

All Available Free Open Source!

Using all the techniques and frameworks described herein, the Granular mobile team has been able to achieve unprecedented platform parity and at the same time reduce development and maintenance costs. Once we overcame the initial instability of working with the Kotlin/Native compiler and its idiosyncrasies, we have seen a dramatic reduction in defects in the wild. Moreover, any issue we do encounter can be easily isolated in a new test and fixed more quickly and easily than ever before. Our solutions can be deployed to all supported platforms with a single code change, instead of one change for each platform. By embracing consistent architecture and ubiquitous adoption of reactive design patterns, we have enabled a new level of stability in our product ecosystem.

Now that we have developed a rich appreciation for the value of these tools and proven them in the marketplace, we believe the framework is ready to share with the world. It gives me great pleasure to announce the publication of all the KMP tools we’ve added to the PMVP library. Anyone can now take advantage of the framework with simple gradle dependencies and Cocoapods. We’re extremely excited to share this with you, and we look forward to seeing all the amazing things you make. Go to pmvp dot org to get started!

--

--

Aubrey Goodman
Granular Engineering

Rocket scientist, 20yr software engineering veteran, space nerd.