作者:g小志
地址:https://www.jianshu.com/p/f15b126d7234
声明:本文是 g小志 原创投稿,转发等联系原作者授权。
前言
最近发现热修复比较火,很多文章也做了介绍。所以自己也简单的学习下。因为自己在实际项目中用到。所以为了防止忘记,写成博客做成笔记,同时也帮助一些没有接触过的小伙伴能快速使用与入门。废话少说。进入主题。
热修复的概念
上面是热修复。简单解释就是在线更新。比如我们已发布的应用突然产生了严重的BUG,按照旧方法,只能能下一次版本修复后重新发布。然后用户重新去下载。这样其实给用户的体验就很不好。既浪费流量同时在重新下载的时候也会产生用户流失等等一系列影响。可能只是一个小小的BUG就到时用于的流失,显然不是我们想看到的。那么有没有上面方法可以在不发布新版本,重新下载的情况下修复BUG呢?因此热修复技术应运而生。下面我们来现在现在比较火的而修复都有那些:
这4中热修复技术各有优缺点他们分别是微信-Tinker,QQ控件的超级补丁-QZone,阿里-AndFix,美团-Robust。从图中我们也可以了解到。功能最少的是AndFix,Tinker最复杂。当然这不是决定他们好坏的标准。具体的选择还是根据自身的时机情况而定。而选择学习的话,选一个最容易和一个最复杂的。在熟练后理解其他的也会水到渠成。
AndFix
这篇文章就先来学习下AndFix。关于Tinker请参考我的另一片文章。
特点:
与 Tinker 相比他的特点就是即生效,不过只能修复方法级别的 BUG,不支持 gradle。他的修复流程这里就不过多介绍了,详细见 AndFix官网。用一句话总结,就是找到 BUG 的方法,修改后生成 apatch 文件并通过注解标记修复的方法。在修复时就加载修复补丁文件,完成修复。下面我们就来具体使用下。具体步骤如下:
引入依赖
//引入AndFix热修复模块
compile 'com.alipay.euler:andfix:0.5.0@aar'
可以看到和我们平时引用其他库基本是一样的,非常简单。
核心方法
其实不光他的引入非常简单,使用起来也是非常简单。主要的Api就4个方法。如下:
PatchManager patchManager = new PatchManager(this); //创建PatchManager 他是Andfix的核心类
patchManager.init(appVersion);//初始化 传入应用版本号
patchManager.loadPatch(); //加载 官网建议我们初始化完成后就调用
patchManager.addPatch(path); //传入指定文件 修复BUG
好了应用层的核心代码就这些了。下面就来实现一个有BUG的应用,然后利用AndFix去修复。代码如下:
XML:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.example.ggxiaozhi.hotfix.MainActivity">
<Button
android:onClick="onClick"
android:text="Bug"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<Button
android:onClick="onFix"
android:text="Fix"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
MainActivity代码:
public class MainActivity extends AppCompatActivity {
private static final String FILE_END = ".apatch";//规定修复补丁的文件格式是.apatch文件
private String mPatchDir;//修复补丁的存放路径
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//最后的文件所在路径为storage/emulated/0/Android/data/com.example.ggxiaozhi.hotfix/cache/apatch/
mPatchDir = getExternalCacheDir().getAbsolutePath() + "/apatch/";
//创建文件夹
File file = new File(mPatchDir);
if (!file.exists()) {
file.mkdir();
}
}
private void showToast() {
boolean isShow = false;
String str = "存在一个BUG";
if (isShow) {
str = "方法BUG修复完成";
}
Toast.makeText(this, str, Toast.LENGTH_SHORT).show();
}
public void onClick(View view) {
showToast();
}
public void onFix(View view) {
AndFixPatchManager.getInstance().addPatch(getPatchName());
}
//加载修复文件的文件名
public String getPatchName() {
return mPatchDir.concat("andfix").concat(FILE_END);
}
}
代码也非常简单。这里讲解下。首先定义2个常量,一个是固定了我们加载补丁的文件格式。另一个是补丁文件所在的文件夹。这里使用的是应用的内部文件夹下的apatch文件夹。这个就是放补丁文件的文件夹位置。前面我们说了AndFix只能修复方法级的BUG,所以正常情况下点击产生BUG会Toast:存在一个BUG,当我们修复完成够就会Toast:方法BUG修复完成。道理很简单。现在我们要做的就是生成一个有问题的带签名apk(关于如何生成带签名的apk这里我就不过多介绍了)。生成后改名为old.apk,也就是存在BUG的apk。
提示:这里我们用到的AndFixPatchManager只不过是对PatchManager的简单封装:
AndFixPatchManager:
public class AndFixPatchManager {
private static AndFixPatchManager mInstance;
private static PatchManager mPatchManager;
public static AndFixPatchManager getInstance() {
if (mInstance == null) {
synchronized (AndFixPatchManager.class) {
if (mInstance == null) {
mInstance = new AndFixPatchManager();
}
}
}
return mInstance;
}
/**
* 初始化AndFix
*
* @param context 上下文
*/
public void initPatch(Context context) {
//初始化
mPatchManager = new PatchManager(context);
mPatchManager.init(Utils.getVersionName(context));
//加载patch
mPatchManager.loadPatch();
}
/**
* 加载我们的Patch文件
* @param path .patch文件 路径
*/
public void addPatch(String path) {
try {
if (mInstance!=null){
mPatchManager.addPatch(path);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
这里没有什么难点,不过别忘了在Application中初始化调用initPatch()方法,同时要加上读写内存卡的权限
修改BUG
现在我们想修复这个BUG,那么我们修改代码如下:
...省略
private void showToast() {
boolean isShow = true;
String str = "存在一个BUG";
if (isShow) {
str = "方法BUG修复完成";
}
Toast.makeText(this, str, Toast.LENGTH_SHORT).show();
}
...省略
这里也非常简单,我们只是把isShow改成true,这样修复后就与old.apk打印的结果不同。完成修复。省略部分完全不变。
命令行生成.apatch补丁文件
准备工作已经完成了下面我们利用Andfix工具来生成.patch补丁文件。下载完成后目录如下:
这里对应.bat是Window用来生成补丁文件。另一个是mac生成补丁文件。我这里用的是Window所以我用的是.bat(如果想直接在命令行使用别忘记配置环境变量)。同时把有BUG的old.apk文件与修复BUG后的new.apk文件同时将签名文件一起放在这个目录下。完成这些后此路径如下:完成这些后使用命令行输入如下:
来简单说明下参数:
首先有可以看到有2个命令
-f 这个是用来生成.apatch补丁文件的;
-m 是用来合并多个.apatch文件的。
这里我们是生成所以用到第一个命令。这里需要填入的参数分别为:
-a 签名文件的别名
-e 签名文件别名的密码
-f 修改BUG的.apk文件
-k 签名文件的路径
-n 签名文件的名字
-o 补丁文件的输出文件夹路径(没有会自动创建)
-p 签名文件密码
-t 存在BUG的.apk文件
在明白这些后我们就会在outputs文件下生成.apatch文件。在实际开发中我们可以将这个文件方法服务器上。然后用户去拉取文件下载到指定目录,或是服务器主动推到用户应用上。这里为了简便我是直接将补丁文件装到了上面我们定义的应用内部路径上的(生成的.apatch文件的名字记得要修改,因为前面我们定义了文件名为annfix.apatch文件。避免找不到)。
修复BUG
最后直接运行后点击修复Button就完成修复了。这里我用的是真机。截图有些麻烦就不给大家截图了。亲测有效。
总结
上面就是Andfix的使用。下面我们来总结下注 此图是通过其他文章所得,具体实际情况还需要自己实践
一些基本的注意点和总结目前就这些,待以后熟练后在加入一些新的要注意的点。
因为他本身是一个依赖,所以在混淆等操作时处理一致。
首先热修复都存在一些兼容性的问题如果选择Andfix(其他也一样)要做好兼容性的处理。我在使用时先用的小米2A(api=19)修复失败。魅蓝Note(api=21)修复成功。如果流程吗,没有问题看看是不是机型不支持热修复
只能修复方法级别的BUG。通过官网我们也已经知道了。所以它无法添加新类和新字段。.无法动态加入新功能模块,有别于dex的替换。他的思路如下:
但是也不是所有的方法级别的BUG都能修复。如:
AndFix原理分析
说道原理分期,那我们就不得不去看下他的源码。下面我们就从上面提到的4个API入手看下。
mPatchManager = new PatchManager(context);
我们先看下这个方法:
public class PatchManager {
private static final String TAG = "PatchManager";
// patch extension
private static final String SUFFIX = ".apatch";
private static final String DIR = "apatch";
private static final String SP_NAME = "_andfix_";
private static final String SP_VERSION = "version";
/**
* context
*/
private final Context mContext;
/**
* AndFix manager 真正修复的类
*/
private final AndFixManager mAndFixManager;
/**
* patch directory 修复补丁路径
*/
private final File mPatchDir;
/**
* patchs .apatch文件包装类
*/
private final SortedSet<Patch> mPatchs;
/**
* classloaders
*/
private final Map<String, ClassLoader> mLoaders;
/**
* @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>();
}
....
}
首先在PatchManager类中定义了几个常量,同时在构造方法中进行初始化。下面我们接着看第二个api:
mPatchManager.init(Utils.getVersionName(context));
PatchManager#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;
}
//读取数据存储XML中版本号
SharedPreferences sp = mContext.getSharedPreferences(SP_NAME,
Context.MODE_PRIVATE);
String ver = sp.getString(SP_VERSION, null);
if (ver == null || !ver.equalsIgnoreCase(appVersion)) {//如果.apatch文件中的版本号与上次不同说明进行了版本迭代 那么就删除所有的.apatch补丁文件
cleanPatch();
sp.edit().putString(SP_VERSION, appVersion).commit();
} else {
//初始化补丁文件
initPatchs();
}
}
private void initPatchs() {
//得到补丁文件夹下的所有补丁文件集合
File[] files = mPatchDir.listFiles();
for (File file : files) {
//遍历集合 将文件封装成Patch类
addPatch(file);
}
}
/**
* add patch file
*
* @param file
* @return patch
*/
private Patch addPatch(File file) {
Patch patch = null;
if (file.getName().endsWith(SUFFIX)) {//判断传入的文件是否是补丁文件格式
try {
// 将文件封装成Patch类
patch = new Patch(file);
//加入集合
mPatchs.add(patch);
} catch (IOException e) {
Log.e(TAG, "addPatch", e);
}
}
//返回
return patch;
}
//版本升级则删除补丁文件
private void cleanPatch() {
File[] files = mPatchDir.listFiles();
for (File file : files) {
mAndFixManager.removeOptFile(file);
if (!FileUtil.deleteFile(file)) {
Log.e(TAG, file.getName() + " delete error.");
}
}
}
...
这里省略其他代码,主要的代码都有注释。大致流程就是,在调用init()初始化的时候,先判断有没有版本更新。补丁文件与应用版本一致那么就会遍历补丁文件夹下的所有文件并封装成Patch类同时加入mPatchs集合中。那么我来看下Patch类主要封装了些神马:
public class Patch implements Comparable<Patch> {
private static final String ENTRY_NAME = "META-INF/PATCH.MF";
private static final String CLASSES = "-Classes";
private static final String PATCH_CLASSES = "Patch-Classes";
private static final String CREATED_TIME = "Created-Time";
private static final String PATCH_NAME = "Patch-Name";
/**
* patch file
*/
private final File mFile;
/**
* name
*/
private String mName;
/**
* create time
*/
private Date mTime;
/**
* classes of patch key: 补丁文件名 value:修改了那些类,这些类的信息
*/
private Map<String, List<String>> mClassesMap;
public Patch(File file) throws IOException {
mFile = file;
init();
}
@SuppressWarnings("deprecation")
private void init() throws IOException {
JarFile jarFile = null;
InputStream inputStream = null;
try {
//将补丁文件封装成JarFile
jarFile = new JarFile(mFile);
JarEntry entry = jarFile.getJarEntry(ENTRY_NAME);
inputStream = jarFile.getInputStream(entry);
Manifest manifest = new Manifest(inputStream);
Attributes main = manifest.getMainAttributes();
//获取补丁文件的name
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(","));
//判断传入的文件格式是否是我们Andfix能够处理的格式
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();
}
}
}
...
}
Patch类中的核心代码就init()方法。将我们的补丁文件封装成jarFile格式,然后去解析。得到补丁文件中修改了BUG的类的信息。并将其放到mClassesMap集合中并对外提供方法方便其他类调用。
我们对第二个api:PatchManager#init()方法分析完成后,下面我们在对第三个API分析下:PatchManager#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);
}
}
}
可以看到这个方法的注解 加载补丁文件,在我们的Application启动的时候,这个是为什么我们在初始化的时候就调用loadPatch()方法的原因。可以看到这个就是讲我们前面在AndFix指定的目录下得到的patch文件集合进行遍历并调用mAndFixManager.fix()方法。看来这个方法才是真正修复BUG的。
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;
}
}
//根据补丁文件创建DexFile
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
}
//利用对DexFile的遍历并找到我们要修复的class文件
clazz = dexFile.loadClass(entry, patchClassLoader);
if (clazz != null) {
//调用这个方法区修复
fixClass(clazz, classLoader);
}
}
} catch (IOException e) {
Log.e(TAG, "pacth", e);
}
}
/**
* fix class
*
* @param clazz
* class
*/
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);
}
}
}
/**
* replace method
*
* @param classLoader classloader
* @param clz class
* @param meth name of target method
* @param method source method
*/
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类中的这方法
private static native void replaceMethod(Method dest, Method src);
这个流程就是就是先找到补丁文件中要修复的类,找到类后再找到这么类中要修复的方法。如何判断哪些方法是需要修复的呢?就是通过注解。最后将这个带注解的类利用类加载去加载,最后利用native层去实现替换。由于本人能力有限,native就不去分析了。那么这个注解和这个结果到底是怎么样的呢?能不能直观的去看见呢?那么我们就从这个补丁文件入手,其实这个补丁文件核心如下:
其实补丁文件的核心就是这个dex文件
META-INF下为:
可以看到这与在Patch类中定义的格式是一样的。所以Patch是对补丁文件的包装成类的。
上面我们可以看到AndFix是通过注解来获取要被替换的方法,大家看AndFix的集成文档可以看到这段:How to use?Prepare two android packages, one is the online package, the other one is the package after you fix bugs by coding.Generate .apatch file by providing the two package。没错,就是在生成补丁文件的时候把发生改变的类增加了一个CF后缀,然后把对应的方法动态加上了注解,最后丢到了补丁中的dex文件中,我们反编译一下这个dex文件,看看AndFix帮我们生成的类:
(如何反编译笔者也不是很理解,所以下面的这张图是利用其它优秀文章的图片。但是原理是一样的)
可以看到,AndFix自动帮我们加上了一个methodReplace的注解,注解里的内容就是要被替换的原类中的类名和方法。最后我们看一下native层真正做的事情。到这里也就分析结束了。
结语
这篇文章虽然对使用中的太多坑没有过多的讲解。不过对于完全没有接触过的小伙伴应该还是很有帮助的吧。从使用到原理我们都有了一定的认识。由于本人接触也没多久,这篇文章主要是记录自己的学习与帮助没有接触过的小伙伴应。如果更深入的理解还需要对.dex .class文件以及虚拟机和DVM都要有一定的理解(我也不懂!哈哈)。不过孰能生巧,在熟练使用后在去探究更深层次,会更容易理解。如果想简单了解.dex .class文件以及虚拟机和DVM请参考我的另外两个笔记。下篇我们讲讲最难的Tinker的使用与分析。下篇见
如果你是大神或是对热修复已经很了解了:
推荐文章 黑科技热修复的Java层实现 也可以用java层实现热修复
本人是个接触 Android 不就的菜鸟。很多热修复也没有研究的很透彻。如果有错误希望大家指出,不胜感激。