iOS开发中Mach-O的体积优化

1,298 阅读6分钟

这是我参与11月更文挑战的第10天,活动详情查看:2021最后一次更文挑战

Mach-O体积优化

什么是Bitcode?英文原意是:

Bitcode is an intermediate representation of a compiled program. apps you upload to App Store Connect that contain bitcode will be compiled and linked on the App Store. Including bitcode will allow Apple to re-optimize your app binary in the future without the need to submit a new version of your app to the App Store.
For iOS apps, bitcode is the default, but optional. For watchOS and tvOS apps, bitcode is required. If you provide bitcode, all apps and frameworks in the app bundle (all targets in the project) need to include bitcode.

Bitcode是编译后生成汇编之前的中间表现:

16116712152906.png 底层的编译流程如下:

16116711615481.png 包含Bitcode并上传到App Store ConnectApps会在App Store上编译和链接。包含Bitcode可以在不提交新版本App的情况下,允许Apple在将来的时候再次优化你的App二进制文件。
在Xcode中,默认开启Bitcode。如果你的App支持Bitcode,App使用到的其他二进制形式也要支持Bitcode:

16116712953459.png

链接时间优化(LTO)

Link Time Optimization (LTO)链接时间优化是指:

  • 链接阶段执行模块间优化。 通过整个程序分析和跨模块优化来获得更好的运行时性能的方法。
    在编译阶段,clang将发出LLVM bitcode而不是目标文件。
    链接器识别这些Bitcode文件,并在链接期间调用LLVM以生成将构成可执行文件的最终对象。
    接下来会加载所有输入的Bitcode文件,并将它们合并在一起以生成一个模块。
    通俗来讲,链接器将所有目标文件拉到一起,并将它们组合到一个程序中。链接器可以查看整个程序,因此可以进行整个程序的分析和优化。通常,链接器只有在将程序翻译成机器代码后才能看到该程序。
    LLVM的LTO机制是通过把LLVM IR传递给链接器,从而可以在链接期间执行整个程序分析和优化。所以,LTO的工作方式是编译器输出的目标文件不是常规目标文件:它是LLVM IR文件,仅通过目标文件扩展名伪装为目标文件。

16116598983025.jpg LTO有两种模式:

  • Full LTO是将每个单独的目标文件中的所有LLVM IR代码组合到一个大的module中,然后对其进行优化并像往常一样生成机器代码。
  • Thin LTO是将模块分开,但是根据需要可以从其他模块导入相关功能,并进行优化和机器代码生成。 进行LTO而不是一次全部编译的优点是(部分)编译与LTO并行进行。对于完整的LTO(-flto=full),仅并行执行语义分析,而优化和机器代码生成则在单个线程中完成。对于ThinLTO(-flto=thin),除全局分析步骤外,所有步骤均并行执行。因此,ThinLTOFullLTO或一次编译快得多。
    使用的编译链接参数有:
clang:
-flto=<value> 设置LTO的模式:full或者thin,默认full。
-lto_library <path> 指定执行LTO方式的库所在位置。当执行链接时间优化(LTO)时,链接器将自动去链接libLTO.dylib,或者从指定路径链接。

Xcode Build Setting中的设置为:

16116658707107.jpg 通过实例来分析下:

--- a.h ---
extern int foo1(void);
extern void foo2(void);
extern void foo4(void);

--- a.c ---
#include "a.h"

static signed int i = 0;

void foo2(void) {
  i = -1;
}

static int foo3() {
  foo4();
  return 10;
}

int foo1(void) {
  int data = 0;

  if (i < 0)
    data = foo3();

  data = data + 42;
  return data;
}

--- main.c ---
#include <stdio.h>
#include "a.h"

void foo4(void) {
  printf("Hi\n");
}

int main() {
  return foo1();
}

进入终端运行:

  1. 将a.c编译生成bitcode格式文件
clang -flto -c a.c -o a.o
  1. 将main.c正常编译成目标文件
clang -c main.c -o main.o
  1. 通过LTO将a.c和main.c通过LTO方式链接到一起
clang -flto a.o main.o -o main

按照LTO优化方式:

  1. 链接器首先按照顺序读取所有目标文件(此时,是bitcode文阿金,仅伪装成目标文件)并收集符号信息。
  2. 接下来,链接器使用全局符号表解析符号。找到未定义的符号,替换weak符号等。
  3. 按照解析的结果,告诉执行LTO的库文件(默认是libLTO.dylib)哪些符号是需要的。紧接着,链接器调用优化器和代码生成器,返回通过合并bitcode文件并应用各种优化过程而创建的目标文件。然后,更新内部全局符号表。
  4. 链接器继续执行,直到生成可执行文件。

我们实例中,LTO整个的优化顺序为:

  1. 首先读取a.o(bitcode文件)收集符号信息。链接器将foo1()、foo2()、foo4()识别为全局符号。
  2. 读取main.o(真正的目标文件),找到目标文件中使用的符号信息。此时,main.o使用了foo1(),定义了foo4()。
  3. 链接器完成了符号解析过程后,发现foo2()未在任何地方使用它将其传递给LTO。foo2()一旦可以删除,意味着发现foo1()里面调用foo3()的判断是中为假,也就是foo3()也没有使用,也可以删除。
  4. 符号处理完毕后,将处理结果传递给优化器和代码生成器,同时,将a.o合并到main.o中。
  5. 修改main.o的符号表信息。继续链接,生成可执行文件。 查看最后生成的可执行文件main的符号表信息:

%E6%88%AA%E5%B1%8F2021-01-26%20%E4%B8%8B%E5%8D%888.42.35.png 可以看到,链接完成之后,我们自己声明的函数只剩下:mainfoo1foo4
这个地方有个问题,foo4函数并没有在任何地方使用,为什么没有把它干掉?
因为LTO优化以入口文件需要的符号为准,来向外进行解析优化。所以,要优化掉foo4,那么就需要使用一个新的功能dead strip

dead strip

链接器的-dead_strip参数的作用是:

Remove functions and data that are unreachable by the entry point or exported symbols.

简单来讲,就是移除入口函数或者没有被导出符号使用到的函数或者代码。
现在foo4正式符合这种功能情况,所以,可以通过-dead_strip来删除掉无用代码。

放大到动态库,在创建动态库时可以使用-mark_dead_strippable_dylib

Specifies that the dylib being built can be dead strip by any
client.  That is, the dylib has no initialization side effects.
So if a client links against the dylib, but never uses any symbol
from it, the linker can optimize away the use of the dylib.

指明,如果并没有使用到该动态库的符号信息,那么链接器将会自动优化该动态库。不会因为路径问题崩溃。同时,你也可以在APP中使用-dead_strip_dylibs获得相同的功能。