在客户端开发过程中,我们可能会遇到这样一种需求:点击某个按钮弹出一个弹窗,提示我们可以更新到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<String, Integer, String> {
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
<?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"/>
<application
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">
<service
android:name=".update.UpdateService"
android:enabled="true"
android:exported="true"></service>
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<!-- Android7以上需要 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.update_app.file_provider"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities">
<meta-data
android: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-path
name="update_app_cache_files"
path="/update" />
<external-path
name="update_app_external_files"
path="/" />
<external-cache-path
name="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;
@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