Android的新技术在不断更迭,各种bug修复也如火如荼,增量更新,插件化开发,热修复等等,数不胜数,这一节,就来盘点盘点热修复的来龙去脉
热修复说明
目前在热修复发面,国内众多公司都提出了解决方案,比较出名的例如阿里的Andfix,现在更新到到第三代,新名字叫做Sophix,腾讯的Tinker,饿了么的Amigo,美团的Robust等等,这里先做阿里和腾讯的例子,因为这两个热修复方案比较典型,一个是从底层C出发,一个是从Java层出发,接下来我们就来看看Tinker的方案是怎么实现热修复的
仿腾讯热修复
腾讯的热修复是基于Android加载class来实现热修复的,那么知道这个原理以后,我们也来自己做一个热修复工具,要知道怎么实现这个功能的,就需要先了解Android的类加载机制
实现原理
众所周知,其实Android的apk就是一个压缩包,在这个安装包里面放了资源文件(res),资源描述文件(rasc),类文件集合(dex),在这些文件里面,代码编译生成的字节码文件被打包到了dex中,那么Android在使用类的时候会去加载这个dex文件,而加载这个类则是通过PathClassLoader这个类去加载的,那么这个类里面做了些什么事情呢?
我们查看其源代码,由于这是一个系统级的类,Google在SDK中并没有将其提供出来,那么要查看这个类要么通过下载源代码,要么在线查看,这里推荐一个国内的地址,可以在线查看源代码:androidxref还有androidos
鉴于PathClassLoader的代码并不多,这里贴出源代码,在源代码里面有这样一句话JAR/ZIP/APK files, possibly containing a "classes.dex" file as well as arbitrary resources. Raw ".dex" files (not inside a zip file)
,也就是说其可以加载JAR,ZIP,APK格式的压缩包,但在里面必须有classes.dex文件,这里仅仅是两个构造方法,那在父类里面又做了什么呢?
package dalvik.system;
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}
查看源代码,我们得知其继承了BaseDexClassLoader,那么在这个父类里面又做了啥,这里就不细细展开来讲了,这里只关注我们需要的东西,我们注意到,DexPathList
这个类里面就是一系列的集合,最需要注意的就是private Element[] dexElements;
这个属性,这个属性,这里就是dex的集合,我们的类方法就是从这个获取到的,使用DexPathList
的findClass()
方法查找类,从而获取到类的,这就是Android类的加载过程,那么我们的想法就可以得以实现。也就是说通过反射得到PathClassLoader
这个类就可以调用方法去查找我们的类了,当然也可以调用BaseDexClassLoader
···
private final DexPathList pathList;
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class "" + name + "" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
···
上面得到的Class这个方法是怎么实现的呢,浏览DexPathList
源代码发现,是通过类的名字获取到类的
public Class findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
这里的关键地方在于DexFile的loadClassBinaryName()方法,那么这个方法是怎么查找类的,再继续查看DexFile的源代码,这里直接调到Native方法里面去了,为了一探究竟,我们再去Native方法一探究竟
public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
return defineClass(name, loader, mCookie, this, suppressed);
}
private static Class defineClass(String name, ClassLoader loader, Object cookie,
DexFile dexFile, List<Throwable> suppressed) {
Class result = null;
try {
result = defineClassNative(name, loader, cookie, dexFile);
} catch (NoClassDefFoundError e) {
if (suppressed != null) {
suppressed.add(e);
}
} catch (ClassNotFoundException e) {
if (suppressed != null) {
suppressed.add(e);
}
}
return result;
}
···
private static native Class defineClassNative(String name, ClassLoader loader, Object cookie, DexFile dexFile)
throws ClassNotFoundException, NoClassDefFoundError;
defineClassNative()
这个方法的实现位于../art/runtime/native/dalvik_system_DexFile.cc
,仔细观察,我们在这里的重心在于找到类是怎么被找到的,我们会发现在将jstring做了一系列转化以后,又调用了FindClassDef()
方法,在这个方法里面才真实的去查找类
static jclass DexFile_defineClassNative(JNIEnv* env,
jclass,
jstring javaName,
jobject javaLoader,
jobject cookie,
jobject dexFile) {
std::vector<const DexFile*> dex_files;
const OatFile* oat_file;
if (!ConvertJavaArrayToDexFiles(env, cookie, /*out*/ dex_files, /*out*/ oat_file)) {
VLOG(class_linker) << "Failed to find dex_file";
DCHECK(env->ExceptionCheck());
return nullptr;
}
ScopedUtfChars class_name(env, javaName);
if (class_name.c_str() == nullptr) {
VLOG(class_linker) << "Failed to find class_name";
return nullptr;
}
const std::string descriptor(DotToDescriptor(class_name.c_str()));
const size_t hash(ComputeModifiedUtf8Hash(descriptor.c_str()));
for (auto& dex_file : dex_files) {
const DexFile::ClassDef* dex_class_def = dex_file->FindClassDef(descriptor.c_str(), hash);
if (dex_class_def != nullptr) {
ScopedObjectAccess soa(env);
ClassLinker* class_linker = Runtime::Current()->GetClassLinker();
StackHandleScope<1> hs(soa.Self());
Handle<mirror::ClassLoader> class_loader(
hs.NewHandle(soa.Decode<mirror::ClassLoader*>(javaLoader)));
class_linker->RegisterDexFile(*dex_file, class_loader.Get());
mirror::Class* result = class_linker->DefineClass(soa.Self(),
descriptor.c_str(),
hash,
class_loader,
*dex_file,
*dex_class_def);
// Add the used dex file. This only required for the DexFile.loadClass API since normal
// class loaders already keep their dex files live.
class_linker->InsertDexFileInToClassLoader(soa.Decode<mirror::Object*>(dexFile),
class_loader.Get());
if (result != nullptr) {
VLOG(class_linker) << "DexFile_defineClassNative returning " << result
<< " for " << class_name.c_str();
return soa.AddLocalReference<jclass>(result);
}
}
}
VLOG(class_linker) << "Failed to find dex_class_def " << class_name.c_str();
return nullptr;
}
那么顺理成章的,我们应该去查找这个FindClassDef()是怎么实现的,在../art/runtime/dex_file.cc
终于找到了我们想要的答案,在这里,我们看到了在Native层,其处理就是通过遍历dex,查找到符合的类名就返回,这一点对于我们猜想的实现十分重要
const DexFile::ClassDef* DexFile::FindClassDef(const char* descriptor, size_t hash) const {
DCHECK_EQ(ComputeModifiedUtf8Hash(descriptor), hash);
if (LIKELY(lookup_table_ != nullptr)) {
const uint32_t class_def_idx = lookup_table_->Lookup(descriptor, hash);
return (class_def_idx != DexFile::kDexNoIndex) ? &GetClassDef(class_def_idx) : nullptr;
}
// Fast path for rate no class defs case.
const uint32_t num_class_defs = NumClassDefs();
if (num_class_defs == 0) {
return nullptr;
}
const TypeId* type_id = FindTypeId(descriptor);
if (type_id != nullptr) {
uint16_t type_idx = GetIndexForTypeId(*type_id);
for (size_t i = 0; i < num_class_defs; ++i) {
const ClassDef& class_def = GetClassDef(i);
if (class_def.class_idx_ == type_idx) {
return &class_def; //通过一系列计算,找到就返回
}
}
}
return nullptr;
}
那么我们在回来看看Java层的代码还有啥可以榨取的,通过对同级目录的观察,发现另外一类DexClassLoader
,这个类用途在于加载dex文件,这里是不是和PathClassLoader
类似,这就对了,这两者的区别就在于继承父类时候初始化的方式不同,PathClassLoader
没有传递optimizedDirectory
参数,也就是说其默认了路径,这个路径就是/data/data/{应用包名}
,而DexClassLoader
可以加载任意目录的dex文件,而PathClassLoader
只可以加载指定目录下的dex文件
package dalvik.system;
import java.io.File;
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
}
}
经过上述的分析,我们已经知道怎么Android是怎么实现类的加载的,简单来说就是先加载dex,然后遍历dex里面的类,有符合的类名就返回,不在继续向下查找。基于此,我们可以在这里搞点事情,既然他是从头到尾去遍历类名,那么我将有修复好的类搞到dex里面去,并且放在最前面,那是不是要使用有bug那个类的时候,先找到的是我搞上去的类,然后不再向下查找,那基于Java层的修复不就可以搞定了么,至此,Java层修复的原理已经介绍完毕,接下来便是去实现这个想法了
准备工作
要实现这个大胆的想法,我们先要有dex的加载器,选择上面提到的三者都可以,这里选择DexClassLoader
,因为这个可以从任何目录加载dex,在没有root权限的时候也很方便做实验,由于这里的初始化需要一个参数,那就选择没有过的PathClassLoader
好了,思路有了,也规划好了,那么就开始码代码吧,首先模拟出一个bug,这里就编写一个带有除零操作的类好了
首先我们是要用到一个工具,这个工具就是multidex
,这个工具的作用就是每个类生成单独的.class
字节码文件
使用multidex
就需要先配置gradle,先是要引入依赖
dependencies {
implementation 'com.android.support:multidex:1.0.3'
}
并在gradle中激活
android {
···
defaultConfig {
···
multiDexEnabled true
···
}
···
}
在Application中使用
public class MyApplition extends Application {
···
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
···
MultiDex.install(base);
···
}
···
}
记得在AndroidManifest里面的application标签中引入Application
<application
···
android:name=".MyApplition"
··· >
···
</application>
然后还要记得关闭Instant run
,因为这个功能会影响我们的实验,这个功能也是类似热修复的
Settings
-> Build,Exection,Deployment
-> Instant run
,在这里关闭这个功能
热修复的实验环境搭建完毕,接下来就可以开始做实验
实现过程
首先我们来模拟一个bug的出现,就用一个带有除零错误的类为例,这个类里面就一个方法,这个方法除零了,产生了bug
public class HotFixTest {
public void div(Context context){
int a = 10;
int b = 0;
Toast.makeText(context, a + " / " + b + " = " + (a / b), Toast.LENGTH_SHORT).show();
}
}
首先构建出必要的布局,由于这里是热修复测试,这里就简单的两个按键,一个代表出错方法的调用,一个代表热修复
布局
<?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">
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="有除零bug"
android:onClick="onTouch"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="修复bug"
android:onClick="onHotFix"/>
</LinearLayout>
主活动代码,在这里hotFix()
方法只是移动了dex文件,其具体实现在热修复工具类里面
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
public void onTouch(View view) {
HotFixTest hotFixTest = new HotFixTest();
hotFixTest.div(getApplicationContext());
}
public void onHotFix(View view) {
hotFix();
}
private void hotFix() {
//目标目录:/data/data/packageName/odex
File fileDir = getDir(Constants.DEX_DIR, Context.MODE_PRIVATE);
String name = "classes2.dex";
String filePath = fileDir.getAbsolutePath() + File.separator + name;
File file = new File(filePath);
if (file.exists()) {
file.delete();
}
//将修复好的classes2.dex移动到目标目录
InputStream is = null;
FileOutputStream os = null;
try {
is = new FileInputStream(Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + name);
os = new FileOutputStream(filePath);
int len = 0;
byte[] buffer = new byte[1024];
while ((len = is.read(buffer)) != -1) {
os.write(buffer, 0, len);
}
if (file.exists()) {
Toast.makeText(this, "bug修复完成", Toast.LENGTH_SHORT).show();
}
//热修复
HotFixUtils.loadFixedDex(this);
} catch (Exception e) {
e.printStackTrace();
}
}
}
热修复工具类,在代码中有详细注释,这里就不赘述了
public class HotFixUtils {
private static HashSet<File> loadedDex = new HashSet<File>();
public static void loadFixedDex(Context context) {
if (context == null) {
return;
}
File fileDir = context.getDir(Constants.DEX_DIR, Context.MODE_PRIVATE);
File[] listFiles = fileDir.listFiles();
loadedDex.clear();
//遍历所有的dex,有的应用可能有多个dex,然后存入集合
for (File file : listFiles) {
if (file.getName().startsWith("classes") && file.getName().endsWith(".dex")) {
loadedDex.add(file);
}
}
doDexInject(context, fileDir, loadedDex);
}
//将新的dex合并到旧的dex
private static void doDexInject(final Context context, File filesDir, HashSet<File> loadedDex) {
String optimizeDir = filesDir.getAbsolutePath() + File.separator + "opt_dex";
File fopt = new File(optimizeDir);
if (!fopt.exists()) {
fopt.mkdirs();
}
try {
//加载当前应用程序的dex,此时加载的是/data/data/{包名}下的dex
PathClassLoader pathLoader = (PathClassLoader) context.getClassLoader();
//这里采用的是每个dex都写入新的类,如果知道在哪个dex里面的类有bug,也可以直接加在里面
for (File dex : loadedDex) {
//加载指定的修复的dex文件。
DexClassLoader classLoader = new DexClassLoader(dex.getAbsolutePath(),
fopt.getAbsolutePath(), null, pathLoader);
//通过反射得到ElementsList
Object oldDexObj = getPathList(classLoader);
Object newDexObj = getPathList(pathLoader);
Object oldDexElementsList = getDexElements(oldDexObj);
Object newDexElementsList = getDexElements(newDexObj);
//合并新旧dex,将class添加至头部
Object dexElements = combineArray(oldDexElementsList, newDexElementsList);
//重写给dexElements赋值
Object pathList = getPathList(pathLoader);
setField(pathList, pathList.getClass(), "dexElements", dexElements);
}
} catch (Exception e) {
e.printStackTrace();
}
}
//获取pathList属性值
private static Object getPathList(Object baseDexClassLoader) throws Exception {
return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}
//获取dexElements属性值
private static Object getDexElements(Object obj) throws Exception {
return getField(obj, obj.getClass(), "dexElements");
}
//属性获取
private static Object getField(Object obj, Class<?> clazz, String field)
throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
Field localField = clazz.getDeclaredField(field);
localField.setAccessible(true);
return localField.get(obj);
}
//属性赋值
private static void setField(Object obj, Class<?> cl, String field, Object value) throws Exception {
Field localField = cl.getDeclaredField(field);
localField.setAccessible(true);
localField.set(obj, value);
}
//数组合并,其实就是将新加入的class写到以前的dex最前面
private static Object combineArray(Object arrayOld, Object arrayNew) {
Class<?> localClass = arrayOld.getClass().getComponentType();
int i = Array.getLength(arrayOld);
int j = Array.getLength(arrayNew);
int sum = i + j;
Object result = Array.newInstance(localClass, sum);
for (int k = 0; k < sum; k++) {
if (k < i) {
Array.set(result, k, Array.get(arrayOld, k));
} else {
Array.set(result, k, Array.get(arrayNew, k - i));
}
}
return result;
}
}
上面又到了一个常量类,这个类里面就一个静态属性
public class Constants {
public static final String DEX_DIR = "odex";
}
到这里,我们的测试就搭建完毕了,此时我们执行除法运算的时候,会直接crash掉,修改源代码,编译生成.class
文件
这里我简单地修改一下除法里面的b的值,将其修改为2,在编译项目,记得不要运行,不然就覆盖了原来的apk了
public class HotFixTest {
public void div(Context context){
int a = 10;
int b = 2;
Toast.makeText(context, a + " / " + b + " = " + (a / b), Toast.LENGTH_SHORT).show();
}
}
编译完成以后,在当前项目的build
->intermediates
->classes
->debug
->{包名}
下面会有生成的class文件,将有问题的class文件复制出来,记得连同文件夹包名文件夹一起,例如我的包名是com.cj5785.hotfixtest
,那么我复制出来的文件夹结构图如下
└─dex
└─com
└─cj5785
└─hotfixtest
HotFixTest.class
使用sdk里面的一个工具将这个文件打包成dex文件,这个文件夹里面可以有多个class文件,也可以有多个目录,但是其相对位置一定要正确
这个工具在build-tool
-> {版本号}
下面,dx.bat
就是我们要使用的工具
然后在命令函中输入参数,由于我这里直接把这个工具所在路径加到环境变量中了,这里就直接使用了
dx --dex --output=E: empclasses2.dex E: empdex
--output
的参数就是输出目录,后面接的就是我们刚才复制出来的文件根目录
根据我们上面写的代码,这里我们将生成的classes2.dex
文件放入根目录,启动软件进行验证
以下是我的验证效果,说明这这种方案的可行性还是有的