שליטה בחשיפה של הסמלים יכולה להקטין את גודל ה-APK, לשפר את זמני הטעינה ולעזור למפתחים אחרים להימנע מתלות מקרית בפרטי ההטמעה. הדרך היעילה ביותר לעשות זאת היא באמצעות סקריפטים של גרסאות.
סקריפטים של גרסאות הם תכונה של מקשרי ELF שאפשר להשתמש בהם כצורה חזקה יותר של -fvisibility=hidden
. בהמשך מוסבר בהרחבה על היתרונות של השימוש בסקריפטים של גרסאות, או איך משתמשים בהם בפרויקט.
במסמכי התיעוד של GNU שמקושרים למעלה ובכמה מקומות נוספים בדף הזה, תראו התייחסויות ל'גרסאות סמלים'. הסיבה לכך היא שהמטרה המקורית של הקבצים האלה הייתה לאפשר לספריות להכיל כמה גרסאות של סמל (בדרך כלל פונקציה) כדי לשמור על תאימות לבאגים בספריות. Android תומך גם בשימוש כזה, אבל בדרך כלל הוא שימושי רק לספקי ספריות של מערכת הפעלה, ואפילו אנחנו לא משתמשים בו ב-Android כי targetSdkVersion
מציע את אותם יתרונות עם תהליך הצטרפות מכוון יותר. בנושא של המסמך הזה, אין צורך להבין מונחים כמו 'גרסת סמל'. אם לא מגדירים כמה גרסאות של אותו סמל, 'symbol
version' הוא רק קיבוץ שרירותי של סמלים בקובץ.
אם אתם מחברים ספריות (בין אם הממשק שלכם הוא 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), תצטרכו לספור את פלטפורמת ה-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:
*;
};
שימוש בסקריפט הגרסה בזמן ה-build
צריך להעביר את סקריפט הגרסה למקשר בזמן ה-build. פועלים לפי השלבים שמתאימים למערכת ה-build שלכם.
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.
אחר
אם מערכת ה-build שבה משתמשים כוללת תמיכה מפורשת בסקריפטים של גרסאות, צריך להשתמש בהן.
אחרת, צריך להשתמש בדגלים הבאים של הקישור:
-Wl,--version-script,path/to/libapp.map.txt -Wl,--no-version-undefined
האופן שבו מציינים את הפרמטרים האלה תלוי במערכת ה-build, אבל בדרך כלל יש אפשרות בשם LDFLAGS
או משהו דומה. צריך להיות אפשרי לפתור את path/to/libapp.map.txt
מתוך ספריית העבודה הנוכחית של ה-linker. לרוב קל יותר להשתמש בנתיב מוחלט.
אם אתם לא משתמשים במערכת build, או אם אתם רוצים להוסיף תמיכה בסקריפטים של גרסאות, צריך להעביר את הדגלים האלה אל clang
(או clang++
) בזמן הקישור, אבל לא במהלך ההידור.
יתרונות
אפשר לשפר את גודל ה-APK באמצעות סקריפט גרסה, כי הוא מצמצם את קבוצת הסמלים הגלויה בספרייה. אם תודיעו למקשר אילו פונקציות זמינות למבצעי הקריאה, הוא יוכל להסיר מהספרייה את כל הקוד שלא ניתן לגשת אליו. התהליך הזה הוא סוג של חיסכון בקוד לא פעיל. ה-linker לא יכול להסיר את ההגדרה של פונקציה (או סמל אחר) שלא מוסתרת, גם אם אף פעם לא קוראים לפונקציה, כי ה-linker חייב להניח שסמל גלוי הוא חלק מהממשק הציבורי של הספרייה. הסתרת הסמלים מאפשרת למקשר להסיר פונקציות שלא נקראו, וכך לצמצם את גודל הספרייה.
הביצועים של טעינת הספרייה משפרים את הביצועים מסיבות דומות: צריך להשתמש בהעברות לסמלים גלויים כי הסמלים האלה אי-אפשריים. זו כמעט אף פעם לא ההתנהגות הרצויה, אבל היא נדרשת לפי מפרט ELF, ולכן היא ברירת המחדל. אבל מכיוון שהמקשר לא יכול לדעת אילו סמלים (אם בכלל) אתם מתכוונים להציב ביניהם, הוא צריך ליצור העברות לכל סמל גלוי. הסתרת הסמלים האלה מאפשרת למקשר להשמיט את ההעברות האלה לטובת קפיצות ישירות, וכך מפחיתה את כמות העבודה שהמקשר הדינמי צריך לבצע בזמן טעינת הספריות.
ספירה מפורשת של ממשק ה-API גם מונעת מלקוחות הספריות להסתמך בטעות על פרטי ההטמעה של הספרייה, כי הפרטים האלה לא יהיו גלויים.
השוואה לחלופות
סקריפטים של גרסאות מניבים תוצאות דומות לחלופות כמו -fvisibility=hidden
או __attribute__((visibility("hidden")))
לכל פונקציה.
כל שלוש הגישות קובעות אילו סמלים של ספרייה יהיו גלויים לספריות אחרות ול-dlsym
.
החיסרון הגדול ביותר בשתי הגישות האחרות הוא שאפשר להסתיר בהן רק סמלים שהוגדרו בספרייה שנוצרת. הם לא יכולים להסתיר סמלים מיחסי התלות של הספרייה בספריות סטטיות. מקרה נפוץ מאוד שבו יש לכך השפעה הוא כשמשתמשים ב-libc++_static.a
. גם אם ה-build שלכם משתמש ב--fvisibility=hidden
, הסימנים של הספרייה עצמה יוסתרו, אבל כל הסימנים שכלולים ב-libc++_static.a
יהפכו לסימנים ציבוריים של הספרייה. לעומת זאת, סקריפטים של גרסאות מציעים שליטה מפורשת בממשק הציבורי של הספרייה. אם הסמל לא מופיע במפורש כגלוי בסקריפט הגרסה, הוא יהיה מוסתר.
ההבדל השני יכול להיות יתרון וגם חיסרון: צריך להגדיר מפורשות את הממשק הציבורי של הספרייה בסקריפט של הגרסה. בספריות JNI זה קל מאוד, כי הממשק היחיד הנדרש לספריית JNI הוא JNI_OnLoad
(כי שיטות JNI שרשומים ב-RegisterNatives()
לא חייבות להיות ציבוריות). בספריות עם ממשק ציבורי גדול, זה יכול להיות נטל תחזוקה נוסף, אבל בדרך כלל כדאי להשקיע בו.