欢迎来到本系列教程的第四十一篇。在前四十篇文章中,你已经学习了从Swift基础到Core Data的全方位iOS开发技能。现在,你能够构建出功能完善、数据持久化的专业应用了。但是,如何让应用产生收入?如何实现高级功能解锁?如何设置订阅服务?
StoreKit 2是苹果最新的应用内购买框架,它简化了购买流程,提供了更现代的Swift API。无论是消耗品(游戏金币)、非消耗品(解锁高级功能)、自动续期订阅(会员服务),StoreKit都能帮你实现。
在这一篇中,你将学到:
-
StoreKit 2基础
-
产品配置与获取
-
购买流程
-
交易验证
-
-
产品类型
-
消耗品
-
非消耗品
-
自动续期订阅
-
非续期订阅
-
-
收据验证
-
本地验证
-
服务器验证
-
防作弊策略
-
-
订阅管理
-
订阅状态检查
-
续期管理
-
退订处理
-
-
实战项目:构建带订阅功能的待办应用
一、StoreKit 2基础
1.1 配置应用内购买
步骤1:在App Store Connect中配置产品
-
登录App Store Connect
-
选择你的应用 → 功能 → 应用内购买
-
添加产品(消耗品/非消耗品/订阅)
步骤2:在Xcode中配置
在Signing & Capabilities中添加In-App Purchase能力
1.2 产品管理
swift
import StoreKit
// MARK: - 产品管理器
@MainActor
class StoreManager: ObservableObject {
static let shared = StoreManager()
@Published var products: [Product] = []
@Published var purchasedProductIDs: Set<String> = []
@Published var subscriptionStatus: SubscriptionStatus = .notSubscribed
enum SubscriptionStatus {
case notSubscribed
case subscribed(expiryDate: Date)
case expired
case gracePeriod
}
private let productIDs = [
"com.example.app.premium_monthly",
"com.example.app.premium_yearly",
"com.example.app.coins_100",
"com.example.app.coins_500",
"com.example.app.feature_unlock"
]
private var updateListenerTask: Task<Void, Error>?
init() {
// 监听交易更新
updateListenerTask = listenForTransactions()
Task {
await requestProducts()
await updatePurchasedProducts()
}
}
deinit {
updateListenerTask?.cancel()
}
// 获取产品列表
func requestProducts() async {
do {
let products = try await Product.products(for: productIDs)
await MainActor.run {
self.products = products.sorted { $0.displayName < $1.displayName }
}
} catch {
print("获取产品失败: \(error)")
}
}
// 更新已购产品
func updatePurchasedProducts() async {
var purchased: Set<String> = []
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
if transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
}
}
await MainActor.run {
self.purchasedProductIDs = purchased
}
}
// 监听交易更新
private func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
await self.updatePurchasedProducts()
await transaction.finish()
} catch {
print("交易验证失败: \(error)")
}
}
}
}
// 验证交易
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.failedVerification
case .verified(let safe):
return safe
}
}
// 购买产品
func purchase(_ product: Product) async throws {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await updatePurchasedProducts()
await transaction.finish()
case .userCancelled:
throw StoreError.userCancelled
case .pending:
throw StoreError.pending
default:
break
}
}
// 检查是否已购买
func isPurchased(_ productID: String) -> Bool {
return purchasedProductIDs.contains(productID)
}
// 恢复购买
func restorePurchases() async throws {
try await AppStore.sync()
await updatePurchasedProducts()
}
}
enum StoreError: LocalizedError {
case failedVerification
case userCancelled
case pending
case productNotFound
var errorDescription: String? {
switch self {
case .failedVerification: return "交易验证失败"
case .userCancelled: return "购买已取消"
case .pending: return "交易处理中"
case .productNotFound: return "未找到产品"
}
}
}
二、产品展示与购买
2.1 产品商店视图
swift
import SwiftUI
import StoreKit
// MARK: - 商店视图
struct StoreView: View {
@StateObject private var storeManager = StoreManager.shared
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showingRestoreAlert = false
var body: some View {
NavigationView {
List {
// 订阅产品
if let monthly = storeManager.products.first(where: { $0.id == "com.example.app.premium_monthly" }),
let yearly = storeManager.products.first(where: { $0.id == "com.example.app.premium_yearly" }) {
Section(header: Text("会员订阅"), footer: Text("订阅后解锁所有高级功能")) {
ProductRow(product: monthly, storeManager: storeManager)
ProductRow(product: yearly, storeManager: storeManager)
}
}
// 消耗品
let consumables = storeManager.products.filter {
$0.type == .consumable && $0.id.hasPrefix("com.example.app.coins")
}
if !consumables.isEmpty {
Section(header: Text("金币充值"), footer: Text("金币可用于购买虚拟道具")) {
ForEach(consumables, id: \.id) { product in
ProductRow(product: product, storeManager: storeManager)
}
}
}
// 非消耗品
if let featureUnlock = storeManager.products.first(where: { $0.id == "com.example.app.feature_unlock" }) {
Section(header: Text("永久解锁"), footer: Text("一次性购买,永久使用")) {
ProductRow(product: featureUnlock, storeManager: storeManager)
}
}
}
.navigationTitle("商店")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("恢复购买") {
Task {
await restorePurchases()
}
}
}
}
.overlay {
if isLoading {
ProgressView()
.background(Color.black.opacity(0.3))
}
}
.alert("恢复成功", isPresented: $showingRestoreAlert) {
Button("确定", role: .cancel) { }
} message: {
Text("您的购买已恢复")
}
.alert("错误", isPresented: .constant(errorMessage != nil)) {
Button("确定") { errorMessage = nil }
} message: {
Text(errorMessage ?? "")
}
}
}
private func restorePurchases() async {
isLoading = true
do {
try await storeManager.restorePurchases()
showingRestoreAlert = true
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
// MARK: - 产品行视图
struct ProductRow: View {
let product: Product
@ObservedObject var storeManager: StoreManager
@State private var isPurchasing = false
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(product.displayName)
.font(.headline)
Text(product.description)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if storeManager.isPurchased(product.id) {
Label("已购买", systemImage: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.caption)
} else {
Button {
Task {
await purchase()
}
} label: {
if isPurchasing {
ProgressView()
.controlSize(.small)
} else {
Text(product.displayPrice)
.font(.headline)
.foregroundColor(.blue)
}
}
.disabled(isPurchasing)
}
}
.padding(.vertical, 8)
}
private func purchase() async {
isPurchasing = true
do {
try await storeManager.purchase(product)
} catch {
print("购买失败: \(error)")
}
isPurchasing = false
}
}
三、订阅管理
3.1 订阅状态检查
swift
// MARK: - 订阅管理器扩展
extension StoreManager {
// 检查订阅状态
func checkSubscriptionStatus() async {
var isActive = false
var expiryDate: Date?
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
if transaction.productID == "com.example.app.premium_monthly" ||
transaction.productID == "com.example.app.premium_yearly" {
if let expirationDate = transaction.expirationDate {
if expirationDate > Date() {
isActive = true
expiryDate = expirationDate
}
} else if transaction.revocationDate == nil {
// 非续期订阅或永久购买
isActive = true
}
}
}
await MainActor.run {
if isActive, let expiry = expiryDate {
self.subscriptionStatus = .subscribed(expiryDate: expiry)
} else if isActive {
self.subscriptionStatus = .subscribed(expiryDate: Date.distantFuture)
} else {
self.subscriptionStatus = .notSubscribed
}
}
}
// 获取订阅产品
var subscriptionProducts: [Product] {
products.filter { $0.type == .autoRenewable }
}
// 管理订阅
func manageSubscriptions() {
guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
UIApplication.shared.open(url)
}
}
3.2 订阅状态显示视图
swift
// MARK: - 订阅状态视图
struct SubscriptionStatusView: View {
@StateObject private var storeManager = StoreManager.shared
@State private var subscriptionInfo: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("会员状态")
.font(.headline)
HStack {
Image(systemName: isPremiumUser ? "crown.fill" : "crown")
.foregroundColor(isPremiumUser ? .yellow : .gray)
Text(subscriptionInfo)
.font(.subheadline)
.foregroundColor(isPremiumUser ? .primary : .secondary)
Spacer()
if isPremiumUser {
Button("管理订阅") {
storeManager.manageSubscriptions()
}
.font(.caption)
} else {
NavigationLink("升级会员") {
StoreView()
}
.font(.caption)
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(10)
}
.padding()
.onAppear {
updateSubscriptionInfo()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
updateSubscriptionInfo()
}
}
var isPremiumUser: Bool {
if case .subscribed = storeManager.subscriptionStatus {
return true
}
return storeManager.isPurchased("com.example.app.feature_unlock")
}
private func updateSubscriptionInfo() {
Task {
await storeManager.checkSubscriptionStatus()
await MainActor.run {
switch storeManager.subscriptionStatus {
case .subscribed(let expiryDate):
let formatter = DateFormatter()
formatter.dateFormat = "yyyy年MM月dd日"
subscriptionInfo = "会员有效期至 \(formatter.string(from: expiryDate))"
case .expired:
subscriptionInfo = "会员已过期"
case .gracePeriod:
subscriptionInfo = "会员宽限期中"
case .notSubscribed:
subscriptionInfo = "非会员用户"
}
}
}
}
}
四、收据验证
4.1 本地收据验证
swift
// MARK: - 收据验证服务
class ReceiptValidator {
static let shared = ReceiptValidator()
// 获取收据数据
func getReceiptData() -> Data? {
guard let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) else {
return nil
}
return receiptData
}
// 本地验证(仅检查收据是否存在)
func hasValidReceipt() -> Bool {
return getReceiptData() != nil
}
// 解析收据(简化版)
func parseReceipt() -> [String: Any]? {
guard let receiptData = getReceiptData() else { return nil }
let receiptString = receiptData.base64EncodedString()
// 实际应该发送到服务器验证
return ["receipt": receiptString]
}
// 验证特定产品是否已购买
func isProductPurchased(productID: String) -> Bool {
// 使用StoreKit 2的Transaction.currentEntitlements
// 实际实现已在StoreManager中
return StoreManager.shared.isPurchased(productID)
}
}
4.2 服务器验证
swift
// MARK: - 服务器验证服务
class ServerReceiptValidator {
private let verifyURL = "https://buy.itunes.apple.com/verifyReceipt"
private let sandboxURL = "https://sandbox.itunes.apple.com/verifyReceipt"
// 发送收据到服务器验证
func verifyReceipt(receiptData: Data, completion: @escaping (Result<[String: Any], Error>) -> Void) {
let receiptString = receiptData.base64EncodedString()
var request = URLRequest(url: URL(string: verifyURL)!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [
"receipt-data": receiptString,
"password": "YOUR_SHARED_SECRET", // 在App Store Connect中获取
"exclude-old-transactions": true
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "No data", code: -1)))
return
}
do {
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
// 检查状态码
if let status = json["status"] as? Int {
if status == 21007 {
// 沙盒环境,重试沙盒URL
self.verifySandbox(receiptData: receiptData, completion: completion)
return
}
}
completion(.success(json))
} catch {
completion(.failure(error))
}
}.resume()
}
private func verifySandbox(receiptData: Data, completion: @escaping (Result<[String: Any], Error>) -> Void) {
let receiptString = receiptData.base64EncodedString()
var request = URLRequest(url: URL(string: sandboxURL)!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [
"receipt-data": receiptString,
"password": "YOUR_SHARED_SECRET"
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "No data", code: -1)))
return
}
do {
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
completion(.success(json))
} catch {
completion(.failure(error))
}
}.resume()
}
}
五、实战:带订阅功能的待办应用
5.1 高级功能视图
swift
// MARK: - 高级功能视图(需要订阅解锁)
struct PremiumFeatureView: View {
@StateObject private var storeManager = StoreManager.shared
var body: some View {
NavigationView {
if isPremiumUser {
// 已解锁的高级内容
PremiumContentView()
} else {
// 锁定状态,显示升级页面
LockedContentView()
}
}
}
var isPremiumUser: Bool {
if case .subscribed = storeManager.subscriptionStatus {
return true
}
return storeManager.isPurchased("com.example.app.feature_unlock")
}
}
// MARK: - 锁定内容视图
struct LockedContentView: View {
@State private var showingStore = false
var body: some View {
VStack(spacing: 30) {
Image(systemName: "lock.fill")
.font(.system(size: 70))
.foregroundColor(.gray)
Text("高级功能")
.font(.title)
.bold()
Text("订阅会员或永久解锁,享受所有高级功能")
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.padding(.horizontal)
VStack(alignment: .leading, spacing: 16) {
FeatureRow(icon: "icloud", text: "iCloud同步")
FeatureRow(icon: "chart.line.uptrend.xyaxis", text: "数据统计图表")
FeatureRow(icon: "bell", text: "智能提醒")
FeatureRow(icon: "paintpalette", text: "主题定制")
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
.padding(.horizontal)
Button("查看会员方案") {
showingStore = true
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding()
.sheet(isPresented: $showingStore) {
StoreView()
}
}
}
struct FeatureRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundColor(.blue)
.frame(width: 24)
Text(text)
.font(.subheadline)
Spacer()
}
}
}
// MARK: - 高级内容视图
struct PremiumContentView: View {
var body: some View {
VStack(spacing: 20) {
Image(systemName: "crown.fill")
.font(.system(size: 50))
.foregroundColor(.yellow)
Text("会员专属内容")
.font(.largeTitle)
.bold()
Text("感谢您的支持!以下是您的高级功能")
.foregroundColor(.secondary)
List {
PremiumFeatureRow(icon: "icloud", title: "iCloud同步", description: "在多设备间同步数据")
PremiumFeatureRow(icon: "chart.line.uptrend.xyaxis", title: "数据统计", description: "查看详细支出图表")
PremiumFeatureRow(icon: "bell", title: "智能提醒", description: "按时提醒待办事项")
PremiumFeatureRow(icon: "paintpalette", title: "主题定制", description: "自定义应用外观")
}
.listStyle(.insetGrouped)
}
.padding(.top)
}
}
struct PremiumFeatureRow: View {
let icon: String
let title: String
let description: String
var body: some View {
HStack(spacing: 16) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(.blue)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(description)
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
}
5.2 设置页面集成
swift
// MARK: - 设置页面
struct SettingsView: View {
@StateObject private var storeManager = StoreManager.shared
@AppStorage("isProUser") private var isProUser = false
@State private var subscriptionInfo = ""
var body: some View {
Form {
Section("会员状态") {
SubscriptionStatusView()
}
Section("高级功能") {
NavigationLink("会员专属功能") {
PremiumFeatureView()
}
}
Section("支持我们") {
Button("评价应用") {
openAppStore()
}
Button("推荐给朋友") {
shareApp()
}
NavigationLink("隐私政策") {
PrivacyPolicyView()
}
}
}
.navigationTitle("设置")
.onAppear {
updateSubscriptionInfo()
}
}
private func updateSubscriptionInfo() {
Task {
await storeManager.checkSubscriptionStatus()
}
}
private func openAppStore() {
let appID = "YOUR_APP_ID"
guard let url = URL(string: "https://apps.apple.com/app/id\(appID)") else { return }
UIApplication.shared.open(url)
}
private func shareApp() {
let appURL = "https://apps.apple.com/app/idYOUR_APP_ID"
let activityVC = UIActivityViewController(activityItems: [appURL], applicationActivities: nil)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
rootVC.present(activityVC, animated: true)
}
}
}
struct PrivacyPolicyView: View {
var body: some View {
ScrollView {
Text("隐私政策内容...")
.padding()
}
.navigationTitle("隐私政策")
}
}
六、总结
StoreKit 2让应用内购买变得更加简单和安全:
| 类型 | 特点 | 使用场景 |
|---|---|---|
| 消耗品 | 可多次购买,会消耗 | 游戏币、道具 |
| 非消耗品 | 一次性购买,永久拥有 | 解锁功能、移除广告 |
| 自动续期订阅 | 定期扣费,自动续期 | 会员服务、订阅内容 |
| 非续期订阅 | 定期购买,不自动续期 | 优惠券、限时通行证 |
最佳实践
swift
struct IAPBestPractices {
/*
1. 产品配置
- 产品ID使用反向域名格式
- 提供清晰的描述和截图
- 设置合适的定价等级
2. 用户体验
- 在合适的时机提示购买
- 购买前展示产品价值
- 提供恢复购买功能
3. 安全考虑
- 服务器验证收据
- 定期检查订阅状态
- 处理退款和投诉
4. 测试
- 使用沙盒环境测试
- 测试订阅续期场景
- 测试恢复购买功能
*/
}
StoreKit让您的应用能够实现商业价值。掌握这些技能后,您将能够构建出可持续盈利的应用!💰