Post

iOS App Versioning Explained|Key Rules and Comparison Solutions

Discover essential iOS app versioning rules and effective comparison methods to streamline your app updates, avoid deployment errors, and enhance user experience with precise version control.

iOS App Versioning Explained|Key Rules and Comparison Solutions

点击这里查看本文章简体中文版本。

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

This post was translated with AI assistance — let me know if anything sounds off!


About iOS App Version Numbers

Version Number Rules and Comparison Solutions

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

Photo by James Yarema

Preface

Every iOS app developer encounters two numbers: Version Number and Build Number. Recently, I faced a requirement related to version numbers to prompt users for app reviews. Along the way, I explored some facts about version numbers. At the end of the article, I will also share my complete solution for version number checks.

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

XCode Help

Semantic Version x.y.z

First, let’s introduce the “Semantic Versioning” specification. It mainly addresses issues related to software dependencies and management, such as those we often encounter with Cocoapods. For example, if I use Moya 4.0, which depends on Alamofire 2.0.0, and Alamofire gets updated—whether adding new features, fixing bugs, or completely redesigning the architecture (breaking backward compatibility)—without a common versioning standard, it would become chaotic. You wouldn’t know which versions are compatible or safe to update.

The semantic version consists of three parts: x.y.z

  • x: Major version: When you make incompatible API changes

  • y: Minor version: When you add backward-compatible functional improvements

  • z: Patch number: When you make backward-compatible bug fixes

General Rules:

  • Must be a non-negative integer

  • No leading zeros allowed

  • 0.y.z versions indicate the initial development stage and should not be used for official releases.

  • In ascending numerical order

Comparison Method:

First compare the major version number; if the major versions are equal, then compare the minor version number; if the minor versions are also equal, then compare the revision number.

ex: 1.0.0 < 2.0.0 < 2.1.0 < 2.1.1

You can also add a “pre-release version number (e.g., 1.0.1-alpha)” or “build metadata (e.g., 1.0.0-alpha+001)” after the patch number. However, iOS app version numbers do not allow these two formats to be uploaded to the App Store, so this will not be discussed further here. For more details, please refer to Semantic Versioning.

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

Practical Use

Regarding the actual use of version control in iOS apps, since we only mark the release app version and there are no dependencies with other apps or software, the definition in practice is determined by each team. The following is just a personal opinion:

  • x: Major version number: Used for significant updates (multiple page interface redesigns, launch of key features)

  • y: Minor version number: For optimizing and enhancing existing features (adding small features under major functions)

  • z: Patch version: Used when fixing bugs in the current version

Generally, the revision number is only changed for emergency fixes (Hot Fix); under normal circumstances, it remains 0. When a new version is released, it can be reset to 0.

EX: First release (1.0.0) -> Enhance features of the first version (1.1.0) -> Found issues to fix (1.1.1) -> Found more issues (1.1.2) -> Continue enhancing first version features (1.2.0) -> Major update (2.0.0) -> Found issues to fix (2.0.1) … and so on

Version Number vs. Build Number

Version Number (APP version number)

  • For App Store and external identification use

  • Property List Key: CFBundleShortVersionString

  • Content can only consist of numbers and “.”

  • The official recommendation is to use the semantic versioning format x.y.z.

  • 2020121701, 2.0, 2.0.0.1 are all acceptable
    (The summary below shows the naming conventions of app version numbers on the App Store)

  • No more than 18 characters

  • If the format is incorrect, you can build & run but cannot package and upload to the App Store.

  • Can only increase upwards, no duplicates, no decreases

It is common to use semantic versions x.y.z or x.y.

Build Number

  • Internal development process and phase identification are for internal use only and will not be disclosed to users.

  • Packaging and uploading to the App Store for identification use (the same build number cannot be uploaded repeatedly)

  • Property List Key: CFBundleVersion

  • Content can only consist of numbers and “.”

  • The official recommendation is to use the semantic versioning format x.y.z.

    1. 2020121701, 2.0, and 2.0.0.1 are all acceptable
  • No more than 18 characters

  • Build & run works, but cannot package and upload to the App Store

  • The same app version number cannot be duplicated, but different app version numbers can have the same build number.
    ex: 1.0.0 build: 1.0.0, 1.1.0 build: 1.0.0 ✅

It is common to use the date and number (each new version starts from 0), combined with CI/fastlane to automatically increment the build number during packaging.

I briefly analyzed the version number formats of the apps on the leaderboard, as shown in the above image.

Generally, x.y.z is used as the main format.

Version Number Comparison and Determination Method

Sometimes we need to use version checks, for example: force an update if the version is below x.y.z, or prompt for a review at a specific version. In these cases, we need the ability to compare two version strings.

Simple Method

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

You can also write a String Extension:

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

⚠️However, be aware that judging similarity based on different formats may cause errors:

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

In fact, we know that 1 == 1.0.0, but using this method will result in .orderedDescending; you can refer to this article for padding zeros before comparison. Normally, once we choose an app version format, it should not change—if you use x.y.z, keep using x.y.z consistently, and avoid switching between x.y.z and x.y.

Complex Method

You can directly use the existing wheel: mrackwitz/Version The following is a rebuilt wheel.

For complex methods, the semantic versioning format x.y.z is followed. Regex is used to parse strings, and comparison operators are implemented manually. Besides the basic =/>/≥/</≤ operators, the ~> operator (same as Cocoapods version specification) is also supported, along with static input.

~> operator is defined as:

Greater than or equal to this version but less than (previous major version + 1)

1
2
3
4
EX:
~> 1.2.1: (1.2.1 <= version < 1.3) 1.2.3,1.2.4...
~> 1.2: (1.2 <= version < 2) 1.3,1.4,1.5,1.3.2,1.4.1...
~> 1: (1 <= version < 2) 1.1.2,1.2.3,1.5.9,1.9.0...
  1. First, we need to define the Version object:
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
    }
}

You can see that Version is a storage for major, minor, and patch. The parsing method is written as static for easy external use. It can accept formats like 1.0.0 or ≥1.0.1, which makes string parsing and config file parsing convenient.

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

Regex is modified based on the Regex reference provided in the Semantic Versioning document.

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-]+)*))?$

Considering the project involves mixed Objective-C, OC must also be usable, so all are declared as @objcMembers, and compatible OC syntax is used as a compromise.

(Actually, you can directly use VersionOperator with enum: String, and Result with tuple/struct)

*If your implementation inherits from NSObject and implements Comparable/Equatable’s ==, remember to also implement !=, as the original NSObject’s != operation will not behave as expected.

2. Implement Comparable Method:

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
        }
    }
}

It actually implements the judgment logic described earlier, and finally opens a compareWith method interface for external use to directly input the parsed results and get the final judgment.

Usage Example:

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

Or…

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

Supports the operators >/≥/</≤/=/!=/~>.

Next Steps

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

Currently planning to further optimize the version, adjust performance testing, organize and package, then run the process to create my own CocoaPods.

However, there is already a complete Version for handling Pod projects, so there is no need to reinvent the wheel. I just wanted to streamline the build process XD.

Maybe I will also submit a PR implementing ~> for an existing library.

References:

If you have any questions or feedback, feel free to contact me.


Buy me a beer

This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.

Improve this page on Github.

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