本文是 adat 项目的第二篇。主要内容是对 iOS 和 macOS 应用内购相关的释疑,包括各种问题出现的原因、如何解决以及最佳实践等。作者还结合亲身经历,给开发者、运营者提供了一些实用建议。
内容概览
- 配置
- 错误信息
- 本地化
- 票据
- 订阅
- 其他问题
配置
必须要上传 App 才能测试应用内购买吗?
不需要。测试内购不要求上传 App。在App Store Connect
创建好商品并确保没有问题,就可以测试内购了。
注意,不要将未开发完成的测试包上传至 App Store Connect 并提审,因为它很大概率会被拒审。如果审核被拒,内购商品将会被置为不可用,测试内购一定会失败,记得再次测试前修改商品变为可用。
推荐做法是:在 App 其他功能基本完成后,先提交一个不含内购功能的 App,待审核通过后,再把内购项加上,再提交一次审核。这样做的好处是:如果有问题,大部分已经在第一次审核的时候就暴露出来了,并且有充足的时间修改。最后的一次提交审核,基本只是审核内购项,会快很多。
如何帮助 Apple 打击购买期间的欺诈行为?
SKPayment
提供了一个applicationUsername
属性,它可以帮助 Apple 识别请求购买时的异常活动。它的值最好是服务器生成的用户标识符的单向哈希值。创建交易对象后,将该哈希值设置为 applicationUsername 属性的值,然后再添加到交易队列SKPaymentQueue
。
注意,不要用开发者账号的 Apple ID、用户的 Apple ID 或未哈希的用户标识符来填充 applicationUsername。
下面提供了一个示例方法来创建哈希值,以用户标识为参数,返回值是用户标识的SHA-256
值:
// Custom method to calculate the SHA-256 hash using Common Crypto.
- (NSString *)hashedValueForAccountName:(NSString *)userAccountName {
const int HASH_SIZE = 32;
unsigned char hashedChars[HASH_SIZE];
const char *accountName = [userAccountName UTF8String];
size_t accountNameLen = strlen(accountName);
// Confirm that the length of the user name is small enough
// to be recast when calling the hash function.
if (accountNameLen > UINT32_MAX) {
NSLog(@"Account name too long to hash: %@", userAccountName);
return nil;
}
CC_SHA256(accountName, (CC_LONG)accountNameLen, hashedChars);
// Convert the array of bytes into a string showing its hex representation.
NSMutableString *userAccountHash = [[NSMutableString alloc] init];
for (int i = 0; i < HASH_SIZE; i++) {
// Add a dash every four bytes, for readability.
if (i != 0 && i%4 == 0) {
[userAccountHash appendString:@"-"];
}
[userAccountHash appendFormat:@"%02x", hashedChars[i]];
}
return userAccountHash;
}
将方法返回结果填充到SKMutablePayment
对象的 applicationUsername 属性:
// product is an SKProduct object.
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product];
//Populate applicationUsername with your customer's username on your server.
payment.applicationUsername = [self hashedValueForAccountName:@"userNameOnYourServer"];
// Submit payment request.
[[SKPaymentQueue defaultQueue] addPayment:payment];
支持自动续期订阅的最低版本。
目前自动续期订阅
(Auto-renewable Subscriptions)只支持 iOS 和 macOS 平台,最低版本分别是iOS 4.2
和macOS 10.9
。
什么时候使用 restoreCompletedTransactions 方法?
该方法只能用于自动续期订阅和非消耗型产品,使用场景如下:
- 用户在一台设备上订阅后,换了其他设备,在新设备上需要恢复交易。
- 用户卸载重装了 App 后,需要恢复交易。
这个方法为无服务器的 App 提供了很好的支持。如果你的 App 有服务器且支持
App Store 服务器通知
(App Store Server Notifications),能够管理好用户的订阅项目,那么可以不使用该方法。
每个应用可以创建多少个内购项目?
每个开发者账号允许创建最多10000
个内购项目,账号内所有 App 共享该额度,因此每个应用至多创建 10000 个内购项目。
内购项目可以跨平台使用,也可以自定义其使用场景,可以说非常够用。
错误信息
错误信息是指调用StoreKit
的接口时弹出的提示信息,下面列举了一些常见的信息。
Your account info has changed
您的账户信息已变更。
出现该信息表明测试账号在 App Store 中登录了。沙盒账号一旦在 App Store 中登录就会变成了普通 Apple ID,即是一个正常用户账号,不再具有沙盒特性。
解决办法:在设置
中退出登录该 Apple ID,并在 App Store Connect 创建一个新的沙盒账号,暂不在设备上登录。打开 App 调用支付接口,在弹窗中输入新沙盒账号即可。
Cannot connect to iTunes Store
无法连接至 iTunes Store。
出现这种提示的可能情况有:
- 沙盒环境暂不可用。
- App 的
Info.plist
缺失CFBundleVersion
信息。 - App 在模拟器中运行,而模拟器不支持内购。
- 要购买的产品当前处于不可用状态。
针对沙盒环境不可用的情况,可以在设备的 Safari 中输入以下链接查看其状态:
https://developer.apple.com/system-status/
系统各种服务的状态:
This Apple ID has not yet been used in this iTunes Store
该 Apple ID 尚未在 iTunes Store 中使用。
出现这个提示的原因是在 iTunes Store 中登录了测试账号。
解决办法:参考 Your account info has changed。
You’ve already purchased this. Tap OK to download it again for free
您已经购买了该项目,点击确定免费下载。
这只是一个普通提示,不能当作错误来看待。这是因为你在购买一个已经购买过的非消耗型项目,StoreKit 提示你不会被再次扣费。
该提示仅针对非消耗型项目,因为只有非消耗型项目才允许有托管内容,进而才会有下载一说。
You’ve already purchased this. Would you like to get it again for free?
您已经购买了该项目,是否要免费获取?
原因同上。
This In-App Purchase has already been bought. It will be restored for free.
你已经购买了该内购项目,将为您免费恢复。
出现该提示的原因是:之前购买过产品但没有调用SKPaymentQueue
的finishTransaction
方法,这次又尝试购买相同的产品。
SKPaymentQueue 是 App Store 处理交易的全局队列,finishTransaction 方法的作用是告知 App Store,我已经彻底处理好了该笔交易,后续再也不依赖这笔交易了。App Store 收到信息后,会在适当的时机将交易从队列中移除。移除完成后,App Store 还会通过回调告知 App 移除成功:
- (void)paymentQueue:(SKPaymentQueue *)queue removedTransactions:(NSArray<SKPaymentTransaction *> *)transactions;
You’ve already purchased this In-App Purchase but it hasn’t been downloaded
您已经购买了该内购项目,但尚未下载。
原因同上,但仅针对非消耗型产品。
This is not a test user account. Please create a new account in the Sandbox environment
该账户不是测试账户,请在沙盒环境创建一个新的测试账户。
出现这个提示的原因是:调用支付接口后弹出的登录弹窗中输入了非沙盒账号。
解决办法:参考 Your account info has changed。
本地化
设备语言不是英文,但返回的产品的 localizedDescription 和 localizedTitle 却一直是英文。
返回的localizedDescription
和localizedTitle
的本地化信息是基于当前 iTunes Store 的语言的,而不是设置中的语言,当前 iTunes Store 语言又取决于测试账号。想查看哪个地区的本地化信息,就创建并使用哪个地区的测试账号。
票据
如何使用 cancellation_date 字段?
该字段仅适用于自动续期订阅、非续期订阅和非消耗型产品。当用户申请退款(Refund)或撤销家庭共享(Family Sharing)时,票据校验返回的 JSON 数据中才会有该字段。因此可以利用该字段监测用户退款,并及时收回已经发放的产品或服务。
如何选择票据校验地址?
测试阶段使用沙盒地址:
https://sandbox.itunes.apple.com/verifyReceipt
在 App Store 发布之后使用正式地址:
https://buy.itunes.apple.com/verifyReceipt
App 审核团队在沙盒环境中测试,因此会访问沙盒地址校验票据。
最佳实践:无论是测试阶段还是正式发布阶段,总是先去正式环境校验,如果返回21007
状态码,再去沙盒环境校验。这种做法的好处是无需在 App 的测试、审核、发布等各个阶段频繁切换地址。
状态码 21007 代表沙盒环境产生的票据却发到了正式环境去校验,状态码0
代表票据校验成功。
校验票据失败并返回状态码为一串数字
可能的原因有:
- 没有对原始票据进行
Base64
编码,而是直接将原始票据拿去校验。 - 使用的 Base64 算法有问题,无法正确编码票据中的某些字符。建议采用官方自带的算法。
- 访问票据校验地址 POST 的数据不是 JSON 格式。
下面是用包含自动续期订阅的票据请求校验的 JSON 示例:
{
"exclude-old-transactions": true/false,
"receipt-data" : "...",
"password" : "..."
}
审核期间购买成功但未发货
检查是否使用了正确的校验地址。参考:如何选择票据校验地址?
校验票据返回空的 in_app 数组
空数组表明 App Store 还没有记录当前用户的任何交易,可能是票据未更新导致的。
消耗型产品在购买成功后会添加进票据,在 finishTransaction 成功后会从交易队列移除,在下次更新票据时正式从票据移除,而自动续期订阅、非续期订阅和非消耗型产品自购买成功就会永久保留在票据中。如果应用只提供消耗型产品,那么在票据当前没有产品并且本次购买还未来得及更新时就拿去校验,就会出现空数组的情况。
出现这种情况,可以使用SKReceiptRefreshRequest
来显式刷新票据。该方法会弹窗让用户授权。
如何处理 appStoreReceiptURL 为空的情况?
如果appStoreReceiptURL
为空,可以先假定用户没有购买成功,不给发货。然后弹窗提示用户票据过期或丢失,需要刷新一下才行,待用户同意后利用SKReceiptRefreshRequest
来刷新票据,在回调requestDidFinish
和didFailWithError
中判断刷新结果。如果刷新成功,拿到票据后走正常的发货流程,如果刷新失败,再根据自身业务作具体处理。
SKReceiptRefreshRequest 会弹窗让用户授权,而用户可能会取消授权,App 必须要能正确处理取消授权的情况。
出现这种情况的一种典型场景是:从TestFlight
或 Xcode 安装 App。从 App Store 安装或 iCloud 恢复安装,票据一般都是存在的,但也有某些未知情况票据确实不存在。
订阅
如何创建并上传托管的非消耗型产品
在 Xcode 中新建Target
,选择Other
下的In-App Purchase Content
,完成内容填充后,点击 Product -> Archive 打开Organizer
,在左上角的列表中选择类型为In-App Purchases
的项目,然后点击Distribute Content
上传到 App Store Connect。
如何处理自动续期订阅产品不同时长的情况?
通过订阅管理页面
让用户自己调整自动续期订阅。在 App 内打开以下地址:
https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/manageSubscriptions
打开后会先跳转至 Safari,然后再打开 iTunes Store,并自动弹出订阅管理页面。
最新版地址:
https://apps.apple.com/account/subscriptions
该地址会直接打开 App Store,并自动弹出订阅管理页面。
如何将自动续期订阅转为普通内购项目?
操作步骤如下:
- 在 App Store Connect 中移除订阅项目。
- 创建新的内购项目,从代码中移除订阅并使用新的内购项目。
- 测试。
第一步会导致订阅变为不可自动续期,并且订阅的用户会收到邮件提醒。对已经订阅的用户,依旧要提供对应的服务直到订阅结束。例如,用户在 4 月 1 日购买了为期一个月的自动续期订阅产品,你在 4 月 19 日移除了该产品,然而你必须要提供对应的服务直到 5 月 1 日。
为什么 App 在前台也没有收到任何续期通知
首先检查交易监听器是否在 App 一启动就添加了,并且确保在 App 的整个生命周期都没有释放。然后将 App 切换到后台,再切换到前台,这个操作会触发 StoreKit 去检查 App Store 是否有续期通知到来。如果有续期到来,那么交易监听器的paymentQueue:updatedTransactions:
就会被调用,也就是收到续期通知了。
服务器很少收到 RENEWAL 类型的通知
只有 App Store 将已过期
的订阅续订成功才会发出RENEWAL
类型的通知,在订阅过期之前就续订成功,是不会发出 RENEWAL 类型的通知的,因此需要先检查你的代码是否匹配这样的处理逻辑。
在订阅到期前24小时
, App Store 会从用户绑定的支付方式中扣款,如果因为某些原因导致扣款失败,这种情况会在技术上判定为过期,即使没有真正过期。如果后续尝试扣款成功了,也会发出RENEWAL
类型的通知。
虽然 RENEWAL 已被标记为废弃(DEPRECATED),2021 年 3 月 10 日之后不会出现在服务器通知中,但是直到发文时刻(2021.12.09),作者依然会收到 RENEWAL 通知。
其他问题
为什么产品 ID 会出现在 invalidProductIdentifiers 数组中?
可能的原因有:
- 没有使用确切的 Bundle ID(Explicit Bundle ID),即使用了通配符 Bundle ID。
- App 审核被拒或开发者撤回审核。
- 内购项目缺失元数据。
- 没有使用和 Bundle ID 匹配的描述文件进行签名。
- 修改了产品信息,但信息还未同步到所有 App Store 服务器。
- 没有按照要求完成 App Store Connect 后台的
协议、税务和银行业务
的配置。 - 非消耗型产品有需要 Apple 托管的内容,但是这些内容还未上传。
- 传给
SKProductsRequest
的产品 ID 还没有在 App Store Connect 后台创建。
非消耗型产品只要有需要 Apple 托管但还未上传的内容,就会一直处于不可用状态,直到内容上传成功。可以先关闭内容托管,待内容准备就绪之后再打开。
如果你刚续期了开发者会员资格(Developer Membership),请前往协议、税务和银行业务
,检查一下是否有新的条款需要同意、新的银行信息需要更新。如果会员资格过期了,这些条款信息都会随之过期。
App 已经过审了,为什么还会有产品 ID 出现在 invalidProductIdentifiers 数组中?
App 审核通过后,还需经开发者批准才能发布到 App Store。一旦批准,应用 ID(Application ID)就被激活,可以在 App Store 访问到。App Store 中 App 的内购产品 ID 也需要激活,同样依赖这个批准操作。在某些情况下,内购产品 ID 的激活时间可能会滞后于应用激活时间48小时
。
如果开发者不批准,后续新创建的任何产品 ID 都不会被激活。要想测试新的产品 ID,就只能先批准了。产品 ID 一旦被激活,后续提交的 App 更新就可以直接使用,即使 App 更新还没有批准发布。
对于游戏运营者,如果卡在开服时间才批准发布,这样很有可能出现 App Store 搜不到游戏的情况。开服往往有大量氪金活动,如果再叠加内购产品 ID 激活滞后的问题,那玩家肯定要炸锅。要是真出现这种情况,请理解开发者,此时 TA 比谁都慌,信任 TA,并耐心等待。
建议至少提前 1 天批准发布,一方面留足时间给 App Store 服务器同步数据,很大程度上能避免上述 2 个问题。已经预约或从某些渠道得知新游即将发布的玩家,会提前下载好游戏,并注册账号。此时就能提前消化掉一部分账号注册流量,让账号相关服务压力有所减缓。最后,如果发现重大 BUG,也还有补救时机,解决后立即提交一个新包去审核,希望还来得及。
调用了 restoreCompletedTransactions 方法却没有恢复任何产品
可能的原因有:
- 队列中有未完成的交易。只要有未完成的交易,恢复操作就不会返回任何产品。
- 之前没有购买过自动续期订阅、非消耗型产品或免费订阅,因此恢复不出来产品。
- 尝试恢复非续期订阅或消耗型产品,而这些类型的产品是不支持恢复操作的。
- CFBundleVersion 未按照规范设置:三个非负整数以句点(.)连接而成的字符串。
审核员购买产品时 App 没有任何反应,或者直接崩溃了
可能的原因有:
- 通过
addPayment:
方法传入的SKPayment
对象可能为空,或者其关联的产品 ID 不可用。 - 如果 App 根据票据校验结果来发货,很有可能是校验地址不对导致校验失败。解决办法参考上文:如何选择票据校验地址?
对于第一种情况,下面是错误示例(禁止
在 App 中这样做,很可能导致崩溃):
@property SKProductsRequest *request;
@property SKProduct *product;
- (IBAction)purchase:(id)sender {
NSSet *productID = [NSSet setWithObject:@"product_identifier"];
// Create a product request.
self.request = [[SKProductsRequest alloc] initWithProductIdentifiers:productID];
self.request.delegate = self;
// Send the product request to the App Store.
[self.request start];
}
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
// product is an instance of SKProduct. The app assumes that response.products is
// not empty by getting its first element without any checks.
self.product = [response.products firstObject];
NSLog(@"Name: %@", self.product.localizedTitle);
// The app creates a payment request for a product whose value could be nil.
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:self.product];
// If the product was nil, the app will crash when executing addPayment:.
[[SKPaymentQueue defaultQueue] addPayment:payment];
}
最佳实践:
- 向 App Store 请求商品详情
- 检查 response.products 是否包含要购买的产品 ID 并获取对应的
SKProduct
对象 - 判断 SKProduct 对象是否为空,如果不为空则调用 addPayment: 方法。
推荐做法示例:
@property SKProduct *product;
@property SKProductsRequest *request;
- (void)fetchProductInformation {
NSSet *productID = [NSSet setWithObject:@"product_identifier"];
// Create a product request.
self.request = [[SKProductsRequest alloc] initWithProductIdentifiers:productID];
self.request.delegate = self;
// Send the product request to the App Store.
[self.request start];
}
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
// Be sure that the products array is not empty before fetching its content.
if ([response.products count] > 0)
{
// product is an instance of SKProduct.
self.product = [response.products firstObject];
NSLog(@"Name: %@", self.product.localizedTitle);
}
}
- (IBAction)purchase:(id)sender {
if (self.product != nil)
{
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:self.product];
[[SKPaymentQueue defaultQueue] addPayment:payment];
}
}
参考文档
英文原文:https://developer.apple.com/library/archive/technotes/tn2413
工匠日记 - 本文作者