1. 程式人生 > >教你打造一個Android元件化開發框架

教你打造一個Android元件化開發框架

*本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出

CC:Component Caller,一個android元件化開發框架, 已開源,github地址:https://github.com/luckybilly/CC
本文主要講解框架實現原理,如果只是想了解一下如何使用,可直接到github上檢視README文件。
想了解如何用CC實現立即開始元件化開發,並漸進式地改造自己的專案,戳這裡

前言

首先說明一下,本文將講述的元件化與業內的外掛化(如:Atlas, RePlugin等)不是同一個概念

元件化 vs 外掛化
【圖片來源於網路】

元件化開發:就是將一個app分成多個Module,每個Module都是一個元件(也可以是一個基礎庫供元件依賴),開發的過程中我們可以單獨除錯部分元件,元件間不需要互相依賴,但可以相互呼叫,最終釋出的時候所有元件以lib的形式被主app工程依賴並打包成1個apk。

外掛化開發:和元件化開發略有不用,外掛化開發時將整個app拆分成很多模組,這些模組包括一個宿主和多個外掛,每個模組都是一個apk(元件化的每個模組是個lib),最終打包的時候將宿主apk和外掛apk(或其他格式)分開或者聯合打包。

本文將主要就以下幾個方面進行介紹:

一、為什麼需要元件化?

二、CC的功能介紹

三、CC技術要點

四、CC執行流程詳細解析

五、使用方式介紹

一、為什麼需要元件化?

關於使用元件化的理由,上網能搜到很多,如業務隔離、單獨以app執行能提高開發及除錯效率等等這裡就不多重複了,我補充一條:元件化之後,我們能很容易地實現一些元件層面的AOP,例如:

  • 輕易實現頁面資料(網路請求、I/O、資料庫查詢等)預載入的功能
    • 元件被呼叫時,進行頁面跳轉的同時非同步執行這些耗時邏輯
    • 頁面跳轉並初始化完成後,再將這些提前載入好的資料展示出來
  • 在元件功能呼叫時進行登入狀態校驗
  • 藉助攔截器機制,可以動態給元件功能呼叫新增不同的中間處理邏輯

二、CC的功能介紹

  1. 支援元件間相互呼叫(不只是Activity跳轉,支援任意指令的呼叫/回撥)
  2. 支援元件呼叫與Activity、Fragment的生命週期關聯
  3. 支援app間跨程序的元件呼叫(元件開發/除錯時可單獨作為app執行)

    • 在獨立執行元件時非常有用,比如:一個元件的某個功能要用到使用者的登入資訊,若未登入則調起登入元件的登入頁面,若已登入則獲取當前使用者資訊。此時可以直接使用主app中的登入元件及使用者在主app中的登入狀態,該元件作為app獨立執行時無需依賴登入元件。
  4. 支援app間呼叫的開關及許可權設定(滿足不同級別的安全需求,預設開啟狀態且不需要許可權)
  5. 支援同步/非同步方式呼叫
  6. 支援同步/非同步方式實現元件
  7. 呼叫方式不受實現方式的限制(例如:可以非同步呼叫另一個元件的同步實現功能。注:不要在主執行緒同步呼叫耗時操作)
  8. 支援新增自定義攔截器(按新增的先後順序執行)
  9. 支援超時設定
  10. 支援手動取消
  11. 編譯時自動註冊元件(IComponent),無需手動維護元件登錄檔(使用ASM修改位元組碼的方式實現)
  12. 支援動態註冊/反註冊元件(IDynamicComponent)
  13. 支援元件間傳遞Fragment等非基礎型別的物件(元件在同一個app內時支援、跨app傳遞非基礎型別的物件暫不支援)
  14. 儘可能的解決了使用姿勢不正確導致的crash:

    • 元件呼叫處、回撥處、元件實現處的crash全部在框架內部catch住
    • 同步返回或非同步回撥的CCResult物件一定不為null,避免空指標

demo效果演示

元件A打包在主app中,元件B為單獨執行的元件app,下圖演示了在主app中呼叫兩者的效果,並將結果以Json的格式顯示在下方。demo下載地址):

demo

三、 CC技術要點

實現CC元件化開發框架主要需要解決的問題有以下幾個方面:

  • 元件如何自動註冊?
  • 如何相容同步/非同步方式呼叫元件?
  • 如何相容同步/非同步方式實現元件?
  • 如何進行跨程序元件任意功能的呼叫(不只是啟動Activity)?
  • 元件如何更方便地在application和library之間切換?
  • 如何實現startActivityForResult?
  • 如何阻止非法的外部呼叫?
  • 如何與Activity、Fragment的生命週期關聯起來

3.1 元件如何自動註冊?

為了減少後期維護成本,想要實現的效果是:當需要新增某個元件到app時,只需要在gradle中新增一下對這個module的依賴即可(通常都是maven依賴,也可以是project依賴)

最初想要使用的是annotationProcessor通過編譯時註解動態生成元件對映表程式碼的方式來實現。但嘗試過後發現行不通,因為編譯時註解的特性只在原始碼編譯時生效,無法掃描到aar包裡的註解(project依賴、maven依賴均無效),也就是說必須每個module編譯時生成自己的程式碼,然後要想辦法將這些分散在各aar種的類找出來進行集中註冊。

ARouter的解決方案是:

  • 每個module都生成自己的java類,這些類的包名都是’com.alibaba.android.arouter.routes’
  • 然後在執行時通過讀取每個dex檔案中的這個包下的所有類通過反射來完成對映表的註冊,詳見ClassUtils.java原始碼

    執行時通過讀取所有dex檔案遍歷每個entry查詢指定包內的所有類名,然後反射獲取類物件。這種效率看起來並不高。

ActivityRouter的解決方案是(demo中有2個元件名為’app’和’sdk’):

  • 在主app module中有一個@Modules({"app", "sdk"})註解用來標記當前app內有多少元件,根據這個註解生成一個RouterInit類
  • 在RouterInit類的init方法中生成呼叫同一個包內的RouterMapping_app.map
  • 每個module生成的類(RouterMapping_app.java 和 RouterMapping_sdk.java)都放在com.github.mzule.activityrouter.router包內(在不同的aar中,但包名相同)
  • 在RouterMapping_sdk類的map()方法中根據掃描到的當前module內所有路由註解,生成了呼叫Routers.map(…)方法來註冊路由的程式碼
  • 在Routers的所有api介面中最終都會觸發RouterInit.init()方法,從而實現所有路由的對映表註冊

    這種方式用一個RouterInit類組合了所有module中的路由對映表類,執行時效率比掃描所有dex檔案的方式要高,但需要額外在主工程程式碼中維護一個元件名稱列表註解: @Modules({“app”, “sdk”})

還有沒有更好的辦法呢?

Transform API: 可以在編譯時(dex/proguard之前)掃描當前要打包到apk中的所有類,包括: 當前module中java檔案編譯後的class、aidl檔案編譯後的class、jar包中的class、aar包中的class、project依賴中的class、maven依賴中的class。

ASM: 可以讀取分析位元組碼、可以修改位元組碼

二者結合,可以做一個gradle外掛,在編譯時自動掃描所有元件類(IComponent介面實現類),然後修改位元組碼,生成程式碼呼叫掃描到的所有元件類的構造方法將其註冊到一個元件管理類(ComponentManager)中,生成元件名稱與元件物件的對映表。

此gradle外掛被命名為:AutoRegister,現已開源,並將功能升級為編譯時自動掃描任意指定的介面實現類(或類的子類)並自動註冊到指定類的指定方法中。只需要在app/build.gradle中配置一下掃描的引數,沒有任何程式碼侵入,原理詳細介紹傳送門

3.2 如何相容同步/非同步方式呼叫元件?

通過實現java.util.concurrent.Callable介面同步返回結果來相容同步/非同步呼叫:

  • 同步呼叫時,直接呼叫CCResult result = Callable.call()來獲取返回結果
ExecutorService.submit(callable)

3.3 如何相容同步/非同步方式實現元件?

呼叫元件的onCall方法時,可能需要非同步實現,並不能同步返回結果,但同步呼叫時又需要返回結果,這是一對矛盾。
此處用到了Object的wait-notify機制,當元件需要非同步返回結果時,在CC框架內部進行阻塞,等到結果返回時,通過notify中止阻塞,返回結果給呼叫方

注意,這裡要求在實現一個元件時,必須確保元件一定會回撥結果,即:需要確保每一種導致呼叫流程結束的邏輯分支上(包括if-else/try-catch/Activity.finish()-back鍵-返回按鈕等等)都會回撥結果,否則會導致呼叫方一直阻塞等待結果,直至超時。類似於向伺服器傳送一個網路請求後伺服器必須返回請求結果一樣,否則會導致請求超時。

3.4 如何進行跨程序元件任意功能的呼叫(不只是啟動Activity)?

市面上常見的元件化框架採用的通訊解決方案有:

  • URLScheme(例如:ActivityRouterARouter等)
    • 優勢有:
      • 基因中自帶支援從webview中呼叫
      • 不用互相註冊(不用知道需要呼叫的app的程序名稱等資訊)
    • 劣勢有:
      • 只能單向地給元件傳送資訊,適用於啟動Activity和傳送指令,不適用於獲取資料(例如:獲取使用者元件的當前使用者登入資訊)
      • 需要有個額外的中轉Activity來統一處理URLScheme
      • 如果裝置上安裝了多個使用相同URLScheme的app,會彈出選擇框(多個元件作為app同時安裝到裝置上時會出現這個問題)
      • 無法進行許可權設定,無法進行開關設定,存在安全性風險
  • AIDL (例如:ModularizationArchitecture)
    • 優勢有:
      • 可以傳遞Parcelable型別的物件
      • 效率高
      • 可以設定跨app呼叫的開關
    • 劣勢有:
      • 呼叫元件之前需要提前知道該元件在那個程序,否則無法建立ServiceConnection
      • 元件在作為獨立app和作為lib打包到主app時,程序名稱不同,維護成本高

設計此功能時,我的出發點是:作為元件化開發框架基礎庫,想盡量讓跨程序呼叫與在程序內部呼叫的功能一致,對使用此框架的開發者在切換app模式和lib模式時儘量簡單,另外需要儘量不影響產品安全性。因此,跨元件間通訊實現的同時,應該滿足以下條件:

  • 每個app都能給其它app呼叫
  • app可以設定是否對外提供跨程序元件呼叫的支援
  • 元件呼叫的請求發出去之後,能自動探測當前裝置上是否有支援此次呼叫的app
  • 支援超時、取消

基於這些需求,我最終選擇了BroadcastReceiver + Service + LocalSocket來作為最終解決方案:

如果appA內發起了一個當前app內不存在的元件:Component1,則建立一個LocalServerSocket,同時傳送廣播給裝置上安裝的其它同樣使用了此框架的app,同時,若某個appB內支援此元件,則根據廣播中帶來的資訊與LocalServerSocket建立連線,並在appB內呼叫元件Component1,並將結果通過LocalSocket傳送給appA。

BroadcastReceiver是android四大元件之一,可以設定接收許可權,能避免外部惡意呼叫。並且可以設定開關,接收到此廣播後決定是否響應(假裝沒接收到…)。
之所以建立LocalSocket連結,是為了能繼續給這次元件呼叫請求傳送超時和取消的指令。

用這種方式實現時,遇到了3個問題:

  • 由於廣播接收器定義在基礎庫中,所有app內都有,當用戶在主執行緒中同步呼叫跨app的元件時,呼叫方主執行緒被阻塞,廣播接收器也在需要主執行緒中執行,導致廣播接收器無法執行,直至timeout,元件呼叫失敗。
    • 將廣播接收器放到子程序中執行問題得到解決
  • 被呼叫的app未啟動或被手動結束程序,遇到廣播接收不到的問題
    • 這個問題暫時未很好的解決,但考慮到元件化開發只在開發期間需要用到跨程序通訊,開發者可以通過手動在系統設定中給對應的app賦予自啟動許可權來解決問題
  • 跨程序呼叫時,只能傳遞基本資料型別,無法獲取Fragment等java物件
    • 這個問題在app內部呼叫時不存在,app內部來回傳遞的都是Map,可以傳遞任何資料型別。但由於程序間通訊是通過字串來回傳送的,暫時支援不了非基本資料型別,未來可以考慮支援Serializable

3.5 元件如何更方便地在application和library之間切換?

關於切換方式在網路上有很多文章介紹,基本上都是一個思路:在module的build.gradle中設定一個變數來控制切換apply plugin: 'com.android.application'apply plugin: 'com.android.library'以及sourceSets的切換。
為了避免在每個module的build.gradle中配置太多重複程式碼,我做了個封裝,預設為library模式,提供2種方式切換為application模式:在module的build.gradle中新增ext.runAsApp = true或在工程根目錄中local.properties中新增module_name=true

使用這個封裝只需一行程式碼:

// 將原來的 apply plugin: 'com.android.application'或apply plugin: 'com.android.library'
//替換為下面這一行
apply from: 'https://raw.githubusercontent.com/luckybilly/CC/master/cc-settings.gradle'

3.6 如何實現startActivityForResult?

android的startActivityForResult的設計也是為了頁面傳值,在CC元件化框架中,頁面傳值根本不需要用到startActivityForResult,直接作為非同步實現的元件來處理(在原來setResult的地方呼叫CC.sendCCResult(callId, ccResult)另外需要注意:按back鍵及返回按鈕的情況也要回調結果)即可。

如果是原來專案中存在大量的startActivityForResult程式碼,改造成本較大,可以用下面這種方式來保留原來的onActivityResult(…)及activity中setResult相關的程式碼:

  • 在原來呼叫startActivityForResult的地方,改用CC方式呼叫,將當前context傳給元件

    CC.obtainBuilder("demo.ComponentA")
        .setContext(context)
        .addParams("requestCode", requestCode)
        .build()
        .callAsync();
  • 在元件的onCall(cc)方法中用startActivityForResult的方式開啟Activity

       @Override
       public boolean onCall(CC cc) {
           Context context = cc.getContext();
           Object code = cc.getParams().get("requestCode");
           Intent intent = new Intent(context, ActivityA.class);
           if (!(context instanceof Activity)) {
               //呼叫方沒有設定context或app間元件跳轉,context為application
               intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
           }
           if (context instanceof Activity && code != null && code instanceof Integer) {
               ((Activity)context).startActivityForResult(intent, (Integer)code);
           } else {
               context.startActivity(intent);
           }
           CC.sendCCResult(cc.getCallId(), CCResult.success());
           return false;
       }

3.7 如何阻止非法的外部呼叫?

為了適應不同需求,有2個安全級別可以設定:

  • 許可權驗證(給程序間通訊的廣播設定許可權,一般可設定為簽名級許可權校驗),步驟如下:

    • 新建一個module
    • 在該module的build.gradle中新增對基礎庫的依賴,如: compile 'com.billy.android:cc:0.3.0'
    • 在該module的src/main/AndroidManifest.xml中設定許可權及許可權的級別,參考component_protect_demo
    • 其它每個module都額外依賴此module,或自定義一個全域性的cc-settings.gradle,參考cc-settings-demo-b.gradle
  • 外部呼叫是否響應的開關設定(這種方式使用起來更簡單一些)

    • 在Application.onCreate()中呼叫CC.enableRemoteCC(false)可關閉響應外部呼叫

為了方便開發者接入,預設是開啟了對外部元件呼叫的支援,並且不需要許可權驗證。app正式釋出前,建議呼叫CC.enableRemoteCC(false)來關閉響應外部呼叫本app的元件。

3.8 如何與Activity、Fragment的生命週期關聯起來

背景:在使用非同步呼叫時,由於callback物件一般是使用匿名內部類,會持有外部類物件的引用,容易引起記憶體洩露,這種記憶體洩露的情況在各種非同步回撥中比較常見,如Handler.post(runnable)、Retrofit的Call.enqueue(callback)等。

為了避免記憶體洩露及頁面退出後取消執行不必要的任務,CC添加了生命週期關聯的功能,在onDestroy方法被呼叫時自動cancel頁面內所有未完成的元件呼叫

  • Activity生命週期關聯

    在api level 14 (android 4.0)以上可以通過註冊全域性activity生命週期回撥監聽,在onActivityDestroyed方法中找出所有此activity關聯且未完成的cc物件,並自動呼叫取消功能:

    application.registerActivityLifecycleCallbacks(lifecycleCallback);
  • android.support.v4.app.Fragment生命週期關聯

    support庫從25.1.0開始支援給fragment設定生命週期監聽:

    FragmentManager.registerFragmentLifecycleCallbacks(callback)

    可在其onFragmentDestroyed方法中取消未完成的cc呼叫

  • andorid.app.Fragment生命週期關聯(暫不支援)

四、 CC執行流程詳細解析

元件間通訊採用了元件匯流排的方式,在基礎庫的元件管理類(ComponentMananger)中註冊了所有元件物件,ComponentMananger通過查詢對映表找到元件物件並呼叫。

當ComponentMananger接收到元件的呼叫請求時,查詢當前app內元件清單中是否含有當前需要呼叫的元件

  • 有: 執行App內部CC呼叫的流程:

App內部元件呼叫匯流排

  • 沒有:執行App之間CC呼叫的流程

    App之間元件呼叫匯流排

4.1 元件的同步/非同步實現和元件的同步/非同步呼叫原理

  • 元件實現時,當元件呼叫的相關功能結束後,通過CC.sendCCResult(callId, ccResult)將呼叫結果傳送給框架
  • IComponent實現類(元件入口類)onCall(cc)方法的返回值代表是否非同步回撥結果:
    • true: 將非同步呼叫CC.sendCCResult(callId, ccResult)
    • false: 將同步呼叫CC.sendCCResult(callId, ccResult)。意味著在onCall方法執行完之前會呼叫此方法將結果發給框架
  • 當IComponent.onCall(cc)返回false時,直接獲取CCResult並返回給呼叫方
  • 當IComponent.onCall(cc)返回true時,將進入wait()阻塞,知道獲得CCResult後通過notify()中止阻塞,繼續執行,將CCResult返回給呼叫方
  • 通過ComponentManager呼叫元件時,建立一個實現了java.util.concurrent.Callable介面ChainProcessor類來負責具體元件的呼叫
    • 同步呼叫時,直接執行ChainProcessor.call()來呼叫元件,並將CCResult直接返回給呼叫方
    • 非同步呼叫時,將ChainProcessor放入執行緒池中執行,通過IComponentCallback.onResult(cc, ccResult)將CCResult回撥給呼叫方

執行過程如下圖所示:

CC相容同步/非同步呼叫和實現原理圖

4.2 自定義攔截器(ICCInterceptor)實現原理

  • 所有攔截器按順序存放在呼叫鏈(Chain)中
  • 在自定義攔截器之前有1個CC框架自身的攔截器:
    • ValidateInterceptor
  • 在自定義攔截器之後有2個CC框架自身的攔截器:
    • LocalCCInterceptor(或RemoteCCInterceptor)
    • Wait4ResultInterceptor
  • Chain類負責依次執行所有攔截器interceptor.intercept(chain)
  • 攔截器intercept(chain)方法通過呼叫Chain.proceed()方法獲取CCResult

    攔截器呼叫流程

4.3 App內部CC呼叫流程

當要呼叫的元件在當前app內部時,執行此流程,完整流程圖如下:

App內部CC呼叫流程圖

CC的主體功能由一個個攔截器(ICCInterceptor)來完成,攔截器形成一個呼叫鏈(Chain),呼叫鏈由ChainProcessor啟動執行,ChainProcessor物件在ComponentManager中被建立。
因此,可以將ChainProcessor看做一個整體,由ComponentManager建立後,呼叫元件的onCall方法,並將元件執行後的結果返回給呼叫方。
ChainProcessor內部的Wait4ResultInterceptor
ChainProcessor的執行過程可以被timeout和cancel兩種事件中止。

4.4 App之間CC呼叫流程

當要呼叫的元件在當前app內找不到時,執行此流程,完整流程圖如下:

App之間CC呼叫流程圖

五、使用方式介紹

CC的整合非常簡單,僅需4步即可完成整合:

  1. 新增自動註冊外掛

    buildscript {
        dependencies {
            classpath 'com.billy.android:autoregister:1.0.4'
        }
    }
  2. 引用apply cc-settings.gradle檔案代替 ‘app plugin …’

    apply from: 'https://raw.githubusercontent.com/luckybilly/CC/master/cc-settings.gradle'
    
  3. 實現IComponent介面建立一個元件類

    public class ComponentA implements IComponent {
    
        @Override
        public String getName() {
            //元件的名稱,呼叫此元件的方式:
            // CC.obtainBuilder("demo.ComponentA").build().callAsync()
            return "demo.ComponentA";
        }
    
        @Override
        public boolean onCall(CC cc) {
            Context context = cc.getContext();
            Intent intent = new Intent(context, ActivityComponentA.class);
            if (!(context instanceof Activity)) {
                //呼叫方沒有設定context或app間元件跳轉,context為application
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            }
            context.startActivity(intent);
            //傳送元件呼叫的結果(返回資訊)
            CC.sendCCResult(cc.getCallId(), CCResult.success());
    
            return false;
        }
    }
  4. 使用CC.obtainBuilder("component_name").build().call()呼叫元件

    //同步呼叫,直接返回結果
    CCResult result = CC.obtainBuilder("demo.ComponentA").build().call();
    //或 非同步呼叫,不需要回調結果
    CC.obtainBuilder("demo.ComponentA").build().callAsync();
    //或 非同步呼叫,在子執行緒執行回撥
    CC.obtainBuilder("demo.ComponentA").build().callAsync(new IComponentCallback(){...});
    //或 非同步呼叫,在主執行緒執行回撥
    CC.obtainBuilder("demo.ComponentA").build().callAsyncCallbackOnMainThread(new IComponentCallback(){...});

更多用法請看github上的README

結語

本文比較詳細地介紹了android元件化開發框架《CC》的主要功能、技術方案及執行流程,並給出了使用方式的簡單示例。
大家如果感興趣的話可以從GitHub上clone原始碼來進行具體的分析,如果有更好的思路和方案也歡迎貢獻程式碼進一步完善CC。

系列文章

致謝

交流

billy(齊翊)的微信二維碼