概述
iOS內(nèi)購是指蘋果 App Store 的應用內(nèi)購買,即In-App Purchase,簡稱IAP(以下本文關于內(nèi)購都簡稱為IAP),是蘋果為 App 內(nèi)購買虛擬商品或服務提供的一套交易系統(tǒng)。為什么我們需要掌握IAP這套流程呢,因為App Store審核指南規(guī)定:
如果您想要在 app 內(nèi)解鎖特性或功能 (解鎖方式有:訂閱、游戲內(nèi)貨幣、游戲關卡、優(yōu)質(zhì)內(nèi)容的訪問
限或解鎖完整版等),則必須使用 App 內(nèi)購買項目。App 不得使用自身機制來解鎖內(nèi)容或功能,
如許可證密鑰、增強現(xiàn)實標記、二維碼等。App 及其元數(shù)據(jù)不得包含按鈕、外部鏈接或其他行動號
召用語,以指引用戶使用非 App 內(nèi)購買項目機制進行購買。
這段話的大概意思就是APP內(nèi)的虛擬商品或服務,必須使用 IAP 進行購買支付,不允許使用支付寶、微信支付等其它第三方支付方式(包括Apple Pay),也不允許以任何方式(包括跳出App、提示文案等)引導用戶通過應用外部渠道購買。如果違反此規(guī)定,apple審核人員不會讓你的APP上架?。?!
內(nèi)購前準備
APP內(nèi)集成IAP代碼之前需要先去開發(fā)賬號的ITunes Connect進行以下三步操作:
1,后臺填寫銀行賬戶信息
2,配置商品信息,包括產(chǎn)品ID,產(chǎn)品價格等
3,配置用于測試IAP支付功能的沙箱賬戶。
填寫銀行賬戶信息一般交由產(chǎn)品管理人員負責,開發(fā)者不需要關注,開發(fā)者需要關注的是第二步和第三步。
配置內(nèi)購商品
IAP 是一套商品交易系統(tǒng),而非簡單的支付系統(tǒng),每一個購買項目都需要在開發(fā)者后臺的Itunes Connect后臺為 App 創(chuàng)建一個對應的商品,提交給蘋果審核通過后,購買項目才會生效。內(nèi)購商品有四種類型:
- 消耗型項目:只可使用一次的產(chǎn)品,使用之后即失效,必須再次購買,如:游戲幣、一次性虛擬道具等
- 非消耗型項目:只需購買一次,不會過期或隨著使用而減少的產(chǎn)品。如:電子書
- 自動續(xù)期訂閱:允許用戶在固定時間段內(nèi)購買動態(tài)內(nèi)容的產(chǎn)品。除非用戶選擇取消,否則此類訂閱會自動續(xù)期,如:Apple Music這類按月訂閱的商品(有些雞賊的開發(fā)者以此收割對IAP商品不熟悉的用戶,參考App Store“流氓”軟件)
- 非續(xù)期訂閱:允許用戶購買有時限性服務的產(chǎn)品,此 App 內(nèi)購買項目的內(nèi)容可以是靜態(tài)的。此類訂閱不會自動續(xù)期
配置商品信息需要注意產(chǎn)品ID和產(chǎn)品價格
1,產(chǎn)品 ID 具有唯一性,建議使用項目的 Bundle Identidier 作為前綴后面拼接自定義的唯一的商品名或者 ID(字母、數(shù)字),這里有個坑:一旦新建一個內(nèi)購商品,它的產(chǎn)品ID將永遠被占用,即使該商品已經(jīng)被刪除,已創(chuàng)建的內(nèi)購商品除了產(chǎn)品 ID 之外的所有信息都可以修改,如果刪除了一個內(nèi)購商品,將無法再創(chuàng)建一個相同產(chǎn)品 ID 的商品,也意味著該產(chǎn)品 ID 永久失效!??!
2,在創(chuàng)建IAP項目的時候,需要設定價格,產(chǎn)品價格只能從蘋果提供的價格等級去選擇,這個價格等級是固定的,同一價格等級會對應各個國家的貨幣,比如等級1對應1美元、6元人民幣,等級2對應2美元、12元人民幣……最高等級87對應999.99美元、6498元人民幣。另外可能是為了照顧某些貨幣區(qū)的開發(fā)者和用戶,還有一些特殊的等級,比如備用等級A對應1美元、1元人民幣,備用等級B對應1美元、3元人民幣這樣。除此之外,IAP項目不能定一個9.9元人民幣這樣不符合任何等級的價格。詳細價格等級表可以看蘋果的官方價格等級文檔
蘋果的價格等級表通常是不會調(diào)整的,但也不排除在某些貨幣匯率發(fā)生巨大變化的情況下,對該貨幣的定價進行調(diào)整,調(diào)整前蘋果會發(fā)郵件通知開發(fā)者。
3,商品分成
App Store上的付費App和App內(nèi)購,蘋果與開發(fā)者默認是3/7分成。但實際上,在某些地區(qū)蘋果與開發(fā)者分成之前需要先扣除交易稅,開發(fā)者的實際分成不一定是70%。從2015年10月開始,蘋果對中國地區(qū)的App Store購買扣除了2%的交易稅,對于中國區(qū)帳號購買的IAP,開發(fā)者的實際分成在68%~69%之間。而且中國以外不同地區(qū)的交易稅標準也存在差異,如蘋果的官方價格等級文檔
,如果需要嚴格計算實際收入,可能需要把這個部分也考慮進來。
針對不同地區(qū)的內(nèi)購,內(nèi)購價格和對應的開發(fā)者實際收入在蘋果的價格等級表中有詳細列舉。
另外,根據(jù)蘋果在2016年6月的新規(guī)則,針對Auto-Renewable Subscription類型的IAP,如果用戶購買的訂閱時間超過1年,那么從第二年開始,開發(fā)者可以獲得85%的分成。詳情可查看蘋果的訂閱產(chǎn)品價格說明
沙箱賬戶
新的內(nèi)購產(chǎn)品上線之前,測試人員一般需要對內(nèi)購產(chǎn)品進行測試,但是內(nèi)購涉及到錢,所以蘋果為內(nèi)購測試提供了 沙箱測試賬號 的功能,Apple Pay 推出之后 沙箱測試賬號`也可以用于 Apple Pay 支付的測試,沙箱測試賬號 簡單理解就是:只能用于內(nèi)購和 Apple Pay 測試功能的 Apple ID,它并不是真實的 Apple ID。
填寫沙箱測試賬號信息需要注意以下幾點:
- 電子郵件不能是別人已經(jīng)注冊過 AppleID 的郵箱
- 電子郵箱可以不是真實的郵箱,但是必須符合郵箱格式
- App Store 地區(qū)的選擇,測試的時候彈出的提示框以及結(jié)算的價格會按照沙箱賬號選擇的地區(qū)來,建議測試的時候新建幾個不同地區(qū)的賬號進行測試?。?!
沙箱賬號測試的使用:
- 首先沙箱測試賬號必須在真機環(huán)境下進行測試,并且是 adhoc 證書或者 develop 證書簽名的安裝包,沙盒賬號不支持直接從 App Store 下載的安裝包
- 去真機的 App Store 退出真實的 Apple ID 賬號,退出之后并不需要在App Store 里面登錄沙箱測試賬號
- 然后去 App 里面測試購買商品,會彈出登錄框,選擇
使用現(xiàn)有的 Apple ID,然后登錄沙箱測試賬號,登錄成功之后會彈出購買提示框,點擊購買,然后會彈出提示框完成購買。
內(nèi)購流程
IAP的支付流程分為客戶端和服務端,客戶端的工作如下:
- 獲取內(nèi)購產(chǎn)品列表(從App內(nèi)讀取或從自己服務器讀取),向用戶展示內(nèi)購列表
- 用戶選擇某個內(nèi)購產(chǎn)品后,先請求可用的內(nèi)購產(chǎn)品的本地化信息列表,此次調(diào)用Apple的StoreKit庫的代碼
- 得到內(nèi)購產(chǎn)品的本地化信息后,根據(jù)用戶選擇的內(nèi)購產(chǎn)品的ID得到內(nèi)購產(chǎn)品
- 根據(jù)內(nèi)購產(chǎn)品發(fā)起IAP購買請求,收到購買完成的回調(diào)
- 購買流程結(jié)束后, 向服務器發(fā)起驗證憑證以及支付結(jié)果的請求
- 自己的服務器將支付結(jié)果信息返回給前端并發(fā)放虛擬產(chǎn)品
前端支付流程圖如下:

------------------------------ IAPManager.h -----------------------------
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef enum {
IAPPurchSuccess = 0, // 購買成功
IAPPurchFailed = 1, // 購買失敗
IAPPurchCancel = 2, // 取消購買
IAPPurchVerFailed = 3, // 訂單校驗失敗
IAPPurchVerSuccess = 4, // 訂單校驗成功
IAPPurchNotArrow = 5, // 不允許內(nèi)購
}IAPPurchType;
typedef void (^IAPCompletionHandle)(IAPPurchType type,NSData *data);
@interface IAPManager : NSObject
+ (instancetype)shareIAPManager;
- (void)startPurchaseWithID:(NSString *)purchID completeHandle:(IAPCompletionHandle)handle;
@end
NS_ASSUME_NONNULL_END
------------------------------ IAPManager.m -----------------------------
#import "IAPManager.h"
#import <Foundation/Foundation.h>
#import <StoreKit/StoreKit.h>
@interface IAPManager()<SKPaymentTransactionObserver,SKProductsRequestDelegate>{
NSString *_currentPurchasedID;
IAPCompletionHandle _iAPCompletionHandle;
}
@end
@implementation IAPManager
+ (instancetype)shareIAPManager{
static IAPManager *iAPManager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken,^{
iAPManager = [[IAPManager alloc] init];
});
return iAPManager;
}
- (instancetype)init{
self = [super init];
if (self) {
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
}
return self;
}
- (void)dealloc{
[[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}
- (void)startPurchaseWithID:(NSString *)purchID completeHandle:(IAPCompletionHandle)handle{
if (purchID) {
if ([SKPaymentQueue canMakePayments]) {
_currentPurchasedID = purchID;
_iAPCompletionHandle = handle;
//從App Store中檢索關于指定產(chǎn)品列表的本地化信息
NSSet *nsset = [NSSet setWithArray:@[purchID]];
SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:nsset];
request.delegate = self;
[request start];
}else{
[self handleActionWithType:IAPPurchNotArrow data:nil];
}
}
}
- (void)handleActionWithType:(IAPPurchType)type data:(NSData *)data{
#if DEBUG
switch (type) {
case IAPPurchSuccess:
NSLog(@"購買成功");
break;
case IAPPurchFailed:
NSLog(@"購買失敗");
break;
case IAPPurchCancel:
NSLog(@"用戶取消購買");
break;
case IAPPurchVerFailed:
NSLog(@"訂單校驗失敗");
break;
case IAPPurchVerSuccess:
NSLog(@"訂單校驗成功");
break;
case IAPPurchNotArrow:
NSLog(@"不允許程序內(nèi)付費");
break;
default:
break;
}
#endif
if(_iAPCompletionHandle){
_iAPCompletionHandle(type,data);
}
}
- (void)verifyPurchaseWithPaymentTransaction:(SKPaymentTransaction *)transaction{
//交易驗證
NSURL *recepitURL = [[NSBundle mainBundle] appStoreReceiptURL];
NSData *receipt = [NSData dataWithContentsOfURL:recepitURL];
if(!receipt){
// 交易憑證為空驗證失敗
[self handleActionWithType:IAPPurchVerFailed data:nil];
return;
}
// 購買成功將交易憑證發(fā)送給服務端進行再次校驗
[self handleActionWithType:IAPPurchSuccess data:receipt];
NSError *error;
NSDictionary *requestContents = @{
@"receipt-data": [receipt base64EncodedStringWithOptions:0]
};
NSData *requestData = [NSJSONSerialization dataWithJSONObject:requestContents
options:0
error:&error];
if (!requestData) { // 交易憑證為空驗證失敗
[self handleActionWithType:IAPPurchVerFailed data:nil];
return;
}
NSString *serverString = @"https:xxxx";
NSURL *storeURL = [NSURL URLWithString:serverString];
NSMutableURLRequest *storeRequest = [NSMutableURLRequest requestWithURL:storeURL];
[storeRequest setHTTPMethod:@"POST"];
[storeRequest setHTTPBody:requestData];
[[NSURLSession sharedSession] dataTaskWithRequest:storeRequest completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (error) {
// 無法連接服務器,購買校驗失敗
[self handleActionWithType:IAPPurchVerFailed data:nil];
} else {
NSError *error;
NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
if (!jsonResponse) {
// 服務器校驗數(shù)據(jù)返回為空校驗失敗
[self handleActionWithType:IAPPurchVerFailed data:nil];
}
NSString *status = [NSString stringWithFormat:@"%@",jsonResponse[@"status"]];
if(status && [status isEqualToString:@"0"]){
[self handleActionWithType:IAPPurchVerSuccess data:nil];
} else {
[self handleActionWithType:IAPPurchVerFailed data:nil];
}
#if DEBUG
NSLog(@"----驗證結(jié)果 %@",jsonResponse);
#endif
}
}];
// 驗證成功與否都注銷交易,否則會出現(xiàn)虛假憑證信息一直驗證不通過,每次進程序都得輸入蘋果賬號
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}
#pragma mark - SKProductsRequestDelegate
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response{
NSArray *product = response.products;
if([product count] <= 0){
#if DEBUG
NSLog(@"--------------沒有商品------------------");
#endif
return;
}
SKProduct *p = nil;
for(SKProduct *pro in product){
if([pro.productIdentifier isEqualToString:_currentPurchasedID]){
p = pro;
break;
}
}
#if DEBUG
NSLog(@"productID:%@", response.invalidProductIdentifiers);
NSLog(@"產(chǎn)品付費數(shù)量:%lu",(unsigned long)[product count]);
NSLog(@"產(chǎn)品描述:%@",[p description]);
NSLog(@"產(chǎn)品標題%@",[p localizedTitle]);
NSLog(@"產(chǎn)品本地化描述%@",[p localizedDescription]);
NSLog(@"產(chǎn)品價格:%@",[p price]);
NSLog(@"產(chǎn)品productIdentifier:%@",[p productIdentifier]);
#endif
SKPayment *payment = [SKPayment paymentWithProduct:p];
[[SKPaymentQueue defaultQueue] addPayment:payment];
}
//請求失敗
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error{
#if DEBUG
NSLog(@"------------------從App Store中檢索關于指定產(chǎn)品列表的本地化信息錯誤-----------------:%@", error);
#endif
}
- (void)requestDidFinish:(SKRequest *)request{
#if DEBUG
NSLog(@"------------requestDidFinish-----------------");
#endif
}
#pragma mark - SKPaymentTransactionObserver
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions{
for (SKPaymentTransaction *tran in transactions) {
switch (tran.transactionState) {
case SKPaymentTransactionStatePurchased:
[self verifyPurchaseWithPaymentTransaction:tran];
break;
case SKPaymentTransactionStatePurchasing:
#if DEBUG
NSLog(@"商品添加進列表");
#endif
break;
case SKPaymentTransactionStateRestored:
#if DEBUG
NSLog(@"已經(jīng)購買過商品");
#endif
// 消耗型不支持恢復購買
[[SKPaymentQueue defaultQueue] finishTransaction:tran];
break;
case SKPaymentTransactionStateFailed:
[self failedTransaction:tran];
break;
default:
break;
}
}
}
// 交易失敗
- (void)failedTransaction:(SKPaymentTransaction *)transaction{
if (transaction.error.code != SKErrorPaymentCancelled) {
[self handleActionWithType:IAPPurchFailed data:nil];
}else{
[self handleActionWithType:IAPPurchCancel data:nil];
}
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}
@end
/* 調(diào)用支付方法
- (void)purchaseWithProductID:(NSString *)productID{
[[IAPManager shareIAPManager] startPurchaseWithID:productID completeHandle:^(IAPPurchType type,NSData *data) {
}];
}
*/
服務端的工作:
- 接收iOS端發(fā)過來的購買憑證,判斷憑證是否已經(jīng)存在或驗證過,然后存儲該憑證。將該憑證發(fā)送到蘋果的服務器驗證,并將驗證結(jié)果返回給客戶端。
恢復購買
內(nèi)購有4種:消耗型項目,非消耗型,自動續(xù)期訂閱,非續(xù)期訂閱。 其中”非消耗型“和”自動續(xù)期訂閱“需要提供恢復購買的功能,例如創(chuàng)建一個恢復按鈕,不然審核很可能會被拒絕。
//調(diào)起蘋果內(nèi)購恢復接口
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
“消耗型項目”和“非續(xù)期訂閱”蘋果不會提供恢復的接口,不要調(diào)用上述方法去恢復,否則有可能被拒?。。?/p>
“非續(xù)期訂閱”也是跨設備同步的,所以原則上來說也需要提供恢復購買的功能,但需要依靠app自建的賬戶體系恢復,不能用上述蘋果提供的接口。
內(nèi)購掉單
掉單是用戶付款買商品,錢扣了,商品卻沒到賬。掉單一旦發(fā)生,用戶通常會很生氣地來找客服。然后客服只能找開發(fā)人員把商品給用戶手動加上。顯然,傷害用戶的體驗,特別是傷害付費用戶的體驗,是一件相當糟糕的事情。
掉單是如何產(chǎn)生的呢?這需要從IAP支付的技術(shù)流程說起。
IAP的支付流程:
1,發(fā)起支付
2,扣費成功
3,得到receipt(支付憑據(jù))
4,去后臺驗證憑據(jù)獲取商品交易狀態(tài)
5,返回數(shù)據(jù),驗證成功前端刷新數(shù)據(jù)
-
漏單情況一:
2到3環(huán)節(jié)出問題屬于蘋果的問題,目前沒做處理。
- 漏單情況二:
3到4的時候出問題,比如斷網(wǎng)。此時前端會把支付憑據(jù)持久化存儲下來,如果期間用戶卸載APP此單在前端就真漏了,如果沒有協(xié)助,下次重新打開app進入購買頁會先判斷有無未成功的支付,有就提示用戶,用戶選擇找回,重走4,5流程。這一步看產(chǎn)品需求怎么做,可以讓用戶自主選擇是否恢復未成功的支付也可以前端默默恢復就行。
- 漏單情況三:
4到5的時候出問題。此時后臺其實已經(jīng)成功,只是前端沒獲取到數(shù)據(jù),當漏單處理,下次進入的時候先刷新數(shù)據(jù)即可。
內(nèi)購注意事項
- 交易憑據(jù)receipt判重
一般來說驗證支付憑據(jù)(receipt)是否有效放后臺去做,如果后臺不做判重,同一個憑據(jù)就可以無數(shù)次驗證通過,因為蘋果也不判重,這就會導致前端可以憑此取到的一個支付憑據(jù)可以去后臺無數(shù)次做校驗!?。?!,后臺就會給前端發(fā)放無數(shù)次商品,但是用戶只支付了一次錢,所以安全的做法是后臺把驗證通過的支付憑據(jù)做個記錄,每次來新的憑據(jù)先判斷是否已經(jīng)使用過,防止多次發(fā)放商品。