文件列表里有“报告2”和“报告10”,刷新后两个同名项目又交换位置。问题未必出在界面刷新,也可能是比较器没有定义完整顺序。在云端 Mac 上跑 iOS 测试时,把排序从视图里抽出来,先用小样本验收规则,比录屏猜测更容易复现。
先写清楚排序契约
本文采用一套文件列表规则:名称升序,数字按自然顺序比较,忽略大小写与重音;名称比较相等时,按持久业务标识升序。固定比较区域,避免测试跟着机器设置变化。
这不是所有产品的默认答案。面向用户的通讯录可能需要按当前语言排序;跨设备共享的文件列表则可能要求一致顺序。先选定需求,再写实现,不要把“看着顺眼”当验收标准。
稳定标识必须在刷新前后保持不变。数组下标、接收时间和每次创建的新 UUID 都不能用于打破同名项的平局。数据入口还应拒绝重复标识,否则最终排序键也无法区分项目。
稳定排序只能保留输入里同值项目的相对顺序,不能修复服务端每次返回不同顺序的问题。
把比较器独立成纯函数
将以下文件加入应用目标。示例固定使用 en_US_POSIX,表示这里选择了固定比较策略,并不表示它适合所有语言的名称展示。
import Foundation
struct ListEntry {
let id: String
let name: String
}
func entryLess(_ lhs: ListEntry, _ rhs: ListEntry) -> Bool {
let order = lhs.name.compare(
rhs.name,
options: [.numeric, .caseInsensitive, .diacriticInsensitive],
range: nil,
locale: Locale(identifier: "en_US_POSIX")
)
if order != .orderedSame {
return order == .orderedAscending
}
return lhs.id < rhs.id
}
不要在比较过程中读取网络状态、修改计数器或查询会变化的用户设置。一次排序会多次比较同一对象,结果必须一致。也不要用“小于等于”实现比较器:对象不能排在自身之前。
名称比较等价不代表业务对象相同。“Resume”和“résumé”在当前策略下可能进入同一组,但不能因此合并记录。排序、搜索和去重是三个不同契约。
用小样本锁住关键行为
在单元测试文件中导入应用模块,然后加入测试类。代码放在能访问上述类型的测试目标内;将 ListSortingTests 中的样本扩展为自己的文件名即可。
import XCTest
final class ListSortingTests: XCTestCase {
let items = [
ListEntry(id: "d", name: "Report10"),
ListEntry(id: "b", name: "report2"),
ListEntry(id: "a", name: "REPORT2"),
ListEntry(id: "c", name: "Report1")
]
func testNaturalOrderAndTies() {
XCTAssertEqual(
items.sorted(by: entryLess).map(\.id),
["c", "a", "b", "d"]
)
}
func testComparatorProperties() {
for a in items {
XCTAssertFalse(entryLess(a, a))
for b in items {
if entryLess(a, b) {
XCTAssertFalse(entryLess(b, a))
}
for c in items {
if entryLess(a, b) && entryLess(b, c) {
XCTAssertTrue(entryLess(a, c))
}
}
}
}
}
func testInputOrderDoesNotChangeOutput() {
let expected = items.sorted(by: entryLess).map(\.id)
XCTAssertEqual(
Array(items.reversed()).sorted(by: entryLess).map(\.id),
expected
)
let rotated = Array(items.dropFirst()) + [items[0]]
XCTAssertEqual(
rotated.sorted(by: entryLess).map(\.id),
expected
)
}
}
第一项测试验收数字顺序和同名项。第二项检查反自反性、非对称性与传递性,能抓住临时拼接多种规则造成的矛盾。第三项改变输入顺序,验证结果不依赖接收顺序。
三重循环适合十几个代表性样本,不要直接套在完整生产列表上。大数据集应另测排序耗时,不能把性质检查误当性能基准。
补齐 Unicode 与空值边界
继续加入空名称、前导零、中文名称、组合重音和表情序列。预组合字符与“字母加组合重音”可能显示相同;不能用字符串长度判断它们的显示顺序。
对“文件02”和“文件2”,不要擅自假设数字比较后的结果。先明确产品是否把它们视为同组,再在选定运行时中验证。如果必须区分前导零,可在名称等价后增加明确的次级名称规则,最后仍以业务标识收口。
缺失名称要在模型转换阶段统一处理,例如转换为空串,或定义“缺失项置后”的分支。不要在比较器中随机补默认值。降序也应重新定义键方向,不能简单写成升序结果取反;取反会让相等对象被判断为“小于”。
在云端运行并记录失败证据
在云端 Mac 的测试任务中固定 Xcode、模拟器运行时和比较区域,用已有的单元测试入口执行这些测试。保存提交标识、工具链版本、失败输入和实际输出标识序列,不需要记录真实客户文件名。
先验收纯函数,再验收界面:连续刷新两次,确认同名项目不交换位置;删除再插入一项,确认其余项目的相对顺序符合规则。界面层必须复用同一个比较器,不能另写一套排序。
固定区域并不保证系统整理规则永远不变。升级运行时后,复核代表性语言样本;若差异来自系统规则,决定接受变更还是采用自定义排序键,而不是直接更新全部快照。
最终交付应包含排序契约、代表性样本、比较器性质检查和刷新验收结果。这样,下次顺序变化时能判断是数据、规则还是运行时发生了变化,而不是再次从截图开始猜。
常见问题
排序算法稳定,是否就能避免同名项目跳动?
不能。稳定排序保留的是输入中的相对顺序;如果服务端返回顺序变化,同名项仍会变化。需要用持久且唯一的业务标识作为最终排序键。
固定比较区域后,是否可以永久复用同一份排序快照?
不宜。固定区域消除了用户设置带来的变量,但系统字符串整理规则仍可能随运行时变化。保留契约性质测试,并在升级运行时时复核代表性样本。