云端 Mac 上的自动化测试全部通过,客户端却把 1.10.0 判成比 1.9.0 更旧。这不是构建故障,而是升级判断用了字符串排序。另一个常见问题是把解析失败当作“无需更新”,服务端一旦下发错误格式,更新入口便悄悄消失。
下面给版本判断建立独立测试边界:严格解析、比较优先级、最后执行升级策略。所有样本都在本地运行,不依赖网络,也不需要签名资产。
先定义哪些版本允许进入比较
本文处理遵循 SemVer 的业务版本,格式为三个数字段,可附预发布标识与构建元数据。例如 2.4.0-rc.1+build.7。它不是应用发布平台版本字段的通用校验器,不能直接拿来替代 CFBundleShortVersionString 的发布规则。
核心数字不允许前导零;预发布中的纯数字标识也一样。预发布与元数据只能包含 ASCII 字母、数字和连字符,点分隔的每段不能为空。元数据允许数字前导零,且不参与优先级比较。
“版本更高”不等于“必须升级”。最低支持版本、灰度范围、测试通道和发布撤回属于策略层,不能塞进比较器。
先约定:非法输入返回解析失败,而不是退回 0.0.0;生产入口还应限制字符串长度,例如最多 256 字节,避免异常数据占用不必要的资源。
实现不经过整数转换的比较器
下面代码放入测试 Target 可访问的 Swift 文件。数字段先比较长度,再比较 ASCII 顺序,因此不需要把超长数字转换为 Int。构建元数据在校验后丢弃,合成的相等判断也就符合“优先级相同”的语义。
struct SemVersion: Comparable {
let core: [String]
let prerelease: [String]
private static func numeric(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) }
}
private static func validNumber(_ value: String) -> Bool {
numeric(value) && (value == "0" || !value.hasPrefix("0"))
}
private static func validIdentifier(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy {
(48...57).contains($0) || (65...90).contains($0)
|| (97...122).contains($0) || $0 == 45
}
}
private static func fields(_ value: String) -> [String] {
value.split(separator: ".", omittingEmptySubsequences: false)
.map(String.init)
}
init?(_ raw: String) {
guard raw.utf8.count <= 256 else { return nil }
let parts = raw.split(
separator: "+", omittingEmptySubsequences: false
).map(String.init)
guard parts.count <= 2 else { return nil }
if parts.count == 2 {
guard Self.fields(parts[1]).allSatisfy(Self.validIdentifier)
else { return nil }
}
let version = parts[0].split(
separator: "-", maxSplits: 1,
omittingEmptySubsequences: false
).map(String.init)
let core = Self.fields(version[0])
guard core.count == 3, core.allSatisfy(Self.validNumber)
else { return nil }
let pre = version.count == 2 ? Self.fields(version[1]) : []
guard pre.allSatisfy({
Self.validIdentifier($0)
&& (!Self.numeric($0) || Self.validNumber($0))
}) else { return nil }
self.core = core
self.prerelease = pre
}
private static func numberLess(_ lhs: String, _ rhs: String) -> Bool {
lhs.count == rhs.count ? lhs < rhs : lhs.count < rhs.count
}
static func < (lhs: Self, rhs: Self) -> Bool {
for (a, b) in zip(lhs.core, rhs.core) where a != b {
return numberLess(a, b)
}
if lhs.prerelease.isEmpty { return false }
if rhs.prerelease.isEmpty { return true }
for (a, b) in zip(lhs.prerelease, rhs.prerelease) where a != b {
let an = numeric(a)
let bn = numeric(b)
if an && bn { return numberLess(a, b) }
if an != bn { return an }
return a < b
}
return lhs.prerelease.count < rhs.prerelease.count
}
}
这里没有自动去空格,也没有接受 v 前缀。严格入口不应偷偷修复协议。如果现有接口允许这些写法,应在单独的兼容层转换,并记录转换规则,避免客户端与服务端各猜一套。
用固定样本锁住优先级
最少覆盖四类:核心数字递增、正式版高于预发布版、数字标识低于非数字标识,以及相同前缀下更长的预发布列表更高。下面的测试可以直接放进单元测试文件;实现位于应用模块时,补上该模块的测试导入。
import XCTest
final class SemVersionTests: XCTestCase {
func testPrecedence() throws {
let ordered = [
"1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta",
"1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11",
"1.0.0-rc.1", "1.0.0", "1.9.0", "1.10.0",
"999999999999999999999999.0.0"
]
for (a, b) in zip(ordered, ordered.dropFirst()) {
let left = try XCTUnwrap(SemVersion(a))
let right = try XCTUnwrap(SemVersion(b))
XCTAssertLessThan(left, right)
XCTAssertFalse(right < left)
}
}
func testMetadataAndInvalidInput() throws {
let first = try XCTUnwrap(SemVersion("1.2.3+001"))
let second = try XCTUnwrap(SemVersion("1.2.3+build.9"))
XCTAssertEqual(first, second)
for raw in [
"", "1.2", "01.2.3", "1.2.3-01", "1.2.3-",
"1.2.3+", "1.2.3+a..b", "1.2.3+a+b",
" 1.2.3", "1.2.3\n", "1.2.3"
] {
XCTAssertNil(SemVersion(raw), raw)
}
}
}
不要只测相邻版本。补充任意三元组的传递性:若甲小于乙、乙小于丙,甲必须小于丙。同时检查自身不小于自身,以及同优先级的两个输入互不小于对方。这些属性比单个升级案例更容易发现排序器缺陷。
把比较结果接到策略层
解析失败不是无需升级
业务接口最好显式返回三种结果:可比较、当前版本非法、远端版本非法。远端数据错误时保留当前应用功能,并产生可检索的错误事件;不要把它转换成“当前已是最新版本”。强制升级策略遇到非法最低版本,应走事先定义的故障处理分支,而不是临时猜测。
测试通道也要独立判断。正式通道不应仅因 2.0.0-beta.1 高于 1.9.0 就提示安装预发布版。先筛选允许的通道,再比较版本,最后执行灰度和最低支持规则。
同一版本重新打包时,元数据可以用于追踪,但不能借它改变 SemVer 顺序。需要更新这类产物时,应额外保存发布序号,测试其单调性,并明确是否允许跨通道比较。
在云端节点留下可复查的验收结果
将这些测试加入现有共享 Scheme 的单元测试入口,并沿用工程固定的模拟器目标。记录源码提交、工具链版本、测试结果与失败样本,不需要连接线上更新接口。这样同一次提交在本地和云端比较的是同一份契约。
合并前检查:合法样本全部可解析;非法输入无一被修复后放行;元数据不影响优先级;超长数字不溢出;解析错误不会显示成“无需更新”;正式通道不会误选预发布版本。
最后用几个刻意改坏的实现验证测试本身:把数字比较改成字典序、让元数据参与排序、允许前导零。每次修改都应使对应测试失败。门禁能抓住这些错误,才算真正守住了升级入口,而不只是让测试面板多了一行绿色。
常见问题
能直接用字符串排序判断是否需要升级吗?
不能。字符串排序可能把 1.10.0 排在 1.9.0 前面,也不会正确处理预发布优先级。应先严格解析,再按核心版本和预发布标识逐项比较。
构建元数据变化应该触发升级吗?
SemVer 比较不把加号后的构建元数据计入优先级。如果需要区分同版本的重新打包产物,应另设发布序号并制定独立策略。