Elements Blog

Guide: Creating a CocoaPod using Kotlin Multiplatform Mobile library

This post is about how we managed to create a usable Android .jar and iOS CocoaPod using Kotlin Multiplatform Mobile, host it in a private repository and making it proper maintainable. At time of writing this, this is not properly supported yet by Kotlin Multiplatform Mobile.

Table of contents

  • Introduction
  • What is the problem?
  • Determining options
  • The journey
  • Conclusion? Problem solved?
  • Future improvements
  • Resources

Introduction

At our company we are working on an app for a client which is native Android and native iOS. The client wanted us to implement an analytics library which will send tags to a dashboard. This way the client can see user flows and user interaction with the app, which can be used to improve user experience.

Now, since we are programmers, we don’t like to do things twice. In this case to have to manage these analytics tags in two places, so instead of going the easy way to make a list of strings in Android and a list of strings in iOS, we spend 3 days figuring out and developing a multiplatform statically typed library.

There is enough documentation about how to code a library, but there is not much documentation about configuring the project properly. That is what this guide is about. Check the Resources on the bottom for documentation on how to start with coding.

Furthermore, this guide assumes you have knowledge about how CocoaPods works and what it consists of. Otherwise you can refer to here.
It is also assumed you have knowledge about the Command Line Interface (CLI) and Git.

I have setup a project as an example for you to have alongside this guide. Feel free to clone and play with it here.

What is the problem?

We have two native apps with a requirement to send analytics tags. These tags (which are just strings) should be the same on both platforms. We want to maintain this list in one place efficiently and in a way we can potentially reuse it for different projects. This would also mean, ideally, that we would have statically typed tags instead of magic strings to catch errors on compile time.

Determining options

To come up with a solution to our problem we first had a little brainstorm session to check our options. We had some requirements to keep in mind:

  • We would like statically typed resources, similar to R in Android and R.swift in iOS, so we can catch errors at compile time
  • We would like a single source of truth for easy maintenance
  • Resource should be easy to update
  • The Android plugin and iOS pod should be easy to update

A colleague immediately mentioned Kotlin Multiplatform Mobile (KMM), but because we had not worked with that within our company we also looked at other solutions. Like, maybe we could import an excel sheet? Maybe we can use phrase.com (which we use to manage localisation) to also include these tags? What about a Flutter plugin?

Excel

First we thought of an excel sheet, this would be the simplest way to keep track of the tags in a single place, but this would have to be imported somehow and this would not grant the static typing we longed for, so this is not a solution.

Phrase

We briefly thought about putting the tags in phrase.com, but that is just a bit silly, since the purpose of the tags is totally different. This would grant the static typing, but the key-value pairs in phrase would look like screenA: "screenA" which is not ideal, so we scratched this idea.

Flutter

Next, we had a little bit of experience making a mobile app with Flutter so we considered it, but this would mean working in a different language (Dart) which not everyone is familiar with and creating multiplatform libraries is also not really matured in Flutter. This was not an ideal option.

KMM

After weighing pros and cons, we decided to go with Kotlin Multiplatform Mobile (surprise surprise). This was heavily influenced by our Android developer and his curiosity in trying KMM, but also because of KMMs ability to generate an iOS CocoaPod using command line. Next to that it is an interesting choice to try a new multiplatform technology, so we went with it.

Image for post

The journey

At the time of writing this, and doing this little project, KMM is still in alpha and documentation is limited. That’s why I hope this post will help other people wanting to do the same. This lack of documentation meant we ran into issues which were not yet addressed.

Basically the idea is to use KMM to have a single codebase where we define appropriate classes and strings for the tags. From this we can create a plugin for Android and a CocoaPod for iOS. Easypeasy, right?

Starting the project

IntelliJ IDEA would be the preferred editor to start a new KMM project with, together with the Kotlin plugin. Here you can find all the explanation to start with a new multiplatform plugin. Just make sure you select Mobile Library when creating a new project.

Image for post

No items found.

Write your library

In src/commonMain/kotlin/<projct name>/ you can write your code you want to create a library for. In the ExampleProject I created a class ExampleTag with some example tags.

In build.gradle.kts we set the library version and name:


val frameworkVersion = "1.0.0"
val frameworkName = "ExampleProject"

Android configuration

To compile a jar file we have to add some configuration to build.gradle.kts.
First we will add some sourceSets for Android (should be already created):


kotlin {
  ...
  
  sourceSets {  
    val commonMain by getting
    val commonTest by getting {
    dependencies {
      implementation(kotlin("test-common"))
      implementation(kotlin("test-annotations-common"))
    }
  }
    
  val androidMain by getting {
    dependencies {
      implementation("com.google.android.material:material:1.2.1")
    }
  }

  val androidTest by getting {
    dependencies {
      implementation(kotlin("test-junit"))
      implementation("junit:junit:4.13")
    }
  }
}

Next we have to create an Android function to compile the library to a jar file. You can do this by adding the following function:


kotlin {
  ...
  
  sourceSets {
    ...
  }
}

android {
    compileSdkVersion(29)
    defaultConfig {
        minSdkVersion(24)
        targetSdkVersion(29)
        versionCode = 1
        versionName = "1.0"
    }
    buildTypes {
        getByName("release") {
            isMinifyEnabled = false
        }
    }
}

Now it is possible to create the jar file for Android. Run the ./gradlew build command, it will be created in /build/libs.

In Android we create a jar file and put this in our project manually, because of a lacking up-to-date artifactory. Add the generated *.jar to the libs directory in you Android project and you are good to go!

iOS configuration

For dependencies in iOS we use CocoaPods, so we need KMM to generate one for us, which conveniently is possible. We will generate a .framework file and manually configure a .podspec file. When this is done we can upload them to a repository for use with CocoaPods.

But first we need to configure the build.gradle.kts file to use the appropriate iOS architectures. When running an iOS simulator on your Macbook the architecture used is x86_64 while real iPhones use the ARM64 architecture. In the build.gradle.kts you will see a function kotlin with within there android() and iosX64("ios") { ... }. This means Android and iOS are supported, but this iosX64 only supports simulators. This will mean you cannot run a project with this library on a real device, which we of course do want to do. So we will extend iosX64 with the following:


kotlin {
  val iosFrameworkName = Config.frameworkName
  
  android()
  iosX64 { binaries.framework(iosFrameworkName) }
  iosArm64 { binaries.framework(iosFrameworkName) }
  ...
}

Next we have to configure sourceSets. These are references to dependencies if you need them. Add this inside the kotlin function. It will look something like this:


kotlin {
  ...
  sourceSets {
    val commonMain by getting
    val commonTest by getting {
      depedencies {
        implementation(kotlin("test-common"))
        implementation(kotlin("test-annotations-common"))
      }
    }
    val androidMain by getting {
      dependencies {
        implementation("com.google.android.material:material:1.2.1")
      }
    }
    val androidTest by getting {
      dependencies {
        implementation(kotlin("test-junit"))
        implementation("junit:junit:4.13"))
      }
    }
    val iosMain by creating {
      dependencies { }
    }
    val iosTest by creating {
      dependencies { } 
    }
    getByName("iosArm64Main") { dependsOn(iosMain) }
    getByName("iosArm64Test") { dependsOn(iosTest) }
    getByName("iosX64Main") { dependsOn(iosMain) }
    getByName("iosX64Test") { dependsOn(iosTest) }
  }
}

Last bit for us to do to be able to generate a proper framework, is to add tasks. These tasks will run when the gradle task to generate a framework will be fired. It will look something like this:


kotlin {
  ...
  
  sourceSets {
    ...
  }
  
  tasks {
    register("universalFrameworkDebug",
    org.jetbrains.kotlin.gradle.tasks.FatFrameworkTask::class) {
      baseName = iosFrameworkName
      from(
        iosArm64().binaries.getFramework(iosFrameworkName, "Debug"),
        iosX64().binaries.getFramework(iosFrameworkName, "Debug")
      )
      destinationDir = buildDir.resolve("bin/universal/debug")
      group = "Universal framework"
      description = "Builds a universal (fat) debug framework"
      dependsOn("link${iosFrameworkName}DebugFrameworkIosArm64")
      dependsOn("link${iosFrameworkName}DebugFrameworkIosX64")
    }
    register("universalFrameworkRelease",
    org.jetbrains.kotlin.gradle.tasks.FatFrameworkTask::class) {
      baseName = iosFrameworkName
      from(
        iosArm64().binaries.getFramework(iosFrameworkName, "Release"),
        iosX64().binaries.getFramework(iosFrameworkName, "Release")
      )
      destinationDir = buildDir.resolve("bin/universal/release")
      group = "Universal framework"
      description = "Builds a universal (fat) release framework"
      dependsOn("link${iosFrameworkName}ReleaseFrameworkIosArm64")
      dependsOn("link${iosFrameworkName}ReleaseFrameworkIosX64")
    }
    register("universalFramework") {
      dependsOn("universalFrameworkDebug")
      dependsOn("universalFrameworkRelease")
    }
  }
}

This will register the tasks universalFrameworkDebug and universalFrameworkRelease. We are gonna use this to build our framework.

This concludes the configuration for creation of the iOS framework. We set the appropriate architectures and made tasks to generate a framework.

Image for post

Gradle tasks

Run the following commands in the root of the project to generate the framework:

./gradlew clean — Deletes the build directory
./gradlew universalFrameworkRelease — Generates the .framework directory (in build/bin/universal/release/)

Creating a podspec file

A .podspec file is also necessary if you want to use your generated framework with cocoapods (If you don’t want to do this, there is also the possibility to manually add the framework to your XCode project).

Create a new file in IntelliJ with the name <library name>.podspec and add the following code:


Pod::Spec.new do |spec|
  spec.name = 'ExampleProject'
  spec.version = '1.0.0'
  spec.homepage = 'https://www.cocoapods.org'
  spec.source = { :git => "", :tag => "{spec.version}" }
  spec.authors = ''
  spec.license = ''
  spec.summary = 'An example project'
  spec.static_framework = true
  spec.vendored_frameworks = "ExampleFramework.framework
  spec.libraries = "c++"
  spec.module_name = "#{spec.name}_umbrella"
  spec.ios.deployment_target = '13.0'
end

Run through all the values and fill them out with your information. The thing you need to keep in mind is the spec.version, this should be updated every time you make a change to this library. A little script can help with this. We have set the library version in build.gradle.kts and it is possible to copy this version number to the .podspec file, but we could also make a little update script to do it for us.

In the ExampleProject root is a scripts directory which contains a updateCocoapod.sh script. It looks like this:


DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
REPO_VERSION=$(cat $DIR/../build.gradle.kts | grep -m 1 frameworkVersion | cut -d'"' -f 2)
sed -i '' -e "s?spec\.version = .*?""spec\.version = \'$REPO_VERSION\'?" $DIR/../ExampleProject.podspec

This script will get the version from the build.gradle.kts file and replace the version in the ExampleProject.podspec file with the newer version. You can run this script from the project root using sh scripts/updateCocoapod.sh.

Almost there

So now we have the .framework directory and the .podspec file, everything we need to create a CocoaPod. The last thing to do is to upload it to a repository. Ray Wenderlich has a good guide on how to upload a CocoaPod here. It covers both uploading to private repositories and uploading your podspec to the CocoaPods Master Specs repository.

In the ExampleProject I also added a script in the scripts directory called updateRepository.sh, you can use this to easily push the .framework and .podspec to your repository. This will also tag the commit with the library version, if the library version is not bumped you will get an error.

How to use in XCode

Add your fancy new library to the Podfile in an XCode project and run pod install to start using it. Use it as follows:


import UIFoundation
import ExampleProject

class TestClass {
    init() {
        let message: String = Greeting.greeting() // `Greeting` is a class within the `ExampleProject` library
        print(message) // Will print "Hello,  !"
        
        let tagString: String = ExampleTag.nextButton
        print(tagString) // Will print "next_button"
    }
}

Photo by Shunya Koide on Unsplash

Conclusion? Problem solved?

For us this was a frustrating and fun process, but in the end it was worth it. We now have a single source of truth for the tags and we can use them statically in Android and iOS. Next to that it is easy to update this multiplatform library with new tags and to create and upload a framework from it.

After we chose Kotlin Multiplatform Mobile as a solution, the main issue we ran into was the configuration of the iOS architectures. There is very limited documentation about this, although quite a crucial step in the usability. After figuring out we had to use iosX64 and iosARM64, we were already much closer to our intended goal.
Next we had to upload the .framework and .podspec file to a repository, and while this is not thát much work, it would be easier to automate it. So a script was a solution and made it much easier to update the repository.

In the end it was a cool challenge and we are proud of the results. While it was not the easiest solution to our problem, I do recommend trying it out if you run into a similar situation we did.

And yes, problem solved 🕺🏻

Future improvements?

KMM is still in alpha so a lot of things can be changed by the time you are reading this, but that is why I wrote this guide. It might be a bit complex to set up and it is useful to have some reference.

Currently the process for Android is very manual, the compiled Android library is added to the project manually. That could certainly be improved by using something like artifactory to make it a dependency we can update by changing the library version, like we can do in iOS now using CocoaPods.

Resources

[1] https://kotlinlang.org/docs/tutorials/mpp/multiplatform-library.html
[2] https://kotlinlang.org/docs/tutorials/native/apple-framework.html
[3] https://kotlinlang.org/docs/reference/mpp-build-native-binaries.html#build-universal-frameworks
[4] https://kotlinlang.org/docs/reference/native/cocoapods.html
[5] https://github.com/Schroefdop/KMMExample

No items found.

This blog was written by

Wouter Vermeij

on

Feb 2, 2021

Kotlin
Cocoapods
iOS
Multiplatform
Libraries