云端 Mac 上验收 iOS 集合身份:Hashable、去重与字段更新

云端 Mac 上验收 iOS 集合身份:Hashable、去重与字段更新

远端拉回同一条记录,名称更新了,列表却多出一行;另一处把它放进 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;存在租户作用域时,还应把租户标识纳入业务键。

为你的工程,保留一台独享 Mac

Mac mini 物理节点,非虚拟机。按天、周、月或季租用,先选用途与配置,再核对控制台实时返回的可用状态。

选择机型与租期 查看完整价格