Visibilité des symboles de contrôle

Contrôler la visibilité des symboles peut réduire la taille de l'APK, améliorer les temps de chargement et aider d'autres développeurs à éviter les dépendances accidentelles sur les détails d'implémentation. Le moyen le plus robuste d'y parvenir est d'utiliser des scripts de version.

Les scripts de version sont une fonctionnalité des lisseurs ELF qui peuvent être utilisés comme une forme plus robuste de -fvisibility=hidden. Consultez la section Avantages ci-dessous pour une explication plus détaillée, ou poursuivez votre lecture pour découvrir comment utiliser des scripts de version dans votre projet.

Dans la documentation GNU dont le lien figure ci-dessus et dans quelques autres endroits de cette page, vous trouverez des références aux "versions de symboles". En effet, l'objectif initial de ces fichiers était de permettre l'existence de plusieurs versions d'un symbole (généralement une fonction) dans une seule bibliothèque afin de préserver la compatibilité des bugs dans les bibliothèques. Android est également compatible avec cette utilisation, mais elle n'est généralement utile qu'aux fournisseurs de bibliothèques d'OS. Nous ne les utilisons même pas dans Android, car targetSdkVersion offre les mêmes avantages avec un processus d'activation plus délibéré. Pour le sujet de ce document, ne vous préoccupez pas de termes tels que "version de symbole". Si vous ne définissez pas plusieurs versions du même symbole, la "version du symbole" n'est qu'un regroupement de symboles nommé de manière arbitraire dans le fichier.

Si vous êtes l'auteur d'une bibliothèque (que votre interface soit C/C++ ou Java/Kotlin et que votre code natif ne soit qu'un détail d'implémentation) plutôt qu'un développeur d'applications, veillez également à lire les conseils pour les fournisseurs de middleware.

Écrire un script de version

Dans l'idéal, une application (ou un AAR) qui inclut du code natif contiendra exactement une bibliothèque partagée, avec toutes ses dépendances liées de manière statique à cette bibliothèque, et l'interface publique complète de cette bibliothèque est JNI_OnLoad. Cela permet d'appliquer les avantages décrits sur cette page aussi largement que possible. Dans ce cas, en supposant que la bibliothèque soit nommée libapp.so, créez un fichier libapp.map.txt (le nom n'a pas besoin de correspondre, et le suffixe .map.txt n'est qu'une convention) avec le contenu suivant (vous pouvez omettre les commentaires):

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

Si votre application comporte plusieurs bibliothèques partagées, vous devez ajouter un script de version par bibliothèque.

Pour les bibliothèques JNI qui n'utilisent pas JNI_OnLoad et RegisterNatives(), vous pouvez lister chacune des méthodes JNI avec leurs noms JNI tronqués.

Pour les bibliothèques autres que JNI (généralement les dépendances de bibliothèques JNI), vous devez énumérer l'ensemble de votre surface d'API. Si votre interface est en C++ plutôt qu'en C, vous pouvez utiliser extern "C++" { ... } dans un script de version de la même manière que dans un fichier d'en-tête. Exemple :

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

Utiliser le script de version lors de la compilation

Le script de version doit être transmis au linker lors de la compilation. Suivez les étapes appropriées à votre système de compilation ci-dessous.

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.

Autre

Si le système de compilation que vous utilisez est explicitement compatible avec les scripts de version, utilisez-le.

Sinon, utilisez les indicateurs d'association suivants:

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

La manière dont elles sont spécifiées dépend de votre système de compilation, mais il existe généralement une option appelée LDFLAGS ou quelque chose de similaire. path/to/libapp.map.txt doit être résolu à partir du répertoire de travail actuel du linker. Il est souvent plus facile d'utiliser un chemin d'accès absolu.

Si vous n'utilisez pas de système de compilation ou si vous êtes le responsable d'un tel système et que vous souhaitez ajouter la prise en charge des scripts de version, ces indicateurs doivent être transmis à clang (ou clang++) lors de l'association, mais pas lors de la compilation.

Avantages

La taille de l'APK peut être améliorée lorsque vous utilisez un script de version, car il réduit l'ensemble visible de symboles dans une bibliothèque. En indiquant exactement à l'éditeur de liens quelles fonctions sont accessibles aux appelants, celui-ci peut supprimer tout le code inaccessible de la bibliothèque. Ce processus est un type d'élimination du code mort. Le liant ne peut pas supprimer la définition d'une fonction (ou d'un autre symbole) qui n'est pas masquée, même si la fonction n'est jamais appelée, car le liant doit supposer qu'un symbole visible fait partie de l'interface publique de la bibliothèque. Le masquage des symboles permet au linker de supprimer les fonctions qui ne sont pas appelées, ce qui réduit la taille de la bibliothèque.

Les performances de chargement de la bibliothèque sont améliorées pour des raisons similaires: des relocalisations sont requises pour les symboles visibles, car ces symboles sont interposables. Ce n'est presque jamais le comportement souhaité, mais c'est ce qui est requis par la spécification ELF. C'est donc le comportement par défaut. Cependant, comme le liant ne peut pas savoir quels symboles (le cas échéant) vous souhaitez interpoler, il doit créer des déplacements pour chaque symbole visible. Le masquage de ces symboles permet au liant d'omettre ces relocalisations au profit de sauts directs, ce qui réduit la quantité de travail que le liant dynamique doit effectuer lors du chargement des bibliothèques.

L'énumération explicite de votre surface d'API empêche également les consommateurs de vos bibliothèques de dépendre par erreur des détails d'implémentation de votre bibliothèque, car ces détails ne seront pas visibles.

Comparaison avec d'autres solutions

Les scripts de version offrent des résultats similaires à ceux d'autres options telles que -fvisibility=hidden ou __attribute__((visibility("hidden"))) par fonction. Les trois approches contrôlent les symboles d'une bibliothèque qui sont visibles par les autres bibliothèques et par dlsym.

Le plus grand inconvénient des deux autres approches est qu'elles ne peuvent masquer que les symboles définis dans la bibliothèque en cours de création. Ils ne peuvent pas masquer les symboles des dépendances de bibliothèques statiques de la bibliothèque. Un cas très courant où cela fait la différence est l'utilisation de libc++_static.a. Même si votre build utilise -fvisibility=hidden, les symboles de la bibliothèque seront masqués, mais tous les symboles inclus à partir de libc++_static.a deviendront des symboles publics de votre bibliothèque. En revanche, les scripts de version offrent un contrôle explicite de l'interface publique de la bibliothèque. Si le symbole n'est pas explicitement indiqué comme visible dans le script de version, il sera masqué.

L'autre différence peut être à la fois un avantage et un inconvénient: l'interface publique de la bibliothèque doit être définie explicitement dans un script de version. Pour les bibliothèques JNI, cela est en réalité simple, car la seule interface nécessaire à une bibliothèque JNI est JNI_OnLoad (car les méthodes JNI enregistrées avec RegisterNatives() n'ont pas besoin d'être publiques). Pour les bibliothèques disposant d'une interface publique volumineuse, cela peut représenter une charge de maintenance supplémentaire, mais s'avère généralement intéressante.