從零開始開發 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 回傳的商品資訊,我們可以設計類似以下的商城頁品誘惑使用者購買。
在顯示商品資訊時,最麻煩的是語言問題,針對不同國家最好能顯示不同的語言和金額,比方美國顯示英文 & 美金,台灣顯示中文和新台幣。
感謝 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
}
}
}
}
我們可從 SKPaymentTransaction
的 payment.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 功能主要透過以下程式,呼叫 SKPaymentQueue
的 restoreCompletedTransactions
。
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。