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

StoreKit 2 представляє фундаментальну зміну в тому, як обробляються покупки у застосунку на iOS. Представлений з iOS 15, цей сучасний фреймворк значно спрощує код, необхідний для управління підписками та валідації транзакцій. Технічні співбесіди iOS регулярно охоплюють цю тему для оцінки досвіду розробників у монетизації застосунків.
StoreKit 2 використовує нативний API Swift з async/await, усуваючи необхідність у складних зворотних викликах та валідації чеків на стороні клієнта. Інтерв'юери цінують кандидатів, які можуть сформулювати фундаментальні відмінності від StoreKit 1.
Огляд Архітектури StoreKit 2
StoreKit 2 побудований на сучасній архітектурі, яка повністю використовує Swift Concurrency. На відміну від StoreKit 1, всі операції є асинхронними та використовують нативні типи Swift.
Отримання Доступних Продуктів
Перший крок включає завантаження продуктів, налаштованих в App Store Connect. StoreKit 2 спрощує цю операцію за допомогою декларативного API.
import StoreKit
actor ProductService {
// Ідентифікатори продуктів, налаштованих в App Store Connect
private let productIdentifiers: Set<String> = [
"com.app.subscription.monthly",
"com.app.subscription.yearly",
"com.app.premium.lifetime"
]
// Кеш продуктів для уникнення повторних мережевих викликів
private var cachedProducts: [Product] = []
func loadProducts() async throws -> [Product] {
// Повертає кеш, якщо доступний
guard cachedProducts.isEmpty else {
return cachedProducts
}
// Асинхронний запит до 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:
// Витратні продукти (кредити, життя тощо)
return "Consumable"
case .nonConsumable:
// Постійні покупки (розблокування функцій)
return "Non-Consumable"
case .autoRenewable:
// Підписки з автоматичним поновленням
return "Auto-Renewable Subscription"
case .nonRenewable:
// Підписки без поновлення (тимчасовий пропуск)
return "Non-Renewable Subscription"
@unknown default:
return "Unknown"
}
}
}Підписки з автоматичним поновленням представляють домінуючу бізнес-модель для сучасних iOS-застосунків.
Обробка Транзакцій Купівлі
Процес купівлі в StoreKit 2 стає лінійним завдяки async/await. Більше немає делегатів і вкладених зворотних викликів зі StoreKit 1.
Ініціювання Купівлі
import StoreKit
actor PurchaseManager {
enum PurchaseError: Error {
case productNotFound
case purchaseCancelled
case purchasePending
case verificationFailed
case unknown
}
func purchase(_ product: Product) async throws -> Transaction {
// Ініціює купівлю з системним модальним вікном
let result = try await product.purchase()
switch result {
case .success(let verification):
// Перевіряє підпис транзакції
let transaction = try checkVerification(verification)
// Позначає транзакцію як завершену
await transaction.finish()
return transaction
case .userCancelled:
// Користувач скасував модальне вікно купівлі
throw PurchaseError.purchaseCancelled
case .pending:
// Купівля очікує (батьківське схвалення тощо)
throw PurchaseError.purchasePending
@unknown default:
throw PurchaseError.unknown
}
}
private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let safe):
// Транзакція підписана та перевірена App Store
return safe
case .unverified(_, let error):
// Недійсний або пошкоджений підпис
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() {
// Скасовує будь-яке попереднє спостереження
updateTask?.cancel()
updateTask = Task(priority: .background) {
// Нескінченний потік оновлень транзакцій
for await result in Transaction.updates {
do {
let transaction = try self.checkVerification(result)
// Обробляє транзакцію за її типом
await self.handleTransaction(transaction)
// Завжди завершує транзакцію
await transaction.finish()
} catch {
// Реєструє помилку для налагодження
print("Transaction verification failed: \(error)")
}
}
}
}
func stopObserving() {
updateTask?.cancel()
updateTask = nil
}
private func handleTransaction(_ transaction: Transaction) async {
switch transaction.productType {
case .autoRenewable:
// Оновлює статус підписки
await updateSubscriptionStatus(transaction)
case .nonConsumable:
// Розблоковує преміум-функцію
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 {
// Реалізація оновлення статусу
}
private func unlockFeature(_ productID: String) async {
// Реалізація розблокування функцій
}
enum PurchaseError: Error {
case verificationFailed
}
}Спостереження за транзакціями має розпочинатися щойно застосунок запускається, щоб не пропустити жодного оновлення.
Поширені Питання Співбесіди про Підписки StoreKit 2
Співбесіди iOS часто включають конкретні питання про управління підписками. Ось найпоширеніші з докладними відповідями.
Питання 1: Як Перевірити Поточний Статус Підписки?
StoreKit 2 надає прямий доступ до активних прав через Transaction.currentEntitlements.
import StoreKit
struct SubscriptionStatusChecker {
struct SubscriptionStatus {
let isActive: Bool
let expirationDate: Date?
let willRenew: Bool
let productID: String?
}
static func checkCurrentStatus() async -> SubscriptionStatus {
// Отримує всі активні транзакції
var latestTransaction: Transaction?
for await result in Transaction.currentEntitlements {
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
)
}
// Перевіряє, чи підписка все ще активна
let isActive = transaction.expirationDate ?? Date.distantPast > Date()
// Отримує інформацію про поновлення
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
}
// Отримує статус поновлення
let status = try? await subscription.status.first
return status?.renewalInfo
}
}Цей підхід надає точний стан підписки без серверних викликів.
Готовий до співбесід з iOS?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Питання 2: Як Обробляти Безкоштовні Пробні Версії та Промо-Пропозиції?
StoreKit 2 спрощує доступ до інформації про пропозиції. Інтерв'юери часто перевіряють розуміння різних типів пропозицій.
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:
// Безкоштовний пробний період
return .freeTrial(duration: formatPeriod(offer.period))
case .payAsYouGo:
// Знижена ціна на кілька періодів
return .payAsYouGo(
periods: offer.periodCount,
price: offer.price
)
case .payUpFront:
// Одноразовий платіж за період
return .payUpFront(
duration: formatPeriod(offer.period),
price: offer.price
)
@unknown default:
return .none
}
}
static func checkEligibility(for product: Product) async -> Bool {
// Перевіряє, чи може користувач скористатися пропозицією
guard let subscription = product.subscription else {
return false
}
// isEligibleForIntroOffer вказує, чи це новий передплатник
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 є важливим, щоб уникнути відображення недоступних пропозицій.
Питання 3: Як Відновити Попередні Покупки?
Відновлення покупок є обов'язковою функцією згідно з рекомендаціями App Store.
import StoreKit
actor RestoreManager {
struct RestoreResult {
let restoredProducts: [String]
let activeSubscriptions: [String]
}
func restorePurchases() async throws -> RestoreResult {
var restoredProducts: [String] = []
var activeSubscriptions: [String] = []
// Синхронізує з App Store
try await AppStore.sync()
// Ітерує по всіх транзакціях користувача
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
switch transaction.productType {
case .nonConsumable:
// Відновлює постійні покупки
restoredProducts.append(transaction.productID)
case .autoRenewable:
// Перевіряє, чи активна підписка
if let expiration = transaction.expirationDate,
expiration > Date() {
activeSubscriptions.append(transaction.productID)
}
default:
// Витратні не можна відновити
break
}
}
return RestoreResult(
restoredProducts: restoredProducts,
activeSubscriptions: activeSubscriptions
)
}
}AppStore.sync() примусово синхронізує з серверами Apple, корисно після перевстановлення.
Серверна Валідація Чеків
Серверна валідація залишається рекомендованою для застосунків з критичними підписками. StoreKit 2 представляє JWS (JSON Web Signatures) для сучасної валідації.
Витяг Даних Транзакції для Сервера
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 {
// Створює корисне навантаження для сервера
let payload = TransactionPayload(
transactionID: String(transaction.id),
originalTransactionID: String(transaction.originalID),
productID: transaction.productID,
purchaseDate: transaction.purchaseDate,
expirationDate: transaction.expirationDate,
jwsRepresentation: transaction.jwsRepresentation ?? ""
)
// Кодує в JSON
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let jsonData = try encoder.encode(payload)
// Надсилає на сервер
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
}
// Декодує відповідь сервера
let validationResponse = try JSONDecoder().decode(
ValidationResponse.self,
from: data
)
return validationResponse.isValid
}
}
struct ValidationResponse: Codable {
let isValid: Bool
let subscriptionStatus: String?
}jwsRepresentation містить криптографічний підпис, який може бути перевірений сервером.
Apple тепер надає App Store Server API для серверної валідації. Цей сучасний API замінює застарілий ендпоінт verifyReceipt і пропонує розширені функції, такі як сповіщення сервер-до-сервера.
Налаштування Серверних Сповіщень
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?
}
}Ці сповіщення дозволяють підтримувати стан підписки синхронізованим з бекендом.
Обробка Помилок та Граничні Випадки
Співбесіди часто перевіряють розуміння сценаріїв помилок та їх правильної обробки.
Поширені Помилки в 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 {
// Перевіряє, чи це помилка StoreKit
if let storeKitError = error as? StoreKitError {
switch storeKitError {
case .networkError:
return .networkError
case .userCancelled:
// Не показувати помилку, користувач скасував
return .unknown
case .notAvailableInStorefront:
return .productNotAvailable
case .notEntitled:
return .notAuthorized
default:
return .unknown
}
}
// Помилки купівлі
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
}
}
// Зберігає локально
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 [:]
}
// Фільтрує застарілі права
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() {
// Запустити спостереження за транзакціями
startTransactionObserver()
// Завантажити початковий стан
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 перетворює управління покупками у застосунку на сучасний та безпечний досвід розробки. Ключові моменти, які слід пам'ятати для співбесіди:
✅ Нативний async/await API: більше немає складних делегатів і зворотних викликів зі StoreKit 1
✅ Автоматична верифікація: VerificationResult обробляє криптографічну валідацію транзакцій
✅ Transaction.updates: асинхронний потік для фонових транзакцій
✅ Transaction.currentEntitlements: прямий доступ до активних прав користувача
✅ Промо-пропозиції: isEligibleForIntroOffer для перевірки придатності
✅ Серверна валідація: JWS та App Store Server API для підвищеної безпеки
✅ transaction.finish(): обов'язково після доставки контенту
✅ Офлайн-обробка: локальне кешування прав для безперебійного досвіду
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Теги
Поділитися
Пов'язані статті

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. Поширені запитання з докладними відповідями.

Топ 25 запитань зі Swift на співбесідах для iOS-розробників
Підготовка до технічних співбесід з iOS: 25 найпоширеніших запитань зі Swift — optionals, closures, ARC, протоколи, async/await та просунуті патерни з прикладами коду.