TheRouter 页面跳转源码分析

TheRouter是什么

TheRouter是货拉拉开源的 Android 平台中对页面、服务模块化整合开发、提供路由功能的中间件,提倡的是简单且够用。

Github: https://github.com/HuolalaTech/hll-wp-therouter-android
官网: https://therouter.cn

简单使用示例

Activtiy跳转

TheRouter.build("http://therouter.cn/test/activity").withString("name", "姓名").navigation();

获取依赖服务

// 假设当前有一个用户信息获取服务
public interface IUserService {
    String getUserInfo();
}

// 服务提供方
@ServiceProvider(returnType = IUserService.class)
public static UserServiceImpl test() {
    xxx
}

// 服务使用方
TheRouter.get(IUserService::class.java)?.getUserInfo()

主要代码结构

TheRouter
  ├─app
  │   └──代码使用示例Demo
  ├─business-a
  │   └──用于模块化业务模块的演示模块
  ├─business-b
  │   └──用于模块化业务模块的演示模块
  ├─business-base
  │   └──用于模块化基础模块的演示模块
  ├─apt
  │   └──注解处理器相关代码
  ├─plugin
  │   └──编译期 Gradle 插件源码
  └─router
      └──路由库核心代码

核心源码分析

TheRouter.build("http://therouter.cn/test/activity").withString("name", "姓名").navigation();

从最常用的跳转开始分析,基本可了解到 TheRouter 的运转原理。这行完成跳转的代码最终效果是携带参数跳转到对应的 Activity,在 Android 层面来说最后一定是通过调用 startActivity 或是 startActivityForResult 来完成跳转。

分为几步来看:

  1. TheRouter 调用 Build 生成 Navigator,过程是怎样的
  2. Navigator 是什么
  3. Navigator 调用 navigation 是怎样执行到 startActivity 的

生成 Navigator

通过查看源码TheRouter.build()实际上调用的是Navigator的构造方法,

@JvmStatic
fun build(url: String?): Navigator {
    return Navigator(url)
}

而在构造方法里面,可以看到代码的实现比较复杂,但关键部分都有注释,基本都能看得懂,主要可以分为两部分:

  • 拦截器修改url
  • 解析存储url上的参数
init {
    require(!TextUtils.isEmpty(url), "Navigator", "Navigator constructor parameter url is empty")
    for (handle in fixHandles) {
        handle?.let {
            url = it.fix(url)
        }
    }
    uri = Uri.parse(url ?: "")
    // queryParameterNames() 会自动decode,造成外部逻辑错误,所以这里需要根据&手动截取k=v
    uri.encodedQuery?.split("&")?.forEach {
        val idx = it.indexOf("=")
        val key = if (idx > 0) it.substring(0, idx) else it
        val value: String? = if (idx > 0 && it.length > idx + 1) it.substring(idx + 1) else null
        // 通过url取到的value,都认为是string,autowired解析的时候会做兼容
        extras.putString(key, value)
    }
}

Navigator 是什么

根据 官网文档的介绍 https://therouter.cn/docs/2022/08/28/01
TheRouter的页面跳转都由 Navigator 导航器去操作的。

内部大致可以分为四部分:

  • ur/path 的解析与填充
  • 路由表匹配
  • 执行跳转
  • 参数解析

参数的解析

我看的是1.1.1-rc1版本的代码,url的解析基本上就是通过uri去解析的。看到注释上,写了一个老版本的问题:

 uri = Uri.parse(url ?: "")
//        for (key in uri.queryParameterNames) {
//            // 通过url取到的value,都认为是string,autowired解析的时候会做兼容
//            extras.putString(key, uri.getQueryParameter(key))
//        }
        // queryParameterNames() 会自动decode,造成外部逻辑错误,所以这里需要根据&手动截取k=v
uri.encodedQuery?.split("&")?.forEach {
    val idx = it.indexOf("=")
    val key = if (idx > 0) it.substring(0, idx) else it
    val value: String? = if (idx > 0 && it.length > idx + 1) it.substring(idx + 1) else null
    // 通过url取到的value,都认为是string,autowired解析的时候会做兼容
    extras.putString(key, value)
}

上面被注释掉的代码就是老版本的解析,注释写的很清楚,由于uri.queryParameterNames() 会自动decode,造成外部逻辑错误,所以新版本就改成了根据&手动截取k=v的方式做了。

在执行解析uri之前,其实还做了一次拦截器的替换。
按照官网文档:Path 修改器的应用场景是用于修复客户端上路由 path 错误问题。

例如:相对路径转绝对路径,或由于服务端下发的链接无法固定https或http,但客户端代码写死了 https 的 path,就可以用这种方式统一。

注:所有的拦截器必须在 TheRouter.build() 方法调用前添加处理器,否则处理前的path不会被修改。这个倒也合理。

for (handle in fixHandles) {
    handle?.let {
        url = it.fix(url)
    }
}

路由表

TheRouter是个动态路由,所以路由表其实被弱化为一个很泛的概念了。
感觉有很多套路由表,最终都会被汇总到一个支持正则表达式的Map里面。

首先讲路由表的创建来源,我能找到的就是这四种:

  • 从当前模块,通过 APT 解析@Route生成的
  • 从依赖 aar 的路由表中读取的
  • 从json文件中读取的
  • 代码添加的路由表

APT生成的路由表

先看第一种,最好理解的,就是注解处理器解析生成的。

@Route(path = "http://therouter.com/home", action = "action://scheme.com",
        description = "第二个页面", params = {"hello", "world"})
public class HomeActivity extends AppCompatActivity {
}

参数释义

  • path: 路由path 【必传】。
    建议是一个url。path内支持使用正则表达式(为了匹配效率,正则必须包含反双斜杠\),允许多个path对应同一个Activity(Fragment)。
  • action: 自定义事件【可选】。
    一般用来打开目标页面后做一个执行动作,例如自定义页面弹出广告弹窗
  • description: 页面描述【可选】。
    会被记录到路由表中,方便后期排查的时候知道每个path或Activity是什么业务
  • params: 页面参数【可选】。
    自动写入intent中,允许写在路由表中动态下发修改默认值,或通过路由跳转时代码传入。

AAR依赖传递的路由表

所有的aar,只要是包含有路由表的,都会有一个叫 RouterMap__TheRouter__202xxxxx 最后面是一段 hashcode。

这个文件其实在源码编译的时候也能找到,在 build/source/kapt/debug/a/RouterMap__TheRouter__202xxxxx 里面。

这个文件包含两部分:

ROUTERMAP 是一个 json 格式的路由表,下面的addRoute 方法,是路由表的代码实现,这应该也是为什么 TheRouter 能号称无反射的原因。

public static final String ROUTERMAP = "[{\"path\":\"http://kymjs.com/business_a/testinject\",\"className\":
\"com.therouter.demo.shell.TestInjectActivity\",\"action\":\"\",\"description\":\"\",\"params\":{}},{\"path\":\"http://kymjs.com/business_a/testinject3\",
\"className\":\"com.therouter.demo.shell.TestActivity\",\"action\":\"\",
\"description\":\"\",\"params\":{}},{\"path\":\"http://kymjs.com/business_a/testinject4\",\"className\":\"com.therouter.demo.shell.MultiThreadActivity\",
\"action\":\"\",\"description\":\"\",\"params\":{}},{\"path\":\"http://kymjs.com/therouter/demo_service_provider\",\"className\":
\"com.therouter.demo.shell.MainActivity\",\"action\":\"\",\"description\":\"\",\"params\":{}}]";

public static void addRoute() {
    com.therouter.router.RouteItem item1 = new com.therouter.router.RouteItem("http://kymjs.com/business_a/testinject","com.therouter.demo.shell.TestInjectActivity","","");
    com.therouter.router.RouteMapKt.addRouteItem(item1);
    com.therouter.router.RouteItem item2 = new com.therouter.router.RouteItem("http://kymjs.com/business_a/testinject3","com.therouter.demo.shell.TestActivity","","");
    com.therouter.router.RouteMapKt.addRouteItem(item2);
    com.therouter.router.RouteItem item3 = new com.therouter.router.RouteItem("http://kymjs.com/business_a/testinject4","com.therouter.demo.shell.MultiThreadActivity","","");
    com.therouter.router.RouteMapKt.addRouteItem(item3);
    com.therouter.router.RouteItem item4 = new com.therouter.router.RouteItem("http://kymjs.com/therouter/demo_service_provider","com.therouter.demo.shell.MainActivity","","");
    com.therouter.router.RouteMapKt.addRouteItem(item4);
}

从json文件读取的路由表

TheRouter项目每次编译后,会在apk内生成一份路由表,默认路径为:/assets/therouter/routeMap.json

同时这份路由表也支持远端动态下发,例如远端可以针对不同的APP版本,下发不同的路由表达到配置目的。因此有两种推荐的方式可供使用方选择:

  1. 将打包系统与配置系统打通,每次新版本APP打包后自动将assets/目录中的配置文件上传到配置系统,下发给对应版本APP 。优点在于全自动不会出错。
  2. 配置系统无法打通,线上手动下发需要修改的路由项,因为 TheRouter 会自动用最新下发的路由项覆盖包内的路由项。优点在于精确,且流量资源占用小。

注:一旦你设置了自定义的InitTask,原框架内路由表初始化任务将不再执行,你需要自己处理找不到路由表时的兜底逻辑,一种建议的处理方式见如下代码。

// 此代码 必须 在 Application.super.onCreate() 之前调用
RouteMap.setInitTask(new RouterMapInitTask() {
    /** 
     * 此方法执行在异步
     */
    @Override
    public void asyncInitRouteMap() {
        // 此处为纯业务逻辑,每家公司远端配置方案可能都不一样
        // 不建议每次都请求网络,否则请求网络的过程中,路由表是空的,可能造成APP无法跳转页面
        // 最好是优先加载本地,然后开异步线程加载远端配置
        String json = Connfig.doHttp("routeMap");
        // 建议加一个判断,如果远端配置拉取失败,使用包内配置做兜底方案,否则可能造成路由表异常
        if (!TextUtils.isEmpty(json)) {
            List<RouteItem> list = new Gson().fromJson(json, new TypeToken<List<RouteItem>>() {
            }.getType());
            // 建议远端下发路由表差异部分,用远端包覆盖本地更合理
            RouteMap.addRouteMap(list);
        } else {
            // 在异步执行TheRouter内部兜底路由表
            initRouteMap()
        }
    }
});

执行跳转

执行跳转的主要方法总共有三个,分别是:跳转到Activity、获取跳转的Fragment、获取跳转的Intent。
主要逻辑都差不多,我们主要看 Activity 的跳转。

代码很长,我就不全贴了。Activity 的跳转分五个部分:

  • 判断是否延迟跳转
  • 拦截器处理
  • 解析跳转的路由表
  • 执行跳转
  • 跳转页面参数解析

延迟跳转

延迟跳转是个比较创新的设计,安装官网的说法,延迟跳转主要应用场景有两种:

  • 第一种:初始化时期,如果路由表的量非常巨大时。这种情况在别的路由框架上要么会白屏一段时间,要么直接丢弃这次跳转。在TheRouter中,框架会暂存当前的跳转动作,在路由表初始化完成后立刻执行跳转。
  • 第二种:从Android 8.0开始,Activity 不能在后台启动页面,这对于业务判断造成了很大的影响。由于可能会有前台 Service 的情况,不能单纯以 Activity 生命周期判断前后台。在TheRouter中,框架允许业务自定义前后台规则,如果为后台情况,可以将跳转动作暂存,当进入前台后再恢复跳转。
// 暂存的动作可以有多个,会在恢复时按顺序执行
TheRouter.build("http://therouter.com/home")
        .withInt("key1", 12345678)
        .padding()// 暂存当前跳转动作
        .navigation(context);
        
// 恢复
//toplevel方法,无需类名调用,Java请通过NavigatorKt类名调用
sendPendingNavigator();   

又是拦截器

在这一步其实是有两个拦截器,一个是在路由表解析之前,一个是在路由表解析之后。
这里我就一起讲了。

  1. 路由表解析之前的,叫 页面替换器

应用场景:需要将某些path指定为新链接的时候使用。 也可以用在修复链接的场景,但是与 path 修改器不同的是,修改器通常是为了解决通用性的问题,替换器只在页面跳转时才会生效,更多是用来解决特性问题。

例如模块化的时候,首页壳模板组件中开发了一个SplashActivity广告组件作为应用的MainActivity,在闪屏广告结束的时候自动跳转业务首页页面。 但是每个业务不同,首页页面的 Path 也不相同,而不希望让每个业务线自己去改这个首页壳模板组件,此时就可以组件中先写占位符https://kymjs.com/splash/to/home,让接入方通过 Path 替换器解决。

Navigator.addPathReplaceInterceptor(new PathReplaceInterceptor() {
    @Override
    public String replace(String path) {
        if ("https://kymjs.com/splash/to/home".equals(path)) {
            return "https://kymjs.com/business/home";
        }
        return path;
    }
});

2 . 路由表解析之后的,叫 路由替换器

应用场景:常用在未登录不能使用的页面上。例如访问用户钱包页面,在钱包页声明的时候,可以在路由表上声明本页面是需要登录的,在路由跳转过程中,如果落地页是需要登录的,则先替换路由到登录页,同时将原落地页信息作为参数传给登录页,登录流程处理完成后可以继续执行之前的路由操作。

路由替换器的拦截点更靠后,主要用于框架已经从路由表中根据 path 找到路由以后,对找到的路由做操作。

这种逻辑在所有页面跳转前写不太合适,以前的做法通常是在落地页写逻辑判断用户是否具有权限,但其实在路由层完成更合适。

Navigator.addRouterReplaceInterceptor(new RouterReplaceInterceptor() {
    @Override
    public RouteItem replace(RouteItem routeItem) {
        if (user.age() < 18 && routeItem.getClassName().contains("ChildrenProhibitActivity")) {
            RouteItem target = new RouteItem();
            target.setClassName(HomeActivity.class.getName());
            String[] path = {"https://kymjs.com/too/young"};
            target.setPathArray(path);
            target.setDescription("也可以在这里修改原有路由的参数信息");
            return target;
        }
        return routeItem;
    }
});

解析跳转的路由表

导航器跟路由表的交互,最核心的方法就是这个match方法,他是负责将一个url转换成TheRouter路由项的主要方法。

@Synchronized
fun matchRouteMap(url: String?): RouteItem? {
    var path = TheRouter.build(url ?: "").simpleUrl
    if (path.endsWith("/")) {
        path = path.substring(0, path.length - 1)
    }
    // copy是为了防止外部修改影响路由表
    val routeItem = ROUTER_MAP[path]?.copy()
    // 由于路由表中的path可能是正则path,要用入参替换掉
    routeItem?.path = path
    return routeItem
}

最后的跳转

最终的跳转,本质上还是调用的 context.startActivity 去做的,所以所有 Activity 的跳转方法,TheRouter也都支持。

if (fragment != null) {
    debug("Navigator::navigation", "fragment.startActivity ${routeItem.className}")
    fragment.startActivity(intent)
} else {
    debug("Navigator::navigation", "startActivity ${routeItem.className}")
    context.startActivity(intent)
}
val inAnimId = routeItem.getExtras().getInt(KEY_ANIM_IN)
val outAnimId = routeItem.getExtras().getInt(KEY_ANIM_OUT)
if (inAnimId != 0 || outAnimId != 0) {
    if (context is Activity) {
        debug("Navigator::navigation", "overridePendingTransition ${routeItem.className}")
        context.overridePendingTransition(
            routeItem.getExtras().getInt(KEY_ANIM_IN),
            routeItem.getExtras().getInt(KEY_ANIM_OUT)
        )
    } else {
        if (TheRouter.isDebug) {
            throw RuntimeException("TheRouter::Navigator context is not Activity, ignore animation")
        }
    }
}

跳转的回调

如果使用TheRouter跳转,传入了一个不识别的的path,则不会有任何处理。你也可以定义一个默认的全局回调,来处理跳转情况,如果落地页是 Fragment 则不会回调。
当然,跳转结果的回调不止这一个用途,可以根据业务有自己的处理。

回调也可以单独为某一次跳转设置,navigation()方法有重载可以传入设置。

NavigatorKt.defaultNavigationCallback(new NavigationCallback() {
    // 落地页Activity打开后,执行到onCreate会回调
    @Override
    public void onActivityCreated(@NonNull Navigator navigator, @NonNull Activity activity) {
        super.onActivityCreated(navigator, activity);
    }

    // startActivity执行后会立刻回调
    @Override
    public void onArrival(@NonNull Navigator navigator) {
        super.onArrival(navigator);
    }
    
    // 找到待跳转的落地页时就会回调(startActivity之前)
    @Override
    public void onFound(@NonNull Navigator navigator) {
        super.onFound(navigator);
    }

    // 找不到落地页的时候会回调
    @Override
    public void onLost(@NonNull Navigator navigator) {
        super.onLost(navigator);
    }
});

页面参数解析

TheRouter的所有页面跳转参数解析都可以通过 @Autowired 解析的,当然也能通过 Intent 去解析。 Intent解析我们都会,就不说了,下面讲一下 @Autowired 的实现。

所有加了 @Autowired 注解的类,在编译以后都会生成一个单独的工具类,XXX__TheRouter__Autowired,这个类就是用来填充变量内容的。 实际上我们调用的inject() 方法:

TheRouter.inject(this);

就会间接调用生成类去做填充变量。

而这个填充过程的实现,实际上是由AutowiredParser去实现的。

这个 Parser 是允许我们自定义的,也就是说如果我们希望替换掉TheRouter的解析,也可以通过自定义的方式实现对 @Autowired 的解析。TheRouter内默认的解析方式是这样的:

class DefaultUrlParser : AutowiredParser {

    override fun <T> parse(type: String?, target: Any?, item: AutowiredItem?): T? {
        if (item?.id != 0) {
            return null
        }
        when (target) {
            is Activity -> {
                return parseValue(target.intent?.extras?.get(item.key), type) as T?
            }
            is Fragment -> {
                return parseValue(target.arguments?.get(item.key), type) as T?
            }
            is androidx.fragment.app.Fragment -> {
                return parseValue(target.arguments?.get(item.key), type) as T?
            }
        }
        return null
    }

    private fun <T> parseValue(value: Any?, type: String?): T? {
        if (value == null || type == null) {
            return null
        }

        if (value.javaClass.name.equals(transformNumber(type), true)) {
            return value as T?
        }

        if (value.javaClass.name.equals("java.lang.String")) {
            try {
                return transform(type, value.toString()) as T?
            } catch (e: NumberFormatException) {
            }
        }
        return null
    }
}

其他API

判断一个 url 是否为路由Path
如果返回为空,表示当前url不是路由表内的path

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

推荐阅读更多精彩内容