תרחישי שימוש ודוגמאות לכללים

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

השתקפות

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

רפלקציה עם מחלקות שנטענו לפי שם

ספריות בדרך כלל טוענות מחלקות באופן דינמי באמצעות שם המחלקה כ-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, עדיין צריך להשתמש בכלל keep כדי להצהיר שהמופע של המחלקה נוצר באופן רפלקטיבי, וכדי להגן על החברים שאליהם מתבצעת גישה רפלקטיבית, כמו בנאי.

לדוגמה, נניח שיש לכם ספרייה ואפליקציה שמשתמשת בספרייה – טוען הספרייה יוצר מופע של ממשק 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>(); }: הכלל הזה מכוון לחברים ספציפיים במחלקות שזוהו בכלל הראשון – במקרה הזה, בנאי.
    • -keepclassmembers class * implements com.example.library.StartupTask: שומר את הגורמים הספציפיים (מתודות, שדות) של המחלקה שמטמיעה את הממשק StartupTask, אבל רק אם המחלקה המוטמעת עצמה נשמרת.
    • { <init>(); }: זהו בורר החברים. ‫<init> הוא השם הפנימי המיוחד של בנאי ב-Java bytecode. החלק הזה מכוון ספציפית לבנאי ברירת המחדל, ללא ארגומנטים.
    • הכלל הזה קריטי כי הקוד שלך קורא ל-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 מהספרייה האופציונלית, כי שום דבר במודול הזה לא משתמש בו ישירות. כלל השמירה שומר על המחלקה ועל הבונה שלה, ומאפשר לספריית הליבה ליצור מופע שלה בהצלחה.

שימוש ברפלקציה כדי לגשת למשתנים פרטיים

שימוש ברפלקציה כדי לגשת לקוד פרטי או מוגן שלא נכלל בממשק 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. ההפך הוא גם נכון – יכולות להיות בעיות בקריאות מלמטה (downcalls) מ-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 כדי למנוע את האופטימיזציה של סוג האפליקציה. בנוסף, אם מתודות שמופעלות מקוד Native משתמשות במחלקות שלכם בחתימות שלהן כפרמטרים או כסוגי החזרה, אתם צריכים גם לוודא שלא משנים את השם של המחלקות האלה.

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

-keepclassmembers,includedescriptorclasses class com.example.JniBridge {
    public void onNativeEvent(com.example.model.NativeData);
}

-keep class NativeData{
        <init>(java.lang.Integer, java.lang.String);
}

כללי השמירה האלה מונעים את ההסרה או השינוי של השם של השיטה 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. הוא מזהה באופן ייחודי את הפונקציה ליצירת אובייקט שמקבלת שני פרמטרים: הראשון הוא Integer והשני הוא String.

שיחות עקיפות בפלטפורמה

העברת נתונים באמצעות הטמעה של Parcelable

ב-Android framework נעשה שימוש ברפלקציה כדי ליצור מופעים של האובייקטים שלכם.Parcelable בפיתוח מודרני של Kotlin, מומלץ להשתמש בפלאגין kotlin-parcelize, שיוצר באופן אוטומטי את ההטמעה הדרושה של Parcelable, כולל השדה CREATOR והשיטות שהמסגרת צריכה.

לדוגמה, בדוגמה הבאה נעשה שימוש בתוסף 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.

אם אתם כותבים מחלקה Parcelable באופן ידני ב-Kotlin בלי להשתמש ב-@Parcelize, אתם אחראים לשמירה של השדה CREATOR ושל בנאי שמקבל Parcel. אם לא עושים את זה, האפליקציה קורסת כשהמערכת מנסה לבצע דה-סריאליזציה של האובייקט. השימוש ב-@Parcelize הוא הסטנדרט, והוא בטוח יותר.

כשמשתמשים בפלאגין kotlin-parcelize, חשוב לשים לב לדברים הבאים:

  • התוסף יוצר באופן אוטומטי שדות CREATOR במהלך הקומפילציה.
  • קובץ proguard-android-optimize.txt מכיל את כללי keep שנדרשים כדי לשמור את השדות האלה לצורך פעולה תקינה.
  • מפתחי אפליקציות צריכים לוודא שכל keepהכללים הנדרשים קיימים, במיוחד אם מדובר בהטמעות בהתאמה אישית או בתלות בצד שלישי.

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

עם זאת, ספריות פופולריות של צד שלישי (כמו Gson,‏ Retrofit ו-Kotlinx Serialization) כוללות באופן אוטומטי כללי שמירה משלהן לשימוש ב-R8. כשמשתמשים בגרסאות עדכניות של הספריות האלה, לא צריך להוסיף כללי שמירה ידניים לפרויקט.

Gson

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

כדי לוודא ש-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 מתעלם משדות שמסומנים בשינוי transient במהלך סריאליזציה ודה-סריאליזציה, ולכן כלל השמירה מכוון באופן ספציפי לשדות לא זמניים (!transient).

התאמה מחדש

‫Retrofit היא ספריית רשת שבודקת שיטות של ממשק שירות עם הערות HTTP (כמו @GET או @POST) באמצעות השתקפות כדי ליצור בקשות רשת ולהמיר תשובות.

‫Retrofit יוצרת באופן דינמי הטמעות של ממשקי ה-API בזמן ריצה באמצעות Proxy.newProxyInstance(). מכיוון ש-R8 לא רואה אף מחלקה שמיישמת את הממשקים האלה באופן סטטי, יכול להיות שהוא יסיר את השיטות או את סוגי ההחזרה הגנריים שלהן.

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

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

החל מגרסה 2.10.0 של Retrofit, הספרייה כוללת אוטומטית את כללי השמירה הרשמיים שנדרשים כדי לשמור על ברירות המחדל של ההערות, על הפרמטרים של שיטות השירות ועל המטא-נתונים של המחלקה. מידע נוסף מופיע במאמר בנושא כללים שמשמשים את 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