관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 3. StoreKit - SwiftyStoreKit을 이용하여 IAP (In App Purchase) 쉽게 구현 방법 본문

iOS 응용 (swift)

[iOS - swift] 3. StoreKit - SwiftyStoreKit을 이용하여 IAP (In App Purchase) 쉽게 구현 방법

jake-kim 2022. 6. 7. 22:06

1. StoreKit - IAP (In App Purchases, 인앱 결제) 사용 방법 (Sandbox, 인앱 결제 앱 등록)

2. StoreKit - IAP(In App Purchases, 인앱 결제) 적용 방법 (코딩 및 구현)

3. StoreKit - SwiftyStoreKit을 이용하여 IAP (In App Purchase) 쉽게 구현 방법

SwiftyStoreKit

  • git repo
  • 매우 단순하게 IAP 서비스를 구현할 수 있는 프레임워크
  • SwiftyStoreKit을 사용하지 않는다면 2번 포스팅 글에서 알아보았듯이 비동기적으로 payment에 관한 상태를 delegate에서 처리하는 형태
  • SwiftyStoreKit을 사용하면 싱글톤과 클로저로 직관적으로 처리가 가능
SwiftyStoreKit.completeTransactions(atomically: true) { purchases in
  ...
}

예제로 사용할 내용

  • 버튼 3개 존재하는 화면
    • restore 버튼 - 이전에 구입했던 내용이 있을 때, 이 버튼을 클릭하여 복구하는 기능
    • RandomColor 버튼 - 클릭하면 배경색상을 랜덤 컬러로 변경할 수 있는 기능 (인앱 결제를 통해 enabled 되는 기능)
    • Buy RandomColor 버튼 - RandomColor 기능을 기입하는 버튼

SwiftyStoreKit 구현 구조

  • 비동기 처리에서 RxSwift, RxCocoa를 사용할 때를 위해서 Observable로 Wrapping하여 구현
    • (Rx를 사용하지 않는다면 delegate 패턴을 사용하여 구현)
  • 싱글톤으로 구현하는 것보단 protocol을 준수했을때만 해당 scope 안에서 사용할 수 있도록 하는 mix-in 패턴으로 구현
  • cocoapods을 사용할때 아래 3가지 프레임워크 사용
pod 'SwiftyStoreKit'
pod 'RxSwift'
pod 'RxCoacoa'

SwiftyStoreKit 사용 

  • mix-in, Traits 패턴을 사용하기위해 Interface와 Service 정의
import StoreKit
import SwiftyStoreKit
import RxSwift
import RxCocoa

// Interface
protocol IAPTraits {
  static var iapService: IAPServiceType { get }
}

extension IAPTraits {
  static var iapService: IAPServiceType { IAPService.shared }
}

// Service
protocol IAPServiceType {
  func getPaymentStateObservable() -> Observable<SKPaymentTransactionState>
  func getLocalPriceObservable(productID: String) -> Observable<String>
  func restorePurchaseObservable() -> Observable<Void>
  func purchase(productID: String) -> Observable<Void>
}

private final class IAPService: IAPServiceType {
  static let shared = IAPService()
  
  private init() {}
}
  • 각 메소드에서 observable.onNext()로 Error를 방출하는 케이스에서 사용할 에러타입 정의
enum IAPError: Error {
  case invalidProductID(String)
  case unknown(Error?)
  case failedRestorePurchases([(SKError, String?)])
  case noRetrievedProduct
  case noRestorePurchases
  case noProducts
  case canceledPayment
}
  • getPaymentStateObservable() -> Observable<SKPaymentTransactionState> 구현
    • transaction의 상태를 알 수 있으며, 거래의 상태에 대한 상태값을 얻을 수 있는 메소드
    • Observable로 wrapping하기위해서 Observable.create로 정의
func getPaymentStateObservable() -> Observable<SKPaymentTransactionState> {
  .create { observer in
    SwiftyStoreKit.completeTransactions(atomically: true) { purchases in
      for purchase in purchases {
        switch purchase.transaction.transactionState {
        case .purchased, .restored:
          SwiftyStoreKit.finishTransaction(purchase.transaction)
        default:
          break
        }
        observer.onNext(purchase.transaction.transactionState)
      }
    }
    return Disposables.create()
  }
}
  • getLocalPriceObservable(productID: String) -> Observable<String> 구현
    • 인수로 들어온 productID의 가격을 조회하는 메소드
func getLocalPriceObservable(productID: String) -> Observable<String> {
  .create { observer in
    SwiftyStoreKit.retrieveProductsInfo([productID]) { result in
      if let product = result.retrievedProducts.first {
        let priceString = product.localizedPrice ?? ""
        print("Product: \(product.localizedDescription), price: \(priceString)")
        observer.onNext(priceString)
      } else if let invalidProductId = result.invalidProductIDs.first {
        print("Invalid product identifier: \(invalidProductId)")
        observer.onError(IAPError.invalidProductID(invalidProductId))
      } else {
        print("Error: \(String(describing: result.error))")
        observer.onError(IAPError.unknown(result.error))
      }
    }
    return Disposables.create()
  }
}
  • restorePurchaseObservable() -> Observable<Void> 구현
    • 구매했던 정보를 불러오는 메소드
func restorePurchaseObservable() -> Observable<Void> {
  .create { observer in
    SwiftyStoreKit.restorePurchases(atomically: true) { results in
      if results.restoreFailedPurchases.count > 0 {
        print("Restore Failed: \(results.restoreFailedPurchases)")
        observer.onError(IAPError.failedRestorePurchases(results.restoreFailedPurchases))
      } else if results.restoredPurchases.count > 0 {
        print("Restore Success: \(results.restoredPurchases)")
        observer.onNext(())
      } else {
        observer.onError(IAPError.noRestorePurchases)
      }
    }
    return Disposables.create()
  }
}
  • purchase(productID: String) -> Observable<Void> 정의
    • 인수로 들어온 productID 상품을 구매하는 메소드
func purchase(productID: String) -> Observable<Void> {
  .create { observer in
    SwiftyStoreKit.purchaseProduct(productID, quantity: 1, atomically: true) { result in
      switch result {
      case .success:
        observer.onNext(())
      case .error(let error):
        switch error.code {
        case .unknown:
          print("Unknown error. Please contact support")
        case .clientInvalid:
          print("Not allowed to make the payment")
        case .paymentCancelled:
          observer.onError(IAPError.canceledPayment)
        case .paymentInvalid:
          print("The purchase identifier was invalid")
        case .paymentNotAllowed:
          print("The device is not allowed to make the payment")
        case .storeProductNotAvailable:
          print("The product is not available in the current storefront")
        case .cloudServicePermissionDenied:
          print("Access to cloud service information is not allowed")
        case .cloudServiceNetworkConnectionFailed:
          print("Could not connect to the network")
        case .cloudServiceRevoked:
          print("User has revoked permission to use this cloud service")
        default:
          print((error as NSError).localizedDescription)
        }
      }
    }
    return Disposables.create()
  }
}

사용하는 쪽

  • ViewController에서 IAPTraits를 준수하여, Self.iapService로 위에서 정의한 서비스를 사용할 수 있도록 정의
  • UI와 프로퍼티 선언
import UIKit
import RxSwift
import RxCocoa

class ViewController: UIViewController, IAPTraits {
  private let restoreButton: UIButton = { ... }
  private let colorButton: UIButton = { ... }
  private let buyButton: UIButton = { ... }
  
  private let productID = "some product id"
  private var canUseRandomColor = false {
    didSet {
      self.colorButton.isEnabled = self.canUseRandomColor
      self.buyButton.isEnabled = !self.colorButton.isEnabled
    }
  }
}
  • viewDidLoad에서 constraint 정의 및 바인딩
    • 특정 상품을 구매했는지 확인하는 것은 UserDefaults에도 저장하여 관리
// autolayout은 생략...

// binding
Self.iapService.getPaymentStateObservable()
  .withUnretained(self)
  .subscribe(onNext: { ss, state in
    switch state {
    case .purchased, .restored:
      ss.canUseRandomColor = true
    default:
      ss.canUseRandomColor = false
    }
  })
  .disposed(by: self.disposeBag)

Self.iapService.getLocalPriceObservable(productID: self.productID)
  .withUnretained(self)
  .subscribe(onNext: { ss, price in
    ss.buyButton.setTitle("Buy RandomColor (\(price)$)", for: .normal)
  })
  .disposed(by: self.disposeBag)

guard UserDefaults.standard.bool(forKey: self.productID) == true else { return }
self.canUseRandomColor = true
  • Restore 버튼을 눌렀을 때 restore 처리
@objc private func didTapRestoreButton() {
  Self.iapService.restorePurchaseObservable()
    .withUnretained(self)
    .subscribe(onNext: { ss, _ in
      // UserDefaults로 해당 product 구입했는지 저장
      UserDefaults.standard.set(true, forKey: ss.productID)
      ss.canUseRandomColor = true
    })
    .disposed(by: self.disposeBag)
}
  • buyButton을 탭했을때 처리
@objc private func didTapBuyButton() {
  Self.iapService.purchase(productID: self.productID)
    .withUnretained(self)
    .subscribe(onError: { print("error \($0)") })
    .disposed(by: self.disposeBag)
}
  • color 버튼을 눌렀을 때 처리
@objc private func didTapColorButton() {
  self.view.backgroundColor = UIColor(red: CGFloat(drand48()), green: CGFloat(drand48()), blue: CGFloat(drand48()), alpha: 1.0)
}

* 전체 코드: https://github.com/JK0369/ExSwiftyStoreKit

 

* 참고

https://medium.com/gitconnected/beginner-ios-dev-in-app-purchase-iap-made-simple-with-swiftystorekit-3add60e9065d

 

Comments