严选 Android 组件化实践

6字数 8708阅读 3742

0 背景

早前严选 Android 工程,业务模块和功能模块不多,工程较为简单,全部的业务代码均在主 app 工程,全部的业务 Activity 均在 module/ 目录下,相关的网络请求封装在 http 目录下,使用 volley 封装支持 http 请求和 wzp 请求;业务请求协议实现,均放在 app/httptask/ 下,业务层使用请求不区分是 wzp 还是 http(s);全部的工具方法,如 DeviceUtilBitmapHelper 均在 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 参数直接触发跳转,近 30switch-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. 历史原因

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

  2. 跳转 Activity 的不同 fragment

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

img

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 行左右

Alt pic

apt 生成的类目录

Alt pic

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 找不到,但无法定位到真正的业务代码错误逻辑。

Alt pic

由于 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-case30 个大幅降至 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> 中的 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 {
    <init>();
}
-keep public class * extends android.app.Application {
<init>();
    void attachBaseContext(android.content.Context);
}
-keep public class * extends android.app.Activity {
    <init>();
}
-keep public class * extends android.app.Service {
    <init>();
}
-keep public class * extends android.content.ContentProvider {
    <init>();
}
-keep public class * extends android.content.BroadcastReceiver {
    <init>();
}
-keep public class * extends android.app.backup.BackupAgent {
    <init>();
}
# 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 为最小。(自己并未实际用过)
    参考文章 Android-Easy-MultiDex

  3. gradle 2.3.0

    gradle 中通过 multiDexKeepProguardmultiDexKeepFile 设置必须放置 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<String, IRouterGroup> pageGroups,
                            List<HTMethodRouterEntry> methodEntries,
                            List<HTInterceptorEntry> annoInterceptors) {
        ...
    }
}

public final class HTRouterTable {

    ...
    private final HashMap<String, IRouterGroup> mRouterGroups = new HashMap<String, IRouterGroup>();
    private final List<HTInterceptorEntry> mInterceptors = new LinkedList<HTInterceptorEntry>();
    private final List<HTMethodRouterEntry> mMethodRouters = new LinkedList<HTMethodRouterEntry>();

    private Map<String, IRouterGroup> pageRouterGroup() {
    if (mRouterGroups.isEmpty()) {
      mRouterGroups.put("m", new HTRouterGroup$$m());
      ...
    }
    return mRouterGroups;
  }

  private List<HTInterceptorEntry> interceptors() {
    if (mInterceptors.isEmpty()) {
      mInterceptors.add(new HTInterceptorEntry("http://www.you.163.com/activity/detail/{id}.shtml", new ProductDetailInterceptor()));
      ...
    }
    return mInterceptors;
  }

  private List<HTMethodRouterEntry> 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));
      }
      ...
    }
    return mMethodRouters;
  }
}

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

Alt pic

新建了一个 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<HTRouterEntry> mPageRouters = new LinkedList<HTRouterEntry>();

  public List<HTRouterEntry> 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<String, IRouterGroup> 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 种:

  1. 全局拦截 → 全局拦截器

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

    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();
            }
        }
    }
    
    Alt pic

    登录拦截效果

  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<HTInterceptorEntry> 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();
    
    Alt pic

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

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<PhotoInfo> 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<HTMethodRouterEntry> mMethodRouters = new LinkedList<HTMethodRouterEntry>();
    
      ...
    
      private List<HTMethodRouterEntry> 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");
    }
    
  4. 结果示例

    Alt pic

3.5 main dex 优化处理

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

public final class HTRouterGroup$$m implements IRouterGroup {
  private final List<HTRouterEntry> mPageRouters = new LinkedList<HTRouterEntry>();

  public List<HTRouterEntry> 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() 即可。

  1. 漏调初始化方法
  2. 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 接口组件化

除了严选最初集成的 HTHTTP 网络库、HTRecycleView、HTEventbus 等基础模块之外,可以保持独立,后面开发的很多业务模块,甚至基础模块都需要使用到原 app 工程里面的功能或者类型,于是我们碰到了 微信 Android 同样的问题,也同样开始了初步的接口下沉和类型下沉,具体下沉的实例和背景原因如下:

  1. 基础功能类下沉

    前面讲述过,我们在 app 工程的 common/util/ 下积累了一部分 util 类,如 FileUtil、BitmapHelper、CookieUtil、LogUtil 等,这里部分是业务无关,部分是业务相关。如 FileUtil、BitmapHelper 和业务逻辑无关,而CookieUtil 和 LogUtil 和业务相关。此外,基础功能代码和其他代码也存在少量耦合,在下沉的过程中,容易导致更多的代码下沉。

    • CookieUtil 提供了业务相关的 Set-Cookie 解析保存和提取组装到网络请求、WebView,WebView Cookie 需要添加相关的 domain、httpOnly 设置。
    • LogUtil 提供了不同情况的日志表示逻辑。测试包将 error 日志通过对话框的形式展现给测试,同时记录错误日志到本地文件,普通日志正常 adb 展示;线上包将 error 日志上报服务器,普通日志关闭。这里对话框复用了 SingleAlertBuilder,写本地文件复用了存储模块
  2. 数据类型下沉

    工程里面全局事件总线通过 event 发送,在构建多工程模块的情况下,需要涉及跨工程通信的时候,就需要将相关 event 下沉到底层。

以上基础功能下沉和数据类型下沉过程中,我们构建了 yxcommonbase、yxlogger、yxstorage 等基础工程,app 工程和其他工程依赖这几个基础工程,这里不仅有业务工程、还有部分基础工程,如网络诊断工程。由于下沉的频率较高,基本上每个版本都要下沉一点点,导致其他基础工程无法以 aar 包的形式集成 app,如 app 工程引用基础模块 A,A 引用 yxcommonbase,虽然 A 代码完全没变,由于 yxcommonbase 发生了一点变化,我们还是需要重新发布 A aar。由于这种多级引用关系的存在,而 yxcommonbase 又是最底层的模块,最终导致的是全部的基础工程都无法以 aar 包的形式集成 app。最严重的还是长期的下沉最终会导致模块边界破坏、基础工程中心化。

4.1 方案选择

为阻止下沉情况,我们需要重新整理通信方式、功能调用方式,考虑有以下选择:

4.1.1 方法路由

首先能想到的就是前面提到的方法路由,使用方法路由能很好的统一 sdk 的页面路由、并支持服务端命令推送,在这方面使用方法路由是非常合适的。而方法路由一个最大的问题就是,每个方法都需要定义协议,而工程间的通信、功能复用情况会非常多,很容易导致协议过多,维护成本过大,为此在工程间通信方面并不是好的选择。此外对于特殊参数,如 Context、Bitmap 等,方法路由就显得比较无力。

4.1.2 ServiceLoader

排除通过方法路由进行通信,更安全和开发更适应的方式是接口通信。使用接口通信的方式,Java 原生提供的方式有 ServiceLoader

Jdk6 提供的一种 SPI(Service Provider Interfaces)机制,流程如下:
META-INF/services 下放置配置文件,Caculator 在接口工程 A,CalulatorImpl 在实现工程 B

文件名:com.example.Caculator
文件内容:com.example.CalulatorImpl

接口使用工程 C 定义 Factory 类,通过 get() 来得到需要的具体实例,工程 C 并不需要知道 Caculator 的实现类。

public class CalculatorFactory {
    public CalculatorFactory() {
        ServiceLoader<Caculator> loader = ServiceLoader.load(Caculator.class);
        mIterator = loader.iterator();
    }
    
    private Iterator<Caculator> mIterator;
    
    Caculator get() {
        return mIterator.next();
    }
    
    boolean hasNextDisplay() {
        return mIterator.hasNext();
    }
}

查看源码可以发现,ServiceLoader 通过 c.newInstance() 接口创建实现对象,为此有以下缺点:

  1. 不支持单例和使用参数来创建对象
  2. 只能使用 LazyIterator 获取实现对象,当有多个实现类时,使用工程需要通过遍历的方式查找目标对象,将不需要的实现类创建对象,浪费性能
  3. 需要定义接口工程,和实现工程中的配置文件,实现过程编码较多
public S next() {
    ...
        c = Class.forName(cn, false, loader);
    ...
    try {
        S p = service.cast(c.newInstance());
        providers.put(cn, p);
        return p;
    } catch (Throwable x) {
        ...
    }
    throw new Error();          // This cannot happen
}

代码片段来源 Android-25 ServiceLoader.java

4.1.3 ARouter Provider 路由

ARouter 提供了 Provider 路由,使用样例如下:

@Route(path = "/yourservicegroupname/hello")
public class HelloServiceImpl implements HelloService {
    Context mContext;

    @Override
    public void sayHello(String name) {
        Toast.makeText(mContext, "Hello " + name, Toast.LENGTH_SHORT).show();
    }

    @Override
    public void init(Context context) {
        mContext = context;
        Log.e("testService", HelloService.class.getName() + " has init.");
    }
}

代码片段来源

((HelloService) ARouter.getInstance().build("/yourservicegroupname/hello").navigation()).sayHello("mike");

HelloServiceImpl 使用样例

使用页面路由相同的方式,通过注解 path 来定义 Provider 路由。HelloServiceImpl 必须实现 IProvider 接口,实现 void init(Context context) 方法。如果我们将 HelloService 接口定义在接口工程,HelloServiceImpl 定义在接口实现工程 A(或 app 主工程),那工程 B 就能使用 Provider 路由来调用到工程 A 中的方法。

但也有几点不便之处:

  1. navigation() 调用值需要做强制类型转换,业务开发需要查询协议文档,确定 path 和 接口 HelloService 之间的联系

    public Object navigation() {
        return navigation(null);
    }
    
  2. 提供的接口实现类要有无参构造函数,和 init(Context context) 方法,不支持其他的构造函数,实用类为单例

    public class LogisticsCenter {
        ...
        public synchronized static void completion(Postcard postcard) {
            ...
            Class<? extends IProvider> providerMeta = (Class<? extends IProvider>) routeMeta.getDestination();
            // 重点 1:单例
            IProvider instance = Warehouse.providers.get(providerMeta);
            if (null == instance) { // There's no instance of this provider
                IProvider provider;
                try {
                    // 重点 2:无参构造函数
                    provider = providerMeta.getConstructor().newInstance();
                    // 重点 3:init(Context context) 初始化方法
                    provider.init(mContext);
                    Warehouse.providers.put(providerMeta, provider);
                    instance = provider;
                } catch (Exception e) {
                    throw new HandlerException("Init provider failed! " + e.getMessage());
                }
            }
            ...
        }
        ...
    }    
    
  3. 接口类 HelloService 相关的参数类型,或者返回值类型,需下沉到接口工程

4.1.4 WMRouter RouterService 功能

美团参考 ServiceLoader 的原理,提供了自己的 RouterService 功能,使用如下:

@RouterService(interfaces = ILocationService.class, key = DemoConstant.SINGLETON, singleton = true)
public class FakeLocationService implements ILocationService {

    private final Handler mHandler = new Handler();

    @Override
    public boolean hasLocation() {
        return false;
    }

    @Override
    public void startLocation(final LocationListener listener) {
        ...
    }
}

代码片段来源

ILocationService locationService = Router.getService(ILocationService.class, DemoConstant.SINGLETON);

接口实例获取

相比 ARouter,有以下优点:

  1. 接口服务实例获取不需要强制类型转换,业务开发不容易写错

    public static <I, T extends I> T getService(Class<I> clazz, String key) {
        return ServiceLoader.load(clazz).get(key);
    }
    
  2. 支持单例管理,无参构造、Context 构造和自定义 Factory

    针对有多个构造函数的服务类,需要定义多个自定义 Factory 类

    public interface IFactory {
        @NonNull
        <T> T create(@NonNull Class<T> clazz) throws Exception;
    }
    
  3. 支持相同接口的多个实现类,业务工程通过 key 选择

此外,同 ServiceLoaderARouter 方法,上述方案非常优秀,但尚有有相同不便之处:

  1. 接口参数类型和返回类型要下沉到接口工程,而参数类型和返回类型可能和原工程有耦合,触发更多的代码需要下沉到接口工程
  2. 接口实现类,不支持静态方法
  3. 需要修改原有代码逻辑
    • 添加基类或接口
    • 修改方法为接口方法

4.2 AutoApi 组件方案

总结以上方法,开发 AutoApi 组件,使用注解自动配置,自动生成接口类和实现类,支持跨工程共享接口。

4.2.1 支持多构造函数创建接口实例

@AutoApiClassAnno
public class AddUtil {

    private int mData1 = 0;
    private int mData2 = 0;

    @AutoApiConstructAnno
    public AddUtil(int data1, int data2) {
        mData1 = data1;
        mData2 = data2;
    }

    @AutoApiConstructAnno
    public AddUtil(int data) {
        mData1 = data;
        mData2 = data;
    }

    @AutoApiMethodAnno
    public int calu() {
        return mData1 + mData2;
    }
}

服务类通过注解标记,提供对外构造函数和普通方法

自动生成接口类和工厂类如下:

/**
 * com.netease.demo.autoapi.AddUtil's api Interface
 */
public interface AddUtilApi extends ApiBase {
  int calu();
}
/**
 * com.netease.demo.autoapi.AddUtil api Class's factory Interface
 */
public interface AddUtilApiFactory {
  AddUtilApi newInstance(int data1, int data2);
  AddUtilApi newInstance(int data);
}

接口使用样例:

AddUtilApiFactory factory = AutoApi.getApiFactory("AddUtilApi");
AddUtilApi api = factory.newInstance(11, 12);
int result0 = api.calu(); // 23

4.2.2 支持静态方法

@AutoApiClassAnno
public class AddUtil {
    
    ...
    @AutoApiMethodAnno
    public int calu() {
        return mData1 + mData2;
    }
    @AutoApiMethodAnno
    public static int add(int a, int b) {
        return a + b;
    }
}

服务类

/**
 * com.netease.demo.autoapi.AddUtil's api Interface
 */
public interface AddUtilApi extends ApiBase {
  int calu();
  int add(int a, int b);
}

自动生成接口类

AddUtilApiFactory factory = AutoApi.getApiFactory("AddUtilApi");
AddUtilApi api = factory.newInstance(11, 12);
int result1 = api.calu(); // 23
int result2 = api.add(11, 12); // = 23

接口使用

4.2.3 支持静态方法构造接口实例(支持单例)

@AutoApiClassAnno(name = "AppSingleton")
public class Singleton {

    private static Singleton sInstance = null;

    @AutoApiClassBuildMethodAnno()
    public static Singleton getInstance() {
        if (sInstance == null) {
            synchronized (Singleton.class) {
                if (sInstance == null) {
                    sInstance = new Singleton();
                }
            }
        }

        return sInstance;
    }

    private Singleton() {
    }

    @AutoApiMethodAnno()
    public String foo1(String str1, String str2) {
        Log.i("Singleton", "foo1 called");
        return str1 + "_" + str2;
    }
}

服务类

/**
 * com.netease.demo.autoapi.Singleton api Class's factory Interface
 */
public interface AppSingletonApiFactory {
  AppSingleton getInstance();
}

自动生成的接口工厂类

AppSingletonApiFactory apiFactory = AutoApi.getApiFactory("AppSingleton");
AppSingleton singleton = apiFactory.getInstance();
String result = singleton.foo1("var1", "var2");

apiFactory.getInstance() 通过 Singleton.getInstance() 获取实例

4.2.4 避免服务类相关类型下沉至接口工程

严选 app 工程引入了 HTHttp 组件,其中 HTHttp 工程是基于 Volley 封装的一个网络库。此外,严选工程基于 HTHttp 封装了 WZP 请求(网易邮件私有协议)。WZP 的封装、配置、初始化等代码和严选 app 主工程耦合较大,而我们的业务子工程便需要 WZP 请求功能。若采用现有开源方案,我们基本需要将 WZP 模块下沉至底层,而这不是我们期望看到的。这里我提供的 AutoApi 组件通过自动生成参数接口类型,来避免类型下沉的问题。

@AutoApiClassAnno(includeSuperApi = true)
public class SharedWzpCommonRequestTask extends BaseWzpCommonRequestTask {

    private String mUrl;
    private String mApi;
    private Class mModelClass;

    @AutoApiClassBuildMethodAnno()
    public static SharedWzpCommonRequestTask newInstance(String url, Class modelClass,
                                                         Map<String, String> queryParams,
                                                         Map<String, String> headerMap,
                                                         Map<String, Object> bodyMap) {
        Builder builder = new Builder();
        return builder.setApi(url)
                .setModelClass(modelClass)
                .setQueryParams(queryParams)
                .setHeaders(headerMap)
                .setBodyMap(bodyMap)
                .build();
    }

    private SharedWzpCommonRequestTask(SharedWzpDataModel data) {
        mUrl = data.mUrl;
        mApi = data.mApi;
        mModelClass = data.mModelClass;
    }
    ...
}

public class BaseWzpCommonRequestTask {
    ...
    
    @AutoApiMethodAnno()
    public Request query(@AutoApiCallbackAnno HttpListener listener) {
        ...
    }
    @AutoApiMethodAnno()
    public Request queryArray(@AutoApiCallbackAnno HttpListener listener) {
        ...
    }
}

@AutoApiClassAnno(allPublicNormalApi = true, includeSuperApi = true)
public interface HttpListener extends BaseHttpListener {
    void onCancel();
}

public interface BaseHttpListener {
    void onHttpSuccess(String httpName, Object result);
    void onHttpError(String httpName, int errorCode, String errorMsg);
}

@AutoApiClassAnno(allPublicNormalApi = true)
public class Request {
    public void cancel() {
        ...
    }
}

WZP HttpTask 服务类及相关类

public interface SharedWzpCommonRequestTaskApi extends ApiBase {
  RequestApi query(HttpListenerApi listener);
  RequestApi queryArray(HttpListenerApi listener);
}

public interface HttpListenerApi extends ApiBase {
  void onCancel();
  void onHttpSuccess(String httpName, Object result);
  void onHttpError(String httpName, int errorCode, String errorMsg);
}

public interface RequestApi extends ApiBase {
  void cancel();
}

编译自动生成接口类

public interface SharedWzpCommonRequestTaskApiFactory {
  SharedWzpCommonRequestTaskApi newInstance(String url, Class modelClass, Map<String, String> queryParams, Map<String, String> headerMap, Map<String, Object> bodyMap);
}

编译自动生成工厂接口类

SharedWzpCommonRequestTaskApiFactory apiFactory = AutoApi.getApiFactory("SharedWzpCommonRequestTaskApi");
        SharedWzpCommonRequestTaskApi api = apiFactory.newInstance("/xhr/test/a", null, null, null, null);
        RequestApi request = api.query(new HttpListenerApi() {
            @Override
            public void onCancel() {  }

            @Override
            public void onHttpSuccess(String httpName, Object result) {  }

            @Override
            public void onHttpError(String httpName, int errorCode, String errorMsg) {  }
            
            @Override
            public Object getApiServiceTarget() {
                return null;
            }
        });

        // 取消请求
        request.cancel();

业务子工程使用接口服务

以上避免了接口参数类型 HttpListener 和返回类型 Request 下沉至接口工程,业务子工程对于服务类相关的代码类型并无感知

4.2.5 实际对象获取

在跨工程中,我们也能使用 EventBus 进行通信,EventBus 通过 event 的 class 类型来区分事件类型,为了支持子工程能给 app 工程发送事件,但又不想 app 工程中的 event 类下沉到底层,也不想让子工程感知到 event 实际类型信息(如event classname),我们可以通过接口类的 getApiServiceTarget 方法获取实际对象

@AutoApiClassAnno()
public class EventA {
    ...
}

app 工程 Event 类型

EventAApiFactory factory = AutoApi.getApiFactory("EventAApi");
EventAApi api = factory.newInstance();
EventBus.getDefault().post(api.getApiServiceTarget());

业务子工程发送 EventBus 事件

4.2.6 支持接口泛型

@AutoApiClassAnno
public class JsonUtil {
    ...
    
    @AutoApiMethodAnno
    public static <T> T toJsonObj(String jsonStr, Class<T> clazz) {
        try {
            if (!TextUtils.isEmpty(jsonStr)) {
                return JSONObject.parseObject(jsonStr, clazz, Feature.IgnoreNotMatch);
            }
        } catch (Throwable e) {
            LogUtil.yxLogE(e);
        }
        return null;
    }
}

app 工程服务类

public interface JsonUtilApi extends ApiBase {
  ...
  
  <T> T toJsonObj(String jsonStr, Class<T> clazz);
}

自动生成的接口类

5 总结

严选比起其他大厂,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. 支持接口参数泛型

htrouter 源码地址:https://github.com/bitterbee/htrouter

auto-api 源码地址:https://github.com/bitterbee/auto-api

推荐阅读更多精彩内容