Android 增量更新

654 阅读7分钟

我们知道随着功能不断增加,apk 的体积也会不断增大。如果每次更新都需要用户下载全新的 apk 覆盖用户手机老的版本的话,会浪费用户的流量,也会增加服务器带宽。要想实现此需求的话,就需要了解一下 bisdiff/bspatch 。顾名思义,diff 就是通过算法计算两个文件得到差异包,patch 就是补丁(经过 diff 后的差异包),可以通过 patch 将源文件和补丁文件组合成新的文件,使得用户无需下载全新的文件,而只下载补丁文件就可得到新的文件啦!

补丁文件应该放在服务器端使用,用户端通过正常的更新方式去下载补丁文件。下面的实现方案都在本地,不模拟从服务器下载补丁文件的流程。笔者是 Mac OS,读者可以使用 linux 系统来测试,比如 Ubuntu、Centos 等等。

Mac 下载 bsdiff/bspatch

下载链接 bsdiff/bspatch,当前下载的版本是 4.3。下载到本地后解压可看到如下文件:

可以看到其实就只有两个 C 源文件,还提供了 Makefile 文件,既然提供了 Makefile 源文件,那么我们就可以执行 make 命令。如图所示:

这是因为 Makefile 文件中,命令前面没有使用 tab 键,这个是 makefile 的语法规则。如图所示:

修改后在执行就可以看到生成了可执行文件 bspatch、bsdiff

当然如果是 Mac 系统,你可能还会遇到一个报错,找不到 u_char。这个时候需要在 bspatch.c 中加入

#ifdef __APPLE__ 
#include <sys/types.h> 
#endif

到此环境就已经配置完毕,我们接下来看 Android 如何实现。

Android 中实现增量更新

  1. 我们新建一个 Android 工程,这里我选择的是默认的空白工程,当然你也可以使用支持 C/C++ 的工程创建。

  1. 添加 bspatch 源码, 不需要 bsdiff,因为 Android 不需要实现生成补丁,只需要根据补丁文件合成新的 apk 即可。 首先分析一下 bspatch.c

#include <bzlib.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <err.h>
#include <unistd.h>
#include <fcntl.h>

.... 省略...

我们都知道一般情况下尖括号<> 都是系统的头文件,但是这里有个特殊的地方, 就是 #include <bzlib.h>, 这个头文件系统并不存在,而需要我们引入它的源码。它的源码下载地址 bzip2, 直接搜索关键字 bzip2 然后选择,如图所示:

将其解压后的文件下图所示:

可以看到有很多文件,既有 C 源文件,也有一些其他文件,当然我们这里只关心 C 源文件。可是也发现有很多文件,一个办法就是将其全部拷入,另一种方式就是查看 Makefile 文件,看看其如何构建的。OK, 那我们就来看看 Makefile

SHELL=/bin/sh

# To assist in cross-compiling
# 交叉编译相关的工具
CC=gcc
AR=ar
RANLIB=ranlib
LDFLAGS=

BIGFILES=-D_FILE_OFFSET_BITS=64
# 传递给编译器的指令
CFLAGS=-Wall -Winline -O2 -g $(BIGFILES)

# Where you want it installed when you do 'make install'
# 将其安装到 /url/local 下
PREFIX=/usr/local


# OBJS 变量,这里是关键,可以看到 ***.o 文件其实就是通过 .c 源文件编译得到,这里他们就会当作一个个目标来用。
OBJS= blocksort.o  \
      huffman.o    \
      crctable.o   \
      randtable.o  \
      compress.o   \
      decompress.o \
      bzlib.o

# 一般情况下,开源项目都会在  Makefile 中提供 all 目标,它告诉需要那些目标来构建最终的可执行文件
all: libbz2.a bzip2 bzip2recover test

# bzip2 目标依赖 libbz2.a  bzip2.o
bzip2: libbz2.a bzip2.o
	# 这里就调用了 CC 编译器以及指定一些参数,还有链接库
	$(CC) $(CFLAGS) $(LDFLAGS) -o bzip2 bzip2.o -L. -lbz2

bzip2recover: bzip2recover.o
	$(CC) $(CFLAGS) $(LDFLAGS) -o bzip2recover bzip2recover.o

# 根据 OBJS 生成 libbz2.a
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

check: test
test: bzip2
	@cat words1
	./bzip2 -1  < sample1.ref > sample1.rb2
	./bzip2 -2  < sample2.ref > sample2.rb2
	./bzip2 -3  < sample3.ref > sample3.rb2
	./bzip2 -d  < sample1.bz2 > sample1.tst
	./bzip2 -d  < sample2.bz2 > sample2.tst
	./bzip2 -ds < sample3.bz2 > sample3.tst
	cmp sample1.bz2 sample1.rb2 
	cmp sample2.bz2 sample2.rb2
	cmp sample3.bz2 sample3.rb2
	cmp sample1.tst sample1.ref
	cmp sample2.tst sample2.ref
	cmp sample3.tst sample3.ref
	@cat words3

# 安装到 /usr/local/bin
install: bzip2 bzip2recover
	if ( test ! -d $(PREFIX)/bin ) ; then mkdir -p $(PREFIX)/bin ; fi
	if ( test ! -d $(PREFIX)/lib ) ; then mkdir -p $(PREFIX)/lib ; fi
	if ( test ! -d $(PREFIX)/man ) ; then mkdir -p $(PREFIX)/man ; fi
	if ( test ! -d $(PREFIX)/man/man1 ) ; then mkdir -p $(PREFIX)/man/man1 ; fi
	if ( test ! -d $(PREFIX)/include ) ; then mkdir -p $(PREFIX)/include ; fi
	cp -f bzip2 $(PREFIX)/bin/bzip2
	cp -f bzip2 $(PREFIX)/bin/bunzip2
	cp -f bzip2 $(PREFIX)/bin/bzcat
	cp -f bzip2recover $(PREFIX)/bin/bzip2recover
	chmod a+x $(PREFIX)/bin/bzip2
	chmod a+x $(PREFIX)/bin/bunzip2
	chmod a+x $(PREFIX)/bin/bzcat
	chmod a+x $(PREFIX)/bin/bzip2recover
	cp -f bzip2.1 $(PREFIX)/man/man1
	chmod a+r $(PREFIX)/man/man1/bzip2.1
	cp -f bzlib.h $(PREFIX)/include
	chmod a+r $(PREFIX)/include/bzlib.h
	cp -f libbz2.a $(PREFIX)/lib
	chmod a+r $(PREFIX)/lib/libbz2.a
	cp -f bzgrep $(PREFIX)/bin/bzgrep
	ln -s -f $(PREFIX)/bin/bzgrep $(PREFIX)/bin/bzegrep
	ln -s -f $(PREFIX)/bin/bzgrep $(PREFIX)/bin/bzfgrep
	chmod a+x $(PREFIX)/bin/bzgrep
	cp -f bzmore $(PREFIX)/bin/bzmore
	ln -s -f $(PREFIX)/bin/bzmore $(PREFIX)/bin/bzless
	chmod a+x $(PREFIX)/bin/bzmore
	cp -f bzdiff $(PREFIX)/bin/bzdiff
	ln -s -f $(PREFIX)/bin/bzdiff $(PREFIX)/bin/bzcmp
	chmod a+x $(PREFIX)/bin/bzdiff
	cp -f bzgrep.1 bzmore.1 bzdiff.1 $(PREFIX)/man/man1
	chmod a+r $(PREFIX)/man/man1/bzgrep.1
	chmod a+r $(PREFIX)/man/man1/bzmore.1
	chmod a+r $(PREFIX)/man/man1/bzdiff.1
	echo ".so man1/bzgrep.1" > $(PREFIX)/man/man1/bzegrep.1
	echo ".so man1/bzgrep.1" > $(PREFIX)/man/man1/bzfgrep.1
	echo ".so man1/bzmore.1" > $(PREFIX)/man/man1/bzless.1
	echo ".so man1/bzdiff.1" > $(PREFIX)/man/man1/bzcmp.1

clean: 
	rm -f *.o libbz2.a bzip2 bzip2recover \
	sample1.rb2 sample2.rb2 sample3.rb2 \
	sample1.tst sample2.tst sample3.tst

# 目标依赖,通过 CC 命令生成, 也就是主要使用了如下的几个源文件
blocksort.o: blocksort.c
	@cat words0
	$(CC) $(CFLAGS) -c blocksort.c
huffman.o: huffman.c
	$(CC) $(CFLAGS) -c huffman.c
crctable.o: crctable.c
	$(CC) $(CFLAGS) -c crctable.c
randtable.o: randtable.c
	$(CC) $(CFLAGS) -c randtable.c
compress.o: compress.c
	$(CC) $(CFLAGS) -c compress.c
decompress.o: decompress.c
	$(CC) $(CFLAGS) -c decompress.c
bzlib.o: bzlib.c
	$(CC) $(CFLAGS) -c bzlib.c
bzip2.o: bzip2.c
	$(CC) $(CFLAGS) -c bzip2.c
bzip2recover.o: bzip2recover.c
	$(CC) $(CFLAGS) -c bzip2recover.c

.... 省略 ....


简单分析了下 Makefile,我们可以知道需要的源文件有如下的几个, 如图所示:

但是我们不需要 bzip2.c, 因为不需要调用 bzip2 来压缩文件。只需要将如下的导入到 Android Studio 中即可,如图所示:

笔者这里用的是 ndk-build 的方式来进行构建,当然你也可以使用 CMamke 的方式。Android.mk 配置如下:

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

# 导入 bzlib 下所有的头文件
LOCAL_C_INCLUDES := bzlib

# 模块名称
LOCAL_MODULE := bspatch

# 如果换行,需要用换行符\, 然后前面必须要有一个 tab 键
LOCAL_SRC_FILES := bspatch_native.cpp bzlib/bspatch.c bzlib/blocksort.c bzlib/huffman.c bzlib/crctable.c bzlib/randtable.c bzlib/compress.c bzlib/decompress.c bzlib/bzlib.c

# 链接系统的 log 日志库
LOCAL_LDLIBS := -llog

# 生成动态库
include $(BUILD_SHARED_LIBRARY)

Application.mk 文件只有一行配置,也可以删掉这个文件,在app 下的 build.gradle 中配置过滤。

# 生成 armeabi-v7a 平台
APP_ABI := armeabi-v7a

配置 app 下的 build.gradle 文件

android {
    .....

    externalNativeBuild {
        ndkBuild {
            // 必须要加入这行,指定 ndk-build 查找到 Andorid.mk 路径
            path 'src/main/jni/Android.mk'
        }
    }
}

点击 Build 下 Refresh Linked C++ Projects, 如图:

如果看到头文件都不报红,也可以正常运行起来就可以接着下一步。

  1. 编写本地方法,需要通过补丁生成新的 apk 的命令格式为 bspatch oldapk newapk patch

那我们本地方法的的参数也就可以确定了,方法声明如下:

public native void generateNewApkByPatch(String oldApkFile, String newApkFile, String patchFile);

本地方法如何生成呢? 首先读者需要了解以下 JNI 头文件规则,不了解的可以看我的其他文章。这里直接给出头文件定义。


#include <jni.h>

extern "C"
JNIEXPORT void JNICALL
Java_com_hxj_bsdiffdemo_MainActivity_generateNewApkByPatch(JNIEnv *env, jobject jobj,
        jstring old_apk_file_, jstring new_apk_file_, jstring patch_file_);



void Java_com_hxj_bsdiffdemo_MainActivity_generateNewApkByPatch(JNIEnv *env, jobject jobj,
        jstring old_apk_file_, jstring new_apk_file_, jstring patch_file_) {
    const char *old_apk_file = env->GetStringUTFChars(old_apk_file_, NULL);
    const char *new_apk_file = env->GetStringUTFChars(new_apk_file_, NULL);
    const char *patch_file = env->GetStringUTFChars(patch_file_, NULL);

    // 执行 dspatch 操作
    //TODO 

    env->ReleaseStringUTFChars(old_apk_file_, old_apk_file);
    env->ReleaseStringUTFChars(new_apk_file_, new_apk_file);
    env->ReleaseStringUTFChars(patch_file_, patch_file);
}

请务必记得 GetStringUTFChars 和 ReleaseStringUTFChars 成对出现, 防止内存泄漏。

接下来我们只要调用 bspatch 提供的函数即可,我们可以知道它有入口函数 main, 并且提供了两个参数

int main(int argc,char * argv[])
{
	FILE * f, * cpf, * dpf, * epf;
	BZFILE * cpfbz2, * dpfbz2, * epfbz2;
	int cbz2err, dbz2err, ebz2err;
	int fd;
	ssize_t oldsize,newsize;
	ssize_t bzctrllen,bzdatalen;
	u_char header[32],buf[8];
	u_char *old, *new;
	off_t oldpos,newpos;
	off_t ctrl[3];
	off_t lenread;
	off_t i;

    // 告诉我们数组指针长度必须为 4
	if(argc!=4) errx(1,"usage: %s oldfile newfile patchfile\n",argv[0]);

	/* Open patch file */
	if ((f = fopen(argv[3], "r")) == NULL)
		err(1, "fopen(%s)", argv[3]);

    .....

	return 0;
}

知道了需要传入的参数,那么我们就将 JNI 本地函数修改,如下所示:


#include <jni.h>

extern int main(int argc,char * argv[]);

extern "C"
JNIEXPORT void JNICALL
Java_com_hxj_bsdiffdemo_MainActivity_generateNewApkByPatch(JNIEnv *env, jobject jobj,
        jstring old_apk_file_, jstring new_apk_file_, jstring patch_file_);



void Java_com_hxj_bsdiffdemo_MainActivity_generateNewApkByPatch(JNIEnv *env, jobject jobj,
        jstring old_apk_file_, jstring new_apk_file_, jstring patch_file_) {
    const char *old_apk_file = env->GetStringUTFChars(old_apk_file_, NULL);
    const char *new_apk_file = env->GetStringUTFChars(new_apk_file_, NULL);
    const char *patch_file = env->GetStringUTFChars(patch_file_, NULL);

    // 定一个数组指针,里面存放都是 char * 指针
    char *args[4];

    // 拼接命令, 格式为: bspatch old_apk_file new_apk_file patch_file
    args[0] = (char *)"bspatch";
    args[1] = (char *) old_apk_file;
    args[2] = (char *) new_apk_file;
    args[3] = (char *) patch_file;

    main(4, args);

    env->ReleaseStringUTFChars(old_apk_file_, old_apk_file);
    env->ReleaseStringUTFChars(new_apk_file_, new_apk_file);
    env->ReleaseStringUTFChars(patch_file_, patch_file);
}

  1. java 层函数实现, 前面完成了 JNI 本地函数编写,接下来就就是 java 层业务逻辑处理啦!这一层逻辑就不赘述,详情请结合注释看代码:
public class MainActivity extends AppCompatActivity {

    private Button mBtnUpdate;

    private TextView mTvVersion;

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

    public native void generateNewApkByPatch(String oldApkFile, String newApkFile, String patchFile);

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mBtnUpdate = findViewById(R.id.btn_update);
        mTvVersion = findViewById(R.id.tv_version);

        PackageManager packageManager = getPackageManager();
        try {
            PackageInfo packageInfo = packageManager.getPackageInfo(getPackageName(), 0);
            String versionName = packageInfo.versionName;
            Log.i("James", "onCreate versionName: " + versionName);
            mTvVersion.setText("当前的版本:" + versionName);
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }

        mBtnUpdate.setOnClickListener(new View.OnClickListener() {

            @Override
            public void onClick(View v) {
                // 检查是否有写 sdcard 权限
                if (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
                        != PackageManager.PERMISSION_GRANTED) {
                    if (ActivityCompat.shouldShowRequestPermissionRationale(MainActivity.this,
                            Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
                        // 自定义提示框.
                    } else {
                        ActivityCompat.requestPermissions(MainActivity.this,
                                new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
                                100);
                    }
                } else {
                    update();
                }
            }
        });
    }

    private void update() {

        new UpdateApkAsyckTask().execute();
    }

    class UpdateApkAsyckTask extends AsyncTask<Void, Void, File> {

        @Override
        protected void onPreExecute() {
            super.onPreExecute();
        }

        @Override
        protected File doInBackground(Void... voids) {
            // 获取当前 apk 安装的路径.
            String oldApkFilePath = getApplicationInfo().sourceDir;

            // 新 apk 所在目录.
            File newApkFile = new File(Environment.getExternalStorageDirectory(), "test_new.apk");
            boolean newApkFileExist = false;
            if (!newApkFile.exists()) {
                try {
                    newApkFileExist = newApkFile.createNewFile();
                } catch (IOException e) {
                    newApkFileExist = false;
                    e.printStackTrace();
                }
            } else {
                newApkFileExist = true;
            }

            if (!newApkFileExist) {
                Log.e("James", "doInBackground new apk file 文件不存在.");
                return null;
            }

            // 服务器下载后的补丁文件
            File patchFile = new File(Environment.getExternalStorageDirectory(), "test.patch");
            if (!patchFile.exists()) {
                Log.e("James", "doInBackground 暂未发现新版本.");
                return null;
            }

            // 获取新的 apk 安装路径.
            String newApkFilePath = newApkFile.getAbsolutePath();

            // 获取补丁文件路径
            String patchFilePath = patchFile.getAbsolutePath();

            // 调用 JNI 函数生成新的 apk 
            generateNewApkByPatch(oldApkFilePath, newApkFilePath, patchFilePath);

            return newApkFile;
        }

        @Override
        protected void onPostExecute(File file) {

            if (file == null || !file.exists()) return;

            Log.i("James", "onPostExecute: " + file.getTotalSpace());
            // 安装 apk, 请注意 7.0 以上需要 authorities 
            Intent intent = new Intent(Intent.ACTION_VIEW);
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            if (Build.VERSION.SDK_INT >= 24) {
                //Android 7.0及以上
                // 参数2 清单文件中provider节点里面的authorities ; 参数3  共享的文件,即apk包的file类
                Uri apkUri = FileProvider.getUriForFile(MainActivity.this,
                        getApplicationInfo().packageName + ".provider", 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 并且界面颜色为白色。

生成补丁包

通过 adb 工具上传到 sdcard 目录,常用命令如下:

adb devices  查看当前连接设备

adb shell 进入到手机 shell 环境, 如果有如果手机连接,可以使用 adb -s 设备名 shell 进入

adb install -r xxx.apk 强制安装,去掉 r 普通安装

adb push  abc.txt  /sdcard/   将 abc.txt 上传到 /sdcard/目录下

上传到 sdcard 下,如图所示:

最后一步,点击更新按钮,可以看到开始安装

点击打开后,可以看到版本为 2.0, 界面背景也变了颜色。

最主要的是,test.patch 很小,只有几百k。

好了,到此就已经全部介绍完毕。其实还算简单的,需要一些 NKD 开发基础,ndk-build 工具的使用,以及 Makefile 的语法。

需要代码的请 点击