网络知识 娱乐 iOS 内购(In-App Purchase)详解

iOS 内购(In-App Purchase)详解

iOS 内购(In-App Purchase)详解

概述

IAP 全称:In-App Purchase,是指苹果 App Store 的应用内购买,是苹果为 App 内购买虚拟商品或服务提供的一套交易系统。

适用范围:在 App 内需要付费使用的产品功能或虚拟商品/服务,如游戏道具、电子书、音乐、视频、订阅会员、App的高级功能等需要使用 IAP,而在 App 内购买实体商品(如淘宝购买手机)或者不在 App 内使用的虚拟商品(如充话费)或服务(如滴滴叫车)则不适用于 IAP。

简而言之,苹果规定:适用范围内的虚拟商品或服务,必须使用 IAP 进行购买支付,不允许使用支付宝、微信支付等其它第三方支付方式(包括Apple Pay),也不允许以任何方式(包括跳出App、提示文案等)引导用户通过应用外部渠道购买。

内购前准备

APP内集成IAP代码之前需要先去开发账号的ITunes Connect进行以下三步操作:

1,后台填写银行账户信息;

2,配置商品信息,包括产品ID,产品价格等;

3,配置用于测试IAP支付功能的沙箱账户。

填写银行账户信息一般交由产品管理人员负责,开发者不需要关注,开发者需要关注的是第二步和第三步。

银行账户信息填写

关于如何去 Itunes Connect 后台填写账户信息,本文不做讨论,可以参考:iOS内购一条龙—账户信息填写

配置内购商品

IAP 是一套商品交易系统,而非简单的支付系统,每一个购买项目都需要在开发者后台的Itunes Connect后台为 App 创建一个对应的商品,提交给苹果审核通过后,购买项目才会生效。内购商品有四种类型:

  • 消耗型项目:只可使用一次的产品,使用之后即失效,必须再次购买,如:游戏币、一次性虚拟道具等;
  • 非消耗型项目:只需购买一次,不会过期或随着使用而减少的产品。如:电子书;
  • 自动续期订阅:允许用户在固定时间段内购买动态内容的产品。除非用户选择取消,否则此类订阅会自动续期,如:Apple Music这类按月订阅的商品;
  • 非续期订阅:允许用户购买有时限性服务的产品,此 App 内购买项目的内容可以是静态的。此类订阅不会自动续期。

配置商品信息需要注意产品ID和产品价格

1,产品 ID 具有唯一性,建议使用项目的 Bundle Identidier 作为前缀后面拼接自定义的唯一的商品名或者 ID(字母、数字),这里有个坑:一旦新建一个内购商品,它的产品ID将永远被占用,即使该商品已经被删除,已创建的内购商品除了产品 ID 之外的所有信息都可以修改,如果删除了一个内购商品,将无法再创建一个相同产品 ID 的商品,也意味着该产品 ID 永久失效。

2,在创建IAP项目的时候,需要设定价格,产品价格只能从苹果提供的价格等级去选择,这个价格等级是固定的,同一价格等级会对应各个国家的货币,比如等级1对应1美元、6元人民币,等级2对应2美元、12元人民币……最高等级87对应999.99美元、6498元人民币。另外可能是为了照顾某些货币区的开发者和用户,还有一些特殊的等级,比如备用等级A对应1美元、1元人民币,备用等级B对应1美元、3元人民币这样。除此之外,IAP项目不能定一个9.9元人民币这样不符合任何等级的价格。详细价格等级表可以看苹果的官方价格等级文档。苹果的价格等级表通常是不会调整的,但也不排除在某些货币汇率发生巨大变化的情况下,对该货币的定价进行调整,调整前苹果会发邮件通知开发者。

3,商品分成,App Store上的付费App和App内购,苹果与开发者默认是3/7分成。但实际上,在某些地区苹果与开发者分成之前需要先扣除交易税,开发者的实际分成不一定是70%。从2015年10月开始,苹果对中国地区的App Store购买扣除了2%的交易税,对于中国区帐号购买的IAP,开发者的实际分成在68%~69%之间,而且中国以外不同地区的交易税标准也存在差异。

配置沙箱测试账号

新的内购产品上线之前,测试人员一般需要对内购产品进行测试,但是内购涉及到钱,所以苹果为内购测试提供了 沙箱测试账号 的功能,Apple Pay 推出之后 沙箱测试账号`也可以用于 Apple Pay 支付的测试,沙箱测试账号 简单理解就是:只能用于内购和 Apple Pay 测试功能的 Apple ID,它并不是真实的 Apple ID。

填写沙箱测试账号信息需要注意以下几点:

  • 电子邮件不能是别人已经注册过 AppleID 的邮箱;
  • 电子邮箱可以不是真实的邮箱,但是必须符合邮箱格式;
  • App Store 地区的选择,测试的时候弹出的提示框以及结算的价格会按照沙箱账号选择的地区来,建议测试的时候新建几个不同地区的账号进行测试。

沙箱账号测试的使用:

  • 首先沙箱测试账号必须在真机环境下进行测试,并且是 adhoc 证书或者 develop 证书签名的安装包,沙盒账号不支持直接从 App Store 下载的安装包;
  • 去真机的 App Store 退出真实的 Apple ID 账号,退出之后并不需要在App Store 里面登录沙箱测试账号;
  • 然后去 App 里面测试购买商品,会弹出登录框,选择使用现有的 Apple ID,然后登录沙箱测试账号,登录成功之后会弹出购买提示框,点击购买,然后会弹出提示框完成购买。

内购流程

  • 获取内购产品列表(从App内读取或从自己服务器读取),向用户展示内购列表
  • 用户选择某个内购产品后,先请求可用的内购产品的本地化信息列表,此次调用Apple的StoreKit库的代码
  • 得到内购产品的本地化信息后,根据用户选择的内购产品的ID得到内购产品
  • 根据内购产品发起IAP购买请求,收到购买完成的回调
  • 购买流程结束后, 向服务器发起验证凭证以及支付结果的请求
  • 服务器接收iOS端发过来的购买凭证,判断凭证是否已经存在或验证过,然后存储该凭证。将该凭证发送到苹果的服务器验证,并将验证结果返回给客户端
  • 自己的服务器将支付结果信息返回给前端并发放虚拟产品

流程图如下:

在这里插入图片描述

代码逻辑:

---------------------LCLInAppPurchase.h---------------------
#import 
#import 

static NSString *InAppPurchaseFailRefuse = @"该商品暂时无法购买,请稍后重试";
static NSString *InAppPurchaseFailRequest = @"操作失败,请稍后重试";
static NSString *InAppPurchaseFailBuy = @"购买失败,请稍后重试";
static NSString *InAppPurchaseFailResume = @"恢复失败,您未购买过该商品";



@interface LCLInAppPurchase : NSObject

- (id)init;

//发起内购
- (void)launchInAppPurchase:(NSString *)productId;

//恢复内购
- (void)resumeInAppPurchase:(NSString *)productId ;
-(void)removeObserver;


@end

---------------------LCLInAppPurchase.m---------------------
#import "LCLInAppPurchase.h"


@interface LCLInAppPurchase()
{
    int _isResume;//是否恢复的购买
    NSString *_productId;//内购中的产品ID
}
@end

@implementation LCLInAppPurchase


- (id)init{
    self = [super init];
    
    
    if (self) {
        [[SKPaymentQueue defaultQueue] addTransactionObserver:self];
        
    }
    
    return self;
}

-(void)removeObserver{
    [[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}
- (void)launchInAppPurchase:(NSString *)productId{
   
    _isResume = 0;
    _productId = productId;
    if([SKPaymentQueue canMakePayments]){
        [self requestProductData:productId];
    }else{
        NSLog(@"不允许程序内付费");
    }
}
- (void)resumeInAppPurchase:(NSString *)productId{
    _isResume=1;
    _productId = productId;
    [[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
}
- (void)requestProductData:(NSString *)type{
    NSLog(@"-------------请求对应的产品信息----------------");
    NSArray *product = [[NSArray alloc] initWithObjects:type, nil];
    NSSet *nsset = [NSSet setWithArray:product];
    SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:nsset];
    request.delegate = self;
    [request start];
}
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response{
    NSLog(@"--------------收到产品反馈消息---------------------");
    NSArray *product = response.products;
    if([product count] == 0){
        NSLog(@"--------------没有商品------------------");
        return;
    }
    NSLog(@"productID:%@", response.invalidProductIdentifiers);
    SKProduct *p = nil;
    for (SKProduct *pro in product) {
        NSLog(@"%@", [pro description]);
        NSLog(@"%@", [pro localizedTitle]);
        NSLog(@"%@", [pro localizedDescription]);
        NSLog(@"%@", [pro price]);
        NSLog(@"%@", [pro productIdentifier]);
        if([pro.productIdentifier isEqualToString:_productId]){
            p = pro;
        }
    }
    SKPayment *payment = [SKPayment paymentWithProduct:p];
    NSLog(@"发送购买请求");
    [[SKPaymentQueue defaultQueue] addPayment:payment];
    // 可以把我们的自己订单和IAP的交易订单绑定,本地存储订单信息
}
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error{
    NSLog(@"------------------错误-----------------:%@", error);
}
- (void)requestDidFinish:(SKRequest *)request{
    NSLog(@"------------反馈信息结束-----------------");
}
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transaction{
    NSString *resultA=@"";
    SKPaymentTransaction *tran = [transaction lastObject];
    switch (tran.transactionState) {
        case SKPaymentTransactionStatePurchased:
            NSLog(@"交易完成");
            if (_isResume==0) {
                NSData *receiptData = [NSData dataWithContentsOfURL:[[NSBundle mainBundle] appStoreReceiptURL]];
                resultA=[self encode:receiptData.bytes length:receiptData.length];
                NSLog(@"购买结果票据:%@",resultA);
                // 收据发送到服务器
                // 收据验证成功之后结束交易
                [[SKPaymentQueue defaultQueue] finishTransaction:tran];
                 // 删除保存的订单信息
            }
            else
            {
                NSString *resultB=[self encode:tran.transactionReceipt.bytes length:tran.transactionReceipt.length];
                NSLog(@"恢复结果票据:%@",resultB);
                // 收据发送到服务器
                // 收据验证成功之后结束交易
                [[SKPaymentQueue defaultQueue] finishTransaction:tran];
            }
            
            break;
        case SKPaymentTransactionStatePurchasing:
            NSLog(@"商品添加进列表");
            break;
        case SKPaymentTransactionStateRestored:
            NSLog(@"已经购买过商品");
            [[SKPaymentQueue defaultQueue] finishTransaction:tran];
            break;
        case SKPaymentTransactionStateFailed:
            NSLog(@"交易失败");
            NSLog(@"%ld",tran.error.code);
            [[SKPaymentQueue defaultQueue] finishTransaction:tran];
            [self errorReason:tran.error];
            break;
        default:
            break;
    }
}
- (void)errorReason:(NSError *)error{
    NSString *detail;
    if (error != nil) {
        switch (error.code) {
            case SKErrorUnknown:
                NSLog(@"SKErrorUnknown");
                detail = @"未知的错误,您可能正在使用越狱手机";
                break;
            case SKErrorClientInvalid:
                NSLog(@"SKErrorClientInvalid");
                detail = @"当前苹果账户无法购买商品(如有疑问,可以询问苹果客服)";
                break;
            case SKErrorPaymentCancelled:
                NSLog(@"SKErrorPaymentCancelled");
                detail = @"订单已取消";
                break;
            case SKErrorPaymentInvalid:
                NSLog(@"SKErrorPaymentInvalid");
                detail = @"订单无效(如有疑问,可以询问苹果客服)";
                break;
            case SKErrorPaymentNotAllowed:
                NSLog(@"SKErrorPaymentNotAllowed");
                detail = @"当前苹果设备无法购买商品(如有疑问,可以询问苹果客服)";
                break;
            case SKErrorStoreProductNotAvailable:
                NSLog(@"SKErrorStoreProductNotAvailable");
                detail = @"当前商品不可用";
                break;
            default:
                NSLog(@"No Match Found for error");
                detail = @"未知错误";
                break;
        }
    }
}
- (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 > 18) & 0x3F];
        output[index + 1] =                    table[(value >> 12) & 0x3F];
        output[index + 2] = (i + 1) > 6)  & 0x3F] : '=';
        output[index + 3] = (i + 2) > 0)  & 0x3F] : '=';
    }
    return [[NSString alloc] initWithData:data encoding:NSASCIIStringEncoding];
}
- (void)dealloc{
    [self removeObserver];
    [[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}

@end

自动续期订阅

自动续期订阅需要增加一个参数password,秘钥在APP内购买项目处创建。服务器提供URL用以接收苹果服务器通知,包含订阅状态变更或App内购买项目退款等。

丢单及其他问题处理

IAP的支付流程:

1,发起支付

2,扣费成功

3,得到receipt(支付凭据)

4,去后台验证凭据获取商品交易状态

5,返回数据,验证成功前端刷新数据

  • 漏单情况一:2到3环节出问题属于苹果的问题,目前没做处理。

  • 漏单情况二:3到4的时候出问题,比如断网。此时前端会把支付凭据持久化存储下来,如果期间用户卸载APP此单在前端就真漏了,如果没有协助,下次重新打开app进入购买页会先判断有无未成功的支付,有就提示用户,用户选择找回,重走4,5流程。这一步看产品需求怎么做,可以让用户自主选择是否恢复未成功的支付也可以前端默默恢复就行。

  • 漏单情况三:4到5的时候出问题。此时后台其实已经成功,只是前端没获取到数据,当漏单处理,下次进入的时候先刷新数据即可。

  • 交易凭据receipt判重。一般来说验证支付凭据(receipt)是否有效放后台去做,如果后台不做判重,同一个凭据就可以无数次验证通过,因为苹果也不判重,这就会导致前端可以凭此取到的一个支付凭据可以去后台无数次做校验