Android 自定义插件-- 彻底解决method not found 问题

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<QualifiedContent.ContentType> {
        return TransformManager.CONTENT_CLASS
    }

    override fun isIncremental(): Boolean {
        return false
    }

    override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
        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<String>()
        //处理全部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 的信息 打印出来 就再也不怕 这种类型的异常啦。

作者:vivo祁同伟
链接:https://juejin.im/post/6875677659096219655

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,829评论 1 331
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,603评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,846评论 0 226
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,600评论 0 191
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,780评论 3 272
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,695评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,136评论 2 293
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,862评论 0 182
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,453评论 0 229
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,942评论 2 233
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,347评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,790评论 2 236
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,293评论 3 221
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,839评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,448评论 0 181
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,564评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,623评论 2 249