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 2. Integrating KMP into Our Existing App 2.1 Deciding on Shared Code Placement 2.2 Organizing the Shared Code 2.3 Creating a KMP Module 2.4 Multi-module Architecture and Umbrella Module 2.5 CI: Testing Shared Code on Android and iOS 3. Distributing Your KMP Code 3.1 Options for Distributing KMP Code 3.2 Swift Package Manager (SPM) 3.3 Automating Distribution 4. Android and iOS Implementation Methods 4.1 Feature Selection 5. Issues in KMP Cross-Platform Module Implementation 6. Effects 7. Moving Forward: Future Plans and Challenges 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 ※ 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: graph TB; subgraph Android Repository AndroidApp end subgraph iOS Repository iOSApp end subgraph KMP Repository KMP end KMP --> AndroidApp KMP --> iOSApp 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: graph TB; subgraph Android Repository KMP --> AndroidApp end subgraph iOS Repository KMP --> iOSApp end 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: graph TB; subgraph One Repository KMP --> AndroidApp KMP --> iOSApp end [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. graph LR; api --> data --> featureA data --> featureB data --> featureC 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: graph LR; subgraph KMP api --> data --> featureA data --> featureB data --> featureC end featureA --> iOSApp featureB --> iOSApp featureC --> iOSApp What we get (with duplication): graph LR; subgraph KMP api --> data --> featureA end subgraph KMP1 api1(api copy) --> data1(data copy) --> featureB end subgraph KMP2 api2(api copy2) --> data2(data copy2) --> featureC end featureA --> iOSApp featureB --> iOSApp featureC --> iOSApp style api1 fill:#f88 style api2 fill:#f88 style data1 fill:#f88 style data2 fill:#f88 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. graph LR; subgraph KMP subgraph Umbrella api --> data --> featureA data --> featureB data --> featureC end end Umbrella --> iOSApp style Umbrella fill:#8f88 Here is a build.gradle.kts example: 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: Compile the KMP code into an .xcframework . Package the .xcframework into a zip file and calculate its checksum. Create a new release page on GitHub and upload the zip file as part of the release assets. Obtain the zip file’s URL from the release page. Generate the Package.swift file based on the URL and checksum. Commit the Package.swift file and add a git tag to mark the release. 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 ) // 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. 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 #!/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: 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. 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. 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.