iOS APP バージョン番号について
バージョン番号のルールと判定比較の解決策

Photo by James Yarema
はじめに
すべての iOS アプリ開発者が必ず直面する 2 つの数字、Version Number と Build Number;最近ちょうどバージョン番号に関する要件があり、バージョン番号を判定してユーザーにアプリの評価を促す機能を作ることになりました。それに伴い、バージョン番号について調べてみました。記事の最後には私のバージョン番号判定ソリューション全集も掲載しています。

セマンティックバージョン x.y.z
まず「セマンティックバージョニング」という規格を紹介します。これはソフトウェアの依存関係や管理の問題を解決するためのもので、私たちがよく使うCocoapodsなどに適用されます。例えば、Moya 4.0を使っているとします。Moya 4.0はAlamofire 2.0.0に依存しています。もしAlamofireが更新された場合、それが新機能の追加か、バグ修正か、あるいは互換性のない大幅な構造変更かもしれません。このとき、バージョン番号に共通のルールがなければ、どのバージョンが互換性があり更新可能か分からず、混乱が生じます。
セマンティックバージョンは3つの部分で構成されます: x.y.z
-
x: メジャーバージョン (major):互換性のないAPI変更を行った場合
-
y: マイナーバージョン (minor):下位互換の機能追加を行った場合
-
z: パッチ番号 (patch):後方互換性のあるバグ修正を行ったとき
共通ルール:
-
非負の整数でなければなりません
-
ゼロ埋めは禁止です
-
0.y.z は開発初期段階を示し、正式リリースのバージョン番号としては使用すべきではありません。
-
数値は増加順である必要があります
比較方法:
まずメジャーバージョンを比較し、メジャーバージョンが同じ場合はマイナーバージョンを比較し、マイナーバージョンも同じ場合はパッチバージョンを比較します。
例: 1.0.0 < 2.0.0 < 2.1.0 < 2.1.1
また、修正番号の後に「プレリリース情報(例: 1.0.1-alpha)」や「ビルドメタデータ(例: 1.0.0-alpha+001)」を付けることもできますが、iOSアプリのバージョン番号としてはこれらの形式はApp Storeにアップロードできないため、ここでは詳しく説明しません。詳細は「語意化版本」を参照してください。
✅:1.0.1、1.0.0、5.6.7
❌:01.5.6、a1.2.3、2.005.6
実際の使用例
iOSアプリのバージョン管理に実際に使用する場合、リリース版のバージョンを示すためだけであり、他のアプリやソフトウェアとの依存関係は存在しません。そのため、実際の運用における定義は各チームが独自に決めることになります。以下はあくまで個人的な考えです:
-
x: メジャーバージョン (major):大規模な更新時(複数画面の刷新や主要機能のリリース)
-
y: マイナーバージョン (minor):既存機能の改善や強化時(大きな機能の下での小機能追加)
-
z: パッチ番号 (patch):現在のバージョンのバグ修正時に使用
一般的に緊急修正(Hot Fix)の場合のみリビジョン番号を変更し、通常は 0 のままにします。新しいバージョンをリリースする際には、リビジョン番号を再び 0 に戻すことができます。
例:初版リリース(1.0.0) -> 初版の機能強化(1.1.0) -> 問題発見による修正(1.1.1) -> 再度問題発見(1.1.2) -> 引き続き初版の機能強化(1.2.0) -> 大幅リニューアル(2.0.0) -> 問題発見による修正(2.0.1) … 以下同様
バージョン番号とビルド番号の違い
バージョン番号(APP バージョン番号)
-
App Store、外部識別用
-
プロパティリストキー:
CFBundleShortVersionString -
内容は数字と「.」のみで構成されている必要があります
-
公式でもセマンティックバージョニングの x.y.z 形式の使用が推奨されています
-
2020121701、2.0、2.0.0.1 どれも可
(以下に App Store 上のアプリバージョン番号の命名方法の総覧があります) -
18文字以内でお願いします
-
フォーマットが合っていなくてもビルド&実行はできるが、App Storeへのアップロードはできない
-
上昇のみ可能、重複不可、下降禁止
一般的にセマンティックバージョニング x.y.z または x.y が使われます。
ビルド番号
-
内部開発プロセスや段階の識別に使用し、ユーザーには公開しません。
-
App Store にアップロードする際の識別(同じ build number では再アップロードできません)
-
プロパティリストキー:
CFBundleVersion -
内容は数字と「.」のみで構成されている必要があります
-
公式でもセマンティックバージョニングの x.y.z 形式の使用が推奨されています
-
1、2020121701、2.0、2.0.0.1 いずれも可能です
-
18文字以内でお願いします
-
フォーマットが合っていなくてもビルド&実行はできるが、App Storeへのアップロードはできない
-
同じ APP のバージョン番号内では重複できませんが、異なる APP バージョン番号では重複可能です。
例: 1.0.0 build: 1.0.0, 1.1.0 build: 1.0.0 ✅
一般的には日付や番号(各新バージョンは0から開始)を使用し、CIやfastlaneでビルド番号を自動的にインクリメントします。

ランキング上のアプリのバージョン番号の形式を少し集計しました。上の図の通りです。
一般的には x.y.z を主に使います。
バージョン番号の比較と判定方法
時にはバージョンを使って判定する必要があります。例えば、x.y.z バージョン未満なら強制更新を促す、特定のバージョンと等しい場合に評価の招待を行う、などです。このような場合、2つのバージョン文字列を比較する機能が必要になります。
簡単な方法
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 と途中で変えないようにしましょう。
複雑な方法
既存のライブラリを直接使用可能: mrackwitz/Version 以下は自作の実装です。
複雑な方法では、セマンティックバージョン x.y.z のフォーマット規則に従い、独自に正規表現で文字列を解析し、比較演算子を実装しています。基本の =/>/≥/</≤ に加え、~> 演算子(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...
- まずは 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 {
// 演算子で始まる場合...
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 {
// 演算子未指定の場合...
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()
}
}
}
// 対応するオブジェクト
@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 や ≥1.0.1 のような形式を渡すことができ、文字列解析や設定ファイルの解析に便利です。
Input: 1.0.0 => 出力: .unSpecified, Version(1.0.0)
Input: ≥ 1.0.1 => 出力: .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(1,0,0) >= Version(0,0,9) //true...
>/≥/</≤/=/!=/~>演算子をサポートしています。
次のステップ
テストケース…
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として提出するかもしれません。
参考資料:
Post は ZMediumToMarkdown によって Medium から変換されました。



コメント