在客户端开发过程中,我们可能会遇到这样一种需求:点击某个按钮弹出一个弹窗,提示我们可以更新到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; } 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() {@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"/> <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"); } 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() {@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() { @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
来源地址:https://blog.csdn.net/qq_36828822/article/details/129852751