周一發(fā)布了新版本,當(dāng)天晚上用戶就為app未測試到的bug發(fā)飆了嚷缭,恩供嚎,很快就找到了問題所在,一個容易疏忽的空指針峭状。雖然只是一個小小的bug但是不修復(fù)是很影響用戶體驗的啊克滴,如果要重新修復(fù)上線,波及范圍太廣了优床,所有用戶又要重新下載劝赔。
我們可以讓這個bug“偷偷”的修復(fù)
類加載的過程
類加載由ClassLoader的實現(xiàn)類完成。玩過反編譯的都知道胆敞,我們在解壓了apk之后着帽,最終會需要dex格式的文件來搞事杂伟,這個dex由class文件打包而成。那么安卓中仍翰,要加載dex文件中的class文件赫粥,需要用到DexClassLoader或者PathClassLoader
我們可以直接在AS中點開,但是卻無法正常查看予借,因為這些是系統(tǒng)級的源碼越平。我們可以選擇下載源碼,或者直接在AndroidXRef中找一找灵迫。
1. 先來看看類加載器
PathClassLoader 可以加載Android系統(tǒng)中的dex文件
DexClassLoader 可以加載任意目錄的dex/zip/apk/jar文件 , 但是要指定optimizedDirectory.
這兩個類加載器都繼承BaseDexClassLoader, 并且在構(gòu)造函數(shù)中, DexClassLoader多傳入了一個optimizedDirectory, 這一點先暫記一下
看一下BaseDexClassLoader的構(gòu)造方法:
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);
}
...
}
構(gòu)造方法中初始化了pathList, 傳入三個參數(shù) , 分別為
dexPath:目標(biāo)文件路徑(一般是dex文件秦叛,也可以是jar/apk/zip文件)所在目錄。熱修復(fù)時用來指定新的dex
optimizedDirectory:dex文件的輸出目錄(因為在加載jar/apk/zip等壓縮格式的程序文件時會解壓出其中的dex文件瀑粥,該目錄就是專門用于存放這些被解壓出來的dex文件的)挣跋。
libraryPath:加載程序文件時需要用到的庫路徑。
parent:父加載器
2. 加載類的過程
在BaseDexClassLoader中 , 緊接著構(gòu)造函數(shù)的是一個叫findClass的方法 , 這個方法用來加載dex文件中對應(yīng)的class文件.
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
//從pathList中找到相應(yīng)類名的class文件
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;
}
大體上不難理解, 拿到初始化完成的 pathList 之后 , 根據(jù)類名找出相應(yīng)的class字節(jié)碼文件, 如果沒有異常直接返回class.
接下來我們繼續(xù)跟進(jìn)pathList
3. DexPathList
DexPathList 源碼在這里
好了, 點開源碼不要慌 , 我們目前只需要知道兩個東西:
- 構(gòu)造函數(shù). 我們在BaseDexClassLoader中實例化DexPathList需要用到
- findClass方法, 在BaseDexClassLoader的findClass中, 本質(zhì)調(diào)用了DexpathList的fndClass方法.
其他的方法姑且不用關(guān)心.
1->構(gòu)造函數(shù)
public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory) {
this.definingContext = definingContext;
ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
// save dexPath for BaseDexClassLoader
this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions);
this.nativeLibraryDirectories = splitPaths(libraryPath, false);
this.systemNativeLibraryDirectories =
splitPaths(System.getProperty("java.library.path"), true);
List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);
this.nativeLibraryPathElements = makePathElements(allNativeLibraryDirectories, null,
suppressedExceptions);
}
首先 , 將傳入的classLoader保存起來 , 接下來使用makePathElements方法 ,來初始化Element數(shù)組 .
那接下來無疑是分析makeDexElements()方法了狞换,因為這部分代碼比較長避咆,引用一下大神的分析:
private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) {
// 1.創(chuàng)建Element集合
ArrayList<Element> elements = new ArrayList<Element>();
// 2.遍歷所有dex文件(也可能是jar、apk或zip文件)
for (File file : files) {
ZipFile zip = null;
DexFile dex = null;
String name = file.getName();
...
// 如果是dex文件
if (name.endsWith(DEX_SUFFIX)) {
dex = loadDexFile(file, optimizedDirectory);
// 如果是apk修噪、jar蝇摸、zip文件(這部分在不同的Android版本中拔稳,處理方式有細(xì)微差別)
} else {
zip = file;
dex = loadDexFile(file, optimizedDirectory);
}
...
// 3.將dex文件或壓縮文件包裝成Element對象墨榄,并添加到Element集合中
if ((zip != null) || (dex != null)) {
elements.add(new Element(file, false, zip, dex));
}
}
// 4.將Element集合轉(zhuǎn)成Element數(shù)組返回
return elements.toArray(new Element[elements.size()]);
}
總體來說静浴,DexPathList的構(gòu)造函數(shù)是將一個個的目標(biāo)(可能是dex磷籍、apk适荣、jar、zip , 這些類型在一開始時就定義好了)封裝成一個個Element對象院领,最后添加到Element集合中弛矛。
其實,Android的類加載器(不管是PathClassLoader比然,還是DexClassLoader)丈氓,它們最后只認(rèn)dex文件,而loadDexFile()是加載dex文件的核心方法强法,可以從jar万俗、apk、zip中提取出dex饮怯,但這里先不分析了闰歪,因為第1個目標(biāo)已經(jīng)完成,等到后面再來分析吧蓖墅。
2->findClass方法
public Class findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
在DexPathList的構(gòu)造函數(shù)中已經(jīng)初始化了dexElements库倘,所以這個方法就很好理解了临扮,只是對Element數(shù)組進(jìn)行遍歷,一旦找到類名與name相同的類時教翩,就直接返回這個class杆勇,找不到則返回null。
為什么是調(diào)用DexFile的loadClassBinaryName()方法來加載class饱亿?這是因為一個Element對象對應(yīng)一個dex文件蚜退,而一個dex文件則包含多個class。也就是說Element數(shù)組中存放的是一個個的dex文件路捧,而不是class文件9匕浴!杰扫!這可以從Element這個類的源碼和dex文件的內(nèi)部結(jié)構(gòu)看出队寇。
熱修復(fù)的實現(xiàn)方法
加載class會使用BaseDexClassLoader,在加載時章姓,會遍歷文件下的element佳遣,并從element中獲取dex文件
方案 ,class文件在dex里面 , 找到dex的方法是遍歷數(shù)組 , 那么熱修復(fù)的原理, 就是將改好bug的dex文件放進(jìn)集合的頭部, 這樣遍歷時會首先遍歷修復(fù)好的dex并找到修復(fù)好的類 . 這樣 , 我們就能在沒有發(fā)布新版本的情況下 , 修改現(xiàn)有的bug凡伊。雖然我們無法改變現(xiàn)有的dex文件零渐,但是遍歷的順序是從前往后的,在舊dex中的目標(biāo)class是沒有機(jī)會上場的系忙。
手?jǐn)]一個熱修復(fù)Demo
在了解了大致的熱修復(fù)過程之后诵盼,我們要準(zhǔn)備好以下幾個東西:
- 帶有bug的apk,并且可以獲取到dex文件來修復(fù)
- 已修復(fù)bug的dex文件
因為修復(fù)工作是需要隱秘的進(jìn)行的 , 畢竟有bug也不是什么光彩的事兒 , 所以我吧dex的插入操作放在Splash界面中. 在Splash時先檢測有沒有dex文件, 如果有則進(jìn)行插入 , 否則直接進(jìn)入MainActivity.
1->寫一個有bug的程序
哇, 是不是第一次見到這么爽的需求~
首先在MainActivty中寫一個bug出來:
public class BugTest {
public void getBug(Context context) {
//模擬一個bug
int i = 10;
int a = 0;
Toast.makeText(context, "Hello,Minuit:" + i / a, Toast.LENGTH_SHORT).show();
}
}
public class MainActivity extends AppCompatActivity {
Button btnFix;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
init();
new BugTest().getBug(MainActivity.this);
}
private void init() {
btnFix = findViewById(R.id.btn_fix);
}
}
運(yùn)行這段代碼必然會報錯的 , 但是我們要首先吧這段代碼裝到手機(jī)上 , 方便之后的修復(fù).
接下來編寫SplashActivity以及工具類 . 大家可以根據(jù)具體邏輯修改
/**
*@author Minuit
*@time 2018/6/25 0025 15:50
*/
public class FixDexUtil {
private static final String DEX_SUFFIX = ".dex";
private static final String APK_SUFFIX = ".apk";
private static final String JAR_SUFFIX = ".jar";
private static final String ZIP_SUFFIX = ".zip";
public static final String DEX_DIR = "odex";
private static final String OPTIMIZE_DEX_DIR = "optimize_dex";
private static HashSet<File> loadedDex = new HashSet<>();
static {
loadedDex.clear();
}
/**
* 加載補(bǔ)丁银还,使用默認(rèn)目錄:data/data/包名/files/odex
*
* @param context
*/
public static void loadFixedDex(Context context) {
loadFixedDex(context, null);
}
/**
* 加載補(bǔ)丁
*
* @param context 上下文
* @param patchFilesDir 補(bǔ)丁所在目錄
*/
public static void loadFixedDex(Context context, File patchFilesDir) {
// dex合并之前的dex
doDexInject(context, loadedDex);
}
/**
*@author Minuit
*@time 2018/6/25 0025 15:51
*@desc 驗證是否需要熱修復(fù)
*/
public static boolean isGoingToFix(@NonNull Context context) {
boolean canFix = false;
File externalStorageDirectory = Environment.getExternalStorageDirectory();
// 遍歷所有的修復(fù)dex , 因為可能是多個dex修復(fù)包
File fileDir = externalStorageDirectory != null ?
externalStorageDirectory :
new File(context.getFilesDir(), DEX_DIR);// data/data/包名/files/odex(這個可以任意位置)
File[] listFiles = fileDir.listFiles();
for (File file : listFiles) {
if (file.getName().startsWith("classes") &&
(file.getName().endsWith(DEX_SUFFIX)
|| file.getName().endsWith(APK_SUFFIX)
|| file.getName().endsWith(JAR_SUFFIX)
|| file.getName().endsWith(ZIP_SUFFIX))) {
loadedDex.add(file);// 存入集合
//有目標(biāo)dex文件, 需要修復(fù)
canFix = true;
}
}
return canFix;
}
private static void doDexInject(Context appContext, HashSet<File> loadedDex) {
String optimizeDir = appContext.getFilesDir().getAbsolutePath() +
File.separator + OPTIMIZE_DEX_DIR;
// data/data/包名/files/optimize_dex(這個必須是自己程序下的目錄)
File fopt = new File(optimizeDir);
if (!fopt.exists()) {
fopt.mkdirs();
}
try {
// 1.加載應(yīng)用程序dex的Loader
PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();
for (File dex : loadedDex) {
// 2.加載指定的修復(fù)的dex文件的Loader
DexClassLoader dexLoader = new DexClassLoader(
dex.getAbsolutePath(),// 修復(fù)好的dex(補(bǔ)斗缒)所在目錄
fopt.getAbsolutePath(),// 存放dex的解壓目錄(用于jar、zip蛹疯、apk格式的補(bǔ)督洳啤)
null,// 加載dex時需要的庫
pathLoader// 父類加載器
);
// 3.開始合并
// 合并的目標(biāo)是Element[],重新賦值它的值即可
/**
* BaseDexClassLoader中有 變量: DexPathList pathList
* DexPathList中有 變量 Element[] dexElements
* 依次反射即可
*/
//3.1 準(zhǔn)備好pathList的引用
Object dexPathList = getPathList(dexLoader);
Object pathPathList = getPathList(pathLoader);
//3.2 從pathList中反射出element集合
Object leftDexElements = getDexElements(dexPathList);
Object rightDexElements = getDexElements(pathPathList);
//3.3 合并兩個dex數(shù)組
Object dexElements = combineArray(leftDexElements, rightDexElements);
// 重寫給PathList里面的Element[] dexElements;賦值
Object pathList = getPathList(pathLoader);// 一定要重新獲取,不要用pathPathList捺弦,會報錯
setField(pathList, pathList.getClass(), "dexElements", dexElements);
}
Toast.makeText(appContext, "修復(fù)完成", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 反射給對象中的屬性重新賦值
*/
private static void setField(Object obj, Class<?> cl, String field, Object value) throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cl.getDeclaredField(field);
declaredField.setAccessible(true);
declaredField.set(obj, value);
}
/**
* 反射得到對象中的屬性值
*/
private static Object getField(Object obj, Class<?> cl, String field) throws NoSuchFieldException, IllegalAccessException {
Field localField = cl.getDeclaredField(field);
localField.setAccessible(true);
return localField.get(obj);
}
/**
* 反射得到類加載器中的pathList對象
*/
private static Object getPathList(Object baseDexClassLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}
/**
* 反射得到pathList中的dexElements
*/
private static Object getDexElements(Object pathList) throws NoSuchFieldException, IllegalAccessException {
return getField(pathList, pathList.getClass(), "dexElements");
}
/**
* 數(shù)組合并
*/
private static Object combineArray(Object arrayLhs, Object arrayRhs) {
Class<?> clazz = arrayLhs.getClass().getComponentType();
int i = Array.getLength(arrayLhs);// 得到左數(shù)組長度(補(bǔ)丁數(shù)組)
int j = Array.getLength(arrayRhs);// 得到原dex數(shù)組長度
int k = i + j;// 得到總數(shù)組長度(補(bǔ)丁數(shù)組+原dex數(shù)組)
Object result = Array.newInstance(clazz, k);// 創(chuàng)建一個類型為clazz饮寞,長度為k的新數(shù)組
System.arraycopy(arrayLhs, 0, result, 0, i);
System.arraycopy(arrayRhs, 0, result, i, j);
return result;
}
}
接下來 , 我們在Splash中進(jìn)行檢測以及修復(fù)工作
if (FixDexUtil.isGoingToFix(activity)) {
FixDexUtil.loadFixedDex(activity, Environment.getExternalStorageDirectory());
}
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(2000);
startActivity(new Intent(activity,MainActivity.class));
finish();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
接下來 , 在As中一定一定要把instance run取消勾選,因為instance run用到的原理也是熱修復(fù)的原理列吼,也就是在重新運(yùn)行app時不會完整的安裝幽崩,只會安裝你修改過的代碼。
編譯運(yùn)行:
恩 寞钥, 接下來我們要修復(fù)bug慌申,并且將修復(fù)好的包放進(jìn)sd卡里面,這樣在Splash開始時就會自動遍歷到dex凑耻。
2->編寫修復(fù)好的dex
定位一下bug是出現(xiàn)在BugTest 中 , 所以我們首先修復(fù)bug
public void getBug(Context context) {
int i = 10;
int a = 1;
Toast.makeText(context, "Hello,Minuit:" + i / a, Toast.LENGTH_SHORT).show();
}
然后將class文件打包成dex文件
首先點擊Build->Rebuild Project 來重新構(gòu)建, 構(gòu)建完成之后, 可以在app / build / interintermediate / debug / 包名/ 找到你剛剛修改的class文件 , 將他拷貝出來
注意 , 拷貝出來要連同包名一起, 像這樣
因為在dex中的class文件是包名.類名的形式 , 所以我們在做dex文件時, 也要講相對應(yīng)的包名加上 . 這里反編譯一個demo作為例子:
接下來就要生成dex文件了
要將class文件打包成dex文件太示,就需要用到dx指令柠贤,這個dx指令類似于java指令。dx指令也需要有程序來提供类缤,它就在Android SDK的build-tools目錄下各個Android版本目錄之中臼勉。
接下來使用指令來編譯, shift+右擊 打開命令行 , 輸入指令:
dx --dex --output c:\Users\Administrator\Desktop\dex\classes.dex c:\Users\Administrator\Desktop\dex
具體的語法大家手動dx --help自己看一下 , 輸入如下指令后 , 我桌面的dex文件下, 與剛剛拷貝的文件夾平級會出現(xiàn)一個classes.dex的文件
接下來將dex文件拷貝到sd卡下面 , 當(dāng)然如果是真實項目去下載的話 , 當(dāng)然是要下載到特定目錄了
至此, 在Splash界面的檢測時會見到到目標(biāo)的dex文件, 返回true , 會開始進(jìn)行熱修復(fù)(拼接Element數(shù)組)的操作, 再次進(jìn)入到主界面當(dāng)然就不會報錯了.
那么, 出錯的那個class去哪里了??? 它還在整個Elements的集合中的某一個dex中, 只不過沒有機(jī)會調(diào)用到了而已餐弱。