高效的 iOS 版本检测方法

极少数应用程序很“奢侈”的只支持最新版本的 iOS。 设置一个较低的部署目标以及基于特定 iOS 版本的代码分支通常是很有必要的。虽然苹果公司的信息有些矛盾,还是有各种办法来完成这个。最近在这条 tweet上看到有人警告说,不要这样做:

#define IsIOS7 ([[[[UIDevice currentDevice] systemVersion] substringToIndex:1] intValue]>=7)

GitHub 搜索显示,有超过 8000 的结果调用了 substringToIndex:1 。所有这些代码碰到 iOS 10 就“懵逼”了。因为 iOS 10 会被检测成 iOS 1 了,估计只有在越狱的应用中才会出现吧。

又是同样的老故事。Windows 9 变成 Windows 10 是因为有太多代码通过 if (name.startsWith("windows 9")) 来检查 Windows 95 和 98 了。

新 API

苹果公司令人惊讶的花了相当长的时间才意识到这个问题并提供了更好的 API。iOS 8 中,终于有了一些改进!现在 NSProcessInfo 有一个新的 operatingSystemVersion 方法,更重要的是还有 - (BOOL)isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion)version 方法来检查。

if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion){.majorVersion = 9, .minorVersion = 1, .patchVersion = 0}]) {
    NSLog(@"Hello from > iOS 9.1");
}

// Using short-form for the struct, we can make things somewhat more compact:
if ([NSProcessInfo.processInfo isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion){9,3,0}]) {
    NSLog(@"Hello from > iOS 9.3");
}

我们在 PSPDFKit 做了什么

PSPDFKit 是一个关于PDF的SDK,我们可以用它在PDF文档上实现查看、注释以及填写表单的功能。最开始写这个 SDK 的时候还是 iOS 4,随着一系列新 iOS 版本的发布,它也不断的在改进。那个时候还没有专门的 API 来检测版本,许多应用采用类似下面的代码:

if ([[[UIDevice currentDevice] systemVersion] isEqualToString:@"7.0"]) {
    //do stuff
}

这样用不好,所以我们从来没这样用过。比较字符串速度可能很快,但是在这种情况下是个错误的选择。正确的做法是像下面这样

#define SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(v) \
  ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] != NSOrderedAscending)

if (SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(@"9.0")) {
    ...
}

这样又太笨重了,容易出错。有种更简单的方法。我们可以用 NSFoundationVersionNumber(或者 kCFCoreFoundationVersionNumber)来比较。系统检测开销降低到一个简单的 if 比较。不需要调用其它方法,所以它效率极高,即使在紧凑的循环中表现也不错。

if (NSFoundationVersionNumber >= NSFoundationVersionNumber_iOS_9_0) {
    // do stuff for iOS 9 and newer
}
else {
    // do stuff for older versions than iOS 9
}

事实上,这正是苹果公司在 2013 构建现代应用程序技术论坛中章节 2所建议的。

告诫:有时候会缺少一些常量。NSFoundationVersionNumber 是在 NSObjCRuntime.h 中定义的,作为 Xcode 7.3.1 的一部分,我们设定常数范围从 iPhone OS 2 到 #define NSFoundationVersionNumber_iOS_8_4 1144.17 - 而不是 9.0-9.3\。 对于 kCFCoreFoundationVersionNumber 也一样。注意,虽然这些数字很相似,但是它们的意义是不同的,所以使用其中一个或者另外一个。

如果你做 macOS 开发的话,也可以使用 NSAppKitVersionNumber ,它通常是更新到最新的

在 SDK 10(Xcode 8)苹果补充了缺少的数字,甚至还有未来的版本。

#define NSFoundationVersionNumber_iOS_9_0 1240.1
#define NSFoundationVersionNumber_iOS_9_1 1241.14
#define NSFoundationVersionNumber_iOS_9_2 1242.12
#define NSFoundationVersionNumber_iOS_9_3 1242.12
#define NSFoundationVersionNumber_iOS_9_4 1280.25
#define NSFoundationVersionNumber_iOS_9_x_Max 1299

会有 iOS 9.4 吗?考虑到 iOS 10 将在未来 3 个月内发布,而且 9.3.3 仍然是 beta 版,我估计是不会有了,但是最好还是占个坑吧。在 PSPDFKit 中,我们是使用下面的模式来定义缺少的版本号。如果代码以一个更高的最低部署目标构建,代码会自动编译,当我们遗漏了一些 iOS 版本时,这会很有帮助。

// iOS 9 compatibility
#ifndef kCFCoreFoundationVersionNumber_iOS_9_0
#define kCFCoreFoundationVersionNumber_iOS_9_0 1223.1
#endif

#define PSPDF_IS_IOS9_OR_GREATER (kCFCoreFoundationVersionNumber >= kCFCoreFoundationVersionNumber_iOS_9_0)

#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 90000
#define PSPDF_IF_IOS9_OR_GREATER(...) \
if (PSPDF_IS_IOS9_OR_GREATER) { \
PSPDF_PARTIAL_AVAILABILITY_BEGIN \
__VA_ARGS__ \
PSPDF_PARTIAL_AVAILABILITY_END }
#else
#define PSPDF_IF_IOS9_OR_GREATER(...)
#endif

#if defined(__IPHONE_OS_VERSION_MIN_REQUIRED) && __IPHONE_OS_VERSION_MIN_REQUIRED < 90000
#define PSPDF_IF_PRE_IOS9(...)  \
if (kCFCoreFoundationVersionNumber < kCFCoreFoundationVersionNumber_iOS_9_0) { \
PSPDF_PARTIAL_AVAILABILITY_BEGIN \
__VA_ARGS__ \
PSPDF_PARTIAL_AVAILABILITY_END }
#else
#define PSPDF_IF_PRE_IOS9(...)
#endif

请注意部分可用的宏。这是添加到 SDK 9 中出现的一个警告。当我们打包进版本代码块时,这些没什么用,所以禁用它。我们有一些单独的宏,在做一些其他类型的可用性检查时会有用。我们在某些情况下使用,例如在 UICollectionView 中实现一些交互动画,在 iOS 9 中拖拽 tab 或者 page,同时也要支持 iOS 8。

#define PSPDF_PARTIAL_AVAILABILITY_BEGIN \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Wpartial-availability\"")

#define PSPDF_PARTIAL_AVAILABILITY_END \
_Pragma("clang diagnostic pop")

为什么用这些宏

自从前段时间我们放弃了 iOS 7,我们可以轻易的切换到新的 isOperatingSystemAtLeastVersion: 方法上。其内部实现是通过调用 operatingSystemVersion ,是相当高效的。但它会产生更多的代码,仍然比我们现在的实现要慢一点。我没看到过基础检测的正面比较,但是可以肯定的说用了这些宏会更好,如果没有用宏的话,赶紧试试吧。

如果我们直接看 operatingSystemVersion 的实现,确实有点丑。它被缓存了,但是它通过调用 _CFCopySystemVersionDictionary() 生成版本号,然后查找 kCFSystemVersionProductVersionKey (就是 ProductVersion),然后对该字符串执行 componentsSeparatedByString: 。不知道为啥,我更期望这是硬编码,但是从外部字典文件读取可能更加灵活。

Swift

由于 Swift 2.0 是支持内置版本检查的语言,以前是这么用的:

if NSProcessInfo().isOperatingSystemAtLeastVersion(NSOperatingSystemVersion(majorVersion: 10, minorVersion: 0, patchVersion: 0)) {
    // modern code
}

现在可以用更少的代码完成同样的事:

if #available(iOS 10.0, *) {
    // modern code
} else {
    // Fallback on earlier versions
}

Swift 还适用于代码块中 API 调用的可用性检查,所以我们保证了编译时安全,在 Objective-C 是无法轻易做到的。 在 “Swift in Practice” (WWDC 2015, Session 411) 8:40 开始,一名苹果工程师详细介绍了这一特性。

那么,Swift 底层是怎样实现的?好在它是开源的而且有着良好的结构。让我们来看看 Availability.swift:

/// Returns 1 if the running OS version is greater than or equal to
/// major.minor.patchVersion and 0 otherwise.
///
/// This is a magic entry point known to the compiler. It is called in
/// generated code for API availability checking.
@_semantics("availability.osversion")
public func _stdlib_isOSVersionAtLeast(
  _ major: Builtin.Word,
  _ minor: Builtin.Word,
  _ patch: Builtin.Word
) -> Builtin.Int1 {
#if os(OSX) || os(iOS) || os(tvOS) || os(watchOS)
  let runningVersion = _swift_stdlib_operatingSystemVersion()
  let queryVersion = _SwiftNSOperatingSystemVersion(
    majorVersion: Int(major),
    minorVersion: Int(minor),
    patchVersion: Int(patch)
  )

  let result = runningVersion >= queryVersion

  return result._value
#else
  // FIXME: As yet, there is no obvious versioning standard for platforms other
  // than Darwin-based OS', so we just assume false for now. 
  // rdar://problem/18881232
  return false._value
#endif
}

现在,更有趣的是,_swift_stdlib_operatingSystemVersion() 是干什么的,它是怎么定义的?想要找到答案的话,我们得离开舒适的 Swift 世界了,然后深入探究 [“疯狂”的 Objective-C++] 。进入 Availability.mm:

/// Return the version of the operating system currently running for use in
/// API availability queries.
_SwiftNSOperatingSystemVersion swift::_swift_stdlib_operatingSystemVersion() {
  static NSOperatingSystemVersion version = ([]{
    // Use -[NSProcessInfo.operatingSystemVersion] when present
    // (on iOS 8 and OS X 10.10 and above).
    if ([NSProcessInfo
         instancesRespondToSelector:@selector(operatingSystemVersion)]) {
      return [[NSProcessInfo processInfo] operatingSystemVersion];
    } else {
      // Otherwise load and parse from SystemVersion dictionary.
      return operatingSystemVersionFromPlist();
    }
  })();

  return { version.majorVersion, version.minorVersion, version.patchVersion };
}

Swift 使用了 iOS 8 的新 API,但是低于 iOS 8 的版本又回退到糟糕的方法了,开放了 @"/System/Library/CoreServices/SystemVersion.plist" 文件。这样结果就会被缓存,版本检测会访问硬盘,但是只访问一次。我的第一反应是发送一个变化的 pull 请求,简单的使用已有的公用 API(systemVersion),然而 Xcode 8 设置最小部署目标为 iOS 8,我们不可能看到另外一个有着 Swift 更新的 Xcode 7.3.x 发布,所以这段代码在低于 iOS 8 的版本可能是完全无用的。

更多关于向后兼容

值得注意的是,苹果正在努力让这些版本检测成为不必要的。当然还有 respondsToSelector:instancesRespondToSelector: ,它们是 Objective-C 的一部分。假设你已经了解这些,那么使用它们是极好的,在某些情况下,我们也会使用。然而,还是有些情况不适用的。例如,有时苹果会把已经存在于一些组件或者更深层的内部构造中的有着不同特性的 API 公开。这就是为什么 appStoreReceiptURL 在 iOS 7 中添加,但是 iOS 6 中也存在的原因。在这种情况下,显式的版本是更可靠的。此外,当你希望放弃旧版本的 iOS 时,也更容易清理代码。所有需要你做的就是移除兼容性宏和修复构建错误。

弱链接

在很早的时候,使用一个不在所有版本中可用的类意味着要使用下面的模式:

Class cls = NSClassFromString (@"NSRegularExpression");
if (cls) {
    // Create an instance of the class and use it.
} else {
    // Alternate code path to follow when the
    // class is not available.
}

随着 iOS 4.2 弱链接类的添加,现在这要简单得多︰

if ([UIPrintInteractionController class]) {
    // Create an instance of the class and use it.
} else {
    // Alternate code path to follow when the
    // class is not available.
}

Greg Parker 在他的 Hamster Emporium 文章中分享了更多,包括这个梗:

为 Objective-C 增加弱导入是 Snow Leopard 没有按时发布的原因。假设在 Mac OS X 10.7(以猫科动物命名)按时发布,直到 Mac OS X 10.8 你才能用的上。

弱链接可以扩展到一个整体框架。在 PSPDFKit,我们为 SafariServices 做了扩展,其中包含 SFSafariViewController(在 iOS 9 中加入)。

// Part of our .xcconfig file:
-weak_framework SafariServices

弱链接在启动的时候会有些性能损耗,所以当你不得不使用的时候再用。想学习更多,看看 Apple's SDK Compatibility Guide

结论

对于大多数应用,在 Objective-C 中使用 isOperatingSystemAtLeastVersion:,在 Swift 中使用 #available() 就足够了。了解底层实现还是很有趣的,一切都比字符串比较要好。如果你喜欢刨根问底,那么 PSPDFKit 就是你该来的地方。

更新

发表这篇文章之后,Devin Coughlin ,#available 方法的作者回复了为什么在 Swift 中不能使用 systemVersion

@steipete Also: 为什么不用 "systemVersion"? 因为 UIDevice 在 macOS 中不存在,然后我们想在所有平台上使用同样的代码路径。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,688评论 1 330
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,559评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,749评论 0 226
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,581评论 0 191
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,741评论 3 271
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,684评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,122评论 2 292
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,847评论 0 182
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,441评论 0 228
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,939评论 2 232
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,333评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,783评论 2 236
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,275评论 3 220
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,830评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,444评论 0 180
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,553评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,618评论 2 249

推荐阅读更多精彩内容

  • 家乡再次进入秋冬季! 这样绚丽的色彩,令人沉醉!
    锦瑟薇雪阅读 122评论 0 0
  • 小时候调皮不懂事,因为我妈脾气好,从来没有骂过我,更不要说打我,所以很自然的我也就没有怕过她,倒是时不时的...
    找祢真难阅读 372评论 0 1
  • 投射:1投射撿到超大客戶讓我終身致富! 2投射有刷卡錢讓我喜出望外! 3投射遇到真姻緣發生在我身上!感賞:1感賞7...
    謝奕鋒阅读 233评论 0 0
  • “十步杀一人,千里不留行。事了拂衣去,深藏身与名。” 我国古代是个讲究行侠仗义的国度,在春秋战国时期,游侠的故事让...
    要阿龙阅读 7,115评论 1 12