Sophix—阿里终极热修复方案
不过阿里作为大厂咋可能没有个自己的热更新框架呢,所以阿里爸爸最近还是做了一个新的热更新框架SopHix
巴巴再次证明我是最强的,谁都没我厉害!!!因为我啥都支持,而且没缺点。。简直就是无懈可击!
那么我们就来项目集成下看看具体的使用效果吧! 先去创建个应用:
获取AppId:24582808-1,和AppSecret:da283640306b464ff68ce3b13e036a6e 以及RSA密钥**。三个参数配置在application节点下面:
<meta-data
android:name="com.taobao.android.hotfix.IDSECRET"
android:value="24582808-1" />
<meta-data
android:name="com.taobao.android.hotfix.APPSECRET"
android:value="da283640306b464ff68ce3b13e036a6e" />
<meta-data
android:name="com.taobao.android.hotfix.RSASECRET"
android:value="MIIEvAIBA**********" />
添加maven仓库地址:
repositories {
maven {
url "http://maven.aliyun.com/nexus/content/repositories/releases"
}
}
添加gradle坐标版本依赖:
compile 'com.aliyun.ams:alicloud-android-hotfix:3.1.0'
我们测试一个简单一点的例子:
项目结构也很简单
—-方法出现错误的Sophix热更新实现—-
MainActivity:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
((TextView)findViewById(R.id.tv)).setText(String.valueOf(BuildConfig.VERSION_NAME));
findViewById(R.id.btn_click).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent;
intent = new Intent(MainActivity.this,SecondActivity.class);
startActivity(intent);
}
});
}
}
其实就是有一个文本框显示当前版本,还有一个按钮用来跳转到SecondActivity
而SecondActivity的内容:
public class SecondActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
String s = null;
findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(SecondActivity.this, "弹出框内容弹出错啦!", Toast.LENGTH_SHORT).show();
}
});
}
}
也很简单,只有一个按钮,按钮点击之后弹出一个Toast显示“弹出框内容弹出错啦!”
就这样,我们的一个上线app完成了(粗糙是粗糙了点),下面来看下效果吧(请谅解我第一次录屏的渣渣技术,以后会做的越来越好)
然后我们的用户开始用了,发现一个bug!“弹出框弹出的内容是错误的!”,用户可不管别的,马上给我改好啊!此时的开发er估计心头千万头草泥马在奔腾了,求神拜佛上线不要出问题,刚上线就出问题了,“where is my 测试er!!!”不说了,赶紧修吧,最暴力的方法就是SecondActivity的Toast中弹出“弹出框内容弹正常啦!”一句代码搞定!bingo!
如果没有热更新,可能就要搞个临时版本或者甚至发布一个新版本,但是现在我们有了Sophix,就不需要这么麻烦了。
首先我们去下载补丁打包工具(不得不说,做的确实比较丑。。。)
旧包:<必填> 选择基线包路径(有问题的APK)。
新包:<必填> 选择新包路径(修复过该问题APK)。
日志:打开日志输出窗口。
高级:展开高级选项
设置:配置其他信息。
GO!:开始生成补丁。
所以首先我们把旧包和新包添加上之后,配置好之后看看会发生什么吧!
强制冷启动是补丁打完后重启才生效。
时间看情况吧,因为项目本身内容比较少,所以生成补丁的速度比较快,等一下就好了。项目比较大的话估计需要等的时间长一点
我们来看看到底生成了什么?打开补丁生成目录
这个就是我们生成的补丁文件了,下一步补丁如何使用?
我们打开阿里的管理控制台,将补丁上传到控制台,就可以发布了.
这里有个坑,我用自己的中兴手机发现在使用补丁调试工具的时候一直获取包名错误,然后就借了别人的华为手机测试然后就可以了。最后我是在模拟器上完成录制的。
我们首先下载调试工具来看看效果吧,首先连接应用(坑就在这里,有的手机可能会提示包名错误,但是其实是没有错的,虽然官网给出了解决方案,可依旧没有解决,不得已只能用模拟器了)
然后有两种方式可以加载补丁包,一种是扫二维码,还有一种是加载本地补丁jar包,模拟器上实在不好操作啊!!!最后我屈服了,借了同学的手机扫二维码加载补丁包了。。。然后就会有log提示
从图中的log提示我们可以看出首先下载了补丁包,然后打补丁完成,要求我们重启APP,那我们就重启呗,看到的当然就应该是补丁打好的1.1版本和Toast弹出正常啦!!
当然了,目前我们还是在调试工具上加载的补丁包,我们接下来将补丁包发布后就可以不用调试工具,直接打开app就可以实现打补丁了,这样就完成了bug的修复!
其实这么看起来确实是非常简单就实现了热修复,主要我们的生成补丁工作都是交给阿里提供的工具实现了,其实我们也能看得出来,Sophix和前面介绍的AndFix很像,不同的地方是补丁文件已经给出工具可以一键生成了,而且支持的东西更多了。其他比如so库和library以及资源文件的更新大家可以查看官方文档了解。
其实Sophix主要是在阿里百川HotFix的版本上的一个更新,而HotFix又是什么呢?
所以阿里爸爸一直在进步着呢,知道技术存在问题就要去解决问题,这不,从Dexposed–>AndFix–>HotFix–>Sophix,技术是越来越成熟了。
下面介绍另外一个大厂的几种热更新方案
Qzone超级补丁 & 微信Tinker 企鹅大厂的热更新方案
巴巴家的热更新技术一直在发展,作为互联网巨头的腾讯怎甘落后,所以也是穷追不舍的干起来!
Qzone超级补丁
因为超级补丁技术是基于DEX分包方案,使用了多DEX加载的原理,所以我先给大家简单讲下DEX加载的一些东西:
Android程序要运行需要先编译打包成dex,之后才可以被Android虚拟机解析运行。因此我们如果想要即时修补bug就要让修复的代码被Android虚拟机识别,如何才能让虚拟机认识我们修改过的代码呢,也就是我们需要把修改过的代码打包成单独的dex。
然后接下来要做的就是如何让虚拟机加载我们修改过后的dex jar包中的类呢?
我们需要了解的是类加载器是如何加载类的。
在Android中 有 2种类加载器:
PathClassLoader和DexClassLoader,源码如下:
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}
}
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String libraryPath,
ClassLoader parent) {
super(dexPath, null, libraryPath, parent);
}
}
这两者的区别是:
DexClassLoader:可以加载jar/apk/dex,可以从SD卡中加载未安装的apk;
PathClassLoader:要传入系统中apk的存放Path,所以只能加载已经安装的apk文件。
两个类都只是简单的对BaseDexClassLoader做了一下封装,具体的实现还是在父类里。不过这里也可以看出,PathClassLoader的optimizedDirectory只能是null,进去BaseDexClassLoader看看这个参数是干什么的
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
this.originalPath = dexPath;
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
这里创建了一个DexPathList实例:
public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory) {
……
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory);
}
private static Element[] makeDexElements(ArrayList<File> files,
File optimizedDirectory) {
ArrayList<Element> elements = new ArrayList<Element>();
for (File file : files) {
ZipFile zip = null;
DexFile dex = null;
String name = file.getName();
if (name.endsWith(DEX_SUFFIX)) {
dex = loadDexFile(file, optimizedDirectory);
} else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
|| name.endsWith(ZIP_SUFFIX)) {
zip = new ZipFile(file);
}
……
if ((zip != null) || (dex != null)) {
elements.add(new Element(file, zip, dex));
}
}
return elements.toArray(new Element[elements.size()]);
}
private static DexFile loadDexFile(File file, File optimizedDirectory)
throws IOException {
if (optimizedDirectory == null) {
return new DexFile(file);
} else {
String optimizedPath = optimizedPathFor(file, optimizedDirectory);
return DexFile.loadDex(file.getPath(), optimizedPath, 0);
}
}
//**
//* Converts a dex/jar file path and an output directory to an
//* output file path for an associated optimized dex file.
//
private static String optimizedPathFor(File path,
File optimizedDirectory) {
String fileName = path.getName();
if (!fileName.endsWith(DEX_SUFFIX)) {
int lastDot = fileName.lastIndexOf(".");
if (lastDot < 0) {
fileName += DEX_SUFFIX;
} else {
StringBuilder sb = new StringBuilder(lastDot + 4);
sb.append(fileName, 0, lastDot);
sb.append(DEX_SUFFIX);
fileName = sb.toString();
}
}
File result = new File(optimizedDirectory, fileName);
return result.getPath();
}
我们不需要弄的特别明白,只要知道这里optimizedDirectory是用来缓存我们需要加载的dex文件的,并创建一个DexFile对象,如果它为null,那么会直接使用dex文件原有的路径来创建DexFile对象。
optimizedDirectory必须是一个内部存储路径,无论哪种动态加载,加载的可执行文件一定要存放在内部存储。DexClassLoader可以指定自己的optimizedDirectory,所以它可以加载外部的dex,因为这个dex会被复制到内部路径的optimizedDirectory;而PathClassLoader没有optimizedDirectory,所以它只能加载内部的dex,这些大都是存在系统中已经安装过的apk里面的。
上面还只是创建了类加载器的实例,其中创建了一个DexFile实例,用来保存dex文件,我们猜想这个实例就是用来加载类的。
Android中,ClassLoader用loadClass方法来加载我们需要的类
public Class<?> loadClass(String className) throws ClassNotFoundException {
return loadClass(className, false);
}
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
Class<?> clazz = findLoadedClass(className);
if (clazz == null) {
ClassNotFoundException suppressed = null;
try {
clazz = parent.loadClass(className, false);
} catch (ClassNotFoundException e) {
suppressed = e;
}
if (clazz == null) {
try {
clazz = findClass(className);
} catch (ClassNotFoundException e) {
e.addSuppressed(suppressed);
throw e;
}
}
}
return clazz;
}
loadClass方法调用了findClass方法,而BaseDexClassLoader重载了这个方法,到BaseDexClassLoader看看
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = pathList.findClass(name);
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
结果还是调用了DexPathList的findClass
public Class findClass(String name) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext);
if (clazz != null) {
return clazz;
}
}
}
return null;
}
这里遍历了之前所有的DexFile实例,其实也就是遍历了所有加载过的dex文件,再调用loadClassBinaryName方法一个个尝试能不能加载想要的类。
public Class loadClassBinaryName(String name, ClassLoader loader) {
return defineClass(name, loader, mCookie);
}
private native static Class defineClass(String name, ClassLoader loader, int cookie);
上面的类加载中DexPathList的findClass,一个classloader可以包含多个dex,其中这个集合中的对象就是所有的dex文件,然后调用从头开始遍历所有的dex 如果在dex中找到所需要的类,那么就直接返回,也就是说如果存在多个dex 在前一个dex中找到了需要找到的类,也就不会继续查找其他dex中有没有这个类了。
而dex.loadClassBinaryName(name, definingContext)在这个dex中查找相应名字的类,之后 defineClass 把字节码交给虚拟机就完成了类的加载。
也许你看到这里会比较晕,没关系,上面的你可以当做没看到,直接看下面这句话吧:如果要加载一个类,就会调用 ClassLoader 的 findClass 方法,在dex中查找这个类,找到后加载到内存
so,我们的关键人物就是在 findClass 的时候让类加载找到我们修复过后的类,而不是未修复的类。
例如,比如说要修复的类名为 BugClass 我们要做的就是将这个类修改为正确的后,打包成 dex 的 jar,然后想办法让类加载去查找我们打包的jar中的 BugClass 类 而不是先前的 BugClass 类,这样,加载类的时候使用的就是我们修复过后的代码,而忽略掉原本的有问题的代码。问题又转变到了如何让我们自己打包的dex文件放到原本的 dex 文件之前,也就是把我们打包的 dex 放到 dexElements 集合的靠前的位置
这样算是把超级补丁的原理讲了一遍,应该有一个大概的认识了,而超级补丁所做的就是让类加载器只找到我们修复完成的类!
通俗的说 也就是我们要改变的是 dexElements 中的内容,在其中添加一个 dex 而且放在靠前的位置,而 dexElements 是 PathClassLoader类中的一个成员变量。
因为Qzone超级补丁并没有开源,在这里只是给大家讲了类加载机制来说下实现原理,具体的实现过程应该是这样子的(图可能是最直观的):
通过反射的方式获取应用的PathdexClassloader—>PathList—>DexElements,再获取补丁dex的DexClassloader—>PathList—>DexElements,然后通过combinArray的方法将2个DexElements合并,补丁的DexElements放在前面,然后使用合并后的DexElements作为PathdexClassloader中的DexElements,这样在加载的时候就可以优先加载到补丁dex,从中可以加载到我们的补丁类。与我们加Multidex的做法相似,能基本保证稳定性与兼容性
优势:
没有合成整包(和微信Tinker比起来),产物比较小,比较灵活
可以实现类替换,兼容性高。(某些三星手机不起作用)
不足:
不支持即时生效,必须通过重启才能生效。
为了实现修复这个过程,必须在应用中加入两个 dex ! dalvikhack.dex 中只有一个类,对性能影响不大,但是对于patch.dex来说,修复的类到了一定数量,就需要花不少的时间加载。
在ART模式下,如果类修改了结构,就会出现内存错乱的问题。为了解决这个问题,就必须把所有相关的调用类、父类子类等等全部加载到 patch.dex 中,导致补丁包异常的大,进一步增加应用启动加载的时候,耗时更加严重。