@zyl06
2018-09-21T09:45:08.000000Z
字数 16609
阅读 1583
Android 路由
早前严选 Android 工程,使用原生 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 唤起 APP 和 推送唤起 APP 的场景下会显得力不从心,随着公开的跳转协议越来越多,代码中 switch-case 也会越来越多,最后难以维护。
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;case ConstantsRT.ORDER_DETAIL_ROUTER_PATH:clazz = OrderDetailActivity.class;...break;...... 省略 28 个 case! ☹️...default: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 使用样例
参考 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 对工程框架的作用是巨大的,然而随着多期业务迭代和工程复杂度的提升,发现的几个痛点如下:
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 代码
合并分支后偶现,由于业务代码其他的编译不通过,导致 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 还是遵循 65536 的逻辑,而打包流程中 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));...}
思考框架本身,其实可以发现仅有 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'}
为了解决这里的问题,我们可以稍微降低对实现封装的隐藏程度,修改初始化接口,需要业务层将 router 映射表显式的传入。修改后就能发现仅有 HTRouterTable 里面的映射表接口需要 apt 生成,而其余的代码均可通过直接编码。
HTRouterManager.init();→HTRouterManager.init(HTRouterTable.pageRouters(),HTRouterTable.methodRouters(),HTRouterTable.interceptors());
HTRouterTable.methodRouters() 和 HTRouterTable.interceptors() 先忽略,后续解释
新建了一个 Android Library
htrouter,引用工程htrouterdispatch,app 工程修改引用htrouter
经过优化,router 跳转的逻辑代码可通过直接编码方式实现,普通 Android 开发也能轻松修改其中的逻辑,同时 apt 生成的类从 6 个直接减少至 1 个 HTRouterTable。若出现业务层代码编译错误导致 apt 生成失败,最终导致编译器提示 HTRouterTable not found,可仅需注释掉初始化代码即可。
/*** 用于用户启动Activity或者通过URL获得可以跳转的目标*/public final class HTRouterTable {public static final String HT_URL_PARAMS_KEY = "ht_url_params_map";private static final List<HTRouterEntry> PAGE_ROUTERS = new LinkedList<HTRouterEntry>();private static final List<HTInterceptorEntry> INTERCEPTORS = new LinkedList<HTInterceptorEntry>();private static final List<HTMethodRouterEntry> METHOD_ROUTERS = new LinkedList<HTMethodRouterEntry>();public static List<HTRouterEntry> pageRouters() {if (PAGE_ROUTERS.isEmpty()) {PAGE_ROUTERS.add(new HTRouterEntry("com.netease.yanxuan.module.home.category.activity.CategoryPushActivity", "yanxuan://homepage_categoryl2", 0, 0, false));...}return PAGE_ROUTERS;}public static List<HTInterceptorEntry> interceptors() {if (INTERCEPTORS.isEmpty()) {PAGE_ROUTERS.add(new HTRouterEntry("com.netease.yanxuan.module.home.recommend.activity.TagActivity", "yanxuan://tag", 0, 0, false));...}return INTERCEPTORS;}public static List<HTMethodRouterEntry> methodRouters() {if (METHOD_ROUTERS.isEmpty()) {{List<Class> paramTypes = new ArrayList<Class>();paramTypes.add(Context.class);paramTypes.add(String.class);paramTypes.add(int.class);METHOD_ROUTERS.add(new HTMethodRouterEntry("http://www.you.163.com/jumpA", "com.netease.hearttouch.example.JumpUtil", "jumpA", paramTypes));}...}return METHOD_ROUTERS;}}
针对登录拦截需求,当时的临时解决方案如下:
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.setOnLoginResultListener(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");}}
方法路由触发逻辑
除了设置动画、是否关闭当前页面等参数,这里方法路由的调用方式和页面路由完全一致,同样支持 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 static List<HTRouterEntry> routers() {if (ROUTERS.isEmpty()) {...ROUTERS.add(new HTRouterEntry("com.netease.yanxuan.module.subject.SubjectActivity", "yanxuan://subject", 0, 0, false));...}return ROUTERS;}
通过优化拦截器,解决登录拦截问题,优化子模块和全局代码划分;通过提供方法路由,解决 sdk 页面的路由跳转问题;通过区分路由表生成代码和其他跳转逻辑,优化 apt 代码生成逻辑的复杂性和和维护性;通过修改路由表对类的直接引用,解决 main-dex 问题。
除此之外,路由框架并未对 module 子工程的 Activity 做路由集成,严选当前也没做更进一步的业务组件化。后续有需求进一步补充文章。