2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter In-App Purchase】Part 2: ストアの商品表示と購入処理の実装

2
Posted at

はじめに

Part 1 では iOS・Android・Flutter それぞれの基本設定を行いました。
各プラットフォームのセットアップが完了していることを前提に、今回はいよいよ Flutter のコード実装に入ります。

⚠️ 注意: Part 1 のセットアップが完全に完了していないと、ストアから商品リストを取得できません。必ず事前に確認してください。


実装の全体フロー

アプリ起動時に以下の順序で処理を行います。

1. ストアへの接続確認(isAvailable)
2. 現在販売中の商品リストを取得(queryProductDetails)
3. 購入済み商品リストを取得(restorePurchases)
4. 購入ストリームのリスナーを設定(purchaseStream)
5. ユーザーの購入操作を処理(buyConsumable / buyNonConsumable)

2.1 アプリ起動時の初期化処理

ストア接続の確認と商品・購入済みリストの取得

StatefulWidgetinitState 内で初期化を行います。

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.pendingCompletePurchasetrue の場合、必ず 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(定期購読)の自動更新処理やキャンセル対応など、より実践的な実装について解説します。


参考リンク

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?