考拉Android客户端路由总线设计 (上篇)

猪小花1号2018-08-28 12:05

前言

当前,Android路由框架已经有很多了,如雨后春笋般出现,大概是因为去年提出了Android组件化的概念。当一个产品的业务规模上升到一定程度,或者是跨团队开发时,团队/模块间的合作问题就会暴露出来。如何保持团队间业务的往来?如何互不影响或干涉对方的开发进度?如何调用业务方的功能?组件化给上述问题提供了一个答案。组件化所要解决的核心问题是解耦,路由正是为了解决模块间的解耦而出现的。本文阐述了考拉Android端的路由设计方案,尽管与市面上的方案大同小异,但更多的倾向于与考拉业务进行一定程度的结合。


传统的页面跳转

页面跳转主要分为三种,App页面间跳转、H5跳转回App页面以及App跳转至H5。


App页面间跳转

App页面间的跳转,对于新手来说一般会在跳转的页面使用如下代码:

Intent intent = new Intent(this, MainActivity.class);
intent.putExtra("dataKey", "dataValue");
startActivity(intent);

对于有一定经验的程序员,会在跳转的类生成自己的跳转方法:

public class OrderManagerActivity extends BaseActivity {
    public static void launch(Context context, int startTab) {
        Intent i = new Intent(context, OrderManagerActivity.class);
        i.putExtra(INTENT_IN_INT_START_TAB, startTab);
        context.startActivity(i);
    }
}

无论使用哪种方式,本质都是生成一个Intent,然后再通过Context.startActivity(Intent)/Activity.startActivityForResult(Intent, int)实现页面跳转。这种方式的不足之处是当包含多个模块,但模块间没有相互依赖时,这时候的跳转会变得相当困难。如果已知其他模块的类名以及对应的路径,可以通过Intent.setComponent(Component)方法启动其他模块的页面,但往往模块的类名是有可能变化的,一旦业务方把模块换个名字,这种隐藏的Bug对于开发的内心来说是崩溃的。另一方面,这种重复的模板代码,每次至少写两行才能实现页面跳转,代码存在冗余。


H5-App页面跳转

对于考拉这种电商应用,活动页面具有时效性和即时性,这两种特性在任何时候都需要得到保障。运营随时有可能更改活动页面,也有可能要求点击某个链接就能跳转到一个App页面。传统的做法是对WebViewClient.shouldOverrideUrlLoading(WebView, String)进行拦截,判断url是否有对应的App页面可以跳转,然后取出url中的params封装成一个Intent传递并启动App页面。

感受一下在考拉App工程中曾经出现过的下面这段代码:

public static Intent startActivityByUrl(Context context, String url, boolean fromWeb, boolean outer) {
    if (StringUtils.isNotBlank(url) && url.startsWith(StringConstants.REDIRECT_URL)) {  
        try {
            String realUrl = Uri.parse(url).getQueryParameter("target");
            if (StringUtils.isNotBlank(realUrl)) {
                url = URLDecoder.decode(realUrl, "UTF-8");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

Intent intent = null;
try {
Uri uri = Uri.parse(url);
String host = uri.getHost();
List<String> pathSegments = uri.getPathSegments();
String path = uri.getPath();
int segmentsLength = (pathSegments == null ? 0 : pathSegments.size());

if (!host.contains(StringConstants.KAO_LA)) {
return null;
}

if((StringUtils.isBlank(path))){
do something...
return intent;
}

if (segmentsLength == 2 && path.startsWith(StringConstants.JUMP_TO_GOODS_DETAIL)) {
do something...
} else if (path.startsWith(StringConstants.JUMP_TO_SPRING_ACTIVITY_TAB)) {
do something...
} else if (path.startsWith(StringConstants.JUMP_TO_SPRING_ACTIVITY_DETAIL) && segmentsLength == 3) {
do something...
} else if (path.startsWith(StringConstants.START_CART) && segmentsLength == 1) {
do something...
} else if (path.startsWith(StringConstants.JUMP_TO_COUPON_DETAIL)
|| (path.startsWith(StringConstants.JUMP_TO_COUPON) && segmentsLength == 2)) {
do something...
} else if (canOpenMainPage(host, uri.getPath())) {
do something...
} else if (path.startsWith(StringConstants.START_ORDER)) {
if (!UserInfo.isLogin(context)) {
do something...
} else {
do something...
}
} else if (path.startsWith(StringConstants.START_SAVE)) {
do something...
} else if (path.startsWith(StringConstants.JUMP_TO_NEW_DISCOVERY)) {
do something...
} else if (path.startsWith(StringConstants.JUMP_TO_NEW_DISCOVERY_2) && segmentsLength == 3) {
do something...
} else if (path.startsWith(StringConstants.START_BRAND_INTRODUCE)
|| path.startsWith(StringConstants.START_BRAND_INTRODUCE2)) {
do something...
} else if (path.startsWith(StringConstants.START_BRAND_DETAIL) && segmentsLength == 2) {
do something...
} else if (path.startsWith(StringConstants.JUMP_TO_ORDER_DETAIL)) {
if (!UserInfo.isLogin(context) && outer) {
do something...
} else {
do something...
}
} else if (path.startsWith("/cps/user/certify.html")) {
do something...
} else if (path.startsWith(StringConstants.IDENTIFY)) {
do something...
} else if (path.startsWith("/album/share.html")) {
do something...
} else if (path.startsWith("/album/tag/share.html")) {
do something...
} else if (path.startsWith("/live/roomDetail.html")) {
do something...
} else if (path.startsWith(StringConstants.JUMP_TO_ORDER_COMMENT)) {
if (!UserInfo.isLogin(context) && outer) {
do something...
} else {
do something...
}
} else if (openOrderDetail(url, path)) {
if (!UserInfo.isLogin(context) && outer) {
do something...
} else {
do something...
}
} else if (path.startsWith(StringConstants.JUMP_TO_SINGLE_COMMENT)) {
do something...
} else if (path.startsWith("/member/activity/vip_help.html")) {
do something...
} else if (path.startsWith("/goods/search.html")) {
do something...
} else if(path.startsWith("/afterSale/progress.html")){
do something...
} else if(path.startsWith("/afterSale/apply.html")){
do something...
} else if(path.startsWith("/order/track.html")) {
do something...
}
} catch (Exception e) {
e.printStackTrace();
}

if (intent != null && !(context instanceof Activity)) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
return intent;
}

这段代码整整260行,看到代码时我的内心是崩溃的。这种做法的弊端在于:

  • 判断不合理。上述代码仅判断了HOST是否包含StringConstants.KAO_LA,然后根据PATH区分跳转到哪个页面,PATH也只判断了起始部分,当URL越来越多的时候很有可能造成误判。
  • 耦合性太强。已知拦截的所有页面的引用都必须能够拿到,否则无法跳转;
  • 代码混乱。PATH非常多,从众多的PATH中匹配多个已知的App页面,想必要判断匹配规则就要写很多函数解决;
  • 拦截过程不透明。开发者很难在URL拦截的过程中加入自己的业务逻辑,如打点、启动Activity前添加特定的Flag等;
  • 没有优先级概念,也无法降级处理。同一个URL,只要第一个匹配到App页面,就只能打开这个页面,无法通过调整优先级跳转到别的页面或者使用H5打开。


App页面-H5跳转

这种情况不必多说,启动一个WebViewActivity即可。


页面路由的意义

路由最先被应用于网络中,路由的定义是通过互联的网络把信息从源地址传输到目的地址的活动。页面跳转也是相当于从源页面跳转到目标页面的过程,每个页面可以定义为一个统一资源标识符(URI),在网络当中能够被别人访问,也可以访问已经被定义了的页面。路由常见的使用场景有以下几种:

  • App接收到一个通知,点击通知打开App的某个页面(OuterStartActivity)
  • 浏览器App中点击某个链接打开App的某个页面(OuterStartActivity)
  • App的H5活动页面打开一个链接,可能是H5跳转,也可能是跳转到某一个native页面(WebViewActivity)
  • 打开页面需要某些条件,先验证完条件,再去打开那个页面(需要登录)
  • App内的跳转,可以减少手动构建Intent的成本,同时可以统一携带部分参数到下一个页面(打点)

除此之外,使用路由可以避免上述弊端,能够降低开发者页面跳转的成本。


考拉路由总线

路由框架

点击查看大图

考拉路由框架主要分为三个模块:路由收集路由初始化以及页面路由。路由收集阶段,定义了基于Activity类的注解,通过Android Processing Tool(以下简称“APT”)收集路由信息并生成路由表类;路由初始化阶段,根据生成的路由表信息注入路由字典;页面路由阶段,则通过路由字典查找路由信息,并根据查找结果定制不同的路由策略略。

路由设计思路

总的来说,考拉路由设计追求的是功能模块的解耦,能够实现基本路由功能,以及开发者使用上足够简单。考拉路由的前两个阶段对于路由使用者几乎是无成本的,只需要在使用路由的页面定义一个类注解@Router即可,页面路由的使用也相当简单,后面会详细介绍。


功能设计

路由在一定程度上和网络请求是类似的,可以分为请求、处理以及响应三个阶段。这三个阶段对使用者来说既可以是透明的,也可以在路由过程中进行拦截处理。考拉路由框架目前支持的功能有:


  1. 支持基本Activity的启动,以及startActivityForResult回调;✅
  2. 支持不同协议执行不同跳转;(kaola://*、http(s)://*、native://*等)✅
  3. 支持多个SCHEME/HOST/PATH跳转至同一个页面;((pre.)*.kaola.com(.hk))✅
  4. 支持路由的正则匹配;✅
  5. 支持Activity启动使用不同的Flag;✅
  6. 支持路由的优先级配置;✅
  7. 支持对路由的动态拦截、监听以及降级;✅

以上功能保证了考拉业务模块间的解耦,也能够满足目前产品和运营的需求。


接口设计

点击查看大图


一个好的模块或框架,需要事先设计好接口,预留足够的权限供调用者支配,才能满足各种各样的需求。考拉路由框架在设计过程中使用了常见的设计模式,如Builder模式、Factory模式、Wrapper模式等,并遵循了一些设计原则。(最近在看第二遍Effective Java,对以下原则深有体会,推荐看一下)

  • 针对接口编程,而不是针对实现编程

这条规则排在最前面的原因是,针对接口编程,不管是对开发者还是对使用者,真的是百利而无一害。在路由版本迭代的过程中,底层对接口无论实现怎样的修改,也不会影响到上层调用。对于业务来说,路由的使用是无感知的。


考拉路由框架在设计过程中并未完全遵循这条原则,下一个版本的迭代会尽量按照这条原则来实现。但在路由过程中的关键步骤都预留了接口,具体有:


RouterRequestCallback

public interface RouterRequestCallback {
    void onFound(RouterRequest request, RouterResponse response);

    boolean onLost(RouterRequest request);
}

路由表中是否能够匹配到路由信息的回调,如果能够匹配,则回调onFound(),如果不能够匹配,则返回onLost()onLost()的结果由开发来定义,如果返回的结果是true,则认为开发者处理了这次路由不匹配的结果,最终返回RouterResult的结果是成功路由。


RouterHandler

public interface RouterHandler extends RouterStarter {
    RouterResponse findResponse(RouterRequest request); 
}

路由处理与启动接口,根据给定的路由请求,查找路由信息,根据路由响应结果,分发给相应的启动器执行后续页面跳转。这个接口的设计不太合理,功能上不完善,后续会重新设计这个接口,让调用方有权限干预查找路由的过程。


RouterResultCallback

public interface RouterResultCallback {
    boolean beforeRoute(Context context, Intent intent);

    void doRoute(Context context, Intent intent, Object extra);

    void errorRoute(Context context, Intent intent, String errorCode, Object extra);
}

匹配到路由信息后,真正执行路由过程的回调。beforeRoute()这个方法是在真正路由之前的回调,如果开发者返回true,则认为这条路由信息已被调用者拦截,不会再回调后面的doRoute()以及执行路由。在路由过程中发生的任何异常,都会回调errorRoute()方法,这时候路由中断。


ResponseInvoker

public interface ResponseInvoker {
    void invoke(Context context, Intent intent, Object... args);
}

路由执行者。如果开发需要执行路由前进行一些全局操作,例如添加额外的信息传入到下一个Activity,则可以自己实现这个接口。路由框架提供默认的实现:ActivityInvoker。开发也可以继承ActivityInvoker,重写invoke()方法,先实现自己的业务逻辑,再调用super.invoke()方法。


OnActivityResultListener

public interface OnActivityResultListener {
    void onActivityResult(int requestCode, int resultCode, Intent data);
}

特别强调一下这个Listener。本来这个回调的作用是方便调用者在执行startActivityForResult的时候可以通过回调来告知结果,但由于不保留活动的限制,离开页面以后这个监听器是无法被系统保存(saveInstanceState)的,因此不推荐在Activity/Fragment中使用回调,而是在非Activity组件/模块里使用,如View/Window/Dialog。这个过程已经由core包里的CoreBaseActivity实现,开发使用的时候,可以直接调用CoreBaseActivity.startActivityForResult(intent, requestCode, onActivityResultListener),也可以通过KaolaRouter.with(context).url(url).startForResult(requestCode, onActivityResultListener)调用。例如,要启动订单管理页并回调:

KaolaRouter.with(context)
        .url(url)
        .data("orderId", "replace url param key.")
        .startForResult(1, new OnActivityResultListener() {
            @Override
            public void onActivityResult(int requestCode, int resultCode, Intent data) {
                DebugLog.e(requestCode + " " + resultCode + " " + data.toString());
            }
        });

RouterResult

public interface RouterResult {

    boolean isSuccess();

    RouterRequest getRouterRequest();

    RouterResponse getRouterResponse();
}

告知路由的结果,路由结果可以被干预,例如RouterRequestCallback.onLost(),返回true的时候,路由也是成功的。这个接口不管路由的成功或失败都会返回。


不随意暴露不必要的API

“要区别设计良好的模块与设计不好的模块,最重要的因素在于,这个模块对于外部的其他模块而⾔言,是否隐藏其内部数据和其他实现细节。设计良好的模块会隐藏所有的实现细节,把它的API与它的实现清晰地隔离开来。然后,模块之间只通过它们的API进行通信,一个模块不需要知道其他模块的内部工作情况。这被称为封装(encapsulation)。”(摘自Effective Java, P58)

举个例子,考拉路由框架对路由调用的入参做了限制,一旦入参,则不能再做修改,调用者无需知道路由框架对使用这些参数怎么实现调用者想要的功能。实现上,由RouterRequestWrapper继承自RouterRequestBuilder,后者通过Builder模式给用户构造相关的参数,前者通过Wrapper模式装饰RouterRequestBuilder中的所有变量,并在RouterRequestWrapper类中提供所有参数的get函数,供路由框架使用。


单一职责

无论是类还是方法,均需要遵循单一职责原则。一个类实现一个功能,一个方法做一件事。例如,KaolaRouterHandler是考拉路由的处理器,实现了RouterHandler接口,实现路由的查找与转发;RouterRequestBuilder用于收集路由请求所需参数;RouterResponseFactory用于生成路由响应的结果。


提供默认实现

针对接口编程的好处是随时可以替换实现,考拉路由框架在路由过程中的所有监听、拦截以及路由过程都提供了默认的实现。使用者即可以不关心底层的实现逻辑,也可以根据需要替换相关的实现。


考拉路由实现原理

考拉路由框架基于注解收集路由信息,通过APT实现路由表的动态生成,类似于ButterKnife的做法,在运行时导入路由表信息,并通过正则表达式查找路由,根据路由结果实现最终的页面跳转。


收集路由信息

首先定义一个注解@Router,注解里包含了路由协议、路由主机、路由路径以及路由优先级。

@Target(ElementType.TYPE) 
@Retention(RetentionPolicy.CLASS) 
public @interface Router {
    /**
     * URI协议,已经提供默认值,默认实现了四种协议:https、http、kaola、native
     */
    String scheme() default "(https|http|kaola|native)://";

    /**
     * URI主机,已经提供默认值
     */
    String host() default "(pre\\.)?(\\w+\\.)?kaola\\.com(\\.hk)?";

    /**
     * URI路径,选填,如果使用默认值,则只支持本地路由,不支持url拦截
     */
    String value() default "";

    /**
     * 路由优先级,默认为0。
     */
    int priority() default 0;
}

对于需要使用路由的页面,只需要在类的声明处加上这个注解,标明这个页面对应的路由路径即可。例如:

@Router("/app/myQuestion.html") 
public class MyQuestionAndAnswerActivity extends BaseActivity { 
    ……
}

那么通过APT生成的标记这个页面的url则是一个正则表达式:

(https|http|kaola|native)://(pre\.)?(\w+\.)?kaola\.com(\.hk)?/app/myQuestion\.html

路由表则是由多条这样的正则表达式构成。 


网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者黎星授权发布。