アプリの更新内容をアプリ内で見せる — 独自APIで更新管理する設計
家族向けアプリ「ぽんっ!」(旧称:でいりんぐ)で、アプリの更新内容をアプリ内で見せる仕組みを入れました。バージョンが上がったときに更新内容のダイアログを出し、設定からは過去の履歴も見られます。更新内容は独自に用意した API で管理し、アプリ側はそれを取得して表示するだけ、という分担にしています。どう分担して、どこで迷ったかを残します。
背景: 更新内容の置き場所を1か所にしたい
アプリの更新内容は、ストアの「新機能」欄にも書きますが、それだけだと後から見返せません。かといってアプリ側にリリースノートを直接埋め込むと、文言を直すたびにアプリを再ビルド・再申請することになります。
そこで、更新内容の実体はアプリの外に置き、アプリはそれを取得して表示するだけにしました。文言の修正は配信側を直すだけで済み、アプリの再ビルドは不要です。この「実体は外、アプリは表示だけ」という分担が今回の要です。
なぜ Remote Config ではなく独自 API か
「アプリの外から出し分ける」だけなら Firebase Remote Config も候補でした。最終的に独自の更新管理 API にしたのは、次の理由からです。
- Remote Config は本来、フラグや設定値(数値・真偽・短い文字列)を出し分けるための仕組みです。リリースノートのような、見出しや箇条書きを含む長めの文章を持たせるには向きません。
- 更新内容は Markdown で書いて管理したいと考えました。プラットフォーム別のバージョンや過去の履歴を、素直な形で扱いたかったためです。設定値ストアにリッチな本文を押し込むと、構造も編集も無理が出ます。
- 同じ更新内容を別の場所でも使い回せるよう、単一ソースの「文章」として管理できるほうが都合がよいと判断しました。
- Remote Config の取得・反映(fetch → activate)やキャッシュの都合に、アプリ内での表示タイミングを縛られたくありませんでした。
要するに「設定値の出し分け」と「読み物の配信」は、必要な性質が違うという判断です。前者に最適化された仕組みへ後者を載せると、どこかで無理が出ます。
独自 API 側の分担
更新内容は独自の API で管理しています。アプリからはプラットフォームと現在のバージョンを渡し、該当する更新内容(バージョン・タイトル・本文)を受け取ります。本文は Markdown のまま返し、整形はアプリ側で行います(後述)。
配信側では、検索インデックスや一覧導線に載せる/載せない、公開前の下書きといった可視性の管理も行っていますが、本記事では実装の詳細には踏み込みません。要点は「実体は配信側の単一ソース、アプリは表示に徹する」という分担です。
アプリ側: 取得とキャッシュ
アプリは package_info_plus で現在のバージョンを取り、http で更新内容の JSON を取得します。成功レスポンスは SharedPreferences にプラットフォーム別のキーでキャッシュし、ネットワーク障害やタイムアウト時は直近のキャッシュにフォールバックします(識別子は説明用の仮名)。
Future<List<ReleaseNote>> fetch(String platform) async {
final prefs = await SharedPreferences.getInstance();
final cacheKey = cacheKeyFor(platform);
try {
final response = await client.get(endpointFor(platform)).timeout(timeout);
if (response.statusCode == 200) {
// 配信側は charset を明示しないため、日本語が化けないよう UTF-8 で明示デコードする。
final decodedBody = utf8.decode(response.bodyBytes);
await prefs.setString(cacheKey, decodedBody);
return parse(decodedBody);
}
} catch (_) {
// 障害時はキャッシュにフォールバックする。
}
final cached = prefs.getString(cacheKey);
if (cached != null) return parse(cached);
return const <ReleaseNote>[];
}
ハマったのは文字化けでした。配信側のレスポンスは charset を明示しないため、response.body に任せると日本語が化けます。response.bodyBytes を utf8.decode で明示的にデコードして回避しています。取得先は --dart-define で差し替えられるようにして、ローカルやステージングに向けて動作確認できるようにしました。
アプリ側: 表示ポリシー
表示の出し分けにはいくつか決めごとがあります。
- おとなロールのみ。更新時ダイアログはおとなホームの初回フレーム後に出し、こども向け画面には出しません。
- 更新時ダイアログは最新の該当バージョン1件だけ。
- 初回インストールでは出さない。
- 過去分は設定「アプリについて」の履歴一覧で全件見られる。
判定は、前回ダイアログを出したバージョンを SharedPreferences に持たせて組み立てています(識別子は仮名)。
final currentVersion = await ref.read(currentVersionProvider.future);
final lastShown = await ref.read(lastShownVersionProvider.future);
if (lastShown == null) {
// 初回インストールは記録するだけで出さない。
await ref.read(lastShownVersionProvider.notifier).markShown(currentVersion);
return;
}
if (lastShown == currentVersion) return;
final notes = await ref.read(releaseNotesProvider.future);
// 現バージョンに一致するノートがあるときだけ表示する。
初回インストールを除外するのは、lastShown == null を「初めて起動した」とみなし、ダイアログを出さずに現在バージョンを記録するだけにすることで実現しています。こうすると、新規ユーザーにいきなり更新内容が出ることはなく、次のアップデートから機能します。
取得失敗や該当ノート無しのときは何も記録しません。記録しないことで次回起動時に再試行され、ネットワーク不調でその回だけ出せなかったケースを取りこぼしません。ダイアログ表示は最後まで best-effort で、失敗してもホーム表示を妨げないよう握りつぶしています。
履歴一覧と更新時ダイアログは、同じ取得結果を共有します。取得を 1 つの Provider にまとめ、双方がそれを watch するだけにしました。
/// 現プラットフォームのリリースノート一覧(新しい順)。
/// 履歴一覧・更新時ダイアログの双方がこの1回の取得を共有する。
@riverpod
Future<List<ReleaseNote>> releaseNotes(Ref ref) {
final platform = ref.watch(platformProvider);
return ref.watch(repositoryProvider).fetch(platform);
}
軽量 Markdown 描画で外部依存を避ける
本文は Markdown のまま届きますが、リリースノートに必要な表現は見出しと箇条書きくらいです。フル機能の Markdown パッケージを1つ増やすほどではないと判断し、見出し(行頭 #)と箇条書き(行頭 - / * / ・)だけを解釈する軽量描画にしました。それ以外の行はそのままテキストとして出します。
for (final rawLine in body.split('\n')) {
final line = rawLine.trim();
if (line.startsWith('#')) {
// 見出しとして描画
}
final bullet = RegExp(r'^([-*・])\s*').firstMatch(line);
if (bullet != null) {
// 箇条書きとして描画
}
// それ以外は本文テキスト
}
配信側はフルの Markdown を HTML 化することもできますが、アプリには HTML ではなく Markdown 原文を返し、アプリ側で必要な範囲だけ描画する、という役割分担にしています。
履歴一覧で日付を出さない
当初は履歴カードにバージョンと日付を並べていましたが、日付表示は外しました。理由は次のとおりです。
- 並び順(新しい順)は配信側が日付で降順に並べて返すので、アプリで順序を判断するために日付を見せる必要がない。
- 配信の JSON はバージョン・タイトル・本文だけを返す形にしており、日付はカードに出すための情報として持っていない。
順序付けに使う情報(配信側の日付)と、ユーザーに見せる情報は別物です。日付はデータとして並び順のために使い、UI には出さない、という切り分けにしました。カードにはバージョンとタイトル、本文だけが並びます。
まとめ
- 更新内容の実体はアプリの外(自前の更新管理 API)に単一ソースで置き、アプリは表示だけを担うと、文言修正でアプリを再ビルドしなくて済む。
- 「設定値の出し分け(Remote Config)」と「読み物の配信」は必要な性質が違う。リッチな本文・履歴・プラットフォーム別バージョンを扱うなら、Markdown を単一ソースにする設計が素直。
- 更新時ダイアログは「おとなのみ・最新1件・初回除外・失敗時は記録しない」で組むと、出しすぎず取りこぼしもない。
- 描画に必要な表現がわずかなら、フル機能の依存を足さず軽量描画で足りることもある。