如何应对Android面试官 -> Android 如何实现增量更新,Tinker patch包生成核心原理

前言


本章主要讲解 Dex 格式分析以及增量更新实现原理;

增量更新


增量更新:记录版本之间的差异信息,把这个差异信息记录到一个文件里面,将这个文件下发到旧的版本上,实现增量更新,而无需下载完整的新版本;

bsdiff

基于二进制的一个差分算法,不依赖产物,例如一个是 mp3 一个是 mp4 都可以进行差分算法;

官网下载链接: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

arduino 复制代码
defaultConfig {
    externalNativeBuild{
        cmake{
            abiFilters 'x86','armeabi-v7a'
        }
    }
}
css 复制代码
android {
    externalNativeBuild{
        cmake{
            path 'src/main/cpp/CMakeLists.txt'
        }
    }
}

可以看到 android 下的这个配置和 defaultConfig 下的配置是不一样的,android 下的是指定编译文件的地址,你可以修改它,只要能指向你的编译文件即可;

而 defaultConfig 下的 externalNativeBuild 下的是给编译用的,也就是说,我们要编译的目标平台有哪些;

另外,当我们在引入 so 文件的时候,可以通过 ndk 配置,指定要引入的 so 架构包

arduino 复制代码
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 加入进来;

scss 复制代码
add_library(
        bspatch_utlis
        SHARED
        native-lib.cpp
        bspatch.c)

第一个参数表示:我们想要生成的 so 文件名字,最终生成的 so 文件名字就是:libbspatch_utils.so;

同步的我们将 target_link_libraries 方法的第一个参数也修改成 bspatch_utlis

scss 复制代码
target_link_libraries(
        bspatch_utlis
        log)

执行 sync,等待 sync 完成,然后我们进入 bspatch.c 文件可以看到,很多红色的报错已经消失掉了,但是还是会有一些报错,我们来逐一的看下;

可以看到 bspatch 其实依赖了 bzip,但是它找不到 bzip,那么就需要手动的将 bzip 导入进来;我们只需要去官网下载 bzip 的源码,然后导入进来即可;

bzip官方地址:www.bzip.org/

然后复制到 cpp 目录下:

然后,我们需要将这些 .c 文件全部引入进来,批量引入是有技巧的,CMake 给我们提供了一些 API,方便我们批量引入;

scss 复制代码
aux_source_directory()

这个方法需要两个参数,一个传入 bzip2,一个传入 SOURCE; 第一个是 bzip2 的相对目录,第二个表示,把这个 bzip2 下的所有源文件放到一个 SOURCE 集合中,这个 SOURCE 是一个变量,你可以任意的起名字,可以叫 AAA、CCC等等;

这样,我们就可以直接引用这个 SOURCE 就可以了;

scss 复制代码
add_library(
        bspatch_utlis
        SHARED
        native-lib.cpp
        bspatch.c
        ${SOURCE})

sync 之后,我们执行一下 Make Project,结果发现还是报错:

发现,其实还是找不到 bzlib.h 我们明明已经引入了,但是还是报错,这个是因为什么呢?这个就是因为 C 和 C++ 在引入头文件的时候,使用 "" 和 <> 的区别,这么我们需要兼容处理下;

scss 复制代码
# 设置头文件查找路径
include_directories(bzip2)

我们需要告诉编译器,从 bzip2 下查找相关头文件;sync 一下,等待执行结果,我们来 make 下;可以看到,我们这次就 Make 成功了;

这样我们的 bspatch_utils 就引入进来了,怎么调用这个 so 呢?我们来写一个 Java 调用层;

调用 bspatch_utils


csharp 复制代码
public class BsPatchUtils {

    static {
        System.loadLibrary("bspatch_utlis");
    }

    public static native int patch();
}

我们来写一个 Java 层调用的类,加载我们创建的 so,并通过 native 关键字来调用这个 so 中的 patch 方法;

patch 方法默认是不存在的,我们通过 AS 的提示,可以在 native-lib.cpp 中生成这个方法的实现;

arduino 复制代码
extern "C"
JNIEXPORT jint JNICALL
Java_com_example_bsdiff_BsPatchUtils_patch(JNIEnv *env, jclass clazz) {
    // TODO: implement patch()
}

因为我们调用 patch 逻辑,需要旧的 apk 文件,新的 apk 文件,以及对应的 patch 文件,所以 Java 层的方法修改如下:

arduino 复制代码
public static native int patch(String oldApk, String newApk, String patch);

native_lib.cpp 中方法生成如下:

kotlin 复制代码
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 中引入这个方法;

arduino 复制代码
extern "C" {
    extern int executePatch(int argc, char *argv[]);
}

因为是 cpp 文件调用 c 文件中的方法,这里我们使用兼容模式来执行;接着我们来调用这个函数,以及传递相应的参数进去

scss 复制代码
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


调用非常简单,直接贴代码

kotlin 复制代码
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 读取出来的格式读取不同的字节即可;

ini 复制代码
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

欢迎三连


来都来了,点个关注,点个赞吧,你的支持是我最大的动力~~~

相关推荐
张拭心7 分钟前
Android 17 来了!新特性介绍与适配建议
android·前端
SimonKing2 小时前
OpenCode AI辅助编程,不一样的编程思路,不写一行代码
java·后端·程序员
FastBean2 小时前
Jackson View Extension Spring Boot Starter
java·后端
Kapaseker2 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
黄林晴3 小时前
Android17 为什么重写 MessageQueue
android
Seven973 小时前
剑指offer-79、最⻓不含重复字符的⼦字符串
java
皮皮林55112 小时前
Java性能调优黑科技!1行代码实现毫秒级耗时追踪,效率飙升300%!
java
冰_河12 小时前
QPS从300到3100:我靠一行代码让接口性能暴涨10倍,系统性能原地起飞!!
java·后端·性能优化
桦说编程15 小时前
从 ForkJoinPool 的 Compensate 看并发框架的线程补偿思想
java·后端·源码阅读