關於『65535問題』的一點研究與思考
背景
目前來說,對於使用Android Studio的朋友來說,MultiDex應該不陌生,就是Google為了解決『65535天花板』問題而給出的官方解決方案,但是這個方案並不完美,所以美團又給出了非同步載入Dex檔案的方案。今天這篇文章是我最近研究MultiDex方案的一點收穫,最後還留了一個沒有解決的問題,如果你有思路的話,歡迎交流!
產生65535問題的原因
單個Dex檔案中,method個數採用使用原生型別short來索引,即2個位元組最多65536個method,field、class的個數也均有此限制,關於如何解決由於引用過多基礎依賴專案,造成field超過65535問題,請參考
對於Dex檔案,則是將工程所需全部class檔案合併且壓縮到一個DEX檔案期間,也就是使用Dex工具將class檔案轉化為Dex檔案的過程中, 單個Dex檔案可被引用的方法總數(自己開發的程式碼以及所引用的Android框架、類庫的程式碼)被限制為65536。
這就是65535問題的根本來源。
LinearAlloc問題的原因
這個問題多發生在2.x版本的裝置上,安裝時會提示INSTALL_FAILED_DEXOPT,這個問題發生在安裝期間,在使用Dalvik虛擬機器的裝置上安裝APK時,會通過DexOpt工具將Dex檔案優化為ODex檔案,即Optimised Dex,這樣可以提高執行效率。
在Android版本不同分別經歷了4M/5M/8M/16M限制,目前主流4.2.x系統上可能都已到16M, 在Gingerbread或以下系統LinearAllocHdr分配空間只有5M大小的, 高於Gingerbread的系統提升到了8M。Dalvik linearAlloc是一個固定大小的緩衝區。dexopt使用LinearAlloc來儲存應用的方法資訊。Android 2.2和2.3的緩衝區只有5MB,Android 4.x提高到了8MB或16MB。當應用的方法資訊過多導致超出緩衝區大小時,會造成dexopt崩潰,造成INSTALL_FAILED_DEXOPT錯誤。
Google提出的MultiDex方案
當App不斷迭代的時候,總有一天會遇到這個問題,為此Google也給出瞭解決方案,具體的操作步驟我就不多說了,無非就是配置Application和Gradle檔案,下面我們簡單看一下這個方案的實現原理。
MultiDex實現原理
實際起作用的是下面這個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)的實現,程式碼很容易理解,重點的地方都有註釋
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環境),已經原生支援多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檔案已經被載入過了,就返回
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是一個數組,裡面的每一個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欄位中,就可以實現類的動態載入,這也是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的原始碼,可以發現MultiDex在冷啟動時,因為會同步的反射安裝Dex檔案,進行IO操作,容易導致ANR
- 在冷啟動時因為需要安裝Dex檔案,如果Dex檔案過大時,處理時間過長,很容易引發ANR
- 採用MultiDex方案的應用因為linearAlloc的BUG,可能不能在2.x裝置上啟動
美團的多Dex分包、動態非同步載入方案
首先我們要明白,美團的這個動態非同步載入方案,和外掛化的動態載入方案要解決的問題不一樣,我們這裡討論的只是單純的為了解決65535問題,並且想辦法解決Google的MutiDex方案的弊端。
多Dex分包
首先,採用Google的方案我們不需要關心Dex分包,開發工具會自動的分析依賴關係,把需要的class檔案及其依賴class檔案放在Main Dex中,因此如果產生了多個Dex檔案,那麼classes.dex內的方法數一般都接近65535這個極限,剩下的class才會被放到Other Dex中。如果我們可以減小Main Dex中的class數量,是可以加快冷啟動速度的。
美團給出了Gradle的配置,但是由於沒有具體的實現,所以這塊還需要研究。
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();
}
}
}
實現Dex自定義分包的關鍵是分析出class之間的依賴關係,並且干涉Dex檔案的生成過程。
Dex也是一個工具,通過設定引數可以實現哪一些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數,預設是65535,通過修改這個值可以減少Main Dex檔案的大小和個數。比如一個App混淆後方法數為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過程進行劫持。這也是我現在還沒有解決的問題,有解決方案的朋友可以指點一下!
非同步載入方案
其實前面的操作都是為了這一步操作的,無論將Dex分成什麼樣,如果不能非同步載入,就解決不了ANR和載入白屏的問題,所以非同步載入是一個重點。
非同步載入主要問題就是:如何避免在其他Dex檔案未載入完成時,造成的ClassNotFoundException問題?
美團給出的解決方案是替換Instrumentation,但是部落格中未給出具體實現,我對這個技術點進行了簡單的實現,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的時候,會經過這個方法,所以我們在這裡可以進行劫持,如果其他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;
}
}
那麼替換系統預設的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開發者,喜歡技術分享,熱愛開源。