Обновено: 25 юни 2026 г.
StoreKit 2 е модерният, базиран на Swift Concurrency API на Apple за покупки в приложението (in-app purchases) и абонаменти. Той заменя стария Objective-C ориентиран StoreKit от ерата на iOS 3. В iOS 26 рамката получи няколко важни допълнения: Win-Back Offers, по-добро интегриране със Server-Side Notifications V2 и подобрена верификация през JWS подписи. Това ръководство показва как да внедрите продукти, да валидирате транзакции, да обработвате абонаменти и да тествате всичко локално, без да напускате Xcode.
Какво е StoreKit 2 и какво ново има в iOS 26?
StoreKit 2 е третото поколение на API-то за монетизация на Apple, обявено на WWDC 2021 и достъпно от iOS 15 нататък. За разлика от оригиналния StoreKit, който се базираше на SKPaymentQueue, делегати и notification центрове наследени още от Objective-C епохата, StoreKit 2 е написан изцяло на Swift и използва async/await, AsyncSequence и силно типизирани value types вместо NSDictionary базирани отговори.
Честно казано, разликата в developer experience е драматична. Първото приложение, в което мигрирах от StoreKit 1, се сви от около 600 реда delegate boilerplate до към 180 реда четим Swift. И никога не съм мечтал да се връщам обратно.
В iOS 26 рамката получи няколко допълнения. Най-важното е стабилизирането на Win-Back Offers (въведени в iOS 18), които позволяват на разработчиците да предлагат специални условия на потребители, отписали се от абонамент. Има и подобрена поддръжка за Promotional Offers с подписи, генерирани от App Store Server API, и нов Transaction.updates канал, който агресивно се рестартира при backgrounding на приложението.
Според официалната Apple Developer документация за StoreKit, минималният deployment target за основните StoreKit 2 API-та остава iOS 15, но някои нови възможности изискват iOS 17 или iOS 18.
StoreKit 1 срещу StoreKit 2: какви са разликите?
Ако вече сте работили с StoreKit 1, миграцията към StoreKit 2 не е просто toggle. Двата API-та съществуват едновременно и могат да се използват в едно и също приложение. Но за нови проекти няма причина да започвате с класическата рамка. Таблицата по-долу обобщава ключовите различия, които срещам най-често на code review.
| Аспект | StoreKit 1 | StoreKit 2 |
|---|---|---|
| Език и архитектура | Objective-C, delegate базиран | Swift, async/await, AsyncSequence |
| Верификация на транзакции | App Store receipt (PKCS#7), нужен сървър | JWS подписани транзакции, локална проверка |
| Минимална версия | iOS 3+ | iOS 15+ |
| Получаване на продукти | SKProductsRequest + делегат | try await Product.products(for:) |
| Active entitlements | Парсване на receipt | Transaction.currentEntitlements |
| Тип-безопасност | Слаба (стрингови ключове) | Силна (enum-и, struct-и) |
| Refund handling | Само сървърно | Клиентски beginRefundRequest |
Зареждане на продукти с Product API
Първата стъпка във всяка интеграция е извличането на конфигурираните в App Store Connect продукти. В StoreKit 2 това е едно извикване на Product.products(for:), което приема масив от product идентификатори и връща масив от Product структури.
import StoreKit
@MainActor
final class StoreManager: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
private let productIDs: Set<String> = [
"com.example.app.pro_monthly",
"com.example.app.pro_yearly",
"com.example.app.remove_ads"
]
func loadProducts() async {
do {
let storeProducts = try await Product.products(for: productIDs)
self.products = storeProducts.sorted { $0.price < $1.price }
} catch {
print("Грешка при зареждане на продукти: \(error)")
}
}
}
Обърнете внимание, че Product вече съдържа всичко нужно за UI: displayName, description, displayPrice (вече локализирана като стринг) и subscription (опционален обект с информация за период, free trial и introductory offer). Не ви трябва NumberFormatter за цената, защото Apple я форматира за валутата и локала на потребителя.
Ако строите по-сложни модели на състоянието около това, погледнете нашето ръководство за Observation Framework и @Observable в SwiftUI. Модерният заместител на ObservableObject ще ви позволи да премахнете @Published декорациите.
Реализация на покупка и обработка на резултата
Същинската покупка е едно извикване на product.purchase(). Резултатът е enum Product.PurchaseResult с три случая: .success(let verification), .userCancelled и .pending (например при Ask to Buy родителски контрол).
extension StoreManager {
func purchase(_ product: Product) async throws {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await updateEntitlements()
await transaction.finish()
case .userCancelled:
return
case .pending:
// Изчаква външно одобрение (родител, банка)
return
@unknown default:
return
}
}
}
Верификация на транзакции с JWS
Една от най-съществените промени спрямо StoreKit 1 е, че всяка транзакция идва обвита в VerificationResult<Transaction>. Apple подписва JWS токена с App Store ключ, а StoreKit го верифицира локално, без нужда от сървърно извикване. Това спестява латентност и премахва зависимостта от backend за прости приложения.
enum StoreError: Error {
case failedVerification
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw StoreError.failedVerification
case .verified(let safe):
return safe
}
}
За production приложения с реален backend все пак препоръчвам да изпращате jwsRepresentation на сървъра за двойна проверка, защото клиентската верификация може да бъде заобиколена при jailbreak. Подробностите ги има в App Store Server API документацията.
Слушане за външни транзакции
Покупки, направени извън вашия flow (например family sharing, повторни покупки или resolved pending transactions) пристигат през Transaction.updates. Това е AsyncSequence, която трябва да започне да слуша възможно най-рано в жизнения цикъл на приложението.
func observeTransactionUpdates() -> Task<Void, Never> {
Task.detached {
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
await self.updateEntitlements()
await transaction.finish()
} catch {
print("Невалидна транзакция: \(error)")
}
}
}
}
Стартирайте този Task от App.init() или ранен .task модификатор. Ако се чудите как AsyncSequence се вписва в общата картина на конкурентността, прочетете нашето ръководство за Swift Concurrency с async/await и актьори.
Абонаменти, групи и Win-Back Offers
Абонаментите в StoreKit 2 се организират в Subscription Groups. Потребител може да има активен само един продукт от дадена група в даден момент. Това е критично за дизайна на ценовия план: monthly и yearly tier на един и същ продукт винаги отиват в една група.
Получаването на статуса на абонамент става през Product.subscription?.status:
func subscriptionStatus(for product: Product) async -> Product.SubscriptionInfo.Status? {
guard let subscription = product.subscription else { return nil }
let statuses = try? await subscription.status
return statuses?.first
}
Стойността .state на статуса включва .subscribed, .expired, .inBillingRetryPeriod, .inGracePeriod и .revoked. UI логиката трябва да третира grace period и billing retry като активен абонамент, защото потребителят все още има достъп, докато App Store опитва да възстанови плащането.
Win-Back Offers (iOS 18+)
Win-Back Offers са специални промоционални оферти, които App Store предлага автоматично на потребители, отказали се от абонамент. Конфигурират се в App Store Connect и стават достъпни през Product.SubscriptionInfo.WinBackOffer:
if #available(iOS 18.0, *) {
let offers = product.subscription?.winBackOffers ?? []
if let offer = offers.first {
let result = try await product.purchase(options: [.winBackOffer(offer)])
// обработка...
}
}
Тестване на StoreKit 2 в Xcode
Една от най-подценяваните възможности на StoreKit 2 е локалното тестване с .storekit конфигурационен файл. Това премахва нуждата да създавате Sandbox tester акаунти за всеки сценарий и работи дори в Simulator. Честно казано, спестих си часове чакане на Sandbox sync, откакто започнах да го използвам по подразбиране.
- В Xcode: File → New → File → StoreKit Configuration File.
- Дефинирайте продукти и абонаментни групи в редактора.
- В схемата за изпълнение: Edit Scheme → Run → Options → StoreKit Configuration, после изберете файла.
- Стартирайте. Приложението вижда продуктите дори без App Store Connect конфигурация.
За автоматизирани тестове Apple предоставят SKTestSession, която ви позволява да манипулирате транзакции от XCTest или Swift Testing:
import StoreKitTest
import Testing
@Test func purchaseFlow() async throws {
let session = try SKTestSession(configurationFileNamed: "Products")
session.disableDialogs = true
session.clearTransactions()
let manager = StoreManager()
await manager.loadProducts()
let product = try #require(manager.products.first)
try await manager.purchase(product)
#expect(manager.purchasedProductIDs.contains(product.id))
}
За въведение в Swift Testing рамката, която заменя XCTest, вижте нашата миграционна статия за Swift Testing.
App Store Server Notifications V2
За production системи с backend, App Store Server Notifications V2 са незаменими. Apple праща JWS-подписан webhook при всяко събитие: нов абонамент, подновяване, refund, отказ. За разлика от V1, форматът е стабилен и включва пълни Transaction и RenewalInfo payload-и.
Конфигурирането става в App Store Connect (App Information → App Store Server Notifications). Един често пропускан детайл: задайте отделни URL-и за Production и Sandbox, защото и двата ще получават събития от съответната среда. Вижте официалната спецификация за Server Notifications V2 за пълния списък типове събития. За schema validation на JWS payload-ите силно препоръчвам RFC 7515 като референция.
Чести грешки и как да ги избегнете
След няколко години код-ревюта на StoreKit 2 интеграции, ето грешките, които виждам най-често:
- Кеширане на entitlements в UserDefaults. Просто никога. Винаги питайте
Transaction.currentEntitlements, защото той е достоверният източник и работи правилно при family sharing и device switch. - Забравяне на
transaction.finish(). Резултат: безкрайно пристигащи покупки презTransaction.updatesпри всеки старт. - Стартиране на
Transaction.updatesв SwiftUI.onAppear. Твърде късно. Транзакции, пристигнали преди изобразяването на view, се губят. Стартирайте вApp.initили.task. - Пренебрегване на
.pendingрезултата. Ask to Buy транзакциите изискват UI обратна връзка. Иначе потребителят остава с впечатление, че плащането е минало. - Тестване само в Sandbox.
.storekitфайлът е по-бърз и покрива 95% от сценариите без зависимост от Apple infrastructure.
Често задавани въпроси
Каква е разликата между StoreKit 1 и StoreKit 2?
StoreKit 2 е написан на Swift с async/await и силно типизирани структури, докато StoreKit 1 използва Objective-C делегати и слабо типизирани NSDictionary отговори. StoreKit 2 също предоставя локална JWS верификация на транзакции, което премахва нуждата от сървърна валидация за прости приложения.
Мога ли да използвам StoreKit 2 в проект, който поддържа iOS 14?
Не директно. StoreKit 2 изисква минимум iOS 15. Можете обаче да го използвате с #available(iOS 15.0, *) блок и да оставите StoreKit 1 като fallback за по-стари версии.
Как се тества StoreKit 2 без App Store Connect акаунт?
Чрез .storekit конфигурационен файл, създаден в Xcode (File → New → File → StoreKit Configuration File). След като го изберете в Run схемата, приложението вижда дефинираните локално продукти и може да симулира покупки, refund-и и subscription renewals без интернет връзка.
Как се верифицира JWS подпис на транзакция?
StoreKit 2 верифицира JWS подписа автоматично и връща VerificationResult.verified или .unverified. За допълнителна сигурност в production среда изпратете transaction.jwsRepresentation към вашия сървър, който може да направи независима проверка през App Store Server API.
Какво представляват Win-Back Offers в iOS 26?
Win-Back Offers са промоционални оферти, които App Store автоматично предлага на потребители, отписали се от абонамент. Въведени са в iOS 18 и остават напълно поддържани в iOS 26. Конфигурират се в App Store Connect и се достъпват през product.subscription?.winBackOffers.




