背景
目前來說,對于使用Android Studio的朋友來說,MultiDex應該不陌生,就是Google為了解決『65535天花板』問題而給出的官方解決方案,但是這個方案并不完美,所以美團又給出了異步加載Dex文件的方案。今天這篇文章是我最近研究MultiDex方案的一點收獲,最后還留了一個沒有解決的問題,如果你有思路的話,歡迎交流!
產生65535問題的原因
單個Dex文件中,method個數(shù)采用使用原生類型short來索引,即4個字節(jié)最多65536個method,field、class的個數(shù)也均有此限制,關于如何解決由于引用過多基礎依賴項目,造成field超過65535問題,請參考@寒江不釣的這篇文章『當Field邂逅65535』。
對于Dex文件,則是將工程所需全部class文件合并且壓縮到一個DEX文件期間,也就是使用Dex工具將class文件轉化為Dex文件的過程中, 單個Dex文件可被引用的方法總數(shù)(自己開發(fā)的代碼以及所引用的Android框架、類庫的代碼)被限制為65536。
這就是65535問題的根本來源。
LinearAlloc問題的原因
這個問題多發(fā)生在2.x版本的設備上,安裝時會提示INSTALL_FAILED_DEXOPT,這個問題發(fā)生在安裝期間,在使用Dalvik虛擬機的設備上安裝APK時,會通過DexOpt工具將Dex文件優(yōu)化為ODex文件,即Optimised Dex,這樣可以提高執(zhí)行效率。
在Android版本不同分別經(jīng)歷了4M/5M/8M/16M限制,目前主流4.2.x系統(tǒng)上可能都已到16M, 在Gingerbread或以下系統(tǒng)LinearAllocHdr分配空間只有5M大小的, 高于Gingerbread的系統(tǒng)提升到了8M。Dalvik linearAlloc是一個固定大小的緩沖區(qū)。dexopt使用LinearAlloc來存儲應用的方法信息。Android 2.2和2.3的緩沖區(qū)只有5MB,Android 4.x提高到了8MB或16MB。當應用的方法信息過多導致超出緩沖區(qū)大小時,會造成dexopt崩潰,造成INSTALL_FAILED_DEXOPT錯誤。
Google提出的MultiDex方案
當App不斷迭代的時候,總有一天會遇到這個問題,為此Google也給出了解決方案,具體的操作步驟我就不多說了,無非就是配置Application和Gradle文件,下面我們簡單看一下這個方案的實現(xiàn)原理。
MultiDex實現(xiàn)原理
實際起作用的是下面這個jar包
~/sdk/extras/android/support/multidex/library/libs/android-support-multidex.jar
不管是繼承自MultiDexApplication還是重寫attachBaseContext(),實際都是調用下面的方法
public class MultiDexApplication extends Application {
protected void attachBaseContext(final Context base) {
super.attachBaseContext(base);
MultiDex.install((Context)this);
}
}
下面重點看下MutiDex.install(Context)的實現(xiàn),代碼很容易理解,重點的地方都有注釋
static {
//第二個Dex文件的文件夾名,實際地址是/date/date/<package_name>/code_cache/secondary-dexes
SECONDARY_FOLDER_NAME = "code_cache" + File.separator + "secondary-dexes";
installedApk = new HashSet<String>();
IS_VM_MULTIDEX_CAPABLE = isVMMultidexCapable(System.getProperty("java.vm.version"));
}
public static void install(final Context context) {
//在使用ART虛擬機的設備上(部分4.4設備,5.0+以上都默認ART環(huán)境),已經(jīng)原生支持多Dex,因此就不需要手動支持了
if (MultiDex.IS_VM_MULTIDEX_CAPABLE) {
Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
return;
}
if (Build.VERSION.SDK_INT < 4) {
throw new RuntimeException("Multi dex installation failed. SDK " + Build.VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + ".");
}
try {
final ApplicationInfo applicationInfo = getApplicationInfo(context);
if (applicationInfo == null) {
return;
}
synchronized (MultiDex.installedApk) {
//如果apk文件已經(jīng)被加載過了,就返回
final String apkPath = applicationInfo.sourceDir;
if (MultiDex.installedApk.contains(apkPath)) {
return;
}
MultiDex.installedApk.add(apkPath);
if (Build.VERSION.SDK_INT > 20) {
Log.w("MultiDex", "MultiDex is not guaranteed to work in SDK version " + Build.VERSION.SDK_INT + ": SDK version higher than " + 20 + " should be backed by " + "runtime with built-in multidex capabilty but it's not the " + "case here: java.vm.version=\"" + System.getProperty("java.vm.version") + "\"");
}
ClassLoader loader;
try {
loader = context.getClassLoader();
}
catch (RuntimeException e) {
Log.w("MultiDex", "Failure while trying to obtain Context class loader. Must be running in test mode. Skip patching.", (Throwable)e);
return;
}
if (loader == null) {
Log.e("MultiDex", "Context class loader is null. Must be running in test mode. Skip patching.");
return;
}
try {
//清楚之前的Dex文件夾,之前的Dex放置在這個文件夾
//final File dexDir = new File(context.getFilesDir(), "secondary-dexes");
clearOldDexDir(context);
}
catch (Throwable t) {
Log.w("MultiDex", "Something went wrong when trying to clear old MultiDex extraction, continuing without cleaning.", t);
}
final File dexDir = new File(applicationInfo.dataDir, MultiDex.SECONDARY_FOLDER_NAME);
//將Dex文件加載為File對象
List<File> files = MultiDexExtractor.load(context, applicationInfo, dexDir, false);
//檢測是否是zip文件
if (checkValidZipFiles(files)) {
//正式安裝其他Dex文件
installSecondaryDexes(loader, dexDir, files);
}
else {
Log.w("MultiDex", "Files were not valid zip files. Forcing a reload.");
files = MultiDexExtractor.load(context, applicationInfo, dexDir, true);
if (!checkValidZipFiles(files)) {
throw new RuntimeException("Zip files were not valid.");
}
installSecondaryDexes(loader, dexDir, files);
}
}
}
catch (Exception e2) {
Log.e("MultiDex", "Multidex installation failure", (Throwable)e2);
throw new RuntimeException("Multi dex installation failed (" + e2.getMessage() + ").");
}
Log.i("MultiDex", "install done");
}
從上面的過程來看,只是完成了加載包含著Dex文件的zip文件,具體的加載操作都在下面的方法中
installSecondaryDexes(loader, dexDir, files);
下面重點看下
private static void installSecondaryDexes(final ClassLoader loader, final File dexDir, final List<File> files) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
if (!files.isEmpty()) {
if (Build.VERSION.SDK_INT >= 19) {
install(loader, files, dexDir);
}
else if (Build.VERSION.SDK_INT >= 14) {
install(loader, files, dexDir);
}
else {
install(loader, files);
}
}
}
到這里為了完成不同版本的兼容,實際調用了不同類的方法,我們僅看一下>=14的版本,其他的類似
private static final class V14
{
private static void install(final ClassLoader loader, final List<File> additionalClassPathEntries, final File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
//通過反射獲取loader的pathList字段,loader是由Application.getClassLoader()獲取的,實際獲取到的是PathClassLoader對象的pathList字段
final Field pathListField = findField(loader, "pathList");
final Object dexPathList = pathListField.get(loader);
//dexPathList是PathClassLoader的私有字段,里面保存的是Main Dex中的class
//dexElements是一個數(shù)組,里面的每一個item就是一個Dex文件
//makeDexElements()返回的是其他Dex文件中獲取到的Elements[]對象,內部通過反射makeDexElements()獲取
//expandFieldArray是為了把makeDexElements()返回的Elements[]對象添加到dexPathList字段的成員變量dexElements中
expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList<File>(additionalClassPathEntries), optimizedDirectory));
}
private static Object[] makeDexElements(final Object dexPathList, final ArrayList<File> files, final File optimizedDirectory) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
final Method makeDexElements = findMethod(dexPathList, "makeDexElements", (Class<?>[])new Class[] { ArrayList.class, File.class });
return (Object[])makeDexElements.invoke(dexPathList, files, optimizedDirectory);
}
}
PathClassLoader.java
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String libraryPath,
ClassLoader parent) {
super(dexPath, null, libraryPath, parent);
}
}
BaseDexClassLoader的代碼如下,實際上尋找class時,會調用findClass(),會在pathList中尋找,因此通過反射手動添加其他Dex文件中的class到pathList字段中,就可以實現(xiàn)類的動態(tài)加載,這也是MutiDex方案的基本原理。
public class BaseDexClassLoader extends ClassLoader {
private final DexPathList pathList;
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
}
缺點
通過查看MultiDex的源碼,可以發(fā)現(xiàn)MultiDex在冷啟動時,因為會同步的反射安裝Dex文件,進行IO操作,容易導致ANR
- 在冷啟動時因為需要安裝Dex文件,如果Dex文件過大時,處理時間過長,很容易引發(fā)ANR
- 采用MultiDex方案的應用因為linearAlloc的BUG,可能不能在2.x設備上啟動
美團的多Dex分包、動態(tài)異步加載方案
首先我們要明白,美團的這個動態(tài)異步加載方案,和插件化的動態(tài)加載方案要解決的問題不一樣,我們這里討論的只是單純的為了解決65535問題,并且想辦法解決Google的MutiDex方案的弊端。
多Dex分包
首先,采用Google的方案我們不需要關心Dex分包,開發(fā)工具會自動的分析依賴關系,把需要的class文件及其依賴class文件放在Main Dex中,因此如果產生了多個Dex文件,那么classes.dex內的方法數(shù)一般都接近65535這個極限,剩下的class才會被放到Other Dex中。如果我們可以減小Main Dex中的class數(shù)量,是可以加快冷啟動速度的。
美團給出了Gradle的配置,但是由于沒有具體的實現(xiàn),所以這塊還需要研究。
tasks.whenTaskAdded { task ->
if (task.name.startsWith('proguard') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
task.doLast {
makeDexFileAfterProguardJar();
}
task.doFirst {
delete "${project.buildDir}/intermediates/classes-proguard";
String flavor = task.name.substring('proguard'.length(), task.name.lastIndexOf(task.name.endsWith('Debug') ? "Debug" : "Release"));
generateMainIndexKeepList(flavor.toLowerCase());
}
} else if (task.name.startsWith('zipalign') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
task.doFirst {
ensureMultiDexInApk();
}
}
}
實現(xiàn)Dex自定義分包的關鍵是分析出class之間的依賴關系,并且干涉Dex文件的生成過程。
Dex也是一個工具,通過設置參數(shù)可以實現(xiàn)哪一些class文件在Main Dex中。
afterEvaluate {
tasks.matching {
it.name.startsWith('dex')
}.each { dx ->
if (dx.additionalParameters == null) {
dx.additionalParameters = []
}
dx.additionalParameters += '--multi-dex'
dx.additionalParameters += '--set-max-idx-number=30000'
println("dx param = "+dx.additionalParameters)
dx.additionalParameters += "--main-dex-list=$projectDir/multidex.keep".toString()
}
}
- --multi-dex 代表采用多Dex分包
- --set-max-idx-number=30000 代表每個Dex文件中的最大id數(shù),默認是65535,通過修改這個值可以減少Main Dex文件的大小和個數(shù)。比如一個App混淆后方法數(shù)為48000,即使開啟MultiDex,也不會產生多個Dex,如果設置為30000,則就產生兩個Dex文件
- --main-dex-list= 代表在Main Dex中的class文件
需要注意的是,上面我給出的gredle task,只在1.4以下管用,在1.4+版本的gradle中,app:dexXXX task 被隱藏了(更多信息請參考Gradle plugin的更新信息),jacoco, progard, multi-dex三個task被合并了。
The Dex task is not available through the variant API anymore….
The goal of this API is to simplify injecting custom class manipulations without having to deal with tasks, and to offer more flexibility on what is manipulated. The internal code processing (jacoco, progard, multi-dex) have all moved to this new mechanism already in 1.5.0-beta1.
所以通過上面的方法無法對Dex過程進行劫持。這也是我現(xiàn)在還沒有解決的問題,有解決方案的朋友可以指點一下!
異步加載方案
其實前面的操作都是為了這一步操作的,無論將Dex分成什么樣,如果不能異步加載,就解決不了ANR和加載白屏的問題,所以異步加載是一個重點。
異步加載主要問題就是:如何避免在其他Dex文件未加載完成時,造成的ClassNotFoundException問題?
美團給出的解決方案是替換Instrumentation,但是博客中未給出具體實現(xiàn),我對這個技術點進行了簡單的實現(xiàn),Demo在這里MultiDexAsyncLoad,對ActivityThread的反射用的是攜程的解決方案。
首先繼承自Instrumentation,因為這一塊需要涉及到Activity的啟動過程,所以對這個過程不了解的朋友請看我的這篇文章【凱子哥帶你學Framework】Activity啟動過程全解析。
/**
* Created by zhaokaiqiang on 15/12/18.
*/
public class MeituanInstrumentation extends Instrumentation {
private List<String> mByPassActivityClassNameList;
public MeituanInstrumentation() {
mByPassActivityClassNameList = new ArrayList<>();
}
@Override
public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
if (intent.getComponent() != null) {
className = intent.getComponent().getClassName();
}
boolean shouldInterrupted = !MeituanApplication.isDexAvailable();
if (mByPassActivityClassNameList.contains(className)) {
shouldInterrupted = false;
}
if (shouldInterrupted) {
className = WaitingActivity.class.getName();
} else {
mByPassActivityClassNameList.add(className);
}
return super.newActivity(cl, className, intent);
}
}
至于為什么重寫了newActivity(),是因為在啟動Activity的時候,會經(jīng)過這個方法,所以我們在這里可以進行劫持,如果其他Dex文件還未異步加載完,就跳轉到Main Dex中的一個等待Activity——WaitingActivity。
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
ActivityInfo aInfo = r.activityInfo;
if (r.packageInfo == null) {
r.packageInfo = getPackageInfo(aInfo.applicationInfo, r.compatInfo,
Context.CONTEXT_INCLUDE_CODE);
}
Activity activity = null;
try {
java.lang.ClassLoader cl = r.packageInfo.getClassLoader();
activity = mInstrumentation.newActivity(
cl, component.getClassName(), r.intent);
} catch (Exception e) {
}
}
在WaitingActivity中可以一直輪訓,等待異步加載完成,然后跳轉至目標Activity。
public class WaitingActivity extends BaseActivity {
private Timer timer;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_wait);
waitForDexAvailable();
}
private void waitForDexAvailable() {
final Intent intent = getIntent();
final String className = intent.getStringExtra(TAG_TARGET);
timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
while (!MeituanApplication.isDexAvailable()) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
Log.d("TAG", "waiting");
}
intent.setClassName(getPackageName(), className);
startActivity(intent);
finish();
}
}, 0);
}
@Override
protected void onDestroy() {
super.onDestroy();
if (timer != null) {
timer.cancel();
}
}
}
異步加載Dex文件放在什么時候合適呢?
我放在了Application.onCreate()中
public class MeituanApplication extends Application {
private static final String TAG = "MeituanApplication";
private static boolean isDexAvailable = false;
@Override
public void onCreate() {
super.onCreate();
loadOtherDexFile();
}
private void loadOtherDexFile() {
new Thread(new Runnable() {
@Override
public void run() {
MultiDex.install(MeituanApplication.this);
isDexAvailable = true;
}
}).start();
}
public static boolean isDexAvailable() {
return isDexAvailable;
}
}
那么替換系統(tǒng)默認的Instrumentation在什么時候呢?
當SplashActivity跳轉到MainActivity之后,再進行替換比較合適,于是
public class MainActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
MeituanApplication.attachInstrumentation();
}
}
MeituanApplication.attachInstrumentation()實際就是通過反射替換默認的Instrumentation
public class MeituanApplication extends Application {
public static void attachInstrumentation() {
try {
SysHacks.defineAndVerify();
MeituanInstrumentation meiTuanInstrumentation = new MeituanInstrumentation();
Object activityThread = AndroidHack.getActivityThread();
Field mInstrumentation = activityThread.getClass().getDeclaredField("mInstrumentation");
mInstrumentation.setAccessible(true);
mInstrumentation.set(activityThread, meiTuanInstrumentation);
} catch (Exception e) {
e.printStackTrace();
}
}
}
至此,異步加載Dex方案的一個基本思路就通了,剩下的就是完善和版本兼容了。
參考資料
- Android 使用android-support-multidex解決Dex超出方法數(shù)的限制問題,讓你的應用不再爆棚
- dex分包變形記
- Android dex分包方案
- 美團Android DEX自動拆包及動態(tài)加載簡介
- 手動分割Dex文件的build.gradle配置
- Multi-dex to rescue from the infamous 65536 methods limit
- secondary-dex-gradle
- Using Gradle to split external libraries in separated dex files to solve Android Dalvik 64k methods limit
關于我
江湖人稱『凱子哥』,Android開發(fā)者,喜歡技術分享,熱愛開源。