بدلا من الانضمام
بدأ كل شيء بحقيقة أنني كنت أرغب في معرفة التفاصيل الدقيقة لإعدادات Gradle ، لفهم إمكانياتها في تطوير Android (وبالفعل). لقد بدأت مع دورة الحياة والكتب ، وكتبت تدريجيًا مهام بسيطة ، وحاولت إنشاء أول مكون إضافي من Gradle (في buildSrc ) ثم بدأ.
قرر أن يفعل شيئًا قريبًا من العالم الحقيقي لتطوير Android ، وكتب مكونًا إضافيًا يوزع ملفات الترميز بتنسيق xml ، ويقوم بإنشاء كائن Java عليها مع روابط إلى طرق العرض. ثم انغمس في تحويل بيان التطبيق (هذا مطلوب بالمهمة الحقيقية في مسودة العمل) ، لأنه بعد التحول استغرق حوالي 5K خطوط ، والعمل في IDE مع مثل ملف xml صعب للغاية.
لذلك اكتشفت كيفية إنشاء التعليمات البرمجية والموارد لمشروع أندرويد ، ولكن مع مرور الوقت كنت أرغب في شيء أكثر. كان هناك فكرة أنه سيكون من الرائع تحويل AST (شجرة التركيب المستخلص) إلى وقت ترجمة كما يفعل Groovy خارج الصندوق . هذا metaprogramming يفتح العديد من الاحتمالات ، سيكون هناك خيال.
حتى أن النظرية لم تكن مجرد نظرية ، فقد قررت تعزيز دراسة الموضوع بإنشاء شيء مفيد لتطوير Android. أول ما يتبادر إلى الذهن هو الحفاظ على الدولة عند إعادة تكوين مكونات النظام. وبصورة تقريبية ، فإن حفظ المتغيرات في Bundle بسيط بقدر الإمكان مع الحد الأدنى من الغليان.
من أين تبدأ؟
- أولاً ، تحتاج إلى فهم كيفية الوصول إلى الملفات الضرورية في دورة حياة Gradle في مشروع Android ، والذي سنقوم بتحويله بعد ذلك.
- ثانياً ، عندما نحصل على الملفات اللازمة ، نحتاج إلى فهم كيفية تحويلها بشكل صحيح.
لنبدأ بالترتيب:
الوصول إلى الملفات في وقت الترجمة
نظرًا لأننا سنستلم ملفات في وقت الترجمة ، فنحن بحاجة إلى مكون إضافي Gradle يقوم باعتراض الملفات والتعامل مع التحول. البرنامج المساعد في هذه الحالة هو بسيط قدر الإمكان. لكن أولاً ، سأريك كيف build.gradle
ملف الوحدة النمطية build.gradle
مع المكون الإضافي:
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' }
apply plugin: 'java-gradle-plugin'
أن هذه وحدة نمطية تحتوي على مكون إضافي من grad.apply plugin: 'groovy'
هذا المكون الإضافي ضروري حتى تتمكن من الكتابة على الأخاديد (لا يهم هنا ، يمكنك كتابة ما لا يقل عن Groovy ، على الأقل Java ، على الأقل Kotlin ، كل ما تريد). لقد اعتدت في الأصل على كتابة المكونات الإضافية على الأخاديد ، نظرًا لأن لديها كتابة ديناميكية وأحيانًا يمكن أن تكون مفيدة ، وإذا لم تكن هناك حاجة إليها ، يمكنك ببساطة وضع تعليق توضيحي على @TypeChecked
.implementation gradleApi()
- قم بتوصيل تبعية واجهة برمجة تطبيقات Gradle بحيث يمكن الوصول إلى org.gradle.api.Plugin
، org.gradle.api.Project
، إلخ.- هناك حاجة إلى
'com.android.tools.build:gradle:3.5.0'
و 'com.android.tools.build:gradle-api:3.5.0'
للوصول إلى كيانات المكون الإضافي android. - مكتبة
'com.android.tools.build:gradle-api:3.5.0'
لتحويل الرمز الثنائي ، سنتحدث عنها لاحقًا.
دعنا ننتقل إلى البرنامج المساعد نفسه ، كما قلت ، الأمر بسيط للغاية:
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()) } }
لنبدأ بـ isAndroidApp
و isAndroidLib
، وهنا نتحقق من أن هذا هو مشروع / مكتبة Android ، إذا لم يكن الأمر كذلك ، isAndroidApp
isAndroidLib
استثناء. بعد ذلك ، قم بتسجيل YourTransform
في البرنامج المساعد android من خلال androidExtension
. YourTransform
هو كيان للحصول على مجموعة الملفات الضرورية وتحويلها المحتمل ؛ يجب أن يرث com.android.build.api.transform.Transform
class الفئة المجردة.
دعنا YourTransform
مباشرة إلى YourTransform
، YourTransform
أولاً في الطرق الرئيسية التي تحتاج إلى إعادة تعريف:
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
- هنا تحتاج إلى إرجاع الاسم الذي سيتم استخدامه لمهمة التحويل ، على سبيل المثال ، لتجميع debug ، في هذه الحالة سيتم استدعاء المهمة مثل هذا: transformClassesWithYourTransformForDebug
.getInputTypes
- getInputTypes
إلى الأنواع التي نحن مهتمون بها: الفئات ، الموارد ، أو كلاهما (انظر com.android.build.api.transform.QualifiedContent.DefaultContentType
). إذا حددت CLASSES ثم للتحول ، فسوف نحصل على ملفات فئة فقط ، وفي هذه الحالة تكون موضع اهتمامنا.getScopes
- يشير إلى النطاقات التي سنقوم بتحويلها (راجع com.android.build.api.transform.QualifiedContent.Scope
). النطاقات هي نطاق الملفات. على سبيل المثال ، في حالتي ، إنه PROJECT_ONLY ، مما يعني أننا سنحول فقط تلك الملفات المتعلقة بوحدة المشروع. هنا يمكنك أيضًا تضمين الوحدات الفرعية والمكتبات وما إلى ذلك.isIncremental
- نحن هنا نقول isIncremental
الإضافي android ما إذا كان isIncremental
يدعم التجميع التزايدي: إذا كان هذا صحيحًا ، نحتاج إلى حل جميع الملفات التي تم تغييرها وإضافتها وحذفها بشكل صحيح ، وإذا كانت خاطئة ، فسوف يتم نقل جميع الملفات إلى التحول ، ومع ذلك ، إذا لم تكن هناك تغييرات في المشروع ، ثم لن يتم استدعاء التحول.
لا يزال أبسط والأكثر حلو الطريقة التي يتم بها تحويل ملفات transform(TransformInvocation transformInvocation)
. لسوء الحظ ، لم أتمكن من العثور على شرح طبيعي لكيفية العمل مع هذه الطريقة بشكل صحيح ، لقد وجدت مقالات صينية فقط وأمثلة قليلة دون توضيحات خاصة ، إليك أحد الخيارات.
ما فهمته أثناء دراسة كيفية العمل مع محول:
- يتم ربط جميع المحولات بعملية تجميع السلسلة. وهذا هو ، تكتب المنطق الذي سيكون
تقلص في عملية أنشئت بالفعل. بعد المحول الخاص بك ، سوف يعمل آخر ، الخ - مهم جدًا: حتى إذا لم تكن تخطط لتحويل أي ملف ، على سبيل المثال ، لا ترغب في تغيير ملفات الجرة التي ستصل إليك ، فلا تزال بحاجة إلى نسخها إلى دليل الإخراج الخاص بك دون تغيير. هذا البند يتبع من الأول. إذا لم تقم بنقل الملف على طول السلسلة إلى محول آخر ، فلن يكون الملف موجودًا في النهاية.
النظر في كيف ينبغي أن تبدو طريقة التحويل:
@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) } } }
عند مدخلنا يأتي TransformInvocation
، والذي يحتوي على جميع المعلومات اللازمة لمزيد من التحولات. أولاً ، نقوم بتنظيف الدليل حيث سيتم تسجيل ملفات transformInvocation.outputProvider.deleteAll()
جديدة ، ويتم ذلك ، لأن المحول لا يدعم التجميع الإضافي ويجب عليك حذف الملفات القديمة قبل التحويل.
بعد ذلك ، نذهب إلى جميع المدخلات وفي كل إدخال نذهب إلى الأدلة والملفات جرة. قد تلاحظ أن كل ملفات الجرة يتم نسخها ببساطة للانتقال إلى المحول التالي. علاوة على ذلك ، يجب أن يحدث النسخ في دليل build/intermediates/transforms/YourTransform/...
المحول build/intermediates/transforms/YourTransform/...
يمكن الحصول على الدليل الصحيح باستخدام transformInvocation.outputProvider.getContentLocation
.
ضع في اعتبارك طريقة تقوم بالفعل باستخراج ملفات محددة للتعديل:
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) } } } }
عند المدخل نحصل على الدليل مع الكود المصدري والدليل حيث تريد كتابة الملفات المعدلة. نذهب بشكل متكرر من خلال جميع الدلائل والحصول على ملفات الصف. قبل التحول ، لا يزال هناك فحص صغير يسمح لك بالتخلص من الفصول الإضافية.
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) }
لذلك وصلنا إلى أسلوب transformSingleFile
، والذي يتدفق بالفعل إلى الفقرة الثانية من خطتنا الأصلية
ثانياً ، عندما نحصل على الملفات اللازمة ، نحتاج إلى فهم كيفية تحويلها بشكل صحيح.
للحصول على تحويل أقل ملاءمة لملفات الفئة الناتجة ، توجد عدة مكتبات: javassist ، والتي تتيح لك تعديل كلاً من الكود الثنائي والرمز المصدر (ليس من الضروري الغوص في دراسة الكود الثنائي ) و ASM ، مما يتيح لك تعديل الكود الثنائي فقط وله واجهات برمجة تطبيقات مختلفة.
لقد اخترت نظام ASM ، حيث كان من المثير للاهتمام الغوص في بنية الرمز الفرعي ، بالإضافة إلى ذلك ، يوزع Core API الملفات بناءً على مبدأ محلل SAX ، والذي يضمن الأداء العالي.
قد تختلف طريقة transformSingleFile
اعتمادًا على أداة تعديل الملف المحددة. في حالتي ، يبدو الأمر بسيطًا جدًا:
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() }
نقوم بإنشاء ClassReader
لقراءة ملف ، نقوم بإنشاء ClassWriter
لكتابة ملف جديد. أستخدم ClassWriter.COMPUTE_FRAMES لحساب إطارات المكدس تلقائيًا ، حيث أنني تعاملت أكثر أو أقل مع السكان المحليين و Args_size (مصطلحات الرمز الثانوي) ، لكنني لم أفعل الكثير مع الإطارات حتى الآن. حساب الإطارات تلقائيًا أبطأ قليلاً من القيام بذلك يدويًا.
ثم قم بإنشاء StaterClassVisitor
الخاص بك الذي يرث من ClassVisitor
ويمر classWriter. اتضح أن منطق تعديل الملف الخاص بنا يتم فرضه أعلى ClassWriter القياسي. في مكتبة ASM ، يتم إنشاء جميع كيانات Visitor
بهذه الطريقة. بعد ذلك ، نقوم بتكوين مجموعة من وحدات البايت للملف الجديد وإنشاء الملف.
المزيد من التفاصيل عن طلبي العملي للنظرية التي تم دراستها سوف يذهب.
إنقاذ الدولة في الحزمة باستخدام الشرح
لذا ، أعدت نفسي مهمة التخلص من لوحة تخزين البيانات في حزمة قدر الإمكان عند إعادة إنشاء النشاط. أردت أن أفعل كل شيء مثل هذا:
public class MainActivityJava extends AppCompatActivity { @State private int savedInt = 0;
لكن في الوقت الحالي ، من أجل زيادة الكفاءة ، قمت بهذا (سأخبرك لماذا):
@Stater public class MainActivityJava extends AppCompatActivity { @State(StateType.INT) private int savedInt = 0;
وانها تعمل حقا! بعد التحول ، يبدو رمز MainActivityJava
كما يلي:
@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); }
الفكرة بسيطة للغاية ، دعنا ننتقل إلى التنفيذ.
لا يسمح Core API بالحصول على البنية الكاملة لملف الفصل بأكمله ، بل نحتاج إلى الحصول على جميع البيانات اللازمة بطرق معينة. إذا نظرت إلى StaterClassVisitor
، يمكنك أن ترى أنه في طريقة visit
نحصل على معلومات حول الفصل ، في StaterClassVisitor
نتحقق مما إذا كان قد تم وضع علامة على @Stater
.
ثم يعمل ClassVisitor
بنا عبر جميع حقول الفصل ، visitField
أسلوب visitField
، إذا كانت الفئة بحاجة إلى تحويل ، 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 }
يتحقق @State
التعليقات التوضيحية @State
، ويقوم بدوره بإرجاع StateAnnotationVisitor
في أسلوب 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 }
التي تشكل بالفعل قائمة الحقول اللازمة للحفظ / الاستعادة:
@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) }
لقد اتضح أن بنية زوارنا تشبه الشجرة ، والذين ، نتيجة لذلك ، يشكلون قائمة SaverField
SaverField مع جميع المعلومات التي نحتاجها لإنشاء حالة حفظ.
بعد ذلك ، يبدأ برنامج ClassVisitor
في العمل من خلال الأساليب وتحويل onCreate
و onSaveInstanceState
. إذا لم يتم العثور على طرق ، visitEnd
(تسمى بعد اجتياز الفصل بأكمله) يتم إنشاؤها من الصفر.
أين هو bytecode؟
يبدأ الجزء الأكثر إثارة للاهتمام في فصول OnCreateVisitor
و OnSavedInstanceStateVisitor
. من أجل التعديل الصحيح لل bytecode ، من الضروري على الأقل تمثيل هيكلها قليلاً. جميع أساليب ورموز شفرة ASM تشبه إلى حد كبير التعليمات الفعلية للشفرة ، وهذا يسمح لك بالعمل بنفس المفاهيم.
النظر في مثال لتعديل طريقة onCreate
ومقارنتها مع التعليمات البرمجية التي تم إنشاؤها:
if (savedInstanceState != null) { this.savedInt = savedInstanceState.getInt("com/example/stater/MainActivityJava_savedInt"); }
يرتبط التحقق من حزمة مقابل الصفر بالإرشادات التالية:
Label l1 = new Label() mv.visitVarInsn(Opcodes.ALOAD, 1) mv.visitJumpInsn(Opcodes.IFNULL, l1)
بكلمات بسيطة:
- قم بإنشاء تسمية l1 (مجرد تسمية يمكنك الذهاب إليها).
- نحمل في الذاكرة المتغير المرجعي بالفهرس 1. بما أن الفهرس 0 يتوافق دائمًا مع الإشارة إلى هذا ، في هذه الحالة 1 هو الإشارة إلى
Bundle
في الوسيطة. - الصفر تحقق نفسها وبيان الانتقال إلى التسمية l1.
visitLabel(l1)
تحديد visitLabel(l1)
بعد العمل مع الحزمة.
عند العمل مع الحزمة ، نذهب إلى قائمة الحقول التي تم إنشاؤها وندعو تعليمات PUTFIELD
- تعيين إلى متغير. لنلقِ نظرة على الكود:
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 )
MethodDescriptorUtils.primitiveIsObject
- هنا نتحقق من أن المتغير لديه نوع مجمّع ، إذا كان الأمر كذلك ، فاعتبر نوع المتغير Serializable
. ثم يتم استدعاء getter من الحزمة ، مسبوكة إذا لزم الأمر وتعيين لمتغير.
هذا كل شيء ، يحدث إنشاء الكود في طريقة onSavedInstanceState
بطريقة مماثلة ، على سبيل المثال .
ما هي المشاكل التي واجهتك؟- العقبة الأولى التي
@Stater
تعليق توضيحي @Stater
. يمكن أن يرث نشاطك / جزء من BaseActivity
، مما يعقد إلى حد كبير فهم ما إذا كنت تريد إنقاذ حالة أم لا. سيتعين عليك الذهاب إلى جميع أولياء أمور هذه الفئة لتكتشف أن هذا نشاط حقًا. يمكن أن يقلل أيضًا من أداء برنامج التحويل البرمجي (في المستقبل هناك فكرة للتخلص من التعليقات التوضيحية @Stater
بشكل أكثر فاعلية). - سبب تحديد
StateType
بشكل صريح هو نفس سبب StateType
الأول. تحتاج إلى مزيد من تحليل الفصل لفهم أنه لا Parcelable
أو Serializable
. لكن الخطط لديها بالفعل أفكار للتخلص من StateType
:).
قليلا عن الأداء
للتحقق ، قمت بإنشاء 10 عمليات تنشيط ، ولكل منها 46 حقلًا مُخزَّنًا من أنواع مختلفة ، ودققت في الأمر ./gradlew :app:clean :app:assembleDebug
. يتراوح الوقت الذي تستغرقه عملية التحويل من 108 إلى 200 مللي ثانية.
نصائح
إذا كنت مهتمًا بالنظر إلى TraceClassVisitor
الناتج ، فيمكنك توصيل TraceClassVisitor
(المقدمة من ASM) بعملية التحويل الخاصة بك:
private static void transformClass(String inputPath, String outputPath) { ... TraceClassVisitor traceClassVisitor = new TraceClassVisitor(classWriter, new PrintWriter(System.out)) StaterClassVisitor adapter = new StaterClassVisitor(traceClassVisitor) ... }
في هذه الحالة ، ستقوم TraceClassVisitor
بأكمله للفئات التي مرت به ، وهي أداة مريحة للغاية في مرحلة تصحيح الأخطاء.
إذا تم تعديل الرمز الثانوي بشكل غير صحيح ، فثمة أخطاء غير مفهومة للغاية ، لذا ، إذا كان ذلك ممكنًا ، فمن المفيد تسجيل مقاطع يحتمل أن تكون خطرة في الكود أو لإنشاء استثناءات.
لتلخيص
يعد تعديل شفرة المصدر أداة قوية. مع ذلك ، يمكنك تنفيذ العديد من الأفكار. Proguard ، عالم ، robolectric وغيرها من الأطر العمل على هذا المبدأ. AOP هو ممكن أيضا على وجه التحديد بفضل تحويل الرمز.
ويتيح معرفة بنية bytecode للمطور إمكانية فهم ما يتم تجميع التعليمات البرمجية التي كتبها في النهاية. وعند التعديل ، ليس من الضروري التفكير في اللغة المكتوبة بالكود ، في Java أو Kotlin ، ولكن لتعديل bytecode مباشرة.
بدا هذا الموضوع مثيرا للاهتمام بالنسبة لي ، وكانت الصعوبات الرئيسية عند تطوير واجهة برمجة تطبيقات Transform من Google ، لأنها لا ترضي مع الوثائق والأمثلة الخاصة. يحتوي ASM ، بخلاف Transform API ، على وثائق ممتازة ، ويحتوي على دليل مفصل للغاية في شكل ملف pdf يحتوي على 150 صفحة. ونظرًا لأن طرق الإطار تشبه إلى حد كبير تعليمات التعليمات البرمجية الحقيقية ، فإن الدليل مفيد بشكل مضاعف.
أفكر في هذا الانغماس في التحول ، الرمز الثنائي ، وهذا لم ينته بعد ، سأستمر في الدراسة ، وربما أكتب شيئًا آخر.
مراجع
جيثب سبيل المثال
ASM
مقالة هبر حول البيتي كود
أكثر قليلا عن bytecode
تحويل API
حسنا ، قراءة الوثائق