Building Kotlin Multiplatform projects in a CI/CD pipeline
Developer Advocate
Kotlin is one of the most versatile programming languages available, in large part because of the Kotlin team’s focus on bringing it to as many platforms as possible. It is the primary language for developing Android applications and is popular for JVM backends. Kotlin also features targets for native binary compilation with Kotlin/Native, and for web through Kotlin/JS. One of its most promising features is the ability to target multiple platforms it compiles to.
This is where Kotlin Multiplatform Mobile (KMM) comes in: it lets you write and reuse the same Kotlin code across Android and iOS applications. As mobile clients often aim for feature parity, this is a clever approach to avoid duplicating work, and we all know how developers love avoiding duplicate work. Trust me, I’m a developer and I know how true this is.
Anyway, back to Kotlin Multiplatform. This is a different approach from cross-platform tools such as Flutter and React Native, which provide unified UI on top of everything. KMM lets you write common business logic while keeping the user interface and experience firmly in the domain of their native platforms. In many cases, that approach is preferable to application users, and therefore your customers.
This article will show you how to get started building KMM projects in a CI/CD pipeline, and include that in your team’s development workflow.
Notes for following along
This article assumes understanding of Kotlin, as well as familiarity with at least one of Android or iOS, ideally both platforms. This article does not teach you KMM. For that, review one of existing tutorials on the Kotlin’s site, or the Readme from the sample application.
Also note: KMM is currently in Alpha version. The technology and the APIs are constantly changing and evolving and we are doing our best to keep the samples working and up to date, yet it should come with no guarantees beyond the API versions in the samples.
Building multiplatform projects in a CI/CD pipeline
To build a multiplatform project in the context of CI/CD, think of it as building distinct projects for each individual target platform. Our project is based on Kotlin’s own Multiplatform sample - a RSS reader application. It contains 2 apps, as well as a shared Kotlin codebase in the form of a Kotlin library. The shared library stays in Kotlin for compilation on Android, and compiles down to native code to run on ARM64 for iOS targets.
Android app code is located in androidApp
, and written in a familiar Kotlin codebase. The iOS app is written in Swift, and located in iosApp
. To understand how KMM makes everything work, check out the official KMM documentation documentation and the sample project on Kotlin’s GitHub repo.
Building a basic pipeline
The simplest CI/CD pipeline for KMM projects contains 2 jobs in a single workflow, that execute the build for each respective platform. Jobs in CircleCI are a succession of commands or steps, which execute in a predetermined environment. This environment is the key to building the app for different platforms. For iOS, we need to build it on Mac hardware, passing in macos
for executor. Android is more permissive, but we can still pick a pre-built environment that contains all the SDKs and everything else built in. For this tutorial, we will use the android
Docker image as provided by the android
orb.
The jobs themselves are straightforward and not really relevant for this tutorial: check out the code, compile, maybe run some tests, build, maybe even deploy. As part of this guide we will not focus on the jobs. Instead we will spend some time setting up the environment and workflows for the build. If you want to learn more about setting up the build and test job for Android you can read about it in this post. If you want to learn more about setting up the build and tests on iOS, you can read more in this tutorial in the CircleCI documentation.
version: 2.1
orbs:
android: circleci/android@1.0.3
jobs:
build-android:
executor: android/android
steps:
- checkout
- android/restore-build-cache
- android/restore-gradle-cache
- run:
name: Run Android tests
command: ./gradlew androidApp:testDebugUnitTest
- android/save-gradle-cache
- android/save-build-cache
build-ios:
macos:
xcode: 12.4.0
steps:
- checkout
- run:
name: Allow proper XCode dependency resolution
command: |
sudo defaults write com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM YES
rm ~/.ssh/id_rsa || true
for ip in $(dig @8.8.8.8 bitbucket.org +short); do ssh-keyscan bitbucket.org,$ip; ssh-keyscan $ip; done 2>/dev/null >> ~/.ssh/known_hosts || true
for ip in $(dig @8.8.8.8 github.com +short); do ssh-keyscan github.com,$ip; ssh-keyscan $ip; done 2>/dev/null >> ~/.ssh/known_hosts || true
- run:
name: Install Gem dependencies
command: |
cd iosApp
bundle install
- run:
name: Fastlane Tests
command: |
cd iosApp
fastlane scan
workflows:
build-all:
jobs:
- build-android
- build-ios
Building the Android app
The best way to start building Android apps is by using the Android orb. This gives you a few built-in jobs and commands that make it easier to build. For this exercize, we are installing Gradle dependencies, and running a Gradle command to run the unit tests.
The main difference Kotlin Multiplatform brings is that the app is not the top level project. Instead, it is located in the androidApp
module. This module has a dependency on shared
, so we need to invoke Gradle commands with the androidApp:
prefix.
orbs:
android: circleci/android@1.0.3
jobs:
build-android:
executor: android/android
steps:
- checkout
- android/restore-build-cache
- android/restore-gradle-cache
- run:
name: Run Android tests
command: ./gradlew androidApp:testDebugUnitTest
- android/save-gradle-cache
- android/save-build-cache
- store_artifacts:
path: androidApp/build/outputs/apk/debug
Building the iOS app
As we mentioned above, building iOS apps requires Mac hardware with XCode installed. For the build-ios
job, we will use macos
executor where we pass the xcode version as parameter. In our case it is: xcode: 12.4.0
.
The rest of the steps are common, regardless of whether you use CocoaPods or Swift Package Manager. You will usually install some dependencies, and then you can build the app and run the tests using Fastlane. For this tutorial, we will just run some tests.
The shared Kotlin code is automatically built as a framework when we initiate a build with Fastlane, so there is nothing left to do here.
jobs:
...
build-ios:
macos:
xcode: 12.4.0
steps:
- checkout
- run:
name: Allow proper XCode dependency resolution
command: |
sudo defaults write com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM YES
rm ~/.ssh/id_rsa || true
for ip in $(dig @8.8.8.8 bitbucket.org +short); do ssh-keyscan bitbucket.org,$ip; ssh-keyscan $ip; done 2>/dev/null >> ~/.ssh/known_hosts || true
for ip in $(dig @8.8.8.8 github.com +short); do ssh-keyscan github.com,$ip; ssh-keyscan $ip; done 2>/dev/null >> ~/.ssh/known_hosts || true
- run:
name: Install Gem dependencies
command: |
cd iosApp
bundle install
- run:
name: Fastlane Tests
command: |
cd iosApp
fastlane scan
Creating an advanced pipeline with dynamic config
This sample pipeline works well to build and test both target platforms of your KMM app every time someone commits to the repository. But we can do better. The reality of mobile development is that we have specialists focusing their work on their respective platforms. There are the folks who like and use Android, love crafting great UX and experiences for Android, and know the platform inside and out. On the other hand, there are developers who focus on iOS in the same way. There is always some cross over working on the common codebase. There will still be individuals who specialize on either one or the other platform on most teams.
Development progress usually takes shape in one of these configurations:
- Android frontend codebase only
- iOS frontend codebase only
- Shared KMM codebase that is consumed by both frontend codebases
For the first two scenarios, building only for the platform being worked on makes much more sense, and saves time, credits, and efficiency.
To optimize this flow we can use CircleCI’s dynamic config feature, which helps us orchestrate projects like this much more efficiently. Dynamic config lets you build only the parts of the app that you have changed.
Dynamic config works through a setup workflow, which in the first step evaluates the codebase, detects what has changed (more on this a bit later), and only then runs the real workflow, the one that is building the portion of the app that is relevant.
The setup workflow still uses the config.yml
that CircleCI users are probably familiar with. The workflow that gets executed uses a path filtering orb, which detects the changes compared to a specified branch. Here is the full config.
version: 2.1
setup: true
orbs:
# the path-filtering orb is required to continue a pipeline based on
# the path of an updated fileset
path-filtering: circleci/path-filtering@0.0.2
workflows:
select-for-build:
jobs:
- path-filtering/filter:
base-revision: master
config-path: .circleci/continue-config.yml
mapping: |
shared/.*|^(?!shared/.*|iosApp/.*|androidApp/.*).* build-all true
androidApp/.* build-android true
iosApp/.* build-ios true
Here is a break down of this config:
setup:true
indicates to CircleCI that we are using dynamic configuration, and that this is the first part of the pipeline to run. The setup workflow is this configuration’s only workflow. It has a single job, path-filtering/filter
, which is from the path-filtering
orb. The job takes a few parameters:
base-revision
indicates the branch to compare againstmapping
determines which paths to compare (more about this later)- The
config-path
points tocontinue-config.yml
, which is the next config to evaluate after this setup workflow. In our case we are pointing to a static file, but we could also construct this new configuration file programmatically beforehand.
To use mapping
, you define the paths of the project to compare against the codebase in the base revision. There is one path per line. Then, set a pipeline parameter to pass to the subsequent pipeline, and its value. All these are separated by whitespace.
Lines 2 and 3 are fairly straightforward:
androidApp/.* build-android true
In this case, the path we are interested in is all files and subdirectories in androidApp/
directory. That is where the codebase for the Android-specific frontend is. The pipeline parameter, build-android
, is set to true
.
The first matching line however is more complex because of the much longer regex matcher:
shared/.*|^(?!shared/.*|iosApp/.*|androidApp/.*).* build-all true
It matches paths in both shared/
directory, which is where our Kotlin multiplatform code is located, as well as anything that is not in shared/
, iosApp/
, or androidApp/
directories, which includes any other top level files. We have set the pipeline parameter build-all
to true
, which will trigger builds of the apps for both platform.
As specified in continue-config.yml
, after the mapping has evaluated all changes and set all relevant pipeline parameters, CircleCI stops this job, and kicks off the second part of this dynamic workflow. Here is the complete file.
version: 2.1
orbs:
android: circleci/android@1.0.3
parameters:
build-all:
type: boolean
default: false
build-android:
type: boolean
default: false
build-ios:
type: boolean
default: false
jobs:
build-android:
executor: android/android
steps:
- checkout
- android/restore-build-cache
- android/restore-gradle-cache
- run:
name: Build Android app
command: ./gradlew androidApp:assembleDebug
- android/save-gradle-cache
- android/save-build-cache
build-ios:
macos:
xcode: 12.4.0
steps:
- checkout
- run:
name: Allow proper XCode dependency resolution
command: |
sudo defaults write com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM YES
rm ~/.ssh/id_rsa || true
for ip in $(dig @8.8.8.8 bitbucket.org +short); do ssh-keyscan bitbucket.org,$ip; ssh-keyscan $ip; done 2>/dev/null >> ~/.ssh/known_hosts || true
for ip in $(dig @8.8.8.8 github.com +short); do ssh-keyscan github.com,$ip; ssh-keyscan $ip; done 2>/dev/null >> ~/.ssh/known_hosts || true
- run:
name: Install Gem dependencies
command: |
cd iosApp
bundle install
- run:
name: Fastlane Tests
command: |
cd iosApp
fastlane scan
workflows:
run-android:
when:
or:
- << pipeline.parameters.build-android >>
- << pipeline.parameters.build-all >>
jobs:
- build-android
run-ios:
when:
or:
- << pipeline.parameters.build-ios >>
- << pipeline.parameters.build-all >>
jobs:
- build-ios
The setup:true
line is not included here, which indicates that this is a ‘standard’ CircleCI configuration file. It does contain the parameters
section with the pipeline parameters we defined in the previous stage.
parameters:
build-all:
type: boolean
default: false
build-android:
type: boolean
default: false
build-ios:
type: boolean
default: false
To use pipeline parameters we first need to define them in the pipeline. We used them earlier. Now you specify their types and default values: all boolean
and set to false
.
workflows:
run-android:
when:
or:
- << pipeline.parameters.build-android >>
- << pipeline.parameters.build-all >>
jobs:
- build-android
run-ios:
when:
or:
- << pipeline.parameters.build-ios >>
- << pipeline.parameters.build-all >>
jobs:
- build-ios
Once the parameters are defined, we can use them when filtering workflows. Unlike the much simpler config shown the previous section, we need to split the jobs across 2 workflows:
- The job for Android is named
run-android
- The job for iOS is named
run-ios
We can use the when
stanza combined with logical operators to specify filters for when to run a particular workflow. For Android, it is either a build-all
or build-android
pipeline parameter. For iOS, it is a build-ios
or build-all
.
This workflow runs selectively based on the changed files that run the jobs relevant to the workflow. This set up lets your team operate faster and more efficiently, while still leveraging all the multiplatform capabilities of KMM.
Conclusion
In this tutorial, you have learned how to set up a pipeline that builds Kotlin Multiplatform Mobile projects on CircleCI. It is similar to building any other project, except for knowing which platform to build for first. Mobile platforms are often developed separately, so we have used the CircleCI dynamic configuration feature to build just the portion of the app developers are currently working on.
If you have any questions or suggestions about this article, or ideas for future articles and guides, reach out to me on Twitter - @zmarkan or email me.