Cómo controlar la visibilidad de los símbolos

El control de la visibilidad de los símbolos puede reducir el tamaño del APK, mejorar los tiempos de carga y ayudar a otros desarrolladores a evitar dependencias accidentales en los detalles de implementación. La forma más sólida de hacerlo es con secuencias de comandos de versión.

Las secuencias de comandos de versión son una función de los enlazadores ELF que se pueden usar como una forma más sólida de -fvisibility=hidden. Consulta la sección Beneficios que aparece a continuación para obtener una explicación más detallada o sigue leyendo para aprender a usar secuencias de comandos de versión en tu proyecto.

En la documentación de GNU vinculada anteriormente y en algunos otros puntos de esta página, verás referencias a "versiones de símbolos". Esto se debe a que el intent original de estos archivos era permitir que existan varias versiones de un símbolo (por lo general, una función) en una sola biblioteca para preservar la compatibilidad de errores en las bibliotecas. Android también admite ese uso, pero, por lo general, solo es útil para los proveedores de bibliotecas del SO, y ni siquiera los usamos en Android porque targetSdkVersion ofrece los mismos beneficios con un proceso de habilitación más deliberado. En el tema de este documento, no te preocupes por términos como "versión de símbolo". Si no defines varias versiones del mismo símbolo, la “versión del símbolo” es solo una agrupación de símbolos con un nombre arbitrario en el archivo.

Si eres autor de una biblioteca (ya sea que tu interfaz sea C/C++ o Java/Kotlin y tu código nativo sea solo un detalle de implementación) en lugar de desarrollador de apps, asegúrate de leer también Sugerencias para proveedores de middleware.

Cómo escribir una secuencia de comandos de versión

En el caso ideal, una app (o AAR) que incluye código nativo contendrá exactamente una biblioteca compartida, con todas sus dependencias vinculadas de forma estática en esa biblioteca, y la interfaz pública completa de esa biblioteca es JNI_OnLoad. Esto permite que los beneficios descritos en esta página se apliquen de la manera más amplia posible. En ese caso, suponiendo que la biblioteca se llame libapp.so, crea un archivo libapp.map.txt (el nombre no tiene que coincidir y el sufijo .map.txt es solo una convención) con el siguiente contenido (puedes omitir los comentarios):

# 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 tu app tiene más de una biblioteca compartida, debes agregar una secuencia de comandos de versión por biblioteca.

En el caso de las bibliotecas de JNI que no usan JNI_OnLoad y RegisterNatives(), puedes enumerar cada uno de los métodos de JNI con sus nombres de JNI truncados.

Para las bibliotecas que no son JNI (por lo general, dependencias de bibliotecas JNI), deberás enumerar la superficie completa de tu API. Si tu interfaz es C++ en lugar de C, puedes usar extern "C++" { ... } en una secuencia de comandos de versión de la misma manera que lo harías en un archivo de encabezado. Por ejemplo:

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

Usa la secuencia de comandos de la versión cuando compiles

La secuencia de comandos de versión se debe pasar al vinculador durante la compilación. Sigue los pasos adecuados para tu sistema de compilación que se indican a continuación.

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.

Otro

Si el sistema de compilación que usas tiene compatibilidad explícita para las secuencias de comandos de versiones, usa esa.

De lo contrario, usa las siguientes marcas del vinculador:

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

La forma en que se especifiquen dependerá de tu sistema de compilación, pero, por lo general, hay una opción llamada LDFLAGS o algo similar. path/to/libapp.map.txt se debe resolver desde el directorio de trabajo actual del vinculador. A menudo, es más fácil usar una ruta de acceso absoluta.

Si no usas un sistema de compilación o eres el encargado de su mantenimiento y quieres agregar compatibilidad con la secuencia de comandos de versión, esas marcas se deben pasar a clang (o clang++) cuando se vincula, pero no cuando se compila.

Beneficios

El tamaño del APK se puede mejorar cuando se usa una secuencia de comandos de versión, ya que minimiza el conjunto visible de símbolos en una biblioteca. Si le indica al vinculador exactamente a qué funciones pueden acceder los llamadores, el vinculador puede quitar todo el código inaccesible de la biblioteca. Este proceso es un tipo de eliminación de código muerto. El vinculador no puede quitar la definición de la función (o de otro símbolo) que no está oculta, incluso si nunca se llama a la función, porque el vinculador debe suponer que un símbolo visible es parte de la interfaz pública de la biblioteca. Ocultar los símbolos permite que el vinculador quite las funciones a las que no se llama, lo que reduce el tamaño de la biblioteca.

El rendimiento de carga de la biblioteca mejora por razones similares: las reubicaciones son obligatorias para los símbolos visibles porque esos símbolos son intercambiables. Ese casi nunca es el comportamiento deseado, pero es lo que requiere la especificación de ELF, por lo que es la opción predeterminada. Sin embargo, como el vinculador no puede saber qué símbolos (si los hay) deseas que sean interpolables, debe crear reubicaciones para cada símbolo visible. Ocultar esos símbolos permite que el vinculador omita esas reubicaciones en favor de saltos directos, lo que reduce la cantidad de trabajo que el vinculador dinámico debe realizar cuando carga bibliotecas.

Enumerar explícitamente tu plataforma de API también evita que los consumidores de tus bibliotecas dependan por error de los detalles de implementación de tu biblioteca, ya que esos detalles no serán visibles.

Comparación con alternativas

Las secuencias de comandos de versión ofrecen resultados similares a las alternativas, como -fvisibility=hidden o __attribute__((visibility("hidden"))) por función. Los tres enfoques controlan qué símbolos de una biblioteca son visibles para otras bibliotecas y para dlsym.

La mayor desventaja de los otros dos enfoques es que solo pueden ocultar los símbolos definidos en la biblioteca que se compila. No pueden ocultar símbolos de las dependencias de bibliotecas estáticas de la biblioteca. Un caso muy común en el que esto hace una diferencia es cuando se usa libc++_static.a. Incluso si tu compilación usa -fvisibility=hidden, aunque los símbolos propios de la biblioteca estarán ocultos, todos los símbolos incluidos en libc++_static.a se convertirán en símbolos públicos de tu biblioteca. Por el contrario, las secuencias de comandos de la versión ofrecen un control explícito de la interfaz pública de la biblioteca. Si el símbolo no se indica explícitamente como visible en la secuencia de comandos de la versión, se ocultará.

La otra diferencia puede ser una ventaja o una desventaja: la interfaz pública de la biblioteca debe definirse de forma explícita en una secuencia de comandos de versión. Para las bibliotecas JNI, esto es en realidad trivial, ya que la única interfaz necesaria para una biblioteca JNI es JNI_OnLoad (porque los métodos JNI registrados con RegisterNatives() no tienen que ser públicos). Para las bibliotecas con una gran interfaz pública, esto puede ser una carga de mantenimiento adicional, pero que suele ser valiosa.