Post

iOS APP 版本号规则|语意化版本与版本比较技术详解

iOS 开发者常见版本号管理痛点,掌握语意化版本规范及 Version 与 Build Number 差异,并实作 Swift 版本号比较方法,提升版本判断准确性与自动化管理效率。

iOS APP 版本号规则|语意化版本与版本比较技术详解

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


iOS APP 版本号那些事

版本号规则及判断比较解决方案

Photo by [James Yarema](https://unsplash.com/@jamesyarema?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by James Yarema

前言

所有 iOS APP 开发者都会碰到的两个数字,Version Number 和 Build Number;最近刚好遇到需求跟版本号有关,要做版本号判断邀请使用者评价 APP,顺便挖掘了一下关于版本号的事;文末也会附上我的版本号判断解决大全。

[XCode Help](https://help.apple.com/xcode/mac/current/#/devba7f53ad4){:target="_blank"}

XCode Help

语意化版本 x.y.z

首先介绍「 语意化版本 」这份规范,主要是要解决软体相依及软体管理上的问题,如我们很常在使用的 Cocoapods ;假设我今天使用 Moya 4.0,Moya 4.0 使用并依赖 Alamofire 2.0.0,如果今天 Alamofire 有更新了,可能是新功能、可能是修复问题、可能是整个架构重做(不相容旧版);这时候如果对于版本号没有一个公共共识规范,将会变得一团乱,因为你不知道哪个版本是相容的、可更新的。

语意化版本由三个部分组成: x.y.z

  • x: 主版号 (major):当你做了不相容的 API 修改

  • y: 次版号 (minor):当你做了向下相容的功能性新增

  • z: 修订号 (patch):当你做了向下相容的问题修正

通用规则:

  • 必须为非负的整数

  • 不可补零

  • 0.y.z 开头为开发初始阶段,不应该用于正式版版号

  • 以数值递增

比较方式:

先比 主版号,主版号 等于时 再比 次版号,次版号 等于时 再比 修订号。

ex: 1.0.0 < 2.0.0 < 2.1.0 < 2.1.1

另外还可在修订号之后加入「先行版号资讯 (ex: 1.0.1-alpha)」或「版本编译资讯 (ex: 1.0.0-alpha+001)」但 iOS APP 版号并不允许这两个格式上传至 App Store,所以这边就不做赘述,详细可参考「 语意化版本 」。

✅:1.0.1, 1.0.0, 5.6.7 ❌:01.5.6, a1.2.3, 2.005.6

实际使用

关于实际使用在 iOS APP 版本控制上,因为我们仅作为 Release APP 版本的标记,不存在与其他 APP、软体相依问题;所以在实际使用上的定义就因应各团队自行定义,以下仅为个人想法:

  • x: 主版号 (major):有重大更新时(多个页面介面翻新、主打功能上线)

  • y: 次版号 (minor):现有功能优化、补强时(大功能下的小功能新增)

  • z: 修订号 (patch):修正目前版本的 bug时

一般如果是紧急修复(Hot Fix)才会动到修订号,正常状况下都为 0;如果有新的版本上线可以将它归回 0。

EX: 第一版上线(1.0.0) -> 补强第一版的功能 (1.1.0) -> 发现有问题要修复 (1.1.1) -> 再次发现有问题 (1.1.2) -> 继续补强第一版的功能 (1.2.0) -> 全新改版 (2.0.0) -> 发现有问题要修复 (2.0.1) … 以此类推

Version Number vs. Build Number

Version Number (APP 版本号)

  • App Store、外部识别用

  • Property List Key: CFBundleShortVersionString

  • 内容仅能由数字和「.」组成

  • 官方也是建议使用语意化版本 x.y.z 格式

  • 2020121701、2.0、2.0.0.1 都可 (下面会有总表统计 App Store 上 App 版本号的命名方式)

  • 不可超过 18 个字元

  • 格式不合可以 build & run 但无法打包上传到 App Store

  • 仅能往上递增、不能重复、不能下降

一般习惯使用语意化版本 x.y.z 或 x.y。

Build Number

  • 内部开发过程、阶段识别使用,不会公开给使用者

  • 打包上传到 App Store 识别使用(相同 build number 无法重复打包上传)

  • Property List Key: CFBundleVersion

  • 内容仅能由数字和「.」组成

  • 官方也是建议使用语意化版本 x.y.z 格式

  • 1、2020121701、2.0、2.0.0.1 都可

  • 不可超过 18 个字元

  • 格式不合可以 build & run 但无法打包上传到 App Store

  • 同个 APP 版本号下不能重复,反之不同APP 版本号可以重复 ex: 1.0.0 build: 1.0.0, 1.1.0 build: 1.0.0 ✅

一般习惯使用日期、number(每个新版本都从 0 开始),并搭配 CI/fastlane 自动在打包时递增 build number。

稍微统计了一下排行版上 app 的版本号格式,如上图。

一般还是以 x.y.z 为主。

版本号比较及判断方式

有时候我们会需要使用版本进行判断,例如:低于 x.y.z 版本则跳强制更新、等于某个版本跳邀请评价,这时候就需要能比较两个版本字串的功能。

简易方式

1
2
3
4
5
let version = "1.0.0"
print(version.compare("1.0.0", options: .numeric) == .orderedSame) // true 1.0.0 = 1.0.0
print(version.compare("1.22.0", options: .numeric) == .orderedAscending) // true 1.0.0 < 1.22.0
print(version.compare("0.0.9", options: .numeric) == .orderedDescending) // true 1.0.0 > 0.0.9
print(version.compare("2", options: .numeric) == .orderedAscending) // true 1.0.0 < 2

也可以写 String Extension:

1
2
3
4
5
extension String {
    func versionCompare(_ otherVersion: String) -> ComparisonResult {
        return self.compare(otherVersion, options: .numeric)
    }
}

⚠️但需注意若遇到格式不同要判断相同是会有误:

1
2
let version = "1.0.0"
version.compare("1", options: .numeric) //.orderedDescending

实际我们知道 1 == 1.0.0 ,但若用此方式判断将得到 .orderedDescending ;可 参考此篇文章补0后再判断 的做法;正常情况下我们选定 APP 版本格式后就不应该再变了,x.y.z 就一直用 x.y.z,不要一下 x.y.z 一下 x.y。

复杂方式

可直接使用已用轮子: mrackwitz/Version 以下为重造轮子。

复杂方式这边遵照使用语意化版本 x.y.z 最为格式规范,自行使用 Regex 做字串颇析并自行实作比较操作符,除了基本的 =/>/≥/</≤ 外还多实作了 ~> 操作符(同 Cocoapods 版本指定方式)并支援静态输入。

~> 操作符的定义是:

大于等于此版本但小于此版本的(上一阶层版号+1)

1
2
3
4
EX:
~> 1.2.1: (1.2.1 <= 版本 < 1.3) 1.2.3,1.2.4...
~> 1.2: (1.2 <= 版本 < 2) 1.3,1.4,1.5,1.3.2,1.4.1...
~> 1: (1 <= 版本 < 2) 1.1.2,1.2.3,1.5.9,1.9.0...
  1. 首先我们需要定义出 Version 物件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
@objcMembers
class Version: NSObject {
    private(set) var major: Int
    private(set) var minor: Int
    private(set) var patch: Int

    override var description: String {
        return "\(self.major),\(self.minor),\(self.patch)"
    }

    init(_ major: Int, _ minor: Int, _ patch: Int) {
        self.major = major
        self.minor = minor
        self.patch = patch
    }

    init(_ string: String) throws {
        let result = try Version.parse(string: string)
        self.major = result.version.major
        self.minor = result.version.minor
        self.patch = result.version.patch
    }

    static func parse(string: String) throws -> VersionParseResult {
        let regex = "^(?:(>=\\|>\\|<=\\|<\\|~>\\|=\\|!=){1}\\s*)?(0\\|[1-9]\\d*)\\.(0\\|[1-9]\\d*)\\.(0\\|[1-9]\\d*)$"
        let result = string.groupInMatches(regex)

        if result.count == 4 {
            //start with operator...
            let versionOperator = VersionOperator(string: result[0])
            guard versionOperator != .unSupported else {
                throw VersionUnSupported()
            }
            let major = Int(result[1]) ?? 0
            let minor = Int(result[2]) ?? 0
            let patch = Int(result[3]) ?? 0
            return VersionParseResult(versionOperator, Version(major, minor, patch))
        } else if result.count == 3 {
            //unSpecified operator...
            let major = Int(result[0]) ?? 0
            let minor = Int(result[1]) ?? 0
            let patch = Int(result[2]) ?? 0
            return VersionParseResult(.unSpecified, Version(major, minor, patch))
        } else {
            throw VersionUnSupported()
        }
    }
}

//Supported Objects
@objc class VersionUnSupported: NSObject, Error { }

@objc enum VersionOperator: Int {
    case equal
    case notEqual
    case higherThan
    case lowerThan
    case lowerThanOrEqual
    case higherThanOrEqual
    case optimistic

    case unSpecified
    case unSupported

    init(string: String) {
        switch string {
        case ">":
            self = .higherThan
        case "<":
            self = .lowerThan
        case "<=":
            self = .lowerThanOrEqual
        case ">=":
            self = .higherThanOrEqual
        case "~>":
            self = .optimistic
        case "=":
            self = .equal
        case "!=":
            self = .notEqual
        default:
            self = .unSupported
        }
    }
}

@objcMembers
class VersionParseResult: NSObject {
    var versionOperator: VersionOperator
    var version: Version
    init(_ versionOperator: VersionOperator, _ version: Version) {
        self.versionOperator = versionOperator
        self.version = version
    }
}

可以看到 Version 就是个 major,minor,patch 的储存器,解析方式写成 static 方便外部呼叫使用,可能传递 1.0.0 or ≥1.0.1 这两种格式,方便我们做字串解析、设定档解析。

1
2
Input: 1.0.0 => Output: .unSpecified, Version(1.0.0)
Input: ≥ 1.0.1 => Output: .higherThanOrEqual, Version(1.0.0)

Regex 是参考「 语意化版本文件 」中提供的 Regex 参考进行修改的:

1
^(0\\|[1-9]\d*)\.(0\\|[1-9]\d*)\.(0\\|[1-9]\d*)(?:-((?:0\\|[1-9]\d*\\|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0\\|[1-9]\d*\\|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$

*因考量到专案与 Objective-c 混编, OC 也要能使用所以都宣告为 @objcMembers、也妥协使用兼容OC 的写法。

(其实可以直接 VersionOperator 使用 enum: String、Result 使用 tuple/struct)

*若实作物件派生自 NSObject 在实作 Comparable/Equatable == 时记得也要实作 !=,原始 NSObject 的 != 操作不会是你预期的结果。

2.实作 Comparable 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
extension Version: Comparable {
    static func < (lhs: Version, rhs: Version) -> Bool {
        if lhs.major < rhs.major {
            return true
        } else if lhs.major == rhs.major {
            if lhs.minor < rhs.minor {
                return true
            } else if lhs.minor == rhs.minor {
                if lhs.patch < rhs.patch {
                    return true
                }
            }
        }

        return false
    }

    static func == (lhs: Version, rhs: Version) -> Bool {
        return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
    }

    static func != (lhs: Version, rhs: Version) -> Bool {
        return !(lhs == rhs)
    }

    static func ~> (lhs: Version, rhs: Version) -> Bool {
        let start = Version(lhs.major, lhs.minor, lhs.patch)
        let end = Version(lhs.major, lhs.minor, lhs.patch)

        if end.patch >= 0 {
            end.minor += 1
            end.patch = 0
        } else if end.minor > 0 {
            end.major += 1
            end.minor = 0
        } else {
            end.major += 1
        }
        return start <= rhs && rhs < end
    }

    func compareWith(_ version: Version, operator: VersionOperator) -> Bool {
        switch `operator` {
        case .equal, .unSpecified:
            return self == version
        case .notEqual:
            return self != version
        case .higherThan:
            return self > version
        case .lowerThan:
            return self < version
        case .lowerThanOrEqual:
            return self <= version
        case .higherThanOrEqual:
            return self >= version
        case .optimistic:
            return self ~> version
        case .unSupported:
            return false
        }
    }
}

其实就是实现前文所述判断逻辑,最后开一个 compareWith 的方法口,方便外部直接将解析结果带入得到最终判断。

使用范例:

1
2
3
4
5
6
7
8
let shouldAskUserFeedbackVersion = ">= 2.0.0"
let currentVersion = "3.0.0"
do {
  let result = try Version.parse(shouldAskUserFeedbackVersion)
  result.version.comparWith(currentVersion, result.operator) // true
} catch {
  print("version string parse error!")
}

或是…

1
Version(1,0,0) >= Version(0,0,9) //true...

支援 >/≥/</≤/=/!=/~> 操作符。

下一步

Test cases…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import XCTest

class VersionTests: XCTestCase {
    func testHigher() throws {
        let version = Version(3, 12, 1)
        XCTAssertEqual(version > Version(2, 100, 120), true)
        XCTAssertEqual(version > Version(3, 12, 0), true)
        XCTAssertEqual(version > Version(3, 10, 0), true)
        XCTAssertEqual(version >= Version(3, 12, 1), true)

        XCTAssertEqual(version > Version(3, 12, 1), false)
        XCTAssertEqual(version > Version(3, 12, 2), false)
        XCTAssertEqual(version > Version(4, 0, 0), false)
        XCTAssertEqual(version > Version(3, 13, 1), false)
    }

    func testLower() throws {
        let version = Version(3, 12, 1)
        XCTAssertEqual(version < Version(2, 100, 120), false)
        XCTAssertEqual(version < Version(3, 12, 0), false)
        XCTAssertEqual(version < Version(3, 10, 0), false)
        XCTAssertEqual(version <= Version(3, 12, 1), true)

        XCTAssertEqual(version < Version(3, 12, 1), false)
        XCTAssertEqual(version < Version(3, 12, 2), true)
        XCTAssertEqual(version < Version(4, 0, 0), true)
        XCTAssertEqual(version < Version(3, 13, 1), true)
    }

    func testEqual() throws {
        let version = Version(3, 12, 1)
        XCTAssertEqual(version == Version(3, 12, 1), true)
        XCTAssertEqual(version == Version(3, 12, 21), false)
        XCTAssertEqual(version != Version(3, 12, 1), false)
        XCTAssertEqual(version != Version(3, 12, 2), true)
    }

    func testOptimistic() throws {
        let version = Version(3, 12, 1)
        XCTAssertEqual(version ~> Version(3, 12, 1), true) //3.12.1 <= $0 < 3.13.0
        XCTAssertEqual(version ~> Version(3, 12, 9), true) //3.12.1 <= $0 < 3.13.0
        XCTAssertEqual(version ~> Version(3, 13, 0), false) //3.12.1 <= $0 < 3.13.0
        XCTAssertEqual(version ~> Version(3, 11, 1), false) //3.12.1 <= $0 < 3.13.0
        XCTAssertEqual(version ~> Version(3, 13, 1), false) //3.12.1 <= $0 < 3.13.0
        XCTAssertEqual(version ~> Version(2, 13, 0), false) //3.12.1 <= $0 < 3.13.0
        XCTAssertEqual(version ~> Version(3, 11, 100), false) //3.12.1 <= $0 < 3.13.0
    }

    func testVersionParse() throws {
        let unSpecifiedVersion = try? Version.parse(string: "1.2.3")
        XCTAssertNotNil(unSpecifiedVersion)
        XCTAssertEqual(unSpecifiedVersion!.version == Version(1, 2, 3), true)
        XCTAssertEqual(unSpecifiedVersion!.versionOperator, .unSpecified)

        let optimisticVersion = try? Version.parse(string: "~> 1.2.3")
        XCTAssertNotNil(optimisticVersion)
        XCTAssertEqual(optimisticVersion!.version == Version(1, 2, 3), true)
        XCTAssertEqual(optimisticVersion!.versionOperator, .optimistic)

        let higherThanVersion = try? Version.parse(string: "> 1.2.3")
        XCTAssertNotNil(higherThanVersion)
        XCTAssertEqual(higherThanVersion!.version == Version(1, 2, 3), true)
        XCTAssertEqual(higherThanVersion!.versionOperator, .higherThan)

        XCTAssertThrowsError(try Version.parse(string: "!! 1.2.3")) { error in
            XCTAssertEqual(error is VersionUnSupported, true)
        }
    }
}

目前打算将 Version 再进行优化、效能测试调整、整理打包,然后跑一次建立自己的 cocoapods 流程。

不过目前已经有很完整的 Version 处理 Pod 专案,所以不必要重造轮子,单纯只是想顺一下建立流程XD。

也许也还会为已有的轮子提交实作 ~> 的 PR。

参考资料:

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.