需求
最近要做sdk的热更新
因为需求方的sdk其实是jar包,只有class文件,没有资源文件,所以此文只针对class文件更新
首先罗列下一个轻量级更新框架的功能最小边界:
- 需要配置文件描述更新包
- 更新包需要在线下载,并检验包的完整性
- 只针对特定版本
- 针对特定渠道
- 补丁包的版本控制
调研
市面上比较流行的热更新有Tinker、QZone、AndFix、Sophix、Robust、Dexposed
这些大家都很熟悉,但绝大部分是针对app的
先回顾下class文件是如何实现热修复的:
概念:
DexClassLoader和PathClassLoader
回顾下Android中Classloader的知识
DexClassLoader
和PathClassLoader
都是继承自BaseDexClassLoader
看下Android10的代码
public class DexClassLoader extends BaseDexClassLoader {
/**
* ...
*/
public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}
可见这两个类没有本质上区别,网上说的optimizedDirectory
在Api26之后已废弃
只不过PathClassLoader多了构造函数参数sharedLibraryLoaders,可以加载系统类
ClassLoader
在Java的ClassLoader中:
protected Class> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// First, check if the class has already been loaded
Class> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
return c;
}
findLoadedClass是保证加载的类不会被加载
findBootstrapClassOrNull返回的是空
所以调用的findClass方法
BaseDexClassLoader的findClass方法:
@Override
protected Class> findClass(String name) throws ClassNotFoundException {
// First, check whether the class is present in our shared libraries.
if (sharedLibraryLoaders != null) {
for (ClassLoader loader : sharedLibraryLoaders) {
try {
return loader.loadClass(name);
} catch (ClassNotFoundException ignored) {
}
}
}
// Check whether the class in question is present in the dexPath that
// this classloader operates on.
List suppressedExceptions = new ArrayList();
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;
}
可以由传进来的sharedLibraryLoaders来加载,但DexClassLoader的sharedLibraryLoaders为空
是交由DexPathList类来处理实现findClass
DexPathList
DexPathList(ClassLoader definingContext, String dexPath,
String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
...
this.definingContext = definingContext;
ArrayList suppressedExceptions = new ArrayList();
// save dexPath for BaseDexClassLoader
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions, definingContext, isTrusted);
//native相关
this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
this.systemNativeLibraryDirectories =
splitPaths(System.getProperty("java.library.path"), true);
this.nativeLibraryPathElements = makePathElements(getAllNativeLibraryDirectories());
...
}
通过Element[]数组来存储dex,makeDexElements这个方法:
private static Element[] makeDexElements(List files, File optimizedDirectory,
List suppressedExceptions, ClassLoader loader, boolean isTrusted) {
Element[] elements = new Element[files.size()];
int elementsPos = 0;
for (File file : files) {
if (file.isDirectory()) {
elements[elementsPos++] = new Element(file);
} else if (file.isFile()) {
String name = file.getName();
DexFile dex = null;
if (name.endsWith(DEX_SUFFIX)) {
// Raw dex file (not inside a zip/jar).
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
if (dex != null) {
elements[elementsPos++] = new Element(dex, null);
}
} catch (IOException suppressed) {
System.logE("Unable to load dex file: " + file, suppressed);
suppressedExceptions.add(suppressed);
}
} else {
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
} catch (IOException suppressed) {
suppressedExceptions.add(suppressed);
}
if (dex == null) {
elements[elementsPos++] = new Element(file);
} else {
elements[elementsPos++] = new Element(dex, file);
}
}
if (dex != null && isTrusted) {
dex.setTrusted();
}
} else {
System.logW("ClassLoader referenced unknown path: " + file);
}
}
if (elementsPos != elements.length) {
elements = Arrays.copyOf(elements, elementsPos);
}
return elements;
}
DexPathList在构造函数通过makeDexElements方法,生成Element[]数组
在BaseDexClassLoader中调用的是DexPathList的findClass方法:
public Class> findClass(String name, List suppressed) {
for (Element element : dexElements) {
Class> clazz = element.findClass(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
很清楚的看到element是按顺序取出
也就是说两个element如果有相同的class,第二个就不会加载,如果将目标dex包插队到列队最前面,则可以实现目标dex包中的class替换掉其他dex中的class
热修复的实现:
- DexClassLoader加载目标dex
- 将目标的和系统的 dexElements 进行合并
- 赋值给系统的 pathList
public void loadCustomDex(Context mContext){
//遍历所有修复的dex
File fileDir = yourfilepath;
File[] listFiles = fileDir.listFiles();
for (File f : listFiles){
if(isValid(f)){
dexList.add(f);
}
}
//合并与应用
PathClassLoader pathClassLoader = (PathClassLoader) mContext.getClassLoader();
for (File f : dexList){
//加载指定的dex文件。
DexClassLoader dexClassLoader = new DexClassLoader(f.getAbsolutePath(),null,null,pathClassLoader);
//合并
Object dexObj = getPathList(dexClassLoader);
Object pathObj = getPathList(pathClassLoader);
Object dexElementsList = getDexElements(dexObj);
Object pathElementsList = getDexElements(pathObj);
Object newElements = combineArray(dexElementsList,pathElementsList);
//给PathList里面的dexElements赋值
Object pathList = getPathList(pathClassLoader);
setField(pathList,pathList.getClass(),"dexElements",newElements);
}
}
private Object getPathList(Object obj) throws Exception {
return reflectField(obj,Class.forName("dalvik.system.BaseDexClassLoader"),"pathList");
}
private Object getDexElements(Object obj) throws Exception {
return reflectField(obj,obj.getClass(),"dexElements");
}
private static Object combineArray(Object array1, Object array2) {
Class> localClass = array1.getClass().getComponentType();
int i = Array.getLength(array1);
int j = i + Array.getLength(array2);
Object result = Array.newInstance(localClass, j);
for (int k = 0; k < j; ++k) {
if (k < i) {
Array.set(result, k, Array.get(array1, k));
} else {
Array.set(result, k, Array.get(array2, k - i));
}
}
return result;
}
方案:
上述的热更新实现方式有两个问题需要解决:
- app中可以在Application中的attachBaseContext中去做插入dex,SDK的类是不知道何时被调用的
- ISPREVERIFIED问题
如果一个类的static方法,private方法,override方法以及构造函数中引用了其他类,而且这些类都属于同一个dex文件,此时该类就会被打上CLASS_ISPREVERIFIED
如果在运行时被打上CLASS_ISPREVERIFIED的类引用了其他dex的类,就会报错
解决ISPREVERIFIED问题,常用的就是“插装”(往字节码里插入自定义的字节码),我们只要让所有类都引用其他dex中的某个类就可以了:
- 当安装apk的时候,classes.dex内的类都会引用一个在不相同dex中的XX类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志了,只要没被打上这个标志的类都可以进行打补丁操作
- 我们需要在源码编译成字节码之后,在字节码中进行插入操作。对字节码进行操作的框架有很多,但是比较常用的则是ASM和javaassist
那么有没有简单点的方法:
- 将工程拆分,保证要进行更换的工程与其他工程没有直接引用(都用反射)
- 对要进行更换的工程采用全量替换方法,考虑到sdk体积比较小,此方法最简单
配置文件
对应需求中的最小边界,定义了如下的配置文件:
{
"checksum": {
"TYPE": "md5",
"value": "20d81614ab8ac44b3185af0f5e8a96b2"
},
"channel": "abcdefgh",
"version": "1.0.0",
"subVersion": 1,
"package": "http://robinfjb.github.io/dex.jar",
"className": "robin.sdk.sdk_impl2.ServiceImpl"
}
-
checksum
是对包完整性的验证 -
channel
是渠道,在sdk一般用appkey作为渠道标识 -
version
目标版本,由于sdk的定制化比较多,不同版本的代码功能都不一样,所以只针对某个版本更新 -
subVersion
补丁版本,只有新的补丁才会应用 -
package
包的下载地址 -
className
新包的入口类名,后面会讲到
工程
我们选择类似类加载方案
将整个sdk拆分成 业务实现,更新模块,对外api 三个模块
- 业务实现:
sdk-impl
, 更新则只要更新业务实现模块 - 更新模块:
sdk-dynamic
, 实现热更新下载,应用 - 对外api:
sdk
,sdk对外暴露的类 - 代理:
sdk-proxy
为了避免sdk-impl
对sdk
的依赖,用单独工程维护两个模块之间的接口 - 公共:
sdk-common
放一些基础类,比如log之类
代码
命名
补丁类可以命名为packagename+version
比如业务包名:robin.sdk.sdk_impl
补丁包名则为:robin.sdk.sdk_impl1
关键代码:
代理:
例子中使用了CS结构,我们将service代理:
public interface ServiceProxy {
void onCreate(Context var1);
int onStartCommand(Intent var1, int var2, int var3);
IBinder onBind(Intent var1);
boolean onUnBind(Intent var1);
void onDestroy();
}
对外的service中实现:
public final class RobinService extends Service {
@Override
public void onCreate() {
try {
context = getApplicationContext();
proxy = serviceLoad();
proxy.onCreate(this);
checkUpdate();
} catch (Throwable e) {
}
}
@Override
public int onStartCommand(Intent var1, int var2, int var3) {
return proxy.onStartCommand(var1, var2, var3);
}
@Override
public IBinder onBind(Intent var1) {
return proxy.onBind(var1);
}
@Override
public boolean onUnbind(Intent var1) {
return proxy.onUnBind(var1);
}
@Override
public void onDestroy() {
proxy.onDestroy();
}
}
然后在sdk-impl中实现代理接口:
public class ServiceImpl implements ServiceProxy {
}
这个类就是我们需要更换的类
下载补丁
在程序入口(Service onCreate
)方法, 开启下载
先下载配置文件,下载完成后:
DyInfo dyInfo = new DyInfo(new JSONObject(response));
if (checkDyInfo(context, dyInfo)) {//检查配置文件有效性
LogUtil.e(UPDATE_TAG, "downloadDyJar");
downloadDyJar(dyInfo, context);
}
checkDyInfo检查当前包是否需要下载与应用补丁
几个关键检测:
- 检测key是否等于当前key,说明是针对某个key发的补丁,如果为空说明是全部渠道都应用补丁
- 检测SDK版本是否和配置文件中一致,补丁只针对某个版本
- 检测补丁版本,在补丁应用成功后会记录应用的补丁版本,只有新的补丁版本大于已应用的,补丁才会下载与生效
下载完成后:检查文件完整性与保存配置信息
if (!checkJarMd5(context, dyInfo.checksumValue)) {
LogUtil.e(UPDATE_TAG, "checkJarMd5 fail");
deleteFailjar(context);
} else {
//保存jar信息
SpUtil.setDyInfo(context, dyInfo);
LogUtil.e(UPDATE_TAG, "checkJarMd5 success");
}
应用补丁
try {
DyInfo dyInfo = SpUtil.getDyInfo(context);
if (UpdateManager.checkDyInfo(context, dyInfo) && checkJar(dyInfo, usingJar)) {
DexClassLoader dexClassLoader = new DexClassLoader(usingJar.getAbsolutePath(),
context.getCacheDir().getAbsolutePath(), null, context.getClassLoader());
Class libclass = dexClassLoader.loadClass(dyInfo.className);
lib = (ServiceProxy) libclass.newInstance();
SpUtil.setPatchVersion(context, dyInfo.subVersion);
LogUtil.e(DYNAMIC_TAG, "动态包已加载成功 ");
}
} catch (Throwable throwable) {
LogUtil.e(DYNAMIC_TAG, "动态包加载异常:" + throwable.getLocalizedMessage());
}
if(lib == null) {
try {
Class libclass = context.getClassLoader().loadClass("robin.sdk.sdk_impl.ServiceImpl");
lib = (ServiceProxy) libclass.newInstance();
} catch (Throwable throwable) {
LogUtil.e(DYNAMIC_TAG, "正常包加载异常:" + throwable.getLocalizedMessage());
}
}
- 读出上一次下载的配置文件与补丁包,进行校验
-
DexClassLoader
加载目标usingJar(data/user/0/packagename/file/robin/dex.jar)
的包 -
DexClassLoader
加载包中的类ServiceImpl
(类名比如:robin.sdk.sdk_impl1.ServiceImpl
) - 如果未加载到,则加载默认包中的类
robin.sdk.sdk_impl.ServiceImpl
脚本
sdk
Android studio的assembleXXX
往往打的都是aar包,我们需要一个jar包的gradle task:
task makeSdkJar(type: Jar) {
//指定生成的jar名
baseName 'sdk'
//从哪里打包class文件
from('build/intermediates/javac/release/classes/')
from('../sdk-proxy/build/intermediates/javac/release/classes/')
from('../sdk-common/build/intermediates/javac/release/classes/')
from('../sdk-dynamic/build/intermediates/javac/release/classes/')
from('../sdk-impl/build/intermediates/javac/release/classes/')
}
makeSdkJar.dependsOn(clean, 'compileReleaseJavaWithJavac')
注意:由于gradle版本不同,intermediates目录的路径可能会有变化
打出sdk.jar包,然后进行混淆
task _proguardJar(dependsOn: makeSdkJar, type: proguard.gradle.ProGuardTask) {
String inJar = makeSdkJar.archivePath.getAbsolutePath()
println("正在混淆jar...path= " + inJar)
injars inJar
outjars "build/libs/proguard.jar"
configuration "$rootDir/sdk/proguard-rules.pro"
}
proguard-rules.pro
中,需要keep住所有需要反射的类
-keep public class robin.sdk.*.ServiceImpl {*;}
-keep class robin.sdk.*.ServiceImpl$* {*;}
对于sdk对外的类,也需要keep:
-keep public class robin.sdk.hotfix.RobinClient {*;}
-keep class robin.sdk.hotfix.RobinClient$* {*;}
补丁
这里采用全量打包方式,将sdk的jar包解压,去除hotfix,proxy,sdk_common,service_dynamic
目录:
//打patch任务
task renameJar(type: Copy) {
from 'build/libs/'
include 'proguard.jar'
destinationDir file('build/libs/')
rename 'proguard.jar', "classes.zip"
}
task upzip(dependsOn: renameJar, type: Copy) {
def zipFile = file('build/libs/classes.zip')
def outputDir = file("build/libs/unzip")
from zipTree(zipFile)
into outputDir
}
task _patchProguardJar(dependsOn: upzip, type: Jar) {
//指定生成的jar名
baseName 'patch'
from('build/libs/unzip/')
exclude('robin/sdk/hotfix')
exclude('robin/sdk/proxy')
exclude('robin/sdk/sdk_common')
exclude('robin/sdk/service_dynamic')
doLast {
delete('build/libs/unzip')
delete('build/libs/classes.zip')
}
}
将jar包转dex方便DexClassLoader加载:
task _jarToDex(type: Exec) {
commandLine 'cmd'
doFirst {
//jar文件对象
def srcFile = file("/build/libs/hot.jar")
//需要生成的dex文件对象
def desFile = file(srcFile.parent + "/" + "dex.jar")
workingDir srcFile.parent
//拼接dx.bat执行的参数
def list = []
list.add("/c")
list.add("dx")
list.add("--dex")
list.add("--output")
list.add(desFile)
list.add(srcFile)
args list
}
}
测试验证
使用_proguardJar task打包sdk.jar文件,放到测试app中:
app的gradle依赖:
implementation files("libs/sdk.jar")
首次启动:
日志如下:
此日志为CLient和Service直接的交互,可见Service为
robin.sdk.sdk_impl.ServiceImpl
,为原版的Service
此日志为ServiceImpl中类的引用,目前的类为
robin.sdk.sdk_impl.a
(已混淆)
Service启动后会满足条件自动下载补丁包;
第二次启动:
日志如下:
加载动态包后的:
原来的
robin.sdk.sdk_impl.a
已替换为
robin.sdk.sdk_impl2.a
Service也替换成功
工程代码:
https://github.com/robinfjb/Android_SDK_Hotfix