Infinito Nirone 7

白羽の矢を刺すスタイル

DroidKaigi 2020 のスタッフを支える(はずだった)技術 - 運営スタッフ向けアプリ

DroidKaigi 2020 を支えるはずだった技術シリーズの、DroidKaigi 当日にスタッフを支えるはずだった技術の話として、運営スタッフ向けアプリを作った話をします。

運営スタッフ向けアプリを作り始めたきっかけ

DroidKaigi も過去 5 開催をしてきていて、2020 で 6 回目になる予定でした。どの年も当日へ向けてたくさんのミーティングを重ね、担当ごとのタスクをこなすのに必要な情報をまとめてドキュメント化し、当日も Slack やトランシーバを駆使して綿密に連絡を取り合うなど、スタッフ間のコミュニケーションが円滑に進むよう準備をしています。

当日、スタッフが参照するドキュメントは多岐にわたります。ひとり一つの担当が割り当てられたとして、見るべきドキュメントは「全スタッフに共通する情報をもっているドキュメント」「担当ごと固有のタスクの説明を盛り込んだドキュメント」「各自の行動計画を表にしたドキュメント」の3つがあります。運営スタッフはこれらのドキュメントを見つつも、トランシーバで行き交う会話や Slack での情報共有を適宜拾い重要なお知らせに注意を向けています。

運営スタッフの持ち物としては、プリントアウトしたドキュメント類・運営スタッフはスマホ・トランシーバ・その他様々なツール類(養生テープとか軍手とか)が基本装備です。が、せっかくデータとしていつでも見られるところにドキュメント類をおいていて、スマホも連絡を取り合うために常に持ち歩いているので、スマホからのアクセス性を向上させれば持ち歩くものが減らせそうです。また「セッションの進行がうまくいっているかどうかをうまく可視化できたら面白そう」という発想で、アプリからセッションの進行管理をし、Slack と連携してログが残せるようなものができたら面白そう、というアイディアもあって、運営スタッフ向けアプリを作ることにしました。

結果、トランシーバでリアルタイムの報告・連絡をし、運営スタッフ向けアプリと Slack ではそこから得られた情報をストックしておく流れができました。

運営スタッフ向けアプリでできること

お知らせ

運営スタッフ全員に知っておいてほしいお知らせを表示するものです。新しいお知らせが増えると Push 通知でお知らせを出します。

セッションの進行管理

セッションの進行管理機能では、どのセッションの開始や終了を Slack にログを残したり、セッションルームにいるスタッフ向けのチートシートを表示したり、機材トラブルなどの場面で Slack へアラートを投げたりすることができます。また DroidKaigi 2020 での新たな試みとして、司会者のスクリプトを端末の音声合成で読み上げる機能も作りました。

セッションルームのスタッフ向けのドキュメントを見ると、開始x分前・開始時・終了x分前・終了時のタイムラインでアナウンスすべき内容や確認事項が細かく定義されています。どのセッションでも共通して使えるように落とし込まれているので、これらをアプリで管理できるようにしています。

行動計画の表示

スタッフの行動計画はスプレッドシートに記載しています。このスプレッドシートをうまく作って、Sheets API を使って行動計画をデータとして取り出し、アプリで表示しています。

受付管理

2019 でも作っていた、QR コードを読み込んで受付をする機能です。

ロジスティクス

DroidKaigi ではたくさんの荷物を事前に運び込み、終了後にはもとの場所へ搬出する作業があります。ロジスティクスの機能では、どの荷物がどこへ行くべきかを QR コードを使って管理できるようにしています。これも 2020 ではじめた取り組みです。ロジスティクス機能の詳しい話は、きっと @satsukies さんや @e10dokup さんがしてくれる…

写真撮影管理

写真撮影担当スタッフ向けに、どのセッションの写真を誰が撮影したかログを残しておく機能です。@e10dokup さんが率先して取り組んでくれました。

公式アプリと連携する機能

公式アプリに Push 通知をおくるための機能です。

設計

これらの機能開発をうまく進められるように設計を考えてみました。

自分自身の DroidKaigi 2020 での発表内容 でも触れる予定でしたが、いくつかのプラクティスを取り込んだ設計にしています。

モジュール構成

多人数で開発していけるよう、できる限りモジュールを小さく保てるような構成としました。

  • レイヤードアーキテクチャをベースに、レイヤーごとにモジュールを分割する (本当はユースケースレイヤがあるべきだけど今回は作らなかった)
    • repository_*: データアクセスに関する部分の実装。API や DB へのアクセスをする部分と、それらを透過的に扱うインタフェースを提供するモジュール。
    • feature_*: 行動予定画面、お知らせ画面、受付画面など、機能ごとに画面を実装するモジュール。プレゼンテーションの部分。
    • common _*: 横断的につかうユーティリティをおいておくモジュール。
    • app: feature_* で実装した画面をつなぐナビゲーションの実装をおいたり、Dagger の設定をおいたりして、最終的に apk を吐き出すモジュール。

f:id:KeithYokoma:20200303174812p:plain
全モジュールの一覧

プレゼンテーションレイヤの設計

当初は画面の状態をうまく表現するための仕組みとして Flux やそれに近いものを導入してみようかと考えていましたが、そこまでしなくても考え方だけ取り入れて、できる限りシンプルに実装できるよう整理することとしました。

  • プレゼンテーションレイヤは MVVM 構成。
    • ひとつの ViewModel につきひとつの State オブジェクトをもち、LiveData で State オブジェクトの変更を Fragment や Activity に通知する。簡易版 Flux のようなつくり。
    • State は data class で表現し、メンバとして非同期処理の状態を表現するオブジェクトを持たせ、非同期処理実行中・成功・失敗などの状態を表せるようにしている。
    • ViewModel は Android Architecture Component ViewModel を拡張し、Dagger で Fragment や Activity に注入する。

例えば、各自の行動予定を読み込んでくる部分の実装は次のような感じになります。Fragment は注入された ViewModelstate を observe し、View の更新をすることになります。

// State オブジェクト
data class ScheduleViewState(
  val schedule: Loadable<List<Schedule>, FailureReason> = Loadable.Ready
)

// ViewModel
class ScheduleViewModel(
  private val scheduleDataSource: ScheduleDataSource // 行動予定のデータを取ってくるデータアクセス層のインタフェース
) : ViewModel() {

  // 実際には MutableLiveData を公開してしまったまま作り込んでいた。LiveData を公開し、MutableLiveData は公開しないほうがいい…
  val _state: MutableLiveData = MutableLiveData(ScheduleViewState())
  val state: LiveData<ScheduleViewState> = _state

  private val disposables = CompositeDisposable()

  fun loadSchedule() {
    // DataSource は RxJava で結果を返すようにしている
    scheduleDataSource.load()
      .observeOn(AndroidSchedulers.mainThread())
      .doOnSubscribe {
        _state.nextState { // ScheduleViewState の schedule を読込中状態に更新
          it.copy(schedule = Loadable.Loading)
        }
      }
      .subscribe({ result ->
        _state.nextState { // ScheduleViewState の schedule を読込済み状態に更新
          it.copy(schedule = Loadable.Success(result))
        }
      }, { exp ->
        _state.nextState { // ScheduleViewState の schedule を読込失敗状態に更新
          it.copy(schedule = Loadable.Failure(InvalidResponse(exp.message.orEmpty())))
        }
      })
      .addTo(disposables)
  }

  override fun onCleared() {
    disposables.clear()
    super.onCleared()
  }
}

State オブジェクトで使っている Loadable は次のように実装しています。FailureReason はどんな原因で読み込みが失敗したかを説明するオブジェクトで、Exception から変換して作ることを想定しています。Plain なオブジェクトとしておくと、kotlinx.serializationParcelable との相性が良くなるので、失敗の理由を直接 Exception で表現することを避けています。

interface FailureReason {
  val message: String
}

sealed class Loadable<out V : Any, out E : FailureReason> {

  val isReady: Boolean
    get() = this is Ready

  val isLoading: Boolean
    get() = this is Loading

  val isSuccess: Boolean
    get() = this is Success

  val isFailure: Boolean
    get() = this is Failure

  object Ready : Loadable<Nothing, Nothing>()

  object Loading : Loadable<Nothing, Nothing>()

  class Success<V : Any>(val value: V) : Loadable<V, Nothing>() {
    // ...
  }

  class Failure<E : FailureReason>(val reason: E) : Loadable<Nothing, E>() {
    // ...
  }
}

ユニットテスト

これらを踏まえて、ViewModel のユニットテストは次のように書いています (ユニットテストは Spek を使って書いています)。

class ScheduleViewModelTest : Spek({

  // テスト準備
  beforeEachTest {
    RxAndroidPlugins.setMainThreadSchedulerHandler { Scheduler.trampoline() }
    ArchTaskExecutor.getInstance().setDelegate(TestExecutor())
  }

  afterEachTest {
    RxAndroidPlugins.reset()
    ArchTaskExecutor.getInstance().setDelegate(null)
  }

  Feature("ScheduleViewModel#loadSchedule") {

    val dataSource: ScheduleDataSource by memoized(CachingMode.EACH_GROUP) {
      mockk<ScheduleDataSource>(relaxed = true)
    }

    // Schedule が正しく読み込めたケース
    Scenario("Load data successfully") {
      lateinit var viewModel: ScheduleViewModel
      lateinit var observer: Observer<ScheduleViewState>
      lateinit var changedStateSlot: CapturingSlot<ScheduleViewState>

      // ViewModel を準備して…
      Given("ViewModel with initial state and state observer") {
        changedStateSlot = slot()
        observer = mockk(relaxed = true) {
          every { onChanged(capture(changedStateSlot)) } just Runs
        }
        every { dataSource.loadApiData() } returns Observable.just("foo")
        viewModel = NotificationViewModel(dataSource)
      }

      // ScheduleViewState を observe し…
      When("Start observing states") {
        viewModel.state.observeForever(observer)
      }

      // 読み込みを実行すると…
      And("Start loading") {
        viewModel.loadSchedule()
      }

      // 初期状態 -> 読込中 -> 成功 の順で ScheduleViewState が通知される
      Then("State changes observed in the specified order") {
        verifyOrder {
          observer.onChanged(eq(ScheduleViewState(schedule = Initial)))
          observer.onChanged(eq(ScheduleViewState(schedule = Loading())))
          observer.onChanged(eq(ScheduleViewState(schedule = Success(listOf(Schedule(/* mock data */))))))
        }
      }
    }

    // ...
  }
})

class TestExecutor : TaskExecutor() {
  override fun executeOnDiskIO(runnable: Runnable) { runnable.run() }
  override fun isMainThread(): Boolean = true
  override fun postToMainThread(runnable: Runnable) { runnable.run() }
}

UI の実装

Activity や Framgment では、ViewModel が公開している LiveData を observe して、State オブジェクトの状態に従って処理を記述します。LiveData には RxJava のように途中でオペレータをはさんで、LiveData で通知するオブジェクトに変換をかけたり、オブジェクトの変更があったときだけ observe する仕組みがあるので、observe する側はこれを活用します。

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // ...

    viewModel.loadSchedule()
    viewModel.state
      .map { it.schedule }
      .distinctUntilChanged()
      .observe(this, Observer { state ->
        // ProgressBar の表示・非表示は Loadable が Loading かどうかで変える
        binding.progress.isVisible = state.isLoading

        // 成功時・失敗時の制御は when で切り替える
        when (state) {
          is Loadable.Success -> {
            // smart cast が効くので、直接データが取り出せる
            adapter.set(state.value)
          }
          is Loadable.Failure -> {
            // エラーのときは snackbar を出す
            Snackbar.make(binding.root, state.reason.message, Snackbar.LENGTH_LONG).show()
          }
        }
      })
  }

Tips

Sheets API でセルの範囲指定をしたときの挙動

たとえば、次のような表があったとします。何も書いていないセルは空白セルです。

A B C
1 hoge fuga piyo
2 foo
3 bar
4

この表のA1からC4までを範囲指定して取得する*1場合、次のようなデータが得られます。

values: [
  ["hoge", "fuga", "piyo"],
  ["", "foo"],
  ["bar"]
]

4x3のマトリクスではなく、 trailing empty cells (列では値があるセルより後ろ、範囲の末尾までの空白セル、行では値があるセルの含まれる行より後ろ、範囲の末尾まですべてが空白セルの行) が含まれない複雑な形の配列が得られます。これは Sheets API の仕様で、空白を含めるようにするオプションはありません。

もし空白を許容し、すべてのセルに何らかの値があることを期待する場合、範囲をひとつ拡張してEOLなどのマーカーを入れておく必要があります。

A B C D
1 hoge fuga piyo EOL
2 foo EOL
3 bar EOL
4 EOL
values: [
  ["hoge", "fuga", "piyo", "EOL"],
  ["", "foo", "", "EOL"],
  ["bar", "", "", "EOL"],
  ["", "", "", "EOL"]
]

行動計画データを Sheets API で取り出すときには、この仕様のためにアプリにとっては無意味なデータをもたせて、空白セルも取得できるようにしています。

音声合成エンジン

Android には pico という音声合成エンジンが積んであります。これはもともと SVOX 社が作ったもので、モバイル用に軽量化されています。TextToSpeech を使うと標準で pico エンジンが使われますが、Play Store で他の音声合成エンジンをインストールして、それを使うことも可能です。

音声合成には、SSML というマークアップ言語も存在します。読み上げのときの発音をコントロールしたり、ルビをふるように別の読み方をさせたり、声調を変えて強調したりといったことを可能にしてくれるものです。

pico エンジンは SSML のサブセットをサポートしていて、休止を意味する <break> と、ピッチ・発話速度・音量を調整する <prosody> の 2 つが使えます。

DroidKaigi 運営スタッフ向けアプリでは、これに加えて読み替えを表す <sub> のサポートを足しました。たとえば、次のようなマークアップが与えられたとき、音声合成エンジンがDroidKaigiどろいどかいぎ と読み上げるようにマークアップを加工します。簡単なマークアップの処理ですね(<sub> で囲われた文を alias の値で上書きしてシミュレートする)。

あなたと<sub alias="どろいどかいぎ">DroidKaigi</prosody>、いますぐ参加

ちなみに、DroidKaigi のセッションルームでは、司会者は日本語・英語の両方でアナウンスをします。通常、日本語に続いて英語のアナウンスをしていますが、運営スタッフ向けアプリでこれを再現するため、音声合成エンジンが日本語の文章の読み上げを終えたことを検知したら音声合成エンジンの言語設定を英語に切り替えて英語の文章を読み上げるようにしました。これで、日本語の文章は日本語の音声合成で聞きやすく、英語の文章は英語の音声合成で聞きやすくなります。

もし、SSML を活用してより表現力の豊かな読み上げを実現する場合には、GCP や AWS の音声合成サービスを利用する必要があります。これらはより多くの SSML の機能を使えます。

ただし、音声合成の読み上げにも限界があります。たとえば、人名の読み上げはとても考えることが多く難しいことのひとつです。人名はその人の出生地(Nationality)や好みに紐付いて読み方が変わります。特に、Charles のようなどの言語でも同じつづりをする名前は、言語ごとに読み方が異なります(Charles は英語ならチャールズ、フランス語ならシャルル)。しかし音声合成エンジンは、文単位で言語設定を変えることができても、文の途中で言語設定を変えることはできません。SSML で発音記号を与えればなんとか読み上げられるかもしれませんが、そうすると今度はデータセットの準備がとても大変になります。

余談ですが、音声合成エンジンの発する声には種類があり、音声パックとしてあとからダウンロードできるようになっています。pico エンジンの場合、各言語ごとに音声1音声2といった名前で配布されており、高めの声、低めの声、太い声などを切り替えられます。Google Play で公開されているサードパーティの音声合成エンジンでは性別による声調の区別がありますが、pico では単に音声1音声2といった単純な命名で切り替えるようになっています。

おわりに

これまでは公式アプリのように毎年1からアプリを組み直していましたが、今回からは次回以降も同じコードベースを使って継続してメンテナンスしていく形にできるよう開発を進めました。今後もより DroidKaigi を支えていくアプリとなるよう改善をしていくつもりです。

DroidKaigi 2020 を支える(はずだった)技術シリーズ、はじめます

 DroidKaigi 運営スタッフの @keithyokoma です。

 記事タイトルにあるとおり、DroidKaigi 2020 を支える(はずだった)技術シリーズと題して DroidKaigi 運営スタッフによる一連のブログ投稿をはじめます。

企画の意図

 DroidKaigi はこれまで、2015 年から数えて 5 回開催しており、DroidKaigi 2020 で 6 回目の開催となる予定です。
残念ながら 2 月開催は中止となりましたが*1、過去数ヶ月にわたって運営スタッフ一同非常に多くの時間と労力を使って準備を進めてきており、せっかくなら何らかの形でこの準備の成果をお見せできるようにし、少しでも何らかのお役に立ちたいという思いから、このシリーズ企画が動き始めました。

 DroidKaigi の理念に照らし、運営スタッフもまた Android またはそれに関わる周辺技術の知見共有とコミュニティ活動に関わる一員として、DroidKaigi 2020 を支える(はずだった)技術シリーズの執筆をしていきます。

 DroidKaigi には様々な領域を担当する運営スタッフがいます。会場のネットワークを敷設・運用したりする担当もあれば、受付のしくみを構築する担当、食事の手配をする担当、セッションの採択をする担当、当日の様子を記録に残す担当など、多岐にわたっています。
 このシリーズ企画では、その各領域の担当者が思い思いに DroidKaigi 2020 を支える(はずだった)技術、あるいはこれまでの準備を支えてきた技術について語ります*2。DroidKaigi 特有の成分が多いとは思いますが、もしかすると他のカンファレンス・ミートアップ運営や技術コミュニティ活動に活かせる何かが生まれるかもしれません。各ブログ記事へのリンク集も用意しますので、ご活用ください。

DroidKaigi 2020 を支える(はずだった)技術シリーズ投稿集

新しい記事が投稿され次第順次追記していきます。

記事タイトル 投稿者
DroidKaigi 2020 を支える(はずだった)技術シリーズ、はじめます @keithyokoma
DroidKaigi 2020 中止のうらばなし @roishi2j2
DroidKaigi 2020 のスタッフを支える(はずだった)技術 - 運営スタッフ向けアプリ @keithyokoma

*1:もちろん、延期の可能性を完全に諦めたわけではなく、なんとか DroidKaigi 2020 を開催できないか現在も議論をしていますので、公式の発表があるまで今しばらくお待ちいただければと思います。

*2:すでに公式に告知していますが、発表者の方々の発表資料は今すぐ公開していただいても大丈夫ですし、他のカンファレンスでの登壇資料として活用していただいても構いません。

DroidKaigi 2020 撮影機材

カンファレンス撮影に関する詳しい話は @e10dokup さんが https://ameblo.jp/e10dokup/entry-12575646342.html でしてくれてるので、自分は DroidKaigi 2020 で使う予定だった撮影機材について、昨年からのアップデートを書いておこうと思います。

ボディ

  1. Canon EOS 6D MarkII: 前回も使ってたやつ。個人所有。手軽にフルサイズが楽しめてよいけど、長時間露光など、もうちょっと踏み込んだところの機能では EOS 5D に及ばないので、そろそろ 5D が欲しくなってきた。
  2. Canon EOS 5D MarkIV: 今回予定していたレンタル機材。せっかくなら使ってみないとね。絶対欲しくなる自身がある。

レンズ

  1. Canon EF 24-70mm f/2.8L II USM: キットレンズの 24-105mm がちょっと暗くて辛くなってきたので買っちった。これで会場内の様子を伝えるための引きの絵はバッチリや。
  2. Canon EF 70-200mm f/2.8L IS III USM: 前回レンタル機材としてつかったレンズ。買っちった。これでセッションの発表者をバッチリ撮るんや。

おわりに

去年からのアップデートと言うか、ただただレンズ買っちった報告になりました。次回はボディも個人所有で2台持ちになれるといいですね。

RxJava で delay など時間に関するオペレータを使ったときのテスト

例えば、次のコードのような 5秒後に "foo" という String を emit する Observable をかえす関数があるとします。

fun observeFoo(): Observable<String> = Observable.just("foo").delay(5, TimeUnit.SECONDS)

この関数をユニットテストすることを考えます。

RxJava では ObservableSingle などがどんな値を emit したかを検証する目的で TestObserver をつかってユニットテストを書きます。 ObservableSingle などには、この TestObserver に変換する test() メソッドがあるので、このあとに 1つめの値は "foo" である ことを確かめるメソッドをチェインしていきます。

@Test
fun observeFoo_getAfter5s() {
  observeFoo()
    .test()
    .assertValueAt(0) { value ->
      value == "foo"
    }
}

このテストはそのままでは動きません。なぜなら 5秒後 ということを無視しており、すぐさま assertValueAt で値が"foo"かどうか検証してしまうからです。

TestObserver には、値が X 個流れてくるまで待つために awaitCount メソッドが用意してあります。この場合は5秒後に1個値が出るので、次のようにすると正しくテストが動きます。

@Test
fun observeFoo_getAfter5s() {
  observeFoo()
    .test()
    .awaitCount(1) // 値が 1 個くるまで待つ
    .assertValueAt(0) { value ->
      value == "foo"
    }
}

ただし、この方法では、とにかく値が1個くるまでスレッドを止めて待つため、ユニットテストの実行を5秒間止めてしまいます。 同様のテストケースが増えれば増えるほど、勢いよくテストが遅くなるのは明らかです。

delay は ComutationScheduler を使って時間をはかり、指定した時間が経過したら値を emit します。 RxJava ではいろいろな Scheduler をテスト用に上書きするための RxJavaPlugins があり、さらに時間を仮想的に操作するための TestScheduler も用意してあります。 これらを用いて次のように記述すると、5秒間スレッドを止めることなく即座に値の検証が可能になります。

@Test
fun observeFoo_getAfter5s() {
   // delay は標準で computation scheduler を使うので、はじめに computation scheduler を test scheduler に上書きする
  val testScheduler = TestScheduler()
  RxJavaPlugins.setComputationSchedulerHandler { testScheduler }

  // TestObserver を得る
  val testObserver = observeFoo().test()

  // 仮想的に時間を 5 秒すすめる
  testScheduler.advanceTimeBy(5, TimeUnit.SECONDS)

  // "foo" が emit されているので検証ができる
  testObserver.assertValueAt(0) { value ->
    value == "foo"
  }
}

最後に、RxJavaPlugins で上書きしたものは、@After などで RxJavaPlugins.reset() で元に戻しておくことを忘れないでください。

Kotlin 1.4 の開発版を試す

やっていますか?

昨年の KotlinConf 2019 で Kotlin 1.4 についてのアナウンスがあり、今春のリリースを目指して開発が進んでいるそうです。 Releases · JetBrains/kotlin · GitHub を見ると、すでに現時点でも 1.4 向けの変更にタグが打たれているので、1.4-dev 版を試すことができるようになっています。

ビルドスクリプトとプロジェクトが見る Maven リポジトリを追加する

1.4-dev のタグがついた成果物は https://dl.bintray.com/kotlin/kotlin-dev/ に上がっています。 Gradle Plugin もここにあるので、Project Roon の build.gradle にある buildscript と、各モジュールの build.gradle にある repositories に次のようにして Maven リポジトリを設定しておきます。

maven {
  url "https://dl.bintray.com/kotlin/kotlin-dev/"
}

1.4-dev のビルド番号

この記事を書いた時点では一番新しいビルドとして 1.4.0-dev-1316 が Maven リポジトリにあり、ちょうど10秒くらい前に 1.4.0-dev-1325 のタグが GitHub に表示されるようになりました。そのうち 1.4.0-dev-1325 も Maven リポジトリに上がると思います。 ビルドスクリプトの dependencies には、implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.0-dev-1325" と記述すれば OK です。

注意

RC どころかアルファ版でもない開発版なので、ガンガン変更が入ります。1.4 でどうなるかちょっと試すくらいの気持ちで使ってください。

kotlinx.serialization でオブジェクトをシリアライズする #potatotips 67

potatotips #67 で kotlinx.serialization を使ったオブジェクトのシリアライズの手順について話してきたのでまとめておきます。いくつか発表のときにはできなかった部分を掘り下げています。

イベントリンク

potatotips #67 (iOS/Android開発Tips共有会) - connpass

資料

speakerdeck.com

kotlinx.serialization とは

Kotlin の各種オブジェクトを直列化して JSON や Protocol Buffers などのフォーマットに落とすためのしくみです。 2019年12月現在で最新版は 0.14.0 です。

導入

Project root にある build.gradle に kotlinx.serialization の classpath を追加し、各モジュールの build.gradle で apply plugin します。 Plugin のバージョンは Kotlin のバージョンと合わせます。

buildscript {
  ext.kotlin_version = '1.3.61'

  repositories {
    jcenter()
  }

  dependencies {
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
  }

}
apply plugin: "kotlin"
apply plugin: "kotlinx-serialization"

そして kotlinx-serialization-runtime を各モジュールの build.gradle の dependencies に追加します。

dependencies {
  implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.14.0"
}

使い方

kotlinx.serialization では、オブジェクトを直列化する方法と、直列化したものを何らかのフォーマットに落とす方法が分かれていて、ここでは直列化する方法の部分を見ていきます。

まずクラスが Serializable かどうかをアノテーションで表明します。

import kotlinx.serialization.Serializable

@Serializable
data class SampleScreenState(
  val familyName: String,
  val givenName: String,
  val age: Int
)

メンバーがすべてプリミティブな型か、@Serializable がついている型の場合は自動で直列化のロジックも吐き出してくれます。これは直列化の方法を知っている KSerializer<T> の実装をプラグインが作ってくれて、そのインスタンスを返すメソッドを Companion Object に設定してくれるようになっています。

val sampleScreenState: SampleScreenState = // ...

// 直列化のロジックを持った KSerializer<SampleScreenState> の実装が得られる
val serializer = SampleScreenState.serializer()

次のように、@Serializable がついていない型をメンバーに持つ場合は、Serializer has not been found for type 'Any'. To use context serializer as fallback, explicitly annotate type or property with @ContextualSerialization というメッセージを吐き出してコンパイルエラーになります。なぜなら直列化の方法がわからないメンバーは直列化できないからです。java.io.Serializable とおなじく、@Serializable をつけたクラスのメンバーはすべてプリミティブな型 (IntDoubleString、あるいは enum class)か KSerializer<T> を定義した型 (@Serializable) でなければなりません。

シリアライズについての実装の間違いをコンパイル時に検知できてよいですね。

import kotlinx.serialization.Serializable

data class SampleData(
  val text: String
)

@Serializable
data class SampleScreenState(
  val familyName: String,
  val givenName: String,
  val age: Int,
  val something: SampleData = SampleData("sample") // ここがコンパイルエラー
)

SampleData を直列化するには、SampleData にも @Serializable をつけます。

import kotlinx.serialization.Serializable

@Serializable
data class SampleData(
  val text: String
)

@Serializable
data class SampleScreenState(
  val familyName: String,
  val givenName: String,
  val age: Int,
  val something: SampleData = SampleData("sample")
)

もしメンバーを直列化の対象外としたい場合は、@kotlinx.serialization.Transient アノテーションをつかいます。Kotlin JVM に標準の @kotlin.jvm.Transient ではないことに気をつけてください。

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient

@Serializable
data class SampleScreenState(
  val familyName: String,
  val givenName: String,
  val age: Int,
  @Transient val something: SampleData = SampleData("sample")
)

KSerializer が生成できたら、あとはフォーマッターにシリアライズしたいオブジェクトと KSerializer を渡せば、好きなフォーマットに落とせます。

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration

fun main() {
    val state = SampleScreenState(/* ... */)
    val json = Json(JsonConfiguration.Stable)

    val serialized: String = json.stringify(SampleScreenState.serializer(), state)

    val deserialized: SampleScreenState = json.parse(SampleScreenState.serializer(), serialized)
}

ライブラリに定義された型をシリアライズする

次のような型がライブラリに定義されているとします。

sealed class Abstract

class Sample4(
    val text: String,
    val number: Int
) : Abstract()

class class Sample5(
    val number: Int
) : Abstract()

ライブラリにあるので直接 @Serializable をつけられません。また KSerializer<T> も生成されないので自分で直列化の方法を定義する必要があり、上記の Abstract 型をメンバーとすると、Abstract 型と Sample4Sample5 の親子関係も知らせなければなりません。

kotlinx.serialization では、クラスの親子関係を後付けで定義するための仕組みとして SerializersModule を用意しています。これをつかって親子関係を知らせたうえで、Sample4Sample5 の直列化の方法も合わせて定義します。

import kotlinx.serialization.Decoder
import kotlinx.serialization.Encoder
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialDescriptor
import kotlinx.serialization.internal.SerialClassDescImpl
import kotlinx.serialization.modules.SerializersModule

@UseExperimental(ImplicitReflectionSerializer::class)
// フォーマッタに渡して使う
val abstractTypeModule = SerializersModule {
    polymorphic<Abstract> {
        addSubclass(Sample4Serializer)
        addSubclass(Sample5Serializer)
    }
}

object Sample4Serializer : KSerializer<Sample4> {
    override val descriptor: SerialDescriptor = object : SerialClassDescImpl(Sample4::class.java.name) {
        init {
            addElement("string") // 0 番目のプロパティ
            addElement("number") // 1 番目のプロパティ
        }
    }

    override fun deserialize(decoder: Decoder): Sample4 {
        val structure = decoder.beginStructure(descriptor)
        val string = structure.decodeStringElement(descriptor, 0) // 0 番目のプロパティから String をデコードする
        val number = structure.decodeIntElement(descriptor, 1) // 1 番目のプロパティから Int をデコードする
        structure.endStructure(descriptor)
        return Sample4(string)
    }

    override fun serialize(encoder: Encoder, obj: Sample4) {
        val structure = encoder.beginStructure(descriptor)
        structure.encodeStringElement(descriptor, 0, obj.string) // 0 番目のプロパティに String をエンコードする
        structure.encodeIntElement(descriptor, 1, obj.number) // 1 番目のプロパティに Int をエンコードする
        structure.endStructure(descriptor)
    }
}

object Sample5Serializer : KSerializer<Sample5> {
    override val descriptor: SerialDescriptor = object : SerialClassDescImpl(Sample5::class.java.name) {
        init {
            addElement("number")
        }
    }

    override fun deserialize(decoder: Decoder): Sample5 {
        val structure = decoder.beginStructure(descriptor)
        val number = structure.decodeIntElement(descriptor, 0)
        structure.endStructure(descriptor)
        return Sample5(number)
    }

    override fun serialize(encoder: Encoder, obj: Sample5) {
        val structure = encoder.beginStructure(descriptor)
        structure.encodeIntElement(descriptor, 0, obj.number)
        structure.endStructure(descriptor)
    }
}

これらを使って次の Sample6 を JSON にフォーマットすると、{"abstractSample":{"type":"dev.keithyokoma.Sample4","string":"foo","number":1}}が出力されます。"abstractSample"に対応する部分が JsonObject となり、この JsonObject ではもとがどの型だったかを覚えておくために "type" に FQCN が出力されます。

import kotlinx.serialization.Polymorphic
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration

@Serializable
data class Sample6(
    @Polymorphic val abstractSample: Abstract
)

fun main() {
    val json = Json(JsonConfiguration.Stable, abstractTypeModule)
    val serialized = json.stringify(Sample6.serializer(), Sample6(Sample4("foo", 1)))
    println(serialized)
}

sealed class のシリアライズ

sealed class もシリアライズできます。ただし、Kotlin と kotlinx.serialization のバージョンの組み合わせでJSON フォーマッタを使った場合の結果が変わります。

Kotlin 1.3.61 と kotlinx.serialization 0.14.0

次のように @Serializable で表明します。sealed class とその子クラスすべてに @Serializable をつけます。

import kotlinx.serialization.Serializable

@Serializable
sealed class Sealed

@Serializable
data class Sample2(
    val text: String
) : Sealed()

@Serializable
data class Sample3(
    val number: Int
) : Sealed()

@Serializable
data class Sample1(
    val text: String,
    val sealed: Sealed
)

これを次のように JSON でフォーマットすると、{"text":"foo","sealed":{"type":"dev.keithyokoma.Sample2","text":"bar"}} が出力されます。

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration

fun main() {
    val sample = Sample1("foo", Sample2("bar"))
    val json = Json(JsonConfiguration.Stable)
    val serialized: String = json.stringify(Sample1.serializer(), sample)
    println(serialized)

Kotlin 1.3.30 以上 と kotlinx.serialization 0.11.0 から 0.13.0 まで

このバージョンでは、sealed class には @Serializable をつけずその子クラスに @Serializable をつけます。また sealed class の型のメンバーには @Polymorphic をつけます。 しかし実はこれだけでは不十分で、ライブラリの型をシリアライズするときに利用したSerializersModule で sealed class の親と子の継承関係を教えてあげる必要があります。今回は sealed class の子クラスには KSerializer がアノテーションで生成されるので、自分で記述することはありません。

import kotlinx.serialization.Polymorphic
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule

sealed class Sealed

@Serializable
data class Sample2(
    val text: String
) : Sealed()

@Serializable
data class Sample3(
    val number: Int
) : Sealed()

// フォーマッタに渡して使う
@UseExperimental(ImplicitReflectionSerializer::class)
val sampleTypeModule = SerializersModule {
    polymorphic<Sealed> {
        addSubclass<Sample2>()
        addSubclass<Sample3>()
    }
}

@Serializable
data class Sample1(
    val text: String,
    @Polymorphic val sealed: Sealed
)

これを次のように JSON でフォーマットすると、{"text":"foo","sealed":{"type":"dev.keithyokoma.Sample2","text":"bar"}} が出力されます。

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration

fun main() {
    val sample = Sample1("foo", Sample2("bar"))
    val json = Json(JsonConfiguration.Stable, sampleTypeModule)
    val serialized: String = json.stringify(Sample1.serializer(), sample)
    println(serialized)

kotlinx.serialization 0.10.0 まで

このバージョンでは次の記述で問題なくシリアライズできます。

import kotlinx.serialization.Polymorphic
import kotlinx.serialization.Serializable

sealed class Sealed

@Serializable
data class Sample2(
    val text: String
) : Sealed()

@Serializable
data class Sample3(
    val number: Int
) : Sealed()

@Serializable
data class Sample1(
    val text: String,
    @Polymorphic val sealed: Sealed
)

これを次のようにフォーマットすると {"text":"foo","sealed":["dev.keithyokoma.Sample2",{"text":"bar"}]} が出力されます。"sealed"の構造に注目すると、kotlinx.serialization 0.10.0 では JsonArray にして出力することがわかります。JsonArray の最初の要素が元の型の FQCN で、2番目の要素にメンバーをフォーマットした JsonObject が入ります。

import kotlinx.serialization.json.Json

fun main() {
    val sample = Sample1("foo", Sample2("bar"))
    val json = Json()
    val serialized: String = json.stringify(Sample1.serializer(), sample)
    println(serialized)

互換モード

元がだどの型だったかを覚えておくための FQCN が 0.10.0 以前は JsonArray の最初の要素に入っていたのに対し、0.11.0 以降は JsonObject のプロパティに出力されます。これを、0.11.0 以降でも 0.10.0 以前のように JsonArray の形に出力して互換性を保つようにするオプションが用意されています。

val json = Json(JsonConfiguration.Stable.copy(useArrayPolymorphism = true), abstractTypeModule)

"type" という名前のメンバーを持つ型をシリアライズする

次のように、sealed class で表される型のひとつに type というメンバーを持つものがあるとします。

import kotlinx.serialization.Polymorphic
import kotlinx.serialization.Serializable

@Serializable
sealed class Sealed

@Serializable
data class Sample2(
    val type: String
) : Sealed()

@Serializable
data class Sample3(
    val number: Int
) : Sealed()

@Serializable
data class Sample1(
    val text: String,
    val sealed: Sealed
)

これをそのまま次のようにフォーマットすると、{"text":"foo","sealed":{"type":"dev.keithyokoma.Sample2","type":"bar"}}と出力されます。見事に "type" が 2 つできてしまいました。これをデシリアライズすると、2つ目の "type" を見に行ってしまい知らない型 (bar) になってしまいます。

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration

fun main() {
    val json = Json(JsonConfiguration.Stable)
    val serialized = json.stringify(Sample1.serializer(), Sample1("foo", Sample2("bar")))
    println(serialized)
}

これを回避する方法は3つあります。

1つめはメンバーに Json にフォーマットするときの名前を与える方法です。次に示すように @SerialName で Json のプロパティ名を設定すると、結果は{"text":"foo","sealed":{"type":"dev.keithyokoma.Sample2","my_type":"bar"}}となります。

@Serializable
data class Sample2(
    @SerialName("my_type")
    val type: String
) : Sealed()

2つめはJsonの設定で FQCN を入れるプロパティ名を変える方法です。JsonConfiguration#classDiscriminator に任意の文字列を指定して、FQCN のプロパティ名が変えられます。次の例では {"text":"foo","sealed":{"clazz":"dev.keithyokoma.Sample2","type":"bar"}} を出力します。

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration

fun main() {
    val json = Json(JsonConfiguration.Stable.copy(classDiscriminator = "clazz"))
    val serialized = json.stringify(Sample1.serializer(), Sample1("foo", Sample2("bar")))
    println(serialized)
}

3つめは先ほどの互換モードを利用する方法です。JsonArray の要素の位置で決め打ちになるので名前が衝突することがなくなります。

OAuth 2.0 の Redirect URI で戻ってくる画面に Navigation Architecture Component の DeepLink を設定すると直面する問題と回避策

みなさん、やっていますか?私はやっています。

Slack API を使いたくて、Slack の OAuth の仕様通り Redirect URI を設定してアプリに auth codestate を戻すために Navigation Architecture Component を使った DeepLink の実装をしたところ、盛大にハマったので記録を残しておきます。

続きを読む