StoreKit2 实际接入时候的踩坑与解决实录

9,459 阅读10分钟

在WWDC2021时,苹果介绍了新版StoreKit的使用 WWDC2021 MeetStoreKit2,作为紧跟时代的弄潮儿,我们就开始琢磨怎么在当前版本上兼容使用 StoreKit2.

环境确认

在开始接入之前,我们需要先确认下 StoreKit2 支持什么环境:

  1. iOS 15.0 及以后
  2. swift 5.5

那么只要保证 Xcode 是 13.0 以后的版本即可满足需求。

接入方式

在接入方式上,要么就是直接在项目中写个 category 或者单独的一个类进行管理内购,要么就是单独生成一个静态库/动态库实现功能,再提供给项目进行接入。
出于组件化的考虑(方便复用、更新与移除,并且也方便多个项目一起使用),于是决定使用 framewrok 的形式实现了 StoreKit2 的充值功能。
于是,这里就遇到了第一个坑:要么把framwrok支持的最低版本设置为15.0,要么在对应的类上增加

@available(iOS 15.0, *)

表示这个类仅支持15.0以上(因为 StoreKit2 仅支持 15.0 及以上)。


那么有的小伙伴可能就要问了,这个坑它体现在什么地方呢?问得好,这两个方案的问题具体的影响分别如下:

  1. 最低版本设置为 15.0 ,那么生成的架构就仅支持 arm64 与 x86_64,而不在支持 armv7、armv7s、i386

仅支持 arm64
Xcode 这边的规则是如果最低版本设置为 iOS 11.0 及以上时,就只支持 arm64 架构了 (存疑的小伙伴可以build devices设置为Any iOS Device 修改最低支持版本并查看括号后面的架构)(吐个槽,随说现在 32 位架构的设备很少了,但公司要求兼容,那也不得不想办法兼容)

最低支持版本为 iOS 11.0 最低支持版本为 iOS 10.3
  1. @available(iOS 15.0, *)的坑体现在高低版本的 @available 的兼容性上,也就是高版本Xcode库中如果使用到 @available 那么必须使用同版本或者更高版本的 Xcode 才能编译,否则会出现 __isPlatformVersionAtLeast not found 的异常。

不理解为什么要考虑这一点的小伙伴肯定在想大家都升级到同一大版本不就可以了吗?那么我的回答是:

其实 Xcode 存在不同版本的情况是比较常见的,特别是如果有一些大的项目或者低维的项目都是尽量以维稳为主,除非苹果要求提审必须使用 xx 版本之后的 Xcode 编译的代码。
尤其后续的版本 Xcode 14 也仅支持 arm64 与 x86_64,不再兼容 32 位架构,此时就回到了问题1,公司想要能兼容就尽量兼容 (卑微打工人的无奈)


简单总结一下,就是第一个方案不支持 32 位架构,第二个方案至少需要相同版本 Xcode 且后续可能也不支持 32 位架构。
既然两个方案或早或迟都会有 32 位架构的兼容问题,那么就想办法把兼容架构补上,一劳永逸!
这时候,就需要请出我们万能的 lipo 命令了。使用到的就是 lipo -create命令合并架构库,而说出这一点,相信大家也都知道我们想做的是什么了。
是的,我们所需要做的事情就是生成一个空实现的支持 32 位架构的库(可以通过macro处理,也可以通过单独特殊处理后恢复)。移除 64 位架构之后,再然后将这个仅支持 32 位架构库的可执行文件额外进行保存,后续在实际的仅支持 64 位架构库生成之后,通过 lipo -create 命令实现库的合并,从而让 sdk 可以正常编译。

空实现32位架构处理

graph LR
空实现代码 --> 编译生成库 --> 合并模拟器与真机架构 --> 通过lipo移除arm64与x86_64架构 --> 保存移除架构后的可执行文件armv7Store

标准库生成处理

graph LR
标准实现代码 --> 编译生成库 --> 合并模拟器与真机架构 --> 合并保存的armv7Store --> 输出库

同步给出标准库生成所对应的脚本

#!/bin/sh
#要build的target名
TARGET_NAME=${PROJECT_NAME}
if [[ $1 ]]
then
TARGET_NAME=$1
fi
UNIVERSAL_OUTPUT_FOLDER="${SRCROOT}/../lib/SwiftStoreKitSDK/"

#创建输出目录,并删除之前的framework文件
mkdir -p "${UNIVERSAL_OUTPUT_FOLDER}"
rm -rf "${UNIVERSAL_OUTPUT_FOLDER}/${TARGET_NAME}.framework"

#分别编译模拟器和真机的Framework
xcodebuild -target "${TARGET_NAME}" ONLY_ACTIVE_ARCH=NO -configuration ${CONFIGURATION} -sdk iphoneos BUILD_DIR="${BUILD_DIR}" BUILD_ROOT="${BUILD_ROOT}" clean build 
xcodebuild -target "${TARGET_NAME}" ONLY_ACTIVE_ARCH=NO -configuration ${CONFIGURATION} -sdk iphonesimulator BUILD_DIR="${BUILD_DIR}" BUILD_ROOT="${BUILD_ROOT}" clean build

#拷贝framework到univer目录
cp -R "${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${TARGET_NAME}.framework" "${UNIVERSAL_OUTPUT_FOLDER}"

#合并framework,输出最终的framework到build目录
#删除模拟器的 arm64 架构,避免合并时候出现重复架构导致合并失败的问题
lipo -remove arm64 "${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${TARGET_NAME}.framework/${TARGET_NAME}" -output "${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${TARGET_NAME}.framework/${TARGET_NAME}"

lipo -create -output "${UNIVERSAL_OUTPUT_FOLDER}/${TARGET_NAME}.framework/${TARGET_NAME}" "${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${TARGET_NAME}.framework/${TARGET_NAME}" "${BUILD_DIR}/${CONFIGURATION}-iphoneos/${TARGET_NAME}.framework/${TARGET_NAME}"

#合并32位架构
lipo -create -output "${UNIVERSAL_OUTPUT_FOLDER}/${TARGET_NAME}.framework/${TARGET_NAME}" "${UNIVERSAL_OUTPUT_FOLDER}/${TARGET_NAME}.framework/${TARGET_NAME}" "${UNIVERSAL_OUTPUT_FOLDER}/armV7Store"

#删除编译之后生成的无关的配置文件
dir_path="${UNIVERSAL_OUTPUT_FOLDER}/${TARGET_NAME}.framework/"
for file in ls $dir_path
do
if [[ ${file} =~ ".xcconfig" ]]
then
rm -f "${dir_path}/${file}"
fi
done

#判断build文件夹是否存在,存在则删除
if [ -d "${SRCROOT}/build" ]
then
rm -rf "${SRCROOT}/build"
fi

rm -rf "${BUILD_DIR}/${CONFIGURATION}-iphonesimulator" "${BUILD_DIR}/${CONFIGURATION}-iphoneos"

#打开合并后的文件夹
open "${UNIVERSAL_OUTPUT_FOLDER}"

到这里,我们就算是走完了 StoreKit 接入的第一步了。

客户端代码实现

接入方式的坑踩完之后,我们就正式开始接入 StoreKit 的库了,这里是苹果提供的 StoreKit2 的代码实现 Implementing a store in your app using the StoreKit API
提炼归纳总结之后,主要的代码内容为:


func pay(productId:String,uuid:UUID) {

    Task {

        do {

            var requestProductList:[Product] = []

            requestProductList = try await Product.products(for: [productId])

            guard requestProductList.count > 0 else {

            //TODO: 失败回调处理

            return

        }

        currentProduct = requestProductList.first!

        productList.append(currentProduct!)

        guard currentProduct != nil else {return}

        let reuslt = try await currentProduct!.purchase(options: [Product.PurchaseOption.appAccountToken(uuid)])

        switch reuslt {

            case .pending:

            print("pending");

            case .userCancelled:

            //TODO: 失败回调处理

            case .success(let result):

            handleVerfiedTransaction(result: result)

            @unknown default: break

        }

        } catch let storeKit as StoreKitError {

            //TODO: 失败回调处理

        }

        catch {

            //TODO: 失败回调处理

        }

    }

}

/// 验证与初步处理验证通过的凭证信息

/// - Parameter result: 带验证消息的凭证信息

func handleVerfiedTransaction(result:VerificationResult<Transaction>) {

    switch result {

        case .unverified(let unsafe,let verifError):

            //TODO: 失败回调处理

            Task {await unsafe.finish()}

            return

        case .verified(let safe):

            if safe.productType == .consumable {

            //TODO: 成功回调处理

            }

    }

}

StoreKit2 中我们最关注的就是新增加的 AppAccountToken。在旧版的充值中,是不支持凭证与订单之间的关联的,大家伙就只能各显神通,通过各种各样的方式想办法实现关联,之前也写过一篇文章iOS 内购处理方案与流程的探究专门介绍过个人觉得能实现较好关联的方案,但总归是非官方的偏门方案。 于是在看到了 appAccountToken 之后觉得内购的春天总算要来了,不用再苦逼的想办法关联订单了
appAccountToken 的作用就是将你传递的 UUID 类型的对象在充值成功之后在凭证中返回,并且在苹果服务端中持续保存。后续如果通过苹果返回的订单号查询凭证信息时也会带有这个 appAccountToken 从而实现对应用户的查询。

那么第二个块的重点就在于如何实现这个 UUID 类型的对象,通常的充值总会带有一个找服务端创建订单的操作 (如果没有,那么请说服老板补上),所以可以考虑直接调用 UUID() 生成一个随机的 UUID 对象,再将 UUID 传递给服务端进行下单,下单完成后,把这个 UUID 传递给 StoreKit 进行充值。等充值完成之后,就可以在凭证中找到这个 UUID,从而实现凭证与订单之间的强关联。

但是呢,这个但是他还是来了。这个是可能是属于个别情况,也就是我公司之前有被苹果警告过,是关于风控那边的内容。原先的 applicationUserName 是苹果用作风控进行使用的(虽说最新的 applicationUserName 没有说明用作欺诈风控了,但是以防万一适当该怂还是要怂一些),并且不保证会传递会客户端,之前警告的时候有特意强调这个字段的使用(虽然是按照建议的标准来使用的),所以我们是使用了用户id来转化实现,可以考虑以下几个方案,不过以下方式会将订单与凭证之间的关联转为凭证与用户之间的关联,不过相同用户发货到账影响不大

  1. 用户id为纯数字时,直接填充,不足的部分补充F
  2. 用户id为特殊字符串时,正反各可以md5加密生成16位后拼接,为了防止低概率的md5撞库
  3. 用户id不会太长时,转 hex 字符串并使用 pkcs5 或者 pkcs7 的方式进行填充

恭喜,到了这里客户端的到充值步骤就算是完成了。完成了充值的第二步,再往后就是充值成功之后如何验证凭证的问题了。

服务端凭证校验

旧版的凭证,一般是通过 NSBundle 获取本地凭证来实现,而新版这边由于苹果出了新的JWS 的接口,~~具体的实现就由服务端大佬来考虑了,我们就不用在意这些细节,~~那么就考虑使用 Get Transaction History 直接通过凭证ID返回凭证信息(主要是考虑到一个原则-客户端易篡改不可信)。不过这个接口有一个前提,即交易不能被关闭,如果交易被关闭,那么这个接口是查不到对应信息的.

另外还有一点需要注意,这个接口返回的是数组,有时需要查询整个数组才能找到对应的凭证id。

而这里,就出现了另外一个大坑,新旧版交易同时兼容的情况下出现的。
因为实际环境比较复杂,这边简单说下出现的前提情况吧

  1. 新版旧版的实现方案不相通,包括订单的处理规则也不同
  2. 新版旧版做了灰度,会同时对新旧版交易做监听,灰度判断下单与充值时候走新充值还是旧充值
  3. 新旧版的凭证上报验证时候先接收的凭证消息,通知客户端关闭充值后,再去匹配订单号(为了防止特殊情况下,找不到订单号时一直卡住,导致用户无法充值)

在这个前提下,苹果有一个隐藏的,至少笔者没有找到有说明的点(也有可能是我漏了?),那就是重新通知交易成功的时候(交易未关闭时,重新回调交易凭证),会两边同时通知,有时也会出现旧版通知交易关闭之后,后续还会在新版的交易监听中通知回调。

收到找不到匹配订单号的交易凭证时,也关闭交易事务的原因,分别是因为笔者这边旧版是有做锁单处理的,可参考iOS 内购处理方案与流程的探究理解,如果不关闭,那么后续将一直无法交易;二是新版充值这边,如果相同商品,上笔交易未关闭,则苹果下发的回调将一直是未关闭的那笔,也造成用户无法交易

那么就会有以下三种情况:

  1. 旧版下单时候
sequenceDiagram
苹果->>客户端: 回调充值成功(新)
客户端->>服务端: 上报凭证ID到新版充值上报接口
服务端->>苹果: 通过 history 接口获取凭证信息
苹果-->>服务端: 返回凭证信息
服务端->>服务端: 凭证信息入库
服务端-->>客户端: 通知关闭交易
服务端->>服务端: 通过凭证查询订单信息(由于旧版下单,无法找到订单,无法发货)
苹果->>客户端: 回调充值成功(旧)
客户端->>服务端: 上报凭证信息到旧版充值上报接口
服务端->>服务端: 发现凭证id重复
服务端-->>客户端: 通知关闭交易
  1. 新版下单时候
sequenceDiagram
苹果->>客户端: 回调充值成功(旧)
客户端->>服务端: 上报凭证ID到旧版充值上报接口
服务端-->>客户端: 通知关闭交易
苹果->>客户端: 回调充值成功(新)
客户端->>服务端: 上报凭证信息到新版充值上报接口
苹果-->>服务端: 返回凭证信息中不存在该订单号(交易事务已被关闭)
服务端-->>客户端: 通知关闭交易
服务端->>苹果: 通过 verifyReceipt 接口获取凭证信息
苹果-->>服务端: 返回凭证信息
服务端->>服务端: 凭证信息入库
服务端->>服务端: 通过凭证查询订单信息(由于新版下单,无法找到订单,无法发货)
服务端->>服务端: 发现凭证id重复
  1. 新版下单时候
sequenceDiagram
苹果->>客户端: 回调充值成功(旧)
客户端->>服务端: 上报凭证ID到旧版充值上报接口
服务端-->>客户端: 通知关闭交易
服务端->>苹果: 通过 verifyReceipt 接口获取凭证信息
苹果-->>服务端: 返回凭证信息
服务端->>服务端: 凭证信息入库
服务端->>服务端: 通过凭证查询订单信息(由于新版下单,无法找到订单,无法发货)
苹果->>客户端: 回调充值成功(新)
客户端->>服务端: 上报凭证信息到新版充值上报接口
服务端->>服务端: 发现凭证id重复
服务端-->>客户端: 通知关闭交易

这三种情况都会造成用户充值不到账的情况,虽然情况相对少,但是对于单个用户而言,肯定会炸锅,毕竟人家真金白银花出去了,结果什么反应都没有,换谁都肯定不爽,于是我们连夜讨论出了两个解决方案

  1. 服务端临时处理方案:上报的凭证都优先通过 history 接口获取凭证信息,如果存在 appAccountToken 那么固定走新版本对应的订单查询接口(通过 appAccountToken 做关联),如果不存在,那么就不操作,返回客户端不处理,等待旧版的数据上报,上报成功,凭证入库之后,走旧版本对应订单查询(通过 UniqueVendorIdentifier 查询)
graph LR
收到凭证信息 --> 调用history接口获取凭证信息 --> 存在appAccountToken --> 通过新版本订单查询接口关联订单
调用history接口获取凭证信息 --> 不存在appAccountToken --> 通过旧版本订单查询接口关联订单
  1. 客户端完整解决方案:收到新版充值回调时,判断回调内容中是否有 appAccountToken ,如果存在则直接上报,若不存在,则记录订单号,等旧版回调上报成功之后,同步关闭两边凭证,收到旧版充值回调时,如果存在 applicationUserName,那么直接上报旧版回调,否则等待新版充值回调做判断(applicationUserName 可能直接存在为空)
graph LR
收到新版凭证回调 --> 判断回调是否存在appAccountToken --> 存在appAccountToken --> 上报新版充值
判断回调是否存在appAccountToken --> 不存在appAccountToken --> 是否是旧版已记录的订单id
是否是旧版已记录的订单id --> 非旧版已记录的订单id --> 记录订单id --> 等待旧版上报
是否是旧版已记录的订单id --> 是旧版已记录的订单id --> 上报旧版充值 

收到旧版凭证回调 --> 判断回调是否存在applicationUserName --> 存在applicationUserName --> 上报旧版充值
判断回调是否存在applicationUserName --> 不存在applicationUserName --> 是否是新版已记录的订单id  --> 是新版已记录的订单id --> 上报旧版充值
是否是新版已记录的订单id  --> 非新版已记录的订单id --> 记录订单id与凭证信息 --> 等待新版凭证回调

到这里,基本可以正常的使用我们的 StoreKit2 了,U1S1,稳定后的 StoreKit2 还是挺香的,而且服务端也有了 JWS 的使用基础,后续在新增加调用 Look Up Order ID 等接口的时候,也更加简单方便。

结尾

以上则是笔者在使用 StoreKit2 时踩到的一些坑以及处理解决方案的思考与处理过程。
如果有遇到其他的情况,也欢迎随时讨论。