For over a decade, I have been experimenting with ways to "write once, run anywhere" - where anywhere was really Android and iOS. The problem I always had was that this seemed to turn a complex problem of dealing with 2 platforms into a complex problem of dealing with 3 platforms: Android, iOS, and the new platform. The new platform had its own learning curve and its own limitations - getting you to a 90% solution, but you still have to drop into the native platforms to deliver the desired 100%.
Welcome Kotlin Multiplatform
Kotlin Multiplatform or KMP is a clever approach that essentially doubles down on the Android investment, and generates some or all of an iOS app "for free." Let's walk through how this works.
KMP leverages the fundamental language and structure of an Android app. Kotlin is the language of modern Android apps, and KMP apps are written in Kotlin. Many Android libraries are "KMP Ready" and because KMP supports "progressive adoption" you can start using just a bit of KMP in your Android and iOS app. One approach is to add a bit of shared KMP business logic to your existing Android and iOS apps. Let's walk through how that works.
Let's Convert an Android App to a KMP App for Android + iOS
As a reference, I will use JetBrains' tutorial entitled "Make your Android application work on iOS" at https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-integrate-in-existing-app.html
To start, I am going to just use their sample app so that I can familiarize myself with how a shared KMP module can be leveraged within Android and iOS, but eventually we will get to adding a shared KMP module to my own (amazing:) apps. Let's get started.
Create a New App from Version Control
First, let's pull down a starter app. As of the time of this writing, you need to use a custom repo (not the one in the blog post linked above).
NO: https://github.com/Kotlin/kmm-integration-sample
YES: https://github.com/SebastianAigner/kmm-integration-sample
Now in Android Studio go to File > New > Project from Version Control and paste in the URL above (https://github.com/SebastianAigner/kmm-integration-sample). x
You will likely get an option to run an AGP upgrade...gopher it!
Switch to the Project View
Now we are off to the races!
Add a Shared KMP Module
The shared KMP module is where you add your shared business logic.
Click File > New > New Module > Kotlin Multiplatform Shared Module.
Call your shared module either shared
(or shared2
if there is already a shared module). Do not "Add sample tests" and choose Regular framework.
Click Finish.
Test out building your application - if it looks like this, continue.
Get Shared KMP Module Wired Up
In the
build.gradle.kts
file of the shared module, ensure thatcompileSdk
andminSdk
are the same as those in thebuild.gradle.kts
of your Android application in theapp
module.- If they're different, update them in the
build.gradle.kts
of the shared module.
- If they're different, update them in the
Add a dependency on the shared module to the
build.gradle.kts
in theapp
directory of your Android application.dependencies { implementation (project(":shared2")) }
Synchronize the Gradle files by clicking Sync Now in the notification.
Ensure that the Shared KMP Module is properly wired up by updating the
LoginActivity
class in thecom.jetbrains.simplelogin.androidapp.ui.login
package of the theapp/src/main/java/
directory to include the following line within the onCreate function:
Log.i("Login Activity", "Hello from shared module: " + (Greeting().greet()))
You will need to import the Greeting library, and you should see an option to do this that resolves to your newly created shared2
KMP module.
Restart the application, search for "Login Activity" or something like that so you can see your informational log message "Hello from shared module" - it works💥!
Make the Business Logic Multiplatform
If you are following along on the JetBrains tutorial, we are at https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-integrate-in-existing-app.html#make-the-business-logic-cross-platform
You are going to move the data folder from app > src > main > java > <Android Package>
to shared > src > commonMain > kotlin > <Shared Package>
as shown below.
You will be presented with an option to Refactor - take it. You will be presented with some warnings about platform-specific capabilities that will not be available in commonMain. Review them for your own understanding of the changes will we need to make to "commonize" the Android-specific code, and then continue.
Replace Android-specific code with cross-platform code
In LoginDataSource
replace IOException (which does not exist in iOS) with RuntimeException (a Kotlin function that works across platforms).
// Before
// return Result.Error(IOException("Error logging in", e))
// After
return Result.Error(RuntimeException("Error logging in", e))
In LoginDataValidator
make the following change:
// Before
// private fun isEmailValid(email: String) = Patterns.EMAIL_ADDRESS.matcher(email).matches()
// After
private fun isEmailValid(email: String) = emailRegex.matches(email)
companion object {
private val emailRegex =
("[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}" +
"\\@" +
"[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" +
"(" +
"\\." +
"[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25}" +
")+").toRegex()
}
Replace Android-specific code with Platform-Specific code
- Create a
Utils.kt
file in thecom.jetbrains.simplelogin.shared2
package of theshared/src/commonMain
directory and provide theexpect
declaration:
package com.jetbrains.simplelogin.shared2
expect fun randomUUID(): String
- You will see an error highlighted for
randomUUID
- right-click it and select the option to "Add missing actual declarations."
- This will cause a new file to be created at
kotlin/com/dyor/shared2/
Utils.android
.kt
- add the following code:
package com.dyor.shared2
import java.util.*
actual fun randomUUID() = UUID.randomUUID().toString()
- And another new file to be created at
kotlin/com/dyor/shared2/Utils.ios.kt
- add the following code:
package com.dyor.shared2
import platform.Foundation.NSUUID
actual fun randomUUID(): String = NSUUID().UUIDString()
- Now wire up the new randomUUID
import com.dyor.shared2.randomUUID
...
//Replace the fakeUser that uses the Android-specific code to commonMain reference
//Each platform will rely on the appropriate platform-specific code at build time.
//val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Jane Doe")
val fakeUser = LoggedInUser(randomUUID(), "Jane Doe")
Connect the framework to your iOS project
- In Xcode, click new project > iOS (not Multiplatform) > iOS. Set Product Name to simpleLoginIOS, select your Team, add your organizational identifier, and click next.
- Choose the root folder of your KMP project (e.g.,
kmm-integration-sample
).Close out Xcode.Back in Android Studio, you should see the new iOS app directory (simpleLoginIOS) in your Project view:
- Rename (Refactor > Rename)
simpleLoginIOS
toiosApp
per the KMP naming conventions, and we are ready to start wiring it together.
Connect the Framework to iOS Project
- Back in Xcode, click
simpleLoginIOS
(you only renamed the folder, not the iOS project) >Build Phases
>+
>New Run Script Phase
- Paste this code, using the proper name for your shared directory:
cd "$SRCROOT/.."
./gradlew :shared2:embedAndSignAppleFrameworkForXcode
Move this
Run Script
phase above theCompile Sources
phase.Disable
User Script Sandboxing
- Build the project in Xcode. This did not work for me. I got the following error:
/Users/mattdyor/Library/Developer/Xcode/DerivedData/simpleLoginIOS-gxdlxcirlhjolrarssjplwsezbae/Build/Intermediates.noindex/simpleLoginIOS.build/Debug-iphonesimulator/simpleLoginIOS.build/Script-C2BBDA972C3C493E0009B647.sh: line 3: ./gradlew: No such file or directory
🤔 Seems that this error was caused by no Gradle installed on my machine. This is a new-ish laptop, so I got some work to do...starting with installing Brew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Add Brew to your PATH (directions are provided after you run the above command). Then run brew install gradle
- wait for the internet to download, and then in a refreshed terminal (e.g., close it and open a new terminal so that Gradle can be found by the terminal) navigate to the root directory of your KMP project and type gradle wrapper
. This may have been caused because I failed to include cd "$SRCROOT/.."
in my Run Script Phase. Leaving this here just in case. 🤔
Use the Shared Module from Swift
Update your ContentView.swift
to import the Shared KMP Module and use the Greeting function from commmonMain
import SwiftUI
import shared
struct ContentView: View {
var body: some View {
Text(Greeting().greet())
.padding()
}
}
Run it in iOS and you will have an AMAZING KMP app rendering in iOS, leveraging some shared business logic.
This was just warm-up. In the next post, we will get into adding a shared KMP module that includes a database. We will use Android's KMP-ready Room library. Get ready.