我曾经在开发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中,咱们通常使用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吧!
在Android Studio
中建立一个Application
,做为咱们最终须要发布的应用程序。
该Application
暂时不须要作特别的配置,你只要实现一个MainActivity
,而后显示一个TextView
就能够了!
这时,你的工程可能长这个样子:
在建立插件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
包。
此时,你的工程是这样的:
咱们终于要实现咱们的插件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
。
此时,咱们的工程长这个样子,这已是咱们工程的最终样子了:
有了插件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。
实现了本身的类加载器以后,咱们使用该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. 动态加载基础