【動態加載】Android動態加載進階:代理Activity模式

技術背景

簡單模式中,使用ClassLoader加載外部的Dex或Apk文件,可以加載一些本地APP不存在的類,從而執行一些新的代碼邏輯。但是使用這種方法卻不能直接啓動插件裏的Activity。

啓動沒有註冊的Activity的兩個主要問題

Activity等組件是需要在Manifest中註冊後才能以標準Intent的方式啓動的(如果有興趣強烈推薦你瞭解下Activity生命週期實現的機制及源碼),通過ClassLoader加載並實例化的Activity實例只是一個普通的Java對象,能調用對象的方法,但是它沒有生命週期,而且Activity等系統組件是需要Android的上下文環境的(Context等資源),沒有這些東西Activity根本無法工作。

使用插件APK裏的Activity需要解決兩個問題

  1. 如何使插件APK裏的Activity具有生命週期;

  2. 如何使插件APK裏的Activity具有上下文環境(使用R資源);

代理Activity模式爲解決這兩個問題提供了一種思路。

代理Activity模式

這種模式也是我們項目中,繼“簡單動態加載模式”之後,第二種投入實際生產項目的開發方式。

其主要特點是:主項目APK註冊一個代理Activity(命名爲ProxyActivity),ProxyActivity是一個普通的Activity,但只是一個空殼,自身並沒有什麼業務邏輯。每次打開插件APK裏的某一個Activity的時候,都是在主項目裏使用標準的方式啓動ProxyActivity,再在ProxyActivity的生命週期裏同步調用插件中的Activity實例的生命週期方法,從而執行插件APK的業務邏輯。

ProxyActivity + 沒註冊的Activity = 標準的Activity

下面談談代理模式是怎麼處理上面提到的兩個問題的。

處理插件Activity的生命週期

目前還真的沒什麼辦法能夠處理這個問題,一個Activity的啓動,如果不採用標準的Intent方式,沒有經歷過Android系統Framework層級的一系列初始化和註冊過程,它的生命週期方法是不會被系統調用的(除非你能夠修改Android系統的一些代碼,而這已經是另一個領域的話題了,這裏不展開)。

那把插件APK裏所有Activity都註冊到主項目的Manifest裏,再以標準Intent方式啓動。但是事先主項目並不知道插件Activity裏會新增哪些Activity,如果每次有新加的Activity都需要升級主項目的版本,那不是本末倒置了,不如把插件的邏輯直接寫到主項目裏來得方便。

那就繞繞彎吧,生命週期不就是系統對Activity一些特定方法的調用嘛,那我們可以在主項目裏創建一個ProxyActivity,再由它去代理調用插件Activity的生命週期方法(這也是代理模式叫法的由來)。用ProxyActivity(一個標準的Activity實例)的生命週期同步控制插件Activity(普通類的實例)的生命週期,同步的方式可以有下面兩種:

  • 在ProxyActivity生命週期裏用反射調用插件Activity相應生命週期的方法,簡單粗暴。

  • 把插件Activity的生命週期抽象成接口,在ProxyActivity的生命週期裏調用。另外,多了這一層接口,也方便主項目控制插件Activity。

這裏補充說明下,Fragment自帶生命週期,用Fragment來代替Activity開發可以省去大部分生命週期的控制工作,但是會使得界面跳轉比較麻煩,而且Honeycomb以前沒有Fragment,無法在API11以前的系統使用。

在插件Activity裏使用R資源

使用代理的方式同步調用生命週期的做法容易理解,也沒什麼問題,但是要使用插件裏面的res資源就有點麻煩了。簡單的說,res裏的每一個資源都會在R.java裏生成一個對應的Integer類型的id,APP啓動時會先把R.java註冊到當前的上下文環境,我們在代碼裏以R文件的方式使用資源時正是通過使用這些id訪問res資源,然而插件的R.java並沒有註冊到當前的上下文環境,所以插件的res資源也就無法通過id使用了。

這個問題困擾了我們很久,一開始的項目急於投入生產,所以我們索性拋開res資源,插件裏需要用到的新資源都通過純Java代碼的方式創建(包括XML佈局、動畫、點九圖等),蛋疼但有效。知道網上出現瞭解決這一個問題的有效方法(一開始貌似是在手機QQ項目中出現的,但是沒有開源所以不清楚,在這裏真的佩服這些對技術這麼有追求的開發者)。

記得我們平時怎麼使用res資源的嗎,就是“getResources().getXXX(resid)”,看看“getResources()”

@Override
    public Resources getResources() {
        if (mResources != null) {
            return mResources;
        }
        if (mOverrideConfiguration == null) {
            mResources = super.getResources();
            return mResources;
        } else {
            Context resc = createConfigurationContext(mOverrideConfiguration);
            mResources = resc.getResources();
            return mResources;
        }
    }

看起來像是通過mResources實例獲取res資源的,在找找mResources實例是怎麼初始化的,看看上面的代碼發現是使用了super類ContextThemeWrapper裏的“getResources()”方法,看進去

Context mBase;
    public ContextWrapper(Context base) {
        mBase = base;
    }
@Override
    public Resources getResources()
    {
        return mBase.getResources();
    }

看樣子又調用了Context的“getResources()”方法,看到這裏,我們知道Context只是個抽象類,其實際工作都是在ContextImpl完成的,趕緊去ContextImpl裏看看“getResources()”方法吧

@Override
    public Resources getResources() {
        return mResources;
    }

…………
……
你TM在逗我麼,還是沒有mResources的創建過程啊!啊,不對,mResources是ContextImpl的成員變量,可能是在構造方法中創建的,趕緊去看看構造方法(這裏只給出關鍵代碼)。

resources = mResourcesManager.getTopLevelResources(packageInfo.getResDir(),
                        packageInfo.getSplitResDirs(), packageInfo.getOverlayDirs(),
                        packageInfo.getApplicationInfo().sharedLibraryFiles, displayId,
                        overrideConfiguration, compatInfo);
mResources = resources;

看樣子是在ResourcesManager的“getTopLevelResources”方法中創建的,看進去

 Resources getTopLevelResources(String resDir, String[] splitResDirs,
            String[] overlayDirs, String[] libDirs, int displayId,
            Configuration overrideConfiguration, CompatibilityInfo compatInfo) {
        Resources r;
        AssetManager assets = new AssetManager();
        if (libDirs != null) {
            for (String libDir : libDirs) {
                if (libDir.endsWith(".apk")) {
                    if (assets.addAssetPath(libDir) == 0) {
                        Log.w(TAG, "Asset path '" + libDir +
                                "' does not exist or contains no resources.");
                    }
                }
            }
        }
        DisplayMetrics dm = getDisplayMetricsLocked(displayId);
        Configuration config ……;
        r = new Resources(assets, dm, config, compatInfo);
        return r;
    }

看來這裏是關鍵了,看樣子就是通過這些代碼從一個APK文件加載res資源並創建Resources實例,經過這些邏輯後就可以使用R文件訪問資源了。具體過程是,獲取一個AssetManager實例,使用其“addAssetPath”方法加載APK(裏的資源),再使用DisplayMetrics、Configuration、CompatibilityInfo實例一起創建我們想要的Resources實例。

最終訪問插件APK裏res資源的關鍵代碼如下

    try {  
        AssetManager assetManager = AssetManager.class.newInstance();  
        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);  
        addAssetPath.invoke(assetManager, mDexPath);  
        mAssetManager = assetManager;  
    } catch (Exception e) {  
        e.printStackTrace();  
    }  
    Resources superRes = super.getResources();  
    mResources = new Resources(mAssetManager, superRes.getDisplayMetrics(),  
            superRes.getConfiguration());  

注意,有的人擔心從插件APK加載進來的res資源的ID可能與主項目裏現有的資源ID衝突,其實這種方式加載進來的res資源並不是融入到主項目裏面來,主項目裏的res資源是保存在ContextImpl裏面的Resources實例,整個項目共有,而新加進來的res資源是保存在新創建的Resources實例的,也就是說ProxyActivity其實有兩套res資源,並不是把新的res資源和原有的res資源合併了(所以不怕R.id重複),對兩個res資源的訪問都需要用對應的Resources實例,這也是開發時要處理的問題。(其實應該有3套,Android系統會加載一套framework-res.apk資源,裏面存放系統默認Theme等資源)

額外補充下,這裏你可能注意到了我們採用了反射的方法調用AssetManager的“addAssetPath”方法,而在上面ResourcesManager中調用AssetManager的“addAssetPath”方法是直接調用的,不用反射啊,而且看看SDK裏AssetManager的“addAssetPath”方法的源碼(這裏也能看到具體APK資源的提取過程是在Native裏完成的),發現它也是public類型的,外部可以直接調用,爲什麼還要用反射呢?

    /**
     * Add an additional set of assets to the asset manager.  This can be
     * either a directory or ZIP file.  Not for use by applications.  Returns
     * the cookie of the added asset, or 0 on failure.
     * {@hide}
     */
    public final int addAssetPath(String path) {
        synchronized (this) {
            int res = addAssetPathNative(path);
            makeStringBlocks(mStringBlocks);
            return res;
        }
    }

這裏有個誤區,SDK的源碼只是給我們參考用的,APP實際上運行的代碼邏輯在android.jar裏面(位於android-sdk\platforms\android-XX),反編譯android.jar並找到ResourcesManager類就可以發現這些接口都是對應用層隱藏的。

public final class AssetManager{
  AssetManager(){throw new RuntimeException("Stub!"); } 
  public void close() { throw new RuntimeException("Stub!"); } 
  public final InputStream open(String fileName) throws IOException { throw new RuntimeException("Stub!"); } 
  public final InputStream open(String fileName, int accessMode) throws IOException { throw new RuntimeException("Stub!"); } 
  public final AssetFileDescriptor openFd(String fileName) throws IOException { throw new RuntimeException("Stub!"); } 
  public final native String[] list(String paramString) throws IOException;

  public final AssetFileDescriptor openNonAssetFd(String fileName) throws IOException { throw new RuntimeException("Stub!"); } 
  public final AssetFileDescriptor openNonAssetFd(int cookie, String fileName) throws IOException { throw new RuntimeException("Stub!"); } 
  public final XmlResourceParser openXmlResourceParser(String fileName) throws IOException { throw new RuntimeException("Stub!"); } 
  public final XmlResourceParser openXmlResourceParser(int cookie, String fileName) throws IOException { throw new RuntimeException("Stub!"); } 
  protected void finalize() throws Throwable { throw new RuntimeException("Stub!");
  }
  public final native String[] getLocales();
}

到此,啓動插件裏的Activity的兩大問題都有解決的方案了。

代理模式的具體項目 dynamic-load-apk

上面只是分析了代理模式的關鍵技術點,如果運用到具體項目中去的話,除了兩個關鍵的問題外,還有許多繁瑣的細節需要處理,我們需要設計一個框架,規範插件APK項目的開發,也方便以後功能的擴展。這裏,dynamic-load-apk向我們展示了許多優秀的處理方法,比如:

  1. 把Activity關鍵的生命週期方法抽象成DLPlugin接口,ProxyActivity通過DLPlugin代理調用插件Activity的生命週期;

  2. 設計一個基礎的BasePluginActivity類,插件項目裏使用這些基類進行開發,可以以接近常規Android開發的方式開發插件項目;

  3. 以類似的方式處理Service的問題;

  4. 處理了大量常見的兼容性問題(比如使用Theme資源時出現的問題);

  5. 處理了插件項目裏的so庫的加載問題;

  6. 使用PluginPackage管理插件APK,從而可以方便地管理多個插件項目;

處理插件項目裏的so庫的加載

這裏需要把插件APK裏面的SO庫文件解壓釋放出來,在根據當前設備CPU的型號選擇對應的SO庫,並使用System.load方法加載到當前內存中來,具體分析請參考 Android動態加載補充 加載SD卡的SO庫

多插件APK的管理

動態加載一個插件APK需要三個對應的DexClassLoaderAssetManagerResources實例,可以用組合的方式創建一個PluginPackage類存放這三個變量,再創建一個管理類PluginManager,用成員變量HashMap<dexPath,pluginPackage>的方式保存PluginPackage實例。

具體的代碼請參考原項目的文檔、源碼以及Sample裏面的示例代碼,在這裏感謝singwhatiwanna的開源精神。

實際應用中可能要處理的問題

插件APK的管理後臺

使用動態加載的目的,就是希望可以繞過APK的安裝過程升級應用的功能,如果插件APK是打包在主項目內部的那動態加載純粹是多次一舉。更多的時候我們希望可以在線下載插件APK,並且在插件APK有新版本的時候,主項目要從服務器下載最新的插件替換本地已經存在的舊插件。爲此,我們應該有一個管理後臺,它大概有以下功能:

  1. 上傳不同版本的插件APK,並向APP主項目提供插件APK信息查詢功能和下載功能;

  2. 管理在線的插件APK,並能向不同版本號的APP主項目提供最合適的插件APK;

  3. 萬一最新的插件APK出現緊急BUG,要提供舊版本回滾功能;

  4. 出於安全考慮應該對APP項目的請求信息做一些安全性校驗;

插件APK合法性校驗

加載外部的可執行代碼,一個逃不開的問題就是要確保外部代碼的安全性,我們可不希望加載一些來歷不明的插件APK,因爲這些插件有的時候能訪問主項目的關鍵數據。

最簡單可靠的做法就是校驗插件APK的MD5值,如果插件APK的MD5與我們服務器預置的數值不同,就認爲插件被改動過,棄用。

是熱部署,還是插件化?

這一部分作爲補充說明,如果不太熟悉動態加載的使用姿勢,可能不是那麼容易理解。

談到動態加載的時候我們經常說到“熱部署”和“插件化”這些名詞,它們雖然都和動態加載有關,但是還是有一點區別,這個問題涉及到主項目與插件項目的交互方式。前面我們說到,動態加載方式,可以在“項目層級”做到代碼分離,按道理我們希望是主項目和插件項目不要有任何交互行爲,實際上也應該如此!這樣做不僅能確保項目的安全性,也能簡化開發工作,所以一般的做法是

只有在用戶使用到的時候才加載插件

主項目還是像常規Android項目那樣開發,只有用戶使用插件APK的功能時才動態加載插件並運行,插件一旦運行後,與主項目沒有任何交互邏輯,只有在主項目啓動插件的時候才觸發一次調用插件的行爲。比如,我們的主項目裏有幾款推廣的遊戲,平時在用戶使用主項目的功能時,可以先靜默把遊戲(其實就是一個插件APK)下載好,當用戶點擊遊戲入口時,以動態加載的方式啓動遊戲,遊戲只運行插件APK裏的代碼邏輯,結束後返回主項目界面。

一啓動主項目就加載插件

另外一種完全相反的情形是,主項目只提供一個啓動的入口,以及從服務器下載最新插件的更新邏輯,這兩部分的代碼都是長期保持不變的,應用一啓動就動態加載插件,所有業務邏輯的代碼都在插件裏實現。比如現在一些遊戲市場都要求開發者接入其SDK項目,如果SDK項目採用這種開發方式,先提供一個空殼的SDK給開發者,空殼SDK能從服務器下載最新的插件再運行插件裏的邏輯,就能保證開發者開發的遊戲每次啓動的時候都能運行最新的代碼邏輯,而不用讓開發者在SDK有新版本的時候重新更換SDK並構建新的遊戲APK。

讓插件使用主項目的功能

明明,說了不要交互的,偏偏,Android開發者就是這麼執着於技術。

有些時候,比如,主項目裏有一個成熟的圖片加載框架ImageLoader,而插件裏也有一個ImageLoader。如果一個應用同時運行兩套ImageLoader,那會有許多額外的性能開銷,如果能讓插件也用主項目的ImageLoader就好了。另外,如果在插件裏需要用到用戶登錄功能,我們總不希望用戶使用主項目時進行一次登錄,進入插件時由來一次登錄,如果能在插件裏使用主項目的登錄狀態就好了。

因此,有些時候我們希望插件項目能調用主項目的功能。怎麼處理好呢,由於插件項目與主項目是分開的,我們在開發插件的時候,怎麼調用主項目的代碼啊?這裏需要稍微瞭解一下Android項目間的依賴方式。

想想一個普通的APK是怎麼構建和運行的,Android SDK提供了許多系統類(如Activity、Fragment等,一般我們也喜歡在這裏查看源碼),我們的Android項目依賴Android SDK項目並使用這些類進行開發,那構建APK的時候會把這些類打包進來嗎?不會,要是每個APK都打包一份,那得有多少冗餘啊。所以Android項目至少有兩種依賴的方式,一種構建時會把被依賴的項目(Library)的類打包進來,一種不會。

在Android Studio打開項目的Project Structure,找到具體Module的Dependencies選項卡

可以看到Library項目有個Scope屬性,這裏的Compile模式就是會把Library的類打包進來,而Provided模式就不會。

注意,使用Provided模式的Library只能是jar文件,而不能是一個Android Library項目,因爲後者可能自帶了一些res資源,這些資源無法一併塞進標準的jar文件裏面。到這裏我們明白,Android SDK的代碼其實是打包進系統ROM(俗稱Framework層級)裏面的,我們開發Android項目的時候,只是以Provided模式引用android.jar,從這個角度也佐證了上面談到的“爲什麼APP實際運行時AssetManager類的邏輯會與Android SDK裏的源碼不一樣”。

現在好辦了,如果要在插件裏使用主項目的ImageLoader,我們可以把ImageLoader的相關代碼抽離成一個Android Libary項目,主項目以Compile模式引用這個Libary,而插件項目以Provided模式引用這個Library(編譯出來的jar),這樣能實現兩者之間的交互了,當然代價也是明顯的。

  1. 我們應該只給插件開放一些必要的接口,不然會有安全性問題;

  2. 作爲通用模塊的Library應該保持不變(起碼接口不變),不然主項目與插件項目的版本同步會複雜許多;

  3. 因爲插件項目已經嚴重依賴主項目了,所以插件項目不能獨立運行,因爲缺少必要的環境

最後我們再說說“熱部署”和“插件化”的區別,一般我們把獨立運行的插件APK叫熱部署,而需要依賴主項目的環境運行的插件APK叫做插件化。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章