Kotlin Multiplatform evaluation, part 2: how to design background functionality

Olga Deryabina
9 min readDec 11, 2023

--

Important note: This article reflects the state of KMP, Android, and iOS APIs as of December 2023. Always refer to the official documentation before finalizing your design, as the most current options may differ from what is described here. For instance, in June 2024, Android announced significant updates to the HealthConnect APIs, including the availability of callback listeners (https://developer.android.com/health-and-fitness/guides/health-services/monitor-background).

Recently, I commenced a series of posts aimed at elucidating various aspects of my Kotlin Multiplatform experiments. The first article in the series can be found here: https://medium.com/@OlgaDery/kmm-evaluation-part-1-how-to-deal-with-common-code-and-platform-level-dependencies-40fbbf1b754a. The main goal was to build something that feels like the real-world applications I’ve worked on, which features:

  • Platform-specific functionality
  • Background functionality

In this article, I’ll share what I discovered about designing background functionality for collecting health (HealthKit on iOS and HealthConnect on Android) and location data, a common challenge for developers in the healthcare and wellness industry.

Motivation

Building background functionality for Android or iOS isn’t easy, as any experienced app developer knows. It’s particularly tricky to ensure stability and consistency because the OS can deprioritize tasks, causing delays, cancellations, or even terminating the app entirely.

Both platforms use common principles to prioritize tasks, considering factors like battery level and user engagement. However, there are specific nuances for each platform. For instance:

iOS:

  • Application States: iOS has predictable and well-documented application states. The OS is strict about transitioning apps to “suspended” and “terminated” states .
  • Background APIs: iOS offers specific mechanisms for background tasks, documented in Apple’s guidelines. Developers need to manage configuration and permissions. Properly configured tasks can still execute even if the app is “terminated.” For example, the OS can restart an app to receive location updates with the user’s permission.

Android:

  • Battery-Saving Modes: Android’s battery-saving modes are more complex, quite unpredictable and can vary by manufacturer.
  • Task Scheduling: Android specific APIs like TimerTask and JobScheduler are affected by Doze mode, which can cancel or reschedule tasks . Platform-agnostic tools like coroutine jobs are sometimes more reliable for precise timing.
  • Background APIs: APIs like WorkerManager help run scheduled tasks even when the app is inactive, but they have limitations (Android doesn’t guarantee a precise time of their execution) and cannot fully relaunch all app components. Though steps have been taken towards the development of “fit-for-purpose” APIs, aiming to help developers overcome certain OS-level restrictions (as exemplified here: https://developer.android.com/develop/connectivity/bluetooth/companion-device-pairing), their numbers remain quite restricted.
  • Doze Mode Workarounds: Android offers tricks like foreground services and specific permissions (https://developer.android.com/training/monitoring-device-state/doze-standby#support_for_other_use_cases) to mitigate Doze mode effects. However, it’s crucial to note that these workarounds should not be considered for general use cases.

These differences make it challenging to design background tasks that run consistently on both platforms using only platform-agnostic tools like Kotlin Multiplatform (KMP). My goal wasn’t to provide a definitive solution but to compare options in Kotlin/Native and see if any are promising for creating a cross-platform “skeleton” along with platform-specific tools.

Approaches to Background Tasks

There are two main ways to gather data from desired sources, regardless of the app’s state:

  1. Scheduled Jobs: Run at predefined intervals.
  2. Event-Driven Callbacks: Trigger whenever a specific event occurs. This depends on the API, as not all data sources offer callback capabilities.

These approaches are not always interchangeable, and understanding their limitations and advantages is crucial.

Ways to go

Broadly, there are two mechanisms for gathering data from desired sources. One option is to schedule a job to run at set intervals, while the other is to subscribe to a callback that triggers when a specific event happens. The second method depends on the API since not all data sources support callbacks. The first approach is usually simple and easy to implement.

It’s important to understand that these two methods aren’t always interchangeable, although sometimes they can be. Knowing their limitations and benefits is essential.

Callback-based approach

With Kotlin (either Native or JVM), we can handle callbacks with a specific type of flow — callbackFlow (https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/callback-flow.html). I used this approach to get the location updates. This is a code sample from my project:

private val _locationUpdates: Flow<LocationRecord> = callbackFlow {
val callbackProviderImpl = object : LocationCallbackProvider {

override fun onResultReceived(locationRecord: LocationRecord) {
trySend(locationRecord)
}
}
locationDatasource.locationServiceNativeClient.updateCallbackProvider(callbackProviderI mpl)
locationDatasource.subscribe()


awaitClose {
locationDatasource.unsubscribe()
}
}

This is declared in one of the “commonMain” classes (which is shared across all the targets). The idea is to build the hierarchy of dependencies the way so that overridden “onResultReceived” would be called in the actual class with the platform-specific dependencies right after the native callback gets fired, and the new data gets immediately published to the desired channel (that’s why we call “trySend”).

We have some mandatory components here (I omit the optional elements, for example, LocationDataSource class, which provides an extra layer of abstraction and helps with unit testing, but in general is not required):

  1. LocationCallbackProvider: This is our custom callback interface featuring a function to be invoked when the actual callback is triggered.
  2. LocationServiceNativeClient: Expect/actual class where the native dependencies are utilized and initial callbacks handled.
  3. CallbackFlow: This is essentially a cold flow. Upon the subscriber initiating the collection of the flow, the builder’s block of code is executed. We initialize the LocationCallbackProvider anonymous object and provide it to the “LocationServiceNativeClient” instance. Subsequently, we call the “subscribe” function, which executes platform-specific code to initiate the subscription on either the Android or iOS location data native provider. It’s important to note that “AwaitClose” is another integral element in this process (refer to https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.channels/await-close.html).

Probably, it’s worth mentioning that the flow is encapsulated within a job that is managed in the repository class. When the job commences, it starts collecting the flow, thereby triggering a subscription to the location data. To cease collecting updates, one simply needs to stop the job, which in turn cancels the coroutine, causing the flow to cease being collected and invoke “awaitClose”, leading to the termination of our location data subscription. Below is the code from the “repository”:

//job declaration
private var locationDataCollectionJob: Job = launch(start = CoroutineStart.LAZY) {
locationDataManager.locationFlow().collect{
//do something with the location data
...
}
}

//starting the job
override fun startDataCollectionJob() {
launch {
mutex.withLock {
if (locationDataCollectionJob.isActive) {
return@launch
}
locationDataCollectionJob.start()
}
}
}

//finishing the job
override fun stopDataCollectionJob() {
if (locationDataCollectionJob.isActive) {
locationDataCollectionJob.cancel()
}
}

The resulting dependency structure is as follows:

While this structure may seem somewhat intricate, it enables the abstraction of elements and steps involved in managing subscriptions and handling callbacks, providing a common framework for both platforms. Importantly, this approach adheres to the principles of clean code, ensuring unit-testability.

Scheduled job

As mentioned earlier, the primary drawback of this approach is that not all APIs offer callback-based mechanisms. For instance, when retrieving data from Android HealthConnect, the only available option is direct querying (while in iOS, at least some data types can be obtained via callbacks). Hence, I view it as a legitimate use case to design and test a coroutine job that runs periodically, executing queries to retrieve records from the data sources.

I set up a job like this:

private var dataCheckJob: Job? = launch(start = CoroutineStart.LAZY) {
while (this.isActive) {
val record = readHealthDataFromHealthProviderAndConstructRecord()
if (record != null) {
insertHealthRecord(record = record)
checkingRecordsSince = record.time
}
delay(dataCollectionJobDelay)
}
}

We employed this approach at one of my previous companies to initiate and terminate the scanning for Bluetooth devices when the app was operating in the background, and the job proved to be highly stable. It’s worth noting, however, that the app concurrently ran a foreground service, as previously mentioned, signaling to the OS that the app’s tasks should not be deprioritized.

The implementation is relatively straightforward. In contrast to the first approach, there’s no need to design extra layers of abstraction, and it’s easily testable.

Results

Now it’s time to compare these approaches on both platforms. There are some results of testing the app in the background:

What insights can we derive from these results? Clearly, there are identified issues. Rigorous and extended tests, manipulating phone modes, or experimenting with battery drainage are not even necessary to highlight these concerns. Yet, do we have any indications on how we might address these issues when constructing a real Kotlin Multiplatform (KMP) app with the same functionality? I believe so. Let’s delve into it.

Observations reveal that the iOS app exhibits stable behavior with location callbacks, aligning with our expectations for iOS, as discussed in the “Motivation” section. With proper use of APIs, necessary configurations, and permissions, the behavior is anticipated to remain stable even in the background. At the same time, a coroutine job on iOS may experience delays when the app is backgrounded.

On the Android front, location callbacks appear to be deprioritized, although they are still triggered, albeit less frequently. A coroutine job executes as expected but does not prevent process termination. According to documentation (and based on my experience), running a foreground service specifically configured for location updates can help reduce the likelihood of inconsistency/termination.

Conclusion

So, what are our practical takeaways?

  • For Android: Obviously, we have to utilize platform-specific components like foreground services to mitigate the risks of tasks deprioritization or process termination. This will likely result in comparable stability for both callbacks and scheduled jobs.
  • For iOS: Platform-provided callbacks encapsulated in the Kotlin callback flow seem reliable. At the same time, it’s evident that coroutine jobs can be deprioritized.

This implies that for feature parity, I would definitely rely on using callback-based approach in the KMP project rather than on a coroutine job. The challenge, as mentioned, lies in the fact that not all APIs are callback-based, at least in Android. The workaround will be implementing a repetitive coroutine job in the actual Android class to replace a callback subscription. Let’s look at a pseudocode:

Here is the actual class hosting Android dependencies and utilizing callbacks:

//Android-specific class allowing us to access the desired data
private var nativeClient: NativeAndroidClient? = null

//Android-specific callback interface
lateinit var locationCallback: LocationCallback

//Kotlin/Native interface with a callback we override in the callback flow declaration
actual override var flowCallbackProvider: CallbackFlowCallbackProvider? = null

//our function to start data collection
actual override fun subscribeToServices() {
//Do something to configure nativeClient
...

//Override native callback and chain it with the callback
nativeCallback = object : NativeAndroidCallback() {

override fun onCallbackResult(result: Any?) {
flowCallbackProvider?.onResultReceived(result)
}
}
nativeClient?.subscribeToUpdates())
}

This is how we can declare a similar class if we can not use callbacks:

private var nativeClient: NativeAndroidClient? = null

actual override var flowCallbackProvider: CallbackFlowCallbackProvider? = null

//This is a job we will start instead of subscribing for updates via callbacks
private var job: Job? = launch(start = CoroutineStart.LAZY) {

while (this.isActive) {
val record = nativeClient.querySomeDataForTimePeriod(startDate: Date, endDate: Date)
if (record != null) {

//We still can use the same mechanism to publish our new data to the callbackFlow
flowCallbackProvider?.onResultReceived(result)
}
delay(dataCollectionJobDelay)
}
}

//our function to start data collection
actual override fun subscribeToServices() {
job.start()
}

All the upper layers remain the same as in the callbacks-based design.

Once again, I want to emphasize that this isn’t the sole recipe suitable for everyone’s needs. For instance, it is entirely possible that, in the absence of a callback-based mechanism available on iOS for a specific API, we may need to proceed using pure native tools, such as Android WorkManager or iOS Background Tasks, taking into account their respective limitations. Given the overall complexity of the problem under discussion and the multitude of potential edge cases, I emphasize that, irrespective of the chosen approach, the solution must undergo thorough testing on actual devices. This testing should encompass various operating system versions and different battery levels, among other factors. My intention here is to underscore the critical points to consider when selecting and designing a solution.

Thanks for reading, and if anyone wants to look at the source code, the project is available here — https://github.com/OlgaDery/health_connect_multiplatform/tree/main

--

--

Olga Deryabina

Mobile Developer with a passion for product design. Love the startup environment as it encourages us to think beyond conventional boundaries.