開発Tips

アプリの更新内容をアプリ内で見せる — 独自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.bodyBytesutf8.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件・初回除外・失敗時は記録しない」で組むと、出しすぎず取りこぼしもない。
  • 描画に必要な表現がわずかなら、フル機能の依存を足さず軽量描画で足りることもある。