Android熱修復實戰之AndFix

目錄

寫在前面

一、AndFix基本介紹

1.1、AndFix簡介

1.2、AndFix方法體替換規則

1.3、AndFix BUG修復過程

二、AndFix代碼實戰

2.1、AndFix集成

2.2、AndFix初始化

2.3、構建APK

2.4、修復BUG

三、AndFix源碼解析


寫在前面

上一篇《Android熱修復技術簡介》中對Android的熱修復技術的概念和常用的技術方案做了一個簡單的介紹,那麼今天就來實戰一下熱修復技術,我們使用的是AndFix,爲什麼是它?因爲無論是從使用上還是原理上AndFix都是相對簡單的,畢竟這是實戰的第一篇,還是要有個由易到難的過程的,好了,話不多說,開始吧!

一、AndFix基本介紹

1.1、AndFix簡介

AndFix項目地址:https://github.com/alibaba/AndFix,大家訪問這個地址去看它的詳細介紹,我這裏只是簡單的列一下:

  • 阿里巴巴開源的Android熱修復工具,Android hot fix的縮寫,旨在幫助開發者修復線上應用出現的BUG
  • 支持2.3-7.0,ARM和X86架構,Dalvik和Art運行時(注意某些機器上可能還是不兼容的)
  • Andfix生成的差異包的後綴名是 .apatch,可以將差異包從server分發到client去修復BUG
  • AndFix只能用於方法級別的替換,修復方法中產生的BUG,使用場景有限。它通過自定義註解的方式來判斷哪些方法需要被替換,替換時是通過Native層的方法去完成替換的,所以這也就造成了AndFix的兼容性其實並沒有那麼強,因爲Native層可能會隨着API版本不同而改變,不同的運行時機制都需要重新適配。

1.2、AndFix方法體替換規則

1.3、AndFix BUG修復過程

二、AndFix代碼實戰

2.1、AndFix集成

添加AndFix依賴,這一步沒啥好說的,直接到它的GitHub主頁上去複製就OK了:

//引入AndFix模塊
implementation 'com.alipay.euler:andfix:0.5.0@aar'

2.2、AndFix初始化

根據GitHub文檔上的How to use這一部分的說明,我們來對AndFix做初始化操作。這裏創建一個類AndFixPatchManager來統一管理AndFix所有的API,這樣做一是爲了方便管理,二是爲了可以降低AndFix對我們代碼的侵入性:

/**
 * 作者:created by Jarchie
 * 時間:2020/5/22 14:33:16
 * 郵箱:[email protected]
 * 說明:管理AndFix所有的API
 */
public class AndFixPatchManager {

    private static AndFixPatchManager mInstance = null;
    private static PatchManager mPatchManager = null;

    //單例模式雙檢查機制
    public static AndFixPatchManager getInstance(){
        if (mInstance == null){
            synchronized (AndFixPatchManager.class){
                if (mInstance == null){
                    mInstance = new AndFixPatchManager();
                }
            }
        }
        return mInstance;
    }

    //初始化AndFix方法
    public void initPatch(Context context){
        mPatchManager = new PatchManager(context);
        mPatchManager.init(Utils.getVersionName(context));
        mPatchManager.loadPatch();
    }

    //加載Patch文件
    public void addPatch(String path){
        try {
            if (mPatchManager!=null){
                mPatchManager.addPatch(path);
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }

}

這裏面用到了一個Utils.getVersionName()的工具方法,這個方法就是來獲取應用版本信息的:

    //獲取版本名稱
    public static String getVersionName(Context context) {
        String versionName = "1.0.0";
        try {
            PackageManager pm = context.getPackageManager();
            PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0);
            versionName = pi.versionName;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return versionName;
    }

然後在項目的Application類中調用上面寫好的初始化方法即可完成AndFix的初始化操作:

/**
 * 作者:created by Jarchie
 * 時間:2020/5/22 14:40:30
 * 郵箱:[email protected]
 * 說明:自定義的Application類
 */
public class BaseApp extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        //完成AndFix的初始化
        initAndFix();
    }

    private void initAndFix() {
        AndFixPatchManager.getInstance().initPatch(this);
    }
}

2.3、構建APK

2.3.1、構建異常APK

①、創建佈局

先來創建一個佈局文件activity_main.xml,內容很簡單,兩個按鈕,一個模擬異常場景,一個模擬修復場景:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:id="@+id/mCreateBug"
        android:layout_width="match_parent"
        android:layout_height="45dp"
        android:layout_margin="20dp"
        android:background="@color/colorPrimary"
        android:gravity="center"
        android:text="生成BUG"
        android:textColor="#fff" />

    <TextView
        android:id="@+id/mFixBug"
        android:layout_width="match_parent"
        android:layout_height="45dp"
        android:layout_marginLeft="20dp"
        android:layout_marginRight="20dp"
        android:background="@color/colorPrimary"
        android:gravity="center"
        android:text="修復BUG"
        android:textColor="#fff" />
</LinearLayout>

界面如下:當我們點擊生成BUG按鈕時,我們的程序會發生崩潰Crash掉:

②、編寫業務代碼

首先是對差異包的後綴名、存放路徑等的一個初始化操作:

    private static final String TAG = MainActivity.class.getSimpleName();
    //定義差異包文件的後綴名
    private static final String FILE_SUFFIX = ".apatch";
    //定義差異包文件的存放路徑
    private String mPatchDir;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //初始化差異包文件路徑
        mPatchDir = getExternalCacheDir().getAbsolutePath()+"/apatch/";
        Log.e(TAG, "完整路徑--->"+mPatchDir);
        //創建文件夾
        File file = new File(mPatchDir);
        if (file == null || !file.exists()){
            file.mkdir();
        }
    }

然後是構造apatch文件的完整路徑,當點擊修復BUG的時候,調用PatchManager的addPath方法加載文件:

    @Override
    public void onClick(View view) {
        switch (view.getId()){
            case R.id.mFixBug: //修復Bug
                AndFixPatchManager.getInstance().addPatch(getPatchName());
                break;
        }
    }

    //構造patch文件名
    private String getPatchName(){
        return mPatchDir.concat("jaqandfix").concat(FILE_SUFFIX);
    }

③、模擬BUG產生

在產生BUG按鈕的點擊事件的方法中我們模擬一次Crash的產生:

@Override
public void onClick(View view) {
    switch (view.getId()){
        case R.id.mCreateBug: //生成Bug
            Utils.printLog();
            break;
    }
}

然後在Utils類中的printLog()方法中給它製造Crash,這裏就簡單的讓它產生空指針異常:

//構造異常方法
public static void printLog() {
    String info = null;
    Log.e("jarchie-andfix", info);
}

④、Build異常APK

在構建APK時,我們需要構建帶簽名的版本,這是爲了接下來生成apatch文件用的。關於如何構建release版本的APK我就不多說了,相信沒有不知道的吧,構建完了之後,你可以把它弄到你的手機上,通過adb push或者文件傳輸工具都可以,只要安裝到你手機上就行,注意這裏需要將這個有bug的apk保存一份,因爲後面要用到。

2.3.2、構建正常APK

①、修改空指針異常

public static void printLog() {
    String info = "Jarchie"; //修復空指針
    Log.e("jarchie-andfix", info);
}

②、構建修復後的APK

將修改後的代碼重新打包,生成新的release包,這裏也將新的apk包保存一份。

2.4、修復BUG

①、生成apatch文件

生成apatch文件主要是用到了apkpatch這個命令行工具,這個工具包在github上有,大家下載到自己電腦上就行了:

裏面就3個文件,windows用戶使用.bat的這個,Linux或者MAC OS的用戶使用.sh的這個。

然後我將之前Build的兩個apk和jks文件都複製到這個文件夾中,並且新建了一個文件夾outputs作爲apatch文件的輸出目錄:

然後打開控制檯,進入到apkpatch這個目錄下,執行apkpatch命令來看一下這個命令的用法介紹:

上面的是用來生成apatch文件,下面的是用來合併多個patch文件爲一個的時候用的,具體的參數下面也都給出了,並且也都有註釋說明(雖然都是英文,但相信你都能看的懂)。

然後我們就來使用apkpatch命令來生成我們的.apatch差異包:

執行完這個命令就生成了我們的差異包,並且它還會告訴你哪個類的哪個方法做了修改,正好就是我們的printLog()方法修改了。

進到本地目錄中可以看到確實生成了apatch文件,我將它重命名爲 jaqandfix.apatch

②、push apatch文件

在生成了apatch文件之後,就可以將它放到手機對應的目錄中,這一步操作同樣也沒有限制具體的方法,你可以通過文件傳輸工具,也可以直接通過adb命令將文件push到對應的目錄,我這裏使用adb命令的方式進行:

可以看到,我們手機中對應的目錄下面已經有了push進來的jaqandfix.apatch文件。

③、修復BUG

再次進入App,然後首先點擊修復BUG,它會去load這個補丁文件,當你再次點擊產生BUG時,你會發現BUG已經被修復了。

注意:官網上給出的是2.1-7.0的版本,如果你各種操作步驟都是正確的,但是沒有效果,那就換一臺手機試一下,因爲畢竟這個東西並不是所有機型都適配的,這裏主要是學習它的方法。還有一點是,實際應用中,補丁文件是肯定不可能通過adb push這種方式進入用戶手機中的,基本上都是通過服務端下發,客戶端是一個下載文件的過程,這一點也需要注意。

到這裏就已經說完了AndFix的修復流程,整個流程總結下來就是下面這張簡化的圖:

三、AndFix源碼解析

首先找到之前封裝的AndFixPatchManager類,然後找到initPatch()方法:

    //初始化AndFix方法
    public void initPatch(Context context){
        mPatchManager = new PatchManager(context);
        mPatchManager.init(Utils.getVersionName(context));
        mPatchManager.loadPatch();
    }

從代碼中可以看到,所有的操作都是通過AndFix的PatchManager類來完成的,很明顯是外觀模式,將所有的API都包含在了PatchManger中,所以不需要關注AndFix其他模塊的作用。這裏需要說明一點,閱讀源碼我們不可能把每一個類的每一行代碼都完全弄懂,我們讀源碼是爲了瞭解這個框架的實現過程,所以最好的方式就是結合在應用層我們自己的業務代碼中調用它的那些類和方法,按照順序一一跟進閱讀,把整個調用流程串起來就OK了。

好,現在來打開PatchManager類,首先看一下它裏面幾個比較重要的成員變量:

    /**
	 * context
	 */
	private final Context mContext;
	/**
	 * AndFix manager
	 */
	private final AndFixManager mAndFixManager;
	/**
	 * patch directory
	 */
	private final File mPatchDir;
	/**
	 * patchs
	 */
	private final SortedSet<Patch> mPatchs;
	/**
	 * classloaders
	 */
	private final Map<String, ClassLoader> mLoaders;
  • AndFixManager:所有的方法替換、BUG修復都是由AndFixManager來完成的
  • SortedSet<Patch>:經過排序後的Set集合,包含應用所有的Patch文件

接着來看一下它的構造方法,因爲我們在應用層最先調用的就是它的構造方法:

    /**
	 * @param context
	 *            context
	 */
	public PatchManager(Context context) {
		mContext = context;
		mAndFixManager = new AndFixManager(mContext);
		mPatchDir = new File(mContext.getFilesDir(), DIR);
		mPatchs = new ConcurrentSkipListSet<Patch>();
		mLoaders = new ConcurrentHashMap<String, ClassLoader>();
	}

可以看到構造方法主要就是進行了一系列的初始化:上下文、AndFixManager、文件夾、數據結構等等的初始化操作。

接着來看我們應用層調用的第一個方法init()方法:

    /**
	 * initialize
	 * 
	 * @param appVersion
	 *            App version
	 */
	public void init(String appVersion) {
		if (!mPatchDir.exists() && !mPatchDir.mkdirs()) {// make directory fail
			Log.e(TAG, "patch dir create error.");
			return;
		} else if (!mPatchDir.isDirectory()) {// not directory
			mPatchDir.delete();
			return;
		}
		SharedPreferences sp = mContext.getSharedPreferences(SP_NAME,
				Context.MODE_PRIVATE);
		String ver = sp.getString(SP_VERSION, null);
		if (ver == null || !ver.equalsIgnoreCase(appVersion)) {
			cleanPatch();
			sp.edit().putString(SP_VERSION, appVersion).commit();
		} else {
			initPatchs();
		}
	}

入參是需要傳入當前應用的版本號,然後內部一開始是進行了文件夾的判斷,滿足了條件之後,它會從AndFix的SharedPreferences中拿到之前保存的版本號,然後通過這個版本號和入參中傳入的版本號去做一個判斷,如果不同,表明我們的應用已經做了升級,然後就會調用cleanPatch()去刪除所有的Patch文件,同時更新版本號,用於下一次的比較,如果版本號相同,表明沒有升級,則會調用initPatchs()方法,接下來,跟進這個initPatchs()方法:

private void initPatchs() {
		File[] files = mPatchDir.listFiles();
		for (File file : files) {
			addPatch(file);
		}
	}

這個方法很簡單,就是遍歷指定Patch文件夾下的所有文件,然後將它們通過addPatch()方法添加到mPatchs這個PatchList中,跟進addPatch()方法看一下:

private Patch addPatch(File file) {
		Patch patch = null;
		if (file.getName().endsWith(SUFFIX)) {
			try {
				patch = new Patch(file);
				mPatchs.add(patch);
			} catch (IOException e) {
				Log.e(TAG, "addPatch", e);
			}
		}
		return patch;
	}

這個方法內部首先是判斷傳入的文件後綴名是否符合.apatch格式,如果符合,將其轉化爲Patch文件,然後將文件添加到PatchList中,所以這裏的mPatchs內部就是保存了所有的Patch文件。然後點擊Patch類進入到這個類中看一下它是如何將一個File轉化爲Patch類的?這個Patch類就相當於是一個實體類,這個類中定義了一些成員變量:

    /**
	 * patch file
	 */
	private final File mFile;
	/**
	 * name
	 */
	private String mName;
	/**
	 * create time
	 */
	private Date mTime;
	/**
	 * classes of patch
	 */
	private Map<String, List<String>> mClassesMap;

主要有傳入的文件、文件名、mClassMap等,mClassMap是存儲了本次Patch文件所有要修復的class的字符串,然後會調用類中的init()方法完成解析:

private void init() throws IOException {
		JarFile jarFile = null;
		InputStream inputStream = null;
		try {
			jarFile = new JarFile(mFile);
			JarEntry entry = jarFile.getJarEntry(ENTRY_NAME);
			inputStream = jarFile.getInputStream(entry);
			Manifest manifest = new Manifest(inputStream);
			Attributes main = manifest.getMainAttributes();
			mName = main.getValue(PATCH_NAME);
			mTime = new Date(main.getValue(CREATED_TIME));

			mClassesMap = new HashMap<String, List<String>>();
			Attributes.Name attrName;
			String name;
			List<String> strings;
			for (Iterator<?> it = main.keySet().iterator(); it.hasNext();) {
				attrName = (Attributes.Name) it.next();
				name = attrName.toString();
				if (name.endsWith(CLASSES)) {
					strings = Arrays.asList(main.getValue(attrName).split(","));
					if (name.equalsIgnoreCase(PATCH_CLASSES)) {
						mClassesMap.put(mName, strings);
					} else {
						mClassesMap.put(
								name.trim().substring(0, name.length() - 8),// remove
																			// "-Classes"
								strings);
					}
				}
			}
		} finally {
			if (jarFile != null) {
				jarFile.close();
			}
			if (inputStream != null) {
				inputStream.close();
			}
		}
	}

這個方法是首先把文件轉化成jar文件,然後解析jar文件中的所有字段比如:PATCH_NAME、CREATED_TIME等,這些字段是我們之前通過apatch命令行工具生成apatch文件的時候添加的,所以在這裏可以直接解析了。然後來說mClassMap是如何初始化的,它會找到所有的Class,然後判斷一下是不是自己要解析的PATCH_CLASS,如果是就添加到以當前Patch文件名爲key的Map中,添加進來之後當你後續使用的時候,就可以直接通過getClasses傳入當前的Patch文件名獲取這個Patch文件中所有要修復的Class的絕對路徑:

public List<String> getClasses(String patchName) {
		return mClassesMap.get(patchName);
	}

現在我們應該清楚了這個Patch文件的作用了,它就是將普通磁盤上的File轉化成PatchFile方便使用。OK,到這裏這個PatchManager的init()方法就說完了,總結一下它的作用就是對Patch文件的刪除和添加。

應用層中在我們下載完Patch文件之後,我們調用了addPatch()方法還記得嗎?mPatchManager.addPatch(path); 現在就來看一下這個addPatch()方法是如何實現的?

    /**
	 * add patch at runtime
	 * 
	 * @param path
	 *            patch path
	 * @throws IOException
	 */
	public void addPatch(String path) throws IOException {
		File src = new File(path);
		File dest = new File(mPatchDir, src.getName());
		if(!src.exists()){
			throw new FileNotFoundException(path);
		}
		if (dest.exists()) {
			Log.d(TAG, "patch [" + path + "] has be loaded.");
			return;
		}
		FileUtil.copyFile(src, dest);// copy to patch's directory
		Patch patch = addPatch(dest);
		if (patch != null) {
			loadPatch(patch);
		}
	}

前面就是一些判斷和文件的創建,它會先把磁盤上的文件拷貝到mPatchDir下面,拷貝完成之後會將文件解析成Patch類,然後會添加到mPatchs這個PatchList中,添加完以後,最後調用了loadPatch()方法,正是因爲調用了loadPatch()方法所以可以完成BUG的修復,在loadPatch()方法內部調用了AndFixManager去完成了方法的替換,所以接着來看一下loadPatch()方法的實現過程。

    /**
	 * load patch,call when application start
	 * 
	 */
	public void loadPatch() {
		mLoaders.put("*", mContext.getClassLoader());// wildcard
		Set<String> patchNames;
		List<String> classes;
		for (Patch patch : mPatchs) {
			patchNames = patch.getPatchNames();
			for (String patchName : patchNames) {
				classes = patch.getClasses(patchName);
				mAndFixManager.fix(patch.getFile(), mContext.getClassLoader(),
						classes);
			}
		}
	}

	/**
	 * load specific patch
	 * 
	 * @param patch
	 *            patch
	 */
	private void loadPatch(Patch patch) {
		Set<String> patchNames = patch.getPatchNames();
		ClassLoader cl;
		List<String> classes;
		for (String patchName : patchNames) {
			if (mLoaders.containsKey("*")) {
				cl = mContext.getClassLoader();
			} else {
				cl = mLoaders.get(patchName);
			}
			if (cl != null) {
				classes = patch.getClasses(patchName);
				mAndFixManager.fix(patch.getFile(), cl, classes);
			}
		}
	}

loadPatch()方法有兩個重載的方法,上面的沒有參數的方法會遍歷mPatchs這個集合,對所有的Patch文件中的Class都調用一次AndFixManager的fix()方法,下面的有參數的方法就是單一的修復指定Patch文件中的Class字節碼,無論是有參還是無參的方法都調用了mAndFixManager.fix()方法,接着來看一下這個方法內部又是如何實現的?

public synchronized void fix(File file, ClassLoader classLoader,
			List<String> classes) {
		if (!mSupport) {
			return;
		}

		if (!mSecurityChecker.verifyApk(file)) {// security check fail
			return;
		}

		try {
			File optfile = new File(mOptDir, file.getName());
			boolean saveFingerprint = true;
			if (optfile.exists()) {
				// need to verify fingerprint when the optimize file exist,
				// prevent someone attack on jailbreak device with
				// Vulnerability-Parasyte.
				// btw:exaggerated android Vulnerability-Parasyte
				// http://secauo.com/Exaggerated-Android-Vulnerability-Parasyte.html
				if (mSecurityChecker.verifyOpt(optfile)) {
					saveFingerprint = false;
				} else if (!optfile.delete()) {
					return;
				}
			}

			final DexFile dexFile = DexFile.loadDex(file.getAbsolutePath(),
					optfile.getAbsolutePath(), Context.MODE_PRIVATE);

			if (saveFingerprint) {
				mSecurityChecker.saveOptSig(optfile);
			}

			ClassLoader patchClassLoader = new ClassLoader(classLoader) {
				@Override
				protected Class<?> findClass(String className)
						throws ClassNotFoundException {
					Class<?> clazz = dexFile.loadClass(className, this);
					if (clazz == null
							&& className.startsWith("com.alipay.euler.andfix")) {
						return Class.forName(className);// annotation’s class
														// not found
					}
					if (clazz == null) {
						throw new ClassNotFoundException(className);
					}
					return clazz;
				}
			};
			Enumeration<String> entrys = dexFile.entries();
			Class<?> clazz = null;
			while (entrys.hasMoreElements()) {
				String entry = entrys.nextElement();
				if (classes != null && !classes.contains(entry)) {
					continue;// skip, not need fix
				}
				clazz = dexFile.loadClass(entry, patchClassLoader);
				if (clazz != null) {
					fixClass(clazz, classLoader);
				}
			}
		} catch (IOException e) {
			Log.e(TAG, "pacth", e);
		}
	}

首先是一些安全性的判斷,它會驗證簽名是否符合,驗證通過纔會繼續往下走,它會將Patch文件中的File轉化成DexFile,然後遍歷dexFile中的所有變量,在while循環中真正找到要修復的classes,因爲我們傳入的其實是Class文件的Name,所以這個while真正的遍歷就是通過name調用dexFile的loadClass找到真正要修復的Class字節碼,然後又調用了fixClass來完成方法的替換,所以還要繼續往下來看fixClass又完成了哪些操作?

private void fixClass(Class<?> clazz, ClassLoader classLoader) {
		Method[] methods = clazz.getDeclaredMethods();
		MethodReplace methodReplace;
		String clz;
		String meth;
		for (Method method : methods) {
			methodReplace = method.getAnnotation(MethodReplace.class);
			if (methodReplace == null)
				continue;
			clz = methodReplace.clazz();
			meth = methodReplace.method();
			if (!isEmpty(clz) && !isEmpty(meth)) {
				replaceMethod(classLoader, clz, meth, method);
			}
		}
	}

它的入參就是真正要修復的Class字節碼和ClassLoader,方法內部首先是通過反射找到字節碼中所有的方法,接着是定義了一個註解,這個註解就是之前一開始介紹到的AndFix是通過註解找到哪些方法是需要被替換的,接着會遍歷所有的方法來看一下哪個方法上有methodReplace這個註解,如果有就把這個方法記錄下來,接着調用replaceMethod()方法來完成方法的替換,繼續跟進replaceMethod()方法看一下它內部又是如何實現的?

private void replaceMethod(ClassLoader classLoader, String clz,
			String meth, Method method) {
		try {
			String key = clz + "@" + classLoader.toString();
			Class<?> clazz = mFixedClass.get(key);
			if (clazz == null) {// class not load
				Class<?> clzz = classLoader.loadClass(clz);
				// initialize target class
				clazz = AndFix.initTargetClass(clzz);
			}
			if (clazz != null) {// initialize class OK
				mFixedClass.put(key, clazz);
				Method src = clazz.getDeclaredMethod(meth,
						method.getParameterTypes());
				AndFix.addReplaceMethod(src, method);
			}
		} catch (Exception e) {
			Log.e(TAG, "replaceMethod", e);
		}
	}

這個方法中最關鍵的一句代碼就是:AndFix.addReplaceMethod(src, method); 接着跟到這個方法中:

public static void addReplaceMethod(Method src, Method dest) {
		try {
			replaceMethod(src, dest);
			initFields(dest.getDeclaringClass());
		} catch (Throwable e) {
			Log.e(TAG, "addReplaceMethod", e);
		}
	}

然後addReplaceMethod()中又調用了replaceMethod()方法,接着跟到replaceMethod()方法中:

private static native void replaceMethod(Method dest, Method src);

可以發現這個方法是native方法,所以到這裏我們就跟不下去了,它應該是通過C層對dex文件的操作完成最終方法的替換。

到這裏源碼閱讀就結束了,以上就是整個AndFix的執行流程。

今天就先到這裏吧,下一篇準備來說說Tinker的使用,下期見!

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