Android 路由設計最佳實踐

sunshine8發表於2017-06-19

引子

這篇文章會告訴你

  • 什麼是路由,是為了解決什麼問題才產生的
  • 業界現狀是怎麼樣的,我們可以做什麼來優化當前的問題
  • 路由設計思路是怎麼樣的,該怎麼設計比較好
  • 如何用註解實現路由表
  • URL的引數如何依賴注入到Activity、Fragement
  • 如何HookOnActivityResult,不需要再進行requstCode判斷
  • 如何非同步攔截路由,實現執行緒切換,不阻塞頁面跳轉
  • 如何用Apt實現Retrofit介面式呼叫
  • 如何找到Activity的呼叫方
  • 如何實現路由的安全呼叫
  • 如何避開Apt不能彙總所有Module路由的問題

前言

當前Android的路由庫實在太多了,剛開始的時候想為什麼要用路由表的庫,用Android原生的Scheme碼不就好了,又不像iOS只能類依賴,後面越深入就越發現當時想的太簡單了,後面看到Retrofit和OKHttp,才想到頁面請求本質和網路請求不是一樣嗎,終於業界最簡單高效的路由方案1.0出來了

OkDeepLink

背景

什麼是路由

根據路由表頁面請求分發到指定頁面

使用場景

  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

註冊路由

具體使用見OkDeepLink

路由結構圖

詳細比較

大部分路由庫都用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<String> getFileNameByPackageName(Context context, String packageName) throws PackageManager.NameNotFoundException, IOException {
        List<String> 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<String> 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<Request>() {
                    @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堆疊沒有采用這種方案

getwindow().addflags( windowmanager.layoutparams.flag_not_focusable
| windowmanager.layoutparams.flag_not_touch_modal 
| windowmanager.layoutparams.flag_not_touchable);

結果返回

這裡我封裝了一個庫RxActivityResult去捕獲onActivityResult,這樣能保正流式呼叫

譬如拍照可以這樣寫,先定義一個介面

    public interface ImageCaptureService {

    @Action(MediaStore.ACTION_IMAGE_CAPTURE)
    Observable<Response> 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的元件,只有特定應用可以開啟

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

                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />

                <data
                    android:ssp="com.app.test"
                    android:host="app"
                    android:scheme="odl" />
            </intent-filter>
        </activity>

這三種方法,比較適合的還是簽名校驗為主,adb過濾為副

如何解決路由造成的Activity堆疊錯亂的問題

activity的launchMode使用不當會照成閃屏頁面開啟多次的問題,可以參考我這篇文章

未來展望

路由是一個基礎模組,技術難度雖然不是很大,但是如果每個開發都重新踩一遍,價效比就比較低,我希望能把路由相關的所有鏈路都替你弄好,你可以留著時間去幹其他更重要的事情,譬如陪陪家人,逗逗狗什麼的。
接下來我會在這幾個方面努力,把整條鏈路補全。

  • 做一個像Swagger的平臺,支援一鍵匯出所有路由、二維碼開啟路由
  • 註解修改AndroidManifest,不再需要路由表
  • 支援路由方法接收器,Url直接開啟某個方法,不再侷限Activity

如果大家有意見,歡迎聯絡我kingofzqj@gmail.com

相關文章