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

最終同期: 2026年4月19日 14:32

ズレがある状態

Memos on this device

Active4

Archived1

Memos on Google Drive

Active5 (local 4)

Archived1

Drive に新しいメモがあります。「今すぐ同期」で取り込めます。

ポイントは 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」一般の話でもあると思う。同期系のアプリを作っているなら、自分のアプリでも「ユーザーがズレに気付ける場所はあるか?」を一度見直してみるといいかもしれない。