הפעלת הפוטנציאל המלא של הכלי R8 Optimizer

‫R8 מספק שני מצבים: מצב תאימות ומצב מלא. מצב מלא מספק לכם אופטימיזציות חזקות שמשפרות את ביצועי האפליקציה.

המדריך הזה מיועד למפתחי Android שרוצים להשתמש באופטימיזציות הכי חזקות של R8. המאמר מסביר את ההבדלים העיקריים בין תאימות לבין מצב מלא, ומספק את ההגדרות הנדרשות כדי להעביר את הפרויקט בצורה בטוחה ולמנוע קריסות נפוצות בזמן הריצה.

הפעלת מצב מלא

כדי להפעיל את המצב המלא, מסירים את השורה הבאה מקובץ gradle.properties:

android.enableR8.fullMode=false // Remove this line to enable full mode

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

מאפיינים הם מטא-נתונים שמאוחסנים בקובצי מחלקות שעברו קומפילציה, והם לא חלק מהקוד שניתן להפעלה. עם זאת, יכול להיות שיהיה צורך בהם בסוגים מסוימים של רפלקציה. דוגמאות נפוצות כוללות את Signature (ששומר על מידע על סוג גנרי אחרי מחיקת הסוג), InnerClasses ו-EnclosingMethod (לשיקוף של מבנה המחלקה) והערות שניתן לראות בזמן הריצה.

בדוגמה הבאה מוצג מאפיין Signature של שדה בבייטקוד. בשדה:

List<User> users;

קובץ המחלקה שעבר קומפילציה יכיל את הבייטקוד הבא:

.field public static final users:Ljava/util/List;
    .annotation system Ldalvik/annotation/Signature;
        value = {
            "Ljava/util/List<",
            "Lcom/example/package/User;",
            ">;"
        }
    .end annotation
.end field

ספריות שמשתמשות הרבה ברפלקציה (כמו Gson) מסתמכות לעיתים קרובות על המאפיינים האלה כדי לבדוק את המבנה של הקוד שלכם ולהבין אותו באופן דינמי. כברירת מחדל במצב המלא של R8, מאפיינים נשמרים רק אם המחלקה, השדה או השיטה המשויכים נשמרים במפורש.

בדוגמה הבאה אפשר לראות למה צריך מאפיינים ואילו כללי שמירה צריך להוסיף כשעוברים ממצב תאימות למצב מלא.

בדוגמה הבאה אנחנו מבצעים דה-סריאליזציה של רשימת משתמשים באמצעות ספריית Gson.


import com.google.gson.Gson
import com.google.gson.reflect.TypeToken

data class User(
    @SerializedName("username")
    var username: String? = null,
    @SerializedName("age")
    var age: Int = 0
)

fun GsonRemoteJsonListExample() {
    val gson = Gson()

    // 1. The JSON string for a list of users returned from remote
    val jsonOutput = """[{"username":"alice","age":30}, {"username":"bob","age":25}]"""

    // 2. Deserialize the JSON string into a List<User>
    // We must use TypeToken for generic types like List
    val listType = object : TypeToken<List<User>>() {}.type
    val deserializedList: List<User> = gson.fromJson(jsonOutput, listType)

    // Print the list
    println("First user from list: ${deserializedList}")
}

במהלך הקומפילציה, מחיקת הטיפוסים ב-Java מסירה את הארגומנטים של הטיפוסים הגנריים. המשמעות היא שבזמן הריצה, גם List<String> וגם List<User> מופיעים כ-List גולמי. לכן, ספריות כמו Gson, שמסתמכות על רפלקציה, לא יכולות לקבוע את סוגי האובייקטים הספציפיים ש-List הוגדר להכיל כשמבטלים את הסדר של רשימת JSON, מה שעלול להוביל לבעיות בזמן הריצה.

כדי לשמור על פרטי הסוג, Gson משתמש ב-TypeToken. הוספת תגי עטיפה TypeToken שומרת את פרטי הביטול הסדרתי הנדרשים.

הביטוי ב-Kotlin‏ object:TypeToken<List<User>>() {}.type יוצר מחלקה פנימית אנונימית שמרחיבה את TypeToken וכוללת את פרטי הסוג הגנרי. בדוגמה הזו, המחלקה האנונימית נקראת $GsonRemoteJsonListExample$listType$1.

שפת התכנות Java שומרת את החתימה הגנרית של מחלקת-על כמטא-נתונים, שנקראים מאפיין Signature, בקובץ המחלקה שעבר קומפילציה. ‫TypeToken משתמש במטא-נתונים האלה של Signature כדי לשחזר את הסוג בזמן הריצה. כך Gson יכול להשתמש ברפלקציה כדי לקרוא את Signature ולגלות בהצלחה את הסוג המלא List<User> שהוא צריך בשביל ביטול הסדרתיות.

כש-R8 מופעל במצב תאימות, הוא שומר על המאפיין Signature של מחלקות, כולל מחלקות פנימיות אנונימיות כמו $GsonRemoteJsonListExample$listType$1, גם אם לא מוגדרים כללי שמירה ספציפיים באופן מפורש. כתוצאה מכך, מצב התאימות של R8 לא דורש כללים מפורשים נוספים כדי שהדוגמה הזו תפעל כצפוי.

// keep rule for compatibility mode
-keepattributes Signature

כש-R8 מופעל במצב מלא, המאפיין Signature של המחלקה הפנימית האנונימית $GsonRemoteJsonListExample$listType$1 מוסר. בלי מידע על הסוג הזה ב-Signature, ‏ Gson לא יכול למצוא את סוג האפליקציה הנכון, ולכן מתקבלת IllegalStateException. הכללים שצריך להגדיר כדי למנוע את זה הם:

// keep rule required for full mode
-keepattributes Signature
-keep,allowobfuscation,allowshrinking,allowoptimization class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking,allowoptimization class * extends com.google.gson.reflect.TypeToken
  • -keepattributes Signature: הכלל הזה מורה ל-R8 לשמור את המאפיין ש-Gson צריך לקרוא. במצב מלא, R8 שומר רק את המאפיין Signature עבור מחלקות, שדות או שיטות שתואמים באופן מפורש לכלל keep.

  • -keep,allowobfuscation,allowshrinking,allowoptimization class com.google.gson.reflect.TypeToken: הכלל הזה נחוץ כי TypeToken עוטף את סוג האובייקט שמבצעים לו דה-סריאליזציה. אחרי מחיקת הסוג, נוצרת מחלקה פנימית אנונימית כדי לשמור את מידע הסוג הגנרי. אם לא מציינים במפורש שצריך לשמור על com.google.gson.reflect.TypeToken,‏ R8 במצב מלא לא יכלול את סוג המחלקה הזה במאפיין Signature שנדרש לביטול הסדרתי.

  • -keep,allowobfuscation,allowshrinking,allowoptimization class * extends com.google.gson.reflect.TypeToken: הכלל הזה שומר על מידע הסוג של מחלקות אנונימיות שמרחיבות את TypeToken, כמו $GsonRemoteJsonListExample$listType$1 בדוגמה הזו. בלי הכלל הזה,‏ R8 במצב מלא מסיר את פרטי הסוג הנדרשים, וגורם לכך שביטול הסדרת הנתונים נכשל.

החל מגרסה 2.11.0 של Gson, הספרייה כוללת כללים נדרשים לשמירה שנדרשים לביטול הסדרתיות במצב מלא. כשמפתחים את האפליקציה עם R8 מופעל, R8 מוצא ומחיל את הכללים האלה מהספרייה באופן אוטומטי. כך האפליקציה מקבלת את ההגנה שהיא צריכה בלי שתצטרכו להוסיף או לתחזק את הכללים הספציפיים האלה בפרויקט באופן ידני.

חשוב להבין שהכללים שצוינו קודם פותרים רק את הבעיה של גילוי הסוג הגנרי (למשל, ‫List<User>). בנוסף, R8 משנה את השמות של השדות של המחלקות. אם לא משתמשים בהערות (annotations) במודלים של הנתונים, Gson לא יצליח לבצע דה-סריאליזציה של JSON כי שמות השדות לא יתאימו יותר למפתחות ה-JSON.@SerializedName

עם זאת, אם אתם משתמשים בגרסה של Gson שקודמת לגרסה 2.11, או אם המודלים שלכם לא משתמשים בהערה @SerializedName, אתם צריכים להוסיף כללי שמירה מפורשים למודלים האלה.

שמירה של בנאי ברירת המחדל

במצב מלא של R8, הקונסטרוקטור ללא ארגומנטים או קונסטרוקטור ברירת המחדל לא נשמר באופן מרומז, גם אם המחלקה עצמה נשמרת. אם יוצרים מופע של מחלקה באמצעות class.getDeclaredConstructor().newInstance() או class.newInstance(), צריך לשמור באופן מפורש את בנאי no-args במצב מלא. לעומת זאת, במצב תאימות, תמיד נשמר בנאי ללא ארגומנטים.

נבחן דוגמה שבה נוצר מופע של PrecacheTask באמצעות רפלקציה כדי לקרוא באופן דינמי לשיטה run שלו. במצב תאימות, לא צריך להוסיף כללים כדי שהתרחיש הזה יפעל, אבל במצב מלא, בנאי ברירת המחדל של PrecacheTask יוסר. לכן נדרש כלל שמירה ספציפי.

// In library
interface StartupTask {
    fun run()
}
// The library object that loads and executes the task.
object TaskRunner {
    fun execute(taskClass: Class<out StartupTask>) {
        // The class isn't removed, but its constructor might be.
        val task = taskClass.getDeclaredConstructor().newInstance()
        task.run()
    }
}

// In app
class PreCacheTask : StartupTask {
    override fun run() {
        Log.d("Pre cache task", "Warming up the cache...")
    }
}

fun runTaskRunner() {
    // The library is given a direct reference to the app's task class.
    TaskRunner.execute(PreCacheTask::class.java)
}
# Full mode keep rule
# default constructor needs to be specified

-keep class com.example.fullmoder8.PreCacheTask {
    <init>();
}

השינוי בגישה מופעל כברירת מחדל

במצב תאימות, R8 לא משנה את הרשאות הגישה של שיטות ושדות בתוך מחלקה. עם זאת, במצב מלא, R8 משפר את האופטימיזציה על ידי שינוי הנראות של השיטות והשדות, למשל מפרטי לציבורי. כך אפשר להשתמש ביותר הטמעה.

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

כדי להבין למה לא מומלץ לגשת לחברים פרטיים באמצעות רפלקציה, ולמה צריך כללי שמירה כדי לשמור את השדות או השיטות האלה, אפשר לעיין בדוגמה הזו.