#39 實作IAP內購功能

開發的產品經過上架後,決定實作IAP功能,讓自己的產品能夠依照不同程度族群的需求提供各種程度的服務,第一個實作的功能是去廣告。

實作內購功能有許多前置作業跟開發階段,不過不難,照著幾個不錯的分享文章後成功完成,各位可以先參考

這邊簡單摘要一下IAP實作上的流程,前提是你已經是開發者會員

  1. 完成付費App協議
  2. 完成沙盒測試帳號設定
  3. 設定好內購項目與內容
  4. 實作內購功能與模型
  5. 內購功能測試
  6. 包版送審

前面兩個就跳過不說,請參考上方連結,從第3點開始,會開始有些不同,也是新手特別要注意的。

完成付費協議
設定沙盒帳號

現在App Store Connect將訂閱制直接從內購功能中拆出來,所以在內購項目中只能選擇"消耗性產品"與"非消耗性產品",而"自動續訂"與"非續訂"放到訂閱項目中設定,除此之外的設定流程都跟上方連結相同,不再贅述。

內購項目
訂閱項目

實作內購功能上注意需要配置的項目,本次僅以非耗損性產品為例

  1. 自訂IAP方法,用Singleton Pattern來呼叫裡面的function,包含抓App Store Connect設定的內購項目與執行購買,或是回復已購買產品,並將已購買的資訊保存在UserDefault內。
  2. 交易代理人,用來監控交易的結果
  3. 內購代理人,用來處理從App Store Connect抓下來的內購資訊
  4. 內購清單
  5. 用UserDefault來存放已購買的key
  6. 用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

IBOutlet配置

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,有興趣可以下載參考

--

--