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

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

כדי למנוע את ההסרה של השדה הפרטי על ידי R8, מוסיפים כלל -keep באפליקציה:

-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 לקוד מקורי – אבל קובץ ברירת המחדל proguard-android-optimize.txt כולל את הכלל הבא כדי שהקריאות מלמטה יפעלו. הכלל הזה מונע את ההסרה של שיטות מובנות.

-keepclasseswithmembernames,includedescriptorclasses class * {
  native <methods>;
}

אינטראקציה עם קוד Native באמצעות Java Native Interface ‏ (JNI)

כשמשתמשים ב-JNI באפליקציה כדי לבצע קריאות מ-native (C/C++) code ל-Java או ל-Kotlin, ‏ R8 לא יכול לראות אילו שיטות נקראות מהקוד המקורי. אם אין הפניות ישירות לשיטות האלה באפליקציה, R8 מניח בטעות שהשיטות האלה לא בשימוש ומסיר אותן, מה שגורם לקריסה של האפליקציה.

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

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

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