Android Hot Fix 小试

最近在做一个特别小型的项目,会不时根据用户的反馈改变或者增加一些功能,当然,更多的是修改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);
	}
}


将修复后的ActivityA打包,转成dex 的jar包,上传到服务器。

在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调用的优雅性,并且不能共同引入将方法束缚住的接口。

效果如下:


至此已经大概完成了我们热修复库大体架构的开发,思路都在这了,剩下的版本管理啊什么的功能可以自行添加。具体源代码可参见:


KSimpleHotFix







  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值