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 を支えていくアプリとなるよう改善をしていくつもりです。