在客户端开发过程中,我们可能会遇到这样一种需求:点击某个按钮弹出一个弹窗,提示我们可以更新到apk的某个版本,或者我们可以通过服务端接口进行强制更新。在这种需求中,我们是不需要通过应用商店来更新我们的apk的,而是直接在apk内部进行版本更新。这次我们就来看看实现这种应用内更新的几种方式。当然,这种玩法只能在国内玩,海外的话会被Googleplay据审的。如果是海外的应用要更新apk,只能在GooglePlay上上传新版本的包。

全量更新

什么是全量更新呢?举个例子,假设现在用户手机上的apk是1.0版本,如果想要升级到2.0版本,全量更新的处理方式则是把2.0版本的apk全部下载下来进行覆盖安装。那么,我们该如果设计一个合理的全量更新方案呢?

  • 服务端
    需要提供一个接口,这个接口返回来的body中包含新版本的包的下载地址以及该包的md5值用于下载完成之后进行校验用
  • 客户端
    访问该服务端接口,下载新版本的包(其实就是字节流的读写),然后进行覆盖安装

做完上面这2点其实就可以实现一个较为完整的全量更新功能。

客户端核心代码如下:

package com.mvp.myapplication.update;import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Binder;
import android.os.Build;
import android.os.Environment;
import android.os.IBinder;
import android.text.TextUtils;
import android.util.Log;import androidx.core.content.FileProvider;import com.mvp.myapplication.utils.MD5Util;import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;public class UpdateService extends Service {public static final String KEY_MD5 = "MD5";public static final String URL = "downloadUrl";private boolean startDownload;//开始下载public static final String TAG = "UpdateService";private DownloadApk downloadApkTask;private String downloadUrl;private String mMd5;private UpdateProgressListener updateProgressListener;private LocalBinder localBinder = new LocalBinder();public class LocalBinder extends Binder {public void setUpdateProgressListener(UpdateProgressListener listener) {UpdateService.this.setUpdateProgressListener(listener);}}private void setUpdateProgressListener(UpdateProgressListener listener) {this.updateProgressListener = listener;}/*** 获取FileProvider的auth*/private static String getFileProviderAuthority(Context context) {try {for (ProviderInfo provider : context.getPackageManager().getPackageInfo(context.getPackageName(), PackageManager.GET_PROVIDERS).providers) {if (FileProvider.class.getName().equals(provider.name) && provider.authority.endsWith(".update_app.file_provider")) {return provider.authority;}}} catch (PackageManager.NameNotFoundException ignore) {}return null;}private static Intent installIntent(Context context, String path) {Intent intent = new Intent(Intent.ACTION_VIEW);intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);intent.addCategory(Intent.CATEGORY_DEFAULT);if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {Uri fileUri = FileProvider.getUriForFile(context, getFileProviderAuthority(context), new File(path));intent.setDataAndType(fileUri, "application/vnd.android.package-archive");intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);} else {intent.setDataAndType(Uri.fromFile(new File(path)), "application/vnd.android.package-archive");}return intent;}public UpdateService() {}@Overridepublic int onStartCommand(Intent intent, int flags, int startId) {if (!startDownload && intent != null) {startDownload = true;mMd5 = intent.getStringExtra(KEY_MD5);downloadUrl = intent.getStringExtra(URL);downloadApkTask = new DownloadApk(this, mMd5);downloadApkTask.execute(downloadUrl);}return super.onStartCommand(intent, flags, startId);}@Overridepublic IBinder onBind(Intent intent) {return localBinder;}@Overridepublic boolean onUnbind(Intent intent) {return true;}@Overridepublic void onDestroy() {if (downloadApkTask != null) {downloadApkTask.cancel(true);}if (updateProgressListener != null) {updateProgressListener = null;}super.onDestroy();}private static String getSaveFileName(String downloadUrl) {if (downloadUrl == null || TextUtils.isEmpty(downloadUrl)) {return System.currentTimeMillis() + ".apk";}return downloadUrl.substring(downloadUrl.lastIndexOf("/"));}private static File getDownloadDir(UpdateService service) {File downloadDir = null;if (Environment.getExternalStorageDirectory().equals(Environment.MEDIA_MOUNTED)) {downloadDir = new File(service.getExternalCacheDir(), "update");} else {downloadDir = new File(service.getCacheDir(), "update");}if (!downloadDir.exists()) {downloadDir.mkdirs();}return downloadDir;}private void start() {if (updateProgressListener != null) {updateProgressListener.start();}}private void update(int progress) {if (updateProgressListener != null) {updateProgressListener.update(progress);}}private void success(String path) {if (updateProgressListener != null) {updateProgressListener.success(path);}Intent i = installIntent(this, path);startActivity(i);//自动安装stopSelf();}private void error() {if (updateProgressListener != null) {updateProgressListener.error();}stopSelf();}private static class DownloadApk extends AsyncTask<String, Integer, String> {private final String md5;private UpdateService updateService;public DownloadApk(UpdateService service, String md5) {this.updateService = service;this.md5 = md5;}@Overrideprotected void onPreExecute() {super.onPreExecute();if (updateService != null) {updateService.start();}}@Overrideprotected String doInBackground(String... strings) {final String downloadUrl = strings[0];final File file = new File(UpdateService.getDownloadDir(updateService),UpdateService.getSaveFileName(downloadUrl));Log.d(TAG, "download url is " + downloadUrl);Log.d(TAG, "download apk cache at " + file.getAbsolutePath());File dir = file.getParentFile();if (!dir.exists()) {dir.mkdirs();}HttpURLConnection httpConnection = null;InputStream is = null;FileOutputStream fos = null;long updateTotalSize = 0;URL url;try {url = new URL(downloadUrl);httpConnection = (HttpURLConnection) url.openConnection();httpConnection.setConnectTimeout(20000);httpConnection.setReadTimeout(20000);Log.d(TAG, "download status code: " + httpConnection.getResponseCode());if (httpConnection.getResponseCode() != 200) {return null;}updateTotalSize = httpConnection.getContentLength();if (file.exists()) {if (updateTotalSize == file.length()) {// 下载完成if (TextUtils.isEmpty(md5) || MD5Util.getMD5String(file).toUpperCase().equals(md5.toUpperCase())) {return file.getAbsolutePath();}} else {file.delete();}}file.createNewFile();is = httpConnection.getInputStream();fos = new FileOutputStream(file, false);byte buffer[] = new byte[4096];int readSize = 0;long currentSize = 0;while ((readSize = is.read(buffer)) > 0) {fos.write(buffer, 0, readSize);currentSize += readSize;publishProgress((int) (currentSize * 100 / updateTotalSize));}// download success} catch (Exception e) {e.printStackTrace();return null;} finally {if (httpConnection != null) {httpConnection.disconnect();}if (is != null) {try {is.close();} catch (IOException e) {e.printStackTrace();}}if (fos != null) {try {fos.close();} catch (IOException e) {e.printStackTrace();}}}try {if (TextUtils.isEmpty(md5) || MD5Util.getMD5String(file).toUpperCase().equals(md5.toUpperCase())) {return file.getAbsolutePath();}} catch (IOException e) {e.printStackTrace();return file.getAbsolutePath();}Log.e(TAG, "md5 invalid");return null;}@Overrideprotected void onProgressUpdate(Integer... values) {super.onProgressUpdate(values);if (updateService != null) {updateService.update(values[0]);}}@Overrideprotected void onPostExecute(String s) {super.onPostExecute(s);if (updateService != null) {if (s != null) {updateService.success(s);} else {updateService.error();}}}}public static class Builder {private String downloadUrl;private String md5;private ServiceConnection serviceConnection;protected Builder(String downloadUrl) {this.downloadUrl = downloadUrl;}public static Builder create(String downloadUrl) {if (downloadUrl == null) {throw new NullPointerException("downloadUrl == null");}return new Builder(downloadUrl);}public String getMd5() {return md5;}public Builder setMd5(String md5) {this.md5 = md5;return this;}public Builder build(Context context, UpdateProgressListener listener) {if (context == null) {throw new NullPointerException("context == null");}Intent intent = new Intent();intent.setClass(context, UpdateService.class);intent.putExtra(URL, downloadUrl);intent.putExtra(KEY_MD5, md5);UpdateProgressListener delegateListener = new UpdateProgressListener() {@Overridepublic void start() {if (listener != null) {listener.start();}}@Overridepublic void update(int var1) {if (listener != null) {listener.update(var1);}}@Overridepublic void success(String path) {try {context.unbindService(serviceConnection);} catch (Throwable t) {Log.e("UpdateService", "解绑失败" + t.getMessage());}if (listener != null) {listener.success(path);}}@Overridepublic void error() {try {context.unbindService(serviceConnection);} catch (Throwable t) {Log.e("UpdateService", "解绑失败" + t.getMessage());}if (listener != null) {listener.error();}}};serviceConnection = new ServiceConnection() {@Overridepublic void onServiceConnected(ComponentName name, IBinder service) {LocalBinder binder = (LocalBinder) service;binder.setUpdateProgressListener(delegateListener);}@Overridepublic void onServiceDisconnected(ComponentName name) {}};context.bindService(intent, serviceConnection, Context.BIND_IMPORTANT);context.startService(intent);return this;}}public interface UpdateProgressListener {void start();void update(int var);void success(String path);void error();}
}
package com.mvp.myapplication;import androidx.appcompat.app.AppCompatActivity;import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;import com.mvp.myapplication.update.UpdateService;public class MainActivity extends AppCompatActivity {private Button btnAllUpdate, btnAddUpdate, btnHotUpdate;private String url,md5;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);btnAddUpdate = findViewById(R.id.btn_add_update);btnAllUpdate = findViewById(R.id.btn_all_update);btnHotUpdate = findViewById(R.id.btn_hot_update);Log.e("MainActivity","onCreate");btnAllUpdate.setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View v) {UpdateService.Builder.create(url).setMd5(md5).build(MainActivity.this, new UpdateService.UpdateProgressListener() {@Overridepublic void start() {Log.e("MainActivity", "start");}@Overridepublic void update(int var) {Log.e("MainActivity", "update ===> " + var);}@Overridepublic void success(String path) {Log.e("MainActivity", "success ===> " + path);}@Overridepublic void error() {Log.e("MainActivity", "error");}});}});}
}
  • AndroidManifest
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"xmlns:tools="http://schemas.android.com/tools"><uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/><applicationandroid:allowBackup="true"android:dataExtractionRules="@xml/data_extraction_rules"android:fullBackupContent="@xml/backup_rules"android:icon="@mipmap/ic_launcher"android:label="@string/app_name"android:roundIcon="@mipmap/ic_launcher_round"android:supportsRtl="true"android:theme="@style/Theme.MyApplication"tools:targetApi="31"><serviceandroid:name=".update.UpdateService"android:enabled="true"android:exported="true"></service><activityandroid:name=".MainActivity"android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter><meta-dataandroid:name="android.app.lib_name"android:value="" /></activity><!-- Android7以上需要 --><providerandroid:name="androidx.core.content.FileProvider"android:authorities="${applicationId}.update_app.file_provider"android:exported="false"android:grantUriPermissions="true"tools:replace="android:authorities"><meta-dataandroid:name="android.support.FILE_PROVIDER_PATHS"android:resource="@xml/update_app_path" /></provider></application></manifest>
  • update_app_path
<?xml version="1.0" encoding="utf-8"?>
<paths><cache-pathname="update_app_cache_files"path="/update" /><external-pathname="update_app_external_files"path="/" /><external-cache-pathname="update_app_external_cache_files"path="/update" />
</paths>

热更新

严格意义上来说,个人认为热更新并不是用来进行包体升级,更多的用来进行修复bug的。例如,由于某个程序员的失误,在某个类中抛出了一个空指针异常,导致程序执行到该类后一直崩溃。这种情况下,其实就可以使用热更新来处理。因为,我们并没有大改app中的功能,只是某个类报错了。但个人认为热更新其实也不能解决所有的奔溃问题的,这些黑科技或多或少都是有一些兼容性的问题的,像Tinker就必须要冷启动才能修复,而且受限于Android的版本。
具体是技术实现方式可以参考笔者之前写的一篇博客:Android热修复1以及Android热更新十:自己写一个Android热修复

增量更新

什么是增量更新呢?举了例子,假设我们需要将apk从v1.0升级到v2.0,这时我们可以通过全量更新的方式,下载2.0版本的apk然后进行覆盖安装。但是,一般情况下2.0版本的apk往往包含了1.0版本的功能,理论上我们只需要下载二者的差分包,然后将差分包与1.0版本的包进行合并即可生成一个2.0版本的包。这样做的好处自然就是节约了流量了。像几乎所有的应用商店都使用增量更新的方式来更新apk。那么,我们该我们使用增量更新呢?这个就要借助一个工具:bsdiff
注意:如果想要使用增量更新,那么必须要有一个旧版本的apk,如果用户安装完apk后直接把旧版本的apk删掉了,那么还是老老实实使用全量更新的方式吧。

  • 拆——拆分出差分包

bsdiff oldfile newfile1 patchfile

  • 合——将旧版本的包与差分包进行合并

bspatch oldfile newfile2 patchfile

使用上面两步便可以完成差分包的拆分与合并,新生成的newfile2 与newfile1的md5是一致的。但是上面这两步法我们是在pc端进行的,我们该如何在代码中实现上面的逻辑呢?首先,拆分的逻辑还是在pc端中进行,客户端只需要关注如何合并差分包。
首先,我们需要导入bspatch相关的类

接着,我们新建一个类用于调用c相关的代码:

package com.mvp.myapplication.utils;public class BSPatchUtil {// Used to load the 'native-lib' library on application startup.static {System.loadLibrary("bspatch");}/*** native方法 使用路径为oldApkPath的apk与路径为patchPath的补丁包,合成新的apk,并存储于newApkPath** 返回:0,说明操作成功** @param oldApkPath 示例:/sdcard/old.apk* @param outputApkPath 示例:/sdcard/output.apk* @param patchPath  示例:/sdcard/xx.patch* @return*/public static native int bspatch(String oldApkPath, String outputApkPath,String patchPath);}
package com.mvp.myapplication;import androidx.appcompat.app.AppCompatActivity;import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
import android.view.View;
import android.widget.Button;import com.mvp.myapplication.update.UpdateService;
import com.mvp.myapplication.utils.BSPatchUtil;import java.io.File;public class MainActivity extends AppCompatActivity {private Button btnAllUpdate, btnAddUpdate, btnHotUpdate;private String url, md5;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);btnAddUpdate = findViewById(R.id.btn_add_update);btnAllUpdate = findViewById(R.id.btn_all_update);btnHotUpdate = findViewById(R.id.btn_hot_update);Log.e("MainActivity", "onCreate");btnAllUpdate.setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View v) {UpdateService.Builder.create(url).setMd5(md5).build(MainActivity.this, new UpdateService.UpdateProgressListener() {@Overridepublic void start() {Log.e("MainActivity", "start");}@Overridepublic void update(int var) {Log.e("MainActivity", "update ===> " + var);}@Overridepublic void success(String path) {Log.e("MainActivity", "success ===> " + path);}@Overridepublic void error() {Log.e("MainActivity", "error");}});}});btnAddUpdate.setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View v) {genNewApk();}});}private void genNewApk() {String oldpath = getApplicationInfo().sourceDir;String newpath = (this.getCacheDir().getAbsolutePath()+ File.separator+ "composed_hivebox_apk.apk");String patchpath = (this.getCacheDir().getAbsolutePath()+ File.separator+ "bs_patch");Log.e("MainActivity", "oldpath is " + oldpath + "\n newpath is " + newpath + "\n patchpath is " + patchpath);BSPatchUtil.bspatch(oldpath, newpath, patchpath);}
}

注意:需要修改bspatch.c文件中的Java_com_mvp_myapplication_utils_BSPatchUtil_bspatch方法签名:改为BSPatchUtil 的包名,例如BSPatchUtil对应的路径为com.dxl.testbatch.util.BSPatchUtil,那么native方法签名就是Java_com_mvp_myapplication_utils_BSPatchUtil_bspatch
这样便可以通过jni调用到c层面的代码。

接着,修改build.gradle文件,添加下面圈中的闭包

最后,我们执行Make Project命令,正常情况下便可以生成如下几个so库

最后,我们在把so库放入jniLibs文件夹中,然后build下生成apk包

然后,我们将差分包bs_patch放入手机的data/data目录下,点击按钮就会生成composed_hivebox_apk.apk这个apk包,将其与v2.0的包进行MD5对比,发现是一致的。如此,我们便实现了一个简单的增量更新逻辑。
Demo地址:https://gitee.com/hzulwy/add_-update/tree/master/MyApplication

Android中的全量更新、增量更新以及热更新相关推荐

  1. Sqoop导出模式——全量、增量insert、更新update的介绍以及脚本示例

    背景信息 SQOOP支持直接从Hive表到RDBMS表的导出操作,也支持HDFS到RDBMS表的操作, 当前需求是从Hive中导出数据到RDBMS,有如下两种方案: Ø  从Hive表到RDBMS表的 ...

  2. python 读取文件夹 增量文件_Python实现目录文件的全量和增量备份

    目标: 1.传入3个参数:源文件路径,目标文件路径,md5文件 2.每周一实现全量备份,其余时间增量备份 1.通过传入的路径,获取该路径下面的所有目录和文件(递归) 方法一:使用os.listdir ...

  3. Linux随笔19-MySQL主从复制、Percona XtraBackup实现全量和增量备份、ProxySQL实现读写分离

    Contents 1. MySQL5.7实现主从复制 1.1 基础环境 1.2. 配置主从复制 1.2.1. master节点上的配置 1.2.2. slave节点上的配置 1.2.3. 中继日志问题 ...

  4. mongodb数据同步到elasticsearch的中间件,支持全量,增量,实时同步等多种同步情景。(syncs MongoDB to Elasticsearch in realtime) (Mong

    GitHub - levonmo/mongo-sync-elasticsearch: mongodb数据同步到elasticsearch的中间件,支持全量,增量(新增修改删除),实时同步等多种同步情景 ...

  5. 关于全量与增量 的思考

    一.数据同步:全量与增量 1.背景 数据如果保留多份,就会存在一致性问题,就需要同步,同步分为两大类:全量和增量 2. 概述 数据如果要保留副本,要么同时写(就是多写),或者进行复制:异步写(即从主数 ...

  6. Android热更新初探,Bugly热更新的集成和使用(让你的应用轻松具备热更新能力)

    介绍   在介绍Bugly之前,需要先向大家简单介绍下一些热更新的相关内容.当前市面的热补丁方案有很多,其中比较出名的有阿里的AndFix.美团的Robust以及QZone的超级补丁方案.但它们都存在 ...

  7. MySQL数据库全量、增量备份与恢复

    MySQL数据库全量.增量备份与恢复 数据库备份的重要性 在生产的环境中,数据的安全性是至关重要的,任何数据的丢失都可能产生严重的后果. 造成数据丢失的原因 程序错误 人为商店 计算机失败 磁盘失败 ...

  8. hive全量与增量~的思考

    1.背景 数据如果保留多份,就会存在一致性问题,就需要同步,同步分为两大类:全量和增量 2. 概述 数据如果要保留副本,要么同时写(就是多写),或者进行复制:异步写(即从主数据拷贝到副本): 同时写( ...

  9. DataX oracle同步mysql(全量和增量)

    本篇博客说说DataX如何进行全量和增量数据同步,虽然用演示oracle同步到mysql,但其他数据库之间的同步都差不多 1.DataX介绍 DataX 是一个异构数据源离线同步工具,致力于实现包括关 ...

最新文章

  1. hdu1160FatMouse's Speed(DP)
  2. kafka配置_Kafka生产环境的几个重要配置参数
  3. javascript中动态添加事件
  4. jquery实用方法
  5. 漫步数学分析三十四——链式法则
  6. 计算机分级无法度量视频,雨林木风win7旗舰版电脑评分时出现无法度量视频播放性能...
  7. Hbase与传统关系型数据库对比
  8. 2020张宇1000题【好题收集】【第七章:三重积分、曲线曲面积分】
  9. 吐血总结:Python学习方向、发展副业求职全攻略(自学Python做副业,教你如何月入10000+)
  10. java ehcache使用_ehcache使用报错
  11. 怎样恢复误删计算机管理员,请问误删了系统管理员的一个帐户,怎样恢复?
  12. css设置背景颜色透明度
  13. unit怎么发音_“unit”怎么读?
  14. 实现SDT(software-defined Things)的IOT案例(2)
  15. 5G 网络的会话性管理上下文对比介绍
  16. Java腐烂的橘子leetcode
  17. 求生之路2正版rpg服务器,求生之路2怎么屏蔽rpg服务器 求生之路2屏蔽rpg服务器方法-游侠网...
  18. BASE32编码 --记录
  19. 推荐算法实践-章节三-推荐系统冷启动问题-阅读总结
  20. 云大计算机初试最高分,2019年云南大学考研初试成绩及总分排名查询通知

热门文章

  1. 计算机桌面输入法没有了怎么办,输入法不见了怎么办,小编教你电脑输入法不见了怎么办...
  2. 一篇文章,帮你搞定“查看更多”的全场景攻略
  3. 学计算机的网名,小学生可爱网名 好听的学生网名
  4. Vue之this.$route.query和this.$route.params的使用与区别
  5. 全国青少年软件编程(Scratch 3级)等级考试试卷----试题详解
  6. Office Visio 2013安装
  7. 了解这些后,再去决定要不要买mac
  8. 第三届中国互联网安全领袖峰会:聚焦新秩序下网络安全之道
  9. 网络层—— 转发(IP、ARP、DCHP、ICMP、网络层编址、网络地址转换)
  10. DCHP协议的工作流程简述