Flutter で個人開発したアプリ「露出帳」を、iPhone / iPad / Mac / visionOS / Android の 5 プラットフォームにリリースしました。Free 版に加えて Pro 機能を 買い切り型のアプリ内課金(IAP) で提供する構成にしています。
サブスクリプション全盛の時代に、なぜ買い切りを選んだのか。Flutter の in_app_purchase プラグインでどう実装したのか。実プロダクトとしてリリースして見えてきた、ドキュメントには書かれていない落とし穴も含めて、この記事に全部まとめました。
「Flutter で買い切り IAP をどう実装するか」を調べている方に、必要な情報が一通り揃った状態でお届けします。
この記事の対象読者
- Flutter で個人開発・受託開発をしているエンジニア
- サブスクではなく買い切りで収益化したいと考えている方
in_app_purchaseプラグインの実装事例を探している方- iOS / Android 両方への課金実装を一気通貫で学びたい方
なぜサブスクではなく買い切りを選んだのか
露出帳は、フィルム写真家向けの「露出計算アプリ」です。Sunny 16 ルール、相反則不軌補正、Push / Pull 現像計算、ND フィルター計算、蛇腹補正、フラッシュガイドナンバー計算など、フィルム写真の現場で使う計算機能を統合しています。
この種のユーティリティアプリには、サブスクリプションは構造的に合いません。理由は単純で、ユーザーが「毎月課金される価値」を感じづらい。撮影に出かけるたびに使うとは限らないし、年に数回しか使わないユーザーもいる。それでもサブスクで月額を取り続けるのは、ユーザーから見れば「いつ解約すべきか」という判断の負担を強いるだけです。
買い切り型なら:
- ユーザーは一度払えば、必要なときにいつでも使える
- 開発者は「価値ある機能を作って売る」という古典的なシンプルさに戻れる
- 長期的に解約管理・継続率を気にする必要がない
露出帳の Pro 機能は ¥800 / $7.99 の買い切り に設定しています。「お弁当 1 食分の価格で、フィルム写真がある限り一生使える」という訴求が、このアプリのターゲット層(趣味として写真を撮る大人)に最も自然に届く価格帯と判断しました。
使用する技術スタック
露出帳の課金実装で使ったのは以下の構成です:
- Flutter 3.x — 5 プラットフォーム単一コードベース
in_app_purchaseプラグイン(公式、現在は ^3.x 系) — iOS / Android 両対応shared_preferences— Pro 状態のローカル永続化
サードパーティ製の課金ライブラリ(RevenueCat など)も検討の余地はありますが、個人開発の規模・買い切り 1 商品のみという要件では、公式 in_app_purchase で十分です。サブスクや複雑な購入種別を扱うわけではないので、外部サービスへの依存を増やすメリットがありません。
Step 1: プロジェクトのセットアップ
pubspec.yaml に依存を追加
dependencies:
flutter:
sdk: flutter
in_app_purchase: ^3.2.0
shared_preferences: ^2.2.2
追加後、flutter pub get を実行。
iOS 側の設定(Xcode)
Xcode でプロジェクトを開き、ターゲットの「Signing & Capabilities」タブで In-App Purchase Capability を追加します。これを忘れると、後で App Store Connect の商品が認識されません。
Android 側の設定
android/app/src/main/AndroidManifest.xml に課金パーミッションが必要です。in_app_purchase プラグインが自動で追加してくれますが、念のため確認:
<uses-permission android:name="com.android.vending.BILLING" />
Step 2: ストア側で商品を作成する
App Store Connect での設定
- 「機能」→「App 内課金」→「+」で新規商品を作成
- 商品タイプ:「非消費型(Non-Consumable)」を選択
- 製品 ID:例
com.vjconnect_software.roshutsucho.pro_unlock(一意な ID。Android と統一推奨) - 参照名:管理用なので「Pro Unlock」など分かりやすい名前で
- 価格:価格帯(Tier)から選択(¥800 なら Tier 5)
- 表示名・説明:購入画面に表示される文言(日本語・英語の両方を登録)
- レビュー情報:審査用のスクリーンショットと審査メモを準備
「非消費型」を選ぶ理由:買い切りで Pro 機能を恒久的に解放するため。「消費型」を選ぶと、購入のたびに消費される仕様になり、ユーザーが何度も購入できてしまいます。間違えると大ごとになるので注意。
Google Play Console での設定
- 「収益化」→「アプリ内アイテム」→「商品を作成」
- 製品 ID:iOS と同じ ID を使う(実装が単純化される)
- 商品名・説明文:日本語・英語両方を登録
- 価格:¥800 を設定(自動で他国通貨に換算される)
- 状態:「アクティブ」に設定
注意点:Google Play では「定期購入」と「アプリ内アイテム」が別カテゴリです。「アプリ内アイテム」を必ず選んでください。
Step 3: 課金処理を実装する
ここからが本題のコード実装です。露出帳で実際に使っている構造をもとに、シンプルにまとめます。
購入処理を担うクラスを作る
課金関連の処理を一箇所にまとめた PurchaseService クラスを作ります。シングルトンとして動作させるのが扱いやすいです。
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PurchaseService extends ChangeNotifier {
static const String _proProductId = 'com.vjconnect_software.roshutsucho.pro_unlock';
static const String _isProKey = 'is_pro_user';
final InAppPurchase _iap = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>>? _subscription;
List<ProductDetails> _products = [];
bool _isPro = false;
bool _isAvailable = false;
bool get isPro => _isPro;
bool get isAvailable => _isAvailable;
List<ProductDetails> get products => _products;
Future<void> initialize() async {
// 1. ストア接続可否をチェック
_isAvailable = await _iap.isAvailable();
if (!_isAvailable) {
debugPrint('IAP unavailable');
return;
}
// 2. 商品情報を取得
await _loadProducts();
// 3. 購入更新リスナーを開始
_subscription = _iap.purchaseStream.listen(
_onPurchaseUpdate,
onDone: () => _subscription?.cancel(),
onError: (error) => debugPrint('Purchase stream error: $error'),
);
// 4. ローカル保存の Pro 状態をロード
await _loadProStatus();
}
Future<void> _loadProducts() async {
final response = await _iap.queryProductDetails({_proProductId});
if (response.error != null) {
debugPrint('Product load error: ${response.error}');
return;
}
_products = response.productDetails;
notifyListeners();
}
Future<void> _loadProStatus() async {
final prefs = await SharedPreferences.getInstance();
_isPro = prefs.getBool(_isProKey) ?? false;
notifyListeners();
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
購入を実行するメソッド
Future<bool> buyPro() async {
if (_products.isEmpty) {
debugPrint('Product not loaded');
return false;
}
final product = _products.firstWhere((p) => p.id == _proProductId);
final purchaseParam = PurchaseParam(productDetails: product);
// 非消費型は buyNonConsumable を使う(consumable と間違えないこと!)
try {
return await _iap.buyNonConsumable(purchaseParam: purchaseParam);
} catch (e) {
debugPrint('Buy error: $e');
return false;
}
}
購入更新を受け取るハンドラ
Future<void> _onPurchaseUpdate(List<PurchaseDetails> purchases) async {
for (final purchase in purchases) {
if (purchase.status == PurchaseStatus.pending) {
// ローディング表示など
debugPrint('Purchase pending');
} else if (purchase.status == PurchaseStatus.purchased ||
purchase.status == PurchaseStatus.restored) {
// 購入成功 or 復元成功
await _grantPro();
} else if (purchase.status == PurchaseStatus.error) {
debugPrint('Purchase error: ${purchase.error}');
}
// 重要:購入完了を必ず通知(これを忘れるとストアが purchase を再送し続ける)
if (purchase.pendingCompletePurchase) {
await _iap.completePurchase(purchase);
}
}
}
Future<void> _grantPro() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_isProKey, true);
_isPro = true;
notifyListeners();
}
注意点:completePurchase() の呼び出しは絶対に忘れないでください。これをサボると、ストア側が「未完了の購入」とみなして、アプリ起動のたびに purchase イベントが再送されます。ユーザー体験を著しく損ねるので、エラーハンドリングをしてでも必ず呼ぶようにします。
Step 4: 復元購入機能を実装する
買い切り型の課金では、「購入の復元」機能は必須です。理由は以下の通り:
- ユーザーが端末を変更したとき、過去に購入した Pro を引き継げる必要がある
- アプリを再インストールしたとき、Pro 状態を回復できる必要がある
- App Store のレビューガイドラインで明示的に要求されている(4.2.7 など)
実装はシンプルです:
Future<void> restorePurchases() async {
try {
await _iap.restorePurchases();
// 復元結果は purchaseStream リスナー経由で受け取る
// (PurchaseStatus.restored として届く)
} catch (e) {
debugPrint('Restore error: $e');
}
}
UI 側に「購入を復元」ボタンを設置し、このメソッドを呼び出すだけ。復元成功時の処理は、購入時と同じ _onPurchaseUpdate リスナーで PurchaseStatus.restored として受け取ります。
Step 5: UI に Pro 機能のロックを組み込む
Pro 状態に応じて、機能の表示・実行可否を切り替えます。ChangeNotifier を継承しているので、Provider や ChangeNotifierProvider と組み合わせて使うのが綺麗です。
// Pro 専用機能の画面
class ReciprocityCalculatorScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final purchaseService = context.watch<PurchaseService>();
if (!purchaseService.isPro) {
return ProUnlockScreen(); // 課金誘導画面
}
return ActualCalculatorWidget(); // 本来の機能
}
}
Step 6: テストとリリース
iOS でのテスト方法
- App Store Connect で「サンドボックステスター」アカウントを作成
- 実機の「設定」→「App Store」→「サンドボックスアカウント」でサインイン
- アプリを実機で起動 → 購入処理を実行
- サンドボックス環境では実課金は発生しない(無料でテスト可能)
Android でのテスト方法
- Google Play Console の「設定」→「ライセンスのテスト」でテスターアカウントを追加
- 内部テストトラックにアプリを公開
- テスターアカウントで実機からインストール
- 購入処理を実行(実課金は発生せず、自動承認される)
露出帳のリリースで実際に詰まった落とし穴
ここからが、ドキュメントには書かれていない実体験。
1. App Store レビューで「Restore Purchases ボタンが見つからない」と指摘された
初回リリースで、App Store のレビュアーから「購入の復元ボタンが見つからない」というリジェクト理由で差し戻されました。アプリの設定画面の奥に置いていたのが原因です。
対処:Pro 機能のロック画面(課金誘導画面)に、「購入する」ボタンと並べて「購入を復元する」ボタンを目立つ位置に配置。これで再申請が通りました。
教訓:復元ボタンは設定画面の奥ではなく、課金誘導画面そのものに並べて置く。レビュアーは決まったフローでチェックするので、課金画面で見つからないと一発でリジェクトになります。
2. iOS と Android で商品 ID を統一しなかった初期実装の反省
初期実装で、iOS と Android で違う商品 ID を設定していました(iOS: pro_unlock、Android: roshutsucho_pro)。これだと、コード内でプラットフォーム判定して ID を切り替える必要があり、無駄な分岐が増えます。
対処:両プラットフォームで同じ ID(com.vjconnect_software.roshutsucho.pro_unlock)に統一。これで queryProductDetails に渡す Set が単一の値で済みます。
3. pendingCompletePurchase を忘れて起動するたびに購入が再発火
開発中、completePurchase() を呼び忘れていて、アプリを再起動するたびに過去の購入イベントが再送される現象に遭遇しました。Pro 機能が勝手に「アンロックされる」という挙動になり、テスト用には便利だが本番では大問題。
対処:_onPurchaseUpdate 内で、ステータスに関係なく pendingCompletePurchase が true なら completePurchase() を呼ぶよう徹底。
まとめ
Flutter で買い切り型 IAP を実装するポイントを整理:
- 公式
in_app_purchaseプラグインで十分(シンプルな買い切り 1 商品なら) - 商品タイプは必ず 「非消費型(Non-Consumable)」 を選ぶ
- 商品 ID は iOS / Android で統一すべし
- 復元購入機能は必須、課金誘導画面に並べて配置する
completePurchase()の呼び忘れは致命的、必ず呼ぶ- テストはサンドボックステスター / ライセンステスターで実施
サブスクモデルが主流の今、買い切りはむしろ差別化になります。ユーザーが「払って終わり」というシンプルな体験を求めている領域では、買い切り型は今でも有効な選択です。
露出帳の開発全体については、ケーススタディページ でアーキテクチャ判断や 5 プラットフォーム展開の詳細をまとめています。Flutter ネイティブ判断の使い分けなど、技術選定の話に踏み込んだ内容です。
Flutter での個人開発・受託開発のご相談は、お問い合わせ からどうぞ。買い切り IAP の実装・コードレビュー、IAP まわりのトラブル対応など、相談ベースで対応できます。
