Android動態加載插件APK

問題原由

我曾經在開發Android Application的過程當中遇到過那個有名的65k方法數的問題。若是你開發的應用程序變得很是龐大,你八成會遇到這個問題。html

這個問題實際上體現爲兩個方面:
1、65k方法數
Android的APK安裝包將編譯後的字節碼放在dex格式的文件中,供Android的JVM加載執行。不幸的是,單個dex文件的方法數被限制在了65536以內,這其中除了咱們本身實現的方法以外,還包括了咱們用到的Android Framework方法、其餘library包含的方法。若是咱們的方法總數超過了這個限制,那麼咱們在嘗試打包時,會拋出以下異常:java

Conversion to Dalvik format failed:
Unable to execute dex: method ID not in [0, 0xffff]: 65536

在比較新的Android構建工具下多是以下異常:android

trouble writing output:
Too many field references: 131000; max is 65536.
You may try using --multi-dex option.

2、APK安裝失敗
Android官方推薦了一個叫作MultiDex的工具,用來在打包時將方法分散放到多個dex內,以此來解決65K方法數的問題。可是,除此以外,方法數過多還會帶來dex文件過大的問題。git

在安裝APK時,系統會運行一個叫作dexopt的程序,dexopt會使用Dalvik LinearAlloc緩衝區來存儲應用的方法信息。在Android 2.x的系統中,該緩衝區大小僅爲5M,當咱們的dex文件過大超過該緩衝區大小時,就會遇到APK安裝失敗的問題。github

思路

對於如上的兩個問題,有個很是有名的方案,就是採用動態加載插件化APK的方法。segmentfault

插件化APK的思路爲:將部分代碼分離出來放在另外的APK中,作成插件APK的形式,在咱們的應用程序啓動後,在使用時動態加載該插件APK中的內容。api

該思路簡單來講即是將部分代碼放在了另一個獨立的APK中,而不是放在咱們本身的dex中。這樣一方面減小了咱們本身dex中方法總數,另外一方面也減少了dex文件的大小,所以能夠解決如上兩個方面的問題。對於這個插件APK包含的類,咱們能夠在使用到的時候再加載進來,這即是動態加載的思路。app

要實現插件化APK,咱們只須要解決以下3個問題:ide

  • 如何生成插件APK工具

  • 如何加載插件APK

  • 如何使用插件APK中的內容

類加載器

在實現插件化APK以前,咱們須要先了解一下Android中的類加載機制,做爲實現動態加載的基礎。

在Android中,咱們經過ClassLoader來加載應用程序運行須要的類。ClassLoader是一個抽象類,咱們須要繼承該類來實現具體的類加載器的行爲。在Android中,ClassLoader的實現類採用了代理模型(Delegation Model)來執行類的加載。每個ClassLoader類都有一個與之相關聯的父加載器,當一個ClassLoader類嘗試加載某個類時,首先會委託其父加載器加載該類。若是父加載器成功加載了該類,則不會再由該子加載器進行加載;若是父加載器未能加載成功,則再由子加載器進行類加載的動做。

在Android中,咱們通常使用DexClassLoaderPathClassLoader進行類的加載。

  • DexClassLoader: 能夠從.jar或者.apk文件中加載類;

  • PathClassLoader: 只能從系統內存中已安裝的內容中加載類。

對於咱們的插件化APK,顯然須要使用DexClassLoader進行自定義類加載。咱們看一下DexClassLoader的構造方法:

/**
 * Create DexClassLoader
 * @param dexPath String: the list of jar/apk files containing classes and resources, delimited by File.pathSeparator, which defaults to ":" on Android
 * @param optimizedDirectory String: directory where optimized dex files should be written; must not be null
 * @param librarySearchPath String: the list of directories containing native libraries, delimited by File.pathSeparator; may be null
 * @param parent ClassLoader: the parent class loader
 */
DexClassLoader (String dexPath, 
                String optimizedDirectory, 
                String librarySearchPath, 
                ClassLoader parent)

從以上能夠看到,該構造方法的入參中除了指定各類加載路徑外,還須要指定一個父加載器,以此實現咱們以上提到的類加載代理模型。

步驟規劃

爲了讓整個coding過程變得簡單,咱們來實現一個簡單得不能再簡單的功能:在主Activity上以"年-月-日"的格式顯示當前的日期。爲了讓插件APK的整個思路清晰一點,咱們想要實現以下設定:

  • 提供一個插件化APK,提供一個生成日期的方法;

  • 應用程序主Activity中經過插件APK中的方法獲取到該日期,顯示在TextView中。

有了如上的鋪墊,咱們如今能夠明確咱們的實現步驟:

  • 建立咱們的Application;

  • 建立一個共享接口的library module;

  • 生成插件APK;

  • 實現自定義類加載器;

  • 實現動態加載。

好了,讓咱們開始coding吧!

1. 建立Application

Android Studio中建立一個Application,做爲咱們最終須要發佈的應用程序。
Application暫時不須要作特別的配置,你只要實現一個MainActivity,而後顯示一個TextView就能夠了!

這時,你的工程可能長這個樣子:
clipboard.png

2. 建立共享接口

在建立插件APK以前,咱們還須要再作一些準備。
因爲咱們將一部分方法放到了插件APK裏,這也就意味着,咱們在本身的app module中對這些方法是不可見的,這就須要有一個機制讓app module中使用這些方法變成可能。

在這裏,咱們採用一個公共的接口來進行方法的定義。你能夠理解爲咱們在app插件APK之間搭了一座橋,咱們在app module中使用接口定義的這些方法,而方法的具體實現放在了插件APK中。

咱們建立一個library module,命名爲library。在該library module中,咱們建立一個TestInterface接口,在該接口中定義以下方法:

/**
 * 定義方法: 將時間戳轉換成日期
 * @param dateFormat    日期格式
 * @param timeStamp     時間戳,單位爲ms
 */
String getDateFromTimeStamp(String dateFormat, long timeStamp);

如上註釋所示,該方法將給定的時間戳按照指定的格式轉換成一個日期字符串。咱們期待在插件APK中實現該方法,而且在app中經過該方法獲取到咱們須要的日期。

爲了讓插件APK引用該library定義的接口,咱們須要生成一個jar包,首先,在library modulegradle腳本中增長以下配置:

android.libraryVariants.all { variant ->
    def name = variant.buildType.name
    if (name.equals(com.android.builder.core.BuilderConstants.DEBUG)) {
        return; // Skip debug builds.
    }
    def task = project.tasks.create "jar${name.capitalize()}", Jar
    task.dependsOn variant.javaCompile
    task.from variant.javaCompile.destinationDir
    artifacts.add('archives', task);
}

而後在工程根目錄執行以下命令:

./gradlew :library:jarRelease

而後就能夠在該library module的/build/libs目錄下看到一個library.jar包。

此時,你的工程是這樣的:
clipboard.png

3. 生成插件APK

咱們終於要實現咱們的插件APK了!
在工程中建立一個module,類型選擇爲application(而不是library),取名爲plugin

將上一步中生成的library.jar放到該plugin module的libs目錄下,在gradle腳本中添加

provided files('libs/library.jar')

即可以引用library中定義的共享接口了。

正如如上所說,咱們在該plugin module中作方法的具體實現,所以,咱們建立一個TestUtil類,實現如上定義的TestInterface接口定義的方法:

/**
 * 測試插件包含的工具類
 * Created by Anchorer on 16/7/31.
 */
public class TestUtil implements TestInterface {

    /**
     * 將時間戳轉換成日期
     * @param dateFormat    日期格式
     * @param timeStamp     時間戳,單位爲ms
     */
    public String getDateFromTimeStamp(String dateFormat, long timeStamp) {
        DateFormat format = new SimpleDateFormat(dateFormat);
        Date date = new Date(timeStamp);
        return format.format(date);
    }

}

這樣一來,插件部分的代碼就寫完了!接下來,咱們須要生成一個插件APK,將該APK放在應用程序app module的SourceSet下,供app module的類加載器進行加載。爲此,咱們在plugin的gradle腳本中添加以下配置:

buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'

            applicationVariants.all { variant ->
                variant.outputs.each { output ->
                    def apkName = "plugin.apk"
                    output.outputFile = file("$rootProject.projectDir/app/src/main/assets/plugin/" + apkName)
                }
            }
        }
    }

該腳本將生成的apk放在app的assets目錄下。

最後,在工程根目錄執行:

./gradlew :plugin:assembleRelease

即可以在/app/src/main/assets/plugin目錄下生成了一個plugin.apk文件。到此爲止,咱們便生成了咱們的插件APK

此時,咱們的工程長這個樣子,這已是咱們工程的最終樣子了:

clipboard.png

4. 實現自定義類加載器

有了插件APK,接下來咱們須要在應用程序運行時,在須要的時候加載這個APK中的內容。實現咱們本身的類加載器,咱們分爲以下兩個步驟:

  • 將該APK複製到SD卡中;

  • 從SD卡中加載該APK。

咱們實現一個PluginLoader類,來執行插件的加載。在這個類中,實現如上提供的兩個關鍵方法。

首先,將APK複製到SD卡的代碼比較簡單:

/**
 * 將插件APK保存至SD卡
 * @param pluginName    插件APK的名稱
 */
private boolean savePluginApkToStorage(String pluginName) {
    String pluginApkPath = this.getPlguinApkDirectory() + pluginName;

    File plugApkFile = new File(pluginApkPath);
    if (plugApkFile.exists()) {
        try {
            plugApkFile.delete();
        } catch (Throwable e) {}
    }
    
    BufferedInputStream inStream = null;
    BufferedOutputStream outStream = null;

    try {
        InputStream stream = TestApplication.getInstance().getAssets().open("plugin/" + pluginName);
        inStream = new BufferedInputStream(stream);
        outStream = new BufferedOutputStream(new FileOutputStream(pluginApkPath));
        
        final int BUF_SIZE = 4096;
        byte[] buf = new byte[BUF_SIZE];
        while(true) {
            int readCount = inStream.read(buf, 0, BUF_SIZE);
            if (readCount == -1) {
                break;
            }
            outStream.write(buf,0, readCount);
        }
    } catch(Exception e) {
        return false;
    } finally {
        if (inStream != null) {
            try {
                inStream.close();
            } catch (IOException e) {}
            inStream = null;
        }
        
        if (outStream != null) {
            try {
                outStream.close();
            } catch (IOException e) {}
            outStream = null;
        }
    }
    return true;
}

其次,咱們要建立本身的DexClassLoader

DexClassLoader classLoader = null;
try {
    String apkPath = getPlguinApkDirectory() + pluginName;
    File dexOutputDir = TestApplication.getInstance().getDir("dex", 0);
    String dexOutputDirPath = dexOutputDir.getAbsolutePath();
            
    ClassLoader cl = TestApplication.getInstance().getClassLoader();
    classLoader = new DexClassLoader(apkPath, dexOutputDirPath, null, cl);
} catch(Throwable e) {}

這裏咱們使用如上提到的DexClassLoader的構造方法,其中第一個參數是咱們插件APK的路徑,最後一個參數是Application生成的父ClassLoader。

5. 實現動態加載

實現了本身的類加載器以後,咱們使用該ClassLoader進行類的加載就能夠了!

使用ClassLoader加載類,咱們調用loadClass(String className)就能夠了。這一步比較簡單:

/**
 * 加載指定名稱的類
 * @param className    類名(包含包名)
 */
public Object newInstance(String className) {
    if (mDexClassLoader == null) {
        return null;
    }
    
    try {
        Class<?> clazz = mDexClassLoader.loadClass(className);
        Object instance = clazz.newInstance();
        return instance;
    } catch (Exception e) {
        Log.e(Const.LOG, "newInstance className = " + className + " failed" + " exception = " + e.getMessage());
    }
    
    return null;
}

有了這個加載方法以後,咱們就能夠加載以上實現的TestUtil類了:

TestInterface testManager = (TestInterface) mPluginLoader.newInstance("org.anchorer.pluginapk.plugin.TestUtil");
mMainTextView.setText(testPlugin.getDateFromTimeStamp("yyyy-MM-dd", System.currentTimeMillis()));

至此爲止,代碼所有完成。啓動應用程序,咱們能夠看到主界面成功顯示了當前的日期。

源碼

該示例工程的源代碼我放到了本身的GitHub上:
Github/Anchorer/PluginApk

這個工程對代碼進行了必定程度的封裝:

  • PluginManager: 該類統一提供了建立類加載器和加載具體類的全部入口;

  • PluginLoader: 該類具體建立了類加載器,執行具體的加載類的行爲;

  • MainActivity: 主Activity,展現瞭如何調用插件內的方法。

參考

提供一些我本身在探索過程當中參考的文章:
1. ClassLoader
2. DexClassLoader
3. multidex
4. 動態加載基礎

相關文章
相關標籤/搜索