分析CC组件化框架源码之gradle插件路由收集 | 七日打卡

1,502 阅读8分钟

前言

在编译时,扫描即将打包到apk中的所有类,将所有组件类收集起来,通过修改字节码的方式生成注册代码到组件管理类中,从而实现编译时自动注册的功能,不用再关心项目中有哪些组件类了。 特点:不需要注解,不会增加新的类;性能高,不需要反射,运行时直接调用组件的构造方法;能扫描到所有类,不会出现遗漏;支持分级按需加载功能的实现。--来源框架作者齐翊

alibaba/ARouter的 gradle 路由收集插件也是这位作者写的,这个两个插件代码大同小异。

让我们从路由查找,路由写入,路由缓存,组件隔离区分析吧。

废话不说,开始分析。

路由查找

了解TransformAPI:Transform API是从Gradle 1.5.0版本之后提供的,它允许第三方在打包Dex文件之前的编译过程中修改java字节码(自定义插件注册的transform会在ProguardTransform和DexTransform之前执行,所以自动注册的类不需要考虑混淆的情况). 了解TransformAPI:Transform API是从Gradle 1.5.0版本之后提供的,它允许第三方在打包Dex文件之前的编译过程中修改java字节码(自定义插件注册的transform会在ProguardTransform和DexTransform之前执行,所以自动注册的类不需要考虑混淆的情况).

来让我们看看路由是从哪里开始收集的;去收集什么,我们从RegisterTransform这个类开始。

收集的就是实现于IComponent这个接口的类。重点关注一下。

public interface IComponent {
    String getName();
    boolean onCall(CC cc);
}

遍历输入文件:

1,遍历jar,input.jarInputs.each,哇。这么简单就能得到我们项目的jar文件了。

2,遍历目录,input.directoryInputs.each 这一部分是class文件。也很简单得到。

我们先跳过扫描缓存部分,下面说缓存的时再分析。

     CodeScanner scanProcessor = new CodeScanner(extension.list, cacheMap)
        // 遍历输入文件
        inputs.each { TransformInput input ->
            // 遍历jar
            input.jarInputs.each { JarInput jarInput ->
              //把发生变动的类,把这个类的接口,从缓存中移除。
                if (jarInput.status != Status.NOTCHANGED && cacheMap) {
                    cacheMap.remove(jarInput.file.absolutePath)
                }
                //扫描jar
                scanJar(jarInput, outputProvider, scanProcessor)
            }
            // 遍历目录
            input.directoryInputs.each { DirectoryInput directoryInput ->
                long dirTime = System.currentTimeMillis()
                def root = scanClass(outputProvider, directoryInput, scanProcessor)
                long scanTime = System.currentTimeMillis()
                println "${PLUGIN_NAME} cost time: ${System.currentTimeMillis() - dirTime}, scan time: ${scanTime - dirTime}. path=${root}"
            }
        }

遍历目录class

先说遍历class吧,扫描class比扫描jar,要少一步jarFile的分析,后面都是一样的。后面是调用scanProcessor.scanClass去扫描class,让我们进去核心地带scanClass遨游一番。

这块可能需要你懂一点ASM,不懂也没事,后面找个中文的ASM api看看就好,不复杂。这里我重点关注一下ScanClassVisitor

    boolean scanClass(InputStream inputStream, String filePath) {
        int api = getAsmApiLevel()
        try {
            ClassReader cr = new MyClassReader(inputStream)
            ClassWriter cw = new ClassWriter(cr, 0)
            //重点关注
            ScanClassVisitor cv = new ScanClassVisitor(api, cw, filePath)
            cr.accept(cv, ClassReader.EXPAND_FRAMES)
            inputStream.close()
            return cv.found
        }
   

ScanClassVisitor

到了核心地带,桃花深处,ScanClassVisitor中的visit方法了,让我拨开接口扫描的奥秘。黎明就在眼前...

这里我们分三步走:

第一步:shouldProcessThisClassForRegister,是否过滤找个类,找个是可以配置的,也是为了减少扫描的时间,比如一些第三方库,什么okhttp,android自带的就没必要扫描了。节省体力,不,是节省时间的开销。

第二步:superClassNames,这是也是可以配置的,就是接口的父类。如你用一个BaseComponent,实现于IComponent,把一些公用的方法写在里面。但这里也不是重点。

 void visit(int version, int access, String name, String signature,
                   String superName, String[] interfaces) {
            super.visit(version, access, name, signature, superName, interfaces)
         //省了抽象类、接口、非public等类无法调用其无参构造方法的代码
            infoList.each { ext ->
                if (shouldProcessThisClassForRegister(ext, name)) {
                    def interfaceName = ext.interfaceName
                    if (superName != 'java/lang/Object' && !ext.superClassNames.isEmpty(){
                        for (int i = 0; i < ext.superClassNames.size(); i++) {
                            if (ext.superClassNames.get(i) == superName) {
                                gotOne(interfaceName, name, ext)
                                return
                            }
                        }
                    }
                    if (interfaceName && interfaces != null) {
                        interfaces.each { itName ->
                        //最核心的重点
                            if (itName == interfaceName) {
                                gotOne(interfaceName, name, ext)
                            }
                        }
                    }
                }
            }
        }

第三步:敲黑板,最核心的重点来了,if (itName == interfaceName) 就这一步,判断类是否有实现IComponent。有就记录起来ext.classList.add(className) ,这里class 是一个set list列表,扫描结束后,需要把这些对象注入到管理类 就是fileContainsInitClass。稍后下面组件接口注入说吧,addToCacheMap添加到缓存,稍微留意一下这里,后面的缓存会说到这里。

到此类的扫描接口就结束了

void gotOne(String interfaceName, String className, RegisterInfo ext) {
    ext.classList.add(className) //需要把对象注入到管理类 就是fileContainsInitClass
    found = true
    //添加到缓存
    addToCacheMap(interfaceName, className, filePath)
}

遍历jar

遍历jar,就是多了一步jarFile的处理,后面还是跟扫描class一样的流程,还是调用scanClass。这里就过了。

   //扫描jar包
   boolean scanJar(File jarFile, File destFile) {
  ....
       def srcFilePath = jarFile.absolutePath
       def file = new JarFile(jarFile)
       Enumeration enumeration = file.entries()
       while (enumeration.hasMoreElements()) {
           JarEntry jarEntry = (JarEntry) enumeration.nextElement()
           String entryName = jarEntry.getName()
           //support包不扫描
           if (entryName.startsWith("android/support"))
               break
           checkInitClass(entryName, destFile, srcFilePath)
           //是否要过滤这个类,这个可配置
           if (shouldProcessClass(entryName)) {
               InputStream inputStream = file.getInputStream(jarEntry)
               //还是调用 scanClass。
               scanClass(inputStream, jarFile.absolutePath)
               inputStream.close()
           }
       }
 ....
       return true
   }

扫描到组件的写入

实现了IComponent接口的组件会通过ASM写入到ComponentManager这个类下面。最终是被初始化添加到ConcurrentHashMap里面,像这样:

class ComponentManager{
    static {
        registerComponent(new DynamicComponentOption());
        //加载类时自动调用初始化:注册所有组件
        //通过插件生成组件注册代码
        //生成的代码如下:
        registerComponent(new ComponentA());
        registerComponent(new ComponentB());
        //初始化到ConcurrentHashMap
        }
  }  

接着我来看看是如何写入的,激动人心的时刻到来了,辛苦了半天。 注意这一句代码, RegistryCodeGenerator.insertInitCodeTo(ext),接着我们继续方法往里面走吧。

        extension.list.each { ext ->
            if (ext.fileContainsInitClass) {
                println('')
                println("insert register code to file:" + ext.fileContainsInitClass.absolutePath)
                if (ext.classList.isEmpty()) {
                    project.logger.error("No class implements found for interface:" + ext.interfaceName)
                } else {
                    ext.classList.each {
                        println(it)
                    }
                    //注入组件
                    RegistryCodeGenerator.insertInitCodeTo(ext)
                }
            } else {
                project.logger.error("The specified register class not found:" + ext.registerClassName)
            }
        }

我们还是挑软骨头啃,看注入class的这一部分,RegistryCodeGenerator generateCodeIntoClassFile,经过层层关卡,我们会来到这个MyMethodVisitor类中的visitInsn。核心注入就在这里了。若未指定则默认为static块,即<clinit>方法),生成的代码是直接调用扫描到的类的无参构造方法。

        @Override
        void visitInsn(int opcode) {
            if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)) {
                extension.classList.each { name ->
                    if (!_static) {
                        //加载this
                        mv.visitVarInsn(Opcodes.ALOAD, 0)
                    }
                    String paramType
                    if (extension.paramType == RegisterInfo.PARAM_TYPE_CLASS){
                        mv.visitLdcInsn(Type.getType("L${name};"))
                        paramType = 'java/lang/Class'
                    } else if (extension.paramType == RegisterInfo.PARAM_TYPE_CLASS_NAME){
                        mv.visitLdcInsn(name.replaceAll("/", "."))
                        paramType = 'java/lang/String'
                    } else {
                        //用无参构造方法创建一个组件实例
                        mv.visitTypeInsn(Opcodes.NEW, name)
                        mv.visitInsn(Opcodes.DUP)
                        mv.visitMethodInsn(Opcodes.INVOKESPECIAL, name, "<init>", "()V", false)
                        paramType = extension.interfaceName
                    }
                    int methodOpcode = _static ? Opcodes.INVOKESTATIC : Opcodes.INVOKESPECIAL
                    //调用注册方法将组件实例注册到组件库中
                    mv.visitMethodInsn(methodOpcode
                            , extension.registerClassName
                            , extension.registerMethodName
                            , "(L${paramType};)V"
                            , false)
                }
            }
            super.visitInsn(opcode)
        }

这些一大堆乱七八糟的东西java/lang/Class, java/lang/String等 一定奇怪吧!因为我们用的ASM是操作字节码的,所以这里你需要对JAVA的字节码有一定的了解。再了解一ASM 的api,那就容易理解了。

到此核心部分已经结束了

下面的组件扫描缓存和组件隔离是加分项了,来,让我拖着疲惫的身躯继续吧,其实我不累,只是我身体累而已。

组件缓存

组件缓存是使用官方提供的增量编译提供的数据,进行差异缓存的。真的是天然的优势。看看增量编译文件的几个状态。

public enum Status {
    /**
     * The file was not changed since the last build.
     没改变
     */
    NOTCHANGED,
    /**
     * The file was added since the last build.
     新增
     */
    ADDED,
    /**
     * The file was modified since the last build.
     改变
     */
    CHANGED,
    /**
     * The file was removed since the last build.
     删除
     */
    REMOVED;
}

RegisterTransform方法的transform开始看。

如果开启缓存模式,就去 RegisterCache.getRegisterCacheFile获取缓存文件,这个文件会生成在build下的一个cc-register目录下,名为register-cache.json,具体大家看看这个文件辅助类RegisterCache。如果缓存文件为空,就去全局扫描。

   if (cacheEnabled) { //开启了缓存
          gson = new Gson()
         cacheFile = RegisterCache.getRegisterCacheFile(project)
     if (clearCache && cacheFile.exists())
         cacheFile.delete()
         cacheMap = RegisterCache.readToMap(cacheFile, new          
         TypeToken<HashMap<String,ScanHarvest>>() {
            }.getType())

            if (cacheMap.isEmpty()) {
                 // 全局扫描 
                 isAllScan = true
            }
        }

缓存接口的信息,className类名,interfaceName接口名,isInitClass是否是初始化类。processName目前没有用到,应该是备用。

把这些信息都读取到cacheMap里面,这个map key是文件的绝对路径。这些信息用于后面文件写入。

/**
 * 已扫描到接口或者codeInsertToClassName jar的信息
 */
class ScanHarvest {
    List<Harvest> harvestList = new ArrayList<>()
    class Harvest {
        String className
        String interfaceName
        boolean isInitClass
        String processName
    }
}

这里我依旧从 RegisterTransform scanClass 的缓存开始说起:

    def scanClass(TransformOutputProvider outputProvider, DirectoryInput directoryInput, CodeScanner scanProcessor) {
         ....
        // changedFiles 为空 或者 关闭缓存
        if (directoryInput.changedFiles.isEmpty() || !cacheEnabled || isAllScan) {
            //遍历目录下的每个文件
            directoryInput.file.eachFileRecurse { File file ->
                scanClassFile(file, root, leftSlash, scanProcessor, dest)
            }
        } else {
            //移除发生改变的缓存
            directoryInput.changedFiles.each { fileList ->
                cacheMap.remove(fileList.key.absolutePath)
            }
            cacheMap.each { cache ->
                if (cache.key.endsWith(".class")) {
                    def path = cache.key.replace(root, '')
                    scanProcessor.hitCache(new File(cache.key), new File(dest, path))
                }
            }
            //扫描发生改变的文件
            directoryInput.changedFiles.each { fileList ->
                def file = fileList.key
                if (fileList.value == Status.CHANGED || fileList.value == Status.ADDED) {
                    scanClassFile(file, root, leftSlash, scanProcessor, dest)
                }
            }
        }
        // 处理完后拷到目标文件
        FileUtils.copyDirectory(directoryInput.file, dest)
        return root

    }

这里我们分五个步骤:

第一步:如果 changedFiles 为空 或者 关闭缓存,就全局扫描。

第二步:移除发生改变的缓存

第三步:查看class是否可以命中缓存,有就添加class list 和 设置fileContainsInitClass。这里大家可以去看看这个scanProcessor.hitCache方法。

第四步:扫描发生改变的文件,和scanClass一样。

第五步:处理完后拷到目标文件, FileUtils.copyDirectory(directoryInput.file, dest)

scanJar的缓存就不说了,因为跟scanClass是差不多的。

那么这些缓存是如何保存下来的?

这个就得关心一下我们上面scanClass的流程了,就在scanClass的第三步。可以回头看看。这里不说了。详细代码在CodeScanner的内部类ScanClassVisitorvisit方法 中。

void gotOne(String interfaceName, String className, RegisterInfo ext) {
    ext.classList.add(className) //需要把对象注入到管理类 就是fileContainsInitClass
    found = true
    //添加到缓存
    addToCacheMap(interfaceName, className, filePath)
}

到此组件接口缓存就讲完了,下面说说组件之间的代码隔离。

组件之间的隔离

我们可以在项目的demo中的build.gradle文件,看到依赖: 'demo_component_a',addComponent是一个自定义依赖,这个在写代码时,代码是隔离的,只有打包时候才会依赖进去。

dependencies {
    //Notice:组件之间不要互相依赖,只在主app module依赖其它组件
    addComponent 'demo_component_a'
    addComponent 'demo_component_kt'

}

具体是如何管理的实现的,具体代码在这个类ProjectModuleManageraddComponentDependencyMethod方法。核心在于这个一行代码。project.dependencies.add(dependencyMode, realDependency)

  def dependencyMode = GradleVersion.version(project.gradle.gradleVersion) >= GradleVersion.version('4.1') ? 'api' : 'compile'
                if (realDependency) {
                    //通过参数传递的依赖方式,如:
                    // project(':moduleName')
                    // 或
                    // 'com.billy.demo:demoA:1.1.0'
                   project.dependencies.add(dependencyMode, realDependency)

到此,全文结束了,谢谢大家。

CC组件化代码地址

项目中cc-register这个文件夹为gradle插件,就是文中讨论分析的。

参考

ASM API文档

齐翊 AutoRegister: 一种更高效的组件自动注册方案 juejin.cn/post/684490…