Android热更新技术的研究与实现(一)
本文因为篇幅较长,可能会有错别字出现,望见谅。
第一部分重点是将当下热门的热更新方案实现之后再研究,第二部分则是自己动手实现一个自己的热更新框架。
这个词出现的时间已经很久了,感觉现在要找工作才来看是晚了不少,但是好东西什么时候学习都不晚的。
今天看到一句话,和大家分享下,人一生有三样东西是别人抢不走的:
吃进胃里的食物
藏在心中的梦想
读进大脑里的书
所以趁着我们的时光正好,多学点东西肯定是赚翻的!!(当然多吃点也没错,不配点图感觉好突兀)
言归正传,首先我们要了解与热更新相关的一些概念吧!
组件化—-就是将一个app分成多个模块,每个模块都是一个组件(Module),开发的过程中我们可以让这些组件相互依赖或者单独调试部分组件等,但是最终发布的时候是将这些组件合并统一成一个apk,这就是组件化开发。我之前的开发方式基本上都是这一种。具体可以参考Android组件化方案
插件化–将整个app拆分成很多模块,这些模块包括一个宿主和多个插件,每个模块都是一个apk(组件化的每个模块是个lib),最终打包的时候将宿主apk和插件apk分开或者联合打包。开发中,往往会堆积很多的需求进项目,超过 65535 后,插件化就是一个解决方案。
放张图帮大家理解:
热更新 – 更新的类或者插件粒度较小的时候,我们会称之为热修复,一般用于修复bug!!比如更新一个bug方法或者紧急修改lib包,甚至一个类等。2016 Google 的 Android Studio 推出了Instant Run 功能 同时提出了3个名词;
“热部署” – 方法内的简单修改,无需重启app和Activity。 “暖部署” – app无需重启,但是activity需要重启,比如资源的修改。 “冷部署” – app需要重启,比如继承关系的改变或方法的签名变化等。
所以站在app开发者角度的“热”是指在不发版的情况来实现更新,而Google提出的“热”是指值无需重新启动。 同时在开发插件化的时候也有两种情景,一种是插件与宿主apk没有交互,只是在用户使用到的时候进行一次吊起,还有一种是与宿主有很多的交互。
增量更新,与热更新区别最大的一个,其实这个大家应该很好理解,安卓上的有些很大的应用,特别是游戏,大则好几个G的多如牛毛,但是每次更新的时候却不是要去下载最新版,而只是下载一个几十兆的增量包就可以完成更新了,而这所使用的技术就是增量更新了。实现的过程大概是这个样子的:我们手机上安装着某个大应用,下载增量包之后,手机上的apk和增量包合并形成新的包,然后会再次安装,这个安装过程可能是可见的,或者应用本身有足够的权限直接在后台安装完成。
今天碰到Android Studio的更新,这应该就是增量更新啦!补丁包只有51M,如果下载新版本有1G多。
有一些这样的情况, 当一个App发布之后,突然发现了一个严重bug需要进行紧急修复,这时候公司各方就会忙得焦头烂额:重新打包App、测试、向各个应用市场和渠道换包、提示用户升级、用户下载、覆盖安装。有时候仅仅是为了修改了一行代码,也要付出巨大的成本进行换包和重新发布。老是发布版本用户会疯掉的!!!(好吧 猿猿们也会疯掉。。)
这时候就提出一个问题:有没有办法以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装?
这种需要替换运行时新的类和资源文件的加载,就可以认为是热操作了。而在热更新出现之前,通过反射注解、反射调用和反射注入等方式已经可以实现类的动态加载了。而热更新框架的出现就是为了解决这样一个问题的。
从某种意义上来说,热更新就是要做一件事,替换。当替换的东西属于大块内容的时候,就是模块化了,当你去替换方法的时候,叫热更新,当你替换类的时候,加热插件,而且重某种意义上讲,所有的热更新方案,都是一种热插件,因为热更新方案就是在app之外去干这个事。就这么简单的理解。无论是替换一个类,还是一个方法,都是在干替换这件事请。。这里的替换,也算是几种hook操作,无论在什么代码等级上,都是一种侵入性的操作。
所以总结一句话简单理解热更新 HotFix 就是改变app运行行为的技术!(或者说就是对已发布app进行bug修复的技术) 此时的猿猿们顿时眼前一亮,用户也笑了。。
好的,现在我们已经知道热更新为何物了,那么我们就先看看热更新都有哪些成熟的方案在使用了。
在我们写好的安卓项目中,有很多逻辑代码,在预编译和编译阶段互相连在一起,各种业务逻辑的链接和lib的链接,各种变量和运算符的的编译优化;
热更新方案发展至今,有很多团队开发过不同的解决方案,包括Dexposed、AndFix,(HotFix)Sophix,Qzone超级补丁的类Nuwa方式,微信的Tinker, 大众点评的nuwa、百度金融的rocooFix, 饿了么的amigo以及美团的robust、腾讯的Bugly热更新。
苹果公司现在已经禁止了热更新,不过估计也组织不了开发者们的热情吧!
我先讲几种方案具体如何使用,说下原理,最后再讲如何实现一个自己的热更新方案!
“Dexposed”是大厂阿里以前的一个开源热更新项目,基于Xposed“Xposed”的AOP框架,方法级粒度,可以进行AOP编程、插桩、热补丁、SDK hook等功能。
Xposeed大家如果不熟悉的话可以在网上搜一下” Xposed源码剖析——概述”,我以前用Xposed做过一些小东西(其实就是获取root权限后hook修改一些手机数据,比如支付宝步数,qq微信步数等,当然了,余额啥的是改不了的),在这里就不献丑了,毕竟重点也不是这个。我们可以看出Xposed有一个缺陷就是需要root,而Dexposed就是一个不需要root权限的hook框架。目前阿里系主流app例如手机淘宝,支付宝,天猫都使用了Dexposed支持在线热更新。
Dexposed中的AOP原理来自于Xposed。在Dalvik虚拟机下,主要是通过改变一个方法对象方法在Dalvik虚拟机中的定 义来实现,具体做法就是将该方法的类型改变为native并且将这个方法的实现链接到一个通用的Native Dispatch方法上。这个 Dispatch方法通过JNI回调到Java端的一个统一处理方法,最后在统一处理方法中调用before, after函数来实现AOP。在Art虚拟机上目前也是是通过改变一个 ArtMethod的入口函数来实现。
android4.4之后的版本都用Art取代了Dalvik,所以要hook Android4.4以后的版本就必须去适配Art虚拟机的机制。目前官方表示,为了适配Art的dexposed_l只是beta版,所以最好不要在正式的线上产品中使用它。
Dexposed已经被抛弃,原因很明显,4.4以后不支持了,我们就不细细分析这个方案了,感兴趣的朋友可以通过“这里”了解。简单讲下它的实现方式:
引入一个名为patchloader的jar包,这个函数库实现了一个热更新框架,宿主apk(可能含有bug的上线版本)在发布时会将这个jar包一起打包进apk中
补丁apk(已修复线上版本bug的版本)只是在编译时需要这个jar包,但打包成apk时不包含这个jar包,以免补丁apk集成到宿主apk中时发生冲突
补丁apk将会以provided的形式依赖dexposedbridge.jar和patchloader.jar
通过在线下载的方式从服务器下载补丁apk,补丁apk集成到宿主apk中,使用补丁apk中的函数替换原来的函数,从而实现在线修复bug的功能。
AndFix是一个Android App的在线热补丁框架。使用此框架,我们能够在不重复发版的情况下,在线修改App中的Bug。AndFix就是 “Android Hot-Fix”的缩写。支持Android 2.3到6.0版本,并且支持arm与X86系统架构的设备。完美支持Dalvik与ART的Runtime。AndFix 的补丁文件是以 .apatch 结尾的文件。它从你的服务器分发到你的客户端来修复你App的bug 。
AndFix的实现方式(画的丑勿怪⊙﹏⊙):
首先添加依赖
compile ‘com.alipay.euler:andfix:0.3.1@aar’
然后在Application.onCreate() 中添加以下代码
patchManager = new PatchManager(context);
patchManager.init(appversion);//current version
patchManager.loadPatch();
可以用这句话获取appversion,每次appversion变更都会导致所有补丁被删除,如果appversion没有改变,则会加载已经保存的所有补丁。
String appversion= getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
然后在需要的地方调用PatchManager的addPatch方法加载新补丁,比如可以在下载补丁文件之后调用。
之后就是打补丁的过程了,首先生成一个apk文件,然后更改代码,在修复bug后生成另一个apk。通过官方提供的工具apkpatch生成一个.apatch格式的补丁文件,需要提供原apk,修复后的apk,以及一个签名文件。
通过网络传输或者adb push的方式将apatch文件传到手机上,然后运行到addPatch的时候就会加载补丁。
AndFix更新的原理:
首先通过虚拟机的JarFile加载补丁文件,然后读取PATCH.MF文件得到补丁类的名称
使用DexFile读取patch文件中的dex文件,得到后根据注解来获取补丁方法,然后根据注解中得到雷鸣和方法名,使用classLoader获取到Class,然后根据反射得到bug方法。
jni层使用C++的指针替换bug方法对象的属性来修复bug。
具体的代码主要都是我们在Application中初始化的PatchManager中。
public PatchManager(Context context) {
mContext = context;
mAndFixManager = new AndFixManager(mContext);//初始化AndFixManager
mPatchDir = new File(mContext.getFilesDir(), DIR);//初始化存放patch补丁文件的文件夹
mPatchs = new ConcurrentSkipListSet();//初始化存在Patch类的集合,此类适合大并发
mLoaders = new ConcurrentHashMap();//初始化存放类对应的类加载器集合
}
其中mAndFixManager = new AndFixManager(mContext);//初始化AndFixManager:
public AndFixManager(Context context) {
mContext = context;
mSupport = Compat.isSupport();//判断Android机型是否适支持AndFix
if (mSupport) {
mSecurityChecker = new SecurityChecker(mContext);//初始化签名判断类
mOptDir = new File(mContext.getFilesDir(), DIR);//初始化patch文件存放的文件夹
if (!mOptDir.exists() && !mOptDir.mkdirs()) {// make directory fail
mSupport = false;
Log.e(TAG, "opt dir create error.");
} else if (!mOptDir.isDirectory()) {// not directory
mOptDir.delete();//如果不是文件目录就删除
mSupport = false;
}
}
}
。。。。。。。。。。。。
然后是对版本的初始化mPatchManager.init(appversion),init(String appVersion)代码如下:
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);//存储关于patch文件的信息
//根据你传入的版本号和之前的对比,做不同的处理
String ver = sp.getString(SP_VERSION, null);
if (ver == null || !ver.equalsIgnoreCase(appVersion)) {
cleanPatch();//删除本地patch文件
sp.edit().putString(SP_VERSION, appVersion).commit();//并把传入的版本号保存
} else {
initPatchs();//初始化patch列表,把本地的patch文件加载到内存
}
}
/*************省略初始化、删除、加载具体方法实现*****************/
init初始化主要是对patch补丁文件信息进行保存或者删除以及加载。
那么patch补丁文件是如何加载的呢?其实patch补丁文件本质上是一个jar包,使用JarFile来读取即可:
public Patch(File file) throws IOException {
mFile = file;
init();
}
@SuppressWarnings("deprecation")
private void init() throws IOException {
JarFile jarFile = null;
InputStream inputStream = null;
try {
jarFile = new JarFile(mFile);//使用JarFile读取Patch文件
JarEntry entry = jarFile.getJarEntry(ENTRY_NAME);//获取META-INF/PATCH.MF文件
inputStream = jarFile.getInputStream(entry);
Manifest manifest = new Manifest(inputStream);
Attributes main = manifest.getMainAttributes();
mName = main.getValue(PATCH_NAME);//获取PATCH.MF属性Patch-Name
mTime = new Date(main.getValue(CREATED_TIME));//获取PATCH.MF属性Created-Time
mClassesMap = new HashMap>();
Attributes.Name attrName;
String name;
List strings;
for (Iterator> it = main.keySet().iterator(); it.hasNext();) {
attrName = (Attributes.Name) it.next();
name = attrName.toString();
//判断name的后缀是否是-Classes,并把name对应的值加入到集合中,对应的值就是class类名的列表
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();
}
}
}
然后就是最重要的patchManager.loadPatch():
public void loadPatch() {
mLoaders.put("*", mContext.getClassLoader());// wildcard
Set patchNames;
List classes;
for (Patch patch : mPatchs) {
patchNames = patch.getPatchNames();
for (String patchName : patchNames) {
classes = patch.getClasses(patchName);//获取patch对应的class类的集合List
mAndFixManager.fix(patch.getFile(), mContext.getClassLoader(),
classes);//修复bug方法
}
}
}
循环获取补丁对应的class类来修复bug方法,mAndFixManager.fix(patch.getFile(), mContext.getClassLoader(),classes):
public synchronized void fix(File file, ClassLoader classLoader,
List classes) {
if (!mSupport) {
return;
}
//判断patch文件的签名
if (!mSecurityChecker.verifyApk(file)) {// security check fail
return;
}
/******省略部分代码********/
//加载patch文件中的dex
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 {//重写ClasLoader的findClass方法
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 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);//获取有bug的类文件
if (clazz != null) {
fixClass(clazz, classLoader);// next code
}
}
} catch (IOException e) {
Log.e(TAG, "pacth", e);
}
}
private void fixClass(Class> clazz, ClassLoader classLoader) {
Method[] methods = clazz.getDeclaredMethods();
MethodReplace methodReplace;
String clz;
String meth;
for (Method method : methods) {
//获取此方法的注解,因为有bug的方法在生成的patch的类中的方法都是有注解的
methodReplace = method.getAnnotation(MethodReplace.class);
if (methodReplace == null)
continue;
clz = methodReplace.clazz();//获取注解中clazz的值
meth = methodReplace.method();//获取注解中method的值
if (!isEmpty(clz) && !isEmpty(meth)) {
replaceMethod(classLoader, clz, meth, method);//next code
}
}
}
private void replaceMethod(ClassLoader classLoader, String clz,
String meth, Method method) {
try {
String key = clz + "@" + classLoader.toString();
Class> clazz = mFixedClass.get(key);//判断此类是否被fix
if (clazz == null) {// class not load
Class> clzz = classLoader.loadClass(clz);
// initialize target class
clazz = AndFix.initTargetClass(clzz);//初始化class
}
if (clazz != null) {// initialize class OK
mFixedClass.put(key, clazz);
Method src = clazz.getDeclaredMethod(meth,
method.getParameterTypes());//根据反射获取到有bug的类的方法(有bug的apk)
AndFix.addReplaceMethod(src, method);//src是有bug的方法,method是补丁方法
}
} catch (Exception e) {
Log.e(TAG, "replaceMethod", e);
}
}
public static void addReplaceMethod(Method src, Method dest) {
try {
replaceMethod(src, dest);//调用了native方法
initFields(dest.getDeclaringClass());
} catch (Throwable e) {
Log.e(TAG, "addReplaceMethod", e);
}
}
private static native void replaceMethod(Method dest, Method src);
从上面的bug修复源码可以看出,就是在找补丁包中有@MethodReplace注解的方法,然后反射获取原apk中方法的位置,最后进行替换。
而最后调用的replaceMethod(Method dest,Method src)则是native方法,源码中有两个replaceMethod:
extern void dalvik_replaceMethod(JNIEnv* env, jobject src, jobject dest);//Dalvik
extern void art_replaceMethod(JNIEnv* env, jobject src, jobject dest);//Art
从源码的注释也能看出来,因为安卓4.4版本之后使用的不再是Dalvik虚拟机,而是Art虚拟机,所以需要对不同的手机系统做不同的处理。
首先看Dalvik替换方法的实现:
extern void __attribute__ ((visibility ("hidden"))) dalvik_replaceMethod(
JNIEnv* env, jobject src, jobject dest) {
jobject clazz = env->CallObjectMethod(dest, jClassMethod);
ClassObject* clz = (ClassObject*) dvmDecodeIndirectRef_fnPtr(
dvmThreadSelf_fnPtr(), clazz);
clz->status = CLASS_INITIALIZED;
Method* meth = (Method*) env->FromReflectedMethod(src);
Method* target = (Method*) env->FromReflectedMethod(dest);
LOGD("dalvikMethod: %s", meth->name);
meth->jniArgInfo = 0x80000000;
meth->accessFlags |= ACC_NATIVE;//把Method的属性设置成Native方法
int argsSize = dvmComputeMethodArgsSize_fnPtr(meth);
if (!dvmIsStaticMethod(meth))
argsSize++;
meth->registersSize = meth->insSize = argsSize;
meth->insns = (void*) target;
meth->nativeFunc = dalvik_dispatcher;//把方法的实现替换成native方法
}
Art替换方法的实现:
//不同的art系统版本不同处理也不同
extern void __attribute__ ((visibility ("hidden"))) art_replaceMethod(
JNIEnv* env, jobject src, jobject dest) {
if (apilevel > 22) {
replace_6_0(env, src, dest);
} else if (apilevel > 21) {
replace_5_1(env, src, dest);
} else {
replace_5_0(env, src, dest);
}
}
//以5.0为例:
void replace_5_0(JNIEnv* env, jobject src, jobject dest) {
art::mirror::ArtMethod* smeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(src);
art::mirror::ArtMethod* dmeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(dest);
dmeth->declaring_class_->class_loader_ =
smeth->declaring_class_->class_loader_; //for plugin classloader
dmeth->declaring_class_->clinit_thread_id_ =
smeth->declaring_class_->clinit_thread_id_;
dmeth->declaring_class_->status_ = (void *)((int)smeth->declaring_class_->status_-1);
//把一些参数的指针给补丁方法
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->access_flags_ = dmeth->access_flags_;
smeth->frame_size_in_bytes_ = dmeth->frame_size_in_bytes_;
smeth->dex_cache_initialized_static_storage_ =
dmeth->dex_cache_initialized_static_storage_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->vmap_table_ = dmeth->vmap_table_;
smeth->core_spill_mask_ = dmeth->core_spill_mask_;
smeth->fp_spill_mask_ = dmeth->fp_spill_mask_;
smeth->mapping_table_ = dmeth->mapping_table_;
smeth->code_item_offset_ = dmeth->code_item_offset_;
smeth->entry_point_from_compiled_code_ =
dmeth->entry_point_from_compiled_code_;
smeth->entry_point_from_interpreter_ = dmeth->entry_point_from_interpreter_;
smeth->native_method_ = dmeth->native_method_;//把补丁方法替换掉
smeth->method_index_ = dmeth->method_index_;
smeth->method_dex_index_ = dmeth->method_dex_index_;
LOGD("replace_5_0: %d , %d", smeth->entry_point_from_compiled_code_,
dmeth->entry_point_from_compiled_code_);
}
其实这个替换过程可以看做三步完成
打开链接库得到操作句柄,获取native层的内部函数,得到ClassObject对象
修改访问权限的属性为public
得到新旧方法的指针,新方法指向目标方法,实现方法的替换。
如果我们想知道补丁包中到底替换了哪些方法,可以直接方便易patch文件,然后看到的所有含有@ReplaceMethod注解的方法基本上就都是需要替换的方法了。
最近我在学习C++,顿时感觉到还是这种可以控制底层的语言是多么强大,不过Java可以调用C++,也就没什么可吐槽的了!
好的,现在AndFix我们分析了一遍它的实现过程和原理,其优点是不需要重启即可应用补丁,遗憾的是它还是有不少缺陷的,这直接导致阿里再次抛弃了它,缺陷如下:
并不能支持所有的方法修复
不支持YunOS
无法添加新类和新的字段
需要使用加固前的apk制作补丁,但是补丁文件很容易被反编译,也就是修改过的类源码容易泄露。
使用加固平台可能会使热补丁功能失效(看到有人在360加固提了这个问题,自己还未验证)。