背景
很多Android应用都内置了新版本检测与在线更新功能,这个简单的功能主要包括检测、下载、安装三个环节,演示效果如下:
下载完成以后,自动打开apk,跳到安装界面,交由用户操作:
思路
想要实现上述功能,主要是分三个步骤来进行:
- App端向服务端发送网络请求,获取App的最新版本号信息,进行比较,如果服务端返回的版本号大于当前App的版本号,则开启第二步,下载新版本App;
- 有新版本App时,开启下载,并在界面上给出下载进度提示,增加交互性;
- 在下载达到100%的进度时,通过代码打开apk实现安装。
实现
1. 版本检测
版本检测就是通过发送网络请求至App的服务端,从服务端查询到最新版App的版本号是多少,一般来说,可以通过请求静态资源(手动配置文件等)或动态接口的方式来获取最新的版本号。
- 静态资源的话主要就是在服务端放置一个可以被访问的配置文件,其中写明了最新的版本号是多少;
动态接口的话就是服务端维护一个接口,可以返回版本号,好处就是可以与数据库结合,做一些更加复杂的操作,例如维护版本更新记录等。
在本篇文章里面,为了简单表达,我们使用第一种静态资源的方式,在服务端放置一个文本文件version,内容为JSON格式。其访问地址为http://host/app/version,访问后得到的内容形如:
{
"versionCode": 1,
"fileName" : "abc-20210806.apk"
}
其中,versionCode是最新版本App的versionCode(Android应用的配置属性),fileName是最新版App的文件名称,用来配合着做文件下载。
App端检测版本的代码:
RetrofitRequest.sendGetRequest(Constant.URL_APP_VERSION, new RetrofitRequest.ResultHandler(context) {
...
@Override
public void onResult(String response) {
if (response == null || response.trim().length() == 0) {
Toast.makeText(context, R.string.layout_version_no_new, Toast.LENGTH_SHORT).show();
LoadingDialog.close();
return;
}
try {
JSONObject jsonObject = new JSONObject(response);
if (!jsonObject.has("versionCode") || !jsonObject.has("fileName")) {
Toast.makeText(context, R.string.layout_version_no_new, Toast.LENGTH_SHORT).show();
LoadingDialog.close();
return;
}
newVersionCode = jsonObject.getInt("versionCode");
newFileName = jsonObject.getString("fileName");
int versionCode = VersionUtil.getVersionCode(context);
LoadingDialog.close();
if (newVersionCode > versionCode) {
showUpdateDialog(newFileName);
} else {
if (!isAutoCheck) {
Toast.makeText(context, R.string.layout_version_no_new, Toast.LENGTH_SHORT).show();
}
}
} catch (JSONException e) {
Toast.makeText(context, R.string.layout_version_no_new, Toast.LENGTH_SHORT).show();
LoadingDialog.close();
}
}
...
});
其中newVersionCode > versionCode
就是最服务器端的版本号与本App的版本进行比较的代码,根据比较结果,如果当前不是最新版本,则显示更新提醒对话框showUpdateDialog(newFileName)
。
private void showUpdateDialog(final String fileName) {
ConfirmDialog dialog = new ConfirmDialog(context, new ConfirmDialog.OnClickListener() {
@Override
public void onConfirm() {
showDownloadDialog(fileName);
}
});
dialog.setTitle(R.string.note_confirm_title);
dialog.setContent(R.string.layout_version_new);
dialog.setConfirmText(R.string.layout_yes);
dialog.setCancelText(R.string.layout_no);
dialog.show();
}
2. 下载新版本apk
用户在更新对话框中点击“是”时,表示需要下载最新版apk,此时显示下载进度对话框,并启动下载,实时刷新下载进度:
private void showDownloadDialog(String fileName) {
Builder builder = new Builder(context);
View view = LayoutInflater.from(context).inflate(R.layout.dialog_download, null);
proDownload = (ProgressBar) view.findViewById(R.id.pro_download);
tvPercent = (TextView) view.findViewById(R.id.txt_percent);
tvKbNow = (TextView) view.findViewById(R.id.txt_kb_now);
tvKbAll = (TextView) view.findViewById(R.id.txt_kb_all);
Button btnCancel = (Button) view.findViewById(R.id.btn_cancel);
btnCancel.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (downloadDialog != null) {
downloadDialog.dismiss();
}
cancelUpdate = true;
}
});
downloadDialog = builder.create();
downloadDialog.setCanceledOnTouchOutside(false);
downloadDialog.show();
downloadDialog.getWindow().setContentView(view);
downloadApk(fileName);
}
下载的后台线程和前端百分比更新动作:
private void downloadApk(String fileName) {
ExecutorService executorService = Executors.newFixedThreadPool(1);
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(Constant.URL_CONTRACT_BASE)
.callbackExecutor(executorService)
.build();
String url = String.format(Constant.URL_APP_DOWNLOAD, fileName);
FileRequest fileRequest = retrofit.create(FileRequest.class);
Call<ResponseBody> call = fileRequest.download(url);
call.enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) {
if (response.isSuccessful()) {
if (writeResponseBodyToDisk(response.body())) {
downloadDialog.dismiss();
} else {
mHandler.sendEmptyMessage(DOWNLOAD_ERROR);
}
} else {
mHandler.sendEmptyMessage(DOWNLOAD_ERROR);
}
}
@Override
public void onFailure(Call<ResponseBody> call, Throwable t) {
mHandler.sendEmptyMessage(DOWNLOAD_ERROR);
}
});
}
private boolean writeResponseBodyToDisk(ResponseBody body) {
savePath = StorageUtil.getDownloadPath(context);
File apkFile = new File(savePath, newFileName);
InputStream inputStream = null;
OutputStream outputStream = null;
try {
byte[] fileReader = new byte[4096];
long fileSize = body.contentLength();
long fileSizeDownloaded = 0;
inputStream = body.byteStream();
outputStream = new FileOutputStream(apkFile);
BigDecimal bd1024 = new BigDecimal(1024);
totalByte = new BigDecimal(fileSize).divide(bd1024, BigDecimal.ROUND_HALF_UP).setScale(0).intValue();
while (!cancelUpdate) {
int read = inputStream.read(fileReader);
if (read == -1) {
mHandler.sendEmptyMessage(DOWNLOAD_FINISH);
break;
}
outputStream.write(fileReader, 0, read);
fileSizeDownloaded += read;
progress = (int) (((float) (fileSizeDownloaded * 100.0 / fileSize)));
downByte = new BigDecimal(fileSizeDownloaded).divide(bd1024, BigDecimal.ROUND_HALF_UP).setScale(0).intValue();
mHandler.sendEmptyMessage(DOWNLOAD_ING);
}
outputStream.flush();
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
private void showProgress() {
proDownload.setProgress(progress);
tvPercent.setText(progress + "%");
tvKbAll.setText(totalByte + "Kb");
tvKbNow.setText(downByte + "Kb");
}
3. 安装apk
最新版本的apk下载完成后,调用安装代码执行安装动作。新旧版Android SDK安装方式略有区别,详见代码:
private void installApk() {
File apkFile = new File(savePath, newFileName);
if (!apkFile.exists()) {
return;
}
Intent intent = new Intent(Intent.ACTION_VIEW);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
Uri contentUri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", apkFile);
intent.setDataAndType(contentUri, "application/vnd.android.package-archive");
} else {
intent.setDataAndType(Uri.parse("file://" + apkFile.toString()), "application/vnd.android.package-archive");
}
context.startActivity(intent);
}
总结
Android端的版本更新相对比较自由,不受应用商店的限制。实现起来思路清晰,各环节一步步走下来还算简单,只是这其中有几点需要开发者注意:
- 这个的版本号
versionCode
对应的是build.gradle中的versionCode不是versionName,Android系统也是根据versionCode来确定安装的应用是否为新版本; - 想要在进度条中准确显示下载进度的话,App在下载时应能够读取到apk的大小,如果apk是以静态资源形式提供的,还比较方便,一般从web服务器上都能够读到,如上述的代码
body.contentLength()
。如果是通过从服务端的文件流接口返回的话,一定要让文件流接口正确返回Http请求的Content-Length属性,否则无法读取到apk的大小,就无法准确的表达进度了。 - 上述的演示操作是用户主动更新,如果想要做后台无交互的自动更新,则只需要修改一个构造参数,使用
new UpdateManager(this, UpdateManager.CHECK_AUTO).checkUpdate()
即可,检测过程不会有loading效果。 - 动态权限申请、Dialog定制等不是本文的重点,但源码完整可用,包含这部分内容。
- 服务端版本配置文件和下载程序代码,都放置在源码的versionConfig文件夹内,仅供参考。