更多 Swift 背后知识

计时器

import SwiftUI

struct ElapsedTime: View {
    let startTime: Date = Date.now

    var body: some View {
        Text(TimeDataSource<Date>.currentDate, format: .offset(to: startTime, allowedFields: [.hour, .minute, .second]))
    }
}

#Preview {
    ElapsedTime()
}

以前写一个计时器通常需要:

@State private var now = Date()

Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
    now = Date()
}

// 或

TimelineView(.periodic(from: .now, by: 1))

protocol

protocol像struct,但是没有实现。其实像Java里的Interface,C++里的纯抽象类。

protocol View {
    var body: some View { get }
}

这里没有任何实现,大括号里只说了get,意味着这是一个只读变量。

struct ContentView: View { ... }

我们是在承诺实现名为 View 的协议中的所有变量和函数。协议可以根据需要包含任意多的变量和函数。

为什么需要协议

协议作为契约

使用协议进行代码共享

extension View {
    func font(_ font: Font) -> some View {
        // 创建并返回一个自身已修改为具有给定字体的视图
    }
}

协议用作类型

protocol 使用 some 作类型

some 关键字对调用方或被调用方隐藏了具体类型。

当 some 用于返回值类型时,它对调用方隐藏了具体类型。 当 some 用于参数类型时,它对被调用方隐藏了具体类型。

在任何情况下,编译器在编译时都知道该类型。例如,var body 的返回类型是 some View。 实际返回的类型由 var body 中的代码决定(即由被调用方决定)。 调用方只知道它遵循 View 协议。

当 some 用于参数类型时,只有调用方知道该类型。 被调用方只能使用协议中的函数/变量(这类似于泛型)。

例如,动画视图修饰符中 value 参数的类型:

func animation(Animation?, value: some Equatable)

protocol 使用 any 作为类型

想象一个由实现同一协议的不同具体类型组成的集合:

let views: [any View] = [PegView(), CodeView()]

约束与收益

我们知道遵循协议会带来功能上的收益(例如视图修饰符)。但我们也使用协议来约束其他类型,以确保它们按照我们的意愿运行。这对于泛型中的“不在乎(don’t care)”类型尤为重要。

Swift 中 Dictionary 的声明方式:

struct Dictionary<Key, Value> where Key: Hashable

Dictionary 结构体约束其 Key(即“不在乎”的部分)必须遵循 Hashable 协议。

where 子句使得原本的“不在乎”现在变成了“有一定要求”。只能拥有键为 Hashable 的字典。

Equatable Hashable Identifiable

Euqatable 协议

Equatable 协议中仅包含一个函数:

protocol Equatable {
    static func == (lhs: Self, rhs: Self) -> Bool
}

Swift 中的 == 运算符知道调用此静态(即全局)函数,以比较该类型的两个对象。 大写的 Self 表示“当前正在遵循该协议的类型”。以 Kind 枚举为例的函数声明如下:

static func == (lhs: Kind, rhs: Kind) -> Bool

Array 的 Equatable 实现

extension Array: Equatable where Element: Euqatable {
    static func == (lhs: Array<Element>, rhs: Array<Element>) -> Bool {
        lhs.count == rhs.count && lhs.indices.allSatisfy {
            lhs[$0] == rhs[$0] }
    }
}

UI 中的 Equatable

在 SwiftUI 中,将某事物设为 Equatable 的一个好处是可以使用

.onChange(of:) {}

其中的 of: 参数必须是 Equatable的。

SwiftUI 会反复检查该参数是否与其之前的状态 == (相等)。

import SwiftUI

struct ElapsedTime: View {
    @State private var password = ""
    @State private var passwordStrength = "请输入密码"
    
    var body: some View {
        VStack {
            Form {
                Section(header: Text("账号设置")) {
                    SecureField("输入新密码", text: $password)
                }
                
                Section(header: Text("密码强度检查")) {
                    Text(passwordStrength)
                        .foregroundColor(strengthColor)
                        .bold()
                }
            }
        }
        // 👇 这里是核心:只要 password 变量发生变化,就会触发这个闭包
        .onChange(of: password) { oldValue, newValue in
            checkPasswordStrength(newValue)
        }
    }
    
    // 简单的强度检查逻辑
    private func checkPasswordStrength(_ pass: String) {
        if pass.isEmpty {
            passwordStrength = "请输入密码"
        } else if pass.count < 6 {
            passwordStrength = "太短了(至少6位)"
        } else if pass.count < 10 {
            passwordStrength = "中等强度"
        } else {
            passwordStrength = "强密码 💪"
        }
    }
    
    // 根据强度返回不同的颜色
    private var strengthColor: Color {
        switch passwordStrength {
        case "强密码 💪": return .green
        case "中等强度": return .orange
        case "太短了(至少6位)": return .red
        default: return .gray
        }
    }
}

#Preview {
    ElapsedTime()
}

Hashable

可哈希性(Hashability),Hashable(可哈希)是一个协议,用于加速在集合中查找 Equatable(可等价性)元素的速度。两个通过 == 判断为相等的对象,绝不能“哈希”出不同的值。

protocol Hashable: Equatable {
    func hash(into hasher: inout Hasher)
}

任何表现得像 Hashable 的对象,也必须表现得像 Equatable。

let dict = [
    "apple": 1,
    "banana": 2
]

为什么查找 apple 很快,因为 “apple” 会变成一个 hash value,直接定位 bucket,而不是一个个比较。

对于 struct Hasher 内部实现了一种哈希算法

var hasher = Hasher()

hasher.combine(100)
hasher.combine("Tom")
let value = hasher.finalize() // Int

Swift 为了防止 Hash Collision Attack(哈希碰撞攻击),每次程序启动都会随机初始化哈希种子(seed)。不要把 hash 值保存到文件或数据库,更不要用于网络协议。

UI 中的 Identity

你的 SwiftUI 界面是由 Model(模型)数据驱动 的。为了让 UI 与数据保持同步,SwiftUI 经常需要能够识别(identify) Model 中的数据。随后,它会根据每个数据对象的身份(Identity),把 View 与 Model 中对应的数据关联起来。

身份(Identity)对于 动画(Animation) 来说可能是最重要的概念。随着 Model 数据发生变化,View 会移动、出现或者消失,以反映这些变化。如果 Model 中的数据没有唯一身份,UI 就很难追踪哪些元素发生了变化。

一个最典型、也是大家最熟悉的例子,就是 ForEach。

Identity 是 SwiftUI Diff(差异比较)算法的基础,没有 Identity,就没有高效更新,也没有流畅动画。有点像 React.js

ForEach 与 Identity

你把一个 集合(Collection) 传给 ForEach,它会为集合中的每一个元素创建一个 View。

当集合发生变化时,ForEach 会努力保持它创建的 View 与集合中的元素一一对应(匹配)。

也就是说:

而不是把所有 View 全部重建。高效的差异化更新。

因此,ForEach 要求你必须为集合中的每一个元素提供一个唯一且稳定(stable)的标识符(Identifier)。

方式一:使用 id 参数,为集合中的每个元素指定一个能够返回其唯一标识符的属性。

id: \.self

这里的 self 是一个特殊的属性,它表示:集合中的元素本身。这种写法只有满足以下条件才能工作:

集合中的元素本身必须:

如果你使用 数组索引 作为元素的 Identity,只有唯一 和 遵守Hashable,稳定性需要你自己保证,绝不能重新排序底层数组,也不能 在数组中间删除元素,在数组中间插入元素,唯一允许的是 只能在数组末尾追加、只能删除数组最后一个元素。

如果一个集合中的元素不是唯一的(unique)、不是稳定的(stable),或者本身不符合 Hashable(而不像数组索引那样)怎么办?

那么你不能使用:id: \.self 不过你仍然可以使用:id: \.someOtherVar 也就是说,如果你的元素中有某个属性:是 Hashable 能唯一标识该元素 那么就可以把它作为 id。

方式二:元素实现 Identifiable

protocol Identifiable {
    associatedtype ID: Hashable
    var id: Self.ID
}
struct Todo: Identifiable {
    let id = UUID()
    var title: String
}

ForEach(todos) { todo in
    Text(todo.title)
}

class

它就是一个面向对象中的 类(class),和你在其他编程语言里使用的 class 完全一样。

它支持单继承(single inheritance)。在 SwiftUI 中基本不会使用继承。

SwiftUI 更推荐,Protocol、Struct、Composition,而不是

View
 ├── MyButton
 │     ├── RedButton
 │           ├── BigRedButton

和struct唯一真正巨大的区别是,class是一个引用类型 而不是 值类型。

@Observable

Tracking changes(跟踪变化),SwiftUI 会跟踪 Model 中的所有变化,并确保 UI 始终反映最新状态。

对于 struct 来说,这很容易,因为 Swift 完全知道结构体什么时候发生了修改。(这也是 Swift 实现 Copy-on-Write(写时复制) 等机制的基础。)

但对于 class,情况通常不同,因为 Swift 并不知道类实例内部什么时候发生了修改。不过 SwiftUI 提供了一个宏,它会包装(wrap)你的类中的属性,并在属性变化时通知 SwiftUI。

struct 自己就会产生新的值,所以 SwiftUI 能检测到变化; class 永远是同一个对象,所以 SwiftUI 不知道里面什么时候改了,因此需要 @Observable 告诉它。

struct User {
    var name: String
}
@State private var user = User(name: "Tom")
user.name = "Jerry"

虽然看起来只是改了一个属性。实际上发生的是:旧User被新的User整个替换,因为 struct 是 Value Type(值类型)。SwiftUI 能够发现 user 这个值变了,然后重新计算 body,整个过程不用观察对象。

class User {
    var name = "Tom"
}
@State private var user = User()
user.name = "Jerry"

修改user内部,user指针没变,对象地址没变。SwiftUI 看见的是,user没变啊,于是 body 不刷新,它不知道 对象里面偷偷改了。

import Observation

@Observable
class User {
    var name = "Tom"
}

编译器会偷偷帮你生成很多代码

class User {

    private let registrar = ObservationRegistrar()

    var name: String {
        get {
            registrar.access(...)
            return _name
        }

        set {
            registrar.withMutation(...) {
                _name = newValue
            }
        }
    }
}