IAP

苹果内购(IAP)从入门到精通(7)- StoreKit2

苹果内购(IAP)从入门到精通(7)- StoreKit2

Posted by WTJ on July 7, 2022

Product drawio

一、StoreKit 1 存在的问题

  1. 苹果后台能否查看到退款的订单详情?

不能。只能苹果处理退款后发通知给我们的服务器,告知发生了一笔退款

  1. 消耗性、非消耗性、非续期订阅、自动续订能不能在沙盒环境测试退款?

不能。系统没提供这种测试方式。

  1. 能够将用户反馈的苹果收据里的 orderID 与具体的交易进行关联吗?

不能。

  • 服务器端 Receipt 收据解析后,没有包含 orderID 信息,所以无法直接关联他们之间的联系。
  • 不支持使用苹果收据里的 orderID 去苹果服务器查询交易信息,没有提供这个 API(StoreKit 2 出来后支持去查询 StoreKit1 的交易了,developer.apple.com/documentati… )。
  1. 在开发过程中,无法直接关联 transaction 与 orderID 之间联系,虽然有一个 applicationUserName 字段,可以存储一个信息。但是这个字段是不是 100%靠谱,在某些情况下会丢失存储的数据。
  2. 无法主动的去苹果服务器获取交易历史记录,退款信息。无法根据用户提供的苹果收据里的 orderID 主动关联上我们当前已知的订单。
  3. 目前 sk1 的 skproduct 无法区分消耗品,非消耗品,订阅商品,非连续订阅商品。
  4. sk1 存在队列监听,每次购买需要通过队列监听对应的购买状态的变更,所有的 transaction 的回调都在监听当中,不好区分哪些是补单的 transaction 和正常购买的 transaction。

二、StoreKit v2 新特性

StoreKit 2 新特性主要包含三部分:

  • StoreKit 2:关于在 App 里 API 的更新和变化,包含应用内更改订阅、退款等;
  • Server to Server:苹果服务器与开发者服务器之间的通讯,包括苹果通知、开发者主动请求苹果服务器、新的验证收据流程等;
  • Sandbox Test:关于沙盒测试环境相关的更新,还有一些注意事件等。

2.1 StoreKit 2 API

0de6da003ad14c76bc9f629fd673f61c~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

  • 一套新的基于 Swift 语言特性
  • 更新收据和交易(数据格式和字段变更)
  • 更多订阅类型的接口
  • 相同的 StoreKit 框架

2.2 只支持 Swift 开发

  • StoreKit 2 使用了 Swift 5.5 的新特性进行开发,完全修改了获取商品、发起交易、管理交易信息等接口 API 的实现方式。
  • 并且,StoreKit 2 只支持 iOS 15+

swift.org/blog/

2.3 新 API

aa046397a964421ea5ec2b467be70e52~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

StoreKit 2 提供了以上更新的类(方法)来轻松访问 IAP 接口,可以理解为增强的版本,详细下文会讲解。

  • Products(商品):有关在 App Store Connect 中配置的内购品项的信息
  • Purchases(购买):更新购买品项接口的可选参数,可绑定用户ID
  • Transaction info(交易信息):更新交易信息的内容格式
  • Transaction history(交易历史):提供查询交易历史记录的接口
  • Subscription status(订阅状态):提供订阅品项的状态查询接口

2.3.1 Product

e40b522708de4566a72d68d6e32597d3~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

新增了一些商品类型,订阅信息,这些字段信息在 StoreKit 1 里是没有的。

Product 类增加了品项的类型:

public static let consumable: Product.ProductType

public static let nonConsumable: Product.ProductType

public static let nonRenewable: Product.ProductType

public static let autoRenewable: Product.ProductType
  • 通过新增的 product type 我们可以轻易的知道当前的商品是消耗品还是订阅商品
/// Whether the user is eligible to have an introductory offer applied to their purchase.
@inlinable public var isEligibleForIntroOffer: Bool { get async }
/// Whether the user is eligible to have an introductory offer applied to a purchase in this
/// subscription group.
/// - Parameter groupID: The group identifier to check eligibility for.
public static func isEligibleForIntroOffer(for groupID: String) async -> Bool

2.3.2 isEligibleForIntroOffer

  • isEligibleForIntroOffer 针对于自动连续订阅的第一次购买优惠,我们可以直接感知到当前的商品是不是用户的 Apple ID 下的第一次购买

举个例子:

某些 APP 会有会员订阅服务,那些服务会有 1 个月,3 个月,12 个月等的自动续期,同时还会有一些第一次购买的优惠,这个第一次购买的优惠就是首购优惠,并且这个优惠跟 Apple ID 挂钩,跟 APP 内自己的账号体系无关,例如小马哥旗下产品,自有的账号体系是 QQ 号 + 微信号,那么我们在之前是无法简单得判断你这个 Apple ID 是否享受过首购优惠了,毕竟用户可以有多个 QQ 号,或者多个 微信号,在弹出苹果的购买页面前,我们是不知道这个 Apple ID 有没有享受过首购优惠的,会对用户产生误解,我在上一个页面还告诉我首个月只要 18 块钱,实际支付的时候为什么要 25 元了 ? 这个对用户的购买意愿肉眼可见是有下降的。

现在我们就可以通过 isEligibleForIntroOffer 这个属性,轻松又方便得提前拿到这些信息,对已经享受过的Apple ID账号不展示这个优惠。

2.3.3 BackingValue

这是⼀个苹果预备的字段,⽅便以后在推出新的特性后,⽼版本的 iOS 系统也可以使⽤新的内购类型。 暂时没有看到使⽤,期待后续的更新。

2.3.4 提供了新的获取商品接口

public static func products<Identifiers>(for identifiers: Identifiers) async throws -> [Product] where Identifiers : Collection, Identifiers.Element == String

2.3.5 Purchase opthons

d42a66f1c8ed44e3ac5bfbc18a03177a~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

除了原有的请示品项信息外,购买时,增加了一些可选参数 Purchase opthons。

5d8a31a5e69b44f5a87356e8336d48bd~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

除了购买数据、促销优惠 外,最重要的是新字段:App account token!

类似 SKPayment.applicationUsername 字段,但是 appAccountToken 信息会永久保存在 Transaction 信息内。

c3ad9d5414c547b5b7b621738e07e091~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

  • 开发者创建 App account token
  • 关联到 App 里的用户账号
  • App account token 使用 UUID 格式
  • 在交易(Transcation)订单中永久保存

appAccountToken 字段是由开发者创建的;关联到 App 里的用户账号;使用 UUID 格式;永久存储在 Transaction 信息里。

PS:这里的 appAccountToken 字段苹果的意思是用来存储用户账号信息的,但是应该也可以用来存储 orderID 相关的信息,需要将 orderID 转成 UUID 格式塞到 Transaction 信息内,方便处理补单、退款等操作。

public func purchase(options: Set<Product.PurchaseOption> = []) async throws -> Product.PurchaseResult


let uuid = Product.PurchaseOption.appAccountToken(UUID.init(uuidString: "uid")!)

// 发起一笔购买之后,直接等待苹果的返回结果,无需在paymenqueue中等待transaction状态的更新。
//使用sk2发起的购买的订单的信息,在sk1所有的回调接口都不会得到相应的transaction的更新状态
let result = try await product.purchase(options: [uuid])

// demo
func purchase(_ product: Product) async throws -> Transaction? {
    //Begin a purchase.
    let result = try await product.purchase()

    switch result {
    case .success(let verification):
      let transaction = try checkVerified(verification)

      //Deliver content to the user.
      await updatePurchasedIdentifiers(transaction)

      //Always finish a transaction.
      await transaction.finish()

      return transaction
    case .userCancelled, .pending:
      return nil
    default:
      return nil
    }
  }

2.3.5 Transcation

02937fb09618414d8682daa40bcb66a9~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

  • 每笔交易一个对象
  • 签名的交易信息,数据格式使用 JWS(JSON Web Signature)
  • 使用原生接口读取数据

这里插入一下 Manage in-app purchases on your server 里讲解使用 JWS 数据格式的原因:

  • 1、增强安全性
  • 2、更容易解码
  • 3、不用连接苹果服务器验证,开发者本地就可以单独验单!

331fe6b953da4c38a2945eed6d545361~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

系统会验证是否是一个合法的 Transaction,此时系统不再提供 base64 的 receip string 信息,只需要上传 transaction.id 和 transaction.originalID,服务器端根据需要选择合适的 ID 进行验证。 这里的 transactionID 非常重要!

这里简单的说一下,拿到的 JWS 格式的 transaction info 格式:

Base64() + "." + Base64(payload) + "." + sign( Base64(header) + "." + Base64(payload) )

这个 header 与 payload 通过 header 中声明的 alg 加密方式,使用密钥 secret 进行加密,生成签名。然后逆向构造过程,decode出 JWT 的三个部分:

  • 头部(Header)
  • 载荷(PayLoad)
  • 签名(signature)
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension VerificationResult where SignedType == Transaction {

    /// The raw JSON web signature for the signed value.
    public var jwsRepresentation: String { get }

    /// The data for the header component of the JWS.
    public var headerData: Data { get }

    /// The data for the payload component of the JWS.
    public var payloadData: Data { get }

    /// The data for the signature component of the JWS.
    public var signatureData: Data { get }

    /// The signature of the JWS, converted to a `CryptoKit` value.
    public var signature: P256.Signing.ECDSASignature { get }

    /// The component of the JWS that the signature is computed over.
    public var signedData: Data { get }

    /// The date the signature was generated.
    public var signedDate: Date { get }

    /// A SHA-384 hash of `AppStore.deviceVerificationID` appended after
    /// `deviceVerificationNonce` (both lowercased UUID strings).
    public var deviceVerification: Data { get }

    /// The nonce used when computing `deviceVerification`.
    /// - SeeAlso: `AppStore.deviceVerificationID`
    public var deviceVerificationNonce: UUID { get }
}

  • StoreKit v2 提供了验证 JWS 格式的 API,开发者可以直接调用,不需要自行解析。

  • 客户端只需要将 验单里的jwsRepresentation传给服务器,服务器去验证

2.3.6 监听 Transaction 更新

func listenForTransactions() -> Task<Void, Error> {
    return Task.detached {
      //Iterate through any transactions which didn't come from a direct call to `purchase()`.
      for await result in Transaction.updates {
        do {
          let transaction = try self.checkVerified(result)

          //Deliver content to the user.
          await self.updatePurchasedIdentifiers(transaction)

          //Always finish a transaction.
          await transaction.finish()
        } catch {
          //StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user.
          print("Transaction failed verification")
        }
      }
    }
  }

针对 transaction 的更新,这个监听是让我们监听:

  1. 这笔订单用户开启了一笔购买,这笔订单在苹果那边还没有得到结果,用户杀死 app 或者用户卸载了 app 并重新安装 app,这个时候我们可以通过这个监听收到对应的 transaction 更新
  2. 用户发起一笔购买,这笔购买可能因为网络状态不好的因素,在端上收到了失败的 transaction 回调,但是后续苹果发现这种 case,重新下发 transaction 到端上进行对应的验证。

2.4 Transaction History

提供了三个新的交易(Transcation)相关的 API:

  • All transactions:全部的购买交易订单
  • Latest transactions:最新的购买交易订单。(分为订阅品项和除订阅品项外的所有类型二种)
  • Current entitlements:当前用户有购买的权限。(全部的订阅品项、和非消耗品项)

根据可以购买的订阅商品、非消耗品可以过滤出已经购买过的商品。

extension Transaction {
  public static var all: Transaction.Transactions { get }
  public static var currentEntitlements: Transaction.Transactions { get }
  public static func currentEntitlement(for productID: String) async -> VerificationResult<Transaction>?
  public static func latest(for productID: String) async -> VerificationResult<Transaction>?
  public static var unfinished: Transaction.Transactions { get }
}

2.4.1 同步不同设备的购买记录。

这个 API 可以替换 StoreKit 1 里面的恢复购买 API,调用该方法后,系统会弹出提示框要求输入 AppleID 帐号密码信息。

extension AppStore {
  public static func sync() async throws
}

2.4.2 Current entitlements

62f1bd820207416f8d504189235d6f96~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

Current entitlements 这个目前是,方便开发者直接通过接口就能读取当前用户可用的订阅品项和非消耗品项,不用开发者做硬编码写死 productID 请求苹果查询,直接一个接口搞定!特别是对个人开发者来说,确定是很方便,不用搭服务器。

9719b483ae804e04b3bac2091d66f8e0~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

  • 查询同一个用户在不同的设备上的交易订单,假设用户在 A 设备购买了一笔交易订单,那么在用户的 B 设备上,可以实时查到这个购买的交易订单。
  • 苹果工程师说,一般系统会自动刷新,逼不得已不需要使用同步接口刷新。

0b935e1a3aaf4e32b9d21f5c6f0de095~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

  • 一般情况下,第一次打开 App 时,开发者就可以通过 StoreKit 2 提供的接口在后台实时帮用户恢复购买记录。
  • 对于非消耗品项,用户在一个新设备时,可能需要提供给用户恢复购买记录的UI 入口。
  • 而对于订阅类型,比如某个视频网站的月卡,虽然都是登陆一个苹果账号,但是购买时,是绑定到视频网络的用户的,不是绑定到苹果账号下,所以,订阅类型可能就无法直接恢复啊。

c400759d18df41ea980830d4bd132a0a~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

  • 所有的交易都可以用在所有的 StoreKit 接口;
  • 使用 StoreKit v1 的购买记录,在 v2 的接口也可以获取到;
  • 使用 v2 进行的购买可在统一收据中获得。

2.5 Subscription status

0226732430e94bceb37427d3d70b1bb7~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

订阅类型项目的状态,比如主动获取最新的交易、获取更新订阅的状态,获取更新订阅的信息等。其中获取更新订阅的信息,可以获取更新的状态、品项 id、如果过期的话,可以知道过期的原因。(比如用户取消、扣费失败、订阅正常过期等。)获取的所有数据都是 JWS 格式验证。

2.6 show manager subscriptions

可以直接唤起 App Store 里的管理订阅页面。

extension AppStore {

  @available(iOS 15.0, *)
  @available(macOS, unavailable)
  @available(watchOS, unavailable)
  @available(tvOS, unavailable)
  public static func showManageSubscriptions(in scene: UIWindowScene) async throws
}

接口如上,调用后,打开的界面如下:

416d9505615c419ea0563074d0d4bd28~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

可以在开发者 App 中取消订阅、升级或降级订阅等级等。

2.7 request refund API

提供了新的发起退款 API,允许用户在开发者的 App 中直接进行退款申请。用户进行申请退款后,App 可以收到通知、另外苹果服务器也会通知开发者服务器。(沙盒环境也可进行退款测试了,但是 App Store 里还没开启这个功能。)

extension Transaction {
  public static func beginRefundRequest(for transactionID: UInt64, in scene: UIWindowScene) async throws -> Transaction.RefundRequestStatus
}

0b5e23951f0a4965a6adb2f0be7ce7af~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

参考:

StoreKit 3步曲

【WWDC21 10114】 初见 StoreKit 2 - 小专栏.pdf