Kontrolowanie widoczności symboli

Kontrolowanie widoczności symboli może zmniejszyć rozmiar pliku APK, skrócić czas wczytywania i pomóc innym programistom uniknąć przypadkowych zależności od szczegółów implementacji. Najlepszym sposobem na to jest użycie skryptów wersji.

Skrypty wersji to funkcja tagów łączących pliki ELF, których można używać jako bardziej niezawodnej formy -fvisibility=hidden. Aby uzyskać bardziej szczegółowe wyjaśnienia, zapoznaj się z sekcją Zalety poniżej lub przeczytaj dalej, aby dowiedzieć się, jak używać skryptów wersji w projekcie.

W dokumentacji GNU, do której link znajduje się powyżej, oraz w kilku innych miejscach na tej stronie znajdziesz odniesienia do „wersji symboli”. Dzieje się tak, ponieważ pierwotnym zamiarem było umożliwienie istnienia w pojedynczej bibliotece wielu wersji symbolu (zwykle funkcji) w celu zachowania zgodności z błędami w bibliotekach. Android obsługuje też takie użycie, ale jest ono przydatne tylko dla dostawców bibliotek w systemie operacyjnym. Nawet my nie używamy ich w Androidzie, ponieważ targetSdkVersion zapewnia te same korzyści przy bardziej przemyślanym procesie wyrażania zgody. W przypadku tematu tego dokumentu nie musisz się przejmować terminami takimi jak „wersja symbolu”. Jeśli nie definiujesz wielu wersji tego samego symbolu, „symbol wersja” to tylko dowolnie nazwane grupowanie symboli w pliku.

Jeśli jesteś autorem biblioteki (niezależnie od tego, czy Twój interfejs jest napisany w C/C++ czy w Java/Kotlin i czy Twój kod natywny jest tylko szczegółem implementacji), a nie deweloperem aplikacji, przeczytaj też wskazówki dla dostawców oprogramowania pośredniczącego.

Pisanie skryptu wersji

W idealnym przypadku aplikacja (lub plik AAR) zawierająca kod natywny będzie zawierać dokładnie jedną wspólną bibliotekę ze wszystkimi zależnościami połączonymi statycznie z tą biblioteką. Cały interfejs publiczny tej biblioteki to JNI_OnLoad. Dzięki temu korzyści opisane na tej stronie mogą być stosowane w jak najszerszym zakresie. W tym przypadku, zakładając, że biblioteka ma nazwę libapp.so, utwórz plik libapp.map.txt (nazwa nie musi być taka sama, a sufiks .map.txt to tylko konwencja) o tej treści (komentarze możesz pominąć):

# 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.
    *;
};

Jeśli Twoja aplikacja ma więcej niż 1 współdzieloną bibliotekę, musisz dodać 1 skrypt wersji na każdą bibliotekę.

W przypadku bibliotek JNI, które nie używają funkcji JNI_OnLoad ani RegisterNatives(), możesz zamiast tego podać każdą metodę JNI wraz z jej zniekształconą nazwą JNI.

W przypadku bibliotek innych niż JNI (zwykle bibliotek zależnych od JNI) musisz wyliczyć pełną powierzchnię interfejsu API. Jeśli Twój interfejs jest w języku C++, a nie C, możesz użyć extern "C++" { ... } w skrypcie wersji w taki sam sposób jak w pliku nagłówka. Na przykład:

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:
    *;
};

Używanie skryptu wersji podczas kompilowania

Podczas kompilacji należy przekazać skrypt wersji do tagu łączącego. Poniżej znajdziesz odpowiednie instrukcje dotyczące systemu kompilacji.

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.

Inne

Jeśli używany system kompilacji obsługuje skrypty wersji, użyj ich.

W przeciwnym razie użyj tych flag linkera:

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

Sposób ich określenia zależy od systemu kompilacji, ale zwykle jest to opcja o nazwie LDFLAGS lub podobnej. path/to/libapp.map.txt musi być rozwiązywalny w bieżącym katalogu roboczym linkera. Często łatwiej jest użyć ścieżki bezwzględnej.

Jeśli nie używasz systemu kompilacji lub jesteś jego opiekunem i chcesz dodać obsługę skryptu wersji, te flagi powinny być przekazywane do clang (lub clang++) podczas łączenia, ale nie podczas kompilacji.

Zalety

Za pomocą skryptu wersji można zmniejszyć rozmiar pliku APK, ponieważ minimalizuje on widoczny zestaw symboli w bibliotece. Informując linkera, które funkcje są dostępne dla wywołujących, można usunąć z biblioteki cały kod, do którego nie można dotrzeć. Jest to rodzaj eliminowania martwego kodu. Tag łączący nie może usunąć definicji funkcji (lub innego symbolu), która nie jest ukryta, nawet jeśli funkcja nie jest nigdy wywoływana, ponieważ tag łączący musi zakładać, że widoczny symbol jest częścią publicznego interfejsu biblioteki. Ukrywanie symboli umożliwia linkerowi usuwanie funkcji, które nie są wywoływane, co zmniejsza rozmiar biblioteki.

Wydajność wczytywania biblioteki poprawia się z podobnych powodów: przeniesienia są wymagane w przypadku widocznych symboli, ponieważ symbole te są wymienne. Jest to zachowanie, którego prawie nigdy nie chcemy, ale jest wymagane przez specyfikację ELF, więc jest domyślne. Ponieważ linker nie może wiedzieć, które symbole mają być przesłonięte, musi utworzyć przeniesienia dla każdego widocznego symbolu. Ukrywanie tych symboli umożliwia linkerowi pominięcie tych przekierowań na rzecz bezpośrednich skoków, co zmniejsza ilość pracy, jaką musi wykonać linker dynamiczny podczas wczytywania bibliotek.

Wyraźne wyliczenie interfejsu API zapobiega też temu, aby użytkownicy bibliotek przypadkowo polegali na szczegółach ich implementacji, ponieważ te szczegóły nie będą widoczne.

Porównanie z alternatywnymi rozwiązaniami

Skrypty wersji dają podobne wyniki jak alternatywne metody, takie jak -fvisibility=hidden lub __attribute__((visibility("hidden"))). Wszystkie te podejścia określają, które symbole biblioteki są widoczne dla innych bibliotek i dla dlsym.

Największą wadą tych dwóch metod jest to, że mogą one ukryć tylko symbole zdefiniowane w budowanej bibliotece. Nie można ukryć symboli z zależności biblioteki statycznej. Bardzo często ma to znaczenie w przypadku libc++_static.a. Nawet jeśli Twoja wersja korzysta z biblioteki -fvisibility=hidden, a własne symbole biblioteki są ukryte, wszystkie symbole zawarte w bibliotece -fvisibility=hidden staną się publicznymi symbolami Twojej biblioteki.libc++_static.a Skrypty wersji umożliwiają natomiast kontrolowanie publicznego interfejsu biblioteki. Jeśli symbol nie jest wyraźnie wymieniony jako widoczny w skrypcie wersji, jest ukryty.

Druga różnica może być zaletą i wadą: publiczny interfejs biblioteki musi być wyraźnie zdefiniowany w skrypcie wersji. W przypadku bibliotek JNI jest to trywialne, ponieważ jedyny wymagany interfejs biblioteki JNI to JNI_OnLoad (ponieważ metody JNI zarejestrowane w RegisterNatives() nie muszą być publiczne). W przypadku bibliotek z dużym interfejsem publicznym może to oznaczać dodatkowy nakład pracy związany z konserwacją, ale zwykle jest to uzasadnione.