云端 Mac 上的 iOS 版本升级判定:SemVer 边界测试

云端 Mac 上的 iOS 版本升级判定:SemVer 边界测试

云端 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 比较不把加号后的构建元数据计入优先级。如果需要区分同版本的重新打包产物,应另设发布序号并制定独立策略。

给构建流程一台独享的云端 Mac

整台物理机独享,非虚拟机。按构建量选择机型与租期,让归档、签名和缓存留在自己的节点上。

选择云端 Mac 配置

下一次归档,在独享的云端 Mac 上出炉

按构建量选机器,按团队位置选节点。先核对方案,再开始交接工具链。