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 から、それぞれのプラットフォーム向けのアプリやライブラリが生成されます。
プロジェクトの構成
サンプルプロジェクトの構成は以下の通りです。
ディレクトリ | 内容 |
---|---|
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
により独立した依存関係を定義できるようになっています。
commonMain
、commonTest
sourceSets は Common sourceSets として必ず定義されています。
library/src/commonMain
、 library/src/commonTest
ディレクトリがソースコードの読み込み対象として設定されています。それぞれ dependencies により、Common が依存するライブラリを指定します。
target として android
、 iosArm64
、 iosX64
を定義したため、それぞれ androidMain
、 iosArm64Main
、 iosX64Main
が sourceSets として定義されています。
androidMain
の sourceSets は library/src/androidMain
ディレクトリが設定されるはずですが、今回は Android Library として library/src/android
ディレクトリを用意しているため、library/src/androidMain
ディレクトリは使いません。
iosArm64Main
と iosX64Main
は独自に定義している 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.gradle
は library/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 コードの依存関係は以下の通りです。
Common コードは、純粋な Kotlin (Kotlin/Native) 実装である必要があり、Platform specific コードや Platform の機能を参照することはできません。逆に、Platform specific コードは Common コードを参照することができます。
Common コードから Platform specific コードへアクセスしたいときは Kotlin/Native MPP の機能である expect
と actual
を使います。
実際のクラス実装を示して、 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
に対応する実装であることを示します。
Platform
と Platform.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 ライブラリとして利用可能です。
iOS 向け .framework をビルドする
iOS 向けに .framework を生成します。
% ./gradlew :library:linkIosArm64
または
% ./gradlew :library:linkIosX64
以下のように、library/build/bin
以下に KotlinMultiPlatform.framework
が生成されます。
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 に以下の設定を追加します。
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 の設定を追加します。
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 Libraries
に build/KotlinMultiPlatform.framework
を追加します。
Copy Bundle Resources
のあとに Copy Kotlin Common Library
として Copy Files Phase
を追加します。
Destination
を Framework
に設定し、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 コードを最小限となるように設計することをおすすめします。