iOS应用内支付(IAP)详解

在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


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

推荐阅读更多精彩内容