简介
什么是增量更新呢?增量更新可以帮助我们减少用户更新apk所耗费的流量。
具体的做法是,在老版本apk和新版本apk中,差分出这两个apk文件之间,不同的部分,得到一个patch(补丁)文件。
比如我们之前的apk是10M,新的apk是12M,一般情况下,差分出来的补丁文件的大小在2M左右。
因此,用户在更新apk时,就只需要下载这2M的patch文件,通过app将patch文件和旧apk合成,就可以得到新的apk。
增量更新的步骤
我们来总结一下,
【服务器端】
我们需要通过【diff(差分)】操作,得到patch文件。
【app端】
我们需要从服务器端下载patch文件,并将其与旧apk文件合并,得到新版本的apk文件,然后提示用户安装。
预备环境
Mac 10.12.2(Linux)
Android Studio 2.3.3
通过SDK Manager安装
bsdiff 差分与合并工具: http://www.daemonology.net/bsdiff
bsdiff 4.3下载地址:http://www.daemonology.net/bsdiff/bsdiff-4.3.tar.gz
bzip2 bsdiff的依赖库: http://www.bzip.org/downloads.html
bzip2-1.0.6下载地址:http://www.bzip.org/1.0.6/bzip2-1.0.6.tar.gz
bsdiff是用c写的库,它提供了两个工具:bsdiff和bspatch,分别用来差分和合并。
在服务器端安装bspatch
首先我们将下载好的两个包bsdiff-4.3.tar.gz和bzip2-1.0.6.tar.gz解压到同一个目录下,这里我们仅保留bsdiff的MakeFile文件。
然后我们打开MakeFile文件,在CFLAGS中添加-Du_char=”unsigned char”,并且【重要】,
在第13行和15行前面按Tab键,使得这两行代码向后缩进一格。
保存文件之后,我们在此目录下执行cmake命令,此时应该会编译得到bsdiff和bspatch两个可执行程序,服务器端我们只需要bspatch。
我们可以在终端中使用cp bspatch /usr/local/bin
将bspatch移动到用户应用目录下,之后就可以直接在终端中使用它。
bspatch命令有3个参数,
oldfile 旧apk文件路径
newfile 新apk文件路径
patchfile patch文件生成的路径
app端
我们新建一个工程,
注意选择支持C++,之后保持默认选项就好。
然后,我们在布局中添加一个Button,指定text为“执行增量更新”。
删除Android Studio为我们生成的native-lib.cpp文件,然后将刚刚解压出来的那些.c/.h文件复制到cpp目录下,
将AS自动生成的那个CMakeLists.txt文件拷贝到cpp目录下,并且在bzip2目录下再创建一个CMakeLists.txt
形成这样的结构:
编辑bzip2目录下的那个CMakeLists.txt
编辑cpp目录下的CMakeLists.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
cmake_minimum_required(VERSION
3.4.
1)
set(CMAKE_VERBOSE_MAKEFILE
on)
set(CMAKE_CXX_FLAGS
"${CMAKE_CXX_FLAGS} -std=gnu++11 -Wall -DGLM_FORCE_SIZE_T_LENGTH")
set(CMAKE_CXX_FLAGS
"${CMAKE_CXX_FLAGS} -DGLM_FORCE_RADIANS")
set(bzip2_src_DIR
${CMAKE_SOURCE_DIR})
add_subdirectory(
${bzip2_src_DIR}/bzip2)
add_library(
bspatch
SHARED
bspatch.c )
find_library(
log-lib
log )
target_link_libraries(
bspatch
${log-lib} )
|
CMake的语法这里就不再介绍了,有兴趣请自行查阅相关资料。
然后,我们需要修改一下bspatch.c的内容。
在bspatch.c中:
1
2
3
4
5
6
7
8
9
10
11
12
|
...
#include <sys/types.h>
#include <jni.h>
...
#include "bzip2/crctable.c"
#include "bzip2/compress.c"
#include "bzip2/decompress.c"
#include "bzip2/randtable.c"
#include "bzip2/blocksort.c"
#include "bzip2/huffman.c"
...
|
接下来,我们需要编写native方法,来调用差分函数。
在MainActivity.java中添加native方法,以及引入动态库文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
public
class MainActivity extends AppCompatActivity {
...
static {
System.loadLibrary(
"bspatch");
}
* Native方法 合并更新文件
*
@param oldAPKPath 原APK路径
*
@param newAPKPath 要生成的新APK路径
*
@param patchPath 增量更新补丁包路径
*
@return 成功返回 0
*/
public native int patch(String oldAPKPath, String newAPKPath, String patchPath);
}
|
然后回到bspatch.c文件中,添加jni方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
JNIEXPORT jint JNICALL
Java_cn_xiaoniaojun_diff_MainActivity_patch
(JNIEnv *env, jobject instance, jstring oldAPKPath_,
jstring newAPKPath_, jstring patchPath_) {
const
char *oldAPKPath = (*env)->GetStringUTFChars(env, oldAPKPath_,
0);
const
char *newAPKPath = (*env)->GetStringUTFChars(env, newAPKPath_,
0);
const
char *patchPath = (*env)->GetStringUTFChars(env, patchPath_,
0);
int argc =
4;
char* argv[
4];
argv[
0] =
"bspatch";
argv[
1] = oldAPKPath;
argv[
2] = newAPKPath;
argv[
3] = patchPath;
int ret = bspatch_main(argc, argv);
(*env)->ReleaseStringUTFChars(env, oldAPKPath_, oldAPKPath);
(*env)->ReleaseStringUTFChars(env, newAPKPath_, newAPKPath);
(*env)->ReleaseStringUTFChars(env, patchPath_, patchPath);
}
|
注意,这里用到了一个小技巧,要把main()函数修改成patch_main(),才可以调用。
OK,native方法写好了,接下来我们就来编写Java代码,实现增量合并更新。
完整的MainActivity.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
|
public
class MainActivity extends AppCompatActivity {
public
static
final String SDCARD_PATH
= Environment.getExternalStorageDirectory() + File.separator;
public
static
final String PATCH_FILE =
"old-to-new.patch";
public
static
final String NEW_APK_FILE =
"latest.apk";
static {
System.loadLibrary(
"bspatch");
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button btnIncrementalUpdate = (Button) findViewById(R.id.btn_incremental_update);
btnIncrementalUpdate.setOnClickListener(
new View.OnClickListener() {
@Override
public void onClick(View v) {
new IncrementalUpdateTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
});
LogI(getPatchFilePath());
}
private
class IncrementalUpdateTask extends AsyncTask<Void, Void, Boolean> {
@Override
protected Boolean doInBackground(Void... params) {
String oldApkPath = SDCARD_PATH +
"oldapk.apk";
File oldApkFile =
new File(oldApkPath);
File patchFile =
new File(getPatchFilePath());
if (oldApkFile.exists() && patchFile.exists()) {
LogI(
"正在合并增量文件...");
String newApkPath = getNewApkFilePath();
patch(oldApkPath, newApkPath, getPatchFilePath());
LogI(
"增量文件的MD5值为:" + SignUtils.getMd5ByFile(patchFile));
LogI(
"新文件的MD5值为:" + SignUtils.getMd5ByFile(
new File(newApkPath)));
return
true;
}
LogI(
"找不到补丁文件");
return
false;
}
@Override
protected void onPostExecute(Boolean result) {
super.onPostExecute(result);
if (result) {
LogI(
"合并成功,开始安装");
ApkUtils.installApk(MainActivity.
this, getNewApkFilePath());
}
else {
LogI(
"合并失败");
}
}
}
private String getNewApkFilePath() {
return SDCARD_PATH + NEW_APK_FILE;
}
private String getPatchFilePath() {
return SDCARD_PATH + PATCH_FILE;
}
private void LogI(String log) {
Log.i(
"MainActivity", log);
}
* Native方法 合并更新文件
*
@param oldAPKPath 原APK路径
*
@param newAPKPath 要生成的新APK路径
*
@param patchPath 增量更新补丁包路径
*
@return 成功返回 0
*/
public native int patch(String oldAPKPath, String newAPKPath, String patchPath);
}
|
工具类:
MD5检测工具类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
public
class SignUtils {
* 判断文件的MD5值是否为指定值
*
@param file1
*
@param md5
*
@return
*/
public static boolean checkMd5(File file1, String md5) {
if(TextUtils.isEmpty(md5)) {
throw
new RuntimeException(
"md5 cannot be empty");
}
if(file1 !=
null && file1.exists()) {
String file1Md5 = getMd5ByFile(file1);
return file1Md5.equals(md5);
}
return
false;
}
* 获取文件的MD5值
*
@param file
*
@return
*/
public static String getMd5ByFile(File file) {
String value =
null;
FileInputStream in =
null;
try {
in =
new FileInputStream(file);
MessageDigest digester = MessageDigest.getInstance(
"MD5");
byte[] bytes =
new
byte[
8192];
int byteCount;
while ((byteCount = in.read(bytes)) >
0) {
digester.update(bytes,
0, byteCount);
}
value = bytes2Hex(digester.digest());
}
catch (Exception e) {
e.printStackTrace();
}
finally {
if (
null != in) {
try {
in.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
}
return value;
}
private static String bytes2Hex(byte[] src) {
char[] res =
new
char[src.length *
2];
final
char hexDigits[] = {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'a',
'b',
'c',
'd',
'e',
'f'};
for (
int i =
0, j =
0; i < src.length; i++) {
res[j++] = hexDigits[src[i] >>>
4 &
0x0f];
res[j++] = hexDigits[src[i] &
0x0f];
}
return
new String(res);
}
}
|
Apk安装工具类
注意,在Android 7.0以上,为了提高私有文件的安全性,面向 Android 7.0 或更高版本的应用私有目录被限制访问 (0700)。此设置可防止私有文件的元数据泄漏,如它们的大小或存在性。
因此,在Android 7以上,在应用之间共享文件受到了很大的限制。
如果使用如下的代码:
1
2
3
4
5
6
7
8
|
public void installApk(File file) {
Intent intent =
new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.setDataAndType(Uri.fromFile(file),
"application/vnd.android.package-archive");
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
|
系统将会抛出一个 FileUriExposedException
异常。
解决办法是,使用FileProvider。
在Manifest文件中,<application></application>
标签之间,添加:
1
2
3
4
5
6
7
8
9
|
<provider
android:authorities=
"cn.xiaoniaojun.fileprovider"
android:name=
"android.support.v4.content.FileProvider"
android:exported=
"false"
android:grantUriPermissions=
"true" >
<meta-data
android:name=
"android.support.FILE_PROVIDER_PATHS"
android:resource=
"@xml/file_paths" />
</provider>
|
以启用FileProvider。注意<meta-data>
标签,我们还需要提供一个资源文件,用来标识需要共享的文件。
在res文件下新建一个xml目录,并添加file_paths.xml文件。
1
2
3
4
|
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<root-path name="latest.apk" path="" />
</paths>
|
这里<root_path>
并未在官方文档中指出,它指代Environment.getExternalStorageDirectory()
目录,
这个目录一般为/storage/emulator/0/
,path为添加在其后的路径,name为文件名。
这里,指明我们的新apk文件在/storage/emulator/0/latest.apk
路径下。
OK,配置完FileProvider后,我们编写ApkUtils.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
public
class ApkUtils {
public static void installApk(Context context, String apkPath) {
File file =
new File(apkPath);
if (file.exists()) {
Uri apkUri = FileProvider.getUriForFile(context,
"cn.xiaoniaojun.fileprovider", file);
Intent intent =
new Intent(Intent.ACTION_VIEW);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(apkUri,
"application/vnd.android.package-archive");
context.startActivity(intent);
}
}
}
|
请大家自行与之前的installApk()方法对比,看看使用FileProvider后,获取文件Uri的方式有怎样的变化。
至此,增量更新的代码就编写完成了。
测试
首先,在当前工程,使用Build->Build Apk生成旧版本apk文件。
然后,我们编辑Button的文本,改为“开始增量更新 V2”。还可以在资源文件中放几张图片,以扩充apk大小,使得增量更新的效果更明显。
改好了之后,再Build Apk一次。
现在,我们得到了两个apk文件,改名为newapk.apk和oldapk.apk,执行dspatch命令,得到old-to-new.patch文件。
我们将oldapk.apk文件和old-to-new.patch文件上传至SDCard根目录下:
在终端中,执行命令($表示本地终端,#表示adb终端):
1
2
3
4
5
|
$ adb shell
$ adb push newapk.apk /storage/emulated/0/
$ adb push old-to-new.patch /storage/emulated/0/
|
最后别忘了给app读取外部存储空间的权限哦!
在manifest中,添加:
1
2
3
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET"/>
|
好了,接下来我们把旧apk安装到模拟器上,然后记得给权限哦!
准备工作完成,启动旧apk,点击“启动增量更新”:
可以看到,点击增量更新后,app自动在后台合成apk并提示用户更新。
转载自:http://xiaoniaojun.cn/2017/03/10/Android-7-%E5%A2%9E%E9%87%8F%E6%9B%B4%E6%96%B0-Mac.html