使用 APT 開發(fā)組件化框架的若干細節(jié)問題

jeremy-bishop-LZykn1xi4ek-unsplash.jpg

1、APT 開發(fā)組件化框架的細節(jié)問題

在之前的文章 更高級的 Android 啟動任務調(diào)度庫 中,我介紹了開發(fā)支持組件化的啟動任務調(diào)度工具的使用 APT 實現(xiàn)了組件化掃描并發(fā)現(xiàn)注解。但是之前的文章并沒有詳細介紹 APT 如何加載到指定的生成類。這里存在一些細節(jié)問題,我詳細說明下。

1.1 如何避免生成的類沖突

首先,當我們?yōu)槭褂昧俗⒔獾?ISchedulerJob 生成 JobHunter 實現(xiàn)類的時候是將所有的實現(xiàn)類統(tǒng)一放到 me.shouheng.startup.hunter 這個包名下面(JobHunter 的實現(xiàn)類中使用反射的方式裝載所有的 ISchedulerJob 實例)。那么問題來了,多個組件都往這個包下面生成類的時候如何避免類沖突。因為,雖然每個 module 打包成 aar 的時候,在當前的 aar 里,我們無需考慮類沖突問題,但是多個 aar 中如果在同一包下面存在同一個類名的類,那么其他的 aar 中的類會被覆蓋掉(只有一個生效)。

解決上述問題的一個辦法是在 gradle 文件里使用注解標明當前的 module name,然后生成類的時候使用 module name 作為后綴,依此來避免出現(xiàn)類沖突現(xiàn)象,

javaCompileOptions {
    annotationProcessorOptions {
        arguments = [STARTUP_MODULE_NAME: project.getName()]
    }
}

1.2 如何動態(tài)加載 APK 同一包下面所有的類

按照上述配置方式,不同的 module 會向同一個包 me.shouheng.startup.hunter 下面生成不同的類。那么在啟動應用的時候我們?nèi)绾握业皆摪旅娴乃械纳深惸??這里我參考了 ARouter 的方案,即在啟動的時候獲取到當前啟動的 APK,解析 APK 信息,遍歷 dex,搜索指定包名下的類,核心代碼如下,

抽取 APK,獲取 dex 路徑,

public static List<String> getSourcePaths(Context context) throws PackageManager.NameNotFoundException, IOException {
    ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
    File sourceApk = new File(applicationInfo.sourceDir);

    List<String> sourcePaths = new ArrayList<>();
    sourcePaths.add(applicationInfo.sourceDir); //add the default apk path

    //the prefix of extracted file, ie: test.classes
    String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;

//        如果VM已經(jīng)支持了MultiDex,就不要去Secondary Folder加載 Classesx.zip了,那里已經(jīng)么有了
//        通過是否存在sp中的multidex.version是不準確的,因為從低版本升級上來的用戶,是包含這個sp配置的
    if (!isVMMultidexCapable()) {
        //the total dex numbers
        int totalDexNumber = getMultiDexPreferences(context).getInt(KEY_DEX_NUMBER, 1);
        File dexDir = new File(applicationInfo.dataDir, SECONDARY_FOLDER_NAME);

        for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
            //for each dex file, ie: test.classes2.zip, test.classes3.zip...
            String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
            File extractedFile = new File(dexDir, fileName);
            if (extractedFile.isFile()) {
                sourcePaths.add(extractedFile.getAbsolutePath());
                //we ignore the verify zip part
            } else {
                throw new IOException("Missing extracted secondary dex file '" + extractedFile.getPath() + "'");
            }
        }
    }
    return sourcePaths;
}

讀取 dex,獲取報名下面所有的類,

public static Set<String> getFileNameByPackageName(
        Context context, final String packageName, Executor executor
) throws PackageManager.NameNotFoundException, IOException, InterruptedException {
    final Set<String> classNames = new HashSet<>();

    List<String> paths = getSourcePaths(context);
    final CountDownLatch parserCtl = new CountDownLatch(paths.size());

    for (final String path : paths) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                DexFile dexfile = null;

                try {
                    if (path.endsWith(EXTRACTED_SUFFIX)) {
                        //NOT use new DexFile(path), because it will throw "permission error in /data/dalvik-cache"
                        dexfile = DexFile.loadDex(path, path + ".tmp", 0);
                    } else {
                        dexfile = new DexFile(path);
                    }
                                        // 遍歷 dex 中所有的 class
                    Enumeration<String> dexEntries = dexfile.entries();
                    while (dexEntries.hasMoreElements()) {
                        String className = dexEntries.nextElement();
                        if (className.startsWith(packageName)) {
                            classNames.add(className);
                        }
                    }
                } catch (Throwable ignore) {
                    Log.e("AndroidStartup", "Scan map file in dex files made error.", ignore);
                } finally {
                    if (null != dexfile) {
                        try {
                            dexfile.close();
                        } catch (Throwable ignore) {
                        }
                    }

                    parserCtl.countDown();
                }
            }
        });
    }

    parserCtl.await();
    return classNames;
}

2、上述方案的問題和解決方案

2.1 解析 APK 加載類的一個問題

按照上述方案可以解決獲取同一個包名下面的類的問題,但是這會導致其他問題。采用了上面的方案之后,當我啟動 APP 的時候會有明顯的停頓效果。雖然很短暫,但是可以肉眼感受到使用之前和之后的差距。

導致這個問題的原因是,上述動態(tài)解析和加載的過程是需要消耗一定的時間的,很顯然包越大,解析的時間越長,停頓的時間也就越長。這顯然是我們無法接受的。那么有沒有其他的方案可以解決這個問題呢?我能想到的也就兩種解決方案,

解決方案一:使用緩存,第一次解析之后后面再從緩存中加載;

解決方案二:插樁,根本解決問題,問題是插樁實現(xiàn)比較繁瑣,影響編譯;

2.2 解決方案一:使用 SharedPreferences 緩存

這里的思路是,通過解析 APK 讀取到所有的類之后將其存儲到 SharedPreferences. 不過這里根據(jù)是否處于 debug 模式以及 App 的版本信息進行存儲。也就是說,對于 Release 版本,每次存儲的類是跟 App 版本對應的,只有當 App 版本更新的時候才需要再次解析。那么,也就是說,按照 SharedPreferences 緩存的方案,第一次啟動某一個版本的應用的時候需要解析,會有卡頓效果,后面會走緩存。

private fun gatherByPackageScan(context: Context): List<JobHunter> {
    val hunters = mutableListOf<JobHunter>()
    val hunterImplClasses: Set<String>
    if (debuggable || PackageUtils.isNewVersion(context, logger)) {
        // 讀取包名下所有的類
        hunterImplClasses = ClassUtils.getFileNameByPackageName(
            context, "me.shouheng.startup.hunter", executor?:DefaultExecutor.INSTANCE)
        if (hunterImplClasses.isNotEmpty()) {
            // 存儲類名到 SharedPreferences
            context.getSharedPreferences(STARTUP_SP_CACHE_KEY, Context.MODE_PRIVATE)
                .edit().putStringSet(STARTUP_SP_KEY_HUNTERS, hunterImplClasses).apply();
        }
        PackageUtils.updateVersion(context)
    } else {
        hunterImplClasses = HashSet(
            context.getSharedPreferences(STARTUP_SP_CACHE_KEY, Context.MODE_PRIVATE)
                .getStringSet(STARTUP_SP_KEY_HUNTERS, setOf())
        )
    }
    hunterImplClasses.forEach {
        val hunterImplClass = Class.forName(it)
        hunters.add(hunterImplClass.newInstance() as JobHunter)
    }
    return hunters
}

2.3 解決方案二:通過 ASM 插樁,動態(tài)掃描

插樁的思路應該還是比較清晰的,因為在 Gradle 編譯的 tarnsform 階段可以動態(tài)地對 class 進行掃描,所以,我們可以很容易地實現(xiàn)發(fā)現(xiàn)指定包名之下所有的類的目標。找到這些類之后,只需要再通過插樁的方式將其寫入到指定的 class 里,然后再根據(jù)標志位走插樁對應的方法而不是上述解析 APK 的方式即可。

1. 插樁思路

這里的實現(xiàn)思路是,調(diào)用 scanAnnotations()方法的時候首先會走 gatherHunters() 的邏輯,這里有一個標志位 registerByPlugin. 我們插樁的邏輯是在 gatherHunters() 方法中插入 addHunter() 的代碼。addHunter() 方法中會根據(jù)傳入的類名進行反射,獲取 JobHunter 所有實現(xiàn)類的實例。

/** Scan annotations for job by [ISchedulerJob]. */
fun scanAnnotations(context: Context) {
    try {
        if (jobHunters == null) {
            gatherHunters()
            if (registerByPlugin) {
                logger?.i("Gathered hunters by startup-register plugin.");
            } else {
                jobHunters = gatherByPackageScan(context)
            }
        }
        jobHunters?.forEach { jobHunter ->
            val jobs = jobHunter.hunt()
            jobs?.let {
                this.jobs.addAll(it)
            }
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

private fun gatherHunters() {
    registerByPlugin = false
    jobHunters = mutableListOf()
    // addHunter()
}

private fun addHunter(className: String) {
    registerByPlugin = true
    if (!TextUtils.isEmpty(className)) {
        try {
            val clazz = Class.forName(className)
            val obj = clazz.getConstructor().newInstance()
            if (obj is JobHunter) {
                (jobHunters as? MutableList)?.add(obj)
            } else {
                logger?.i("Register failed, class name: $className should implements one " +
                        "of me/shouheng/startup/JobHunter.")
            }
        } catch (e: java.lang.Exception) {
            logger?.e("Register class error: $className", e)
        }
    }
}

2. 插樁實現(xiàn)

首先,定義一個插件并注冊 transform,

class RegisterPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        val hasApp = project.plugins.hasPlugin(AppPlugin::class.java)
        if (hasApp) {
            Logger.make(project)
            Logger.i("Project enable startup-register plugin")
            val android = project.extensions.getByType(AppExtension::class.java)
            val transform = RegisterTransform(project)
            android.registerTransform(transform)
        }
    }
}

然后,在自定義的 transform 中掃描本地目錄和 aar,

class RegisterTransform(private val project: Project): Transform() {

    // ...

    override fun transform(
        context: Context?,
        inputs: MutableCollection<TransformInput>?,
        referencedInputs: MutableCollection<TransformInput>?,
        outputProvider: TransformOutputProvider?,
        isIncremental: Boolean
    ) {
        Logger.i("Start scan register info in jar file.")
        val startTime = System.currentTimeMillis()

        if (!isIncremental) outputProvider?.deleteAll()

        inputs?.forEach { input ->
            scanJarInputs(input.jarInputs, outputProvider)
            scanDirectoryInputs(input.directoryInputs, outputProvider)
        }
        Logger.i("Scan finish, current cost time " + (System.currentTimeMillis() - startTime) + "ms")

        insertImplClasses()
        Logger.i("Generate code finish, current cost time: " + (System.currentTimeMillis() - startTime) + "ms")
    }
}

并使用自定義的 ClassVisitor 獲取所有 JobHunter 實現(xiàn),

class ScanClassVisitor(api: Int, cv: ClassVisitor): ClassVisitor(api, cv) {

    override fun visit(version: Int, access: Int, name: String?,
                       signature: String?, superName: String?, interfaces: Array<String>?) {
        super.visit(version, access, name, signature, superName, interfaces)
        interfaces?.any { it == JON_HUNTER_FULL_PATH }?.let {
            if (it && name != null && !hunterImplClasses.contains(name)) {
                hunterImplClasses.add(name)
            }
        }
    }
}

讀取到所有的類之后再使用自定義的 MethodVisitor 實現(xiàn)上述方法插入和調(diào)用,

class GeneratorMethodVisitor(
    api: Int,
    mv: MethodVisitor,
    private val classes: List<String>
): MethodVisitor(api, mv) {

    override fun visitInsn(opcode: Int) {
        if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)) {
            classes.forEach {
                val name = it.replace("/", ".")
                mv.visitVarInsn(Opcodes.ALOAD, 0)
                mv.visitLdcInsn(name) // Class name
                // generate invoke register method into AndroidStartupBuilder.gatherHunters()
                mv.visitMethodInsn(Opcodes.INVOKESPECIAL, GENERATE_TO_CLASS_NAME,
                    ADD_HUNTER_METHOD_NAME, "(Ljava/lang/String;)V", false)
            }
        }
        super.visitInsn(opcode)
    }

    override fun visitMaxs(maxStack: Int, maxLocals: Int) {
        super.visitMaxs(maxStack+4, maxLocals)
    }
}

上面就完成了插樁的代碼。在使用的時候只需要添加上我們的自定義插件即可實現(xiàn)組建中自定義掃描。

3. ASM 插樁問題排查方法

插樁之后的代碼 R8 編譯報錯的問題:因編譯期間問題,插樁完畢之后,R8 再執(zhí)行插樁后代碼的時候報錯,這里只給出報錯的 class,但是無法確定真正錯誤的代碼的位置。

Caused by: com.android.tools.r8.CompilationFailedException: Compilation failed to complete, position: Lme/shouheng/startup/AndroidStartupBuilder;gatherHunters()V, origin: /Users/wangshouheng/Desktop/repo/github/AndroidStartup/sample/app/build/intermediates/transforms/StartupRegister/debug/83.jar:me/shouheng/startup/AndroidStartupBuilder.class

    ... 36 more
    Suppressed: java.lang.RuntimeException: java.util.concurrent.ExecutionException: com.android.tools.r8.utils.Z: java.lang.ArrayIndexOutOfBoundsException: -1
        at com.android.tools.r8.D8.d(D8.java:143)
        ... 38 more
    Caused by: java.util.concurrent.ExecutionException: com.android.tools.r8.utils.Z: java.lang.ArrayIndexOutOfBoundsException: -1
        at com.google.common.util.concurrent.AbstractFuture.getDoneValue(AbstractFuture.java:552)
        at com.google.common.util.concurrent.AbstractFuture.get(AbstractFuture.java:513)
        at com.google.common.util.concurrent.FluentFuture$TrustedFuture.get(FluentFuture.java:86)
Caused by: java.lang.ArrayIndexOutOfBoundsException: -1

        at com.android.tools.r8.utils.a1.a(SourceFile:11)
        at com.android.tools.r8.utils.a1.a(SourceFile:40)
        at com.android.tools.r8.utils.a1.a(SourceFile:38)
        at com.android.tools.r8.utils.a1.a(SourceFile:37)
        at com.android.tools.r8.ir.conversion.O.a(SourceFile:339)
        at com.android.tools.r8.D8.d(D8.java:36)
        ... 38 more

此時,可以采用下面兩種解決方案,

方案一:使用 JD-GUI 打開報錯的 jar,因為 class 解析錯誤,所以無法獲得完整的堆棧。方案不行!

方案二:解壓 jar,反編譯 class 文件,指令 javap -c -p AndroidStartupBuilder.class 這里的 -c 要求輸出反編譯結(jié)果的參數(shù),-p 是反編譯內(nèi)容包含 private 的字段和方法的參數(shù)。按照上述指令可以輸出反編譯結(jié)果。我們可以通過閱讀反編譯結(jié)果來發(fā)現(xiàn)錯誤的真實的位置。方案可行!

ASM 插件無法閱讀 Kotlin 字節(jié)碼或者 Kotlin 字節(jié)碼編譯失敗問題:Kotlin 代碼可以使用 AS 自帶工具 Tools->Kotlin->Show kotlin Byte Code 查看 Kotlin 文件的字節(jié)碼。對于簡單的 ASM 插樁代碼,還可以直接使用查看 Kotlin 字節(jié)碼的形式,然后手動翻譯成 ASM 的代碼。

3、總結(jié)

使用 APT 實現(xiàn)注解掃描,如果不是組件化的應用場景,并不需要實現(xiàn)上述緩存和插樁的邏輯,比如 ButterKnife,只需要調(diào)用一下對應的方法,完成 APT 相關代碼的自動裝載即可。但是如果是組件化場景,為了實現(xiàn)指定包名下面的類的掃描,走插樁和緩存似乎是必經(jīng)之路。不過準確來說,我們是不需要插樁的,只需要 transform 階段能夠掃描到類,存儲下來,然后應用啟動的時候動態(tài)加載即可。所以,除了插樁之外,我們還有沒有其他的可選的辦法呢?當然有,比如讀取到類之后寫入到 json 文件里面,然后啟動的時候讀取 json 文件并解析出生成的類。不過,這種方式性能上會比插樁稍遜一籌。

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容