iOS收藏iOS开发心得iOS 技术文档收录

[iOS]贝聊 IAP 实战之见坑填坑

2017-12-03  本文已影响2256人  e2f2d779c022

大家好,我是贝聊科技 的 iOS 工程师 @NewPan

注意:文章中讨论的 IAP 是指使用苹果内购购买消耗性的项目。

这次为大家带来我司 IAP 的实现过程详解,鉴于支付功能的重要性以及复杂性,文章会很长,而且支付验证的细节也关系重大,所以这个主题会包含三篇。

第一篇:[iOS]贝聊 IAP 实战之满地是坑,这一篇是支付基础知识的讲解,主要会详细介绍 IAP,同时也会对比支付宝和微信支付,从而引出 IAP 的坑和注意点。
第二篇:[iOS]贝聊 IAP 实战之见坑填坑,这一篇是高潮性的一篇,主要针对第一篇文章中分析出的 IAP 的问题进行具体解决。
第三篇:[iOS]贝聊 IAP 实战之订单绑定,这一篇是关键性的一篇,主要讲述作者探索将自己服务器生成的订单号绑定到 IAP 上的过程。

不用担心,我从来不会只讲原理不留源码,我已经将我司的源码整理出来,你使用时只需要拽到工程中就可以了,下面开始我们的内容 。

源码在这里。

上一篇的分析了 IAP 存在的问题,有九个点。如果你不知道是哪九个点,建议你先去看一下上一篇文章。现在我们根据上一篇总结的问题一个一个来对应解决。

作者写了一个给 iPhone X 去掉刘海的 APP,而且其他 iPhone 也可以玩,有兴趣的话去 App Store 看看。点击前往。

01.越狱的问题

关于越狱导致的问题,总是充满了不确定性,每个人都不一样,但是都是受到了攻击导致的。所以,我们采取的方式简单粗暴,越狱用户一律不允许使用 IAP 服务。这里我也建议你这么做。我的源码中有一个工具类用来检测用户是否越狱,类名是 BLJailbreakDetectTool,里面只有一个方法:

/**
 * 检查当前设备是否已经越狱。
 */
+ (BOOL)detectCurrentDeviceIsJailbroken;

如果你不想使用我封装的方法,也可以使用友盟统计里有一个方法,如果你的项目接入了友盟统计,你 #import <UMMobClick/MobClick.h> ,里面有个类方法:

/**
 * 判断设备是否越狱,依据是否存在apt和Cydia.app
 */
+ (BOOL)isJailbroken;

02.交易订单的存储

上一篇文章说到,苹果只会在交易成功以后通过 - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions 通知我们交易结果,而且一个 APP 生命周期只通知一次,所以我们万万不能依赖苹果的这个方法来驱动收据的查询。我们要做的是,首先一旦苹果通知我们交易成功,我们就要将交易数据自己存起来。然后再说然后,这样一来我们就可以摆脱苹果通知交易结果一个生命周期只通知一次的噩梦。

那这么敏感的交易收据,我们存在哪里呢?存数据库?存 UserDefault?用户一卸载 APP 就毛都没有了。这样的东西,只有一个地方存最合适,那就是 keychainkeychain 的特点就是第一安全;第二,绑定 APP ID,不会丢,永远不会丢,卸载 APP 以后重装,仍然能从 keychain 里恢复之前的数据。

好,我们现在开始设计我们的存储工具。在开始之前,我们要使用一个第三方框架 UICKeyChainStore,因为 keychain 是 C 接口,很难用,这个框架对其做了面向对象的封装。我们现在就基于这个框架进行封装。

#import <UICKeyChainStore/UICKeyChainStore.h>
#import "BLWalletCompat.h"

NS_ASSUME_NONNULL_BEGIN

@class BLPaymentTransactionModel;

@protocol BLWalletTransactionModelsSaveProtocol<NSObject>

@optional

/**
 * 存储交易模型.
 *
 * @param models 交易模型. @see `BLPaymentTransactionModel`
 * @param userid 用户 id.
 */
- (void)bl_savePaymentTransactionModels:(NSArray<BLPaymentTransactionModel *> *)models
                                forUser:(NSString *)userid;

/**
 * 删除指定 `transactionIdentifier` 的交易模型.
 *
 * @param transactionIdentifier 交易模型唯一标识.
 * @param userid                用户 id.
 *
 * @return 是否删除成功. 失败的原因可能是因为标识无效(已存储数据中没有指定的标识的数据).
 */
- (BOOL)bl_deletePaymentTransactionModelWithTransactionIdentifier:(NSString *)transactionIdentifier
                                                          forUser:(NSString *)userid;

/**
 * 删除所有的 `transactionIdentifier` 交易模型.
 *
 * @param userid 用户 id.
 */
- (void)bl_deleteAllPaymentTransactionModelsIfNeedForUser:(NSString *)userid;

/**
 * 获取所有交易模型, 并排序.
 *
 * @return models 交易模型. @see `BLPaymentTransactionModel`
 * @param userid  用户 id.
 */
- (NSArray<BLPaymentTransactionModel *> * _Nullable)bl_fetchAllPaymentTransactionModelsSortedArrayUsingComparator:(NSComparator NS_NOESCAPE _Nullable)cmptr
                                                                                                          forUser:(NSString *)userid
                                                                                                            error:(NSError * __nullable __autoreleasing * __nullable)error;

/**
 * 获取所有交易模型.
 *
 * @param userid 用户 id.
 *
 * @return models 交易模型. @see `BLPaymentTransactionModel`
 */
- (NSArray<BLPaymentTransactionModel *> * _Nullable)bl_fetchAllPaymentTransactionModelsForUser:(NSString *)userid
                                                                                         error:(NSError * __nullable __autoreleasing * __nullable)error;

/**
 * 改变某笔交易的验证次数.
 *
 * @param transactionIdentifier 交易模型唯一标识.
 * @param modelVerifyCount      交易验证次数.
 * @param userid                用户 id.
 */
- (void)bl_updatePaymentTransactionModelStateWithTransactionIdentifier:(NSString *)transactionIdentifier
                                                      modelVerifyCount:(NSUInteger)modelVerifyCount
                                                               forUser:(NSString *)userid;

/**
 * 存储某笔交易的订单号和订单价格以及 md5 值.
 *
 * @param transactionIdentifier 交易模型唯一标识.
 * @param orderNo               订单号.
 * @param priceTagString        订单价格.
 * @param md5                   交易收据是否有变动的标识.
 * @param userid                用户 id.
 */
- (void)bl_savePaymentTransactionModelWithTransactionIdentifier:(NSString *)transactionIdentifier
                                                        orderNo:(NSString *)orderNo
                                                 priceTagString:(NSString *)priceTagString
                                                            md5:(NSString *)md5
                                                        forUser:(NSString *)userid;

@end

/**
 * 存储结构为: dict - set - model.
 *
 * 第一层 data, 是字典的归档数据.
 * 第二层字典, 以 userid 为 key, set 的归档 data.
 * 第二层集合, 是所有 model 的归档数据.
 */
@interface BLWalletKeyChainStore : UICKeyChainStore<BLWalletTransactionModelsSaveProtocol>

+ (BLWalletKeyChainStore *)keyChainStoreWithService:(NSString *_Nullable)service;

@end

NS_ASSUME_NONNULL_END

我们要保存的对象是 BLPaymentTransactionModel,这个对象是一个模型,头文件如下:

#import <Foundation/Foundation.h>
#import "BLWalletCompat.h"

NS_ASSUME_NONNULL_BEGIN

@interface BLPaymentTransactionModel : NSObject<NSCoding>

#pragma mark - Properties

/**
 * 事务 id.
 */
@property(nonatomic, copy, nonnull, readonly) NSString *transactionIdentifier;

/**
 * 交易时间(添加到交易队列时的时间).
 */
@property(nonatomic, strong, readonly) NSDate *transactionDate;

/**
 * 商品 id.
 */
@property(nonatomic, copy, readonly) NSString *productIdentifier;

/**
 * 后台配置的订单号.
 */
@property(nonatomic, copy, nullable) NSString *orderNo;

/**
 * 价格字符.
 */
@property(nonatomic, copy, nullable) NSString *priceTagString;

/**
 * 交易收据是否有变动的标识.
 */
@property(nonatomic, copy, nullable) NSString *md5;

/*
 * 任务被验证的次数.
 * 初始状态为 0,从未和后台验证过.
 * 当次数大于 1 时, 至少和后台验证过一次,并且未能验证当前交易的状态.
 */
@property(nonatomic, assign) NSUInteger modelVerifyCount;

#pragma mark - Method

/**
 * 初始化方法(没有收据的).
 *
 * @warning: 所有数据都必须有值, 否则会报错, 并返回 nil.
 *
 * @param productIdentifier       商品 id.
 * @param transactionIdentifier   事务 id.
 * @param transactionDate         交易时间(添加到交易队列时的时间).
 */
- (instancetype)initWithProductIdentifier:(NSString *)productIdentifier
                    transactionIdentifier:(NSString *)transactionIdentifier
                          transactionDate:(NSDate *)transactionDate;

@end

NS_ASSUME_NONNULL_END

就是一些交易的关键信息。我们在这个对象实现归档和解档的方法以后,就可以将这个对象归档成为一段 data,也可以从一段 data 中解档出这个对象。同时,我们需要实现这个对象的 -isEqual: 方法,因为,因为我们在进行对象判等的时候,要进行一些关键信息的比对,来确定两个交易是否是同一笔交易。代码太多了,我就不粘贴了,细节还需要您自己下载代码进去看。

现在回到 keyChain 上来。每个 BLPaymentTransactionModel 对象归档成一个 NSData,多个 data 组成一个集合,再将这个集合归档,然后保存在一个以 userid 为 key 的字典中,然后再对字典进行归档,然后再保存到 keyChain 中。

请记住这个数据归档的层级,要不然,实现文件里看起来有点懵。

03.验证队列

到现在为止我们可以对交易数据进行存储了,也就是说,一旦 IAP 通知我们有新的成功的交易,我们立马把这笔交易相关的数据转换成为一个交易模型,然后把这个模型归档存到 keyChain,这样我们就能将验证数据的逻辑独立出来了,而不用依赖 IAP 的回调。

现在我们开始考虑如何根据已有的数据来上传到我们自己的服务器,从而驱动我们的服务器向苹果服务器的查询,如下图所示。

我们可以设计一个队列,队列里有当前需要查询的交易 model,然后将 model 组装成为一个 task,然后在这个 task 中向我们的服务器发起请求,根据服务器返回结果再发起下一次请求,就是上图的驱动方式 5,这样形成一个闭环,直到这个队列中所有的模型都被处理完了,那么队列就处于休眠状态。

而第一次驱动队列执行的有四种情况。

第一种是初始化的时候,发现 keyChain 中还有没有处理完需要验证的交易,那么此时就开始从 keyChain 动态筛选出数据初始化队列,初始化完以后,就可以开始向服务器发起验证请求了,也就是驱动方式 1。至于为什么说是动态筛选,因为这里的任务有优先级,我们等会再说。

第二种驱动任务执行的方式是,当前队列处于休眠状态,没有任务要执行,此时用户发起购买,就会直接将当前交易放到任务队列中,开始向服务器发起验证请求,也就是驱动方式 2

第三种是用户从没有网络到有网络的时候,会去对 keyChain 做一次检查,如果有没有处理完的交易,一样会向服务器发起请求,也就是驱动方式 3

第四种是用户从后台进入前台的时候,会去对 keyChain 做一次检查,如果有没有处理完的交易,一样会向服务器发起请求,也就是驱动方式 4

有了上面四种类型的触发验证的逻辑以后,我们就能最大程度保证所有的交易都会向服务器发起验证请求,而且是永不停止的进行,直到所有的交易都验证完才会停止。

刚才说从 keyChain 中取数据有一个动态筛选的操作,这是什么意思呢?首先,我们向服务器发起的验证,不一定成功,如果失败了,我们就要给这个交易模型打上一个标记,下次验证的时候,应该优先验证那些没有被打上标记的交易模型。如果不打标记,可能会出现一直在验证同一个交易模型,阻塞了其他交易模型的验证。

// 动态规划当前应该验证哪一笔订单.
- (NSArray<BLPaymentTransactionModel *> *)dynamicPlanNeedVerifyModelsWithAllModels:(NSArray<BLPaymentTransactionModel *> *) allTransationModels {
    // 防止出现: 第一个失败的订单一直在验证, 排队的订单得不到验证.
    NSMutableArray<BLPaymentTransactionModel *> *transactionModelsNeverVerify = [NSMutableArray array];
    NSMutableArray<BLPaymentTransactionModel *> *transactionModelsRetry = [NSMutableArray array];
    for (BLPaymentTransactionModel *model in allTransationModels) {
        if (model.modelVerifyCount == 0) {
            [transactionModelsNeverVerify addObject:model];
        }
        else {
            [transactionModelsRetry addObject:model];
        }
    }
    
    // 从未验证过的订单, 优先验证.
    if (transactionModelsNeverVerify.count) {
        return transactionModelsNeverVerify.copy;
    }
    
    // 验证次数少的排前面.
    [transactionModelsRetry sortUsingComparator:^NSComparisonResult(BLPaymentTransactionModel * obj1, BLPaymentTransactionModel * obj2) {
       
        return obj1.modelVerifyCount < obj2.modelVerifyCount;
        
    }];
    
    return transactionModelsRetry.copy;
}

04.压入新交易

上面验证队列里我还有压入情景没有解释,压入情景有三种情况。

第一种是出现意外,就是初始化的时候,如果出现用户刚好交易完,但是 IAP 没有通知我们交易完成的情况,那么此时再去 IAP 的交易队列里检查一遍,如果有没有被持久化到 keyChain 的,就直接压入 keyChain 中进行持久化,一旦进入 keyChain 中,那么这笔交易就能被正确处理,这种情况在测试环境下经常出现。

第二种是正常交易,IAP 通知交易完成,此时将交易数据压入 keyChain 中。

第三种和第一种类似,用户从后台进入前台的时候,也会去检查一遍沙盒中有没有没有持久化的交易,一旦有,就把这些交易压入 keyChain 中。

上面三个压入情景,能最大程度上保证我们的持久化数据能和用户真实的交易同步,从而预防苹果出现交易成功却没有通知我们而导致的 bug。

05.项目结构总结

到现在为止,我们的结构已经有了大体了,现在我们来总结一下我们现在的项目结构。

BLPaymentManager 是交易管理者,负责和 IAP 通讯,包括商品查询和购买功能,也是交易状态的监听者,对接沙盒中收据数据的获取和更新,是我们整个支付的入口。它是一个单例,我们的验证队列是挂在它身上的。每当有新的交易进来的时候(不管是什么情景进来的),它都会把这笔交易丢给 BLPaymentVerifyManager,让 BLPaymentVerifyManager 负责去验证这笔交易是否有效。最后,BLPaymentVerifyManager 也会和 BLPaymentManager 通讯,告诉 BLPaymentManager 某笔交易的状态,让 BLPaymentManager 处理掉指定的交易。

BLPaymentVerifyManager 是验证交易队列管理者,它内部有一个需要验证的交易 task 队列,它负责管理这些队列的状态,并且驱动这些任务的执行,保证每笔交易验证的先后循序。它的内部有一个 keyChain,它的队列中的任务都是从 keyChain 中初始化过来的。同时它也管理着keyChain 中的数据,对keyChain 进行增删改查等操作,维护keyChain 的状态。同时也和 BLPaymentManager 通讯,更新交易的状态(finish 某笔交易)。

keyChain 不用说了,负责交易数据的持久化,提供增删改查等接口给它的管理者使用。

BLPaymentVerifyTask 负责和服务器通讯,并且将通讯结果回调出来给 BLPaymentVerifyManager,驱动下一个验证操作。

06.收据不同步处理

有同行反馈说,IAPbug,这个 bug 就是明明通知交易已经成功了,但是去沙盒中取收据时,发现收据为空,这个问题也是要具体应对的。

现在做了以下的处理,每次和后台通讯的结果归为三类,第一类,收据有效,验证通过;第二类,收据无效,验证失败;第三类,发生错误,需要重新验证。每个 task 回来都是只有可能是这三种情况的一种,然后 task 的回调会给队列管理者,队列管理者会把回调传出去给交易管理者,此时交易管理者在下面的代理方法中更新最新的收据,并把新收据重新传给队列管理者,队列管理者下次发起请求就是使用最新的收据进行验证操作。

@protocol BLPaymentVerifyTaskDelegate<NSObject>

@required

/**
 * 验证收到结果通知, 验证收据有效.
 */
- (void)paymentVerifyTaskDidReceiveResponseReceiptValid:(BLPaymentVerifyTask *)task;

/**
 * 验证收到结果通知, 验证收据无效.
 */
- (void)paymentVerifyTaskDidReceiveResponseReceiptInvalid:(BLPaymentVerifyTask *)task;

/**
 * 验证请求出现错误, 需要重新请求.
 */
- (void)paymentVerifyTaskUploadCertificateRequestFailed:(BLPaymentVerifyTask *)task;

@end

07.注意点

/**
 * 注销当前支付管理者.
 *
 * @warning ⚠️ 在用户退出登录时调用.
 */
- (void)logoutPaymentManager;

/**
 * 开始支付事务监听, 并且开始支付凭证验证队列.
 *
 * @warning ⚠️ 请在用户登录时和用户重新启动 APP 时调用.
 *
 * @param userid 用户 ID.
 */
- (void)startTransactionObservingAndPaymentTransactionVerifingWithUserID:(NSString *)userid;
/**
 * 是否所有的待验证任务都完成了.
 *
 * @warning error ⚠️ 退出前的警告信息(比如用户有尚未得到验证的订单).
 */
- (BOOL)didNeedVerifyQueueClearedForCurrentUser;

08.还有哪些问题?

到现在为止,第一篇上提及的八个问题,有七个在这一篇文章中都有对应的解决方案。由于篇幅原因,我就不大段大段的贴代码了,具体实践,肯定要看源码的,并且我写了巨细无比的注释,保证每个人都能看懂。

但是真的就没有问题了吗?不是的,现在已知的问题还有两个。

第一个问题,看起来要鸡蛋放在两个篮子里,比方说,数据要同时持久化到 keyChain 和沙盒中。但是这次没有做,接下来看情况,如果确实有这种问题,可能会这么做。

第二个问题,是苹果 IAP 设计上的一个大的缺陷,看似无解,出现这种情况,也就是用户千方百计要阻止交易成功,那只能他把苹果的订单邮件发给我们,我们手动给他加钱。

其他还有问题的话,请各位在评论区补充,一起讨论,谢谢你的阅读!!

我的文章集合

下面这个链接是我所有文章的一个集合目录。这些文章凡是涉及实现的,每篇文章中都有 Github 地址,Github 上都有源码。

我的文章集合索引

你还可以关注我自己维护的简书专题 iOS开发心得。这个专题的文章都是实打实的干货。如果你有问题,除了在文章最后留言,还可以在微博 @盼盼_HKbuy上给我留言,以及访问我的 Github
上一篇下一篇

猜你喜欢

热点阅读