KINTO Tech Blog
KMP (Kotlin Multiplatform)

Applying KMP to an Existing App: Our Team’s Experience and Achievements

Cover Image for Applying KMP to an Existing App: Our Team’s Experience and Achievements

This article is the 12th day of the KINTO Technologies Advent Calendar 2024. 🎅🎄

Hello, we are the Android development team at the 'KINTO Kantan Moushikomi App' (KINTO Easy Application App).
Today, we want to share the process of implementing Kotlin Multiplatform (KMP) into our existing app,
the reasons behind it, and the changes and improvements it has brought.

Over the past year, we have been exploring ways to maximize development efficiency
between iOS and Android platforms. During this process, KMP caught our attention,
and our team would like to share how this technology has innovatively improved our development process.

Contents

1. Reasons for Implementing KMP in an Existing App

At the time, our team faced a shortage of iOS development resources.
To address this challenge, the Android team decided to leverage Kotlin Multiplatform (KMP)
to create shared business logic for both iOS and Android platforms.

This approach reduced code duplication across operating systems
and allowed the Android team to utilize their expertise to support iOS development.

This strategy was seen as a crucial solution to alleviate staffing issues
and significantly enhance development productivity,
which became the decisive reason for integrating KMP technology into our existing app.

[Summary of the Background]

  • Addressing the shortage of iOS development resources
  • Leveraging the Android team’s expertise in Kotlin
  • Reducing code duplication across operating systems
  • Improving development productivity and strengthening team collaboration

We were interested in KMP.
※ Let's eliminate duplicated efforts across operating systems by modularizing business logic into a KMP library.

2. Integrating KMP into Our Existing App

Before implementing any KMP code, we made several strategic decisions about where to place and how to organize our shared code.

2.1 Deciding on Shared Code Placement

Our mobile app has a typical setup with two separate development teams: an Android team working on the Android repository and an iOS team working on the iOS repository.

When introducing KMP, the first question that arises is: where should the shared code reside?

Option 1: Shared Code in a Separate Repository

This option involves creating a new repository for the shared code, which can be accessed by both the Android and iOS repositories.
The repository structure would look like this:

Option 2: Shared Code in the Android Repository

In this option, the shared code is placed in the Android repository, allowing the Android team to manage the shared codebase.
The repository structure would look like this:

Option 3: Merge Android and iOS Repositories into a Monorepo

This option involves merging the Android and iOS repositories into a monorepo, allowing both teams to access the shared codebase.
The repository structure would look like this:

[Our Decision]

After considering the pros and cons of each option, we decided to place the shared code in the Android repository. This decision was based on the following factors:

  • Minimizing the impact on existing workflows
  • Easier to manage the shared codebase

2.2 Organizing the Shared Code

Once we decided where to place the shared code, the next decision was how to organize it.

Since our existing Android app follows a multi-module architecture, we wanted to maintain a clear separation between the shared module and the platform-specific modules.
We decided to place the KMP module in the shared directory within the Android repository, alongside the existing Android modules.
for example:

:app            // Android app module
:domain         // Android-specific module
:shared:api     // KMP module
:shared:validation  // KMP module

2.3 Creating a KMP Module

A Gradle module for KMP includes:

  1. A build.gradle.kts file.
  2. A src subfolder.

For Android modules, we apply the com.android.library plugin and include an android {} block:

plugins {
    id("com.android.library")
}

android {
    // Android-specific configurations
}

For KMP modules, we use the multiplatform plugin and define a kotlin {} block:

plugins {
    kotlin("multiplatform")
}

kotlin {
    // KMP configurations
}

This setup allowed us to support both Android- and KMP-specific requirements in our shared codebase.

  • 2.4 Multi-module Architecture and Umbrella Module

Limitations of Multiple Modules

In Android, splitting code into multiple modules is standard for complex projects. However, KMP currently supports exposing only one module to iOS.

For example, suppose we have three modules in our shared codebase: featureA, featureB, and featureC.
Each module depends on the data module, which in turn depends on the api module.

We want to expose these three modules to iOS. In an ideal scenario, iOS developers would import only the modules they need, like so:

import featureA
import featureB

<swift code here>

However, due to the limitations of KMP, this approach results in duplicated code in the iOS app.

What we want:

What we get (with duplication):

Umbrella Module

To work around this limitation, we introduced an umbrella module.

An umbrella module is a "empty" module that does not contain source code but used to manage dependencies.

Here is a build.gradle.kts example:

build.gradle.kts
kotlin {
    val xcf = XCFramework()
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach {
        it.binaries.framework {
            baseName = "Umbrella"
            binaryOption("bundleId", "com.example.shared")
            export(project(":shared:featureA"))
            export(project(":shared:featureB"))
            export(project(":shared:featureC"))
            xcf.add(this)
        }
    }
    sourceSets {
        val commonMain by getting {
            dependencies {
                api(project(":shared:featureA"))
                api(project(":shared:featureB"))
                api(project(":shared:featureC"))
            }
        }
    }
}

The umbrella module simplifies the integration process for iOS developers, ensuring a seamless and efficient development experience across platforms.

2.5 CI: Testing Shared Code on Android and iOS

We always write tests for our code, and the shared code is no exception.
Due to platform differences, some features may not work as expected on iOS.
To ensure compatibility, run tests on both Android and iOS.
Unlike Android, which can run tests on any OS, iOS tests must be run on macOS.

3. Distributing your KMP code

Once you finish writing your KMP code, the next step is to distribute it to iOS app.

3.1 Options for Distributing KMP Code

You can distribute your KMP code by source code or binary.

Source Code Distribution

With source code distribution, iOS developers must compile the KMP code themselves. This approach requires setting up a Kotlin build environment, including tools like Java VM and Gradle.

Challenges:

  • Every iOS developer needs to configure the KMP build environment.
  • This increases the complexity of onboarding KMP code into the iOS project.

Binary Distribution

A better option is binary distribution. By providing precompiled libraries, we eliminate the need for iOS developers to manage an additional build environment, making it much easier to integrate shared code.

Advantages:

  • Reduces setup effort for iOS developers.
  • Ensures consistent builds across environments.

3.2 Swift Package Manager (SPM)

For iOS, there are two main dependency management systems: CocoaPods and Swift Package Manager (SwiftPM). The choice depends on your iOS team’s preferences.
Fortunately, our iOS team has fully transitioned to SwiftPM, so we only need to support SwiftPM.

What is a Swift Package?

A Swift Package is essentially a Git repository that includes:

  • Swift source code.
  • A Package.swift manifest file.
  • Semantic versioning via Git tags.

Binary Distribution with SwiftPM

Since SwiftPM 5.3, it has supported binaryTarget, allows you to distribute precompiled libraries instead of source code.

Creating a Swift Package with Binary Distribution

Here’s a brief explanation of how we publish KMP code as a Swift Package:

  1. Compile the KMP code into an .xcframework.
  2. Package the .xcframework into a zip file and calculate its checksum.
  3. Create a new release page on GitHub and upload the zip file as part of the release assets.
  4. Obtain the zip file’s URL from the release page.
  5. Generate the Package.swift file based on the URL and checksum.
  6. Commit the Package.swift file and add a git tag to mark the release.
  7. Associate the git tag with the release page and officially publish the GitHub release.

For detailed instructions, refer to the [KMP documentation on Remote SPM export].(https://kotlinlang.org/docs/native-spm.html)

Package.swift
// swift-tools-version: 5.10

import PackageDescription

let packageName = "Umbrella"

let package = Package(
    name: packageName,
    platforms: [
        .iOS(.v13)
    ],
    products: [
        .library(
            name: packageName,
            targets: [packageName]),
    ],
    targets: [
        .binaryTarget(
            name: packageName,
            url: "https://url/to/some/remote/xcframework.zip",
            checksum: "The checksum of the ZIP archive that contains the XCFramework."
    ]
)

3.3 Automating Distribution

Manual distribution can be time-consuming. To streamline the process, we created a GitHub Actions workflow for automation.

publish-kmp-ios.yml
name: Publish KMP for iOS
on:
  workflow_dispatch:
    inputs:
      release_version:
        description: 'Semantic Version'
        required: true
        default: '1.0.0'

env:
  DEVELOPER_DIR: /Applications/Xcode_15.3.app

jobs:
  build:
    runs-on: macos-14
    steps:
      - name: Checkout
        uses: actions/checkout@master

      - name: set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'zulu'

      - name: "Build and Publish"
        env:
          RELEASE_VERSION: ${{ github.event.inputs.release_version }}
          GH_TOKEN: ${{ github.token }}
        run: ./scripts/publish_iOS_Framework.sh $RELEASE_VERSION    
publish_iOS_Framework.sh
#!/bin/sh
set -e

MODULE_NAME="<your module name>"
VERSION=$1

# version name for github release
RELEASE_VERSION="$MODULE_NAME-$VERSION"

# tag name for git tag
TAG="$VERSION"

TMP_BRANCH="kmp_release_$VERSION"

# check if VERSION is in semver format
if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
  echo "VERSION should be in semver format like 1.0.0"
  exit 1
fi


ZIPFILE=./shared/$MODULE_NAME/build/XCFrameworks/release/$MODULE_NAME.xcframework.zip

echo "Building $MODULE_NAME $VERSION"

./gradlew assembleKintoOneCoreReleaseXCFramework

echo "creating zip file"
pushd ./shared/$MODULE_NAME/build/XCFrameworks/release/
zip -r $MODULE_NAME.xcframework.zip $MODULE_NAME.xcframework
popd

# fetch tags
git fetch --tags

# get previous release tag
PREVIOUS_RELEASE_TAG=$(git tag --sort=-creatordate | grep -v ^version | head -n 1)
echo "previous release tag: $PREVIOUS_RELEASE_TAG"

# create github draft release
echo "creating github release $RELEASE_VERSION"
gh release create $RELEASE_VERSION -d --generate-notes --notes-start-tag $PREVIOUS_RELEASE_TAG
gh release upload $RELEASE_VERSION $ZIPFILE

echo "retrieving asset api url"
# get asset api url of uploaded zip file from github release
# eg: "https://api.github.com/repos/{username}/{repo}/releases/assets/132406451"
ASSET_API_URL=$(gh release view $RELEASE_VERSION --json assets | jq -r '.assets[0].apiUrl')
# add suffix .zip to url
ASSET_API_URL="${ASSET_API_URL}.zip"


# Generate Package.swift
./scripts/generate_SPM_Manifest_File.sh $ZIPFILE $ASSET_API_URL

# commit Package.swift and add tag
git checkout -b $TMP_BRANCH

git add .
git commit -m "release $VERSION"
git tag -a $TAG -m "$MODULE_NAME $VERSION"
git push origin $TAG


# update github release to point to the new tag
gh release edit $RELEASE_VERSION --tag $TAG

4. Android and iOS Implementation Methods

In this project, we introduced a new common module to our existing app using Kotlin Multiplatform (KMP). To minimize potential platform-specific issues, we carefully selected and implemented features that could work reliably across Android and iOS. The focus was on establishing a cross-platform module by selecting OS-independent functionality and keeping implementations simple for initial testing in production environments. Below is an outline of the feature selection criteria and the implementation process.

4.1 Feature Selection

To identify potential challenges of deploying KMP in production, we prioritized features that would not depend on platform-specific implementations and could be handled with minimal dependencies. The criteria for feature selection included:

  1. OS-Independent Functionality : We select the feature that would be OS-independent to avoid unexpected issues in production, leaving out elements that required specific OS-level controls, such as communication, storage, and permissions.
  2. Minimizing Additional Libraries : To reduce the risk of maintenance, we select the feature that could be implemented only with the Kotlin standard library without relying on additional libraries.
  3. Library Prioritization : When selecting libraries, we prioritized official Kotlin libraries first, then libraries recommended in official Kotlin documentation, and finally, third-party libraries as a last selection.

Based on these criteria, input validation were chosen as the initial cross-platform functionality to implement with KMP. And full-width/half-width character transformation feature added.

Android

Input Validation Implementation

By default, Android implementation has only lack of library functionality or interface difference problems, but it was no-big deal.

The input validation feature was structured according to general object-oriented programming (OOP) principles, with an emphasis on reusability and consistency.

1. Defining Common Interfaces : We defined Validator and ValidationResult interfaces to create a consistent foundation for validating input across both platforms.

abstract class ValidationResult(
    /**
     * Informations about input and fail reason.
     */
    val arguments: Map<String, Any?>,
    requiredKeys: Set<String>
)

fun interface Validator<T, R : ValidationResult> {
    /**
     * @return validation result or `null` if the target is valid.
     */
    operator fun invoke(target: T): R?
}

2. Validator Implementation by Input Type : Separate validators and result classes were created for different input types, such as email and password validation.

class IntRangeValidator(
    /**
     * min bound(inclusive).
     */
    val min: Int,
    /**
     * Max bound(inclusive).
     */
    val max: Int
) : Validator<String, IntRangeValidationResult> {
    companion object {
        const val PATTERN = "0|(-?[1-9][0-9]*)"

        val REGEX = PATTERN.toRegex()

        const val ARG_NUMBER = "number"
        const val ARG_RANGE = "range"
        const val ARG_PATTERN = "pattern"
    }

    val range = min..max

    override fun invoke(target: String): IntRangeValidationResult? {
        when {
            target.isEmpty() ->
                return RequiredIntRangeValidationResult()

            !target.matches(REGEX) ->
                return IllegalPatternIntRangeValidationResult(target, PATTERN)
        }

        return try {
            target.toInt(10).let { number ->
                if (number !in range) {
                    OutOfRangeIntRangeValidationResult(target, range)
                } else {
                    null
                }
            }
        } catch (e: NumberFormatException) {
            OutOfRangeIntRangeValidationResult(target, range)
        }
    }
}

3. Test Code Creation : To validate the module’s accuracy across platforms, we implemented extensive test cases using the kotlin-test package, ensuring stable functionality on both Android and iOS.

import kotlin.random.Random
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull

class IntRangeValidatorTest {
    private var min = 0
    private var max = 0
    private lateinit var validator: IntRangeValidator

    @BeforeTest
    fun setUp() {
        min = Random.nextInt()
        max = Random.nextInt(min + 1, Int.MAX_VALUE)
        validator = IntRangeValidator(min, max)
    }

    @AfterTest
    fun tearDown() {
        min = 0
        max = 0
    }

    @Test
    fun `invoke - decimal number string`() {
        val validator = IntRangeValidator(Int.MIN_VALUE, Int.MAX_VALUE)

        for (number in listOf(
            "0",
            "1",
            "111",
            "${Int.MAX_VALUE}",
            "-1",
            "${Int.MIN_VALUE}",
            "${Random.nextInt(Int.MAX_VALUE)}",
            "-${Random.nextInt(Int.MAX_VALUE - 1)}"
        )) {
            // WHEN
            val result = validator(number)

            // THEN
            assertNull(result)
        }
    }
}

Full-width/Half-width Character Transformation Implementation

In addition to input validation, we implemented a character transformation feature to automatically convert between full-width and half-width characters based on application requirements.

1. Defining Extendable Interface : To enable multiple and complex conversions, we defined an interface that could be inherited to handle various character transformations. Kotlin has functional interface(fun interface) and operator function(operator fun) features helped to implement this.

fun interface TextConverter {
    operator fun invoke(input: String): String

    operator fun plus(other: TextConverter) = TextConverter { input ->
        other(this(input))
    }
}

2. Defining Mapping Constants for Conversion : We created a character mapping table that listed the full-width/half-width characters and their conversions, allowing transformations by referencing predefined mappings.

open class SimpleTextConverter(
    val map: Map<String, String>
) : TextConverter {
    override operator fun invoke(input: String): String {
        var result = input
        for ((key, value) in map) {
            result = result.replace(key, value)
        }
        return result
    }
}

class RemoveLineSeparator(
    map: Map<String, String> = mapOf(
            "\n" to "",
            "\r" to ""
        )
) : SimpleTextConverter(map)

object HalfwidthDigitToFullwidthDigitConverter : SimpleTextConverter(
    mapOf(
        "0" to "0",
        "1" to "1",
        "2" to "2",
        "3" to "3",
        "4" to "4",
        "5" to "5",
        "6" to "6",
        "7" to "7",
        "8" to "8",
        "9" to "9"
    )
)

val NUMBER_CONVERTER = FullwidthDigitToHalfwidthDigitConverter +
    RemoveLineSeparator()

3. Automatic Conversion Functionality : The transformation function was designed to automatically convert full-width characters to half-width or vice versa, creating a consistent and predictable input experience.

By selecting these OS-independent features and implementing them with KMP, we were able to establish a stable, reusable module that could be deployed reliably across Android and iOS.

Integration into iOS

Our KMP code was distributed as a Swift Package, our iOS team using XcodeGen to manage Xcode project files.
Integrating KMP code into iOS app can be easily done by add 4 lines code to project.yml file.

packages:
+  Umbrella:
+    url: https://github.com/your-org/your-android-repository
+    minorVersion: 1.0.0
targets:
  App:
    dependencies:
+     - package: Umbrella
      - package: ...

However, since our code resides in private repositories, some additional setup is required.
For full details see: Credential Setup for Private Repositories in SwiftPM

5. Issues in KMP Cross-Platform Module Implementation

During the development of a KMP common module, several technical challenges arose, particularly with handling basic functionalities, multibyte characters, encoding. Below is an overview of these issues and how they were resolved.

No Unicode Codepoints Support in Kotlin Standard Library

In order to accurately process multibyte characters such as Kanji and surrogate pairs within input validation, we decided to implement Unicode Codepoint-based regular expressions. This approach allowed us to precisely match and validate characters based on their positions within the Unicode spectrum rather than merely treating them as individual characters. However, we encountered issue.

Kotlin’s String class does not natively support handling Unicode Codepoints, nor does it provide an official library for this purpose, especially surrogate pairs.

So to ensure precise handling of multibyte characters based on codepoints, we use a third-party library, which allowed us to match complex characters like Kanji more accurately within our regular expressions.

No Encoding Support for Non-UTF

To maintain compatibility with legacy systems, it was necessary to support encoding in Shift-JIS (MS932). But KMP does not support Shift-JIS encoding natively.

Text transfer to the legacy system required to check encodable or not in MS932, for which we opted to use the ktor-client library to handle encoding. However, the iOS version of ktor-client only supports UTF-based encoding schemes, making it challenging to implement MS932 encoding.

Due to MS932 encoding limitations, we abandoned the use of code points for Kanji verification. Instead, we declared a constant that included the entire list of Kanji characters required for validation, converting these to Unicode codepoints for reference when needed.

Unicode Codepoint Issue

When implementing full-width and half-width character transformations, we encountered discrepancies in codepoint differences between certain characters, making a simple addition/subtraction approach ineffective.

For example, the Japanese full-width characters ァ'(U+30A1) and (U+30A2) differ by only 1 in codepoints. In contrast, the half-width characters (U+FF67) and (U+FF71) differ by 10 in codepoints. This inconsistency meant that a unified approach to transformation was not feasible.

We resolved this by creating a constant mapping table for all transformations, explicitly defining all full-width and half-width characters and their respective mappings. This approach allowed us to handle a variety of characters accurately in transformation operations.

By addressing these challenges, we enhanced the stability and completeness of our KMP common module, ensuring accurate functionality across both Android and iOS platforms.

6. Effects

Technical Effects:

  • Process Consistency: The implementation of KMP has minimized operational discrepancies between iOS and Android, reducing the frequency of errors during QA.
  • Code Reusability: Code validated by the Android team is also used on iOS, enhancing development efficiency across both platforms.

OS Collaboration and Optimization of Development Resources:

  • Reduced Communication Burden: KMP allows the Android team to handle most maintenance independently, enabling the iOS team to focus on version upgrades and minor maintenance.
  • This leads to more efficient use of development resources and strengthened collaboration between the teams.

Project Management Challenges:

  • Development and Maintenance Costs: Initial setup requires time, but afterward, development can continue as usual. However, development costs may increase due to restrictions on using Android-specific and Java-based libraries.
  • Resource Allocation: Development processes focused on the Android team can lead to resource shortages during busy periods. As the Android team primarily manages features implemented with KMP,
  • the iOS team's understanding is relatively low, necessitating balanced resource distribution and training.

7. Moving Forward: Future Plans and Challenges

Implementing Future Expansion Plans Through Ongoing Education and Training

Currently, our team is developing and executing an internal education and training program to make more effective use of Kotlin Multiplatform (KMP) technology.
This program goes beyond technical details, focusing on enhancing teamwork and project management skills. By doing so, we aim to not only improve technical abilities but also manage projects more effectively and strengthen collaboration between teams.

Future Plans: Transitioning Common Logic to KMP

Going forward, our team plans to transition more common logic to KMP, which will help maximize code reuse between iOS and Android applications and reduce the complexity of maintenance, thereby enhancing development efficiency.

Key Logic to Transition:

  • API Client: BFF, OCR
  • Business Logic: Cache management, etc.
  • Utilities: Formatting of text (time, usage fees), version comparison (terms of use), etc.
  • Local Storage: App settings, authentication tokens, etc.

By implementing these plans, we expect to strengthen the efficiency and collaboration of cross-platform development, enabling our team to perform development tasks across platforms more effectively.

Thank you for reading, and we hope this provides a useful reference for teams that have not yet applied KMP technology.

Facebook

関連記事 | Related Posts

We are hiring!

【プロジェクトマネージャー】モバイルアプリ開発G/大阪

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。

【iOSエンジニア】モバイルアプリ開発G/東京

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。