控管符號顯示設定

控管符號的顯示設定可縮減 APK 大小、縮短載入時間,並協助其他開發人員避免意外依附實作細節。最可靠的方法是使用版本指令碼。

版本指令碼是 ELF 連結器的功能,可用於提供更強大的 -fvisibility=hidden 形式。如需進一步瞭解詳情,請參閱下方的「優點」;如要瞭解如何在專案中使用版本指令碼,請繼續閱讀。

在上述連結的 GNU 說明文件和本頁面的其他幾個位置,您會看到「符號版本」的參照。這是因為這些檔案的原始意圖允許多個符號 (通常是函式) 的版本存在於單一程式庫中,以便在程式庫中保留錯誤相容性。Android 也支援這項用途,但這類用途通常只適用於 OS 程式庫供應商,而且我們甚至不會在 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_OnLoadRegisterNatives() 的 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 方法不必公開)。對於擁有大型公用介面的程式庫而言,這可能會是額外的維護負擔,但通常值得這麼做。