第十二章:iOS高级系统能力与 UIKit 互操作

本章覆盖 iOS 开发进阶系统能力:StoreKit 2(App 内购)、WidgetKit(桌面小组件)、ARKit(增强现实)、CoreData(传统 ORM)、UIKit 互操作(UIViewRepresentable / UIViewControllerRepresentable),以及 App Clips 轻 App。


12.1 UIKit 互操作

SwiftUI 与 UIKit 并非对立,可以互相嵌入使用。UIKit 互操作有两个方向

方向 协议 典型场景
将 UIKit 控件嵌入 SwiftUI UIViewRepresentable WKWebView、MKMapView、自定义画布
将 UIViewController 嵌入 SwiftUI UIViewControllerRepresentable AVPlayerViewController、系统分享、邮件
将 SwiftUI 视图嵌入 UIKit UIHostingController 在老项目中逐步迁移到 SwiftUI

为什么需要互操作?

  • SwiftUI 目前尚未支持 WebView、某些地图渲染层、视频播放器等组件,必须借助 UIKit
  • 老项目中已有大量 UIKit 组件,可封装后在 SwiftUI 中复用,无需重写
  • 溢出屏幕(Sheet、FullScreen)暗藏了大量 UIKit 的 ViewController 调度逻辑

Coordinator 的作用是什么?

UIKit 大量使用 Delegate 模式回调事件。因为 SwiftUI 视图是值类型(struct),无法直接作为 delegate(delegate 需要是引用类型)。Coordinator 就是一个夹层(class),专门充当 UIKit 的 Delegate/DataSource,再通过引用父 UIViewRepresentable 回写数据到 SwiftUI。

UIViewRepresentable - 在 SwiftUI 中使用 UIKit 视图

swift 复制代码
import SwiftUI
import UIKit

// ① 包装 UIKit 的 WKWebView(SwiftUI 无原生 WebView)
import WebKit

struct WebViewContainer: UIViewRepresentable {
    let url: URL
    @Binding var isLoading: Bool
    @Binding var title: String
    
    // 创建 UIKit View
    func makeUIView(context: Context) -> WKWebView {
        let config = WKWebViewConfiguration()
        let webView = WKWebView(frame: .zero, configuration: config)
        webView.navigationDelegate = context.coordinator
        return webView
    }
    
    // 更新 UIKit View(SwiftUI 数据变化时调用)
    func updateUIView(_ webView: WKWebView, context: Context) {
        let request = URLRequest(url: url)
        webView.load(request)
    }
    
    // Coordinator:充当 UIKit 的 Delegate
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: WebViewContainer
        init(_ parent: WebViewContainer) { self.parent = parent }
        
        func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
            parent.isLoading = true
        }
        
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            parent.isLoading = false
            parent.title = webView.title ?? ""
        }
        
        func webView(_ webView: WKWebView,
                     didFailProvisionalNavigation navigation: WKNavigation!,
                     withError error: Error) {
            parent.isLoading = false
        }
    }
}

// 使用 WebViewContainer
struct WebBrowserView: View {
    let url: URL
    @State private var isLoading = false
    @State private var pageTitle = ""
    
    var body: some View {
        NavigationStack {
            ZStack {
                WebViewContainer(url: url, isLoading: $isLoading, title: $pageTitle)
                
                if isLoading {
                    ProgressView("加载中...")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .background(.ultraThinMaterial)
                }
            }
            .navigationTitle(pageTitle.isEmpty ? "浏览器" : pageTitle)
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

// ② 包装 MSMFPlayerViewController(视频播放器)
import MediaPlayer
import AVKit

struct VideoPlayer: UIViewControllerRepresentable {
    let url: URL
    
    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let controller = AVPlayerViewController()
        controller.player = AVPlayer(url: url)
        controller.player?.play()
        return controller
    }
    
    func updateUIViewController(_ uiViewController: AVPlayerViewController,
                                context: Context) { }
}

// ③ 包装 UIActivityViewController(系统分享)
struct ShareSheet: UIViewControllerRepresentable {
    let items: [Any]
    var excludedActivityTypes: [UIActivity.ActivityType]? = nil
    
    func makeUIViewController(context: Context) -> UIActivityViewController {
        let controller = UIActivityViewController(
            activityItems: items,
            applicationActivities: nil
        )
        controller.excludedActivityTypes = excludedActivityTypes
        return controller
    }
    
    func updateUIViewController(_ uiViewController: UIActivityViewController,
                                context: Context) { }
}

// ④ 包装 MFMailComposeViewController(发邮件)
import MessageUI

struct MailComposeView: UIViewControllerRepresentable {
    let recipient: String
    let subject: String
    let body: String
    
    @Environment(\.dismiss) var dismiss
    
    func makeUIViewController(context: Context) -> MFMailComposeViewController {
        let vc = MFMailComposeViewController()
        vc.setToRecipients([recipient])
        vc.setSubject(subject)
        vc.setMessageBody(body, isHTML: false)
        vc.mailComposeDelegate = context.coordinator
        return vc
    }
    
    func updateUIViewController(_ vc: MFMailComposeViewController, context: Context) { }
    
    func makeCoordinator() -> Coordinator { Coordinator(self) }
    
    class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
        let parent: MailComposeView
        init(_ parent: MailComposeView) { self.parent = parent }
        
        func mailComposeController(_ controller: MFMailComposeViewController,
                                   didFinishWith result: MFMailComposeResult,
                                   error: Error?) {
            parent.dismiss()
        }
    }
}

在 UIKit 中使用 SwiftUI 视图

swift 复制代码
import UIKit
import SwiftUI

// 在 UIViewController 中嵌入 SwiftUI 视图
class MainViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 创建 SwiftUI View
        let swiftUIView = ArticleCardView(
            title: "Swift 5.9 新特性",
            summary: "宏系统、@Observable 等"
        )
        
        // 包装为 UIHostingController
        let hostingController = UIHostingController(rootView: swiftUIView)
        
        // 添加为子 ViewController
        addChild(hostingController)
        view.addSubview(hostingController.view)
        hostingController.didMove(toParent: self)
        
        // 设置约束
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            hostingController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            hostingController.view.heightAnchor.constraint(equalToConstant: 200)
        ])
    }
}

// SwiftUI View 嵌入 UITableViewCell
class ArticleTableViewCell: UITableViewCell {
    private var hostingController: UIHostingController<ArticleCardView>?
    
    func configure(with article: Article) {
        let cardView = ArticleCardView(title: article.title, summary: article.summary)
        
        if let host = hostingController {
            host.rootView = cardView
        } else {
            let host = UIHostingController(rootView: cardView)
            hostingController = host
            
            host.view.backgroundColor = .clear
            contentView.addSubview(host.view)
            host.view.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                host.view.topAnchor.constraint(equalTo: contentView.topAnchor),
                host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
                host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
                host.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
            ])
        }
    }
}

12.2 StoreKit 2 - App 内购

App 内购 (In-App Purchase) 是 App 的核心商业模式之一。StoreKit 2(iOS 15+)是对旧版的全面重新设计,基于 async/await,收据在底层自动完成,不再需要手动获取进行云端验证。

三种商品类型对比:

类型 计费方式 典型设计 常见场景
订阅型(autoRenewable) 按周期自动续费 VIP 会员、云存储 Netflix、Spotify
消耗型(consumable) 一次性购买可重复买 金币、等级第一 游戏道具、普通话时长
非消耗型(nonConsumable) 一次性买断,永久拥有 功能解锁、主题包 过滤器、高级功能

StoreKit 2 核心流程说明:

  1. 加载商品Product.products(for: ids) 从 App Store 远程加载商品信息(价格、描述等),展现给用户
  2. 发起购买product.purchase() 启动 App Store 支付流,系统自动处理验证、支付密码,App 无需干预
  3. 验证收据VerificationResult<Transaction> 返回结果,.verified 表示收据真实,.unverified 则应拒绝权益
  4. 完成交易 :必须调用 transaction.finish(),否则 App Store 会重复提示购买(设计为阻止安装后不调 finish 导致流失的机制)
  5. 监听交易更新 :必须在 App 启动时监听 Transaction.updates,处理订阅续费、过期、退款等异步事件

常见盲区: 不调用 transaction.finish() 是新手最常见的错误。另外,收据验证应在服务端进行二次验证(appAccountToken + 你自己的购买记录),而非仅依赖客户端验证。

swift 复制代码
import StoreKit

// 内购管理器
@Observable
@MainActor
class StoreKitManager {
    
    // 产品 ID(在 App Store Connect 中配置)
    static let productIDs: Set<String> = [
        "com.yourapp.premium.monthly",   // 订阅(月度)
        "com.yourapp.premium.yearly",    // 订阅(年度)
        "com.yourapp.coins_100",         // 消耗型(100金币)
        "com.yourapp.extra_themes",      // 非消耗型(主题包)
    ]
    
    var products: [Product] = []
    var purchasedProductIDs: Set<String> = []
    var isLoading = false
    var errorMessage: String?
    
    // 是否为 Premium 用户
    var isPremium: Bool {
        purchasedProductIDs.contains("com.yourapp.premium.monthly") ||
        purchasedProductIDs.contains("com.yourapp.premium.yearly")
    }
    
    private var updateListenerTask: Task<Void, Error>?
    
    init() {
        updateListenerTask = listenForTransactions()
        Task { await loadProducts() }
        Task { await updatePurchasedProducts() }
    }
    
    deinit {
        updateListenerTask?.cancel()
    }
    
    // 加载商品列表
    func loadProducts() async {
        isLoading = true
        do {
            products = try await Product.products(for: Self.productIDs)
            products.sort { $0.price < $1.price }
        } catch {
            errorMessage = "加载商品失败:\(error.localizedDescription)"
        }
        isLoading = false
    }
    
    // 购买
    func purchase(_ product: Product) async -> Bool {
        do {
            let result = try await product.purchase()
            
            switch result {
            case .success(let verification):
                // 验证收据
                let transaction = try checkVerified(verification)
                await updatePurchasedProducts()
                await transaction.finish()  // 必须调用,否则会重复提示购买
                return true
                
            case .userCancelled:
                return false
                
            case .pending:
                // 等待(如家长控制审批)
                return false
                
            @unknown default:
                return false
            }
        } catch {
            errorMessage = "购买失败:\(error.localizedDescription)"
            return false
        }
    }
    
    // 恢复购买
    func restorePurchases() async {
        do {
            try await AppStore.sync()
            await updatePurchasedProducts()
        } catch {
            errorMessage = "恢复购买失败:\(error.localizedDescription)"
        }
    }
    
    // 更新已购产品列表
    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)
                }
            }
        }
        
        purchasedProductIDs = purchased
    }
    
    // 监听交易更新(处理订阅续费、退款等)
    private func listenForTransactions() -> Task<Void, Error> {
        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
        }
    }
    
    enum StoreError: Error {
        case failedVerification
    }
}

// 内购商店页面
struct StoreView: View {
    @State private var store = StoreKitManager()
    @State private var isPurchasing = false
    
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 20) {
                    // Premium 状态
                    if store.isPremium {
                        PremiumBadgeView()
                    }
                    
                    // 订阅产品
                    subscriptionSection
                    
                    // 一次性购买
                    oneTimePurchaseSection
                    
                    // 恢复购买
                    Button("恢复购买") {
                        Task { await store.restorePurchases() }
                    }
                    .foregroundStyle(.secondary)
                }
                .padding()
            }
            .navigationTitle("高级版")
            .overlay { if store.isLoading || isPurchasing { ProgressView() } }
            .alert("错误", isPresented: .constant(store.errorMessage != nil)) {
                Button("确定") { store.errorMessage = nil }
            } message: {
                Text(store.errorMessage ?? "")
            }
        }
    }
    
    var subscriptionSection: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("订阅计划").font(.headline)
            
            ForEach(store.products.filter { $0.type == .autoRenewable }) { product in
                ProductRow(product: product, isPurchased: store.purchasedProductIDs.contains(product.id)) {
                    Task {
                        isPurchasing = true
                        _ = await store.purchase(product)
                        isPurchasing = false
                    }
                }
            }
        }
    }
    
    var oneTimePurchaseSection: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("一次性购买").font(.headline)
            
            ForEach(store.products.filter { $0.type != .autoRenewable }) { product in
                ProductRow(product: product, isPurchased: store.purchasedProductIDs.contains(product.id)) {
                    Task {
                        isPurchasing = true
                        _ = await store.purchase(product)
                        isPurchasing = false
                    }
                }
            }
        }
    }
}

struct ProductRow: View {
    let product: Product
    let isPurchased: Bool
    let onPurchase: () -> Void
    
    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(product.displayName).font(.headline)
                Text(product.description).font(.caption).foregroundStyle(.secondary)
            }
            Spacer()
            if isPurchased {
                Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
            } else {
                Button(product.displayPrice, action: onPurchase)
                    .buttonStyle(.borderedProminent)
            }
        }
        .padding()
        .background(.regularMaterial)
        .cornerRadius(12)
    }
}

12.3 WidgetKit - 桌面小组件

WidgetKit 允许 App 在 iOS 主屏幕和锁屏上展示小组件,iOS 16+ 支持锁屏小组件。

swift 复制代码
import WidgetKit
import SwiftUI

// 定义小组件入口
@main
struct MyWidgetBundle: WidgetBundle {
    var body: some Widget {
        ArticleWidget()        // 文章摘要小组件
        QuickActionWidget()    // 快捷操作小组件
    }
}

// ① 时间线条目(数据快照)
struct ArticleEntry: TimelineEntry {
    let date: Date           // 时间线时间点
    let title: String
    let summary: String
    let authorName: String
    let viewCount: Int
}

// ② 时间线提供者(决定何时刷新、提供什么数据)
struct ArticleTimelineProvider: TimelineProvider {
    
    // 占位视图(首次加载时显示)
    func placeholder(in context: Context) -> ArticleEntry {
        ArticleEntry(
            date: Date(),
            title: "示例文章标题",
            summary: "这是文章摘要,展示在小组件上...",
            authorName: "作者",
            viewCount: 0
        )
    }
    
    // 快照(小组件库中预览时使用)
    func getSnapshot(in context: Context,
                     completion: @escaping (ArticleEntry) -> Void) {
        let entry = ArticleEntry(
            date: Date(),
            title: "Swift Macros 详解",
            summary: "Swift 5.9 宏系统让开发更高效...",
            authorName: "技术达人",
            viewCount: 1024
        )
        completion(entry)
    }
    
    // 时间线(决定刷新策略)
    func getTimeline(in context: Context,
                     completion: @escaping (Timeline<ArticleEntry>) -> Void) {
        Task {
            // 从 App Group 共享数据(主 App 和 Widget Extension 共享)
            let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp")
            let cachedTitle = sharedDefaults?.string(forKey: "latest_article_title") ?? "暂无最新文章"
            
            // 也可以网络请求(注意 Widget 中不能使用 URLSession.shared,需要 background session)
            
            let entry = ArticleEntry(
                date: Date(),
                title: cachedTitle,
                summary: "点击查看详情",
                authorName: "编辑推荐",
                viewCount: 0
            )
            
            // 1小时后刷新
            let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())!
            let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
            completion(timeline)
        }
    }
}

// ③ 小组件 View(支持 small / medium / large / accessoryCircular / accessoryRectangular)
struct ArticleWidgetView: View {
    let entry: ArticleEntry
    @Environment(\.widgetFamily) var widgetFamily
    
    var body: some View {
        switch widgetFamily {
        case .systemSmall:
            smallView
        case .systemMedium:
            mediumView
        case .systemLarge:
            largeView
        case .accessoryCircular:
            // 锁屏圆形小组件
            ZStack {
                AccessoryWidgetBackground()
                Image(systemName: "doc.text.fill")
                    .font(.title2)
            }
        case .accessoryRectangular:
            // 锁屏矩形小组件
            VStack(alignment: .leading) {
                Text(entry.title).font(.headline).lineLimit(1)
                Text(entry.summary).font(.caption).lineLimit(2)
            }
        default:
            smallView
        }
    }
    
    var smallView: some View {
        VStack(alignment: .leading, spacing: 4) {
            Image(systemName: "doc.text.fill")
                .foregroundStyle(.blue)
            Spacer()
            Text(entry.title)
                .font(.headline)
                .lineLimit(2)
            Text(entry.authorName)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .padding()
        .widgetURL(URL(string: "yourapp://article/latest"))
    }
    
    var mediumView: some View {
        HStack(spacing: 12) {
            VStack(alignment: .leading, spacing: 4) {
                Text("最新文章")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Text(entry.title)
                    .font(.headline)
                    .lineLimit(2)
                Text(entry.summary)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .lineLimit(2)
            }
            Spacer()
            VStack {
                Image(systemName: "eye")
                Text("\(entry.viewCount)")
                    .font(.caption)
            }
            .foregroundStyle(.secondary)
        }
        .padding()
        .widgetURL(URL(string: "yourapp://article/latest"))
    }
    
    var largeView: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("今日推荐").font(.headline)
            Divider()
            Text(entry.title).font(.title3.bold())
            Text(entry.summary).font(.body).foregroundStyle(.secondary)
            Spacer()
            HStack {
                Text(entry.authorName).font(.caption).foregroundStyle(.secondary)
                Spacer()
                Text(entry.date, style: .relative).font(.caption).foregroundStyle(.secondary)
            }
        }
        .padding()
        .widgetURL(URL(string: "yourapp://article/latest"))
    }
}

// ④ 小组件定义
struct ArticleWidget: Widget {
    let kind = "ArticleWidget"
    
    var body: some WidgetConfiguration {
        StaticConfiguration(
            kind: kind,
            provider: ArticleTimelineProvider()
        ) { entry in
            ArticleWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("最新文章")
        .description("在主屏幕查看最新推荐文章")
        .supportedFamilies([
            .systemSmall, .systemMedium, .systemLarge,
            .accessoryCircular, .accessoryRectangular
        ])
    }
}

// 主 App 向 Widget 推送数据(通过 App Group)
class WidgetDataManager {
    static func updateWidget(with article: ArticleEntity) {
        let defaults = UserDefaults(suiteName: "group.com.yourapp")
        defaults?.set(article.title, forKey: "latest_article_title")
        defaults?.set(article.summary, forKey: "latest_article_summary")
        
        // 立即刷新小组件
        WidgetCenter.shared.reloadTimelines(ofKind: "ArticleWidget")
    }
    
    static func reloadAllWidgets() {
        WidgetCenter.shared.reloadAllTimelines()
    }
}

12.4 CoreData - 传统本地数据库

虽然 iOS 17 推出了 SwiftData,许多现有项目仍使用 CoreData。了解 CoreData 对维护老项目至关重要。

swift 复制代码
import CoreData
import SwiftUI

// CoreData Stack 封装
class PersistenceController {
    static let shared = PersistenceController()
    
    let container: NSPersistentContainer
    
    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "DataModel")
        
        if inMemory {
            container.persistentStoreDescriptions.first?.url =
                URL(fileURLWithPath: "/dev/null")
        }
        
        container.loadPersistentStores { storeDescription, error in
            if let error = error as NSError? {
                fatalError("CoreData 加载失败:\(error)")
            }
        }
        
        // 自动将外部变更合并到视图上下文
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
    
    // 保存上下文
    func save() {
        let context = container.viewContext
        guard context.hasChanges else { return }
        
        do {
            try context.save()
        } catch {
            print("CoreData 保存失败:\(error)")
        }
    }
    
    // 后台操作(避免阻塞主线程)
    func performBackgroundTask(_ action: @escaping (NSManagedObjectContext) -> Void) {
        container.performBackgroundTask { backgroundContext in
            action(backgroundContext)
            try? backgroundContext.save()
        }
    }
}

// CoreData CRUD 操作
extension PersistenceController {
    
    // 创建
    func createTask(title: String, priority: Int16 = 1) -> TaskEntity? {
        let context = container.viewContext
        let task = TaskEntity(context: context)
        task.id = UUID()
        task.title = title
        task.priority = priority
        task.createdAt = Date()
        task.isCompleted = false
        save()
        return task
    }
    
    // 查询
    func fetchTasks(predicate: NSPredicate? = nil,
                    sortDescriptors: [NSSortDescriptor] = []) -> [TaskEntity] {
        let request: NSFetchRequest<TaskEntity> = TaskEntity.fetchRequest()
        request.predicate = predicate
        request.sortDescriptors = sortDescriptors.isEmpty
            ? [NSSortDescriptor(keyPath: \TaskEntity.createdAt, ascending: false)]
            : sortDescriptors
        
        let context = container.viewContext
        return (try? context.fetch(request)) ?? []
    }
    
    // 更新(直接修改托管对象属性并 save)
    func toggleTaskCompletion(_ task: TaskEntity) {
        task.isCompleted.toggle()
        save()
    }
    
    // 删除
    func deleteTask(_ task: TaskEntity) {
        container.viewContext.delete(task)
        save()
    }
    
    // 批量删除(高性能)
    func deleteAllCompletedTasks() {
        let request = NSBatchDeleteRequest(
            fetchRequest: NSFetchRequest<NSFetchRequestResult>(entityName: "TaskEntity")
        )
        request.predicate = NSPredicate(format: "isCompleted == YES")
        request.resultType = .resultTypeObjectIDs
        
        let context = container.viewContext
        if let result = try? context.execute(request) as? NSBatchDeleteResult,
           let objectIDs = result.result as? [NSManagedObjectID] {
            NSManagedObjectContext.mergeChanges(
                fromRemoteContextSave: [NSDeletedObjectsKey: objectIDs],
                into: [context]
            )
        }
    }
}

// SwiftUI 视图中使用 CoreData
struct CoreDataTaskListView: View {
    // @FetchRequest 自动监听 CoreData 变化
    @FetchRequest(
        entity: TaskEntity.entity(),
        sortDescriptors: [NSSortDescriptor(keyPath: \TaskEntity.createdAt, ascending: false)],
        predicate: NSPredicate(format: "isCompleted == NO")
    )
    private var pendingTasks: FetchedResults<TaskEntity>
    
    @Environment(\.managedObjectContext) private var viewContext
    
    var body: some View {
        List {
            ForEach(pendingTasks) { task in
                HStack {
                    Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                        .foregroundStyle(task.isCompleted ? .green : .secondary)
                        .onTapGesture {
                            PersistenceController.shared.toggleTaskCompletion(task)
                        }
                    Text(task.title ?? "")
                }
            }
            .onDelete { indexSet in
                indexSet.forEach { index in
                    PersistenceController.shared.deleteTask(pendingTasks[index])
                }
            }
        }
    }
}

// 在 App 入口注入
struct CoreDataDemoApp: App {
    let persistenceController = PersistenceController.shared
    
    var body: some Scene {
        WindowGroup {
            CoreDataTaskListView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

12.5 ARKit - 增强现实

ARKit 结合 RealityKit 提供了强大的 AR 体验开发能力。

swift 复制代码
import ARKit
import RealityKit
import SwiftUI

// ARView 包装(RealityKit + ARKit)
struct ARViewContainer: UIViewRepresentable {
    @Binding var selectedObject: ARObjectType
    
    enum ARObjectType: CaseIterable {
        case box, sphere, model
    }
    
    func makeUIView(context: Context) -> ARView {
        let arView = ARView(frame: .zero)
        
        // 配置 AR Session(世界追踪)
        let config = ARWorldTrackingConfiguration()
        config.planeDetection = [.horizontal, .vertical]  // 检测水平和垂直平面
        config.environmentTexturing = .automatic           // 环境贴图(自动反射)
        
        if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) {
            config.sceneReconstruction = .meshWithClassification  // LiDAR 场景重建
        }
        
        arView.session.run(config, options: [.resetTracking, .removeExistingAnchors])
        arView.session.delegate = context.coordinator
        
        // 点击放置物体
        let tapGesture = UITapGestureRecognizer(
            target: context.coordinator,
            action: #selector(Coordinator.handleTap(_:))
        )
        arView.addGestureRecognizer(tapGesture)
        
        context.coordinator.arView = arView
        return arView
    }
    
    func updateUIView(_ arView: ARView, context: Context) {
        context.coordinator.selectedObject = selectedObject
    }
    
    func makeCoordinator() -> Coordinator { Coordinator(self) }
    
    class Coordinator: NSObject, ARSessionDelegate {
        let parent: ARViewContainer
        weak var arView: ARView?
        var selectedObject: ARObjectType = .box
        
        init(_ parent: ARViewContainer) { self.parent = parent }
        
        @objc func handleTap(_ gesture: UITapGestureRecognizer) {
            guard let arView = arView else { return }
            let location = gesture.location(in: arView)
            
            // 射线检测平面
            let results = arView.raycast(
                from: location,
                allowing: .estimatedPlane,
                alignment: .horizontal
            )
            
            guard let result = results.first else { return }
            
            // 放置 3D 物体
            placeObject(at: result.worldTransform, in: arView)
        }
        
        func placeObject(at transform: simd_float4x4, in arView: ARView) {
            let anchor = AnchorEntity(world: transform)
            
            let entity: ModelEntity
            switch selectedObject {
            case .box:
                let mesh = MeshResource.generateBox(size: 0.1, cornerRadius: 0.005)
                let material = SimpleMaterial(color: .blue, roughness: 0.5, isMetallic: true)
                entity = ModelEntity(mesh: mesh, materials: [material])
                
            case .sphere:
                let mesh = MeshResource.generateSphere(radius: 0.05)
                let material = SimpleMaterial(color: .red, roughness: 0.3, isMetallic: false)
                entity = ModelEntity(mesh: mesh, materials: [material])
                
            case .model:
                // 加载 USDZ 模型
                if let modelEntity = try? ModelEntity.load(named: "toy_robot") {
                    entity = modelEntity
                } else {
                    return
                }
            }
            
            // 添加碰撞组件(支持手势交互)
            entity.generateCollisionShapes(recursive: true)
            arView.installGestures([.all], for: entity)
            
            anchor.addChild(entity)
            arView.scene.addAnchor(anchor)
        }
        
        // 检测到平面时的回调
        func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
            for anchor in anchors {
                if let planeAnchor = anchor as? ARPlaneAnchor {
                    print("检测到\(planeAnchor.alignment == .horizontal ? "水平" : "垂直")平面")
                }
            }
        }
    }
}

// AR 界面
struct ARDemoView: View {
    @State private var selectedObject: ARViewContainer.ARObjectType = .box
    @State private var showARUnavailableAlert = false
    
    var body: some View {
        ZStack(alignment: .bottom) {
            // AR 视图占满全屏
            if ARWorldTrackingConfiguration.isSupported {
                ARViewContainer(selectedObject: $selectedObject)
                    .ignoresSafeArea()
            } else {
                ContentUnavailableView(
                    "不支持 AR",
                    systemImage: "arkit",
                    description: Text("此设备不支持 ARKit")
                )
            }
            
            // 底部控制面板
            HStack(spacing: 20) {
                ForEach(ARViewContainer.ARObjectType.allCases, id: \.self) { type in
                    Button {
                        selectedObject = type
                    } label: {
                        Text(type == .box ? "方块" : type == .sphere ? "球体" : "模型")
                            .padding(.horizontal, 16)
                            .padding(.vertical, 8)
                            .background(selectedObject == type ? .blue : .regularMaterial)
                            .foregroundStyle(selectedObject == type ? .white : .primary)
                            .cornerRadius(20)
                    }
                }
            }
            .padding()
            .background(.ultraThinMaterial)
        }
        .navigationTitle("AR 体验")
        .navigationBarTitleDisplayMode(.inline)
    }
}

12.6 Swift 枚举进阶

枚举是 Swift 最强大的特性之一,远超其他语言的枚举能力。

swift 复制代码
// ① 关联值(Enum with Associated Values)
enum PaymentResult {
    case success(transactionID: String, amount: Double)
    case partialSuccess(transactionID: String, paid: Double, remaining: Double)
    case failure(code: Int, message: String)
    case pending(estimatedTime: TimeInterval)
}

func handlePayment(_ result: PaymentResult) {
    switch result {
    case .success(let transactionID, let amount):
        print("支付成功 \(transactionID),金额:¥\(amount)")
        
    case .partialSuccess(let id, let paid, let remaining):
        print("部分支付 \(id),已付 ¥\(paid),剩余 ¥\(remaining)")
        
    case .failure(let code, let message):
        print("支付失败(\(code)):\(message)")
        
    case .pending(let time):
        print("处理中,预计 \(Int(time))秒")
    }
}

// ② 枚举方法与计算属性
enum Direction: CaseIterable {
    case north, south, east, west
    
    var opposite: Direction {
        switch self {
        case .north: return .south
        case .south: return .north
        case .east: return .west
        case .west: return .east
        }
    }
    
    var vector: (x: Int, y: Int) {
        switch self {
        case .north: return (0, 1)
        case .south: return (0, -1)
        case .east:  return (1, 0)
        case .west:  return (-1, 0)
        }
    }
    
    func rotated(clockwise: Bool) -> Direction {
        let all = Direction.allCases
        let index = all.firstIndex(of: self)!
        let offset = clockwise ? 1 : -1
        return all[(index + offset + all.count) % all.count]
    }
}

// ③ 递归枚举(indirect)
indirect enum Expression {
    case number(Double)
    case addition(Expression, Expression)
    case multiplication(Expression, Expression)
    case negation(Expression)
    
    func evaluate() -> Double {
        switch self {
        case .number(let n):
            return n
        case .addition(let a, let b):
            return a.evaluate() + b.evaluate()
        case .multiplication(let a, let b):
            return a.evaluate() * b.evaluate()
        case .negation(let e):
            return -e.evaluate()
        }
    }
}

// (3 + 4) * -(2)
let expr = Expression.multiplication(
    .addition(.number(3), .number(4)),
    .negation(.number(2))
)
print(expr.evaluate())  // -14.0

// ④ 枚举遵循协议 + Codable
enum UserRole: String, Codable, CaseIterable, Comparable {
    case guest = "guest"
    case user = "user"
    case moderator = "moderator"
    case admin = "admin"
    
    static func < (lhs: UserRole, rhs: UserRole) -> Bool {
        let order: [UserRole] = [.guest, .user, .moderator, .admin]
        return order.firstIndex(of: lhs)! < order.firstIndex(of: rhs)!
    }
    
    var displayName: String {
        switch self {
        case .guest: return "访客"
        case .user: return "用户"
        case .moderator: return "版主"
        case .admin: return "管理员"
        }
    }
    
    var permissions: Set<Permission> {
        switch self {
        case .guest: return [.read]
        case .user: return [.read, .write, .comment]
        case .moderator: return [.read, .write, .comment, .moderate]
        case .admin: return Set(Permission.allCases)
        }
    }
    
    func canPerform(_ permission: Permission) -> Bool {
        permissions.contains(permission)
    }
}

enum Permission: String, CaseIterable {
    case read, write, comment, moderate, deleteUser
}

// ⑤ where 子句在枚举 switch 中的应用
func describe(_ value: PaymentResult) -> String {
    switch value {
    case .success(_, let amount) where amount > 1000:
        return "大额支付成功"
    case .success(_, let amount) where amount > 0:
        return "小额支付成功"
    case .failure(let code, _) where (400..<500).contains(code):
        return "客户端错误"
    case .failure(let code, _) where (500..<600).contains(code):
        return "服务端错误"
    default:
        return "其他状态"
    }
}

// ⑥ defer 语句 - 延迟执行(保证清理代码执行)
func processFile(at path: String) throws {
    let fileHandle = try FileHandle(forReadingAtPath: path) ?? {
        throw NSError(domain: "App", code: 1)
    }()
    
    defer {
        fileHandle.closeFile()  // 无论成功失败,函数退出时都会执行
        print("文件已关闭")
    }
    
    // 读取文件内容(如果抛出异常,defer 仍然执行)
    let data = fileHandle.readDataToEndOfFile()
    let content = String(data: data, encoding: .utf8)
    print("文件内容:\(content ?? "")")
}

// defer 在锁场景中的应用
class ThreadSafeCache {
    private var cache: [String: Any] = [:]
    private let lock = NSLock()
    
    func set(_ value: Any, for key: String) {
        lock.lock()
        defer { lock.unlock() }  // 确保锁一定被释放
        cache[key] = value
    }
    
    func get(_ key: String) -> Any? {
        lock.lock()
        defer { lock.unlock() }
        return cache[key]
    }
}

12.7 App Clips 轻 App

App Clips 允许用户无需完整安装即可使用 App 的特定功能(扫码、NFC 触碰、链接等方式唤起)。

swift 复制代码
import AppClip
import SwiftUI

// App Clip 的主视图(精简功能,关键流程)
@main
struct OrderAppClip: App {
    var body: some Scene {
        WindowGroup {
            OrderClipView()
                .onContinueUserActivity(NSUserActivityTypes.browsingWeb) { activity in
                    // 处理 Universal Link(从二维码、NFC、Safari 唤起)
                    guard let url = activity.webpageURL else { return }
                    handleInvocationURL(url)
                }
        }
    }
    
    func handleInvocationURL(_ url: URL) {
        // 解析 URL 参数,如:https://yourshop.com/order?storeID=123&tableID=5
        let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
        let storeID = components?.queryItems?.first(where: { $0.name == "storeID" })?.value
        let tableID = components?.queryItems?.first(where: { $0.name == "tableID" })?.value
        
        print("餐厅 \(storeID ?? ""), 桌号 \(tableID ?? "")")
    }
}

struct OrderClipView: View {
    @State private var selectedItems: [MenuItem] = []
    
    var body: some View {
        NavigationStack {
            List(MenuItem.samples) { item in
                HStack {
                    Text(item.name)
                    Spacer()
                    Text("¥\(item.price, specifier: "%.2f")")
                    Button("+") {
                        selectedItems.append(item)
                    }
                    .buttonStyle(.bordered)
                }
            }
            .navigationTitle("点单")
            .safeAreaInset(edge: .bottom) {
                // 下单结算
                if !selectedItems.isEmpty {
                    Button("结算(共\(selectedItems.count)件)") {
                        // App Clip 引导安装完整 App
                        SKOverlay.present(
                            in: UIApplication.shared.connectedScenes.first as! UIWindowScene,
                            configuration: SKOverlay.AppConfiguration(appIdentifier: "1234567890")
                        )
                    }
                    .buttonStyle(.borderedProminent)
                    .padding()
                    .background(.regularMaterial)
                }
            }
        }
    }
}

章节总结

高级能力 框架/技术 核心 API
UIKit 互操作 UIViewRepresentable makeUIView / updateUIView / Coordinator
UIViewController 互操作 UIViewControllerRepresentable makeUIViewController / Coordinator
App 内购 StoreKit 2 Product.products / product.purchase() / Transaction
桌面小组件 WidgetKit TimelineProvider / Widget / WidgetCenter
传统数据库 CoreData NSPersistentContainer / @FetchRequest
增强现实 ARKit + RealityKit ARView / ARWorldTrackingConfiguration / AnchorEntity
枚举进阶 Swift 关联值 / 递归 / indirect / where
轻 App App Clips SKOverlay / Universal Links

Demo 说明

文件 演示内容
UIKitInteropDemo.swift WebView / 视频播放 / 系统分享
StoreKitDemo.swift 订阅 + 一次性购买 + 恢复购买 完整流程
WidgetDemoWidget.swift 静态小组件 + 锁屏小组件
CoreDataDemo.swift CRUD + @FetchRequest + 批量删除
ARKitDemo.swift 平面检测 + 放置 3D 物体 + 手势交互
EnumAdvancedDemo.swift 关联值 / 递归 / defer / where
相关推荐
songgeb8 小时前
用 AI 降低 iOS 客户端 UI 自动化测试难度
ios·测试
我现在不喜欢coding9 小时前
Swift 核心协议揭秘:从 Sequence 到 Collection,你离标准库设计者只差这一步
ios·swift
开心就好20259 小时前
使用Edge和ADB进行Android Webview远程调试的完整教程
前端·ios
开心就好202511 小时前
iOS应用上架全流程:从证书申请到发布避坑指南
后端·ios
梦想不只是梦与想12 小时前
flutter 与 Android iOS 通信?以及实现原理(一)
android·flutter·ios·methodchannel·eventchannel·basicmessage
冰凌时空14 小时前
30 Apps 第 1 天:待办清单 App —— 数据层完整设计
前端·ios
2501_9159090615 小时前
Xcode从入门到精通:全面解析iOS开发IDE的核心功能与实际应用指南
ide·vscode·ios·个人开发·xcode·swift·敏捷流程
懋学的前端攻城狮15 小时前
登录与注册:不止于UI,更关乎安全与用户体验的闭环
ios
卢锡荣16 小时前
单芯双 C 盲插,一线通显电 ——LDR6020P 盲插 Type‑C 显示器方案深度解析
c语言·开发语言·ios·计算机外设·电脑