Android动态加载插件APK

4

问题起因

我曾经在开发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中,我们一般使用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. 动态加载基础

你可能感兴趣的

载入中...