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 提供自動轉換與同步技術。