method not found 问题的困扰

现在稍微有一些规模的app的基础结构 可能都是这样的:

项目多了以后 这些业务仓 可能会多大几十个,甚至上百个,大部分情况下 这些业务仓都是以aar的形式集成到最终的app包中, 且这几十个业务仓 都分别属于不同的团队开发, 然后就渐渐演变成下图的样子:

大家都知道 android在打包的时候 如果一个aar 有不同的版本存在,那么默认总是引用版本号最高的版本。 这个时候就会出现一个问题了:

基础库在迭代升级的时候 很可能要对某些方法进行修改,比如修改方法的返回值 ,修改方法的参数,甚至于要删除方法等等,但是如果你碰到上述的场景就要小心了,因为很多业务仓 依赖的还是老版本的基础库,他们运行是正常的,而你的新版本的基础库版本号提高以后删除了某个方法,假设他们又用到了这个方法,那么实际运行的时候 就会报method not found的 错误了。

有人问 那你每次升级基础库的时候强制要求业务仓也跟着升级不就行了?当然是不行的。。。因为很多都是跨部门的业务,没有合理的理由 他们是不愿意 每次都跟着你的升级而升级的。 除非你告诉他们: 兄弟 你某个类的某个方法 我们这版本修改过了,你必须要升级一下,否则crash(method not found)

如何解决这个问题?

其实解决问题的思路 无非就是在编译的时候 拿到这些class的方法的信息,想办法分析分析 有哪些方法里面 某一行调用的某个方法已经不存在了。 显而易见的 我们首先要做的就是 拿到全部的class。 插件中 想拿到全部的class 很简单,主要注册一个transform就可以了,transform 的input 可以给出我们想要的全部class, 注意只要使用了transform 那么有input 就必须要有output,否则你的app 运行起来就会报class not found的错误了。

拿到这些class以后 问题就简单了,我们可以利用javassit 这个工具 来分析我们的class,然后用一个空的ExprEditor来触发一个异常, 只要报了异常就说明在classpool 里面 没有这个类 或者说有这个类 但是没有这个方法。

这里还要注意的就是,构建javassit的classpool的时候 一定要记得把android.jar 也加进去,否则会报很多android的系统方法找不到。 这里不同的project 使用的android sdk 版本都不同。所以我们还需要实现一个小功能 就是动态的获取android.jar的路径。

好了,实现该插件的思路和要点就阐述完毕了 下面直接上代码把

代码实现

动态获取 project下的android.jar的 路径(这里要感谢didi-booster给出的简洁实现 给我省了很多事);

import java.io.File
import java.io.FileNotFoundException
import java.util.Properties
private val HOME = System.getProperty("user.home")
private val CWD = System.getProperty("user.dir")
/**
* 这个类主要用来取 当前工程的 android.jar 的 绝对路径
*
* 因为不一样的人 不一样的操作系统 不一样的 project 他们的 android.jar 路径并不一样
*
* 我们需要拿到这个路径 添加到classPath中 才可以对字节码做相关的操作 否则asm和 javassist 都有可能出问题
*
*
*/
class AndroidSdk {
companion object {
/**
* 输入apiLevel 你就可以得到 你使用的 android.jar 的path了
*
* @param apiLevel
* @return
*/
fun getAndroidJar(apiLevel: Int = findPlatform()): File {
val jar = File(getLocation(), "platforms${File.separator}android-${apiLevel}${File.separator}android.jar")
return jar.takeIf { it.exists() } ?: throw FileNotFoundException(jar.path)
}
fun findPlatform(): Int = File(getLocation(), "platforms").listFiles()?.filter {
it.name.startsWith("android-") && File(it, "android.jar").exists()
}?.map {
it.name.substringAfter("android-")
}?.max()?.toInt() ?: throw RuntimeException("No platform found")
/**
* 找到当前系统的sdk 安装目录,按照下面的顺序去找
* 如果4种方法都找不到 那就只能抛异常了
*
* 1\. ANDROID_HOME environment variable
* 2\. android command in PATH
* 3\. local.properties
* 4\. platform dependent path:
*
* - macosx: ~/Library/Android/sdk
* - linux: ~/Android/sdk
* - windows: ~\AppData\Local\Android\sdk
*/
fun getLocation(): File = System.getenv("ANDROID_HOME")?.takeIf {
it.isNotBlank()
}?.let {
File(it)
}?.takeIf {
it.exists() && it.isDirectory
} ?: System.getenv("PATH").splitToSequence(File.pathSeparator).map {
File(it, "android")
}.find {
it.exists() && it.canExecute()
}?.canonicalFile?.parentFile?.parentFile ?: File(CWD, "local.properties").let { local ->
if (local.exists()) {
val props = Properties();
local.inputStream().use {
props.load(it)
}
props.getProperty("sdk.dir", null)?.let {
File(it)
}?.takeIf {
it.exists() && it.isDirectory
}
} else {
null
}
} ?: when {
OS.isMac() -> File(HOME, "Library${File.separator}Android${File.separator}sdk").takeIf { it.exists() && it.isDirectory }
OS.isLinux() -> File(HOME, "Android${File.separator}sdk").takeIf { it.exists() && it.isDirectory }
OS.isWindows() -> File(HOME, "AppData${File.separator}Local${File.separator}Android${File.separator}sdk").takeIf { it.exists() && it.isDirectory }
else -> null
}
?: throw RuntimeException("`ANDROID_HOME` is not set and `android` command not in your PATH")
}
}

复制代码

看一下最关键的transform怎么写:

import com.android.build.api.transform.Format
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformInvocation
import com.android.build.gradle.internal.pipeline.TransformManager
import javassist.ClassPool
import javassist.expr.ExprEditor
import javassist.expr.MethodCall
import org.gradle.api.Project
import java.io.File
import java.util.zip.ZipFile
class MethodNotFoundTransform(project: Project) : Transform() {
val project = project
override fun getName(): String {
return "MethodNotFoundTransform"
}
override fun getInputTypes(): MutableSet {
return TransformManager.CONTENT_CLASS
}
override fun isIncremental(): Boolean {
return false
}
override fun getScopes(): MutableSet {
return TransformManager.SCOPE_FULL_PROJECT
}
override fun transform(transformInvocation: TransformInvocation) {
val outputProvider = transformInvocation.outputProvider
val classPool = ClassPool()
val androidSdkPath = AndroidSdk.getAndroidJar(ProjectFileRead.getCompileSdkVersion(project)).absolutePath
println("-------------androidSdkPath: $androidSdkPath")
//这里必须要将编译时使用的 android.jar 也加入到path中 否则会出现很多系统方法找不到 从而误报的情况
classPool.appendClassPath(androidSdkPath)
val errorInfoPath = JenkisHelper.getJenkinsFindDir(project) + File.separator + "MethodDetect.txt"
println("将method not found 信息 写入:" + errorInfoPath)
val errorInfoFile = File(errorInfoPath)
var errorInfoMarkString = ""
val destJarList = ArrayList()
//处理全部class的输入
transformInvocation.inputs.forEach { input ->
//处理jar包
input.jarInputs.forEach { jarInput ->
//有输入 就必须要有输出,否则会出错 导致很多class 丢失
val dest = outputProvider.getContentLocation(jarInput.file.absolutePath, jarInput.contentTypes, jarInput.scopes, Format.JAR)
//拷贝的过程 一定不能丢
jarInput.file.copyTo(dest, true)
//将我们拷贝完毕的class Path 也 add 到 classPool中
classPool.appendClassPath(dest.absolutePath)
//每次拷贝一个 都要输出到一个list中 记录位置
destJarList.add(dest.absolutePath)
}
//目录型的其实不需要处理,因为我们的主工程下面仍旧有代码,method not found 的情况 会在编译的时候就报错
//但是这里为了统一,暂时也add进去
input.directoryInputs.forEach {
classPool.appendClassPath(it.file.absolutePath)
val dest = outputProvider.getContentLocation(
it.name,
it.contentTypes,
it.scopes,
Format.DIRECTORY
)
println("name:" + it.name + " dest" + dest)
//这个地方 一定注意是文件夹的拷贝 否则要出错 运行时崩溃 你怕不怕
it.file.copyRecursively(dest, true)
}
}
println("-------------jar包拷贝结束开始分析----")
destJarList.forEach { jar ->
val zipFile = ZipFile(jar)
zipFile.entries().asSequence().filter {
//我们只处理class文件,因为 有些jar包可能携带了其他文件
it.name.endsWith("class")
}.forEach { zipEntry ->
//这里取到的entry 因为都是/ 作为分隔符 而js是用. 作为分隔符 所以这里要转换一下
val t1 = zipEntry.name.replace("/", ".")
// t1取的值是 xxxx.class 我们这里将.class 后缀完全去掉 就可以拿到我们完整的类名了
val t2 = t1.substring(0, t1.lastIndexOf("."))
// 拿到完整的类名以后 就可以从cp中取 每个类了
val t3 = classPool.getCtClass(t2)
// 遍历每个类中的 每个方法
t3.methods.forEach { ctMethod ->
//这里不是每个方法都需要校验的,过滤掉 我们不需要处理的 系统方法,第三方sdk方法 等等 只校验我们自己的业务逻辑代码
if (ctMethod.declaringClass.name.startsWith("com.xiaomi.space") && !ctMethod.declaringClass.name.startsWith("com.xiaomi.analytics")) {
ctMethod.instrument(object : ExprEditor() {
override fun edit(m: MethodCall?) {
super.edit(m)
try {
m?.method?.instrument(ExprEditor())
} catch (e: Exception) {
e.message?.let {
errorInfoMarkString += "${e.message}\n"
errorInfoMarkString += "问题可能发生在类:${ctMethod.declaringClass.name}的 ${ctMethod.name} 方法中\n"
errorInfoMarkString += "---------------------------------------------\n"
//}
}
}
}
})
}
}
errorInfoFile.writeText(errorInfoMarkString)
}
}
println("---------------MethodNotFoundTransform transform end !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
}
}

复制代码

最终将检测结果输出到ci上

这样每次编译项目的时候可以将method not found 的信息 打印出来 就再也不怕 这种类型的异常啦。