Shapes Gestures Persistence

GeometryReader

GeometryReader 是 SwiftUI 里专门用来读取父视图尺寸和位置信息的容器。很多人第一次用它都会被坑,因为它看起来像个布局工具,实际上它的主要职责是: 获取几何信息(Geometry),顺便参与布局。

GeometryReader { geometry in
    Text("Hello")
}
// 其中
geometry.size.width
geometry.size.height
// 表示 GeometryReader 实际获得的空间的大小

例如

struct ContentView: View {
    var body: some View {
        GeometryReader { geo in
            VStack {
                Text("宽度: \(Int(geo.size.width))")
                Text("高度: \(Int(geo.size.height))")
            }
        }
    }
}

运行后会显示当前可用区域大小

struct ContentView: View {
    var body: some View {
        GeometryReader { geo in
            Color.red
        }
    }
}

红色会铺满整个 GeometryReader 获得的空间。

获取百分比布局,例如做响应式 UI

GeometryReader { geo in
    Rectangle()
        .fill(.blue)
        .frame(width: geo.size.width * 0.8)
}

获取安全区域

GeometryReader { geo in

    Text("Top Safe Area")

    Text("\(geo.safeAreaInsets.top)")
}

获取坐标,GeometryReader 最强大的地方

import SwiftUI

struct ContentView: View {

    var body: some View {

        GeometryReader { geo in

            VStack(spacing: 20) {

                Text("宽: \(Int(geo.size.width))")
                Text("高: \(Int(geo.size.height))")

                Rectangle()
                    .fill(.blue)
                    .frame(
                        width: geo.size.width * 0.8,
                        height: 100
                    )

                Circle()
                    .fill(.green)
                    .frame(
                        width: min(
                            geo.size.width,
                            geo.size.height
                        ) * 0.3
                    )
            }
            .frame(
                maxWidth: .infinity,
                maxHeight: .infinity
            )
        }
    }
}

#Preview {
    ContentView()
}

Shapes 形状

Shape 是一个继承自 View 的协议,换句话说 所有的 Shape 也都是 View,SwiftUI 已经 内置了许多 Shape,例如:RoundedRectangle 圆角矩形、Circle 圆形、Capsule 胶囊形 等等。

自定义 Shape

如果想创建属于自己的 Shape(自定义形状)怎么办?Shape 协议(通过扩展)已经帮你实现了 View 的 body 属性。 但是,它新增了一个你必须实现的方法:

func path(in rect: CGRect) -> Path {
    return a Path
}

在这个方法里,你需要创建并返回一个 Path,它可以绘制任何你想要的图形。Path 提供了大量用于绘图的函数(可以查看官方文档)。

它能够组合:直线 lines、圆弧 arcs、贝塞尔曲线 Bezier curves 等等,从而构成一个完整的 Shape 形状。

import SwiftUI

struct Star: SwiftUI.Shape{
    var points: Int = 5
    var sharpness: CGFloat = 0.5     // 内外半径比

    func path(in rect: CGRect) -> Path {
        var path = Path()
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let outer = min(rect.width, rect.height) / 2
        let inner = outer * sharpness
        let step = Double.pi / Double(points)

        for i in 0..<(points * 2) {
            let radius = i % 2 == 0 ? outer : inner
            let angle = Double(i) * step - .pi / 2
            let p = CGPoint(
                x: center.x + CGFloat(cos(angle)) * radius,
                y: center.y + CGFloat(sin(angle)) * radius
            )
            if i == 0 { path.move(to: p) }
            else     { path.addLine(to: p) }
        }
        path.closeSubpath()
        return path
    }
}

struct ContentView: View {

    var body: some View {
        Star()
    }
}

#Preview {
    ContentView()
}

GeometryReader 里用 Path

import SwiftUI

struct ContentView: View {

    var body: some View {
        GeometryReader { geo in

            Path { path in

                path.move(
                    to: CGPoint(
                        x: geo.size.width / 2,
                        y: 0
                    )
                )

                path.addLine(
                    to: CGPoint(
                        x: geo.size.width,
                        y: geo.size.height
                    )
                )

                path.addLine(
                    to: CGPoint(
                        x: 0,
                        y: geo.size.height
                    )
                )

                path.closeSubpath()
            }
            .fill(.red)
        }
    }
}

#Preview {
    ContentView()
}

.contentShape

.contentShape() 是 SwiftUI 中一个非常重要但容易被忽略的 Modifier,它的作用是:

定义 View 哪一部分参与命中测试(Hit Testing),也就是响应点击、手势、拖拽、Hover 等交互的区域。

例如

Text("Hello")
    .padding()
    .background(.blue)
    .onTapGesture {
        print("Tap")
    }

看起来整个蓝色区域都应该可以点击。实际上:默认只有 Text 本身可以点击。padding 出来的空白可能并不会参与 Hit Test。

Text("Tap Me")
    .padding(50)
    .background(.blue)
    .foregroundStyle(.white)
    .onTapGesture {
        print("点击")
    }

整个 Rectangle 都变成点击区域。加入 contentShape。

Modifier 作用 是否影响显示 是否影响点击
.frame() 改变布局大小 ❌(不直接决定命中区域)
.background() 添加背景
.clipShape() 裁剪显示 ❌(通常不改变命中区域)
.contentShape() 定义命中测试区域

Gestures 手势

获取用户输入,SwiftUI 拥有强大的原语,用于识别由用户手指做出的“手势”(“gestures”)。这被称为多点触控(因为可以同时涉及多根手指)。

当多点触控手势发生时,SwiftUI 几乎完全负责了“识别”工作。你唯一需要做的就是去处理这些手势。换句话说,决定当用户进行拖拽(drags)、捏合(pinches)或轻点(taps)时该做什么。

识别手势

让你的视图 View 识别手势,为了让你的视图开始识别某种特定的手势,你需要使用 .gesture 视图修饰符(View modifier)。

myView.gesture(theGesture) // theGesture 必须实现 Gesture 协议 (protocol)

创建手势

创建一个手势,通常,theGesture 将由你创建的某个函数(func)或计算属性(computed var)来创建。 或者也可能是你视图(View)的 body 属性内部的一个局部变量(local var)。

var theGesture: some Gesture {
    return TapGesture(count: 2)
}

这恰好是一个“双击”手势(因为 count: 2)。SwiftUI 现在会识别这个 TapGesture,但它不会对此做任何事情……

处理手势

如何对一个被识别出的手势做点什么呢,这取决于该手势是离散的还是非离散(连续)的。

处理离散手势

TapGesture(轻点手势)是一个离散手势。它属于“一次性发生”(all at once),并且在被识别时只做一件事。

LongPressGesture(长按手势)也可以被当作离散手势来处理。为了在识别出离散手势时“做点什么”,我们使用 .onEnded { }

var theGesture: some Gesture {
    return TapGesture(count: 2)
        .onEnded { /* 做点什么 do something */ }
}

离散手势还拥有你已经熟知的“便捷视图修饰符”(convenience view modifiers)。

myView.onTapGesture(count: Int) { /* 做点什么 do something */ }
myView.onLongPressGesture(...) { /* 做点什么 do something */ }
import SwiftUI

struct ContentView: View {

    @State private var message = "等待手势..."
    @State private var color: Color = .blue
    @State private var tapCount = 0
    @State private var longPressCount = 0

    var body: some View {
        VStack(spacing: 30) {

            Text("离散手势 Demo")
                .font(.largeTitle.bold())

            //--------------------------------------
            // 使用 Gesture + onEnded
            //--------------------------------------
            RoundedRectangle(cornerRadius: 20)
                .fill(color)
                .frame(width: 250, height: 120)
                .overlay(
                    Text("双击我")
                        .font(.title2)
                        .foregroundStyle(.white)
                )
                .gesture(doubleTapGesture)

            //--------------------------------------
            // 使用便捷 Modifier
            //--------------------------------------
            RoundedRectangle(cornerRadius: 20)
                .fill(.green)
                .frame(width: 250, height: 120)
                .overlay(
                    Text("长按我")
                        .font(.title2)
                        .foregroundStyle(.white)
                )
                .onLongPressGesture(minimumDuration: 1.0) {
                    longPressCount += 1
                    message = "长按成功!"

                    color = .orange
                }

            //--------------------------------------
            // 使用 onTapGesture
            //--------------------------------------
            RoundedRectangle(cornerRadius: 20)
                .fill(.purple)
                .frame(width: 250, height: 120)
                .overlay(
                    Text("单击我")
                        .font(.title2)
                        .foregroundStyle(.white)
                )
                .onTapGesture {
                    message = "点击了紫色区域"
                }

            Divider()

            VStack(alignment: .leading, spacing: 10) {

                Text("状态信息")
                    .font(.headline)

                Text("消息:\(message)")
                Text("双击次数:\(tapCount)")
                Text("长按次数:\(longPressCount)")
            }

            Spacer()
        }
        .padding()
        .animation(.smooth, value: color)
    }

    // MARK: - TapGesture + onEnded

    var doubleTapGesture: some Gesture {
        TapGesture(count: 2)
            .onEnded {

                tapCount += 1

                message = "检测到双击!"

                color = color == .blue ? .red : .blue

                print("Double Tap")
            }
    }
}

#Preview {
    ContentView()
}

处理非离散手势

其他手势是非离散的(例如:DragGesture 拖拽、MagnifyGesture 缩放、RotationGesture 旋转)。

对于这些手势,你可以在手势的 value(值)发生变化时同步更新你的状态。

var theGesture: some Gesture {
    DragGesture(...)
        .onChanged { value in /* 根据 value 改变某些视图或模型的状态 change some View or Model state based on value */ }
        .onEnded { value in /* 在手势结束时做点什么 do something at the end of the gesture */ }
}

这个 .onChanged 会导致你传给它的闭包在手指移动时被调用。该 value 的具体内容因手势而异。

import SwiftUI

struct ContentView: View {

    // 当前偏移
    @State private var offset: CGSize = .zero

    // 当前缩放
    @State private var scale: CGFloat = 1.0

    // 当前旋转角度
    @State private var rotation: Angle = .zero

    var body: some View {

        VStack(spacing: 30) {

            Text("非离散手势 Demo")
                .font(.largeTitle.bold())

            Text("""
拖拽:移动方块
双指缩放:改变大小
双指旋转:旋转方块
""")
                .multilineTextAlignment(.center)

            RoundedRectangle(cornerRadius: 20)
                .fill(.blue.gradient)
                .frame(width: 180, height: 180)

                // 当前状态
                .scaleEffect(scale)
                .rotationEffect(rotation)
                .offset(offset)

                // 同时响应多个手势
                .gesture(dragGesture)
                .simultaneousGesture(magnifyGesture)
                .simultaneousGesture(rotationGesture)

                .animation(.spring(), value: offset)
                .animation(.spring(), value: scale)
                .animation(.spring(), value: rotation)

            Divider()

            VStack(alignment: .leading, spacing: 8) {

                Text("当前状态")
                    .font(.headline)

                Text("Offset : x = \(Int(offset.width)), y = \(Int(offset.height))")
                Text(String(format: "Scale : %.2f", scale))
                Text(String(format: "Rotation : %.1f°", rotation.degrees))
            }

            Spacer()
        }
        .padding()
    }

    // MARK: - DragGesture

    var dragGesture: some Gesture {

        DragGesture()

            .onChanged { value in

                // value.translation 表示拖动距离
                offset = value.translation
            }

            .onEnded { value in

                print("拖拽结束")
                print("起点:\(value.startLocation)")
                print("终点:\(value.location)")
                print("拖动距离:\(value.translation)")
            }
    }

    // MARK: - MagnifyGesture

    var magnifyGesture: some Gesture {

        MagnifyGesture()

            .onChanged { value in

                // value.magnification 是当前缩放比例
                scale = value.magnification
            }

            .onEnded { value in

                print("缩放结束:\(value.magnification)")
            }
    }

    // MARK: - RotationGesture

    var rotationGesture: some Gesture {

        RotateGesture()

            .onChanged { value in

                rotation = value.rotation
            }

            .onEnded { value in

                print("旋转结束:\(value.rotation.degrees)°")
            }
    }
}

#Preview {
    ContentView()
}

Persistence 持久化

Storing Data Permanently 永久存储数据,除了 SwiftData 之外,在 iOS 中还有很多种数据 持久化 的 方法

这个示例展示了一个宏观的持久化概念对比,使用 @AppStorage 底层是 UserDefaults 存储轻量级偏好设置,并提示了其他持久化方案的存在

import SwiftUI

struct ContentView: View {
    // 使用 @AppStorage 演示轻量级数据持久化 (底层为 UserDefaults)
    @AppStorage("appLaunchCount") private var launchCount: Int = 0
    @State private var showAdvancedDB = false
    
    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "internaldrive")
                .font(.system(size: 60))
                .foregroundColor(.blue)
            
            Text("Persistence 持久化")
                .font(.largeTitle.bold())
            
            Text("本应用已启动次数: \(launchCount)")
                .font(.title2)
            
            Text("这种轻量级数据适合使用 UserDefaults。")
                .multilineTextAlignment(.center)
                .foregroundColor(.secondary)
                .padding()
            
            Button("模拟一次启动") {
                launchCount += 1
            }
            .buttonStyle(.borderedProminent)
            
            Divider()
            
            Button("查看其他持久化方案") {
                showAdvancedDB = true
            }
        }
        .padding()
        .onAppear {
            // 每次视图出现,计数器+1
            launchCount += 1
        }
        .confirmationDialog("其他持久化方案", isPresented: $showAdvancedDB, titleVisibility: .visible) {
            Button("SwiftData") { }
            Button("File System (FileManager)") { }
            Button("CloudKit") { }
            Button("Firebase (第三方)") { }
            Button("取消", role: .cancel) { }
        } message: {
            Text("除了 UserDefaults,还可以使用上述方案进行重量级数据持久化。")
        }
    }
}

#Preview {
    ContentView()
}

File System 文件系统

你的应用程序看到的 iOS 文件系统就像一个普通的 Unix 文件系统。

它始于根目录 /,当然 这里也存在文件保护机制,就像普通的 Unix 一样,所以你无法看到所有的内容。 事实上,你只能在你的应用程序“沙盒 sandbox” 中进行读取和(有时可以进行)写入操作。

为什么需要沙盒?

沙盒里都有什么?

访问文件系统:

当我们访问文件系统时,我们总是从一个指向特定目录的 URL 开始。这些目录可以通过 URL 的静态属性来获取……

这个示例演示了如何获取并展示沙盒中不同的系统目录 URL

import SwiftUI

struct ContentView: View {
    @State private var directories: [(String, URL)] = []
    
    var body: some View {
        NavigationStack {
            List(directories, id: \.0) { item in
                VStack(alignment: .leading, spacing: 4) {
                    Text(item.0)
                        .font(.headline)
                    Text(item.1.path)
                        .font(.caption)
                        .foregroundColor(.secondary)
                        .lineLimit(2)
                }
            }
            .navigationTitle("File System 沙盒")
            .toolbar {
                Button("获取目录") {
                    fetchDirectories()
                }
            }
        }
    }
    
    private func fetchDirectories() {
        directories = []
        // 获取系统提供的沙盒目录 URL
        directories.append(("Documents 目录", URL.documentsDirectory))
        directories.append(("Application Support 目录", URL.applicationSupportDirectory))
        directories.append(("Caches 目录", URL.cachesDirectory))
        // 获取 App Bundle 目录
        if let bundleURL = Bundle.main.bundleURL.path(percentEncoded: false).split(separator: "/").dropLast().joined(separator: "/").asURL {
             directories.append(("Bundle 目录", bundleURL))
        }
    }
}

// 简单的 String 转 URL 扩展,仅为演示
extension String {
    var asURL: URL? {
        URL(string: "file:///" + self)
    }
}

#Preview {
    ContentView()
}

URL

在这些系统路径之上构建

URL 方法:

示例, 在文档目录下拼接一个名为 “filename.doc” 的文件路径

let url = URL.documentsDirectory.appendingPathComponent("filename.doc")

了解 URL 的另一端有些什么

var isFileURL: Bool // is this a file URL (whether file exists or not) or something else?

这是一个文件类型的 URL 吗(无论该文件实际是否存在),还是其他类型的 URL?

func resourceValues(forKeys: [URLResourceKey]) throws -> [URLResourceKey: Any]?

获取指定键的资源属性值,该方法可能会抛出异常

示例键名:.creationDateKey(创建日期键)、.isDirectoryKey(是否为目录键)、.fileSizeKey(文件大小键)

Data

从 URL 读取二进制数据,

init(contentsOf: URL, options: Data.ReadingOptions) throws

例如

let data = try Data(contentsOf: url)

这里让 options 保持默认值 [] ,这个函数会 throw 抛出错误。

如果这个 URL 是网络 URL,这个方法并不能正确用于网络请求,而且它会阻塞线程(blocks),因此绝不要用它来读取网络资源。 它只应该用于读取本地文件(file URL)。

异步读取 URL 中的数据

let (data, urlresponse) =
    try await URLSession.shared.data(from: url)

这个方法既可能 throw 抛出错误,又是 async 异步的,因此需要使用 await。

urlresponse 中包含了:

很多时候我们会忽略这些内容,只使用 data。

由于这里需要 await,因此它必须运行在 异步执行环境(asynchronous execution context)中。

例如:

将二进制数据写入文件 URL:

func write(
    to url: URL,
    options: Data.WritingOptions
) throws -> Bool

写入选项可以是,

try data.write(
    to: fileURL,
    options: [.atomic, .withoutOverwriting]
)

这个示例演示了如何在系统目录上拼接路径组件和扩展名,并检查 URL 属性

import SwiftUI

struct ContentView: View {
    @State private var fileURL: URL?
    @State private var isFileURL = false
    @State private var fileExists = false
    
    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "link")
                .font(.system(size: 50))
            
            Text("URL 路径拼接与检查")
                .font(.title2.bold())
            
            if let url = fileURL {
                VStack(spacing: 10) {
                    Text("完整路径:")
                        .font(.headline)
                    Text(url.path)
                        .padding()
                        .background(Color.gray.opacity(0.1))
                        .cornerRadius(8)
                    
                    Text("isFileURL: \(isFileURL ? "" : "")")
                    Text("文件是否存在: \(fileExists ? "" : "")")
                }
                .padding()
            } else {
                Text("点击下方按钮构建 URL")
                    .foregroundColor(.secondary)
            }
            
            Button("构建并检查 URL") {
                buildAndCheckURL()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
    
    private func buildAndCheckURL() {
        // 1. 拼接路径组件
        let docURL = URL.documentsDirectory
        // 2. 拼接文件名和扩展名
        let finalURL = docURL.appendingPathComponent("myProfile").appendingPathExtension("json")
        
        fileURL = finalURL
        isFileURL = finalURL.isFileURL
        
        // 检查文件是否存在
        fileExists = FileManager.default.fileExists(atPath: finalURL.path)
    }
}

#Preview {
    ContentView()
}

FileManager 文件管理器

提供各种文件操作功能,例如:

fileExists(atPath: String) -> Bool

用于判断指定路径上的文件是否存在:还可以完成:

线程安全 Thread Safety,只要同一个 FileManager 实例始终只在一个线程中使用,它就是线程安全的。

Delegate(代理),FileManager 还提供了 Delegate(代理)。当某些事件发生时,会调用代理对象中的对应方法,你可以借此介入文件操作过程。

这个示例演示了使用 FileManager 创建目录、检查存在性以及删除文件。

import SwiftUI

struct ContentView: View {
    @State private var logText = ""
    private let fm = FileManager.default
    
    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "folder.badge.gearshape")
                .font(.system(size: 50))
            
            Text("FileManager 演示")
                .font(.title2.bold())
            
            ScrollView {
                Text(logText)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
            .frame(height: 150)
            .padding()
            .background(Color.gray.opacity(0.1))
            .cornerRadius(8)
            
            HStack {
                Button("创建目录") { createDirectory() }
                Button("检查存在") { checkExistence() }
                Button("删除目录") { removeDirectory() }
            }
            .buttonStyle(.bordered)
        }
        .padding()
        .onAppear { logText = "准备就绪...\n" }
    }
    
    private var customDirURL: URL {
        URL.documentsDirectory.appendingPathComponent("MyCustomFolder")
    }
    
    private func createDirectory() {
        do {
            try fm.createDirectory(at: customDirURL, withIntermediateDirectories: true)
            logText += "✅ 目录已创建: \(customDirURL.lastPathComponent)\n"
        } catch {
            logText += "❌ 创建失败: \(error.localizedDescription)\n"
        }
    }
    
    private func checkExistence() {
        let exists = fm.fileExists(atPath: customDirURL.path)
        logText += exists ? "🔍 目录存在\n" : "🔍 目录不存在\n"
    }
    
    private func removeDirectory() {
        do {
            try fm.removeItem(at: customDirURL)
            logText += "🗑️ 目录已删除\n"
        } catch {
            logText += "❌ 删除失败: \(error.localizedDescription)\n"
        }
    }
}

#Preview {
    ContentView()
}

Bundle 资源包

用于访问随应用一起打包发布的资源文件(支持本地化资源)。例如

图片、JSON、音频、字体、txt、plist、等各种资源,可以根据 文件名、文件扩展名 搜索并获取资源文件。

这个示例演示了如何从 App Bundle 中读取内置的资源文件(这里以读取项目自带的 Info.plist 为例)

import SwiftUI

struct ContentView: View {
    @State private var bundleFiles: [String] = []
    @State private var plistContent = ""
    
    var body: some View {
        VStack(spacing: 15) {
            Image(systemName: "shippingbox")
                .font(.system(size: 50))
            
            Text("Bundle 资源包读取")
                .font(.title2.bold())
            
            Text("读取 App 内置的 Info.plist:")
                .font(.headline)
            
            ScrollView {
                Text(plistContent)
                    .font(.system(.caption, design: .monospaced))
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
            .frame(maxHeight: 200)
            .padding()
            .background(Color.gray.opacity(0.1))
            .cornerRadius(8)
        }
        .padding()
        .onAppear {
            readInfoPlist()
        }
    }
    
    private func readInfoPlist() {
        // 1. 在 Bundle 中查找指定名称和扩展名的资源
        if let url = Bundle.main.url(forResource: "Info", withExtension: "plist") {
            plistContent += "找到资源路径:\n\(url.path)\n\n"
            
            // 2. 读取内容
            do {
                let data = try Data(contentsOf: url)
                if let dict = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] {
                    plistContent += "内容解析成功:\n"
                    // 打印前几个 Key
                    for (key, value) in dict.prefix(3) {
                        plistContent += "\(key): \(value)\n"
                    }
                }
            } catch {
                plistContent += "读取失败: \(error)"
            }
        } else {
            plistContent = "未找到 Info.plist"
        }
    }
}

#Preview {
    ContentView()
}

Codable 机制

本质上,它是一种将对象中的所有变量收集到一个可持久化的数据块中(例如 JSON 数据)的方式。

它是让 struct(结构体) 或 class(类) 能够持久化(保存到文件系统或其他地方)的一个非常好的方法。为了能够工作,你想要持久化的类型必须实现 Codable 协议。

对于只包含其他 Codable 类型的结构体(struct),Swift 会自动为你合成(synthesize)Codable 实现。

对于 enum(枚举) 也是如此,但前提是它没有关联值(associated data),或者关联值本身也是 Codable。

如果你的所有变量并不是 Codable(或者你使用的是 class),那么你就必须自己编写代码来实现 Codable。

已经实现 Codable 的标准类型,

String、Bool、Int、Double、Float、URL、Optional、Array、Dictionary、 Set、Data、Date、DateComponents、DateInterval、Calendar、CGFloat、 AffineTransform、CGPoint、CGSize、CGRect、CGVector

这个示例演示了 Swift 自动合成的 Codable,将一个结构体数组编码为 JSON 并解码回来。

import SwiftUI

struct Student: Codable {
    var name: String
    var age: Int
    var gpa: Double
}

struct ContentView: View {
    @State private var jsonString = ""
    
    var body: some View {
        VStack(spacing: 15) {
            Image(systemName: "tray.and.arrow.down")
                .font(.system(size: 50))
            
            Text("Codable 自动合成")
                .font(.title2.bold())
            
            ScrollView {
                Text(jsonString)
                    .font(.system(.body, design: .monospaced))
                    .padding()
            }
            .frame(maxHeight: .infinity)
            .background(Color.gray.opacity(0.1))
            .cornerRadius(10)
        }
        .padding()
        .onAppear {
            encodeDecodeDemo()
        }
    }
    
    private func encodeDecodeDemo() {
        let students = [
            Student(name: "张三", age: 20, gpa: 3.8),
            Student(name: "李四", age: 21, gpa: 3.5)
        ]
        
        do {
            // 编码为 JSON
            let encoder = JSONEncoder()
            encoder.outputFormatting = .prettyPrinted
            let jsonData = try encoder.encode(students)
            
            // 转换为字符串展示
            jsonString = String(data: jsonData, encoding: .utf8) ?? "转换失败"
            
            // 解码回对象
            let decodedStudents = try JSONDecoder().decode([Student].self, from: jsonData)
            jsonString += "\n\n✅ 成功解码回来,第一个学生是: \(decodedStudents.first?.name ?? "未知")"
            
        } catch {
            jsonString = "Error: \(error)"
        }
    }
}

#Preview {
    ContentView()
}

Codable 的实现

Codable Implementation,如果你无法获得编译器自动合成(automatic synthesis)的 Codable 实现,那么你就需要自己实现它。

添加一个解码初始化方法 (Initializer)为你的 struct 或 class 添加一个初始化方法(也可以写在 extension 中):

init(from decoder: Decoder) throws {
    ...
}

实现编码方法,还需要实现一个编码函数

func encode(to encoder: Encoder) throws {
    ...
}

在这两个函数内部,你需要使用:Decoder(解码器)、Encoder(编码器)来保存(store)和恢复(restore)你的各个成员变量。

编码器/解码器(encoder/decoder)有一个名为 container(容器)的变量,你可以用它来存储/恢复变量……

init(from decoder: Decoder) throws {
    self.init() // 如果是一个类(class)
    let container = try decoder.container(keyedBy: CodingKeys.self)
    name = try container.decode(String.self, forKey: .name) // name 是我的变量之一
    ...
}

func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(name, forKey: .name)
}

此外,你必须在上方定义一个名为 CodingKeys 的枚举(enum),并为你的每一个变量(vars)提供一个 case……

enum CodingKeys: String, CodingKey { // 必须遵循 CodingKey 协议
    case name
    case fubar = "foo"
    . . .
}

这个示例演示了当变量名与JSON键名不同时,如何手动实现 Codable 和 CodingKeys。

import SwiftUI

// 自定义实现 Codable
struct ServerConfig: Codable {
    var appName: String
    var apiKey: String
    
    // 定义 CodingKeys 映射代码名与 JSON 键名
    enum CodingKeys: String, CodingKey {
        case appName = "application_name" // JSON 中叫 application_name
        case apiKey = "api_key"           // JSON 中叫 api_key
    }
    
    init(appName: String, apiKey: String) {
        self.appName = appName
        self.apiKey = apiKey
    }
    
    // 自定义解码
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        appName = try container.decode(String.self, forKey: .appName)
        apiKey = try container.decode(String.self, forKey: .apiKey)
    }
    
    // 自定义编码
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(appName, forKey: .appName)
        try container.encode(apiKey, forKey: .apiKey)
    }
}

struct ContentView: View {
    @State private var resultText = ""
    
    var body: some View {
        VStack(spacing: 15) {
            Image(systemName: "hammer")
                .font(.system(size: 50))
            
            Text("Codable 手动实现")
                .font(.title2.bold())
            
            ScrollView {
                Text(resultText)
                    .font(.system(.body, design: .monospaced))
                    .padding()
            }
            .frame(maxHeight: .infinity)
            .background(Color.gray.opacity(0.1))
            .cornerRadius(10)
        }
        .padding()
        .onAppear {
            manualCodableDemo()
        }
    }
    
    private func manualCodableDemo() {
        let originalConfig = ServerConfig(appName: "MySwiftApp", apiKey: "12345-ABCDE")
        
        do {
            let encoder = JSONEncoder()
            encoder.outputFormatting = .prettyPrinted
            
            // 编码
            let jsonData = try encoder.encode(originalConfig)
            let jsonString = String(data: jsonData, encoding: .utf8)!
            resultText = "编码后的 JSON:\n\(jsonString)\n\n"
            
            // 模拟从服务端获取到的 JSON 进行解码
            let mockServerJSON = """
            {
              "application_name" : "DownLoadedApp",
              "api_key" : "99999-ZZZZZ"
            }
            """
            let data = mockServerJSON.data(using: .utf8)!
            let decodedConfig = try JSONDecoder().decode(ServerConfig.self, from: data)
            
            resultText += "解码后的对象:\nappName: \(decodedConfig.appName)\napiKey: \(decodedConfig.apiKey)"
            
        } catch {
            resultText = "Error: \(error)"
        }
    }
}

#Preview {
    ContentView()
}

JSON

一旦你的结构体 (struct)、类 (class) 或枚举 (enum) 实现了 Codable 协议,你就可以将它转换为 JSON(一种标准格式)。

struct MyType: Codable {
    var someDate: Date
    var someString: String
    var other: SomeOtherType // SomeOtherType 也必须是 Codable 的!
}

这个结构体可以被转换为 JSON,看起来会像下面这样

{
    "someDate" : "2017-11-05T16:30:00Z",
    "someString" : "Hello",
    "other" : <无论 SomeOtherType  JSON 中长什么样>
}

如何将编码 Codable 转换为 JSON 格式

let object: MyType = ... // MyType 必须遵循 Codable 协议
let jsonData: Data = try JSONEncoder().encode(object)

JSON 是一种文本格式,你可以像这样用你的 jsonData 生成一个 JSON 字符串

let jsonString = String(data: jsonData, encoding: .utf8) // JSON 总是使用 UTF-8 编码

也可以直接将 jsonData 写入到通过 FileManager 获取的指定 URL 文件中

try jsonData.write(to: url) // 注意:这同样会抛出异常

如何将 jsonData 转换回结构体 类

if let myObject: MyType = try? JSONDecoder().decode(MyType.self, from: jsonData) {
    // 使用刚从 jsonData 解码出来的 myObject 执行某些操作
}

这个示例专注于将获取到的 JSON 数据写入本地文件,然后再从文件读取并解码。

import SwiftUI

struct MyBook: Codable, Identifiable {
    var id: Int
    var title: String
    var author: String
}

struct ContentView: View {
    @State private var books: [MyBook] = []
    @State private var statusMessage = ""
    
    var body: some View {
        VStack {
            Image(systemName: "books.vertical")
                .font(.system(size: 50))
                .padding(.top)
            
            Text("JSON 文件读写")
                .font(.title2.bold())
            
            Text(statusMessage)
                .foregroundColor(.blue)
            
            List(books) { book in
                VStack(alignment: .leading) {
                    Text(book.title).bold()
                    Text("作者: \(book.author)").font(.caption).foregroundColor(.gray)
                }
            }
            
            HStack {
                Button("保存 JSON 到文件") {
                    saveJSONToFile()
                }
                .buttonStyle(.borderedProminent)
                
                Button("从文件加载 JSON") {
                    loadJSONFromFile()
                }
                .buttonStyle(.bordered)
            }
            .padding()
        }
    }
    
    private var fileURL: URL {
        URL.documentsDirectory.appendingPathComponent("books.json")
    }
    
    private func saveJSONToFile() {
        let sampleBooks = [
            MyBook(id: 1, title: "SwiftUI 实战", author: "Apple"),
            MyBook(id: 2, title: "iOS 编程", author: "Big Nerd Ranch")
        ]
        
        do {
            // 1. 编码为 JSON Data
            let jsonData = try JSONEncoder().encode(sampleBooks)
            // 2. 写入文件系统
            try jsonData.write(to: fileURL, options: [.atomic])
            statusMessage = "✅ 保存成功! (\(fileURL.lastPathComponent))"
            books = sampleBooks
        } catch {
            statusMessage = "❌ 保存失败: \(error.localizedDescription)"
        }
    }
    
    private func loadJSONFromFile() {
        do {
            // 1. 从文件读取 Data
            let data = try Data(contentsOf: fileURL)
            print(fileURL) // macos 访达 + shift command + g 前往路径 可以看到 book.json
            // 2. 解码为对象数组
            let decodedBooks = try JSONDecoder().decode([MyBook].self, from: data)
            books = decodedBooks
            statusMessage = "✅ 读取成功,共 \(decodedBooks.count) 本书。"
        } catch {
            statusMessage = "❌ 读取失败: \(error.localizedDescription) (请先点击保存)"
            books = []
        }
    }
}

#Preview {
    ContentView()
}

UserDefaults

UserDefaults 是一个非常简单、持久化的、类似于字典(dictionary-like)的东西。

要使用它,你需要一个 UserDefaults 的实例。最常用的是这一个:

let defaults = UserDefaults.standard

存储数据 Storing Data

要存储一些东西

defaults.set(object, forKey: "SomeKey") // object 必须是一个属性列表(Property List)

属性列表(Property List)是一个概念(不是一个协议或具体的类型)。

String、Int、Double、[Property List](数组)、Dictionary<String, List Property>(字典)都是属性列表。

并且 Data 也是一个属性列表,因此任何实现了 Codable 协议的对象都可以被转换为 JSON 数据,并放入 UserDefaults 中。

为了方便起见,还有许多诸如此类的函数

defaults.setDouble(37.5, forKey: "MyDouble")

用于唯一标识的键(forKey)字符串在你的整个应用程序中是共享的,因此请选择良好且不冲突的名称。

检索数据 (Retrieving Data),要检索某些内容

let i: Int? = defaults.integer(forKey: "MyInteger")
let b: Data? = defaults.data(forKey: "MyData")
let u: URL? = defaults.url(forKey: "MyURL")
let strings: [String]? = defaults.stringArray(forKey: "MyStrings")
// 等等...

进阶:获取字典或包含任意内容的数组

检索一个字典或一个包含“任何内容”的数组,但字符串(String)除外,情况会更复杂一些……

let a = array(forKey: "MyArray")
// 将会返回一个 Array<Any>?(可选的任意类型数组)。

在此时,你必须使用 Swift 中的 as 操作符来对数组元素进行“类型转换”(type cast)。

这个示例演示了如何使用 UserDefaults 存储和读取各种基本类型,以及通过 Data 存储遵循 Codable 的对象。

import SwiftUI

struct UserSettings: Codable {
    var username: String
    var notificationsEnabled: Bool
}

struct ContentView: View {
    let defaults = UserDefaults.standard
    
    @State private var username: String = ""
    @State private var notificationsEnabled: Bool = false
    @State private var favoriteNumber: Int = 0
    
    var body: some View {
        NavigationStack {
            Form {
                Section("基本类型") {
                    TextField("用户名", text: $username)
                        .onChange(of: username) { _, newValue in
                            defaults.set(newValue, forKey: "userNameKey")
                        }
                    
                    Toggle("开启通知", isOn: $notificationsEnabled)
                        .onChange(of: notificationsEnabled) { _, newValue in
                            defaults.set(newValue, forKey: "notificationsKey")
                        }
                    
                    Stepper("最喜欢的数字: \(favoriteNumber)", value: $favoriteNumber, in: 0...100)
                        .onChange(of: favoriteNumber) { _, newValue in
                            defaults.set(newValue, forKey: "favNumberKey")
                        }
                }
                
                Section("操作") {
                    Button("保存 Codable 对象") {
                        saveCodableObject()
                    }
                    Button("读取所有数据并刷新界面") {
                        loadAllData()
                    }
                    Button("清除所有数据", role: .destructive) {
                        defaults.removeObject(forKey: "userNameKey")
                        defaults.removeObject(forKey: "notificationsKey")
                        defaults.removeObject(forKey: "favNumberKey")
                        defaults.removeObject(forKey: "userSettingsKey")
                        loadAllData()
                    }
                }
            }
            .navigationTitle("UserDefaults 演示")
        }
        .onAppear {
            loadAllData()
        }
    }
    
    private func saveCodableObject() {
        let settings = UserSettings(username: username.isEmpty ? "Guest" : username, notificationsEnabled: notificationsEnabled)
        // 将 Codable 对象转为 JSON Data 存入 UserDefaults
        if let data = try? JSONEncoder().encode(settings) {
            defaults.set(data, forKey: "userSettingsKey")
            print("Codable 对象已保存到 UserDefaults")
        }
    }
    
    private func loadAllData() {
        // 读取基本类型
        username = defaults.string(forKey: "userNameKey") ?? ""
        notificationsEnabled = defaults.bool(forKey: "notificationsKey")
        favoriteNumber = defaults.integer(forKey: "favNumberKey")
        
        // 读取 Codable 对象
        if let data = defaults.data(forKey: "userSettingsKey") {
            if let settings = try? JSONDecoder().decode(UserSettings.self, from: data) {
                print("读取到的 Codable 对象: \(settings)")
                // Update UI with loaded settings
                username = settings.username
                notificationsEnabled = settings.notificationsEnabled
            }
        }
    }
}

#Preview {
    ContentView()
}

plist 打开支持文档查看

Info.plist Values

Support Document Browser 更改为 YES。

iPhone 可以在文件 我的iPhone 中查看文件。