การสัมภาษณ์ StoreKit 2: การจัดการการสมัครสมาชิกและการตรวจสอบใบเสร็จ

เชี่ยวชาญคำถามสัมภาษณ์ iOS เกี่ยวกับ StoreKit 2 การจัดการการสมัครสมาชิก การตรวจสอบใบเสร็จ และการนำการซื้อในแอปไปใช้ พร้อมตัวอย่างโค้ด Swift ที่ใช้งานได้จริง

สถาปัตยกรรมการสมัครสมาชิก iOS StoreKit 2 และการตรวจสอบใบเสร็จ

StoreKit 2 แสดงถึงการเปลี่ยนแปลงพื้นฐานในวิธีจัดการการซื้อในแอปบน iOS เปิดตัวพร้อมกับ iOS 15 framework ที่ทันสมัยนี้ช่วยลดความซับซ้อนของโค้ดที่จำเป็นสำหรับการจัดการการสมัครสมาชิกและการตรวจสอบธุรกรรมอย่างมาก ด้วย iOS 18, StoreKit 1 ถูก deprecated อย่างเป็นทางการ ทำให้ StoreKit 2 เป็นเส้นทางเดียวที่รองรับ การสัมภาษณ์ทางเทคนิค iOS มักครอบคลุมหัวข้อนี้เพื่อประเมินความเชี่ยวชาญของนักพัฒนาในการสร้างรายได้จากแอป

ข้อมูลเชิงลึกสำคัญสำหรับการสัมภาษณ์

StoreKit 2 ใช้ประโยชน์จาก API Swift ดั้งเดิมด้วย async/await ทำให้ไม่จำเป็นต้องใช้ callback ที่ซับซ้อนและการตรวจสอบใบเสร็จฝั่งไคลเอนต์ ผู้สัมภาษณ์ให้ความสำคัญกับผู้สมัครที่สามารถอธิบายความแตกต่างพื้นฐานจาก StoreKit 1 ได้ และฟีเจอร์ใหม่ของ iOS 18.4 เช่น appTransactionID และ SubscriptionOfferView

ภาพรวมสถาปัตยกรรม StoreKit 2

StoreKit 2 ถูกสร้างขึ้นบนสถาปัตยกรรมที่ทันสมัยซึ่งใช้ประโยชน์จาก Swift Concurrency อย่างเต็มที่ ไม่เหมือน StoreKit 1 การดำเนินการทั้งหมดเป็นแบบอะซิงโครนัสและใช้ประเภท Swift ดั้งเดิม

การดึงผลิตภัณฑ์ที่มีอยู่

ขั้นตอนแรกเกี่ยวข้องกับการโหลดผลิตภัณฑ์ที่กำหนดค่าใน App Store Connect StoreKit 2 ทำให้การดำเนินการนี้ง่ายขึ้นด้วย API แบบประกาศ

ProductService.swiftswift
import StoreKit

actor ProductService {

    // Product identifiers configured in App Store Connect
    private let productIdentifiers: Set<String> = [
        "com.app.subscription.monthly",
        "com.app.subscription.yearly",
        "com.app.premium.lifetime"
    ]

    // Product cache to avoid repeated network calls
    private var cachedProducts: [Product] = []

    func loadProducts() async throws -> [Product] {
        // Return cache if available
        guard cachedProducts.isEmpty else {
            return cachedProducts
        }

        // Asynchronous request to the App Store
        let products = try await Product.products(for: productIdentifiers)
        cachedProducts = products
        return products
    }

    func product(for identifier: String) async throws -> Product? {
        let products = try await loadProducts()
        return products.first { $0.id == identifier }
    }
}

การใช้ actor รับประกันความปลอดภัยของเธรดเมื่อจัดการแคชผลิตภัณฑ์

ประเภทผลิตภัณฑ์ใน StoreKit 2

StoreKit 2 แยกแยะประเภทผลิตภัณฑ์ที่แตกต่างกันอย่างชัดเจน การเข้าใจความแตกต่างนี้เป็นสิ่งสำคัญสำหรับการสัมภาษณ์

ProductType+Extensions.swiftswift
import StoreKit

extension Product.ProductType {

    var displayName: String {
        switch self {
        case .consumable:
            // Consumable products (credits, lives, etc.)
            return "Consumable"
        case .nonConsumable:
            // Permanent purchases (feature unlock)
            return "Non-Consumable"
        case .autoRenewable:
            // Subscriptions with automatic renewal
            return "Auto-Renewable Subscription"
        case .nonRenewable:
            // Subscriptions without renewal (temporary pass)
            return "Non-Renewable Subscription"
        @unknown default:
            return "Unknown"
        }
    }
}

การสมัครสมาชิกแบบต่ออายุอัตโนมัติเป็นรูปแบบธุรกิจหลักสำหรับแอปพลิเคชัน iOS ที่ทันสมัย

การจัดการธุรกรรมการซื้อ

กระบวนการซื้อใน StoreKit 2 กลายเป็นเชิงเส้นด้วย async/await ไม่มี delegate และ callback ซ้อนกันจาก StoreKit 1 อีกต่อไป

การเริ่มต้นการซื้อ

ตั้งแต่ iOS 18.2 เป็นต้นไป การซื้อต้องใช้พารามิเตอร์ UI context การเปลี่ยนแปลงนี้ทำให้แน่ใจว่า sheet การซื้อจะปรากฏอย่างถูกต้องในลำดับชั้น view ของแอป

PurchaseManager.swiftswift
import StoreKit
import UIKit

actor PurchaseManager {

    enum PurchaseError: Error {
        case productNotFound
        case purchaseCancelled
        case purchasePending
        case verificationFailed
        case unknown
    }

    func purchase(_ product: Product, in scene: UIWindowScene) async throws -> Transaction {
        // Initiate purchase with UI context (iOS 18.2+)
        let result = try await product.purchase(confirmIn: scene)

        switch result {
        case .success(let verification):
            // Verify the transaction signature
            let transaction = try checkVerification(verification)

            // Mark the transaction as finished
            await transaction.finish()

            return transaction

        case .userCancelled:
            // User cancelled the purchase modal
            throw PurchaseError.purchaseCancelled

        case .pending:
            // Purchase pending (parental approval, etc.)
            throw PurchaseError.purchasePending

        @unknown default:
            throw PurchaseError.unknown
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            // Transaction signed and verified by the App Store
            return safe
        case .unverified(_, let error):
            // Invalid or corrupted signature
            throw PurchaseError.verificationFailed
        }
    }
}

เมธอด finish() มีความสำคัญอย่างยิ่ง: มันแจ้ง App Store ว่าเนื้อหาได้ถูกส่งมอบให้ผู้ใช้แล้ว

คำเตือนการสัมภาษณ์

อย่าลืมเรียก transaction.finish() หลังจากส่งมอบเนื้อหา ธุรกรรมที่ยังไม่เสร็จสิ้นจะถูกกู้คืนในการเปิดแอปครั้งถัดไป ทำให้เกิดพฤติกรรมที่ไม่คาดคิด

การฟังธุรกรรมเบื้องหลัง

StoreKit 2 แนะนำ Transaction.updates ซึ่งเป็นสตรีมอะซิงโครนัสที่ปล่อยธุรกรรมที่เกิดขึ้นภายนอกแอป (การต่ออายุ การซื้อ family sharing ฯลฯ)

TransactionObserver.swiftswift
import StoreKit

actor TransactionObserver {

    private var updateTask: Task<Void, Never>?

    func startObserving() {
        // Cancel any previous observation
        updateTask?.cancel()

        updateTask = Task(priority: .background) {
            // Infinite stream of transaction updates
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerification(result)

                    // Process the transaction based on its type
                    await self.handleTransaction(transaction)

                    // Always finish the transaction
                    await transaction.finish()
                } catch {
                    // Log the error for debugging
                    print("Transaction verification failed: (error)")
                }
            }
        }
    }

    func stopObserving() {
        updateTask?.cancel()
        updateTask = nil
    }

    private func handleTransaction(_ transaction: Transaction) async {
        switch transaction.productType {
        case .autoRenewable:
            // Update subscription status
            await updateSubscriptionStatus(transaction)
        case .nonConsumable:
            // Unlock premium feature
            await unlockFeature(transaction.productID)
        default:
            break
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            return safe
        case .unverified:
            throw PurchaseError.verificationFailed
        }
    }

    private func updateSubscriptionStatus(_ transaction: Transaction) async {
        // Implementation of status update
    }

    private func unlockFeature(_ productID: String) async {
        // Implementation of feature unlock
    }

    enum PurchaseError: Error {
        case verificationFailed
    }
}

การสังเกตการณ์ธุรกรรมควรเริ่มทันทีที่แอปเปิด เพื่อหลีกเลี่ยงการพลาดอัปเดตใดๆ

คำถามสัมภาษณ์ทั่วไปเกี่ยวกับการสมัครสมาชิก StoreKit 2

การสัมภาษณ์ iOS มักรวมถึงคำถามเฉพาะเกี่ยวกับการจัดการการสมัครสมาชิก ต่อไปนี้เป็นคำถามที่พบบ่อยที่สุดพร้อมคำตอบโดยละเอียด สำหรับหัวข้อสัมภาษณ์ Swift เพิ่มเติม ดู คู่มือ Swift structured concurrency

คำถามที่ 1: จะตรวจสอบสถานะการสมัครสมาชิกปัจจุบันได้อย่างไร?

StoreKit 2 ให้การเข้าถึง entitlement ที่ใช้งานอยู่โดยตรง โปรดทราบว่า Transaction.currentEntitlement(for:) ถูก deprecated ตั้งแต่ iOS 18.4 API ใหม่ currentEntitlements(for:) ส่งคืน sequence อะซิงโครนัสเพื่อจัดการหลาย entitlement (เช่น การสมัครสมาชิกพร้อมการเข้าถึง Family Sharing)

SubscriptionStatusChecker.swiftswift
import StoreKit

struct SubscriptionStatusChecker {

    struct SubscriptionStatus {
        let isActive: Bool
        let expirationDate: Date?
        let willRenew: Bool
        let productID: String?
        let appTransactionID: UInt64?  // iOS 18.4+
    }

    static func checkCurrentStatus(for productID: String) async -> SubscriptionStatus {
        // Use the new currentEntitlements(for:) API (iOS 18.4+)
        var latestTransaction: Transaction?

        for await result in Transaction.currentEntitlements(for: productID) {
            if case .verified(let transaction) = result {
                if transaction.productType == .autoRenewable {
                    if latestTransaction == nil ||
                       transaction.purchaseDate > latestTransaction!.purchaseDate {
                        latestTransaction = transaction
                    }
                }
            }
        }

        guard let transaction = latestTransaction else {
            return SubscriptionStatus(
                isActive: false,
                expirationDate: nil,
                willRenew: false,
                productID: nil,
                appTransactionID: nil
            )
        }

        // Check if the subscription is still active
        let isActive = transaction.expirationDate ?? Date.distantPast > Date()

        // Retrieve renewal information
        let renewalInfo = await getRenewalInfo(for: transaction.productID)

        return SubscriptionStatus(
            isActive: isActive,
            expirationDate: transaction.expirationDate,
            willRenew: renewalInfo?.willAutoRenew ?? false,
            productID: transaction.productID,
            appTransactionID: transaction.appTransactionID
        )
    }

    private static func getRenewalInfo(for productID: String) async -> Product.SubscriptionInfo.RenewalInfo? {
        guard let product = try? await Product.products(for: [productID]).first,
              let subscription = product.subscription else {
            return nil
        }

        // Retrieve renewal status
        let status = try? await subscription.status.first
        return status?.renewalInfo
    }
}

วิธีการนี้ให้สถานะการสมัครสมาชิกที่ถูกต้องโดยไม่ต้องเรียกเซิร์ฟเวอร์ ฟิลด์ appTransactionID ที่เปิดตัวใน iOS 18.4 ช่วยให้ติดตามลูกค้าข้ามธุรกรรมได้โดยไม่ต้องเรียก server-to-server

พร้อมที่จะพิชิตการสัมภาษณ์ iOS แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

คำถามที่ 2: จะจัดการการทดลองใช้ฟรีและข้อเสนอโปรโมชันได้อย่างไร?

StoreKit 2 ทำให้การเข้าถึงข้อมูลข้อเสนอง่ายขึ้น ผู้สัมภาษณ์มักทดสอบความเข้าใจเกี่ยวกับประเภทข้อเสนอที่แตกต่างกัน ตั้งแต่ iOS 18.4 เป็นต้นไป ข้อเสนอโปรโมชันสามารถเซ็นได้โดยใช้ JWS กับ PromotionOfferV2SignatureCreator ใหม่ที่มีอยู่ใน App Store Server Library

OfferManager.swiftswift
import StoreKit

struct OfferManager {

    enum OfferType {
        case freeTrial(duration: String)
        case payAsYouGo(periods: Int, price: Decimal)
        case payUpFront(duration: String, price: Decimal)
        case oneTime(price: Decimal)  // iOS 17.2+: offer codes for all product types
        case none
    }

    static func getIntroductoryOffer(for product: Product) -> OfferType {
        guard let subscription = product.subscription,
              let offer = subscription.introductoryOffer else {
            return .none
        }

        switch offer.paymentMode {
        case .freeTrial:
            // Free trial period
            return .freeTrial(duration: formatPeriod(offer.period))

        case .payAsYouGo:
            // Reduced price over multiple periods
            return .payAsYouGo(
                periods: offer.periodCount,
                price: offer.price
            )

        case .payUpFront:
            // Single payment for a period
            return .payUpFront(
                duration: formatPeriod(offer.period),
                price: offer.price
            )

        case .oneTime:
            // One-time offer code (consumables, non-consumables, non-renewing)
            return .oneTime(price: offer.price)

        @unknown default:
            return .none
        }
    }

    static func checkEligibility(for product: Product) async -> Bool {
        // Check if user can benefit from the offer
        guard let subscription = product.subscription else {
            return false
        }

        // isEligibleForIntroOffer indicates if this is a new subscriber
        return await subscription.isEligibleForIntroOffer
    }

    private static func formatPeriod(_ period: Product.SubscriptionPeriod) -> String {
        let value = period.value
        switch period.unit {
        case .day:
            return "(value) day(value > 1 ? "s" : "")"
        case .week:
            return "(value) week(value > 1 ? "s" : "")"
        case .month:
            return "(value) month(value > 1 ? "s" : "")"
        case .year:
            return "(value) year(value > 1 ? "s" : "")"
        @unknown default:
            return ""
        }
    }
}

เมธอด isEligibleForIntroOffer มีความสำคัญในการหลีกเลี่ยงการแสดงข้อเสนอที่ไม่สามารถใช้งานได้ โปรดทราบว่า offer codes ที่ก่อนหน้านี้จำกัดเฉพาะการสมัครสมาชิก auto-renewable ตอนนี้รองรับ consumables, non-consumables และการสมัครสมาชิก non-renewing ตั้งแต่ iOS 16.3

คำถามที่ 3: จะกู้คืนการซื้อก่อนหน้าได้อย่างไร?

การกู้คืนการซื้อเป็นฟีเจอร์บังคับตาม App Store Review Guidelines

RestoreManager.swiftswift
import StoreKit

actor RestoreManager {

    struct RestoreResult {
        let restoredProducts: [String]
        let activeSubscriptions: [String]
    }

    func restorePurchases() async throws -> RestoreResult {
        var restoredProducts: [String] = []
        var activeSubscriptions: [String] = []

        // Synchronize with the App Store
        try await AppStore.sync()

        // Iterate through all user transactions
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            switch transaction.productType {
            case .nonConsumable:
                // Restore permanent purchases
                restoredProducts.append(transaction.productID)

            case .autoRenewable:
                // Check if subscription is active
                if let expiration = transaction.expirationDate,
                   expiration > Date() {
                    activeSubscriptions.append(transaction.productID)
                }

            default:
                // Consumables are not restorable
                break
            }
        }

        return RestoreResult(
            restoredProducts: restoredProducts,
            activeSubscriptions: activeSubscriptions
        )
    }
}

AppStore.sync() บังคับการซิงโครไนซ์กับเซิร์ฟเวอร์ของ Apple มีประโยชน์หลังจากการติดตั้งใหม่

SubscriptionOfferView: Merchandising การสมัครสมาชิกยุคใหม่

iOS 18.4 แนะนำ SubscriptionOfferView ซึ่งเป็นคอมโพเนนต์ SwiftUI ที่แสดงตัวเลือกการสมัครสมาชิกพร้อมการโหลด metadata ผลิตภัณฑ์อัตโนมัติ นี่เป็นหัวข้อสัมภาษณ์ทั่วไปสำหรับนักพัฒนาที่สร้างแอปที่ใช้การสมัครสมาชิก

SubscriptionOfferViewExample.swiftswift
import SwiftUI
import StoreKit

struct SubscriptionSettingsView: View {
    let subscriptionGroupID = "com.app.subscription.group"
    
    var body: some View {
        VStack(spacing: 16) {
            // Show upgrade options for current subscribers
            SubscriptionOfferView(groupID: subscriptionGroupID)
                .visibleRelationship(.upgrade)
                .prefersPromotionalIcon(true)
                .subscriptionOfferViewDetailAction { path in
                    path.append("subscriptionDetail")
                }
            
            // Show current plan details
            SubscriptionOfferView(groupID: subscriptionGroupID)
                .visibleRelationship(.current)
        }
        .padding()
    }
}

พารามิเตอร์ visibleRelationship ควบคุมว่า tier การสมัครสมาชิกใดจะปรากฏ: .upgrade แสดงแพ็คเกจที่สูงกว่าหนึ่ง tier, .downgrade แสดง tier ที่ต่ำกว่า, .crossgrade แสดง tier ที่เทียบเท่ากับมูลค่าที่ดีกว่า และ .all แสดงทุกแพ็คเกจในกลุ่ม

การตรวจสอบใบเสร็จฝั่งเซิร์ฟเวอร์

การตรวจสอบฝั่งเซิร์ฟเวอร์ยังคงแนะนำสำหรับแอปพลิเคชันที่มีการสมัครสมาชิกที่สำคัญ StoreKit 2 ใช้ JWS (JSON Web Signatures) สำหรับการตรวจสอบที่ทันสมัย endpoint verifyReceipt ที่ deprecated ถูกแทนที่ด้วย App Store Server API

การดึงข้อมูลธุรกรรมสำหรับเซิร์ฟเวอร์

ServerValidation.swiftswift
import StoreKit

struct TransactionPayload: Codable {
    let transactionID: String
    let originalTransactionID: String
    let productID: String
    let purchaseDate: Date
    let expirationDate: Date?
    let jwsRepresentation: String
    let appTransactionID: String?  // iOS 18.4+
}

actor ServerValidationService {

    private let apiEndpoint = "https://api.example.com/verify"

    func validateWithServer(_ transaction: Transaction) async throws -> Bool {
        // Create payload for the server
        let payload = TransactionPayload(
            transactionID: String(transaction.id),
            originalTransactionID: String(transaction.originalID),
            productID: transaction.productID,
            purchaseDate: transaction.purchaseDate,
            expirationDate: transaction.expirationDate,
            jwsRepresentation: transaction.jwsRepresentation ?? "",
            appTransactionID: transaction.appTransactionID.map { String($0) }
        )

        // Encode to JSON
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        let jsonData = try encoder.encode(payload)

        // Send to server
        var request = URLRequest(url: URL(string: apiEndpoint)!)
        request.httpMethod = "POST"
        request.httpBody = jsonData
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let (data, response) = try await URLSession.shared.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            return false
        }

        // Decode server response
        let validationResponse = try JSONDecoder().decode(
            ValidationResponse.self,
            from: data
        )

        return validationResponse.isValid
    }
}

struct ValidationResponse: Codable {
    let isValid: Bool
    let subscriptionStatus: String?
}

jwsRepresentation มีลายเซ็นเข้ารหัสที่เซิร์ฟเวอร์สามารถตรวจสอบได้ ฟิลด์ appTransactionID ใหม่ช่วยให้ติดตามลูกค้าได้อย่างแม่นยำโดยไม่ต้องมี round-trip เซิร์ฟเวอร์เพิ่มเติม

App Store Server API

App Store Server API ของ Apple ให้การตรวจสอบฝั่งเซิร์ฟเวอร์ด้วย token JWS, Server Notifications V2 สำหรับเหตุการณ์การสมัครสมาชิกแบบเรียลไทม์ และ SubscriptionStatus API ใหม่ที่รับ Transaction IDs (iOS 18.4+) สำหรับการ query สถานะโดยตรง

การกำหนดค่าการแจ้งเตือนเซิร์ฟเวอร์

Server Notifications V2 ช่วยให้รับเหตุการณ์แบบเรียลไทม์ได้

ServerNotificationTypes.swiftswift
enum NotificationType: String, Codable {
    case subscribed = "SUBSCRIBED"
    case didRenew = "DID_RENEW"
    case didFailToRenew = "DID_FAIL_TO_RENEW"
    case didChangeRenewalStatus = "DID_CHANGE_RENEWAL_STATUS"
    case expired = "EXPIRED"
    case gracePeriodExpired = "GRACE_PERIOD_EXPIRED"
    case offerRedeemed = "OFFER_REDEEMED"
    case priceIncrease = "PRICE_INCREASE"
    case refund = "REFUND"
    case renewalExtended = "RENEWAL_EXTENDED"
    case revoke = "REVOKE"
}

struct ServerNotification: Codable {
    let notificationType: NotificationType
    let subtype: String?
    let data: NotificationData

    struct NotificationData: Codable {
        let bundleId: String
        let environment: String
        let signedTransactionInfo: String
        let signedRenewalInfo: String?
    }
}

การแจ้งเตือนเหล่านี้ช่วยให้รักษาสถานะการสมัครสมาชิกซิงโครไนซ์กับ backend ได้

การจัดการข้อผิดพลาดและกรณีขอบ

การสัมภาษณ์มักทดสอบความเข้าใจเกี่ยวกับสถานการณ์ข้อผิดพลาดและการจัดการที่เหมาะสม สำหรับข้อมูลเพิ่มเติมเกี่ยวกับรูปแบบสถาปัตยกรรม iOS ดู คู่มือสัมภาษณ์ iOS senior เรื่อง design patterns

ข้อผิดพลาดทั่วไปใน StoreKit 2

PurchaseErrorHandler.swiftswift
import StoreKit

struct PurchaseErrorHandler {

    enum UserFacingError {
        case networkError
        case paymentFailed
        case notAuthorized
        case productNotAvailable
        case unknown

        var message: String {
            switch self {
            case .networkError:
                return "Connection failed. Please check your internet connection."
            case .paymentFailed:
                return "Payment failed. Please verify your payment information."
            case .notAuthorized:
                return "In-app purchases are disabled on this device."
            case .productNotAvailable:
                return "This product is not currently available."
            case .unknown:
                return "An unexpected error occurred."
            }
        }
    }

    static func handle(_ error: Error) -> UserFacingError {
        // Check if it's a StoreKit error
        if let storeKitError = error as? StoreKitError {
            switch storeKitError {
            case .networkError:
                return .networkError
            case .userCancelled:
                // Don't show error, user cancelled
                return .unknown
            case .notAvailableInStorefront:
                return .productNotAvailable
            case .notEntitled:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        // Purchase errors
        if let purchaseError = error as? Product.PurchaseError {
            switch purchaseError {
            case .invalidQuantity:
                return .unknown
            case .productUnavailable:
                return .productNotAvailable
            case .purchaseNotAllowed:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        return .unknown
    }
}

การจัดการข้อผิดพลาดที่ชัดเจนช่วยปรับปรุงประสบการณ์ผู้ใช้และทำให้การดีบักง่ายขึ้น

การสนับสนุนโหมดออฟไลน์

OfflineCapabilityManager.swiftswift
import StoreKit

actor OfflineCapabilityManager {

    private let userDefaults = UserDefaults.standard
    private let entitlementsKey = "cached_entitlements"

    func cacheCurrentEntitlements() async {
        var entitlements: [String: Date] = [:]

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                entitlements[transaction.productID] = transaction.expirationDate
            }
        }

        // Save locally
        if let data = try? JSONEncoder().encode(entitlements) {
            userDefaults.set(data, forKey: entitlementsKey)
        }
    }

    func getCachedEntitlements() -> [String: Date] {
        guard let data = userDefaults.data(forKey: entitlementsKey),
              let entitlements = try? JSONDecoder().decode(
                  [String: Date].self,
                  from: data
              ) else {
            return [:]
        }

        // Filter expired entitlements
        let now = Date()
        return entitlements.filter { _, expiration in
            expiration > now
        }
    }

    func hasValidEntitlement(for productID: String) -> Bool {
        let cached = getCachedEntitlements()
        return cached[productID] != nil
    }
}

การแคชในเครื่องช่วยรักษาการเข้าถึงฟีเจอร์พรีเมียมแม้ไม่มีการเชื่อมต่อ

แนวทางปฏิบัติที่ดีที่สุดสำหรับการผลิต

สถาปัตยกรรมที่แนะนำ

SubscriptionService.swiftswift
import StoreKit
import Combine

@MainActor
final class SubscriptionService: ObservableObject {

    static let shared = SubscriptionService()

    @Published private(set) var products: [Product] = []
    @Published private(set) var purchasedProductIDs: Set<String> = []
    @Published private(set) var subscriptionStatus: SubscriptionStatus = .none

    private var transactionObserver: Task<Void, Never>?

    enum SubscriptionStatus {
        case none
        case active(expirationDate: Date)
        case expired
        case inGracePeriod
    }

    private init() {
        // Start transaction observation
        startTransactionObserver()

        // Load initial state
        Task {
            await loadProducts()
            await updateSubscriptionStatus()
        }
    }

    private func startTransactionObserver() {
        transactionObserver = Task.detached(priority: .background) {
            for await result in Transaction.updates {
                if case .verified(let transaction) = result {
                    await transaction.finish()
                    await self.updateSubscriptionStatus()
                }
            }
        }
    }

    func loadProducts() async {
        do {
            let productIDs = [
                "com.app.subscription.monthly",
                "com.app.subscription.yearly"
            ]
            products = try await Product.products(for: productIDs)
        } catch {
            print("Failed to load products: (error)")
        }
    }

    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            if case .verified(let transaction) = verification {
                await transaction.finish()
                await updateSubscriptionStatus()
            }
        case .userCancelled:
            break
        case .pending:
            break
        @unknown default:
            break
        }
    }

    func updateSubscriptionStatus() async {
        var purchasedIDs: Set<String> = []
        var latestSubscription: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                purchasedIDs.insert(transaction.productID)

                if transaction.productType == .autoRenewable {
                    if latestSubscription == nil ||
                       transaction.purchaseDate > latestSubscription!.purchaseDate {
                        latestSubscription = transaction
                    }
                }
            }
        }

        purchasedProductIDs = purchasedIDs

        if let subscription = latestSubscription,
           let expiration = subscription.expirationDate {
            if expiration > Date() {
                subscriptionStatus = .active(expirationDate: expiration)
            } else {
                subscriptionStatus = .expired
            }
        } else {
            subscriptionStatus = .none
        }
    }

    deinit {
        transactionObserver?.cancel()
    }
}

สถาปัตยกรรมแบบรวมศูนย์นี้ทำให้การจัดการสถานะการซื้อทั่วทั้งแอปพลิเคชันง่ายขึ้น

แหล่งข้อมูล

ประเด็นสำคัญสำหรับการสัมภาษณ์ StoreKit 2

StoreKit 2 เปลี่ยนแปลงการจัดการการซื้อในแอปให้เป็นประสบการณ์การพัฒนาที่ทันสมัยและปลอดภัย ประเด็นสำคัญที่ควรจดจำสำหรับการสัมภาษณ์:

  • API async/await ดั้งเดิมขจัด delegate และ callback ที่ซับซ้อนจาก StoreKit 1 ซึ่งถูก deprecated ตั้งแต่ iOS 18
  • การตรวจสอบอัตโนมัติผ่าน VerificationResult จัดการการตรวจสอบเข้ารหัสของธุรกรรม
  • Transaction.updates ให้สตรีมอะซิงโครนัสสำหรับธุรกรรมเบื้องหลังรวมถึงการต่ออายุและ family sharing
  • Transaction.currentEntitlements(for:) แทนที่ currentEntitlement(for:) ที่ deprecated สำหรับการจัดการหลาย entitlement
  • ฟิลด์ appTransactionID (iOS 18.4+) ช่วยให้ติดตามลูกค้าโดยไม่ต้องเรียก server-to-server
  • SubscriptionOfferView ให้ UI ในตัวสำหรับ merchandising การสมัครสมาชิกพร้อม visibleRelationship สำหรับตัวเลือก upgrade, downgrade และ crossgrade
  • ข้อเสนอโปรโมชันสามารถเซ็นได้โดยใช้ JWS กับ PromotionOfferV2SignatureCreator
  • Offer codes ตอนนี้รองรับ consumables, non-consumables และการสมัครสมาชิก non-renewing (iOS 16.3+)
  • การตรวจสอบเซิร์ฟเวอร์ใช้ App Store Server API พร้อม token JWS แทนที่ endpoint verifyReceipt ที่ deprecated
  • เรียก transaction.finish() เสมอหลังจากส่งมอบเนื้อหาเพื่อป้องกันพฤติกรรมการกู้คืนที่ไม่คาดคิด
  • ใช้การแคช entitlement ออฟไลน์เพื่อประสบการณ์ผู้ใช้ที่ราบรื่นโดยไม่ต้องเชื่อมต่อ

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

ชาเลนจ์ประจำวัน

คุณหาบั๊กใน iOS เจอไหม

โค้ดจริงหนึ่งชิ้น บั๊กที่ซ่อนอยู่หนึ่งจุด วันละหนึ่งครั้ง ลองได้โดยไม่ต้องมีบัญชี

Anthony Fillion-Maillet

เขียนโดย

Anthony Fillion-Maillet

ผู้ก่อตั้ง SharpSkill

เป็นนักพัฒนาฟูลสแตกมากว่า 10 ปี ดูแล SharpSkill และรับผิดชอบทุกสิ่งที่เผยแพร่ที่นี่

อัปเดตเมื่อ 20 ตุลาคม 2569

แท็ก

#ios
#storekit
#in-app-purchase
#swift
#interview

แชร์

บทความที่เกี่ยวข้อง