Android中的全量更新、增量更新以及热更新

Android中的全量更新、增量更新以及热更新

在客户端开发过程中,我们可能会遇到这样一种需求:点击某个按钮弹出一个弹窗,提示我们可以更新到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() {

}

@Override

public 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);

}

@Override

public IBinder onBind(Intent intent) {

return localBinder;

}

@Override

public boolean onUnbind(Intent intent) {

return true;

}

@Override

public 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 {

private final String md5;

private UpdateService updateService;

public DownloadApk(UpdateService service, String md5) {

this.updateService = service;

this.md5 = md5;

}

@Override

protected void onPreExecute() {

super.onPreExecute();

if (updateService != null) {

updateService.start();

}

}

@Override

protected 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;

}

@Override

protected void onProgressUpdate(Integer... values) {

super.onProgressUpdate(values);

if (updateService != null) {

updateService.update(values[0]);

}

}

@Override

protected 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() {

@Override

public void start() {

if (listener != null) {

listener.start();

}

}

@Override

public void update(int var1) {

if (listener != null) {

listener.update(var1);

}

}

@Override

public void success(String path) {

try {

context.unbindService(serviceConnection);

} catch (Throwable t) {

Log.e("UpdateService", "解绑失败" + t.getMessage());

}

if (listener != null) {

listener.success(path);

}

}

@Override

public void error() {

try {

context.unbindService(serviceConnection);

} catch (Throwable t) {

Log.e("UpdateService", "解绑失败" + t.getMessage());

}

if (listener != null) {

listener.error();

}

}

};

serviceConnection = new ServiceConnection() {

@Override

public void onServiceConnected(ComponentName name, IBinder service) {

LocalBinder binder = (LocalBinder) service;

binder.setUpdateProgressListener(delegateListener);

}

@Override

public 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;

@Override

protected 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() {

@Override

public void onClick(View v) {

UpdateService.Builder.create(url)

.setMd5(md5)

.build(MainActivity.this, new UpdateService.UpdateProgressListener() {

@Override

public void start() {

Log.e("MainActivity", "start");

}

@Override

public void update(int var) {

Log.e("MainActivity", "update ===> " + var);

}

@Override

public void success(String path) {

Log.e("MainActivity", "success ===> " + path);

}

@Override

public void error() {

Log.e("MainActivity", "error");

}

});

}

});

}

}

AndroidManifest

xmlns:tools="http://schemas.android.com/tools">

android: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">

android:name=".update.UpdateService"

android:enabled="true"

android:exported="true">

android:name=".MainActivity"

android:exported="true">

android:name="android.app.lib_name"

android:value="" />

android:name="androidx.core.content.FileProvider"

android:authorities="${applicationId}.update_app.file_provider"

android:exported="false"

android:grantUriPermissions="true"

tools:replace="android:authorities">

android:name="android.support.FILE_PROVIDER_PATHS"

android:resource="@xml/update_app_path" />

update_app_path

name="update_app_cache_files"

path="/update" />

name="update_app_external_files"

path="/" />

name="update_app_external_cache_files"

path="/update" />

热更新

严格意义上来说,个人认为热更新并不是用来进行包体升级,更多的用来进行修复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;

@Override

protected 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() {

@Override

public void onClick(View v) {

UpdateService.Builder.create(url)

.setMd5(md5)

.build(MainActivity.this, new UpdateService.UpdateProgressListener() {

@Override

public void start() {

Log.e("MainActivity", "start");

}

@Override

public void update(int var) {

Log.e("MainActivity", "update ===> " + var);

}

@Override

public void success(String path) {

Log.e("MainActivity", "success ===> " + path);

}

@Override

public void error() {

Log.e("MainActivity", "error");

}

});

}

});

btnAddUpdate.setOnClickListener(new View.OnClickListener() {

@Override

public 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

相关推荐

office365网页版无法使用 楚留香手游帮派秘籍多久刷新?帮派秘籍刷新时间
office365网页版无法使用 微博UID如何查?一键轻松搞定!
365登录器 “毛衣”为什么起球?原因在这,在这,在这!