控制符号可见性可以减小 APK 大小、缩短加载时间,并帮助其他开发者避免意外依赖于实现细节。最稳健的方法是使用版本脚本。
版本脚本是 ELF 链接器的一项功能,可以用作 -fvisibility=hidden
的更稳健形式。有关更详细的解释,请参阅下面的优势,或者继续阅读以了解如何在您的项目中使用版本脚本。
在上面链接的 GNU 文档以及本页面的其他几个地方,您会看到“符号版本”的引用。这是因为这些文件的最初目的是允许符号(通常是函数)的多个版本存在于单个库中,以便在库中保留错误兼容性。Android 也支持这种用法,但这通常只对操作系统库供应商有用,即使我们在 Android 中也不使用它们,因为 targetSdkVersion
通过更慎重的选择加入流程提供了相同的优势。对于本文档的主题,请不用担心“符号版本”之类的术语。如果您没有定义同一符号的多个版本,“符号版本”只是文件中符号的任意命名分组。
如果您是库作者(无论您的接口是 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_OnLoad
和 RegisterNatives()
的 JNI 库,您可以改为列出每个 JNI 方法及其 JNI 混编名称。
对于非 JNI 库(通常是 JNI 库的依赖项),您需要列出您的完整 API 接口。如果您的接口是 C++ 而非 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 接口还可以防止您的库的使用者错误地依赖您的库的实现细节,因为这些细节将不可见。
与替代方案的比较
版本脚本提供了与替代方案类似的结果,例如 -fvisibility=hidden
或按函数使用的 __attribute__((visibility("hidden")))
。这三种方法都可以控制库的哪些符号对其他库和 dlsym
可见。
其他两种方法最大的缺点是它们只能隐藏正在构建的库中定义的符号。它们无法隐藏库的静态库依赖项中的符号。一个常见的例子是使用 libc++_static.a
的情况。即使您的构建使用 -fvisibility=hidden
,虽然库本身的符号会被隐藏,但从 libc++_static.a
中包含的所有符号都将成为您库的公共符号。相比之下,版本脚本提供了对库公共接口的显式控制;如果符号未在版本脚本中明确列为可见,它将被隐藏。
另一个区别既是优点也是缺点:库的公共接口必须在版本脚本中显式定义。对于 JNI 库来说,这实际上很简单,因为 JNI 库唯一必要的接口是 JNI_OnLoad
(因为使用 RegisterNatives()
注册的 JNI 方法不需要是公共的)。对于具有大型公共接口的库,这可能会增加额外的维护负担,但这通常是值得的。