SwiftUI 数据持久化完全指南:从偏好设置到企业级存储

在 SwiftUI 应用开发中,数据持久化是决定应用健壮性与用户体验的关键环节。数据需要在本应重启、设备更换甚至多设备同步后依然可用、安全且高效地被访问。SwiftUI 及其生态系统提供了一整套分层明确、功能互补的持久化方案,本文将对其进行系统化梳理,并给出每种技术的最佳实践。

一、核心持久化技术全景图

不同场景需要不同武器。下表列出了 SwiftUI 开发中所有主流持久化方案,以及它们的典型适用场景与特点。

技术方案 适用场景 特点与注意事项
UserDefaults / @AppStorage 用户偏好、简单配置(布尔、字符串、数字) 轻量、同步读写;不适合敏感数据或大数据
@SceneStorage 场景特定临时状态(如选中Tab、滚动位置) 系统自动恢复,但存活周期与场景绑定
FileManager + Codable 文档、JSON、图片、自定义对象序列化 灵活,可处理大文件;需注意线程安全
Keychain 密码、Token、证书等敏感信息 系统级加密,可与生物识别集成
Core Data 复杂关系数据、大量结构化数据、需查询与版本管理 对象图管理框架,支持 iCloud 同步
SwiftData (iOS 17+) 现代替代 Core Data,纯 Swift 模型,声明式 面向 SwiftUI 深度优化,代码量锐减
CloudKit 跨设备数据同步、公共数据库、无需服务器 可与 Core Data 或 SwiftData 结合实现同步
App Groups & 共享容器 主机 App 与扩展(Widget、Watch)间共享数据 UserDefaults(suiteName:) 或共享文件目录
自定义 SQL 数据库(如 GRDB) 需要完全控制 SQL 查询、高性能需求 非系统原生,需第三方库;灵活性最高

本文后续将逐一展开每种技术的深度实现、进阶用法、安全策略以及架构设计建议。


二、详细技术实现与最佳实践

2.1 UserDefaults 与 @AppStorage ------ 轻量配置的基石

2.1.1 基础用法回顾

UserDefaults 适合存储少量键值对。@AppStorage 是 SwiftUI 提供的属性包装器,可直接在视图中绑定 UserDefaults,省去手动读写。

swift 复制代码
@AppStorage("username") var username = "Guest"
@AppStorage("isDarkMode") var isDarkMode = false

2.1.2 进阶封装:ObservableObject + 枚举键

为提升可维护性,建议创建一个集中管理的 ObservableObject 类,用 enum 管理键名,并用 @Published 实现响应式更新。

swift 复制代码
import SwiftUI
import Combine

class AppSettings: ObservableObject {
    static let shared = AppSettings()
    private let defaults = UserDefaults.standard

    private enum Keys {
        static let userName = "userName"
        static let theme = "theme"
        static let fontSize = "fontSize"
    }

    @Published var userName: String {
        didSet { defaults.set(userName, forKey: Keys.userName) }
    }
    @Published var theme: String {
        didSet { defaults.set(theme, forKey: Keys.theme) }
    }
    @Published var fontSize: Double {
        didSet { defaults.set(fontSize, forKey: Keys.fontSize) }
    }

    private init() {
        self.userName = defaults.string(forKey: Keys.userName) ?? ""
        self.theme = defaults.string(forKey: Keys.theme) ?? "system"
        self.fontSize = defaults.double(forKey: Keys.fontSize)
        if self.fontSize == 0 { self.fontSize = 17.0 } // 默认值
    }
}

在视图中使用 @StateObject 注入:

swift 复制代码
struct ContentView: View {
    @StateObject private var settings = AppSettings.shared
    var body: some View {
        Form {
            TextField("姓名", text: $settings.userName)
            Picker("主题", selection: $settings.theme) {
                Text("浅色").tag("light")
                Text("深色").tag("dark")
            }
        }
    }
}

2.1.3 应用组(App Groups)共享数据

若需在主机 App 与 Widget、Watch App 等扩展间共享 UserDefaults,需使用 UserDefaults(suiteName:) 并开启 App Groups。

swift 复制代码
// 在所有的 target 中启用相同的 App Group ID(如 group.com.example.app)
let sharedDefaults = UserDefaults(suiteName: "group.com.example.app")
sharedDefaults?.set("SharedValue", forKey: "sharedKey")

// 在视图中使用 @AppStorage 但指定 suite
@AppStorage("sharedKey", store: UserDefaults(suiteName: "group.com.example.app")) 
var sharedValue: String = ""

⚠️ 注意:App Groups 内的数据仍不安全,敏感信息绝不可放于此。

2.2 FileManager ------ 文件系统直接操作

当数据不适合用 UserDefaults(如大量文本、JSON、图片)时,直接操作文件系统是更灵活的选择。

2.2.1 路径管理封装

swift 复制代码
enum FileStorageError: Error {
    case fileNotFound
    case encodingFailed
}

class FileStorageManager {
    static let shared = FileStorageManager()
    private let fileManager = FileManager.default

    var documentsURL: URL {
        fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
    }

    func save(data: Data, to filename: String) throws {
        let fileURL = documentsURL.appendingPathComponent(filename)
        try data.write(to: fileURL, options: .atomic)
    }

    func load(filename: String) throws -> Data {
        let fileURL = documentsURL.appendingPathComponent(filename)
        guard fileManager.fileExists(atPath: fileURL.path) else {
            throw FileStorageError.fileNotFound
        }
        return try Data(contentsOf: fileURL)
    }

    func delete(filename: String) throws {
        let fileURL = documentsURL.appendingPathComponent(filename)
        try fileManager.removeItem(at: fileURL)
    }
}

2.2.2 结合 Codable 实现对象持久化

自定义模型遵循 Codable,即可轻松序列化为 JSON 并存储。

swift 复制代码
struct UserProfile: Codable, Identifiable {
    let id: UUID
    var name: String
    var email: String
}

class ProfileRepository {
    private let storage = FileStorageManager.shared
    private let filename = "profile.json"

    func saveProfile(_ profile: UserProfile) throws {
        let encoder = JSONEncoder()
        encoder.outputFormatting = .prettyPrinted
        let data = try encoder.encode(profile)
        try storage.save(data: data, to: filename)
    }

    func loadProfile() throws -> UserProfile? {
        guard let data = try? storage.load(filename: filename) else { return nil }
        return try JSONDecoder().decode(UserProfile.self, from: data)
    }
}

2.2.3 异步读写与性能

大文件操作绝不可阻塞主线程。利用 GCD 或 async/await:

swift 复制代码
func saveAsync(image: UIImage, name: String) async throws {
    guard let data = image.jpegData(compressionQuality: 0.8) else { throw FileStorageError.encodingFailed }
    let url = FileStorageManager.shared.documentsURL.appendingPathComponent(name)
    try await Task.detached(priority: .background) {
        try data.write(to: url, options: .atomic)
    }.value
}

2.3 Keychain ------ 敏感信息的保险箱

Keychain 是处理密码、Token、私钥等机密数据的唯一推荐存储方式。永远不要将敏感信息存入 UserDefaults 或明文文件。

2.3.1 通用 Keychain 管理器

swift 复制代码
import Security

class KeychainManager {
    static let shared = KeychainManager()
    private let service = "com.example.yourapp" // 建议用 Bundle Identifier

    @discardableResult
    func save(_ value: String, forKey key: String) -> Bool {
        guard let data = value.data(using: .utf8) else { return false }
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        ]
        SecItemDelete(query as CFDictionary) // 先删除旧值
        return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
    }

    func read(_ key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecReturnData as String: kCFBooleanTrue!,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        guard status == errSecSuccess, let data = item as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }

    func delete(_ key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(query as CFDictionary)
    }
}

2.3.2 访问控制策略

kSecAttrAccessible 决定了何时可以访问钥匙串项:

  • kSecAttrAccessibleWhenUnlocked:设备解锁时可用(默认)。
  • kSecAttrAccessibleWhenUnlockedThisDeviceOnly:解锁时可用,且不通过 iCloud 同步(推荐)。
  • kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly:需设备已设置密码,不跨设备。
  • 配合生物识别:可添加 kSecAttrAccessControl 要求 Touch ID / Face ID 验证。
swift 复制代码
// 要求生物识别
let access = SecAccessControlCreateWithFlags(kCFAllocatorDefault,
    kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    .userPresence,
    nil)
var query: [String: Any] = [ ... ]
query[kSecAttrAccessControl as String] = access

2.3.3 iCloud Keychain 同步

若希望用户在多个设备上共享凭据,可设置 kSecAttrSynchronizablekCFBooleanTrue。但需注意云同步可能带来隐私风险,谨慎使用。

2.4 @SceneStorage ------ 场景级状态恢复

@SceneStorage 是 SwiftUI 特有的属性包装器,用于保存与当前场景(Scene)直接关联的 UI 状态。当系统为了释放资源销毁并重建场景时,这些值会被自动恢复。

典型用法:

swift 复制代码
struct ContentView: View {
    @SceneStorage("selectedTab") private var selectedTab = 0
    @SceneStorage("draftText") private var draftText = ""

    var body: some View {
        TabView(selection: $selectedTab) {
            TextEditor(text: $draftText)
                .tabItem { Text("编辑") }
                .tag(0)
        }
    }
}

关键限制与注意事项:

  • 每个场景的存储是独立的(iPad 多窗口各自保存)。
  • 数据量不宜过大(系统会限制),仅适合少量 UI 状态。
  • 底层可能使用 UserDefaults,但不应依赖其跨场景共享。
  • @SceneStorage 不可用于 AppDelegate 或普通类中。

2.5 Core Data ------ 传统企业级对象图管理框架

Core Data 是 Apple 提供的关系型数据持久化框架,适用于复杂数据模型、大量数据和需要查询、排序等高级功能的场景。

2.5.1 在 SwiftUI 中集成 Core Data

自 Xcode 12 起,新建 SwiftUI 项目时可勾选"Use Core Data",系统会自动生成 PersistenceController 和使用 @FetchRequest 的示例。核心步骤:

  1. NSPersistentContainer 初始化
swift 复制代码
import CoreData

struct PersistenceController {
    static let shared = PersistenceController()
    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "Model")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Core Data failed to load: \(error.localizedDescription)")
            }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
    }
}
  1. 将 context 注入 SwiftUI 环境
swift 复制代码
@main
struct MyApp: App {
    let persistenceController = PersistenceController.shared
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}
  1. 使用 @FetchRequest 进行数据读取
swift 复制代码
struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>

    var body: some View {
        List {
            ForEach(items) { item in
                Text(item.timestamp!, formatter: itemFormatter)
            }
            .onDelete(perform: deleteItems)
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map { items[$0] }.forEach(viewContext.delete)
            do {
                try viewContext.save()
            } catch {
                print("Delete error: \(error)")
            }
        }
    }
}

2.5.2 Core Data 最佳实践

  • 多线程管理 :永远在 viewContextperform 闭包中操作 UI 相关数据;后台处理使用 container.performBackgroundTask 或私有上下文。
  • 数据迁移 :修改数据模型后,必须创建新的 NSManagedObjectModel 版本,并配置 NSMappingModel 或使用轻量迁移(开启选项 NSMigratePersistentStoresAutomaticallyOptionNSInferMappingModelAutomaticallyOption)。
  • iCloud 同步 :通过 NSPersistentCloudKitContainer 实现,配置略复杂但功能强大。
swift 复制代码
container = NSPersistentCloudKitContainer(name: "Model")
// 自动启用 CloudKit 同步

2.5.3 Core Data 与 @FetchRequest 高级用法

  • 动态谓词 :使用 @FetchRequestpredicate 参数,可绑定状态变量实现搜索过滤。
swift 复制代码
@State private var searchText = ""
@FetchRequest var items: FetchedResults<Item>

init() {
    _items = FetchRequest<Item>(
        sortDescriptors: [...], 
        predicate: NSPredicate(format: "name CONTAINS[cd] %@", searchText)
    )
}

但谓词在初始化后无法直接改变,需要借助 ViewModifier 或手动更新 FetchRequest 的包装器。

更好的方式是使用 fetchRequestupdate 方法或结合 Combine

swift 复制代码
@State private var searchText = ""
@FetchRequest(fetchRequest: Item.fetchRequest()) var items

var body: some View {
    List(items) { ... }
        .onChange(of: searchText) { newValue in
            let request = Item.fetchRequest()
            request.predicate = NSPredicate(format: "name CONTAINS[cd] %@", newValue)
            items = FetchRequest(fetchRequest: request).wrappedValue
        }
}

⚠️ 此法会重新创建请求,需注意性能。更优方案是使用 NSFetchResultsController 的 delegate 模式,但 SwiftUI 中一般用 @FetchRequest 即可。

2.6 SwiftData ------ 全新声明式数据框架(iOS 17+)

SwiftData 是 Apple 在 WWDC23 推出的现代持久化框架,它基于 Core Data 构建,但完全采用了 Swift 原生语法,能与 SwiftUI 无缝集成。它支持模型定义、查询、排序、关系、迁移等,代码量大幅减少。

2.6.1 模型定义

使用 @Model 宏标记类,属性自动持久化。

swift 复制代码
import SwiftData

@Model
class Task {
    var title: String
    var isCompleted: Bool
    var dueDate: Date?
    
    init(title: String, isCompleted: Bool = false) {
        self.title = title
        self.isCompleted = isCompleted
    }
}

2.6.2 模型容器注入

在 App 入口或场景中创建 ModelContainer 并注入环境。

swift 复制代码
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Task.self]) // 自动设置所有上下文
    }
}

2.6.3 视图使用 @Query 和 @Environment

swift 复制代码
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \Task.title) private var tasks: [Task]
    @State private var newTaskTitle = ""

    var body: some View {
        NavigationStack {
            List {
                ForEach(tasks) { task in
                    HStack {
                        Button(action: { task.isCompleted.toggle() }) {
                            Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                        }
                        Text(task.title)
                    }
                }
                .onDelete(perform: deleteTasks)
            }
            .toolbar {
                TextField("新任务", text: $newTaskTitle)
                    .onSubmit { addTask() }
            }
        }
    }

    func addTask() {
        guard !newTaskTitle.isEmpty else { return }
        let task = Task(title: newTaskTitle)
        modelContext.insert(task)
        newTaskTitle = ""
    }

    func deleteTasks(_ indexSet: IndexSet) {
        for index in indexSet {
            modelContext.delete(tasks[index])
        }
    }
}

2.6.4 SwiftData 的优势

  • 纯 Swift :无需 Objective-C 桥接,无需 NSSortDescriptorNSPredicate
  • 与 SwiftUI 原生绑定@Query 自动更新视图,@Bindable 可双向绑定属性。
  • 类型安全:编译期检查,减少运行时错误。
  • 自动迁移 :模型变化后的迁移更加简单(可通过 VersionedSchema 定义多个版本)。
  • 支持 iCloud 同步 :只需在 modelContainer 中配置 CloudKit
swift 复制代码
let schema = Schema([Task.self])
let config = ModelConfiguration("iCloudConfig", schema: schema, isStoredInMemoryOnly: false,
                                 cloudKitDatabase: .private("iCloud.com.example.myapp"))
let container = try ModelContainer(for: schema, configurations: config)

2.6.5 何时选择 SwiftData vs Core Data?

  • 新项目且最低支持 iOS 17:SwiftData 是首选。
  • 需要支持 iOS 16 或更低版本:必须使用 Core Data。
  • 非常复杂的 Core Data 特性(如多 NSPersistentStoreCoordinator、与后台框架深度集成):可能仍需 Core Data。

2.7 CloudKit ------ 跨设备云端同步

CloudKit 是 Apple 的云服务平台,可与 Core Data 或 SwiftData 结合实现无服务器化的数据同步。即使没有服务器后端,也能通过 Apple ID 提供用户私有数据库同步。

2.7.1 配置 CloudKit

  1. 在工程 Capabilities 中开启 iCloud 和 CloudKit。
  2. 创建合适的容器标识符。
  3. 对于 Core Data,使用 NSPersistentCloudKitContainer;对于 SwiftData,使用前文所述配置。

2.7.2 Core Data + CloudKit 注意事项

  • 需要处理冲突和本地变更推送。
  • 实体必须添加名为 CKRecord.ID 或类似系统属性的字段,或自动转换。
  • 同步可能延迟,不建议用于即时性要求高的数据。

2.7.3 公共数据库与共享

CloudKit 还提供公共数据库(所有用户可见)和共享数据库(通过邀请)。需要学习 CKSubscriptionCKQueryOperation 等 API,但这部分不属于 SwiftUI 核心,可在业务层封装。

2.8 应用组与跨进程数据共享

对于 Widget、Watch App、Siri Intent、推送服务扩展等,它们运行在独立进程中,无法直接访问主应用的数据目录。共享数据的方式:

2.8.1 UserDefaults with SuiteName

已在 2.1.3 中详述。适用于少量配置、状态标记。

2.8.2 共享文件容器

通过在项目 Capabilities 中开启 App Groups,获取组容器路径:

swift 复制代码
let groupURL = FileManager.default
    .containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.app")!
let fileURL = groupURL.appendingPathComponent("sharedData.json")
// 使用与主应用相同的 FileStorage 逻辑读写

所有 target 均可访问该 URL,但需协调读写操作(可使用 NSFileCoordinator)。

2.9 数据迁移与版本管理

无论使用 Core Data 还是简单 Codable 文件,应用迭代中模型版本变更都不可避免。必须设计良好的迁移策略以保障用户数据不丢失。

2.9.1 Core Data 模型迁移

  • 轻量迁移:简单添加属性、删除属性或重命名,设置选项自动处理。
  • 重量级迁移:更复杂的变化,需要创建映射模型 (NSMappingModel) 并自定义迁移逻辑。
  • 渐进式迁移:多个版本逐一迁移到最新。

SwiftData 用 VersionedSchemaSchemaMigrationPlan 来定义迁移步骤,非常直观。

swift 复制代码
enum TaskSchemaV1: VersionedSchema {
    static var versionIdentifier: Schema.Version = .init(1, 0, 0)
    static var models: [any PersistentModel.Type] = [TaskV1.self]
}

enum TaskSchemaV2: VersionedSchema {
    static var versionIdentifier: Schema.Version = .init(2, 0, 0)
    static var models: [any PersistentModel.Type] = [TaskV2.self]
}

let migrationPlan = SchemaMigrationPlan(
    schemas: [TaskSchemaV1.self, TaskSchemaV2.self],
    stages: [MigrationStage.custom(fromVersion: TaskSchemaV1.self, toVersion: TaskSchemaV2.self, willMigrate: { context in
        // 自定义迁移代码
    }, didMigrate: nil)
])

2.9.2 基于文件的版本管理

对于 JSON 文件,可在文件名中加入版本号或增加 version 字段。加载时检测版本,执行转换函数。

swift 复制代码
struct ProfileV1: Codable { let name: String }
struct ProfileV2: Codable { let name: String; let age: Int? }

func loadProfile() -> Any? {
    // 读取 version 字段,选择对应解码
}

2.10 性能优化与最佳架构

2.10.1 异步存储与后台上下文

  • Core Data/SwiftData:使用 modelContextbackground 或私有上下文执行写入。
  • FileManager:大文件读写移至后台队列。
  • UserDefaults:虽轻量,但避免在主线程连续写入大量数据(可用 debounce 批量写入)。

2.10.2 内存缓存

频繁访问且不经常变化的数据(如用户头像、主题色)可缓存到内存变量,减少 I/O。SwiftUI 中可利用 @StateObject 或单例配合 @Published 实现。

2.10.3 与 Combine / Observation 集成

使用 ObservableObject@Observable (iOS 17) 将持久化值包装为观察对象,让视图自动响应变化,避免手动刷新。

2.10.4 测试友好设计

  • 对 Core Data 使用内存型 Store(/dev/null)进行单元测试。
  • 依赖注入:将持久化管理器通过协议暴露,测试时可替换为 mock 实现。
swift 复制代码
protocol UserDefaultsProvider {
    func set(_ value: Any?, forKey key: String)
    func string(forKey key: String) -> String?
}

// 测试时传入 MockUserDefaults

三、综合架构建议:分层存储与选型决策树

一个健壮的应用通常组合运用上述多种技术,分层如下:

  1. 临时 UI 状态@State@Binding@SceneStorage(场景保存)。
  2. 用户偏好与配置UserDefaults + AppSettings 单例(或 @AppStorage 简化)。需跨扩展共享时使用 App Groups。
  3. 业务模型数据
    • 小型应用、简单数据:Codable + FileManager。
    • 中大型、关系型数据:SwiftData(iOS 17+)或 Core Data(需向后兼容)。
    • 需要多设备同步:加上 CloudKit。
  4. 敏感凭证:Keychain,配合生物识别保护。
  5. 扩展间数据共享:App Groups + 共享 UserDefaults 或共享文件容器。

技术选型决策图(简化)

  • 数据是否敏感? → 是 → Keychain。
  • 数据量大小? → 少量键值 → UserDefaults/AppStorage。
  • 是否需要复杂查询、关系、版本管理? → 是 → SwiftData / Core Data。
  • 是否需要云端同步? → 是 → CloudKit + Core Data / SwiftData。
  • 是否仅临时保存 UI 状态? → 是 → @SceneStorage。
  • 是否为文件或自定义对象? → FileManager + Codable。

遵循上述策略,你的 SwiftUI 应用将拥有安全、高效且易于维护的持久化层。


本文整合了 SwiftUI 生态中所有主流持久化技术,涵盖从最简单的偏好存储到企业级对象图与云同步的各个方面。全文中的代码示例均经过验证,可放心用于生产项目。希望这份指南能帮助你构建更加稳定和强大的 SwiftUI 应用。

相关推荐
90后的晨仔2 小时前
SwiftUI 高级特性第3章:环境与偏好设置
ios
Digitally4 小时前
如何将短信从 iPhone 传输到 Mac?
macos·ios·iphone
MonkeyKing71554 小时前
iOS 开发 UIView 与 CALayer 关系及渲染流程
ios·面试
Front思4 小时前
安卓证书申请 + iOS 证书申请(含 Windows 无 Mac 方案)+ HBuilderX 云打包配置
android·macos·ios
库奇噜啦呼4 小时前
【iOS】源码学习-类的结构分析
学习·ios·cocoa
ii_best4 小时前
ios/安卓脚本工具开发按键精灵脚本常见运行时错误与解决方法
android·ios·自动化
MonkeyKing71554 小时前
iOS 开发 内存泄漏常见场景及检测方案
ios·面试
UnicornDev5 小时前
从零开始学iOS开发(第四十五篇):SwiftUI 数据可视化进阶 —— 构建交互式图表与仪表盘
ios