控制符号可见性可以减小 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 方法不需要是公共的)。对于具有大型公共接口的库,这可能需要额外的维护工作,但这通常是值得的。