我正在参与「启航计划」

Apple 的文档尽管全,可是信息量巨大,很简单遗失一些重要的信息,也很简单让人迷失在跳来跳去的链接里。这篇文章我想整理 Apple 内购相关的文档,记录内购开发进程以及踩的坑,供后来者参阅,少走弯路。

1、前期准备

1.1 注册成为正式的开发者

Apple 内购需求付费的开发者账号才干运用。前期需求先注册开发者账号。

  • 去 Apple Store 下载 Apple Developer App 并装置
  • 在 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 开发者,看你需求从头登录一下

这儿创立沙盒测验员之后留意:

  1. 没必要为了测验退出非沙盒的账号
  2. 能够在 “设置-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 服务器的通信。这是第一步。考虑到文章篇幅问题,服务器的规划计划咱们放到下一篇文章中介绍。