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
שומרת את פרטי הביטול הסדרתי הנדרשים.
הביטוי object:TypeToken<List<User>>() {}.type ב-Kotlin יוצר מחלקה פנימית אנונימית שמרחיבה את 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.
אם אתם משתמשים בגרסה של Gson שקודמת לגרסה 2.11.0, כללי השמירה שנדרשים כדי למנוע את הבעיה הזו הם:
// 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 במצב מלא מסיר את פרטי הסוג הנדרשים, וגורם לכך שביטול הסדרת הנתונים נכשל.
חשוב להבין שהכללים ששותפו קודם פותרים רק את הבעיה של גילוי הסוג הגנרי (לדוגמה, List<User>). בנוסף, R8 משנה את השם של השדות של המחלקות. אם לא משתמשים בהערות @SerializedName במודלים של הנתונים, Gson לא יצליח לבצע דה-סריאליזציה של JSON כי שמות השדות לא יתאימו יותר למפתחות ה-JSON.
עם זאת, אם אתם משתמשים בגרסה של Gson שקודמת לגרסה 2.11, או אם המודלים שלכם לא משתמשים בהערה @SerializedName, אתם צריכים להוסיף כללי שמירה מפורשים למודלים האלה.
שמירה של constructor ברירת המחדל
במצב המלא של R8, ה-constructor ללא ארגומנטים או ברירת המחדל לא נשמר באופן מרומז, גם אם המחלקה עצמה נשמרת. אם אתם יוצרים מופע של מחלקה באמצעות class.getDeclaredConstructor().newInstance() או class.newInstance(), אתם צריכים לשמור באופן מפורש את בנאי no-args במצב מלא. לעומת זאת, במצב תאימות, constructor ללא ארגומנטים תמיד נשמר.
נבחן דוגמה שבה נוצר מופע של 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 כדי לשמור על חברי המועדון, וכך גם על הגדרות החשיפה המקוריות שלהם.
מידע נוסף זמין בדוגמה הזו, שבה מוסבר למה לא מומלץ לגשת לחברים פרטיים באמצעות השתקפות, ומהם כללי השמירה שמאפשרים לשמור את השדות או השיטות האלה.
מטא-נתונים ספציפיים ל-Kotlin
כשמהדרים קוד Kotlin, מהדר Kotlin מאחסן מטא-נתונים ספציפיים לשפה (כמו מאפיין המציין אם ערך יכול להיות ריק (nullability), פונקציות הרחבה וחתימות של שגרות המשך (coroutine)) באנוטציה @kotlin.Metadata בכל קובץ מחלקה.
אם האפליקציה או התלות שלה משתמשות ב-Kotlin reflection (kotlin.reflect), ספריית ה-reflection מנתחת את המטא-נתונים האלה בזמן הריצה כדי לבדוק את מבנה המחלקה.
במצב מלא של R8, הכלי מסיר הערות כברירת מחדל אם לא מציינים במפורש שרוצים לשמור אותן. בנוסף, אם R8 מצמצם את המחלקות בלי לשמור ולעדכן את המטא-נתונים, ההשתקפות של Kotlin תיכשל בזמן הריצה, מה שיוביל להתנהגות בלתי צפויה או לקריסות (כמו KotlinReflectionInternalError).
כדי למנוע התנהגות בלתי צפויה ולוודא שפונקציות ההשתקפות של Kotlin פועלות בצורה תקינה אחרי מזעור הקוד, צריך לשמור על הערות שניתן לראות בזמן הריצה ולשמור באופן מפורש את המחלקה kotlin.Metadata:
# Preserve runtime-visible annotations required for inspecting metadata
-keepattributes RuntimeVisibleAnnotations
# Keep Kotlin metadata to ensure kotlin.reflect functions correctly
-keep class kotlin.Metadata { *; }