Membuat aturan keep dari anotasi

Jetpack Keep memungkinkan Anda mendeklarasikan penggunaan refleksi dan Java Native Interface (JNI) dengan anotasi langsung di kode sumber Kotlin atau Java, bukan di file aturan penyimpanan terpisah. Plugin Gradle Keep Jetpack membaca anotasi ini pada waktu build dan membuat aturan keep yang dibutuhkan R8.

Jetpack Keep membantu pengoptimalan R8 tetap efektif seiring perubahan codebase Anda:

  • Aturan tetap berada di samping kode yang memerlukannya. Saat mengganti nama atau menghapus kode yang menggunakan refleksi, Anda mengupdate anotasi di tempat yang sama. Tidak ada file aturan terpisah yang harus disinkronkan.

  • Aturan yang dihasilkan bersifat bersyarat. R8 mempertahankan target panggilan reflektif hanya jika R8 juga mempertahankan kode yang melakukan panggilan. Jika kode panggilan tidak digunakan dan R8 menghapusnya (sering terjadi pada fitur library opsional), R8 juga dapat menghapus target, sehingga aturan dari fitur yang tidak digunakan tidak akan menambah beban.

  • Lint menemukan refleksi untuk Anda. Library ini mencakup pemeriksaan lint yang menandai panggilan reflektif yang tidak diberi anotasi, dan yang dapat menambahkan banyak anotasi secara otomatis.

Cara kerja Jetpack Keep

Pertimbangkan fungsi yang membuat instance class berdasarkan nama. Jika R8 tidak dapat mengetahui bahwa kode Anda menggunakan class, R8 akan menghapus konstruktor class, dan aplikasi akan error saat runtime. Untuk mencegah error, Anda dapat menambahkan aturan seperti berikut ke file aturan keep:

# Unconditional: R8 keeps this constructor even if it removes the reflection that invokes it.
-keep class com.example.ResourceLocatorImpl {
    <init>();
}

Dengan Jetpack Keep, Anda menganotasi fungsi yang melakukan refleksi:

// ResourceLocatorImpl isn't on this module's compile classpath, so both the
// annotation and the code refer to it by name.
@UsesReflectionToConstruct(
    className = "com.example.ResourceLocatorImpl",
    // Omitting parameterTypes keeps every constructor. An empty array keeps
    // only the no-argument constructor, which the code calls.
    parameterTypes = [],
)
fun loadResourceLocator(): ResourceLocator {
    val implClass = Class.forName("com.example.ResourceLocatorImpl")
    return implClass.getDeclaredConstructor().newInstance() as ResourceLocator
}

Pada waktu build, plugin Gradle menghasilkan aturan penyimpanan untuk konstruktor. Tidak seperti aturan yang ditulis secara manual sebelumnya, aturan yang dihasilkan bersifat kondisional: R8 mempertahankan konstruktor hanya jika loadResourceLocator tetap ada di aplikasi yang dioptimalkan.

Persyaratan

Untuk menggunakan Jetpack Keep, project Anda memerlukan Plugin Android Gradle (AGP) 8.7 atau yang lebih tinggi.

Plugin Gradle mendukung jenis modul berikut:

Jenis modul Plugin yang diterapkan modul Penyiapan tambahan
Aplikasi Android com.android.application Tidak ada
Library Android com.android.library Tidak ada
Library Multiplatform Kotlin dengan target Android com.android.kotlin.multiplatform.library Tidak ada
Library JVM (Java atau Kotlin) java-library atau org.jetbrains.kotlin.jvm Mendaftarkan transformasi JAR

Menyiapkan Jetpack Keep

Untuk menggunakan Jetpack Keep, tambahkan library dan plugin Gradle ke setiap modul yang menggunakan refleksi atau JNI. Library menyediakan anotasi dan pemeriksaan lint, dan plugin mengubah anotasi menjadi aturan penyimpanan.

  1. Dalam file libs.versions.toml, tambahkan library dan plugin. Library dan plugin memiliki versi yang sama:

    [versions]
    # The library and the plugin ship together. Use one version for both so
    # that they stay in sync when you upgrade.
    annotationKeep = "1.0.0-alpha01"
    
    [libraries]
    androidx-annotation-keep = { group = "androidx.annotation.keep", name = "keep", version.ref = "annotationKeep" }
    
    [plugins]
    androidx-annotation-keep = { id = "androidx.annotation.keep", version.ref = "annotationKeep" }
    

    Gradle mendownload plugin dari repositori Maven Google, jadi pastikan repositori pluginManagement di file settings.gradle.kts Anda mencakup google().

  2. Dalam file build.gradle.kts root, deklarasikan plugin bersama AGP tanpa menerapkannya:

    plugins {
        alias(libs.plugins.android.application) apply false
        // Other plugins.
    
        // The plugin relies on AGP classes that it doesn't bundle, so declare
        // it next to AGP. Declaring it here also gives every module the same
        // plugin version.
        alias(libs.plugins.androidx.annotation.keep) apply false
    }
    
  3. Dalam file build.gradle.kts setiap modul yang menggunakan refleksi atau JNI, terapkan plugin dan tambahkan dependensi library:

    plugins {
        alias(libs.plugins.android.application)
        // The plugin reads annotations only from this module's own code, so
        // apply it in every module that uses Jetpack Keep.
        alias(libs.plugins.androidx.annotation.keep)
    }
    
    dependencies {
        implementation(libs.androidx.annotation.keep)
    }
    
  4. Sinkronkan project Anda dengan file Gradle.

Untuk setiap varian build modul aplikasi Android, plugin mendaftarkan tugas <variant>ExtractKeepRules, seperti releaseExtractKeepRules, dan meneruskan aturan yang dihasilkan ke R8 untuk varian tersebut. Untuk modul library, plugin memaketkan aturan yang dihasilkan sebagai aturan keep konsumen dalam output library. Modul library JVM memerlukan penyiapan tambahan.

Menemukan refleksi dengan lint

Jetpack Keep mencakup pemeriksaan lint berikut:

ReflectionWithoutKeepAnnotations
Menandai panggilan reflektif, seperti Class.forName, getDeclaredConstructor, getDeclaredMethod, dan getDeclaredField, yang tidak memiliki anotasi keep yang cocok.
MissingKeepAnnotationPlugin
Menandai modul yang bergantung pada androidx.annotation.keep:keep, tetapi tidak menerapkan plugin Gradle androidx.annotation.keep. Tanpa plugin, build Anda tidak akan membuat aturan penyimpanan dari anotasi.

Untuk menggunakan lint guna menganotasi modul Android, ikuti langkah-langkah berikut:

  1. Jalankan lint pada modul untuk menemukan panggilan reflektif yang memerlukan anotasi:

    ./gradlew :app:lint
    
  2. Menerapkan perbaikan yang dapat dilakukan lint secara otomatis:

    ./gradlew :app:lintFix
    

    Lint dapat menambahkan anotasi secara otomatis saat dapat menentukan class dan anggota yang direfleksikan oleh kode.

  3. Tinjau setiap anotasi yang ditambahkan untuk mengonfirmasi bahwa anotasi tersebut cocok dengan fungsi kode Anda. Misalnya, pastikan jenis parameter cocok dengan konstruktor atau metode yang dipanggil kode Anda.

  4. Selesaikan temuan yang tersisa di Android Studio. Jika lint tidak dapat menentukan class target, perbaikan cepat akan menyisipkan anotasi dengan placeholder TODO untuk Anda selesaikan. Anda juga dapat menambahkan anotasi secara manual.

    Misalnya, fungsi berikut menerima nama class sebagai parameter, sehingga lint tidak dapat menentukan class mana yang dibuatnya. Perbaikan cepat menambahkan anotasi dengan placeholder:

    @UsesReflectionToConstruct(
        className = TODO(),
        parameterTypes = []
    )
    fun createInstance(className: String): Any =
        Class.forName(className).getDeclaredConstructor().newInstance()
    

    Ganti placeholder dengan class yang dibuat oleh kode Anda. Jika class tersedia pada waktu kompilasi, gunakan classConstant, bukan className. Di Kotlin, kode Anda tidak akan dikompilasi hingga Anda mengganti TODO().

  5. Periksa aturan keep yang dihasilkan plugin dari anotasi Anda sebelum menghapus aturan yang ada. Untuk modul aplikasi, jalankan tugas ekstraksi untuk varian rilis:

    ./gradlew :app:releaseExtractKeepRules
    

    Plugin akan menulis aturan ke app/build/generated/release/androidx.annotation.keep.rules.pro. Untuk modul pustaka, lihat Memverifikasi aturan yang dihasilkan.

  6. Hapus aturan penyimpanan yang digantikan oleh anotasi baru dari file aturan penyimpanan Anda. Aturan juga dapat mencakup refleksi yang tidak ditandai oleh lint, seperti refleksi di modul lain atau di library, jadi periksa setiap aturan sebelum Anda menghapusnya:

    • Untuk aturan seluruh paket, seperti -keep class com.example.** { *; }, pastikan anotasi atau aturan lain mencakup semua refleksi pada class paket.
    • Untuk aturan yang mempertahankan kelas atau anggota tertentu, pastikan aturan tersebut cocok dengan salah satu aturan yang dihasilkan, atau tidak ada refleksi lain yang bergantung padanya.
  7. Uji build rilis Anda.

Memberi anotasi pada kode Anda

Tambahkan anotasi Jetpack Keep ke kode yang melakukan refleksi: fungsi, konstruktor, kolom, atau class yang berisi panggilan reflektif. Anotasi menjelaskan target refleksi, dan aturan yang dihasilkan hanya mempertahankan target tersebut jika R8 mempertahankan kode yang dianotasi.

Pilih anotasi berdasarkan fungsi kode Anda:

Annotation Gunakan saat kode Anda
@UsesReflectionToConstruct Memanggil konstruktor melalui refleksi, misalnya dengan getDeclaredConstructor().newInstance().
@UsesReflectionToAccessMethod Memanggil metode melalui refleksi, misalnya dengan getMethod atau getDeclaredMethod.
@UsesReflectionToAccessField Membaca atau menulis kolom melalui refleksi, misalnya dengan getField atau getDeclaredField.
@UnconditionallyKeep Dipanggil dari luar kode Kotlin atau Java aplikasi Anda, misalnya dari kode native melalui JNI. Gunakan anotasi ini hanya jika Anda tidak dapat menggunakan anotasi @UsesReflection*, yang menghasilkan aturan bersyarat.

Semua anotasi @UsesReflection* mengikuti konvensi berikut:

  • Identifikasi class target dengan classConstant atau className. Jika class tersedia pada waktu kompilasi, gunakan classConstant. Compiler memeriksa referensi, dan pemfaktoran ulang IDE seperti penggantian nama akan memperbaruinya. Gunakan className hanya jika class tidak tersedia pada waktu kompilasi, seperti class dalam dependensi opsional. Kedua parameter ini saling terpisah.
  • Menargetkan kelas yang paling spesifik. Aturan yang dihasilkan akan mempertahankan anggota yang cocok di class yang Anda tentukan dan di subclass atau implementasi antarmukanya. Semakin umum class, semakin banyak kode yang dipertahankan R8.
  • Tentukan jenis parameter untuk mempertahankan satu overload. Jika Anda tidak menetapkan parameterTypes atau parameterTypeNames, aturan akan mempertahankan semua konstruktor atau semua overload metode. Untuk hanya mempertahankan konstruktor atau metode tanpa argumen, teruskan array kosong.
  • Ulangi anotasi untuk beberapa target. Jika satu fungsi merefleksikan lebih dari satu class atau anggota, tambahkan satu anotasi untuk setiap target.

Membuat objek dengan refleksi

Gunakan @UsesReflectionToConstruct saat kode Anda membuat instance class melalui refleksi. Dalam contoh berikut, task runner membuat instance implementasi antarmuka StartupTask berdasarkan nama. Karena anotasi menentukan antarmuka, aturan yang dihasilkan akan mempertahankan konstruktor tanpa argumen dari setiap penerapan StartupTask, selama R8 mempertahankan execute:

interface StartupTask {
    fun run()
}

object TaskRunner {
    // The task class is known only at runtime, so target the interface that
    // every task implements.
    @UsesReflectionToConstruct(classConstant = StartupTask::class, parameterTypes = [])
    fun execute(className: String) {
        val task = Class.forName(className)
            .getDeclaredConstructor()
            .newInstance() as StartupTask
        task.run()
    }
}

Memanggil metode dengan refleksi

Gunakan @UsesReflectionToAccessMethod saat kode Anda memanggil metode melalui refleksi. Tetapkan methodName ke nama metode. Dalam contoh berikut, kode memanggil metode statis dalam dependensi opsional. Class tidak tersedia pada waktu kompilasi, sehingga anotasi menggunakan className, bukan classConstant:

object CrashReporting {
    @UsesReflectionToAccessMethod(
        className = "com.example.crashlib.CrashReporter",
        methodName = "log",
        // Omitting parameterTypes keeps every log overload. Keep only the one
        // that the code calls.
        parameterTypes = [String::class],
    )
    fun log(message: String) {
        val reporterClass = try {
            Class.forName("com.example.crashlib.CrashReporter")
        } catch (e: ClassNotFoundException) {
            // Crash reporting is optional, so skip it instead of failing.
            return
        }
        reporterClass.getMethod("log", String::class.java).invoke(null, message)
    }
}

Mengakses kolom dengan refleksi

Gunakan @UsesReflectionToAccessField saat kode Anda membaca atau menulis kolom melalui refleksi. Tetapkan fieldName ke nama kolom:

class UserProfileFetcher {
    // User doesn't expose internalId, so reading it requires reflection.
    @UsesReflectionToAccessField(classConstant = User::class, fieldName = "internalId")
    fun readInternalId(user: User): Any? {
        val field = User::class.java.getDeclaredField("internalId")
        field.isAccessible = true
        return field.get(user)
    }
}

Menjaga kode yang dipanggil dari luar aplikasi Anda

Gunakan @UnconditionallyKeep untuk kode yang dipanggil dari luar kode Kotlin atau Java aplikasi Anda, seperti metode yang dipanggil kode native melalui JNI. Anotasikan elemen yang dipanggil, bukan kode yang memanggilnya:

class NativeBridge {
    // Only native code calls this method, through a JNI lookup by name that
    // R8 can't see. Keep the method and its name unconditionally.
    @UnconditionallyKeep
    fun onNativeEvent(message: String) {
        Log.d("NativeBridge", "Received from native code: $message")
    }

    // The default proguard-android-optimize.txt file already keeps native
    // method names, so this method doesn't need an annotation.
    external fun startNativeProcess()
}

Aturan yang dihasilkan akan mempertahankan metode yang dianotasi dan class yang memuatnya, dengan nama aslinya. Aturan ini tidak mempertahankan class lain yang dirujuk oleh tanda tangan metode. Jika kode native bergantung pada class tersebut, beri anotasi juga, atau tambahkan aturan keep untuknya. Untuk mengetahui informasi selengkapnya tentang JNI dan R8, lihat Interaksi dengan kode native melalui JNI.

Secara default, @UnconditionallyKeep mempertahankan nama elemen. Jika pemanggil eksternal tidak bergantung pada nama, tetapkan shouldPreserveName = false agar R8 dapat mengganti nama elemen.

Memverifikasi aturan yang dihasilkan

Bagian ini menjelaskan cara memeriksa aturan yang dihasilkan untuk setiap jenis modul. Untuk modul aplikasi, plugin meneruskan aturan langsung ke R8. Untuk modul library, plugin mengemas aturan sebagai aturan keep konsumen dalam output library. Aplikasi yang bergantung pada library Anda akan menerapkan aturan ini secara otomatis saat menjalankan R8, meskipun aplikasi tersebut tidak menggunakan Jetpack Keep. Untuk informasi selengkapnya tentang aturan keep konsumen, lihat Pengoptimalan untuk penulis library.

Aplikasi Android

Untuk memeriksa aturan keep yang dihasilkan plugin untuk modul aplikasi, jalankan tugas ekstraksi untuk varian build:

./gradlew :app:releaseExtractKeepRules

Plugin menulis aturan untuk setiap varian ke app/build/generated/<variant>/androidx.annotation.keep.rules.pro. Komentar # context: sebelum setiap aturan mengidentifikasi kode yang dianotasi yang menghasilkannya.

Untuk memeriksa pengaruh aturan yang dihasilkan terhadap pengoptimalan bersama dengan aturan keep lainnya, gunakan R8 Configuration Analyzer.

Library Android

Modul library Android tidak memerlukan penyiapan tambahan. Saat Anda mem-build AAR, plugin akan menambahkan aturan yang dihasilkan ke file proguard.txt AAR, setelah aturan keep konsumen yang sudah ada dalam AAR. Plugin mendaftarkan tugas <variant>KeepRulesTransformAar untuk setiap varian build guna melakukannya.

Untuk memverifikasi aturan, build AAR, misalnya dengan ./gradlew :mylibrary:assembleRelease, dan buka proguard.txt di AAR yang dihasilkan. Aturan yang dibuat muncul di bagian dengan header berikut:

#################################################################
#   Rules generated from androidx.annotation.keep annotations   #
#################################################################

Di macOS dan Linux, Anda juga dapat mencetak aturan dari command line:

unzip -p mylibrary/build/intermediates/aar/release/releaseKeepRulesTransformAar/mylibrary-release.aar proguard.txt

Library Multiplatform Kotlin

Modul library Multiplatform Kotlin dengan target Android juga tidak memerlukan penyiapan tambahan. Saat Anda mem-build AAR untuk target Android, plugin akan menambahkan aturan yang dihasilkan ke file proguard.txt AAR dengan cara yang sama seperti untuk library Android. Plugin mendaftarkan tugas androidMainKeepRulesTransformAar untuk melakukannya.

Untuk memverifikasi aturan, jalankan tugas tersebut dan buka proguard.txt di AAR yang dibuatnya. Di macOS dan Linux, Anda dapat mencetak aturan dari command line:

./gradlew :mylibrary:androidMainKeepRulesTransformAar
unzip -p mylibrary/build/intermediates/aar/androidMain/androidMainKeepRulesTransformAar/mylibrary.aar proguard.txt

Library JVM

Sebelum Anda dapat memverifikasi aturan untuk modul library JVM, konfigurasikan modul agar plugin menambahkan aturan yang dihasilkan ke salinan JAR-nya. Kemudian, jalankan tugas transformasi yang Anda daftarkan dan periksa file aturan dalam JAR yang dibuatnya. Misalnya, jika Anda memberi nama tugas keepRulesJar, Anda dapat mencetak aturan di macOS dan Linux dengan perintah berikut:

./gradlew :mylibrary:keepRulesJar
unzip -p mylibrary/build/intermediates/annotation-keep/keepRulesJar/mylibrary-keep-rules.jar \
    "META-INF/com.android.tools/*" "META-INF/proguard/*"

Mengonfigurasi library JVM

Untuk modul library JVM yang menghasilkan JAR, plugin tidak mengubah JAR secara otomatis. Sebagai gantinya, gunakan fungsi annotationKeep.registerJavaArchiveTransform untuk mendaftarkan tugas yang membuat salinan JAR dengan aturan yang dihasilkan, lalu publikasikan JAR tersebut. Tambahkan kode berikut ke file build.gradle.kts modul library:

plugins {
    id("java-library")
    alias(libs.plugins.jetbrains.kotlin.jvm)
    alias(libs.plugins.androidx.annotation.keep)
}

val jarTask = tasks.named<Jar>("jar")

// Apps run R8 on this library's code, so the generated rules must be inside
// the JAR. The plugin handles this automatically only for Android modules.
val keepRulesJarTask = annotationKeep.registerJavaArchiveTransform(
    taskName = "keepRulesJar",
    input = jarTask.flatMap { it.archiveFile },
    fileName = jarTask.flatMap { it.archiveFileName }.map { it.replace(".jar", "-keep-rules.jar") },
)

// Consumers of this library resolve apiElements and runtimeElements, so add
// the JAR that contains the generated rules to them.
configurations.configureEach {
    if (name == "apiElements" || name == "runtimeElements") {
        outgoing.artifact(keepRulesJarTask.flatMap { it.outputJar })
    }
}

dependencies {
    implementation(libs.androidx.annotation.keep)
}

Transformasi menambahkan aturan yang dihasilkan ke lokasi aturan R8 yang ditargetkan JAR, META-INF/com.android.tools/r8/proguard.txt. Jika JAR sudah berisi aturan di META-INF/com.android.tools/ atau META-INF/proguard/, transformasi akan menambahkan aturan yang dihasilkan ke file aturan yang ada. Untuk informasi selengkapnya tentang lokasi ini, lihat Mendukung berbagai versi R8.

Setelah mendaftarkan transformasi, verifikasi aturan dalam JAR.

Contoh: Memuat library add-on opsional

Library sering menggunakan refleksi untuk memuat add-on opsional. Misalnya, SDK analisis mungkin mengirimkan library inti dan add-on video. Add-on bergantung pada library inti, sehingga library inti tidak dapat merujuk ke class add-on secara langsung. Sebagai gantinya, library inti mencari class add-on berdasarkan nama saat runtime dan mengaktifkan analisis video hanya jika menemukan class.

Karena library inti hanya menggunakan class add-on melalui refleksi, R8 dapat menghapus class atau konstruktornya dari build rilis aplikasi, yang membuat pencarian gagal. Kegagalan hanya terjadi pada build rilis aplikasi yang menyertakan add-on, sehingga sulit untuk diketahui dalam pengujian.

Tanpa Jetpack Keep, add-on akan mengirimkan aturan keep konsumen untuk class-nya, seperti yang ditunjukkan dalam Refleksi untuk mendukung dependensi opsional:

# In the add-on's consumer keep rules file.
-keep class com.example.analytics.video.VideoEventTracker {
    <init>();
}

Aturan ini berada di library yang berbeda dari pencarian yang memerlukannya, sehingga keduanya dapat menjadi tidak sinkron. Dengan Jetpack Keep, Anda dapat membuat anotasi pencarian di library inti:

// In the core library.
interface AnalyticsPlugin {
    fun start()
}

object AnalyticsManager {
    // The annotation and the lookup share this constant so that they can't
    // refer to different classes.
    private const val VIDEO_TRACKER_CLASS =
        "com.example.analytics.video.VideoEventTracker"

    @UsesReflectionToConstruct(
        className = VIDEO_TRACKER_CLASS,
        parameterTypes = [],
    )
    fun initialize() {
        val tracker = try {
            Class.forName(VIDEO_TRACKER_CLASS)
                .getDeclaredConstructor()
                .newInstance() as AnalyticsPlugin
        } catch (e: ClassNotFoundException) {
            // Apps that don't play video leave out the add-on, so a missing
            // class isn't an error.
            return
        }
        // This is an ordinary interface call, not a reflective one, so R8
        // keeps start() without an annotation.
        tracker.start()
    }
}

Anotasi mengikuti panduan berikut:

  • Anotasikan kode yang melakukan refleksi. Anotasi ada di initialize, yang memanggil Class.forName, bukan di class add-on.
  • Pilih anotasi berdasarkan fungsi kode. Pencarian membuat instance class, sehingga kode menggunakan @UsesReflectionToConstruct.
  • Gunakan className untuk class yang tidak dapat Anda referensikan. Class add-on tidak ada di classpath kompilasi library inti, sehingga anotasi tidak dapat menggunakan classConstant.
  • Hanya simpan konstruktor yang dipanggil kode. Pencarian memanggil konstruktor tanpa argumen, sehingga parameterTypes adalah array kosong.

Saat Anda mem-build AAR library inti, plugin akan menambahkan aturan yang mempertahankan VideoEventTracker dan konstruktor tanpa argumennya setiap kali R8 mempertahankan initialize. Aplikasi yang menggunakan SDK menerapkan aturan ini secara otomatis, meskipun tidak menggunakan Jetpack Keep, sehingga add-on tidak memerlukan aturannya sendiri. Dibandingkan dengan aturan add-on, pendekatan ini memiliki keuntungan sebagai berikut:

  • Aturan tetap berada di samping pencarian. Library inti memiliki pencarian dan aturan, sehingga Anda dapat mengupdatenya bersama-sama, bukan mengoordinasikan perubahan di dua library.
  • R8 tetap dapat menghapus kode yang tidak digunakan. Jika aplikasi menyertakan add-on, tetapi tidak pernah memanggil initialize, aturan yang dihasilkan tidak akan berlaku, dan R8 dapat menghapus VideoEventTracker. Aturan add-on menyimpannya di setiap aplikasi yang menyertakan add-on.
  • Lint menangkap pencarian yang tidak dianotasi. Jika library inti kemudian menambahkan pencarian reflektif lain tanpa anotasi, lint akan menandainya, sehingga Anda dapat memperbaikinya sebelum merilis library.

Untuk mengonfirmasi hasilnya, pastikan aturan yang dihasilkan muncul di file proguard.txt AAR, seperti yang dijelaskan dalam Library Android. Kemudian, uji build rilis aplikasi yang menyertakan add-on, karena di situlah aturan yang tidak ada akan merusak analisis video.

Referensi lainnya