リリースノート

無効化をどこに置き、配列を何で同一視するか — ぽんっ! 1.1.2

こども向けの「やること」アプリ「ぽんっ!」(旧称:でいりんぐ)1.1.2 をリリースしました。おとな向けの週間サマリー、端末連携まわりのデータ設計、対応 OS の拡大が主な内容です。

この版で決めたことは、粒度こそ違うものの、いずれも 「どのスコープに責務を置くか」 という同じ形の問いでした。状態の無効化をどのスコープに置くか、配列の同一性を何で決めるか、集計に必要なデータをどこで取るか。順に書きます。

一度だけ取得する状態と、無効化の置き場所

自分自身のユーザー情報は、リアルタイム購読ではなく 一度だけ取得する非同期 provider で持っています。常時購読すると、表示に使わない画面でも読み取りが走り、更新のたびに再描画が波及します。自分の情報は自分の操作でしか変わらないので、購読を張り続ける必要はありません。

代わりに、更新したら明示的に無効化する 責務が発生します。ここをどのスコープに置くかが設計上の分岐点でした。

素直に見えるのは、書き込みを行う Notifier の中に集約する案です。呼び出し側が無効化を書き忘れる余地がなくなります。

// 却下した案: 書き込みを行う Notifier の中で無効化する
class LinkPartner extends _$LinkPartner {
  Future<void> link(...) async {
    await repository.addLink(...);
    ref.invalidate(currentUserProvider); // ← ここでは効かない
  }
}

これは採れませんでした。この Notifier は autoDispose で、書き込みを待っている間に破棄されうるからです。破棄後の ref に対する無効化は例外も出さずに素通りするため、「呼んでいるのに何も起きない」状態になります。この provider が持つライフサイクルの性質については、以前 autoDispose な provider を await 中に触る話 で書いたとおりです。

採用したのは、画面側の ref で無効化する 形です。

// 採用: 操作の起点である画面のスコープで無効化する
await ref.read(linkPartnerProvider.notifier).link(...);
ref.invalidate(currentUserProvider); // 画面は操作の間ずっと生きている

書き忘れの余地は残りますが、確実に効くほうを取りました。一般化すると、副作用の後始末は、その操作より寿命の長いスコープに置く ということです。処理の近くに置くのが読みやすいとは限りません。

判定に使う値は、その場で取り直す

連携画面には「すでに連携済みの相手か」を確かめる判定があります。真になるとユーザーの操作をその場で止めるので、判定材料が古いと 正しい操作を誤って止めて しまいます。別の端末で解除された直後などが該当します。

判定の誤りは対称ではありません。二重に通してしまうのは後段の書き込みが冪等なら吸収できますが、通すべき操作を止めるのは行き止まりになります。そこで、止める判定の直前に自分の情報を取り直す ことにしました。

// 止める判定の直前だけ、最新値を取りに行く
final latest = await refreshCurrentUser().onError((_, __) => cached);
final alreadyLinked = (latest ?? cached).partners.any((p) => p.id == targetId);

取得に失敗したときはキャッシュ値で判定を続け、最終的な可否は後段の書き込み(とサーバ側のルール)に委ねます。クライアントの事前チェックは UX のための早期リターンであって、正しさの担保ではない という切り分けです。

配列の同一性を、何で決めるか

連携相手の一覧は、識別子と表示名を持つマップの配列としてドキュメントに入れています。追記には Firestore の arrayUnion を使っていました。

arrayUnion の重複判定は 要素全体の一致 です。マップを入れる場合、識別子が同じでも表示名が違えば別要素として追加されます。つまり「同じ相手は 1 件」という不変条件は、arrayUnion では表現できていませんでした。

決めたのは、一覧の同一性は識別子で決める と宣言し、それを書き込み側で保証することです。自分のドキュメントは読めるので、読み込み → 識別子で正規化 → 書き戻し、の形にしました。

// 識別子で同一視して置換する。並び順は画面のタブ順に直結するので位置を保つ
final existing = mine.partners.toList();
final at = existing.indexWhere((e) => e.id == entry.id);
at >= 0 ? existing[at] = entry : existing.add(entry);

一方、相手側のドキュメントは読めません。連携が成立していない相手を読む権限は与えていないからです(連携前に相手の情報を読めてしまうほうが問題です)。そのため相手側は arrayUnion のまま残しました。読める側だけで正規化し、非対称を受け入れる という判断です。

トランザクションにしなかった理由も書いておきます。相手側の追記は transform(arrayUnion)が必須で、両側を 1 つのトランザクションに入れると、テストで使っているインメモリ実装では検証できなくなります。ここでは 2 つのドキュメントの原子性(片側だけ更新された状態を作らない)のほうが重要なので、バッチ書き込み+事前読み取りを選びました。代償は「同一アカウントの 2 端末が同時に別の相手を追加したときに後勝ちになる」ことですが、やり直しで復旧できます。

原子性と競合耐性のどちらを捨てるか を明示的に選ぶ、という話です。

集計は、既存のウィンドウを共有する

おとなのホームに、今週のようす(連続日数・達成した日・達成率・ごほうびの回数)を出す行を足しました。

ここで避けたかったのは、同じデータを二度取りに行く ことです。連続日数の計算はすでに直近 90 日ぶんの記録を取得しています。週間サマリーのために「今週ぶん」を別途取りに行くと、やることを 1 つ切り替えるたびにクエリが 2 本走ります。

そこで、90 日ウィンドウの取得を独立した provider に切り出し、連続日数と週間サマリーの両方がそれを参照する形にしました。当日ぶんだけはライブの購読から取り、過去日は共有ウィンドウから読みます。

[当日カードの購読] ─┬─→ 連続日数
                   ├─→ 週間サマリー
[90日ウィンドウ] ──┘   (当日に依存しないのでトグルで再取得されない)

ポイントは、共有するデータを「当日に依存しない範囲」で切ること です。当日ぶんを含めてしまうと、トグルのたびにウィンドウごと無効化されて意味がなくなります。キャッシュの境界は、更新頻度の境界に合わせます。

対応 OS を広げる判断は、ビルド最適化を変える判断でもある

この版で Android 10・11 を対応 OS に加えました。minSdk を下げる変更ですが、これは単なる宣言の書き換えではありません。

R8 は minSdk「SDK_INT は常にこの値以上」という前提 として使い、常に真になる分岐を畳んで else 側を削除します。ライブラリが内部に持っている「この API は SDK n 以上でだけ呼ぶ」というガードも対象です。minSdk を高く設定していると、そのガードごと消えたコードが生成されます。

// ライブラリ側にこう書かれていても
if (Build.VERSION.SDK_INT >= 31) { newApi() } else { legacy() }
// minSdk 31 でビルドすると newApi() の呼び出しだけが残りうる

つまり minSdk は動作対象の宣言であると同時に、最適化の入力 です。対応 OS の範囲を決めるときは、実機での確認をビルド構成ごとに行う必要がある、という前提が変わります。ここは覚えておく価値のある知識でした。

診断できる状態を、設計に含める

連携のように 1 つのユーザー操作に複数の書き込みがぶら下がる フローは、うまくいかなかったときに「どこまで進んだか」が分からないと切り分けができません。画面に出るのは最終結果だけだからです。

そこで、このフローには固定プレフィックスの進行ログを常設しました。方針は 3 つです。

  • フローの節目だけを出す。事前チェック・判定結果・書き込みの開始と結果・リトライの待機。全部出すと読めません
  • 識別子はそのまま出さない。突き合わせに足りる先頭数文字だけにします。ログは人に見せてもらう前提のものです
  • リリースビルドでも出す。実利用者の端末で起きることこそ知りたいので、開発ビルド限定にはしません

あわせて、例外を握りつぶすのをやめました。失敗はエラーコードを保持したまま上位へ渡し、画面のメッセージにもコードを添えます。「保存に失敗しました」だけが残る状態を作らない ということです。ログを後から足すのは、その事象が再現するのを待つのと同じです。観測できることを、最初から設計に含めます。

まとめ

1.1.2 で決めたことを、設計の言葉で並べ直すとこうなります。

  • 一度だけ取得する状態を持つなら、無効化は操作より寿命の長いスコープ に置く
  • ユーザーの操作を止める判定は、その場で最新値を取り直して から行う。事前チェックは UX のためであって正しさの担保ではない
  • 配列の同一性は 識別子で決めると宣言し、読める側で正規化する。読めない側との非対称は受け入れる
  • キャッシュの境界は 更新頻度の境界 に合わせる。当日ぶんと過去ぶんを混ぜない
  • minSdk最適化の入力 でもある。対応 OS を変える判断はビルドの前提を変える
  • 観測できることを設計に含める。ログは後から足すのではなく、最初から置く