控制符号可见性

控制符号可见性可以减小 APK 大小、缩短加载时间,并帮助其他开发者避免意外依赖于实现细节。实现此目的的最可靠方法是使用版本脚本。

版本脚本是 ELF 链接器的一项功能,可用作更强大的 -fvisibility=hidden 形式。如需更详细的说明,请参阅下文中的优势部分;如需了解如何在项目中使用版本脚本,请继续阅读。

在上面链接的 GNU 文档以及本页的其他一些位置,您会看到对“符号版本”的引用。这是因为,这些文件的初衷是允许单个库中存在符号(通常是函数)的多个版本,以便在库中保留 bug 兼容性。Android 也支持这种用法,但它通常只适用于操作系统库供应商,就连我们也不在 Android 中使用它们,因为 targetSdkVersion 通过更慎重的选择加入流程提供相同的好处。对于本文档的主题,请不要担心“符号版本”等术语。如果您没有定义同一符号的多个版本,“symbol version”只是文件中任意命名的符号分组。

如果您是库作者(无论您的接口是 C/C++ 还是 Java/Kotlin,并且您的原生代码只是实现细节),而不是应用开发者,请务必阅读面向中间件供应商的建议

编写版本脚本

理想情况下,包含原生代码的应用(或 AAR)将仅包含一个共享库,其所有依赖项都静态关联到这个库,并且该库的完整公共接口为 JNI_OnLoad。这样可以尽可能广泛地应用本页介绍的优势。在这种情况下,假设该库名为 libapp.so,请创建一个 libapp.map.txt 文件(名称无需匹配,.map.txt 后缀只是惯例),其中包含以下内容(您可以省略注释):

# The name used here also doesn't matter. This is the name of the "version"
# which matters when the version script is actually used to create multiple
# versions of the same symbol, but that's not what we're doing.
LIBAPP {
  global:
    # Every symbol named in this section will have "default" (that is, public)
    # visibility. See below for how to refer to C++ symbols without mangling.
    JNI_OnLoad;
  local:
    # Every symbol in this section will have "local" (that is, hidden)
    # visibility. The wildcard * is used to indicate that all symbols not listed
    # in the global section should be hidden.
    *;
};

如果您的应用有多个共享库,则必须为每个库添加一个版本脚本。

对于不使用 JNI_OnLoadRegisterNatives() 的 JNI 库,您可以列出每个 JNI 方法及其 JNI 重合名称。

对于非 JNI 库(通常是 JNI 库的依赖项),您需要枚举完整的 API Surface。如果您的接口是 C++ 而非 C,您可以在版本脚本中使用 extern "C++" { ... },方法与在头文件中使用 extern "C++" { ... } 相同。例如:

LIBAPP {
  global:
    extern "C++" {
      # A class that exposes only some methods. Note that any methods that are
      # `private` in the class will still need to be visible in the library if
      # they are called by `inline` or `template` functions.
      #
      # Non-static members do not need to be enumerated as they do not have
      # symbols associated with them, but static members must be included.
      #
      # The * exposes all overloads of the MyClass constructor, but note that it
      # will also expose methods like MyClass::MyClassNonConstructor.
      MyClass::MyClass*;
      MyClass::DoSomething;
      MyClass::static_member;

      # All members/methods of a class, including those that are `private` in
      # the class.
      MyOtherClass::*;
      #

      # If you wish to only expose some overloads, name the full signature.
      # You'll need to wrap the name in quotes, otherwise you'll get a warning
      # like like "ignoring invalid character '(' in script" and the symbol will
      # remain hidden (pass -Wl,--no-undefined-version to convert that warning
      # to an error as described below).
      "MyClass::MyClass()";
      "MyClass::MyClass(const MyClass&)";
      "MyClass::~MyClass()";
    };
  local:
    *;
};

在构建时使用版本脚本

版本脚本必须在构建时传递给链接器。请按照下方适用于您的构建系统的步骤操作。

CMake

# Assuming that your app library's target is named "app":
target_link_options(app
    PRIVATE
    -Wl,--version-script,${CMAKE_SOURCE_DIR}/libapp.map.txt
    # This causes the linker to emit an error when a version script names a
    # symbol that is not found, rather than silently ignoring that line.
    -Wl,--no-undefined-version
)

# Without this, changes to the version script will not cause the library to
# relink.
set_target_properties(app
    PROPERTIES
    LINK_DEPENDS ${CMAKE_SOURCE_DIR}/libapp.map.txt
)

ndk-build

# Add to an existing `BUILD_SHARED_LIBRARY` stanza (use `+=` instead of `:=` if
# the module already sets `LOCAL_LDFLAGS`):
LOCAL_LDFLAGS := -Wl,--version-script,$(LOCAL_PATH)/libapp.map.txt

# This causes the linker to emit an error when a version script names a symbol
# that is not found, rather than silently ignoring that line.
LOCAL_ALLOW_UNDEFINED_VERSION_SCRIPT_SYMBOLS := false

# ndk-build doesn't have a mechanism for specifying that libapp.map.txt is a
# dependency of the module. You may need to do a clean build or otherwise force
# the library to rebuild (such as by changing a source file) when altering the
# version script.

其他

如果您使用的构建系统明确支持版本脚本,请使用该构建系统。

否则,请使用以下链接器标志:

-Wl,--version-script,path/to/libapp.map.txt -Wl,--no-version-undefined

这些选项的指定方式取决于您的构建系统,但通常有一个名为 LDFLAGS 或类似名称的选项。path/to/libapp.map.txt 需要从链接器的当前工作目录解析。通常,使用绝对路径更为简单。

如果您不使用构建系统,或者是希望添加版本脚本支持的构建系统维护者,则应在链接时将这些标志传递给 clang(或 clang++),而不是在编译时传递。

优势

使用版本脚本可以缩减 APK 大小,因为它会最大限度地减少库中的可见符号集。通过准确告知链接器调用方可以访问哪些函数,链接器可以从库中移除所有无法访问的代码。此过程是一种死代码消除。链接器无法移除未隐藏的函数(或其他符号)的定义,即使该函数从未被调用也是如此,因为链接器必须假定可见符号是库的公共接口的一部分。通过隐藏符号,链接器可以移除未调用的函数,从而减小库的大小。

库加载性能提升的原因也类似:可见符号需要重定位,因为这些符号是可互换的。这几乎不是期望的行为,但却是 ELF 规范所要求的行为,因此是默认设置。但由于链接器无法得知您想要替换哪些(如果有)符号,因此必须为每个可见符号创建重定位。隐藏这些符号后,链接器便可忽略这些重定位,改为使用直接跳转,从而减少动态链接器在加载库时必须执行的工作量。

明确枚举 API Surface 还可防止库的使用方错误地依赖于库的实现细节,因为这些细节将不可见。

与替代方案的比较

版本脚本提供的结果与 -fvisibility=hidden 或按函数 __attribute__((visibility("hidden"))) 等替代方案提供的结果类似。这三种方法都用于控制哪些库符号对其他库和 dlsym 可见。

另外两种方法的最大缺点是,它们只能隐藏正在构建的库中定义的符号。它们无法隐藏库的静态库依赖项中的符号。在使用 libc++_static.a 时,这种情况会产生很大影响。即使您的 build 使用 -fvisibility=hidden,库本身的符号将会被隐藏,但从 libc++_static.a 中包含的所有符号都将成为库的公共符号。与之相反,版本脚本可对库的公共接口进行显式控制;如果符号未在版本脚本中明确列为可见,则会被隐藏。

另一个区别既有优点也有缺点:必须在版本脚本中明确定义库的公共接口。对于 JNI 库,这实际上是微不足道,因为 JNI 库的唯一必要接口是 JNI_OnLoad(因为使用 RegisterNatives() 注册的 JNI 方法不需要是公共的)。对于具有大型公共接口的库,这可能会带来额外的维护负担,但通常值得这样做。