APK全量简易更新
文章目录
第一章 前言
第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 Instant
Android App Bundle
iOS On-Demand Resources
3. 配置更新
仅更新配置文件
服务器控制开关
无需修改客户端
五、特殊更新技术
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" />
<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:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CompleteUpdate"
tools:targetApi="31">
<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>
</activity>
<!-- 文件提供者 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android: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-path
name="external_files"
path="." />
<external-files-path
name="external_files_path"
path="." />
<files-path
name="files_path"
path="." />
<cache-path
name="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<>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
}
@Override
public void onNext(@NonNull VersionBean versionBean) {
if (callBack != null) {
callBack.onCallBack(versionBean);
}
}
@Override
public void onError(@NonNull Throwable exception) {
Log.i(TAG, "cosmo.onError...exception: " + exception);
}
@Override
public 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; // 版本号 时间 25042901
private 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() {
@Override
public void onProgress(long currentLength, long totalLength) {
int progress = (int) (currentLength * 100 / totalLength);
if (callBack != null) {
callBack.progress(progress);
}
}
@Override
public void onSuccess(File file) {
Log.i(TAG, "cosmo.onSuccess...下载完成");
installApk(context, file);
if (callBack != null) {
callBack.success();
}
}
@Override
public 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及以上版本需要使用FileProvider
Uri 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;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
AppCompatTextView textViewCheckUpdate = findViewById(R.id.text_view_check_update);
// 点击事件中, 检测是否需要更新APK
textViewCheckUpdate.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.AppCompatTextView
android: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、原始数据的存储和恢复的处理