macOS向けAAブラウザ「AA Harbor」のソースをAIでレビューして改善した話

0.11.0のソースをClaudeでレビューし、計測・再描画・保存まわりを直したうえで、残りの改善をCodex向けの作業指示書に分けるまで。

使ったAI
Claude / ChatGPT Codex
難易度
上級

AA Harbor は、したらば・0ch/DAT互換の掲示板を読むための macOS 向けリーダーです。アスキーアート(AA)をずれずに表示し、そのまま編集もできます。

0.11.0 まで作ったところで、「長いスレッドだと重い」「更新したときにどこから増えたか分からない」「取得に失敗したのか、新着がなかったのか区別できない」という不満がたまってきました。そこで次の 0.12 に向けて、ソース一式を AI にレビューしてもらい、改善を進めることにしました。

この記事では、そのとき何を直したか、AI のレビューで何が見つかったか、実装を Codex に任せるために何を用意したかをまとめます。

進め方の全体像

役割は次のように分けました。

工程 担当 やったこと
レビュー・改善案 Claude 0.11.0 のソースを読み、問題点と改善案を洗い出す
フェーズ1の実装 Claude 影響が小さく効果の大きい修正をパッチとテストにする
再レビュー Claude 改善案そのものをもう一度レビューし、誤りを直す
フェーズ2〜4の実装 ChatGPT Codex 作業指示書を1つずつ渡して実装・テストしてもらう
実機での確認 自分 体感の速度、マウス操作、署名・公証など

AA Harbor は SwiftUI・AppKit を使う macOS 専用のプロジェクトです。Linux のクラウド環境ではビルドもテストもできません。そのためフェーズ1のパッチは、Claude が書いた時点ではコンパイルを通していません。Mac 上の Codex CLI で swift test を通すことを、最初のタスク(T01)にしました。

改善の4フェーズ

フェーズ 内容
1 処理時間の計測強化、スクロール時の再描画停止、世代バックアップのI/O削減、終了時保存、DATの寛容な解析
2 保存形式 v3(DAT原本を変更しないファイルにし、スレッドごとの管理情報を持つ)
3 取得結果の状態表示、スレッド単位の更新、自動更新、新着境界
4 差分取得、欠落時の受け入れ、DATの書き出し、容量の整理

「重い」をいきなり直そうとせず、まずどこが重いのかを測れるようにすることをフェーズ1の最初に置きました。

フェーズ1で直したこと

1. 処理時間を区間ごとに測る

更新ボタンを押してから画面に出るまでを、区間に分けて記録するようにしました。

区間 内容
receive / parse 通信と解析
log-validate-old / log-encode / log-write 保存の内訳
ui-apply レス差し替え後のメインスレッドの処理
reader-document / text-layout 表示用の文書作成とレイアウト
refresh-total 更新開始から画面反映までの合計

メニューの「診断」から直近の記録をコピーできます。記録に残すのは区間名・時間・件数だけで、URL・本文・検索語は含めません。この計測結果を見てから、差分取得(フェーズ4)をどこまで急ぐかを決めることにしました。

2. スクロール中に画面全体を描き直さない

レビューで見つかった大きな問題の一つです。スクロールするたびに読書位置を @Published の状態に書き込んでいたため、そのたびにスレ一覧を含む画面全体が再評価されていました。

そこで、スクロール中の位置はいったん手元に保留し、保存の直前や位置を使う処理の直前にだけ反映するようにしました。

/// スクロール中の読書位置。state(@Published)へ毎回書くと画面全体が再評価されるため、
/// ここに保留し、保存の直前・位置を参照する直前にだけ state へ反映する。
private var pendingPosition: (key: String, position: ReadingPosition, read: Int)?

private func applyPendingPosition() {
    guard let pending = pendingPosition else { return }
    pendingPosition = nil
    let read = max(state.readCounts[pending.key] ?? 0, pending.read)
    guard state.positions[pending.key] != pending.position
            || state.readCounts[pending.key] != read else { return }
    state.positions[pending.key] = pending.position
    state.readCounts[pending.key] = read
}

代わりに、スレ一覧の「未読 N」はスクロールが止まってから 0.7 秒後に更新されるようになります。即時表示とどちらを取るかは、AI ではなく自分で決めました。

3. 本文の文字列を毎回まるごと比べない

NSTextView のラッパーは、更新のたびに「表示中の本文と新しい本文が同じか」を文字列全体で比べていました。長いスレッドでは数百KBになり、通信中の進捗表示(0.2秒ごと)でもこの比較が走っていました。

文書を作り直すたびに番号(documentVersion)を進め、番号が変わったときだけ本文を差し替えるようにしました。

let changedText: Bool
if let contentVersion { changedText = context.coordinator.contentVersion != contentVersion }
else { changedText = view.string != text }   // 編集欄は従来どおり文字列で比較

4. 世代バックアップをコピーから rename とハードリンクへ

保存のたびに bak1〜bak4 を読み込んで bak2〜bak5 に書き直していました。十数MBのログでも毎回これが起きます。

新しい方式では、世代の移動は rename だけで行い、bak1 は現行ファイルへのハードリンクにします。新しい内容は必ず一時ファイルに書いてから rename(2) で置き換えるので、現行ファイルの中身は bak1 側に残り、新しい内容と同じファイル(inode)を共有することはありません。

private func publish(_ data: Data, to target: URL) throws {
    let temporary = target.deletingLastPathComponent()
        .appendingPathComponent("." + target.lastPathComponent + "." + UUID().uuidString + ".tmp")
    do {
        try data.write(to: temporary, options: .withoutOverwriting)
        guard Darwin.rename(temporary.path, target.path) == 0 else {
            throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
        }
    } catch {
        try? FileManager.default.removeItem(at: temporary)
        throw error
    }
}

あわせて、library.json の世代は最短 600 秒おきにしか回さないようにしました。読書位置は数秒ごとに保存されるので、以前は5世代が数秒分で埋まり、壊れたときの戻り先として役に立っていませんでした。

5. 終了時の保存を確実にする

終了時の保存をウィンドウ内のビューで受けていたため、ウィンドウを閉じてから ⌘Q すると呼ばれない可能性がありました。状態を管理する Store の持ち主を AppDelegate に移し、applicationWillTerminate で保存するようにしました。

6. 壊れたDAT行で正常なレスを消さない

DAT の行が一部壊れていても、番号を保ったまま代替表示にして読めるようにしました。ここで再レビューが2つの問題を見つけています。

  • 再取得したときに一時的に壊れた行が、保存済みの正常な本文を押し出してしまう
  • 「壊れているかどうか」を本文の文字列で判定すると、案内文を引用しただけの正常な投稿を壊れていると誤認する

そこで、壊れた行の番号を damagedIDs として保存に記録し、その番号に保存済みの正常なレスがあれば、そちらを残すようにしました。

AIのレビューで見つかった、改善案そのものの誤り

AI が出した改善案を、別のタイミングで AI にもう一度レビューさせると、最初の案の誤りがかなり見つかりました。主なものは次のとおりです。

最初の案 問題 直した方針
差分取得で HTTP 416 を「更新なし」とみなす 416 は要求した範囲を取得できないという意味。スレッドが縮んだ可能性がある 全体を取り直す
If-Range で途中の書き換えを検出する 追記しただけでも不一致になり、差分取得の効果がなくなる 条件付き取得を基本にし、追記型の差分は板ごとの任意設定にする
新着の境界をページごとに探す 101番から新着のとき、201番から始まる次のページにも境界が出る スレッド全体で境界のレス番号を1つ決める
ログ形式を「ヘッダ行+レス配列」に変える 0.11.0 で読めなくなり、古い世代へ誤って復旧してしまう 単一の JSON に項目を足すだけにする
DAT書き出しで改行を <br> にする 前後の空白で AA がずれる <br> に空白を付けない

AI は一度にもっともらしい設計を出してくれますが、HTTP の細かい意味や、旧版との互換、AA の空白1文字といった部分では間違えることがあります。AI の案を AI にレビューさせ、さらに自分で判断するという二段構えにしたのは正解でした。

Codexに渡すための準備

フェーズ2〜4は、Codex に1タスクずつ渡せる形に整理しました。

作業指示書を1ファイル=1タスクにする

docs/tasks/ に T01〜T10 の指示書を置きました。それぞれに「目的」「仕様」「テスト」「受け入れ条件」「やらないこと」を書いています。

ID タスク
T01 フェーズ1のビルド確認と修正
T05A スレッドを開いたときの更新確認(既定オン)
T02 保存形式 v3
T04 新着境界と「新着へ」ボタン
T03 取得結果の状態表示とスレッド単位の通信
T05B 表示中スレッドの定期更新(既定オフ)
T06 差分取得
T07〜T09 欠落時の受け入れ、DAT書き出し、容量の整理
T10 読書部分を ReaderModel に分離

「やらないこと」を書いておくと、AI が気を利かせて範囲外まで書き換えるのを防げます。

AGENTS.md に守るべき原則を書く

Codex が最初に読む AGENTS.md には、変えてはいけない原則をまとめました。

  • 利用者のデータを失わない。保存済みのレスや読書位置を減らす変更は、確認画面と旧版の保管なしに行わない
  • 書き込みは「一時ファイル → rename(2)」で行う
  • 全ファイルI/Oは保存用の直列キューで行い、UIスレッドで同期I/Oをしない
  • 正常系だけで終えない。保存の途中失敗、壊れた取得内容、ページの境界、書き出しと読み込みの完全往復をテストする
  • 「未確認」を「確認済み」と書かない。速度改善は計測値なしに断定しない

最後の2つは特に大事です。AI は「動くはずです」と書きがちなので、報告の書式に「未確認・残課題」の欄を必ず入れるようにしました。

作業リポジトリを毎回きれいに作る

0.11.0 のソースZIPから新しいフォルダに作業リポジトリを作り、パッチを当てるスクリプトも用意しました。展開前に、ZIPの中身が0.11.0の62ファイルと完全に一致するかを SHA-256 で確かめ、余計なファイルやシンボリックリンクが混ざっていれば中止します。

Finder で圧縮し直した ZIP は日本語ファイル名の表記(Unicode の正規化)が変わり、この検査で弾かれることがあります。元の ZIP を使うのがコツです。

やってみて分かったこと

  • まず測る。「重い」の原因候補は通信だけでなく、再描画や保存のI/Oにもありました。どれが効いているかは計測で確かめます
  • AIの案はAIに再レビューさせる。1回目の案には、HTTPの意味や互換性の誤りが普通に混ざります
  • 実装を任せるAIには、範囲と禁止事項を渡す。指示書を1タスクずつに分けると、レビューもしやすくなります
  • ビルドできない環境で書いたコードは未確認として扱う。macOS専用アプリでは特に、最初のタスクを「ビルドして直す」にしておくと安全です

次は Codex に T01 から順に渡し、計測結果をもとに差分取得の優先度を決める予定です。

制作ログmacOSSwiftアスキーアート