Transformasi Kode Android


Alih-alih bergabung


Semuanya dimulai dengan fakta bahwa saya ingin mempelajari seluk-beluk pengaturan Gradle, untuk memahami kemampuannya dalam pengembangan Android (dan memang). Saya mulai dengan siklus hidup dan buku - buku , secara bertahap menulis tugas-tugas sederhana, mencoba membuat plugin Gradle pertama saya (di buildSrc ) dan kemudian mulai.


Memutuskan untuk melakukan sesuatu yang dekat dengan dunia nyata pengembangan Android, ia menulis sebuah plugin yang mem-parsing tata letak file markup xml dan membuat objek Java pada mereka dengan tautan ke tampilan. Kemudian ia terlibat dalam transformasi manifes aplikasi (ini diperlukan oleh tugas nyata pada draft kerja), karena setelah transformasi manifes mengambil sekitar 5k baris, dan bekerja dalam IDE dengan file xml seperti itu cukup sulit.


Jadi saya menemukan cara menghasilkan kode dan sumber daya untuk proyek Android, tetapi seiring waktu saya menginginkan sesuatu yang lebih. Ada gagasan bahwa itu akan keren untuk mengubah AST (Pohon Sintaks Abstrak) menjadi waktu kompilasi seperti yang dilakukan Groovy di luar kotak . Pemrograman seperti itu membuka banyak kemungkinan, akan ada fantasi.


Agar teorinya bukan hanya teori, saya memutuskan untuk memperkuat studi tentang topik tersebut dengan menciptakan sesuatu yang berguna untuk pengembangan Android. Hal pertama yang terlintas dalam pikiran adalah pelestarian negara ketika membuat ulang komponen sistem. Secara kasar, menyimpan variabel dalam Bundle sesederhana mungkin dengan boilerplate minimal.


Di mana untuk memulai?


  1. Pertama, Anda perlu memahami cara mendapatkan akses ke file yang diperlukan dalam siklus hidup Gradle di proyek Android, yang akan kami transformasikan nanti.
  2. Kedua, ketika kita mendapatkan file yang diperlukan, kita perlu memahami cara mengubahnya dengan benar.

Mari kita mulai dengan urutan:


Akses file pada waktu kompilasi


Karena kita akan menerima file pada waktu kompilasi, kita memerlukan plugin Gradle yang akan mencegat file dan menangani transformasi. Plugin dalam hal ini sesederhana mungkin. Tapi pertama-tama, saya akan menunjukkan kepada Anda bagaimana file modul build.gradle dengan plugin terlihat seperti:


 apply plugin: 'java-gradle-plugin' apply plugin: 'groovy' dependencies { implementation gradleApi() implementation 'com.android.tools.build:gradle:3.5.0' implementation 'com.android.tools.build:gradle-api:3.5.0' implementation 'org.ow2.asm:asm:7.1' } 

  1. apply plugin: 'java-gradle-plugin' mengatakan bahwa ini adalah modul dengan plugin grad.
  2. apply plugin: 'groovy' diperlukan plugin ini untuk dapat menulis di grooves (tidak masalah di sini, Anda dapat menulis setidaknya Groovy, setidaknya Java, setidaknya Kotlin, sesuka Anda). Saya awalnya terbiasa menulis plugin pada alur, karena ini memiliki pengetikan dinamis dan kadang-kadang bisa bermanfaat, dan jika tidak diperlukan, Anda cukup meletakkan anotasi @TypeChecked .
  3. implementation gradleApi() - hubungkan ketergantungan Gradle API sehingga ada akses ke org.gradle.api.Plugin , org.gradle.api.Project , dll.
  4. 'com.android.tools.build:gradle:3.5.0' dan 'com.android.tools.build:gradle-api:3.5.0' diperlukan untuk mengakses entitas plugin android.
  5. Pustaka 'com.android.tools.build:gradle-api:3.5.0' untuk mentransformasikan bytecode, kita akan membicarakannya nanti.

Mari beralih ke plugin itu sendiri, seperti yang saya katakan, ini cukup sederhana:


 class YourPlugin implements Plugin<Project> { @Override void apply(@NonNull Project project) { boolean isAndroidApp = project.plugins.findPlugin('com.android.application') != null boolean isAndroidLib = project.plugins.findPlugin('com.android.library') != null if (!isAndroidApp && !isAndroidLib) { throw new GradleException( "'com.android.application' or 'com.android.library' plugin required." ) } BaseExtension androidExtension = project.extensions.findByType(BaseExtension.class) androidExtension.registerTransform(new YourTransform()) } } 

Mari kita mulai dengan isAndroidApp dan isAndroidLib , di sini kita hanya memeriksa bahwa ini adalah proyek / perpustakaan Android, jika tidak, berikan pengecualian. Selanjutnya, daftarkan YourTransform dalam plugin android melalui androidExtension . YourTransform adalah suatu entitas untuk memperoleh set file yang diperlukan dan kemungkinan transformasi mereka, ia harus mewarisi kelas abstrak com.android.build.api.transform.Transform .


Mari kita langsung menuju YourTransform , pertama-tama pertimbangkan metode utama yang perlu didefinisikan ulang:


 class YourTransform extends Transform { @Override String getName() { return YourTransform.simpleName } @Override Set<QualifiedContent.ContentType> getInputTypes() { return TransformManager.CONTENT_CLASS } @Override Set<? super QualifiedContent.Scope> getScopes() { return TransformManager.PROJECT_ONLY } @Override boolean isIncremental() { return false } } 

  • getName - di sini Anda perlu mengembalikan nama yang akan digunakan untuk tugas transformasi, misalnya, untuk perakitan debug, dalam hal ini tugas akan dipanggil seperti ini: transformClassesWithYourTransformForDebug .
  • getInputTypes - menunjukkan jenis yang kami minati: kelas, sumber daya, atau keduanya (lihat com.android.build.api.transform.QualifiedContent.DefaultContentType ). Jika Anda menentukan CLASSES maka untuk transformasi kami hanya akan mendapatkan file kelas, dalam hal ini mereka menarik bagi kami.
  • getScopes - menunjukkan cakupan yang akan kita ubah (lihat com.android.build.api.transform.QualifiedContent.Scope ). Lingkup adalah ruang lingkup file. Misalnya, dalam kasus saya, ini PROJECT_ONLY, yang berarti kami hanya akan mengubah file-file yang terkait dengan modul proyek. Di sini Anda juga dapat menyertakan sub-modul, perpustakaan, dll.
  • isIncremental - di sini kami memberi tahu plug-in android apakah transformasi kami mendukung rakitan tambahan: jika benar, maka kita perlu menyelesaikan semua file yang diubah, ditambahkan dan dihapus dengan benar, dan jika salah, maka semua file akan terbang ke transformasi, namun, jika tidak ada perubahan dalam proyek , maka transformasi tidak akan dipanggil.

Tetap yang paling dasar dan paling manis metode di mana transformasi file transform transform(TransformInvocation transformInvocation) akan berlangsung. Sayangnya, saya tidak dapat menemukan penjelasan normal tentang cara bekerja dengan benar dengan metode ini, saya hanya menemukan artikel berbahasa Mandarin dan beberapa contoh tanpa penjelasan khusus, berikut adalah salah satu opsi.


Apa yang saya pahami saat mempelajari cara bekerja dengan transformator:


  1. Semua transformer terhubung ke proses perakitan rantai. Artinya, Anda menulis logika yang akan terjadi diperas menjadi proses yang sudah mapan. Setelah trafo Anda, yang lain akan bekerja, dll.
  2. SANGAT PENTING: bahkan jika Anda tidak berencana mengubah file apa pun, misalnya, Anda tidak ingin mengubah file jar yang akan sampai kepada Anda, mereka masih perlu disalin ke direktori output Anda tanpa mengubah. Item ini mengikuti dari yang pertama. Jika Anda tidak mentransfer file lebih jauh di sepanjang rantai ke transformator lain, maka pada akhirnya file tidak akan ada.

Pertimbangkan seperti apa bentuk transformasi itu:


 @Override void transform( TransformInvocation transformInvocation ) throws TransformException, InterruptedException, IOException { super.transform(transformInvocation) transformInvocation.outputProvider.deleteAll() transformInvocation.inputs.each { transformInput -> transformInput.directoryInputs.each { directoryInput -> File inputFile = directoryInput.getFile() File destFolder = transformInvocation.outputProvider.getContentLocation( directoryInput.getName(), directoryInput.getContentTypes(), directoryInput.getScopes(), Format.DIRECTORY ) transformDir(inputFile, destFolder) } transformInput.jarInputs.each { jarInput -> File inputFile = jarInput.getFile() File destFolder = transformInvocation.outputProvider.getContentLocation( jarInput.getName(), jarInput.getContentTypes(), jarInput.getScopes(), Format.JAR ) FileUtils.copyFile(inputFile, destFolder) } } } 

Di pintu masuk kami, hadir TransformInvocation , yang berisi semua informasi yang diperlukan untuk transformasi lebih lanjut. Pertama, kita membersihkan direktori tempat file transformInvocation.outputProvider.deleteAll() akan direkam, ini dilakukan, karena transformer tidak mendukung rakitan tambahan dan Anda harus menghapus file lama sebelum transformasi.


Selanjutnya, kita melihat semua input dan di setiap input kita melihat direktori dan file jar. Anda mungkin memperhatikan bahwa semua file jar hanya disalin untuk melangkah lebih jauh ke transformator berikutnya. Selain itu, penyalinan harus terjadi pada direktori build/intermediates/transforms/YourTransform/... Direktori yang benar dapat diperoleh dengan menggunakan transformInvocation.outputProvider.getContentLocation .


Pertimbangkan metode yang sudah mengekstraksi file tertentu untuk modifikasi:


 private static void transformDir(File input, File dest) { if (dest.exists()) { FileUtils.forceDelete(dest) } FileUtils.forceMkdir(dest) String srcDirPath = input.getAbsolutePath() String destDirPath = dest.getAbsolutePath() for (File file : input.listFiles()) { String destFilePath = file.absolutePath.replace(srcDirPath, destDirPath) File destFile = new File(destFilePath) if (file.isDirectory()) { transformDir(file, destFile) } else if (file.isFile()) { if (file.name.endsWith(".class") && !file.name.endsWith("R.class") && !file.name.endsWith("BuildConfig.class") && !file.name.contains("R\$")) { transformSingleFile(file, destFile) } else { FileUtils.copyFile(file, destFile) } } } } 

Di pintu masuk kita mendapatkan direktori dengan kode sumber dan direktori tempat Anda ingin menulis file yang dimodifikasi. Kami secara rekursif menelusuri semua direktori dan mendapatkan file kelas. Sebelum transformasi, masih ada pemeriksaan kecil yang memungkinkan Anda untuk menyingkirkan kelas tambahan.


 if (file.name.endsWith(".class") && !file.name.endsWith("R.class") && !file.name.endsWith("BuildConfig.class") && !file.name.contains("R\$")) { transformSingleFile(file, destFile) } else { FileUtils.copyFile(file, destFile) } 

Jadi kami sampai pada metode transformSingleFile , yang sudah mengalir ke paragraf kedua dari rencana awal kami


Kedua, ketika kita mendapatkan file yang diperlukan, kita perlu memahami cara mengubahnya dengan benar.

Transformasi dengan segala kejayaannya


Untuk transformasi yang kurang nyaman dari file kelas yang dihasilkan, ada beberapa perpustakaan: javassist , yang memungkinkan Anda untuk memodifikasi bytecode dan kode sumber (tidak perlu untuk mempelajari studi bytecode) dan ASM , yang memungkinkan Anda untuk memodifikasi hanya bytecode dan memiliki 2 API yang berbeda.


Saya memilih ASM, karena menarik untuk menyelami struktur bytecode dan, di samping itu, API Inti mem-parsing file berdasarkan prinsip parser SAX, yang memastikan kinerja tinggi.


Metode transformSingleFile dapat bervariasi tergantung pada alat modifikasi file yang dipilih. Dalam kasus saya, tampilannya cukup sederhana:


 private static void transformClass(String inputPath, String outputPath) { FileInputStream is = new FileInputStream(inputPath) ClassReader classReader = new ClassReader(is) ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES) StaterClassVisitor adapter = new StaterClassVisitor(classWriter) classReader.accept(adapter, ClassReader.EXPAND_FRAMES) byte [] newBytes = classWriter.toByteArray() FileOutputStream fos = new FileOutputStream(outputPath) fos.write(newBytes) fos.close() } 

Kami membuat ClassReader untuk membaca file, kami membuat ClassWriter untuk menulis file baru. Saya menggunakan ClassWriter.COMPUTE_FRAMES untuk secara otomatis menghitung frame stack, karena saya kurang lebih telah berurusan dengan Lokal dan Args_size (terminologi bytecode), tetapi saya belum melakukan banyak hal dengan frame. Menghitung frame secara otomatis sedikit lebih lambat daripada melakukannya secara manual.
Kemudian buat StaterClassVisitor Anda yang mewarisi dari ClassVisitor dan melewati classWriter. Ternyata logika modifikasi file kita ditumpangkan di atas ClassWriter standar. Di perpustakaan ASM, semua entitas Visitor dibangun dengan cara ini. Selanjutnya, kami membentuk array byte untuk file baru dan menghasilkan file.


Rincian lebih lanjut dari aplikasi praktis saya dari teori yang dipelajari akan masuk.


Menyimpan Status dalam Bundel Menggunakan Anotasi


Jadi, saya mengatur sendiri tugas untuk menyingkirkan boilerplate penyimpanan data dalam bundel sebanyak mungkin saat membuat ulang Activity. Saya ingin melakukan semuanya seperti ini:


 public class MainActivityJava extends AppCompatActivity { @State private int savedInt = 0; 

Tetapi untuk sekarang, untuk memaksimalkan efisiensi, saya melakukan ini (saya akan memberi tahu Anda alasannya nanti):


 @Stater public class MainActivityJava extends AppCompatActivity { @State(StateType.INT) private int savedInt = 0; 

Dan itu benar-benar berfungsi! Setelah transformasi, kode MainActivityJava terlihat seperti ini:


 @Stater public class MainActivityJava extends AppCompatActivity { @State(StateType.INT) private int savedInt = 0; protected void onCreate(@Nullable Bundle savedInstanceState) { if (savedInstanceState != null) { this.savedInt = savedInstanceState.getInt("com/example/stater/MainActivityJava_savedInt"); } super.onCreate(savedInstanceState); } protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putInt("com/example/stater/MainActivityJava_savedInt", this.savedInt); super.onSaveInstanceState(outState); } 

Idenya sangat sederhana, mari beralih ke implementasi.
API Inti tidak memungkinkan Anda untuk memiliki struktur penuh dari seluruh file kelas, kami perlu mendapatkan semua data yang diperlukan dalam metode tertentu. Jika Anda melihat StaterClassVisitor , Anda dapat melihat bahwa dalam metode visit kami mendapatkan informasi tentang kelas, di StaterClassVisitor kami memeriksa apakah kelas kami ditandai dengan anotasi @Stater .


Kemudian ClassVisitor kami berjalan melalui semua bidang kelas, memanggil metode visitField , jika kelas perlu diubah, StaterFieldVisitor kami StaterFieldVisitor :


 @Override FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { FieldVisitor fv = super.visitField(access, name, descriptor, signature, value) if (needTransform) { return new StaterFieldVisitor(fv, name, descriptor, owner) } return fv } 

StaterFieldVisitor memeriksa anotasi @State dan, pada gilirannya, mengembalikan StateAnnotationVisitor dalam metode visitAnnotation :


 @Override AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { AnnotationVisitor av = super.visitAnnotation(descriptor, visible) if (descriptor == Descriptors.STATE) { return new StateAnnotationVisitor(av, this.name, this.descriptor, this.owner) } return av } 

Yang sudah membentuk daftar bidang yang diperlukan untuk menyimpan / memulihkan:


 @Override void visitEnum(String name, String descriptor, String value) { String typeString = (String) value SaverField field = new SaverField(this.name, this.descriptor, this.owner, StateType.valueOf(typeString)) Const.stateFields.add(field) super.visitEnum(name, descriptor, value) } 

Ternyata struktur seperti pohon dari pengunjung kami, yang, sebagai akibatnya, membentuk daftar SaverField SaverField dengan semua informasi yang kami butuhkan untuk menghasilkan kondisi penyimpanan.
Selanjutnya, ClassVisitor kami mulai dijalankan melalui metode dan mentransformasikan onCreate dan onSaveInstanceState . Jika tidak ada metode yang ditemukan, maka di visitEnd (dipanggil setelah melewati seluruh kelas) mereka dihasilkan dari awal.


Dimana bytecode-nya?


Bagian yang paling menarik dimulai di kelas OnCreateVisitor dan OnSavedInstanceStateVisitor . Untuk modifikasi bytecode yang benar, perlu setidaknya mewakili sedikit strukturnya. Semua metode dan opcode ASM sangat mirip dengan instruksi sebenarnya dari batcode, ini memungkinkan Anda untuk beroperasi dengan konsep yang sama.
Pertimbangkan contoh memodifikasi metode onCreate dan membandingkannya dengan kode yang dihasilkan:


 if (savedInstanceState != null) { this.savedInt = savedInstanceState.getInt("com/example/stater/MainActivityJava_savedInt"); } 

Memeriksa bundel untuk nol terkait dengan instruksi berikut:


 Label l1 = new Label() mv.visitVarInsn(Opcodes.ALOAD, 1) mv.visitJumpInsn(Opcodes.IFNULL, l1) //...      mv.visitLabel(l1) 

Dengan kata sederhana:


  1. Buat label l1 (hanya label yang bisa Anda tuju).
  2. Kami memuat ke dalam memori variabel referensi dengan indeks 1. Karena indeks 0 selalu sesuai dengan referensi ini, dalam hal ini 1 adalah referensi ke Bundle dalam argumen.
  3. Tanda nol memeriksa dirinya sendiri dan pernyataan goto pada label l1. visitLabel(l1) ditentukan setelah bekerja dengan bundel.

Saat bekerja dengan bundel, kami memeriksa daftar bidang yang dihasilkan dan memanggil instruksi PUTFIELD - penugasan ke variabel. Mari kita lihat kodenya:


 mv.visitVarInsn(Opcodes.ALOAD, 0) mv.visitVarInsn(Opcodes.ALOAD, 1) mv.visitLdcInsn(field.key) final StateType type = MethodDescriptorUtils.primitiveIsObject(field.descriptor) ? StateType.SERIALIZABLE : field.type MethodDescriptor methodDescriptor = MethodDescriptorUtils.getDescriptorByType(type, true) if (methodDescriptor == null || !methodDescriptor.isValid()) { throw new IllegalStateException("StateType for ${field.name} in ${field.owner} is unknown!") } mv.visitMethodInsn( Opcodes.INVOKEVIRTUAL, Types.BUNDLE, methodDescriptor.method, "(${Descriptors.STRING})${methodDescriptor.descriptor}", false ) // cast if (type == StateType.SERIALIZABLE || type == StateType.PARCELABLE || type == StateType.PARCELABLE_ARRAY || type == StateType.IBINDER ) { mv.visitTypeInsn(Opcodes.CHECKCAST, Type.getType(field.descriptor).internalName) } mv.visitFieldInsn(Opcodes.PUTFIELD, field.owner, field.name, field.descriptor) 

MethodDescriptorUtils.primitiveIsObject - di sini kami memeriksa bahwa variabel memiliki tipe wrapper, jika demikian, pertimbangkan tipe variabel sebagai Serializable . Kemudian pengambil dari bundel dipanggil, dicor jika perlu dan ditugaskan ke variabel.


Itu saja, pembuatan kode dalam metode onSavedInstanceState terjadi dengan cara yang sama, misalnya .


Masalah apa yang Anda temui
  1. @Stater pertama yang @Stater anotasi @Stater ditambahkan. Aktivitas / fragmen Anda dapat diwarisi dari beberapa BaseActivity , yang sangat menyulitkan pemahaman apakah akan menyelamatkan negara atau tidak. Anda harus memeriksa semua orang tua di kelas ini untuk mengetahui bahwa ini benar-benar sebuah Aktivitas. Itu juga dapat mengurangi kinerja kompiler (di masa depan ada ide untuk menghilangkan penjelasan @Stater paling efektif).
  2. Alasan untuk menetapkan StateType secara eksplisit sama dengan alasan untuk halangan pertama. Anda perlu mem-parsing kelas lebih lanjut untuk memahami bahwa itu Parcelable atau Serializable . Tapi rencana sudah punya ide untuk menyingkirkan StateType :).

Sedikit tentang kinerja


Untuk verifikasi, saya membuat 10 aktivasi, masing-masing dengan 46 bidang tersimpan dari jenis yang berbeda, diperiksa pada perintah ./gradlew :app:clean :app:assembleDebug . Waktu yang dibutuhkan oleh transformasi saya berkisar dari 108 hingga 200 ms.


Kiat


  • Jika Anda tertarik melihat bytecode yang dihasilkan, Anda dapat menghubungkan TraceClassVisitor (disediakan oleh ASM) ke proses transformasi Anda:


     private static void transformClass(String inputPath, String outputPath) { ... TraceClassVisitor traceClassVisitor = new TraceClassVisitor(classWriter, new PrintWriter(System.out)) StaterClassVisitor adapter = new StaterClassVisitor(traceClassVisitor) ... } 

    TraceClassVisitor dalam hal ini akan menulis ke konsol seluruh bytecode dari kelas yang melewatinya, utilitas yang sangat nyaman pada tahap debugging.


  • Jika bytecode dimodifikasi secara tidak benar, kesalahan yang sangat tidak dapat dipahami terbang keluar, jadi jika mungkin ada baiknya untuk mencatat bagian yang berpotensi berbahaya dari kode atau untuk menghasilkan pengecualian Anda.



Untuk meringkas


Modifikasi kode sumber adalah alat yang ampuh. Dengan itu, Anda dapat menerapkan banyak ide. Proguard, ranah, robolectric, dan kerangka kerja lainnya bekerja berdasarkan prinsip ini. AOP juga dimungkinkan berkat transformasi kode.
Dan pengetahuan tentang struktur bytecode memungkinkan pengembang untuk memahami kode apa yang ditulisnya dikompilasi pada akhirnya. Dan ketika memodifikasi tidak perlu berpikir dalam bahasa apa kode tersebut ditulis, di Jawa atau di Kotlin, tetapi untuk memodifikasi bytecode secara langsung.


Topik ini tampak sangat menarik bagi saya, kesulitan utama adalah ketika mengembangkan Transform API dari Google, karena mereka tidak suka dengan dokumentasi dan contoh khusus. ASM, tidak seperti Transform API, memiliki dokumentasi yang sangat baik, memiliki panduan yang sangat rinci dalam bentuk file pdf dengan 150 halaman. Dan, karena metode kerangka kerja sangat mirip dengan instruksi bytecode nyata, panduan ini sangat berguna.


Saya berpikir tentang ini perendaman saya dalam transformasi, bytecode, dan ini belum berakhir, saya akan terus belajar dan, mungkin, menulis sesuatu yang lain.


Referensi


Contoh Github
ASM
Artikel Habr tentang bytecode
Sedikit lagi tentang bytecode
Transform API
Nah, baca dokumentasinya

Source: https://habr.com/ru/post/id469237/


All Articles