Why iOS Engineers Should Avoid This Glorified KMM Technique

15 minute read

TL;DR

  • If you’re an iOS engineer who wants to build multiplatform apps using KMM, the few benefits of using the expect/actual syntax do not outweigh those of writing your platform-dependent code in Swift.
  • The use-case for iOS engineers to consider leveraging expect/actual syntax would be when building a multiplatform framework.
  • If you’re going to use expect/actual, be sure to reference Apple’s Objective-C documentation over Swift documentation and use the Kotlin/Native headers for the most accurate representation of the iOS APIs available in Kotlin.


The expect/actual Syntax of Kotlin Multiplatform Mobile (KMM)

The Kotlin Multiplatform SDK includes a syntactical construct integrated into JetBrains IDEs that engineers can use to define classes and functions that are expect -ed (pun intended) to have platform-specific implementations. The actual implementations are also written in Kotlin separately for Android and iOS.

When looking at the code modules in a KMM project: within the KMM shared framework module, you first define a class or a function as expected. Then the actual implementations are written for each supported platform module. The following example illustrates what module the code is defined within for Android and iOS:

alternate text
The `common` module defines the expected classes and functions, and platform modules define the actual implementation.

We looked at some simple examples in the last blog. These examples are helpful because they allow us to wrap our heads around the concept before diving into something more complicated.

I want to take you on a journey of writing some code using this approach to illustrate how this can become more complicated in practice than expected.

In fact, I’m going to make a bold statement and propose that there’s only a single scenario where you, the iOS Engineer, should even consider using the expect/actual syntax.


This post is part of a series on Kotlin Multiplatform:

  1. The iOS Engineer’s Guide to Beginning Kotlin Multiplatform Development
  2. Why iOS Engineers Should Avoid This Glorified KMM Technique


Case Study #1: The “Hello World!” of KMM

This example is included in new KMM projects and confirms the setup of your development environment is correct by ensuring your Android app and iOS app can integrate appropriately with the shared KMM framework.

Common Module expect declaration

The Platform class defines a single property called platform, and returns a string on all supported platforms.

1
2
3
expect class Platform() {
    val platform: String
}

androidMain Module actual implementation

For Android, the platform string indicates the platform is Android along with the SDK version number.

1
2
3
actual class Platform actual constructor() {
    actual val platform: String = "Android ${android.os.Build.VERSION.SDK_INT}"
}

Important: Given that this is the “Hello World!” of KMM, I include the Android implementation here for thoroughness. However, for the remainder of the examples, I will omit the actual Android implementation as it’s unrelated to the primary goal of this post: to illustrate the tradeoffs involved in using expect/actual for writing the platform-dependent iOS code.

iosMain Module actual Implementation

For iOS, we can use the Foundation class of UIDevice, which provides access to the device’s systemName and systemVersion properties.

1
2
3
4
5
6
import platform.UIKit.UIDevice

actual class Platform actual constructor() {
    actual val platform: String = UIDevice.currentDevice.systemName + " " 
        + UIDevice.currentDevice.systemVersion
}

Like any example of “Hello World!”, this is the simplest use case illustrating how you can write platform-dependent code for two platforms (Android and iOS) using the expect/actual syntax.

Writing all of your code in Kotlin

As you likely noticed, you get to write all of this code in Kotlin! This is the main benefit of leveraging the expect/actual syntax. In addition, I find Kotlin delightful to program in with its support for object-oriented and functional programming styles, its modern syntax, tight integration with modern IDEs (Android Studio and IntelliJ), and what I perceive to be highly active support from JetBrains and the community.

Although I would argue that switching from one programming language to another in the context of full-stack development and pair programming doesn’t carry a heavy burden, objectively speaking, being able to write all of your platform-dependent code in a single language does mean less context-switching for the author.

What would this look like in Swift?

If we were to have written the above code in Swift, it would look surprisingly similar:

1
UIDevice.current.systemName + " " + UIDevice.current.systemVersion

Can you spot any differences between this and the Kotlin version above? 🤔


Case Study #2: Persisting Simple Data Structures

One of the most common tasks for mobile engineers is data persistence, and UserDefaults is often a common approach for saving simple data structures.

In this case, we will start with how we might write this code in Swift and abstract a familiar interface to use with expect/actual.

NOTE: In a real-life example, before taking concrete code and abstracting it to a generic interface, it’s prudent to look at all platforms that need to be supported before crafting that interface. Are you interested in seeing detailed examples of how to do this? Let me know!

In Swift if we wanted to save a string value to user defaults, it might look like this:

1
UserDefaults.standard.set("Some value", forKey: "some-key")

Likewise, retrieving that data might be something like:

1
let someValue = UserDefaults.standard.string(forKey: "some-key")

Let’s use expect/actual to implement something similar in KMM.

Common Module expect declaration

Starting with the expect declaration, this acts as the interface that we expect each platform to implement.

There are naturally a variety of ways we can choose how to organize and save data. However, for the sake of simplicity, we’ll use saving and retrieving a string.

1
2
3
4
expect class LocalPersistence {
    fun save(string: String, key: String)
    fun get(key: String): String?
}

iosMain Module actual Implementation

For iOS, instead of re-typing the methods defined in the interface, let’s make the most of the IDE’s tight integration.

Writing out just the actual class declaration…

1
2
actual class LocalPersistence {
}

… shows the familiar red underline on the LocalPersistence class name. A swift stroke of ⌥ + ↵ (Option+Enter) reveals the “Add missing actual members” option:

alternate text

Selecting this will automatically populate our class with the function declarations defined in the expected “interface”:

1
2
3
4
5
6
7
8
actual class LocalPersistence {
    actual fun save(string: String, key: String) {
    }

    actual fun get(key: String): String? {
        TODO("Not yet implemented")
    }
}

+ IDE-Integration Supports expect/actual

Here we can introduce another benefit of this approach: support for expect/actual is baked right into the IDE (thanks to the Kotlin Multiplatform Mobile Plugin). So if you’re missing an implementation somewhere, the IDE will let you know, and assuming you have set up your project correctly, it’s possible to leverage the IDE to help create the actual function/class declaration.

Writing iOS-Platform Dependent Code in Kotlin

Let’s start by implementing the save() method of our actual implementation. First, we type UserDefaults to begin, as we already know from the Swift code that this is the object we want to use.

As expected, the IDE finds the class that we’re looking for.

alternate text

Well, kind of.

Oh! That’s right.

NS-UserDefaults!

Hm. I thought Swift dropped the ‘NS’ from the Foundation classes a while ago, didn’t they?

Ok, so NSUserDefaults.standard

alternate text

Ah! Yes, yes. standardUserDefaults!

Wait.
A.
Second.

This doesn’t seem right. The Swift API that we called above was UserDefaults.standard. I clearly remember migrating Swift code from one version to another when all the APIs suddenly become much more readable. So what’s going on here?

Surprise! Writing KMM code for iOS uses Objective-C APIs, not Swift APIs. 😱

When writing platform-dependent code for iOS using KMM, at least for now, you’ll be integrating with the old Objective-C APIs. That means sometimes slight differences between what is possible, and sometimes major differences such as missing APIs that are not available in Objective-C. With Swift interoperability having been recently removed from the Kotlin Roadmap, we’ll have to keep an eye on their progress to see how this evolves in the future.

It might be a smooth transition if you’ve programmed in Objective-C before and understand the APIs and some lower-level aspects of the C language, such as pointers and memory allocation. However, this could be a stretch if you’ve only programmed in Swift or have yet to experience a language operating at a lower level than Swift.

All those improvements to the Swift language over the last few years…

Yup, that’s right.

😅

Wait, what about the Codable protocol?

😭

(It’s a bummer that you can’t use the Codable protocol in Kotlin/Native, but it’s OK because there’s an even better way to share this kind of code.)

While it’s not impossible to leverage Swift-only APIs from a shared Kotlin/Native framework, it does require quite a bit of complicated tooling to access these in KMM via a static Swift Library.

Aaaaand we march on…

Time to save the string to NSUserDefaults. No biggie, right?

alternate text

There’s no method to set a string in iOS (interestingly, unlike Android), but at least the Objective-C APIs match those that are in Swift. We can use setObject to save a string value for the given key.

1
2
3
4
5
6
7
actual class LocalPersistence {
    actual fun save(string: String, key: String) {
        NSUserDefaults.standardUserDefaults.setObject(string, key)
    }
    
    ...
}

Great! Now onto retrieval. Thankfully, this pretty much matches what we would expect and completes our implementation on iOS:

1
2
3
4
5
6
7
8
9
actual class LocalPersistence {
    actual fun save(string: String, key: String) {
        NSUserDefaults.standardUserDefaults.setObject(string, key)
    }

    actual fun get(key: String): String? {
        return NSUserDefaults.standardUserDefaults.stringForKey(key)
    }
}

Pro-Tip #1: When writing KMM code for iOS using expect/actual, reference Apple’s Objective-C (not Swift)! API documentation

Given that KMM code integrates with the Objective-C framework APIs, your best resource is to reference the Objective-C(not Swift!) API documentation. This will solve a lot of your initial struggles with writing Kotlin/Native code for iOS.

Apple makes this easy for all of their online documentation references with a drop-down to change the language:

alternate text


Case Study #3: Getting the user’s current location

Another everyday use case for mobile developers is getting the user’s current location. Again, we’ll keep things simple by exploring only the prominent use cases: getting permission to acquire a user’s location and retrieving the user’s location.

Let’s start with the Swift code to request user permission to access their location:

1
2
let locationManager = CLLocationManager()
locationManager.requestWhenInUseAuthorization()

Once the user has granted us the ability to access this date, requesting the user’s current location might look like this:

1
2
3
4
5
6
if (
    CLLocationManager.authorizationStatus() == .authorizedWhenInUse ||
    CLLocationManager.authorizationStatus() == .authorizedAlways
) {
    let coordinate = locationManager.location?.coordinate
}

The location property of type CLLocation contains the coordinate property of type CLLocationCoordinate2D. This then exposes latitude and longitude as parameters:

alternate text

And CLLocationDegrees is simply a type alias for a Double:

alternate text

Great! Now we have an idea of what this might look like to help inform our interface definition… at least for iOS.

Note: We could generalize this for the single-use case we have in iOS now, only to learn later that Android approaches solving this problem quite differently. As mentioned previously, generalizing to a platform-independent interface requires additional consideration, and for the sake of this example, I will only focus on the iOS use case.

Common Module expect declaration

We can define the expected “interface” for our use case based on the iOS API:

1
2
3
4
expect class PhysicalLocationServices {
    fun requestPermissions()
    fun currentLocation(): PhysicalLocation?
}

Since we’ll return a user’s PhysicalLocation, let’s define a data class to hold that data:

1
2
3
4
data class PhysicalLocation(
    val longitude: Double,
    val latitude: Double,
)

iosMain Module actual Implementation

Requesting permission for location data

Requesting permissions in Objective-C closely matches the same in Swift. One might keep the locationManager as a property (line #2), and the same API requestWhenInUseAuthorization() can be called to request permissions (line #5):

1
2
3
4
5
6
7
8
9
actual class LocationServices {
    private val locationManager = CLLocationManager()

    actual fun requestPermission() {
        locationManager.requestWhenInUseAuthorization()
    }

    ...
}

Requesting the user’s location

The first step in checking for the user’s location is to confirm that the user has given authorization for the type of permission we expect.

This is an excellent example of an API that seems straightforward, but in this case, is a bit tricky to find because these constants differ slightly between Swift and Objective-C:

Swift   Objective-C
.notDetermined kCLAuthorizationStatusNotDetermined
.restricted kCLAuthorizationStatusRestricted
.denied kCLAuthorizationStatusDenied
.authorizedAlways kCLAuthorizationStatusAuthorizedAlways
.authorizedWhenInUse kCLAuthorizationStatusAuthorizedWhenInUse

The easiest way to find this difference is to reference the Objective-C documentation mentioned above. However, if you’re already in the Android Studio IDE and want to stay in that environment, you can also dig through the Kotlin code.

Pro-Tip #2: All iOS framework Objective-C API headers are accessible in Kotlin

The fastest way to get into these headers is to navigate in the IDE directly to the definition of any object you’re already using (for example, requestWhenInUseAuthorization()) via ⌥ + ⌘ + B (or right-click, Go Toimplementation(s)).

alternate text

This takes you directly to the definition of the API:

alternate text

… where you can then search through to find what you might be looking for:

alternate text

This helps us to write the first check to ensure proper permissions to gather location:

1
2
3
4
5
6
7
8
9
10
11
12
13
actual class LocationServices {
    ...
    
    actual fun currentLocation(): PhysicalLocation? {
        if (locationManager.authorizationStatus == kCLAuthorizationStatusAuthorizedWhenInUse ||
            locationManager.authorizationStatus == kCLAuthorizationStatusAuthorizedAlways
        ) {
            
        }

        return null
    }
}

The next step is to get the latitude and longitude values of the location. Simple, right?

As we saw above with Swift, locationManager.location.coordinate should return a CLLocationCoordinate2D object where we can access the latitude and longitude values:

alternate text

Interesting.

There is a CLLocationCoordinate2D object, but it’s wrapped in a CValue object.

What is a CValue object?

If we take a look at the Objective-C documentation for CLLocationCoordinate2D, we can see that this is a C-struct (not to be mistaken for a Swift struct!):

alternate text

… and the Kotlin/Native header files shows the return type as a kotlinx.cinterop.CValue:

alternate text

The documentation on CValue is thin and IMHO not very easy to understand, though it appears as though the useContents method could be used to access the data:

alternate text

This gives us the final implementation to gather this data, including some handling in case the value is null:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
actual class LocationServices {
    ...
    
    actual fun currentLocation(): PhysicalLocation? {
        if (locationManager.authorizationStatus == kCLAuthorizationStatusAuthorizedWhenInUse ||
            locationManager.authorizationStatus == kCLAuthorizationStatusAuthorizedAlways
        ) {
            locationManager.location?.coordinate?.useContents {
                return PhysicalLocation(latitude, longitude)
            }
        }

        return null
    }
}

Although it’s not clear in the code sample above, the IDE helps us to understand what useContents is giving us by showing that it is, in fact, a CLLocationCoordinate2D object. A screenshot shows the hint given by the IDE that this is of type CLLocationCoordinate2D:

alternate text

Reflecting

As you can see with this example, we’re not just dealing with Objective-C APIs over Swift APIs but also with how Objective-C manages memory.

As with many Objective-C APIs, this involves interacting with C-level APIs and objects. Even though these constructs are exposed within Kotlin/Native through the kotlinx.cinterop package, they can be confusing to use and have very thin documentation. A foundational understanding of C- and Objective-C concepts is a pre-requite to ensure you interact with these constructs safely.


On Practicality

As an iOS engineer whose goal is to more easily share code between iOS and Android to simplify how I can expand my app footprint to additional users, based on the limitations and complexities of using expect/actual, I honestly cannot recommend this approach for sharing platform-dependent code in KMM.


What about Android engineers?

This approach could be attractive for Android developers unfamiliar with iOS development and who want to expand to iOS. However, it’s not easy to recommend this approach even to Android developers. It would be easier to take the time to learn the basics of iOS development instead of trying to slog through understanding limited and dated Objective-C frameworks and manual memory constructs even with the conveniences of the Kotlin language.

Besides, Swift and Kotlin share a lot from a syntactical perspective, so the most significant hurdle to overcome (as with many languages and frameworks these days) would be learning SwiftUI or UIKit over the language itself.


Is there a better way?

You wouldn’t be remiss for thinking this is the only approach to writing platform-dependent code in KMM, given that this is the dominant recommendation from the Kotlin team.

There is a better way: writing your iOS platform-dependent code directly in Swift.

And that will be the deep dive for the next installment of this series!


Is there a time you would recommend using expect/actual?

Yes! It makes sense to go down this route if you’re creating a shared Kotlin/Native framework that supports iOS and other platforms.

While my current experience here is limited, I plan to dig into this soon by releasing a shared framework I am building as a part of an app. Given the current landscape of KMM, I’d recommend you consider porting something you’re making to the open-source community to contribute as well!


Summary: Tradeoffs of expect/actual:

In favor of:

+ Code is 100% written in Kotlin.
+ Syntax is fully supported within JetBrains IDEs for compilation and code generation.
+ Objective-C documentation provides an accurate API reference (over the Swift documentation).
+ Is useful when building a shared Kotlin/Native framework that targets the iOS platform.

Challenges:

- Kotlin/Native iOS APIs support interop with Objective-C APIs, not Swift APIs.
- As such, Kotlin/Native iOS APIs lack many of the modern conveniences of Swift APIs, and any Swift-exclusive APIs are inaccessible.
- While accessing Swift APIs not exposed via Kotlin/Native is technically possible, it requires a manual workaround and considerable effort.
- Working with Objective-C APIs means working with C-APIs, and Kotlin/Native cinterop interface makes this possible but is complicated due to how the APIs are structured and how you manage memory.