Infinito Nirone 7

白羽の矢を刺すスタイル

Podcast を Apple Podcast や Google Podcast に登録する

Podcast を配信するにあたって、Apple Podcast や Google Podcast に登録しておくと、iPhone や Android のアプリから気軽に Podcast を聴けるようになります。各プラットフォーム向けに配信の設定をしておくと、アプリで Podcast の未読管理や前回からの視聴の再開、Chrome Cast での視聴などができて便利です。

この記事では Apple Podcast と Google Podcast で Podcast を配信をする手順をまとめておきます。前提として、Podcast の RSS フィードを何らかの方法で生成しておいてください(r7kamura/yattecastで静的に生成するなど)。RSS フィードには必須のタグと推奨のタグがそれぞれあり、Google のサポートページ を参考にしてください。必須のタグが欠けていたり、RSS フィードが誰でもアクセスできる場所にないとうまく登録できないことがあります。

Apple Podcast

Apple Podcast はちょっと前まで iTunes に統合されていたもので、今は Podcast アプリとして独立しています。配信するには iTunes Connect アカウントが必要です。

iTunes Connect: https://itunesconnect.apple.com/

ログイン後、Podcasts Connect を選択すると RSS フィードを登録する画面が出てくるので、配信したい Podcast の RSS フィードの URL を入力し、必要なデータを入れ登録を申請します。 1日ほど審査に時間がかかりますが、審査が通るとすぐに審査完了のお知らせが iTunes Connect アカウントのメールアドレスに届きます。お知らせには Apple Podcast で登録した Podcast 専用の URL も記述してあるので、それを自分の Podcast のウェブサイトにリンクとして貼って使えます。

Google Podcast

Google Podcast は Google Play Music に統合されていたもので、今は Google Podcast への移行が推奨されています。ちょっと前までは Google Play Music 向けに Podcast の登録ができましたが、今は Google Podcast Manager を使う必要があります。

Google Podcast Manager: https://podcastsmanager.google.com/

Google Podcast では、Podcast の RSS フィード URL を登録後、更にいくつかのステップを踏まなければなりません。RSS フィードを登録すると、RSS フィードに含まれるポッドキャストの Author のメールアドレス宛に、RSS フィードが Google Podcast に登録されたことを通知するメールが届きます。

ここで Podcast の登録が意図したものかどうかの確認をします。メールには確認コードが含まれるため、このコードを入力すると Google Podcast に Podcast が登録されます。ただし登録が終わってもすぐには Google Podcast には反映されません。RSS フィードがインデックスに登録されるまでに2〜3日かかるので、気長に待ちましょう。

統計データ

Apple Podcast も Google Podcast も、Podcast がどれくらい聴かれているかの統計データが閲覧できます。それぞれ iTunes Connect や Google Podcast Manager で閲覧できるようになっているので、配信側としても簡単に統計データを取れて便利です。

在宅勤務で手元にない端末でのアプリのデバッグをするために準備しておくこと

コロナ禍で在宅勤務が一気に広まり、自宅でアプリの開発やリリースをすることが多くなりました。この状況のなかで、アプリの挙動に問題があるなどで動作確認をしようと思うとき、その問題に対処するためのデータを集めたり、問題が発生したときの状況を確認するための手立てを持っておく必要があります。この記事では、手元にない端末で起きる問題のトラブルシューティングをしやすくするために準備しておくとよいことを書き残しておこうと思います。

端末のログを収集する

ある端末でアプリがクラッシュしたときに、そのスタックトレースを記録しておくことはとても重要ですが、そのクラッシュにいたるまでに何があったかをログとして収集しておくことで、クラッシュが起きた状況を把握しやすくなります。

Firebase CrashlyticsDeployGate など、端末からログを収集して Web コンソール上で閲覧できるようにしておくと、なにか問題が起きたときにそれまでのログをいつでもリモートで確認できます。Timber などと組み合わせて、各ログレベルのログを収集できるようにしておくとよいです。

Firebase Crashlytics の場合:

class CrashlyticsTree : Timber.Tree() {
  override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
    when (priority) {
      Log.VERBOSE -> {
        Crashlytics.log(priority, tag, message)
      }
      Log.DEBUG -> {
        Crashlytics.log(priority, tag, message)
      }
      Log.INFO -> {
        Crashlytics.log(priority, tag, message)
      }
      Log.WARN -> {
        Crashlytics.log(priority, tag, message)
      }
      Log.ERROR -> {
        Crashlytics.log(priority, tag, message)
        t?.let {
          Crashlytics.logException(it)
        }
      }
      else -> {
        Crashlytics.log(priority, tag, message)
        t?.let {
          Crashlytics.logException(it)
        }
      }
    }
  }
}

DeployGate の場合:

class DeployGateTree : Timber.Tree() {
  override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
    if (!DeployGate.isInitialized())
      return

    when (priority) {
      Log.VERBOSE -> {
        DeployGate.logVerbose(message)
      }
      Log.DEBUG -> {
        DeployGate.logDebug(message)
      }
      Log.INFO -> {
        DeployGate.logInfo(message)
      }
      Log.WARN -> {
        DeployGate.logWarn("$message $t")
      }
      Log.ERROR -> {
        DeployGate.logError("$message $t")
      }
      else -> {
        DeployGate.logError("wtf: $message $t")
      }
    }
  }
}

カスタムキーの設定

Firebase Crashlytics はロギング以外に Key-Value ペアで表現するカスタムキーを設定できます。これをつかうと、端末のログでは収集しきれないデータを収集できます。主に永続化されている設定値(アプリ内での設定として設けている、ユーザが好きに選べる設定の値)や、バージョン情報以外のビルド時のデータ(BuildConfig に設定している値)をもたせておくと、特定の設定値のユーザのみクラッシュするなど状況を把握するのに役立ちます。

カスタムキーを設定した状態でアプリがクラッシュすると、クラッシュレポートにすべてのカスタムキーが紐付いて保存されます。

Crashlytics.crashlytics().setCustomValue(100, "preference_something_duration_int")

Crashlytics のクラッシュレポートを Slack に通知する

新規クラッシュを検知した場合や、短時間に高頻度でおなじクラッシュが発生した場合など、いくつかの条件で Crashlytics から Slack に通知する設定があります。 リリース後のプロダクションビルドではもちろん、QA 中などリリース前のビルドでも有効にしておくと、テストしている人からのバグレポートの補助的な役割として利用できます。

リモートでログを確認する

リモートで今動いているアプリのログを確認する方法もあります。DeployGate や BUGFENDERShipbook などがこの機能を持っています。この方法であれば、クラッシュ以外にもログを集められます。

リモートで端末を操作する

リモートで端末を操作するための手段もあります。 AWS Device FarmKobiton など幅広いモバイル端末を用意したサービスから、オンプレミスな環境にOpen STFを立ち上げでおくもの、あるいは Samsung Remote Test Lab のようにマニュファクチャラーが用意しているサービスを利用するなどで、ブラウザや専用のアプリケーションからリモートにある端末でアプリを操作できます。

バグ報告を円滑にするためのデバッグ情報を表示する

クラッシュレポートやログ以外にも、端末に関すデータやアプリのビルドに関するデータもデバッグに役立ちます。リリース前のアプリでだれでも簡単にこれらのデータを見られるようデバッグ用の表示を作っておくと、QA やドッグフーディングなど社内でのテスト中に見つかったクラッシュや不具合の報告により多くのデータを含められるようになります。 簡単な例としては JakeWharton/u2020 にあるようなデバッグ用の UI を用意するものですが、Drawer でなくとも、設定画面にデバッグ情報を見る画面を開くメニューを置いておくでもよいです。

このデバッグ情報として入れておくとよいものを次にあげます。

  • git のコミットハッシュ
  • git のブランチ名
  • デバイスのモデル名やマニュファクチャラー名
  • API Level
  • ディスプレイのスペック (解像度や dpi など)

See also

speakerdeck.com

SnapCamera で使える、背景を合成するレンズ3分クッキング

SnapCamera で使えるエフェクト(レンズ)は Lens Studio - Lens Studio by Snap Inc. というツールで自作できます。 カメラの映像から人だけをくり抜いて背景をはめるレンズから、3D モデルを配置したり、簡単な物理演算を組み合わせて 3D モデルを動かしたりするレンズまで、いろいろなレンズが作れます。

というわけで背景をはめるレンズを作るところをやってみたので、Lens Studio の使い方をすこし解説してみようと思います。

Lens Studio でプロジェクトを作成する

Lens Studio を起動すると、おすすめプロジェクトテンプレートや自分の作ったプロジェクトなどが一覧できるホーム画面が表示されます。

f:id:KeithYokoma:20200413223152p:plain

背景をはめるレンズは Segmentation というテンプレートから簡単に作れるので、これを使います。

背景を設定する

プロジェクトを読み込むと、次のような画面が出てきます。 左側はプロジェクトで使う画像や 3D モデルなどを管理するペイン(Resources, Objects)、真ん中には背景や3Dモデルとカメラ、光源の位置関係を編集するペインとレンズで使う効果のパラメータを調整するペイン (Inspector) があり、右側には作っているレンズのプレビュー (Preview) が表示されているはずです。

f:id:KeithYokoma:20200413223551p:plain

まず、設定したい背景を Resources ペインにドラッグ・アンド・ドロップで読み込ませます。

f:id:KeithYokoma:20200413224807p:plain

次に、読み込んだ背景をはめ込むために Objects ペインから SegmentationController [EDIT ME] を選び、Inspector でパラメータを変更します。

デフォルトでは半透明のピンク色にハートの画像が斜めに移動する背景が設定されています。 このピンク色やハートの画像は InspectorScript 部分で設定しています。

f:id:KeithYokoma:20200413224932p:plain

今回は画像をはめたいだけなので、Use Background Color のチェックを外します。つぎに、 Image Texture のファイル名をクリックし、画像選択ダイアログから先ほど読み込んだ画像を指定します。

f:id:KeithYokoma:20200413225058p:plain

すると斜めに動いていたハートが指定した画像に変わります。あとは Tiled のチェックボックスを外せば画像が固定されます。

f:id:KeithYokoma:20200413225213p:plain

以上で背景を自分の好きな画像に設定できました。ここで左上の Publish Lens でプロジェクトをアップロードし、Live になるのを待つと SnapCamera で選択できるようになります (検索にヒットするには時間がかかるようなので、Live になった Lens を Share するボタンで URL をコピーしておくと、すぐに検索窓から直接自分の Lens を選べます)。

デスクトップ画面でのプレビューを見る

プレビューでデスクトップ画面を再現するときは、プレビューの下の Device Simulation から Desktop を選びます。

f:id:KeithYokoma:20200413225349p:plain

ポートレートではなく、顔だけくりぬく

電波少年のようにする場合、顔だけをくりぬいて背景を合成したいですよね。

Lens Studio では、ポートレートのほか、顔だけ、首から下、髪だけをくり抜くオプションを設定できます。 この設定を変えて電波少年を再現するには、Resources ペインにある Segmentation Texture [EDIT ME] を選び、Inspector に表示される TextureTypePortrait Face に変えます。

f:id:KeithYokoma:20200413225912p:plain

このままだとくりぬいた顔の部分に背景が合成されてしまうため、すぐ下の Invert Mask にチェックを入れます。すると顔以外の部分に背景が当てはめられます。

f:id:KeithYokoma:20200413230031p:plain

これでいつでも電波少年になります。

我が家のリモートワーク環境

昨今の情勢を受けてリモートワークが始まり1ヶ月ほど経ちました。

キャンプチェアとローテーブルでの作業環境

以前は自宅にもクルーズアンドアトラスを置いて会社と同じデスク環境を持っていましたが、引っ越しを期に手放してしまったために今はHelinox のキャンプチェアローテーブルを使っています。最初のオフィスデスク・チェアがクルーズアンドアトラスで3年ほど仕事をし背もたれを後ろに倒して座るやり方になれきっているので、キャンプチェアでも後傾姿勢で座っています。

リモートワーク開始当初は会社支給の MacBook Pro を使って作業しており、ラップトップ特有の事情でどうしても前傾姿勢での作業になってしまうため、だんだんと辛くなってきました。最近は会社のサポートとして会社にある iMac を自宅まで届けてくれる施策をバックオフィスチームが実行してくれたおかげで、ローテーブルに iMac と MacBook Pro を置いて作業をしていますが、依然として椅子の座り方がどうしても後傾姿勢にできない問題がありました。

iMac が手元にあるいま、キャンプチェアで後傾姿勢になるためには、キーボードやトラックパッドをひざ上に載せられる環境が必要です。最初はキャンプグッズのアルミローテーブルをひざ上に載せ、その上にキーボードやトラックパッドを置いていましたが、若干スペースが足りないのと、キーボードやトラックパッドが滑り落ちてくる問題があったため、サンワダイレクトが出しているひざ上テーブルを購入することにしました(知人が同じようなものを使っていたのを思い出してすぐ買いました)。

ネットワーク

ずっと AirMac Extreme を使い続けてきましたが、随分前に新型のリリースが止まったこと、WiFi の規格も古く、IPoE もできないということで、Fast.com で計測すると 39Mbps しか出ない状況でした。そろそろ日頃つかうものとしては厳しくなってきたため、これを期にAterm WX6000HPを購入し置き換え、無事に WiFi で 410Mbps、有線で 760Mbps でる環境になりました。ハングアウトなどの映像もクリアに映るようになったそうです。

その他

一人ぐらしなのもありリモートワークをすると完全に孤独な作業になります。気にしなければどうということはないのですが、そうは言っても長期間誰の声も聞こえない環境で仕事をするのは寂しいもので、いつもいろいろな工夫をして寂しさを紛らわせています。

さぎょいぷ

ハングアウトなどで擬似的におなじ作業空間をつくり、ぼやきながら作業するチャットルーム的なものです。

Chrome Cast

Netflix、dアニメ、JSports などオンデマンド配信の映像をひたすら Chrome Cast でテレビに映しています。特に JSports はサイクルロードレースのプランを契約しており、最短1.5時間・最長7時間のレース配信は作業用 BGM にとても便利です。

運動

家からでなくなるので、普段の通勤での徒歩すらもしなくなるため、運動を全くしなくなっています。自分は自転車が趣味で100km以上のロングライドが好みですが、休日のサイクリングも難しい状況なので、何かしら代替手段が必要です。幸い、ローラー台 があり Zwift というバーチャルサイクリングの環境もあるので、時間を見つけてローラー台で自転車をこいでいます。

また今月頭にリングフィットアドベンチャーが購入できたので、リングコンが届いたらこれも一緒にできるようになりますね。

持続的なアプリ開発のための DX を支える技術 #DroidKaigi 2020

この記事は、DroidKaigi 2020 で発表予定だったセッション「持続的なアプリ開発のための DX を支える技術」を解説するための記事です。

セッション概要

Android の歴史はすでに 10 年を超え、数多のアプリケーションがストアで公開されています。これらのアプリケーションの中には、何年も継続的にバージョンアップを重ねているものもたくさんあります。

このセッションでは、このような持続的なアプリケーション開発・リリースをうまく回す秘訣として DX という言葉をとらえ、アーキテクチャやテストのほか、日々の開発に関わるワークフローをメンテナンスするための考え方や手立てとして、モバイル CI や Android 向け各種ツールキットの導入と効率化、Gradle をベースにした独自タスク開発の方法などを紹介します。

資料

speakerdeck.com

一部実装の詳細を資料に委ねています。適宜資料と合わせてご覧ください(開設の流れは資料に合わせています)。

解説

このセッションで目指すもの

このセッションでは、エンジニアリングチームとしてよい DX を獲得するための指針として、開発着手前からリリース後の運用に至るまでの間にどんなことに注目するとよいかを示し、モバイルアプリ開発のプロセスを持続的に素早く回していくための考え方や実践例を解説します。

良い DX を支える技術

ここでいう DX とは Developer Experience*1 の略語のことです。大まかに言うと、日々の開発が健全に回せているかどうかを表現するもので、良し悪しを測るもの(良い DX = 開発に健全に取り組めている、悪い DX = 開発に健全に取り組めていない)です。

私たちは普段、コードに様々な変更を加えることでプロダクトを作っています。変更の理由は様々ですが、どれもプロダクトを成長させるために必要なことをしているはずです。ただ、それが本当に成長に寄与するかどうかはリリースしてみなければわかりません。そのため、1週間から2週間程度の期間で区切って定期的にリリースをすることで、それが狙い通り機能しているかどうかをチェックするプラクティスがうまれ、実践されています。

この1週間から2週間程度の期間というのはプロジェクトやチームによって様々ですが、この期間で設計をし、実装からQAまで完了するには、日々のワークフローを素早く健全にまわしていく「良いDXのため」の基礎体力やカルチャーが必要で、かつこれを持続的に取り組んでいくことが必要になります。

このセッション(記事)では、私たち開発者が日々使っているツールやサービスなどを活用しながら、良い DX を支え続けていくためのポイントを抑え、チームでの取り組みに落とし込むための指針を示していきます。

良い DX を支える技術: 事前準備

まずはじめに、チームの中での認識をあわせておくことがあります。たとえば、コーディング規約やブランチの運用方法などがあげられます。

コーディング規約

コードの書き方についてチームで認識を揃え、誰がどのコードを見てもある程度の読みやすさを確保します。

タブインデントかスペースインデントか、一行の長さは何文字までかといったスタイルから、Kotlin や Java での式の書き方など言語ごとの記法についても決めておきます。特に言語ごと特有の記法については、その記法に決めた理由や目的も合わせて明確にしておきましょう。

規約について決めたら、.editorconfig を使って誰でもおなじスタイルでコードを記述できるようにしておきます。Android Studio をはじめさまざまな IDE やエディタは自動でこのファイルを読み取り、スタイルを適用してくれます(プラグインを入れることで対応できるものもあります)。また静的解析ツールもこのファイルを参照できるので、CI で静的解析を実行するときにも利用できます。

次にプロジェクトのルートディレクトリに配置する.editorconfigファイルの例を示します。この例では、すべてのファイルについてインデントはスペース2つで、末尾の空白を取り除くなどを設定し、Java や Kotlin のソースコードについては 1 行につき 140 文字まで許容する設定になっている一方、Markdown では末尾のスペースにも意味がある(スペース2個で改行する)ので、特別に取り除かない設定をしています。

root = true

[*] // apply the following styles to all files
indent_style = space
indent_size = 2

end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.{java,kt,kts,xml}] // only apply to java/kt/kts/xml files
max_line_length = 140

[*.md] // only apply to markdown files
trim_trailing_whitespace = false

ブランチ戦略

ここでは Git を使うことを前提としますが、ブランチ戦略としてブランチごとに役割を定め、ワークフローを作っていくことで、差分管理を整理します。

Git には Git flowGitHub flow の2つのよく知られたブランチ戦略があります。もちろん、これ以外のブランチ戦略を採用するのもありですが、大事なことは、どのブランチにはどんな差分をコミット・マージすべきかを共有しておくことです。

たとえば、Git flow には master、develop の 2 つの主要なブランチ(GitHub などでは Protected branches に指定するような、全員の差分をまとめておくブランチ)と、作業ごとに使い分ける release、hotfix、feature ブランチがあります。develop ブランチは普段の機能開発用の差分を持っており、リリースに必要な差分が揃ったら release ブランチを作ります。release ブランチでは QA などで見つかったバグを修正する差分のみを保持し、すべてのバグ修正がおわったら master ブランチと develop ブランチにマージします。登場するブランチが多く管理コストはかかりますが、特定のリリースへ向けた差分を管理しやすく、モバイルアプリケーションのリリースライフサイクルとの相性が良いブランチ戦略です。

f:id:KeithYokoma:20200330191311p:plain
Git flow

一方で GitHub flow は Git flow を簡素化し、master のみをを主要なブランチとして差分を管理します。リリースも常に master から行うので、master にある全ての差分がリリース可能な状態であることが求められます。いつでもリリースしてよい状態が保たれることが前提となるので、どちらかといえば日々継続的にデプロイするサーバーアプリケーション向きのブランチ戦略です。

f:id:KeithYokoma:20200330191332p:plain
GitHub flow

あるいは、Git flow と GitHub flow の中間のようなブランチ戦略を考えても良いかもしれません。Git flow では master ブランチにリリース可能な差分を持っており、master ブランチのコミットはタグとほぼ一致するはずです。このタグを打つ場所を、release ブランチや hotfix ブランチの最後のコミットとしてよいのであれば、master ブランチと develop ブランチはひとつにまとめられます。

f:id:KeithYokoma:20200330191350p:plain
Composition of Git flow and GitHub flow

良い DX を支える技術: 設計

設計と一言で言っても、さまざまな考え方があります。クリーンアーキテクチャ、レイヤードアーキテクチャといった大枠の考え方もあれば、MVC や MVP、MVVM などプレゼンテーションにフォーカスした考え方もあります。いずれにしても大事なことは、クラスごとに与える責務をうまく定義して、その責務をユニットテストで検証することです。特に Android アプリなどのモバイルアプリでは、次の 3 つが設計を考える上での大きなトピックになります。

  1. API 通信やビジネスロジックを画面の実装から分離したい
  2. 画面が持つ状態と、その状態遷移をうまく表現したい
  3. 状態に応じた UI の変更ロジックを分離したい

どのトピックでも、それぞれに設計パターンがあるので、もうすこし掘り下げて解説します。

設計パターン

API 通信やビジネスロジックを画面の実装から分離する

MVC、MVP、MVVM ともにプレゼンテーションにフォーカスした設計パターンで、どのパターンでも Model と呼ばれる場所にはさらにいくつもの役割が存在します。API 通信やビジネスロジックはまさにこの Model と呼ばれている役割のなかにあります。Model の役割は多岐にわたりとてもフワッとしているので、API 通信であればそれに沿った命名を、ビジネスロジックであればそれをうまく表現する命名をし関心を分離していくほうが、どこで何をしているのか明確になりテストも容易です。

そこで、プレゼンテーションだけではなく全体としてどんな役割があるかを整理した考え方としてレイヤードアーキテクチャやクリーンアーキテクチャなどの考え方を取り入れます。 レイヤードアーキテクチャでは API 通信のような箇所をインフラストラクチャ層やデータアクセス層と呼び、ビジネスロジックに相当する箇所をビジネス層やドメイン層と呼んで管理します。これによって、それぞれの層のユニットテストで何をチェックしたらよいかもうまく決められるようになります。

画面が持つ状態と、その状態遷移をうまく表現したい

たとえば、API から1つのデータを取得し表示する画面を考えてみても、その画面が持ちうる状態はすくなくとも API を呼び出す前の初期状態、API を呼び出している最中の状態、API が成功を返してきた状態、API がエラーを返してきた状態の 4つあります。またこの状態の遷移には一定のルールがあり、成功・エラーは必ず呼び出し中の状態から遷移する制約が考えられます。

画面が持つ状態をドメインオブジェクトのようなものとしてとらえ、その状態をどのように遷移させるかをドメインロジックのようなものとしてとらえると、状態を表すクラスと、状態遷移のロジックを書くクラスの2つを作り、状態遷移のロジックの正しさをユニットテストで検証できるとうまく設計できそうです。

これを仕組みとしてフレームワーク化したものが Redux や Flux です。これらはもう少し登場人物が多く作るものが増えていますが、根本の考え方は変わりません。これらを実装に落とし込んだものとして、DroiduxRxReduxK などがあります。

もし Redux や Flux のフレームワークが重厚長大と感じる場合には、Rx のオペレータで状態遷移のロジックを表現し、新たな状態を下流へ通知するような流れを実装しても、同じことができます。Rx から LiveData への変換をすれば、プレゼンテーションでの実装は更に簡素化できます。この実装例は DroidKaigi 2020 のスタッフを支える(はずだった)技術 - 運営スタッフ向けアプリ - Infinito Nirone 7 の「プレゼンテーションレイヤの設計」にかんたんな例を示していますので、詳しい解説はこの記事を参照してください。次に RxJava と LiveData を組み合わせ、Android Architecture Component ViewModel を使った実装を示します。ここでさらに SavedStateHandle を組み合わせると、Activity の破棄と復帰にも対応できますようになります(別途 State を Parcelable にする必要はありますが)。

// 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()
  }
}

この状態遷移をテストするコードを次に示します。ある操作をしたとき、期待した順番で状態遷移が起きることを LiveData で検証します。

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 の変更ロジックを分離したい

状態をうまく表現できれば、プレゼンテーションの責務は「状態に対応して適切に UI を変更すること」になります。一方で、Activity や Fragment はこれ以外にもライフサイクルの管理などモバイルアプリ特有の責務もあります。ライフサイクル管理についてはどうしても切り離せないのでそのままにしておくとして、状態に対応して適切に UI を変更する責務は、何らかの形で委譲ができそうに思います。これは Jetpack の Data Binding を使ってもよいですし、単純な委譲パターンを実装するでもよいでしょう。ユニットテストで UI の更新ロジックを検証するなら、単純な委譲パターンを採用するとやりやすいでしょう。あるいは、View コンポーネントをまとめて取り扱う Custom View を定義し、Activity や Fragment からはその Custom View で取り扱っている型のオブジェクトを渡すような作りにするのもよさそうです。

モジュール構成

設計パターンを考える上で、どんなモジュール構成にするかも少なからず影響があります。

シングルモジュールであれば、設計パターン如何に関わらずモジュールは常に1つなので、構成については何も考えることはありません。マルチモジュールの場合は構成をどうするかについて共通認識が必要になります。 一方、マルチモジュールの場合にはモジュール間の依存関係を定義でき、依存関係のないモジュールにはアクセスできなくなります。必要なモジュールにのみアクセス可能な状態にしておくことができるのは、シングルモジュールにはないマルチモジュールの利点です。

では、マルチモジュールにするとして、どのようにモジュールを分割するとよいでしょうか。いくつかの方法が考えられます。

  1. レイヤードアーキテクチャのレイヤーごとにモジュールを作る
  2. 機能やドメイン単位でモジュールを作る
  3. 1 と 2 を組み合わせてモジュールを作る

どの分け方でも良いですが、分け方に応じてモジュールの命名規則を予め決めておくと、どのモジュールにどのコードがあるかがわかりやすくなります。また、モジュール間の依存関係についても、適切な依存関係と不適切な依存関係についてあらかじめ決めておきましょう。

次にレイヤードアーキテクチャに基づいてモジュールを分けたときの命名規則を示します。

  • アプリケーション本体: app
  • プレゼンテーション/画面: feature_**
  • ユースケース/ドメイン/ビジネスロジック: usecase_**
  • インフラストラクチャ/データアクセス: repository_**
  • 横断的な共通機能: common_**

命名規則を決めたら、モジュール間の依存関係の作り方についてルールを設けます。

このモジュール構成の場合、app モジュールはすべての機能について知っている必要があるため、すべての feature_** モジュールに依存することは適切であると決めます。

一方 feature_** モジュールは他の feature_** モジュールについて知っているべきでしょうか。これは画面同士のつなぎこみ(ナビゲーション)をどのモジュールで実装するかによります。feature_** モジュール同士を依存させない場合には、画面遷移のためのインタフェースだけを feature_** モジュールで定義し、その実装は app で行い注入する仕組みが必要です。feature_** モジュール同士の依存を許容する場合は、直接画面遷移の実装を feature_** モジュールに書けますが、モジュールが循環参照しないよう気をつける必要があります。どちらも、アプリケーションの設計が必要になります。

レイヤードアーキテクチャに基づいたモジュールの分け方では、同じレイヤー内のモジュールに依存することはなく、違うレイヤーにあるモジュールに依存するのは正しいとするのがスッキリしそうです。

f:id:KeithYokoma:20200330194916p:plain
Good dependency

f:id:KeithYokoma:20200330194935p:plain
OK

f:id:KeithYokoma:20200330194956p:plain
NG

これとは別に、機能横断的に使われるモジュールはどのモジュールからでも依存してよいことにします。ただし、機能横断的なモジュールは往々にして関心が膨らんでコード量がふえがちなので、取り扱う関心事(UI 向けのユーティリティ、API 通信のユーティリティなど)ごとにモジュールを分けておくと、インフラストラクチャのモジュールが UI の共通機能へアクセスしてしまうようなことが減らせます。

良い DX を支える技術: 開発プロセス

どんな設計パターンを採用するかが決まり、モジュールの扱い方も決まったら、アプリケーションの実装が始められますが、日々の開発ではコードを書く以外にも様々なタスクがあります。ビルドが成功するかどうかをチェックしたり、コードが事前に決めた規約に沿って書かれているか、あるいは意図が伝わりやすい書き方かどうかをチェックしたりなど、コードの変更がうまく機能し、品質が保たれていることをチェックをしているはずです。ここでは、それらの取り組みを持続的にまわしていくための技術を解説します。

ビルドの自動化

日々の開発の中で、自分のつくった差分がうまく動かせるかどうかは最低限自分の環境で試すはずです。

それが他の人の環境でも動かせるかどうか、あるいは他のビルド設定でも動かせるかどうかもチェックしておくと、差分を安心してマージできます。しかし、これをいろいろなブランチで手作業でやるにはとても手間がかかるので、差分が正しくコンパイルでき、正常に成果物が生成できることを自動化しましょう。

自動化の手段は CircleCI や TravisCI、Bitrise、Wercker、Jenkins など多種多様なサービスやソリューションがあるので、詳しいセットアップの方法はそれらのサービスのドキュメントを参照してください。

テストの自動化

ビルドの自動化ができたら、次はテストの自動化をしましょう。これで、テストコードで検証している範囲で、差分が意図したとおりに動いていることが確かめられます。多くの CI サービスではテストの結果を、コードをホストするサービス(GitHub など)に通知してくれるので、コードレビューをするときのチェック項目の1つとしてかんたんにチェック可能なものになります。

ただし、テストの自動化はとても重要ですが、プロダクトの成長とともにどうしても実行時間が長くなります。ビルドやテストなど自動化したものが5分ほどで終わるなら問題ないかもしれませんが、15分や20分かかるとなると、その待ち時間は少なくありません。実行時間の増加は避けられないことではありますが、その増加のしかたはできる限り最小限にとどめられるようにしておきましょう。次に、それを実現するいくつかの方法を示します。

  1. Gradle の並列実行オプションを使い、できる限り多くのプロセスでビルドする (e.g. Improving the Performance of Gradle Builds)
  2. 一部の CI サービスで利用できる分散実行のスキームを使い、テストを分散実行する (e.g. CircleCI での Android プロジェクトのビルド設定と自動化の工夫 - Mercari Engineering Blog)
  3. 時間に依存するテストでの待ち時間をへらす (e.g. RxJava で delay など時間に関するオペレータを使ったときのテスト - Infinito Nirone 7)
  4. 不要なテストをへらす

CircleCI におけるテストの分散実行

CircleCI にはコンテナを複数起動し、コンテナごとに別のテストを実行するための仕組みがあります。次の図では、マルチモジュール構成なプロジェクトにおける、単一のコンテナでテストを実行する場合の概念図と複数コンテナでテストを実行する場合の概念図を対比しています。

f:id:KeithYokoma:20200330195128p:plain

もしシングルモジュール構成で分散する場合は、CircleCI が提供している CLI ツールを使ってコンテナごとに実行すべきテストを決定できます。

マルチモジュール構成で分散する場合でも CircleCI の CLI ツールは利用できますが、ファイル単位で分散することになり、ツールの吐き出した結果からモジュール名を取り出して加工しないと、実行時にエラーとなります。このため、モジュールごとに分散させるほうが単純です。次に、どのコンテナでどのモジュールをテストするかを決めるロジックを示します。

// 連番で割り当てられるコンテナの ID。
val containerIndex = System.getenv("CIRCLE_NODE_INDEX")?.toInt() ?: 0
// 起動するコンテナの総数
val totalContainer = System.getenv("CIRCLE_NODE_TOTAL")?.toInt() ?: 0

// 指定された ID のコンテナでどのモジュールのテストを実行するかを絞り込む
val modules = project.subprojects
  .withIndex()
  .filter { it.index % totalContainer == containerIndex }
  .map { it.value }

// 絞り込んだモジュールで対しテストを実行する
modules.forEach { module ->
  "./gradlew :${module.name}:test".runCommand()
}

静的解析の自動化

静的解析の自動化では、はじめにチームで認識を合わせたコーディング規約にあったスタイルでコードが書かれているかを検証します。言語特有の記法や設計について静的解析で検証できることは少ないかもしれませんが、これはコードレビューで私たちの目でチェックし、議論していきます。

静的解析には ktlint や checkstyle、findbugs など様々なツールがあります。どれも一定のフォーマットに従って XML を吐き出してくれるので、その XML を Danger などに食わせると、GitHub などのサービスとうまく連携し、コード上にインラインコメントの形で結果をフィードバックできます。コードに対するフィードバックをインラインコメントにしておくと、どこが問題だったかが分かりやすくなります。

配信の自動化

アプリケーションのビルドやテストがうまく動くことはもちろん重要ですが、それと同じくらい、実際の端末で動作を確かることも重要です。ビルドしたアプリケーションを素早くインストールし確かめられるようにすることで、QA によるアプリケーションの品質チェックをはじめ、チームや社内でのドッグフーディングなどでも、素早くフィードバックをもらえるようになります。

リリース前のアプリケーションの配信には DeployGate や Firebase App Distribution、HockeyApp などが利用できます。ビルドの自動化で成果物ができたあと、これらのサービスに apk や aab をアップロードすれば、同じサービスを使ってアプリケーションを管理している人に通知がとび、インストールできるようになります。

様々なブランチのビルド結果をアップロードしておくと、社外の人との共同プロジェクトなどでもアプリケーションの配信ができるようになり便利です。

ワークフローの自動化

たとえば、Git flow を採用しているプロジェクトで、release ブランチにおけるバグ修正を定常的に master ブランチにマージし続けることを自動化しておくと、リリース完了後に release ブランチをマージするよりもコンフリクトする可能性が減らせます。ブランチ戦略にあわせた自動化の作戦が必要ですが、できるだけ小さく差分を取り込み、コンフリクトなどのリスクを最小化することを目的としましょう。

f:id:KeithYokoma:20200330200447p:plain
release ブランチの定常的マージ

この他、コードレビューのレビュワーを自動でアサインするため CODEOWNERS を設定したり、Pull Reminders を導入して未レビューの Pull Request を通知したり、Pull Request Template を設定して共通のフォーマットで差分の説明を書いてもらうようにするといった各種の設定も、ワークフローをうまく回していく鍵になります。

良い DX を支える技術: リリース

リリースはいつも緊張するものです。新しい機能が期待通り動作するか、バグ修正が別のバグを生み出していないか、気になることは数えだしたらきりがないほど出てくると思います。問題が起きないことに越したことはありませんが、仮になにか起きたとしてもすぐにそれを検知し対処できるようにしておくと安心して修正に取り組めます。また、Google Play Store での段階的公開以外にアプリケーションの機能の公開をコントロールする仕組みを持っておくと、傷が浅いうちにトラブルを解決できるようになります。

フィーチャーフラグ

フィーチャートグルなどとも言うそうです。機能の公開をコントロールするためのフラグで、公開・非公開の2値でコントロールする以外にも、ABテストのような複数パターンを分岐するものも考えられます。

フィーチャーフラグを使う場面としておおきく2つ想定しています。

1. 新しい機能を段階的に公開したいとき

新しい機能を段階的に公開する場合、フィーチャーフラグの値は変えたいときにいつでも変えられるようにするため、Firebase Remote Config などアプリケーションのリリースを伴わない手段が必要です。

2. 1回のリリースサイクルを超えた開発をしたいとき

1回のリリースサイクルを超えた開発をする場合、開発用にブランチを分けて開発終了時にマージする方法がオーソドックスですが、開発差分が大きくなればなるほどマージの作業が手間になります。こまめに master ブランチの差分を開発用ブランチに取り込む方法もありますが、コミット履歴が少し入り組んだ状態になってしまいます。もし機能開発がフィーチャーフラグによる出し分けのコントロールが可能な場合、はじめにフィーチャーフラグを定義し非公開状態にしておき、開発が終わったら公開状態にする差分をいれるようにすることで、開発用ブランチで大きな差分を管理する必要がなくなります。

次にフィーチャーフラグを用いた、リリースサイクルを超えた開発の例を示します。Freature A 機能はバージョン 1.3.0 にリリースをする予定のものですが、開発の始まりは1.1.0 のリリース前から始まっています。フィーチャーフラグを用いれば、1.3.0 の直前でフラグを書き換えるまで、1.1.01.2.0 のリリースでは Feature A は公開されません。

f:id:KeithYokoma:20200331115409p:plain
フィーチャーフラグを用いたリリースサイクルを超えた開発の図

これを実現する手段を次に示します。

  1. BuildConfig にフィーチャーフラグを定義する
  2. 新しい機能へアクセスするためのユーザインタフェースのつなぎこみ作業を最後に実施する
  3. 開発者向けの設定画面で機能の有効・無効を切り替える
BuildConfig にフィーチャーフラグを定義する

BuildConfig にフィーチャーフラグを定義する場合はシンプルに build.gradle に次のようなフィールドを生成するよう書き足します。

defaultConfig { {
  buildConfigField("boolean", "IS_FEATURE_A_ENABLED", "false")
}

f:id:KeithYokoma:20200331120746p:plain
BuildConfig を使う場合

新しい機能へアクセスするためのユーザインタフェースのつなぎこみ作業を最後に実施する

既存の画面に新しいボタンなどを設置し、新機能へアクセスするような作りの場合、そのボタンの設置をリリースしたいバージョンの前に行うことで、それより前のバージョンでは誰もその機能にアクセスできなくなります。直接フラグを利用するわけではありませんが、同様の効果がある例です。

f:id:KeithYokoma:20200331120805p:plain
ユーザインタフェースのつなぎこみ作業を最後に実施する場合

開発者向けの設定画面で機能の有効・無効を切り替える

開発者向けの設定画面で機能の有効・無効を切り替える場合は、次のスクリーンショットに示すような画面を作っておく必要があります。

f:id:KeithYokoma:20200331121129p:plain
開発者向けのフィーチャーフラグ切替画面の例

フィーチャーフラグを用いる場合の注意点

フィーチャーフラグを用いる場合、フラグで非公開になった部分はすべて既存機能から独立している必要があります。新しい機能の開発のために既存機能に影響のあるような共通部分の変更をしてしまうと、せっかくフィーチャーフラグで新しい機能を非公開にしていても、既存機能の動作が途中で変わってしまう可能性があります。このため、うまく既存機能から分離するための設計が必要になります。

f:id:KeithYokoma:20200331121600p:plain
共通部分の変更がないよう設計する必要がある

また Firebase Remote Config や BuildConfig にフラグを定義する場合、完全に公開がおわったらそのフラグを削除することを忘れないようにしましょう。また、フラグを有効化するタイミングを間違えないようにすることも重要です。これらはどちらも開発者の運用に委ねられるため、可能なら UI のつなぎこみ作業をもって公開とするような他の手段を先に検討しておくほうがよいです*2

リリーストレイン

規模の大きなチームの場合、一度のリリースでたくさんの機能を公開できるようになります。一方で、どこかの機能開発の進捗が全体のリリースに影響を及ぼすような体制だと、リリースの都度いつリリースするのかの調整が必要になります。この問題の解法の1つとして、リリーストレインがあります。

リリーストレインはリリーススケジュールを固定化し、リリースへ向けた準備のスケジュールも固定化します。これにより、固定されたスケジュールを動かすのではなく、機能ごとにリリースのタイミングを固定されたスケジュールから選択するようになります。もし予定していたスケジュールに間に合わない場合は、次のリリーススケジュールを待つことになりますが、全体のリリーススケジュールを調整することはなくなります。

モニタリング

新しいバージョンが無事機能しているかどうかをモニタリングし、問題があれば早期に気づくためのしくみを作っておきます。検知すべき問題にはアプリのクラッシュや ANR (Android Not Responding) のほか、クラッシュはしないがアプリの表示上でエラーと出るような例外ケースの頻発、機能の劣化(リグレッション)やパフォーマンスの劣化などがあげられます。

Crashlytics を使ってモニタリングをする場合、クラッシュや、Crashlytics で集計しているキャッチした例外の件数が著しく伸びた場合にアラートを発する仕組みが用意されています。また Crashlytics は Slack への通知の仕組みももっているので、Slack を中心にモニタリングすることができるようになります。

f:id:KeithYokoma:20200331124314p:plain
ベロシティアラートの設定

f:id:KeithYokoma:20200331124329p:plain
Slack への通知の設定

ロギング

Crashlytics で検知した問題を解決するためには、適切なロギングが重要です。Crashlytics のレポートに添付されている例外のスタックトレースだけで解決できることもあれば、例外が発生する直前でどんなことが起きていたかを知らないと根本的な解決に至らない問題もあります。

Crashlytics には例外以外にも、ログを残しておくための仕組みもあります。Timber などをつかってロギングのインタフェースを統一し、ログ出力の先を Logcat と Crashlytics に振り分けておきましょう。

また key-value ペアとして様々な状態を記録することもできます。アプリのもつ設定値(e.g. ある機能が有効かどうか、別の機能の設定値は1,2,3のうちどれか、など)やユーザの状態(e.g. チュートリアルが完了したかどうか、など)*3を記録しておくと、問題が起きる前提条件を絞り込むための情報となります。

まとめ

プロジェクトが始まってから、リリース後の運用までを総ざらいし、どんなことに注目するとよい DX が得られるかを解説してきました。ここで解説したことを順番に取り組むもよし、つまみ食いして一番困っていることから取り組むもよし、様々な応用のしかたがあると思います。

解説ではプロジェクトの始まりからリリースまでの順序で見てきましたが、すでにプロジェクトが走ってきている場面でも、よい DX を得る改善ができるはずです。大事なことは、現状のよいところや改善したいことの認識をチーム内で合わせておき、何から取り組んでいくかを明文化していくことだと思います。また、失敗は小さく、リカバリーを素早くできるような仕組みを作っておくと、なにか問題がおきても気持ちよく解決に取り組めるようになります。

*1:https://gfx.hatenablog.com/entry/2018/06/28/100103

*2:https://martinfowler.com/bliki/FeatureToggle.html の Release toggles are the last thing you should do のセクションを参照

*3:プライバシーポリシーとも関わる部分なので注意して設計しましょう

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