@zyl06
2019-03-29T03:26:15.000000Z
字数 31970
阅读 2900
组件化 路由
早前严选 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() {@Overridepublic 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 协议的时候,需要大量的沟通和业务代码修改。
当严选 3.x.x 版本的时候,工程中就已经出现跨工程接口复用的问题(如跨工程需要支持埋点、本地异常日志记录模块等);当严选 4.x.x 版本的时候,需要处理处理跨模块 wzp 请求复用、跨工程 EventBus 通信问题。上述的简单设计已经完全不满足场景,本文就介绍严选在多版本迭代过程中,如何逐步处理和优化页面组件化、基础功能组件化。
参考 DeepLink从认识到实践,接入杭研 ht-router,由此通过注解的方式统一了 H5 唤醒、推送唤醒、正常启动 APP 的逻辑,上面点击跳转的逻辑得到了简化:
view.setOnClickListener(new View.OnClickListener() {@Overridepublic 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() {@Overridepublic 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 类
历史原因
严选工程中 CategoryL2Activity 有 yanxuan://category 和 yanxuan://categoryl2 2 个 scheme,而同一个参数 categoryid 在不同的 scheme 下有不同的含义,为此在拦截器中添加新的字段,CategoryL2Activity 中仅需处理 2 个新加的字段,不必知道自身的 scheme
跳转 Activity 的不同 fragment
严选首页 MainPageActivity 拥有 5 个 tab fragment,不同的 tab 会有不同的 scheme,拦截器中直接根据不同的 scheme,添加参数来指定不同的 tab,首页仅需处理 tab 参数显示不同的 fragment
ht-router的其他优点、用法、api 见文章 DeepLink从认识到实践,这里不再叙述
ht-router 对工程框架的作用是巨大的,然而随着多期业务迭代和工程复杂度的提升,逐渐发现路由框架的多个痛点:
下述痛点,在其他第三方框架上很多并不存在。当时集成的时候,router 框架刚兴起,ARouter、天猫统跳、ActivityRouter 等并没有像现在的功能强大;另外如 ARouter,通过 path 定义 group 和跳转目标,而严选工程以 host 标识跳转目标,也有些差异
ht-router 通过 apt 生成的类有 6 个,其中 HTRouterManager 有 600 行代码,去除 init 方法中初始化 router 映射表的 100 行左右代码,剩余还有 500 行左右
apt 生成的类目录
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 代码
由于整个路由表在 HTRouterManager 中,偶现(常见合并分支后)由于业务代码编译不通过,导致 apt 代码未生成,大量提示报错 HTRouterManager 找不到,但无法定位到真正的业务代码错误逻辑。
由于 HTRouterManager 在业务代码中广泛被使用,暂未有很好的办法解决这个报错,临时的处理办法是从同事处拷贝 apt 文件夹,临时绕过错误报错,修改业务层代码错误后 rebuild
第一次碰到比较懵逼,花了不少时间处理定位和解决问题,(⊙﹏⊙)b
针对未登录状态,跳转需要登录状态的 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;}...}
前面 RouterUtil 中的 switch-case 从 30 个大幅降至 7 个(即便是 7 个,感觉代码也不优雅),但这里的特殊处理逻辑属于各个页面的业务逻辑,不应该在 RouterUtil 中。路由的一个很大作用,就是将各个页面解耦,能为后期模块化等需求打下坚实基础,而这里的全局拦截处理逻辑,显然是和模块解耦是背道而驰的。
当然这些特殊的处理逻辑完全可以挪到各个 Activity 中,但是不是有机制能很好的处理这种场景,同时 Activity 是否需要关心自身当前的 scheme 是什么?
我们发现接入的子工程如图片选择器等也有自己的页面,而 apt 的代码生成功能是对 app 工程生效,不支持其他子工程的路由注解,为此子工程的页面就无法享受路由带来的好处。
最初通过 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 {*;}
解决方法
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 = []}// optionaldx.additionalParameters += "--main-dex-list=$projectDir/maindexlist.txt".toString()dx.additionalParameters += "--minimal-main-dex"}}
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
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));...}
严选 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.htmlhttp://m.you.163.com/product/101.html...
ht-router 跳转路由表中没有的 url 时,支持自动将 scheme 替换成 http,然后降级成 H5 页面去加载。而推送业务场景中,因为 app 版本兼容性原因需要的降级方案复杂的多:
以上业务场景,要求我们路由跳转支持自定义的降级方案
由于跨工程后,假设 2 个业务工程都集成了 ht-router,使用 apt 实现的路由表生成逻辑会被执行 2 次,由于生成的 class 类是相同包名和类名,为此后期编译会产生类冲突。此外,多个路由表如何整合使用,也是我们要考虑的地方。
思考框架本身,其实可以发现仅有 router 映射表是需要根据注解编译生成的,其他的全部代码都是固定代码,完全可以 sdk 中直接编码提供。反过来思考为何当初 sdk 开发需要编写繁重的 apt 生成代码,去生成这些固定的逻辑,可以发现 htrouterdispatch-process 工程是一个纯 java 工程,部分纯 java 类的提供在 htrouterdispatch。由于无法引用 Android 类,同时期望业务层接口能完美隐藏内部实现,为此和 Android 相关的类,索性全部由 apt 生成。
apply plugin: 'java' // 使用 apply plugin: 'com.android.library' 编译报错sourceCompatibility = JavaVersion.VERSION_1_7targetCompatibility = JavaVersion.VERSION_1_7dependencies {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() 先忽略,后续解释
新建了一个 Android Library
htrouter,引用工程htrouterdispatch,app 工程修改引用htrouter
经过优化,router 跳转的逻辑代码可通过直接编码方式实现,普通 Android 开发也能轻松修改其中的逻辑,同时 apt 生成的类从 6 个直接减少至 1 个 HTRouterTable 和几个 HTRouterGroup。而自动生成的路由表和业务逻辑已经没有直接联系,就不会出现因为路由表未生成导致的大量编译出错问题。
参考 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;}}
针对登录拦截需求,当时的临时解决方案如下:
needLogin 字段HTRouterEntry 记录 needLogin 信息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() {@Overridepublic 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() {@Overridepublic void onLoginSuccess() {runnable.run();}@Overridepublic void onLoginFail() {// do nothing}});LoginActivity.start(context);}return true;}return false;}
可以发现这种处理方式并不通用,同时需要业务层代码全部修改调用方式,未修改的接口还是可能出现以未登录态进入需要登录的页面(这种情况也确实在后面发生过,后来我们要求前端跳转之前,先通过 jsbridge 唤起登录页面(⊙﹏⊙)b)。我们需要一种通用规范的方式处理拦截逻辑,同时能适用各种场景,也能规避业务层的错误。
为避免业务层绕过拦截器直接调用到 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 {@Overridepublic 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() {@Overridepublic void onLoginSuccess() {call.proceed();}@Overridepublic void onLoginFail() {call.cancel();}});LoginActivity.start(params.getContext());} else {call.proceed();}}}
登录拦截效果
业务页面固定拦截 → 注解拦截器
上面剩余的 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 {@Overridepublic 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
业务页面动态拦截
比如 onClick 方法内执行路由跳转时,需要弹窗提示用户是否继续跳转,其他场景跳转并不需要这个弹窗,这种场景的拦截器我们认为是动态拦截
HTRouterCall.newBuilder(data.schemeUrl).context(mContext).interceptors(new IRouterInterceptor() {@Overridepublic void intercept(final IRouterCall call) {Log.i("TEST", call.toString());AlertDialog dialog = new AlertDialog.Builder(mContext).setTitle("alert").setMessage("是否继续").setPositiveButton("继续", new DialogInterface.OnClickListener() {@Overridepublic void onClick(DialogInterface dialog, int which) {call.proceed();}}).setNegativeButton("取消", new DialogInterface.OnClickListener() {@Overridepublic void onClick(DialogInterface dialog, int which) {call.cancel();}}).create();dialog.show();}}).build().start();
优先级:动态拦截器 > 注解拦截器 > 全局拦截器
我们接入了七鱼、HTImagePick 等 sdk,这些 sdk 也有自己的页面,而这部分页面并不能通过前面的路由方式打开,其原因如下:
对应的页面唤起需要通过 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 中的页面路由跳转。具体用法示例如下
通过 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");}}
方法路由表生成
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;}}
方法路由触发逻辑
除了设置动画、是否关闭当前页面等参数,这里方法路由的调用方式和页面路由完全一致,同样支持 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");}
结果示例
这里的处理逻辑较为简单,仅需修改类引用为类名字符串,后续跳转时通过反射获取类
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;}}
为推送等提供降级跳转方案,添加了 downgradeUrls 参数设置,若当前 urlA 并不识别,则会依次对 urlB,urlC 做判断并尝试跳转
HTRouterCall.newBuilder(urlA).context(ProductDetailActivity.this).downgradeUrls(urlB, urlC).sourceIntent(sourceIntent).requestCode(1001).forResult(true).build().start();
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
@Aspectpublic final class HTRouterTable {...@After("execution(void com.netease.hearttouch.router.HTRouterCall.init())")public void init(JoinPoint joinPoint) {HTRouterManager.init(pageRouterGroup(), methodRouters(), interceptors());}...}
除了严选最初集成的 HTHTTP 网络库、HTRecycleView、HTEventbus 等基础模块之外,可以保持独立,后面开发的很多业务模块,甚至基础模块都需要使用到原 app 工程里面的功能或者类型,于是我们碰到了 微信 Android 同样的问题,也同样开始了初步的接口下沉和类型下沉,具体下沉的实例和背景原因如下:
基础功能类下沉
前面讲述过,我们在 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,写本地文件复用了存储模块
数据类型下沉
工程里面全局事件总线通过 event 发送,在构建多工程模块的情况下,需要涉及跨工程通信的时候,就需要将相关 event 下沉到底层。
以上基础功能下沉和数据类型下沉过程中,我们构建了 yxcommonbase、yxlogger、yxstorage 等基础工程,app 工程和其他工程依赖这几个基础工程,这里不仅有业务工程、还有部分基础工程,如网络诊断工程。由于下沉的频率较高,基本上每个版本都要下沉一点点,导致其他基础工程无法以 aar 包的形式集成 app,如 app 工程引用基础模块 A,A 引用 yxcommonbase,虽然 A 代码完全没变,由于 yxcommonbase 发生了一点变化,我们还是需要重新发布 A aar。由于这种多级引用关系的存在,而 yxcommonbase 又是最底层的模块,最终导致的是全部的基础工程都无法以 aar 包的形式集成 app。最严重的还是长期的下沉最终会导致模块边界破坏、基础工程中心化。
为阻止下沉情况,我们需要重新整理通信方式、功能调用方式,考虑有以下选择:
首先能想到的就是前面提到的方法路由,使用方法路由能很好的统一 sdk 的页面路由、并支持服务端命令推送,在这方面使用方法路由是非常合适的。而方法路由一个最大的问题就是,每个方法都需要定义协议,而工程间的通信、功能复用情况会非常多,很容易导致协议过多,维护成本过大,为此在工程间通信方面并不是好的选择。此外对于特殊参数,如 Context、Bitmap 等,方法路由就显得比较无力。
排除通过方法路由进行通信,更安全和开发更适应的方式是接口通信。使用接口通信的方式,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() 接口创建实现对象,为此有以下缺点:
LazyIterator 获取实现对象,当有多个实现类时,使用工程需要通过遍历的方式查找目标对象,将不需要的实现类创建对象,浪费性能
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
ARouter 提供了 Provider 路由,使用样例如下:
@Route(path = "/yourservicegroupname/hello")public class HelloServiceImpl implements HelloService {Context mContext;@Overridepublic void sayHello(String name) {Toast.makeText(mContext, "Hello " + name, Toast.LENGTH_SHORT).show();}@Overridepublic 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 中的方法。
但也有几点不便之处:
navigation() 调用值需要做强制类型转换,业务开发需要查询协议文档,确定 path 和 接口 HelloService 之间的联系
public Object navigation() {return navigation(null);}
提供的接口实现类要有无参构造函数,和 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 providerIProvider 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());}}...}...}
接口类 HelloService 相关的参数类型,或者返回值类型,需下沉到接口工程
美团参考 ServiceLoader 的原理,提供了自己的 RouterService 功能,使用如下:
@RouterService(interfaces = ILocationService.class, key = DemoConstant.SINGLETON, singleton = true)public class FakeLocationService implements ILocationService {private final Handler mHandler = new Handler();@Overridepublic boolean hasLocation() {return false;}@Overridepublic void startLocation(final LocationListener listener) {...}}
ILocationService locationService = Router.getService(ILocationService.class, DemoConstant.SINGLETON);
接口实例获取
相比 ARouter,有以下优点:
接口服务实例获取不需要强制类型转换,业务开发不容易写错
public static <I, T extends I> T getService(Class<I> clazz, String key) {return ServiceLoader.load(clazz).get(key);}
支持单例管理,无参构造、Context 构造和自定义 Factory
针对有多个构造函数的服务类,需要定义多个自定义 Factory 类
public interface IFactory {@NonNull<T> T create(@NonNull Class<T> clazz) throws Exception;}
支持相同接口的多个实现类,业务工程通过 key 选择
此外,同 ServiceLoader,ARouter 方法,上述方案非常优秀,但尚有有相同不便之处:
总结以上方法,开发 AutoApi 组件,使用注解自动配置,自动生成接口类和实现类,支持跨工程共享接口。
@AutoApiClassAnnopublic class AddUtil {private int mData1 = 0;private int mData2 = 0;@AutoApiConstructAnnopublic AddUtil(int data1, int data2) {mData1 = data1;mData2 = data2;}@AutoApiConstructAnnopublic AddUtil(int data) {mData1 = data;mData2 = data;}@AutoApiMethodAnnopublic 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
@AutoApiClassAnnopublic class AddUtil {...@AutoApiMethodAnnopublic int calu() {return mData1 + mData2;}@AutoApiMethodAnnopublic 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(); // 23int result2 = api.add(11, 12); // = 23
接口使用
@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()获取实例
严选 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() {@Overridepublic void onCancel() { }@Overridepublic void onHttpSuccess(String httpName, Object result) { }@Overridepublic void onHttpError(String httpName, int errorCode, String errorMsg) { }@Overridepublic Object getApiServiceTarget() {return null;}});// 取消请求request.cancel();
业务子工程使用接口服务
以上避免了接口参数类型 HttpListener 和返回类型 Request 下沉至接口工程,业务子工程对于服务类相关的代码类型并无感知
在跨工程中,我们也能使用 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 事件
@AutoApiClassAnnopublic class JsonUtil {...@AutoApiMethodAnnopublic 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);}
自动生成的接口类
严选比起其他大厂,Android 组件化方面做得还比较初步,但也根据我们的业务场景做了适合我们自己的方案
在路由方案方面,我们做了如下优化:
main-dex 问题;在接口组件化方面,我们通过提供方法路由,支持方法推送,并开发了 AutoApi 组件方案
includeSuperApi 支持不修改服务类基类,提供基类的服务方法;htrouter 源码地址:https://github.com/bitterbee/htrouter
auto-api 源码地址:https://github.com/bitterbee/auto-api