Unity手游iOS内购集成实战:从StoreKit桥接到服务器安全验证
2026/8/6 7:28:11 网站建设 项目流程

1. 项目概述与核心价值

“Unity调用原生iOS内购实现”,这个标题听起来像是一个具体的开发任务,但对于真正在一线做过Unity手游商业化,尤其是经历过苹果审核“毒打”的开发者来说,它背后代表的是一个完整的、充满细节和陷阱的技术实现体系。这不仅仅是调用一个API那么简单,它关乎你的游戏能否顺利上架App Store、能否稳定地从玩家那里收到钱,以及后续能否高效地处理退款、订阅续期等复杂业务。

我经历过不止一个项目,因为内购集成时的某个疏忽,导致上线后出现“掉单”(用户付了钱但没收到道具)、审核被拒,甚至是线上大规模事故。所以,今天我想抛开官方文档那些理想化的流程,从一个实战老手的角度,和你深度拆解一下,在Unity里集成iOS原生内购,到底需要关注哪些核心环节、会遇到哪些坑,以及如何构建一个健壮、可维护的内购模块。无论你是独立开发者还是团队中的技术负责人,这些从真实项目中踩坑总结的经验,都能帮你省下大量排查问题的时间。

简单来说,我们的目标是在Unity C#脚本中,通过一个清晰的桥梁,安全、可靠地调用iOS的StoreKit框架,完成从商品查询、发起购买、处理交易凭证到最终交付虚拟物品的完整闭环。这个过程涉及Unity与iOS原生代码(Objective-C或Swift)的交互、苹果服务器验证、客户端数据持久化以及异常处理等多个层面。

2. 整体架构设计与技术选型

在动手写代码之前,我们先要厘清架构。为什么不能直接用Unity IAP(Unity官方的应用内购服务)?实际上,Unity IAP是一个很好的选择,它封装了多平台(iOS, Google Play, Amazon等)的差异,提供了统一的接口。但对于我们这个特定主题“调用原生iOS内购”,我们更聚焦于理解其底层原理,或者在某些需要深度定制、对包体大小极度敏感、或对Unity IAP版本有兼容性顾虑的场景下,自己动手实现这套机制。

2.1 核心架构:C#与Objective-C/Swift的桥接

Unity运行在Mono或IL2CPP之上,最终在iOS上是一个原生的App。要让C#代码调用iOS的StoreKit,必须通过一个“桥接层”。最主流、最稳定的方式就是使用[DllImport("__Internal")]来调用外部静态函数,这些函数实现在我们自己编写的Objective-C(.mm文件)或Swift(需要额外桥接头文件)代码中。

为什么选择Objective-C (.mm) 而不是纯C接口?虽然可以用C函数包装,但StoreKit的API(如SKPaymentQueue、SKProductsRequest)是高度面向对象的,用Objective-C来封装会更加自然和方便,可以直接使用Foundation和StoreKit框架的所有特性。.mm文件表示这是Objective-C++文件,允许我们在其中混用C++和Objective-C,这对于需要与C#交互传递复杂数据时特别有用。

基本数据流设计:

  1. C#层(业务逻辑层):定义内购管理器(如IAPManager.cs),提供InitializeRequestProductsPurchaseProductRestorePurchases等方法。它持有商品列表,管理购买状态。
  2. C#层(桥接接口层):定义一个静态类iOSIAPBridge.cs,其中使用[DllImport(“__Internal”)]声明一系列外部方法,如_iap_initialize_iap_requestProducts_iap_purchaseProduct
  3. Native层(Objective-C桥接实现):创建UnityIAPManager.mm文件,实现上述C函数。这些函数内部会创建Objective-C对象(如遵循SKProductsRequestDelegateSKPaymentTransactionObserver协议的对象),调用StoreKit API。
  4. 回调机制:iOS原生层的交易状态更新(如购买成功、失败、恢复完成)需要通过Unity的UnitySendMessage函数,将结果回调给Unity场景中指定的GameObject和其上的Method。这是Unity与原生代码回调的标准方式。

2.2 商品类型与服务器验证策略选型

苹果内购商品主要分四类:消耗型(如金币)、非消耗型(如永久去广告)、自动续期订阅、非续期订阅。对于消耗型商品,服务器端验证(Server-side Verification)是必须的,这是防止客户端被破解、保证交易安全性的黄金准则。

验证流程设计:

  1. 客户端购买成功后,会从SKPaymentTransactiontransactionReceipt(在iOS 7+)或整个appStoreReceiptURL(iOS 7+ 推荐)获取交易凭证。
  2. 客户端将这个凭证(Base64编码的字符串)发送到你自己的游戏服务器。
  3. 游戏服务器携带此凭证,调用苹果的验证服务器(沙盒环境https://sandbox.itunes.apple.com/verifyReceipt,生产环境https://buy.itunes.apple.com/verifyReceipt)进行验证。
  4. 苹果服务器返回一个JSON,包含交易状态、商品标识、购买时间等信息。重点:服务器必须校验返回状态(status为0)、商品ID是否匹配、以及是否存在重复验证(防止凭据被复用)
  5. 服务器验证通过后,通知游戏客户端发放道具,并记录该笔交易ID,确保仅发放一次。

注意:绝对不要在客户端仅凭SKPaymentTransaction的状态就直接发放道具。即使不破解,网络延迟或客户端崩溃也可能导致状态误判。服务器验证是唯一可信源。

2.3 开发环境与依赖准备

在开始编码前,确保你的环境就绪:

  • Unity版本:选择一个稳定的LTS版本,如2022.3 LTS。确保iOS Build Support模块已安装。
  • Xcode:安装最新稳定版本的Xcode,并确保命令行工具已配置。
  • Apple开发者账号:需要付费加入Apple Developer Program,才能在真机上测试和上架。
  • App内购买项目配置:在App Store Connect中为你的App提前创建好内购商品(Consumable, Non-Consumable等),填写详细的参考名称和描述,并上传截图(审核用)。商品ID(Product Identifier)是你代码中需要使用的关键字符串,建议命名规范如com.yourcompany.yourgame.gold100

3. 核心模块实现与代码解析

接下来,我们分步实现核心模块。我会提供关键代码片段并解释其意图和注意事项。

3.1 C#层:定义与桥接

首先,我们定义内购管理器和桥接接口。

// IAPManager.cs using UnityEngine; using System; using System.Collections.Generic; public class IAPManager : MonoBehaviour { // 单例模式,便于全局访问 public static IAPManager Instance { get; private set; } // 用于接收iOS原生回调的GameObject名称,必须与挂载此脚本的对象名一致 public const string CallbackObjectName = "IAPManager"; // 商品列表缓存 private Dictionary<string, ProductInfo> m_ProductCatalog = new Dictionary<string, ProductInfo>(); public class ProductInfo { public string id; public string title; public string description; public string localizedPrice; // 格式化后的价格字符串,如“¥6.00” } void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); // 常驻,避免购买过程中场景切换导致回调丢失 // 初始化内购,通常在游戏启动后调用 InitializeIAP(); } void Start() { // 确保对象名正确,用于接收UnitySendMessage回调 if (gameObject.name != CallbackObjectName) { Debug.LogWarning($"IAPManager建议挂载在名为{CallbackObjectName}的GameObject上,当前对象名:{gameObject.name}"); } } public void InitializeIAP() { // 调用原生初始化 iOSIAPBridge.Initialize(CallbackObjectName); Debug.Log("IAP初始化调用完成。"); } public void RequestProducts(string[] productIds) { if (productIds == null || productIds.Length == 0) { Debug.LogError("商品ID列表为空。"); return; } // 将ID数组拼接为逗号分隔的字符串,传递给原生层 string productIdString = string.Join(",", productIds); iOSIAPBridge.RequestProducts(productIdString); } public void PurchaseProduct(string productId) { if (string.IsNullOrEmpty(productId)) { Debug.LogError("商品ID无效。"); return; } if (!m_ProductCatalog.ContainsKey(productId)) { Debug.LogError($"尝试购买未查询到的商品: {productId},请先调用RequestProducts。"); return; } iOSIAPBridge.PurchaseProduct(productId); } public void RestorePurchases() { iOSIAPBridge.RestorePurchases(); } // --- 以下方法将由iOS原生层通过UnitySendMessage调用 --- // 方法名必须与UnitySendMessage中指定的完全一致 // 商品信息回调 void OnProductsReceived(string productData) { // productData 格式可能是 "id1|title1|desc1|price1,id2|title2|desc2|price2" Debug.Log($"收到商品信息: {productData}"); // 解析productData,填充m_ProductCatalog... // 具体解析逻辑略,需处理字符串分割和转义 } // 购买成功回调(此时仅表示客户端交易队列完成,必须等待服务器验证) void OnPurchaseSuccess(string transactionInfo) { // transactionInfo 应包含 transactionIdentifier 和 receiptData (Base64) Debug.Log($"客户端购买成功: {transactionInfo}"); // 解析出交易ID和收据,发送给自己的服务器进行验证 // StartCoroutine(SendReceiptToServer(transactionId, receiptData)); } // 购买失败回调 void OnPurchaseFailed(string errorMessage) { Debug.LogError($"购买失败: {errorMessage}"); // 通知UI更新,可能是用户取消、支付失败等 } // 恢复购买完成回调 void OnRestoreFinished(string result) { Debug.Log($"恢复购买完成: {result}"); // result 可能包含恢复的交易数量或状态信息 } }
// iOSIAPBridge.cs using System.Runtime.InteropServices; using UnityEngine; public static class iOSIAPBridge { // 声明导入的C函数。函数名前的下划线是C语言的常见约定。 // CallingConvention.Cdecl 是必须的,因为Objective-C/C++使用C调用约定。 [DllImport("__Internal")] private static extern void _iap_initialize(string gameObjectName); [DllImport("__Internal")] private static extern void _iap_requestProducts(string productIds); [DllImport("__Internal")] private static extern void _iap_purchaseProduct(string productId); [DllImport("__Internal")] private static extern void _iap_restorePurchases(); // 包装方法,供C#层调用 public static void Initialize(string gameObjectName) { if (Application.platform == RuntimePlatform.IPhonePlayer) { _iap_initialize(gameObjectName); } else { Debug.LogWarning("iOS IAP 功能仅在 iOS 平台可用。"); } } public static void RequestProducts(string productIds) { if (Application.platform == RuntimePlatform.IPhonePlayer) { _iap_requestProducts(productIds); } } public static void PurchaseProduct(string productId) { if (Application.platform == RuntimePlatform.IPhonePlayer) { _iap_purchaseProduct(productId); } } public static void RestorePurchases() { if (Application.platform == RuntimePlatform.IPhonePlayer) { _iap_restorePurchases(); } } }

3.2 iOS原生层:Objective-C++实现

在Unity项目的Assets目录下创建Plugins/iOS文件夹,将以下UnityIAPManager.mm文件放入其中。Unity在构建Xcode工程时会自动将其包含。

// UnityIAPManager.mm #import <StoreKit/StoreKit.h> #import <Foundation/Foundation.h> // 定义一个C接口,供C#通过DllImport调用 extern "C" { // 初始化,设置交易观察者 void _iap_initialize(const char* gameObjectName); // 请求商品信息 void _iap_requestProducts(const char* productIds); // 购买商品 void _iap_purchaseProduct(const char* productId); // 恢复购买 void _iap_restorePurchases(); } // 内部使用的Objective-C类,遵循StoreKit协议 @interface UnityIAPNativeManager : NSObject <SKProductsRequestDelegate, SKPaymentTransactionObserver> @property (nonatomic, strong) NSArray<SKProduct *> *products; @property (nonatomic, copy) NSString *unityCallbackObject; // 接收UnitySendMessage的GameObject名 + (instancetype)sharedInstance; @end @implementation UnityIAPNativeManager + (instancetype)sharedInstance { static UnityIAPNativeManager *sharedInstance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ sharedInstance = [[self alloc] init]; }); return sharedInstance; } - (instancetype)init { if (self = [super init]) { // 将自己添加为支付队列的观察者,这是接收所有交易更新的关键 [[SKPaymentQueue defaultQueue] addTransactionObserver:self]; NSLog(@"[UnityIAP] 已添加交易观察者"); } return self; } - (void)initializeWithGameObject:(const char*)gameObjectName { _unityCallbackObject = [NSString stringWithUTF8String:gameObjectName]; NSLog(@"[UnityIAP] 初始化完成,回调对象: %@", _unityCallbackObject); } - (void)requestProductsWithIds:(const char*)productIdsCStr { NSString *productIdsStr = [NSString stringWithUTF8String:productIdsCStr]; NSArray *productIdArray = [productIdsStr componentsSeparatedByString:@","]; NSSet *productIdentifiers = [NSSet setWithArray:productIdArray]; SKProductsRequest *productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers]; productsRequest.delegate = self; [productsRequest start]; NSLog(@"[UnityIAP] 开始请求商品信息: %@", productIdArray); } - (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response { NSLog(@"[UnityIAP] 收到商品信息响应"); NSMutableArray *productInfoArray = [NSMutableArray array]; NSNumberFormatter *priceFormatter = [[NSNumberFormatter alloc] init]; [priceFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4]; [priceFormatter setNumberStyle:NSNumberFormatterCurrencyStyle]; for (SKProduct *product in response.products) { [priceFormatter setLocale:product.priceLocale]; NSString *localizedPrice = [priceFormatter stringFromNumber:product.price]; // 构建商品信息字符串,格式:id|title|desc|price // 注意:标题和描述中可能包含管道符‘|’,需要转义或使用其他分隔符,这里简单处理 NSString *info = [NSString stringWithFormat:@"%@|%@|%@|%@", product.productIdentifier, product.localizedTitle ?: @"", product.localizedDescription ?: @"", localizedPrice ?: @""]; [productInfoArray addObject:info]; NSLog(@"[UnityIAP] 找到商品: %@ - %@", product.productIdentifier, localizedPrice); } // 无效商品ID for (NSString *invalidId in response.invalidProductIdentifiers) { NSLog(@"[UnityIAP] 无效商品ID: %@", invalidId); // 可以通知Unity哪些ID无效 } // 将所有商品信息拼接成一个字符串,用逗号分隔,发送回Unity NSString *resultString = [productInfoArray componentsJoinedByString:@","]; UnitySendMessage([self.unityCallbackObject UTF8String], "OnProductsReceived", [resultString UTF8String]); } - (void)purchaseProduct:(const char*)productIdCStr { NSString *productId = [NSString stringWithUTF8String:productIdCStr]; for (SKProduct *product in self.products) { if ([product.productIdentifier isEqualToString:productId]) { SKPayment *payment = [SKPayment paymentWithProduct:product]; [[SKPaymentQueue defaultQueue] addPayment:payment]; NSLog(@"[UnityIAP] 已加入支付队列: %@", productId); return; } } // 如果商品未找到(理论上不应该发生,因为C#层已检查),通知Unity错误 NSString *errorMsg = [NSString stringWithFormat:@"Product %@ not found in local catalog.", productId]; UnitySendMessage([self.unityCallbackObject UTF8String], "OnPurchaseFailed", [errorMsg UTF8String]); } - (void)restorePurchases { // 对于非消耗品和订阅,调用此方法会向用户弹窗要求登录Apple ID,并重新发送已购买的交易 [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; NSLog(@"[UnityIAP] 开始恢复购买"); } #pragma mark - SKPaymentTransactionObserver // 这是核心回调,所有交易状态更新都在这里处理 - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions { for (SKPaymentTransaction *transaction in transactions) { switch (transaction.transactionState) { case SKPaymentTransactionStatePurchasing: // 交易正在进行中,无需特别处理 NSLog(@"[UnityIAP] 交易进行中: %@", transaction.payment.productIdentifier); break; case SKPaymentTransactionStatePurchased: { // 交易成功完成! NSLog(@"[UnityIAP] 交易成功: %@, 交易ID: %@", transaction.payment.productIdentifier, transaction.transactionIdentifier); // 获取收据。iOS 7+ 推荐使用appStoreReceiptURL获取整个应用的收据。 NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL]; NSString *receiptBase64 = [receiptData base64EncodedStringWithOptions:0]; // 构建回调信息,包含交易ID和收据数据 NSString *callbackInfo = [NSString stringWithFormat:@"%@|%@", transaction.transactionIdentifier ?: @"", receiptBase64 ?: @""]; UnitySendMessage([self.unityCallbackObject UTF8String], "OnPurchaseSuccess", [callbackInfo UTF8String]); // 重要:必须调用finishTransaction,从队列中移除该交易。 // 但注意:在服务器验证成功并发放道具后,才应该最终调用。这里先调用,假设服务器验证是同步或可靠的。 // 更安全的做法是将交易暂存,等服务器确认后再finish。这里为简化先finish。 [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; break; } case SKPaymentTransactionStateFailed: { // 交易失败(用户取消、支付失败等) NSLog(@"[UnityIAP] 交易失败: %@, 错误: %@", transaction.payment.productIdentifier, transaction.error.localizedDescription); NSString *errorMsg = transaction.error.localizedDescription ?: @"Unknown error"; UnitySendMessage([self.unityCallbackObject UTF8String], "OnPurchaseFailed", [errorMsg UTF8String]); [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; break; } case SKPaymentTransactionStateRestored: // 恢复购买完成的交易(针对非消耗品) NSLog(@"[UnityIAP] 交易已恢复: %@", transaction.payment.productIdentifier); // 处理恢复的逻辑(通常与购买成功类似,需要服务器验证) // UnitySendMessage(... "OnPurchaseSuccess" ...); // 可以复用或使用单独回调 [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; break; case SKPaymentTransactionStateDeferred: // 交易已推迟(例如,儿童发起购买需要家长同意) NSLog(@"[UnityIAP] 交易被推迟: %@", transaction.payment.productIdentifier); // 通知Unity交易处于等待状态 UnitySendMessage([self.unityCallbackObject UTF8String], "OnPurchaseDeferred", [transaction.payment.productIdentifier UTF8String]); break; default: break; } } } - (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue { NSLog(@"[UnityIAP] 恢复购买流程完成"); UnitySendMessage([self.unityCallbackObject UTF8String], "OnRestoreFinished", "Success"); } - (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error { NSLog(@"[UnityIAP] 恢复购买失败: %@", error.localizedDescription); UnitySendMessage([self.unityCallbackObject UTF8String], "OnRestoreFinished", [error.localizedDescription UTF8String]); } @end // C函数实现 void _iap_initialize(const char* gameObjectName) { [[UnityIAPNativeManager sharedInstance] initializeWithGameObject:gameObjectName]; } void _iap_requestProducts(const char* productIds) { [[UnityIAPNativeManager sharedInstance] requestProductsWithIds:productIds]; } void _iap_purchaseProduct(const char* productId) { [[UnityIAPNativeManager sharedInstance] purchaseProduct:productId]; } void _iap_restorePurchases() { [[UnityIAPNativeManager sharedInstance] restorePurchases]; }

3.3 服务器端验证示例(Node.js片段)

客户端拿到收据(Base64字符串receiptData)后,需要发送到自己的游戏服务器。服务器进行验证:

// 示例:Node.js + Express 服务器端验证路由 const express = require('express'); const axios = require('axios'); const router = express.Router(); router.post('/verify-iap-receipt', async (req, res) => { const { receiptData, productId } = req.body; // 1. 基本校验 if (!receiptData || !productId) { return res.status(400).json({ success: false, message: 'Missing parameters' }); } // 2. 准备请求苹果验证服务器的数据 const requestData = { 'receipt-data': receiptData, 'password': 'YOUR_SHARED_SECRET', // 从App Store Connect获取,用于订阅验证,非订阅可空 'exclude-old-transactions': true // 建议为true,只返回最新交易 }; // 3. 判断使用沙盒还是生产环境URL // 技巧:先尝试生产环境,如果返回状态码21007(沙盒收据发往生产环境),则改用沙盒环境 let verificationUrl = 'https://buy.itunes.apple.com/verifyReceipt'; let isSandbox = false; try { let response = await axios.post(verificationUrl, requestData); let result = response.data; // 状态码21007表示收据是沙盒环境的,但发往了生产环境 if (result.status === 21007) { verificationUrl = 'https://sandbox.itunes.apple.com/verifyReceipt'; isSandbox = true; response = await axios.post(verificationUrl, requestData); result = response.data; } // 4. 验证核心逻辑 if (result.status === 0) { // 验证通过 const receipt = result.receipt; const inApp = receipt.in_app || []; // 购买记录数组 // 查找与本次productId匹配的、且未处理过的交易 // 注意:需要根据transaction_id去重,防止重复发放 const targetTransaction = inApp.find(tx => tx.product_id === productId && !isTransactionProcessed(tx.transaction_id) // 假设的查重函数 ); if (targetTransaction) { // 5. 进一步校验(可选但重要) // - 校验bundle_id是否与你的App一致 if (receipt.bundle_id !== 'com.yourcompany.yourgame') { return res.json({ success: false, message: 'Bundle ID mismatch' }); } // - 校验商品价格(从receipt里可以拿到,与你后台配置对比) // - 校验购买时间、是否退款等(latest_receipt_info字段可能包含更多信息) // 6. 记录该笔交易,防止重复验证 markTransactionAsProcessed(targetTransaction.transaction_id); // 7. 通知游戏服务器发放道具 // await grantItemToUser(req.userId, productId); return res.json({ success: true, transactionId: targetTransaction.transaction_id, environment: isSandbox ? 'Sandbox' : 'Production' }); } else { // 未找到匹配的、未处理的交易 return res.json({ success: false, message: 'No valid transaction found for this product' }); } } else { // 苹果验证失败 return res.json({ success: false, message: `Apple verification failed with status: ${result.status}` }); } } catch (error) { console.error('验证收据时发生网络或服务器错误:', error); return res.status(500).json({ success: false, message: 'Server error during verification' }); } }); // 辅助函数:检查交易是否已处理(需结合数据库实现) function isTransactionProcessed(transactionId) { // 查询数据库,检查该transaction_id是否已存在并处理过 // 返回 true 或 false return false; // 示例 } function markTransactionAsProcessed(transactionId) { // 将transactionId插入数据库,标记为已处理 }

4. 关键细节、陷阱与实战经验

实现基本流程后,真正的挑战在于细节处理。以下是我从多个项目中总结的关键点:

4.1 收据处理与验证的进阶问题

  • 收据获取时机:在SKPaymentTransactionStatePurchased状态时,立即从appStoreReceiptURL获取收据数据。但要注意,在极少数情况下(如首次安装后购买),收据文件可能尚未生成或更新,导致读取为空。一个健壮的做法是,如果收据为空,可以延迟一小段时间(如0.5秒)后重试,或者监听SKPaymentQueue的更新,等待收据文件就绪。
  • 收据刷新:对于订阅商品,苹果建议定期(如每天)在服务器端验证latest_receipt(如果请求中包含了password,且是自动续期订阅,响应中会包含此字段),以检查订阅是否续期或已过期。这需要你的服务器有一个定时任务。
  • 重复交易与FinishTransaction的时机:这是最常见的坑之一。finishTransaction:必须在交易处理完毕后调用,以将其从支付队列中移除。最佳实践是:在客户端将收据发送给服务器后,不要立即finish。而是等待服务器验证成功并返回确认后,再调用finish。如果finish过早,而服务器验证失败或网络中断,这笔交易可能会在下次启动时再次出现在队列中(因为未finish),导致重复处理。你需要设计一个机制来暂存已发送但未确认的交易ID,并在服务器确认后清理。
  • 沙盒环境与生产环境:测试时务必使用沙盒环境(在Xcode中设置StoreKit配置,或使用TestFlight)。沙盒环境收据必须发往沙盒验证URL。上面服务器代码中“先生产后沙盒”的降级策略是行业通用做法。

4.2 客户端状态管理与用户体验

  • 网络中断处理:购买过程中网络断开怎么办?StoreKit的交易状态更新是本地队列管理的,即使断网,交易状态也会被记录。当网络恢复后,paymentQueue:updatedTransactions:可能会再次收到之前未完成的交易。因此,你的代码必须能处理重复的交易状态通知,通过交易ID进行幂等性判断。
  • UI阻塞与提示:发起购买(addPayment:)和恢复购买(restoreCompletedTransactions)可能会弹出系统弹窗或要求用户输入密码。在此期间,你的游戏应该适当暂停或给出等待提示。特别是恢复购买,可能会触发Apple ID登录,过程较长。
  • “恢复购买”按钮的实现:对于非消耗品(如解锁关卡包)和订阅,必须提供“恢复购买”按钮,通常放在设置页面。它的实现就是调用restoreCompletedTransactions。恢复完成后,需要通过paymentQueue:updatedTransactions:收到SKPaymentTransactionStateRestored状态的回调,并像处理购买成功一样进行服务器验证和道具发放。
  • 交易状态“Deferred”的处理SKPaymentTransactionStateDeferred状态表示交易已发起但未最终完成,常见于“询问购买”(Ask to Buy)功能,即儿童发起购买需要家长批准。此时,你不能发放道具,但应该给用户一个明确的等待提示(如“等待家长批准”)。后续批准或拒绝会再次触发状态更新。

4.3 上架审核与配置清单

  • App Store Connect配置:确保内购商品的审核截图和描述符合要求,商品类型选择正确。商品ID一旦创建,不能修改,但可以删除后重建(已购买用户会受影响)。
  • 协议、税务和银行业务:在App Store Connect中必须填写完整的协议、税务和银行业务信息,否则即使代码正确,真实用户也无法支付。
  • 沙盒测试账号:在App Store Connect中创建专门的沙盒测试员账号(不能用真实的Apple ID)。测试时,在设备的设置中退出iCloud,然后在App内购买时使用沙盒账号登录。
  • 清除沙盒环境:有时沙盒环境会出现诡异问题(如商品加载不出)。可以尝试在设备设置中退出沙盒账号,甚至重置设备的沙盒环境(通过删除App重装)。
  • 隐私政策与购买说明:如果App内有内购,必须在App Store的元数据中提供隐私政策链接,并在内购商品附近清晰说明购买的是什么、是否可恢复等。

5. 常见问题排查与调试技巧

即使按照最佳实践开发,内购集成依然可能遇到各种问题。这里列出一个速查表:

问题现象可能原因排查步骤与解决方案
商品请求失败,回调无效商品ID1. 商品ID在App Store Connect中不存在或未处于“准备提交”或“已批准”状态。
2. 商品ID拼写错误。
3. 使用的Apple开发者账号/沙盒账号无权限。
4. 网络问题。
1. 登录App Store Connect,确认商品状态。
2. 对比代码中的ID和后台配置的ID(注意大小写)。
3. 确认设备登录的Apple ID是有效的开发者账号或沙盒账号。
4. 在productsRequest:didReceiveResponse:中打印response.invalidProductIdentifiers
购买时一直转圈,不弹支付窗1. 设备未登录任何Apple ID。
2. 设备设置了支付限制(如屏幕使用时间)。
3. 商品信息未成功请求到。
1. 检查设备设置->Apple ID,确保已登录。
2. 检查设置->屏幕使用时间->内容和隐私限制->iTunes与App Store购买。
3. 确保purchaseProduct前,商品已成功加载到SKProduct列表。
购买成功回调收到,但收据为空1. 收据文件尚未被系统写入或更新(罕见)。
2.appStoreReceiptURL路径访问权限问题。
1. 延迟100-500毫秒再读取收据,或监听收据刷新通知(SKReceiptRefreshRequest)。
2. 确保从appStoreReceiptURL读取数据,而不是旧的transactionReceipt属性。
服务器验证返回状态21002/21003等收据数据在传输或处理中损坏。1. 检查客户端发送的收据Base64字符串是否完整,无换行符。
2. 在服务器端打印收到的收据字符串长度,与客户端对比。
3. 确保服务器发送给苹果的JSON格式正确,receipt-data字段的值是Base64字符串。
沙盒测试正常,上线后支付失败1. 服务器验证URL仍指向沙盒环境。
2. 生产环境商品状态异常(如被拒绝、下架)。
3. 用户所在地区不支持该商品或支付方式。
1. 确保生产环境服务器使用生产验证URL,并正确处理21007状态码。
2. 检查App Store Connect中商品状态。
3. 这是正常情况,需做好客户端错误提示。
恢复购买无反应或回调不触发1. 没有非消耗品或订阅商品可恢复。
2. 用户取消了恢复流程(如未输入密码)。
3. 交易观察者(SKPaymentTransactionObserver)未正确添加或已移除。
1. 确保之前用同一个Apple ID购买过非消耗品。
2. 实现paymentQueue:restoreCompletedTransactionsFailedWithError:回调处理错误。
3. 确保在App生命周期早期(如application:didFinishLaunchingWithOptions:)添加观察者,且在整个生命周期内保持。Unity桥接应在初始化时就添加。
重复收到已完成的交易回调交易未正确调用finishTransaction:检查代码逻辑,确保在交易最终处理完毕后(尤其是服务器验证成功后)调用[[SKPaymentQueue defaultQueue] finishTransaction:transaction];

调试技巧:

  • 在Xcode中查看控制台日志:所有NSLog输出都会在这里,是排查原生层问题的第一现场。
  • 使用NSLog详细打印:在paymentQueue:updatedTransactions:的每个状态分支都打印详细的交易信息,包括transactionIdentifier,productIdentifier,error等。
  • 模拟交易中断:在支付弹窗出现时,强制关闭App或切换网络,测试你的代码是否能正确处理中断后的恢复。
  • 使用Charles/Fiddler抓包:在测试服务器验证时,抓取服务器与苹果验证服务器之间的HTTPS请求和响应,查看具体的状态码和返回数据。

集成Unity与iOS原生内购是一个系统工程,涉及客户端、服务器、苹果后台三方的协调。核心在于理解StoreKit的事件驱动模型、严守服务器验证的安全底线,并细致处理各种边界情况和异常流。希望这篇从原理到实战、从代码到避坑的详细解析,能帮助你构建出稳定可靠的游戏内购系统。记住,内购无小事,每一行代码都关系到真金白银的收入和玩家的体验,多测试、多验证总是没错的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询