#39 實作IAP內購功能
開發的產品經過上架後,決定實作IAP功能,讓自己的產品能夠依照不同程度族群的需求提供各種程度的服務,第一個實作的功能是去廣告。
實作內購功能有許多前置作業跟開發階段,不過不難,照著幾個不錯的分享文章後成功完成,各位可以先參考
這邊簡單摘要一下IAP實作上的流程,前提是你已經是開發者會員
- 完成付費App協議
- 完成沙盒測試帳號設定
- 設定好內購項目與內容
- 實作內購功能與模型
- 內購功能測試
- 包版送審
前面兩個就跳過不說,請參考上方連結,從第3點開始,會開始有些不同,也是新手特別要注意的。
現在App Store Connect將訂閱制直接從內購功能中拆出來,所以在內購項目中只能選擇"消耗性產品"與"非消耗性產品",而"自動續訂"與"非續訂"放到訂閱項目中設定,除此之外的設定流程都跟上方連結相同,不再贅述。
實作內購功能上注意需要配置的項目,本次僅以非耗損性產品為例
- 自訂IAP方法,用Singleton Pattern來呼叫裡面的function,包含抓App Store Connect設定的內購項目與執行購買,或是回復已購買產品,並將已購買的資訊保存在UserDefault內。
- 交易代理人,用來監控交易的結果
- 內購代理人,用來處理從App Store Connect抓下來的內購資訊
- 內購清單
- 用UserDefault來存放已購買的key
- 用Bool值變數比對UserDefault來判斷是否購買某產品,決定開啟或關閉某項功能
自訂IAPManager方法要繼承NSObject類別,並且要套用StoreKit SDK,透過SKProductsRequest型別的物件,去抓App Store Connect上設定好的產品資訊
import StoreKit
// 自建內購類別
class IAPManager: NSObject {
// Singleton
static let shared = IAPManager() // 呼叫自己函式
var products = [SKProduct]() // 建立內購物件
fileprivate var productRequest: SKProductsRequest!
func getProductIDs() -> [String] {
["com.purplvampire.NetworkConsole.monthly",
"com.purplvampire.NetworkConsole.removeAds"
]
}
func getProducts() {
let productIds = getProductIDs() // 取得陣列
let productIdsSet = Set(productIds) // 將陣列轉型
// 從Apple Store Connect取得IAP內購資訊
productRequest = SKProductsRequest(productIdentifiers: productIdsSet)
productRequest.delegate = self
productRequest.start()
}
// 支付內購
func buy(product: SKProduct) {
// 先確認是否能購買商品,再執行支付程序
if SKPaymentQueue.canMakePayments() {
// 執行支付程序
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
} else {
assertionFailure("Fail purchase!")
}
}
// 回復已購買狀態
func restore() {
SKPaymentQueue.default().restoreCompletedTransactions()
}
// 將完成交易的值存在雲端或本機
func savePurchasedItemKey() {
#if USE_ICLOUD_STORAGE
let storage = NSUbiquitousKeyValueStore.default
#else
let storage = UserDefaults.standard
#endif
storage.set(true, forKey: "removeAds")
}
}
接著針對代理人productRequest.delegate = self 的部分要進行一些資訊處理,因為抓取資訊是在背景執行,如果抓下來的資料要放到TableView,那麼資料要轉到Main Thread執行緒上處理
// 處理取到的內購資訊
extension IAPManager: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
// 將資料印出
response.products.forEach {
print($0.localizedTitle, $0.price, $0.localizedDescription)
}
// 將資料轉存,配合UI更新要切到Main Thread
DispatchQueue.main.async {
self.products = response.products
}
}
}
再來是針對購買行為的監控與處理,SKPaymentTransactionObserver主要是在SKPaymentQueue.default()這個方法被觸發時執行
// 監控交易狀態
extension IAPManager: SKPaymentTransactionObserver {
// 交易狀態改變時觸發
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
transactions.forEach {
print($0.payment.productIdentifier, $0.transactionState.rawValue)
// 購買或失敗要做結清的動作finishTransaction($0)
switch $0.transactionState {
case .purchasing:
break
case .purchased:
SKPaymentQueue.default().finishTransaction($0)
self.savePurchasedItemKey()
case .failed:
print($0.error ?? "")
// 當失敗的原因不是取消交易時跳出告警
if ($0.error as? SKError)?.code != .paymentCancelled {
// show error alert
}
SKPaymentQueue.default().finishTransaction($0)
// 回復已購買商品
case .restored:
SKPaymentQueue.default().finishTransaction($0)
self.savePurchasedItemKey()
case .deferred:
break
@unknown default:
break
}
}
}
}
另外在AppDelegate也要配置交易監控,並且在App生命週期停止時一併結束監控
//
// AppDelegate.swift
// NetworkConsole
import UIKit
import StoreKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
fileprivate var hasRegisterForNotification: Bool = false
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 啟動時將IAPManager設為SKPaymentQueue的observer
SKPaymentQueue.default().add(IAPManager.shared)
hasRegisterForNotification = true
return true
}
// App終止時停止SKPaymentQueue的observer
func applicationWillTerminate(_ application: UIApplication) {
if hasRegisterForNotification {
SKPaymentQueue.default().remove(IAPManager.shared)
}
}
}
完成IAPManage內購方法後,接著就是將內購功能部署到ViewController的按鈕Function上,這部分因為彼得潘的範例是SwiftUI,所以藉助ChatGPT的幫助改成Swift的程式碼,再進行改寫
上方是SwiftUI的程式碼, 請轉成UIKit的程式碼:
import UIKit
class ProductListViewController: UIViewController {
let iapManager = IAPManager.shared
var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
setupTableView()
setupNavigationBar()
iapManager.getProducts()
}
private func setupTableView() {
tableView = UITableView(frame: view.bounds, style: .plain)
tableView.delegate = self
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
view.addSubview(tableView)
}
private func setupNavigationBar() {
navigationItem.title = "Taylor Swift"
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Restore", style: .plain, target: self, action: #selector(restoreTapped))
}
@objc func restoreTapped() {
iapManager.restore()
}
}
extension ProductListViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return iapManager.products.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let product = iapManager.products[indexPath.row]
cell.textLabel?.text = product.localizedTitle
cell.detailTextLabel?.text = product.regularPrice ?? ""
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let product = iapManager.products[indexPath.row]
iapManager.buy(product: product)
}
}
套用到SettingsVC中的按鈕function
SettingsVC程式碼實作,按鈕完成購買程序後會存一筆key到UserDefault內,這個key值會被用來判斷是否觸發某項/某些功能。
import UIKit
import StoreKit // App評分&購買
class SettingsTableViewController: UITableViewController {
override func viewWillAppear(_ animated: Bool) {
IAPManager.shared.getProducts() // 取得內購資訊
}
// 這個function用來判斷是否已購買商品,參數值預設false表示尚未購買,在didSelectRowAt中呼叫
func removeAds(hasPurchased: Bool = false) {
// 若已經購買則跳告警通知
if hasPurchased {
// message為多語系設定
let message = NSLocalizedString("settingsVC.purchasedMsg", comment: "This function has purchased.")
let alertVC = UIAlertController(title: nil, message: message, preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default)
alertVC.addAction(okAction)
present(alertVC, animated: true)
// 若還沒購買則進入購買程序
} else {
let products: [SKProduct] = IAPManager.shared.products
// 購買商品
let removeAds = products[1]
IAPManager.shared.buy(product: removeAds)
}
}
// 回復已購項目
func restorePurchased() {
IAPManager.shared.restore()
}
// MARK: - Table view data source
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
print(indexPath)
switch indexPath {
case [1,4]:
print("Purchase")
#if USE_ICLOUD_STORAGE
let storage = NSUbiquitousKeyValueStore.default
#else
let storage = UserDefaults.standard
#endif
let removeAds = storage.bool(forKey: "removeAds")
self.removeAds(hasPurchased: removeAds)
case [1,5]:
print("Restore")
self.restorePurchased()
default:
print("none")
}
// 點擊cell後取消選取
self.tableView.deselectRow(at: indexPath, animated: true)
}
}
接著到含有廣告的VC下新增條件式決定是否啟用廣告
import UIKit
import GoogleMobileAds
import AppTrackingTransparency
import AdSupport
class Console2ViewController: UIViewController, GADBannerViewDelegate {
var bannerView: GADBannerView? // 橫幅廣告
var removeAds: Bool = false // 判斷是否要移除廣告
override func viewDidLoad() {
super.viewDidLoad()
// 購買項目確認,key優先存在iCloud,若沒有則存到UserDefault中
#if USE_ICLOUD_STORAGE
let storage = NSUbiquitousKeyValueStore.default
#else
let storage = UserDefaults.standard
#endif
removeAds = storage.bool(forKey: "removeAds") // 若key有存在則為True
// Admob
if #available(iOS 14, *) {
// 請求IDFA廣告識別碼
ATTrackingManager.requestTrackingAuthorization { status in
if status == .authorized {
} else {
}
// 置入廣告前先判斷有無購買決定是否移除廣告
if self.removeAds == false {
// 置入廣告
DispatchQueue.main.async {
self.bannerView = GADBannerView(adSize: GADAdSizeBanner)
self.bannerView?.translatesAutoresizingMaskIntoConstraints = false
// 設定廣告單元編號
// self.bannerView?.adUnitID = "ca-app-pub-3940256099942544/2934735716" // 測試用
self.bannerView?.adUnitID = "ca-app-pub-5052077476353721/"
self.bannerView?.delegate = self
self.bannerView?.rootViewController = self
self.bannerView?.load(GADRequest())
}
}
}
}
}
}
到這邊就完成實作的程序,接著就是驗證跟上架,注意要用沙盒的帳號來進行內購測試,請見Peter的文章。
最後就是上傳測試畫面與說明送審,只要審過就可以讓用戶購買App內的商品了
這是我用來實作的上架App,有興趣可以下載參考