揭秘 @available

2020-10-26 10:14:17 浏览数 (1)

# 【引言】为什么开启本话题

从2017年开始,OC语言可以使用 @available 语法糖判断运行时的系统版本,该语法糖可以帮助我们去掉很多烦人的警告。

2019年,@available 的内部实现进行了优化&升级,随着升级,一个副作用也随之而来:Xcode 10 中编译链接时如果依赖了使用 Xcode 11 打包的动态库或静态库会出现链接错误,导致 APP 无法编译成功( https://juejin.im/post/5d8af88ef265da5b6e0a23ac )。

本文将会介绍 @available 的使用场景、原理并会提供一种解决方案。

# @available 是什么

@available 是一个适配低版本运行环境的工具,该工具通常会与 API_AVAILABLE 宏搭配使用。

首先,我们先扩展一下 NSObject 的能力。请注意,我们通过`API_AVAILABLE(ios(13.0))` 标识了该方法只在 iOS 13及以上系统生效。

代码语言:javascript复制
@interface NSObject (KK)  (void)methodForIOS13 API_AVAILABLE(ios(13.0));@end@implementation NSObject (KK)  (void)methodForIOS13 {    NSLog(@"%@", self);}@end

随后,我们通过以下方法调用该能力。

代码语言:javascript复制
    if ([[UIDevice currentDevice].systemVersion floatValue]>=13.0) {
        [NSObject methodForIOS13];
    }

执行编译操作

代码语言:javascript复制
clang -x objective-c -target x86_64-apple-ios12.0-simulator -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.1.sdk -c main.m

main.m:92:19: warning: 'methodForIOS13' is only available on iOS 13.0 or newer [-Wunguarded-availability-new]
        [NSObject methodForIOS13];
                  ^~~~~~~~~~~~~~~
main.m:78:1: note: 'methodForIOS13' has been marked as being introduced in iOS 13.0 here, but the deployment target is iOS 12.0.0
  (void)methodForIOS13 API_AVAILABLE(ios(13.0));
^
main.m:92:19: note: enclose 'methodForIOS13' in an @available check to silence this warning
        [NSObject methodForIOS13];
                  ^~~~~~~~~~~~~~~
1 warning generated.

通过日志可以看到,clang 很“智能”的产出了一个⚠️。但实际上,我们已经判断运行时的版本号,该⚠️是完全不必要的。

切换到 @available 版本后,再次执行编译,上述的 ⚠️ 立马就消失了。

代码语言:javascript复制
 if (@available(iOS 13.0, *)) {        [NSObject methodForIOS13]; }

# @available 是如何实现的?

在讲 @available 实现之前,我们先梳理一下整体上的编译流程:

  • **预编译** 对源码执行预处理操作,比如展开 `#includes` `#defines`
  • **编译**
    • 解析预处理后的文件,构建 AST(源码中间语言)
    • 根据 AST 产出 IR(编译中间语言)
  • **编译后端** 根据目标机器特性,产出汇编码(可读性高于机器码)

**汇编** 将汇编码转化为机器码

  • **链接** 将多个对象文件组装为单个可执行文件

整理如下:

代码语言:javascript复制
objective-c  -<预处理>-> .ii -<编译>-> ir -<编译后端>-> .s (assembler) -<汇编器>-> .o 对象文件(机器码) -<链接器>-> 可执行文件

下面,我们先看看2017年,`@available(iOS 13.0, *)` 被引入时,该语法是如何生效的。

**编译**阶段,clang 在 AST 新增 `ObjCAvailabilityCheckExpr` 节点,该节点代表源码中的`@available(iOS 13.0, *)`,

根据 AST 产出 IR 时,如果存在该节点,直接转为执行 `int32_t __isOSVersionAtLeast(int32_t Major, int32_t Minor, int32_t Subminor)` 的函数调用。

其次,在**链接**阶段,clang 会自动链接一个静态库 `libclang_rt.*.a`(`/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.0/lib/darwin/libclang_rt.ios.a`)。该静态库提供了 `int32_t __isOSVersionAtLeast(int32_t Major, int32_t Minor, int32_t Subminor)` 函数的实现。

该实现的主要代码逻辑是,读取系统文件 `/System/Library/CoreServices/SystemVersion.plist`,使用 `scanf` 函数读取该文件中 `ProductVersion` 节点的值,并与开发者传入的参数进行比较。

>> 该方法只能在 Darwin平台使用,其它平台不可用。

>> 2017年版本的原始源码已经附在文章末位。感兴趣的读者可以稍后品读一下。

# 链接失败的问题是如何发生的?

上面的方案虽然实现了动态判断系统的方法,但是它严重依赖系统的 `/System/Library/CoreServices/SystemVersion.plist` 文件,不够通用化。所以,设计者们更换了一种更好的方案。

该方案思想如下:运行时环境提供一个判断运行时版本的函数 `_availability_version_check`。开发者的 `@available(iOS 13.0, *)` 代码将会转为执行该函数的实现。

考虑到低版本系统的兼容性问题(低版本运行时没有实现函数 `_availability_version_check`),最终方案为:`@available(iOS 13.0, *)` 调用 `libclang_rt.*.a` 的新函数 `int32_t __isPlatformVersionAtLeast(uint32_t Platform, uint32_t Major,

uint32_t Minor, uint32_t Subminor)`,该函数动态判断宿主是否实现了函数 `_availability_version_check`,如果没有实现,继续调用原有方法,如果实现了,则调用新的函数。

因为 Xcode 11 中附带的静态库 `libclang_rt.*.a`包含新的方法,自然而然的可以直接编译&链接&运行。

但是,一旦通过 Xcode 11 产出了一个静态库或者动态库,该库就会引用外部符号 `int32_t __isPlatformVersionAtLeast(uint32_t Platform, uint32_t Major,

uint32_t Minor, uint32_t Subminor)`

。一旦库被 Xcdeo 10 使用,就会因为无法找到该外部符合的实现导致链接错误 。

# 我们该如何解决?

链接符号缺失的问题思路很简单,手动补上即可。

手动将下面的代码添加到任何一个 `.m` 或者 `.c` 文件,确保被编译&链接即可。

代码语言:javascript复制
static int32_t GlobalMajor, GlobalMinor, GlobalSubminor;

int32_t __isPlatformVersionAtLeast(uint32_t Platform, uint32_t Major,
                                   uint32_t Minor, uint32_t Subminor) {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        const char *VersionStr = [UIDevice currentDevice].systemVersion.UTF8String;
        sscanf(VersionStr, "%d.%d.%d", &GlobalMajor, &GlobalMinor, &GlobalSubminor);
    });
    
    if (Major < GlobalMajor)
       return 1;
     if (Major > GlobalMajor)
       return 0;
     if (Minor < GlobalMinor)
       return 1;
     if (Minor > GlobalMinor)
       return 0;
     return Subminor <= GlobalSubminor;
}

# one more thing

聪明的读者可能会立即想到,如果上述代码能够覆盖系统的方法,那么我们是不是相当于对系统函数进行了hook?

答案是:我们只能hook部分场景。

比如,下面的代码就无法被hook。

代码语言:javascript复制
 if (@available(iOS 3.0, *)) {
        [NSObject methodForIOS13];
 }

实际上,上述代码会经过被编译器进行一个特殊优化,该优化检测到我们设置的运行时版本不会低于 `ios12.0`(通过参数决定 `-target arm64-apple-ios12.0` ),运行时无需判断系统版本就能执行。

所以当APP 运行时,会直接执行 ` [NSObject methodForIOS13];`方法。

附2017年版本的源码:

代码语言:javascript复制
/* These three variables hold the host's OS version. */
static int32_t GlobalMajor, GlobalMinor, GlobalSubminor;
static dispatch_once_t DispatchOnceCounter;


/* Find and parse the SystemVersion.plist file. */
static void parseSystemVersionPList(void *Unused) {
  (void)Unused;


  char *PListPath = "/System/Library/CoreServices/SystemVersion.plist";


#if TARGET_OS_SIMULATOR
  char *PListPathPrefix = getenv("IPHONE_SIMULATOR_ROOT");
  if (!PListPathPrefix)
    return;
  char FullPath[strlen(PListPathPrefix)   strlen(PListPath)   1];
  strcpy(FullPath, PListPathPrefix);
  strcat(FullPath, PListPath);
  PListPath = FullPath;
#endif
  FILE *PropertyList = fopen(PListPath, "r");
  if (!PropertyList)
    return;


  /* Dynamically allocated stuff. */
  CFDictionaryRef PListRef = NULL;
  CFDataRef FileContentsRef = NULL;
  UInt8 *PListBuf = NULL;


  fseek(PropertyList, 0, SEEK_END);
  long PListFileSize = ftell(PropertyList);
  if (PListFileSize < 0)
    goto Fail;
  rewind(PropertyList);


  PListBuf = malloc((size_t)PListFileSize);
  if (!PListBuf)
    goto Fail;


  size_t NumRead = fread(PListBuf, 1, (size_t)PListFileSize, PropertyList);
  if (NumRead != (size_t)PListFileSize)
    goto Fail;


  /* Get the file buffer into CF's format. We pass in a null allocator here *
   * because we free PListBuf ourselves */
  FileContentsRef = CFDataCreateWithBytesNoCopy(
      NULL, PListBuf, (CFIndex)NumRead, kCFAllocatorNull);
  if (!FileContentsRef)
    goto Fail;


  if (&CFPropertyListCreateWithData)
    PListRef = CFPropertyListCreateWithData(
        NULL, FileContentsRef, kCFPropertyListImmutable, NULL, NULL);
  else
    PListRef = CFPropertyListCreateFromXMLData(NULL, FileContentsRef,
                                               kCFPropertyListImmutable, NULL);
  if (!PListRef)
    goto Fail;


  CFTypeRef OpaqueValue =
      CFDictionaryGetValue(PListRef, CFSTR("ProductVersion"));
  if (!OpaqueValue || CFGetTypeID(OpaqueValue) != CFStringGetTypeID())
    goto Fail;


  char VersionStr[32];
  if (!CFStringGetCString((CFStringRef)OpaqueValue, VersionStr,
                          sizeof(VersionStr), kCFStringEncodingUTF8))
    goto Fail;
  sscanf(VersionStr, "%d.%d.%d", &GlobalMajor, &GlobalMinor, &GlobalSubminor);


Fail:
  if (PListRef)
    CFRelease(PListRef);
  if (FileContentsRef)
    CFRelease(FileContentsRef);
  free(PListBuf);
  fclose(PropertyList);
}


int32_t __isOSVersionAtLeast(int32_t Major, int32_t Minor, int32_t Subminor) {
  /* Populate the global version variables, if they haven't already. */
  dispatch_once_f(&DispatchOnceCounter, NULL, parseSystemVersionPList);


  if (Major < GlobalMajor) return 1;
  if (Major > GlobalMajor) return 0;
  if (Minor < GlobalMinor) return 1;
  if (Minor > GlobalMinor) return 0;
  return Subminor <= GlobalSubminor;
}

0 人点赞