拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Unity应用内购脚本开发:从架构设计到服务器验证的完整实现

1. 项目概述为什么Unity内购不能只靠拖拽做Unity移动端开发内购IAP是绕不开的坎。很多新手甚至一些有经验的开发者一提到内购第一反应就是去Asset Store找个插件或者跟着教程拖拖拽拽把商品ID填进去就完事了。结果呢上线后用户反馈“购买失败”、“扣了钱没到账”、“恢复购买没反应”后台数据对不上查起问题来像无头苍蝇。我经历过不止一个项目因为内购逻辑写得稀烂导致上线后收入损失惨重甚至被平台警告。Unity官方的In App Purchasing包com.unity.purchasing确实提供了强大的跨平台支持但它只是一个“桥梁”和“规范”真正的业务逻辑、异常处理、数据同步、防作弊设计全部要靠我们自己的脚本去实现。脚本写得好内购稳定可靠收入流水清晰脚本写得糙那就是给自己埋雷。这篇内容就是要把Unity IAP这套东西从官方API的“形”到实际可用的“神”用脚本彻底讲透。我们不只讲怎么调通购买接口更要讲清楚每个回调背后的状态、网络异常怎么处理、如何设计一个健壮的商品管理系统以及那些官方文档里不会写的“坑”。无论你是要做iOS的App Store、Google Play还是国内的各大安卓渠道这套脚本思维都是通用的核心。2. Unity IAP核心架构与脚本设计思路2.1 官方IAP包的角色是翻译官不是业务员首先得摆正对com.unity.purchasing这个包的认识。它不是帮你把内购所有事都办了的“大管家”而是一个“协议翻译官”。它的核心价值在于用一套统一的C# APIIStoreControllerIExtensionProvider屏蔽了iOS的StoreKit、Google的Billing Client、Amazon的IAP SDK等等这些平台原生接口的差异。举个例子在iOS上初始化商店叫SKPaymentQueue在Google Play上叫BillingClient.startConnection()。但用了Unity IAP后你在脚本里只需要调用UnityPurchasing.Initialize。它帮你干了所有脏活累活。但是初始化成功之后呢用户点击购买按钮后钱扣了但商品没发放怎么办用户换了设备要恢复购买逻辑怎么走这些业务层面的“灵魂”必须由你的脚本来填充。所以我们的脚本设计必须围绕两个核心对象展开IStoreController: 购买操作的主入口调用它的InitiatePurchase监听它的ProcessPurchase事件。IExtensionProvider: 提供平台特定扩展功能比如iOS的RestoreTransactionsGoogle Play的DeferPurchase。一个常见的架构误区是把购买触发、结果处理、商品发放的代码全部揉在一个MonoBehaviour里。这会导致代码高度耦合难以维护和测试。正确的脚本设计思路应该是分层。2.2 脚本分层架构让内购逻辑清晰可维护我推荐将内购脚本至少分为三层这在实际项目中经过验证能极大提升稳定性和可维护性。第一层IAP管理器IAPManager这是一个单例类是整个内购系统的总控中心。它的职责最重初始化Unity IAP服务配置商品列表监听初始化成功/失败回调。持有IStoreController和IExtensionProvider引用作为唯一入口对外提供购买、恢复等接口。处理购买流程的核心事件OnPurchaseCompleteOnPurchaseFailed。这里是决定“扣款成功后到底该给用户发什么”的关键逻辑所在。提供查询接口比如根据商品ID查询商品信息、查询是否已购买等。第二层商品配置与数据层ProductCatalog内购商品不是硬编码在脚本里的。我们需要一个可配置的、易于管理的商品定义方式。Unity IAP支持通过代码配置ProductDefinition但我更推荐结合使用ScriptableObject来创建商品配置资产。 你可以创建一个IAPProductSO的ScriptableObject里面包含string productId: 在各个商店后台配置的唯一ID如com.youcompany.game.gem100。ProductType: 枚举可消耗型如金币、非消耗型如去广告、订阅型。string localizedTitle和string localizedDescription: 本地化的名称和描述虽然通常从商店后台拉取但可以设个默认值。decimal localizedPrice: 价格同样通常从商店拉取。 这样策划或运营可以在Unity编辑器里像搭积木一样配置商品包无需修改代码。第三层UI交互层PurchaseButton/UI这一层是表现层应该尽可能“薄”。一个购买按钮的脚本只应该做两件事获取当前按钮对应的商品ID。调用IAPManager.Instance.PurchaseProduct(productId)。绝对不要在UI按钮的脚本里直接处理购买成功后的金币增加、界面跳转等业务逻辑。这些应该由IAP管理器在核心事件中触发UI层通过事件监听如C#的Action事件或Messenger系统来更新界面状态。这样做的好处是当购买流程需要在无UI的情况下如服务器验证回调发放商品时逻辑依然畅通。注意初始化时机的选择。不要在场景一加载就初始化IAP。应该在玩家有可能进行购买操作的场景如主城、商店界面加载时或者游戏启动后网络稳定时进行初始化。初始化是一个异步网络过程失败是常事必须有加载状态提示和重试机制。3. 核心脚本实现详解从初始化到购买完成3.1 初始化流程脚本化不仅仅是调用一个方法初始化是内购一切功能的基础也是最容易出错的地方。很多教程就一行UnityPurchasing.Initialize(this, builder)这远远不够。首先我们需要创建商品配置。假设我们使用了上述的ScriptableObject方案那么初始化脚本可能这样写using UnityEngine; using UnityEngine.Purchasing; using System.Collections.Generic; public class IAPManager : MonoBehaviour, IStoreListener { private static IAPManager _instance; public static IAPManager Instance _instance; private IStoreController _storeController; private IExtensionProvider _extensionProvider; // 在Inspector中拖入配置好的商品ScriptableObject数组 [SerializeField] private IAPProductSO[] _productAssets; void Awake() { if (_instance ! null _instance ! this) { Destroy(gameObject); return; } _instance this; DontDestroyOnLoad(gameObject); InitializePurchasing(); } private void InitializePurchasing() { if (IsInitialized()) return; var builder ConfigurationBuilder.Instance(StandardPurchasingModule.Instance()); foreach (var productAsset in _productAssets) { builder.AddProduct(productAsset.productId, productAsset.productType, new IDs{ {productAsset.productId, AppleAppStore.Name}, {productAsset.productId, GooglePlay.Name}, // 可以添加更多商店的映射 }); } // 关键开始初始化并指定this为监听器 UnityPurchasing.Initialize(this, builder); } private bool IsInitialized() { return _storeController ! null _extensionProvider ! null; } }这里有几个关键点单例与持久化DontDestroyOnLoad确保IAP管理器在场景切换时存活购买回调不会丢失。商品ID映射new IDs()允许你为同一个逻辑商品设置在不同商店的不同ID。虽然通常保持一样但有些特殊渠道可能需要映射。IStoreListener接口这是核心我们的管理器需要实现这个接口来处理初始化回调和购买回调。3.2 实现IStoreListener接口处理所有异步回调IStoreListener接口定义了四个必须实现的方法它们构成了内购逻辑的脊柱。public class IAPManager : MonoBehaviour, IStoreListener { // ... 之前的代码 ... // 接口方法1: 初始化成功 public void OnInitialized(IStoreController controller, IExtensionProvider extensions) { Debug.Log(IAP初始化成功); _storeController controller; _extensionProvider extensions; // 初始化成功后可以更新UI显示商品价格等信息 foreach (var product in controller.products.all) { Debug.Log($商品: {product.definition.id}, 价格: {product.metadata.localizedPriceString}); } } // 接口方法2: 初始化失败 public void OnInitializeFailed(InitializationFailureReason error) { Debug.LogError($IAP初始化失败: {error}); // 根据错误原因进行友好提示并提供重试按钮 // InitializationFailureReason 枚举包括PurchasingUnavailable, NoProductsAvailable, AppNotKnown 等 } // 接口方法3: 购买成功 (最核心) public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args) { // args.purchasedProduct 包含了购买的商品信息和收据 var product args.purchasedProduct; string productId product.definition.id; Debug.Log($购买成功: {productId}); // 这里是业务逻辑的核心 // 1. 验证收据强烈建议进行服务器验证下文会详述 // 2. 根据productId发放对应的游戏内物品金币、道具、解锁关卡等 GrantProduct(productId); // 3. 如果是消耗型商品必须告诉IAP系统可以完成交易 if (product.definition.type ProductType.Consumable) { return PurchaseProcessingResult.Complete; // 交易结束 } else { // 非消耗品或订阅返回Pending但通常我们也返回Complete除非你需要延迟处理如服务器验证 // 返回Pending后需要在后续调用 controller.ConfirmPendingPurchase(product) return PurchaseProcessingResult.Complete; } } // 接口方法4: 购买失败 public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason) { Debug.LogWarning($购买失败: {product.definition.id}, 原因: {failureReason}); // 根据失败原因提示用户如用户取消、网络错误、支付方式无效等 // PurchaseFailureReason 枚举包括PurchasingUnavailable, ExistingPurchasePending, ProductUnavailable, etc. } // 提供给外部的购买方法 public void PurchaseProduct(string productId) { if (!IsInitialized()) { Debug.LogError(IAP未初始化无法购买); // 可以在这里触发重新初始化或提示用户 return; } Product product _storeController.products.WithID(productId); if (product ! null product.availableToPurchase) { Debug.Log($发起购买: {productId}); _storeController.InitiatePurchase(product); } else { Debug.LogError($商品不可用: {productId}); } } private void GrantProduct(string productId) { // 这里实现发放逻辑可以是一个switch语句也可以从配置数据中读取 // 例如 switch (productId) { case com.youcompany.game.gem100: PlayerData.Instance.AddGems(100); break; case com.youcompany.game.remove_ads: PlayerData.Instance.SetAdsRemoved(true); break; default: Debug.LogError($未知的商品ID: {productId}); break; } // 发放后可以通过事件通知UI更新 // EventSystem.Instance.TriggerEvent(new PurchaseSuccessEvent(productId)); } }在ProcessPurchase中返回PurchaseProcessingResult.Complete是至关重要的一步。对于消耗品这标志着交易最终完成商店会认为商品已交付。如果忘记返回或者逻辑异常没能执行到这一步可能会导致用户付了钱但商店状态一直“待处理”进而无法再次购买同一商品。3.3 恢复购买脚本实现不仅仅是iOS的需求很多人以为只有iOS需要“恢复购买”功能。其实Google Play等平台也有类似的机制虽然叫法不同用于用户重装应用或更换设备后恢复其拥有的非消耗品和订阅。Unity IAP通过IExtensionProvider提供了统一的恢复接口。public class IAPManager : MonoBehaviour, IStoreListener { // ... 之前的代码 ... // 恢复购买方法 public void RestorePurchases() { if (!IsInitialized()) { Debug.LogError(IAP未初始化无法恢复); return; } // 判断平台调用对应的恢复方法 #if UNITY_IOS || UNITY_STANDALONE_OSX var appleExt _extensionProvider.GetExtensionIAppleExtensions(); appleExt.RestoreTransactions((result, error) { if (result) { // 恢复流程已启动结果将通过ProcessPurchase回调返回 Debug.Log(恢复交易已启动...); } else { Debug.LogError($恢复交易启动失败: {error}); } }); #elif UNITY_ANDROID // 对于Google Play通常不需要显式调用恢复。 // 初始化成功后已购买的非消耗品会自动出现在 controller.products 中并可能再次触发ProcessPurchase。 // 但为了统一体验可以调用一个标准方法或直接提示用户。 var googleExt _extensionProvider.GetExtensionIGooglePlayStoreExtensions(); // Google Play没有直接的RestoreTransactions但可以检查是否有未完成的交易 Debug.Log(Android平台已购买项目将在初始化时自动同步。); #endif } }恢复购买的逻辑流当用户点击“恢复购买”按钮调用上述方法主要是iOS。系统会与App Store通信查询该用户在此应用下的所有非消耗品和订阅记录。对于每一笔有效的未消费记录商店会重新发起一次购买流程也就是说你的ProcessPurchase回调会再次被触发product参数里就是需要恢复的商品。这意味着你的GrantProduct发放逻辑必须是幂等的。即无论调用多少次给用户发放“去广告”权限的结果都应该是“用户拥有去广告权限”而不是“用户获得100个去广告权限”。通常对于非消耗品我们在发放前要先检查一下玩家是否已经拥有。4. 收据验证与防作弊设计脚本的安全防线4.1 为什么客户端验证等于没有验证在ProcessPurchase中直接发放物品是极度危险的。一个简单的内存修改工具如GameGuardian就能伪造购买成功的结果或者拦截网络请求让你的游戏“免费”发放所有内购物品。Unity IAP提供的本地收据product.receipt是一个JSON字符串包含了本次购买的详细信息但它存储在客户端同样可以被篡改。因此服务器端验证是商业级内购的必选项。流程应该是客户端购买成功收到收据。客户端将收据product.receipt发送到你自己的游戏服务器。游戏服务器根据平台Apple/Google等将收据转发到对应的官方验证服务器Apple的verifyReceipt端点或Google的purchases.products.verifyAPI。官方服务器返回验证结果确认该收据真实、有效、且未被使用过。你的游戏服务器确认后通知游戏客户端“验证通过可以发物品了”并记录这笔订单到数据库。4.2 实现带服务器验证的购买流程脚本我们需要改造ProcessPurchase方法将其变为一个异步的、等待服务器响应的过程。public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args) { var product args.purchasedProduct; string productId product.definition.id; string receipt product.receipt; // 完整的收据字符串 Debug.Log($购买成功开始服务器验证: {productId}); // 立即返回Pending告诉Unity IAP“交易待处理先别完结” // 这样能防止用户在服务器验证期间重复点击购买 PurchaseProcessingResult result PurchaseProcessingResult.Pending; // 启动一个协程或异步任务进行服务器验证 StartCoroutine(ValidatePurchaseWithServer(product, receipt, (isValid) { if (isValid) { Debug.Log($服务器验证成功发放商品: {productId}); GrantProduct(productId); // 验证通过确认交易 _storeController.ConfirmPendingPurchase(product); } else { Debug.LogError($服务器验证失败交易取消: {productId}); // 验证失败不发放商品。交易状态将保持用户可能需要联系客服。 // 注意不要调用ConfirmPendingPurchase但可以考虑记录日志或提示用户。 } })); return result; // 返回Pending } private System.Collections.IEnumerator ValidatePurchaseWithServer(Product product, string receipt, System.Actionbool callback) { // 构建发送给游戏服务器的数据 var validationRequest new PurchaseValidationRequest { productId product.definition.id, receipt receipt, store product.definition.storeSpecificId, // 或通过Application.platform判断 userId PlayerData.Instance.UserId // 关联用户ID }; string json JsonUtility.ToJson(validationRequest); byte[] postData System.Text.Encoding.UTF8.GetBytes(json); using (UnityWebRequest www new UnityWebRequest(https://your-game-server.com/api/validate_purchase, POST)) { www.uploadHandler new UploadHandlerRaw(postData); www.downloadHandler new DownloadHandlerBuffer(); www.SetRequestHeader(Content-Type, application/json); yield return www.SendWebRequest(); if (www.result UnityWebRequest.Result.Success) { var response JsonUtility.FromJsonPurchaseValidationResponse(www.downloadHandler.text); callback(response.isValid); } else { Debug.LogError($验证请求失败: {www.error}); // 网络失败如何处理建议是失败让用户重试或联系客服。避免网络不好就免费送物品。 callback(false); } } }关键决策点网络超时与重试。服务器验证可能因为网络问题失败。你需要设计重试机制比如最多重试3次并设置一个合理的超时时间如10秒。如果最终仍失败应向用户显示明确的错误信息并建议他们检查网络或联系支持而不是直接发放物品。同时在服务器端对于同一笔收据的重复验证请求要做好幂等处理。4.3 处理订阅商品与本地验证订阅商品ProductType.Subscription比普通商品更复杂因为存在状态是否过期、是否自动续期。Unity IAP的product对象里有一个metadata属性其中subscriptionInfo字段通过GetExtensionISubscriptionInfo获取包含了丰富的订阅信息如过期时间、是否免费试用期、是否自动续订等。即使有服务器验证客户端也应当做基础校验。例如在发放订阅权益前可以先检查subscriptionInfo.isExpired()。如果本地已经过期即使服务器验证通过也可能意味着是过期的收据。这是一种防御性编程。private void GrantSubscriptionProduct(string productId, Product product) { var subscriptionInfo product.GetExtensionISubscriptionInfo(); if (subscriptionInfo ! null) { if (subscriptionInfo.isExpired() Result.True) { Debug.LogWarning(订阅已过期不发放权益。); return; } // 检查是否在免费试用期等 // ... } // 发放订阅权益如VIP身份、月卡奖励等 }5. 平台适配与调试脚本中的那些“坑”5.1 各平台商店配置与脚本的关联脚本写得再好商店后台配置错了也白搭。这里有几个脚本逻辑需要与后台配置强关联的点商品ID脚本中ProductDefinition的id必须与App Store Connect、Google Play Console等后台填写的完全一致包括大小写。建议使用反向域名格式。商品类型ProductTypeConsumable NonConsumable Subscription必须与后台配置的类型匹配。把消耗品配置成非消耗品会导致用户无法重复购买。价格脚本里配置的价格是默认值最终以商店后台配置的当地货币价格为准。脚本中可以通过product.metadata.localizedPrice来获取并显示。5.2 Unity编辑器下的模拟测试在真机测试前可以在编辑器里用Unity IAP的模拟系统Fake Store进行快速流程测试。这需要在初始化时使用StandardPurchasingModule.Instance()的一个特殊参数。// 在InitializePurchasing方法中根据情况选择模块 var module StandardPurchasingModule.Instance(); // 如果在编辑器且想用模拟商店 #if UNITY_EDITOR // 使用FakeStoreUI会有模拟的购买对话框 module.useFakeStoreUIMode FakeStoreUIMode.StandardUser; #endif var builder ConfigurationBuilder.Instance(module); // ... 添加商品 ... UnityPurchasing.Initialize(this, builder);在模拟测试中你可以测试购买成功、失败、取消等各种流程而无需连接真实的商店账户或支付真钱。务必测试“恢复购买”在模拟环境下的表现虽然Fake Store的行为可能与真实商店有差异。5.3 真机调试与日志排查脚本真机调试内购非常麻烦因为涉及沙盒环境、测试账户和网络。你的脚本必须包含详尽的、分级的日志系统。public void PurchaseProduct(string productId) { if (!IsInitialized()) { Debug.LogError([IAP] 尝试购买但未初始化。); ShowMessageToUser(支付系统未就绪请稍后重试。); return; } Product product _storeController.products.WithID(productId); if (product null) { Debug.LogError($[IAP] 未找到商品ID: {productId}); ShowMessageToUser(商品信息获取失败。); return; } if (!product.availableToPurchase) { Debug.LogWarning($[IAP] 商品不可购买: {productId}。原因可能未配置、审核中、或已拥有非消耗品。); ShowMessageToUser(该商品当前不可用。); return; } Debug.Log($[IAP] 发起购买流程商品: {productId}, 价格: {product.metadata.localizedPriceString}); _storeController.InitiatePurchase(product); }在OnPurchaseFailed中更要利用好PurchaseFailureReason枚举给用户明确的、友好的提示而不是简单的“购买失败”。public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason) { string userMessage 购买过程中出现问题。; switch (failureReason) { case PurchaseFailureReason.PurchasingUnavailable: userMessage 您的设备暂不支持应用内购买或商店服务未就绪。; break; case PurchaseFailureReason.ExistingPurchasePending: userMessage 您有一笔交易正在处理中请稍后再试。; break; case PurchaseFailureReason.ProductUnavailable: userMessage 该商品已下架或暂时不可用。; break; case PurchaseFailureReason.SignatureInvalid: case PurchaseFailureReason.PaymentDeclined: userMessage 支付被拒绝请检查您的支付方式。; break; case PurchaseFailureReason.DuplicateTransaction: userMessage 检测到重复交易请勿重复点击。; break; case PurchaseFailureReason.UserCancelled: // 用户主动取消可以不提示或简单提示 userMessage 购买已取消。; break; default: userMessage $购买失败原因: {failureReason}; break; } Debug.LogWarning($[IAP] 购买失败。商品: {product.definition.id}, 原因: {failureReason}); ShowMessageToUser(userMessage); }6. 进阶脚本技巧与项目实战经验6.1 处理“幽灵商品”与初始化顺序有时你会发现在商店后台下架的商品在游戏初始化后controller.products.all里依然存在且availableToPurchase为false。这是Unity IAP的缓存机制导致的。一个健壮的脚本应该在UI上隐藏或标记此类商品。更棘手的是初始化顺序问题。如果你的游戏一启动就加载商店界面而IAP初始化是异步的那么界面可能显示“加载中”或空白。我的经验是采用事件驱动。IAP管理器在OnInitialized成功后抛出一个IAPInitializedEvent事件。商店UI脚本监听这个事件收到后才去从_storeController.products中获取商品列表并刷新UI。同时在初始化完成前所有购买按钮应该是禁用状态。6.2 设计一个可扩展的商品发放系统直接在GrantProduct里写switch-case对于小型项目可行但商品多了会变成噩梦。一个更好的模式是使用“配置表命令模式”。创建一个ProductGrantConfig的ScriptableObject或Json配置将productId映射到一个“奖励指令”字符串。{ com.company.game.gem100: ADD_CURRENCY|GEM|100, com.company.game.starter_pack: UNLOCK_HERO|warrior;ADD_ITEM|sword|1 }在GrantProduct中根据productId查找配置解析指令字符串。创建一个RewardDispatcher类里面注册了处理各种指令如ADD_CURRENCYUNLOCK_HERO的方法。GrantProduct调用RewardDispatcher.Execute(instruction)。这样新增商品和奖励时只需要修改配置表无需改动代码。策划可以自行配置复杂的奖励包。6.3 应对网络异常与订单补单逻辑移动网络环境复杂用户可能在支付成功、但客户端收到回调前或服务器验证请求发出前关闭游戏。这会导致“掉单”。应对此问题需要在脚本中增加本地订单缓存与补单机制。下单时缓存在调用InitiatePurchase的同时将productId、时间戳、一个本地生成的唯一订单号UUID保存到PlayerPrefs或本地文件的一个“待完成订单列表”中。完成时清除在ProcessPurchase中经过服务器验证并成功发放物品后从本地缓存中移除对应的订单。启动时补单游戏启动时或IAP初始化成功后检查本地“待完成订单列表”。如果列表不为空说明有未完成的订单。此时可以尝试引导用户进行“订单恢复”操作或者在谨慎设计的前提下结合服务器查询订单状态的接口进行自动补发。这个逻辑需要与服务器端紧密配合服务器需要记录每一笔验证过的收据防止同一收据被重复补发。6.4 订阅商品的状态同步与续期处理对于订阅商品你不能假设用户购买后就永远拥有。脚本需要定期或在每次应用从后台唤醒时检查订阅状态。Unity IAP 提供了RefreshSubscriptionInfo方法在IExtensionProvider的扩展中可以主动向商店请求更新订阅信息。一个常见的做法是在游戏主循环中比如每天第一次启动时或者当应用从后台回到前台时调用一个CheckSubscriptionStatus方法。该方法遍历所有订阅型商品通过GetExtensionISubscriptionInfo获取最新信息并与本地记录的到期时间对比。如果发现订阅已过期则及时收回玩家的VIP权益并更新UI提示。内购脚本的编写远不止是调用几个API。它关乎游戏的收入命脉和用户体验。从稳健的初始化、清晰的架构分层、到严谨的服务器验证、再到细致的异常处理和状态同步每一个环节都需要用脚本逻辑精心构筑。把本文讨论的这些点都考虑到并实现你的Unity应用内购系统就具备了商业级的可靠性和可维护性。记住多写日志多考虑边界情况在真实支付前进行充分的模拟和沙盒测试这样才能在上线后睡个安稳觉。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门