iOS应用内支付(IAP)详解

作者: 西木柚子 | 来源:发表于2016-04-17 18:39 被阅读23024次

    在iOS开发中如果涉及到虚拟物品的购买,就需要使用IAP服务,我们今天来看看如何实现。

    在实现代码之前我们先做一些准备工作,一步步来看。


    1、IAP流程

    IAP流程分为两种,一种是直接使用Apple的服务器进行购买和验证,另一种就是自己假设服务器进行验证。由于国内网络连接Apple服务器验证非常慢,而且也为了防止黑客伪造购买凭证,通用做法是自己架设服务器进行验证。

    下面我们通过图来看看两种方式的差别:

    1.1、使用Apple服务器

    image

    1.2、自己架设服务器

    image

    简单说下第二中情况的流程:

    1. 用户进入购买虚拟物品页面,App从后台服务器获取产品列表然后显示给用户
    2. 用户点击购买购买某一个虚拟物品,APP就发送该虚拟物品的productionIdentifier到Apple服务器
    3. Apple服务器根据APP发送过来的productionIdentifier返回相应的物品的信息(描述,价格等)
    4. 用户点击确认键购买该物品,购买请求发送到Apple服务器
    5. Apple服务器完成购买后,返回用户一个完成购买的凭证
    6. APP发送这个凭证到后台服务器验证
    7. 后台服务器把这个凭证发送到Apple验证,Apple返回一个字段给后台服务器表明该凭证是否有效
    8. 后台服务器把验证结果在发送到APP,APP根据验证结果做相应的处理

    2、iTunes Connet操作

    搞清楚了自己架设服务器是如何完成IAP购买的流程了之后,我们下一步就是登录到iTunes Connet创建应用和指定虚拟物品价格表

    2.1、创建自己的App

    如下图所示,我们需要创建一个自己的APP,要注意的是这里的Bundle ID一定要跟你的项目中的info.plist中的Bundle ID保证一致。也就是图中红框部分。

    image

    2.2、创建虚拟物品价格表

    2.2.1、虚拟物品分为如下几种:
    1. 消耗品(Consumable products):比如游戏内金币等。

    2. 不可消耗品(Non-consumable products):简单来说就是一次购买,终身可用(用户可随时从App Store restore)。

    3. 自动更新订阅品(Auto-renewable subscriptions):和不可消耗品的不同点是有失效时间。比如一整年的付费周刊。在这种模式下,开发者定期投递内容,用户在订阅期内随时可以访问这些内容。订阅快要过期时,系统将自动更新订阅(如果用户同意)。

    4. 非自动更新订阅品(Non-renewable subscriptions):一般使用场景是从用户从IAP购买后,购买信息存放在自己的开发者服务器上。失效日期/可用是由开发者服务器自行控制的,而非由App Store控制,这一点与自动更新订阅品有差异。

    5. 免费订阅品(Free subscriptions):在Newsstand中放置免费订阅的一种方式。免费订阅永不过期。只能用于Newsstand-enabled apps。

    类型2、3、5都是以Apple ID为粒度的。比如小张有三个iPad,有一个Apple ID购买了不可消耗品,则三个iPad上都可以使用。

    类型1、4一般来说则是现买现用。如果开发者自己想做更多控制,一般选4

    2.2.2、创建成功后如下所示:
    image

    其中产品id是字母或者数字,或者两者的组合,用于唯一表示该虚拟物品,app也是通过请求产品id来从apple服务器获取虚拟物品信息的。

    2.3、设置税务和银行卡信息

    这一步必须设置,不然是无法从apple获取虚拟产品信息。

    设置成功后如下所示:

    image

    更多关于iTunes Connet的操作请才看这篇博文http://openfibers.github.io/blog/2015/02/28/in-app-purchase-walk-through/


    3、iOS端具体代码实现

    完成了上面的准备工作,我们就可以开始着手IAP的代码实现了。

    我们假设你已经完成了从后台服务器获取虚拟物品列表这一步操作了,这一步后台服务器还会返回每个虚拟物品所对应的productionIdentifier,假设你也获取到了,并保存在属性self.productIdent中。

    需要在工程中引入 storekit.framework。

    我们来看看后续如何实现IAP

    3.1、确认用户是否允许IAP

    //移除监听
    -(void)dealloc
    {
        [[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
    }
    
    //添加监听
    - (void)viewDidLoad{
        [super viewDidLoad];
        [self.tableView.mj_header beginRefreshing];
        [[SKPaymentQueue defaultQueue] addTransactionObserver:self];
    }
    
    - (void)buyProdution:(UIButton *)sender{    
        if ([SKPaymentQueue canMakePayments]) {
            [self getProductInfo:self.productIdent];
        } else {
            [self showMessage:@"用户禁止应用内付费购买"];
        }
    }
    

    3.2、发起购买操作

    如果用户允许IAP,那么就可以发起购买操作了

    //从Apple查询用户点击购买的产品的信息
    - (void)getProductInfo:(NSString *)productIdentifier {
        NSArray *product = [[NSArray alloc] initWithObjects:productIdentifier, nil];
        NSSet *set = [NSSet setWithArray:product];
        SKProductsRequest * request = [[SKProductsRequest alloc] initWithProductIdentifiers:set];
        request.delegate = self;
        [request start];
        [self showMessageManualHide:@"正在购买,请稍后"];
    }
    
    // 查询成功后的回调
    - (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
        [self hideHUD];
        NSArray *myProduct = response.products;
        if (myProduct.count == 0) {
            [self showMessage:@"无法获取产品信息,请重试"];
            return;
        }
        SKPayment * payment = [SKPayment paymentWithProduct:myProduct[0]];
        [[SKPaymentQueue defaultQueue] addPayment:payment];
    }
    
    //查询失败后的回调
    - (void)request:(SKRequest *)request didFailWithError:(NSError *)error {
        [self hideHUD];
        [self showMessage:[error localizedDescription]];
    }
    
    

    3.3、购买操作后的回调

    //购买操作后的回调
    - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions {
        [self hideHUD];
        for (SKPaymentTransaction *transaction in transactions)
        {
            switch (transaction.transactionState)
            {
                case SKPaymentTransactionStatePurchased://交易完成
                    self.receipt = [GTMBase64 stringByEncodingData:[NSData dataWithContentsOfURL:[[NSBundle mainBundle] appStoreReceiptURL]]];
                    [self checkReceiptIsValid];//把self.receipt发送到服务器验证是否有效
                    [self completeTransaction:transaction];
                    break;
                    
                case SKPaymentTransactionStateFailed://交易失败
                    [self failedTransaction:transaction];
                    break;
                    
                case SKPaymentTransactionStateRestored://已经购买过该商品
                    [self showMessage:@"恢复购买成功"];
                    [self restoreTransaction:transaction];
                    break;
                    
                case SKPaymentTransactionStatePurchasing://商品添加进列表
                    [self showMessage:@"正在请求付费信息,请稍后"];
                    break;
                    
                default:
                    break;
            }
        }
        
    }
    
    
    
    - (void)completeTransaction:(SKPaymentTransaction *)transaction {
        [[SKPaymentQueue defaultQueue] finishTransaction: transaction];
    }
    
    
    - (void)failedTransaction:(SKPaymentTransaction *)transaction {
        if(transaction.error.code != SKErrorPaymentCancelled) {
            UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:nil message:@"购买失败,请重试"delegate:self cancelButtonTitle:@"取消" otherButtonTitles:@"重试", nil];
            [alertView show];
        } else {
            [self showMessage:@"用户取消交易"];
        }
        
        [[SKPaymentQueue defaultQueue] finishTransaction: transaction];
    }
    
    
    - (void)restoreTransaction:(SKPaymentTransaction *)transaction {
        [[SKPaymentQueue defaultQueue] finishTransaction: transaction];
    }
    

    3.4、向服务器端验证购买凭证的有效性

    在这一步我们需要向服务器验证Apple服务器返回的购买凭证的有效性,然后把验证结果通知用户

    - (void)checkReceiptIsValid{
    
        AFHTTPSessionManager manager]GET:@"后台服务器地址"  parameters::@"发送的参数(必须包括购买凭证)"
        success:^(NSURLSessionDataTask * _Nonnull task, id  _Nonnull responseObject) {
            if(凭证有效){
              你要做的事
            }else{//凭证无效
              你要做的事
            }
            
        } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
                    UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:nil message:@"购买失败,请重试"delegate:self cancelButtonTitle:@"取消" otherButtonTitles:@"重试", nil];
                [alertView show];
        }
    
    }
    
    

    3.5、发送凭证失败的处理

    如果出现网络问题,导致无法验证。我们需要持久化保存购买凭证,在用户下次启动APP的时候在后台向服务器再一次发起验证,直到成功然后移除该凭证。
    保证如下define可在全局访问:

    #define AppStoreInfoLocalFilePath [NSString stringWithFormat:@"%@/%@/", [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject],@"EACEF35FE363A75A"]
    
    
    
    -(void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex
    {
        if (buttonIndex == 0)
        {
            [self saveReceipt];
        }
        else
        {
            [self checkReceiptIsValid];
        }
    }
    
    //AppUtils 类的方法,每次调用该方法都生成一个新的UUID
    + (NSString *)getUUIDString
    {
        CFUUIDRef uuidRef = CFUUIDCreate(kCFAllocatorDefault);
        CFStringRef strRef = CFUUIDCreateString(kCFAllocatorDefault , uuidRef);
        NSString *uuidString = [(__bridge NSString*)strRef stringByReplacingOccurrencesOfString:@"-" withString:@""];
        CFRelease(strRef);
        CFRelease(uuidRef);
        return uuidString;
    }
    
    //持久化存储用户购买凭证(这里最好还要存储当前日期,用户id等信息,用于区分不同的凭证)
    -(void)saveReceipt{
        NSString *fileName = [AppUtils getUUIDString];
        NSString *savedPath = [NSString stringWithFormat:@"%@%@.plist", AppStoreInfoLocalFilePath, fileName];
        
        NSDictionary *dic =[ NSDictionary dictionaryWithObjectsAndKeys:
                            self.receipt,                           Request_transactionReceipt,
                            self.date                               DATE                        
                            self.userId                             USERID
                            nil];
        
        [dic writeToFile:savedPath atomically:YES];
    }
    

    3.6、APP启动后再次发送持久化存储的购买凭证到后台服务器

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions{    
        NSFileManager *fileManager = [NSFileManager defaultManager];
        
        //从服务器验证receipt失败之后,在程序再次启动的时候,使用保存的receipt再次到服务器验证
        if (![fileManager fileExistsAtPath:AppStoreInfoLocalFilePath]) {//如果在改路下不存在文件,说明就没有保存验证失败后的购买凭证,也就是说发送凭证成功。
            [fileManager createDirectoryAtPath:AppStoreInfoLocalFilePath//创建目录
                   withIntermediateDirectories:YES
                                    attributes:nil
                                         error:nil];
        }
        else//存在购买凭证,说明发送凭证失败,再次发起验证
        {
            [self sendFailedIapFiles];
        }
    }
    
    //验证receipt失败,App启动后再次验证
    - (void)sendFailedIapFiles{
        NSFileManager *fileManager = [NSFileManager defaultManager];
        NSError *error = nil;
        
        //搜索该目录下的所有文件和目录
        NSArray *cacheFileNameArray = [fileManager contentsOfDirectoryAtPath:AppStoreInfoLocalFilePath error:&error];
        
        if (error == nil)
        {
            for (NSString *name in cacheFileNameArray)
            {
                if ([name hasSuffix:@".plist"])//如果有plist后缀的文件,说明就是存储的购买凭证
                {
                    NSString *filePath = [NSString stringWithFormat:@"%@/%@", AppStoreInfoLocalFilePath, name];
                    [self sendAppStoreRequestBuyPlist:filePath];
                    
                }
            }
        }
        else
        {
            DebugLog(@"AppStoreInfoLocalFilePath error:%@", [error domain]);
        }
    }
    
    -(void)sendAppStoreRequestBuyPlist:(NSString *)plistPath
    {
        NSDictionary *dic = [NSDictionary dictionaryWithContentsOfFile:plistPath];
        
        //这里的参数请根据自己公司后台服务器接口定制,但是必须发送的是持久化保存购买凭证
        NSMutableDictionary *params = [NSMutableDictionary dictionaryWithObjectsAndKeys:
                  [dic objectForKey:USERID],                           USERID,                    
                  [dic objectForKey:DATE],                             DATE,  
                  [dic objectForKey:Receipt],                            Receipt,                                                                             
                  nil];
                                
                                                                           
            AFHTTPSessionManager manager]GET:@"后台服务器地址"  parameters:params  success:^(NSURLSessionDataTask * _Nonnull task, id  _Nonnull responseObject) {
            if(凭证有效){
             [self removeReceipt]
            }else{//凭证无效
              你要做的事
            }
            
        } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
                    
        }
        
     }
    
    //验证成功就从plist中移除凭证
    -(void)sendAppStoreRequestSucceededWithData
    {
        NSFileManager *fileManager = [NSFileManager defaultManager];
        if ([fileManager fileExistsAtPath:AppStoreInfoLocalFilePath])
        {
            [fileManager removeItemAtPath:AppStoreInfoLocalFilePath error:nil];
        }
    }
    
    
    

    至此,整个流程结束,有任何疑问欢迎大家留言


    参考:

    1. http://openfibers.github.io/blog/2015/02/28/in-app-purchase-walk-through/

    2. http://www.himigame.com/iphone-cocos2d/550.html

    3. http://blog.devtang.com/2012/12/09/in-app-purchase-check-list/

    4. http://yarin.blog.51cto.com/1130898/549141

    5. 更多技术文章,欢迎大家访问我的技术博客:http://blog.ximu.site


    相关文章

      网友评论

      • 阿牛哥2020:向服务器端验证购买凭证的有效性,这一步服务器是怎么验证的,有代码吗
      • 呱星人:似乎你这里还有需要完善的地方,你这个失败后需要重启app才能再次发送到服务端,应该多加一个定时器不断的进行重试。
        另外还有个BUG,如果用户多次购买都失败了,会有多个receipt文件,但是你这里是处理成功一个后就把整个目录删除了,这样可能导致后面还没处理的订单丢失
      • e2f2d779c022:写的很棒, 我写了一套完整的 IAP 方案, 处理了 IAP 的九大坑,感兴趣的朋友来看看吧:

        第一篇:[[iOS]贝聊 IAP 实战之满地是坑](https://www.jianshu.com/p/07b5ec193353),这一篇是支付基础知识的讲解,主要会详细介绍 IAP,同时也会对比支付宝和微信支付,从而引出 IAP 的坑和注意点。

        第二篇:[[iOS]贝聊 IAP 实战之见坑填坑](https://www.jianshu.com/p/8e5bf711f9f0),这一篇是高潮性的一篇,主要针对第一篇文章中分析出的 IAP 的问题进行具体解决。

        第三篇:[[iOS]贝聊 IAP 实战之订单绑定](https://www.jianshu.com/p/847838cde48b),这一篇是关键性的一篇,主要讲述作者探索将自己服务器生成的订单号绑定到 IAP 上的过程。
      • 黛沁馨1990:目前遇到一个问题,在第一次使用支付的时候,会跳到设置那边进行付款信息配置,配置完后就直接支付成功了,没有调用到支付回调,更没有支付凭证,这种情况的支付,应该怎么恢复支付状态,让用户的支付变成有效呢?
      • 逆光少年:请问永久会员的情况选择上面哪种类型比较好呢
      • 可惜你不是我的双子座:问一下像简书这样的打赏算不算虚拟物品!他可以直接调用微信支付耶!
      • 275ca96a36d7:请问下,用户申请退款了,被打赏者提现了,这个损失怎么算
      • 275ca96a36d7:苹果审核app时,是在sandbox环境下测试,但是在提交app审核时,需要将issandbox设置为生产环境,这样就会导致审核期间的receipt是sandbox环境生成,却走了生产环境的验证流程,验证失败,被rejected,这个问题如何解决
        西木柚子:@计算机喝酒 用第三方服务器来控制把凭证发送到哪个环境即可
      • 61d817c65aa8:可以加个好友吗
      • ad193b2e3cf5:朋友,我有个疑问,关于漏单的事。依靠什么字段来判断是否是合法的呢?因为你第一次已经finish掉了。
      • 287c54f99e29:您好,请问一下,我做的是非消耗型的产品,第一次购买能成功,但是我第二次购买的时候,在updatedTransactions这个方法中,总是不走SKPaymentTransactionStateRestored这个里边的方法,而是还走SKPaymentTransactionStatePurchasing这个里边的方法,请问是怎么回事?我现在是在做恢复购买的功能,恢复购买是应该进SKPaymentTransactionStateRestored这个里边的方法吧?
      • ShineLing:再请问, 你的这个宏 #define AppStoreInfoLocalFilePath 里面的这个字符串@"EACEF35FE363A75A" 是根据什么来的呢? 还是固定就写这一个?
        41737f6cf9b7:这个应该就是那个 UUID,随机变化的
      • ShineLing:你好, 请问 在向后台发送验证的参数parameters::@"发送的参数(必须包括购买凭证)", 是你文中写到的self.receipt, 是吗?
      • small路飞:请问下,"后台服务器把这个凭证发送到Apple验证",后台怎么获取 apple服务器 的地址呢
        西木柚子:@Areslee 我们的做法是部署两台服务器,一台测试环境,一台正式环境。app测试的时候选择测试服务器,凭证通过测试服务器发送到apple的测试地址,同理正式环境也是如此
        Areslee:@西木柚子 请问这两个地址通过什么判断当前环境,因为不能写死,需要根据当前环境判断使用哪一个地址
        西木柚子:@small路飞 测试时使用地址:https://sandbox.iTunes.Apple.com/verifyReceipt ,生产环境地址:https://buy.itunes.apple.com/verifyReceipt
      • Scofield_0b42:楼主你好, 最后和自己服务器核对信息的时候,用什么字段来判断凭证有效? staus吗?
      • 小马过河ing:有个疑问,内购完成用户是怎么支付的,支付方式是选支付宝还是微信呐,是不是绑定支付宝账户后内购时自动调用支付宝让用户验证付款呐
      • 有梦想的咸鱼宁:请问楼主有没有遇到过 receipt 只获取到一部分的情况 ,困扰了我一天了。。。 没找到问题在哪!!
        西木柚子:@Rookie_ning 那你购买成功了吗
        有梦想的咸鱼宁:@西木柚子 对比别人的,输出的时候明显少了一截, 问题就在xcode8日志输出不全……
        西木柚子:@Rookie_ning 没遇到过,你怎么判断receipt 不完整的
      • 今天星期伍:请问下 为什么我 请求到的receipt 少了很多
        西木柚子:@力了个王 具体描述下 具体少了什么
      • Felixmao:请问 商品价格是在哪里设置的
        西木柚子:@Felixmao https://itunesconnect.apple.com/登录进去设置
      • ae52fd37c031:大神,请教一下为什么我通过商品id返回给我的SKProduct,发起支付的时候,第一次老是失败。要第二次才能成功
        西木柚子:@hy082510 打印下错误日志,在failedTransaction方法里面
      • Sunrain16:@西木柚子,测试demo需要证书的配置吗?比方说发布证书,谢谢
        西木柚子:@查狄轮 http://www.cocoachina.com/ios/20150612/12110.html 这篇文章可以帮助到你
        Sunrain16:@西木柚子 大神,可以加QQ指导一下吗?整了2天,老是卡在一个地方,获取不到注册的商品信息。这是为什么呢?
        西木柚子:@查狄轮 不需要,但是你如果真机测试需要development证书,和平时测试一样
      • 超_iOS:验证失败后,楼主是吧信息存在本地了么?这样用户下次换手机了怎么办??还请楼主赐教
        西木柚子:@李二超 客户端没办法处理 只能等客户投诉 然后去查购买记录
        超_iOS:@西木柚子 换手机了怎么办?
        西木柚子:@李二超 只要他再次启动APP就会重新发送凭证 没成功就一直发
      • 超_iOS:发送凭证失败后,如果我不作处理会出现什么情况呢?
      • mkvege:请问一下 我们自己的后台起到的作用有哪些??
        1 加载商品列表
        2 上传凭证信息
        对吗

        我们的后台是如何知道商品列表的呢。。上传凭证信息具体作用是什么呢
        西木柚子:@闪电侠伟哥 我开头的流程不是写的很清楚吗?仔细看啊
        西木柚子:@闪电侠伟哥 是的,还有和苹果服务器验证凭证的有效性。后台服务器只是返回产品的id,这个是你在iTunesconnect上面配置iap的时候填写的,必须一致。客户端拿到产品ID去苹果服务器请求产品信息,上传凭证是为了验证购买的有效性
      • Windream:请问在测试版本中,如ADHoc版本,是不是只能使用沙盒账号测试?我这里使用appStore账号一直提示无法连接到iTunesStore。
        Windream:@西木柚子 非常感谢!😄
        西木柚子:@Windream 是的
      • 对酒当歌的夜:上面直接说那个用的人少只要微信和支付宝,又是在购买虚拟物品在app上使用的,貌似只有用测试服或者专用测试账号骗过审核后改回去,据说这样被发现会强制下架
        6257a89b1d1c:苹果是不允许这样做的,如果发现会被强制下线的
        西木柚子:@对酒当歌的夜 不懂你要表达什么
      • 2a687b34d296:很不错,内购测试有点疑问,想请教一下方便留个qq么
        OneKeyV:最近项目有用到,学习了
        2a687b34d296:@西木柚子 谢谢
        西木柚子:@CoCoMM 留言即可,太多的话你可以把问题整理一下发我邮箱,我会给你答复的。wangsheng9297@163.com

      本文标题:iOS应用内支付(IAP)详解

      本文链接:https://www.haomeiwen.com/subject/ndiolttx.html