android 组件化_网易严选 Android 组件化实践

点击上方“刘望舒”,选择“星标”

多点在看,就是真爱!

作者 : zyl06  

转载自:

https://www.jianshu.com/p/a503db59c6eb

0 背景

早前严选 Android 工程,业务模块和功能模块不多,工程较为简单,全部的业务代码均在主 app 工程,全部的业务 Activity 均在 module/ 目录下,相关的网络请求封装在 http 目录下,使用 volley 封装支持 http 请求和 wzp 请求;业务请求协议实现,均放在 app/httptask/ 下,业务层使用请求不区分是 wzp 还是 http(s);全部的工具方法,如 DeviceUtil、BitmapHelper 均在 commno/util/ 里面;全局事件 EventBus event model 放在 eventbus/。

common/
    util/
        DeviceUtil
        ...
module/
    PayCompleteActivity
    ...
http/
httptask/
    LoginWzpTask
    ...
eventbus/
...

其中页面之间的跳转,使用原声 Intent 方式。为规范参数传递,做了编码规范,使用静态方法的方式唤起 Activity

public static void start(Context context, ComposedOrderModel model, String skuList) {
    Intent intent = new Intent(context, OrderCommoditiesActivity.class);
    ...
    context.startActivity(intent);
}

public static void start(Context context, ComposedOrderModel model, int skuId, int count) {
    Intent intent = new Intent(context, OrderCommoditiesActivity.class);
    ...
    context.startActivity(intent);
}

OrderCommoditiesActivity

public static void startForResult(Activity context, int requestCode, int selectedCouponId, int skuId, int count, String skuListStr) {
    Intent intent = new Intent(context, CouponListActivity.class);
    ...
    context.startActivityForResult(intent, requestCode);
}

CouponListActivity

针对推送和 H5 scheme 唤起和跳转 Activity,我们编写了统一 scheme 跳转派发逻辑:

public class RouterUtil {
    public static Intent getRouteIntent(Context context, Uri uri) {
        if (uri == null || !TextUtils.equals(uri.getScheme(), "yanxuan")) {
            return null;
        }
        String host = uri.getHost();
        if (host == null) {
            return null;
        }

        Class> clazz = null;
        String param = null;
        switch (host) {
            case ConstantsRT.GOOD_DETAIL_ROUTER_PATH:
                clazz = GoodsDetailActivity.class;
                ...
                break;
            ...
        }
        Intent intent = null;
        if (clazz != null) {
            intent = new Intent();
            intent.setClass(context, clazz);
        }
        return intent;
    }
}

根据输入 scheme,返回跳转 Activity 的 intent

view.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (!TextUtils.isEmpty(schemeUrl)) {
                Intent intent = RouterUtil.getRouteIntent(Uri.parse(schemeUrl));
                if (intent != null) {
                    view.getContext().startActivity(intent);
                }
            }
        }
    });

RouterUtil.getRouteIntent 使用样例

因为是单一 app 工程(排除和业务无关的第三方组件),所以工程内全部的 Activity类、接口、EventBus model 都是可见的,且由于工程量较小,Activity 页面数量尚不多,早期使用上述做法并不会碰到问题。但很快随着版本迭代,业务量的增长,很快爆发出来的就是 scheme 跳转派发逻辑的维护问题

public class RouterUtil {
    public static Intent getRouteIntent(Context context, Uri uri) {
        ...
        switch (host) {
            case ConstantsRT.GOOD_DETAIL_ROUTER_PATH:
                clazz = GoodsDetailActivity.class;
                ...
                break;
            case ConstantsRT.ORDER_DETAIL_ROUTER_PATH:
                clazz = OrderDetailActivity.class;
                ...
                break;
            ...
            ... 省略 28 个 case! ☹️
            ...
            default:
                break;
        }

        ...
    }
}

当严选 2.x.x 版本的时候,我们的 switch-case 就达到 30 个,代码明显不好维护了。同时,我们的 scheme 协议完全是按照业务需求来增加,不支持 scheme 跳转的大量 Activity 很容易和 iOS 不统一,如页面实现和参数使用方面,导致后期开放成 scheme 协议的时候,需要大量的沟通和业务代码修改。

  • 页面实现:Android Activity 还是 Fragment,iOS ViewController 还是 UIView

  • 参数使用:平台相关的参数,以及参数的定义形式

当严选 3.x.x 版本的时候,工程中就已经出现跨工程接口复用的问题(如跨工程需要支持埋点、本地异常日志记录模块等);当严选 4.x.x 版本的时候,需要处理处理跨模块 wzp 请求复用、跨工程 EventBus 通信问题。上述的简单设计已经完全不满足场景,本文就介绍严选在多版本迭代过程中,如何逐步处理和优化页面组件化、基础功能组件化。

1 页面组件化 ht-router 接入

参考 DeepLink从认识到实践,接入杭研 ht-router,由此通过注解的方式统一了 H5 唤醒、推送唤醒、正常启动 APP 的逻辑,上面点击跳转的逻辑得到了简化:

view.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        HTRouterManager.startActivity(view.getContext(), schemeUrl, null, false);
    }
});

RouterUtil 中冗长的 switch-case 代码也得到得到了极大的改善,统一跳转可通过 scheme 参数直接触发跳转,近 30 个 switch-case 减少至 7 个

HTRouterManager.init();
...
// 设置跳转前的拦截,返回 true 拦截不再跳转,返回 false 继续跳转
HTRouterManager.setHtRouterHandler(new HTRouterHandler() {
    @Override
    public boolean handleRoute(Context context, HTRouterHandlerParams routerParams) {
        final Uri uri = !TextUtils.isEmpty(routerParams.url) ? Uri.parse(routerParams.url) : null;
        if (uri == null) {
            return true;
        }

        String host = uri.getHost();
        if (TextUtils.isEmpty(host)) {
            return true;
        }

        switch (host) {
            case ConstantsRT.CATEGORY_ROUTER_PATH: //"category"
                ...
                break;
            ...
            ...省略 5 个
            ...
            case ConstantsRT.MINE_ROUTER_PATH:
                ...
                break;
            default:
                break;
        }
        return false;
    }
});

至于为什么还有 7 个,大体分 2 类

1.历史原因
严选工程中 CategoryL2Activity 有 yanxuan://category 和 yanxuan://categoryl2 2 个 scheme,而同一个参数 categoryid 在不同的 scheme 下有不同的含义,为此在拦截器中添加新的字段,CategoryL2Activity 中仅需处理 2 个新加的字段,不必知道自身的 scheme

2.跳转 Activity 的不同 fragment
严选首页 MainPageActivity 拥有 5 个 tab fragment,不同的 tab 会有不同的 scheme,拦截器中直接根据不同的 scheme,添加参数来指定不同的 tab,首页仅需处理 tab 参数显示不同的 fragment08f6815fcb8357a89016e4c693656ab2.png

ht-router 的其他优点、用法、api 见文章 DeepLink从认识到实践,这里不再叙述

2 ht-router 的痛点

ht-router 对工程框架的作用是巨大的,然而随着多期业务迭代和工程复杂度的提升,逐渐发现路由框架的多个痛点:

下述痛点,在其他第三方框架上很多并不存在。当时集成的时候,router 框架刚兴起,ARouter、天猫统跳、ActivityRouter 等并没有像现在的功能强大;另外如 ARouter,通过 path 定义 group 和跳转目标,而严选工程以 host 标识跳转目标,也有些差异

2.1 apt 生成代码量过大,业务开发较难维护

ht-router 通过 apt 生成的类有 6 个,其中 HTRouterManager 有 600 行代码,去除 init 方法中初始化 router 映射表的 100 行左右代码,剩余还有 500 行左右

bfe2f6be8380cf024319adda45f44371.png

apt 生成的类目录

87f5b60512e2cc7573b000a7b00cf247.png

HTRouterManager.java

参考 apt 的用法,若要生成一个简单的类,对应的 apt 代码会复杂的多。当目标代码量比较多的情况下,apt 的生成代码就会比较难以维护,根据业务场景添加接口,或者修改字段都会相比更加困难。另外 apt 的调试也比较辛苦,需要编译后再查看目标代码是否是有错误。

这里给 ht-router 的开发同学献上膝盖,为业务团队贡献了很多!

/**
 * apt 测试代码
 */
public class TestClass {
  public static final String STATIC_FIELD = "ht_url_params_map";

  public void foo() {
    System.out.println("hello world");
  }
}

目标代码

TypeSpec.Builder testbuilder = classBuilder("TestClass")
            .addModifiers(PUBLIC);
testbuilder.addJavadoc("apt 测试代码\n");
FieldSpec testFieldSpec = FieldSpec
        .builder(String.class, "STATIC_FIELD",
                PUBLIC, STATIC, FINAL)
        .initializer("\"ht_url_params_map\"").build();
testbuilder.addField(testFieldSpec);

MethodSpec.Builder testMethod = MethodSpec.methodBuilder("foo")
        .addModifiers(Modifier.PUBLIC)
        .returns(void.class);
testMethod.addStatement("System.out.println(\"hello world\")");
testbuilder.addMethod(testMethod.build());
TypeSpec generatedClass = testbuilder.build();
JavaFile javaFile = builder(packageName, generatedClass).build();
try {
    javaFile.writeTo(filer);
} catch (IOException e) {
    e.printStackTrace();
}

生成目标代码的 apt 代码

2.2 路由表和业务直接关联

由于整个路由表在 HTRouterManager 中,偶现(常见合并分支后)由于业务代码编译不通过,导致 apt 代码未生成,大量提示报错 HTRouterManager 找不到,但无法定位到真正的业务代码错误逻辑。

b1b72ab1ee5c8364d62b9c4275259675.png

由于 HTRouterManager 在业务代码中广泛被使用,暂未有很好的办法解决这个报错,临时的处理办法是从同事处拷贝 apt 文件夹,临时绕过错误报错,修改业务层代码错误后 rebuild

第一次碰到比较懵逼,花了不少时间处理定位和解决问题,(⊙﹏⊙)b

2.3 拦截功能不满足登录需求

针对未登录状态,跳转需要登录状态的 Activity 的场景,我们期望是先唤起登录页,登录成功后,关闭登录页重定向至目标 Activity;若用户退出登录页,则回到上一个页面。针对已登录状态,则直接唤起目标页面。对于这个需求,ht-router 并不满足,虽然提供了 HTRouterHandler,但仅能判断根据返回值判断是否继续跳转,无法在登录回调中决定是否继续跳转。

public static void startActivity(Activity activity, String url, Intent sourceIntent, boolean isFinish, int entryAnim, int exitAnim) {
    Intent intent = null;
    HTRouterHandlerParams routerParams = new HTRouterHandlerParams(url, sourceIntent);
    if (sHtRouterHandler != null && sHtRouterHandler.handleRoute(activity, routerParams)) {
        return;
    }
    ...
}
2.4 需要拦截处理特殊 scheme 的逻辑还在全局

前面 RouterUtil 中的 switch-case 从 30 个大幅降至 7 个(即便是 7 个,感觉代码也不优雅),但这里的特殊处理逻辑属于各个页面的业务逻辑,不应该在 RouterUtil 中。路由的一个很大作用,就是将各个页面解耦,能为后期模块化等需求打下坚实基础,而这里的全局拦截处理逻辑,显然是和模块解耦是背道而驰的。

当然这些特殊的处理逻辑完全可以挪到各个 Activity 中,但是不是有机制能很好的处理这种场景,同时 Activity 是否需要关心自身当前的 scheme 是什么?

2.5 sdk 页面,无法添加路由注解

我们发现接入的子工程如图片选择器等也有自己的页面,而 apt 的代码生成功能是对 app 工程生效,不支持其他子工程的路由注解,为此子工程的页面就无法享受路由带来的好处。

2.6 router 初始化为类引用,阻碍 main dex 优化

最初通过 multidex 方案解决了 65535 问题后,2年后的现在,又爆出了 Too many classes in –main-dex-list 错误。
原因:dex 分包之后,各 dex 还是遵循 65535 的限制,而打包流程中 dx --dex --main-dex-list=中的 maindexlist.txt 决定了哪些类需要放置进 main-dex。默认 main-dex 包含 manifest 中注册的四大组件,Application、Annonation、multi-dex 相关的类。由于 app 中 四大组件 (特别是 Activity) 比较多和 Application 中的初始化代码,最终还是可能导致 main-dex 爆表。
查看 android−sdks/build−tools/{build-tool-version}/mainDexClasses.rules

-keep public class * extends android.app.Instrumentation {
    ();
}
-keep public class * extends android.app.Application {();
    void attachBaseContext(android.content.Context);
}
-keep public class * extends android.app.Activity {();
}
-keep public class * extends android.app.Service {();
}
-keep public class * extends android.content.ContentProvider {();
}
-keep public class * extends android.content.BroadcastReceiver {();
}
-keep public class * extends android.app.backup.BackupAgent {();
}# We need to keep all annotation classes because proguard does not trace annotation attribute# it just filter the annotation attributes according to annotation classes it already kept.
-keep public class * extends java.lang.annotation.Annotation {
    *;
}

解决方法

1.gradle 1.5.0 之前
执行 dex 命令时添加 --main-dex-list 和 --minimal-main-dex 参数。而这里 maindexlist.txt 中的内容需要开发生成,参考 main-dex 分析工具

afterEvaluate {
    tasks.matching {
        it.name.startsWith("dex")
    }.each { dx ->
        if (dx.additionalParameters == null) {
            dx.additionalParameters = []
        }
    // optional
    dx.additionalParameters += "--main-dex-list=$projectDir/maindexlist.txt".toString()
    dx.additionalParameters += "--minimal-main-dex"
    }
}

参考文章 MultiDex中出现的main dex capacity exceeded解决之道

2.gradle 1.5.0 ~ 2.2.0
现严选使用 gradle plugin 2.1.2,并不支持上面的方法,可使用如下方法。

//处理main dex 的方法测试
afterEvaluate {
    def mainDexListActivity = ['SplashActivity', 'MainPageActivity']
    project.tasks.each { task ->
        if (task.name.startsWith('collect')
                && task.name.endsWith('MultiDexComponents')
                && task.name.contains("Debug")) {
            println "main-dex-filter: found task $task.name"
            task.filter { name, attrs ->
                String componentName = attrs.get('android:name')
                if ('activity'.equals(name)) {
                    def result = mainDexListActivity.find {
                        componentName.endsWith("${it}")
                    }
                    return result != null
                } else {
                    return true
                }
            }
        }
    }
}

这里过滤掉除 SplashActivity,MainPageActivity 之外的其他 activity,但 main-dex 中未满 65535 之前,其他 activity 或类也可能在 main-dex 中,并不能将 main-dex 优化为最小。

可参考 DexKnifePlugin 优化 main-dex 为最小。(自己并未实际用过)
参考文章 http://p.codekk.com/detail/Android/TangXiaoLv/Android-Easy-MultiDex

3.gradle 2.3.0
gradle 中通过 multiDexKeepProguard 或 multiDexKeepFile 设置必须放置 main-dex 的类。
其次设置 additionalParameters 优化 main-dex 为最小

dexOptions {
    additionalParameters '--multi-dex', '--minimal-main-dex', '--main-dex-list=' + file('multidex-config.txt').absolutePath'
}

严选 gradle 版本为 2.1.2,然而按照上述的解决方法发现并没有效果,查看 Application 初始化代码,可以发现 HTRouterManager.init 中引用了全部的 Activity 类

public static void init() {
    ...
    entries.put("yanxuan://newgoods", new HTRouterEntry(NewGoodsListActivity.class, "yanxuan://newgoods", 0, 0, false));
    entries.put("yanxuan://popular", new HTRouterEntry(TopGoodsRcmdActivity.class, "yanxuan://popular", 0, 0, false));
    ...
}
2.7 业务扩张导致路由表过大,内存和性能受损

严选 4.1.7 版本,页面跳转路由表已经注册了 125 个 scheme 协议,其中对外公开使用的 39 个scheme,为此我们 app 工程的页面路由表巨大,而这个路由表在 app 启动的时候就必须初始化装载到内存,整个 app 运行过程中,必须一直持有这部分内存。而一次进程生命周期中,只有少部分 scheme 协议会被用到。
除了内存消耗,另一个比较严重和明显的是性能损失。原因是 ht-router 进行路由表匹配的时候,支持正则匹配。根据 scheme 在路由表中查找路由数据时,需要遍历查找。而现在路由表已经变得巨大,将会导致每次路由查找非常的耗时。

http://m.you.163.com/product/{id}.html

支持匹配以下这种形式的 scheme:

http://m.you.163.com/product/1.html
http://m.you.163.com/product/101.html

2.8 路由跳转不支持自定义降级

ht-router 跳转路由表中没有的 url 时,支持自动将 scheme 替换成 http,然后降级成 H5 页面去加载。而推送业务场景中,因为 app 版本兼容性原因需要的降级方案复杂的多:

  1. 新版本支持商品详情页,老版本不支持详情页,当老版本 app 打开推送内容的时候,我们期望打开 H5 页面展示商品详情

  2. 新版本支持推送跳转红包雨界面,当老版本 app 打开推送内容的时候,我们期望打开 H5 页面引导用户下载更新 APP

  3. 新版本支持购物车独立页面,老版本仅有首页购物车 tab 页,我们期望相同的推送,老版本能打开首页购物车 tab 页

以上业务场景,要求我们路由跳转支持自定义的降级方案

2.9 路由表不支持跨工程

由于跨工程后,假设 2 个业务工程都集成了 ht-router,使用 apt 实现的路由表生成逻辑会被执行 2 次,由于生成的 class 类是相同包名和类名,为此后期编译会产生类冲突。此外,多个路由表如何整合使用,也是我们要考虑的地方。

3 router 框架优化

3.1 apt 生成代码量过大问题优化

思考框架本身,其实可以发现仅有 router 映射表是需要根据注解编译生成的,其他的全部代码都是固定代码,完全可以 sdk 中直接编码提供。反过来思考为何当初 sdk 开发需要编写繁重的 apt 生成代码,去生成这些固定的逻辑,可以发现 htrouterdispatch-process 工程是一个纯 java 工程,部分纯 java 类的提供在 htrouterdispatch。由于无法引用 Android 类,同时期望业务层接口能完美隐藏内部实现,为此和 Android 相关的类,索性全部由 apt 生成。

apply plugin: 'java' // 使用 apply plugin: 'com.android.library' 编译报错

sourceCompatibility = JavaVersion.VERSION_1_7
targetCompatibility = JavaVersion.VERSION_1_7

dependencies {
    compile project (':htrouterdispatch')
    compile 'com.google.auto.service:auto-service:1.0-rc2'
    compile 'com.squareup:javapoet:1.0.0'
}

为了解决这里的问题,可以修改 HTRouterManager 的初始化接口,使用 router 映射表显式的传入,其中几个参数均有 HTRouterTable 提供。修改后就能发现仅有 HTRouterTable 里面的映射表接口需要 apt 生成,而其余的代码均可通过直接编码。

public class HTRouterManager {
    public static void init() {
        ...
    }
}

public class HTRouterManager {
    public static void init(Map pageGroups,
                            List methodEntries,
                            List annoInterceptors) {
        ...
    }
}

public final class HTRouterTable {

    ...
    private final HashMap mRouterGroups = new HashMap();private final List mInterceptors = new LinkedList();private final List mMethodRouters = new LinkedList();private Map pageRouterGroup() {if (mRouterGroups.isEmpty()) {
      mRouterGroups.put("m", new HTRouterGroup$$m());
      ...
    }return mRouterGroups;
  }private List interceptors() {if (mInterceptors.isEmpty()) {
      mInterceptors.add(new HTInterceptorEntry("http://www.you.163.com/activity/detail/{id}.shtml", new ProductDetailInterceptor()));
      ...
    }return mInterceptors;
  }private List methodRouters() {if (mMethodRouters.isEmpty()) {
       {
        List paramTypes = new ArrayList();
        paramTypes.add(Context.class);
        paramTypes.add(String.class);
        paramTypes.add(int.class);
        mMethodRouters.add(new HTMethodRouterEntry("http://www.you.163.com/jumpA", "com.netease.hearttouch.example.JumpUtil", "jumpA", paramTypes));
      }
      ...
    }return mMethodRouters;
  }
}

HTRouterTable.pageRouterGroup()、HTRouterTable.methodRouters() 和 HTRouterTable.interceptors() 先忽略,后续解释

a4fbd9aa8731c2009e3ff6502880e9eb.png

新建了一个 Android Library htrouter,引用工程 htrouterdispatch,app 工程修改引用 htrouter

经过优化,router 跳转的逻辑代码可通过直接编码方式实现,普通 Android 开发也能轻松修改其中的逻辑,同时 apt 生成的类从 6 个直接减少至 1 个 HTRouterTable 和几个 HTRouterGroup。而自动生成的路由表和业务逻辑已经没有直接联系,就不会出现因为路由表未生成导致的大量编译出错问题。

3.2 路由表过大问题优化

参考 ARouter 的路由表分组和延迟加载概念,我们也引入相关机制。ARouter 使用 path 的第一个 segment 作为 group,为此 group 概念由业务层定义。而严选工程中,前期协议并没有考虑到 group,为此 url 中并没有业务字段指定 group,且全部 Activity 绑定是根据 host 字段,基本不定义 path 等。我这里采用 host 的第一个字母作为 group,生成如下 RouterGroup 映射表。

public final class HTRouterGroup$$m implements IRouterGroup {
  private final List mPageRouters = new LinkedList();public List pageRouters() {if (mPageRouters.isEmpty()) {
      mPageRouters.add(new HTRouterEntry("com.netease.hearttouch.example.MainActivity", "yanxuan://member", 2131034128, 2131034126, false));
      ...
    }return mPageRouters;
  }
}

而 HTRouterTable 持有全部的 RouterGroup 对象,支持路由跳转时按需加载路由分组的内容,降低内存消耗。同时进行路由表匹配查找时,就可以仅查找对应路由分组的内容,查找量是原来的 1/26(26 个字母),极大的降低路由查找性能开销

public final class HTRouterTable {

  public static Map pageRouterGroup() {
    if (ROUTER_GROUPS.isEmpty()) {
      ROUTER_GROUPS.put("m", new HTRouterGroup$$m());
      ...
    }
    return ROUTER_GROUPS;
  }
}
3.3 拦截器优化

3.3.1 优化前临时方案
针对登录拦截需求,当时的临时解决方案如下:

1.路由注解添加 needLogin 字段
2.并修改 apt 生成代码,使 HTRouterEntry 记录 needLogin 信息
3.提供 RouterUtil.startActivity 将目标页面的跳转构建成一个 runnable 传入,在登录成功回调中执行 runnable

@HTRouter(url = {PreemptionActivateActivity.ROUTER_URL}, needLogin = true)
public class PreemptionActivateActivity extends Activity {
    ...
}

public static boolean startActivity(final Context context, final String schemeUrl,final Intent sourceIntent, final boolean isFinish) {

    return doStartActivity(context, schemeUrl, new Runnable() {
        @Override
        public void run() {
            HTRouterManager.startActivity(context, schemeUrl, sourceIntent, isFinish);
        }
    });
}

private static boolean doStartActivity(final Context context, final String schemeUrl,final Runnable runnable) {

    if (HTRouterManager.isUrlRegistered(schemeUrl)) {
        HTRouterEntry entry = HTRouterManager.findRouterEntryByUrl(schemeUrl);
        if (entry == null) {
            return false;
        }

        if (entry.isNeedLogin() && !UserInfo.isLogin()) {
            LoginActivity.addOnLoginResultListener(new OnLoginResultListener() {
                @Override
                public void onLoginSuccess() {
                    runnable.run();
                }

                @Override
                public void onLoginFail() {
                    // do nothing
                }
            });
            LoginActivity.start(context);
        }

        return true;
    }

    return false;
}

可以发现这种处理方式并不通用,同时需要业务层代码全部修改调用方式,未修改的接口还是可能出现以未登录态进入需要登录的页面(这种情况也确实在后面发生过,后来我们要求前端跳转之前,先通过 jsbridge 唤起登录页面(⊙﹏⊙)b)。我们需要一种通用规范的方式处理拦截逻辑,同时能适用各种场景,也能规避业务层的错误。3.3.2 拦截器优化和设计
为避免业务层绕过拦截器直接调用到 HTRouterManager,将 HTRouterManager.startActivity 等接口修改为 package 引用范围,此外新定义 HTRouterCall 作为对外接口类。

public class HTRouterCall implements IRouterCall {
    ...
}

public interface IRouterCall {
    // 继续路由跳转
    void proceed();
    // 继续路由跳转
    void cancel();
    // 获取路由参数
    HTRouterParams getParams();
}

定义拦截器 interface 如下:

public interface IRouterInterceptor {
    void intercept(IRouterCall call);
}

总结拦截的需求场景,归纳拦截场景为 3 种:

全局拦截 → 全局拦截器
全局拦截器,通过静态接口设置添加

public static void addGlobalInterceptors(IRouterInterceptor... interceptors) {
    Collections.addAll(sGlobalInterceptors, interceptors);
}

登录拦截需求可以理解是一个全局的需求,全部的 Activity 跳转都需要判断是否需要唤起登录页面。

public class LoginRouterInterceptor implements IRouterInterceptor {

    @Override
    public void intercept(final IRouterCall call) {
        HTDroidRouterParams params = (HTDroidRouterParams) call.getParams();
        HTRouterEntry entry = HTRouterManager.findRouterEntryByUrl(params.url);
        if (entry == null) {
            call.cancel();
            return;
        }

        if (entry.isNeedLogin() && !UserInfo.isLogin()) {
            LoginActivity.setOnLoginResultListener(new OnLoginResultListener() {
                @Override
                public void onLoginSuccess() {
                    call.proceed();
                }

                @Override
                public void onLoginFail() {
                    call.cancel();
                }
            });
            LoginActivity.start(params.getContext());
        } else {
            call.proceed();
        }
    }
}

67e6ca375d75d73f51d860c37dd42fa2.gif

登录拦截效果

2.业务页面固定拦截 → 注解拦截器
上面剩余的 7 个 switch-case 拦截,可以理解为特定业务页面唤起都必须进入的一个拦截处理,分别定义 7 个拦截器类,同样通过注解的方式标记。

以 yanxuan://category 为例子

@HTRouter(url = {"yanxuan://category", "yanxuan://categoryl2"})
public class CategoryL2Activity extends Activity {
    ...
}

对应的注解拦截器

@HTRouter(url = {"yanxuan://category"})
public class CategoryL2Intercept implements IRouterInterceptor {

    @Override
    public void intercept(IRouterCall call) {
        HTRouterParams routerParams = call.getParams();
        Uri uri = Uri.parse(routerParams.url);

        // routerParams.url 添加额外参数
        Uri.Builder builder = uri.buildUpon();
        ...
        routerParams.url = builder.build().toString();

        call.proceed();
    }
}

apt 生成拦截器初始化代码

public static List interceptors() {
    if (INTERCEPTORS.isEmpty()) {
        ...
        INTERCEPTORS.add(new HTInterceptorEntry("yanxuan://category", new CategoryL2Intercept()));
        ...
    }
    return INTERCEPTORS;
}

HTRouterTable

3.业务页面动态拦截
比如 onClick 方法内执行路由跳转时,需要弹窗提示用户是否继续跳转,其他场景跳转并不需要这个弹窗,这种场景的拦截器我们认为是动态拦截

HTRouterCall.newBuilder(data.schemeUrl)
    .context(mContext)
    .interceptors(new IRouterInterceptor() {
        @Override
        public void intercept(final IRouterCall call) {
            Log.i("TEST", call.toString());
            AlertDialog dialog = new AlertDialog.Builder(mContext)
                    .setTitle("alert")
                    .setMessage("是否继续")
                    .setPositiveButton("继续", new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int which) {
                            call.proceed();
                        }
                    })
                    .setNegativeButton("取消", new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int which) {
                            call.cancel();
                        }
                    }).create();
            dialog.show();
        }
    })
    .build()
    .start();

优先级:动态拦截器 > 注解拦截器 > 全局拦截器

3.4 sdk 页面 router 支持

我们接入了七鱼、HTImagePick 等 sdk,这些 sdk 也有自己的页面,而这部分页面并不能通过前面的路由方式打开,其原因如下:

1.我们不能修改他们的代码
2.apt 处理的注解仅能针对引入 apt 的 app 工程
3.对应的页面唤起需要通过 sdk 提供的特殊接口唤起

public static void openYsf(Context context, String url, String title, String custom) {
    ConsultSource source = new ConsultSource(url, title, custom);
    Unicorn.openServiceActivity(context, // 上下文
            title, // 聊天窗口的标题
            source // 咨询的发起来源,包括发起咨询的url,title,描述信息等
    );
}

七鱼客服页面唤起

public void openImagePick(Context context, ArrayList photoInfos, boolean multiSelectMode, int maxPhotoNum, String title) {
    HTPickParamConfig paramConfig = new HTPickParamConfig(HTImageFrom.FROM_LOCAL, null,
            photoInfos, multiSelectMode, maxPhotoNum, title);
    HTImagePicker.INSTANCE.start(context, paramConfig, this);
}

基于此,只需要提供对方法的 router 调用,就能支持 sdk 中的页面路由跳转。具体用法示例如下

1.通过 HTMethodRouter 注解标记跳转方法(非静态方法需实现 getInstance 单例)

public class JumpUtil {

    private static final String TAG = "JumpUtil";
    private static JumpUtil sInstance = null;

    public static JumpUtil getInstance() {
        if (sInstance == null) {
            synchronized (JumpUtil.class) {
                if (sInstance == null) {
                    sInstance = new JumpUtil();
                }
            }
        }
        return sInstance;
    }

    private JumpUtil() {
    }

    @HTMethodRouter(url = {"http://www.you.163.com/jumpA"}, needLogin = true)
    public void jumpA(Context context, String str, int i) {
        String msg = "jumpA called: str=" + str + "; i=" + i;
        Log.i(TAG, msg);
        if (context != null) {
            Toast.makeText(context, msg, Toast.LENGTH_LONG).show();
        }
    }

    @HTMethodRouter(url = {"http://www.you.163.com/jumpB"})
    public static void jumpB(Context context, String str, int i) {
        String msg = "jumpB called: str=" + str + "; i=" + i;
        Log.i(TAG, msg);
        if (context != null) {
            Toast.makeText(context, msg, Toast.LENGTH_LONG).show();
        }
    }

    @HTMethodRouter(url = {"http://www.you.163.com/jumpC"})
    public void jumpC() {
        Log.i(TAG, "jumpC called");
    }
}

2.方法路由表生成

public final class HTRouterTable {

  private final List mMethodRouters = new LinkedList();
  ...private List methodRouters() {if (mMethodRouters.isEmpty()) {
       {
        List<Class> paramTypes = new ArrayList<Class>();
        paramTypes.add(Context.class);
        paramTypes.add(String.class);
        paramTypes.add(int.class);
        mMethodRouters.add(new HTMethodRouterEntry("http://www.you.163.com/jumpA", "com.netease.hearttouch.example.JumpUtil", "jumpA", paramTypes));
      }
       {
        List<Class> paramTypes = new ArrayList<Class>();
        paramTypes.add(Context.class);
        paramTypes.add(String.class);
        paramTypes.add(int.class);
        mMethodRouters.add(new HTMethodRouterEntry("http://www.you.163.com/jumpB", "com.netease.hearttouch.example.JumpUtil", "jumpB", paramTypes));
      }
       {
        List<Class> paramTypes = new ArrayList<Class>();
        mMethodRouters.add(new HTMethodRouterEntry("http://www.you.163.com/jumpC", "com.netease.hearttouch.example.JumpUtil", "jumpC", paramTypes));
      }
    }
    return mMethodRouters;
  }
}

3.方法路由触发逻辑
除了设置动画、是否关闭当前页面等参数,这里方法路由的调用方式和页面路由完全一致,同样支持 needLogin 字段,同样支持全局拦截器、注解拦截器、动态拦截器

// JUMPA 按钮点击
public void onMethodRouter0(View v) {
    HTRouterCall.call(MainActivity.this, "http://www.you.163.com/jumpA?a=lilei&b=10");
}

// JUMPB 按钮点击
public void onMethodRouter1(View v) {
    HTRouterCall.call(MainActivity.this, "http://www.you.163.com/jumpB?a=hanmeimei&b=10");
}

// JUMPC 按钮点击
public void onMethodRouter2(View v) {
    HTRouterCall.call(MainActivity.this, "http://www.you.163.com/jumpC");
}
3.5 main dex 优化处理

这里的处理逻辑较为简单,仅需修改类引用为类名字符串,后续跳转时通过反射获取类

public final class HTRouterGroup$$m implements IRouterGroup {
  private final List mPageRouters = new LinkedList();public List pageRouters() {if (mPageRouters.isEmpty()) {
      mPageRouters.add(new HTRouterEntry("com.netease.hearttouch.example.MainActivity", "yanxuan://m.you.163.com", 2131034128, 2131034126, false));
      ...
    }return mPageRouters;
  }
}
3.6 自定义降级优化

为推送等提供降级跳转方案,添加了 downgradeUrls 参数设置,若当前 urlA 并不识别,则会依次对 urlB,urlC 做判断并尝试跳转

HTRouterCall.newBuilder(urlA)
    .context(ProductDetailActivity.this)
    .downgradeUrls(urlB, urlC)
    .sourceIntent(sourceIntent)
    .requestCode(1001)
    .forResult(true)
    .build()
    .start();
3.7 跨工程路由表整合

ht-router 不支持多工程,是因为 apt 生成的代码包名都是固定的,而多工程就会多次执行 apt 代码生成逻辑,最终会生成多个相同包名和类名的类,最后产生类冲突。类冲突的解决办法较为简单,只需要将包名改成外部传入就可以了

apt {
    arguments {
        routerPkg 'com.netease.demo.router'
    }
}

app 工程指定 routerPkg 参数为 'com.netease.demo.router'

app 工程生成的路由表为 com.netease.demo.router.HTRouterTable。
多个业务工程生成了多个 HTRouterTable 后,需要业务开发调用多次 HTRouterManager.init 将各个路由表注册进去。为了隐藏多个路由表的实现细节,同时避免业务开发调用初始化方法可能产生的错误,这里通过 AspectJ 进行自动收集并注册路由表。业务层仅需要在 Application 中执行 HTRouterCall.init() 即可。

漏调初始化方法
HTRouterTable 引用错误,因为生成的全部路由表都是 HTRouterTable

@Aspect
public final class HTRouterTable {
  ...
  @After("execution(void com.netease.hearttouch.router.HTRouterCall.init())")
  public void init(JoinPoint joinPoint) {
    HTRouterManager.init(pageRouterGroup(), methodRouters(), interceptors());
  }
  ...
}

4 总结

严选比起其他大厂,Android 组件化方面做得还比较初步,但也根据我们的业务场景做了适合我们自己的方案
在路由方案方面,我们做了如下优化:

1.通过区分路由表生成代码和其他跳转逻辑,优化 apt 代码生成逻辑的复杂性和和维护性;
2.通过优化拦截器,解决登录拦截问题,优化子模块和全局代码划分;
3.通过提供方法路由,解决 sdk 页面的路由跳转问题;
4.通过修改路由表对类的直接引用,解决 main-dex 问题;
5.路由表根据 host 自动分组,使用过程中懒加载路由表,优化路由表内存占用较大问题和路由查找性能开销问题
6.路由跳转支持自定义降级
7.支持跨工程的多路由表,使用 AspectJ 自动收集和初始路由表

在接口组件化方面,我们通过提供方法路由,支持方法推送,并开发了 AutoApi 组件方案

1.仅根据注解自动生成接口类和实现类,支持跨工程共享接口;
2.支持构造函数和静态方法创建接口实例;
3.支持单例;
4.支持对外提供服务类的普通方法和静态方法;
5.通过 includeSuperApi 支持不修改服务类基类,提供基类的服务方法;
6.避免服务类接口相关数据类型下沉至接口工程;
7.支持 EventBus;
8.支持接口参数泛型

PS:这篇文章太长了,超过了公众号的发文限制,因此这里只发布了部分内容,想看 接口组件化一节的 可以点击 文末的

----------  END  ----------

分享大前端、Java、跨平台等技术,

关注职业发展和行业动态。

5de4fe00d0125477d02e04f6547a177f.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值