Flutter開発メモ

調べたことを記録しておきます。

FlutterはCSSのような「上書き」ではなく「制約」でレイアウトが決まる

Flutterを書き始めた頃によく混乱するのが、

「テーマで設定したのにサイズが変わらない」 「ボタンの幅が勝手に広がる」

という現象です。

これはFlutterがCSSとは違う考え方でレイアウトを行っているためです。

CSSは「どの指定が勝つか」

例えばCSSでは次のようなコードがあります。

button {
  width: 100px;
}

.primary-button {
  width: 200px;
}

この場合、

  • 詳細度(Specificity)
  • 記述順
  • !important

などによって最終的なサイズが決まります。

つまりCSSは、

「複数の指定のうち、どれが勝つか」

という世界です。

Flutterは「親が制約を渡す」

Flutterではレイアウトの流れが異なります。

親
↓
制約を渡す
↓
子
↓
その範囲内でサイズを決める

例えば次のコードを考えます。

FilledButton(
  style: FilledButton.styleFrom(
    minimumSize: const Size(0, 48),
  ),
  onPressed: () {},
  child: const Text('保存'),
)

これは、

「高さは最低48欲しい」

というボタンの希望です。

しかし親が次のような制約を与えると、

SizedBox(
  width: 300,
  child: FilledButton(
    onPressed: () {},
    child: const Text('保存'),
  ),
)

ボタンは300px幅で表示されます。

これは親が、

「300pxで描いてね」

と指示しているためです。

Themeより親Widgetの方が強い

Flutterでは次の順番で考えると理解しやすいです。

Theme
↓
Widget自身の設定
↓
親Widgetの制約

例えば、

filledButtonTheme: FilledButtonThemeData(
  style: FilledButton.styleFrom(
    minimumSize: const Size(0, 48),
  ),
)

はボタンのデフォルト設定です。

さらに個別のボタンで、

FilledButton(
  style: FilledButton.styleFrom(
    minimumSize: const Size(0, 60),
  ),
)

と書けば60pxが採用されます。

しかし親が、

SizedBox(
  width: 300,
  child: FilledButton(...),
)

と指定していれば、ボタンは300px幅になります。

実際に遭遇した例

例えば次のコードです。

bottomNavigationBar: SafeArea(
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: FilledButton.icon(
      onPressed: () {},
      icon: const Icon(Icons.add),
      label: const Text('カードを追加'),
    ),
  ),
),

テーマでは、

minimumSize: const Size(0, 48)

としているのに、ボタンが横いっぱいに広がります。

最初はテーマ設定がおかしいように見えます。

しかし実際には、

bottomNavigationBar

が子Widgetに対して画面幅いっぱいの制約を渡しているためです。

つまり、

「テーマが負けた」

のではなく、

「親の制約の中でボタンがレイアウトされた」

というのが正しい理解になります。

Flutterでレイアウトが崩れたらまず親を見る

Flutterでサイズがおかしいと感じたら、

まず親Widgetを疑う癖をつけると原因が見つけやすくなります。

よく犯人になるのは次のようなWidgetです。

  • SizedBox
  • Expanded
  • Flexible
  • Container(width: ...)
  • ScaffoldのbottomNavigationBar
  • ScaffoldのAppBar

FlutterはCSSのような「スタイルの勝敗」よりも、

「親から渡される制約」

を理解することが重要です。

この考え方に慣れると、Flutterのレイアウトはかなり読みやすくなります。

【Flutter】XcodeのRunnerって何者?

FlutterでiOSアプリを開発していると、
普段はAndroid StudioVS Codeで作業しているのに、

  • Xcodeを開いて設定」
  • 「RunnerのInfoで変更」

みたいなことが必要になって戸惑うことがある。

今回はこの Runnerとは何か
Xcodeで設定を変えるとFlutter側では何が起きるのか を整理してみる。


Runnerとは何か?

結論からいうと、

  • Runner = Flutterが自動生成したiOS用のXcodeプロジェクト
  • 「Runner」はFlutterが勝手につけた名前
  • iOSアプリとしてビルドされる本体
  • 署名やBundle ID、Capabilitiesなどを設定する場所

Flutterでプロジェクトを作ると、iOS配下にはこんな構成ができる。

  • ios/
  • ios/Runner/
  • ios/Runner.xcodeproj
  • ios/Runner.xcworkspace

この「Runner」がXcodeで開く対象になる。

App Storeに表示される名前とは別で、あくまで内部的なiOS側の器の名前。


なぜ全部Runnerという名前なのか?

Flutterのテンプレートでは、

という名前で固定生成される。

なのでFlutterで複数アプリを作っていても、
iOS側のプロジェクト名は全部Runnerになるのが普通。

Swiftネイティブで開発する場合は、
Xcodeで新規作成時に自分でプロジェクト名を決めるのでRunnerは出てこない。


Xcodeで設定を変えると何が起きる?

例えばXcode

  • PROJECT > Runner > Info > Localizations に Japanese を追加

みたいな操作をすると、
裏では ios/ 配下のファイルが更新される。

  • ios/Runner/Info.plist
  • ios/Runner/ja.lproj/InfoPlist.strings
  • ios/Runner/ja.lproj/LaunchScreen.strings
  • ios/Runner.xcodeproj/project.pbxproj

Android StudioでFlutterプロジェクトを開いていると、
これらのファイルがそのままツリーに反映されて見える。

つまり、

という役割分担。


Git管理はどうする?

Xcodeで変更されたios配下のファイルは基本すべてGit管理対象。

コミットしてよいもの。

  • ios/Runner/Info.plist
  • ios/Runner/*.entitlements
  • ios/Runner/*.lproj/**
  • ios/Runner.xcodeproj/project.pbxproj
  • ios/Podfile(変更があれば)

逆にGit管理しないもの。

  • ios/Pods/
  • ios/Flutter/ephemeral/
  • DerivedData
  • *.xcuserdata

感覚的には、

みたいな位置づけ。


FlutterなのにXcode必須なのはなぜ?

FlutterはUIやロジックをDartで書けるが、

  • 証明書設定
  • Signing
  • Bundle ID
  • Push通知
  • App Groups
  • Associated Domains
  • 権限説明文
  • Localizations

こういったApple固有の設定は
どうしてもXcodeで触る必要がある。

Flutterが吸収しきれない「iOSネイティブの領域」。


Flutter iOSはSwiftに変換されるの?

ここも勘違いされやすいポイント。

Flutter iOSでは、

  • Dart → Swift に変換されるわけではない
  • DartはAOTコンパイルされてARMネイティブコードになる
  • Flutter Engine(C++)と一緒にアプリに組み込まれる

構成イメージ。

  • Dartのネイティブバイナリ
  • Flutter Engine
  • Runner(Swiftの薄いブリッジ)

Runnerはあくまで起動やOS連携の接着剤。


まとめ

  • FlutterのiOS側プロジェクトはRunnerという名前
  • XcodeiOS固有設定を触る場所
  • 変更はios/配下のファイルに反映される
  • Git管理対象になる
  • Android StudioはFlutter開発用IDEであってiOS設定GUIではない

FlutterでiOSアプリを出していると、

「またXcodeか……」

となりがちだけど、
Runnerは単なる殻だと割り切ると気が楽になる。

iOS対応が増えてきたら、この構造を理解しておくと
トラブル対応がかなりスムーズになるはず。

【Flutter】購入状態をDBに保持する設計 ― iOS / Android サブスクを安全に扱うためのデータモデル ―

サブスクリプション実装で、必ず出てくる悩みがあります。

DBには、購入状態として
何を・どこまで・どう持つべきか?

iOSAndroidは思想もAPIも違います。
それを無理に揃えようとすると、設計はすぐに破綻します。

この記事では、

  • DBに「持つべきデータ」
  • なぜその項目が必要なのか
  • iOS / Android の違いをどう吸収するか

を、実務目線の最小構成で整理します。


結論を先に

DBには、次の考え方でデータを持つのが正解です。

  • DBは「最終結果キャッシュ(サーバーが確定した結果)」を置く場所
  • ストア(Apple / Google)が唯一の真実
  • クライアントはDBの結果を信頼する
  • ID差分(iOS / Android)はサーバーで吸収する

👉 DBに“判断材料”を持たせない
(持たせるなら「監査ログ」として別テーブルに分ける)


DBに持つべき最小データ構造

まず結論の形です。
RDBでもNoSQLでも成立するように、論理モデルとして書きます)

entitlements(購買権限)テーブル / ドキュメント

// entitlements/{userId} または entitlements(user_id PK)
{
  userId: string,                 // 主キー
  isPro: boolean,                 // 最終的なPro判定
  platform: "ios" | "android",
  productId: string,              // pro_monthly など

  state: "active" | "expired" | "pending",
  expiresAt: Timestamp | null,    // 有効期限(不明なら null)

  // --- 購入識別子(OS別)---
  iosOriginalTransactionId?: string,
  androidPurchaseToken?: string,

  lastVerifiedAt: Timestamp,      // ストア検証した最終時刻
  updatedAt: Timestamp            // DB更新時刻
}

この構造で、両OSを無理なく扱えます


各フィールドの理由

isPro(最重要)

  • UIが見るのは基本これだけ
  • true / false の 確定結果
  • クライアントで計算しない

👉 UIと課金ロジックを分離できる


platform

  • iOS / Android で扱いが違う
  • サーバー内の分岐に使用

👉 後からWebや別プラットフォームが増えても拡張しやすい


productId

  • 同一アプリで複数サブスクを扱う場合に必須
  • データ移行や障害時の調査にも効く

expiresAt

  • 有効期限の表示
  • キャッシュ有効性の判断
  • バッチ再検証の対象抽出

👉 「いつまで有効か」を明示的に持つ


state(active / expired / pending)

ここが実務ではかなり重要です。

  • active

    • 確定して有効
  • expired

    • 確定して無効
  • pending

    • 即時確定できなかった状態(保留)

なぜ pending が必要か

  • iOS:購入直後に反映されないことがある
  • Android:更新反映にラグが出ることがある

👉 「分からない」を正しく表現するため


購入識別子(OS別)

iOS: originalTransactionId

  • サブスク契約そのもののID
  • 一生変わらない
  • 冪等登録が可能

Android: purchaseToken

  • 更新イベント単位のID
  • 更新ごとに変わる
  • 常に「最新token」を保持する設計が自然

👉 OSごとの思想差をそのまま反映する


lastVerifiedAt

  • 最後にストア照会した時刻
  • 再検証タイミング制御(例:12時間以上経ったら検証)
  • バッチ対象抽出

👉 無駄な検証を減らすための軸


DBに「持たない」ほうがいいもの

これは重要なので明示します。

  • クライアントが送った isPro 判定
  • 生のレシート全文(本体)
  • トランザクション履歴の配列(entitlementsに直積み)
  • Apple ID / Google Account に関する情報

理由はシンプルです。

  • 改ざん可能
  • 容量・コストが増える
  • 「どれが真実か」がブレる

👉 DBは結果置き場でいい


ただし:監査ログは別で持つと強い

「判断材料を持たない」が原則ですが、 調査・障害対応・返金トラブルに備えるなら、別枠でログを持つのは有益です。

purchases(購入イベントログ)テーブル / コレクション(任意)

{
  userId: string,
  platform: "ios" | "android",
  productId: string,

  // iOSなら transactionId / originalTransactionId
  // Androidなら purchaseToken / orderId
  purchaseKey: string,

  verifiedAt: Timestamp,
  verificationResult: {
    isPro: boolean,
    expiresAt: Timestamp | null,
    rawStatus: string
  }
}
  • entitlements:現在の“結論”
  • purchases:過去の“履歴(監査ログ)”

👉 役割を分けると運用が楽になります。


iOS / Android の違いをどう吸収するか

根本的な違い

観点 iOS Android
中心概念 契約 更新イベント
不変ID originalTransactionId なし
保存すべきID otid 最新token
状態反映 非同期あり 非同期あり

ポイントは、

  • DBで無理に統一しない
  • ただし 同じ“結論モデル(isPro/expiresAt/state)” に正規化する

という二段構えです。


サーバーの役割(DBを正しく保つため)

DBに正しい状態を置くために、サーバーでは次を行います。

  • 購入識別子の冪等登録
  • 即時ストア検証(ただし pending の可能性)
  • pending のときだけ短期リトライ(最大30秒など)
  • 最終確定は日次バッチ等で再検証しDB更新

👉 DBは常に「サーバーが確定した結果」だけを持つ


なぜこの設計が安全なのか

  • クライアント主導にならない
  • 即時反映に依存しない
  • OS差分をUIに漏らさない
  • 後から仕様変更に耐える

特に、

  • iOSの originalTransactionId(契約ID)
  • Androidの purchaseToken(イベント鍵)

という 非対称なID を DBで無理に揃えないのが最大のポイントです。


まとめ

DBに持つべきなのは、

  • 判断材料ではなく
  • 判断済みの結果

です。

  • ストアが真実
  • サーバーが通訳
  • DBが最終キャッシュ
  • クライアントは表示係

この役割分担を守れば、 iOS / Android 両対応のサブスク実装は 驚くほどシンプルで壊れにくくなります。

【Flutter】Androidサブスク実装を根本から理解する ― purchaseToken に振り回されないために ―

Androidサブスクリプション実装は、 iOSの感覚で考えると必ず混乱します。

理由はシンプルで、
Androidのサブスクは「契約」ではなく「更新イベント」を中心に設計されているからです。

この記事では、

  • なぜ Android には originalTransactionId が存在しないのか
  • purchaseToken とは何なのか
  • なぜサーバー側で状態管理が必須なのか

を、仕組みの根本から整理します。


結論を先に

Androidのサブスクは、こう理解すると腑に落ちます。

  • サブスク = 更新の連なり
  • 更新ごとに識別子(purchaseToken)が変わる
  • 契約を表す「不変ID」は存在しない
  • 状態管理は開発者側の責務

iOSのように
「1契約 = 1ID」
という世界ではありません。


Google Play が管理している世界

Androidでも、購入の真実は Google Play 側にあります。

ただし管理の粒度が iOS と違います。

Google Play が管理しているのは、

  • Google アカウント(※不可視)
  • アプリ(packageName)
  • 商品(productId)
  • 各購入・更新イベント
  • そのイベントの状態(有効 / 解約 / 期限切れ / 返金)

👉 「契約」という塊はIDとして外に出てこない


Androidサブスクの基本構造

Androidでは、サブスクは次のように扱われます。

更新イベント1(token A)
更新イベント2(token B)
更新イベント3(token C)
  • 更新のたびに purchaseToken が変わる
  • どれが「同一サブスクか」は
    • productId
    • packageName
    • 過去の検証結果 を元に 開発者が判断する

purchaseToken の正体

purchaseToken とは、

  • 購入または更新イベントを一意に表すトーク
  • Google Play が発行
  • 更新ごとに新しくなる
  • サーバー検証の唯一の入口

というものです。

重要なのは、

  • purchaseToken は「契約ID」ではない
  • 「状態を問い合わせるための鍵」

だという点です。


Androidに originalTransactionId が存在しない理由

これは仕様上の欠落ではありません。

Google Play の思想は、

  • 契約という概念を外に出さない
  • その時点の購入イベントを検証せよ
  • 継続判定は開発者が行え

というものです。

そのため、

  • iOSのような「原点ID」
  • 一生変わらないサブスク識別子

そもそも提供されていません


アプリにできること/できないこと

できないこと

  • Google アカウントを見る
  • 契約IDを取得する
  • 1つのIDだけでサブスクを追跡する

できること

  • 購入を開始する
  • purchaseToken を受け取る
  • サーバーで token を検証する
  • 検証結果を保存して状態を構築する

👉 状態管理はアプリ/サーバー側の仕事


なぜサーバー実装が必須なのか

Androidでは特に、
クライアント完結は危険です。

理由:

  • purchaseToken は改ざん可能な場所に届く
  • 過去の token が無効になっている可能性がある
  • 返金・解約は非同期で反映される

だから設計はこうなります。

アプリ
↓ purchaseToken
サーバー
↓ Google Play 検証
状態判定(isPro / expiresAt)

Firestore 等に保存するのは、
「検証済みの最終結果」


iOSとの決定的な違い

観点 iOS Android
中心概念 契約 更新イベント
不変ID originalTransactionId なし
取得単位 契約 purchaseToken
状態管理 Apple主導 開発者主導

👉 AndroidiOS的に考えると破綻します。


実装で押さえるべきポイント

最低限、以下を理解していれば迷いません。

  • purchaseToken は一時的な識別子
  • token は更新ごとに変わる
  • 同一tokenは冪等に扱う
  • 状態はサーバーで確定する
  • クライアントは結果を表示するだけ

まとめ

Androidサブスクリプションは、

  • 契約管理APIではない
  • 不変IDも提供されない

「購入イベント検証APIです。

その前提に立てば、

  • なぜ token を保存するのか
  • なぜ毎回検証が必要なのか
  • なぜサーバーが太くなるのか

すべて自然に理解できます。

iOSAndroidは、
似ているUIの裏で、まったく別の思想で動いている

それを前提に設計することが、
サブスク実装で迷わない最大のコツです。

【Flutter】iOSサブスク実装を根本から理解する  ― originalTransactionId に振り回されないために ―

iOSサブスクリプション実装は、
APIの使い方を追いかけるだけだと、必ずどこかで混乱します。

それは、Appleのサブスクが「契約」という思想で設計されているからです。

この記事では、

  • なぜ originalTransactionId という概念があるのか
  • なぜ取得方法が限定されているのか
  • なぜ restore やサーバー検証が必須なのか

を、仕組みの根本から整理します。


結論を先に

iOSのサブスクはこう考えると一気に理解が進みます。

  • サブスク = 契約
  • 契約は Apple が一元管理する
  • アプリは「契約の結果」を受け取るだけ
  • originalTransactionId は「契約そのもののID」

この前提を押さえないまま実装に入ると、
「なぜ取得できないのか」「なぜ不便なのか」で迷子になります。


Appleが管理している世界

iOSサブスクの主役は、アプリではありません。

Appleはサーバー側で、次の情報をすべて管理しています。

  • Apple ID(※開発者からは見えない)
  • アプリ(Bundle ID)
  • サブスク商品(productId)
  • サブスク契約
  • 契約の状態(有効 / 解約 / 返金 / 期限切れ)

アプリや開発者のサーバーは、
この台帳を「参照させてもらう立場」にすぎません。


サブスクは「購入の集合」ではない

多くの混乱は、ここから生まれます。

iOSでは、

  • サブスク = 購入が毎月繰り返されるもの

ではありません。

正しくは、

  • サブスク = 契約
  • 購入や更新は、その契約に紐づくイベント

という考え方です。


originalTransactionId の正体

originalTransactionId は、

  • 最初にサブスク契約が成立したとき
  • Appleが発行する
  • 契約そのものを表すID

です。

特徴は次の通り。

  • 契約が続く限り一生変わらない
  • 更新・復元・再インストール後も同じ
  • アプリが生成するIDではない
  • 検索キーとして使うことはできない

つまり originalTransactionId は、

「このサブスク契約の背骨」

のような存在です。


アプリにできること/できないこと

できないこと

  • Apple ID を取得する
  • Apple ID × アプリ で契約を検索する
  • originalTransactionId を取りに行く
  • サブスク状態を自己判断する

できること

  • 購入を開始する
  • Appleが返してきたトランザクションを受け取る
  • restore(購入の復元)を要求する
  • 受け取った情報をサーバーで検証する

この制限は「不親切」ではなく、
契約管理をAppleに集約するための設計です。


なぜ restore が必要なのか

restore(購入の復元)は、特別な仕組みです。

Appleはこう考えています。

  • 端末は壊れる
  • アプリは削除される
  • ローカルデータは信用できない

だから、

  • 契約の真実は常に App Store にある
  • 必要なら、もう一度全部照合すればいい

restore とは、

「このApple IDで、このアプリに紐づく契約を
Apple側で洗い出して返してほしい」

という再同期要求です。


レシート検証とサーバーの役割

クライアント(アプリ)は改ざんできます。

そのため、サブスク判定の最終責任は
Appleのサーバー情報にあります。

基本構造は次の通り。

  • アプリがトランザクションを受け取る
  • 自分のサーバーに送る
  • サーバーが Apple に検証を依頼する
  • 正しい契約状態を取得する

サーバーは「判断者」ではなく、
Appleとアプリの間の通訳です。


Androidと混ぜると壊れる理由

AndroidiOSは思想がまったく違います。

iOSAndroid的に、

  • 毎回IDが変わる前提
  • クライアント主導で状態管理

として考えると、必ず破綻します。


最低限、これだけ理解すればOK

iOSサブスク実装で迷わないための要点は以下です。

  • サブスクは「契約」である
  • 契約は Apple が管理する
  • originalTransactionId は契約ID
  • アプリは検索できない
  • 取得できるのは「結果」だけ
  • restore は再同期
  • 正解はサーバー検証

まとめ

iOSサブスクリプションは、

  • 状態管理APIではない
  • 購入履歴APIでもない

「契約結果通知システム」です。

ここを理解すると、

  • なぜ originalTransactionId があるのか
  • なぜ取得方法が限定されているのか
  • なぜサーバー検証が前提なのか

すべてが自然につながります。

実装に入る前に、
まずはこの思想をしっかり頭に入れておくことが、
一番の近道です。

【Flutter】アプリ内課金の“真実”を判定する仕組み:Cloud Functions × ストア(Google / Apple)

アプリ内課金の本当の心臓部は、
「Cloud Functions がストアに購入トークンを問い合わせて、本物かどうか検証する」
という工程です。

これが理解できると、アプリ内課金のシステム全体が一気にクリアになります。

この記事では、
アプリ → Cloud Functions の続きとして、
Cloud Functions ↔ ストア(Google / Apple の関係を詳しく解説します。


Cloud Functions の役割:

「購入トークンの真実をストアに聞きに行くサーバー」

アプリがストアから受け取るものは「purchaseToken(購入の証拠)」です。

しかし、それが

  • 本物の購入か?
  • サブスクは今も有効か?
  • キャンセルされていないか?
  • 返金されていないか?

これらは アプリだけでは判断できません

そこで登場するのが Cloud Functions。

  • アプリからトークンを受け取り
  • Google / Apple の公式APIを呼び
  • その購入が本物かどうか検証し
  • Firestore に本当の Pro / Free 状態を保存し
  • アプリに返す

という セキュリティの要 になっています。


購入検証の全体フロー

購入直後、Cloud Functions では次の流れが起きています。

  1. アプリ → Cloud Functions に token が送られる
  2. Cloud Functions が Google / AppleAPI で問い合わせる
  3. ストアが「この token は本物です」「期限切れです」などと返す
  4. Cloud Functions が entitlements を Firestore に保存する
  5. アプリがその entitlements を読み取り、Pro UI を表示する

こうして、
“Pro 判定の正解”をアプリではなくサーバーが持つ構造になります。


Google Play での検証(purchaseToken)

Google Play のサブスク検証は REST API を使います。

呼び出すAPI

GET [https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptions/{productId}/tokens/{purchaseToken}](https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptions/{productId}/tokens/{purchaseToken})

Cloud Functions は以下を指定してストアに問い合わせます。

  • packageName(アプリID)
  • productId(商品ID)
  • purchaseToken(購入トークン)

ストアから返ってくる情報(例)

{
  "startTimeMillis": "1700000000000",
  "expiryTimeMillis": "1702600000000",
  "autoRenewing": true,
  "cancelReason": 0,
  "paymentState": 1,
  "orderId": "GPA.1234-5678-9012-34567"
}

これにより以下がすべて判明します。

  • 本物の購入かどうか
  • 有効期限
  • 自動更新のON/OFF
  • キャンセル済みか
  • 返金されたか
  • 無料期間かどうか

つまり purchaseToken だけで購入に関する情報がすべてわかるということです。


Apple での検証(transactionId / signedTransactionInfo)

Appleも同様に REST API を使います。

GET https://api.storekit.apple.com/inApps/v1/transactions/{transactionId}

ただし AppleJWT署名ヘッダが必須 で、Google より厳格です。

ストアから返る情報

  • productId
  • bundleId(アプリID)
  • purchaseDate
  • expiresDate(有効期限)
  • originalTransactionId
  • 返金・取り消しの情報(revocation)
  • sandbox/production の区別

Google 同様、購入のすべてが返ってきます。

Cloud Functions はこの結果を使って:

  • 有効期限内→ Pro
  • 期限切れ → Free
  • 返金 → 即 Free

と判定します。


なぜ Firestore に entitlements を保存するのか?

Cloud Functions が検証した結果は Firestore に保存されます。

例:

entitlements_app/{uid}:
  plan: "pro"
  validUntil: "2025-01-31T00:00:00Z"
  source: "google_play"
  lastPurchaseToken: "xxxxxx"

理由は3つ:

  • アプリだけで状態を持つと、不正や改造に弱い
  • アプリを再インストールすると状態が消える
  • 複数端末で Pro 状態を同期できない

Firestore に書いておけば、

  • iPhone でも Android でも
  • 新規インストールでも
  • 別端末でも

常に 正しい Pro 状態が再現できます。


Cloud Functions はどんなタイミングでストアと通信する?

Cloud Functions ↔ ストアの通信は、次のような場面で発生します。

  • 購入直後(アプリから token を送った瞬間)
  • アプリ起動時(必要なら再検証)
  • サブスクの有効期限切れ
  • ストア側でキャンセル・返金があったとき

特にサブスクは毎月更新されるため、

ユーザーがアプリを開いていなくても バックエンド側で状態を維持できることが重要

です。


まとめ

  • Cloud Functions は purchaseToken の真偽をストアに問い合わせるサーバー
  • Google / Apple は token をキーに購入状況を100%回答してくれる
  • Functions はその結果を Firestore に保存し、Pro 判定の唯一の正解を持つ
  • アプリは Firestore の結果に従うだけで OK
  • これにより、不正改造・端末変更・再インストールでも正しく Pro を維持できる

つまり、 「Cloud Functions × ストア」こそがアプリ内課金のセキュリティと正確性を支える中核 ということです。

【Flutter】アプリ内課金を正しく動かすための中核:「アプリ × Cloud Functions」を理解する

アプリ内課金の仕組みは、一見すると
「アプリとストアだけで完結するのでは?」と思いがちです。

しかし実際には、
アプリだけで Pro 判定を行うのは危険であり、
ほとんどの商用アプリが「サーバーで課金を検証する仕組み」を持っています。

その“サーバーでの検証役”が、Firebase の Cloud Functions です。

この記事では 「アプリ ↔ Cloud Functions」部分だけ に絞って説明します。


Cloud Functions の役割をひと言で:

「アプリが渡してくる purchaseToken を検証し、Pro 権限を決定するサーバー」

実は Cloud Functions が果たしている役割はシンプルです。

  • アプリがストアから受け取った purchaseToken / transactionId をサーバーに送る
  • Cloud Functions がそのトークンを使って ストアに「これは本物?」と問い合わせて検証する
  • その結果に基づき Firestore に Pro/Free の状態(entitlements)を保存する
  • アプリへ 正しい課金状態 を返す

つまり Cloud Functions は 「Pro 判定の正解を持つ場所」 です。


なぜアプリだけではダメなのか?

アプリ単体で「isPro=true」を保存すると、以下の問題が起きます。

  • 改造 APK や Jailbreak に弱い(簡単に Pro に偽装できる)
  • トークンの偽物をアプリ自身は見抜けない
  • サブスクの更新状況(自動更新停止・返金など)が追えない
  • 端末間同期できず、ユーザーが混乱する

これらをすべて解決するために、 "Pro かどうかの最終判断はサーバーが行う" という仕組みが必要になります。


「アプリ ↔ Cloud Functions」の流れを理解する

アプリ内課金の仕組みは複雑に見えますが、
アプリ側でやっていること自体は驚くほど少ないです。

流れは次の4段階に整理できます。


① アプリがストアから purchaseToken を受け取る

ユーザーが月額プランなどを購入すると、
Google Play / App Store はアプリに次の情報を返します。

  • productId(どの商品か)
  • purchaseToken / transactionId(購入の証拠)
  • サブスクの有効期限など

アプリが Cloud Functions に送るべきなのは、この 購入トーク です。


② アプリ → Cloud Functions に購入トークンを送る

Flutter のコードでは、
こんなイメージで Cloud Functions を呼び出します。

final callable = FirebaseFunctions.instanceFor(region: "asia-northeast1")
    .httpsCallable("entitlements_applyPurchase");

final result = await callable.call({
  "platform": "android",         // または "ios"
  "productId": "pro_monthly",
  "purchaseToken": token,
});

アプリが Cloud Functions に渡している情報はたった3つ。

  • platform(android / ios
  • productId
  • purchaseToken

Firebase Auth にログイン済みであれば、 uid(ユーザー識別子)は自動的に Cloud Functions に渡されます。


③ Cloud Functions がストアに「この購入、本物?」と確認する

Cloud Functions はアプリからトークンを受け取ると、内部で次の処理を行います。

  • 対応するストアの API にアクセス

  • そのトークンが

    • 本物の購入か
    • 有効期限はいつか
    • キャンセルされていないか
    • 返金されていないか を確認する

この検証結果こそが、課金システムの中核です。

アプリではなく、サーバーが“正しい Pro 判定”を持つ。

ということが最も重要なポイントです。


④ Firestore に entitlements を保存し、アプリに返す

検証に成功したら、Cloud Functions は Firestore に 「このユーザーは Pro で、有効期限は○月○日まで」という情報を書き込みます。

例:

entitlements_app/{uid}:
  plan: "pro"
  validUntil: "2025-01-31T00:00:00Z"
  source: "google_play"
  lastPurchaseToken: "xxxxxx"

その後、アプリに次のようなレスポンスを返します。

{
  "plan": "pro",
  "validUntil": "2025-01-31T00:00:00.000Z"
}

アプリ側はこれを見て

  • 広告非表示
  • インポート上限の拡大
  • Proバッジ表示 などを行います。

アプリは「Proかどうかを決めてはいけない」

この記事の結論はこれです。

「Proかどうかの判定は、アプリではなく Cloud Functions が行う。」

アプリの役割は

  • 購入トークンを受け取って送る
  • Cloud Functions の結果に従う

だけ。

この分業によって、

  • 不正改造対策
  • サブスク更新の継続監視
  • 端末間の同期
  • 安定した Pro 判定
  • 審査基準のクリア

がすべて実現されます。


まとめ:「アプリ × Cloud Functions」は課金システムの心臓部

今回のポイントをまとめると:

  • アプリはストアから購入トークンを受け取るだけ
  • そのトークンを Cloud Functions に送る
  • Cloud Functions がストアで検証し、Firestore に正しい権限を保存する
  • アプリは Cloud Functions の結果だけを信用し、UI を Pro に切り替える
  • Pro 判定の本番データは Firestore にある

これで課金システムは「セキュアで同期可能で、審査に強い」ものになります。