التحكّم في ظهور الرموز

يمكن أن يؤدي التحكّم في ظهور الرموز إلى تقليل حجم حزمة APK وتحسين أوقات التحميل ومساعدة المطوّرين الآخرين في تجنُّب الاعتمادية غير المقصودة على تفاصيل التنفيذ. إنّ أقوى طريقة لإجراء ذلك هي باستخدام نصوص الإصدارات.

النصوص البرمجية للإصدار هي ميزة لبرامج ربط ELF التي يمكن استخدامها كأحد أشكال -fvisibility=hidden الأكثر كفاءة. يمكنك الاطّلاع على المزايا أدناه للحصول على شرح أكثر تفصيلاً، أو مواصلة القراءة لمعرفة كيفية استخدام النصوص البرمجية للإصدارات في مشروعك.

في وثائق GNU المرتبطة أعلاه وفي بضعة أماكن أخرى على هذه الصفحة، سترى إشارات إلى "نُسخ الرموز". يرجع ذلك إلى أنّه كان الغرض الأصلي من هذه الملفات هو السماح بوجود نُسخ متعددة من رمز (عادةً دالة) في مكتبة واحدة للحفاظ على توافقها مع الأخطاء في المكتبات. يتيح نظام التشغيل Android هذا الاستخدام أيضًا، ولكن بشكل عام، لا يكون مفيدًا إلا لمورّدي مكتبات أنظمة التشغيل، وحتى نحن لا نستخدمها في Android لأنّ targetSdkVersion يوفّر المزايا نفسها من خلال عملية تفعيل deliberate. بالنسبة إلى موضوع هذا المستند، لا تقلق بشأن مصطلحات مثل "نسخة الرموز". إذا لم تكن تحدد نسخًا متعددة من نفس الرمز، فإن "نسخة الرموز" هي مجرد مجموعة عشوائية مُعنوَنة من الرموز في الملف.

إذا كنت مؤلف مكتبة (سواء كانت واجهتك 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 التي لا تستخدم JNI_OnLoad وRegisterNatives()، يمكنك بدلاً من ذلك إدراج كل طريقة من طرق JNI مع أسمائها المشوشة في JNI.

بالنسبة إلى المكتبات غير المستندة إلى JNI (عادةً ما تكون عناصر اعتماد المكتبات المستندة إلى JNI)، عليك إدراج مساحة عرض واجهة برمجة التطبيقات الكاملة. إذا كانت واجهتك 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، لذا فهو السلوك التلقائي. ولكن بما أنّ أداة الربط لا يمكنها معرفة الرموز (إذا كانت هناك أي رموز) التي تريد أن تكون قابلة للتوسّط، يجب أن تنشئ عمليات إعادة تحديد موقع لكل رمز مرئي. يؤدي إخفاء هذه الرموز إلى السماح لبرنامج الربط بحذف عمليات إعادة الربط هذه والاكتفاء بالقفزات المباشرة، ما يقلل من مقدار العمل الذي يجب أن يؤديه برنامج الربط الديناميكي عند تحميل المكتبات.

ويؤدي أيضًا إدراج مساحة عرض واجهة برمجة التطبيقات بشكل صريح إلى منع مستخدِمي مكتباتك من الاعتماد عن طريق الخطأ على تفاصيل تنفيذ مكتبتك، لأنّ هذه التفاصيل لن تكون مرئية.

المقارنة بالبدائل

تقدّم نصوص الإصدارات نتائج مشابهة للبدائل، مثل -fvisibility=hidden أو لكل وظيفة __attribute__((visibility("hidden"))). تتحكّم كل الطرق الثلاث في رموز المكتبة التي تظهر في مكتبات أخرى وفي dlsym.

أكبر عيوب المنهجَين الآخرين هو أنّهما لا يمكنهما سوى إخفاء الرموز المحدّدة في المكتبة التي يتم إنشاؤها. ولا يمكنهم إخفاء الرموز من التبعيات الثابتة للمكتبة. من الحالات الشائعة جدًا التي يحدث فيها هذا الاختلاف عند استخدام libc++_static.a. حتى إذا كان الإصدار يستخدم -fvisibility=hidden، سيتم إخفاء رموز المكتبة، وسيصبح كل الرموز المضمّنة من libc++_static.a رموزًا عامة ل مكتبتك. في المقابل، توفّر نصوص الإصدارات إمكانية التحكّم بشكل صريح في واجهة المكتبة العلنية. وإذا لم يتم إدراج الرمز صراحةً على أنّه مرئي في نص الإصدار، سيتم إخفاؤه.

يمكن أن يكون الفرق الآخر إيجابيًا أو سلبيًا: يجب تحديد الواجهة العامة لل مكتبة بشكل صريح في نص إصدار. بالنسبة إلى مكتبات JNI، فإنّ هذا الأمر بسيط جدًا، لأنّ الواجهة الوحيدة اللازمة لمكتبة JNI هي JNI_OnLoad (لأنّ طُرق JNI المسجّلة باستخدام RegisterNatives() لا يلزم أن تكون علنية). بالنسبة إلى المكتبات التي تتضمّن واجهة مستخدم عامة كبيرة، قد يشكّل ذلك مزيدًا من الجهد المبذول في الصيانة، ولكنّه عادةً ما يكون مفيدًا.