iOS內購詳解

概述

iOS內購是指蘋果 App Store 的應用內購買,即In-App Purchase,簡稱IAP(以下本文關於內購都簡稱爲IAP),是蘋果爲 App 內購買虛擬商品或服務提供的一套交易系統。爲什麼我們需要掌握IAP這套流程呢,因爲App Store審覈指南規定:

如果您想要在 app 內解鎖特性或功能 (解鎖方式有:訂閱、遊戲內貨幣、遊戲關卡、優質內容的訪問
限或解鎖完整版等),則必須使用 App 內購買項目。App 不得使用自身機制來解鎖內容或功能,
如許可證密鑰、增強現實標記、二維碼等。App 及其元數據不得包含按鈕、外部鏈接或其他行動號
召用語,以指引用戶使用非 App 內購買項目機制進行購買。

這段話的大概意思就是APP內的虛擬商品或服務,必須使用 IAP 進行購買支付,不允許使用支付寶、微信支付等其它第三方支付方式(包括Apple Pay),也不允許以任何方式(包括跳出App、提示文案等)引導用戶通過應用外部渠道購買。如果違反此規定,apple審覈人員不會讓你的APP上架!!!

內購前準備

APP內集成IAP代碼之前需要先去開發賬號的ITunes Connect進行以下三步操作:

1,後臺填寫銀行賬戶信息

2,配置商品信息,包括產品ID,產品價格等

3,配置用於測試IAP支付功能的沙箱賬戶。

填寫銀行賬戶信息一般交由產品管理人員負責,開發者不需要關注,開發者需要關注的是第二步和第三步。

配置內購商品

IAP 是一套商品交易系統,而非簡單的支付系統,每一個購買項目都需要在開發者後臺的Itunes Connect後臺爲 App 創建一個對應的商品,提交給蘋果審覈通過後,購買項目纔會生效。內購商品有四種類型:

  • 消耗型項目:只可使用一次的產品,使用之後即失效,必須再次購買,如:遊戲幣、一次性虛擬道具等
  • 非消耗型項目:只需購買一次,不會過期或隨着使用而減少的產品。如:電子書
  • 自動續期訂閱:允許用戶在固定時間段內購買動態內容的產品。除非用戶選擇取消,否則此類訂閱會自動續期,如:Apple Music這類按月訂閱的商品(有些雞賊的開發者以此收割對IAP商品不熟悉的用戶,參考App Store“流氓”軟件)
  • 非續期訂閱:允許用戶購買有時限性服務的產品,此 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%之間。而且中國以外不同地區的交易稅標準也存在差異,如蘋果的官方價格等級文檔

,如果需要嚴格計算實際收入,可能需要把這個部分也考慮進來。

針對不同地區的內購,內購價格和對應的開發者實際收入在蘋果的價格等級表中有詳細列舉。

另外,根據蘋果在2016年6月的新規則,針對Auto-Renewable Subscription類型的IAP,如果用戶購買的訂閱時間超過1年,那麼從第二年開始,開發者可以獲得85%的分成。詳情可查看蘋果的訂閱產品價格說明

沙箱賬戶

新的內購產品上線之前,測試人員一般需要對內購產品進行測試,但是內購涉及到錢,所以蘋果爲內購測試提供了 沙箱測試賬號 的功能,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,然後登錄沙箱測試賬號,登錄成功之後會彈出購買提示框,點擊 購買,然後會彈出提示框完成購買。

內購流程

IAP的支付流程分爲客戶端和服務端,客戶端的工作如下:

  • 獲取內購產品列表(從App內讀取或從自己服務器讀取),向用戶展示內購列表
  • 用戶選擇某個內購產品後,先請求可用的內購產品的本地化信息列表,此次調用Apple的StoreKit庫的代碼
  • 得到內購產品的本地化信息後,根據用戶選擇的內購產品的ID得到內購產品
  • 根據內購產品發起IAP購買請求,收到購買完成的回調
  • 購買流程結束後, 向服務器發起驗證憑證以及支付結果的請求
  • 自己的服務器將支付結果信息返回給前端併發放虛擬產品

前端支付流程圖如下:

------------------------------ IAPManager.h -----------------------------
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

typedef enum {
    IAPPurchSuccess = 0,       // 購買成功
    IAPPurchFailed = 1,        // 購買失敗
    IAPPurchCancel = 2,        // 取消購買
    IAPPurchVerFailed = 3,     // 訂單校驗失敗
    IAPPurchVerSuccess = 4,    // 訂單校驗成功
    IAPPurchNotArrow = 5,      // 不允許內購
}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中檢索關於指定產品列表的本地化信息
            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(@"不允許程序內付費");
            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;
    }
    // 購買成功將交易憑證發送給服務端進行再次校驗
    [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) {
                // 服務器校驗數據返回爲空校驗失敗
                [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(@"----驗證結果 %@",jsonResponse);
#endif
        }
    }];
    
    // 驗證成功與否都註銷交易,否則會出現虛假憑證信息一直驗證不通過,每次進程序都得輸入蘋果賬號
    [[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(@"產品付費數量:%lu",(unsigned long)[product count]);
    NSLog(@"產品描述:%@",[p description]);
    NSLog(@"產品標題%@",[p localizedTitle]);
    NSLog(@"產品本地化描述%@",[p localizedDescription]);
    NSLog(@"產品價格:%@",[p price]);
    NSLog(@"產品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中檢索關於指定產品列表的本地化信息錯誤-----------------:%@", 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(@"已經購買過商品");
#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


/* 調用支付方法
 - (void)purchaseWithProductID:(NSString *)productID{
      
     [[IAPManager shareIAPManager] startPurchaseWithID:productID completeHandle:^(IAPPurchType type,NSData *data) {
          
     }];
 }
 */

服務端的工作:

  • 接收iOS端發過來的購買憑證,判斷憑證是否已經存在或驗證過,然後存儲該憑證。將該憑證發送到蘋果的服務器驗證,並將驗證結果返回給客戶端。

恢復購買

內購有4種:消耗型項目,非消耗型,自動續期訂閱,非續期訂閱。 其中”非消耗型“和”自動續期訂閱“需要提供恢復購買的功能,例如創建一個恢復按鈕,不然審覈很可能會被拒絕。

//調起蘋果內購恢復接口
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];

“消耗型項目”和“非續期訂閱”蘋果不會提供恢復的接口,不要調用上述方法去恢復,否則有可能被拒!!!

“非續期訂閱”也是跨設備同步的,所以原則上來說也需要提供恢復購買的功能,但需要依靠app自建的賬戶體系恢復,不能用上述蘋果提供的接口。

內購掉單

掉單是用戶付款買商品,錢扣了,商品卻沒到賬。掉單一旦發生,用戶通常會很生氣地來找客服。然後客服只能找開發人員把商品給用戶手動加上。顯然,傷害用戶的體驗,特別是傷害付費用戶的體驗,是一件相當糟糕的事情。

掉單是如何產生的呢?這需要從IAP支付的技術流程說起。

IAP的支付流程:

1,發起支付

2,扣費成功

3,得到receipt(支付憑據)

4,去後臺驗證憑據獲取商品交易狀態

5,返回數據,驗證成功前端刷新數據

  • 漏單情況一:

    2到3環節出問題屬於蘋果的問題,目前沒做處理。

  • 漏單情況二:

3到4的時候出問題,比如斷網。此時前端會把支付憑據持久化存儲下來,如果期間用戶卸載APP此單在前端就真漏了,如果沒有協助,下次重新打開app進入購買頁會先判斷有無未成功的支付,有就提示用戶,用戶選擇找回,重走4,5流程。這一步看產品需求怎麼做,可以讓用戶自主選擇是否恢復未成功的支付也可以前端默默恢復就行。

  • 漏單情況三:

4到5的時候出問題。此時後臺其實已經成功,只是前端沒獲取到數據,當漏單處理,下次進入的時候先刷新數據即可。

內購注意事項

  • 交易憑據receipt判重

一般來說驗證支付憑據(receipt)是否有效放後臺去做,如果後臺不做判重,同一個憑據就可以無數次驗證通過,因爲蘋果也不判重,這就會導致前端可以憑此取到的一個支付憑據可以去後臺無數次做校驗!!!!,後臺就會給前端發放無數次商品,但是用戶只支付了一次錢,所以安全的做法是後臺把驗證通過的支付憑據做個記錄,每次來新的憑據先判斷是否已經使用過,防止多次發放商品。

參考

iOS 內購(In-App Purchase)總結

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章