Tinker热修复手写简单实现原理
前面一篇文章分析了“App加载dex文件源码分析”,其实热修复原理,也是参照个,原理如下文字和图解:
app在启动的时候:
从手机外部内存加载dex文件和网络获取dex文件其实最后都是一个意思,这里我们就用外部内存来举例。
- 1,先去加载手机外部内存的dex文件,也就是我们修复好的dex文件,再通过反射加载到我们自己的dexElements数组。
- 2,然后再通过反射去拿到系统的dex文件数组。
- 3,创建一个新的Array数组,将自己的dex文件数组依次加载到新创建的Array里面,再去将系统的dex数组也加载到新创建的Array数组里面。
- 4,最后用新创建的Array数组,通过反射去替换掉系统的dexElements数组。
图解如下:
图画的比较丑

接下来就是代码部分
1,首先我们新建一个工程。新创建一个FixDexUtils类,代码有注释,如下:
这个类里面主要是加载外部内存的dex文件,反射获取系统的dexElements数组,将自己的dexElements数组合系统的dexElements数组合并,最后是合并后的新数组反射替换系统的dexElements数组。
public class FixDexUtils {
//创建一个HashSet数组,用来装外部内存的dex文件
private static HashSet<File> loaderDex=new HashSet<>();
static {
loaderDex.clear();
}
public static void loadDex(Context context){
//修复 不止一次 按时间顺序 从外置内存卡的文件夹中拿dex文件
File fileDir=context.getDir("odex",Context.MODE_PRIVATE);
File[] listFiles=fileDir.listFiles();
String optimizeDir=fileDir.getAbsolutePath()+File.separator+"opt_dex";
File fopt=new File(optimizeDir);
if (!fopt.exists()) {
fopt.mkdirs();
}
for (File file : listFiles) {
if (file.getName().startsWith("classes")||file.getName().endsWith(".dex")) {
Log.d("FixDexUtils", "遍历文件:" + file.getAbsolutePath());
loaderDex.add(file);
DexClassLoader classLoader=new DexClassLoader(file.getAbsolutePath(),
optimizeDir,null,context.getClassLoader());
//这个事真正用来加载class
PathClassLoader pathClassLoader= (PathClassLoader) context.getClassLoader();
try {
//系统的ClassLoader Elment[]
Class baseDexClassLoader=Class.forName("dalvik.system.BaseDexClassLoader");
Field pathListField=baseDexClassLoader.getDeclaredField("pathList");
pathListField.setAccessible(true);
Object pathListObject=pathListField.get(pathClassLoader);
//获取 系统Element[]数组
Class systemPathClazz=pathClassLoader.getClass();
Field systemdexElementsField=systemPathClazz.getDeclaredField("dexElements");
systemdexElementsField.setAccessible(true);
//拿到系统 Element[]数组
Object systemdexElements=systemdexElementsField.get(pathListObject);
////////////////////////////////////////////////////////////////////////////////////////
//自己的classLoader Elment[]
Class myDexClassLoader=Class.forName("dalvik.system.BaseDexClassLoader");
Field myPathListField=myDexClassLoader.getDeclaredField("pathList");
myPathListField.setAccessible(true);
Object myPathListObject=myPathListField.get(classLoader);
Class myPathClazz=myPathListObject.getClass();
Field myElementsField=myPathClazz.getDeclaredField("dexElements");
myElementsField.setAccessible(true);
Object myElements=myElementsField.get(myPathListObject);
////////////////////////////////////////////////////////////////////////////////////
//融合
//有一个新数组Element类型
Class<?> sigleElementClazz=systemdexElements.getClass().getComponentType();
//数组
int systemLength=Array.getLength(systemdexElements);
int myLength=Array.getLength(myElements);
int allLength=systemLength+myLength;
Object newElementArray= Array.newInstance(sigleElementClazz,allLength);
for(int i=0;i<allLength;i++){
if(i<myLength){ //将自己的数组放在新创建的数组前面 ,是依次放
Array.set(newElementArray,i,Array.get(myElements,i));
}else { //将系统的数组放在新创建数组的后面 //也是依次放
Array.set(newElementArray,i,Array.get(systemdexElements,i-myLength));
}
}
///////////////////////////////这里是真正的融合的数组,放到系统的PathClassLaoder////////////////////////////////////////////////////
//将新的数组装进系统
Field elementsField=pathListField.getClass().getDeclaredField("dexElements");
elementsField.setAccessible(true);
elementsField.set(pathListObject,newElementArray);
} catch (Exception e) {
e.printStackTrace();
}
}
}
//
}
}
2,由于有的时候,会分包,分包的目的在于解决65535问题,有第三方插件可以使用,如下:
将这个引入build.gradle文件中。
//这主要是分包用的
compile 'com.android.support:multidex:1.0.1'
完整的代码如下:
引入了multidex后,还要在build.gradle里面作一些配置,代码里面有写,有注释,这里就不再详细写了。
android {
compileSdkVersion 26
buildToolsVersion "26.0.2"
defaultConfig {
applicationId "com.gzshengye.tinkertext"
minSdkVersion 15
targetSdkVersion 26
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
//注意,也要在这里设置一下
multiDexEnabled true
flavorDimensions "versionCode"
}
//分包用的,用于支持多少,安卓版本兼容
productFlavors{
dev{
minSdkVersion 21
}
prod{
minSdkVersion 14
}
}
buildTypes {
release {
//如果单独设置这个,只会分包,但是不会分主包
multiDexEnabled true
//分主包用的
multiDexKeepFile file('dex.keep')
//获取主包
def myFile=file('dex.keep')
//打印主包是否存在
println("是否存在:"+myFile.exists())
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
compile fileTree(include: ['*.jar'], dir: 'libs')
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
exclude group: 'com.android.support', module: 'support-annotations'
})
compile 'com.android.support:appcompat-v7:26.0.1'
testCompile 'junit:junit:4.12'
compile 'com.yanzhenjie:permission:1.0.5'
//这主要是分包用的
compile 'com.android.support:multidex:1.0.1'
}
3,还需要在工程结构的app的目录下添加“dex.keep”文件,也就是刚刚在build.gradle里面配置的那个。我的文件里面代码如下:
配置dex.keep文件,主要的作用是,在文件里面配置的类,是分在主包的,也就是主dex文件里面,因为系统在加载dex文件时,主dex是不能出错的。它是有启动整个应用的作用。所以需要将一些关键的类放在dex.keep文件里面,不能分包进其他dex文件里。
com/gzshengye/tinkertext/MainActivity.class
com/gzshengye/tinkertext/MyApplication.class
4,然后我们在MyApplication里面初始化MultiDex和FixDexUtils。完整代码如下:
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
}
@Override
protected void attachBaseContext(Context base) {
MultiDex.install(base);
FixDexUtils.loadDex(base);
super.attachBaseContext(base);
}
}
5,然后创建一个Compute类,这个类是用来测试bug用的。完整代码如下:
这里的10除以0,是肯定会报错的。那么我们放在外置内存里面的dex文件,肯定是10除以1,或者是除以其他不为0的数。
/**用于测试类
* Created by Administrator on 2018/2/26.
*/
public class Compute {
public void compute(Context context){
int i=10;
int j=0;
int k=i/j;
Toast.makeText(context, "等于:"+k, Toast.LENGTH_SHORT).show();
}
}
6,在xml文件里面写上布局,完整的代码如下:
当app运行起来了,如果还没有修复,点击“计算”会报错,因为10除以0,肯定会报错的。然后我再点击“修复”,前提是修改好的dex文件要提前放在外置内存卡里面,这样才能找的到。修复成功后,点击计算,就正常了。
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.gzshengye.tinkertext.MainActivity">
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="compute"
android:text="计算"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="repair"
android:text="修复"/>
<TextView
android:id="@+id/tv"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
7,这里贴出MainActivity类的代码,由于我这里做了权限适配,它会用到读写权限。完整代码如下:
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
private TextView tv;
private android.widget.LinearLayout activitymain;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
this.activitymain = (LinearLayout) findViewById(R.id.activity_main);
this.tv = (TextView) findViewById(R.id.tv);
//这是申请多个权限
AndPermission.with(this)
.requestCode(100)
.permission(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE)
.send();
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
//super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode==100) {
AndPermission.onRequestPermissionsResult(requestCode,permissions,grantResults,listener);
}else {
Toast.makeText(this, "权限申请拒绝", Toast.LENGTH_SHORT).show();
}
}
private PermissionListener listener=new PermissionListener() {
@Override
public void onSucceed(int requestCode, List<String> grantPermissions) {
//权限申请成功回调
if (requestCode==100) {
Log.e("MainActivity", "权限申请成功");
}else {
Log.e("MainActivity", "权限申请出现101");
Toast.makeText(MainActivity.this, "权限申请出现101", Toast.LENGTH_SHORT).show();
}
}
//这是当用户点击不再询问的时候,同时又点击了拒绝,就会走这一步
//注意,这里面要是不给用户的提示,就直接finish();要是用户点击了不再询问和拒绝,用户想再进入应用,就进不来了
@Override
public void onFailed(int requestCode, List<String> deniedPermissions) {
//权限申请失败回调
if(AndPermission.hasAlwaysDeniedPermission(MainActivity.this,deniedPermissions)){
//这应该是跳转到权限设置页面的,但是暂时还没有找到1,该用什么代替
Log.e("MainActivity", "权限申请失败");
Toast.makeText(MainActivity.this, "权限申请失败", Toast.LENGTH_SHORT).show();
AndPermission.defaultSettingDialog(MainActivity.this,1)
.setTitle("请开启权限")
.setMessage("没有权限,将无法使用,请手动去‘权限’里面将需要的权限全部打开")
.setNegativeButton("取消", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
finish();
}
})
.setPositiveButton("去设置")
.show();
}
}
};
/**计算
* @param view
*/
public void compute(View view){
Compute compute=new Compute();
compute.compute(this);
}
/**修复
* @param view
*/
public void repair(View view){
fixBug();
// String name = "out.dex";
// String path = new File(Environment.getExternalStorageDirectory(), name).getAbsolutePath();
File filesDir = this.getDir("odex", Context.MODE_PRIVATE);
String name = "out.dex";
String filePath = new File(filesDir, name).getAbsolutePath();
tv.setText(filePath);
}
private void fixBug() {
File filesDir = this.getDir("odex", Context.MODE_PRIVATE);
String name = "out.dex";
String filePath = new File(filesDir, name).getAbsolutePath();
Log.e(TAG, "路径::"+filePath);
File file = new File(filePath);
if (file.exists()) {
file.delete();
}
InputStream is = null;
FileOutputStream os = null;
try {
Log.i(TAG, "fixBug: " + new File(Environment.getExternalStorageDirectory(), name).getAbsolutePath());
is = new FileInputStream(new File(Environment.getExternalStorageDirectory(), name));
os = new FileOutputStream(filePath);
int len = 0;
byte[] buffer = new byte[1024];
while ((len = is.read(buffer)) != -1) {
os.write(buffer, 0, len);
}
File f = new File(filePath);
if (f.exists()) {
Toast.makeText(this, "dex overwrite", Toast.LENGTH_SHORT).show();
}
//FixManager.loadDex(this);
FixDexUtils.loadDex(this);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} finally {
try {
os.close();
is.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}