6.
Connecting to Platform-Specific API
Written by Saeed Taheri
Any technology that aims to provide a solution for multiplatform development attacks the problem of handling platform differences from a new angle.
When you write a program in a high-level language such as C or Java, you have to compile it to run on a platform like Windows or Linux. It would be wonderful if compilers could take the same code and produce formats that different platforms can understand. However, this is easier said than done.
Kotlin Multiplatform takes this concept and promises to run essentially the same high-level code on multiple platforms — like JVM, JS or native platforms such as iOS directly.
Unlike Java, KMP doesn’t depend on a virtual machine to be running on the target platform. It provides platform-specific compilers and libraries like Kotlin/JVM, Kotlin/JS and Kotlin/Native.
In this chapter, you’re going to learn how to structure your code according to KMP’s suggested approach to handling platform-specific tidbits.
Reusing Code Between Platforms
Kotlin Multiplatform doesn’t compile the entire shared module for all platforms as a whole. Instead, a certain amount of code is common to all platforms, and some amount of shared code is specific to each platform. For this matter, it uses a mechanism called expect/actual.
In Chapter 1, you got acquainted with those two new keywords. Now, you’re going to dive deeper into this concept.
Think of expect as a glorified interface in Kotlin or protocol in Swift. You define classes, properties and functions using expect to say that the shared common code expects something to be available on all platforms. Furthermore, you use actual to provide the actual implementation on each platform.
Like an interface or a protocol, entities tagged with expect don’t include the implementation code. That’s where the actual comes in.
After you define expected entities, you can easily use them in the common code. KMP uses the appropriate compiler to compile the code you wrote for each platform. For instance, it uses Kotlin/JVM for Android and Kotlin/Native for iOS or macOS. Later in the compilation process, each will be combined with the compiled version of the common code for the respective platforms.
You may ask why you need this in the first place. Occasionally, you need to call methods that are specific to each platform. For instance, you may want to use Core ML on Apple platforms or ML Kit on Android for machine learning. You could define certain expect classes, methods and properties in the common code and provide the actual implementation differently for each platform.
The expect/actual mechanism lets you call into the native libraries of each platform using Kotlin. How cool is that!
Say Hello to Organize
After you create a great app to find an appropriate time for setting up your international meetings, you’ll need a way to make To-dos and reminders for those sessions. Organize will help you do exactly that.
As with many apps you use every day, Organize has a page that shows you the device information the app is running on. If you’ve ever faced a bug in your apps, you know how valuable this information can be when debugging.
As of writing this chapter, Android Studio and the KMM plugin do not use Gradle Version Catalogs by default. However, in order to adopt the most modern approach to managing dependencies with Android Studio, the Organize starter project employs them. It also includes additional platforms and pre-configured dependencies.
Furthermore, similar to the project you created in Section 1, you will be using the Regular framework instead of CocoaPods for iOS framework distribution. While Swift Package Manager(SPM) has become the standard method for managing dependencies when developing for Apple platforms, the KMP team has not yet made SPM an available option.
If you ever decide to create a new project yourself, you can change the iOS framework distribution option on the following page.
Updating the Platform Class
As explained earlier, you’re going to create a page for your apps in which you show information about the device the app is running on.
For that matter, most of your work in this chapter relates to the Platform class.
Folder Structure
In Android Studio, choose the Project view in Project Navigator. Inside the shared module, browse through the directory structure.
There’s already a file named Platform.kt inside the project, or to be more exact, there are four of them — one for each platform you support, plus one. To make the expect/actual mechanism work, you’ll need to define the expect and actual entities in exactly the same package in each platform.
In the image above, the expect class for Platform is inside the com.yourcompany.organize package under the commonMain directory. The actual implementations for iOS, Android and desktop are inside the same package under iosMain, androidMain and desktopMain, respectively.
Creating the Platform Class for the Common Module
Open Platform.kt inside the commonMain folder. Replace the expect class definition with the following:
expect class Platform() {
val osName: String
val osVersion: String
val deviceModel: String
val cpuType: String
val screen: ScreenInfo
fun logSystemInfo()
}
expect class ScreenInfo() {
val width: Int
val height: Int
val density: Int?
}
By writing this, you’re making a promise to KMP that you’re going to provide this information. As you see, there’s no implementation for anything here. You define what you want, just like an interface or protocol.
Note: You’ve used Kotlin’s shorthand notation for constructor definition by using
Platform(). This means KMP now expects you to provide an implementation for the constructor alongside the properties and methods.
You may be surprised that you didn’t define Platform or ScreenInfo as a data class; after all, these classes seem a perfect fit for a data class, since they’re essentially data holders.
The reason is that data classes in Kotlin automatically generate some implementations under the hood. Consequently, you can’t use them here, as expect classes shouldn’t have implementations.
You also can’t define nested classes inside an expect class. Hence, you defined the ScreenInfo class outside the Platform definition. You can also create a new file if you desire. Doing it in the same file would work, too.
In the code gutter, click the yellow rhombus with the letter A in it. This lets you navigate to the actual implementation file for the platforms you defined in the project.
If the files aren’t already in their respective places, or you haven’t implemented the actual definition yet, you can put the cursor on the expect class name and press Alt+Enter on the keyboard. Android Studio will help ease the process. This is the case for ScreenInfo, for instance:
Implementing Platform on Android
Go to the Platform.kt inside the androidMain folder.
You’ll see that Android Studio has already started nagging you to fulfill the promise. After all, KMP is in its infancy, and you know how toddlers are!
Replace the entire class definition with this block of code:
//1
actual class Platform actual constructor() {
//2
actual val osName = "Android"
//3
@androidx.annotation.ChecksSdkIntAtLeast(extension = 0)
actual val osVersion = "${Build.VERSION.SDK_INT}"
//4
actual val deviceModel = "${Build.MANUFACTURER} ${Build.MODEL}"
//5
actual val cpuType = Build.SUPPORTED_ABIS.firstOrNull() ?: "---"
//6
actual val screen = ScreenInfo()
//7
actual fun logSystemInfo() {
Log.d(
"Platform",
"($osName; $osVersion; $deviceModel; ${screen.width}x${screen.height}@${screen.density}x; $cpuType)"
)
}
}
// 8
actual class ScreenInfo actual constructor() {
//9
private val metrics = Resources.getSystem().displayMetrics
//10
actual val width = metrics.widthPixels
actual val height = metrics.heightPixels
actual val density: Int? = round(metrics.density).toInt()
}
This seems like a lot of code, but it’s pretty straightforward:
-
You provide the actual implementation for the
Platformas well as its default constructor. Here, you can’t use the shorthand notation as you did in the expect file. You need to explicitly put anactualkeyword before theconstructor. -
For the operating system name, you provided the value
"Android"because you know this code will be compiled for the Android part of the shared module. -
For the operating system version, you used the SDK version from the
Buildclass in Android. Make sure to let the Android Studio import the needed package:android.os.Build. Since you’re inside the Android part of the shared module, you can freely use any Android-specific API. Since you’re checking the SDK version in this property, you must annotate the property with@androidx.annotation.ChecksSdkIntAtLeast(extension = 0). -
For the device model, you used static properties of
MANUFACTURERandMODELfromBuild. -
Thankfully,
Buildcan give you the CPU type of the device using theSUPPORTED_ABISproperty. Since the result may be null on some older versions of Android, provide a default value as well. -
You initialize an instance of
ScreenInfoand store it inscreenproperty. -
Like in an
interface, you provide function implementation here. For now, you’ll use theLogclass in Android to output all the properties to the console. Make sure to importandroid.util.Log. You can safely unwrap the nullablescreenproperty since you initialized it with a non-null value in the previous part. -
You provide the actual implementation for the
ScreenInfoas well as its default constructor. -
For fetching the screen properties, you’ll need a
DisplayMetricsobject. You can get that using this block of code. As you see, you can have extra properties or functions inside the actual class. Make sure to importandroid.content.res.Resources. -
You get the screen width, height and density using the metrics property you defined earlier. Import
kotlin.math.roundto be able to use theroundfunction. For thedensityproperty, you’ll need to explicitly write the type, since if you don’t, its type would be non-nullable. Not only that, but you promised this property to be nullable in the expect file, and one should always stick to their promises. The reason this is of a nullable type will be clear when you implement the desktop part.
Next, you’ll implement the iOS-specific code.
Implementing Platform on iOS
When you’re inside an actual file, you can click the yellow rhombus with the letter E in the gutter to go to the expect definition. While inside Platform.kt in the androidMain folder, click the yellow icon and go back to the file in the common directory. From there, click the A icon and go to the iOS actual file.
The basics of the code you’re going to add are the same as before. This time, though, you’re calling into iOS-specific frameworks such as UIKit, Foundation and CoreGraphics to fetch the needed information.
Replace the actual implementation with the following block of code.
actual class Platform actual constructor() {
//1
actual val osName = when (UIDevice.currentDevice.userInterfaceIdiom) {
UIUserInterfaceIdiomPhone -> "iOS"
UIUserInterfaceIdiomPad -> "iPadOS"
else -> kotlin.native.Platform.osFamily.name
}
//2
actual val osVersion = UIDevice.currentDevice.systemVersion
//3
actual val deviceModel: String
get() {
memScoped {
val systemInfo: utsname = alloc()
uname(systemInfo.ptr)
return NSString.stringWithCString(systemInfo.machine, encoding = NSUTF8StringEncoding)
?: "---"
}
}
//4
actual val cpuType = kotlin.native.Platform.cpuArchitecture.name
//5
actual val screen = ScreenInfo()
//6
actual fun logSystemInfo() {
NSLog(
"($osName; $osVersion; $deviceModel; ${screen.width}x${screen.height}@${screen.density}x; $cpuType)"
)
}
}
actual class ScreenInfo actual constructor() {
//7
actual val width = CGRectGetWidth(UIScreen.mainScreen.nativeBounds).toInt()
actual val height = CGRectGetHeight(UIScreen.mainScreen.nativeBounds).toInt()
actual val density: Int? = UIScreen.mainScreen.scale.toInt()
}
-
There’s a class in
UIKitcalledUIDevicefrom which you can query information about thecurrentDevice. In this code, you’re asking for the interface idiom to differentiate between iOS and iPadOS. TheUIUserInterfaceIdiomenum has a few more cases. For brevity, you used the Kotlin/NativePlatformclass to find information in the else block. -
You can also use
UIDeviceto get the OS version. -
This is by far the clunkiest piece of code you’ll encounter in this book. But don’t worry: KMP isn’t usually like this. It’s here to demonstrate where things can become intricate. Objective-C at its core is C. In C, Structures (also called structs) are a way to group several related variables into one place. Practically, whenever you want to use a C struct, you’ll need to utilize the
cinterop(C Interoperability) package of Kotlin. Using that package has its perks and occasionally, it gets a bit challenging. Here, you’re going to employ a C struct calledutsname. Unix fans, rejoice! In this block 3of code, you’re allocating memory using thememScopedblock and thealloc()function call. Then, you pass a pointer to the allocated memory space to theunamefunction, which retrieves the operating system information and fills it insidesystemInfo. Subsequently, you convert the C String filled with the machine name toNSStringand return it. The cast fromNSStringto Kotlin String is automatic. Phew! -
To obtain the CPU type, you can once again dig into C code, or like here, simply use the Kotlin/Native
Platformclass. -
You initialize an instance of
ScreenInfoand store it inscreenproperty. -
The function implementation is essentially the same as the Android implementation, except that you’re passing the same string to
NSLogfunction. -
You need to combine your knowledge of
UIKitandCoreGraphicsto get the screen properties. First, you utilize theUIScreenclass to fetch information about themainScreenof the device. Then you useCGRectGetWidthandCGRectGetHeightfunctions ofCoreGraphicsto extract the width and height from thenativeBoundsproperty, which is aCGSizeObjective-C, or at its core, a C struct. Just like you did on Android, make sure to explicitly specify the type ofdensity. Go ahead and import all missing packages if you haven’t done so already.
Note: Whenever you use the
cinteroppackage, or call into the Kotlin/Native package, you must opt-in, as those APIs are experimental. If you check out the build.gradle.kts file inside the shared module, at the end of the file, you’ll see that these lines are already there for you:tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile>().configureEach { compilerOptions.freeCompilerArgs.addAll( "-opt-in=kotlinx.cinterop.ExperimentalForeignApi", "-opt-in=kotlin.experimental.ExperimentalNativeApi" ) }Also, you need to annotate the classes of functions in which you’ve used such API. Make sure to add these lines before each class definition:
@kotlinx.cinterop.ExperimentalForeignApi @kotlin.experimental.ExperimentalNativeApi actual class Platform actual constructor() { //... } @kotlinx.cinterop.ExperimentalForeignApi actual class ScreenInfo actual constructor() { //... }
These may look a bit weird for Kotlin and Swift developers. The reason is that you’re using the Objective-C nomenclature for entities. This is how KMP works for Apple platforms. The interoperability is thereby creating a bridge between Kotlin and Objective-C.
The block of code in Section 3 is odd, even for Swift developers. If you were to write this section using Swift, there would also be some travels to the C world:
let deviceModel: String = {
var systemInfo = utsname()
uname(&systemInfo)
let str = withUnsafePointer(to: &systemInfo.machine.0) { ptr in
return String(cString: ptr)
}
return str
}()
Why Objective-C and not Swift, you may ask. Although Swift interoperability is in the works by KMP creators, they chose to go with Objective-C for a couple of reasons:
-
Many of the iOS frameworks themselves are built with Objective-C. Even when you write Swift code, you’re using a bridge.
-
Objective-C has a more flexible and dynamic runtime than Swift. Apparently, Swift’s stricter type-safety features would have made the creation of KMP interoperability with Apple technologies more difficult.
Using Objective-C instead of Swift has some issues, though. For instance, you can’t use some newly introduced frameworks such as AppIntents as they’re Swift-only. Furthermore, you can’t use Swift-only extension functions or properties — or even Swift enum cases — either. You have no choice other than to use the verbose naming of entities in Objective-C.
Implementing Platform on Desktop
Open Platform.kt inside the desktopMain folder.
Replace the actual implementation with this block:
actual class Platform actual constructor() {
//1
actual val osName = System.getProperty("os.name") ?: "Desktop"
//2
actual val osVersion = System.getProperty("os.version") ?: "---"
//3
actual val deviceModel = "Desktop"
//4
actual val cpuType = System.getProperty("os.arch") ?: "---"
//5
actual val screen = ScreenInfo()
//6
actual fun logSystemInfo() {
print("($osName; $osVersion; $deviceModel; ${screen.width}x${screen.height}; $cpuType)")
}
}
actual class ScreenInfo actual constructor() {
//7
private val toolkit = Toolkit.getDefaultToolkit()
actual val width = toolkit.screenSize.width
actual val height = toolkit.screenSize.height
actual val density: Int? = null
}
- The desktop app is based on JVM. As a result, you can use JDK classes and methods to get information about the device. There’s a class in Java called
System. You can get the operating system name by using the staticgetPropertymethod with the"os.name"parameter. Since this method may return null, you provided a default result. - You use the same method as before, but this time with the
"os.version"parameter. - You hard-code the value
"Desktop". JVM doesn’t provide a way to know anything about the manufacturer and model. - Once again,
Systemclass to the rescue! Use"os.arch"as the parameter. - You create an instance of
ScreenInfo, as you did on the other platforms. - Next, you use Kotlin’s
printfunction to output the usual info to the console. - You create an instance of
Toolkitand query the screen size methods on that object. Unfortunately, this property doesn’t give us the screen density.
Java doesn’t have a UI toolkit in itself. If a platform owner wants to use JVM and provide developers with a way to develop user interfaces, it creates a UI toolkit or uses one already available.
You may have heard about Swing or Abstract Window Toolkit (AWT). Jetpack Compose for Desktop uses Swing internally to make window-based desktop applications. As of writing this book, Jetpack Compose for Desktop doesn’t provide a way to query screen information outside its composable methods. However, you can use AWT methods outside the Compose world. The Toolkit class you used, is inside the java.awt package.
Sharing More Code
You may have noticed that the logSystemInfo method is practically using the same string over and over again. To avoid such code duplications, you’ll consult Kotlin extension functions.
Open Platform.kt inside the commonMain folder. As you know, you can’t add implementation to the properties or functions you defined here. However, no one said you can’t use Kotlin extension functions.
At the end of the file, add this:
val Platform.deviceInfo: String
get() {
var result = "($osName; $osVersion; $deviceModel; ${screen.width}x${screen.height}"
screen.density?.let {
result += "@${it}x; "
}
result += "$cpuType)"
return result
}
You’re making the same string, this time, based on the fact that density may be null.
Now go back to the actual files and use this property inside logSystemInfo functions.
In Platform.kt inside androidMain:
actual fun logSystemInfo() {
Log.d("Platform", deviceInfo)
}
In Platform.kt inside iosMain:
actual fun logSystemInfo() {
NSLog(deviceInfo)
}
In Platform.kt inside desktopMain:
actual fun logSystemInfo() {
print(deviceInfo)
}
With this technique, you’re able to share code between the actual implementations.
Updating the UI
Now that the Platform class is ready, you’ve finished your job inside the shared module. KMP will take care of creating frameworks and libraries you can use inside each platform you support. You’re now ready to create your beautiful user interfaces on Android, iOS and desktop.
Android
You’ll do all of your tasks inside the androidApp module. The basic structure of the app is ready for you. Some important files need explaining. These will help you in the coming chapters as well. Here’s what it looks like:
Inside the root folder, there are AppScaffold.kt and AppNavHost.kt. These two files set up the screens of the app and make the navigation between them work as intended. Please don’t hesitate to take a look if you’re interested.
The app has two main screens: RemindersView, which shows a simple “Hello World” for now, and the AboutView, which you’re going to set up in this chapter. Go ahead and open it.
Here, the ContentView is where everything essential happens. Replace its implementation with the following snippet:
@Composable
private fun ContentView() {
val items = makeItems()
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(items) { row ->
RowView(title = row.first, subtitle = row.second)
}
}
}
Here, you get the items you’d like to show out of the function makeItems and put them inside a LazyColumn, which is basically a list view. Import Modifier, LazyColumn and fillMaxSize from the Compose library. Add the following import for the items method:
import androidx.compose.foundation.lazy.items
You’ll implement RowView soon.
Add the makeItems method below ContentView:
private fun makeItems(): List<Pair<String, String>> {
//1
val platform = Platform()
//2
val items = mutableListOf(
Pair("Operating System", "${platform.osName} ${platform.osVersion}"),
Pair("Device", platform.deviceModel),
Pair("CPU", platform.cpuType)
)
//3
val max = max(platform.screen.width, platform.screen.height)
val min = min(platform.screen.width, platform.screen.height)
var displayInfo = "${max}×${min}"
platform.screen.density?.let {
displayInfo += " ${it}x"
}
items.add(Pair("Display", displayInfo))
return items
}
- First, you initialize an instance of the
Platformclass you created earlier. Import it. - Next, you create pairs of data with titles and info from the
platformand store them in a mutable list. - You’ll create a textual representation for the screen. Although you know that
densityproperty isn’t null on Android, it’s better to be safe than sorry when facing nullable properties.
Import max and min from kotlin.math. And for the final piece of this app, add the RowView composable function as follows:
@Composable
private fun RowView(
title: String,
subtitle: String,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Text(
text = title,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
)
}
Divider()
}
}
This is a simple vertical stack of text items that shows a title and subtitle. You can use predefined material typography values to polish things up. These are similar to the predefined text styles in the iOS Dynamic Type feature. Import the needed classes and methods from the Compose library.
That’s the end of your journey on Android in this chapter. Build and run the app, and take a look at the result.
Tap the i button to take a look at the device properties.
Next, you’re going to build the iOS app.
iOS
Although no one can stop you from using Android Studio for editing Swift files, it would be smarter to open Xcode.
Inside the iosApp folder in the project’s root directory, open the Xcode project by double-clicking iosApp.xcodeproj.
The ContentView.swift file is the starting page of the application. It’s already there for you. Take a look if you’d like.
Open AboutView.swift and replace the line where it has Text("Hello World") with this:
AboutListView()
Next, create a new SwiftUI view file called AboutListView by pressing Command-N.
First, import the Shared module at the top of the file:
import Shared
This is the framework KMP created for you. If it gives you an error stating that it’s not found, don’t worry. Building the project will resolve the issue.
Second, add an inner struct in AboutListView to hold the data you’re going to show:
private struct RowItem: Hashable {
let title: String
let subtitle: String
}
The conformance to the Hashable protocol is a necessity for the ForEach structure in SwiftUI.
Third, inside the AboutListView struct, add a property to hold a reference to the items you’re going to show from the Platform class.
private let items: [RowItem] = {
//1
let platform = Platform()
//2
var result: [RowItem] = [
.init(
title: "Operating System",
subtitle: "\(platform.osName) \(platform.osVersion)"
),
.init(
title: "Device",
subtitle: platform.deviceModel
),
.init(
title: "CPU",
subtitle: platform.cpuType
)
]
//3
let width = min(platform.screen.width, platform.screen.height)
let height = max(platform.screen.width, platform.screen.height)
var displayValue = "\(width)×\(height)"
if let density = platform.screen.density {
displayValue += " @\(density)x"
}
result.append(
.init(
title: "Display",
subtitle: displayValue
)
)
//4
return result
}()
- You create an instance of the
Platformclass. - Next, you create an array of
RowIteminstances, containing the info from theplatforminstance. - Then, you calculate the
widthandheightand conditionally unwrap thedensityproperty and append the result to the array. - At the end, you return the array you’d like to show on the page.
Finally, replace the content of the body property with this:
var body: some View {
List {
ForEach(items, id: \.self) { item in
VStack(alignment: .leading) {
Text(item.title)
.font(.footnote)
.foregroundStyle(.secondary)
Text(item.subtitle)
.font(.body)
.foregroundStyle(.primary)
}
.padding(.vertical, 4)
}
}
}
This is a very basic list in SwiftUI. For each item inside the items property, you show a vertical stack of text elements consisting of the title and the subtitle. You also apply a bunch of formatting modifiers such as font and foregroundStyle to make it appear more pleasing to the eye.
Build and run. Then, tap the About button to see the page you created.
Desktop
In Section 1, you learned how to share your UI code between Android and desktop. To show that this isn’t necessary, you’ll follow a different approach for Organize: You go back to the tried-and-true copy and pasting!
The setup for the desktop app is a bit different from the Android app, though they both use Jetpack Compose. One difference is that you don’t use the Jetpack Navigation Component on the desktop app. You also open the About Device page in a new window to be more in line with desktop conventions.
Except for a few nuances in the design for the About page, like showing each data item in a Row instead of a Column, the code is the same. It’s there for you in the starter project. Back in Android Studio, Open AboutView.kt from the desktopApp module, locate //2 and //3 and uncomment the code below them.
There are multiple ways to run the desktop app. You can open the Gradle menu on the side under desktopApp ▸ compose desktop and click run.
The app runs, and it looks mostly like the Android app.
Challenge
Here’s a challenge for you to practice what you learned. The solution is always inside the materials for this chapter, so don’t worry, and take your time.
Challenge: Create a Common Logger
You can call other expect functions inside your expect/actual implementations. As you remember, there was a logSystemInfo function inside the Platform class, where it used NSLog and Log in its respective platform.
Refactor these calls into a new class called Logger. As a bonus, you can add log levels to your implementation.
Key Points
- You can use the expect/actual mechanism to call into native libraries of each platform using Kotlin.
- Expect entities behave so much like an
interfaceorprotocol. - On Apple platforms, Kotlin uses Objective-C for interoperability.
- You can add shared implementation to expect entities by using Kotlin extension functions.
Where to Go From Here?
- Interoperability with C: https://kotlinlang.org/docs/native-c-interop.html
- The expect/actual pattern: https://kotlinlang.org/docs/multiplatform-connect-to-apis.html
Congratulations! You’ve written multiplatform implementations for a class and then used them in native apps on three platforms.