Wawancara StoreKit 2: Manajemen Langganan dan Validasi Tanda Terima

Kuasai pertanyaan wawancara iOS tentang StoreKit 2, manajemen langganan, validasi tanda terima, dan implementasi pembelian dalam aplikasi dengan contoh kode Swift praktis.

Arsitektur langganan iOS StoreKit 2 dan validasi tanda terima

StoreKit 2 mewakili pergeseran fundamental dalam cara pembelian dalam aplikasi ditangani di iOS. Diperkenalkan dengan iOS 15, framework modern ini secara dramatis menyederhanakan kode yang diperlukan untuk manajemen langganan dan validasi transaksi. Wawancara teknis iOS secara teratur membahas topik ini untuk mengevaluasi keahlian developer dalam monetisasi aplikasi.

Wawasan Wawancara Utama

StoreKit 2 memanfaatkan API Swift native dengan async/await, menghilangkan kebutuhan akan callback yang kompleks dan validasi tanda terima sisi klien. Pewawancara menghargai kandidat yang dapat mengartikulasikan perbedaan fundamental dari StoreKit 1.

Tinjauan Arsitektur StoreKit 2

StoreKit 2 dibangun di atas arsitektur modern yang sepenuhnya memanfaatkan Swift Concurrency. Tidak seperti StoreKit 1, semua operasi bersifat asinkron dan menggunakan tipe Swift native.

Mengambil Produk yang Tersedia

Langkah pertama melibatkan pemuatan produk yang dikonfigurasi di App Store Connect. StoreKit 2 menyederhanakan operasi ini dengan API deklaratif.

ProductService.swiftswift
import StoreKit

actor ProductService {

    // Identifier produk yang dikonfigurasi di App Store Connect
    private let productIdentifiers: Set<String> = [
        "com.app.subscription.monthly",
        "com.app.subscription.yearly",
        "com.app.premium.lifetime"
    ]

    // Cache produk untuk menghindari panggilan jaringan berulang
    private var cachedProducts: [Product] = []

    func loadProducts() async throws -> [Product] {
        // Mengembalikan cache jika tersedia
        guard cachedProducts.isEmpty else {
            return cachedProducts
        }

        // Permintaan asinkron ke 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 }
    }
}

Menggunakan actor memastikan keamanan thread saat mengelola cache produk.

Tipe Produk StoreKit 2

StoreKit 2 dengan jelas membedakan antara berbagai tipe produk. Memahami perbedaan ini sangat penting untuk wawancara.

ProductType+Extensions.swiftswift
import StoreKit

extension Product.ProductType {

    var displayName: String {
        switch self {
        case .consumable:
            // Produk yang dapat dikonsumsi (kredit, nyawa, dll.)
            return "Consumable"
        case .nonConsumable:
            // Pembelian permanen (membuka fitur)
            return "Non-Consumable"
        case .autoRenewable:
            // Langganan dengan perpanjangan otomatis
            return "Auto-Renewable Subscription"
        case .nonRenewable:
            // Langganan tanpa perpanjangan (pass sementara)
            return "Non-Renewable Subscription"
        @unknown default:
            return "Unknown"
        }
    }
}

Langganan perpanjangan otomatis mewakili model bisnis dominan untuk aplikasi iOS modern.

Menangani Transaksi Pembelian

Proses pembelian di StoreKit 2 menjadi linier berkat async/await. Tidak ada lagi delegate dan callback bersarang dari StoreKit 1.

Memulai Pembelian

PurchaseManager.swiftswift
import StoreKit

actor PurchaseManager {

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

    func purchase(_ product: Product) async throws -> Transaction {
        // Memulai pembelian dengan modal sistem
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            // Memverifikasi tanda tangan transaksi
            let transaction = try checkVerification(verification)

            // Menandai transaksi sebagai selesai
            await transaction.finish()

            return transaction

        case .userCancelled:
            // Pengguna membatalkan modal pembelian
            throw PurchaseError.purchaseCancelled

        case .pending:
            // Pembelian tertunda (persetujuan orang tua, dll.)
            throw PurchaseError.purchasePending

        @unknown default:
            throw PurchaseError.unknown
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            // Transaksi ditandatangani dan diverifikasi oleh App Store
            return safe
        case .unverified(_, let error):
            // Tanda tangan tidak valid atau rusak
            throw PurchaseError.verificationFailed
        }
    }
}

Metode finish() sangat penting: memberi sinyal kepada App Store bahwa konten telah dikirimkan kepada pengguna.

Peringatan Wawancara

Jangan pernah lupa memanggil transaction.finish() setelah mengirimkan konten. Transaksi yang belum selesai akan dipulihkan pada peluncuran aplikasi berikutnya, menyebabkan perilaku yang tidak terduga.

Mendengarkan Transaksi Latar Belakang

StoreKit 2 memperkenalkan Transaction.updates, stream asinkron yang memancarkan transaksi yang terjadi di luar aplikasi (perpanjangan, pembelian family sharing, dll.).

TransactionObserver.swiftswift
import StoreKit

actor TransactionObserver {

    private var updateTask: Task<Void, Never>?

    func startObserving() {
        // Membatalkan pengamatan sebelumnya
        updateTask?.cancel()

        updateTask = Task(priority: .background) {
            // Stream tak terbatas dari pembaruan transaksi
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerification(result)

                    // Memproses transaksi berdasarkan tipenya
                    await self.handleTransaction(transaction)

                    // Selalu menyelesaikan transaksi
                    await transaction.finish()
                } catch {
                    // Mencatat kesalahan untuk debugging
                    print("Transaction verification failed: \(error)")
                }
            }
        }
    }

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

    private func handleTransaction(_ transaction: Transaction) async {
        switch transaction.productType {
        case .autoRenewable:
            // Memperbarui status langganan
            await updateSubscriptionStatus(transaction)
        case .nonConsumable:
            // Membuka fitur premium
            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 {
        // Implementasi pembaruan status
    }

    private func unlockFeature(_ productID: String) async {
        // Implementasi pembukaan fitur
    }

    enum PurchaseError: Error {
        case verificationFailed
    }
}

Pengamatan transaksi harus dimulai segera setelah aplikasi diluncurkan untuk menghindari kehilangan pembaruan.

Pertanyaan Wawancara Umum tentang Langganan StoreKit 2

Wawancara iOS sering kali mencakup pertanyaan spesifik tentang manajemen langganan. Berikut adalah yang paling umum dengan jawaban terperinci.

Pertanyaan 1: Bagaimana Cara Memeriksa Status Langganan Saat Ini?

StoreKit 2 menyediakan akses langsung ke entitlement aktif melalui Transaction.currentEntitlements.

SubscriptionStatusChecker.swiftswift
import StoreKit

struct SubscriptionStatusChecker {

    struct SubscriptionStatus {
        let isActive: Bool
        let expirationDate: Date?
        let willRenew: Bool
        let productID: String?
    }

    static func checkCurrentStatus() async -> SubscriptionStatus {
        // Mengambil semua transaksi aktif
        var latestTransaction: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                // Memfilter hanya langganan
                if transaction.productType == .autoRenewable {
                    // Menyimpan transaksi terbaru
                    if latestTransaction == nil ||
                       transaction.purchaseDate > latestTransaction!.purchaseDate {
                        latestTransaction = transaction
                    }
                }
            }
        }

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

        // Memeriksa apakah langganan masih aktif
        let isActive = transaction.expirationDate ?? Date.distantPast > Date()

        // Mengambil informasi perpanjangan
        let renewalInfo = await getRenewalInfo(for: transaction.productID)

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

    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
        }

        // Mengambil status perpanjangan
        let status = try? await subscription.status.first
        return status?.renewalInfo
    }
}

Pendekatan ini memberikan status langganan yang akurat tanpa panggilan server.

Siap menguasai wawancara iOS Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Pertanyaan 2: Bagaimana Cara Menangani Uji Coba Gratis dan Penawaran Promosi?

StoreKit 2 menyederhanakan akses ke informasi penawaran. Pewawancara sering menguji pemahaman tentang berbagai jenis penawaran.

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 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:
            // Periode uji coba gratis
            return .freeTrial(duration: formatPeriod(offer.period))

        case .payAsYouGo:
            // Harga lebih rendah selama beberapa periode
            return .payAsYouGo(
                periods: offer.periodCount,
                price: offer.price
            )

        case .payUpFront:
            // Pembayaran sekali untuk satu periode
            return .payUpFront(
                duration: formatPeriod(offer.period),
                price: offer.price
            )

        @unknown default:
            return .none
        }
    }

    static func checkEligibility(for product: Product) async -> Bool {
        // Memeriksa apakah pengguna dapat memanfaatkan penawaran
        guard let subscription = product.subscription else {
            return false
        }

        // isEligibleForIntroOffer menunjukkan apakah ini pelanggan baru
        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 ""
        }
    }
}

Metode isEligibleForIntroOffer sangat penting untuk menghindari menampilkan penawaran yang tidak tersedia.

Pertanyaan 3: Bagaimana Cara Memulihkan Pembelian Sebelumnya?

Pemulihan pembelian adalah fitur wajib menurut pedoman App Store.

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] = []

        // Sinkronisasi dengan App Store
        try await AppStore.sync()

        // Iterasi melalui semua transaksi pengguna
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            switch transaction.productType {
            case .nonConsumable:
                // Memulihkan pembelian permanen
                restoredProducts.append(transaction.productID)

            case .autoRenewable:
                // Memeriksa apakah langganan aktif
                if let expiration = transaction.expirationDate,
                   expiration > Date() {
                    activeSubscriptions.append(transaction.productID)
                }

            default:
                // Consumable tidak dapat dipulihkan
                break
            }
        }

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

AppStore.sync() memaksa sinkronisasi dengan server Apple, berguna setelah penginstalan ulang.

Validasi Tanda Terima Sisi Server

Validasi sisi server tetap direkomendasikan untuk aplikasi dengan langganan kritis. StoreKit 2 memperkenalkan JWS (JSON Web Signatures) untuk validasi modern.

Mengekstrak Data Transaksi untuk Server

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
}

actor ServerValidationService {

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

    func validateWithServer(_ transaction: Transaction) async throws -> Bool {
        // Membuat payload untuk server
        let payload = TransactionPayload(
            transactionID: String(transaction.id),
            originalTransactionID: String(transaction.originalID),
            productID: transaction.productID,
            purchaseDate: transaction.purchaseDate,
            expirationDate: transaction.expirationDate,
            jwsRepresentation: transaction.jwsRepresentation ?? ""
        )

        // Mengkodekan ke JSON
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        let jsonData = try encoder.encode(payload)

        // Mengirim ke 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
        }

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

        return validationResponse.isValid
    }
}

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

jwsRepresentation berisi tanda tangan kriptografi yang dapat diverifikasi oleh server.

App Store Server API

Apple sekarang menyediakan App Store Server API untuk validasi sisi server. API modern ini menggantikan endpoint verifyReceipt yang sudah usang dan menawarkan fitur-fitur canggih seperti notifikasi server-ke-server.

Mengonfigurasi Notifikasi Server

Server Notifications V2 memungkinkan penerimaan event secara real-time.

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?
    }
}

Notifikasi ini memungkinkan menjaga status langganan tetap tersinkronisasi dengan backend.

Penanganan Kesalahan dan Kasus Tepi

Wawancara sering menguji pemahaman tentang skenario kesalahan dan penanganannya yang tepat.

Kesalahan Umum 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 {
        // Memeriksa apakah ini kesalahan StoreKit
        if let storeKitError = error as? StoreKitError {
            switch storeKitError {
            case .networkError:
                return .networkError
            case .userCancelled:
                // Tidak menampilkan kesalahan, pengguna membatalkan
                return .unknown
            case .notAvailableInStorefront:
                return .productNotAvailable
            case .notEntitled:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        // Kesalahan pembelian
        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
    }
}

Penanganan kesalahan yang jelas meningkatkan pengalaman pengguna dan menyederhanakan debugging.

Dukungan Mode Offline

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
            }
        }

        // Menyimpan secara lokal
        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 [:]
        }

        // Memfilter entitlement yang kedaluwarsa
        let now = Date()
        return entitlements.filter { _, expiration in
            expiration > now
        }
    }

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

Caching lokal mempertahankan akses ke fitur premium bahkan tanpa konektivitas.

Praktik Terbaik Produksi

Arsitektur yang Direkomendasikan

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() {
        // Memulai pengamatan transaksi
        startTransactionObserver()

        // Memuat status awal
        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()
    }
}

Arsitektur terpusat ini menyederhanakan manajemen status pembelian di seluruh aplikasi.

Kesimpulan

StoreKit 2 mengubah manajemen pembelian dalam aplikasi menjadi pengalaman pengembangan yang modern dan aman. Poin-poin penting untuk diingat dalam wawancara:

✅ API native async/await: tidak ada lagi delegate dan callback kompleks dari StoreKit 1

✅ Verifikasi otomatis: VerificationResult menangani validasi kriptografi transaksi

Transaction.updates: stream asinkron untuk transaksi latar belakang

Transaction.currentEntitlements: akses langsung ke entitlement aktif pengguna

✅ Penawaran promosi: isEligibleForIntroOffer untuk memeriksa kelayakan

✅ Validasi server: JWS dan App Store Server API untuk keamanan yang ditingkatkan

transaction.finish(): wajib setelah pengiriman konten

✅ Penanganan offline: caching entitlement lokal untuk pengalaman yang lancar

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Tag

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

Bagikan

Artikel terkait