
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 文件并解析出生成的類。不過,這種方式性能上會比插樁稍遜一籌。