本章覆盖 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 核心流程说明:
- 加载商品 :
Product.products(for: ids)从 App Store 远程加载商品信息(价格、描述等),展现给用户 - 发起购买 :
product.purchase()启动 App Store 支付流,系统自动处理验证、支付密码,App 无需干预 - 验证收据 :
VerificationResult<Transaction>返回结果,.verified表示收据真实,.unverified则应拒绝权益 - 完成交易 :必须调用
transaction.finish(),否则 App Store 会重复提示购买(设计为阻止安装后不调 finish 导致流失的机制) - 监听交易更新 :必须在 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 |