如何应对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

欢迎三连


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

相关推荐
与衫几秒前
掌握嵌套子查询:复杂 SQL 中 * 列的准确表列关系
android·javascript·sql
金灰1 分钟前
HTML5--裸体回顾
java·开发语言·前端·javascript·html·html5
菜鸟一皓2 分钟前
IDEA的lombok插件不生效了?!!
java·ide·intellij-idea
爱上语文5 分钟前
Java LeetCode每日一题
java·开发语言·leetcode
bug菌28 分钟前
Java GUI编程进阶:多线程与并发处理的实战指南
java·后端·java ee
程序猿小D41 分钟前
第二百六十九节 JPA教程 - JPA查询OrderBy两个属性示例
java·开发语言·数据库·windows·jpa
极客先躯2 小时前
高级java每日一道面试题-2024年10月3日-分布式篇-分布式系统中的容错策略都有哪些?
java·分布式·版本控制·共识算法·超时重试·心跳检测·容错策略
夜月行者2 小时前
如何使用ssm实现基于SSM的宠物服务平台的设计与实现+vue
java·后端·ssm
程序猿小D2 小时前
第二百六十七节 JPA教程 - JPA查询AND条件示例
java·开发语言·前端·数据库·windows·python·jpa
潘多编程2 小时前
Java中的状态机实现:使用Spring State Machine管理复杂状态流转
java·开发语言·spring