给大家推荐个开源项目

阅读 3543
收藏 95
2018-08-14
原文链接:mp.weixin.qq.com

本文由Xiasm授权投稿https://github.com/Xiasm/EasyRouter

前言

路由跳转和组件化在项目中用了一段时间了,最近对Android中的ARouter路由原理也是研究了一番,于是就给大家分享一下自己的心得体会,并教大家如何实现一款简易的路由框架。本篇文章分为两个部分,第一部分着重剖析ARouter路由的原理,第二部分会带着大家仿照ARouter撸一个自己的路由框架,我们自己撸的路由框架可能没有Arouter众多的功能如过滤器、provider等,但是却实现了ARouter最核心的功能:路由跳转,同时你也能学会如何去设计一个框架等。这里先附上我自己实现的路由框架demo地址:

https://github.com/Xiasm/EasyRouter

第一部分:ARouter原理剖析

说到路由便不得不提一下Android中的组件化开发思想,组件化是最近比较流行的架构设计方案,它能对代码进行高度的解耦、模块分离等,极大地提高开发效率(如有同学对组件化有不理解,可以参考网上众多的博客等介绍,然后再阅读demo源码中的组件化配置进行熟悉)。路由和组件化本身没有什么联系,因为路由的责任是负责页面跳转,但是组件化中两个单向依赖的module之间需要互相启动对方的Activity,因为没有相互引用,startActivity()是实现不了的,必须需要一个协定的通信方式,此时类似ARouter和ActivityRouter等的路由框架就派上用场了。

  • 第一节:ARouter路由跳转的原理

如上图,在组件化中,为了业务逻辑的彻底解耦,同时也为了每个module都可以方便的单独运行和调试,上层的各个module不会进行相互依赖(只有在正式联调的时候才会让app壳module去依赖上层的其他组件module),而是共同依赖于base module,base module中会依赖一些公共的第三方库和其他配置。既然上层的两个module没有依赖,便不能通过startActivity()进行通信,那么如何进行通信呢?可能有同学会想到隐式跳转,这当然也是一种解决方法,但是一个项目中不可能所有的跳转都是隐式的,这样Manifest文件会有很多过滤配置,而且非常不利于后期维护。当然你用反射拿到Activity的class文件也可以实现跳转,但是第一:大量的使用反射跳转对性能会有影响,第二:你需要拿到Activity的类文件,在组件开发的时候,想拿到其他module的类文件是很麻烦的,因为组件开发的时候组件module之间是没有相互引用的,你只能通过找到类的路径去反射拿到这个class,那么有没有一种更好的解决办法呢?办法当然是有的,看下面介绍。在组件化中,我们通常都会在base_module上层再依赖一个router_module,而这个router_module就是负责各个模块之间页面跳转的。用过ARouter路由框架的同学应该都知道,在每个需要对其他module提供调用的Activity中,都会声明类似下面@Route注解,我们称之为路由地址:

@Route(path = "/main/main")
public class MainActivity extends AppCompatActivity {}

@Route(path = "/module1/module1main")
public class Module1MainActivity extends AppCompatActivity {}

那么这个注解有什么用呢,路由框架会在项目的编译期通过注解处理器扫描所有添加@Route注解的Activity类,然后将Route注解中的path地址和Activity.class文件映射关系保存到它自己生成的java文件中,只要拿到了映射关系便能拿到Activity.class。为了让大家理解,我这里来使用近乎伪代码给大家简单演示一下。

public class MyRouters{

    //项目编译后通过apt生成如下方法
    public static HashMap getRouteInfo(HashMap routes) {
        route.put("/main/main", MainActivity.class);
        route.put("/module1/module1main", Module1MainActivity.class);
        route.put("/login/login", LoginActivity.class);
    }
}

这样我们想在app模块的MainActivity跳转到login模块的LoginActivity,那么便只需调用如下:

//不同模块之间启动Activity
public void login(String name, String password) {
    HashMap route = MyRouters.getRouteInfo(new HashMap);
    LoginActivity.class classBean = route.get("/login/login");
    Intent intent = new Intent(this, classBean);
    intent.putExtra("name", name);
    intent.putExtra("password", password);
    startActivity(intent);
}

这样是不是很简单就实现了路由的跳转,既没有隐式意图的繁琐,也没有反射对性能的损耗。用过ARouter的同学应该知道,用ARouter启动Activity应该是下面这个写法:

// 2. Jump with parameters
ARouter.getInstance().build("/test/login")
            .withString("password", 666666)
            .withString("name", "小三")
            .navigation();

那么ARouter背后是怎么样实现跳转的呢?实际上它的核心思想跟上面讲解是一样的,我们在代码里加入的@Route注解,会在编译时期通过apt生成一些存储path和activity.class映射关系的类文件,然后app进程启动的时候会加载这些类文件,把保存这些映射关系的数据读到内存里(保存在map里),然后在进行路由跳转的时候,通过build()方法传入要到达页面的路由地址,ARouter会通过它自己存储的路由表找到路由地址对应的Activity.class(activity.class = map.get(path)),然后new Intent(context, activity.Class),当调用ARouter的withString()方法它的内部会调用intent.putExtra(String name, String value),调用navigation()方法,它的内部会调用startActivity(intent)进行跳转,这样便可以实现两个相互没有依赖的module顺利的启动对方的Activity了。

  • 第二节:ARouter映射关系如何生成

通过上节我们知道在Activity类上加上@Route注解之后,便可通过apt生成对应的路由表。那么现在我们来搞清楚,既然路由和Activity的映射关系我们可以很容易地得到(因为代码都是我们写的,当然很容易得到),那么为什么我们要繁琐的通过apt来生成类文件而不是自己直接写一个契约类来保存映射关系呢。如果站在一个框架开发者的角度去理解,就不难明白了,因为框架是给上层业务开发者调用的,如果业务开发者在开发页面的过程中还要时不时的更新或更改契约类文件,不免过于麻烦?如果有自动根据路由地址生成映射表文件的技术该多好啊!

技术当然是有的,那就是被众多框架使用的apt及javapoet技术,那么什么是apt,什么是javapoet呢?我们先来看下图: 由图可知,apt是在编译期对代码中指定的注解进行解析,然后做一些其他处理(如通过javapoet生成新的Java文件)。我们常用的ButterKnife,其原理就是通过注解处理器在编译期扫描代码中加入的@BindView、@OnClick等注解进行扫描处理,然后生成XXX_ViewBinding类,实现了view的绑定。javapoet是鼎鼎大名的squareup出品的一个开源库,是用来生成java文件的一个library,它提供了简便的api供你去生成一个java文件。可以如下引入javapoet

implementation 'com.squareup:javapoet:1.7.0'

下面我通过demo中的例子带你了解如何通过apt和javapoet技术生成路由映射关系的类文件:

首先第一步,定义注解:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface Route {
    // 路由的路径
    String path();

    // 将路由节点进行分组,可以实现动态加载
    String group() default "";
}

这里看到Route注解里有path和group,这便是仿照ARouter对路由进行分组。因为当项目变得越来越庞大的时候,为了便于管理和减小首次加载路由表过于耗时的问题,我们对所有的路由进行分组。在ARouter中会要求路由地址至少需要两级,如"/xx/xx",一个模块下可以有多个分组,这里我们就将路由地址定为必须大于等于两级,其中第一级是group。

第二步,在Activity上使用注解:

@Route(path = "/main/main")
public class MainActivity extends AppCompatActivity {}

@Route(path = "/main/main2")
public class Main2Activity extends AppCompatActivity {}

@Route(path = "/show/info")
public class ShowActivity extends AppCompatActivity {}

第三步,编写注解处理器,在编译期找到加入注解的类文件,进行处理,这里我只展示关键代码,具体的细节还需要你去demo中仔细研读:

@AutoService(Processor.class)
//  处理器接收的参数
@SupportedOptions(Constant.ARGUMENTS_NAME)
// 注册给哪些注解的
@SupportedAnnotationTypes(Constant.ANNOTATION_TYPE_ROUTE)
public class RouterProcessor extends AbstractProcessor {

    // key:组名 value:类名
    private Map rootMap = new TreeMap<>();

    // 分组 key:组名 value:对应组的路由信息
    private Map> groupMap = new HashMap<>();

    //...

    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {
        super.init(processingEnvironment);
        //...
        elementUtils = processingEnvironment.getElementUtils();
        typeUtils = processingEnvironment.getTypeUtils();
        filerUtils = processingEnvironment.getFiler();

        //参数是模块名 为了防止多模块/组件化开发的时候 生成相同的 xx$$ROOT$$文件
        Map options = processingEnvironment.getOptions();
        if (!Utils.isEmpty(options)) {
            moduleName = options.get(Constant.ARGUMENTS_NAME);
        }
        if (Utils.isEmpty(moduleName)) {
            throw new RuntimeException("Not set processor moudleName option !");
        }
        log.i("init RouterProcessor " + moduleName + " success !");
    }

    @Override
    public boolean process(Set set, RoundEnvironment roundEnvironment) {
        if (!Utils.isEmpty(set)) {
            //被Route注解的节点集合
            Set rootElements = roundEnvironment.getElementsAnnotatedWith(Route.class);
            if (!Utils.isEmpty(rootElements)) {
                processorRoute(rootElements);
            }
            return true;
        }
        return false;
    }


    //...

}

如代码中所示,要想在编译期对注解做处理,就需要RouterProcessor继承自AbstractProcessor并通过@AutoService注解进行注册,然后实现process()方法。process()方法里的set集合就是编译期扫描代码得到的加入了Route注解的文件集合,@SupportedAnnotationTypes(Constant.ANNOTATION_TYPE_ROUTE)指定了需要处理的注解的路径地址,在此就是Route.class的路径地址。init()方法会在注解处理器初始化的时候拿到一些工具类,然后看这句代码moduleName = options.get(Constant.ARGUMENTS_NAME),会得到一个moduleName,这个moduleName需要我们在要使用注解处理器生成路由映射文件的模块gradle文件里配置,而@SupportedOptions(Constant.ARGUMENTS_NAME)会拿到当前module的名字,用来生成唯一对应module下存放分组信息的类文件名。这里变量Constant.ARGUMENTS_NAME的值就是moduleName,在这之前,我们需要在每个组件module的gradle下配置如下

javaCompileOptions {
            annotationProcessorOptions {
                arguments = [moduleName: project.getName()]
            }
        }

这里的@AutoService是为了注册注解处理器,需要我们引入一个google开源的自动注册工具AutoService,如下依赖(当然也可以手动进行注册,不过略微麻烦,这里不太推荐):

implementation 'com.google.auto.service:auto-service:1.0-rc2'

第四步:通过javapoet生成java类:在第三步中process()方法里有一句代码:processorRoute(rootElements),processorRoute()方法里会调用generatedGroup()和generatedRoot()方法分别去生成分组信息相关和路由映射相关Java文件,关于路由映射信息和分组信息的关系,我们下面会讲到,这里先不用理会,你只需要知道它们都是生成的存有映射关系的文件,这里我只贴出generatedRoot()方法,因为生成类文件的原理都是一样的,至于生成什么功能的类,只要你会一个,举一反三,这便没有什么难度。

/**
 * 生成Root类  作用:记录<分组,对应的Group类>
 */
private void generatedRoot(TypeElement iRouteRoot, TypeElement iRouteGroup) {
    //创建参数类型 Map> routes>
    //Wildcard 通配符
    ParameterizedTypeName parameterizedTypeName = ParameterizedTypeName.get(
            ClassName.get(Map.class),
            ClassName.get(String.class),
            ParameterizedTypeName.get(
                    ClassName.get(Class.class),
                    WildcardTypeName.subtypeOf(ClassName.get(iRouteGroup))
            ));
    //生成参数 Map> routes> routes
    ParameterSpec parameter = ParameterSpec.builder(parameterizedTypeName, "routes").build();

    //生成函数 public void loadInfo(Map> routes> routes)
    MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder(Constant.METHOD_LOAD_INTO)
            .addModifiers(Modifier.PUBLIC)
            .addAnnotation(Override.class)
            .addParameter(parameter);
    //生成函数体
    for (Map.Entry entry : rootMap.entrySet()) {
        methodBuilder.addStatement("routes.put($S, $T.class)", entry.getKey(), ClassName.get(Constant.PACKAGE_OF_GENERATE_FILE, entry.getValue()));
    }
    //生成XX_Root_XX类
    String className = Constant.NAME_OF_ROOT + moduleName;
    TypeSpec typeSpec = TypeSpec.classBuilder(className)
            .addSuperinterface(ClassName.get(iRouteRoot))
            .addModifiers(Modifier.PUBLIC)
            .addMethod(methodBuilder.build())
            .build();
    try {
      //生成java文件,PACKAGE_OF_GENERATE_FILE就是生成文件需要的路径
        JavaFile.builder(Constant.PACKAGE_OF_GENERATE_FILE, typeSpec).build().writeTo(filerUtils);
        log.i("Generated RouteRoot:" + Constant.PACKAGE_OF_GENERATE_FILE + "." + className);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

如上,我把每一块代码的作用注释了出来,相信大家很容易就能理解每一个代码段的作用。可见,其实生成文件只是调用一些api而已,只要我们熟知api的调用,生成java文件便没有什么难度。那么大家现在想一个问题,只要我以统一的规则生成所有的映射文件,然后拿到这些映射文件,是不是就可以很轻易的进行路由跳转了。好了,下一部分我们就来实现这个路由框架。

第二部分:动手实现一个路由框架

通过第一部分的讲述,我相信大家对于ARouter的原理已经有了整体轮廓的理解,这一部分,我便会通过代码带你去实现一个自己的路由框架。上节我们讲了如何生成路由映射文件,这节我们考虑下生成这些路由映射文件后,如何统一的去使用这些文件。

  • 第一节:如何拿到和统一管理路由映射文件

通过第一部分的讲述我们知道在Activity类上加上@Route注解之后,便可通过apt来生成对应的路由映射文件,那么现在我们考虑一个问题,就是我们的路由映射文件是在编译期间生成的,那么在程序的运行期间我们要统一调用这些路由信息,便需要一个统一的调用方式。我们先来定义这个调用方式:

public interface IRouteGroup {
    void loadInto(Map atlas);
}

public interface IRouteRoot {
    void loadInto(Map> routes);
}

我们定义两个接口来对生成的java文件进行约束,IRouteGroup是生成的分组关系契约,IRouteRoot是单个分组下路由信息契约,只要我们生成的java文件分别继承自这两个接口并实现loadInto()方法,在运行期间我们就可以统一的调用生成的java文件,获取路由映射信息。在上文中我们时不时的提到分组信息和路由映射信息,其实两者都是通过javapoet生成的类文件,理论上我们只需要生成一种文件,即记录路由地址和Activity一一映射的文件,但是一个项目中众多的路由地址没有任何分组,会很难辨别,所以我们对路由进行分组,下面我用一张图来说明分组和路由的关系: 由图可知,xxx_Root_xxx就是记录着此module下的分组信息,而每一个分组会单独记录此分组下的所有路由映射关系。这样,只要我们拿到所有的分组文件,就能通过分组文件找到任一个分组,从而拿到所有的路由信息。

好了,生成映射文件我们上面已经讲过了,现在我们编译下项目就会在每个组件module的build/generated/source/apt目录下生成相关映射文件。这里我把app module编译后生成的文件贴出来,app module编译后会生成EaseRouter_Root_app文件和EaseRouter_Group_main、EEaseRouter_Group_show等文件,EaseRouter_Root_app文件对应于app module的分组,里面记录着本module下所有的分组信息,EaseRouter_Group_main、EaseRouter_Group_show文件分别记载着当前分组下的所有路由地址和ActivityClass映射信息。如下所示:

public class EaseRouter_Root_app implements IRouteRoot {
  @Override
  public void loadInto(Map> routes) {
    routes.put("main", EaseRouter_Group_main.class);
    routes.put("show", EaseRouter_Group_show.class);
  }
}

public class EaseRouter_Group_main implements IRouteGroup {
  @Override
  public void loadInto(Map atlas) {
    atlas.put("/main/main",RouteMeta.build(RouteMeta.Type.ACTIVITY,Main2\Activity.class,"/main/main","main"));
    atlas.put("/main/main2",RouteMeta.build(RouteMeta.Type.ACTIVITY,Main2\Activity.class,"/main/main2","main"));
  }
}

public class EaseRouter_Group_show implements IRouteGroup {
  @Override
  public void loadInto(Map atlas) {
 atlas.put("/show/info",RouteMeta.build(RouteMeta.Type.ACTIVITY,ShowActivity.class,"/show/info","show"));
  }
}

大家会看到生成的类分别实现了IRouteRoot和IRouteGroup接口,并且实现了loadInto()方法,而loadInto方法通过传入一个特定类型的map就能把分组信息放入map里,只要分组信息存入到特定的map里后,我们就可以随意的从map里取路由地址对应的Activity.class做跳转使用。那么如果我们在login_module中想启动app_module中的MainActivity类,首先,我们已知MainActivity类的路由地址是"/main/main",第一个"/main"代表分组名,那么我们岂不是可以轻易的通过path取到路由地址对应的Activity.class,然后将Activity.class传入intent实现跳转。

  • 第二节 路由框架的初始化

上节我们已经通过apt生成了映射文件,并且知道了如何通过映射文件去调用Activity,然而我们要实现一个路由框架,就要考虑在合适的时机拿到这些映射文件中的信息,以供上层业务做跳转使用。拿到这些路由关系肯定越早越好,这里我们就在Application的onCreate方法中进行框架的初始化,调用EaseRoute.init(),初始化方法里我们去调用loadInfo()方法进行加载映射文件。下面看loadInfo()方法:

private static void loadInfo() throws PackageManager.NameNotFoundException, InterruptedException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
    //获得所有 apt生成的路由类的全类名 (路由表)
    Set routerMap = ClassUtils.getFileNameByPackageName(mContext, ROUTE_ROOT_PAKCAGE);
    for (String className : routerMap) {
        if (className.startsWith(ROUTE_ROOT_PAKCAGE + "." + SDK_NAME + SEPARATOR + SUFFIX_ROOT)) {
            //root中注册的是分组信息 将分组信息加入仓库中
            ((IRouteRoot) Class.forName(className).getConstructor().newInstance()).loadInto(Warehouse.groupsIndex);
        }
    }
    for (Map.Entry> stringClassEntry : Warehouse.groupsIndex.entrySet()) {
        Log.d(TAG, "Root映射表[ " + stringClassEntry.getKey() + " : " + stringClassEntry.getValue() + "]");
    }

}

我们首先通过ClassUtils.getFileNameByPackageName(mContext, ROUTE_ROOT_PAKCAGE)得到apt生成的所有实现IRouteRoot接口的类文件集合,通过上面的讲解我们知道,拿到这些类文件便可以得到所有的路由地址和Activity映射关系。这个ClassUtils.getFileNameByPackageName()方法通过传入一个路径得到此路径下所有的类名,篇幅有限,这里我就不贴出来了,有兴趣的可以去看demo源码,拿到所有的类名之后,通过for循环拿到所有路由分组文件,并反射创建出实例,把这些路由分组存入Warehouse.groupsIndex里。这里Warehouse.groupsIndex就是一个map,是为了存放所有分组,Warehouse类里面还有一个Warehouse.routes的map,此map就是存放路由信息的表了。这里我们把每一个分组文件的分组信息都加入到Warehouse.groupsIndex里,就可以来实现路由跳转了。

  • 第三节 路由跳转实现

经过上节的介绍,我们已经能够在进程初始化的时候拿到所有的路由信息,那么实现跳转便好做了。假如我们要在MainActivity中点击按钮跳转到其他module的activity,那么便只需要在点击按钮时调用如下方法:

EasyRouter.getsInstance().build("/module1/module1main").navigation();

在build的时候,传入要跳转的路由地址,build()方法会返回一个Postcard对象,我们称之为跳卡。然后调用Postcard的navigation()方法完成跳转。用过ARouter的对这个跳卡都应该很熟悉吧!Postcard里面保存着跳转的信息。下面我把Postcard类的代码实现粘下来:

public class Postcard extends RouteMeta {
    private Bundle mBundle;
    private int flags = -1;

    public Postcard(String path, String group) {
        this(path, group, null);
    }

    public Bundle getExtras() {return mBundle;}

    public int getEnterAnim() {return enterAnim;}

    public int getExitAnim() {return exitAnim;}

    public Postcard withString(@Nullable String key, @Nullable String value) {
        mBundle.putString(key, value);
        return this;
    }


    public Postcard withBoolean(@Nullable String key, boolean value) {
        mBundle.putBoolean(key, value);
        return this;
    }

    public Postcard withInt(@Nullable String key, int value) {
        mBundle.putInt(key, value);
        return this;
    }

    //还有许多给intent中bundle设置值得方法我就不一一列出来了,可以看demo里所有的细节


    public Object navigation() {
        return EasyRouter.getsInstance().navigation(null, this, -1, null);
    }
}

如果你是一个Android开发,Postcard类里面的东西就不用我再给你介绍了吧!我们只介绍一个方法navigation(),方法里面会调用EasyRouter类的navigation()方法。EaseRouter的navigation()方法内部会调用prepareCard(postcard)找到路由地址对应的Activity.class。下面请看:

private void prepareCard(Postcard card) {
    RouteMeta routeMeta = Warehouse.routes.get(card.getPath());
    if (null == routeMeta) {
        Class groupMeta = Warehouse.groupsIndex.get(card.getGroup());
        if (null == groupMeta) {
            throw new NoRouteFoundException("没找到对应路由:分组=" + card.getGroup() + "   路径=" + card.getPath());
        }
        IRouteGroup iGroupInstance;
        try {
            iGroupInstance = groupMeta.getConstructor().newInstance();
        } catch (Exception e) {
            throw new RuntimeException("路由分组映射表记录失败.", e);
        }
        iGroupInstance.loadInto(Warehouse.routes);
        //已经准备过了就可以移除了 (不会一直存在内存中)
        Warehouse.groupsIndex.remove(card.getGroup());
        //再次进入 else
        prepareCard(card);
    } else {
        //类 要跳转的activity 或IService实现类
        card.setDestination(routeMeta.getDestination());
        card.setType(routeMeta.getType());
        switch (routeMeta.getType()) {
            case ISERVICE:
                Class destination = routeMeta.getDestination();
                IService service = Warehouse.services.get(destination);
                if (null == service) {
                    try {
                        service = (IService) destination.getConstructor().newInstance();
                        Warehouse.services.put(destination, service);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
                card.setService(service);
                break;
            default:
                break;
        }
    }
}

注意,Warehouse就是专门用来存放路由映射关系的类,里面保存着存路由信息的map,这在ARouter里面也是一样的。这段代码Warehouse.routes.get(card.getPath())通过path拿到对应的RouteMeta,这个RouteMeta里面保存了activityClass等信息。继续往下看,如果拿到的是空,会根据当前路由地址的group拿到对应的分组,通过反射创建实例,然后调用实例的loadInfo方法,把它里面保存的映射信息添加到Warehouse.routes里面,并且再次调用prepareCard(card),这时再通过Warehouse.routes.get(card.getPath())就可以顺利拿到RouteMeta了。进入else{}里面,调用了card.setDestination(routeMeta.getDestination()),这个setDestination就是将RouteMeta里面保存的activityClass放入Postcard里面,既然拿到了Activity.class,便可以通过startActivity启动Activity了。

小结

到这我们的路由原理剖析及手写实现已经讲完了,由于篇幅限制,过多的细节不能一一给大家解释,如果大家读过文章之后还有什么不能融会贯通的地方,可star我的github阅读demo和更详细的讲解。EaseRouter本身只是参照ARouter手动实现的路由框架,并且剔除掉了很多东西,如过滤器等,如果想要用在项目里,建议还是用ARouter更好,毕竟这只是个练手项目,功能也不够全面,当然有同学想对demo扩展后使用那当然更好,遇到什么问题可以及时联系我。我的目的是通过自己手动实现路由框架来加深对知识的理解,如这里面涉及到的知识点apt、javapoet和组件化思路、编写框架的思路等。看到这里,如果感觉干货很多,欢迎关注我的github,里面会有更多干货!

Demo地址

仿ARouter一步步实现一个路由框架,点击 阅读原文 即可访问,欢迎star。

— — — END — — —

推荐阅读那些年面试求职中踩过的坑 如果时光能倒流 Android 开发应该掌握的 Proguard 技巧

评论