關於『65535問題』的一點研究與思考

背景

目前來說,對於使用Android Studio的朋友來說,MultiDex應該不陌生,就是Google爲了解決『65535天花板』問題而給出的官方解決方案,但是這個方案並不完美,所以美團又給出了異步加載Dex文件的方案。今天這篇文章是我最近研究MultiDex方案的一點收穫,最後還留了一個沒有解決的問題,如果你有思路的話,歡迎交流!

產生65535問題的原因

單個Dex文件中,method個數採用使用原生類型short來索引,即2個字節最多65536個method,field、class的個數也均有此限制,關於如何解決由於引用過多基礎依賴項目,造成field超過65535問題,請參考@寒江不釣的這篇文章『當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

  1. 在冷啓動時因爲需要安裝Dex文件,如果Dex文件過大時,處理時間過長,很容易引發ANR
  2. 採用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開發者,喜歡技術分享,熱愛開源。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章