Flutter iOS初始化时内购监听器返回历史购买记录问题
Flutter in_app_purchase iOS初始化时历史购买记录重复处理问题
我正在使用in_app_purchase包为Flutter应用集成内购功能,已完成Google Play和Apple App Store沙箱环境配置,购买功能在双平台均正常运行。
当前配置
购买流程在双平台均正常,可成功订阅并在Google Play及iOS沙箱中正确显示。我创建了Getx Controller来初始化内购监听器和商店信息,相关代码如下:
监听器初始化(onInit方法)
late InAppPurchasePlatform _inAppPurchasePlatform; @override Future<void> onInit() async { _inAppPurchasePlatform = InAppPurchasePlatform.instance; final Stream<List<PurchaseDetails>> purchaseUpdated = _inAppPurchasePlatform.purchaseStream; _subscription0 = purchaseUpdated.listen((List<PurchaseDetails> purchaseDetailsList) { _handlePurchaseUpdates(purchaseDetailsList); }, onDone: () { _subscription0.cancel(); print("purchase listener onDone :"); }, onError: (Object error) { print("purchase listener erro :" + error.toString()); }); initStoreInfo(); super.onInit(); }
initStoreInfo实现
Future<void> initStoreInfo() async { print("initStoreInfo called:"); try { final bool isAvailable = await _inAppPurchasePlatform.isAvailable(); if (!isAvailable) { error.value = 'Store is unavailable. Please try again later.'; return; } print("Store is available."); final List<IAPPlanModel> fetchedPlans = await fetchIAPPlanModel(); plans.addAll(fetchedPlans); _kProductIds.addAll(fetchedPlans .map((plan) => plan.productId ?? '') .where((id) => id.isNotEmpty)); final ProductDetailsResponse productDetailResponse = await _inAppPurchasePlatform .queryProductDetails(_kProductIds.toSet()); if (productDetailResponse.error != null) { error.value = productDetailResponse.error!.message!; return; } if (productDetailResponse.productDetails.isEmpty) { error.value = 'No products available at the moment.'; return; } _linkPlansWithProductDetails(productDetailResponse.productDetails); if (GetPlatform.isAndroid) { final InAppPurchaseAndroidPlatformAddition addition = InAppPurchasePlatformAddition.instance! as InAppPurchaseAndroidPlatformAddition; unawaited( addition.setBillingChoice(BillingChoiceMode.userChoiceBilling)); } else if (Platform.isIOS) { //write code for ios } } catch (e) { print("Error initializing store: $e"); error.value = "Error initializing store: $e"; isLoading.value = false; return; } }
_handlePurchaseUpdates函数
void _handlePurchaseUpdates(List<PurchaseDetails> purchaseDetailsList) async { print("purchaseDetailsList : " + purchaseDetailsList.length.toString()); final list = _plansController.subscriptionsList .map((e) => e.transactionIds) .toList(); list.map((e) => print("listitems : " + e.toString())); print("list : " + list.toString()); print("list : " + list.length.toString()); for (final PurchaseDetails details in purchaseDetailsList) { purchases[details.productID] = details; var purchaseID = details.purchaseID; print(" purchaseID:" + purchaseID.toString()); print("PurchaseDetails.status :" + details.status.toString()); if (details.pendingCompletePurchase) { try { await _inAppPurchasePlatform.completePurchase(details); } catch (e) { print("Error completing purchase: $e"); } } if (details.status == PurchaseStatus.canceled) { purchasePending.value = false; isLoading.value = false; isButtonClicked.value = false; return; } if (details.status == PurchaseStatus.error) { error.value = details.error?.message ?? "Unknown error occurred."; purchasePending.value = false; isLoading.value = false; isButtonClicked.value = false; Get.snackbar( 'Purchase Error', '', backgroundColor: Colors.red, snackPosition: SnackPosition.BOTTOM, duration: const Duration(seconds: 3), ); if (details.error?.message == "BillingResponse.itemAlreadyOwned") { _showAlreadyOwnedDialog(); } print("PurchaseStatus.error " + details.error!.message.toString()); return; } isLoading.value = true; if (details.status == PurchaseStatus.pending) { purchasePending.value = true; return; } else if (details.status == PurchaseStatus.error) { error.value = details.error?.message ?? "Unknown error occurred."; purchasePending.value = false; await _sendPurchaseToBackend(details, false); } else if (details.status == PurchaseStatus.restored) { error.value = details.error?.message ?? "Subscription restored"; isButtonClicked.value = false; return; } else if (details.status == PurchaseStatus.purchased) { activePlanId.value = details.productID; purchasePending.value = true; await _sendPurchaseToBackend(details, true); _processedPurchases.add(details.productID); } if (details.pendingCompletePurchase) { await _inAppPurchasePlatform.completePurchase(details); await fetchUserActivePlan(); } } }
问题描述
在iOS端,应用启动并初始化Getx Controller时,内购监听器purchaseStream会立即返回包含历史购买记录的purchaseDetailsList,状态显示为PurchaseStatus.purchased,此时并未发起新的支付请求,导致无法区分历史购买记录与新订单。
打印的历史交易信息如下:
flutter: old originalT : {transactionState: 1, payment: {productIdentifier: spindle_plus_30_days, applicationUsername: null, requestData: null, quantity: 1, simulatesAskToBuyInSandbox: false }, originalTransaction: null, transactionTimeStamp: 1737634871.0, transactionIdentifier: 2000000838444102, error: null }
已尝试通过按钮触发支付弹窗,该功能正常,但初始化时的问题仍存在。
疑问解答
1. 为何iOS初始化时purchaseStream会返回历史购买记录?
这是iOS StoreKit框架的默认行为:当应用启动时,StoreKit会自动将用户账号下未完成、未标记为已处理的交易推送给应用,确保所有购买交易都能被正确处理,避免因应用崩溃、意外退出等情况导致购买记录丢失。即使交易已经完成,只要应用没有调用completePurchase标记为已处理,下次启动时仍会被推送。
2. 如何区分历史购买记录与新订单?
- 跟踪交易ID:将已成功处理的
transactionIdentifier存储在本地(比如用shared_preferences),每次收到交易时先检查该ID是否已存在于本地列表中,存在则为历史记录。 - 检查交易时间戳:新订单的
transactionTimeStamp是应用本次启动后的时间,历史记录的时间戳则更早,可以通过对比应用启动时间来区分。 - 判断触发来源:新订单是用户主动点击购买按钮发起的,可以在发起购买时记录当前的交易ID或标记,收到回调时匹配该标记。
3. 推荐处理方案避免重复处理历史记录?
维护本地已处理交易ID列表
- 使用
shared_preferences存储已处理的transactionIdentifier,每次处理交易前先查询是否存在:final prefs = await SharedPreferences.getInstance(); final processedIds = prefs.getStringList('processed_transactions') ?? []; if (processedIds.contains(details.transactionIdentifier)) { // 已处理过,跳过 return; } - 处理完成后将交易ID添加到列表并保存:
processedIds.add(details.transactionIdentifier!); await prefs.setStringList('processed_transactions', processedIds);
- 使用
优化_handlePurchaseUpdates逻辑
- 先判断交易是否已处理,再执行后续操作;
- 对于历史购买记录,仅同步用户订阅状态到本地,不重复调用后端接口;
- 确保所有交易都调用
completePurchase,标记为已处理,避免下次启动时再次推送。
初始化时主动恢复购买(可选)
- 在
initStoreInfo中调用_inAppPurchasePlatform.restorePurchases(),确保所有已购买的订阅都被正确同步,同时结合本地已处理列表避免重复操作。
- 在
内容的提问来源于stack exchange,提问作者Rakesh Saini
相关产品推荐
相关产品推荐

