ใช้ R8 ในโหมดเต็ม

R8 มี 2 โหมด ได้แก่ โหมดความเข้ากันได้และโหมดเต็ม โหมดเต็มจะช่วยให้คุณเพิ่มประสิทธิภาพได้อย่างมีประสิทธิภาพ ซึ่งจะช่วยปรับปรุงประสิทธิภาพของแอป

คู่มือนี้มีไว้สำหรับนักพัฒนาแอป 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

หากคุณใช้ 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 คุณต้องเพิ่มกฎการเก็บรักษาที่ชัดเจนสำหรับโมเดลเหล่านั้น

เก็บตัวสร้างเริ่มต้นไว้

ในโหมดเต็มของ R8 ระบบจะไม่เก็บตัวสร้างที่ไม่มีอาร์กิวเมนต์/ตัวสร้างเริ่มต้นไว้โดยนัย แม้ว่าจะเก็บคลาสไว้ก็ตาม หากคุณสร้างอินสแตนซ์ของคลาสโดยใช้ class.getDeclaredConstructor().newInstance() หรือ class.newInstance() คุณต้องเก็บตัวสร้างที่ไม่มีอาร์กิวเมนต์ไว้ในโหมดเต็มอย่างชัดเจน ในทางตรงกันข้าม โหมดความเข้ากันได้จะเก็บตัวสร้างที่ไม่มีอาร์กิวเมนต์ไว้เสมอ

ลองพิจารณาตัวอย่างที่เราสร้างอินสแตนซ์ของ 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 จะจัดเก็บข้อมูลเมตาเฉพาะภาษา (เช่น ความเป็น Null, ฟังก์ชันส่วนขยาย และลายเซ็นของโครูทีน) ไว้ในคำอธิบายประกอบ @kotlin.Metadata ในไฟล์คลาสแต่ละไฟล์

หากแอปหรือทรัพยากร Dependency ของแอปใช้การสะท้อน Kotlin (kotlin.reflect) ไลบรารีการสะท้อนจะแยกวิเคราะห์ข้อมูลเมตานี้ในรันไทม์เพื่อตรวจสอบโครงสร้างคลาส ในโหมดเต็มของ R8 ระบบจะนำคำอธิบายประกอบออกโดยค่าเริ่มต้นหากไม่ได้เก็บคำอธิบายประกอบไว้อย่างชัดเจน นอกจากนี้ หาก R8 ลดขนาดหรือย่อคลาสโดยไม่ได้เก็บและอัปเดตข้อมูลเมตา การสะท้อน Kotlin จะล้มเหลวในรันไทม์ ซึ่งจะทำให้เกิดลักษณะการทำงานที่ไม่คาดคิดหรือข้อขัดข้อง (เช่น KotlinReflectionInternalError)

คุณต้องเก็บคำอธิบายประกอบที่มองเห็นได้ในรันไทม์และเก็บคลาส kotlin.Metadata ไว้โดยชัดเจนเพื่อป้องกันลักษณะการทำงานที่ไม่คาดคิดและให้แน่ใจว่าการสะท้อน Kotlin จะทำงานอย่างถูกต้องหลังจากการลดขนาด

# Preserve runtime-visible annotations required for inspecting metadata
-keepattributes RuntimeVisibleAnnotations

# Keep Kotlin metadata to ensure kotlin.reflect functions correctly
-keep class kotlin.Metadata { *; }