Android
iOS
Kotlin
KotlinNative

Kotlin/Native Multiplatform プロジェクトで Android/iOS 向けの共通ライブラリを作る

Kotlin/Native の Multiplatform プロジェクト (MPP) を使って、Android/iOS 両方に対応したライブラリを作成します。

Kotlin/Native MPP ライブラリプロジェクトは、Android アプリと iOS アプリとでなるべく実装を共通化を目指して設計します。

本記事のサンプルプロジェクトは irgaly/kotlin-multiplatform へ設置しています。

公式のドキュメントは Multiplatform Project: iOS and Android が近いと思います。

Kotlin/Native を Android/iOS アプリ開発に導入していくモチベーションはこちらにまとめています。 → Kotlin/Native を Android/iOS アプリ開発に導入しよう

Kotlin, Kotlin/Native のバージョン

本記事は以下のバージョン時点の情報です。

  • Kotlin 1.3.0-rc-131
    • Kotlin/Native 0.9.3
  • Gradle 4.10.2

Gradle のバージョンも、上記バージョン以降のものを使用します。

開発環境

記事執筆時点の環境です。

  • macOS Mojave 10.14
  • Android Studio 3.2.1
  • Xcode 10.0 (10A255)

IntelliJ IDEA, AppCode でも開発は可能です。

Kotlin/Native Multiplatform プロジェクト (MPP) とは

Kotlin/Native Multiplatform プロジェクト (MPP) は、複数の環境向けのアプリやライブラリを生成するための Kotlin/Native プロジェクトの構成です。

すべてのプラットフォーム向けの共通の Common コードと、プラットフォーム固有の機能へアクセス可能な Platform specific コードを含めることができ、一つの MPP から、それぞれのプラットフォーム向けのアプリやライブラリが生成されます。

プロジェクトの構成

サンプルプロジェクトの構成は以下の通りです。

project_tree.png

ディレクトリ 内容
kotlin-multiplatform
├ android Android アプリプロジェクト (ライブラリ動作確認用アプリ)
├ ios.swift Xcode プロジェクト (ライブラリ動作確認用アプリ)
├ library Kotlin/Native Multiplatform プロジェクト
│   ├ build.gradle Kotlin/Native Multiplatform ビルド構成
│   ├ android.gradle Android Library ビルド構成
│   ├ src/commonMain Kotlin/Native Common Kotlin sourceSet
│   ├ src/android Android Library 向け Kotlin sourceSet
│   ├ src/iosMain Kotlin/Native iOS Framework 向け Kotlin sourceSet
├ build.gradle 全体のプロジェクト設定
├ settings.gradle プロジェクトの定義
└ ...

library モジュールが Kotlin/Native MPP です。

ライブラリの動作確認用にそれぞれのアプリプロジェクトを設置しています。android モジュールは通常の Android アプリプロジェクトで、ios.swift ディレクトリは通常の Xcode プロジェクトです。

このプロジェクトは成果物として iOS 向けの .framework と、Android 向けの .aar を作成します。.framework は Kotlin/Native としてビルドされますが、.aar は Kotlin/Native ではなく Android Library としてビルドします。そのため、MPP ビルド設定として library/build.gradle の他に library/android.gradle の Android ライブラリ向けビルド構成が混ざって存在することになります。

Common sourceSet について

すべてのプラットフォームで共通の Common sourceSet と、プラットフォームごとに個別に実装する Platform specific sourceSet があります。
それぞれのプラットフォーム向けのビルド時に以下の組み合わせでビルドされます。

プラットフォーム 使われる sourceSets の組み合わせ
Android src/commonMain + src/android
iOS src/commonMain + src/iosMain

Common コードは純粋な Kotlin/Native コードだけを実装することができます。Platform specific コードはプラットフォームのすべての機能へアクセスできる実装となります。
src/android , src/iosMain のプラットフォーム固有実装は最低限となるようにして、なるべく多くの共通ロジックを src/commonMain へ実装できるように設計していくことになります。

build.gradle

プロジェクトのすべての gradle モジュール全体に共通するビルド設定です。

buildscript {
    ext {
        kotlin_version = '1.3.0-rc-131'
        kotlinx_coroutines_version = '0.22.5'
    }

    repositories {
        jcenter()
        google()
        maven { url "https://kotlin.bintray.com/kotlinx" }
        maven { url "https://dl.bintray.com/kotlin/kotlin-eap" }
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.2.1'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

allprojects {
    repositories {
        jcenter()
        google()
        maven { url "https://kotlin.bintray.com/kotlinx" }
        maven { url "https://dl.bintray.com/kotlin/kotlin-eap" }
    }
}

Kotlin 1.3.0-rc-131 のために kotlin-eap リポジトリを追加しています。

Android Library モジュールや動作検証用の Android モジュールを含んでいるため com.android.tools.build:gradle も定義します。

library/build.gradle

Kotlin/Native MPP モジュールのビルド設定です。

apply plugin: 'kotlin-multiplatform'
apply plugin: 'maven-publish'

ext {
    android_version = '1.0.0'
    android_build = 1
    android_library_name = "multiplatform"
    ios_version = '1.0.0'
    ios_framework_name = 'KotlinMultiPlatform'
}

apply from: 'android.gradle'

成果物として Android 向け .aar と iOS 向け .framework を生成することになるため、それぞれのリリース情報を ext に定義し、必要な箇所から参照できるようにしています。

MPP 構成とするため、kotlin-multiplatform plugin を適用しています。

また、library モジュールを Android Library プロジェクトとしてもビルドできるようにするため、後述の android.gradle を読み込んでいます。

続いて、kotlin ディレクティブで MPP を構成します。

kotlin {
    targets {
        fromPreset(presets.android, 'android')
        fromPreset(presets.iosArm64, 'iosArm64')
        fromPreset(presets.iosX64, 'iosX64')

        configure([iosArm64, iosX64]) {
            compilations.main {
                outputKinds += FRAMEWORK
            }
        }
    }

targets により、MPP の成果物を定義します。
必要な設定は presets として提供されていますので、用意されている presets を指定します。

presets の一覧がどこにあるか不明ですが、とりあえず kotlinTargetPresetss.kt で presets が作られているようです。

fromPresets の第二引数は任意の target 名です。
configure により iosArm64, iosX64 target の成果物として iOS framework の FRAMEWORK を指定します。

outputKinds の値は GRADLE_PLUGIN.md に記載があります。

EXECUTABLE - an executable file;
KLIBRARY - a Kotlin/Native library (*.klib);
FRAMEWORK - an Objective-C framework;
DYNAMIC - shared native library;
STATIC - static native library.

    sourceSets {
        commonMain {
            dependencies {
                implementation "org.jetbrains.kotlin:kotlin-stdlib-common:$kotlin_version"
            }
        }
        commonTest {
            dependencies {
                implementation "org.jetbrains.kotlin:kotlin-test-annotations-common:$kotlin_version"
                implementation "org.jetbrains.kotlin:kotlin-test-common:$kotlin_version"
            }
        }
        androidMain {
            dependencies {
                implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
            }
        }
        iosMain {
        }
        iosArm64Main {
            dependsOn iosMain
        }
        iosX64Main {
            dependsOn iosMain
        }
    }
}

それぞれの sourceSets を設定します。
各 sourceSets は dependencies により独立した依存関係を定義できるようになっています。

commonMaincommonTest sourceSets は Common sourceSets として必ず定義されています。
library/src/commonMainlibrary/src/commonTest ディレクトリがソースコードの読み込み対象として設定されています。それぞれ dependencies により、Common が依存するライブラリを指定します。

target として androidiosArm64iosX64 を定義したため、それぞれ androidMainiosArm64MainiosX64Main が sourceSets として定義されています。

androidMain の sourceSets は library/src/androidMain ディレクトリが設定されるはずですが、今回は Android Library として library/src/android ディレクトリを用意しているため、library/src/androidMain ディレクトリは使いません。

iosArm64MainiosX64Main は独自に定義している iosMain の設定を引き継ぐように dependsOn を設定しています。
iosMain の sourceSets は library/src/iosMain ディレクトリを対象として定義されているため、arm64 と x64 とは共通のディレクトリを指定していることになります。

afterEvaluate {
    [linkReleaseFrameworkIosArm64, linkDebugFrameworkIosArm64,
     linkReleaseFrameworkIosX64, linkDebugFrameworkIosX64].forEach { task ->
        task.doFirst {
            def target = compilation.target.disambiguationClassifier
            def buildType = compilation.buildTypes.find{ it.debuggable == debuggable }.name.toLowerCase()
            compilation.extraOpts '-o', new File(buildDir, "bin/$target/${compilation.name}/$buildType/framework/${ios_framework_name}.framework").absolutePath
        }
        task.doLast {
            compilation.extraOpts.clear()
        }
    }
...
}

iOS .frameworkの生成にあたり、framework の名称はデフォルトでは gradle project name の library.framework となってしまいます。
現時点では適切な名前を指定するオプションが存在しないため、ワークアラウンドとして afterEvaluate で framework 名称を書き換えるスクリプトを仕込んでいます。

library/android.gradle

library/android.gradlelibrary/build.gradle から参照されているビルド設定です。
これは通常の Android Library プロジェクトとして記載します。

apply plugin: 'com.android.library'

android {
    compileSdkVersion 28

    archivesBaseName = android_library_name
    version android_version
    group 'net.irgaly.kotlin'

...

    sourceSets.each {
        def root = "src/android/${it.name}"
        it.setRoot(root)
        it.java.srcDirs += "${root}/kotlin"
    }

...

}

archivesBaseName で生成されるのライブラリ名を設定しています。

sourceSets で、 library/android/main ディレクトリをプロジェクトリソースディレクトリとして設定し、 library/android/main/kotlin ディレクトリを Android Library の sourceSets として設定しています。

また、MPP 設定側で pretsets.android を設定したことにより、Android Library のビルド設定のうち、sourceSets が更新されます。つまり、Android Library 側で library/android/main/kotlin ディレクトリが設定され、 MPP 側で library/commonMain/kotlin ディレクトリが追加されます。これで、Android Library をビルドするだけで Common sourceSets のクラスも取り込まれるようになります。

Common コードと Platform specific コードの関係

Common と Platform specific コードの依存関係は以下の通りです。

Kotlin_Native MPP.png

Common コードは、純粋な Kotlin (Kotlin/Native) 実装である必要があり、Platform specific コードや Platform の機能を参照することはできません。逆に、Platform specific コードは Common コードを参照することができます。

Common コードから Platform specific コードへアクセスしたいときは Kotlin/Native MPP の機能である expectactual を使います。

実際のクラス実装を示して、 expect / actual 実装を確認していきます。

Common クラスの実装

Android/iOS で共通の実装として User クラスを実装します。
完全にプラットフォーム非依存のロジックであれば Common 以下に kotlin ファイルを設置するだけです。

src/commonMain/kotlin/net/irgaly/kotlin/multiplatform/library/model/User.kt

package net.irgaly.kotlin.multiplatform.library.model

/// プラットフォーム非依存オブジェクト
class User (val name: String)

この User クラスは、どこからでも使うことができます。

expect / actual を用いた Platform specific クラスの実装

Common コードから Platform specific コードへアクセスするために expect / actual キーワードを使用します。

src/commonMain/kotlin/net/irgaly/kotlin/multiplatform/library/model/platform/Platform.kt

package net.irgaly.kotlin.multiplatform.library.platform

expect object Platform {
    val model: String
}

expect キーワードを指定することで、Common コード側にはインタフェースだけが定義され、その実装が Platform specific コード側に存在することを宣言します。
expect は class や object に宣言することで、内包するプロパティやメソッドがすべて expect であるとみなします。

今回は Platform object のインタフェースだけを Common コード側に定義しています。

src/android/main/kotlin/net/irgaly/kotlin/multiplatform/library/model/platform/Platform.kt

package net.irgaly.kotlin.multiplatform.library.platform

import android.os.Build

actual object Platform {
    actual val model: String
        get() = Build.MODEL
}

Android 側の Platform object 実装です。actual を宣言することで、expect に対応する実装であることを示します。
PlatformPlatform.model の実装を提供するので、それぞれに actual を宣言しています。

こちらは通常の Android 向け Kotlin であるため、android.os.Build へアクセスしています。

src/iosMain/kotlin/net/irgaly/kotlin/multiplatform/library/model/platform/Platform.kt

package net.irgaly.kotlin.multiplatform.library.platform

import platform.UIKit.*

actual object Platform {
    actual val model: String
        get() = UIDevice.currentDevice.model
}

iOS 側の Platform object 実装です。こちらも同様に actual を宣言して Platform を実装しています。

Platform specific 側の実装であるため、iOS API である UIDevice を利用することができます。

Android 向け .aar をビルドする

% ./gradlew :library:assembleRelease

通常の Android Library としてビルドされます。
library/build/outputs/aar/multiplatform-1.0.0.aar が生成されており、これを通常の Android ライブラリとして利用可能です。

android_output.png

iOS 向け .framework をビルドする

iOS 向けに .framework を生成します。

% ./gradlew :library:linkIosArm64
または
% ./gradlew :library:linkIosX64

以下のように、library/build/bin 以下に KotlinMultiPlatform.framework が生成されます。

ios_output.png

arm64 版、x64 版が別の .framework に分かれてしまっていて扱いにくい点は残念ですが、ネイティブバイナリ状態の framework として iOS アプリに組み込めるものとなっています。

library.framework は、ワークアラウンドで無理矢理フレームワーク名を変更したために残ったゴミです。削除して構いません。

ライブラリ検証用 Android アプリモジュールを設定する

.aar を組み込むことで Android アプリからライブラリを利用することが可能ですが、
ライブラリの開発中は .aar を生成せずに直接ライブラリを読み込むようにすると動作確認も簡単です。

検証アプリ向けのビルド設定である android/build.gradle は通常の Android モジュールですが、ライブラリを参照するために以下の設定を追加します。

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

android {
...
}

dependencies {
...
    implementation project(':library')
}

project(':library') の依存関係を追加することで Common コードや Android Library コードへのアクセスが可能です。

ライブラリ検証用 iOS Xcode プロジェクトを設定する

iOS も、ライブラリ開発中に検証できる環境を用意します。
こちらは Xcode プロジェクトの Build Phase から gradle を呼び出し、.framework を生成できるようにします。

Swift で Xcode プロジェクトを新規作成し、Build Settings に以下の設定を追加します。

xcode_buildsettings.png

Key Value
KN_LIBRARY_BUILD_PATH/Debug/Any iOS Simulator SDK $SRCROOT/../library/build/bin/iosX64/main/debug/framework/KotlinMultiPlatform.framework
KN_LIBRARY_BUILD_PATH/Debug/Any iOS SDK $SRCROOT/../library/build/bin/iosArm64/main/debug/framework/KotlinMultiPlatform.framework
KN_LIBRARY_BUILD_PATH/Release/Any iOS Simulator SDK $SRCROOT/../library/build/bin/iosX64/main/release/framework/KotlinMultiPlatform.framework
KN_LIBRARY_BUILD_PATH/Release/Any iOS SDK $SRCROOT/../library/build/bin/iosArm64/main/release/framework/KotlinMultiPlatform.framework
KN_LIBRARY_BUILD_TASK/Debug/Any iOS Simulator SDK linkDebugFrameworkIosX64
KN_LIBRARY_BUILD_TASK/Debug/Any iOS SDK linkDebugFrameworkIosArm64
KN_LIBRARY_BUILD_TASK/Release/Any iOS Simulator SDK linkReleaseFrameworkIosX64
KN_LIBRARY_BUILD_TASK/Release/Any iOS SDK linkReleaseFrameworkIosX64

Build Phases の設定を追加します。

xcode_buildphases.png

Compile Sources の前に Build Kotlin Common Library として Run Script Phase を追加します。

mkdir -p "$SRCROOT/build"
"$SRCROOT/../gradlew" -p "$SRCROOT/../library" "$KN_LIBRARY_BUILD_TASK"
rm -rf "$SRCROOT/build/KotlinMultiPlatform.framework"
cp -a "$KN_LIBRARY_BUILD_PATH" "$SRCROOT/build/KotlinMultiPlatform.framework"

gradle で Kotlin/Native MPP をビルドし、その成果物を build ディレクトリへコピーしています。
ライブラリのビルドが必要であればそのビルド時間がかかりますが、すでにビルドが完了していれば gradle 側でビルドはスキップされることになります。

Build Kotlin Common Library を追加したのち、一度 Xcode プロジェクトをビルドして build/KotlinMultiPlatform.framework が設置されることを確認します。

Link Binary With Librariesbuild/KotlinMultiPlatform.framework を追加します。

Copy Bundle Resources のあとに Copy Kotlin Common Library として Copy Files Phase を追加します。
DestinationFramework に設定し、build/KotlinMultiPlatform.framework を追加します。

これで Swift の Xcode プロジェクトから .framework を参照できるようになりました。以下の Swift コードによりライブラリを使用します。Swift からは通常の Objective-C Framework として認識されています。

ViewController.swift

import UIKit
import KotlinMultiPlatform

class ViewController: UIViewController {
    @IBOutlet weak var label: UILabel!
    override func viewDidLoad() {
        super.viewDidLoad()
        let user = User(name: "user name")
        label.text = "\(Platform.init().model) / \(user.name)"
    }
}

ちなみに、Kotlin/Native MPP により生成された KotlinMultiPlatform.framework/Headers/KotlinMultiPlatform.h は以下のようになっています。

Kotlin のクラスの共通基底クラスとして KotlinBase が定義され、 KMPUser クラスが Swift 向けに User という名称でアクセス可能となっていることが分かります。

Platform は Kotlin 側ではシングルトンの object ですが、Swift 側からは Platform.init() でそのシングルトンインスタンスへのアクセスとなります。まだ Kotlin から Swift 向けのインタフェースへの変換は洗練されていない印象です。

#import <Foundation/Foundation.h>

@class KMPUser, KMPPlatform;

NS_ASSUME_NONNULL_BEGIN

@interface KotlinBase : NSObject
- (instancetype)init __attribute__((unavailable));
+ (instancetype)new __attribute__((unavailable));
+ (void)initialize __attribute__((objc_requires_super));
@end;

@interface KotlinBase (KotlinBaseCopying) <NSCopying>
@end;

__attribute__((objc_runtime_name("KotlinMutableSet")))
__attribute__((swift_name("KotlinMutableSet")))
@interface KMPMutableSet<ObjectType> : NSMutableSet<ObjectType>
@end;

__attribute__((objc_runtime_name("KotlinMutableDictionary")))
__attribute__((swift_name("KotlinMutableDictionary")))
@interface KMPMutableDictionary<KeyType, ObjectType> : NSMutableDictionary<KeyType, ObjectType>
@end;

@interface NSError (NSErrorKotlinException)
@property (readonly) id _Nullable kotlinException;
@end;

...

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("User")))
@interface KMPUser : KotlinBase
- (instancetype)initWithName:(NSString *)name __attribute__((swift_name("init(name:)"))) __attribute__((objc_designated_initializer));
@property (readonly) NSString *name;
@end;

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Platform")))
@interface KMPPlatform : KotlinBase
+ (instancetype)alloc __attribute__((unavailable));
+ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable));
+ (instancetype)platform __attribute__((swift_name("init()")));
@property (readonly) NSString *model;
@end;

NS_ASSUME_NONNULL_END

まとめ

Kotlin/Native MPP の基本的な構成を説明しました。
Kotlin/Native MPP では Common コードと Platform specific コードとに分かれており、
expect / actual キーワードで Cmmon コードと Platform specific コードが結合されます。

理屈の上では Platform specific コードからはプラットフォームのすべての機能にアクセスできますが、マルチプラットフォームプロジェクトの強みを活かすには Platform Specific コードを最小限となるように設計することをおすすめします。