開発Tips

Flutter アプリのホーム画面ウィジェットをどう設計したか

こども向けの「やること」アプリに、ホーム画面ウィジェットを追加しました。アプリを開かなくても、今日のやることがどこまで進んだか、育てているキャラクターがどんなようすかが分かります。

ウィジェットは「アプリの画面をそのまま小さくしたもの」ではありません。アプリのプロセスが動いていない時間のほうが長いという前提が、設計のほとんどを決めます。この記事では、Flutter と各 OS のネイティブ実装のあいだで責務をどう切ったかを4つの判断に分けて書きます。

前提: ウィジェットは別プロセスで描かれる

Android の App Widget も iOS の WidgetKit も、アプリ本体とは別のプロセスが描画します。Flutter のウィジェットツリーはそこには存在しません。つまり、

  • 描画のたびにアプリのコードが走るわけではない
  • Flutter 側の状態(Provider の値)を直接読むことはできない
  • 両者が共有できるのは、アプリと拡張で共有するストレージだけ

したがって設計の問いは一貫して「何を、いつ、どの形で共有ストレージに置くか」になります。

設計1: 何を映すかを、アプリの画面状態から切り離す

このアプリは、保護者がひとつの端末で複数の子どもぶんのタブを切り替えて使えます。素直に考えると「いま開いているタブの内容をウィジェットに出す」となりますが、これは採りませんでした。

ウィジェットが映す対象は、OS の設定画面で利用者が選ぶものにしています。アプリ側は「選択肢の一覧」を共有ストレージへ書き出すだけで、どれを映すかには関与しません。

なぜかというと、開いているタブに連動させると次のことが起きるためです。

  • 保護者が自分用のタブを開いたままアプリを閉じると、ウィジェットの対象が消える
  • 子どもが2人いる家庭で、2つのウィジェットを並べて置くことができない
  • 「ウィジェットの中身を変えたいからアプリを開いてタブを切り替える」という、目的と逆の操作が必要になる

ウィジェットの目的は「アプリを開かずに状況が分かること」なので、アプリの画面状態に依存させた時点で目的を損ないます。対象選択は OS が持つ設定 UI に委ね、アプリは選択肢の提供者に徹する、という切り分けにしました。

この判断の副作用として、アプリは「いま表示していない対象」の内容も書き出す必要があります。保護者が自分のタブを開いていても、連携している子ども全員ぶんのスナップショットを更新します。表示とデータ更新を別の責務として扱う、という整理です。

設計2: 表示する文言はアプリ側で組み立てる

ウィジェットには「あと3こ!」「ぜんぶできた!」のような、状態に応じた短い文が出ます。この文をどちら側で作るかは、多言語対応の単一情報源をどこに置くかという問題でした。

採ったのは Flutter 側で完成した文字列を作り、共有ストレージへ文字列として置く方式です。ネイティブ側は受け取った文字列をそのまま描画します。

// スナップショット組み立て(擬似コード)
final speech = decideSpeech(
  doneCount: done,
  totalCount: total,
  // …状態に応じた分岐
);

return WidgetSnapshot(
  doneCount: done,
  totalCount: total,
  // 種別ではなく「完成した文言」を載せる
  message: localizedText(l10n, speech),
);

ネイティブ側に文言を持たせると、翻訳リソースが Flutter の arb ファイルと各 OS のリソースに二重化します。言語を追加するたびに3箇所を直すことになり、片方だけ更新されて表示が混ざる事故が起きやすい。翻訳の単一情報源を arb に保つほうが、ネイティブ側が少し「馬鹿」になることよりも価値が高いと判断しました。

例外はひとつだけあります。共有ストレージが空のときに出す案内文です。これは定義上アプリから渡せないので、ネイティブ側のリソースに持たせています。「データが無い状態の文言だけはネイティブが持つ」という、例外の範囲を明示できる切り方にしました。

ロケール解決も、画面の BuildContext を経由しない形にしています。通知受信をきっかけに更新するとき、UI は生きていないためです。

// 端末のロケールから直接ロードする(擬似コード)
Future<AppLocalizations> loadWidgetLocalizations() {
  final locale = PlatformDispatcher.instance.locale;
  final supported = AppLocalizations.supportedLocales
      .any((l) => l.languageCode == locale.languageCode);
  return AppLocalizations.delegate.load(
    supported ? locale : const Locale('en'),
  );
}

設計3: キャラクターの絵も、同じ考え方で渡す

ウィジェットには育成中のキャラクターが表示されます。キャラクターの見た目は「種類 × 成長段階 × 色違い」の組み合わせで決まり、図鑑から好きな子を選んで固定することもできます。

この組み合わせ解決をネイティブ側でやると、アセットの命名規則と進化のルールを両 OS に複製することになります。そこで、Flutter 側で画面外に描画して PNG に書き出し、そのファイルパスを共有ストレージへ置く方式にしました。ネイティブ側は渡されたパスの画像を表示するだけです。

// 見た目が変わったときだけ描き出す(擬似コード)
final look = (species: species, stage: stage, variant: variant);
if (renderedLook[targetId] == look) return;   // 同じなら描き直さない
renderedLook[targetId] = look;

// 画面に出ていないキャラは画像キャッシュに無いので、先に読み込む
await precacheImage(image.imageProvider, context);
final path = await renderToPng(image, key: renderKeyFor(targetId));

レンダリングは画面外とはいえ1フレーム分の描画を走らせる処理なので、見た目が変わったときだけ実行します。やることをひとつ押すたびに走らせる必要はありません。変化するのは図鑑で選び直したときと進化したときだけです。

書き出し先のパスは対象ごとに固定にしています。ファイルが増え続けないかわり、「パスが変わらないのに中身が変わる」状況が生じるので、更新の通知はパスの変化ではなく描画したことそのものを起点にしています。パスの比較で更新要否を判断すると、キャラクターを選び直しても通知が飛びません。同一性の判定は、識別子ではなく実際に変化した対象に対して行う、という一般則の一例です。

設計4: 前面と背面で更新経路を分ける

ウィジェットの更新経路は2つあります。

状況 経路
アプリが前面にある Flutter 側の状態変化を購読し、変わるたびに書き出す
アプリが動いていない 通知の受信をきっかけに、ネイティブ側が更新する

前面にあるあいだは、状態管理(Riverpod)が変化を検知して自動的に書き出します。やることにスタンプを押せば、ホームに戻った時点でウィジェットは新しくなっています。

一方、子どもが自分の端末でやることを終えたとき、保護者の端末のアプリは動いていません。ここはサーバからの通知をきっかけにネイティブ側で更新します。アプリが生きているあいだの経路と、死んでいるあいだの経路は別物として設計し、共有ストレージという同じ置き場所を介して合流させています。

設計5: 揃っていない情報を待たない

ウィジェットの仕事は「見せること」です。ここから導かれる原則があります。表示に必要な情報の一部が取得できなくても、取れたぶんで出す。

具体的には、表示内容を「必ず要るもの」と「あると良いもの」に分けました。

  • 必ず要るもの: どのキャラクターを映すか、その日の進み具合
  • あると良いもの: 保護者向けの「今日は栄養をあげられる」といった案内

後者は、参照先の読み取りが失敗してもその項目だけ諦めて残りを表示します。全体を巻き添えにしません。非同期の取得を直列に await でつなぐと、ひとつでも解決しない依存があった瞬間に画面全体が出なくなります。ウィジェットの場合、それは中身の描かれないカードがホーム画面に残ることを意味します。利用者から見れば故障と区別がつきません。

// 任意情報は「取れていれば使う」形で読む(擬似コード)
final optional = ref.watch(optionalInfoProvider);   // await しない
final canShowHint = optional.asData?.value?.isAvailable ?? false;

さらに、対象の種類によってはそもそも参照してはいけないデータもあります。このアプリでは、連携した子どものタブと、保護者が端末内に作ったタブが同じ一覧に並びます。前者にしか存在しない情報を後者に対して読みにいくと、権限で拒否されます。同じ一覧に並ぶものが同じ性質を持つとは限らないので、読みにいく前に種別で分岐するようにしました。

「読めないかもしれないものは、読めなかったときの表示を先に決めてから参照する」。ウィジェットのように利用者が自分でリトライできない場所では、この順序が効きます。

まとめ

ホーム画面ウィジェットの設計は、次の4つの問いに答える作業でした。

  1. 何を映すかを誰が決めるか — アプリの画面状態ではなく、OS の設定に委ねる
  2. 文言と画像をどちらで作るか — 単一情報源を保てる側(アプリ)で作り、完成物を渡す
  3. いつ書き出すか — 前面は状態変化、背面は通知。置き場所で合流させる
  4. 情報が欠けたらどうするか — 必須と任意を分け、任意は欠けても表示を止めない

共通しているのは、ネイティブ側を薄く保つという方針です。ロジックを両 OS に複製すると、機能追加のたびに3箇所(Flutter・Android・iOS)で同じ判断を書くことになり、片方だけ古くなります。描画に徹させ、判断はすべてアプリ側に寄せる。ウィジェットに限らず、プラットフォーム境界をまたぐ実装ではこの方針が効きます。