December 22, 2018

Kotlin Multiplatform環境でKotlin SerializationとAndroid ExtensionsのParcelize Annotationを使う

この記事は2018年Kotlin Advent Calendarの22日目の記事です。

今年はAdvent Calendarに空きがあったので、代打で既に書いた記事があるので興味のある方は見てみて下さい

この2つは概念的な内容ですが、この記事は実践的な内容となっていて、どちらかというとTipsです。ただ、gradleの構成等は参考になるのではないかなと思います。

はじめに

クライアントアプリで利用されるSerializerにはKotlin SerializationとParcelize Annotationがあります。
この2つの目的(Kotlin Serializeation: JSON Serializer, Parcelize: Android Parcelable)は異なるのですが、やりたい事(Selialize)は一緒です。 お互いが干渉するものでは無いのですが、Kotlin Multiplatform環境でAndroidアプリを作る場合はどちらも利用することになると思います。
この記事では、Kotlin SerializationとParcelize Annotationを共存させる方法を解説します。

Kotlin Serializationとは

Kotlin Serializationとは、Kotlinで作られたSerializerで、クラスに@Serializable Annotationを付けると自動でSerializeしてくれます。 現在はJSON, CBOR, Protobufをサポートしていて、Kotlin/JVM, Kotlin/JS, Kotlin/Nativeでも利用可能なため、Kotlin Multiplatform環境で利用するSerializerとしてはデファクトスタンダードになっています。
丁度2日前のKotlin Advent Calendarの記事でも解説されているので、使い方はそちらを参考にすると良いと思います。

Kotlin Parcelize Annotationとは

Parcelize Annotationを説明する前に、Parcelableの説明をする必要があります。
Parcelableとは、Android OSのAPIで用意されているSerializerの一種で、Andoridでは画面間でデータを共有する場合には必ずParcelableを実装しなければなりません。 実際のinterfaceは以下のようになっていて、

interface Parcelable {
    int describeContents();
    void writeToParcel(Parcel dest, int flags);
}

意外とシンプルなのですが直列に値がSerializeされていくので、Deselializeの際にデータの復元順番が異なると正しくデータが復元されません。そのため、プロパティを追加する毎に正しい順番でParcelableの実装も行う必要があり、かなり面倒な仕様になっています。

// AとBの順番は一致する必要がある

data class User(
        val id: Int,
        val name: String,
        val email: String
) : Parcelable {

    constructor(parcel: Parcel) : this(
        // A
        parcel.readInt(),            
        parcel.readString(),
        parcel.readString()
    )

    override fun writeToParcel(parcel: Parcel, flags: Int) {
        // B
        parcel.writeInt(id)             
        parcel.writeString(name)
        parcel.writeString(email)
    }

    override fun describeContents(): Int {
        return 0
    }

    companion object CREATOR : Parcelable.Creator<User> {
        override fun createFromParcel(parcel: Parcel): User {
            return User(parcel)
        }

        override fun newArray(size: Int): Array<User?> {
            return arrayOfNulls(size)
        }
    }
}

以前からこの問題を解決するライブラリはいくつか存在していたのですが、1年程前からKotlinでKotlin Andorid ExtensionsとしてParcelize AnnotationをつければParcelableの実装を書かなくても良くなりました。

@Parcelize
data class User(
    val id: Int,
    val name: String,
    val email: String
) : Parcelable

便利さが段違いですね。AndroidでDomain Objectを定義する場合にParcelable Supportを使わない選択肢はありません。 前の記事でも説明した通り、Kotlin Multiplatform環境ではDomain Objectを各プラットフォームで共通化したいと思っています。 その場合、共通のモジュールにDomain Objectを置く必要があるのでAndroid Parcelize Annotationを呼び出す事は出来ません。

この記事では、共通のモジュールからAndroid Parcelize Annotationを呼び出す方法を書きたいと思います。
今回は、Android Parcelize Annotationですが、各プラットフォーム毎に異なるAnnotationを利用したい場合にも有効な手法ですので、やり方を知っておいて損はないと思います。

手順

今回のサンプルリポジトリはこちらにあります。 Kotlin Multiplatform環境の参考にもなると思うので見てみて下さい。

構成

このサンプルのパッケージ構成はこの様になっています。 Kotlin Multiplatformで利用される共通のモジュール名は commonになっていて、今回共通化を行いたいDomain ObjectはCommonモジュールにあるUserクラスになります。
CommonモジュールはAndroid, iOS, Web, Serverで利用予定なので、Android, Native, JS, JVMでbuildしています。
なお、依存関係のバージョンをrootディレクトリにある dependencies.gradleにて解決しているので、各gradleファイルでの参照先はこちらに記述されていることに注意して下さい。(依存関係の運用法は試行錯誤中です)

.
├── KotlinMppAndroidParcelable.iml
├── README.md
├── android
│   ├── build.gradle
│   ├── proguard-rules.pro
│   └── src
│       └── main
│           ├── AndroidManifest.xml
│           ├── java
│           │   └── com
│           │       └── github
│           │           └── aakira
│           │               └── kotlinnativesample
│           │                   └── MainActivity.kt
│           └── res
│               ├── drawable
│               └── values
├── build.gradle
├── common
│   ├── android.gradle
│   ├── build.gradle
│   └── src
│       ├── androidMain
│       │   ├── AndroidManifest.xml
│       │   └── kotlin
│       │       └── com
│       │           └── github
│       │               └── aakira
│       │                   └── kotlinnativesample
│       │                       └── common
│       │                           └── actual.kt
│       ├── commonMain
│       │   └── kotlin
│       │       └── com
│       │           └── github
│       │               └── aakira
│       │                   └── kotlinnativesample
│       │                       └── common
│       │                           ├── common.kt
│       │                           └── model
│       │                               └── User.kt
│       ├── iosMain
│       │   └── kotlin
│       │       └── com
│       │           └── github
│       │               └── aakira
│       │                   └── kotlinnativesample
│       │                       └── common
│       │                           └── actual.kt
│       ├── jsMain
│       │   └── kotlin
│       │       └── com
│       │           └── github
│       │               └── aakira
│       │                   └── kotlinnativesample
│       │                       └── common
│       │                           └── actual.kt
│       └── jvmMain
│           └── kotlin
│               └── com
│                   └── github
│                       └── aakira
│                           └── kotlinnativesample
│                               └── common
│                                   └── actual.kt
├── dependencies.gradle
├── gradle
├── gradle.properties
├── gradlew
├── gradlew.bat
├── ios
└── settings.gradle

Kotlin Serialization

Serializationは本家のREADMEが詳しく書かれているので、本記事で深掘りはしません。

rootディレクトリのgradleファイルに以下を追記します。

buildscript {

    ext.kotlin_version = '1.3.11'

    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.2.1'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

        // 追記
        classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
    }
}

allprojects {
    repositories {
        google()
        jcenter()

        // 追記
        maven { url "https://kotlin.bintray.com/kotlinx" }
    }
}

commonモジュールのbuild.gradleに追記

apply plugin: 'kotlin-multiplatform'
apply plugin: 'kotlinx-serialization'

kotlin {
    sourceSets {
        commonMain {
            dependencies {
                // serialization
                implementation rootProject.ext.serializationCommon
            }
        }
        androidMain {
            dependencies {
                // serialization
                implementation rootProject.ext.serialization
            }
        }
        iosMain {
            dependencies {
                // serialization
                implementation rootProject.ext.serializationNative
            }
        }
        jsMain {
            dependencies {
                // serialization
                implementation rootProject.ext.serializationJs
            }
        }
        jvmMain {
            dependencies {
                // serialization
                implementation rootProject.ext.serialization
            }
        }
    }
}

コード側で利用

gradleの設定は以上です。gradleの設定が済んだらAnnotationをクラスに追加してあげるだけで、動作します。

import kotlinx.serialization.Serializable

@Serializable
data class User(
    val id: Int,
    val name: String,
    val email: String
) 

Android Parcelable Support

common/commonMain

単純に考えれば以下のように、commonモジュールで定義したUserクラスにParcelize Annotationを追加するだけで利用できるように思えますが、commonで定義される箇所にプラットフォーム依存のコードを含めることは出来ません。 なぜなら、commonモジュールではプラットフォーム固有のコードはimportすることが出来ないからです。

import android.os.Parcelable            // cannot import
import kotlinx.android.parcel.Parcelize // cannot import

@Parcelize
data class User(
        val id: Int,
        val name: String,
        val email: String
) : Parcelable

common/andoridMainをAndroid Library化する

Kotlin Multiplatform Libraryでも通常のAndorid Libraryと同じようにcom.android.libraryを読み込む必要があります。 build.gradleとは分けるためにandroid.gradleを用意します。このファイルは通常のAndroid Libraryと同じ形式です。一部抜粋すると以下のようになっています。

apply plugin: 'com.android.library'
apply plugin: 'kotlinx-serialization'
apply plugin: 'kotlin-android-extensions'

android {
    // A
    sourceSets.each {
        def root = "src/androidMain/${it.name}"
        it.setRoot(root)
        it.java.srcDirs += "${root}/kotlin"
        it.manifest.srcFile "src/androidMain/AndroidManifest.xml"
    }

    // B
    androidExtensions {
        experimental = true
    }
}

// C
dependencies {
    implementation rootProject.ext.kotlin

    // serialization
    implementation rootProject.ext.serialization
}

ポイントは3つあります。

  • A

source setsでディレクトリ構成を変更しています。Android Libraryは必ずAndroid.manifestファイルがmainディレクトリ配下に必要になります。そのためデフォルトだとandroidMain配下のディレクトリにmainパッケージを追加しなければならず、他のパッケージ構成とズレてしまうため、パッケージ構成の変更を行っています。この部分は好みの問題ですので、各々で決めて下さい。

  • B

experimentalの機能を利用しているので記述が必要です。

  • C

Androidに関する依存関係はcommon/build.gradle内のdependenciesではなく、こちらに記述していきます。

common/build.grade でandroid.gradleをimport

apply from: 'android.gradle'

expect Annotationを用意

Androidの依存関係が解決できたので、実装に入ります。
前述の様なプラットフォーム依存が発生した時のために、Kotlin Multiplatformにはexpectactualというabstractoverrideの様な関係の修飾子を利用することが出来ます。
これを利用して、Kotin MultiplatformにおいてもAndroid Parcelize Annotationを実装していきます。

  • Kotlin Parcelize Annotation 用にcommonモジュールにexpect Annotationを追加します
@UseExperimental(ExperimentalMultiplatform::class)
@OptionalExpectation
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
expect annotation class AndroidParcelize()

一番上のAnnotation @UseExperimentalはKotlin1.3の新機能です。以前記事を書いたのでそちらを参照して下さい。
下2つのAnnotationは、Parcelizeと同様のものを付けています。

今回のポイントは OptionalExpectation Annotationです。 先程expectactualabstractoverrideの様な関係と述べました。つまり、commonモジュールに書いたexpectは各プラットフォームでactual実装を行わなければなりません。しかし、OptionalExpectationを利用すればactual実装を行わなくてもコンパイルエラーにはならなくなります。
普段のコードで使う事は推奨出来ませんが、今回の例のように1つのプラットフォームのみ実装したい場合に非常に威力を発揮します。

  • Android Parcelable用にcommonモジュールにexpect interfaceを追加します

Android ParcelableのObjectを作成するにはandorid.os.Parcelableを実装する必要があるので、commonモジュールにinterfaceを実装します。

expect interface AndroidParcel

actualを実装

  • Kotlin Parcelize Annotation用のactual
import kotlinx.android.parcel.Parcelize

actual typealias AndroidParcelize = kotlinx.android.parcel.Parcelize

ここもポイントです。
commonモジュールに定義したAndroidParcelize Annotationに対してtypealiasを用いてactualを実装しています。 こうすることで、Andorid用に生成されるKotlin Multiplatformのコードには、独自に作成したAndroidParcelize Annotationに対して、Kotlin Android Supportの機能が追加されます。

  • Android Parcelable用のParcelable

> androidMain/actual.kt

import android.os.Parcelable

actual interface AndroidParcel : Parcelable

> others

actual interface AndroidParcel

AndroidParcelはinterfaceで定義されているので、そのままandroid.os.Parcelableをプロキシしてあげましょう。他のプラットフォームに関しては何も意味を持たないので、空実装で問題ありません。

Android側から使う

簡単な例だとこのようになります。

val user = User(100, "AAkira", "hoge@gmail.com")

startActivity(
  Intent(this, MainActivity::class.java).apply {
    putExtra("user", user)
  }
)

問題なく、AndroidのモジュールからもParcelableとして認識されています。

SelializationとParcelizeの共存

共存といっても、Annotationを2つ追加するのみです。これで問題なく動作します。

import com.github.aakira.kotlinnativesample.common.AndroidParcel
import com.github.aakira.kotlinnativesample.common.AndroidParcelize
import kotlinx.serialization.Serializable

@Serializable
@AndroidParcelize
data class User(
    val id: Int,
    val name: String,
    val email: String
) : AndroidParcel

まとめ

以上の設定で、Kotlin SerializationとParcelize AnnotationをKotlin Multiplatform環境で動作させることが出来ました。このやり方はJetBrainsの人に直接聞いたやり方なので、調べた限りオンライン上にはまだ例が上がっていないので参考になるかなと思って書きました。 ~英語化した方がいい内容な気がするので気が向いたら英語化したいですが、おそらく気は向きませんw~

海外からの問い合わせがあったので英語化しました!
Use the Kotlin serialization and Android percelable on Kotlin MPP

今回のサンプルリポジトリはこちらです。
https://github.com/AAkira/KotlinMultiplatformAndoridParcelize

© AAkira 2018