Android路由框架实践指南

1,729 阅读7分钟

前言

路由框架归根结底就是path和指定跳转目标的映射,通过将这些映射关系保存在路由表中,统一处理所有跳转请求,并提供拦截、降级等功能。本文将探讨一些路由框架上的技术实现细节。
第一步需要从路由的功能性上开始分析,路由比较适合的使用范围是不同模块间的通信/调用,可以通过Path解除模块间的直接耦合,因此跨模块收集路由信息就成了必要的功能,即使引用了不同时打包编译的代码,也可以正确收集到全局的路由信息。
除此之外,路由还最好提供重复Path检测、参数注入、拦截器、跳转降级等功能。

全局路由信息

常见的路由框架如ARouter也可以收集全部的信息——在运行初始化时遍历dex文件,查找所有符合储存路由表信息结构的内容即可,但是这样在运行时的遍历流程比较耗费时间和占用设备性能。这里提出一个编译期进行数据收集的方案,以求将查找遍历流程的开销转移到编译期。
既然需要编译期进行数据的收集,就需要将路由表通过某种形式保存下来,令可以在编译期使用。最简单的便是使用class文件保存,有两个显而易见的好处:一是classloader进行了数据的编解码,只需要使用工具生成可以保存数据且符合class规则的文件即可。二是class为字节码文件,格式较为精简,体积小。
可以在编译期访问到路由表数据,其实就解决了最主要的问题。这里可以将路由表分为两个级别,更好的管理整个应用中的路由数据——分表管理当前代码包(jar或aar)的路由信息,总表管理整个应用的运行时数据。
可以用这个公式表示分表和总表的关系:

App总表 = App分表 + module1分表 + module2分表 + ……

为了更加符合实际情况,这里抽象接口来表示。

//路由分表,每个module一个分表
public interface IRouterNodeProvider {
    //提供路由表内容
    List<RouterNode> getRouterNodes();
}

//路由总表,可以检索到整个app运行时的路由信息
public interface IRouterLoader {
    List<RouterNode> loadNode();
}

子模块编译期间通过解析注解收集当前module的路由信息,并通过字节码工具实现IRouterNodeProvider。一个实现类的例子如下

//通过字节码工具生成的类
public class ComponentNodeProvider implements IRouterNodeProvider {
    public List getRouterNodes() {
        ArrayList var1 = new ArrayList();
        var1.add(RouterNodeFactory.produceRouterNode(NodeType.FRAGMENT, "/login/loginFrag", LoginFragment.class));
        var1.add(RouterNodeFactory.produceRouterNode(NodeType.ACTIVITY, "/login/name", NameActivity.class));
        var1.add(RouterNodeFactory.produceRouterNode(NodeType.ACTIVITY, "/login/choice", ChoiceActivity.class));
        var1.add(RouterNodeFactory.produceRouterNode(NodeType.ACTIVITY, "/login/codeActivity", CodeActivity.class));
        var1.add(RouterNodeFactory.produceRouterNode(NodeType.ACTIVITY, "/login/test", TestActivity.class));
        return var1;
    }
}

在主模块编译期间,遍历输入的子模块,收集所有符合要求子模块的路由表,并组合成总表。
编译期进行全局路由信息的功能就可以这样实现。然后需要处理另一个问题,如果路由信息的量十分庞大,如果在项目第一次使用时就加载所有信息到内存中必然会对内存造成很大的负荷,因此需要根据实际的调用情况进行分块加载。但实际情况中,具体的业务需求也繁杂不一,自动化的分组方式可能效果并不是很好(比如将信息分成等量的几块,用到某块的内容时就加载一整块),这时偏自定义的方式可能更加适合路由的分块策略。

分组懒加载

在创建path的时候填入group,就可以根据分组内容将路由信息分成不同的块,而这些块之间会有很高的关联性,如果其中一个path被使用,其余同组的path也会有很高的几率在接下来的过程中被调用。为了支持分组,需要对生成字节码的接口进行一些改动。

//组分表,在一个module中可能会有多个组分表,每一个group内的所有路由信息对应一个组分表
public interface IRouterNodeProvider {
    List<RouterNode> getRouterNodes();
}
//路由总表
public interface IRouterLazyLoader {
    //根据group可以获取到对应所有组分表的信息
    List<IRouterNodeProvider> lazyLoadFactoryByGroup(String group);
}

改动过后,总表就可以根据group信息直接返回一系列组分表的路由信息并使用。一个总表的具体内容可能如下:

//字节码工具生成的类
public class RouterLazyLoaderImpl implements IRouterLazyLoader {
    public List lazyLoadFactoryByGroup(String group) {
        ArrayList var2 = new ArrayList();
        //这里是group的hash
        switch(group.hashCode()) {
        case -1399907075:
        	//主module生成的路由信息组分表
            var2.add(new NodeProvider());
            return var2;
        case 3151346:
        	//通过编译时的检索,获得的其他module的组分表
            var2.add(new android.khala.gen.router.frag.java_component.NodeProvider());
            return var2;
        case 1984153269:
            var2.add(new android.khala.gen.router.service.java_component.NodeProvider());
            return var2;
        default:
            return var2;
        }
    }

}

根据Path获取到路由信息后,就可以根据信息判断跳转的类型并执行响应的操作流程了。

参数注入

在Activity、Fragment等需要通过bundle传递参数的情况中,很多时候key,value的填写较为繁琐,传参需要校验,获取参数也需要单独的逻辑,并且一旦传递的参数修改,上述的逻辑也都需要改动。参数注入可以通过将key和类中对应的field映射起来,自动生成获取参数的逻辑代码。
这里采用的方案是为使用了参数注入的Activity和Fragment类额外实现一个接口(新增方法)。

public interface IInjectable {
    void autoSynthetic$FieldInjectComponent();
}

//使用被注入的参数前,调用inject()
//inject()逻辑如下
public static void inject(Object target) {
    if (target instanceof IInjectable) {
        ((IInjectable) target).autoSynthetic$FieldInjectComponent();
    }
}
//一个实现的例子
@Inject(name = "a")
private int testInt;
@Inject
private String testString;
@Inject
private int color;

//编译过程中通过字节码工具生成
public void autoSynthetic$FieldInjectSoulComponent() {
    if (this instanceof IInjectable) {
        Bundle var1 = this.getArguments();
        if (var1 != null) {
            this.testInt = var1.getInt("a", this.testInt);
            this.testString = var1.getString("testString");
            this.color = var1.getInt("color", this.color);
        }
    }
}

这种方案通过直接为原有类添加一个新方法,不需要额外新增一个类,也一定程度上减小了AOP导致的包体积增大。

重复Path检测

多模块开发,可能维护不同模块的人员也不同,相应的一些规范也会不同,路由信息的量大到一定程度,就会出现Path重复的问题,从实现角度和原则上来讲,最好Path和跳转目标是一一映射的关系,Path重复就会导致跳转行为无法确定,因此需要尽早提示开发Path重复的情况,人为的开发规定有时并不是那么靠谱,人犯错的情况往往比机器要多的多,所以一定需要进行路由的Path重复检测流程。
基于第一节的内容,路径检测实际上也分为两个模块:

  1. 当前module的路径检测
  2. 整个应用的路径检测

第一点非常简单,编译期收集当前module信息时就可以进行重复的判断并抛出异常,保证当前module的路由信息中Path没有重复情况。第二点在基于这个前提下实现就会容易不少。
上文提到过,该方案的路由信息是通过class储存的,因此完全可以在整体应用的编译期收集分表完成后,将这些路由表信息用新建的ClassLoader加载,得到对应的路由信息,再统一放到一个set中进行重复判断。由此亦可以在编译期抛出应用的Path重复情况,再通知到对应模块进行修改。
方案的问题是会导致编译时间的增长,和打包机器的内存使用率上升。并且如果一个全新的,且迭代较久的组件新加入应用中,可能导致重复情况较多,需要大量修改。

这里其实可以换一个思路,在不同模块之间不去做重复检测,而是通过某种额外手段规避不同模块间的重复情况。比如可以在编译期为每个模块分配一个路径的前缀或者scheme,对开发阶段透明。
以当前方案为例,最简单的方式,将获取指定路由信息的逻辑委托给NodeProvider,修改过后的IRouterNodeProvider#getRouterNodes方法可能是这个样子:

public class ComponentNodeProvider implements IRouterNodeProvider {
    private String prefix = "prefix";

    public RouterNode obtainNode(String path){
        //检索时加上前缀
        return NodeRepository.getByPath(prefix + path);
    }

    public List getRouterNodes() {
        ArrayList var1 = new ArrayList();
        var1.add(RouterNodeFactory.produceRouterNode(NodeType.FRAGMENT, prefix + "/login/loginFrag", LoginFragment.class));
        var1.add(RouterNodeFactory.produceRouterNode(NodeType.ACTIVITY, prefix + "/login/name", NameActivity.class));
        return var1;
    }
}

经过这样处理后,新增一个组件只需要再配置一项不重复的前缀或者scheme即可。

其他

本文描述的路由实现,作为组件化间交互通信的一部分已经开源在github上,地址:github.com/nebulae-pan… 。欢迎各位大佬使用、提出建议。