Android 组件化 —— 路由设计最佳实践

引子

这篇文章会告诉你

  • 什么是路由,是为了解决什么问题才产生的
  • 业界现状是怎么样的,我们可以做什么来优化当前的问题
  • 路由设计思路是怎么样的,该怎么设计比较好
  • 如何用注解实现路由表
  • URL的参数如何依赖注入到Activity、Fragement
  • 如何HookOnActivityResult,不需要再进行requstCode判断
  • 如何异步拦截路由,实现线程切换,不阻塞页面跳转
  • 如何用Apt实现Retrofit接口式调用
  • 如何找到Activity的调用方
  • 如何实现路由的安全调用
  • 如何避开Apt不能汇总所有Module路由的问题

前言

当前Android的路由库实在太多了,刚开始的时候想为什么要用路由表的库,用Android原生的Scheme码不就好了,又不像iOS只能类依赖,后面越深入就越发现当时想的太简单了,后面看到Retrofit和OkHttp,才想到页面请求本质和网络请求不是一样吗,终于业界最简单高效的路由方案1.0出来了
开源的库后面会放在公司github地址上面

背景

什么是路由

根据路由表页面请求分发到指定页面

使用场景

  1. App接收到一个通知,点击通知打开App的某个页面
  2. 浏览器App中点击某个链接打开App的某个页面
  3. 运营活动需求,动态把原生的页面替换成H5页面
  4. 打开页面需要某些条件,先验证完条件,再去打开那个页面
  5. 不合法的打开App的页面被屏蔽掉
  6. H5打开链接在所有平台都一样,方便统一跳转
  7. App存在就打开页面,不存在就去下载页面下载,只有Google的App Link支持

为什么要有路由

Android原生已经支持AndroidManifest去管理App跳转,为什么要有路由库,这可能是大部分人接触到Android各种Router库不太明白的地方,这里我讲一下我的理解

  • 显示Intent:项目庞大以后,类依赖耦合太大,不适合组件化拆分
  • 隐式Intent:协作困难,调用时候不知道调什么参数
  • 每个注册了Scheme的Activity都可以直接打开,有安全风险
  • AndroidMainfest集中式管理比较臃肿
  • 无法动态修改路由,如果页面出错,无法动态降级
  • 无法动态拦截跳转,譬如未登录的情况下,打开登录页面,登录成功后接着打开刚才想打开的页面
  • H5、Android、iOS地址不一样,不利于统一跳转

怎么样的路由才算好路由

路由说到底还是为了解决开发者遇到的各种奇葩需求,使用简单、侵入性低、维护方便是首要条件,不影响你原来的代码,写入代码也很少,这里就要说说我的OkDeepLink的五大功能了,五大功能瞬间击中你的各种痛点,早点下班不是梦。

  • 编译时注解,实现静态路由表,不再需要在臃肿的AndroidManifest中找到那个Actvity写Scheme和Intent Filter
  • 异步拦截器,实现动态路由,安全拦截、动态降级难不倒你
  • 模仿Retrofit接口式调用,实现方式用apt,不耗性能,参数调用不再是问题
  • HookOnActivityResult,支持RxJava响应式调用,不再需要进行requestCode判断
  • 参数依赖注入,自动保存,不再需要手动写onSaveInstanceonCreate(SaveInstace)onNewIntent(Intent)getQueryParamer

注册路由

路由结构图

详细比较

大部分路由库都用Apt(编译时注解)生成路由表,然后用路由表转发到指定页面

方案对比 OkDeepLink Airbnb DeepLinkDispatch 阿里 ARouter 天猫 统跳协议 ActivityRouter
路由注册 注解式接口注册 每个module都要手动注册 每个module的路由表都要类查找 AndroidManiFest配置 每个module都要手动注册
路由查找 路由表 路由表 路由表 系统Intent 路由表
路由分发 Activity转发 Activity转发 Activity转发 Activity转发 Activity转发
动态替换 Rxjava实现异步拦截器 不支持 线程等待 不支持 不支持
动态拦截 Rxjava实现异步拦截器 不支持 线程等待 不支持 主线程
安全拦截 Rxjava实现异步拦截器 不支持 线程等待 不支持 主线程
方法调用 接口 手动拼装 手动拼装 手动拼装 手动拼装
参数获取 Apt依赖注入,支持所有类型,不需要在Activity的onCreate中手动调用get方法 参数定义在path,不利于多人协作 Apt依赖注入,但是要手动调用get方法 手动调用 手动调用
结果返回 Rxjava回调 onActivityResult onActivityResult onActivityResult onActivityResult
Module接入不同App 支持 不支持 支持 不支持 支持

其实说到底,路由的本质就是注册再转发,围绕着转发可以进行各种操作,拦截,替换,参数获取等等,其他Apt、Rxjava说到底都只是为了方便使用出现的,这里你会发现各种路由库反而为了修复各种工具带来的问题,出现了原来没有的问题,譬如DeepLinkDispatch为了解决Apt没法汇总所有Module路由,每个module都要手动注册,ARouter为了解决Apt没法汇总所有Module路由,通过类操作耗时,才出现分组的概念。

原理分析


原理流程图

定义路由


路由定义

我这边是完全按照URL规范了,这里要说一下,现在好多方法是把参数定义在path里面的,虽然这样做,有不需要额外传参数的好处,但是这样路由就没有那么灵活,调试起来就没有那么方便了。
建议有好几款app的公司,host都一样,只有scheme不一样,这样只要替换Scheme就能实现降级,维护也简单。

路由注册

AndroidManifest里面的acitivity声明scheme码是不安全的,所有App都可以打开这个页面,这里就产生有两种方式去注册,

  • 注解产生路由表,通过DispatchActivity转发
  • AndroidManifest注册,将其export=fasle,但是再通过DispatchActivity转发Intent,天猫就是这么做的,比上面的方法的好处是路由查找都是系统调用,省掉了维护路由表的过程,但是AndroidManifest配置还是比较不方便的

我现在还是采用了注解,后面我会结合两种方法,将注解自动修改AndroidManifest,对于接入方是没有变动的,方法已经找到了,用自定义Lint扫描出注解相关的Activity,然后用processManifestTask修改Manifest,有个demo了,后面会接入。

生成路由表

譬如通过Apt把这段代码

public interface SampleService {


    @Path("/main")
    @Activity(MainActivity.class)
    void startMainActivity(@Query("key") String key);
}

生成

@After("execution(* okdeeplink.DeepLinkClient.init(..))")
  public void init() {
    DeepLinkClient.addAddress(new Address("/main", MainActivity.class));
  }

初始化路由表

这里就要提一下使用Apt会造成每个module都要手动注册

DeepLinkDispatch是这么做的

@DeepLinkModule
public class SampleModule {
}
@DeepLinkHandler({ SampleModule.class, LibraryDeepLinkModule.class })
public class DeepLinkActivity extends Activity {
  @Override protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    DeepLinkDelegate deepLinkDelegate = new DeepLinkDelegate(
        new SampleModuleLoader(), new LibraryDeepLinkModuleLoader());
    deepLinkDelegate.dispatchFrom(this);
    finish();
  }
}

ARouter是通过类查找,就比较耗时了,所以他又加入了分组的概念,按需加载

/**
     * 通过指定包名,扫描包下面包含的所有的ClassName
     *
     * @param context     U know
     * @param packageName 包名
     * @return 所有class的集合
     */
    public static List getFileNameByPackageName(Context context, String packageName) throws PackageManager.NameNotFoundException, IOException {
        List classNames = new ArrayList<>();
        for (String path : getSourcePaths(context)) {
            DexFile dexfile = null;

            try {
                if (path.endsWith(EXTRACTED_SUFFIX)) {
                    //NOT use new DexFile(path), because it will throw "permission error in /data/dalvik-cache"
                    dexfile = DexFile.loadDex(path, path + ".tmp", 0);
                } else {
                    dexfile = new DexFile(path);
                }
                Enumeration dexEntries = dexfile.entries();
                while (dexEntries.hasMoreElements()) {
                    String className = dexEntries.nextElement();
                    if (className.contains(packageName)) {
                        classNames.add(className);
                    }
                }
            } catch (Throwable ignore) {
                Log.e("ARouter", "Scan map file in dex files made error.", ignore);
            } finally {
                if (null != dexfile) {
                    try {
                        dexfile.close();
                    } catch (Throwable ignore) {
                    }
                }
            }
        }

        Log.d("ARouter", "Filter " + classNames.size() + " classes by packageName <" + packageName + ">");
        return classNames;
    }

ActivityRouter就比较巧妙了,通过Stub项目,其他地方都是provide的,只有主工程里面用Apt生成RouterInit类,虽然还是要写module的注解

        // RouterInit
        if (hasModules) {
            debug("generate modules RouterInit");
            generateModulesRouterInit(moduleNames);
        } else if (!hasModule) {
            debug("generate default RouterInit");
            generateDefaultRouterInit();
        }

天猫 统跳协议 是最简单的,转发一下Intent就可以,但是这样就没法享受注解的好处了。

而我用aspectj解决了这个问题,会自动汇总所有module的路由省略了这些多余的代码,或者有谁知道用Apt自生怎么解决,请联系我一下。

@After("execution(* okdeeplink.DeepLinkClient.init(..))")
  public void init() {
    DeepLinkClient.addAddress(new Address("/main", MainActivity.class));
  }

路由查找

路由查找就是查找路由表对应的页面,值得提起的就是因为要适应Module接入不同App,Scheme要自动适应,路由表其实是Path---》Activity,这样的话内部跳转的时候ARouterUri是没有的。而我这边是有的,我组装了一个内部的Uri,这样拦截器不会有影响。

public Request buildRequest(Intent sourceIntent) {
        if (sourceIntent == null) {
            return null;
        }
        Intent newIntent = new Intent(sourceIntent);
        Uri uri = newIntent.getData();

        addNewTaskFlag(newIntent);

        if (uri != null) {
            addBundleQuery(newIntent, uri);

            Address entry = new DeepLinkClient(context).matchUrl(uri.toString());
            if (entry == null || entry.getActivityClass() == null) {
                return new Request(newIntent, this).setDeepLink(false);
            }
            newIntent.setComponent(new ComponentName(context, entry.getActivityClass()));

            return new Request(newIntent, this);
        }
        return new Request(newIntent, this).setDeepLink(false);

    }

路由分发

现在所有路由方案分发都是用Activity做分发的,这样做会有这几个缺点

  1. 每次都要启动一个Activity,而Activity就算不写任何代码启动都要0.1秒
  2. 如果是异步等待的话,Activiy要在合适时间finish,不然会有一层透明的页面阻挡操作

对于第一个问题,有两个方法

  1. QQ音乐是把DispatchActivity设为SingleInstacne,但是这样的话,动画会奇怪,堆栈也会乱掉,后退会有一层透明的页面阻挡操作
  2. DispatchActivity只在外部打开的时候调用

我选择了第二种

对于第二个问题,有两个方法

  1. DispatchActivity再把Intent转发到Service,再finish,这种方法唯一的缺陷是拦截器里面的context是Servcie的activity,就没发再拦截器里面弹出对话框了。
  2. DispatchActivity在打开和错误的时候finish,如果activity已经finish了,就用application的context去转发路由

我选择了第二种

  public void dispatchFrom(Intent intent) {
        new DeepLinkClient(this)
                .buildRequest(intent)
                .dispatch()
                .subscribe(new Subscriber() {
                    @Override
                    public void onCompleted() {
                        finish();
                    }

                    @Override
                    public void onError(Throwable e) {
                        finish();
                    }

                    @Override
                    public void onNext(Request request) {
                        Intent dispatchIntent = request.getIntent();
                        startActivity(dispatchIntent);
                    }
                });
    }

其实处理透明Activity阻挡操作可以采用取消所有事件变成无感页面的方法
我找到一种方式解决这个问题解决透明Activity点击不影响用户操作

结果返回

这里我封装了一个库RxActivityResult去捕获onActivityResult,这样能保正流式调用

譬如拍照可以这样写,先定义一个接口

    public interface ImageCaptureService {


    @Action(MediaStore.ACTION_IMAGE_CAPTURE)
    Observable startImageCapture();
}

然后这样调用

public class MainActivity extends AppCompatActivity {

    @Service
    ImageCaptureService imageCaptureService;

    public void captureImage(){
        imageCaptureService
                .startImageCapture()
                .subscribe(new Action1<Response>() {
                    @Override
                    public void call(Response response) {
                        Intent data = response.getData();
                        int resultCode = response.getResultCode();
                        if (resultCode == RESULT_OK) {
                            Bitmap imageBitmap = (Bitmap) data.getExtras().get("data");
                        }
                    }
                });
    }
}
}

是不是很简单,原理是这样的,通过封装一个RxResultHoldFragment去处理onActivityResult

 private IActivityObservable buildActivityObservable() {

            T target = targetWeak.get();

            if (target instanceof FragmentActivity) {
                FragmentActivity activity = (FragmentActivity) target;
                android.support.v4.app.FragmentManager fragmentManager = activity.getSupportFragmentManager();
                IActivityObservable activityObservable = RxResultHoldFragmentV4.getHoldFragment(fragmentManager);
                return activityObservable;
            }

            if (target instanceof Activity) {
                Activity activity = (Activity) target;
                FragmentManager fragmentManager = activity.getFragmentManager();
                IActivityObservable activityObservable = RxResultHoldFragment.getHoldFragment(fragmentManager);
                return activityObservable;
            }
            if (target instanceof Context) {
                final Context context = (Context) target;
                IActivityObservable activityObservable = new RxResultHoldContext(context);
                return activityObservable;
            }

            if (target instanceof Fragment) {
                Fragment fragment = (Fragment) target;
                FragmentManager fragmentManager = fragment.getFragmentManager();
                if (fragmentManager != null) {
                    IActivityObservable activityObservable = RxResultHoldFragment.getHoldFragment(fragmentManager);
                    return activityObservable;
                }
            }
            if (target instanceof android.support.v4.app.Fragment) {
                android.support.v4.app.Fragment fragment = (android.support.v4.app.Fragment) target;
                android.support.v4.app.FragmentManager fragmentManager = fragment.getFragmentManager();
                if (fragmentManager != null) {
                    IActivityObservable activityObservable = RxResultHoldFragmentV4.getHoldFragment(fragmentManager);
                    return activityObservable;
                }
            }
            return new RxResultHoldEmpty();
        }

动态拦截

拦截器是重中之重,有了拦截器可以做好多事情,可以说之所以要做页面路由,就是为了要实现拦截器。ARouter是用线程等待实现的,但是现在有Rxjava了,可以实现更优美的方式。
先来看一下我做的拦截器的效果.

@Intercept(path = "/second")
public class SecondInterceptor extends Interceptor {
    @Override
    public void intercept(final Call call) {

        Request request = call.getRequest();
        final Intent intent = request.getIntent();
        Context context = request.getContext();

        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("Intercept\n");
        stringBuffer.append("URL: " + request.getUrl() + "\n");

        AlertDialog.Builder builder = new AlertDialog.Builder(context,R.style.Theme_AppCompat_Dialog_Alert);
        builder.setTitle("Notice");
        builder.setMessage(stringBuffer);
        builder.setNegativeButton("cancel", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                call.cancel();
            }
        });
        builder.setPositiveButton("ok", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                intent.putExtra("key1", "value3");
                call.proceed();
            }
        });
        builder.setOnDismissListener(new DialogInterface.OnDismissListener() {
            @Override
            public void onDismiss(DialogInterface dialog) {
                call.cancel();
            }
        });
        builder.show();
    }
}

是不是很简单,参考了部分OkHttp的实现思路,加入Rxjava,实现异步拦截。

首先将请求转换成责任链模式RealCallChain,RealCallChain的call方法实际不会执行路由跳转,只有Interceptor里面调用了call.proceed或者call.cancel才会执行.

    private Observable<Request> buildRequest() {
        RealCallChain chain = new RealCallChain(interceptors, 0, request);
        chain.setTimeout(interceptTimeOut);
        chain.call();
        return chain
                .getRequestObservable()
                .map(new Func1<Request, Request>() {
                    @Override
                    public Request call(Request request) {
                        if (interceptors != null) {
                            for (Interceptor interceptor : interceptors) {
                                interceptor.onCall(request);
                            }
                        }
                        return request;
                    }
                });
    }

接着处理异步的问题,这里用到了Rxjava的AsyncSubject和BehaviorSubject,

  1. AsyncSubject具有仅释放Observable释放的最后一个数据的特性,作为路由请求的发送器
  2. BehaviorSubject具有一开始就会释放最近释放的数据的特性,作为路由拦截器的发送器

具体实现看核心代码

    @Override
    public void proceed() {


        if (index >= interceptors.size()) {
            realCall();
            return;
        }
        final Interceptor interceptor = interceptors.get(index);
        Observable
                .just(1)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Action1<Integer>() {
                    @Override
                    public void call(Integer integer) {
                        interceptor.intercept(RealCallChain.this);
                    }
                });

        interceptorSubject.onNext(interceptor);
        index = index + 1;
    }

方法调用

大部分路由库都是手动拼参数调用路由的,这里模仿了Retrofit接口式调用,受了LiteRouter的启发,不过Retrofit使用了动态代理,我使用的Apt没有性能损耗。

通过Apt生成每个接口的实际方法

譬如把SecondService接口

public interface SecondService {

    @Path("/second")
    @Activity(SecondActivity.class)
    void startSecondActivity();
}

生成

@Aspect
public final class SecondService$$Provider implements SecondService {
  public DeepLinkClient deepLinkClient;

  public SecondService$$Provider(DeepLinkClient deepLinkClient) {
    this.deepLinkClient= deepLinkClient;
  }
  @Override
  public void startSecondActivity() {
    Intent intent = new Intent();
    intent.setData(Uri.parse("app://deeplink/second"));
    Request request = deepLinkClient.buildRequest(intent);
    if (request != null) {
      request.start();
    }
  }

  @Around("execution(* okdeeplink.DeepLinkClient.build(..))")
  public Object aroundBuildMethod(ProceedingJoinPoint joinPoint) throws Throwable {
    DeepLinkClient target = (DeepLinkClient)joinPoint.getTarget();
    if (joinPoint.getArgs() == null || joinPoint.getArgs().length != 1) {
      return joinPoint.proceed();
    }
    Object arg = joinPoint.getArgs()[0];
    if (arg instanceof Class) {
      Class buildClass = (Class) arg;
      if (buildClass.isAssignableFrom(getClass())) {
        return new SecondService$$Provider(target);
      }
    }
    return joinPoint.proceed();
  }
}

然后调用

SecondService secondServicenew = DeepLinkClient(target).build(SecondService.class);

SecondService就生成了。
为了调用方便,直接在Activity或者fragement写这段代码,sampleServive就自动生成了

  @Service
  SampleService sampleService;

但是如果用到MVP模式,不是在Activity里面调用路由,后面会支持在这些类里面自动注入SampleService,现在先用java代码build

参数获取

大部分路由库都是手动获取参数的,这样还要传入参数key比较麻烦,这里模仿了ARouter,不过我支持类型更全一些,支持Bundle支持的所有类型,而且不需要在Acitivty的onCreate调用获取代码。
通过Apt把这段代码

public class MainActivity extends AppCompatActivity {

    @Query("key")
    String key;
}

生成

@Aspect
public class MainActivity$$Injector {
  @Around("execution(* okdeeplink.sample.MainActivity.onCreate(..))")
  public void onCreate(ProceedingJoinPoint joinPoint) throws Throwable {
    MainActivity target = (MainActivity)joinPoint.getTarget();
    Bundle dataBundle = new Bundle();
    Bundle saveBundle = (Bundle)joinPoint.getArgs()[0];
    Bundle targetBundle = BundleCompact.getSupportBundle(target);
    if(targetBundle != null) {
      dataBundle.putAll(targetBundle);
    }
    if(saveBundle != null) {
      dataBundle.putAll(saveBundle);
    }
    try {
      target.key= BundleCompact.getValue(dataBundle,"key",String.class);
    } catch (Exception e) {
      e.printStackTrace();
    }
    joinPoint.proceed();
  }

  @After("execution(* okdeeplink.sample.MainActivity.onSaveInstanceState(..))")
  public void onSaveInstanceState(JoinPoint joinPoint) throws Throwable {
    MainActivity target = (MainActivity)joinPoint.getTarget();
    Bundle saveBundle = (Bundle)joinPoint.getArgs()[0];
    Intent intent = new Intent();
    intent.putExtra("key",target.key);
    saveBundle.putAll(intent.getExtras());
  }

  @Around("execution(* okdeeplink.sample.MainActivity.onNewIntent(..))")
  public void onNewIntent(ProceedingJoinPoint joinPoint) throws Throwable {
    MainActivity target = (MainActivity)joinPoint.getTarget();
    Intent targetIntent = (Intent)joinPoint.getArgs()[0];
    Bundle dataBundle = targetIntent.getExtras();
    try {
      target.key= BundleCompact.getValue(dataBundle,"key",String.class);
    } catch (Exception e) {
      e.printStackTrace();
    }
    joinPoint.proceed();
  }
}

Module接入不同App

这里是参考ARouter把path作为key对应activity,这样接入到其他app中,就自动替换了scheme码

DeepLinkClient.addAddress(new Address("/main", MainActivity.class));

安全

现在有好多人用脚本来打开App,然后干坏事,其实时可以用路由来屏蔽掉.

有三种方法供君选择,不同方法适合不同场景

签名屏蔽

就是把所有参数加密成一个数据作为sign参数,然后比对校验,但是这要求加密方法不变,要不然升级了以前的app就打不开了

adb打开屏蔽

在android5.1手机上,用adb打开的app它的mReferrer为空

 public boolean isStartByAdb(android.app.Activity activity){
        if (Build.VERSION.SDK_INT >= 22) {
            android.net.Uri uri = ActivityCompat.getReferrer(activity);
            return uri == null | TextUtils.isEmpty(uri.toString()) ;
        }
        return false;
    }

包名过滤

在Android 4.4手机上, 写了android:ssp的组件,只有特定应用可以打开

"okdeeplink.DeepLinkActivity"
            android:noHistory="true"
            android:theme="@android:style/Theme.Translucent.NoTitleBar">
            
                "android.intent.action.VIEW" />

                "android.intent.category.DEFAULT" />
                "android.intent.category.BROWSABLE" />

                "com.app.test"
                    android:host="app"
                    android:scheme="odl" />
            
        

这三种方法,比较适合的还是签名校验为主,adb过滤为副

如何解决路由造成的Activity堆栈错乱的问题

activity的launchMode使用不当会照成闪屏页面打开多次的问题,可以参考我这篇文章。

未来展望

路由是一个基础模块,技术难度虽然不是很大,但是如果每个开发都重新踩一遍,性价比就比较低,我希望能把路由相关的所有链路都替你弄好,你可以留着时间去干其他更重要的事情,譬如陪陪家人,逗逗狗什么的。
接下来我会在这几个方面努力,把整条链路补全。

  • 做一个像Swagger的平台,支持一键导出所有路由、二维码打开路由
  • 注解修改AndroidManifest,不再需要路由表
  • 支持路由方法接收器,Url直接打开某个方法,不再局限Activity已实现

如果大家有意见,欢迎联系我[email protected]

参考文献

业界做法

  • airbnb开源的页面路由
  • 阿里开源的页面路由
  • 天猫的统跳协议
  • 蘑菇街的页面路由
  • Google App Link
  • 移动DeepLink的前世今生

    设计方案

  • UrlRouter路由框架的设计
  • 移动端路由层设计
  • 客户端路由动态配置
  • 移动端基于动态路由的架构设计
  • Android组件化通信(多进程)
  • iOS 组件化 —— 路由设计思路分析
  • QQ音乐首页Activity的单例实现

    作者开发

  • LiteRouter 模仿retrofit,各个业务分根据需求约定好接口,就像一份接口文档一样
  • ActivityRouter
  • ActivityRouter2
  • AndRouter
  • Router
  • Router2
  • router-android

    安全讨论

  • 如何在Activity中获取调用者 讨论了android里面原生支持找到路由来源的可能性,分析了referrer是如何产生的
  • LauncherFrom
    提供了一种hook activitythread找到launchedFromPackage的方法,不过也只支持5.0以上
  • 高效过滤Intents
    只有包含特定Package URL的 intent 才会唤起页面

你可能感兴趣的:(Android)