iOS APP 版本號那些事

ZhgChgLi
ZRealm Dev.
Published in
20 min readDec 17, 2020

--

版本號規則及判斷比較解決方案

Photo by James Yarema

前言

所有 iOS APP 開發者都會碰到的兩個數字,Version Number 和 Build Number;最近剛好遇到需求跟版本號有關,要做版本號判斷邀請使用者評價 APP,順便挖掘了一下關於版本號的事;文末也會附上我的版本號判斷解決大全。

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 版本則跳強制更新、等於某個版本跳邀請評價,這時候就需要能比較兩個版本字串的功能。

簡易方式

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:

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

⚠️但需注意若遇到格式不同要判斷相同是會有誤:

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)

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 物件:
@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 這兩種格式,方便我們做字串解析、設定檔解析。

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

Regex 是參考「語意化版本文件」中提供的 Regex 參考進行修改的:

^(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 方法:

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 的方法口,方便外部直接將解析結果帶入得到最終判斷。

使用範例:

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!")
}

或是…

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

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

下一步

Test cases…

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。

參考資料:

有任何問題及指教歡迎與我聯絡

--

--

ZhgChgLi
ZRealm Dev.

探索世界、求知若渴、教學相長;更愛電影、美劇、西音、運動、生活. www.zhgchg.li