從零開始開發 iOS App 的 in app purchase (IAP)

in app purchase(IAP) 是 iOS App 一個很重要的功能,尤其當我們想靠 App 賺錢時,先用免費 App 吸引使用者下載,再讓他愛上 App 後瘋狂地透過 IAP 消費是許多熱門 App 賺錢的手段,就像為了傳可愛的 LINE 貼圖給暗戀的女生,我們時常花錢買 LINE 虛擬金幣。

接下來我們將從零開始,一步步打造具有 IAP 功能的 App,包含寫程式前的準備 & 程式撰寫兩個階段。

https://help.apple.com/app-store-connect/#/devb57be10e7

既然是寫 Swift 程式,就讓我們販售 Taylor Swift 好聽的音樂吧,我們希望能販售以下三種商品,使用者可購買單曲 love story & only the young,也可以購買一小時聆聽任何音樂的時間。

寫程式前的準備

設定 App 的銀行帳戶資訊(bank info)

選擇 Agreements, Tax, and Banking

讓 Paid Apps 的狀態是 Active

建立專案,切換到專案的 Signing & Capabilties 頁面

在 Team 欄位選擇付費的 App 開發帳號

只有付費的 App 開發帳號可以開發 IAP 功能。

點選 + Capability

加入 In-App Purchase

此時 Xcode 將自動幫我們生成包含 IAP 功能的 App ID。點選 Provisioning Profile 旁的 i 可看到 App ID 為 com.peter.IAPDemo。

從 Apple 開發網站的 Certificates, Identifiers & Profiles 頁面也可看到剛剛生成的 App ID。

在 App Store Connect 建立 App

https://appstoreconnect.apple.com

點選 My Apps。

點選 + > New App 建立 App。

設定 App 資訊,Bundle ID 選擇剛剛 Xcode 專案的 Bundle ID。

建立 App 後,切換到 Features 頁面

點選 + 新增 IAP 販售的商品

IAP 可販售的四種商品

  • Consumable(消耗的)

可重覆購買的項目,比方 LINE 的虛擬金幣,養魚 App 的魚飼料等。

  • Non-Consumable(不可消耗的)

不可重覆購買的項目,比方購買 Taylor Swift 的單曲 love story。買一次就可終生享用,之後交新女友換新 iPhone 時可免費下載 love story,不用再買一次。

  • Auto-Renewable Subscription(自動訂閱)

依時間收費,比方每個月 $90,下個月到期時會自動續約扣款。(不過使用者自己也可另外取消自動訂閱)

  • Non-Renewing Subscription(非自動訂閱)

依時間收費,跟 Auto-Renewable Subscription 的差別在於它不會自動訂閱。

前面提到我們的 App 想要販售以下商品:

  • 2 首 Taylor Swift 的歌,屬於 Non-Consumable 商品。
  • 聆聽任何音樂一小時,屬於 Consumable 商品。

點選 Non-Consumable

點選 Non-Consumable 新增 Taylor Swift 的 love story。

輸入商品資訊

輸入 love story 的相關資訊,最重要的是 Product ID,到時候我們的程式將以此 ID 連到 Apple 後台取得商品資訊。

App Store Information 欄位填寫的是使用者看到的資訊。

比方下圖使用者購買時,Apple 的購買視窗顯示我們設定的 Display Name 愛的故事。

我們也可點選 Localizations 旁的 + 新增英文,日文,法文等其它語言,吸引外國的消費者購買。

最下方的 Review Information 可等之後 App 準備上架或給 TestFlight 使用者測試時再填寫,它不影響我們待會開發和測試 IAP 功能。

點選右上角的 Save 後,love story 成功出現在 IAP 的商品列表。

新增 Taylor Swift 另一首歌 only the young 的 IAP,選擇 Non-Consumable。

點選 Consumable

點選 Consumable, 新增可聆聽任何音樂一小時的商品。

成功建立後,目前我們的 IAP 有三個商品。

新增 Sandbox 的 test user

為了免費測試 IAP,不用真的花錢,我們有以下兩種方法:

  • TestFlight 的測試者可搭配正式的 Apple ID 免費購買。
  • 從 Xcode 安裝 App 到手機,然後搭配 Sandbox 的 test user 測試。

測試的第一步通常是從 Xcode 安裝 App 到手機,所以我們先在 App Store Connect 網站建立 Sandbox 的 test user,方便待會測試。

值得注意的,test user 的國家最好選美國,選其它國家可能在測試 IAP 功能時會出現錯誤訊息 Cannot connect to iTunes Store

從 Settings App 登入測試帳號

測試 IAP 只能從實機測試,無法從模擬器。因此請先從實機的 Settings App 登入測試帳號,點選 Settings App 的 iTunes & App Store > SANDBOX ACCOUNT 下的 Sign In。

測試帳號只影響 IAP 的測試,當我們在 iPhone 的正式 App 操作時,iOS 會聰明地使用我們正式的 Apple ID。

若之後想登入其它的測試帳號,也要從 Settings App 登出才能登入另一個帳號。

開始寫程式

經過有點漫長的準備後,終於可以進入我們比較喜歡的寫程式階段了。

開發 IAP 功能的程式時,主要分為五個階段,以下我們將逐一介紹。

  • 取得商品的 product ID。
  • 利用 product ID 連到 Apple 後台抓取商品資訊。
  • 產生代表交易的 SKPayment 進行購買。
  • 從 SKPaymentTransactionObserver 的 function 判斷交易的結果。
  • 若交易成功則提供商品給使用者。

取得商品的 product ID

為了販賣商品,我們必須連到 Apple 的後台抓取商品清單。不過 Apple 只認得我們在 App Store Connect 網站設定的 Product ID,因此我們得用剛剛設定的三個 Product ID com.peter.IAPDemo.lovestory,com.peter.IAPDemo.onlytheyoung,com.peter.IAPDemo.listenonehour 來跟 Apple 取得商品清單。

App 程式取得 Product ID 的方法有以下兩種:

  • 讀取 App bundle 資料夾的檔案(比方 plist 檔)或寫死在程式裡。
  • 連到自己的後台抓取包含 Product ID 的 JSON。

待會為了方便測試,我們採用方法一,直接將 Product ID 存在 array。

func getProductIDs() -> [String] {
["com.peter.IAPDemo.lovestory", "com.peter.IAPDemo.onlytheyoung", "com.peter.IAPDemo.listenonehour"]
}

不過此做法其實有缺點。因為之後 App 上架後,我們可從 App Store Connect 網站新增 IAP 商品,但此時程式的 Product ID 也要更新,才能抓取新的商品資訊。

因此較好的做法會是連到自己的後台,從後台抓取 JSON 取得 Product ID,這樣未來有新的商品要銷售時只要從 App Store Connect 網站新增 IAP 商品,App 完全不用做任何修改。

連到 Apple 後台抓取商品資訊

import StoreKitclass IAPManager: NSObject {

static let shared = IAPManager()
var products = [SKProduct]()
fileprivate var productRequest: SKProductsRequest!
func getProductIDs() -> [String] {
["com.peter.IAPDemo.lovestory", "com.peter.IAPDemo.onlytheyoung", "com.peter.IAPDemo.listenonehour"]
}

func getProducts() {
let productIds = getProductIDs()
let productIdsSet = Set(productIds)
productRequest = SKProductsRequest(productIdentifiers: productIdsSet)
productRequest.delegate = self
productRequest.start()
}
}extension IAPManager: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
response.products.forEach {
print($0.localizedTitle, $0.price, $0.localizedDescription)
}
self.products = response.products
}

}

說明

import StoreKit

開發 IAP 功能須 import StoreKit。

class IAPManager: NSObject {

定義 IAPManager 實現 IAP 的相關功能。 由於取得商品資訊時會觸發 SKProductsRequestDelegate 的 function,因此 IAPManager 要遵從 protocol SKProductsRequestDelegate。不過我們還要繼承 NSObject,因為 SKProductsRequestDelegate 繼承 NSObjectProtocol,繼承 NSObject 可幫我們定義 NSObjectProtocol 的相關 function。

func getProducts() {
let productIds = getProductIDs()
let productIdsSet = Set(productIds)
productRequest = SKProductsRequest(productIdentifiers: productIdsSet)
productRequest.delegate = self
productRequest.start()
}

利用 SKProductsRequest 連到 Apple 後台詢問商品資訊,將 IAPManager 自己設為 request 的 delegate,到時候得到商品資訊將觸發 delegate 的 function。以上程式有兩個值得注意的地方:

  • productRequest 宣告為 IAPManager 的 property,因為 Apple 提到系統可能在抓到商品資料前將 request 清掉,所以我們將它存在 IAPManager 的 property 比較保儉。
Be sure to keep a strong reference to the products request object; the system may release it before the request completes.
  • 參數 productIdentifiers 的型別是 Set,因此我們要將 array 變成 Set。
extension IAPManager: SKProductsRequestDelegate {    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
response.products.forEach {
print($0.localizedTitle, $0.price, $0.localizedDescription)
}
self.products = response.products
}

}

得到商品資訊時將觸發 productsRequest(_:didReceive:),我們可從參數 response 的 products 取得商品的 array。array 裡的商品型別是 SKProduct,我們可從它的 property 讀取商品的相關資訊。

抓取商品資訊也有可能失敗,我們可從 response 的 invalidProductIdentifiers 取得有問題的 id。

response.invalidProductIdentifiers

有興趣的朋友可進一步參考以下連結的說明。

測試 App: 取得商品資訊

利用以下程式取得商品資訊,測試 App 在實機執行的結果。( IAP 的購買功能只能在實機測試)

IAPManager.shared.getProducts()

以下為 App 執行後 Console 印出的三個 IAP 商品資訊。由於我們登入的測試帳號是美國帳號,所以金額顯示的單位為美金。

顯示商品資訊,比方名稱,簡介和價錢

透過 Apple 回傳的商品資訊,我們可以設計類似以下的商城頁品誘惑使用者購買。

遊戲 Slidey App 的商城畫面

在顯示商品資訊時,最麻煩的是語言問題,針對不同國家最好能顯示不同的語言和金額,比方美國顯示英文 & 美金,台灣顯示中文和新台幣。

感謝 App Store Connect 網站提供多語言的 App Store Information 設定,因此我們可利用 SKProduct 的 localizedTitle & localizedDescription 顯示使用者熟悉的語言。

那要如何顯示正確的金額呢 ? 難道要查詢匯率手動計算嗎 ? 別擔心,Apple 將依據使用者帳號所在的國家回傳正確的金額給我們,因此我們可利用以下 Apple 官方的範例程式產生金額字串。

extension SKProduct {
var regularPrice: String? {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = self.priceLocale
return formatter.string(from: self.price)
}
}

測試 App: 商品價錢

美國帳號顯示的金額

台灣帳號顯示的金額

產生代表交易的 SKPayment 進行購買

購買商品的程式其實只有兩行,從 SKProduct 產生 SKPayment,然後將它加到 SKPaymentQueue 裡。

func buy(product: SKProduct) {
if SKPaymentQueue.canMakePayments() {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)

} else {
// show error
}
}

值得注意的,使用者的 iPhone 有可能 IAP 功能被限制,比方父母將小孩 iPhone 的購買功能關閉,因此我們最好先用 SKPaymentQueue.canMakePayments() 檢查使用者是否可以買東西。

以上的程式已經能讓使用者購買商品,出現下圖的購買視窗,而且使用者已經可以成功購買。不過有個很重要的問題需要解決,我們必須知道購買是否成功。

ps: 我們也可生成 SKMutablePayment,設定它的 quantity 控制商品購買的數量。比方購買我們在 App Store Connect 建立的 Consumable 商品聆聽音樂一小時,quantity 設為 100 表示想一次購買 100 小時。

let payment = SKMutablePayment(product: product)
payment.quantity = 100

從 SKPaymentTransactionObserver 的 function 判斷交易的結果

當交易成功,失敗或狀態改變時,它會觸發 protocol SKPaymentTransactionObserver 的 function。因此我們讓 IAPManager 遵從 protocol SKPaymentTransactionObserver,然後將 IAPManager 設為 SKPaymentQueue 的 observer,如此即可在交易狀態改變時收到通知。

  • IAPManager 遵從 SKPaymentTransactionObserver,定義交易狀態改變時觸發的 function paymentQueue(_:updatedTransactions:)。
extension IAPManager: SKPaymentTransactionObserver {    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {


}

}
  • 在 App 啟動時將 IAPManager 設為 SKPaymentQueue 的 observer。

使用者買東西時有可能突然沒有網路,造成交易無法順序完成。因此若我們在 App 啟動時將 IAPManager 設為 SKPaymentQueue 的 observer,下次使用者啟動 App 時 IAPManager 將可馬上收到通知,觸發 SKPaymentTransactionObserver 的 function,順利完成之前未完的交易。

import UIKit
import StoreKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

SKPaymentQueue.default().add(IAPManager.shared)
return true
}
  • 判斷交易的結果。

交易狀態改變時將觸發 function paymentQueue(_:updatedTransactions:),我們可從參數 transactions 得知交易的結果。

extension IAPManager: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {

transactions.forEach {
print($0.payment.productIdentifier, $0.transactionState.rawValue)
switch $0.transactionState {
case .purchased:
SKPaymentQueue.default().finishTransaction($0)
case .failed:
print($0.error ?? "")
if ($0.error as? SKError)?.code != .paymentCancelled {
// show error
}
SKPaymentQueue.default().finishTransaction($0)
case .restored:
SKPaymentQueue.default().finishTransaction($0)
case .purchasing, .deferred:
break
@unknown default:
break
}

}
}

}

我們可從 SKPaymentTransactionpayment.productIdentifier 知道購買的商品,從 transactionState 知道交易的狀態,目前有 purchased,failed,restored,purchasing & deferred 5 種狀態。purchased 代表交易成功,因此我們可判斷 transactionState 等於 purchased 時增加使用者可聆聽音樂的時間或開始下載購買的歌曲。

值得注意的,當狀態為代表成功的 purchased & restored(待會說明)和代表失敗的 failed 時,記得要呼叫 finishTransaction 完成交易,否則 iOS 會以為交易還未完成,下次打開 App 時會再觸發 paymentQueue(_:updatedTransactions:)

讓我們舉個例子說明。比方使用者購買了 Taylor Swift 的 love story,App 程式開始下載音樂,當歌曲下載成功後再呼叫 finishTransaction 完成交易。若是網路突然斷線造成下載失敗,由於我們尚未呼叫 finishTransaction,因此 App 下次啟動時會再觸發 paymentQueue(_:updatedTransactions:),讓我們知道使用者購買了 love story,重新進行 love story 的下載,直到下載成功後才呼叫 finishTransaction完成交易。

另外交易失敗時應該要在 App 畫面上顯示錯誤訊息,不過如果使用者自己取消購買則不用,因此以下程式只在 error code 不等於 paymentCancelled 時顯示錯誤。

if ($0.error as? SKError)?.code != .paymentCancelled {
// show error
}

以 SwiftUI App 測試 IAP

現在我們已可實際測試 IAP 商品的購買,以下彼得潘將剛剛的程式整理成一個簡單的 SwiftUI App 實驗。

struct ProductList: View {

@ObservedObject var iapManager = IAPManager.shared

var body: some View {

List(iapManager.products, id: \.productIdentifier) { (product) in
Button(action: {
self.iapManager.buy(product: product)
}) {
HStack {
Text(product.localizedTitle)
Spacer()
Text(product.regularPrice ?? "")
}
}
}
.onAppear {
self.iapManager.getProducts()
}
}
}

當我們抓取到商品資料時, products 內容改變將通知 ProductList 更新畫面。不過畫面的更新必須在 main thread 執行,因此我們切換到 main thread 設定 products。

extension IAPManager: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
response.products.forEach {
print($0.localizedTitle, $0.price, $0.localizedDescription)
}
DispatchQueue.main.async {
self.products = response.products
}
}

}

App 畫面

測試各種商品的購買

  • 購買 Consumable 的聽一小時。

聽一小時屬於可重覆購買的 Consumable 商品,所以我們可重覆購買,比方買十次獲得 10 小時的音樂聆聽時間。成功購買時,Apple 的購買視窗將顯示 You’re all set,Your purchase was successful。

  • 購買 Non-Consumable 的愛的故事。

愛的故事是不可重覆購買的 Non-Consumable 商品,理想上 App 應該先做判斷,若使用者已買過則不給他購買。不過就算讓使用者不小心購買,Apple 也是不會扣錢的,他會告訴使用者 You’ve already purchased this,使用者可以免費購買。

成功購買時提供商品給使用者(Content Delivery)

當使用者成功購買時,我們必須提供商品給使用者,可能是解鎖某個功能,也可能是下載某個檔案(ps: 檔案也可以放在 Apple 的後台)。有興趣的朋友可參考以下連結 Content Delivery 的相關說明。

restore 買過的商品

若商品屬於 non-consumable & auto-renewable subscriptions,Apple 還要求 App 必須實作一個功能才能上架,也就是我們現在要介紹的 restore 功能。

restore 功能可以回復使用者之前買過的商品。比方使用者在 iPhone 11 買了 Taylor Swift 的 love story,後來他用同樣的 Apple ID 登入 iPad Pro,在 iPad Pro 使用 App 時他應該能直接下載聆聽 love story,不用再買一次。因此 App 裡必須提供 restore button,方便使用者點選回復他買過的商品。

實現 restore 功能主要透過以下程式,呼叫 SKPaymentQueuerestoreCompletedTransactions

SKPaymentQueue.default().restoreCompletedTransactions()

當 restore 成功時,我們可從 paymentQueue(_:updatedTransactions:) 讀取到 transactionState 為 restored,然後開始將商品提供給使用者,比方下載 Taylor Swift 的 love story。

證明使用者購買的 receipt (收據)

如果想要更安全,確認使用者真的有花錢購買,Apple 還提供了幫助我們檢查的 receipt 。我們可從 Bundle.main.appStoreReceiptURL 讀取購買的 receipt。

guard let receiptURL = Bundle.main.appStoreReceiptURL,  let data = try? Data(contentsOf: receiptURL)  else {
return
}
print(data)

不過 receipt 是加密過的檔案,所以解讀有點麻煩,有興趣的朋友可參考以下連結的說明。

App 上架前提供 IAP 的 Review 照片

雖然我們已經可以在測試時購買商品,但是它還是假的,不會讓我們賺到一毛錢。為了讓商品能真正銷售,IAP 還要經過 Apple 的審核。

回到 App Store Connect 的 IAP 頁面,此時商品的 Status 是 Missing Metadata,表示我們還有資訊未提供。

因此在審核前記得要在 Review Information 提供商品在 App 裡銷售的畫面,讓商品的狀態變成 Ready to Submit。

為了送審 IAP 的商品,最後我們還要回到 App Store 的 Prepare for Submission 頁面,點選 In-App Purchase 的 + 加入送審的商品。

Apple 官方範例 (UIKit App)

Apple 官方提供完整的 IAP App 範例和說明,有興趣的朋友也可以下載研究。

測試 IAP 範例

若想測試彼得潘的 SwiftUI 範例或 Apple 官方範例,可參考以下三個步驟進行:

  • 完成下圖寫程式前的準備。
  • 修改範例的 Bundle ID。
  • 設定 product ID。

修改 Apple 範例的 ProductIds.plist 或 SwiftUI 範例的 getProductIDs。

SwiftUI IAP 範例下載

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com