内购(IAP)

九月份的时候到公司,开始制作一个游戏的sdk,包括了登录注册,初始化,信息收集等功能... 这些相对来说简单些,最主要的是内购.为什么要做内购呢?

游戏里面包含了虚拟商品(金币,药水等等...)
我们要进行虚拟商品的购买,就必须要通过苹果的内购简称IAP(In-App Purchase)

在内购之前我也查询了一些资料:
iOS内购实现及测试Check List
iOS应用内支付(内购)的个人开发过程及坑!
iOS证书说明和发布内购流程整理
真·iOS内购的完整流程
当然还有官方文档.

开始内购前需要做准备:

开始前要有如下几样东西:

  • itunes connect上注册的商品id
  • 创建的应用的bundle id
  • 沙箱测试的账号和密码

接下来我们就开始开发了:
首先我们要认识这几个类

SKProductSKProduct

对象提供有关您在iTunes Connect中注册的产品的信息。官方文档解释如下

SKProduct objects are returned as part of an SKProductsResponse object. Each product object provides information about a product you previously registered in iTunes Connect.

SKPaymentSKPayment

类定义了一个支付请求, 支付封装了标识特定产品的字符串以及用户想要购买的那些项目的数量。官方文档解释如下

The SKPayment class defines a request to the Apple App Store to process payment for additional functionality offered by your application. A payment encapsulates a string that identifies a particular product and the quantity of those items the user would like to purchase.

SKProductsRequest

SKProductsRequest对象用于从Apple App Store获得产品信息的。官方文档解释如下

An SKProductsRequest object is used to retrieve localized information about a list of products from the Apple App Store. Your application uses this request to present localized prices and other information to the user without having to maintain that list itself.

SKProductsResponse

SKProductsResponse对象存储了从Apple App Store获取得产品信息。官方文档解释如下

An SKProductsResponse object is returned by the Apple App Store in response to a request for information about a list of products.

SKPaymentTransaction

SKPaymentTransaction类定义驻留在支付队列中的支付交易对象。 每当付款被添加到支付队列时,创建这个支付交易对象。 当App Store处理完付款后,交易就会传送到您的应用程式。 完成的交易提供收据和交易标识符,您的应用程序可以使用该标识来保存已处理付款的永久记录。(谷歌翻译,觉得这个解释的挺好)

The SKPaymentTransaction class defines objects residing in the payment queue. A payment transaction is created whenever a payment is added to the payment queue. Transactions are delivered to your application when the App Store has finished processing the payment. Completed transactions provide a receipt and transaction identifier that your application can use to save a permanent record of the processed payment.

上代码之前先说一下我的逻辑:

  1. 先添加一个遮罩层,防止连续多次点击(因为内购的反应真的慢)
  2. 请求商品列表(我没有将所有的商品请求下来,我是买哪个就请求哪个),如果失败就提示用户,并移除遮罩层,如果成功就进行下一步
  3. 得到商品后调用下单接口(我们公司服务器的下单接口,因为公司要订单数据),若失败取消购买,并移除遮罩层,如果成功,将部分订单信息本地化(后面的验签用的,也是公司要求这样做的),进行下一步
  4. 将订单信息添加到支付队列,添加支付监听,若失败移除遮罩层,如果成功就更新本地存储的数据,进行下一步
  5. 验签,无论成功失败,更新本地数据,做响应的处理(根据各个公司的情况),移除遮罩层,移除支付监听

接下来我们就要上代码了

封装一个外部调用的方法,在接口类里面,这样只要外部调用接口类就可以调用内购,不用实际调用内购类,viewController是调用这个接口的控制器,用来设置回调代理,和承载内购控制器,order是订单信息,里面包括产品ID之类的属性,写一个模型利于扩展,后面添加参数的时候不需要修改接口,只要在模型里面添加属性就好了

+ (void)showInAppPurchasedWithViewController:(UIViewController <DYStoreKitControllerDelegate>*)viewController
                                       model:(DYOrderModel *)order {
    //创建控制器传值order
    DYStoreKitController *paymentVC = [[DYStoreKitController alloc] initWithOrder:order];
   //添加遮罩层,我直接把内购功能写成了一个控制器,每次调用内购的时候我就将这个控制器设置为子控制器,并将它的view添加视图上
    [viewController.view addSubview:paymentVC.view];
    [viewController addChildViewController:paymentVC];
    //设置代理
    paymentVC.delegate = viewController;
    //通过这个方法开始内购的流程
    [paymentVC startObserverAndProductRequest];
}

回调方法,将支付结果的报文回调给调用方,方便调试和后续操作,这个回调制作参考,实际的支付结果以服务端的结果为准

//这个是代理回调方法,用来回调给调用方
@protocol DYStoreKitControllerDelegate <NSObject>

//支付回调
- (void)paymentHandler:(NSString *)info;

@end

这个是用来处理支付结束的,每一种情况结束都要调用,写一个方法

- (void)payFinishWithTrinsaction:(SKPaymentTransaction *)transaction state:(NSString *)stateString {
    //如果传了这个参数就完成这个订单的支付
    if (transaction) {
        [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
    }
    //如果有这个数据就执行代理方法回调这个字符串,因为我做的是SDK,需要把结果回调个调用方,故有如下操作
    if (stateString) {
        if ([self.delegate respondsToSelector:@selector(paymentHandler:)]) {
            [self.delegate paymentHandler:stateString];
        }
    }
    //移除控制器和它的view
    [self.view removeFromSuperview];
    [self removeFromParentViewController];
}

开始内购监听和结束内购监听

#pragma mark - 产品监听
- (void)startObserver {
    [[SKPaymentQueue defaultQueue] addTransactionObserver:self];
}

- (void)stopObserver {
    [[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}

请求商品列表方法,也是DYStoreKitController留给外界调用的方法,
内购是可以进行批量购买的,是以支付队列的方式,所以要将proID放到一个数组后转换成NSSet类型进行支付请求的调用

//2-请求商品列表
- (void)startObserverAndProductRequest {
    
    //商品id数组
    NSMutableArray *proIDs = [[NSMutableArray alloc] initWithCapacity:1];
    
    if (self.order.proID) {
        //内购商品ID的数组
        [proIDs addObject:self.order.proID];
        
        if ([SKPaymentQueue canMakePayments]) {
            //开始内购支付监听
            [self startObserver];
            
            NSSet *IDSet = [NSSet setWithArray:proIDs];
            SKProductsRequest *productRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:IDSet];
            //设置内购请求代理,以监听请求结果
            productRequest.delegate = self;
            //开始请求
            [productRequest start];
        }
        else {
            //请求失败提示,DYHudTool是自己写的提示工具
            [DYHudTool showText:@"用户未授权内购" hideDelayAfter:1.0];
            //调用支付结束的方法
            [self payFinishWithTrinsaction:nil state:@"notAuthorization"];
        }
    }
    else {
        //没有上商品ID也结束内购
        [DYHudTool showText:@"未获取商品信息" hideDelayAfter:1.0];
        [self payFinishWithTrinsaction:nil state:@"proID is nil"];
    }
}

#pragma mark - SKProductsRequestDelegate控制器要遵守这个协议
//请求商品成功的返回结果
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
{
    //NSLog(@"products = %@",response.products);
    //得到产品
    self.products = response.products;
    //如果有产品就开始下单并添加到支付队列
    if (self.products.count > 0) {
        [self addPaymentToPamentQueue];
    }
    //没有产品就结束
    else {
        [self payFinishWithTrinsaction:nil state:nil];
    }
    
    //无效的商品id处理
    for (NSString *invalidProductId in response.invalidProductIdentifiers)
    {
        //无效的invalidProductId
    }
}
//请求失败的时候
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error {
    //结束
    [self payFinishWithTrinsaction:nil state:@"requestFailed"];
    
}

下单接口,这个接口是我们要进行订单统计才添加的接口

//3-请求成功后就调用下单接口
- (void)addPaymentToPamentQueue {
    
    for (SKProduct *product in self.products) {
        
        //调服务器的下单接口
        [self orderWithProduct:product Handler:^(NSString *orderID) {
            //下单成功回调,将需要存储的数据存到本地,根据不同的需求处理,这里就不上代码了
            
            //根据产品创建支付并添加到支付队列
            
            SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product];
            [[SKPaymentQueue defaultQueue] addPayment:payment];
            
        }];
    }
}

- (void)orderWithProduct:(SKProduct *)product Handler:(void(^)(NSString *orderID))handler {
    
    NSLog(@"开始下单");
    NSDictionary *parameters = @{
                                 //参数,下单参数,可以根据需求进行修改
                                 };
    [[DYNetworingManager sharedManager] requestWithType:RequestTypePost URLString:URL_CREATER_ORDER parameters:parameters progress:nil success:^(NSURLSessionDataTask *task, id response) {
        int status = [[response objectForKey:@"status"] intValue];
        
        //如果status等于1的时候请求成功
        //若成功就回调
        if (status == 1) {
            NSLog(@"下单成功");
            
            NSDictionary *dataDic = [response objectForKey:@"data"];
            
            NSString *orderid = [dataDic objectForKey:@"orderid"];
            
            //拿到orderid回调
            handler(orderid);
            
        }
        //失败就结束
        else {
            [self payFinishWithTrinsaction:nil state:@"orderFailed"];
        }
        
    } failure:^(NSURLSessionDataTask *task, NSError *error) {
        //网络出错也结束
        [self payFinishWithTrinsaction:nil state:@"netFailed"];
    }];
}

支付结果监听,遵守SKPaymentTransactionObserver协议

//4-监听支付结果

#pragma mark - 内购状态回调 要遵守SKPaymentTransactionObserver协议

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
    
    NSLog(@"transactions回调来了");
    
    for (SKPaymentTransaction *transaction in transactions) {
        
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchased: {

                //这里可以写更新本地数据的代码
                
                // 发送到苹果服务器验证凭证
                [self verifyPurchaseWithPaymentTransaction:transaction];
                //结束支付
                [self payFinishWithTrinsaction:transaction state:@"Purchased"];
            }
                break;
            case SKPaymentTransactionStateFailed: {
                //失败结束
                [self payFinishWithTrinsaction:transaction state:@"支付失败"];
            }
                break;
            case SKPaymentTransactionStateRestored: {
                [self payFinishWithTrinsaction:transaction state:@"恢复已经购买的商品"];
            }
                break;
            case SKPaymentTransactionStatePurchasing: {
                //商品添加进购买队列
            }
                break;
            default: {
                
            }
                break;
        }
    }
}

支付验签步骤

//5-验证购买

-(void)verifyPurchaseWithPaymentTransaction:(SKPaymentTransaction *)transaction{
    //从沙盒中获取交易凭证并且拼接成请求体数据
    NSURL *receiptUrl=[[NSBundle mainBundle] appStoreReceiptURL];
    NSData *transactionReceipt=[NSData dataWithContentsOfURL:receiptUrl];
    //将数据进行base64编码,这个方法是从别地方粘贴过来的
    NSDictionary *requestContents = @{
                                      @"receipt-data": [self encode:(uint8_t *)transactionReceipt.bytes length:transactionReceipt.length]};
    //将数据转换为json格式
    NSData *requestData = [NSJSONSerialization dataWithJSONObject:requestContents options:0 error:nil];
    //再转换为字符串,来发送请求
    NSString *dataString = [[NSString alloc] initWithData:requestData encoding:NSUTF8StringEncoding];
    
    NSString *transid = transaction.transactionIdentifier;
    
    NSDictionary *parms = @{
                            @"dataorg":dataString ? dataString : @"",//验签数据,刚处理好的
                            @"transid":transid ? transid : @"",//transid从transaction中获取的
                            };
    
    
    [[DYNetworingManager sharedManager] requestParm:parms success:^(id response) {
        
        NSLog(@"验签response = %@",response);
        int status = [[response objectForKey:@"status"] intValue];
        
        if (status == 1) {
            //更新本地数据
            
            //结束交易
            [self payFinishWithTrinsaction:transaction state:@"支付成功"];
        }
        else {
            //更新本地数据
            
            //结束交易
            [self payFinishWithTrinsaction:transaction state:@"验签失败,可能是非法的途径,可能是越狱的手机"];
        }
        
    } failed:^(NSError *error) {
        //网络问题也要结束
        [self payFinishWithTrinsaction:nil state:nil];
    }];
    
}

//Base64编码
- (NSString *)encode:(const uint8_t *)input length:(NSInteger)length {
    static char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
    
    NSMutableData *data = [NSMutableData dataWithLength:((length + 2) / 3) * 4];
    uint8_t *output = (uint8_t *)data.mutableBytes;
    
    for (NSInteger i = 0; i < length; i += 3) {
        NSInteger value = 0;
        for (NSInteger j = i; j < (i + 3); j++) {
            value <<= 8;
            
            if (j < length) {
                value |= (0xFF & input[j]);
            }
        }
        
        NSInteger index = (i / 3) * 4;
        output[index + 0] =                    table[(value >> 18) & 0x3F];
        output[index + 1] =                    table[(value >> 12) & 0x3F];
        output[index + 2] = (i + 1) < length ? table[(value >> 6)  & 0x3F] : '=';
        output[index + 3] = (i + 2) < length ? table[(value >> 0)  & 0x3F] : '=';
    }
    
    return [[NSString alloc] initWithData:data encoding:NSASCIIStringEncoding];
}

- (void)dealloc {
    //控制器销毁的时候要移除监听
    [[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
    
}

代码基本如上,本地数据部分没有写,这个需要根据不用的需求来做,就没有附上去

遇到的问题说一下吧

  • 验签地址问题验签有两个地址
//沙盒测试环境验证
#define SANDBOX @"https://sandbox.itunes.apple.com/verifyReceipt"
//正式环境验证
#define AppStore @"https://buy.itunes.apple.com/verifyReceipt"

无论如何都先到正式的环境验证,如果返回的是21007,那么说明这是一个沙盒测试,再去沙盒测试环境去验证,避免了代码的修改,方便,上线后也是这样.

  • 验签数据的问题
//从沙盒中获取交易凭证并且拼接成请求体数据
NSURL *receiptUrl=[[NSBundle mainBundle] appStoreReceiptURL];
NSData *transactionReceipt=[NSData dataWithContentsOfURL:receiptUrl];
//将数据进行base64编码,这个方法是从别地方粘贴过来的
NSDictionary *requestContents = @{
                                  @"receipt-data": [self encode:(uint8_t *)transactionReceipt.bytes length:transactionReceipt.length]};
//将数据转换为json格式
NSData *requestData = [NSJSONSerialization dataWithJSONObject:requestContents options:0 error:nil];
//再转换为字符串,来发送请求
NSString *dataString = [[NSString alloc] initWithData:requestData encoding:NSUTF8StringEncoding];

从沙盒中获取数据后,一定要将其先进行base64编码后,再转换为json格式字符串再来验证,延签的流程需要放到服务端进行,所以我只要把处理好的数据发给服务端就好了。

  • 付过钱后没有验签成功怎么办

这种情况是一个比较少见的情况,但是还是要考虑,这种情况会导致用户付过钱了但是没有收到商品。

我们主要有三种解决方式:

  • 不完成订单方式
  • 补验签方式
  • 联系客服的方式

先说一下不完成订单方式:
这种方式要先介绍一下内购的一个机制:

[[SKPaymentQueue defaultQueue] finishTransaction:transaction];

每一个支付,我只要不执行上面这句代码,这个支付就不会被完成,也就是说系统会一直给你发消息通过下面这个方法:

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions

当然前提是你添加了监听:

[[SKPaymentQueue defaultQueue] addTransactionObserver:self];

而且并没有被移除这样就算我们这次交易成功付过款了,但是没有验签,这个时候我们是没有执行下面这句代码的,

[[SKPaymentQueue defaultQueue] finishTransaction:transaction];

也就是说,当我们下次再开启支付的时候,添加了监听的话,这个订单还会去验签,这样我们就可以确保没问题了。
但是,这样有缺点,你这个支付在队列里,什么时候执行来验签就是一个问题了,而且当我们支付完成后一般会移除支付队列的监听,这样就没有办法进行监听,只有进行下一次支付的时候,添加监听到对列里面才可以进行又一次的验签,如果我一直不去交易怎么办,或者玩家想,我给钱了,又不给我东西,再也不买了怎么办。

补验签方法:
可以通过手动验签就要进行数据的持久化,将订单信息进行本地存储,在本地形成一个支付记录的表格,每一笔订单都有他的支付状态,在客户端上做一个支付记录的展示界面,将客户已支付的订单显示出来,订单状态可以分为已支付且验签和已支付未验签两种,当状态是已支付未验签的时候,就可以点击进行手动验签,手动验签需要前面验签的数据存到本地,手动验签的时候,还是调用原来的验签接口,但是数据都是从本地来拿,这样就解决了这个问题。
这种方式也有一个漏洞,如果用户卸载了那不是本地的数据也没有了吗。所以还有一种更好的存储方式,Keychain可以满足这一需求,Keychain数据并不存放在App的Sanbox中,即使删除了App,资料依然保存在Keychain中。如果重新安装了app,还可以从Keychain获取数据,这样就解决了这一个问题。

联系客服的方式:
这种方式是最简单最直接的,也不用我们客户端对这个问题做什么特殊处理,当玩家充值没有了但是没有得到相应的商品的时候,肯定会打电话给客服,玩家提供相应的数据,我们客服到查一下数据,将没有给玩家的商品直接给补上就好了,毕竟这也只是少数的情况,而且我们有自己的订单系统,这个问题一查就查到了。

  • 充错用户的问题

看别人的博客,说当付过钱后没有网络导致没有验签,这样的话,我切换用户后,下次支付时,系统会把前面的支付结果在来一次,这样就又去调用验签方法,这个时候可能会充错用户。
如果有自己的订单系统的话就可以完全不用考虑这个问题,每一笔订单都是和用户的id绑定的,不会出现充值错的问题。
当人如果没有订单系统的话,可以通过进行数据本地存储来解决这个问题,具体就不说了。

总结

经过一段时间的运行发现自己的思路有些地方还是不是很好,比如说当我调起支付的时候,我会先添加一个遮罩层,在支付完成的时候在去掉,这样其实是一个不是很好的操作,每一个支付都是添加都队列里面的,不是你添进去后就会马上执行,弹出支付框,这样的体验就会比较差,而且在去掉遮罩层的时候有很多种情况,一不留神有一种情况没有去掉,那么这个没办法进行下去了,遮罩还在上面,点都点不了,后来我发现可以在点击购买后就把商品列表辞退掉也是一种处理方法,这样也可以避免用户多次点击,每一种方式都有优点缺点吧,这个可以根据不同情况来进行选择处理方式。

还有就是将订单信息存储为一个属性也是一个不是很好的选择,初步想法,可以将内购控制器设置为一个单例,这样统一管理内购订单,也可以将原来的订单属性变为一个订单数组,这样当前订单没有完成的时候也可以进行下一个订单的处理,这个还有一些考虑的不够周全,还有待完善。

内购我也属于边学边做,也会有地方不是很好,后面发现再进行修改吧,如果有读者看到了不是很合适的地方,希望留言指出,好继续改进。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容