你值得知道的Android 熱修復,以及熱修復原理

鋸齒流沙發表於2017-12-26

一般產品上線週期比較長,而且如果不是強制更新,無法做到100%的使用者都更新,如果上線之後,產品出現bug,那麼怎麼辦?一般都是再發一個版本,或者等到下一個版本再解決。如果再發一個版本,顯然是不靠譜的,使用者安裝也有厭倦的時候,說不定直接把app給解除安裝了,而且給使用者的體驗也不好。如果等到下一個版本再修復,那麼使用者每次使用都出現這個bug,顯然是不能接受的,這都造成使用者量流失,給我們使用者造成不良的影響。

那麼有沒有一種辦法是不需要釋出版本,不需要使用者安裝,而且又能夠及時覺得這個問題的呢?當然是有的,那就是使用熱修復技術。

熱修復技術一般來說有兩種途徑來解決:

1、阿里系的 AndFix,原理是從底層C的二進位制來入手的。

2、騰訊系的 tinker,原理是Java類載入機制來入手的。 當然熱修復技術不單單這兩種,各大公司都開源自己的熱修復技術,如滴滴的 VirtualAPK,美團的Robust等等。

既然有那麼多的熱修復專案開源,讀者可以下載下來學習,看看實現的原理,本文主要是講解從Java類載入器機制來講解熱修復。

首先我們理解DexClassLoader和PathClassLoader,他們都是用來載入應用程式的dex檔案,但DexClassLoader是指可以載入指定的某個dex檔案,而且具有限制性,那就是必須要在應用程式的目錄下面的dex檔案。

PathClassLoader

dexPath:包含classes和resources的jar/apk檔案的路徑; libraryPath:包含本地的目錄列表,可以為null; parent:父類載入器。

DexClassLoader

dexPath:包含classes和resources的jar/apk檔案的路徑; optimizedDirectory:寫入優化的dex檔案的目錄,一定不能是為空; libraryPath:包含本地的目錄列表,可以為null; parent:父類載入器。

DexClassLoader和PathClassLoader類中都只有構造方法,而且構造方法都super到父類了。我們看下BaseDexClassLoader的類載入器的構造方法。

BaseDexClassLoader

BaseDexClassLoader類中包括構造方法,findClass(找到類)、findResourece,findLibrary等等方法,這裡主要看下構造方法。

public BaseDexClassLoader(String dexPath, File optimizedDirectory,
  String libraryPath, ClassLoader parent) {
            super(parent);
            this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);    
}
複製程式碼

主要這裡new了一個DexPathList物件,並且把引數傳過去了,DexPathList到底是幹什麼的呢?我們去探個究竟。

DexPathList

類的簡介中我們知道,DexPathList其實就是與類載入器(ClassLoader)相關聯的一對條目列表。而這些列表包含了dex、jar、zip和apk檔案,而提高了使用列表來查詢類和資源的方法。這裡需要說一下的是平時我們看原始碼一定要主要看作者的註釋,從註釋裡我們看知道類或者方法的功能。

從DexPathList原始碼中我們注意到一個屬性就是dexElements

 /**
    * List of dex/resource (class path) elements.
    * Should be called pathElements, but the Facebook app uses reflection
    * to modify 'dexElements' (http://b/7726934).
    */
private final Element[] dexElements;
複製程式碼

這個就是存放dex/resource的集合,我們注意到該類的findClass方法:

findClass

findClass的時候就是遍歷dexElements,取出DexFile,找到我們需要的類並返回。也就是說我們可以通過dexElements找到我們出bug的類,因為dexElements就是存放Element元素,而Element中包含DexFile,DexFile中可以找到bug類,找到bug類,然後我們替換掉bug類所在的dex檔案就可以了。而且再dexElements介紹中,已經說明了可以通過反射獲取得到dexElements。

那麼我們思考一下:既然DexClassLoader和PathClassLoader都可以載入dex檔案,那麼我們能不能使用多個dex檔案?也就是第一個版本使用的是classes.dex,修復後的補丁包classes2.dex,在補丁包中包涵了我們修復class檔案。然後將這兩個檔案合併,將修復的class替換原來出bug的class,接下來通過反射拿到dexElements,將修復好的dex插入到dexElements的集合,插入的位置就是出現bug的class所在的dex的前面。這樣子做最本質的實現原理就是:類載入器去載入某個類的時候,是去dexElements裡面從頭往下查詢的。

需要多個dex的需要multidex支援。 1、再app的build.gradle中新增

defaultConfig {
        multiDexEnabled true
}

buildTypes {
        release {
            multiDexKeepFile file('dex.keep')
            println "dex keep"
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
        }
}

dependencies {
    compile 'com.android.support:multidex:1.0.1'
}
複製程式碼

2、在application的attachBaseContext新增MultiDex.install(base);

據上面的原理,下面是實現的熱修復的程式碼

FixDexUtils:

public class FixDexUtils {
	private static HashSet<File> loadedDex = new HashSet<File>();
	
	static{
		loadedDex.clear();
	}

	public static void loadFixedDex(Context context){
		if(context == null){
			return ;
		}
		//遍歷所有的修復的dex
		File fileDir = context.getDir(MyConstants.DEX_DIR,Context.MODE_PRIVATE);
		File[] listFiles = fileDir.listFiles();
		for(File file:listFiles){
			if(file.getName().startsWith("classes")&&file.getName().endsWith(".dex")){
				loadedDex.add(file);//存入集合
			}
		}
		//dex合併之前的dex
		doDexInject(context,fileDir,loadedDex);
	}

    //使用合併的dexElements替換出錯的dexElements
	private static void setField(Object obj,Class<?> cl, String field, Object value) throws Exception {
		Field localField = cl.getDeclaredField(field);
		localField.setAccessible(true);
		localField.set(obj,value);
	}


//	fileDir = {File@15225} "/data/data/com.dex.main/app_odex"
//			0 = {File@15240} "/data/data/com.dex.main/app_odex/opt_dex"
//			1 = {File@15241} "/data/data/com.dex.main/app_odex/classes2.dex"
	private static void doDexInject(final Context appContext, File filesDir,HashSet<File> loadedDex) {
		String optimizeDir = filesDir.getAbsolutePath()+File.separator+"opt_dex";
		File fopt = new File(optimizeDir);
		if(!fopt.exists()){
			fopt.mkdirs();
		}
		try {
            //1.使用PathClassLoader載入應用程式的dex(系統的載入器)
			PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();

			for (File dex : loadedDex) {
				//2.通過DexClassLoader載入指定的修復的dex檔案。
				DexClassLoader classLoader = new DexClassLoader(
						dex.getAbsolutePath(),//String dexPath,
						fopt.getAbsolutePath(),//String optimizedDirectory,讀取檔案優化的目錄
						null,//String libraryPath,
						pathLoader//ClassLoader parent
				);
				//3.合併
				Object dexObj = getPathList(classLoader);
				Object pathObj = getPathList(pathLoader);
				Object mDexElementsList = getDexElements(dexObj);
				Object pathDexElementsList = getDexElements(pathObj);
				//合併完成
				Object dexElements = combineArray(mDexElementsList,pathDexElementsList);
				//重寫給PathList裡面的lement[] dexElements;賦值
				Object pathList = getPathList(pathLoader);
				setField(pathList,pathList.getClass(),"dexElements",dexElements);
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
    }

	private static Object getField(Object obj, Class<?> cl, String field)
			throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
		Field localField = cl.getDeclaredField(field);
		localField.setAccessible(true);
		return localField.get(obj);
	}
	private static Object getPathList(Object baseDexClassLoader) throws Exception {
			return getField(baseDexClassLoader,Class.forName("dalvik.system.BaseDexClassLoader"),"pathList");
	}

	private static Object getDexElements(Object obj) throws Exception {
			return getField(obj,obj.getClass(),"dexElements");
	}

	/**
	 * 兩個陣列合並
     * 有bug的dex還是保留,只是把沒bug的插入到有bug的前面即可
	 * @param arrayLhs
	 * @param arrayRhs
     * @return
     */
	private static Object combineArray(Object arrayLhs, Object arrayRhs) {
		Class<?> localClass = arrayLhs.getClass().getComponentType();
		int i = Array.getLength(arrayLhs);//陣列長度
		int j = i + Array.getLength(arrayRhs);新陣列的總長度
		Object result = Array.newInstance(localClass, j);//新建一個陣列,總長度為兩個陣列的總和,即j
		for (int k = 0; k < j; ++k) {
			if (k < i) {
				Array.set(result, k, Array.get(arrayLhs, k));
			} else {
				Array.set(result, k, Array.get(arrayRhs, k - i));
			}
		}
		return result;//返回總的陣列
	}

}
複製程式碼

MyApplication:

public class MyApplication extends Application{
	@Override
	public void onCreate() {
		// TODO Auto-generated method stub
		super.onCreate();
	}
	@Override
	protected void attachBaseContext(Context base) {
		// TODO Auto-generated method stub
		MultiDex.install(base);
		FixDexUtils.loadFixedDex(base);
		super.attachBaseContext(base);

	}
}
複製程式碼

MyConstants:

public class MyConstants {
	public static final String DEX_DIR = "odex";
}
複製程式碼

MyTestClass:

public class MyTestClass {
	public  void testFix(Context context){
		int i = 10;
		int a = 0;
		Toast.makeText(context, "shit:"+i/a, Toast.LENGTH_SHORT).show();
	}
}
複製程式碼

MainActivity:

public class MainActivity extends Activity {

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
	}

	public void test(View v){
		MyTestClass myTestClass = new MyTestClass();
		myTestClass.testFix(this);
	}
	
	public void fix(View v){
		fixBug();
	}

	private void fixBug() {
		//目錄:/data/data/packageName/odex
		File fileDir = getDir(MyConstants.DEX_DIR,Context.MODE_PRIVATE);
		//往該目錄下面放置我們修復好的dex檔案。
		String name = "classes2.dex";
		String filePath = fileDir.getAbsolutePath()+File.separator+name;
		File file= new File(filePath);
		if (fileDir.exists()) {
			if (file.exists()) {
				file.delete();
			}
		}
		//搬家:把下載好的在SD卡里面的修復了的classes2.dex搬到應用目錄filePath
		InputStream is = null;
		FileOutputStream os = null;
		try {
			String dpath = Environment.getExternalStorageDirectory().getAbsolutePath()+File.separator;
			is = new FileInputStream(dpath+name);
			os = new FileOutputStream(filePath);
			int len = 0;
			byte[] buffer = new byte[1024];
			while ((len=is.read(buffer))!=-1){
				os.write(buffer,0,len);
			}

			File f = new File(filePath);
			if(f.exists()){
				Toast.makeText(this	,"dex 重寫成功", Toast.LENGTH_SHORT).show();
			}
			//熱修復
			FixDexUtils.loadFixedDex(this);

		} catch (Exception e) {
			e.printStackTrace();
		}


	}
}
複製程式碼

activity_main:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin">
	<Button 
	    android:layout_width="fill_parent"
	    android:layout_height="wrap_content"
	    android:text="test"
	    android:onClick="test"
	    android:layout_centerInParent="true"
	    android:id="@+id/btn_test"
	    />
	<Button 
	    android:layout_width="fill_parent"
	    android:layout_height="wrap_content"
	    android:text="fix"
	    android:onClick="fix"
	    android:layout_below="@id/btn_test"
	    />
</RelativeLayout>
複製程式碼
編譯dex檔案

1、修復好bug,然後rebuild一下,獲取java檔案對應的所有class檔案(即找到修復好的java檔案所對應的calss檔案):app\build\intermediates\classes\debug\包路徑\類名字,然後右鍵show in Explorer。把檔案複製出來,複製的時候連同整個包名路徑(即帶包名的整個資料夾)都複製出來。(也可以找到class檔案之後選擇壓縮,注意壓縮時選擇絕對路徑,再解壓可保留原有的包目錄)

壓縮

2、cmd到sdk的build-tools\sdk版本 目錄下。

3、使用命令dx --dex --output=D:\Users\dex\classes2.dex D:\Users\dex 命令解釋: --output=D:\Users\dex\classes2.dex 指定輸出路徑和檔名 D:\Users\dex` 最後指定去打包哪個目錄下面的class位元組檔案(注意:要包括全路徑的資料夾,也可以有多個class)

dex

可以看到我這個dex資料夾下有包含全包名路徑的資料夾,該資料夾裡面有class檔案。

當我們點選第一次進來點選test的時候,會閃退,第二次進來,我們點選fix時候,已經把bug修復好了,這時候就不會閃退了。

loader

參考文章: 《Android4.4.2 DexClassLoader原始碼分析》

BaseDexClassLoader原始碼連線:BaseDexClassLoader

DexPathList原始碼連線:DexPathList

相關文章