控制符号可见性可以减小 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_OnLoad
和 RegisterNatives()
的 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 方法不需要是公共的)。对于具有大型公共接口的库,这可能会带来额外的维护负担,但通常值得这样做。