什么是內購?
內購(In-App Purchase)厨喂,顧名思義就是在應用內購買和措。在了解完其含義后,我們還需知道內購(In-App Purchase) 和 蘋果支付(Apple Pay)的區(qū)別蜕煌。
蘋果支付(Apple Pay):是一種支付方式派阱,跟支付寶、微信支付是類似的斜纪,這里就不詳細介紹了贫母。
內購(In-App Purchase):只要在 iOS/iPadOS 設備上的 App 里購買非實物產品 (也就是虛擬產品,如:“qq 幣盒刚、魚翅腺劣、電子書......”) ,都需要走內購流程因块,蘋果從這里面抽走 30% 分成橘原。
內購集成
一般來說,開發(fā)者剛接觸到內購,都會遇到流程不清楚趾断、代碼邏輯混亂和各種踩坑寨辩。那么,如何一次性搞定 iOS 內購歼冰?接下來本文將分成三個部分講解:
- 前期準備工作
- 開發(fā)實現(xiàn)
- 注意事項和踩坑解決辦法
一靡狞、前期準備工作
-
接入內購前,建議閱讀一下蘋果官方的文檔
-
App Store Connect 后臺配置內購項目
推薦閱讀《如何輕松搞定 iOS 內購配置》隔嫡,這里就不詳細介紹了甸怕。
Xcode 工程配置
- 開發(fā)實現(xiàn)流程
二、開發(fā)實現(xiàn)
PS:每個開發(fā)者帳戶可在該帳戶的所有 App 中創(chuàng)建最多 10,000 個 App 內購買項目產品腮恩。App 內購買項目共有四種類型:消耗型梢杭、非消耗型、自動續(xù)期訂閱和非續(xù)期訂閱秸滴。
推薦 Swift 開源庫 DYFStore武契,使用此開源庫可直接省去很多繁瑣復雜的實現(xiàn),提高工作效率荡含。另附 Objective-C 版 DYFStoreKit咒唆。
接入 StoreKit 準備
- 首先在項目工程中加入
StoreKit.framework
- 在實現(xiàn)文件導入
import StoreKit
或#import <StoreKit /StoreKit.h>
- 在實現(xiàn)類遵守協(xié)議
SKProductsRequestDelegate
,SKPaymentTransactionObserver
初始化工作
- 是否允許將日志輸出到控制臺,在 Debug 模式下將
enableLog
設置true
释液,查看內購過程的日志全释,在 Release 模式下發(fā)布 App 時將enableLog
設置false
。 - 添加交易的觀察者误债,監(jiān)聽交易的變化浸船。
- 實例化數(shù)據(jù)持久,存儲交易的相關信息寝蹈。
- 遵守協(xié)議
DYFStoreAppStorePaymentDelegate
李命,處理從 App Store 購買產品的付款。
- Swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Wether to allow the logs output to console.
DYFStore.default.enableLog = true
// Adds an observer that responds to updated transactions to the payment queue.
// If an application quits when transactions are still being processed, those transactions are not lost. The next time the application launches, the payment queue will resume processing the transactions. Your application should always expect to be notified of completed transactions.
// If more than one transaction observer is attached to the payment queue, no guarantees are made as to the order they will be called in. It is recommended that you use a single observer to process and finish the transaction.
DYFStore.default.addPaymentTransactionObserver()
// Sets the delegate processes the purchase which was initiated by user from the App Store.
DYFStore.default.delegate = self
DYFStore.default.keychainPersister = DYFStoreKeychainPersistence()
return true
}
- Objective-C
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Adds an observer that responds to updated transactions to the payment queue.
// If an application quits when transactions are still being processed, those transactions are not lost. The next time the application launches, the payment queue will resume processing the transactions. Your application should always expect to be notified of completed transactions.
// If more than one transaction observer is attached to the payment queue, no guarantees are made as to the order they will be called in. It is recommended that you use a single observer to process and finish the transaction.
[DYFStore.defaultStore addPaymentTransactionObserver];
// Sets the delegate processes the purchase which was initiated by user from the App Store.
DYFStore.defaultStore.delegate = self;
DYFStore.defaultStore.keychainPersister = [[DYFStoreKeychainPersistence alloc] init];
return YES;
}
處理從 App Store 購買產品的付款
只有在 iOS 11.0 或更高的版本箫老,才能處理用戶從 App Store 商店發(fā)起購買產品的請求封字。因為這個接口 ( API ) 是在 iOS 11.0 或更高的版本才生效的。
- Swift
// Processes the purchase which was initiated by user from the App Store.
func didReceiveAppStorePurchaseRequest(_ queue: SKPaymentQueue, payment: SKPayment, forProduct product: SKProduct) {
if !DYFStore.canMakePayments() {
self.showTipsMessage("Your device is not able or allowed to make payments!")
return
}
// Get account name from your own user system.
let accountName = "Handsome Jon"
// This algorithm is negotiated with server developer.
let userIdentifier = DYF_SHA256_HashValue(accountName) ?? ""
DYFStoreLog("userIdentifier: \(userIdentifier)")
DYFStore.default.purchaseProduct(product.productIdentifier, userIdentifier: userIdentifier)
}
- Objective-C
// Processes the purchase which was initiated by user from the App Store.
- (void)didReceiveAppStorePurchaseRequest:(SKPaymentQueue *)queue payment:(SKPayment *)payment forProduct:(SKProduct *)product {
if (![DYFStore canMakePayments]) {
[self showTipsMessage:@"Your device is not able or allowed to make payments!"];
return;
}
// Get account name from your own user system.
NSString *accountName = @"Handsome Jon";
// This algorithm is negotiated with server developer.
NSString *userIdentifier = DYF_SHA256_HashValue(accountName);
DYFStoreLog(@"userIdentifier: %@", userIdentifier);
[DYFStore.defaultStore purchaseProduct:product.productIdentifier userIdentifier:userIdentifier];
}
Indicates whether the user is allowed to make payments.
An iPhone can be restricted from accessing the Apple App Store. For example, parents can restrict their children’s ability to purchase additional content. Your application should confirm that the user is allowed to authorize payments before adding a payment to the queue. Your application may also want to alter its behavior or appearance when the user is not allowed to authorize payments.
創(chuàng)建商品查詢的請求
有兩種策略可用于從應用程序商店獲取有關產品的信息槽惫。
策略1: 在開始購買過程周叮,首先必須清楚有哪些產品標識符。App 可以使用其中一個產品標識符來獲取應用程序商店中可供銷售的產品的信息界斜,并直接提交付款請求仿耽。
- Swift
@IBAction func fetchesProductAndSubmitsPayment(_ sender: Any) {
self.showLoading("Loading...")
let productId = "com.hncs.szj.coin42"
DYFStore.default.requestProduct(withIdentifier: productId, success: { (products, invalidIdentifiers) in
self.hideLoading()
if products.count == 1 {
let productId = products[0].productIdentifier
self.addPayment(productId)
} else {
self.showTipsMessage("There is no this product for sale!")
}
}) { (error) in
self.hideLoading()
let value = error.userInfo[NSLocalizedDescriptionKey] as? String
let msg = value ?? "\(error.localizedDescription)"
self.sendNotice("An error occurs, \(error.code), " + msg)
}
}
private func addPayment(_ productId: String) {
if !DYFStore.canMakePayments() {
self.showTipsMessage("Your device is not able or allowed to make payments!")
return
}
// Get account name from your own user system.
let accountName = "Handsome Jon"
// This algorithm is negotiated with server developer.
let userIdentifier = DYF_SHA256_HashValue(accountName) ?? ""
DYFStoreLog("userIdentifier: \(userIdentifier)")
DYFStore.default.purchaseProduct(productId, userIdentifier: userIdentifier)
}
- Objective-C
- (IBAction)fetchesProductAndSubmitsPayment:(id)sender {
[self showLoading:@"Loading..."];
NSString *productId = @"com.hncs.szj.coin48";
[DYFStore.defaultStore requestProductWithIdentifier:productId success:^(NSArray *products, NSArray *invalidIdentifiers) {
[self hideLoading];
if (products.count == 1) {
NSString *productId = ((SKProduct *)products[0]).productIdentifier;
[self addPayment:productId];
} else {
[self showTipsMessage:@"There is no this product for sale!"];
}
} failure:^(NSError *error) {
[self hideLoading];
NSString *value = error.userInfo[NSLocalizedDescriptionKey];
NSString *msg = value ?: error.localizedDescription;
[self sendNotice:[NSString stringWithFormat:@"An error occurs, %zi, %@", error.code, msg]];
}];
}
- (void)addPayment:(NSString *)productId {
// Get account name from your own user system.
NSString *accountName = @"Handsome Jon";
// This algorithm is negotiated with server developer.
NSString *userIdentifier = DYF_SHA256_HashValue(accountName);
DYFStoreLog(@"userIdentifier: %@", userIdentifier);
[DYFStore.defaultStore purchaseProduct:productId userIdentifier:userIdentifier];
}
策略2: 在開始購買過程,首先必須清楚有哪些產品標識符各薇。App 從應用程序商店獲取有關產品的信息项贺,并向用戶顯示其商店用戶界面君躺。App 中銷售的每個產品都有唯一的產品標識符。App 使用這些產品標識符獲取有關應用程序商店中可供銷售的產品的信息开缎,例如定價棕叫,并在用戶購買這些產品時提交付款請求。
- Swift
func fetchProductIdentifiersFromServer() -> [String] {
let productIds = [
"com.hncs.szj.coin42", // 42 gold coins for ¥6.
"com.hncs.szj.coin210", // 210 gold coins for ¥30.
"com.hncs.szj.coin686", // 686 gold coins for ¥98.
"com.hncs.szj.coin1386", // 1386 gold coins for ¥198.
"com.hncs.szj.coin2086", // 2086 gold coins for ¥298.
"com.hncs.szj.coin4886", // 4886 gold coins for ¥698.
"com.hncs.szj.vip1", // non-renewable vip subscription for a month.
"com.hncs.szj.vip2" // Auto-renewable vip subscription for three months.
]
return productIds
}
@IBAction func fetchesProductsFromAppStore(_ sender: Any) {
self.showLoading("Loading...")
let productIds = fetchProductIdentifiersFromServer()
DYFStore.default.requestProduct(withIdentifiers: productIds, success: { (products, invalidIdentifiers) in
self.hideLoading()
if products.count > 0 {
self.processData(products)
} else if products.count == 0 &&
invalidIdentifiers.count > 0 {
// Please check the product information you set up.
self.showTipsMessage("There are no products for sale!")
}
}) { (error) in
self.hideLoading()
let value = error.userInfo[NSLocalizedDescriptionKey] as? String
let msg = value ?? "\(error.localizedDescription)"
self.sendNotice("An error occurs, \(error.code), " + msg)
}
}
private func processData(_ products: [SKProduct]) {
var modelArray = [DYFStoreProduct]()
for product in products {
let p = DYFStoreProduct()
p.identifier = product.productIdentifier
p.name = product.localizedTitle
p.price = product.price.stringValue
p.localePrice = DYFStore.default.localizedPrice(ofProduct: product)
p.localizedDescription = product.localizedDescription
modelArray.append(p)
}
self.displayStoreUI(modelArray)
}
private func displayStoreUI(_ dataArray: [DYFStoreProduct]) {
if !DYFStore.canMakePayments() {
self.showTipsMessage("Your device is not able or allowed to make payments!")
return
}
let storeVC = DYFStoreViewController()
storeVC.dataArray = dataArray
self.navigationController?.pushViewController(storeVC, animated: true)
}
- Objective-C
- (NSArray *)fetchProductIdentifiersFromServer {
NSArray *productIds = @[@"com.hncs.szj.coin42", // 42 gold coins for ¥6.
@"com.hncs.szj.coin210", // 210 gold coins for ¥30.
@"com.hncs.szj.coin686", // 686 gold coins for ¥98.
@"com.hncs.szj.coin1386", // 1386 gold coins for ¥198.
@"com.hncs.szj.coin2086", // 2086 gold coins for ¥298.
@"com.hncs.szj.coin4886", // 4886 gold coins for ¥698.
@"com.hncs.szj.vip1", // non-renewable vip subscription for a month.
@"com.hncs.szj.vip2" // Auto-renewable vip subscription for three months.
];
return productIds;
}
- (IBAction)fetchesProductsFromAppStore:(id)sender {
[self showLoading:@"Loading..."];
NSArray *productIds = [self fetchProductIdentifiersFromServer];
[DYFStore.defaultStore requestProductWithIdentifiers:productIds success:^(NSArray *products, NSArray *invalidIdentifiers) {
[self hideLoading];
if (products.count > 0) {
[self processData:products];
} else if (products.count == 0 && invalidIdentifiers.count > 0) {
// Please check the product information you set up.
[self showTipsMessage:@"There are no products for sale!"];
}
} failure:^(NSError *error) {
[self hideLoading];
NSString *value = error.userInfo[NSLocalizedDescriptionKey];
NSString *msg = value ?: error.localizedDescription;
[self sendNotice:[NSString stringWithFormat:@"An error occurs, %zi, %@", error.code, msg]];
}];
}
- (void)processData:(NSArray *)products {
NSMutableArray *modelArray = [NSMutableArray arrayWithCapacity:0];
for (SKProduct *product in products) {
DYFStoreProduct *p = [[DYFStoreProduct alloc] init];
p.identifier = product.productIdentifier;
p.name = product.localizedTitle;
p.price = [product.price stringValue];
p.localePrice = [DYFStore.defaultStore localizedPriceOfProduct:product];
p.localizedDescription = product.localizedDescription;
[modelArray addObject:p];
}
[self displayStoreUI:modelArray];
}
- (void)displayStoreUI:(NSMutableArray *)dataArray {
if (![DYFStore canMakePayments]) {
[self showTipsMessage:@"Your device is not able or allowed to make payments!"];
return;
}
DYFStoreViewController *storeVC = [[DYFStoreViewController alloc] init];
storeVC.dataArray = dataArray;
[self.navigationController pushViewController:storeVC animated:YES];
}
創(chuàng)建購買產品的付款請求
- 不使用您的系統(tǒng)用戶帳戶 ID
- Swift
DYFStore.default.purchaseProduct("com.hncs.szj.coin210")
- Objective-C
[DYFStore.defaultStore purchaseProduct:@"com.hncs.szj.coin210"];
- 使用您的系統(tǒng)用戶帳戶 ID
2.1. 計算 SHA256 哈希值的函數(shù)
- Swift
public func DYF_SHA256_HashValue(_ s: String) -> String? {
let digestLength = Int(CC_SHA256_DIGEST_LENGTH) // 32
let cStr = s.cString(using: String.Encoding.utf8)!
let cStrLen = Int(s.lengthOfBytes(using: String.Encoding.utf8))
// Confirm that the length of C string is small enough
// to be recast when calling the hash function.
if cStrLen > UINT32_MAX {
print("C string too long to hash: \(s)")
return nil
}
let md = UnsafeMutablePointer<CUnsignedChar>.allocate(capacity: digestLength)
CC_SHA256(cStr, CC_LONG(cStrLen), md)
// Convert the array of bytes into a string showing its hex represention.
let hash = NSMutableString()
for i in 0..<digestLength {
// Add a dash every four bytes, for readability.
if i != 0 && i%4 == 0 {
//hash.append("-")
}
hash.appendFormat("%02x", md[i])
}
md.deallocate()
return hash as String
}
- Objective-C
CG_INLINE NSString *DYF_SHA256_HashValue(NSString *string) {
const int digestLength = CC_SHA256_DIGEST_LENGTH; // 32
unsigned char md[digestLength];
const char *cStr = [string UTF8String];
size_t cStrLen = strlen(cStr);
// Confirm that the length of C string is small enough
// to be recast when calling the hash function.
if (cStrLen > UINT32_MAX) {
NSLog(@"C string too long to hash: %@", string);
return nil;
}
CC_SHA256(cStr, (CC_LONG)cStrLen, md);
// Convert the array of bytes into a string showing its hex represention.
NSMutableString *hash = [NSMutableString string];
for (int i = 0; i < digestLength; i++) {
// Add a dash every four bytes, for readability.
if (i != 0 && i%4 == 0) {
//[hash appendString:@"-"];
}
[hash appendFormat:@"%02x", md[i]];
}
return hash;
}
2.2. 使用給定的產品標識符和計算過 SHA256 哈希值的用戶帳戶 ID 請求購買產品奕删。
- Swift
DYFStore.default.purchaseProduct("com.hncs.szj.coin210", userIdentifier: "A43512564ACBEF687924646CAFEFBDCAEDF4155125657")
- Objective-C
[DYFStore.defaultStore purchaseProduct:@"com.hncs.szj.coin210" userIdentifier:@"A43512564ACBEF687924646CAFEFBDCAEDF4155125657"];
恢復已購買的付款交易
在某些場景(如切換設備)俺泣,App 需要提供恢復購買按鈕,用來恢復之前購買的非消耗型的產品完残。
- 無綁定用戶帳戶 ID 的恢復
- Swift
DYFStore.default.restoreTransactions()
- Objective-C
[DYFStore.defaultStore restoreTransactions];
- 綁定用戶帳戶 ID 的恢復
- Swift
DYFStore.default.restoreTransactions(userIdentifier: "A43512564ACBEF687924646CAFEFBDCAEDF4155125657")
- Objective-C
[DYFStore.defaultStore restoreTransactions:@"A43512564ACBEF687924646CAFEFBDCAEDF4155125657"];
創(chuàng)建刷新收據(jù)請求
如果 Bundle.main.appStoreReceiptURL
為空伏钠,就需要創(chuàng)建刷新收據(jù)請求,獲取付款交易的收據(jù)谨设。
- Swift
DYFStore.default.refreshReceipt(onSuccess: {
self.storeReceipt()
}) { (error) in
self.failToRefreshReceipt()
}
- Objective-C
[DYFStore.defaultStore refreshReceiptOnSuccess:^{
[self storeReceipt];
} failure:^(NSError *error) {
[self failToRefreshReceipt];
}];
付款交易的變化通知
- 添加商店觀察者熟掂,監(jiān)聽購買和下載通知
- Swift
func addStoreObserver() {
NotificationCenter.default.addObserver(self, selector: #selector(DYFStoreManager.processPurchaseNotification(_:)), name: DYFStore.purchasedNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(DYFStoreManager.processDownloadNotification(_:)), name: DYFStore.downloadedNotification, object: nil)
}
- Objective-C
- (void)addStoreObserver {
[NSNotificationCenter.defaultCenter addObserver:self selector:@selector(processPurchaseNotification:) name:DYFStorePurchasedNotification object:nil];
[NSNotificationCenter.defaultCenter addObserver:self selector:@selector(processDownloadNotification:) name:DYFStoreDownloadedNotification object:nil];
}
- 在適當?shù)臅r候,移除商店觀察者
- Swift
func removeStoreObserver() {
NotificationCenter.default.removeObserver(self, name: DYFStore.purchasedNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: DYFStore.downloadedNotification, object: nil)
}
- Objective-C
- (void)removeStoreObserver {
[NSNotificationCenter.defaultCenter removeObserver:self name:DYFStorePurchasedNotification object:nil];
[NSNotificationCenter.defaultCenter removeObserver:self name:DYFStoreDownloadedNotification object:nil];
}
- 付款交易的通知處理
- Swift
@objc private func processPurchaseNotification(_ notification: Notification) {
self.hideLoading()
self.purchaseInfo = (notification.object as! DYFStore.NotificationInfo)
switch self.purchaseInfo.state! {
case .purchasing:
self.showLoading("Purchasing...")
break
case .cancelled:
self.sendNotice("You cancel the purchase")
break
case .failed:
self.sendNotice(String(format: "An error occurred, \(self.purchaseInfo.error!.code)"))
break
case .succeeded, .restored:
self.completePayment()
break
case .restoreFailed:
self.sendNotice(String(format: "An error occurred, \(self.purchaseInfo.error!.code)"))
break
case .deferred:
DYFStoreLog("Deferred")
break
}
}
- Objective-C
- (void)processPurchaseNotification:(NSNotification *)notification {
[self hideLoading];
self.purchaseInfo = notification.object;
switch (self.purchaseInfo.state) {
case DYFStorePurchaseStatePurchasing:
[self showLoading:@"Purchasing..."];
break;
case DYFStorePurchaseStateCancelled:
[self sendNotice:@"You cancel the purchase"];
break;
case DYFStorePurchaseStateFailed:
[self sendNotice:[NSString stringWithFormat:@"An error occurred, %zi", self.purchaseInfo.error.code]];
break;
case DYFStorePurchaseStateSucceeded:
case DYFStorePurchaseStateRestored:
[self completePayment];
break;
case DYFStorePurchaseStateRestoreFailed:
[self sendNotice:[NSString stringWithFormat:@"An error occurred, %zi", self.purchaseInfo.error.code]];
break;
case DYFStorePurchaseStateDeferred:
DYFStoreLog(@"Deferred");
break;
default:
break;
}
}
- 下載的通知處理
- Swift
@objc private func processDownloadNotification(_ notification: Notification) {
self.downloadInfo = (notification.object as! DYFStore.NotificationInfo)
switch self.downloadInfo.downloadState! {
case .started:
DYFStoreLog("The download started")
break
case .inProgress:
DYFStoreLog("The download progress: \(self.downloadInfo.downloadProgress)%%")
break
case .cancelled:
DYFStoreLog("The download cancelled")
reak
case .failed:
DYFStoreLog("The download failed")
break
case .succeeded:
DYFStoreLog("The download succeeded: 100%%")
break
}
}
- Objective-C
- (void)processDownloadNotification:(NSNotification *)notification {
self.downloadInfo = notification.object;
switch (self.downloadInfo.downloadState) {
case DYFStoreDownloadStateStarted:
DYFStoreLog(@"The download started");
break;
case DYFStoreDownloadStateInProgress:
DYFStoreLog(@"The download progress: %.2f%%", self.downloadInfo.downloadProgress);
break;
case DYFStoreDownloadStateCancelled:
DYFStoreLog(@"The download cancelled");
break;
case DYFStoreDownloadStateFailed:
DYFStoreLog(@"The download failed");
break;
case DYFStoreDownloadStateSucceeded:
DYFStoreLog(@"The download succeeded: 100%%");
break;
default:
break;
}
}
收據(jù)驗證
- 驗證 URL
- Swift
/// The url for sandbox in the test environment.
private let sandboxUrl = "https://sandbox.itunes.apple.com/verifyReceipt"
/// The url for production in the production environment.
private let productUrl = "https://buy.itunes.apple.com/verifyReceipt"
- Objective-C
// The url for sandbox in the test environment.
static NSString *const kSandboxUrl = @"https://sandbox.itunes.apple.com/verifyReceipt";
// The url for production in the production environment.
static NSString *const kProductUrl = @"https://buy.itunes.apple.com/verifyReceipt";
- 常見的驗證狀態(tài)碼和對應的描述
- Swift
/// Matches the message with the status code.
///
/// - Parameter status: The status code of the request response. More, please see [Receipt Validation Programming Guide](https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html#//apple_ref/doc/uid/TP40010573-CH104-SW1)
/// - Returns: A tuple that contains status code and the description of status code.
public func matchMessage(withStatus status: Int) -> (Int, String) {
var message: String = ""
switch status {
case 0:
message = "The receipt as a whole is valid."
break
case 21000:
message = "The App Store could not read the JSON object you provided."
break
case 21002:
message = "The data in the receipt-data property was malformed or missing."
break
case 21003:
message = "The receipt could not be authenticated."
break
case 21004:
message = "The shared secret you provided does not match the shared secret on file for your account."
break
case 21005:
message = "The receipt server is not currently available."
break
case 21006:
message = "This receipt is valid but the subscription has expired. When this status code is returned to your server, the receipt data is also decoded and returned as part of the response. Only returned for iOS 6 style transaction receipts for auto-renewable subscriptions."
break
case 21007:
message = "This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead."
break
case 21008:
message = "This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead."
break
case 21010:
message = "This receipt could not be authorized. Treat this the same as if a purchase was never made."
break
default: /* 21100-21199 */
message = "Internal data access error."
break
}
return (status, message)
}
- Objective-C
/**
Matches the message with the status code.
@param status The status code of the request response. More, please see [Receipt Validation Programming Guide](https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html#//apple_ref/doc/uid/TP40010573-CH104-SW1)
@return A string that contains the description of status code.
*/
- (NSString *)matchMessageWithStatus:(NSInteger)status {
NSString *message = @"";
switch (status) {
case 0:
message = @"The receipt as a whole is valid.";
break;
case 21000:
message = @"The App Store could not read the JSON object you provided.";
break;
case 21002:
message = @"The data in the receipt-data property was malformed or missing.";
break;
case 21003:
message = @"The receipt could not be authenticated.";
break;
case 21004:
message = @"The shared secret you provided does not match the shared secret on file for your account.";
break;
case 21005:
message = @"The receipt server is not currently available.";
break;
case 21006:
message = @"This receipt is valid but the subscription has expired. When this status code is returned to your server, the receipt data is also decoded and returned as part of the response. Only returned for iOS 6 style transaction receipts for auto-renewable subscriptions.";
break;
case 21007:
message = @"This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead.";
break;
case 21008:
message = @"This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead.";
break;
case 21010:
message = @"This receipt could not be authorized. Treat this the same as if a purchase was never made.";
break;
default: /* 21100-21199 */
message = @"Internal data access error.";
break;
}
return message;
}
- 客戶端驗證扎拣,不安全且容易被破解赴肚,不推薦使用
3.1. 使用懶加載實例化 DYFStoreReceiptVerifier
- Swift
lazy var receiptVerifier: DYFStoreReceiptVerifier = {
let verifier = DYFStoreReceiptVerifier()
verifier.delegate = self
return verifier
}()
- Objective-C
- (DYFStoreReceiptVerifier *)receiptVerifier {
if (!_receiptVerifier) {
_receiptVerifier = [[DYFStoreReceiptVerifier alloc] init];
_receiptVerifier.delegate = self;
}
return _receiptVerifier;
}
3.2. 實現(xiàn)協(xié)議 DYFStoreReceiptVerifierDelegate
- Swift
@objc func verifyReceiptDidFinish(_ verifier: DYFStoreReceiptVerifier, didReceiveData data: [String : Any])
@objc func verifyReceipt(_ verifier: DYFStoreReceiptVerifier, didFailWithError error: NSError)
- Objective-C
- (void)verifyReceiptDidFinish:(nonnull DYFStoreReceiptVerifier *)verifier didReceiveData:(nullable NSDictionary *)data;
- (void)verifyReceipt:(nonnull DYFStoreReceiptVerifier *)verifier didFailWithError:(nonnull NSError *)error;
3.3. 驗證收據(jù)
- Swift
// Fetches the data of the bundle’s App Store receipt.
let data = receiptData
self.receiptVerifier.verifyReceipt(data)
// Only used for receipts that contain auto-renewable subscriptions.
//self.receiptVerifier.verifyReceipt(data, sharedSecret: "A43512564ACBEF687924646CAFEFBDCAEDF4155125657")
- Objective-C
// Fetches the data of the bundle’s App Store receipt.
NSData *data = receiptData ?: [NSData dataWithContentsOfURL:DYFStore.receiptURL];
DYFStoreLog(@"data: %@", data);
[_receiptVerifier verifyReceipt:data];
// Only used for receipts that contain auto-renewable subscriptions.
//[_receiptVerifier verifyReceipt:data sharedSecret:@"A43512564ACBEF687924646CAFEFBDCAEDF4155125657"];
- 服務器驗證,相對安全二蓝,推薦
客戶端通過接口將所需的參數(shù)上傳至服務器誉券,接口數(shù)據(jù)最好進行加密處理。然后服務器向蘋果服務器驗證收據(jù)并獲取相應的信息侣夷,服務器比對產品 ID横朋,Bundle Identifier,交易 ID百拓,付款狀態(tài)等信息后,若付款狀態(tài)為0晰甚,通知客戶端付款成功衙传,客戶端完成當前的交易。
推薦閱讀 Apple 官方發(fā)布的收據(jù)驗證編程指南 Receipt Validation Programming Guide厕九。
完成交易
只有在客戶端接收到付款成功并在收據(jù)校驗通過后蓖捶,才能完成交易。這樣扁远,我們可以避免刷單和破解應用內購買俊鱼。如果我們無法完成校驗收據(jù),我們就希望 StoreKit
不斷提醒我們還有未完成的付款畅买。
- Swift
DYFStore.default.finishTransaction(transaction)
- Objective-C
[DYFStore.defaultStore finishTransaction:transaction];
交易信息存儲
DYFStore
提供了兩種數(shù)據(jù)存儲方式 DYFStoreKeychainPersistence
和 DYFStoreUserDefaultsPersistence
并闲。
當客戶端在付款過程中發(fā)生崩潰,導致 App 閃退谷羞,這時存儲交易信息尤為重要帝火。當 StoreKit 再次通知未完成的付款時溜徙,直接從 Keychain 中取出數(shù)據(jù),進行收據(jù)驗證犀填,直至完成交易蠢壹。
- 存儲交易信息
- Swift
func storeReceipt() {
guard let url = DYFStore.receiptURL() else {
self.refreshReceipt()
return
}
do {
let data = try Data(contentsOf: url)
let info = self.purchaseInfo!
let store = DYFStore.default
let persister = store.keychainPersister!
let transaction = DYFStoreTransaction()
if info.state! == .succeeded {
transaction.state = DYFStoreTransactionState.purchased.rawValue
} else if info.state! == .restored {
transaction.state = DYFStoreTransactionState.restored.rawValue
}
transaction.productIdentifier = info.productIdentifier
transaction.userIdentifier = info.userIdentifier
transaction.transactionTimestamp = info.transactionDate?.timestamp()
transaction.transactionIdentifier = info.transactionIdentifier
transaction.originalTransactionTimestamp = info.originalTransactionDate?.timestamp()
transaction.originalTransactionIdentifier = info.originalTransactionIdentifier
transaction.transactionReceipt = data.base64EncodedString()
persister.storeTransaction(transaction)
// Makes the backup data.
let uPersister = DYFStoreUserDefaultsPersistence()
if !uPersister.containsTransaction(info.transactionIdentifier!) {
uPersister.storeTransaction(transaction)
}
self.verifyReceipt(data)
} catch let error {
DYFStoreLog("error: \(error.localizedDescription)")
self.refreshReceipt()
return
}
}
- Objective-C
- (void)storeReceipt {
DYFStoreLog();
NSURL *receiptURL = DYFStore.receiptURL;
NSData *data = [NSData dataWithContentsOfURL:receiptURL];
if (!data || data.length == 0) {
[self refreshReceipt];
return;
}
DYFStoreNotificationInfo *info = self.purchaseInfo;
DYFStore *store = DYFStore.defaultStore;
DYFStoreKeychainPersistence *persister = store.keychainPersister;
DYFStoreTransaction *transaction = [[DYFStoreTransaction alloc] init];
if (info.state == DYFStorePurchaseStateSucceeded) {
transaction.state = DYFStoreTransactionStatePurchased;
} else if (info.state == DYFStorePurchaseStateRestored) {
transaction.state = DYFStoreTransactionStateRestored;
}
transaction.productIdentifier = info.productIdentifier;
transaction.userIdentifier = info.userIdentifier;
transaction.transactionIdentifier = info.transactionIdentifier;
transaction.transactionTimestamp = info.transactionDate.timestamp;
transaction.originalTransactionTimestamp = info.originalTransactionDate.timestamp;
transaction.originalTransactionIdentifier = info.originalTransactionIdentifier;
transaction.transactionReceipt = data.base64EncodedString;
[persister storeTransaction:transaction];
// Makes the backup data.
DYFStoreUserDefaultsPersistence *uPersister = [[DYFStoreUserDefaultsPersistence alloc] init];
if (![uPersister containsTransaction:info.transactionIdentifier]) {
[uPersister storeTransaction:transaction];
}
[self verifyReceipt:data];
}
- 移除交易信息
- Swift
DispatchQueue.main.asyncAfter(delay: 1.5) {
let info = self.purchaseInfo!
let store = DYFStore.default
let persister = store.keychainPersister!
let identifier = info.transactionIdentifier!
if info.state! == .restored {
let transaction = store.extractRestoredTransaction(identifier)
store.finishTransaction(transaction)
} else {
let transaction = store.extractPurchasedTransaction(identifier)
// The transaction can be finished only after the client and server adopt secure communication and data encryption and the receipt verification is passed. In this way, we can avoid refreshing orders and cracking in-app purchase. If we were unable to complete the verification, we want `StoreKit` to keep reminding us that there are still outstanding transactions.
store.finishTransaction(transaction)
}
persister.removeTransaction(identifier)
if let id = info.originalTransactionIdentifier {
persister.removeTransaction(id)
}
}
- Objective-C
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^{
DYFStoreNotificationInfo *info = self.purchaseInfo;
DYFStore *store = DYFStore.defaultStore;
DYFStoreKeychainPersistence *persister = store.keychainPersister;
if (info.state == DYFStorePurchaseStateRestored) {
SKPaymentTransaction *transaction = [store extractRestoredTransaction:info.transactionIdentifier];
[store finishTransaction:transaction];
} else {
SKPaymentTransaction *transaction = [store extractPurchasedTransaction:info.transactionIdentifier];
// The transaction can be finished only after the client and server adopt secure communication and data encryption and the receipt verification is passed. In this way, we can avoid refreshing orders and cracking in-app purchase. If we were unable to complete the verification, we want `StoreKit` to keep reminding us that there are still outstanding transactions.
[store finishTransaction:transaction];
}
[persister removeTransaction:info.transactionIdentifier];
if (info.originalTransactionIdentifier) {
[persister removeTransaction:info.originalTransactionIdentifier];
}
});
注意事項和踩坑解決辦法
-
產品配置好了,為何在測試購買時獲取到產品信息無效呢九巡?
測試內購一定要用真機測試图贸,產品信息如果無效,一般是產品還沒有審核通過 冕广!
-
如果 App 沒有實物購買求妹,不移除支付寶、微信支付的 SDK 行嗎佳窑?
接入內購后制恍,如果 App 沒有實物購買,就必須把支付寶神凑、微信支付的 SDK 刪掉净神,如果 Apple 那邊掃描出來,App 就會被拒審溉委。
-
沙盒測試有時無響應
- 檢查網絡環(huán)境鹃唯,嘗試在 WiFi 或 蜂窩網絡 切換,然后再嘗試購買產品瓣喊。
- 如果上述方法還是沒辦法解決坡慌,那么有可能是蘋果測試服務器宕機或更新服務,等一段時間再嘗試藻三。
-
在沙盒環(huán)境測試 OK洪橘,有沒有必要測試線上環(huán)境呢?
如果在沙盒環(huán)境測試沒有問題棵帽,就沒有必要測試線上熄求。因為提交 App 審核后,蘋果有一套完整的測試機制或者說有更高級的賬號確保線上支付正常逗概。當然 App 上架后弟晚,也可以去線上用真金白銀測試購買產品。
特別注意:如果服務端進行收據(jù)驗證逾苫,那么服務端一定要做好驗證 URL 地址的切換卿城。一般做法就是不管是沙盒還是生產環(huán)境,先去生產環(huán)境驗證铅搓,如果獲取的狀態(tài)碼為21007瑟押,那么可以去沙盒環(huán)境驗證。
-
為什么我的沙盒賬號提示不在此地區(qū)狸吞,請切回本地的應用商店勉耀?
因為沙盒賬號在創(chuàng)建時就已經設置好了地區(qū)指煎,中國的只能在中國的 App Store 測試。
-
訂閱產品和自動續(xù)期訂閱
訂閱產品需要驗證訂閱是否過期便斥,自動續(xù)費在購買流程上至壤,與普通購買沒有區(qū)別,主要的區(qū)別:”除了第一次購買行為是用戶主動觸發(fā)的枢纠,后續(xù)續(xù)費都是 Apple 自動完成的像街,一般在要過期的前24小時開始,蘋果會嘗試扣費晋渺,扣費成功的話镰绎,在 App 下次啟動的時候主動推送給 App“。
// 訂閱特殊處理
if (transaction.originalTransaction) {
// 如果是自動續(xù)費的訂單 originalTransaction 會有內容
} else {
// 普通購買木西,以及第一次購買自動訂閱
}
-
刷單問題
驗證接收到響應信息畴栖,一定要比對 Product Identifier、Bundle Identifier八千、User Identifier吗讶、Transaction Identifier 等信息,防止冒用其他收據(jù)信息領取產品恋捆,還有防止利用外匯匯率差的刷單問題照皆。
-
漏單問題
一般來說,對于消耗性商品沸停,我們用得最多的是在判斷用戶購買成功之后交給我們的服務器進行校驗膜毁,收到服務器的確認后把支付交易 finish 掉。
// finish 支付交易
SKPaymentQueue.default().finishTransaction(transaction)
如果不把支付交易 finish 掉的話愤钾,就會在下次重新打開應用且代碼執(zhí)行到監(jiān)聽內購隊列后瘟滨,此方法 public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction])
都會被回調,直到被 finish 掉為止绰垂。所以為了防止漏單室奏,建議將內購類做成單例,并在程序入口啟動內購類監(jiān)聽內購隊列劲装。這樣做的話,即使用戶在成功購買商品后昌简,由于各種原因沒告知服務器就關閉了應用占业,在下次打開應用時,也能及時把支付交易補回纯赎,這樣就不會造成漏單問題了谦疾。
但事與愿違,在調試中犬金,我們發(fā)現(xiàn)如果在有多個成功交易未 finish 掉的情況下念恍,把應用關閉后再打開六剥,往往會把其中某些任務漏掉,即回調方法少回調了峰伙,這讓我們非常郁悶疗疟。既然官方的 API 不好使,我們只能把這個重任交給后臺的驗證流程了瞳氓,具體的做法下面會講到策彤。
- 驗證響應信息
{
environment = Sandbox;
receipt = {
"adam_id" = 0;
"app_item_id" = 0;
"application_version" = "1.0.3.2";
"bundle_id" = "**********";
"download_id" = 0;
"in_app" = (
{
"is_trial_period" = false;
"original_purchase_date" = "2017-02-08 02:26:13 Etc/GMT";
"original_purchase_date_ms" = 1486520773000;
"original_purchase_date_pst" = "2017-02-07 18:26:13 America/Los_Angeles";
"original_transaction_id" = 1000000271607744;
"product_id" = "**********_06";
"purchase_date" = "2017-02-08 02:26:13 Etc/GMT";
"purchase_date_ms" = 1486520773000;
"purchase_date_pst" = "2017-02-07 18:26:13 America/Los_Angeles";
quantity = 1;
"transaction_id" = 1000000271607744;
},
{
"is_trial_period" = false;
"original_purchase_date" = "2017-02-25 05:59:35 Etc/GMT";
"original_purchase_date_ms" = 1488002375000;
"original_purchase_date_pst" = "2017-02-24 21:59:35 America/Los_Angeles";
"original_transaction_id" = 1000000276891381;
"product_id" = "**********_01";
"purchase_date" = "2017-02-25 05:59:35 Etc/GMT";
"purchase_date_ms" = 1488002375000;
"purchase_date_pst" = "2017-02-24 21:59:35 America/Los_Angeles";
quantity = 1;
"transaction_id" = 1000000276891381;
},
{
"is_trial_period" = false;
"original_purchase_date" = "2017-03-10 05:44:43 Etc/GMT";
"original_purchase_date_ms" = 1489124683000;
"original_purchase_date_pst" = "2017-03-09 21:44:43 America/Los_Angeles";
"original_transaction_id" = 1000000280765165;
"product_id" = "**********_01";
"purchase_date" = "2017-03-10 05:44:43 Etc/GMT";
"purchase_date_ms" = 1489124683000;
"purchase_date_pst" = "2017-03-09 21:44:43 America/Los_Angeles";
quantity = 1;
"transaction_id" = 1000000280765165;
}
);
"original_application_version" = "1.0";
"original_purchase_date" = "2013-08-01 07:00:00 Etc/GMT";
"original_purchase_date_ms" = 1375340400000;
"original_purchase_date_pst" = "2013-08-01 00:00:00 America/Los_Angeles";
"receipt_creation_date" = "2017-03-10 05:44:44 Etc/GMT";
"receipt_creation_date_ms" = 1489124684000;
"receipt_creation_date_pst" = "2017-03-09 21:44:44 America/Los_Angeles";
"receipt_type" = ProductionSandbox;
"request_date" = "2017-03-10 08:50:00 Etc/GMT";
"request_date_ms" = 1489135800761;
"request_date_pst" = "2017-03-10 00:50:00 America/Los_Angeles";
"version_external_identifier" = 0;
};
status = 0;
}
這里面我們最關心的是 in_app 里的數(shù)組,因為根據(jù)蘋果的官方文檔所示匣摘,這些就是付款成功而未被 finish 掉的交易店诗,而一般這個數(shù)組里只會存在一個元素,這里會出現(xiàn)3個音榜,是因為這3個訂單已經被蘋果漏掉了庞瘸,是的,這就是上面所提到的漏單情況赠叼,回調方法是不會再走了擦囊,惡心吧......
但生活還是得繼續(xù),我們可以看到每個交易里都有一些很詳細的信息梅割,一般我們只對 transaction_id (交易 ID)
霜第、original_transaction_id (原始交易 ID)
、product_id (商品 ID)
和 bundle_id (應用包唯一 ID)
等重要信息感興趣户辞,服務器也是憑此作為用戶購買成功的依據(jù)泌类,那么問題來了,這里好像并沒有用戶 ID底燎,是的刃榨,服務器是不知道商品是誰買的,所以我們要把用戶 ID 和交易 ID 也一起發(fā)給服務器双仍,讓服務器與驗證返回的數(shù)據(jù)進行匹對枢希,從而把買家和商品對應起來。
// 設置發(fā)送給服務器的參數(shù)
var param = [String: Any]()
param["receipt"] = receiptBase64
param["userID"] = self.userID
param["transactionID"] = transaction.transactionIdentifier
來到這里朱沃,剛才遺留的漏單問題是時候要拿出來解決了苞轿,剛才也說到了,回調方法有可能少走逗物,甚至還有可能在客戶端啟動后完全不走 (這個只是以防萬一) 搬卒。
我個人建議的做法是,首先在服務端建立2個表翎卓,一個黑表一個白表契邀,黑表是記錄過往真正購買成功的歷史信息,白表是記錄付款成功而未認領的交易信息失暴。在客戶端啟動后的20秒內 (時間可以自己定) 回調方法如果都沒有走坯门,我們就主動把收據(jù)等一些信息上傳給服務器微饥,當然最好把用戶的一些信息,包括賬號ID古戴,手機型號欠橘,系統(tǒng)版本等信息一并帶上,服務器拿到收據(jù)后去蘋果后臺驗證允瞧,把得到的付款成功的交易信息全部寫進白表里 (檢測去重)简软。以后如果有新交易產生,客戶端會把收據(jù)和交易ID等信息傳給服務器述暂,服務器同樣到蘋果后臺驗證后寫進白表痹升,接著在表里看看是否有客戶端所給的交易號信息,如果有再去黑表里檢測是否存在畦韭,黑表不存在則判斷為成功購買并結算商品疼蛾,這時要在白表中刪除對應數(shù)據(jù)和在黑表中添加新數(shù)據(jù),之后回饋給客戶端艺配,客戶端把交易 finish 掉察郁,這個購買流程就算是結束了。這時候白表里記錄著的很有可能就是一些被漏掉的訂單转唉,為什么不是一定而是很有可能皮钠? 因為會存在已經記錄在黑表中但未被客戶端 finish 掉的訂單,此時再到黑表中濾一遍就知道是否是真正的漏單了赠法,這時候只能通過人工的方式去解決了麦轰,比如可以主動跟這位用戶溝通詢問情況,或者是在有用戶反應漏單時砖织,可以在表中檢測相關信息判斷是否屬實等等款侵。另外服務器可以定時檢測兩個表中的數(shù)據(jù)進行去重操作,當然也可以在每次添加進白表前先在黑表中過濾侧纯,不過這樣比較耗性能新锈。如果有更好的想法,希望大家可以在評論區(qū)寫下提示或者思路眶熬。
-
誤充問題
關于這個問題還是挺有趣的妹笆,因為存在這樣的一種情況:用戶A登錄后買了一樣商品,但與服務器交互失敗了娜氏,導致沒有把交易信息告知服務器晾浴,接著他退出了當前帳號,這時候用戶B來了牍白,一登錄服務器,我們就會用當前用戶 ID 把上次沒有走完的內購邏輯繼續(xù)走下去抖棘,接下來的事情相信大家都能想像到了茂腥,用戶B會發(fā)現(xiàn)他獲得了一件商品狸涌,是的,用戶A買的東西被充到了用戶B的手上最岗。
要解決這個問題必須要把交易和用戶 ID 綁定起來帕胆,要怎么做呢?其實很簡單般渡,我們只要在添加交易隊列之前把用戶 ID 設進去即可懒豹。
let payment = SKMutablePayment(product: product)
payment.quantity = quantity
if #available(iOS 7.0, *) {
payment.applicationUsername = userIdentifier
}
SKPaymentQueue.default().add(payment)
然后給服務器發(fā)送的參數(shù)就不再像之前那樣寫了。
// 設置發(fā)送給服務器的參數(shù)
var param = [String: Any]()
param["receipt"] = receiptBase64
param["userID"] = transactions.payment.applicationUsername ?? ""
param["transactionID"] = transaction.transactionIdentifier
最后驯用,想了解更多詳情脸秽,請查看我的 Demo,記得給個 Star蝴乔,????