Android7.0增量更新完整方案及踩坑之旅
Android7.0增量更新完整方案及项目实战
CMake的语法这里就不再介绍了,有兴趣请自行查阅相关资料。
爱鸟人士的博客地址:
http://xiaoniaojun.cn/2017/03/10/Android-7-增量更新-Mac.html
今日案例效果图:
简介
什么是增量更新呢?
增量更新可以帮助我们减少用户更新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安装
NDK
CMake
LLDB
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个参数,
1. oldfile 旧apk文件路径
2.newfile 新apk文件路径
3.patchfile patch文件生成的路径
app端
我们新建一个工程
注意选择支持C++,之后保持默认选项就好。
然后,我们在布局中添加一个Button,指定text为“执行增量更新”。
删除Android Studio为我们生成的native-lib.cpp文件,然后将刚刚解压出来的那些.c/.h文件复制到cpp目录下,
将AS自动生成的那个CMakeLists.txt文件拷贝到cpp目录下,并且在bzip2目录下再创建一个CMakeLists.txt
形成这样的结构:
编辑bzip2目录下的那个CMakeLists.txt
#bzip2PROJECT(bzip2)
编辑cpp目录下的CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)# 支持-std=gnu++11set(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( # Specifies the target library.
bspatch # Links the target library to the log library
# included in the NDK.
${log-lib} )
CMake的语法这里就不再介绍了,有兴趣请自行查阅相关资料。
然后,我们需要修改一下bspatch.c的内容。
在bspatch.c中:
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方法:
NIEXPORT 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
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"; // load increment update lib;
static {
System.loadLibrary("bspatch");
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main); // setup incremental update click action
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检测工具类:
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以上,在应用之间共享文件受到了很大的限制。
如果使用如下的代码:
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>标签之间,添加:
<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文件。
<?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:
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终端):
$ adb shell # 进入手机终端
# su # 取得超级权限
$ adb push newapk.apk /storage/emulated/0/ # 上传
$ adb push old-to-new.patch /storage/emulated/0/
# ls /storage/emulated/0 # 可以看到新上传的文件
最后别忘了给app读取外部存储空间的权限哦! 在manifest中,添加:
<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并提示用户更新。
结语
增量更新的过程其实并不复杂。编码过程中会遇到很多的坑,我都已经给出了解决方法。
示例中只给出了从本地取得oldapk和patch的方案,具体项目中可以把包放在服务器上,并且给用户更友好的提示。
博客地址:
http://xiaoniaojun.cn/2017/03/10/Android-7-增量更新-Mac.html
终端研发部提倡: 没有做不到的,只有想不到的。
在这里获得的不仅仅是技术!
让心,在阳光下学会舞蹈
让灵魂,在痛苦中学会微笑
—终端研发部—
如果你觉得此文对您有所帮助,欢迎入群 QQ交流群 :232203809
微信公众号:终端研发部
这里学到的不仅仅技术