前言
本章主要讲解 Dex 格式分析以及增量更新实现原理;
增量更新
增量更新:记录版本之间的差异信息,把这个差异信息记录到一个文件里面,将这个文件下发到旧的版本上,实现增量更新,而无需下载完整的新版本;
bsdiff
基于二进制的一个差分算法,不依赖产物,例如一个是 mp3 一个是 mp4 都可以进行差分算法;
官网下载链接:http://www.daemonology.net/bsdiff/
上面也有相关的教程,如何在 Window、Mac、Linux 上使用,我们重点要讲解的是 bsdiff 如何在 Android 上使用;
这里介绍下 WIndows 上的使用方式:
bsdiff.c 和 bspatch.c 两个c文件,通过CL编译器 可以生成可执行文件bsdiff.exe 和 bspatch.exe
打开 cmd 执行 bsdiff oldfile newfile pathcfile 生成差分文件,命令行举例:bsdiff old.apk new.apk patch.apk
打开cmd 执行 bspatch oldfile newfile patchfile 使用差分文件和旧文件 生成新文件,命令行举例:bspatch old.apk new.apk patch.apk
那么如何在 Android 上使用 bsdiff 呢?我们来一步一步的探索下;
Android 上使用 bsdiff
首先我们需要在 AS 上创建一个 C++ Project;
创建 C++ Project
当我们创建完一个 C++ Project 之后,可以看到在 app 下的 build.gradle 中,会额外多出几个配置选项,一个是 android 下的,一个是 defaultConfig 下的 externalNativeBuild
defaultConfig {
externalNativeBuild{
cmake{
abiFilters 'x86','armeabi-v7a'
}
}
}
android {
externalNativeBuild{
cmake{
path 'src/main/cpp/CMakeLists.txt'
}
}
}
可以看到 android 下的这个配置和 defaultConfig 下的配置是不一样的,android 下的是指定编译文件的地址,你可以修改它,只要能指向你的编译文件即可;
而 defaultConfig 下的 externalNativeBuild 下的是给编译用的,也就是说,我们要编译的目标平台有哪些;
另外,当我们在引入 so 文件的时候,可以通过 ndk 配置,指定要引入的 so 架构包
ndk {
abiFilters 'x86', 'armeabi-v7a'
}
这个配置是说:打包 APK 的时候,哪些 CPU 平台的 so 要打包进 APK;
增量更新,针对的是手机上由旧版本的 apk 增量更新到新版本的 apk,也就是说我们的 apk 中要支持增量集成,而 patch 的生成,则是放到我们的流水线上,同时将增量 patch 上传到我们的服务器上,供旧的 apk 下载下来进行合成;
所以,我们的工程中要集成的时候 bspatch.c 文件来进行增量合成;
引入 bspatch.c 文件
将 bspatch.c 复制到 cpp 文件夹下即可;
我们接着打开 CMakeLists.txt 文件,来修改我们的编译配置;
我们将里面的注释全部删掉,方便我们来看代码;
重点要关注的是 add_library 方法,我们需要将 bspatch.c 加入进来;
add_library(
bspatch_utlis
SHARED
native-lib.cpp
bspatch.c)
第一个参数表示:我们想要生成的 so 文件名字,最终生成的 so 文件名字就是:libbspatch_utils.so;
同步的我们将 target_link_libraries 方法的第一个参数也修改成 bspatch_utlis
target_link_libraries(
bspatch_utlis
log)
执行 sync,等待 sync 完成,然后我们进入 bspatch.c 文件可以看到,很多红色的报错已经消失掉了,但是还是会有一些报错,我们来逐一的看下;
可以看到 bspatch 其实依赖了 bzip,但是它找不到 bzip,那么就需要手动的将 bzip 导入进来;我们只需要去官网下载 bzip 的源码,然后导入进来即可;
bzip官方地址:http://www.bzip.org/
然后复制到 cpp 目录下:
然后,我们需要将这些 .c 文件全部引入进来,批量引入是有技巧的,CMake 给我们提供了一些 API,方便我们批量引入;
aux_source_directory()
这个方法需要两个参数,一个传入 bzip2,一个传入 SOURCE; 第一个是 bzip2 的相对目录,第二个表示,把这个 bzip2 下的所有源文件放到一个 SOURCE 集合中,这个 SOURCE 是一个变量,你可以任意的起名字,可以叫 AAA、CCC等等;
这样,我们就可以直接引用这个 SOURCE 就可以了;
add_library(
bspatch_utlis
SHARED
native-lib.cpp
bspatch.c
${SOURCE})
sync 之后,我们执行一下 Make Project,结果发现还是报错:
发现,其实还是找不到 bzlib.h 我们明明已经引入了,但是还是报错,这个是因为什么呢?这个就是因为 C 和 C++ 在引入头文件的时候,使用 “” 和 <> 的区别,这么我们需要兼容处理下;
# 设置头文件查找路径
include_directories(bzip2)
我们需要告诉编译器,从 bzip2 下查找相关头文件;sync 一下,等待执行结果,我们来 make 下;可以看到,我们这次就 Make 成功了;
这样我们的 bspatch_utils 就引入进来了,怎么调用这个 so 呢?我们来写一个 Java 调用层;
调用 bspatch_utils
public class BsPatchUtils {
static {
System.loadLibrary("bspatch_utlis");
}
public static native int patch();
}
我们来写一个 Java 层调用的类,加载我们创建的 so,并通过 native 关键字来调用这个 so 中的 patch 方法;
patch 方法默认是不存在的,我们通过 AS 的提示,可以在 native-lib.cpp 中生成这个方法的实现;
extern "C"
JNIEXPORT jint JNICALL
Java_com_example_bsdiff_BsPatchUtils_patch(JNIEnv *env, jclass clazz) {
// TODO: implement patch()
}
因为我们调用 patch 逻辑,需要旧的 apk 文件,新的 apk 文件,以及对应的 patch 文件,所以 Java 层的方法修改如下:
public static native int patch(String oldApk, String newApk, String patch);
native_lib.cpp 中方法生成如下:
extern "C"
JNIEXPORT jint JNICALL
Java_com_example_bsdiff_BsPatchUtils_patch(JNIEnv *env, jclass clazz, jstring old_apk,
jstring new_apk, jstring patch) {
// TODO: implement patch()
}
我们需要在 Java_com_example_bsdiff_BsPatchUtils_patch 这个方法中调用 bspatch.c 中的 main 方法来实现 patch 的逻辑;
因为 main 方法是主入口,所以我们需要调用这个 main 方法,但是 main 其实在 Java C++ 等都属于关键字,我们修改 bspatch.c 中的 main 方法为另一个名字,我们这里把它改成 executePatch;
然后,我们需要在 native-lib.cpp 中引入这个方法;
extern "C" {
extern int executePatch(int argc, char *argv[]);
}
因为是 cpp 文件调用 c 文件中的方法,这里我们使用兼容模式来执行;接着我们来调用这个函数,以及传递相应的参数进去
extern "C" {
extern int executePatch(int argc, char *argv[]);
}
extern "C"
JNIEXPORT jint JNICALL
Java_com_enjoy_dexdiff_BsPatchUtils_patch(JNIEnv *env, jclass clazz, jstring old_apk,
jstring new_apk, jstring patch) {
// bspatch oldfile newfile patchfile
int args = 4;
char *argv[args];
argv[0] = "bspatch";
argv[1] = (char *) (env->GetStringUTFChars(old_apk, 0));
argv[2] = (char *) (env->GetStringUTFChars(new_apk, 0));
argv[3] = (char *) (env->GetStringUTFChars(patch, 0));
//此处 executePatch() 就是上面我们修改出的
int result = executePatch(args, argv);
// 用完之后 release 释放
env->ReleaseStringUTFChars(old_apk, argv[1]);
env->ReleaseStringUTFChars(new_apk, argv[2]);
env->ReleaseStringUTFChars(patch, argv[3]);
__android_log_print(ANDROID_LOG_ERROR,"diff","==%s==%s==%s==%d",argv[1] ,argv[2] ,argv[3],result );
return result;
}
然后 我们接下来在 MainActivity 中调用这个 patch 逻辑;
executePatch
调用非常简单,直接贴代码
fun patch(view: View?) {
val newFile = File(getExternalFilesDir("apk"), "app.apk")
val patchFile = File(getExternalFilesDir("apk"), "patch.apk")
val result = BsPatchUtils.patch(applicationInfo.sourceDir, newFile.absolutePath, patchFile.absolutePath)
if (result == 0) {
install(newFile)
}
}
private fun install(file: File) {
val intent = Intent(Intent.ACTION_VIEW)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // 7.0+以上版本
val apkUri = FileProvider.getUriForFile(
this,
"$packageName.fileprovider", file
)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
} else {
intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
}
startActivity(intent)
}
我们运行整个程序,先将 1.0 的代码安装到手机上,然后修改 1.0 的代码,build 出新的 apk,然后用旧的 apk 和新的 apk 产出 patch 包,然后将 patch 包 push 到指定的目录;
我们最终的运行结果:
Dex文件
dex 是 Android 系统的可执行文件,包含应用程序的全部操作指令以及运行时数据。将原来每个 class 文件都有的共有信息合成一体,这样减少了 class 的冗余;
查看 dex 的格式,我们可以借助 010Editor 来查看想要查看的文件格式;
dex 的生成,我们可以借助命令行来实现;首先需要将 .java 文件编译成 .class 文件,借助 javac xxx.java
javac xxx.java
然后使用 sdk 中提供的 dx 工具来将 class 文件编译成 dex 文件
dx --dex --output=xxx.dex xxx.class
然后就可以将生成的 dex 文件,在 010Editor 中打开查看其格式了;
一个完整的 dex 文件由这几部分构成
它是由文件头、索引区、数据区来构成;
文件头
文件头中有文件的魔数、文件的总长度、文件的校验码;而文件的格式(dex)就是由『魔数』来确定的;
文件头中的所有数据,可以看到第一个就是 magic(魔数)
可以看到魔数它是由 三个字节 + 一个字节的换行符 + 三个字节的版本号 + 0 来构成的,它是一个固定的数据,由 8 个字节组成,
dex 文件头除了第一个魔数,还有 checksum(校验码)、signature【20】(20字节的签名),unit file_size(unit 类型的文件长度)
索引区
索引区中有字符串索引、方法的定义索引、类中的成员属性、方法的索引
我们来看下字符串索引中具体是什么?
可以看到,这个 dex 中有 21 个字符串
每一个字符串都是 4 个字节,这 4 个字节代表一个索引,这个四个字节,在 Java 中就是一个 int,比如说,这个四个字节读取到的数据转换成 int 后是 100,这个 100 就是索引的意思,代表着你从整个文件的第 0 个字节开始读,读到第 100 个字节,从第 100 个字节开始,就是我们的字符串,也可以理解为一个偏移量;读取到偏移值后,再去根据偏移值定位到对应的位置,
Dex Header 解析示例
Header 解析,本质上就是文件IO操作,实现起来很简单,就是根据 010Editor 读取出来的格式读取不同的字节即可;
public class DexHeaderParser {
public void readDexHeader() {
try {
FileInputStream fis = new FileInputStream("src/source/apk/temp/classes.dex");
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int len;
while ((len = fis.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
byte[] bytes = bos.toByteArray();
ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
long magic = byteBuffer.getLong();
long checksum = byteBuffer.getInt();
byte[] signature = new byte[20];
byteBuffer.get(signature);
int fileSize = byteBuffer.getInt();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
其他的数据读取,照着格式写就可以;
DexDiff
DexDiff 是微信结合 Dex 文件格式设计的一个专门针对Dex的差分算法。根据 Dex 的文件格式,对两个 Dex 中每一项数据进行差分记录;
DexDiff 的差分算法更稳定,bsdiff 是基于二进制的,生成的产物不稳定,有时候大,有时候小,而 DexDiff 会比较稳定,它充分利用了 dex 的特点,它会对比两个 dex 中索引区、数据区的每一项数据的差分,并记录下来;
DexDiff 如何对比
这里以 string_ids 为例来进行对比差分,对照两个 dex 文件字符串数据(Dex中数据必须排序):oldDex 与 newDex
oldDex 中有 a b c 三个字符串,newDex 中有 b c e 三个字符串,用肉眼可以看出,oldDex 中的 a 需要标记为DELETE,newDex 中的 e 需要标记为 ADD,依靠这两个数据,结合 oldDex,把 oldDex 中的 a 删除,并把 e 添加到 oldDex 中;
具体的比较标记逻辑是:
oldDex 中 a 与 newDex 中的 b 对比,“a”.compareTo(“b”) < 0 : oldDex 中的 a 标记为:del,oldIndex++ 继续对比;
此时,newIndex = 0; oldIndex = 1; newCount = 3; oldCount=3;
然后 oldDex中 b 与newDex中的 b 对比,“b”.compareTo(“b”) == 0 ,不处理。oldIndex++,newIndex++;
此时,newIndex = 1; oldIndex = 2; newCount = 3; oldCount=3;
接着 oldDex 中 c 与 newDex 中的 c 对比,“c”.compareTo(“c”) == 0 ,不处理。oldIndex++,newIndex++;
到此,oldIndex = 3 = oldCount,newIndex = 2 < oldCount;
因此 newDex 剩余的 Item 全部记为:add;
然后将这些差分信息,写入到一个新的 dex 中,这就是生成的 patch 包;
好了,今天的讲解就到这里吧~~
下一章预告
AMS
欢迎三连
来都来了,点个关注,点个赞吧,你的支持是我最大的动力~~~