一、概述
bsdiff 是一个差量更新算法,算法原理是尽可能多的利用 old 文件中已有的内容,尽可能少的加入新的内容来构建 new 文件。通常的做法是对 old 文件和 new 文件做子字符串匹配或使用 hash 技术,提取公共部分,将 new 文件中剩余的部分打包成 patch 包。在 Patch 阶段,用 copying 和 insertion 两个基本操作即可将 old 文件和 patch 包合成 new 文件(需要记录增加的内容及其在文件的偏移地址两个内容)。对于发生更改的部分,直接在该位置生成新的内容,而未更改的部分写 0 表示 new 文件没有变更这里。
bsdiff 为我们提供了两个工具 bsdiff 和 bspatch,前者用来生成差分包,后者可以将 old 文件和 patch 包合并成新文件。基于此,我们用 bsdiff 实现 Android 的增量更新的步骤为:
- 服务器用 bsdiff 生成 oldApk 与 newApk 的差分包 patch
- 终端下载 patch,通过 bspatch 将其与 oldApk 合并成为 newApk
- 安装 newApk 完成增量更新
Demo 不会涉及服务器和网络下载的内容,仅是手动生成 patch 并直接放到手机存储中来模拟这个过程。
二、编译 bsdiff 工具
bsdiff 提供的两个工具 bsdiff 和 bspatch 需要编译 bsdiff 源码才能生成,源码的下载地址:http://www.daemonology.net/bsdiff/,解压后得到 5 个文件:
接下来就是在 Linux 中编译这些源文件了,过程中会出现几次错误,我们一并说下。
运行 make 命令编译这些源码,会直接报错:
[root@iZwz9e9jwy11blescn79jyZ bsdiff-4.3]# make
Makefile:13: *** missing separator. Stop.
打开 Makefile 去看 13 行,发现 install 指令后的 13、15 两行代码处于行首:
CFLAGS += -O3 -lbz2
PREFIX ?= /usr/local
INSTALL_PROGRAM ?= ${INSTALL} -c -s -m 555
INSTALL_MAN ?= ${INSTALL} -c -m 444
all: bsdiff bspatch
bsdiff: bsdiff.c
bspatch: bspatch.c
install:
${INSTALL_PROGRAM} bsdiff bspatch ${PREFIX}/bin
.ifndef WITHOUT_MAN
${INSTALL_MAN} bsdiff.1 bspatch.1 ${PREFIX}/man/man1
.endif
Makefile 的语法规则是指令后的每一行代码都要跟在 TAB 之后,所以这里在 13、15 行代码前加个 TAB 即可解决。
再次 make 又报错:
cc -O3 -lbz2 bsdiff.c -o bsdiff
bsdiff.c:33:10: fatal error: bzlib.h: No such file or directory
#include <bzlib.h>
^~~~~~~~~
compilation terminated.
make: *** [<builtin>: bsdiff] Error 1
缺少头文件<bzlib.h>,需要安装。不同系统的安装方法:
- Ubuntu: apt install libbz2-dev
- Centos: yum -y install bzip2-devel.x86_64
- Mac: brew install bzip2
如果使用的是 Ubuntu 系统,用以上命令安装时可能会提示无法连接到 Ubuntu 服务器,需要添加 DNS 服务器地址,用 sudo vim /etc/resolv.conf 命令添加代码 nameserver 8.8.8.8 即可。
Centos 到这里应该就可以编译成功了,但是 Ubuntu 可能还会报一个编译错误:
cc -O3 -lbz2 bsdiff.c -o bsdiff
/usr/bin/ld: /tmp/ccBe1tk5.o: in function `main':
bsdiff.c:(.text.startup+0x2aa): undefined reference to `BZ2_bzWriteOpen'
/usr/bin/ld: bsdiff.c:(.text.startup+0x888): undefined reference to `BZ2_bzWrite'
/usr/bin/ld: bsdiff.c:(.text.startup+0x8be): undefined reference to `BZ2_bzWrite'
/usr/bin/ld: bsdiff.c:(.text.startup+0x90a): undefined reference to `BZ2_bzWrite'
/usr/bin/ld: bsdiff.c:(.text.startup+0xb5c): undefined reference to `BZ2_bzWriteClose'
/usr/bin/ld: bsdiff.c:(.text.startup+0xbb6): undefined reference to `BZ2_bzWriteOpen'
/usr/bin/ld: bsdiff.c:(.text.startup+0xbde): undefined reference to `BZ2_bzWrite'
/usr/bin/ld: bsdiff.c:(.text.startup+0xc01): undefined reference to `BZ2_bzWriteClose'
/usr/bin/ld: bsdiff.c:(.text.startup+0xc58): undefined reference to `BZ2_bzWriteOpen'
/usr/bin/ld: bsdiff.c:(.text.startup+0xc80): undefined reference to `BZ2_bzWrite'
/usr/bin/ld: bsdiff.c:(.text.startup+0xca3): undefined reference to `BZ2_bzWriteClose'
collect2: error: ld returned 1 exit status
make: *** [<builtin>: bsdiff] Error 1
解决方法是在 Makefile 中添加两行代码:
CFLAGS += -O3 -lbz2
PREFIX ?= /usr/local
INSTALL_PROGRAM ?= ${INSTALL} -c -s -m 555
INSTALL_MAN ?= ${INSTALL} -c -m 444
all: bsdiff bspatch
bsdiff: bsdiff.c
cc bsdiff.c ${CFLAGS} -o bsdiff #增加
bspatch: bspatch.c
cc bspatch.c ${CFLAGS} -o bspatch #增加
install:
${INSTALL_PROGRAM} bsdiff bspatch ${PREFIX}/bin
ifndef WITHOUT_MAN
${INSTALL_MAN} bsdiff.1 bspatch.1 ${PREFIX}/man/man1
:endif
解决完以上错误,终于编译成功了,会生成两个可执行文件 bsdiff 和 bspatch:
三、生成 patch
执行如下命令可以生成补丁文件 patch:
./bsdiff old.apk new.apk patch
将这个 patch 存入手机中,以模拟从服务器下载 patch 的过程。
四、合成新 apk 并安装
Android 这边要将当前运行的 apk 与 patch 通过 bspatch 合并得到新 apk 并安装,Android 使用 bspatch 功能不能像 Linux 那样编译出一个可执行文件然后通过命令执行的方式合并,而是要通过调用源文件 bspatch.c 中的函数实现合并。
1.项目配置
导入文件
首先就是要将 bspatch.c 文件拷贝到 cpp 目录下,该文件依赖的头文件 bzlib.h 是 bzip2 的文件,所以还需要下载 bzip2 解压并导入相关文件。具体需要导入哪些文件,看 Makefile:
libbz2.a: $(OBJS)
rm -f libbz2.a
$(AR) cq libbz2.a $(OBJS)
@if ( test -f $(RANLIB) -o -f /usr/bin/ranlib -o \
-f /bin/ranlib -o -f /usr/ccs/bin/ranlib ) ; then \
echo $(RANLIB) libbz2.a ; \
$(RANLIB) libbz2.a ; \
fi
最终编译出来的是 libbz2.a 文件,过程中需要 OBJS 这个变量:
OBJS= blocksort.o \
huffman.o \
crctable.o \
randtable.o \
compress.o \
decompress.o \
bzlib.o
而 OBJS 表示的是上述多个 .o 文件,这些 .o 文件是编译的中间产物,是由同名的 .c 文件编译出来的。所以上述各个 .o 文件对应的 .c 文件都需要拷贝到项目中,另外它们中用到的头文件也需要一同拷贝。拷贝之后项目当前目录结构如下:
改写 CMakeLists
cmake_minimum_required(VERSION 3.4.1)
# bzip变量表示后面路径下所有的.c文件
file(GLOB bzip bzip2/*.c)
add_library( native-lib
SHARED
native-lib.cpp
# 编译 bspatch.c 和 bzip 变量指向的文件
bspatch.c
${bzip})
find_library( log-lib
log )
target_link_libraries( native-lib
${log-lib} )
这样开始编译后会有一个编译错误:
app\src\main\cpp\bspatch.c:31:10: fatal error: 'bzlib.h' file not found
去看 bspatch.c 文件找第 31 行发现 bzlib.h 是用尖括号的形式引入的:
#include <bzlib.h>
c 语言中用尖括号引入表示该头文件是系统文件,但是很显然 bzlib.h 是我们手动拷贝到项目中的,不是系统文件。这里有两种解决方法:
- 修改 bspatch.c 文件,将 #include <bzlib.h> 修改为 #include “bzlib.h”
- 如不想修改 bspatch.c ,则可以在 CMakeLists 文件中加一句话:include_directories(bzip2),这样就相当于将 bzip2 目录下的文件添加到 <> 的寻找范围之内了。
2.合成新 apk
调用 bspatch.c 的 main() 函数,传入 old.apk、new.apk 和 patch 的路径就可以将 old.apk 和 patch 合并成 new.apk。Java 代码这边用 BsPatcher 完成对 c 的调用:
public class BsPatcher {
static {
System.loadLibrary("native-lib");
}
public static native boolean bsPatch(String oldApk, String patch, String newApk);
}
native-lib.cpp 主要工作就是调用 bspatch.c 的 main():
#include <jni.h>
#include <string>
#include <android/log.h>
extern "C" {
// 兼容 C,拿到 bspatch.c 的 main()
extern int main(int argc, char *argv[]);
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_frank_deltalupdate_BsPatcher_bsPatch(JNIEnv *env, jclass clazz, jstring old_apk,
jstring patch_, jstring new_apk) {
char *oldApk = (char *) (env->GetStringUTFChars(old_apk, 0));
char *patch = (char *) (env->GetStringUTFChars(patch_, 0));
char *newApk = (char *) (env->GetStringUTFChars(new_apk, 0));
// 打个 log 看看获取的路径是否正确
__android_log_print(ANDROID_LOG_ERROR,"oldApk","%s",oldApk);
__android_log_print(ANDROID_LOG_ERROR,"patch","%s",patch);
__android_log_print(ANDROID_LOG_ERROR,"newApk","%s",newApk);
char *argv[] = {"", oldApk, newApk, patch};
int result = main(4, argv);
// 释放
env->ReleaseStringUTFChars(old_apk, oldApk);
env->ReleaseStringUTFChars(patch_, patch);
env->ReleaseStringUTFChars(new_apk, newApk);
// 合并成功返回 true
return result == 0;
}
UI 这边,因为合并文件是耗时操作,所以调用 BsPatcher.bsPatch() 的工作要放在子线程中:
public class MainActivity extends AppCompatActivity {
...
// 点击更新按钮触发
public void update(View view) {
new Thread(new Runnable() {
@Override
public void run() {
// patch 和 new.apk 都会放在下面这个路径中:
// /sdcard/Android/data/包名/files/apk
File newApk = createNewApk();
File patch = new File(getExternalFilesDir("apk"), "patch");
if (BsPatcher.bsPatch(getApplicationInfo().sourceDir, patch.getAbsolutePath(),
newApk.getAbsolutePath())) {
// 如果合并成功就走安装流程
install(newApk);
}
}
}).start();
}
private void install(File apk) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Uri apkUri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", apk);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
} else {
intent.setDataAndType(Uri.fromFile(apk), "application/vnd.android.package-archive");
}
startActivity(intent);
}
private File createNewApk() {
File newApk = new File(getExternalFilesDir("apk"), "new.apk");
if (!newApk.exists()) {
try {
newApk.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
}
return newApk;
}
}
效果图:
注意处理权限问题,不同的系统和环境如果用以上代码测试失败了,可能是没有申请 WRITE_EXTERNAL_STORAGE 和 REQUEST_INSTALL_PACKAGES 权限。