「データの所有権」を謳うアプリで、端末と Drive の件数ズレをユーザー自身が確認できる UI を作った

AI 主体で執筆

60d Memo のビジョンは「データの所有権をユーザーに返す」こと。メモ本体は Google Drive 上にあり、アプリはビューア兼エディタに過ぎない。だがこの建前を本当に意味あるものにするには、ユーザーが自分の目で「いまデータがどこに何件あるのか」を確認できないといけない。エラー時にアラートを出すだけでは足りなかった、という話。

ビジョンと UI のギャップ

60d Memo は LP にも AppStore の説明にも「データの所有権をユーザーに返す」と書いてある。メモは Google Drive 上の 60dMemo/ フォルダに保存され、ユーザーがいつでも他のアプリやブラウザから中身を覗き、コピーし、削除できる。アプリはそれを編集するインターフェースに過ぎない、という設計だ。

ところが v1.0 をリリースしたあと、ふと気付いた。このビジョンは UI に現れていない。画面に並んでいるのはメモ一覧と編集画面だけで、「いまデータがどこに、どの状態で存在しているか」をユーザーが確かめる手段がない。同期が失敗すればアラートが出る。だがサイレントなドリフトには気付けない。端末に 4 件表示されていても、Drive 側には 5 件あるかもしれない、そんな状態をユーザーが能動的にチェックする場所がなかった。

「データの所有権」を本気で謳うなら、ユーザーが自分の目でズレを検出できないといけない。アプリを信じてもらうための UI ではなく、アプリを疑えるための UI が要る。

どの画面に載せるか

最初は設定画面に「この端末のメモ数」という行を置いていた。だが件数を一箇所で見ても、ユーザーは「それが正しいのか」を判断できない。比較対象が欠けているからだ。件数表示を本当に意味あるものにするには、端末側と Drive 側を同じ画面で並べる必要がある。

置き場所として最適だったのはエクスポート画面だった。理由は 3 つある。

  1. エクスポートは「データがどこに何件あるか」を最も気にする場面。ファイルに出す直前なので、件数へのユーザーの注意が自然に集まっている。
  2. エクスポート対象の範囲(アクティブのみ / アーカイブ込み)と、ストレージ別の件数を並べると、どの数字が何を意味するかが構造として腑に落ちる。
  3. 設定画面の「この端末のメモ数」行は冗長になる。エクスポート画面に集約すれば設定画面は純粋な設定項目だけになり、情報アーキテクチャも整理される。

こうして設定画面から件数行を削除し、エクスポート画面の上部に「端末」と「Google Drive」の 2 ブロックを並べた構造に変更した。

ズレが見える UI

端末 / Drive の件数をそれぞれ「Active」「Archived」で並べる。両者が一致しているときはグレーの caption 表示、ズレているときは Drive 側に 5 (local 4) という形で両方の値を併記し、色をオレンジ系(警告色)に切り替える。

同期済みの状態
Memos on this device
Active4
Archived1
Memos on Google Drive
Active4
Archived1
ズレがある状態
Memos on this device
Active4
Archived1
Memos on Google Drive
Active5 (local 4)
Archived1

ポイントは 4 つ。

Drive 側の件数をどう取るか

Drive 件数のためにメモ本体を全部ダウンロードしていたら、画面を開くたびに API 呼び出しと通信コストが膨らんでしまう。ところが 60d Memo は以前から インデックスファイル方式でデータを持っている。

Google Drive
  └── 60dMemo/
       ├── index.json              ← メタ情報(schemaVersion, メモ一覧)
       └── memos/
            ├── {uuid-1}.json      ← 個別メモ本文
            ├── {uuid-2}.json
            └── ...

index.json にはメモ ID・更新日時・アーカイブ状態・ソフトデリート状態のメタデータだけが並ぶ。本文は入っていない。件数だけ欲しいなら、この 1 ファイルを読むだけで済む。

この設計はもともと同期の高速化のために入れたもので、起動時に modifiedTime を比較するだけで再取得を省略する fast-path として働いている。そこにもう 1 つの役目が乗っかった形だ。集計ロジックも軽い。

struct DriveMemoCounts {
    let active: Int
    let archived: Int

    static func aggregate(from index: DriveIndex) -> DriveMemoCounts {
        var active = 0, archived = 0
        for entry in index.entries {
            guard entry.deletedAt == nil else { continue }  // ソフトデリートは除外
            if entry.archivedAt != nil { archived += 1 } else { active += 1 }
        }
        return DriveMemoCounts(active: active, archived: archived)
    }
}

値型で切っておくと、DriveIndex のテストデータを作るだけで集計ロジックを単体テストできる。@MainActor の同期サービス全体をモックする必要はない。クロスプラットフォーム展開(Android / Web)を見据えて「純粋なロジックは素の Swift で書く」という方針にも合致する。

ユーザーが見ている世界と数字を揃える

件数表示で一番悩んだのは定義の揃え方だ。ズレを見せるなら、両側の数字が同じ意味でないといけない。そうでないと「ズレ」ではなく「ズレているように見えるだけのノイズ」になる。

具体的に揃えたのは 3 点。

原則: 件数表示は「ユーザーが一覧画面で見ている世界」と同じ定義で数える。実装上の便宜(SwiftData の内部カウント、Drive のソフトデリート込み件数など)を露出させない。

設定画面を削ぎ落とす

件数表示をエクスポート画面に集約した副作用として、設定画面から「この端末のメモ数」「最終同期」などの情報系の行が消えた。残ったのはアカウント・同期操作・トラブルシューティング・フィードバックといった純粋な設定項目だけ

これは情報アーキテクチャの原則として前から意識していたが、今回はっきり言語化できた。

原則: 同じ情報を複数の画面に載せない。どうしても見せたいなら「一番詳しく見たい画面」に集約し、他は遷移の動線だけに留める。件数は SettingsViewExportView の両方に置くと重複になる。ユーザーが件数を一番気にするのはエクスポート画面なので、そちらに寄せる。

設定画面の配色リファクタ(60dメモの設定画面を3観点でリファクタ)とは別軸の整理だが、結果として「設定画面は設定だけ、件数はエクスポート画面」という役割分担がきれいに立った。

まだ残っている課題

この UI はエクスポート画面を開いたタイミングでスナップショットを取るだけだ。画面を開いている間に他端末から Drive へメモが追加されても、自動では更新されない。いまは .task で 1 回ロードする実装で、同期ボタンを押したあとに再計算する程度。リアルタイム更新を入れるなら Drive の changes API を叩くことになるが、それは別の話題として育てる予定(→ backlog にエントリを残している)。

あと、件数がゼロのときの表現も悩みどころ。新規ユーザーで端末 0 件 / Drive 0 件のときに「Active 0 / Archived 0」と 4 つゼロが並ぶと初期画面としては賑やかすぎる。ここは空状態の表示を別途組むつもり。

まとめ

これは 60d Memo 固有の UI 話に見えて、クラウドストレージを前提にしたアプリの「信頼のための UI」一般の話でもあると思う。同期系のアプリを作っているなら、自分のアプリでも「ユーザーがズレに気付ける場所はあるか?」を一度見直してみるといいかもしれない。