モバイルエンジニアが 1 人もいない私たちが、Raft のスマホアプリをどうつくったか
11 月 2 日の午後 4 時 25 分、私たちのフィードバックシステムにチケットが何件か届きました。TestFlight の外部テスターが、ある会話を開くたびに Raft の iOS アプリがクラッシュすることに気づいたのです。7 秒のあいだに 2 回クラッシュしていました。
その 30 分後、Lagoon がチャンネルで、修正を入れた新しいビルドを外部テスターにプッシュするかどうか私に聞いてきました。
のメッセージをさかのぼってみると、チケットの振り分けを担当する Agent が、この報告をとっくにモバイルの「責任者」である Lagoon に回していました(Lagoon は Agent で、「人」ではありません)。原因も Lagoon がすぐに突き止めていました。アプリで LaTeX の数式を描画するライブラリにバグがあってフォントがリークし、メッセージ内の数式が増えるとメモリを使い果たしてしまうのです。私がこのメッセージを見たときには、問題はもう直っていました。ただ、ユーザーの手元に入っているのが、まだ古いテストビルドだっただけです。
午後 4 時 57 分、私は一言だけ返しました:「プッシュ」。その 3 分後には、新しいビルドが外部テスターに届いていました。
チケットが届いてから修正を送り出すまで、人がやる必要があったのは、私が返したこの一言だけでした。これがモバイル開発の日常です。モバイルチームの人間は私 1 人だけです。それでどう回しているのか、ここから紹介します。
1. チームのつくり方
アプリのコードはオープンソースで(botiverse/mobile)、この記事に出てくるテストやルールはすべてそこで見られます。まずはコードの話から。アプリは Kotlin Multiplatform と Compose Multiplatform で書かれていて、1 つのコードベースから Android 版と iOS 版をビルドしています。利点は、問題を 1 回直せば、すべてのプラットフォームで直ることです。冒頭の数式によるクラッシュも、こうして直しました。
体制の面では、新しい問題が出てくるたびに、1 つの領域を単独で受け持つ Agent を 1 つずつ増やしてきました。下の図のとおりです:
- 10/18 以前リリース林舟Agent
- 10/18モバイル責任者LagoonAgent
- 10/18フィードバックの振り分けDiRenJieAgent
- 10/19共有コンポーネントSAgent
- 10/24デザインシステム
KirbyAgent - 10/26iOS 受け入れテストJonyAgent
- 10/31リリースを引き継ぐLagoonAgent
- 10 月 18 日以前:主に林舟がリリースを担当していました。
- 10 月 18 日:Lagoon が加わり、Tenny(CTO を務める Agent)から最初の仕事を受けました。Web 版とアプリを、機能ごとに 1 つずつ比べる仕事です。同じ日に、フィードバックの振り分けを担当する Agent の DiRenJie も加わりました。毎日 Android と iOS のフィードバックを分類し、重複を除いてから、それぞれの担当者に振り分けます。
- 10 月 19 日:Lagoon がアプリに足りないコンポーネントを洗い出し、共有コンポーネント専任の Agent に渡しました。
- 10 月 24 日:デザインシステム担当の Agent が加わり、画面が正しく描かれているかを判断するようになりました。
- 10 月 26 日:iOS の受け入れテスト担当の Agent が加わり、シミュレーターで実際にタップして、問題を再現するようになりました。
- 10 月 31 日:リリースに必要な権限はすべて、林舟がいる別のにあり、サーバーをまたいで確認し合っていては遅すぎました。そこでリリースも Lagoon に任せることにし、引き継ぎドキュメントに沿ってで引き継ぎました。
2. 開発とテスト:コードを 1 行ずつ読む代わりに、証拠で確かめる
開発とテストは、本来は別々の仕事です。でも Agent の時代になって、コードはほぼ Agent が書くようになり、人が自分で手を動かすことはほとんどなくなりました。そのぶん、テストの重みが増しています。この考え方は、以前「信頼はCode Reviewの中には宿らない」でも書きました。コードの大部分を Agent が書き、人が 1 行ずつレビューしなくなったら、信頼の拠りどころを、もうコードのレビューに置くことはできません。頼りになるのは、システムがどう動くかを人が予測できるようにしてくれるものです。たとえば、当てになるテスト一式や、バグを 1 つ直すときに、まずそのバグを狙ったテストが赤から緑に変わるのを確かめることです。
モバイルはまさにその状況です。1 週間で 300 件を超える変更がコードベースにマージされ、そのほぼすべてを Agent が を通じて提出しています。(Stamp は私たちのコードツールで、Agent はその上で、自分の名義でコードをプッシュし、PR を作成し、レビューしてマージします。承認は承認した時点のバージョンのコードにだけ有効で、コードが変わればもう一度承認が必要です。)これだけの変更を、私が 1 行ずつ読むことはとてもできません。それでも安心していられるのは、次の 3 層の証拠があるからです。
1. スクリーンショット比較:ベースラインと比べ、Web 版と比べる
UI を変える PR には、どれも「スクリーンショットのベースライン」が付いています。CI がコンポーネントをライトモード、ダークモード、狭い画面、広い画面でそれぞれ描画し、ベースラインとして保存します。その後、誰かの変更で画面が変われば比較は失敗し、PR の作成者はその変化が意図したものだと説明したうえで、ベースラインを撮り直さなければなりません。
アプリのスクリーンショットは Web 版ともしていて、比較レポートは毎回公開しています。Web 版は私たちのデザインの基準で、アプリはできるだけ Web 版と同じ見た目にする必要があります。初めて体系的に比較したとき、ピクセル単位の平均類似度は 68.8% しかありませんでした。最新の比較では 85 画面を比べ、平均 87.6% まで上がっています。まだ差が大きいものは、1 つずつ私のところに持ってきてもらい、私が判断します。
小さな例を挙げます。あるとき、サイドバーの未読の赤い点が Web 版と合っていないことに気づきました。調べると、赤い点の位置を決める基準になる要素が間違っていました。Web 版では赤い点をアイコンの右上に付けているのに、アプリではボタン全体の四角の角に付けていたため、赤い点が 2 つのボタンのあいだに浮いていたのです。修正後、サイドバーの 6 枚のベースライン(ライト、ダーク、コンパクトモードなど)をすべて撮り直しました。新旧のベースラインを比べれば、違いがひと目でわかります。

2. 仮想のスマートフォンに、実際にアプリを使わせる
スクリーンショットのほかに、CI ではあまり見かけない種類のテストも動かしています。クラウド上で仮想の Android スマートフォン(Android エミュレーター)を立ち上げ、アプリをインストールして、人と同じようにタップしたり文字を入力したりしたあと、画面のピクセルを測るのです。

たとえば、「最初の 1 文字」というテストのグループがあります。入力欄に @ を 1 つ打つと、アプリは「誰を @ しますか」という候補ボックスを開きます。候補ボックスを閉じたあと、テストは入力欄の上の領域が本当にきれいになったか、残像や閉じきっていないポップアップが残っていないかを確認します。
ふつうの単体テストが確認するのは、関数の戻り値が正しいかどうかだけです。このテストが確認するのは、ユーザーが最終的に目にする画面が正しいかどうかです。
CI で動いているのはエミュレーターです。実機で確認する必要がある問題では、Agent が でクラウド上の実機を 1 台借ります。testbed は社内のクラウド実機プラットフォームで、本物の Android スマートフォンがそろっています。Agent はリモートでビルドをインストールし、操作し、スクリーンショットを撮って、問題の再現や検証に使います。
3. 赤と緑の検証:テストそのものが信頼できることを示す
3 層目がいちばん面白いところです。「テストは通りました」と言うだけでは足りません。テストそのものが壊れていることもあるからです。そこで、2 つのことを証明するよう求めています。修正を外すとテストが赤になり、修正を入れると緑になること。
10 月 30 日、前述の「最初の 1 文字」テストが、突然すべての PR で失敗するようになりました。
私たちはこれを偶発的な失敗とみなして、そのまま再実行することはしませんでした。まず main のコミット履歴を二分探索したのです。真ん中のコミットでテストを実行して結果が良いか悪いかを見て、問題のある側の半分をさらに探す。これを何回か繰り返して、1 つの変更に絞り込みました。その変更は入力欄の幅を 321dp から 302dp に狭めていて(dp は Android の長さの単位で、おおよそ画面上の 1 ポイントに相当します)、そのせいでテストがサンプリングする領域に、背景色の帯が 1 本入り込んでいました。
次に数字を見てみます。このテストのルールは、その領域で期待どおりのピクセルが 92% 以上(比率 ≥ 0.92)を占めることです。アプリ自体は実は壊れておらず、テストが測る位置がずれていたのです。何も変更せずに 2 回実行すると、結果はどちらも 0.9136 で、完全に一致しました。偶発的なものではなく、決まって起きる問題だということです。テストのサンプリング領域だけを調整すると、比率は 0.9777 になりました。
この数字から、いくつかのことがわかります。0.9136 は「赤」で、このテストが確かに問題を検出できることを示しています。0.9777 は「緑」で、修正が問題に的中していることを示しています。そして 2 回の 0.9136 が完全に一致していることで、運の要素も排除できます。この経験から、私たちは 2 つのことを心に刻みました。1 つは、テスト自身も壊れることがあるということ。あの日、アプリのコードは 1 行も間違っておらず、間違っていたのはテストのほうでした。もう 1 つは、テストが赤になっても、偶発的なものとして再実行で片付けてはいけないということ。まず、毎回赤になるか、数字が毎回同じかを確かめます。安定して赤になるなら、必ず原因があります。

この考え方自体は新しいものではなく、テスト駆動開発の「レッド・グリーン」と同じです。私たちがしたのは、これをすべての PR の要件に書き込んだことです。Agent は自分の PR を自分で承認できますが、その同じレビューの証拠の中に、どのテストを実行したかを明記し、変更前後のスクリーンショットを添付し、「逆方向の検証」欄を埋めなければなりません。修正を外すとどのテストが赤になり、どんなふうに赤になるのかを書く欄です。API の契約や権限を変える変更、あるいはリクエスト数を目に見えて増やす変更は自分では承認できず、必ず Tenny のレビューに回します。私はコードを読みません。この欄を見るだけで、その修正に対応するテストがあるかどうかを判断できます。
3. プロセスの自動化
1. 毎日お昼に 1 回、アプリを Web 版にそろえる
アプリは Web 版と足並みをそろえる必要がありますが、Web 版は毎日新しいバージョンを出しています。そこでモバイル責任者の Lagoon は、毎日北京時間 12 時のを自分に設定しました。時間になると、Web 版の最新の本番リリースとアプリを突き合わせ、アプリが追いかけるべきタスクを洗い出して優先度順にチャンネルに投稿し、1 つずつ片付けていきます。
2. クラッシュの報告から修正まで
冒頭の出来事を分解してみると、実は決まった流れのパイプラインになっています:
- 報告。 アプリがクラッシュするか、ユーザーが「フィードバック」をタップすると、 に自動でチケットが届きます。アプリが自分でまとめた診断ログも付いてきます。
- 振り分け。 フィードバックの振り分けを担当する Agent が、毎日 Hands から Android と iOS のチケットを取得し、分類、重要度の判定、重複の除去をして、それぞれの担当者に振り分けます。
- Symbolication(シンボル化)。 対応するビルドのシンボルファイルを使って、クラッシュしたアドレスを関数名とコードの行に変換します。クラッシュの記録にはメモリアドレスしかなく、人には読めません。シンボル化して初めて、どの行でクラッシュしたのかがわかります。
- ただ 1 つの根本原因を見つける。 その日の 3 件のチケットのうち、2 件はバイト単位で完全に同じで、同じクラッシュが 2 回報告されたものでした。3 件目は、7 秒後に開き直したときに、もう一度クラッシュしたものです。根本原因は 1 つだけ、フォントのリークでした。
- 修正がどのビルドに入っているかを確かめる。 このステップは記憶に頼らず、ビルドに使われたコミットと 1 つずつ照らし合わせます。振り分け担当の Agent が誤りを正したこともあります。1.18.0 の 6 番目のビルドには、もっとあとでマージされた別の修正は含まれていないので、そう書くことはできない、と。
- ユーザーの手元に届ける。 Lagoon がまず私に確認し、私が同意したら、Hands を通じてビルドを TestFlight の外部グループにプッシュします。チケットはすぐには閉じません。実際のユーザーがしばらく使い、同じ種類のクラッシュが出なくなって初めて、本当に直ったとみなします。

3. リリースの自動化
バージョン番号の上げ方は、のちに私の依頼でリポジトリに書き込まれ、Agent はリリースのたびに、このルールに沿って自分で判断しています:
- minor(1.X.0):新機能があるときに上げます。週に 1 回、main のテストがすべて通り、社内ビルドがスモークテストに合格したあとに切り出します。
- patch(1.X.Y):本番環境の問題だけを直します。たとえば、クラッシュ、データの消失、前のバージョンからの退行などです。修正はまず main に入れてから、リリースブランチにチェリーピックします。新機能は紛れ込ませません。
- build(ビルド番号だけを変える):ユーザーには変化がわからないものです。たとえば、ビルドし直す、審査に出し直すといった場合です。
もう 1 つのやり方が、変更履歴です。各バージョンには、リポジトリ内に専用のフォルダーがあります。たとえば 1.20.0 です。あるバージョンに入れる変更があると、その変更をした Agent がマージのときに、フォルダーへ小さなファイルを 1 つ置きます。何を変えたか、どの部分に属するか、どのプラットフォームに入るかを、中国語と英語でそれぞれ 1〜2 文で書きます。書くのはユーザーに見える変化だけで、コードの細部は書きません。1.20.0 のフォルダーには今、こうしたファイルが 57 個あります。
リリース当日、リリース担当の Agent はコミット履歴を見返さず、このフォルダーだけを見ます。小さなファイルを Android 用と iOS 用のリリースノートにそれぞれまとめ、さらに配信チャネルごとの要件に合わせて書き直します。たとえば Google Play は 500 文字まで、TestFlight には「今回テストしてほしいこと」を書く、といった具合です。できたビルドはまず社内テストにだけ配信し、私がインストールして試し、OK を出してから、外部テスターとストアにプッシュします。
こうすれば、変更履歴は変更のたびに少しずつ積み上がっていきます。リリースのときに、このバージョンで結局何が変わったのかを思い出す必要はありませんし、どの Agent の仕事も漏れません。
4. リリース、監視、チケットはすべて Hands で
ここまでの話は、実際にはほとんどが を通っています:
- リリース:CI がビルドした Android と iOS のパッケージは Hands にアップロードされます。Android のアプリ内アップデートは Hands から直接配信します。Google Play の製品版への昇格、App Store への審査提出(名前、サブタイトル、キーワード、リリースノートの入力、ビルドの選択、事前チェック、提出)、TestFlight の外部グループへの配信も、Agent が Hands のコマンドで行います。人が Play Console や App Store Connect を開く必要はありません。
- 監視:クラッシュと ANR は Hands に集まります。CI が mapping と dSYM も一緒にアップロードするので、クラッシュの記録は自動でシンボル化されます。TestFlight のテスターが報告したクラッシュも Hands から取得します。
- チケット:アプリの「フィードバック」は Hands のチケットになり、診断ログとスクリーンショットが付きます。Agent はそのチケットを直接読んで調べます。冒頭の数式によるクラッシュも、こうして届きました。
ビルドを渡したあと、ストア側の結果を人が見張っている必要もありません。Hands は Google Play、App Store、TestFlight での状態の変化を私たちのリリース用チャンネルに投稿します。提出完了、審査待ち、審査通過、公開、リジェクトと、ステップごとに 1 件のメッセージです。App Store と TestFlight の変化は起きたその場で届き、Google Play は Hands が 5 分ごとに確認します。Agent はそれを見て次の作業に移るので、自分で定期的に確認しに行く必要はありません。たとえば 1.22.0 の Android 版では、Lagoon が午後 3 時 38 分に Google Play の製品版へ出すと、すぐにチャンネルに「Release submitted」が届きました。午後 4 時 10 分に Google の審査が通ると「Available on track」が届き、Lagoon はそれを見て、このバージョンを待っていた人と Agent に知らせに行きました。

4. 人はどこで判断するのか
私が自分で決めるのは 3 か所だけで、それ以外はテストと証拠に任せています。
1 つ目は、出すかどうか。 外部へのリリースは、Hands の本番チャネル、Google Play のテスト版と製品版、TestFlight の外部グループ、App Store のどれであっても、対象のバージョンごとに私が OK を出す必要があります。社内向けのチャネルなら、私に聞く必要はありません。冒頭の「プッシュ」の一言がその一例です。私が OK を出す前に、そのビルドはスモークテストを通っていなければならず、何をテストして結果がどうだったかを 1 項目ずつ記録します。「問題なく使える」と一言書くだけでは認められません。
2 つ目は、正しくできているかどうか。 ここにいちばん時間を使っています。Agent が機能ができたと言ったら、それが本当に使えるかを確かめます。想定した手順どおりに最後まで進められ、エッジケースでもおかしくならないこと。そのため Agent は、作業を渡すときに、主要な操作を最初から最後まで通した画面録画を付けてきます。録画ではわからない部分は、testbed で検証セッションを開いてリンクを送ってくれるので、私はブラウザ上でそのクラウド実機を直接操作し、自分でタップして確かめます。自分でビルドをインストールする必要はありません。同じくテストにしにくいものに、画面の見た目が正しいかどうかがあります。これもスクリーンショットと録画を見て、私が判断します。

画面を見て、どうもおかしいと感じるのに、どこがおかしいのか言葉にできないことがあります。そういうときは「なぜこうなっているのか」に立ち返って考えます。たとえばあるとき、Mac で App Store の iPad 版を使っていて、タブレットとは見た目が違うと感じました。調べてみると、Compose 版はマウスを検出しただけで「パソコンサイズ」に切り替わり、文字もボタンも小さくなる仕組みでした。このルールはどこかおかしいと思いました。なぜ文字サイズがマウスと結びついているのか、と。そこで、一から分解して考え直しました:
- 文字の大きさは、画面の大きさと、人が画面からどれだけ離れているかで決まります。だから見るのはウィンドウの幅だけです。幅が 744 以上なら、タブレットも Mac も同じ大画面用の文字サイズを使います。
- ボタンの大きさは、指で操作するか、ポインターで操作するかで決まります。指なら少なくとも 44 必要ですが、トラックパッドなら 32 まで詰められます。入力方法を見る必要があるのは、こちらだけです。

3 つ目は、アーキテクチャの方向性。 大きな方向は私が決めます。たとえば、アプリで選べるランタイムは、もともとクライアントにハードコードされていました。そのため Web 版でランタイムが 1 つ増えるたびに、アプリも新しいバージョンを出さなければなりませんでした。私が投げかけたのは、「もう schema になっているのだから、バックエンドで管理すればいいのでは」という一言です。それで方向が決まりました。バックエンドがサーバーごとにランタイムの一覧を配信し、アプリはそれをそのまま表示します。リクエストの上限、権限、API の契約といった、より細かいトレードオフは、CTO を務める Agent の Tenny がレビューします。コードを書く Agent が自分だけで決めることはできません。
私が注意を向けるのは、この 3 か所だけです。
このやり方を試したいチームへの 3 つのアドバイス
- まず証拠を整えてから、権限を渡す。 エミュレーターでのテスト、スクリーンショットのベースライン、赤と緑の検証をまず整えてから、Agent が自分で PR を承認できるようにします。こうした証拠がないまま権限を渡すと、問題が起きても気づくのが難しくなります。
- 人には判断だけを任せ、手を動かす作業はさせない。 私はコードを読まず、ビルドもつくらず、チケットの振り分けもしません。決めるのは、出すかどうか、正しくできているかどうか、アーキテクチャの方向性だけです。こうしたチームでは、人の注意力がいちばん足りない資源です。Agent には代われないところに使うべきです。
- つまずいた点は、そのたびに決まったやり方として残す。 テストの測る位置がずれていたときには、逆方向の検証を加えました。こうしたやり方はリポジトリとのファイルに書いてあるので、次に引き継ぐ Agent が同じ失敗を繰り返すことはありません。
この記事に出てくる機能
- メッセージにぶら下がるサブの会話
- Raft でのチームの場所
- 複数のサーバーで共有するチャンネル
- Agent が自分の名義でコードを提出する
- アプリと Web 版をピクセル単位で比べた公開結果
- リモートでインストール・操作・検証できるクラウドの実機(社内ツールのため、外部からは使えません)
- Agent が自分にかけるアラーム
- ビルド、配信、フィードバック受付のツール
- Agent が自分でつけるメモ
