我正在参加「掘金·启航计划」
Apple 的文档虽然全,但是信息量巨大,很容易遗漏一些重要的信息,也很容易让人迷失在跳来跳去的链接里。这篇文章我想梳理 Apple 内购相关的文档,记录内购开发过程以及踩的坑,供后来者参考,少走弯路。
1、前期准备
1.1 注册成为正式的开发者
Apple 内购需要付费的开发者账号才能使用。前期需要先注册开发者账号。
- 去 Apple Store 下载
Apple DeveloperApp 并安装 - 在 Apple Developer App 中注册成为开发者
注册比较简单,基本如实填写个人信息即可。有问题搜索一下答案也很容易解决。交了钱之后,审核大概需要 1-2 天,然后就正式入坑 Apple 开发者了。
1.2 创建应用和产品信息
注册成为开发者账号之后可以创建一个应用,并为应用添加几个内购的测试商品。这个过程也比较简单,因为开发过程中不需要考虑审核,创建完成即可。参考官方文档 《Developer - App 内购买项目》 和 《iOS内购,设置及使用》,
- 首先在 App Store Connect 中设置好你的银行和税务信息(需要注册成为开发者并审核通过)
- 然后在 App Store Connect 中创建应用,并为应用添加内购项目信息。这里需要注意的是创建的内购产品的类型,因为审核时可能会卡。从产品的大类上来说,可以分为普通的产品类型和订阅类型。对于消耗和非消耗型产品,消耗型项目是一种使用一次之后即失效的项目。用户可以多次购买这类项目。非消耗型项目是一种用户只需购买一次的项目。这类项目不会过期。对于订阅类型的产品则分为可自动订阅和非自动订阅两种类型。
- 然后在 App Store Connect 的 “用户和访问” 中添加测试账号信息
- 在 Xcode 中启用 in-app-purchase,如果刚注册成为 Apple 开发者,看你需要重新登录一下
这里创建沙盒测试员之后注意:
- 没必要为了测试退出非沙盒的账号
- 可以在 “设置-Apple Store-沙盒账户” 中指定当前使用的沙盒账号
这样前期的准备就基本完成了。
2、客户端开发
Apple 的内购提供了两套购买方案,一套基于 StoreKit2 的新的内购方式,一套是原始的购买和验证方式。两种方式都可以在应用中使用。验证方式存在区别,原始的购买方式是购买后获取到收据之后调用后端接口验证,而新的购买方式是购买之后通过 JWT 的验证方式,只需要少量的订单信息即可以完成验证。
2.1 请求产品信息
使用 StoreKit2 请求产品信息即可,
Task {
do {
let appProducts = try await Product.products(for: productIdentifiers)
debugPrint(appProducts)
} catch {
L.e { "Failed when request products: \(error)" }
}
}
错误 1: “无法完成请求” 及其解决办法
通过上述形式请求的时候,可能会报错 “unknown”. 错误的详细信息类似于 《The Apple in-app purchase test could not get the purchase item information》 这个问题中的描述。
此时可以尝试使用 SwiftyStoreKit 请求数据查看错误信息。SwiftyStoreKit 基于之前的 StoreKit1 开发。
解决这个问题的方式之一可以参考 Stackoverflow 上的答案 《Requesting an In App Purchase in iOS 13 fails》。 即创建一个 StoreKit 的配置文件并与服务器进行同步,然后在 Run 的配置中指定 StoreKit 的信息。官方文档中也有这种配置方式的描述 《Implementing a store in your app using the StoreKit API》.
但是需要注意这种方式打出的 App 在测试的时候表现和基于沙盒或者 TestFlight 表现是不同的。可以参考文档 《Testing at all stages of development with Xcode and the sandbox》。
2.2 购买产品
如文档 App 内购买项目 所述,首先要我们要创建全局的交易状态监听。这里我们在 app 被创建的时候启动监听,
final class TransactionObserver {
var updates: Task<Void, Never>? = nil
init() {
updates = newTransactionListenerTask()
}
deinit {
updates?.cancel() // 销毁的时候取消监听
}
private func newTransactionListenerTask() -> Task<Void, Never> {
Task(priority: .background) {
for await verificationResult in Transaction.updates {
self.handle(updatedTransaction: verificationResult)
}
}
}
private func handle(updatedTransaction verificationResult: VerificationResult<Transaction>) {
guard case .verified(let transaction) = verificationResult else {
return // 无需处理,未交验的订单忽略
}
if let revocationDate = transaction.revocationDate {
// 根据 transaction.productID 移除用户权限,Transaction.revocationReason 中提供了原因
} else if let expirationDate = transaction.expirationDate, expirationDate < Date() {
return // 无需处理,订阅过期
} else if transaction.isUpgraded {
return // 无需处理,存在级别更高的服务
} else {
// 根据 transaction.productID 给予用户权限
}
}
}
如上,只有几个地方需要我们处理。当我们处理完了订单之后调用 Transaction 的实例方法 finish() 结束流程。此外,我们业可以通过 Transaction 的类属性 unfinished 获取未完成的订单。
然后是购买的逻辑。调用步骤 2.1 中请求到的产品实例 Product 的实例方法 purchase(options:) 可以购买指定产品,
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
switch verificationResult {
case .verified(let transaction):
// 订单已经被 Apple 校验,可以授权用户购买的内容
// 处理完授权的逻辑之后调用如下方法结束订单流程
await transaction.finish()
case .unverified(let transaction, let verificationError):
// 基于自己的业务模型处理交验失败的订单
}
case .pending:
break // 等待用户操作
case .userCancelled:
break // 用户取消了购买
@unknown default:
break
}
在购买的过程中还可以指定一些参数,
appAccountToken(_:):UUID,用来关联交易和你的系统的账号promotionalOffer(offerID:keyID:nonce:signature:timestamp:):自动订阅类型需要提供的信息quantity(_:):购买的数量,大于 1 的时候可指定
2.3 获取收据信息
可以在购买完成之后通过读取本地存储的文件来获取收据信息,
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
do {
let receiptData = try Foundation.Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
print(receiptData)
}
catch { print("Couldn't read receipt data with error: " + error.localizedDescription) }
}
购买完成之后如果需要刷新收据,可以使用 SKReceiptRefreshRequest 的方法完成。此外,也可以直接使用三方的库,比如 SwiftyStoreKit 获取收据信息。它提供了一个方法,用于始终获取最新的收据信息。对于之前已经购买过的订单,调用该类的 restoreCompletedTransactions() 方法用来恢复。调用该方法之后之前 finish 过的订单信息将会通过回调监听通知给客户端。
2.4 获取已购买订单信息
使用 Transaction 的静态属性 currentEntitlements 可以获取当前用户已购的交易信息,包括非可消费的商品和订阅类型的商品的交易信息,但是不包含可消费类型产品的交易信息。示例,
func refreshPurchasedProducts() async {
for await verificationResult in Transaction.currentEntitlements {
switch verificationResult {
case .verified(let transaction):
// 授权用户交易
case .unverified(let unverifiedTransaction, let verificationError):
// 校验失败的交易信息
}
}
}
3、服务端开发
内购的过程,客户端开发工作量并不大,工作的重难点在于后端的设计和开发。
3.1 基于收据的校验模式
如 2.3 所示,获取了收据之后我们可以继续验证收据是否合法。为了防止应用被黑,最好的方式是将校验逻辑放在后端来执行。相关的文档位于 《Validating receipts with the App Store》.
验证收据的逻辑,这里提供 Python 和 Java 两套实现代码。可以在测试验证的时候使用 Python,开发服务器的时候使用 java 代码。接口信息位于文档 《verifyreceipt》. 即通过 http 协议发送一个请求到服务器,将收据的信息做 base64 编码之后通过 post 的形式以 json 传递给 Apple 服务器。
import requests, json
receipt = "你的收据"
url = 'https://sandbox.itunes.apple.com/verifyReceipt'
headers = {"Content-type": "application/json"}
data = json.dumps({"receipt-data": receipt})
res = requests.post(url=url, data=data, headers=headers, verify=False).text
print(res)
以及 java 版(基于 OkHttp 进行网络请求),
public static void main(String...args) throws IOException {
String receipt = "你的收据";
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("receipt-data", receipt);
RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), jsonObject.toString());
Request request = new Request.Builder()
.header("Content-type", "application/json")
.url("https://sandbox.itunes.apple.com/verifyReceipt")
.post(body)
.build();
OkHttpClient client = new OkHttpClient.Builder()
.build();
Response response;
try {
response = client.newCall(request).execute();
System.out.println(response.body().string());
} catch (IOException e) {
e.printStackTrace();
}
}
输出的结果是一行 json,其中包含字段 status 为 0. 收据的详细字段说明可以参考文档 《Receipt Validation Programming Guide》.
错误 2:收据校验始终返回 21002 的问题
该问题的表现是获取产品信息和购买流程都很通顺,并且也成功获取到了购买的收据信息。但是因为环境设置错误,导致拿到的收据在校验的时候始终返回错误码 21002. 对问题的详细描述可见我在 Apple 的开发者论坛上提出的问题:developer.apple.com/forums/thre….
如我们在错误 1 中所述,如果构建 APP 的时候指定了本地的产品配置文件。那么在实际购买的时候会走的 XCode 签名而不是 Apple Store 的签名。所以,这种方式获取到的收据信息是无法在沙盒环境中进行校验的。关于 XCode 和沙盒两种不同的测试模式,官方文档中给了详细的对比说明,《使用 Xcode 和沙盒在开发过程中的各个阶段进行测试》。虽然文档挺全面的,但是文档太多,而且归纳做得并不好,所以,有时候容易遗漏一些信息。
另外,判断当前是沙盒环境还是 Xcode 环境的方式就是,在购买之后显示的对话框上会带有环境信息,比如 XCode 的时候显示的 [Environment: XCode],注意区别即可。
对于沙盒测试,我也找到了 Apple 的相关文档:《使用沙盒测试 App 内购买项目》. 我们也可以通过 Revenuecat 的 App Store Receipt Validation 在他们的网站上面测试订单的收据信息。
3.2 基于 JWS 的校验方式
基于 JWS 的校验方式是新的校验方式。相比于基于收据的形式,它无需传递较长的收据信息。只需要将购买完成之后返回的订单 ID 等基础信息上报给服务器处理即可。
JWS 校验方式的接口是基于 REST 风格设计的,返回的数据是 json 格式,身份校验的方式是 JWT. API 的文档位于 《App Store Server API》.
对于 JWT 校验方式,其全称是 JSON Web Tokens. 可以在其官网 jwt.io 中了解关于它的更多信息。官网在 libraries 页下面列举了常用的一些开源库。我们下面使用 Python 和 Java 请求服务器接口的时候就是用的这里推荐的开源库。
按照 JWT 的校验逻辑,我们需要先获取私钥。这里 Apple 使用的是非对称加密,私钥我们保存,公钥 Apple 保存。依据文档 《Creating API Keys to Use With the App Store Server API》,获取私钥的方式是登录 App Store Connect。然后在 “用户和访问” - “密钥” - “密钥类型” - “APP 内购项目” 中生成私钥,下载证书并妥善保管(证书只能下载一次,需要妥善保存)。
对于 Apple 的 JWT 校验方式,其在文档 《Generating Tokens for API Requests》 中说明了要传的 header 和 payload 中所应该包含的信息。相关的信息从 Apple Store Connect 中获取即可,亦可以参考文档 《WWDC21 - App Store Server API 实践总结 》。我这里就不具体说明了。
下面是基于 Python 的验证方式,
import jwt
import time
import requests
# 读取密钥文件证书内容
f = open("xxxxxx.p8")
key_data = f.read()
f.close()
# JWT Header
header = {
"alg": "ES256",
"kid": "你的 kid",
"typ": "JWT"
}
# JWT Payload
payload = {
"iss": "你的 iss",
"iat": int(time.time()),
"exp": int(time.time()) + 60 * 60, # 60 minutes timestamp
"aud": "appstoreconnect-v1",
"bid": "你的 bid"
}
# JWT token
token = jwt.encode(headers=header, payload=payload, key=key_data, algorithm="ES256")
print("JWT Token:", token)
transactionId = "你的 transaction id"
rl = "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/history/%s" % transactionId
header = {
"Authorization": f"Bearer {token}"
}
# 请求和响应
rs = requests.get(url, headers=header)
print("text:\n" + rs.text)
以及基于 Java 的验证方式,
public static void main(String...args) throws InvalidParameterSpecException, IOException, NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
File file = new File("xxxxx.p8");
byte[] privateKeyBytes = Files.readAllBytes(file.toPath());
String unencrypted = new String(privateKeyBytes);
unencrypted = unencrypted.replace("-----BEGIN PRIVATE KEY-----", "");
unencrypted = unencrypted.replace("-----END PRIVATE KEY-----", "");
byte[] decoded = Base64.decode(unencrypted);
KeyFactory kf = KeyFactory.getInstance("EC");
PrivateKey privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(decoded));
Algorithm algorithm = Algorithm.ECDSA256(null, (ECPrivateKey) privateKey);
String token = JWT.create()
.withHeader("{\n" +
"\"alg\": \"ES256\",\n" +
"\"kid\": \"你的 kid\",\n" +
"\"typ\": \"JWT\"\n" +
"}")
.withPayload("{\n" +
" \"iss\": \"你的 iss\",\n" +
" \"iat\": " + (System.currentTimeMillis()/1000) + ",\n" +
" \"exp\": " + (System.currentTimeMillis()/1000 + 60*60) + ",\n" +
" \"aud\": \"appstoreconnect-v1\",\n" +
" \"bid\": \"你的 bid\"\n" +
"}\n")
.sign(algorithm);
System.out.println(token);
String transactionId = "你的 transaction id";
String url = "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/history/" + transactionId;
Request request = new Request.Builder()
.header("Authorization", "Bearer " + token)
.url(url)
.build();
OkHttpClient client = new OkHttpClient.Builder()
.build();
Response response;
try {
response = client.newCall(request).execute();
System.out.println(request);
System.out.println(response.code() + "" + response.body().string());
} catch (IOException e) {
e.printStackTrace();
}
}
这里使用的基于 OkHttp 和 JWT 开源库进行的开发,需要在 Maven 中添加如下依赖,
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>3.11.0</version>
</dependency>
总结
上述我们整理了设计 Apple 内购系统的思路和文档,到此为止,我们走通了和 Apple 服务器的通信。这是第一步。考虑到文章篇幅问题,服务器的设计方案我们放到下一篇文章中介绍。