AIでコードは安くなった。でも開発は楽にならなかった
BiliKitはまだv1にもなっていないが、Swiftのコードはすでに21,300行ある。
内訳はプロダクションコードが約12,700行、テストが約8,400行で、Swiftファイルは125個。Bilibiliの閲覧と検索、WebのQRコードを使ったログイン、AVPlayerによるDASH動画の再生、字幕、再生時間に同期した弾幕のスケジューリングと描画までは動くようになった。
UIはまだかなり直したいし、Macらしい操作も足りない。署名、公証、リリース周り、v1向けの最終回帰テストも残っている。
この規模になって、最初に使っていたAI開発のやり方が通用しなくなった。
当初は機能を説明し、AIに関連ファイルを読ませ、実装後にテストとdiffを確認するだけだった。小さいリポジトリならこれでも十分だった。置き場所を間違えても変更範囲が狭いので、あとから直すのも難しくない。
BiliKitでは、間違いがもっと見つけにくい形で出るようになった。
コンパイルが通るタイプの間違い
再生処理だけでも、ネットワーク、HTTP Range、SIDX解析、HLSプレイリストの生成、ローカルHTTPサーバー、AVPlayerのライフサイクル、キャンセル、シーク、CDNのフォールバックを通る。
認証にはリモートの状態遷移、ephemeralなURLSession、リダイレクトポリシー、Keychain、リクエストへの認証情報付与、ログアウト時の削除、UI状態が関係する。弾幕もprotobufのデコード、セグメントの先読み、重複排除、共通のメディアタイムライン、レーン割り当て、Core Animation、オブジェクトの寿命までつながっている。
AIがコンパイルできないSwiftを書くことは、実はそれほど多くない。困ったのは次のようなケースだった。
- 型が、その意味を所有するモジュールではなく、一番置きやすいモジュールに入る
Task.yield()を一度呼んだだけのテストが、たまたまその実行では通る- UIの更新は止まったのに、キャンセル後も下のリソースが残る
- 匿名で送るべきメディアリクエストに認証Headerが付く
- 一つの判断材料を得るためのBenchmarkが、先に小さなFrameworkへ成長する
- Roadmap上の予定が、現在使える機能としてドキュメントに書かれる
どれも一見すると普通の変更なので、diffを軽く読んだだけでは見逃しやすい。テスト一式が通るものもある。
最初はpromptを長くして対処していた。製品スコープ、モジュール境界、過去の判断、危険な箇所、検証方法をまとめて渡せば、確かに一時的には改善する。
ただ、新しい会話を始めるたびに同じ説明が必要になる。長期的なルールと、その日に取り組んでいる作業も一つの文章に混ざってしまう。これではチャット履歴でプロジェクトを管理しているようなものだった。
プロジェクトの情報をリポジトリへ戻す
BiliKitにはRoadmap、ADR、Threat Model、日付付きの検証記録、開発ガイドを少しずつ追加した。
現在のコードとビルド設定は、今何が存在するかを示す。ADRには簡単に覆したくない判断を残す。Roadmapではv1の範囲と将来の候補を分ける。検証記録は、特定の日付と環境で実際に観測した結果を保存する。
BiliKitには、ダウンロード、トランスコード、ライブ配信、複数アカウント、リージョン解除など、実装案を作りやすい寄り道がかなりある。今のv1には入れない。
少し大きな変更では、実装前に短い契約も書くようになった。
- Goal: どの観測可能な動作を変えるか
- Context: 関連する入口、既存の判断、既知の制限
- Constraints: 依存方向、セキュリティ、キャンセル、寿命、スコープ
- Done when: 何を確認できれば完了か
Goalを一文で書けないときは、だいたい一つのタスクに複数の変更が入っている。「テストが通る」しか完了条件がない場合は、危ない動作をまだ特定できていないことが多い。
新しい会話は、前の会話を丸ごと引き継がなくてもよくなった。契約とリポジトリ内の資料を読めば、必要なところから始められる。
一つの動作を最後までつなぐ
AIに横方向のコードを一気に作らせるのは簡単だ。
Domain Modelを全部作り、次にRepository、Use Case、Viewを並べる。見た目はきれいだが、実際の呼び出し元がないProtocolや、将来を想像して作ったTargetも増えやすい。
BiliKitのレイヤー構成は次のようになっている。
Scene → View → ViewModel → Use Case → Repository Port → Adapter実装するときは、一つの動作についてこのレイヤーを縦に通す。
認証ならQRの状態、Credentialの所有権、認証が必要なEndpoint一つ、UIフロー、再起動後の復元、ログアウトまでをつなぐ。弾幕なら共通のメディアタイムラインとセグメント契約を決め、上限のあるスケジューリングを作り、実際の再生経路にRendererを接続する。
サブシステム全体を一度に生成するより地味だが、あとから消す空の抽象はかなり減った。
diffの大きさでは判断できない
リダイレクト処理の5行でCredentialを外部へ送ってしまうことがある。Taskの所有関係を少し変えただけでリークや古い結果の上書きが起きる。短い永続化Migrationでもデータは消せる。
一方、自動生成されたprotobufの差分は大きくても、ほぼ機械的な変更かもしれない。
そこで現在は、失敗したときの影響で作業を分けている。
- Green: ローカルなUI変更や狭い機械的修正
- Yellow: 通常のFeature、Use Case、複数ファイルのRefactor、公開API
- Red: 認証、Keychain、メディア、リダイレクト、ローカルサーバー、並行処理、リソース寿命、Renderer、Migration、削除、不可逆な変更
Greenは基本チェックと対象diffの確認で済ませる。Yellowではタスク契約を書き、必要なら別の読み取り専用コンテキストでReviewする。Redは先に進め方を決め、ルートが不明な場合は範囲を限定した実験を行い、そのリスクに合った測定を追加する。
全部の変更に同じ手順を要求しないための分類でもある。CSS変更とKeychain変更に同じChecklistを使ってもあまり意味がない。
不確実な実験には、先に複雑さの上限を置くようにした。弾幕Rendererの比較なら、候補を十個も用意したり、再利用可能なBenchmark Frameworkを作ったりする必要はない。製品側の判断に必要な結果が取れればよい。
実際のRenderer比較は、マージしないSpikeブランチで合成負荷を使って行った。方針を決めたあと、プロダクション実装は改めて始めた。実験コードが動くという理由だけで、そのまま基盤にはしなかった。
テストが見ている範囲
字幕のテストで、一度だけTask.yield()を呼べば非同期Streamの状態が更新される、と仮定していたことがある。
普段は通っていたが、統一Gateから全体を別のタイミングで実行したときにRaceが出た。何度か再実行してGreenにするのでは意味がないので、必要な状態が観測できるまで待つテストへ直した。
BiliKitでは、静的チェック、Package Test、App全体のビルドを一つの入口から実行する。そのうえで、変更内容に応じた確認を追加している。
- プロトコル動作には決定的なFixture
- 送信元とリダイレクトポリシーにはNegative Test
- Keychainには署名済みAppでのSmoke Test
- 再生には実際のAVPlayer Probe
- 弾幕には制御された高密度負荷
- メモリと解放には長時間の測定
- 自動化で判断しにくいUIには手動確認
Unsigned BuildではKeychainを確認できない。起動に成功しても、Rendererが30分後にLayerを正しく解放しているかは分からない。
もちろん毎回すべてを実行するわけではない。Swift Packageだけの変更ならPackage Testで終わることもあるし、性能の問題がなければInstrumentsを開く必要もない。
今の進め方
今はだいたい次の順番でBiliKitの作業を進めている。
- 現在のコード、Roadmap、関連する判断、テストを読む
- 観測可能なGoalと完了条件を書く
- 失敗時の影響からリスクを決める
- 不確実な方法を試す前に複雑さの上限を置く
- 最小の縦方向Sliceを実装する
- 重要な変更は新しい読み取り専用コンテキストでReviewする
- 共通の自動Gateを実行する
- その変更に必要な環境依存の確認だけ追加する
- 現在のドキュメントを更新し、過去の検証記録は書き換えない
週末に作る小さなScriptなら、ここまでする必要はない。BiliKitには認証、再生、ローカルサーバー、並行状態、Rendererがあるので、prompt、テスト、diff確認だけの進め方では早い段階で足りなくなった。
BiliKitはまだv1前で、このやり方も今後また変わると思う。
少なくとも、新しい会話にプロジェクト全体を覚えてもらう必要はなくなった。
この経験の一部は、あとから再利用できるCodex Skillsとして切り出した。続きは自分のAIワークフローをコードにした。