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

前言


image.png

本章主要讲解 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


image.png

当我们创建完一个 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 文件,来修改我们的编译配置;

我们将里面的注释全部删掉,方便我们来看代码;

image.png

重点要关注的是 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 文件可以看到,很多红色的报错已经消失掉了,但是还是会有一些报错,我们来逐一的看下;

image.png

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

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

然后复制到 cpp 目录下:

image.png

然后,我们需要将这些 .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,结果发现还是报错:

image.png

发现,其实还是找不到 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 来查看想要查看的文件格式;

image.png

dex 的生成,我们可以借助命令行来实现;首先需要将 .java 文件编译成 .class 文件,借助 javac xxx.java

javac xxx.java

然后使用 sdk 中提供的 dx 工具来将 class 文件编译成 dex 文件

dx --dex --output=xxx.dex xxx.class

然后就可以将生成的 dex 文件,在 010Editor 中打开查看其格式了;

一个完整的 dex 文件由这几部分构成

image.png

它是由文件头、索引区、数据区来构成;

文件头

文件头中有文件的魔数、文件的总长度、文件的校验码;而文件的格式(dex)就是由『魔数』来确定的;

image.png

文件头中的所有数据,可以看到第一个就是 magic(魔数)

image.png

可以看到魔数它是由 三个字节 + 一个字节的换行符 + 三个字节的版本号 + 0 来构成的,它是一个固定的数据,由 8 个字节组成,

dex 文件头除了第一个魔数,还有 checksum(校验码)、signature【20】(20字节的签名),unit file_size(unit 类型的文件长度)

索引区

索引区中有字符串索引、方法的定义索引、类中的成员属性、方法的索引

image.png

我们来看下字符串索引中具体是什么?

image.png

可以看到,这个 dex 中有 21 个字符串

image.png

每一个字符串都是 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

image.png

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

欢迎三连


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

  • 23
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值