はじめに
Part 1 では iOS・Android・Flutter それぞれの基本設定を行いました。
各プラットフォームのセットアップが完了していることを前提に、今回はいよいよ Flutter のコード実装に入ります。
⚠️ 注意: Part 1 のセットアップが完全に完了していないと、ストアから商品リストを取得できません。必ず事前に確認してください。
実装の全体フロー
アプリ起動時に以下の順序で処理を行います。
1. ストアへの接続確認(isAvailable)
2. 現在販売中の商品リストを取得(queryProductDetails)
3. 購入済み商品リストを取得(restorePurchases)
4. 購入ストリームのリスナーを設定(purchaseStream)
5. ユーザーの購入操作を処理(buyConsumable / buyNonConsumable)
2.1 アプリ起動時の初期化処理
ストア接続の確認と商品・購入済みリストの取得
StatefulWidget の initState 内で初期化を行います。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
// ストアに登録している商品IDのセット
const Set<String> _kProductIds = {
'your_consumable_product_id',
'your_non_consumable_product_id',
'your_subscription_id',
};
class StoreScreen extends StatefulWidget {
@override
_StoreScreenState createState() => _StoreScreenState();
}
class _StoreScreenState extends State<StoreScreen> {
final InAppPurchase _iap = InAppPurchase.instance;
bool _isAvailable = false;
List<ProductDetails> _products = [];
List<PurchaseDetails> _purchases = [];
bool _loading = true;
String? _queryProductError;
late StreamSubscription<List<PurchaseDetails>> _subscription;
@override
void initState() {
super.initState();
_initialize();
}
Future<void> _initialize() async {
// 1. ストアへの接続確認
final bool isAvailable = await _iap.isAvailable();
if (!isAvailable) {
setState(() {
_isAvailable = false;
_loading = false;
});
return;
}
// 2. 購入ストリームのリスナーを設定
_subscription = _iap.purchaseStream.listen(
_onPurchaseUpdated,
onDone: () => _subscription.cancel(),
onError: (error) {
// エラーハンドリング
print('purchaseStream error: $error');
},
);
// 3. 商品リストと購入済みリストを同時に取得
await Future.wait([
_fetchProducts(),
_restorePurchases(),
]);
setState(() {
_isAvailable = isAvailable;
_loading = false;
});
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
ストアから商品リストを取得
Future<void> _fetchProducts() async {
final ProductDetailsResponse response =
await _iap.queryProductDetails(_kProductIds);
if (response.error != null) {
setState(() {
_queryProductError = response.error!.message;
});
return;
}
if (response.notFoundIDs.isNotEmpty) {
// 見つからなかった商品IDをログ出力
print('Products not found: ${response.notFoundIDs}');
}
setState(() {
_products = response.productDetails;
});
}
購入済み商品を復元
Consumable(消耗品)の場合は App Store / Google Play 側に購入履歴が残らないため、
自前のバックエンドで購入状態を管理することを推奨します。
Future<void> _restorePurchases() async {
// 購入済み商品の復元リクエスト
// 復元結果は purchaseStream 経由で _onPurchaseUpdated に届く
await _iap.restorePurchases();
}
2.2 購入ストリームのリスナー処理
購入・復元の結果はすべて purchaseStream から PurchaseDetails のリストとして届きます。
purchaseDetails.status に応じて適切な処理を行います。
Future<void> _onPurchaseUpdated(
List<PurchaseDetails> purchaseDetailsList) async {
for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
switch (purchaseDetails.status) {
case PurchaseStatus.pending:
// 購入処理中 → ローディングUIを表示
_showPendingUI();
break;
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
// 購入完了・復元 → バックエンドでレシート検証してからアイテムを付与
final bool valid = await _verifyPurchase(purchaseDetails);
if (valid) {
_deliverProduct(purchaseDetails);
} else {
_handleInvalidPurchase(purchaseDetails);
}
break;
case PurchaseStatus.error:
// エラー処理
print('Purchase error: ${purchaseDetails.error}');
_hidePendingUI();
break;
case PurchaseStatus.canceled:
// ユーザーがキャンセル
_hidePendingUI();
break;
}
// iOS では pendingCompletePurchase が true の場合、必ず completePurchase を呼ぶ
if (purchaseDetails.pendingCompletePurchase) {
await _iap.completePurchase(purchaseDetails);
}
}
}
⚠️ 重要:
purchaseDetails.pendingCompletePurchaseがtrueの場合、必ずcompletePurchase()を呼び出してください。呼ばないと購入フローが完了せず、ユーザーへのアイテム付与が保留状態のままになります。
2.3 商品の購入処理
ユーザーが購入ボタンをタップしたときの処理です。
商品の種類(Consumable / Non-Consumable)によって呼び出すメソッドが異なります。
void _buyProduct(ProductDetails productDetails) {
final PurchaseParam purchaseParam =
PurchaseParam(productDetails: productDetails);
if (_isConsumable(productDetails)) {
// Consumable(消耗品)の購入
_iap.buyConsumable(purchaseParam: purchaseParam);
} else {
// Non-Consumable / Subscription の購入
_iap.buyNonConsumable(purchaseParam: purchaseParam);
}
}
// 消耗品かどうかを判定するヘルパー関数(IDで管理するのが一般的)
bool _isConsumable(ProductDetails productDetails) {
return productDetails.id == 'your_consumable_product_id';
}
2.4 UI の構築
商品リストと購入ボタンをシンプルに表示する例です。
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (!_isAvailable) {
return const Scaffold(
body: Center(child: Text('ストアが利用できません')),
);
}
return Scaffold(
appBar: AppBar(title: const Text('ストア')),
body: ListView.builder(
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return ListTile(
title: Text(product.title),
subtitle: Text(product.description),
trailing: ElevatedButton(
onPressed: () => _buyProduct(product),
child: Text(product.price),
),
);
},
),
);
}
2.5 レシートの検証とアイテムの付与
購入完了後は 必ずバックエンドでレシートを検証 してからアイテムを付与することを強く推奨します。
クライアント側だけで検証するのは不正購入のリスクがあります。
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
// TODO: バックエンド API に verificationData を送信して検証する
// purchaseDetails.verificationData.serverVerificationData を使用
return true; // 実際はサーバーの検証結果を返す
}
void _deliverProduct(PurchaseDetails purchaseDetails) {
// TODO: 購入したアイテムをユーザーに付与する処理
setState(() {
_purchases.add(purchaseDetails);
});
}
void _handleInvalidPurchase(PurchaseDetails purchaseDetails) {
// TODO: 不正な購入への対処
print('Invalid purchase: ${purchaseDetails.productID}');
}
void _showPendingUI() {
setState(() => _loading = true);
}
void _hidePendingUI() {
setState(() => _loading = false);
}
まとめ
今回実装した内容を整理すると以下のとおりです。
| 処理 | メソッド |
|---|---|
| ストア接続確認 | InAppPurchase.instance.isAvailable() |
| 商品リスト取得 | InAppPurchase.instance.queryProductDetails() |
| 購入済み商品の復元 | InAppPurchase.instance.restorePurchases() |
| 購入イベントの受信 | InAppPurchase.instance.purchaseStream |
| Consumable の購入 | InAppPurchase.instance.buyConsumable() |
| Non-Consumable の購入 | InAppPurchase.instance.buyNonConsumable() |
| 購入フローの完了 | InAppPurchase.instance.completePurchase() |
次のパートでは、Subscription(定期購読)の自動更新処理やキャンセル対応など、より実践的な実装について解説します。