最近在做一个特别小型的项目,会不时根据用户的反馈改变或者增加一些功能,当然,更多的是修改BUG。每次改完都要重新发版上传到各种应用商店,接受各种各样不同会规则的审核,审核时间少则一两天,多则四五天,可能还没上线新版就又出来了,因此一直想写一个Android 热修复的库,Alibaba 开源的AndFix 效果很不错,但是有点重了,不太适合轻型的小APP,正好马上要做本科毕业设计了,选了一个平平淡淡的题目,就用这个来发挥一下吧。
由于水平有限,因此只想了在Java层的替换思路。
首先最开始的思路是:
1.易修改的普通类A:在线下载含有修复之后类A' (A'与A的名称以及包名一模一样)的jar 包在APP中调用该类时,利用框架的getLatestClassInstance 去得到实例,这样框架就得以在控制点决定返回修复后的Class 实例了。设想中的调用是:A a=(A)(HotFixManager.getLatestClassInstance("A"));
2.易修改的ActivityA:同上,返回最新的ActivityA.class,在APP中:Intent i=new Intent(this,HotFixManager.getLatestClass("ActivityA"));
好,动手嗨起来!
首先编写普通类A
public class TestA { public void showToast(Activity acticity) { Toast.makeText(acticity, "我是TestA", Toast.LENGTH_SHORT).show(); } }
嗯,假如这个类有个BUG,文案不好,需要修改,那么该改正之后的:
import android.app.Activity;
import android.widget.Toast;
public class TestA {
public void showToast(Activity acticity) {
Toast.makeText(acticity, "我是替补TestA", Toast.LENGTH_SHORT).show();
}
}
好,要想动态加载出修复后的类A,怎么办呢?首先我们把A打成Jar包,但是Android 并不能读取Jar,所以我们要把这个Jar包转成Android 可以读取的Dex 文件。
打Jar包:
把Jar 转成Dex(在Build Tools 中执行以下命令(Mac))
dx --dex --output=patch.jar test.jar
好了,现在我们得到了打包并Dex后的Jar: patch.jar,我们需要一个框架去获取patch.jar,并下载到本地。
代码如下:
private class downloadJarTask extends AsyncTask {
@Override
protected Object doInBackground(Object[] params) {
SimpleDownloader downloader = new SimpleDownloader();
File jar = new File(mContext.getFilesDir() + "/" + JAR_DIR + "/" + "patch.jar");
byte[] data = downloader.download(mConfig.serverPath);
if (data == null || data.length < 1) {
if (jar.exists() && jar.length() >= JAR_MIN_SIZE) {
jarFile = jar;
}
return null;
}
try {
synchronized (jar) {
FileOutputStream fos = new FileOutputStream(jar.getAbsolutePath());
fos.write(data);
fos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
jarFile = jar;
return null;
}
}
好了,现在jar 包也有了,只欠加载啊。开始吧:
public Object getLatestClassInstance(Class sourceClass,
Class[] constructParameterTypes,
Object[] constructParams)
throws CantFindLatestClassInstanceException {
Object maintainable = null;
Class realObjectClazz = null;
Class clazz = latestClasses.get(sourceClass.getName());
if (clazz == null) {
try {
realObjectClazz = getClass().getClassLoader().loadClass(sourceClass.getName());
if (jarFile == null && realObjectClazz != null) {
return getInstance(realObjectClazz, constructParameterTypes, constructParams);
}
ClassLoader originClassLoader=getClass().getClassLoader();
DexClassLoader classLoader = new DexClassLoader(jarFile.getAbsolutePath()
, mContext.getFilesDir() + "/" + DEX_OUT
, null
, originClassLoader);
clazz = classLoader.loadClass(sourceClass.getName());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
if (clazz != null) {
latestClasses.put(sourceClass.getName(), clazz);
maintainable = getInstance(clazz, constructParameterTypes, constructParams);
System.out.println(maintainable);
}
if (maintainable == null) {
if (realObjectClazz != null) {
return getInstance(realObjectClazz, constructParameterTypes, constructParams);
}
throw new CantFindLatestClassInstanceException("Can't find latestClassInstance!");
}
return maintainable;
}
嗯,看起来很完美,很简单嘛!
调用:
TestA test = (TestA) FixManager.getInstance().getLatestClassInstance(TestA.class);
test.showToast(MainActivity.this);
然而运行时:『我是TestA』。。
为何不是『我是替补TestA』!!打开ClassLoader的源码看看:
protected final Class<?> findLoadedClass(String className) {
ClassLoader loader;
if (this == BootClassLoader.getInstance())
loader = null;
else
loader = this;
return VMClassLoader.findLoadedClass(loader, className);
}
ClassLoader 是双亲委托机制,会检查父ClassLoader能不能加载这个Class,如果有已经加载过的Class,如果已经加载过那么返回已经加载过的。嗯。并且这个逻辑的Java层是final的,而核心代码在JNI层,这意味着不能够去修改它。那么现在有两个思路:
1.不用加载原类A的ClassLoader 去加载这个类
2.修改这个ClassLoader 的parent,改成不加载原类A的ClassLoader
好,来试一试:
第一种方案:
clazz = ClassLoader.getSystemClassLoader().loadClass(className);
嗯,调试了一下,果然Class加载出来了。
然而结果是:ClassCastException :类TestA不能转换成类TestA。。原来两个毫不相关的ClassLoader 加载出来的哪怕是一模一样的Class,这两个类也是隔离开的,并不能进行访问,更不用说类型转换了。这也是Java 类加载器的安全机制之一。
第二种方案:一样的结果。因为除非当前的ClassLoader是加载类A的ClassLoader的子节点(也就是正常双亲关系),那么都算作是不同的ClassLoader。
这样一来,不修改JNI层的情况下,并不能加载出和类A一模一样的类出来。
这样一来,只能曲线救国:更改修复后的类A的包了,例如A原本在a.b.c.TestA下,我们约定好把修复后的TestA放在:a.b.c.patch.TestA,并且,修复后的TestA 必须继承TestA,这样类型转换才能够起作用。
那么现在,修复后的TestA的代码是这样的:
package com.kot32.ksimplehotfix.test.patch;
import android.app.Activity;
import android.widget.Toast;
public class TestA extends com.kot32.ksimplehotfix.test.TestA{
public void showToast(Activity acticity) {
Toast.makeText(acticity, "我是替补TestA", Toast.LENGTH_SHORT).show();
}
}
public Object getLatestClassInstance(Class sourceClass,
Class[] constructParameterTypes,
Object[] constructParams)
throws CantFindLatestClassInstanceException {
Object maintainable = null;
Class realObjectClazz = null;
Class clazz = latestClasses.get(sourceClass.getName());
if (clazz == null) {
try {
realObjectClazz = getClass().getClassLoader().loadClass(sourceClass.getName());
if (jarFile == null && realObjectClazz != null) {
return getInstance(realObjectClazz, constructParameterTypes, constructParams);
}
ClassLoader originClassLoader=getClass().getClassLoader();
DexClassLoader classLoader = new DexClassLoader(jarFile.getAbsolutePath()
, mContext.getFilesDir() + "/" + DEX_OUT
, null
, originClassLoader);
//生成加上了.patch 的包名
String fakeClassPath = makeFakePath(sourceClass.getName());
clazz = classLoader.loadClass(fakeClassPath);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
if (clazz != null) {
latestClasses.put(sourceClass.getName(), clazz);
maintainable = getInstance(clazz, constructParameterTypes, constructParams);
System.out.println(maintainable);
}
if (maintainable == null) {
if (realObjectClazz != null) {
return getInstance(realObjectClazz, constructParameterTypes, constructParams);
}
throw new CantFindLatestClassInstanceException("Can't find latestClassInstance!");
}
return maintainable;
}
运行:『我是替补TestA』
结果成功!
那么对于Activity,最佳的方案应该是什么呢?
对于Activity,我们会想到与普通类的区别是:
1.不需要在意类型转换的错误,因为用到Activity Class的地方基本上都是在Intent 中传入,并不需要拿到实例。
2.不需要在意生命周期。因为我们要做的只是替换原有的Activity,并不是像动态加载一样加载全新的Activity,而原有的Activity已经在xml 中声明过,在类名完全一样的情况下,不需要管生命周期。
那么唯一需要突破的点就是:如何在这个Activity已经加载过的情况下,再加载一次,而绕开双亲委托机制呢?
思路1:像普通类的解决方案一样更改包名
思路2:使用最顶级的ClassLoader 去加载Activity,返回加载后的Class。
思路3:有没有可能直接替换掉加载Activity的ClassLoader呢?这样每次跳转时的代码可以完全不用修改。
很明显,更改包名是行不通的,因为Activity的包名xml中是写死的。实验证明方法二可行,然而在使用上仍然没有方法3方便,那么如何替换加载Activity的ClassLoader呢。
了解过动态加载的同学都知道,在Application的父类ContextWrapper 中有一个mBase的对象,Application的 一切行为最后都由它去执行,同时它也是解决动态加载 this 指针的一个关键对象,通过修改它的ClassLoader ,能够做到在Activity 加载时就直接用修改后的Classloader加载。代码如下:
private void initCLassLoader() {
class MyClassLoader extends ClassLoader {
public MyClassLoader(ClassLoader parent) {
super(parent);
}
@Override
public Class<?> loadClass(String className)
throws ClassNotFoundException {
if (CUSTOM_LOADER != null) {
try {
Class<?> c = CUSTOM_LOADER.loadClass(className);
if (c != null)
return c;
} catch (ClassNotFoundException e) {
}
}
return super.loadClass(className);
}
}
Context mBase = Reflect.onObject(mContext).get("mBase");
Object mPackageInfo = Reflect.onObject(mBase).get("mPackageInfo");
Reflect rf = Reflect.onObject(mPackageInfo);
ClassLoader mClassLoader = rf.get("mClassLoader");
ORIGINAL_LOADER = mClassLoader;
MyClassLoader cl = new MyClassLoader(mClassLoader);
//chage current classLoader to MyClassLoader
rf.set("mClassLoader", cl);
DexClassLoader classLoader = new DexClassLoader(jarFile.getAbsolutePath()
, mContext.getFilesDir() + "/" + DEX_OUT
, null
, ORIGINAL_LOADER.getParent());
CUSTOM_LOADER = classLoader;
}
以上代码取自 AndroidDynamicLoader 动态加载框架。
调用时:
Intent intent=new Intent(MainActivity.this,ActivityA.class);
startActivity(intent);
就会自动去用我们自定义的DexClassLoader 去加载Activity,加载后的类也就是我们修复后的类。代码如下:
原有的ActivityA:
package com.kot32.ksimplehotfix.activity;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import com.kot32.ksimplehotfix.R;
public class ActivityA extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_);
}
}
修复后的ActivityA:(这里我们还没有解决资源文件的问题,暂时先用代码直接布局)
package com.kot32.ksimplehotfix.activity;
import android.app.Activity;
import android.os.Bundle;
import android.widget.TextView;
public class ActivityA extends Activity{
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TextView textView=new TextView(this);
textView.setText("我是替补ActivityA");
setContentView(textView);
}
}
在APP的Application 中调用以上替换ClassLoader 的代码。在MainActivity 中直接调用:
Intent intent=new Intent(MainActivity.this,ActivityA.class);
startActivity(intent);
运行结果:『我是替补ActivityA』
成功!
注意,我们目前仅仅是打的Jar包,所以无法针对资源问题进行解决,现在我们将之前打成jar包的文件直接打成APK,运行结果和之前一模一样。不同之处是APK 大小是450k,而jar包大小只有1kb,由此可见,在修复普通类的bug时,并不需要在线传输APK,而只需要传jar包就行了。
我们再来看看如何针对apk来优化修复后ActivityA的资源问题,其实这个方法大家都用烂了,第一直觉就是 addAssetPath,来试试:
在修复后的ActivityA 中:
package com.kot32.ksimplehotfix.activity;
import java.lang.reflect.Method;
import com.kot32.ksimplehotfix.R;
import android.app.Activity;
import android.content.res.AssetManager;
import android.content.res.Resources;
import android.os.Bundle;
public class ActivityA extends Activity{
private final static String JAR_DIR = "jar_dir";
protected AssetManager mAssetManager;//资源管理器
protected Resources mResources;//资源
protected Resources.Theme mTheme;//主题
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
loadResources(getFilesDir() + "/" + JAR_DIR + "/" + "patch.apk");
setContentView(R.layout.activitya);
}
protected void loadResources(String dexPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, dexPath);
mAssetManager = assetManager;
} catch (Exception e) {
e.printStackTrace();
}
Resources superRes = super.getResources();
superRes.getDisplayMetrics();
superRes.getConfiguration();
mResources = new Resources(mAssetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
mTheme = mResources.newTheme();
mTheme.setTo(super.getTheme());
}
@Override
public AssetManager getAssets() {
return mAssetManager == null ? super.getAssets() : mAssetManager;
}
@Override
public Resources getResources() {
return mResources == null ? super.getResources() : mResources;
}
@Override
public Resources.Theme getTheme() {
return mTheme == null ? super.getTheme() : mTheme;
}
}
打包为APK,上传到服务器。打开APP:
运行结果正常,替换成功!(Android 6.0)唯一的缺点就是需要在修复后的ActivityA中进行资源的addAssetPath,有点影响耦合度,不过整体影响不大。
但是运行在Android5.0以下的手机上,调用普通类的更新时,会报错:
Class ref in pre-verified class resolved to unexpected implementation
其原因是因为我们修复后的TestA继承了TestA,在类加载器进行加载时,也会加载它的父类,而父类在Host APP 中已经被加载了,因此进行了重复加载,这里有两个思路:
1.合并新的加载器与原本加载TestA的类加载器的DexElements PathList
2.打包时不把父类TestA打进去。
思路一:观察BaseDexCLassLoader的源码我们可以发现其实它是把Dex文件的加载路径放在了DexElements 这个数组中,那么我们可以利用反射将我们自定义的DexClassLoader的dexElement 数组与原本加载TestA的合并,再用原本加载TestA的ClassLoader 进行加载,可能可行。
尝试后发现仍报错,实际上,同一个类加载器从不同的dex文件中加载相同的class,仍然是不行的。
思路二:只能打成Jar包来fix 了(打包时不把父类打进去)。因为我们要保证在app端api调用的优雅性,并且不能共同引入将方法束缚住的接口。
效果如下:
至此已经大概完成了我们热修复库大体架构的开发,思路都在这了,剩下的版本管理啊什么的功能可以自行添加。具体源代码可参见: