简易APP更新功能
APK全量简易更新
文章目录
- APK全量简易更新
- 第一章 前言
- 第01节 提出问题
- 第02节 更新对比
- 第03节 全量更新
- 第04节 前期准备
- 第二章 案例代码
- 第01节 演示效果图
- 第02节 依赖和清单
- 第03节 网络访问包
- 第04节 测试Activity
- 第三章 后续问题
第一章 前言
第01节 提出问题
为什么需要实现 APK 的更新?
1. 功能增强与改进新增功能:添加用户需求的新特性,保持应用竞争力优化体验:改进UI/UX设计,提升用户操作流畅度性能提升:优化代码结构,减少内存占用,提高运行速度2. 安全修复漏洞修补:修复已发现的安全漏洞,防止黑客攻击数据保护:更新加密算法,保护用户隐私数据权限管理:调整权限请求策略,符合最新安全标准3. 兼容性维护系统适配:适配新版Android系统特性设备支持:确保在新发布设备上正常运行API更新:集成最新的SDK和API接口4. 合规性要求政策合规:满足应用商店和监管机构的最新要求法律变更:调整内容以适应新的法律法规支付规范:更新支付接口符合金融监管要求5. 商业策略营销活动:集成季节性促销或活动内容商业模式:调整订阅模式或内购策略数据分析:改进数据收集和分析功能6. 错误修复崩溃修复:解决导致应用崩溃的关键问题功能修复:修正无法正常工作的功能模块兼容问题:解决特定设备或系统版本上的兼容性问题7. 资源优化体积缩减:优化资源文件,减少APK大小加载优化:改进资源加载机制,提高效率多语言支持:添加或完善多语言资源更新策略建议定期更新:保持稳定的更新节奏(如每月/每季度)紧急更新:对关键安全问题应立即发布更新灰度发布:先向部分用户推送,验证稳定性更新说明:清晰描述更新内容,提高用户更新意愿不更新的风险包括:用户流失、安全威胁、负面评价增加、市场份额下降等。因此,制定合理的APK更新策略对应用长期成功至关重要。
移动端应用更新APK的方式有哪些?
一、按更新渠道分类1. 官方应用商店更新Google Play商店更新(Android) 苹果App Store更新(iOS) 第三方应用市场更新华为应用市场小米应用商店三星Galaxy Store 2. 企业自有渠道更新官网直接下载提供APK/IPA文件下载适用于企业内部分发邮件推送更新向注册用户发送更新通知包含下载链接或更新指引社交媒体通知通过官方账号发布更新公告提供跳转链接二、按更新机制分类1. 全量更新 下载完整的新版本APK/IPA 完全覆盖旧版本 最传统普遍的更新方式2. 增量更新 二进制差分更新只下载变化部分(bsdiff/patch)显著减少下载量需客户端支持合并资源增量更新仅更新变化的资源文件保持主程序不变3. 热更新(Hotfix)代码热更新React Native/Flutter热重载微信小程序式更新不经过应用商店审核资源热更新动态加载新资源包, 常见于游戏资源更新4. 静默更新后台自动下载安装用户无感知完成需系统特殊权限三、按更新触发方式分类1. 用户主动更新手动检查更新, 点击更新按钮, 自主选择更新时间2. 强制更新应用启动时检测, 不更新无法使用, 用于关键安全修复3. 推荐更新弹出更新提示窗, 展示新特性介绍, 提供奖励激励更新4. 定时更新设置自动更新时间, 如夜间自动更新, 需用户预先授权四、按更新内容分类1. 完整应用更新包含所有代码和资源, 通过应用商店分发2. 模块化更新按需下载功能模块Google Play InstantAndroid App BundleiOS On-Demand Resources3. 配置更新仅更新配置文件服务器控制开关无需修改客户端五、特殊更新技术1. A/B测试更新向不同用户群推送不同版本收集使用数据对比用于功能优化决策2. 灰度发布(Canary Release)先向小比例用户推送验证稳定性后扩大降低更新风险3. CDN加速更新通过内容分发网络加快更新包下载速度全球节点覆盖选择建议合规性优先:遵守各平台政策(如苹果限制热更新)按需选择:安全更新用强制,功能更新用推荐考虑成本:增量更新节省流量但开发复杂用户体验:重大更新配合引导说明监控机制:建立更新成功率监控系统不同应用类型推荐组合:社交应用:热更新+频繁小版本金融应用:强制安全更新+严格测试游戏应用:资源热更+大版本商店更新企业应用:MDM统一管理更新
第02节 更新对比
特性 | 全量更新 | 增量更新 | 热更新(Hotfix) |
---|---|---|---|
定义 | 下载完整的新版本安装包 | 只下载新旧版本差异部分 | 不通过商店审核的动态代码更新 |
包大小 | 大(完整APK/IPA) | 小(仅差异文件) | 很小(通常只含修改部分) |
审核要求 | 需应用商店审核 | 需应用商店审核 | 无需商店审核 |
用户感知 | 明显(需重新下载安装) | 较明显(但下载量小) | 基本无感知 |
生效方式 | 安装后重启生效 | 安装后重启生效 | 即时生效或下次启动生效 |
技术复杂度 | 低 | 中 | 高 |
适用场景 | 重大版本更新 | 常规版本更新 | 紧急修复/小功能迭代 |
三种方式的优缺点
一、全量更新优点:1. 实现简单,兼容性好2. 版本状态干净明确3. 符合商店规范,无下架风险缺点:1. 流量消耗大(对用户不友好)2. 更新率较低(用户可能忽略)3. 发布周期长(需审核)二、增量更新优点:1. 节省用户流量(可提升更新率)2. 缩短下载时间3. 降低服务器带宽压力缺点:1. 需要维护版本链(复杂度高)2. 差分失败可能导致更新中断3. 长期增量后需全量"重置"
三、热更新优点:1. 极速响应线上问题(分钟级)2. 用户无感知体验好3. 绕过商店审核周期缺点:1. 技术实现复杂(稳定性风险)2. 苹果审核政策限制(iOS风险)3. 可能带来版本碎片化问题
主要介绍一下,全量更新的实现方式。
第03节 全量更新
流程图介绍
第04节 前期准备
1、需要服务器作用: 保存配置文件信息, 对外提供稳定的接口要求: 这里需要有一个对外访问的稳定接口,接口地址不能随意变化。尝试: 将配置文件, 放在网盘上面, 进行访问, 访问地址会有时限, 几个小时内可以正常访问, 超过时间以后, 无法正常访问了。方案: 采用云服务器, 例如 阿里云ECS 服务器, 基础款1年费用仅需几十元。2、需要资源仓作用: 保存我们编译好的 APK 文件, 对外提供下载的地址要求: 这里需要有一个对外访问的稳定接口,接口地址不能随意变化。尝试:将APK文件, 放在网盘上面, 进行访问, 访问地址会有时限, 几个小时内可以正常访问, 超过时间以后, 无法正常访问了。尝试:将APK文件, 放在 阿里云ECS 服务器, 这里需要大量的流量, 需要充值流量, 个人使用不划算。方案: 采用 gitee 或者 gitlib 亦或是 github 代码托管平台上, 帮忙存储 APK 文件, 托管平台的地址, 就是APK 下载地址。
下面是配置文件信息 config.txt
{"versionCode": 25050101,"versionName": "2.0","apkSize": 7603795,"forceOuterClick": true,"apkDescribe": "1.修复了版本地址\n2.新增强制更新选项\n3.修复已知的bug","apkUrl": "app-debug.apk"
}
第二章 案例代码
第01节 演示效果图
提示下载
安装过程
代码适用场景
1、微小型 APP
2、学习使用
3、学术研究
4、毕业答辩专题
第02节 依赖和清单
在 APP 的 build.gradle 当中
// OkHttp
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.retrofit2:adapter-rxjava3:2.9.0'// RxJava
implementation 'io.reactivex.rxjava3:rxjava:3.1.6'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2' // Android 调度器支持
implementation 'com.github.akarnokd:rxjava3-retrofit-adapter:3.0.0'
清单文件中
<?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.INTERNET" /><!-- 写入外部存储权限 --><uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /><!-- 请求安装包权限 --><uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /><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:networkSecurityConfig="@xml/network_security_config"android:roundIcon="@mipmap/ic_launcher_round"android:supportsRtl="true"android:theme="@style/Theme.CompleteUpdate"tools:targetApi="31"><activityandroid:name=".MainActivity"android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter></activity><!-- 文件提供者 --><providerandroid:name="androidx.core.content.FileProvider"android:authorities="${applicationId}.fileprovider"android:exported="false"android:grantUriPermissions="true"><meta-dataandroid:name="android.support.FILE_PROVIDER_PATHS"android:resource="@xml/file_paths" /></provider></application></manifest>
在 res/xml的 file_paths 文件
<?xml version="1.0" encoding="utf-8"?>
<paths><external-pathname="external_files"path="." /><external-files-pathname="external_files_path"path="." /><files-pathname="files_path"path="." /><cache-pathname="cache_path"path="." />
</paths>
第03节 网络访问包
接口地址
import io.reactivex.rxjava3.core.Observable;
import retrofit2.http.GET;public interface ApiService {String BASE_DOWN_LOAD_URL = "https://xxxxxx/";String BASE_VERSION_URL = "http://xxxxx/";@GET("test")Observable<VersionBean> getVersionDetail();
}
访问类
import android.util.Log;import hu.akarnokd.rxjava3.retrofit.RxJava3CallAdapterFactory;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.annotations.NonNull;
import io.reactivex.rxjava3.core.Observer;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import okhttp3.OkHttpClient;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;public class ApiClient {private static Retrofit retrofit = null;private static final String TAG = ApiClient.class.getSimpleName();private Retrofit getClient() {if (retrofit == null) {OkHttpClient okHttpClient = new OkHttpClient.Builder().build();retrofit = new Retrofit.Builder().baseUrl(ApiService.BASE_VERSION_URL).client(okHttpClient).addConverterFactory(GsonConverterFactory.create()).addCallAdapterFactory(RxJava3CallAdapterFactory.create()).build();}return retrofit;}public void getVersionBean(ICallBack callBack) {getClient().create(ApiService.class).getVersionDetail().subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(new Observer<>() {@Overridepublic void onSubscribe(@NonNull Disposable d) {}@Overridepublic void onNext(@NonNull VersionBean versionBean) {if (callBack != null) {callBack.onCallBack(versionBean);}}@Overridepublic void onError(@NonNull Throwable exception) {Log.i(TAG, "cosmo.onError...exception: " + exception);}@Overridepublic void onComplete() {}});}// 对外的接口回调public interface ICallBack {void onCallBack(VersionBean versionBean);}}
断点下载工具类
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.util.concurrent.TimeUnit;import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;// 断点下载的工具类
public class DownloadUtil {private static final int TIME_OUT = 30 * 1000; // 超时时间private final OkHttpClient client;private final Callback callback;public interface Callback {void onProgress(long currentLength, long fileTotalSize);void onSuccess(File file);void onFailure(String msg);}public DownloadUtil(Callback callback) {this.callback = callback;this.client = new OkHttpClient.Builder().connectTimeout(TIME_OUT, TimeUnit.MILLISECONDS).readTimeout(TIME_OUT, TimeUnit.MILLISECONDS).writeTimeout(TIME_OUT, TimeUnit.MILLISECONDS).build();}public void download(final String url, final String savePath, long totalSize) {new Thread(() -> {InputStream is = null;RandomAccessFile savedFile = null;File file = new File(savePath);try {long downloadedLength = 0;if (file.exists()) {downloadedLength = file.length();}long fileTotalSize = getContentLength(url);if (fileTotalSize == 0) {callback.onFailure("获取文件大小失败");return;}// 在网络读取文件大小未知的情况下, 那么返回给出的数据大小if (fileTotalSize == -1 && totalSize != 0) {fileTotalSize = totalSize;}if (fileTotalSize == downloadedLength) {callback.onSuccess(file);return;}Request request = new Request.Builder().url(url).addHeader("RANGE", "bytes=" + downloadedLength + "-").build();Response response = client.newCall(request).execute();if (response.isSuccessful() && response.body() != null) {is = response.body().byteStream();savedFile = new RandomAccessFile(file, "rw");savedFile.seek(downloadedLength);byte[] b = new byte[1024];int len;long total = downloadedLength;while ((len = is.read(b)) != -1) {savedFile.write(b, 0, len);total += len;callback.onProgress(total, fileTotalSize);}response.body().close();callback.onSuccess(file);} else {callback.onFailure("下载失败");}} catch (Exception e) {callback.onFailure(e.getMessage());} finally {try {if (is != null) {is.close();}if (savedFile != null) {savedFile.close();}} catch (Exception e) {callback.onFailure(e.getMessage());}}}).start();}public void download(final String url, final String savePath) {download(url, savePath, 0);}private long getContentLength(String url) throws IOException {Request request = new Request.Builder().url(url).build();Response response = client.newCall(request).execute();if (response.isSuccessful() && response.body() != null) {long contentLength = response.body().contentLength();response.body().close();return contentLength;}return 0;}
}
网络接口数据实体类
public class VersionBean {private long versionCode; // 版本号 时间 25042901private String versionName; // 版本名称private long apkSize; // APK文件的大小private String apkDescribe; // 当前APK修复的功能描述private String apkUrl; // APK的下载地址private boolean forceOuterClick; // 禁用外部点击//..................省略 getter setter 方法........................
}
管理类
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Environment;
import android.util.Log;import androidx.core.content.FileProvider;import java.io.File;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;// 管理类
// 对外提供两个方法:
// 1. 检查更新 public boolean checkNeedUpdate(Context context, VersionBean versionBean)
// 2. 下载APK public void download(Context context, VersionBean versionBean, ICallBack callBack)
public class UpperManager {private static final String TAG = UpperManager.class.getSimpleName();private static volatile UpperManager instance;private UpperManager() {}public static UpperManager getInstance() {if (instance == null) {synchronized (UpperManager.class) {if (instance == null) {instance = new UpperManager();}}}return instance;}/**** 下载** @param context 上下文对象* @param versionBean 操作实体类* @param callBack 回调函数结果*/public void download(Context context, VersionBean versionBean, ICallBack callBack) {DownloadUtil downloadUtil = new DownloadUtil(new DownloadUtil.Callback() {@Overridepublic void onProgress(long currentLength, long totalLength) {int progress = (int) (currentLength * 100 / totalLength);if (callBack != null) {callBack.progress(progress);}}@Overridepublic void onSuccess(File file) {Log.i(TAG, "cosmo.onSuccess...下载完成");installApk(context, file);if (callBack != null) {callBack.success();}}@Overridepublic void onFailure(String msg) {Log.i(TAG, "cosmo.onFailure...下载失败: " + msg);if (callBack != null) {callBack.failure();}}});String url = ApiService.BASE_DOWN_LOAD_URL + versionBean.getApkUrl();String format = new SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()).format(new Date());String savePath = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + "/completeUpdate_" + format + ".apk";downloadUtil.download(url, savePath, versionBean.getApkSize());}/*** 安装APK文件*/private void installApk(Context context, File apkFile) {Log.i(TAG, "cosmo.installApk...apkFile: " + apkFile);Intent intent = new Intent(Intent.ACTION_VIEW);intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);// Android 7.0及以上版本需要使用FileProviderUri apkUri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", apkFile);intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);intent.setDataAndType(apkUri, "application/vnd.android.package-archive");context.startActivity(intent);}/*** 检查是否需要更新** @param context 上下文对象* @param versionBean 当前的版本*/public boolean checkNeedUpdate(Context context, VersionBean versionBean) {boolean isNeedUpdate = false;try {long versionCodeNet = versionBean.getVersionCode();PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);long versionCodeLocal = packageInfo.versionCode;// 这里测试阶段写的是 >= 正常情况下这里应该是 >isNeedUpdate = versionCodeNet >= versionCodeLocal;Log.i(TAG, "cosmo.checkNeedUpdate..这里测试阶段写的是 >= 正常情况下这里应该是 >.");// isNeedUpdate = versionCodeNet > versionCodeLocal;} catch (PackageManager.NameNotFoundException e) {throw new RuntimeException(e);}return isNeedUpdate;}/**** 回调函数*/public interface ICallBack {void progress(int progress);default void success() {}default void failure() {}}
}
第04节 测试Activity
界面
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.Context;
import android.os.Bundle;
import android.widget.Toast;import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.AppCompatTextView;import com.complete.update.network.ApiClient;
import com.complete.update.network.UpperManager;
import com.complete.update.network.VersionBean;// 界面 Activity
public class MainActivity extends AppCompatActivity {private ProgressDialog mProgressDialog;private final Context mContext = MainActivity.this;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);AppCompatTextView textViewCheckUpdate = findViewById(R.id.text_view_check_update);// 点击事件中, 检测是否需要更新APKtextViewCheckUpdate.setOnClickListener(view -> new ApiClient().getVersionBean(versionBean -> {Toast.makeText(mContext, "version: " + versionBean.getVersionName(), Toast.LENGTH_LONG).show();// 如果不需要更新的情况下. 那么直接返回if (!UpperManager.getInstance().checkNeedUpdate(this, versionBean)) {return;}// 获取到描述信息String apkDescribe = versionBean.getApkDescribe();// 显示对话框, 更新提醒new AlertDialog.Builder(mContext).setTitle("检测到版本更新").setMessage(apkDescribe).setCancelable(!versionBean.isForceOuterClick()).setPositiveButton("更新", (dialogInterface, i) -> showProgressDialog(versionBean)).create().show();}));}// 显示进度对话框, 并且下载private void showProgressDialog(VersionBean versionBean) {if (mProgressDialog == null) {mProgressDialog = new ProgressDialog(mContext);mProgressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);mProgressDialog.setMessage("正在下载更新...");mProgressDialog.setCancelable(false);}mProgressDialog.show();// 下载的逻辑. 回调下载的进度给下载对话框UpperManager.getInstance().download(mContext, versionBean, progress -> mProgressDialog.setProgress(progress));}
}
布局
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"android:id="@+id/main"android:layout_width="match_parent"android:layout_height="match_parent"tools:context=".MainActivity"><androidx.appcompat.widget.AppCompatTextViewandroid:id="@+id/text_view_check_update"android:layout_width="wrap_content"android:layout_height="wrap_content"android:background="@drawable/selector_button_bg"android:clickable="true"android:focusable="true"android:padding="20dp"android:text="检测更新"app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toTopOf="parent" /></androidx.constraintlayout.widget.ConstraintLayout>
第三章 后续问题
说明
后续待拓展的问题中, 包含以下内容未实现:
1、安装完毕之后,无法自启动原始的 APP
2、原始数据的存储和恢复的处理