查看原文
其他

Android7.0增量更新完整方案及踩坑之旅

2017-08-14 爱鸟人士 终端研发部


前言介绍

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   

微信公众号:终端研发部


            

这里学到的不仅仅技术

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存