אופטימיזציה של קוד ומשאבים בפלטפורמה באמצעות R8

מערכת ה-build של פלטפורמת Android‏ (Soong) מריצה את המקמפל R8 ביעדים שמקמפלים בייטקוד של DEX, כולל אפליקציות ל-Android‏ (android_app), בדיקות (android_test) וספריות Java שניתנות להתקנה עם compile_dex: true (כמו services.jar), כדי לכווץ, לשפר ולהסיר קוד ומשאבים שלא בשימוש. בספריות סטטיות (android_library ו-java_library סטטי), Soong לא מפעיל את R8 ישירות, אלא משתמש בבלוק המאפיינים optimize כדי לצרף ולהפיץ כללי שמירה של צרכנים (export_proguard_flags_files: true) ליעדי DEX במורד הזרם שמקשרים אותם באופן סטטי.

למהנדסי פלטפורמות שיוצרים קובצי אימג' של מערכות או חבילות של ספקים, הסרת כללי שמירה רחבים מדי מועילה ישירות לתקינות המערכת:

  • טביעת רגל קטנה יותר של מחיצת המערכת: R8 מסיר מחלקות, שיטות ומשאבים לא פעילים לפני אריזת קובצי APK ו-JAR ב-/system, ב-/system_ext, ב-/product וב-/vendor.
  • ארטיפקטים קטנים יותר שעברו הידור: פחות שיטות DEX פירושן קובצי .odex ו-.vdex קטנים יותר שנוצרו על ידי dex2oat במהלך ההידור בזמן ה-build או במכשיר.
  • פחות עומס על הזיכרון בזמן הריצה: מכיוון ש-Android ממפה את .odex, .vdex וטבלאות המשאבים של ה-APK לזיכרון התהליך באמצעות קריאות mmap לדפים לפי דרישה, קובצי בינארי קטנים יותר מפחיתים את השגיאות החמורות בדפים במהלך הפעלת האפליקציה ומקטינים את הקוד התושב ואת זיכרון המשאבים בתהליכים. במאמר קוד האפליקציה הוא זיכרון מוסבר איך קוד האפליקציה המקומפל משפיע על זיכרון המכשיר.
  • אופטימיזציה יעילה יותר של כל התוכנית: אילוצי שמירה צרים מאפשרים ל-R8 להטמיע שיטות, לבטל וירטואליזציה של ממשקים וקריאות וירטואליות, להסיר שדות שלא בשימוש ולהפיץ קבועים בין מחלקות.

הגדרת אופטימיזציה של R8 ב-Soong

בקובצי Android.bp, מגדירים את האופטימיזציה של R8 באמצעות בלוק המאפיינים optimize ביעדי android_app ו-java_library שניתנים להתקנה (או במודולים סטטיים של android_library ו-java_library כדי לייצא כללי שמירה של צרכנים):

android_app {
    name: "MySystemApp",
    srcs: ["src/**/*.java"],
    optimize: {
        obfuscate: true,
        shrink_resources: true,
    },
}

בטבלה הבאה מפורטים המאפיינים הנפוצים ביותר של optimize ב-Soong (מוגדרים ב-build/soong/java/dex.go):

מאפיין (property) תיאור
enabled ההגדרה קובעת אם R8 יפעל ביעד. ברירת המחדל היא true לכל היעדים של android_app.
shrink ההגדרה קובעת אם להפעיל את התכונה tree shaking כדי להסיר מחלקות, שדות ושיטות שלא ניתן להגיע אליהם. ערך ברירת המחדל הוא true ליעדים של android_app (false למודולים עצמאיים של java_library ולמודולים של בדיקה, שמעבירים את -dontshrink אלא אם הוגדר אחרת).
optimize שולט באופטימיזציות של קוד בייט, כמו הטמעת מתודות, מיזוג מחלקות, הפצת קבועים והסרת ענפים לא פעילים. ברירת המחדל היא true ליעדי android_app (נשלטת על ידי הדגל RELEASE_R8_OPTIMIZE_BY_DEFAULT של גרסת ה-build להפצה).
obfuscate המדיניות קובעת את ההקטנה ושינוי השם של המזהה. כברירת מחדל, הערך הוא false כדי לשמור על תאימות עם גרסאות קודמות, אבל כדאי להגדיר אותו ל-true בכל מקום שבו אפשר, כדי להקטין את קובץ ה-DEX ולבצע אופטימיזציות מעמיקות יותר (ראו הפעלת ערפול קוד בכל מקום שבו אפשר).
shrink_resources הסרת משאבים שלא בשימוש (res/ רשומות) מחבילת ה-APK אחרי כיווץ הקוד. ברירת המחדל היא false.
optimized_shrink_resources מריץ את צינור עיבוד הנתונים המשולב של R8 לצמצום קוד ומשאבים, כך ש-R8 עוקב אחרי הפניות לקוד ולמשאבים במשותף במעבר אחד. כאשר shrink_resources: true מוגדר, ברירת המחדל היא RELEASE_USE_OPTIMIZED_RESOURCE_SHRINKING_BY_DEFAULT (true בגרסאות build רגילות של פלטפורמות).
proguard_flags_files מציג קובצי .flags או .pro ספציפיים למודול שמכילים כללי שמירה בהתאמה אישית. הימנעו מהוספת קבצים בהתאמה אישית אם מספיק להשתמש בהערות רגילות או בברירות מחדל.
export_proguard_flags_files הפצה של proguard_flags_files של הספרייה הזו למודולים במורד הזרם שתלויים בה באופן סטטי.
trace_references_from מציג יעדים תלויים ישירים של הידור DEX (מחוברים דרך instrumentation_for או libs) שההפניות של קוד הבייט שלהם ליעד הזה R8 עוקב אחריהן ושומר אותן באופן אוטומטי (מופץ גם בין וריאנטים של override_android_app ו-override_android_test, ונתמך בספריות Java נלוות לא תלויות בשילוב עם פיצול .impl).
allow_traced_references מאפשר ליעדים תלויים של הידור DEX ישיר (שמחוברים דרך instrumentation_for או libs, כמו בדיקות של ספקים או בדיקות בין מאגרים שלא ניתן לציין ב-trace_references_from) להצטרף למעקב אחר הפניות ה-bytecode שלהם ליעד הזה על ידי הגדרת trace_references: true.
trace_references כשהערך מוגדר ל-true ביעד עם allow_traced_references: true שתלוי ישירות בהידור DEX, מריץ את R8 tracereferences במודול הזה ומעביר את כללי השמירה שנוצרו למודול היעד.
protect_api_surface העברת --protect-api-surface אל R8. כשהוא מוגדר ב-java_sdk_library, הוא גם יוצר ומצרף באופן אוטומטי כללי שמירה של Metalava API בכל היקפי ה-API המופעלים (ציבורי, מערכת, module-lib, שרת מערכת ובדיקה) לספריית ההטמעה.

ספריות סטטיות (android_library, java_library וייבוא של AAR) יכולות גם להגדיר את required_downstream_optimization: { enabled: true, optimize: true } ב-Android.bp כדי לדרוש מכל יעד של הידור DEX במורד הזרם שמקשר אותן באופן סטטי (באופן ישיר או טרנזיטיבי) להפעיל את כיווץ R8 ואת אופטימיזציית קוד הבייט. ‫Soong מפעיל את זה באופן אוטומטי ב-androidx.compose.runtime_runtime, כך שיעדי הקישור של Jetpack Compose עוברים אופטימיזציה באמצעות R8.

הפעלת ערפול בכל מקום שבו הדבר אפשרי

ב-Soong, הערך של obfuscate מוגדר כברירת מחדל ל-false לצורך תאימות היסטורית, אבל כדאי להגדיר באופן מפורש את obfuscate: true ב-android_app וביעד DEX עצמאי בכל מקום שבו זה אפשרי:

  • טביעת רגל קטנה יותר של DEX ו-.vdex: שינוי השם של חבילות, מחלקות, שדות ושיטות למזהים קצרים (a, b) מקטין את מאגר המחרוזות של DEX (string_ids ו-string_data_item) ואת מתארי הסוגים. מערכת Android ממפה קובצי .vdex ו-.odex לזיכרון התהליך, ולכן טבלאות סמלים קטנות יותר מקטינות באופן ישיר גם את גודל המחיצה של המערכת וגם את הזיכרון שבשימוש בזמן הריצה.
  • אופטימיזציות מעמיקות יותר של כל התוכנית: אם מאפשרים ל-R8 לשנות את השמות של המזהים, אפשר למזג מחלקות, לשטח חבילות ולבטל כפילויות של מחלקות סינתטיות בחבילות שונות. אחרת, R8 צריך לדלג על הפעולות האלה כדי למנוע התנגשויות בין שמות.
  • הנגשת דוח קריסה מלא בשפה אנושית (symbolication): הפעלת ערפול קוד (obfuscation) ב-Soong לא מפחיתה את היכולת לבצע ניפוי באגים. לכל יעד שעבר הידור ב-R8, ‏Soong יוצר קובץ מיפוי proguard_dictionary בספריית הביניים של המודול, מקבץ את כל המילונים של המודול לקובץ ה-build‏ proguard-dict.zip, מטמיע גיבוב (hash) של מיפוי (--map-id-template) בכותרת ה-DEX ומשכתב את המאפיינים (--source-file-template) של המחלקה SourceFile כדי ש-retrace יוכל להמיר באופן אוטומטי את דוחות הקריסה לסמלים.

צריך לשמור את obfuscate: false (או לשמר באופן מפורש שמות של ממשקי API ציבוריים) רק בשביל:

  • ספריות משותפות ב-bootclasspath או ב-classpath של system_server: מודולים (java_library או java_sdk_library) שחושפים ממשק API חיצוני שמקושר באופן דינמי על ידי מודולים אחרים בזמן הריצה (כמו יעדים של framework.jar, services.jar או <uses-library>) צריכים לשמור על obfuscate: false או לשמר באופן מפורש את ממשק ה-API שלהם באמצעות כללים של protect_api_surface: true, @KeepForApi או -keep, כדי שרכיבי ה-caller שעברו הידור מול ה-stubs שלהם יוכלו לזהות את שמות המחלקות והאיברים בזמן הריצה. במודולים של java_sdk_library, הגדרת protect_api_surface: true יוצרת באופן אוטומטי כללי שמירה של Metalava ProGuard ומצרפת אותם לספריית ההטמעה בכל היקפי ה-API המופעלים (ציבורי, מערכת, module-lib, שרת מערכת ובדיקה). כשאופטימיזציית R8 כללית מופעלת ביעדי bootclasspath שלא ניתנים לעדכון (framework-minus-apex,‏ ext ו-framework-location), הם כוללים גם את framework-hiddenapi-proguard-rules (שנוצר על ידי hiddenapi_to_proguard) כדי לשמור את החברים שרשומים בקובצי boot/hiddenapi/*.txt פעילים (השמטת hiddenapi-max-target-o.txt).

לפני שמפעילים את obfuscate: true באפליקציה, צריך לוודא שהתנאים המוקדמים הבאים להעברה מתקיימים:

  • תלויות של APK לבדיקה חיצונית: אם קובץ APK חיצוני של android_test מגדיר את instrumentation_for: "MyApp" ומפעיל ישירות מחלקות או שיטות פנימיות של MyApp, שינוי השם של הסמלים האלה גורם ל-NoSuchMethodError או ל-NoClassDefFoundError בזמן הרצת הבדיקה. מגדירים את trace_references_from (או את allow_traced_references עם trace_references: true), יוצרים את הבדיקה כ-APK עם אינסטרומנטציה עצמית שמקושר באופן סטטי ל-MyApp.impl, או מוסיפים הערות ל-test hooks באמצעות @VisibleForTesting (ראו שלב 2: העברת כללי שמירה מבוססי-בדיקה) כדי שהסמלים הפנימיים שנדרשים לבדיקות יישמרו בזמן ערפול הקוד של שאר האפליקציה.
  • רפלקציה מבוססת-מחרוזת ו-JNI: אם מודול מחפש מחלקות, מתודות או שדות לפי שמות מילוליים של מחרוזות (Class.forName,‏ getDeclaredMethod או JNI FindClass ו-GetMethodID) בלי כללי שמירה או הערות, ערפול הקוד משנה את השמות של היעדים האלה ומשבש את החיפושים בזמן הריצה. (שימו לב שרפלקציה ללא הערות נכשלת גם ב-shrink: true וב-optimize: true). הוסיפו הערות לנקודות הכניסה האלה באמצעות המדריך להוספת הערות Keep (@UsesReflection, @UsedByReflection או @UsedByNative) כדי ש-R8 ישמור את השמות שלהן ויטשטש את שאר המודול.

העיקרון של אפס דגלים בהתאמה אישית

במודול האידיאלי של פלטפורמת Android אין קובצי proguard.flags או keep.xml בהתאמה אישית. בגרסת ה-build של פלטפורמת Android, רוב כללי השמירה המותאמים אישית מיותרים:

  • קווי בסיס של פלטפורמות ו-AAPT2: רוב נקודות הכניסה כבר נשמרות באופן אוטומטי על ידי קווי בסיס גלובליים של פלטפורמות (כמו @Keep, שיטות JNI native ו-@VisibleForTesting) או נוצרות על ידי AAPT2 מ-AndroidManifest.xml ומשאבי פריסה (ראו הסבר על כללי השמירה שמוגדרים כברירת מחדל בפלטפורמה).
  • חלופות ממוקדות: במקרים שבהם צריך לשמור על נקודות כניסה, עדיף להשתמש בהערות באתר ההצהרה (keepanno, @VisibleForTesting), בכללי ספרייה מיוצאים (export_proguard_flags_files: true) או בקישור סטטי לבדיקה על פני קובצי .flags מנותקים (ראו בדיקה והעברה של כללי שמירה קיימים).

לפני שמוסיפים או שומרים קובץ proguard.flags או keep.xml בהתאמה אישית, צריך לבדוק אם הכלל כבר מטופל על ידי ערכי ברירת המחדל או אם אפשר להעביר אותו להערות בקוד.

הסבר על כללי השמירה שמוגדרים כברירת מחדל בפלטפורמה

‫Soong מעביר באופן אוטומטי את כללי הבסיס הגלובליים הבאים לכל הפעלה של R8 ביעדי קומפילציה של DEX (מודולים מסוג android_app,‏ android_test ו-java_library שניתנים להתקנה), שמוגדרים ב-build/soong/java/dex.go:

  • ‫build/make/core/proguard.flags: שומרת על מחלקות ואיברים שמסומנים ב-@com.android.internal.annotations.VisibleForTesting בכל החבילות, וב-@VisibleForTesting (androidx.annotation.VisibleForTesting או com.google.common.annotations.VisibleForTesting) בחבילות android.**,‏ com.android.** ו-com.google.android.**. הוא גם משמר את @TestApi, @Keep (androidx.annotation, android.support.annotation, com.android.internal.annotations), @KeepForWeakReference, @WeaklyReferencedCallback ו-@dalvik.annotation.optimization.**.
  • ‫build/make/core/proguard_basic_keeps.flags: שומרת את המאפיינים של SourceFile לדוחות קריסות, הערות הרשאות גישה בזמן ריצה (RuntimeVisible*Annotations), מאפיינים של Exceptions ו-AnnotationDefault, שיטות של native, איברים של Serializable, שיטות של @JavascriptInterface, פונקציות בנאי של Throwable(String), שדות של Parcelable$CREATOR ושדות של protobuf MessageLite.
  • ‫build/make/core/proguard/kotlin.flags: משתיק אזהרות לא מזיקות לגבי הערות מטא ספציפיות של Kotlin ‏ (kotlin.Metadata ו-kotlin.annotation.{AnnotationRetention,AnnotationTarget,Retention,Target}) ומסיר הערות DebugMetadata של Kotlin בגרסאות build להפצה.
  • ‫build/make/core/proguard/checknotnull.flags: מחליף קריאות נפוצות של פונקציות עזר לבדיקת null‏ (com.google.common.base.Preconditions.checkNotNull ו-dagger.internal.Preconditions.checkNotNull*) בבדיקות null תמציתיות של בייטקוד. הקובץ הזה משמיט בכוונה את Objects.requireNonNull כדי לשמור על הודעות חריגות מפורשות בגבולות ה-API של המסגרת.
  • ‫build/make/core/proguard/enumvalues.flags: שומר על השיטות values ו-valueOf בסוגי enum, אלא אם הן מושבתות על ידי תצורת ה-build.
  • כללים שנוצרו באופן אוטומטי על ידי AAPT2: הכלי AAPT2 בודק קובצי AndroidManifest.xml, פריסת XML והעדפות XML ממוזגים כדי ליצור כללי שמירה מדויקים לכל Activity, Service, BroadcastReceiver, ContentProvider, BackupAgent, Application, מחלקת משנה מותאמת אישית של View, מחלקת משנה של Preference ושיטת android:onClick של XML רשומים.

בדיקה והעברה של כללי שמירה קיימים

כשבודקים קובצי proguard.flags או keep.xml קיימים במאגר של פלטפורמה, צריך להעריך כל כלל לפי הסדר, בהתאם להיררכיה הבאה בת ארבעת השלבים:

שלב 1: מוחקים כללים מיותרים או מיושנים

מוחקים כללים שכבר נכללים בקווי הבסיס הגלובליים, במניפסט של AAPT2, בכללי הפריסה או בהגדרות ברירת המחדל של R8, וגם כללים שמפנים למחלקות או לחבילות שכבר לא קיימות.

אם כל הכללים בקובץ .flags מיותרים, צריך למחוק את הקובץ ולהסיר את proguard_flags_files מ-Android.bp. אם הבלוק optimize שנותר רק חוזר על הגדרות ברירת המחדל, צריך להסיר את הבלוק המיותר ולעצב את קובץ ה-build באמצעות bpfmt -w Android.bp. כשמנקים חבילה, צריך לבדוק אם מודולים נלווים בספריות משנה (כמו וריאנטים של יעדי Kotlin) מפנים לאותו קובץ דגלים, ולעדכן אותם ביחד.

שלב 2: העברת כללי שמירה מבוססי-בדיקה

מעבירים את כללי השמירה שמבוססים על בדיקות מחוץ לקובצי .flags מותאמים אישית בסביבת הייצור, באמצעות אחת מהתבניות הבאות:

  • מעקב אחרי הפניות מיעדי בדיקה או מיעדים נלווים (trace_references_from או allow_traced_references): כשגם היעד וגם מודול תלוי ישיר מקמפלים את ה-DEX שלהם (android_app,‏ android_test או java_library שעבר dexing) ומחוברים דרך instrumentation_for או libs, מערכת Soong יכולה להריץ את R8 tracereferences במודול התלוי ולהזין את כללי השמירה שנוצרו לקומפילציית R8 של היעד בלי ליצור מחזור תלות או לדרוש פיצול ידני של ספריית .impl:

    • בדיקות באותו מאגר (trace_references_from): מפרטים את הרכיבים הישירים של instrumentation_for או libs שתלויים במודול היעד (הפעולה הזו גם מופצת באופן אוטומטי בין וריאנטים של override_android_app ו-override_android_test, וממשיכה לתמוך בספריות Java נלוות שלא תלויות במודול כשהן משויכות לפיצול .impl):

      android_app {
          name: "MyApp",
          srcs: ["src/**/*.java"],
          optimize: {
              obfuscate: true,
              shrink_resources: true,
              trace_references_from: ["MyAppTests"],
          },
      }
      
      android_test {
          name: "MyAppTests",
          instrumentation_for: "MyApp",
          srcs: ["tests/**/*.java"],
      }
      
    • בדיקות של ספקים או של מאגרים שונים (allow_traced_references ו-trace_references): כשמודול היעד לא יכול להציג את הרכיב התלוי ישירות ב-trace_references_from (למשל בדיקות במאגר אחר או בעץ ספקים אחר), מגדירים את allow_traced_references: true במודול היעד ומצטרפים מכל רכיב תלוי באמצעות trace_references: true:

      android_app {
          name: "MyApp",
          srcs: ["src/**/*.java"],
          optimize: {
              allow_traced_references: true,
          },
      }
      
      android_test {
          name: "MyAppVendorTests",
          instrumentation_for: "MyApp",
          srcs: ["tests/**/*.java"],
          optimize: {
              trace_references: true,
          },
      }
      
    • רפלקציה מבוססת-מחרוזת בבדיקות: tracereferences בודק הפניות למאגר קבוע של קוד בייטים, ולא יכול לזהות רפלקציה מבוססת-מחרוזת בבדיקות (Class.forName,‏ getDeclaredField, ‏ getDeclaredMethod או רפלקציה של עוזרי הזרקת שדות). הוסיפו הערות לשדות ולשיטות של סביבת הייצור שאליהם ניגשים באמצעות רפלקציה, באמצעות @VisibleForTesting (או @UsedByReflection).

  • קישור ספריית ההטמעה לבדיקות (static_libs): אם אתם רוצים שאף נקודת כניסה לבדיקה בלבד לא תישמר ב-APK בסביבת הייצור, ואתם לא רוצים ששינויים בבדיקה יפעילו מחדש את R8 ביעד בסביבת הייצור, הציבו את קובצי המקור של האפליקציה ב-android_library (MyApp.impl) וקשרו באופן סטטי את MyApp.impl גם ל-MyApp וגם ל-MyAppTests:

    android_library {
        name: "MyApp.impl",
        srcs: ["src/**/*.java"],
        manifest: "AndroidManifest.xml",
    }
    
    android_app {
        name: "MyApp",
        static_libs: ["MyApp.impl"],
        optimize: {
            obfuscate: true,
            shrink_resources: true,
        },
    }
    
    android_test {
        name: "MyAppTests",
        srcs: ["tests/**/*.java"],
        static_libs: ["MyApp.impl"],
    }
    

    התבנית הזו של 3 מודולים מאפשרת להריץ את MyAppTests כבדיקת אינסטרומנטציה עצמית, עם גישה מלאה למחלקות פנימיות, ובמקביל מאפשרת לכווץ את MyApp ולטשטש אותו רק מול נקודות כניסה בסביבת הייצור.

  • הוספת הערות ל-test hooks באמצעות @VisibleForTesting: אם קובץ APK חיצוני לבדיקה קורא רק לכמה שיטות פנימיות או בנאים ביעד android_app, אפשר להוסיף הערות להצהרות האלה באמצעות @VisibleForTesting. ההגדרה הגלובלית של build/make/core/proguard.flags שומרת באופן אוטומטי @VisibleForTesting פריטים בחבילות android.**,‏ com.android.** ו-com.google.android.** בלי קובצי .flags מותאמים אישית. (למודולים של ספקים מחוץ למרחבי השמות האלה, צריך להשתמש ב-trace_references_from, ב-@UsedByReflection או בכללי ספרייה מיוצאים). כשמוסיפים הערות באופן ידני באמצעות @VisibleForTesting במקום להשתמש ב-trace_references_from או ב-trace_references, צריך להוסיף הערות גם למחלקות שמועברות ל-Mockito mock(...) או ל-spy(...) (לא רק ל-stubbed methods), כי mockito-target (dexmaker-mockito) במכשיר יוצר מחלקות משנה בזמן ריצה, ו-R8 יכול לסמן מחלקות ללא מחלקות משנה בתור final (MockitoException: Can't mock/spy class ... - final class).

  • הימנעו מספריות סטטיות חופפות או ארזו אותן מחדש (jarjar_rules): כשגם android_app וגם חבילת ה-APK לבדיקה instrumentation_for מקשרים באופן סטטי לאותה ספרייה (באופן ישיר, כמו guava, או באופן זמני), tracereferences רואה את המחלקות האלה שמוגדרות בבדיקה ולא פולט כללי שמירה לאפליקציה. בזמן הריצה, ה-classloader ההורה של האפליקציה מסתיר את העותק של הבדיקה באמצעות המחלקות המכווצות של R8, מה שגורם ל-IllegalAccessError או ל-NoSuchMethodError. אם הבדיקה לא צריכה עותק משלה, צריך להסיר את העותק הכפול מה-static_libs של הבדיקה (או לקשר אותו דרך libs) כדי למנוע את ההתנגשות. אחרת, צריך לארוז מחדש את חבילות הספרייה הסטטיות החופפות של האפליקציה תחת קידומת ספציפית למודול באמצעות jarjar_rules.

שלב 3: מייצאים את הכללים מהספרייה שבבעלותכם

אם קובץ java_library או android_library משותף דורש כללי שמירה עבור רפלקציה פנימית משלו או קריאות חוזרות (callbacks) של JNI, צריך להגדיר את הכללים ביעד הספרייה ולהגדיר את export_proguard_flags_files: true:

java_library {
    name: "my-shared-library",
    srcs: ["src/**/*.java"],
    optimize: {
        proguard_flags_files: ["proguard.flags"],
        export_proguard_flags_files: true,
    },
}

כל היעדים של android_app ושל הספרייה במורד הזרם שמקושרים באופן סטטי ל-my-shared-library יורשים את הכללים האלה באופן אוטומטי, כך שאין צורך לשכפל אותם באפליקציות במורד הזרם. אם כמה מודולים בספריות שונות צריכים לשתף קבוצת כללים שלא קשורה לספריית קוד אחת, צריך לפרסם את הקובץ .flags באמצעות filegroup מפורש ב-Android.bp כדי שהמודולים יוכלו להפנות אל :my-shared-flags בצורה נקייה בין חבילות. במאמר אופטימיזציה למפתחי ספריות יש הנחיות כלליות ליצירת כללי שמירה לצרכנים של ספריות בלי להגביל את האופטימיזציה של האפליקציה בהמשך.

שלב 4: הוספת הערות להצהרות בקוד המקור

כשניגשים למחלקה, לשיטה, ל-constructor או לשדה באמצעות השתקפות או JNI, והם לא נכללים ב-AAPT2 או בקווי בסיס גלובליים, מחליפים את כללי -keep המנותקים בקובצי .flags בהערות מקור מהמדריך לשמירת הערות (אפשר לעיין בהפניה ל-Javadoc של keepanno):

  • ‫@UsesReflection: עדיף להשתמש בהערה הזו כשאתם שולטים בקוד שמבצע את הרפלקציה. צריך להציב אותו באתר הקריאה לרפלקציה כדי להצהיר לאילו מחלקות, מתודות או שדות יעד מתבצעת גישה באופן דינמי. מכיוון ש-@UsesReflection מקודד באופן אוטומטי תנאי מוקדם לכך שאתר הקריאה עם ההערה עצמו נגיש, R8 מסיר גם את המתקשר וגם את יעד ההשתקפות אם אתר הקריאה לא בשימוש.
  • ‫@UsedByReflection: מציבים במחלקות, בשיטות, בשדות או בבנאים שנוצר מהם מופע או שהם מופעלים באופן רפלקטיבי על ידי קוד או ספריות חיצוניים, כמו מחלקות שנטענות באמצעות Class.forName ממפתחות Bundle או Settings, או מחלקות של פלאגינים שנטענות באמצעות טועני מחלקות דינמיים. ציינו את kind (למשל KeepItemKind.CLASS_AND_METHODS), את preconditions (@KeepCondition) ואת מגבלות הפרמטרים כדי ש-R8 ישמור רק את החוזה הרפלקטיבי המדויק ועדיין יוכל לבצע אופטימיזציה או להסיר איברים לא בשימוש של המחלקה. אל תוסיפו את @UsedByReflection לרכיבים שרשומים במניפסט, כמו JobService או BroadcastReceiver, כי AAPT2 כבר שומר אותם.
  • ‫@UsedByNative: צריך להוסיף ל-methods או לשדות שניגשים אליהם מקוד JNI ב-C או ב-C++‎ באמצעות GetMethodID,‏ GetStaticMethodID או GetFieldID.
  • ‫@KeepForApi: צריך להוסיף למחלקות או לאיברים של API של ספרייה שחייבים להישאר ללא שינוי כשמכווצים את הספרייה עצמה לפני ההפצה.
  • ‫@Keep (androidx.annotation.Keep): משמש כחלופה כשאי אפשר להשתמש ב-keepanno. החלה של @Keep על מחלקה שומרת את המחלקה ואת כל הגורמים שלה ללא תנאי. החלת @Keep על שיטה או שדה פועלת כנקודת כניסה ללא תנאי ששומרת על האיבר ועל המחלקה שמכילה אותו, גם אם המחלקה אף פעם לא נוצרת, ואילו keepanno מבטא נגישות מותנית.

כדי להשתמש בהערות R8 keepanno במודול Soong:

  1. הוספת "keepanno-annotations" אל libs בAndroid.bp:

    libs: [
        "keepanno-annotations",
    ],
    
  2. מייבאים את com.android.tools.r8.keepanno.annotations.* ומוסיפים הערות להצהרה או לאתר הקריאה בקוד המקור של Java או Kotlin:

    import com.android.tools.r8.keepanno.annotations.KeepItemKind;
    import com.android.tools.r8.keepanno.annotations.UsedByReflection;
    
    public final class CustomPluginController {
        @UsedByReflection(
            description = "Instantiated via Class.forName from plugin config",
            kind = KeepItemKind.CLASS_AND_METHODS)
        public CustomPluginController(Context context) {
            // ...
        }
    }
    

    ‫R8 מתרגם הערות keepanno ישירות למודל הפנימי של כללי השמירה ומסיר את ההערות מהפלט הסופי של DEX, בלי להוסיף תקורה של קוד בייט בזמן ריצה.

איך להימנע מבעיות נפוצות

בקטעים הבאים מתוארות בעיות נפוצות ב-proguard.flags וב-keep.xml בקוד הפלטפורמה, ואיך לתקן אותן.

כללי שמירה רחבים של רכיבים

# Don't do this:
-keep class * extends android.app.Activity
-keep class * extends android.app.Service
-keep class * extends android.content.BroadcastReceiver
  • למה זה פוגע בביצועים: הכלל הזה מיותר לחלוטין ב-AAPT2. מכיוון שהיא משתמשת בתו כללי לחיפוש בלי לבדוק אם הרכיב רשום בקובץ AndroidManifest.xml הממוזג הסופי, היא מאלצת את R8 לשמור כל מחלקת משנה של Activity,‏ Service או BroadcastReceiver שנמצאת בנתיב המחלקה, כולל רכיבי ספרייה שלא בשימוש ופעילויות ניפוי באגים מושבתות.
  • התיקון המומלץ: למחוק את הכלל. ‫AAPT2 בודק את המניפסט שעבר מיזוג ויוצר כללי -keep מדויקים לרכיבים רשומים.

כללי שמירה של רכיב ה-click handler ב-XML רחב

# Don't do this:
-keepclassmembers class * {
    public void *(android.view.View);
}
  • למה זה פוגע בביצועים: שמירה של כל שיטת public void *(View) בכל מחלקה במודול מונעת מ-R8 להסיר או להטמיע כל שיטה שמקבלת פרמטר View יחיד.
  • התיקון המומלץ: למחוק את הכלל. ‫AAPT2 סורק קובצי XML של פריסה ויוצר כללי שמירה ממוקדים לשיטות שמאפייני android:onClick מפנים אליהן.

כללי שמירה של Broad View getter ו-setter

# Don't do this:
-keep public class * extends android.view.View {
    public <init>(android.content.Context);
    public <init>(android.content.Context, android.util.AttributeSet);
    public <init>(android.content.Context, android.util.AttributeSet, int);
    public void set*(...);
    public *** get*();
}
  • למה זה פוגע בביצועים: הכלל הזה מאלץ את R8 לשמור כל getter ו-setter בכל מחלקת משנה של View באפליקציה ובכל הספריות המקושרות (כולל ספריות AndroidX ו-Material), מה שמונע הסרה של שיטות לא פעילות והטמעה בקוד של ממשק המשתמש.
  • התיקון המומלץ: למחוק את הכלל. ‫AAPT2 כבר שומר את ה-constructors של מחלקות View בהתאמה אישית שנוצרו מקובצי XML של פריסה. אם הקוד מנפיש מאפיין תצוגה באמצעות שמות של מחרוזות רפלקטיביות כמו ObjectAnimator.ofFloat(view, "translationZ", ...), צריך להחליף את שם המחרוזת בהפניה למאפיין מוקלד (View.TRANSLATION_Z או הטמעה מותאמת אישית של FloatProperty או IntProperty) כדי להימנע לחלוטין מרפלקציה. אם אי אפשר להימנע מגישה רפלקטיבית למאפיינים, צריך להוסיף הערה ל-getter או ל-setter הספציפיים באמצעות @UsedByReflection.

השבתה של אופטימיזציה או ערפול קוד (obfuscation) גורפים

# Don't do this in proguard.flags:
-dontoptimize
-dontshrink
-dontobfuscate
  • למה זה פוגע בביצועים: הוספה של -dontoptimize או -dontshrink בתוך קובץ .flags מבטלת את הגדרות ה-Android.bp של המודול בלי להציג הודעה, ומשביתה את שלבי האופטימיזציה בכל היעד. גרוע מכך, אם ספרייה מייצאת קובץ .flags שמכיל את -dontoptimize, היא משביתה את האופטימיזציה של R8 לכל android_app במורד הזרם שמקשר לספרייה.
  • התיקון המומלץ: צריך להסיר את -dontoptimize,‏ -dontshrink ו--dontobfuscate מקובצי .flags. שולטים בהתנהגות האופטימיזציה באופן מפורש ב-Android.bp באמצעות הבלוק optimize (shrink, optimize, obfuscate) ביעד העלה.

התרעות אבחון בכללים שהוגדרו

# Don't do this in proguard.flags:
-verbose
-printmapping proguard.map
-printusage usage.txt
-printconfiguration config.txt
  • למה זה פוגע ב-build: דגלי אבחון מציפים את יומני ה-build או מנסים לכתוב קובצי פלט לנתיבים מקומיים במהלך build של Soong בארגז חול.
  • התיקון המומלץ: למחוק את הדגלים האלה מקובצי .flags שבוצע להם commit. ‫Soong כותב באופן אוטומטי את פלטי המיפוי והשימוש של R8, כמו proguard_dictionary ו-proguard_usage.zip, לספריית הביניים של המודול ב-out/soong/.intermediates/.

כללי שמירה בסביבת הייצור לקוד בדיקה

  • למה זה פוגע בביצועים: הוספת כללי -keep בהתאמה אישית רק בשביל גישה לבדיקות מאלצת את הקוד להישאר לא מעורפל ולהישמר בגרסאות ה-build בסביבת הייצור. אפשר להגדיר את trace_references_from (או את allow_traced_references עם trace_references: true) או להוסיף הערות לשיטות באמצעות @VisibleForTesting כדי להימנע מתחזוקה ידנית של כללי -keep ולשמור רק את מה שהבדיקות מפנות אליו, אבל הסמלים האלה עדיין נשלחים בקובץ הבינארי בסביבת הייצור.
  • התיקון המומלץ: כדי להסיר כללי -keep לבדיקות בהתאמה אישית, צריך להפעיל את trace_references_from (או את allow_traced_references ו-trace_references: true). לחלופין, אפשר להגדיר את האפליקציה כספריית .impl שמקושרת ל-APK לבדיקת אינסטרומנטציה עצמית באמצעות static_libs, כדי להשאיר את נקודות הכניסה לבדיקה בלבד מחוץ ל-APK לסביבת הייצור, כפי שמתואר בשלב 2: העברת כללי keep מבוססי-בדיקה.

Blanket resource wildcards in keep.xml

<!-- Don't do this in res/raw/keep.xml: -->
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@raw/*,@drawable/*,@string/*" />
  • למה זה פוגע בביצועים: תווי כלליים גורפים שומרים כל משאב מהסוג התואם במודול וביחסי התלות שלו, מה שמבטל את כיווץ המשאבים (shrink_resources: true) ומנפח את ה-APK ואת טבלת ה-resources.arsc הממופה.
  • התיקון המומלץ:
    • עדיף להשתמש בהפניות למשאבים סטטיים ולא ב-keep.xml: אל תשתמשו ב-Resources.getIdentifier כדי לחפש באופן דינמי קבוצה מוגבלת של משאבים, כמו מחרוזות ניסוי ממוספרות או פריטים גרפיים עם עיצוב. במקום זאת, השתמשו בהצהרת switch בזמן הידור או במיפוי על קבועים סטטיים של R.id, R.string או R.drawable. הפניות סטטיות מאפשרות ל-R8 ול-AAPT2 לעקוב אחרי המשאבים הפעילים המדויקים, לבטל את התקורה של חיפוש מחרוזות בזמן הריצה ולהסיר לחלוטין את הצורך ב-keep.xml.
    • רשימה של שמות משאבים ספציפיים: אם נדרש חיפוש דינמי, למשל על ידי קורא רישיונות של צד שלישי, צריך לציין את מזהי המשאבים המדויקים ב-tools:keep (לדוגמה, tools:keep="@raw/third_party_licenses").
    • מחיקת קובצי keep.xml מיותרים: אם המשאבים שברשימה כבר מאוזכרים באופן סטטי בקוד (R.raw.foo) או ב-XML‏ (@raw/foo), הכלי לכיווץ שומר אותם באופן אוטומטי, כך שאפשר למחוק את res/raw/keep.xml.

אימות וביקורת של שינויים בכללים

בכל פעם שמסירים או מצמצמים כללי שמירה ב-proguard.flags או ב-keep.xml, צריך לוודא שהמודול נבנה בצורה נקייה, עובר בדיקות יחידה ובדיקות אינסטרומנטציה ושומר על כל נקודות הכניסה הנדרשות.

מודולים של פיתוח ובדיקה

  1. בונים את המודול בצורה נקייה כדי לוודא ש-R8 ו-AAPT2 מסתיימים בלי אזהרות על הפניות חסרות:

    m <MODULE_NAME>
    
  2. מריצים בדיקות יחידה ובדיקות אינסטרומנטציה למודול באמצעות atest:

    atest <TEST_MODULE_NAME>
    
  3. באפליקציות מערכת, בשירותים עם הרשאות או במודולים שתלויים בחומרה, מריצים בדיקות אינסטרומנטציה ובדיקות של ממשק המשתמש במכשירי היעד הפיזיים או במעבדה לבדיקת מכשירים כדי להפעיל נתיבי רפלקציה בזמן ריצה, קישורי IPC וניפוח משאבים.

בדיקת ההבדלים בין קובצי DEX ומשאבים

משווים בין קובץ ה-APK או ה-JAR המקומפל לפני ואחרי השינויים בכללים, כדי לוודא ש-R8 מסיר קוד ומשאבים לא פעילים בלי להסיר נקודות כניסה צפויות:

  • משתמשים ב-apkanalyzer או ב-dexdump כדי לבדוק את המחלקות, השיטות והשדות שנשמרו בקובצי ה-APK או ה-DEX של הפלט:

    apkanalyzer dex packages $OUT/system/priv-app/<APP_NAME>/<APP_NAME>.apk
    
  • כשמשנים את keep.xml או מפעילים את shrink_resources, משתמשים ב-aapt2 dump resources כדי לוודא שה-build מסיר משאבים שלא בשימוש ושומר את המשאבים הנדרשים:

    aapt2 dump resources $OUT/system/priv-app/<APP_NAME>/<APP_NAME>.apk
    
  • כשמריצים את prebuilts/r8/r8.jar ישירות ב-classes.jar של מודול לפני R8 כדי לאמת שינויים בדגלים אופליין בלי build מלא של הפלטפורמה, צריך להעביר גם את קובצי ה-JAR של classpath --lib של המודול (android.jar,‏ framework.jar ו-libs המשותף) וגם את כללי proguard.options שנוצרו על ידי AAPT2 מ-AndroidManifest.xml ומקובצי ה-XML של הפריסה. בלי הקלט הזה, R8 לא יכול לזהות את סוגי העל של ה-framework‏ (Activity, ‏ Service,‏ Phone, ‏ Handler) והוא לא שומר מספיק שיטות היררכיה.

ניתוח של רדיוס השמירה והכללת הכללים באמצעות R8

הקומפיילר R8 בקוד פתוח כולל מנתח Keep Radius (שנחשף גם ב-Android Studio וב-Gradle בתור R8 Configuration Analyzer) שמודד את ההשפעה המדויקת של כל כלל שמירה במהלך ההידור. ‫Soong משלב את הניתוח הזה ישירות ב-build של פלטפורמת Android (מוגדר ב-build/soong/java/dex.go).

כדי לנתח כללי שמירה של מודול פלטפורמה או של גרסה שלמה:

  1. מריצים את ה-build עם משתנה הסביבה R8_DUMP_KEEP_RADIUS=true:

    R8_DUMP_KEEP_RADIUS=true m <MODULE_NAME>
    

    כשהמשתנה R8_DUMP_KEEP_RADIUS=true מוגדר, Soong מורה ל-R8 לתעד מדדים של כללי שמירה בקובץ r8keepradius.pb זמני לכל מודול שעבר הידור ב-out/soong/.intermediates/. לכל כלל שמירה והערת keepanno, ‏R8 מתעד:

    • רדיוס השמירה המיידי: המחלקות, השדות והשיטות המדויקים שנשמרים על ידי הכלל, יחד עם האילוצים הספציפיים שהוא אוכף על כיווץ, אופטימיזציה או ערפול קוד.
    • הכללת כללים: אילו כללי שמירה או הערות אחרים כבר שומרים את אותם פריטים. אם כלל מותאם אישית נכלל באופן מלא בכלל AAPT2 או בכלל בסיס גלובלי, אפשר למחוק אותו.
    • כללים גלובליים וכללים שחלים על כל החבילה: כללים שמשתמשים בתווים כלליים לחיפוש שחלים על כל החבילה, או שמחילים הנחיות גלובליות להגדרות אישיות.
  2. ממירים את הפלט של r8keepradius.pb לדוח HTML אינטראקטיבי באמצעות KeepRadiusHtmlReportGenerator (כלול ב-prebuilts/r8/r8.jar):

    # Generate an HTML report for a single module
    INTERMEDIATES=out/soong/.intermediates/packages/apps/<APP_NAME>/<APP_NAME>
    java -cp prebuilts/r8/r8.jar \
        com.android.tools.r8.keepradius.KeepRadiusHtmlReportGenerator \
        $INTERMEDIATES/android_common/r8keepradius.pb \
        keep_radius_report.html
    
    # Scan all built modules under out/soong/.intermediates and generate
    # per-module HTML reports plus an aggregate keepradius.html summary
    java -cp prebuilts/r8/r8.jar \
        com.android.tools.r8.keepradius.KeepRadiusHtmlReportGenerator \
        out/soong/.intermediates \
        out/keep_radius_reports
    

    כשמציינים ספרייה, KeepRadiusHtmlReportGenerator עובר על כל קובצי ה-*keepradius*.pb, יוצר דוח HTML לכל מודול ויוצר את out/keep_radius_reports/keepradius.html שמסכם את מספר הפריטים הפעילים, מספר הפריטים שנשמרו וכללי השמירה עם הרדיוס הגבוה ביותר ב-build. מידע נוסף על פירוש הציונים של הכיווץ, האופטימיזציה וההסתרה בדוח שנוצר זמין במאמר שימוש ב-R8 Configuration Analyzer.

‫Soong תומך גם במשתני סביבה נוספים של R8 לאבחון וללולאה פנימית ב-build/soong/java/dex.go:

  • ‫R8_DUMP_INPUT=true: כותב את r8inputs.zip לספריית הביניים של המודול שמכילה את כל קובצי ה-JAR של הקלט, קובצי ה-JAR של הספרייה והגדרות ProGuard הממוזגות לשחזור R8 עצמאי.
  • ‫R8_DUMP_PERFETTO_TRACE=true: כותבת את r8trace.ptrace לספריית הביניים של המודול כדי לבדוק את שלבי הקימפול של R8 ב-Perfetto.
  • ‫SOONG_SKIP_TRACE_REFERENCES=true: דילוג על הפעלת כללי tracereferences שהוגדרו על ידי trace_references_from או trace_references כדי להאיץ את ה-builds של הלולאה הפנימית המקומית כשמבצעים איטרציה על קוד הבדיקה.

אימות כללי הצרכנים של הספרייה

כש-java_library או android_library מייצאים כללי שמירה לצרכנים במורד הזרם (export_proguard_flags_files: true), הכללים האלה לא יכולים לכלול דגלים גלובליים שמשנים או משביתים את האופטימיזציה של האפליקציה הצרכנית.

‫R8 מספק מנתח כללי שמירה בקוד פתוח (ProcessKeepRules), שחשוף ב-build של פלטפורמת Android ככלי המארח process-keep-rules (מוגדר ב-prebuilts/r8/Android.bp):

# Build the R8 keep rules validator host binary
m process-keep-rules

# Validate one or more ProGuard configuration files
out/host/linux-x86/bin/process-keep-rules <PROGUARD_FLAGS_PATH>

הכלי process-keep-rules מנתח כל קובץ תצורה, ואם הוא נתקל בהוראות שאסורות בכללי הצרכן של הספרייה, הוא נכשל ומציג אבחון של הקובץ והשורה. הוראות אסורות כוללות אופטימיזציה גלובלית, כיווץ או השבתת ערפול קוד (כמו -dontoptimize או -dontshrink), אריזה מחדש של חבילות ודגלי שינוי גישה, דגלי אבחון או מיפוי ו--keepattributes ברמת האפליקציה.

בדיקת עצי מקור באמצעות pgaudit.py

כדי לבדוק ספריות קובצי מקור שלא נבנו, לצד מנתחי זמן ה-build של R8, עץ פלטפורמת Android כולל את הסקריפט pgaudit.py בקטע build/make/core/proguard/tools/. כלי הניתוח הסטטי הזה סורק קובצי Android.bp,‏ proguard.flags ו-keep.xml במאגר כדי לסמן כללים שכבר נכללים ב-AAPT2 או בקווי הבסיס של הפלטפורמה הגלובלית, תווים כלליים לחיפוש, שינויים של -dontoptimize וכללי שמירה לבדיקה בלבד:

# Audit a single package directory
./build/make/core/proguard/tools/pgaudit.py packages/apps/Provision/

# Scan a subsystem tree and write a structured JSON report
./build/make/core/proguard/tools/pgaudit.py packages/ --json=audit_report.json

צוותי פלטפורמה ו-OEM יכולים לשלב את pgaudit.py כדי לבצע מיון מהיר של עץ המקור, את process-keep-rules כדי לאמת כללים של ספרייה שיוצאה, ואת R8_DUMP_KEEP_RADIUS=true עם KeepRadiusHtmlReportGenerator כדי למדוד את רדיוס השמירה המדויק של המחלקה, השדה והשיטה של כל כלל ב-build.