TutorialsOct 5, 20219 min read

Building Kotlin Multiplatform projects in a CI/CD pipeline

Zan Markan

Developer Advocate

Developer RP sits at a desk working on an intermediate-level project.

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 against
  • mapping determines which paths to compare (more about this later)
  • The config-path points to continue-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.

Copy to clipboard