📦

Kotlin MultiplatformでiOS/Android共通で使えるKotlinライブラリを作る

2025/03/02に公開

Kotlin Multiplatform (KMP) の可能性を探るため、共通のKotlinコードを、iOS向けのSwift Package・Android向けのMaven Artifactとして公開してみました。
本記事では、その具体的な手順と、KMPで共通ライブラリを作成することについての現時点での個人的な所感を記載します。

実際のソースコードは、以下のGitHubリポジトリにあります。

共通ライブラリ、サンプルのiOSアプリとAndroidアプリ
https://github.com/kaseken/KMPSampleLibrary

iOSアプリ向けのSwift Package
https://github.com/kaseken/KMPSampleSwiftPackage

手順

以下の4つのステップで行いました。

  1. KMPLibrary (Kotlin製の共通ライブラリ) を作成する。
  2. Swift PackageをGitHubに公開する。
  3. Maven ArtifactをGitHub Packageに公開する。
  4. iOS/AndroidアプリでPackageを使用する。

以下の環境で実施しました。

  • Xcode Version 16.1
  • Android Studio Ladybug Feature Drop | 2024.2.2 Patch 2

1. KMP Library (共通ライブラリ) を作成する

Android StudioのNew Projectから、「Kotlin Multiplatform Library」を選択します。
また、Swift Packageとしての公開にXCFrameworkを利用するため、iOS framework distributionは「XCFramework」を選択します。

Projectの種類の選択 XCFrameworkの選択

今回の題材としては、Kotlinで書かれた優先度付きキュー (PriorityQueue) をiOS/Androidアプリでシェアすることとしました (実際のソースコード)。Kotlinコードは、shared/src/commonMain/kotlin/dev.kaseken.kmpsamplelibrary下に配置しました。

KMPSampleLibrary/
├── shared/
│   ├── src/
│   │   ├── commonMain/
│   │   │   ├── kotlin/
│   │   │   │   ├── dev/kaseken/kmpsamplelibrary/PriorityQueue.kt
│   │   ├── iosMain/  # (今回は空)
│   │   ├── androidMain/  # (今回は空)
│   │   ├── commonTest/  # テストコード

今回はPlatformに固有の処理を含めていないため、iOSMainやandroidMainには特に変更を加えていません。ビルドに成功すること、またcommonTest下に書いたテストコードがpassすることを確認しました。

※当初はcomparatorを渡すのではなくPriorityQueue<T: Comparable<T>>のようなインタフェースを持つ実装にしていましたが、XCFramework化するとGenericsの型情報がObjective-Cヘッダに変換される際に T: AnyObject に変わってしまう (Comparableの情報が消えてしまう) ため、Swift側で型安全に扱えなくなるという問題がありました。この問題を回避するため、Comparatorを渡す設計に変更しました。Genericsを使ったコードのSwiftとの相互運用性が低いというのは、KMPの難点の一つかと思いました。

2. Swift PackageをGitHubに公開する

以下の3つのステップで行いました。

2-1. Gradleタスクで、KMP LibraryからXCFrameworkを生成する。
2-2. Swift Packageを作成し、XCFrameworkのWrapperコードを書く。
2-3. Swift PackageをGitHub上にpushする。

2-1. XCFrameworkの生成

build.gradle.ktskotlinブロック内に、以下のようなコードを追加します。
"KMPSampleLibrary"に名前を変えているところ以外は、Project作成時にXCFrameworkを選択していれば、デフォルトで存在するかと思います。

  val xcf = XCFramework("KMPSampleLibrary")
  listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
    it.binaries.framework {
      baseName = "KMPSampleLibrary"
      xcf.add(this)
    }
  }

上記のように設定すると、assembleKMPSampleLibraryXCFrameworkのようなGradleタスクが使用可能になります。ただし、KMPSampleLibraryの部分は各々の設定した名前に依存するため、./gradlew tasksで実際のタスク名をご確認ください。

./gradlew assembleKMPSampleLibraryXCFrameworkを実行すると、Debug/Relase両方のXCFrameworkがbuildディレクトリ内に生成されます。

% ./gradlew assembleKMPSampleLibraryXCFramework
> Task :shared:assembleKMPSampleLibraryDebugXCFramework
xcframework successfully written out to: /Users/kentkaseda/Projects/KMPSample/KMPSampleLibrary/shared/build/XCFrameworks/debug/KMPSampleLibrary.xcframework
> Task :shared:assembleKMPSampleLibraryReleaseXCFramework
xcframework successfully written out to: /Users/kentkaseda/Projects/KMPSample/KMPSampleLibrary/shared/build/XCFrameworks/release/KMPSampleLibrary.xcframework

上記のKMP LibraryからXCFrameworkを作成する方法に関しては、以下の公式ドキュメント・ブログ記事が参考になりました。

2-2. Swift Packageを作成し、XCFrameworkのWrapperコードを書く

Xcodeを開き、File > New > Packageから新規Swift Packageを作成します。

2-1で作成したXCFramework (Releaseビルド) を、Swift Package内にコピーし、package.swift上でbinaryTargetとしてtargetに追加します。これにより、このSwift Package内でimport KMPSampleLibraryのようにして、先ほど作成したPriorityQueueを使用可能となります。

// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "KMPSampleSwiftPackage",
    products: [
        .library(
            name: "KMPSampleSwiftPackage",
            targets: ["KMPSampleSwiftPackage"]
        ),
    ],
    targets: [
        .binaryTarget(
            name: "KMPSampleLibrary",
            path: "KMPSampleLibrary.xcframework"
        ),
        .target(
            name: "KMPSampleSwiftPackage",
            dependencies: [
                "KMPSampleLibrary",
            ]
        ),
        .testTarget(
            name: "KMPSampleSwiftPackageTests",
            dependencies: [
                "KMPSampleSwiftPackage",
            ]
        ),
    ]
)

Swift Package内に、KMPライブラリのWrapperコード (実際のソースコード) を追加しました。

なお、iOS向けにKMPライブラリをビルドすると、Kotlin/NativeのObjective-Cヘッダー(.hファイル)が自動生成されます。今回のPriorityQueueの場合、以下のようなコードが生成されます。

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("PriorityQueue")))
@interface KMPSLPriorityQueue<T> : KMPSLBase
- (instancetype)initWithComparator:(KMPSLInt *(^)(T _Nullable, T _Nullable))comparator __attribute__((swift_name("init(comparator:)"))) __attribute__((objc_designated_initializer));
- (BOOL)isEmpty __attribute__((swift_name("isEmpty()")));
- (T _Nullable)peek __attribute__((swift_name("peek()")));
- (T _Nullable)pop __attribute__((swift_name("pop()")));
- (void)pushElement:(T _Nullable)element __attribute__((swift_name("push(element:)")));
- (int32_t)size __attribute__((swift_name("size()")));
@end

Kotlin側ではcomparatorが(T,T)->Intであったのに対し、Objective-Cのインターフェースでは(T?,T?)->Intに変わっています。
また、push(element:T)push(element:T?)に変換されています。これはKotlinのnull安全性とObjective-C/Swiftのnullabilityの違いによるもので、Swiftとの相互運用性に難がある部分の一つです。

2-3. Swift PackageをGitHub上にpushする

2-2で作成したSwift PackageをGitHub上にpushするだけで、Swift Packageとして公開されます。以下が実際のリポジトリです。
https://github.com/kaseken/KMPSampleSwiftPackage

3. Maven ArtifactをGitHub Packagesに公開する

Android向けのPackageの公開には、Github PackagesのGradle registryを利用しました。
Maven centralに登録する等も考えられますが、アカウント登録等が必要なため、より簡単に登録できるGitHub Packagesを選択しました。

以下の手順のように、build.gradle.ktsにPublish用の設定を行い、また認証用にPersonal Access Token (PAT) を作成します。PATにはread:packagesスコープが必要です。
https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry

最終的に、共通ライブラリのbuild.gradle.ktsは以下のような状態になりました (実際のソースコード)。

./gradlew publishを実行すると、GitHubリポジトリ上にMaven Artifactが公開されます。
例) https://github.com/kaseken?tab=packages&repo_name=KMPSampleLibrary

import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework

plugins {
  alias(libs.plugins.kotlinMultiplatform)
  alias(libs.plugins.androidLibrary)
  id("maven-publish")
}

kotlin {
  androidTarget {
    compilations.all {
      compileTaskProvider.configure { compilerOptions { jvmTarget.set(JvmTarget.JVM_1_8) } }
    }
  }

  val xcf = XCFramework("KMPSampleLibrary")
  listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
    it.binaries.framework {
      baseName = "KMPSampleLibrary"
      xcf.add(this)
    }
  }

  sourceSets {
    commonMain.dependencies {
      // put your multiplatform dependencies here
    }
    commonTest.dependencies { implementation(libs.kotlin.test) }
  }
}

android {
  namespace = "dev.kaseken.kmpsamplelibrary"
  compileSdk = 35
  defaultConfig { minSdk = 29 }
  compileOptions {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
  }
}

group = "com.github.kaseken"

version = "1.0.8"

publishing {
  repositories {
    maven {
      name = "GitHubPackages"
      url = uri("https://maven.pkg.github.com/kaseken/KMPSampleLibrary")
      credentials {
        // 環境変数にGitHubのユーザー名とPATのトークンを入れる。
        username = System.getenv("GITHUB_USERNAME") ?: ""
        password = System.getenv("GITHUB_TOKEN") ?: ""
      }
    }
  }
  publications {
    register<MavenPublication>("gpr") {
      from(components["kotlin"])
      groupId = project.group.toString()
      artifactId = "kmpsamplelibrary"
      version = project.version.toString()
    }
  }
}

4. iOS/Androidアプリで公開したPackageを利用する

iOSアプリでSwift Packageを利用する

利用したいiOSアプリで、XcodeのFile > Add Package Dependenciesから、2-3で作成したGitHubのURLで検索し、Swift Packageを追加します。
Ref) https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app#Add-a-package-dependency

AndroidアプリでMaven Artifactを利用する

利用したいAndroidアプリで、3.で作成したMaven Artifactをbuild.gradle.ktsimplementation("com.github.kaseken:kmpsamplelibrary:1.0.0")のようにdependencyに追加します (実際のソースコード)。

また、settings.gradle.ktsにGitHub Packageのurlとcredentialを追加する必要があります (実際のソースコード)。
Maven Artifactをpublicに公開している場合であっても、credentialの追加は必要でした。

dependencyResolutionManagement {
  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
  repositories {
    google()
    mavenCentral()
    // Configurations for searching GitHub Packages.
    maven {
      url = uri("https://maven.pkg.github.com/kaseken/KMPSampleLibrary")
      credentials {
        username = System.getenv("GITHUB_USERNAME") ?: throw Exception("GITHUB_USERNAME is not set")
        password = System.getenv("GITHUB_TOKEN") ?: throw Exception("GITHUB_TOKEN is not set")
      }
    }
  }
}

今回共通ライブラリ化したPriorityQueueを利用して、配列から最小の要素を取り出す処理を両アプリに追加し、問題なく動いていることを確認しました。

iOS Android

KMP Libraryに対する所感

KMPを利用してiOS/Androidアプリ向けの共通ライブラリを作成することについて、現時点での所感を書きます。
共通のPackageを作ること自体は難しくないと感じました。
一方で、Kotlinでコードを共通化できることのメリット以上に、Swift PackageからKMPコードを参照する際のSwiftとの相互運用性の低さのデメリットが気になりました。先述した通り、Genericsの型情報やNull安全性がObjective-Cの制限により失われる点など、少し試した限りでも使いにくさを感じる部分がいくつかありました。
以下のブログ記事でもObjective-Cのheaderファイルを介することに由来する制限が短所として挙げられていますが、その通りだと思います。
https://developers.freee.co.jp/entry/reflections-on-using-kotlin-platform-at-freee

個人的な結論としては、インタフェースが単純な一方で、内部実装が複雑な処理などをKotlinコードで共通化するなどは考えられるものの、より一般的に積極的にKMPでコードを共通化していくのは難しさがあると感じました。今後、Swiftとの相互運用性が改善すれば、より実用可能性が高まると考えます。

Discussion