Співбесіда StoreKit 2: Управління Підписками та Валідація Чеків
Опануйте питання співбесіди iOS щодо StoreKit 2, управління підписками, валідації чеків та реалізації покупок у застосунку з практичними прикладами коду на Swift.

StoreKit 2 представляє фундаментальну зміну в тому, як обробляються покупки у застосунку на iOS. Представлений з iOS 15, цей сучасний фреймворк значно спрощує код, необхідний для управління підписками та валідації транзакцій. З iOS 18 StoreKit 1 тепер офіційно застарілий, і StoreKit 2 є єдиним підтримуваним шляхом вперед. Технічні співбесіди iOS регулярно охоплюють цю тему для оцінки досвіду розробників у монетизації застосунків.
StoreKit 2 використовує нативний API Swift з async/await, усуваючи необхідність у складних зворотних викликах та валідації чеків на стороні клієнта. Інтерв'юери цінують кандидатів, які можуть сформулювати фундаментальні відмінності від StoreKit 1 та нові функції iOS 18.4, такі як appTransactionID та SubscriptionOfferView.
Огляд Архітектури StoreKit 2
StoreKit 2 побудований на сучасній архітектурі, яка повністю використовує Swift Concurrency. На відміну від StoreKit 1, всі операції є асинхронними та використовують нативні типи Swift.
Отримання Доступних Продуктів
Перший крок включає завантаження продуктів, налаштованих в App Store Connect. StoreKit 2 спрощує цю операцію за допомогою декларативного API.
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 чітко розрізняє різні типи продуктів. Розуміння цієї відмінності є важливим для співбесід.
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. Більше немає делегатів і вкладених зворотних викликів зі StoreKit 1.
Ініціювання Купівлі
Починаючи з iOS 18.2, покупки вимагають параметра контексту UI. Ця зміна забезпечує правильне відображення аркуша покупки в ієрархії представлень застосунку.
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 тощо).
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 надає прямий доступ до активних прав. Зверніть увагу, що Transaction.currentEntitlement(for:) є застарілим з iOS 18.4. Новий currentEntitlements(for:) повертає асинхронну послідовність для обробки кількох прав (наприклад, підписка плюс доступ Family Sharing).
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, дозволяє відстежувати клієнтів між транзакціями без викликів сервер-сервер.
Готовий до співбесід з iOS?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Питання 2: Як Обробляти Безкоштовні Пробні Версії та Промо-Пропозиції?
StoreKit 2 спрощує доступ до інформації про пропозиції. Інтерв'юери часто перевіряють розуміння різних типів пропозицій. Починаючи з iOS 18.4, промо-пропозиції можна підписувати за допомогою JWS з новим PromotionOfferV2SignatureCreator, доступним у App Store Server Library.
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 є важливим, щоб уникнути відображення недоступних пропозицій. Зверніть увагу, що коди пропозицій, раніше обмежені підписками з автоматичним поновленням, тепер підтримують витратні, невитратні продукти та підписки без поновлення з iOS 16.3.
Питання 3: Як Відновити Попередні Покупки?
Відновлення покупок є обов'язковою функцією згідно з App Store Review Guidelines.
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: Сучасна Презентація Підписок
iOS 18.4 представляє SubscriptionOfferView, компонент SwiftUI, який відображає опції підписки з автоматичним завантаженням метаданих продуктів. Це поширена тема співбесід для розробників, які створюють застосунки на основі підписок.
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 контролює, які рівні підписки відображаються: .upgrade показує плани на один рівень вище, .downgrade показує нижчі рівні, .crossgrade показує еквівалентні рівні з кращою цінністю, а .all відображає кожен план у групі.
Серверна Валідація Чеків
Серверна валідація залишається рекомендованою для застосунків з критичними підписками. StoreKit 2 використовує JWS (JSON Web Signatures) для сучасної валідації. Застарілий ендпоінт verifyReceipt було замінено на App Store Server API.
Витяг Даних Транзакції для Сервера
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 дозволяє точне відстеження клієнтів без додаткових викликів сервер-сервер.
App Store Server API від Apple надає серверну валідацію з токенами JWS, Server Notifications V2 для подій підписки в реальному часі та новий SubscriptionStatus API, який приймає Transaction IDs (iOS 18.4+) для прямого запиту статусу.
Налаштування Серверних Сповіщень
Server Notifications V2 дозволяють отримувати події в режимі реального часу.
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?
}
}Ці сповіщення дозволяють підтримувати стан підписки синхронізованим з бекендом.
Обробка Помилок та Граничні Випадки
Співбесіди часто перевіряють розуміння сценаріїв помилок та їх правильної обробки. Більше про архітектурні патерни iOS можна знайти в посібнику для senior iOS по патернах проектування.
Поширені Помилки в StoreKit 2
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
}
}Чітка обробка помилок покращує користувацький досвід та спрощує налагодження.
Підтримка Офлайн-Режиму
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
}
}Локальне кешування зберігає доступ до преміум-функцій навіть без підключення.
Найкращі Практики для Продакшну
Рекомендована Архітектура
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()
}
}Ця централізована архітектура спрощує управління станом покупок у всьому застосунку.
Джерела
- What's new in StoreKit and In-App Purchase, WWDC25 - доповнення API iOS 18.4, включаючи
appTransactionIDтаSubscriptionOfferView - App Store Server API Documentation - серверна валідація з JWS та Server Notifications V2
- StoreKit Documentation - повна довідка API StoreKit 2
Ключові Моменти для Співбесід по StoreKit 2
StoreKit 2 перетворює управління покупками у застосунку на сучасний та безпечний досвід розробки. Ключові моменти, які слід пам'ятати для співбесіди:
- Нативний async/await API усуває складні делегати та зворотні виклики зі StoreKit 1, який тепер застарілий з iOS 18
- Автоматична верифікація через
VerificationResultобробляє криптографічну валідацію транзакцій Transaction.updatesнадає асинхронний потік для фонових транзакцій, включаючи поновлення та family sharingTransaction.currentEntitlements(for:)замінює застарілийcurrentEntitlement(for:)для обробки кількох прав- Поле
appTransactionID(iOS 18.4+) дозволяє відстежувати клієнтів без викликів сервер-сервер SubscriptionOfferViewнадає вбудований UI для презентації підписок зvisibleRelationshipдля опцій upgrade, downgrade та crossgrade- Промо-пропозиції можна підписувати за допомогою JWS з
PromotionOfferV2SignatureCreator - Коди пропозицій тепер підтримують витратні, невитратні продукти та підписки без поновлення (iOS 16.3+)
- Серверна валідація використовує App Store Server API з токенами JWS, замінюючи застарілий ендпоінт
verifyReceipt - Завжди викликайте
transaction.finish()після доставки контенту, щоб запобігти непередбачуваному відновленню - Реалізуйте офлайн-кешування прав для безперебійного користувацького досвіду без підключення
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Чи знайдеш ти помилку в iOS?
Справжній фрагмент коду, прихована помилка, одна спроба на день. Щоб спробувати, акаунт не потрібен.

Автор:
Anthony Fillion-MailletЗасновник SharpSkill
Fullstack-розробник понад 10 років. Керує SharpSkill і відповідає за все, що тут публікується.
Оновлено 20 жовтня 2026 р.
Теги
Поділитися
Пов'язані статті

Swift Testing Framework Співбесіда 2026: Макроси #expect та #require проти XCTest
Опанування нового Swift Testing Framework для iOS-співбесід: макроси #expect і #require, міграція з XCTest, складні шаблони та поширені помилки.

Співбесіда iOS Push Notifications 2026: APNs, токени та troubleshooting
Повний посібник для підготовки до співбесід iOS з тем Push Notifications, APNs, керування токенами та troubleshooting. Поширені запитання з докладними відповідями.

Співбесіда iOS Senior 2026: Питання щодо Архітектури та Шаблонів Проєктування
Підготовка до співбесід iOS senior із ключовими питаннями про MVVM, VIPER, Clean Architecture та шаблони проєктування. Повний посібник із прикладами Swift.