iOS APP 版本号规则|语意化版本与版本比较技术详解
iOS 开发者常见版本号管理痛点,掌握语意化版本规范及 Version 与 Build Number 差异,并实作 Swift 版本号比较方法,提升版本判断准确性与自动化管理效率。
Click here to view the English version of this article.
點擊這裡查看本文章正體中文版本。
基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。
iOS APP 版本号那些事
版本号规则及判断比较解决方案
Photo by James Yarema
前言
所有 iOS APP 开发者都会碰到的两个数字,Version Number 和 Build Number;最近刚好遇到需求跟版本号有关,要做版本号判断邀请使用者评价 APP,顺便挖掘了一下关于版本号的事;文末也会附上我的版本号判断解决大全。
语意化版本 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...
- 首先我们需要定义出 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。
参考资料:
有任何问题及指教欢迎 与我联络 。
本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。