タケハタのブログ

プログラマの生き方、働き方、技術について雑多に書いていくブログです。

Kotlin MPPのライブラリを作りサーバー、Android、iOS、Webでコード共有する

2日遅くなってしまいましたが、Swift/Kotlin愛好会 Advent Calendar 2019 23日目の記事です。

先日KotlinConf 2019のレポートにも書きましたが、Kotlin MPPに関するワークショップを受けてきました。

今回はそこでやったことを参考に、Kotlin MPPでライブラリを作り、複数のプラットフォーム(サーバー、Android、iOS、Web)でロジック共有をするやり方を紹介したいと思います。

ワークショップで使った資料等は有料コンテンツのためリンクは貼れないのですが、下記の公式ドキュメントが参考になると思います。

play.kotlinlang.org

ここで書いてあるような内容をハンズオンで実践しました。

Kotlin MPPとは?

Kotlin Multiplatform Projectのことです。
Kotlinで実装したコードを、ビルドしてAndroid、iOS、Webをはじめとした色々なプラットフォームで共有することができます。

まだExperimentalの機能ですが、KotlinConf 2019でも多くセッションがあったりと、今後のKotlinのアップデートの目玉機能の一つと言えます。

kotlinlang.org

プロジェクトの作成と実装までの準備

Kotlin MPPのプロジェクトを作成します。
その後にbuild.gradleの変更など準備が色々あるので、順番に説明していきます。

IntelliJ IDEAでプロジェクトの作成

まず、IntelliJ IDEAで新しいプロジェクトを作成します。

File -> New -> Project から、Gradleプロジェクトで「Kotlin/Multiplatform」を選択します。
今回の記事ではbuild.gradle.ktsで紹介するため、「Kotlin DSL build script」にもチェックを入れてください。

f:id:take7010:20191225222225p:plain

あとは任意のGroupId、ArtifactIdを設定して作成。

f:id:take7010:20191225222300p:plain

今回のサンプルでは

  • GroupId: com.mpplibrary
  • ArtifactId: kotlin-mpp-library-example

という名前の前提で進めます。

gradleの定義追加

Kotlin MPPでのビルドを実行するには、Gradleでいくつかの記述をする必要があります。

gradle-wrapper.propertiesの修正

まず、gradle-wrapper.propertiesを下記のように修正します。

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

デフォルトの状態から変更点は、distributionUrlです。
ここで指定しているGradleのバージョンが古い状態(このサンプルを作成した時点では5.2.1でした)だとビルド時にエラーになってしまうため、「5.6.1」のURLに変更します。jj

build.gradleへの定義追加

build.gradleは下記になります。

plugins {
    kotlin("multiplatform") version "1.3.61"
    `maven-publish` // ④Mavenに公開するためのプラグイン
}

group = "com.mpplibrary"
version = "1.0.0"

repositories {
    mavenCentral()
}

kotlin {
    // ①対象プラットフォームの定義
    jvm()

    js { browser() }

    iosX64 {
        binaries {
            framework {
                baseName = "mppLibrary"
            }
        }
    }

    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation(kotlin("stdlib-common"))
            }
        }
        val commonTest by getting {
            dependencies {
                implementation(kotlin("test-common"))
                implementation(kotlin("test-annotations-common"))
            }
        }

        // ②各プラットフォームのsrcディレクトリの定義
        val jvmMain by getting {
            dependencies {
                implementation(kotlin("stdlib"))
            }
        }
        val jvmTest by getting {
            dependencies {
                implementation(kotlin("test"))
                implementation(kotlin("test-junit"))
            }
        }

        val jsMain by getting {
            dependencies {
                implementation(kotlin("stdlib-js"))
            }
        }
        val jsTest by getting {
            dependencies {
                implementation(kotlin("test-js"))
            }
        }
    }
}

// ③iOSのライブラリビルドの定義
val packForXcode by tasks.creating(Sync::class) {
    val mode = System.getenv("CONFIGURATION") ?: "DEBUG"
    val framework = kotlin.targets
        .getByName<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>("iosX64")
        .binaries.getFramework(mode)
    inputs.property("mode", mode)

    dependsOn(framework.linkTask)

    val targetDir = File(buildDir, "xcode-frameworks")
    from({ framework.outputDirectory })
    into(targetDir)
}

tasks.getByName("assemble").dependsOn(packForXcode)

①対象プラットフォームの追加

まず、ライブラリをビルドする対象のプラットフォームの設定を追加します。

jvm()

js { browser() }

iosX64 {
    binaries {
        framework {
            baseName = "mppLibrary"
        }
    }
}

jvm() はJVMの定義で、通常KotlinやJavaで作っているライブラリと同様のものになります。
サーバーとAndroidはいずれもKotlinで実装するため、こちらを使います。

js はJavaScriptの定義になります。

iosX64 はiOSの定義で、ここだけ少し記述が特殊になります。
Xcodeで使用するためのフレームワークファイルを作成するための定義になります。
baseName で設定されている名前が、フレームワークファイルのファイル名にも使われます。

②ソースディレクトリの設定

各プラットフォーム毎の固有のコードを配置するソースディレクトリを定義します。
sourceSets 配下に下記を追加します。

val jvmMain by getting {
    dependencies {
        implementation(kotlin("stdlib"))
    }
}
val jvmTest by getting {
    dependencies {
        implementation(kotlin("test"))
        implementation(kotlin("test-junit"))
    }
}

val jsMain by getting {
    dependencies {
        implementation(kotlin("stdlib-js"))
    }
}
val jsTest by getting {
    dependencies {
        implementation(kotlin("test-js"))
    }
}

デフォルトでは commonMain commonTest が定義されていますが、ここが共通ライブラリのコードを配置する場所になります。
(各プラットフォームのMain、Testを追加していますが、今回はテストに関しての説明はしません)

③iOSのライブラリビルドの定義

iOSではもう一個、生成したフレームワークファイルをXcodeから使用するために下記の記述が必要になります。

val packForXcode by tasks.creating(Sync::class) {
    val mode = System.getenv("CONFIGURATION") ?: "DEBUG"
    val framework = kotlin.targets
        .getByName<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>("iosX64")
        .binaries.getFramework(mode)
    inputs.property("mode", mode)

    dependsOn(framework.linkTask)

    val targetDir = File(buildDir, "xcode-frameworks")
    from({ framework.outputDirectory })
    into(targetDir)
}

tasks.getByName("assemble").dependsOn(packForXcode)

④Mavenに公開するためのプラグイン JVMのプラットフォームでライブラリを実行する際、mavenLocalに公開する手順があるため、 maven-publish というプラグインを追加します。

plugins {
    kotlin("multiplatform") version "1.3.61"
    `maven-publish` // ④Mavenに公開するためのプラグイン
}

ソースディレクトリの作成

src ディレクトリ配下に、各プラットフォーム毎コードを配置する下記ディレクトリを作成します。

  • iosX64Main
  • iosX64Test
  • jsMain
  • jsTest
  • jvmMain
  • jvmTest

f:id:take7010:20191225222513p:plain

共通ライブラリの実装

まず、commonMain/kotlin の配下に任意のパッケージを作成します。
注意点として、ここで右クリック->New を選択しても、「Package」が表示されません。
「Directory」を選択して、下記のように入力するしかありません。
(最初ここで地味に戸惑いました)

f:id:take7010:20191225222400p:plain

そして、showMessage.ktという名前で下記を作成します。

fun helloPlatform() =
    "Hello ${ platformName() }"

expect fun platformName(): String

「helloPlatform」が各プラットフォームから共通で実行される処理になります。
プラットフォームの名前を含んだメッセージの文字列を返却するだけの関数です。

そしてその中で呼ばれている「platformName」が下に定義されています。
expect というキーワードを付けると、各プラットフォーム毎の実装が必要であることを意味します。
(プラットフォーム毎の実装がない場合は、expect の付いた関数がなくてももちろん大丈夫です)

expect があると、それに対応する各プラットフォームの実装がないと、コンパイルエラーになります。
プラットフォーム毎の実装は、この後紹介します。

プラットフォーム固有の実装

パッケージの作成

先程作成した下記ソースディレクトリの中に、commonMain で作っていたのと同様のパッケージを作成します。

  • iosX64Main
  • iosX64Test
  • jsMain
  • jsTest
  • jvmMain
  • jvmTest

ちなみにjvmMain、jvmTestの配下だけは、ここで右クリック->New で「Package」が表示されます。

iOSの固有処理実装

まずiOSの固有処理の実装です。
iosX64Main の作成したパッケージの配下に、下記のコードを配置します。

showMessageIos.kt

actual fun platformName(): String = "iOS"

ここで使用している actual というキーワードは、共通処理の実装で使用した expect を付けた関数に対して、プラットフォーム毎の固有処理を実装する関数を意味するものになります。 今回はプラットフォームの名前を返却したいだけなので、ここでは「iOS」という文字列を返しています。

JavaScriptの固有処理実装

こちらも同様に jsMain の配下に、下記のコードを配置します。

showMessageJs.kt

actual fun platformName(): String = "JavaScript"

JVMの固有処理実装

JVMも同様で、 jvmMain の配下に、下記のコードを配置します。

showMessageJvm.kt

actual fun platformName(): String = "JVM"

ビルドしてMavenLocalに公開する

MavenLocalを使うと、ローカルのディレクトリに配置したライブラリを、Mavenリポジトリと同じように参照することができます。
デフォルトでは ~/.m2 の配下が対象になります。

今回のライブラリで実際にやってみます。
build.gradleのversionを1.0.0に変更します。

version = "1.0.0"

そしてGradleビューから Tasks -> build -> buildを実行。
最後に Tasks -> publishing -> publishToMavenLocalを実行すると、 ~/.m2 に各プラットフォームのライブラリが配置されます。
(これが冒頭で説明した maven-publish プラグインのタスクです)

ls ~/.m2/repository/com/mpplibrary
kotlin-mpp-library-example-iosx64   kotlin-mpp-library-example-js       kotlin-mpp-library-example-jvm      kotlin-mpp-library-example-macos    kotlin-mpp-library-example-metadata

さらに各ライブラリの中を見ると、バージョン毎のディレクトリが作られ、その中にjarファイルが存在します。

$ ls ~/.m2/repository/com/mpplibrary/kotlin-mpp-library-example-jvm
1.0.0               maven-metadata-local.xml

Gradleでダウンロードしてきた状態と同じですね。
このMavenLocalの参照方法については後述します。

各プラットフォームから呼び出す

作成したライブラリを各プラットフォームから実行していきます。
今回はライブラリを呼び出すところの説明がメインなので、各プラットフォームのプロジェクト作成などはざっくりした説明にします。

サーバー

まずはサーバーから。
こちらはKtorを使って実装し、JVMのライブラリを呼び出します。

Ktorのプロジェクト作成

IntelliJ IDEAで File -> New -> Projectを選択します。

そしてKtorを選択し、フィーチャーのチェックは全てなしで、デフォルトの状態で作成します。

f:id:take7010:20191225222919p:plain

GroupId、ArtifactIdや作成するディレクトリは任意で構いません。

mpp-libraryの依存関係追加

build.gradleで、先ほど公開したMavenLocalにあるJVMのライブラリへの依存関係を追加します。
まず、MavenLocalを参照するために、 repositoriesmavenLocal() を追加します。

repositories {
    mavenLocal()
    jcenter()
    mavenLocal()
}

そして、 dependencieskotlin-mpp-library-example-jvm への依存関係を追加します。

implementation "com.mpplibrary:kotlin-mpp-library-example-jvm:1.0.0"

この2箇所の記述をすることで、Gradleが ~/.m2 配下にあるライブラリを参照してくれます。

ライブラリの実行

あとは通常通りライブラリの関数をインポートして、実行するだけです。
「Application.kt」を下記のように変更してください。

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused")
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
    routing {
        get("/") {
            call.respondText(helloPlatform())
        }
    }
}

helloPlatform() を実行し、結果の文字列を返却するだけの処理をルートパスで実装しています。 そしてmain関数を実行してアプリケーションを起動し、ブラウザで localhost:8080 へアクセスします。

f:id:take7010:20191225230340p:plain

「Hello JVM」の文字列が表示されていれば成功です。
これでサーバープログラムからJVMのライブラリを呼び出すことができました。

Android

次はAndroidです。
こちらはAndroid Studioでプロジェクトを作成し、ライブラリはサーバーと同様にJVMのものを呼び出します。

プロジェクトの作成

Android Studioを起動し、 File -> New -> New Project を選択します。
そして「Empty Activity」を選択し、Next。

f:id:take7010:20191225223115p:plain

任意のName、Package name、Save locationを入力し、LanguageはKotlin、Minimum API levelは「Lollipop」を選択します。

f:id:take7010:20191225223129p:plain

こちらでFinishを押下すると、プロジェクトが作成されます。

mpp-libraryの依存関係追加

ここはサーバーと基本的に同じです。
ただし、Androidのプロジェクトはbuild.gradleがプロジェクト直下と、 app ディレクトリの配下にあるので、注意してください。

まず、MavenLocalへの参照の追加は、プロジェクト直下のbuild.gradleに記述します。

allprojects {
    repositories {
        google()
        jcenter()
        mavenLocal()
    }
}

そして依存関係の追加を、 app ディレクトリ配下のbuild.gradleに記述します。

implementation 'com.mpplibrary:kotlin-mpp-library-example-jvm:1.0.0'

そしてGradleを再ビルドすると、右上の↓のアイコンを押下して再ビルド(このUIがIntelliJ IDEAと微妙に違うので迷いました)します。

f:id:take7010:20191225223211p:plain

ProjectビューからExtanal Librariesの中を見ると、「kotlin-mpp-library-example-jvm」が追加されているのが分かります。

表示する画面でライブラリを実行する

Androidアプリから、JVMライブラリを実行します。
まず、activity_main.xmlを下記のように変更します。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/mainTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

変更点としては、 android:id="@+id/mainTextView" でmainTextViewにIDを振り、コードから参照できるようにしています。

そして、MainActivity.ktを下記のように変更します。

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mainTextView.text = helloPlatform()
    }
}

mainTextView.text = helloPlatform() で、テキストを helloPlatform の実行結果で書き換えています。
実行すると、下記のような画面になります。

f:id:take7010:20191225223330p:plain

これで、Androidからのライブラリの実行ができました。

iOS

次はiOSです。
Xcodeで作成したSwiftのプロジェクトで、iosx64のライブラリを呼び出します。
(ちなみにiOSが一番面倒です)

プロジェクトの作成

Xcodeを起動し、「Create a new Xcode project」を選択します。

f:id:take7010:20191225223525p:plain

「Single View App」を選択し、Next。

f:id:take7010:20191225223608p:plain

Project Name〜Organization Identifierまでは任意の情報を入力。
Languageは「Swift」、User Interfaceは「SwiftUI」を選択し、Next。

f:id:take7010:20191225223630p:plain

あとは任意のディレクトリを選択し、プロジェクトを作成します。

ライブラリをXcodeのプロジェクトから実行できるようにする

iOSのライブラリは、kotlin-mpp-library-exampleプロジェクトの build -> xcode-frameworks -> mppLibrary.frameworkの中にあります。
共通ライブラリのところで、build.gradleで設定していたディレクトリですね。

これをXcodeのプロジェクトから実行できるようにします。

フレームワークを構成に追加する

作成したフレームワークファイルを構成に追加します。
まず、「General」タブを開き、「Frameworks, Libraries, and Embedded Content」の+ボタンを押下します。

f:id:take7010:20191225224612p:plain

そして下部のプルダウンから「Add Files」を選択し、先ほどのkotlin-mpp-library-exampleプロジェクトにある「mppLibrary.framework」のディレクトリを指定します。

f:id:take7010:20191225224651p:plain f:id:take7010:20191225224718p:plain

これでフレームワークが構成に追加されました。

フレームワークの検索場所を設定する

構成に追加したフレームワークファイルの、検索場所を設定する必要があります。
今度は「Build Settings」のタブを開きます。

「All」「Combined」をアクティブにした状態で、Search Paths -> Framework Search Paths をダブルクリックします。

f:id:take7010:20191225224926p:plain

そして、+ボタンを押下し、フレームワークファイルが配置されているディレクトリ(kotlin-mpp-library-exampleプロジェクトのxcode-frameworks)のパスを入力し、保存します。

これでフレームワークファイルの検索場所が追加されました。
この設定がないと、ビルド時に構成に追加したフレームワークを見つけられずエラーになってしまいます。

ここまでで、やっと実行する準備が整いました。

表示する画面でライブラリを実行する

Androidと同じように、ライブラリを実行して取得した文字列を、画面に表示するようにします。 ContentView.swiftを下記のように変更します。

import SwiftUI
import mppLibrary

struct ContentView: View {
    var body: some View {
        Text(ShowMessageKt.helloPlatform())
        .multilineTextAlignment(.center)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

ShowMessageKt.helloPlatform() でライブラリの helloPlatform メソッドを実行し、表示する Text に設定しています。
そして実行すると

f:id:take7010:20191225225104p:plain

画面に「Hello iOS」の文字列が表示されます。

JavaScript

最後にJavaScriptの実装です。
シンプルなHTMLを作成して、JSのライブラリを呼び出します。

プロジェクトの作成

HTMLとJavaScriptを動かすだけなので作り方は何でも良いですが、ここではIntelliJ IDEAのStatic Webプロジェクトを使います。
File -> New -> Project から、「Static Web」を選択し、任意のプロジェクト名で作成します。

f:id:take7010:20191225225155p:plain

ここでは「webapplication」という名前で作成します。 (Community版の場合はStatic Webがないため、Empty Projectを使用してください)

ライブラリをコピー

iOSと同じように、JSも手動でライブラリを取り込む必要があります。
(といっても全然簡単な手順です)

JSのライブラリは、kotlin-mpp-library-exampleプロジェクトの build/js/packages/kotlin-mpp-library-example の配下に「build/js/packages/kotlin-mpp-library-example.js」という名前で出力されています。
また、このライブラリは build/js/packages_imported/kotlin/x.x.x の配下にある「kotlin.js」に依存しています。

このkotlin-mpp-library-example.js、kotlin.jsの2ファイルを、先ほど作成したwebapplicationプロジェクトの libraries ディレクトリ配下にコピーします。

表示する画面でライブラリを実行する

プロジェクト直下に下記の内容でindex.htmlを作成します。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="libraries/kotlin.js"></script>
    <script type="text/javascript" src="libraries/kotlin-mpp-library-example.js"></script>
    <title>Title</title>
</head>
<body>
<h1 id="kotlin_mpp_message">
</h1>

<script type="text/javascript">
    document.getElementById('kotlin_mpp_message').innerText =
        this['kotlin-mpp-library-example'].com.mpplibrary.helloPlatform()
</script>
</body>
</html>

scrpit タグで先ほど配置した2つのjsファイルを読み込みます。
そして this['kotlin-mpp-library-example'].com.mpplibrary.helloPlatform() でライブラリの helloPlatform メソッドを実行し、 戻り値で「kotlin_mpp_message」の要素のテキストを書き換えています。

このHTMLをブラウザで開くと、「Hello JavaScript」のメッセージが表示されます。

f:id:take7010:20191225230412p:plain

これで、全てのプラットフォームでのライブラリ呼び出しができました。

Kotlin MPPのこれからに期待

ということで、Kotlin MPPを使ってライブラリを作り、実際に各プラットフォームから呼び出すところまで紹介しました。
本当はサーバーとクライアントで疎通して、どう共有するかとかも書きたかったんですが、さすがに長すぎるので次回に回します。

実際書いてみて、プラットフォームによってはGradleの記述や手順が色々面倒だったりと、まだまだ改善の余地はあります。
しかし、このあらゆるプラットフォームでコードを共有できるというのは、やはり魅力的です。

最近はFlutterなども流行っていますが、サーバープログラムまで共有できるというのも、自分がサーバーサイドエンジニアなこともあり惹かれました。

今後もまだまだアップデートが続いていくと思うので、注目してキャッチアップしていきたいと思います。