הדוגמאות הבאות מבוססות על תרחישים נפוצים שבהם משתמשים ב-R8 לאופטימיזציה, אבל צריך הנחיות מתקדמות כדי לנסח כללי שמירה.
השתקפות
באופן כללי, כדי להשיג ביצועים אופטימליים, לא מומלץ להשתמש בהשתקפות. עם זאת, בתרחישים מסוימים, יכול להיות שזה יהיה בלתי נמנע. בדוגמאות הבאות מפורטות הנחיות לגבי כללי השמירה בתרחישים נפוצים שבהם נעשה שימוש בהשתקפות.
אם ההשתקפות נמצאת בקוד של האפליקציה או בספרייה שלכם, אתם יכולים להשתמש בהערות keep במקום לכתוב כללי keep באופן ידני. פלאגין של Gradle יוצר את כללי השמירה מהערות בקוד המקור, כולל כללי שמירה של צרכנים לספריות.
השתקפות עם מחלקות שנטענו לפי שם
ספריות בדרך כלל טוענות מחלקות באופן דינמי באמצעות שם המחלקה בתור String.
עם זאת, R8 לא יכול לזהות מחלקות שנטענות באופן הזה, ויכול להיות שהוא יסיר את המחלקות שהוא מחשיב כלא בשימוש.
לדוגמה, נניח שיש לכם ספרייה ואפליקציה שמשתמשת בספרייה – הקוד מדגים טוען ספרייה שיוצר מופע של ממשק StartupTask שהוטמע על ידי אפליקציה.
קוד הספרייה הוא:
// The interface for a task that runs once.
interface StartupTask {
fun run()
}
// The library object that loads and executes the task.
object TaskRunner {
fun execute(className: String) {
// R8 won't retain classes specified by this string value at runtime
val taskClass = Class.forName(className)
val task = taskClass.getDeclaredConstructor().newInstance() as StartupTask
task.run()
}
}
האפליקציה שמשתמשת בספרייה מכילה את הקוד הבא:
// The app's task to pre-cache data.
// R8 will remove this class because it's only referenced by a string.
class PreCacheTask : StartupTask {
override fun run() {
// This log will never appear if the class is removed by R8.
Log.d("AppTask", "Warming up the cache...")
}
}
fun onCreate() {
// The library is told to run the app's task by its name.
TaskRunner.execute("com.example.app.PreCacheTask")
}
בתרחיש הזה, הספרייה צריכה לכלול קובץ של כללי שמירה לצרכנים עם כללי השמירה הבאים:
-keep class * implements com.example.library.StartupTask {
<init>();
}
בלי הכלל הזה, R8 מסיר את PreCacheTask מהאפליקציה כי האפליקציה לא משתמשת במחלקה באופן ישיר, וזה משבש את השילוב. הכלל מוצא את המחלקות שמיישמות את הממשק StartupTask של הספרייה ושומר אותן, יחד עם בנאי ללא ארגומנטים, וכך מאפשר לספרייה ליצור מופע ולהפעיל את PreCacheTask בהצלחה.
השתקפות עם ::class.java
ספריות יכולות לטעון שיעורים על ידי העברת אובייקט Class ישירות לאפליקציה. זו שיטה אמינה יותר מאשר טעינת שיעורים לפי שם. כך נוצרת הפניה חזקה למחלקה ש-R8 יכול לזהות. עם זאת, למרות שהפעולה הזו מונעת מ-R8 להסיר את המחלקה, עדיין צריך להשתמש בכלל שמירה כדי להצהיר שהמחלקה נוצרת באופן רפלקטיבי, ולהגן על האיברים שאליהם ניגשים באופן רפלקטיבי, כמו ה-constructor.
לדוגמה, נניח שיש לכם ספרייה ואפליקציה שמשתמשת בספרייה – רכיב טעינה של הספרייה יוצר מופע של ממשק StartupTask על ידי קביעת ערך ההפניה לכיתה באופן ישיר.
קוד הספרייה הוא:
// The interface for a task that runs once.
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()
}
}
האפליקציה שמשתמשת בספרייה מכילה את הקוד הבא:
// The app's task is to pre-cache data.
class PreCacheTask : StartupTask {
override fun run() {
Log.d("AppTask", "Warming up the cache...")
}
}
fun onCreate() {
// The library is given a direct reference to the app's task class.
TaskRunner.execute(PreCacheTask::class.java)
}
בתרחיש הזה, הספרייה צריכה לכלול קובץ של כללי שמירה לצרכנים עם כללי השמירה הבאים:
# Allow any implementation of StartupTask to be removed if unused.
-keep,allowobfuscation,allowshrinking class * implements com.example.library.StartupTask
# Keep the default constructor, which is called via reflection.
-keepclassmembers class * implements com.example.library.StartupTask {
<init>();
}
הכללים האלה נועדו לפעול בצורה מושלמת עם סוג כזה של רפלקציה, כדי לאפשר אופטימיזציה מקסימלית ולוודא שהקוד פועל בצורה תקינה. הכללים מאפשרים ל-R8 לטשטש את שם המחלקה ולכווץ או להסיר את ההטמעה של המחלקה StartupTask אם האפליקציה לא משתמשת בה אף פעם. עם זאת, בכל הטמעה, כמו PrecacheTask שמשמשת בדוגמה, נשמרת פונקציית הבנאי שמוגדרת כברירת מחדל (<init>()) שהספרייה צריכה להפעיל.
-
-keep,allowobfuscation,allowshrinking class * implements com.example.library.StartupTask: הכלל הזה מכוון לכל מחלקה שמטמיעה את הממשקStartupTask.-
-keep class * implements com.example.library.StartupTask: הפעולה הזו שומרת על כל מחלקה (*) שמטמיעה את הממשק שלכם. -
,allowobfuscation: ההוראה הזו אומרת ל-R8 שלמרות שהמחלקות נשמרות, אפשר לשנות את השם שלהן או להצפין אותן. זה בטוח כי הספרייה לא מסתמכת על שם המחלקה, אלא מקבלת את האובייקטClassישירות. -
,allowshrinking: שינוי הגישה הזה אומר ל-R8 שאפשר להסיר את המחלקה אם היא לא בשימוש. כך R8 יכול למחוק באופן בטוח הטמעה שלStartupTaskשלא מועברת אף פעם אלTaskRunner.execute(). בקיצור, המשמעות של הכלל הזה היא: אם אפליקציה משתמשת במחלקה שמטמיעה אתStartupTask, R8 שומרת את המחלקה. הכלי R8 יכול לשנות את השם של המחלקה כדי להקטין את הגודל שלה, והוא יכול למחוק אותה אם האפליקציה לא משתמשת בה.
-
-keepclassmembers class * implements com.example.library.StartupTask { <init>(); }: הכלל הזה מטרגט חברים ספציפיים בכיתות שזוהו בכלל הראשון – במקרה הזה, ה-constructor.-
-keepclassmembers class * implements com.example.library.StartupTask: ההגדרה הזו שומרת גורמים ספציפיים (מתודות, שדות) של המחלקה שמטמיעה את הממשקStartupTask, אבל רק אם המחלקה המוטמעת עצמה נשמרת. -
{ <init>(); }: זהו בורר החברים. <init>הוא השם הפנימי המיוחד של פונקציית constructor ב-Java bytecode. החלק הזה מתמקד ספציפית ב-constructor שמוגדר כברירת מחדל, ללא ארגומנטים. - הכלל הזה חשוב מאוד כי הקוד שלך קורא ל-
getDeclaredConstructor().newInstance()בלי ארגומנטים, מה שגורם להפעלת בנאי ברירת המחדל. בלי הכלל הזה, R8 רואה שאף קוד לא קורא ישירות ל-new PreCacheTask(), מניח שה-constructor לא בשימוש ומסיר אותו. המצב הזה גורם לקריסה של האפליקציה בזמן הריצה עםInstantiationException.
-
השתקפות על סמך הערת שיטה
בספריות מוגדרות לעיתים קרובות הערות שמפתחים משתמשים בהן כדי לתייג שיטות או שדות.
לאחר מכן, הספרייה משתמשת ברפלקציה כדי למצוא את האיברים האלה עם ההערות בזמן הריצה. לדוגמה, ההערה @OnLifecycleEvent משמשת למציאת השיטות הנדרשות בזמן הריצה.
לדוגמה, נניח שיש לכם ספרייה ואפליקציה שמשתמשת בספרייה. בדוגמה הזו מוצג אוטובוס אירועים שמאתר מתודות עם ההערה @OnEvent ומפעיל אותן.
זה הקוד של הספרייה:
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class OnEvent
class EventBus {
fun dispatch(listener: Any) {
// Find all methods annotated with @OnEvent and invoke them
listener::class.java.declaredMethods.forEach { method ->
if (method.isAnnotationPresent(OnEvent::class.java)) {
try {
method.invoke(listener)
} catch (e: Exception) { /* ... */ }
}
}
}
}
באפליקציה שמשתמשת בספרייה יש את הקוד הבא:
class MyEventListener {
@OnEvent
fun onSomethingHappened() {
// This method will be removed by R8 without a keep rule
Log.d(TAG, "Event received!")
}
}
fun onCreate() {
// Instantiate the listener and the event bus
val listener = MyEventListener()
val eventBus = EventBus()
// Dispatch the listener to the event bus
eventBus.dispatch(listener)
}
הספרייה צריכה לכלול קובץ כללים לשימור צרכנים שמשמר באופן אוטומטי את כל השיטות באמצעות ההערות שלה:
-keepattributes RuntimeVisibleAnnotations
-keep @interface com.example.library.OnEvent;
-keepclassmembers class * {
@com.example.library.OnEvent <methods>;
}
-
-keepattributes RuntimeVisibleAnnotations: הכלל הזה שומר על הערות שנועדו לקריאה בזמן ריצה. -
-keep @interface com.example.library.OnEvent: הכלל הזה שומר את מחלקת ההערותOnEventעצמה. -keepclassmembers class * {@com.example.library.OnEvent <methods>;}: הכלל הזה שומר כיתה וחברים ספציפיים רק אם הכיתה נמצאת בשימוש והיא מכילה את החברים האלה.-
-keepclassmembers: הכלל הזה שומר מחלקה ואיברים ספציפיים רק אם המחלקה נמצאת בשימוש והיא מכילה את האיברים האלה. -
class *: הכלל חל על כל כיתה. -
@com.example.library.OnEvent <methods>;: הפקודה הזו שומרת כל מחלקה שיש לה שיטה אחת או יותר (<methods>) עם ההערה@com.example.library.OnEvent, ושומרת גם את השיטות עצמן.
-
רפלקציה על סמך אנוטציות של כיתות
ספריות יכולות להשתמש ברפלקציה כדי לסרוק כיתות עם הערה ספציפית. במקרה הזה, המחלקה task runner מוצאת את כל המחלקות עם ההערה ReflectiveExecutor באמצעות רפלקציה ומבצעת את השיטה execute.
לדוגמה, נניח שיש לכם ספרייה ואפליקציה שמשתמשת בספרייה.
הספרייה מכילה את הקוד הבא:
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class ReflectiveExecutor
class TaskRunner {
fun process(task: Any) {
val taskClass = task::class.java
if (taskClass.isAnnotationPresent(ReflectiveExecutor::class.java)) {
val methodToCall = taskClass.getMethod("execute")
methodToCall.invoke(task)
}
}
}
באפליקציה שמשתמשת בספרייה יש את הקוד הבא:
// In consumer app
@ReflectiveExecutor
class ImportantBackgroundTask {
fun execute() {
// This class will be removed by R8 without a keep rule
Log.e("ImportantBackgroundTask", "Executing the important background task...")
}
}
// Usage of ImportantBackgroundTask
fun onCreate(){
val task = ImportantBackgroundTask()
val runner = TaskRunner()
runner.process(task)
}
מכיוון שהספרייה משתמשת ברפלקציה באופן רפלקטיבי כדי לקבל מחלקות ספציפיות, הספרייה צריכה לכלול קובץ כללי שמירה של הצרכן עם כללי השמירה הבאים:
# Retain annotation metadata for runtime reflection.
-keepattributes RuntimeVisibleAnnotations
# Keep the annotation interface itself.
-keep @interface com.example.library.ReflectiveExecutor
# Keep the execute method in the classes which are being used
-keepclassmembers @com.example.library.ReflectiveExecutor class * {
public void execute();
}
ההגדרה הזו יעילה מאוד כי היא מציינת ל-R8 בדיוק מה לשמור.
רפלקציה לתמיכה ביחסי תלות אופציונליים
תרחיש שימוש נפוץ של רפלקציה הוא ליצור תלות רכה בין ספריית ליבה לבין ספריית תוספים אופציונלית. ספריית הליבה יכולה לבדוק אם התוסף כלול באפליקציה, ואם כן, להפעיל תכונות נוספות. כך אפשר לשלוח מודולים של תוספים בלי לאלץ את ספריית הליבה להיות תלויה בהם באופן ישיר.
ספריית הליבה משתמשת בהשתקפות (Class.forName) כדי לחפש מחלקה ספציפית לפי השם שלה. אם הכיתה נמצאת, התכונה מופעלת. אם לא, הוא נכשל בצורה חלקה.
לדוגמה, הקוד הבא שבו ליבת AnalyticsManager בודקת אם יש מחלקה אופציונלית VideoEventTracker כדי להפעיל ניתוח של סרטונים.
ספריית הליבה כוללת את הקוד הבא:
object AnalyticsManager {
private const val VIDEO_TRACKER_CLASS = "com.example.analytics.video.VideoEventTracker"
fun initialize() {
try {
// Attempt to load the optional module's class using reflection
Class.forName(VIDEO_TRACKER_CLASS).getDeclaredConstructor().newInstance()
Log.d(TAG, "Video tracking enabled.")
} catch (e: ClassNotFoundException) {
Log.d(TAG,"Video tracking module not found. Skipping.")
} catch (e: Exception) {
Log.e(TAG, e.printStackTrace())
}
}
}
הקוד של ספריית הווידאו האופציונלית:
package com.example.analytics.video
class VideoEventTracker {
// This constructor must be kept for the reflection call to succeed.
init { /* ... */ }
}
המפתח של הספרייה האופציונלית אחראי לספק את כלל השמירה הנדרש לצרכן. כלל השמירה הזה מבטיח שכל אפליקציה שמשתמשת בספרייה האופציונלית תשמור את הקוד שספריית הליבה צריכה למצוא.
# In the video library's consumer keep rules file
-keep class com.example.analytics.video.VideoEventTracker {
<init>();
}
בלי הכלל הזה, סביר להניח ש-R8 יסיר את VideoEventTracker מהספרייה האופציונלית, כי שום דבר במודול הזה לא משתמש בו ישירות. כלל השמירה שומר על המחלקה ועל ה-constructor שלה, ומאפשר לספריית הליבה ליצור ממנה מופע.
השתקפות כדי לגשת לחברים פרטיים
שימוש ברפלקציה כדי לגשת לקוד פרטי או מוגן שלא נכלל ב-API הציבורי של הספרייה עלול לגרום לבעיות חמורות. הקוד הזה כפוף לשינויים ללא הודעה מוקדמת, מה שעלול לגרום להתנהגות לא צפויה או לקריסות באפליקציה.
כשמסתמכים על רפלקציה עבור ממשקי API לא ציבוריים, יכול להיות שתיתקלו בבעיות הבאות:
- עדכונים חסומים: שינויים בקוד הפרטי או המוגן יכולים למנוע מכם לעדכן לגרסאות מתקדמות יותר של הספרייה.
- הטבות שלא תקבלו: יכול להיות שלא תהיה לכם גישה לפונקציות חדשות, לתיקונים חשובים של קריסות או לעדכוני אבטחה חיוניים.
אופטימיזציות של R8 ורפלקציה
אם אתם חייבים לשקף קוד פרטי או מוגן של ספרייה, חשוב לשים לב במיוחד לאופטימיזציות של R8. אם אין הפניות ישירות לאיברים האלה, יכול להיות ש-R8 יניח שהם לא בשימוש, ואז יסיר אותם או ישנה את השם שלהם.
זה יכול לגרום לקריסות בזמן הריצה, לעיתים קרובות עם הודעות שגיאה מטעות כמו NoSuchMethodException או NoSuchFieldException.
לדוגמה, נניח שאתם רוצים לגשת לשדה פרטי ממחלקה של ספרייה.
ספרייה שלא בבעלותכם מכילה את הקוד הבא:
class LibraryClass {
private val secretMessage = "R8 will remove me"
}
הקוד של האפליקציה שלך:
fun accessSecretMessage(instance: LibraryClass) {
// Use Java reflection from Kotlin to access the private field
val secretField = instance::class.java.getDeclaredField("secretMessage")
secretField.isAccessible = true
// This will crash at runtime with R8 enabled
val message = secretField.get(instance) as String
}
מוסיפים כלל -keep באפליקציה כדי למנוע מ-R8 להסיר את השדה הפרטי:
-keepclassmembers class com.example.LibraryClass {
private java.lang.String secretMessage;
}
-
-keepclassmembers: שומרת על חברים ספציפיים במחלקה רק אם המחלקה עצמה נשמרת. -
class com.example.LibraryClass: מטרגט את המחלקה המדויקת שמכילה את השדה. -
private java.lang.String secretMessage;: השדה הפרטי הספציפי, לפי השם והסוג שלו.
Java Native Interface (JNI)
יכולות להיות בעיות באופטימיזציות של R8 כשעובדים עם קריאות חוזרות מקוד מקורי (קוד C/C++) ל-Java או ל-Kotlin. גם ההפך נכון – יכולות להיות בעיות בקריאות מ-Java או מ-Kotlin לקוד Native. קובץ ברירת המחדל proguard-android-optimize.txt כולל את הכלל הבא כדי שהקריאות ימשיכו לפעול. הכלל הזה מונע את ההסרה של שיטות מובנות.
-keepclasseswithmembernames,includedescriptorclasses class * {
native <methods>;
}
אינטראקציה עם קוד Native דרך Java Native Interface (JNI)
כשהאפליקציה משתמשת ב-JNI כדי לבצע קריאות מקוד Native (C/C++) ל-Java או ל-Kotlin, R8 לא יכול לראות אילו שיטות נקראות מקוד ה-Native. אם אין הפניות ישירות לשיטות האלה באפליקציה, R8 מניח בטעות שהשיטות האלה לא בשימוש ומסיר אותן, מה שגורם לקריסה של האפליקציה.
בדוגמה הבאה מוצגת מחלקת Kotlin עם שיטה שמיועדת לקריאה מספריית Native. ספריית ה-Native יוצרת מופע של סוג אפליקציה ומעבירה נתונים מקוד Native לקוד Kotlin.
package com.example.models
// This class is used in the JNI bridge method signature
data class NativeData(val id: Int, val payload: String)
package com.example.app
// In package com.example.app
class JniBridge {
/**
* This method is called from the native side.
* R8 will remove it if it's not kept.
*/
fun onNativeEvent(data: NativeData) {
Log.d(TAG, "Received event from native code: $data")
}
// Use 'external' to declare a native method
external fun startNativeProcess()
companion object {
init {
// Load the native library
System.loadLibrary("my-native-lib")
}
}
}
במקרה כזה, צריך להודיע ל-R8 כדי למנוע אופטימיזציה של סוג האפליקציה. בנוסף, אם הפעלות method שנקראות מקוד Native משתמשות במחלקות שלכם בחתימות שלהן כפרמטרים או כסוגי החזרה, אתם צריכים גם לוודא שלא משנים את השם של המחלקות האלה.
מוסיפים לאפליקציה את כללי השמירה הבאים:
-keepclassmembers,includedescriptorclasses class com.example.JniBridge {
public void onNativeEvent(com.example.model.NativeData);
}
-keep class NativeData{
<init>(java.lang.Integer, java.lang.String);
}
כללי השמירה האלה מונעים מהכלי R8 להסיר או לשנות את השם של השיטה onNativeEvent ואת סוג הפרמטר שלה – וזה חשוב מאוד.
-
-keepclassmembers,includedescriptorclasses class com.example.JniBridge{ public void onNativeEvent(com.example.model.NativeData);}: ההגדרה הזו שומרת איברים ספציפיים של מחלקה רק אם המחלקה נוצרת קודם בקוד Kotlin או Java. היא מודיעה ל-R8 שהאפליקציה משתמשת במחלקה ושהיא צריכה לשמור איברים ספציפיים של המחלקה.-
-keepclassmembers: שומרת חברים ספציפיים במחלקה רק אם המחלקה מופעלת בקוד Kotlin או Java קודם – היא מציינת ל-R8 שהאפליקציה משתמשת במחלקה ושהיא צריכה לשמור חברים ספציפיים במחלקה. -
class com.example.JniBridge: מטרגט את המחלקה המדויקת שמכילה את השדה. -
includedescriptorclasses: גם שינוי הגישה הזה שומר על כל המחלקות שנמצאות בחתימה או בתיאור של השיטה. במקרה הזה, היא מונעת מ-R8 לשנות את השם של המחלקהcom.example.models.NativeDataאו להסיר אותה, כי היא משמשת כפרמטר. אם השם שלNativeDataישתנה (לדוגמה, ל-a.a), החתימה של השיטה לא תתאים יותר למה שקוד ה-Native מצפה לו, וזה יגרום לקריסה. -
public void onNativeEvent(com.example.models.NativeData);: כאן מציינים את החתימה המדויקת של Java של המתודה שרוצים לשמור.
-
-keep class NativeData{<init>(java.lang.Integer, java.lang.String);}: הכללincludedescriptorclassesמבטיח שהמחלקהNativeDataעצמה תישמר, אבל כל איבר (שדה או שיטה) בתוךNativeDataשהגישה אליו מתבצעת ישירות מקוד ה-JNI המקורי צריך כללי שמירה משלו.-
-keep class NativeData: הפקודה הזו מטרגטת את המחלקה שנקראתNativeData, והבלוק מציין אילו איברים בתוך המחלקהNativeDataצריך לשמור. -
<init>(java.lang.Integer, java.lang.String): זו החתימה של ה-constructor. הוא מזהה באופן ייחודי את ה-constructor שמקבל שני פרמטרים: הראשון הואIntegerוהשני הואString.
-
אתם יכולים גם להוסיף הערות לשיטות שקוד Native קורא להן באמצעות ההערה @UnconditionallyKeep מ-Jetpack Keep, במקום לכתוב להן כללי שמירה. ההערה לא שומרת מחלקות בחתימה של השיטה, כמו NativeData, אז עדיין צריך לשמור את המחלקות האלה בנפרד.
שיחות עקיפות לפלטפורמה
העברת נתונים באמצעות הטמעה של Parcelable
מסגרת Android משתמשת ברפלקציה כדי ליצור מופעים של Parcelable
האובייקטים שלכם. בפיתוח מודרני ב-Kotlin, צריך להשתמש בפלאגין kotlin-parcelize, שיוצר באופן אוטומטי את ההטמעה הנדרשת של Parcelable, כולל השדה CREATOR והשיטות שה-framework צריך.
לדוגמה, בדוגמה הבאה הפלאגין kotlin-parcelize משמש ליצירת מחלקה של Parcelable:
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
// Add the @Parcelize annotation to your data class
@Parcelize
data class UserData(
val name: String,
val age: Int
) : Parcelable
במקרה כזה, אין כלל שמירה מומלץ. kotlin-parcelize
תוסף Gradle יוצר באופן אוטומטי את כללי השמירה הנדרשים עבור הכיתות
שמוסיפים להן את ההערה @Parcelize. הוא מטפל במורכבות בשבילכם, ומוודא ש-CREATOR והבנאים שנוצרו נשמרים לקריאות הרפלקציה של Android framework.
אם אתם כותבים מחלקה של Parcelable באופן ידני ב-Kotlin בלי להשתמש ב-@Parcelize, אתם צריכים לשמור את השדה CREATOR ואת ה-constructor שמקבל Parcel. אם לא תעשו את זה, האפליקציה תקרוס כשהמערכת תנסה לבטל את ההמרה של האובייקט לסדרה. השימוש ב-@Parcelize הוא השיטה הסטנדרטית והבטוחה יותר.
כשמשתמשים בפלאגין kotlin-parcelize, חשוב לשים לב לדברים הבאים:
- הפלאגין יוצר באופן אוטומטי שדות
CREATORבמהלך ההידור. - קובץ
proguard-android-optimize.txtמכיל את כלליkeepשנדרשים כדי לשמור את השדות האלה לצורך פעולה תקינה. - מפתחי אפליקציות צריכים לוודא שכל
keepהכללים הנדרשים קיימים, במיוחד אם מדובר בהטמעות בהתאמה אישית או בתלות בצד שלישי.
ספריות פופולריות
ספריות שמשתמשות בטרנספורמציות של רפלקציה או בייטקוד ניגשות לקוד באופן דינמי בזמן הריצה. אם R8 מסיר או משנה את השם של מחלקות, שדות או שיטות שמתבצעת אליהם גישה בדרך הזו, האפליקציה עלולה לקרוס.
עם זאת, ספריות פופולריות של צד שלישי (כמו Gson, Retrofit ו-Kotlinx Serialization) כוללות באופן אוטומטי כללי שמירה משלהן לצרכני R8. כשמשתמשים בגרסאות עדכניות של הספריות האלה, לא צריך להוסיף לפרויקט כללי שמירה ידניים.
Gson
Gson היא ספריית סריאליזציה ודה-סריאליזציה של JSON שמסתמכת במידה רבה על רפלקציה. כשמשתמשים במצב מלא כדי לבצע אופטימיזציה לאפליקציה, המערכת מסירה חתימות גנריות, בנאים שמוגדרים כברירת מחדל ושדות לא מסומנים, אלא אם מציינים במפורש אחרת.
כדי לוודא ש-Gson פועל בצורה תקינה, מוסיפים כללים ספציפיים כדי לשמור על שדות לא זמניים במחלקות של מודל הנתונים ולשמור על ההיררכיה TypeToken:
# Preserve generic type information required for deserialization
-keepattributes Signature
# Keep all non-transient fields in your data model classes for reflection
-keepclassmembers class com.example.models.** {
!transient <fields>;
}
# Keep TypeToken itself and any anonymous classes extending it
-keep,allowobfuscation,allowshrinking,allowoptimization class com.google.gson.reflect.TypeToken { *; }
-keep,allowobfuscation,allowshrinking,allowoptimization class * extends com.google.gson.reflect.TypeToken
Gson מתעלם משדות שמסומנים ב-modifier transient במהלך הסריאליזציה והדה-סריאליזציה, ולכן כלל השמירה מטרגט באופן ספציפי שדות שהם non-transient (!transient).
השבחה
Retrofit היא ספריית רשת שבודקת שיטות של ממשק שירות עם אנוטציות HTTP (כמו @GET או @POST) באמצעות השתקפות כדי ליצור בקשות לאחזור מהרשת ולהמיר תשובות.
Retrofit יוצר באופן דינמי הטמעות של ממשקי ה-API בזמן הריצה באמצעות Proxy.newProxyInstance(). מכיוון ש-R8 לא רואה מחלקה שמטמיעה את הממשקים האלה באופן סטטי, יכול להיות שהוא יסיר את השיטות או את סוגי ההחזרה הגנריים שלהן.
כללי שמירה מקובצים
Retrofit מסתמך על רפלקציה בזמן הריצה כדי לבדוק פרמטרים גנריים, אנוטציות של מתודות ואנוטציות של פרמטרים. בלי הגדרה נכונה, מצב מלא של R8 יכול להסיר לחלוטין חתימות גנריות מסוגי החזרה, מהמשכים של Kotlin ומכיתות תגובה, או אפילו להחליף ערכי ממשק ב-null, כי ממשקי Retrofit נוצרים באופן דינמי באמצעות שרת proxy.
החל מגרסה Retrofit 2.10.0, הספרייה כוללת אוטומטית את כללי השמירה הרשמיים שנדרשים כדי לשמור על ברירות המחדל של ההערות, על הפרמטרים של שיטות השירות ועל המטא-נתונים של המחלקה. מידע נוסף מופיע במאמר כללים שמשמשים ל-Retrofit.
שמירה על סוגי החזרה גנריים
Retrofit בודק את החתימה הגנרית של סוג ההחזרה (לדוגמה, Observable<Data>) כדי לבצע דה-סריאליזציה נכונה של התגובה מהרשת. אם R8 מסיר את החתימה הגנרית, Retrofit יחליף את האובייקט שנוצר ב-null.
כדי למנוע ממצב מלא של R8 להסיר את החתימה הגנרית של סוגי ההחזרה, משתמשים בכלל התנאי הבא:
# Preserve generic type information for Call/Observable return types
-keepattributes Signature
# If an interface has a Retrofit HTTP annotation, keep its return type (class <3>)
-if interface * {
@retrofit2.http.* public *** *(...);
}
-keep,allowoptimization,allowshrinking,allowobfuscation class <3>
צריך לשמור גם את המחלקה של מודל הנתונים שמוחזרת בפועל (לדוגמה, Data ב-Observable<Data>), כי היא תיבנה באופן רפלקטיבי על ידי הממיר (כמו Gson).
שגרות המשך (coroutines)
כשמשתמשים ב-coroutines של Kotlin, קומפיילר Kotlin משנה את הפונקציות suspend על ידי הוספת פרמטר Continuation לחתימת השיטה המהודרת.
כשספריות כמו Retrofit קוראות באופן רפלקטיבי את החתימה הגנרית של פונקציית suspend, הן מסתמכות על הפרמטר Continuation. במצב מלא, המאפיין Signature נשמר רק למחלקות שנשמרות באופן מפורש. מכיוון ש-Continuation הוא פרמטר סינתטי, R8 מסיר את החתימה שלו כברירת מחדל, וכך שובר את ההשתקפות.
כדי למנוע הסרה של חתימות ולהבטיח תאימות בזמן ריצה במצב מלא, צריך לכלול את הכלל הבא:
# Keep the signature attribute globally
-keepattributes Signature
# Explicitly keep the Continuation class so its signature is not stripped
-keep class kotlin.coroutines.Continuation