远端拉回同一条记录,名称更新了,列表却多出一行;另一处把它放进 Dictionary,又查不到旧状态。遇到这类问题,先别重写刷新逻辑。把模型拿到云端 Mac 上,单独验收相等关系和集合行为,往往能直接找到身份定义的偏差。下面只测试值模型,不需要模拟器,也不接真实接口。
先写清楚什么算同一条记录
假设应用管理多个租户的成员:成员编号只在租户内唯一,名称可以修改。因此业务身份是“租户编号+成员编号”,名称不是身份的一部分。先把这个规则写进测试说明,再实现协议。
这里有两个不同问题:两份数据是否代表同一成员,以及两份数据的所有字段是否相同。若直接让结构体自动合成 Hashable,所有存储字段通常都会参与比较;名称变化后,两份快照便不再相等。
不要为了去重,随意改变所有模型的相等语义。需要比较完整快照时,更稳妥的设计是独立定义不可变身份键,使用身份键到完整记录的字典。本文选择按身份相等的模型,是为了把契约直接展示出来。
相等的值必须采用一致的哈希规则;哈希相同的值却不一定相等。哈希用于缩小查找范围,不是永久记录编号。
用最小脚本固定相等与哈希规则
确认云端 Mac 的 Xcode 命令行工具可用,执行 xcrun swift --version,把输出与代码提交标识一起记录。创建 identity-contract.swift,写入下列内容。示例没有外部依赖,也不读取凭据。
struct Member: Hashable {
let tenantID: String
let memberID: String
var displayName: String
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.tenantID == rhs.tenantID &&
lhs.memberID == rhs.memberID
}
func hash(into hasher: inout Hasher) {
hasher.combine(tenantID)
hasher.combine(memberID)
}
}
let original = Member(
tenantID: "team-a",
memberID: "member-7",
displayName: "Old"
)
var renamed = original
renamed.displayName = "New"
let otherTenant = Member(
tenantID: "team-b",
memberID: "member-7",
displayName: "Old"
)
precondition(original == renamed, "Rename changed identity")
precondition(original.hashValue == renamed.hashValue,
"Equal values hash differently")
precondition(original != otherTenant, "Tenant boundary lost")
var members: Set<Member> = [original]
precondition(!members.insert(renamed).inserted,
"Duplicate identity inserted")
precondition(members.count == 1, "Unexpected member count")
precondition(members.first?.displayName == "Old",
"Insert unexpectedly replaced the member")
let previous = members.update(with: renamed)
precondition(previous?.displayName == "Old",
"Previous member missing")
precondition(members.count == 1, "Update changed member count")
precondition(members.first?.displayName == "New",
"Updated fields missing")
members.insert(otherTenant)
precondition(members.count == 2, "Distinct tenants merged")
let states: [Member: String] = [original: "selected"]
precondition(states[renamed] == "selected",
"Lookup failed after rename")
precondition(states[otherTenant] == nil,
"Lookup crossed tenant boundary")
相等比较和 hash(into:) 使用同一组身份字段。身份字段定义为 let,展示字段可以更新。特别检查一种隐蔽错误:比较时忽略名称,哈希时却加入名称。它违反协议要求,不能指望每次运行都立即暴露。
验收去重,也验收更新后的内容
在脚本所在目录执行:
set -eu
xcrun swiftc -Onone identity-contract.swift -o identity-contract
./identity-contract
成功时脚本安静退出;前置条件失败则返回非零结果。保留 -Onone,不要用会关闭安全检查的 -Ounchecked。接入已有流水线时,直接让这条命令的退出状态决定测试是否通过。
只断言 Set 的数量为一还不够。insert 遇到相等成员时不会替换已有内容,所以去重成功不代表名称更新成功。上例先验证旧名称仍在,再用 update(with:) 验证新名称已经写入。
Dictionary 的测试则回答另一个问题:名称修改后的键还能否找到原状态。若需要保存最新完整记录,建议让字典的值承载记录,键只承载身份,避免把展示数据塞进键里。
补齐边界,不测试具体哈希数字
身份规则与输入规则分开
字符串身份是否区分大小写、是否允许空值,应由接口契约决定,不要在哈希函数内偷偷转换。需要规范化时,应在模型创建前完成,而且相等比较与哈希必须消费同一份规范化结果。
把下列检查加入项目测试:
- 同一租户、同一编号、不同名称:身份相等,替换后保留新名称。
- 不同租户、同一编号:身份不同,两个成员同时存在。
- 同一租户、不同编号:身份不同,状态不能串到另一成员。
- 三份同身份快照:验证相等关系的对称性和传递性。
- 删除后重新插入:验证成员数量及新字段,不依赖遍历顺序。
不要断言某个 hashValue 必须等于固定整数,也不要要求不同身份的哈希绝不重复。跨进程随机化和合法碰撞都会使这种测试失去意义。重新运行脚本可以帮助发现不稳定行为,但不能代替字段规则审查。
把契约落到生产模型与合并门禁
独立脚本证明的是示例逻辑。正式接入时,将断言迁入测试目标,直接导入生产模型;不要长期维护一份测试专用复制品,否则业务代码改错了,脚本仍可能通过。
若当前模型是引用类型,尤其不能在它作为集合成员期间修改参与哈希的字段。集合不会因对象内部变化自动重建索引。优先改为不可变身份键;确需改变身份时,先移除旧成员,再创建并插入新成员。
最后审查持久化和页面选择状态:它们应保存明确业务标识,而不是哈希数字或集合遍历位置。验收完成的标准很具体:改名称不造重复记录,更新后能读到新内容,跨租户不会合并,查找与删除仍然正确。
常见问题
两个不同对象的 hashValue 相同,是否一定是实现错误?
不是。不同身份可以发生哈希碰撞,集合还会通过相等比较区分它们。必须保证的是:两个值相等时,参与哈希的字段也一致;不能反过来要求所有不同值的哈希都不同。
更新名称后,Set 会自动替换已有记录吗?
不会。修改集合外的值副本不影响已有成员。若模型按业务身份比较相等,应显式调用 update(with:) 替换相等成员,再验证成员数量不变且保存的是新字段。
可以把 hashValue 存入数据库作为记录主键吗?
不可以。Swift 哈希具有随机化特征,不保证跨进程或版本稳定。持久身份应使用服务端标识或 UUID;存在租户作用域时,还应把租户标识纳入业务键。