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的使用,下期见!

猜你喜欢

转载自blog.csdn.net/JArchie520/article/details/106518010