Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> Android動態加載插件APK

Android動態加載插件APK

編輯:關於Android編程

問題起因

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

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

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

在比較新的Android構建工具下可能是如下異常:

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

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

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

思路

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

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

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

要實現插件化APK,我們只需要解決如下3個問題:

如何生成插件APK

如何加載插件APK

如何使用插件APK中的內容

類加載器

在實現插件化APK之前,我們需要先了解一下Android中的類加載機制,作為實現動態加載的基礎。

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

在Android中,我們一般使用DexClassLoader和PathClassLoader進行類的加載。

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就可以了!

這時,你的工程可能長這個樣子:
\

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 module的gradle腳本中增加如下配置:

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包。

此時,你的工程是這樣的:
\

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。

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

\

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()));

至此為止,代碼全部完成。啟動應用程序,我們可以看到主界面成功顯示了當前的日期。

源碼地址:https://github.com/JoeySheng/Plugin.git

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved