demo演示:https://github.com/pzl237/UpgradeDemo
今年年初项目终于上线,到目前为止发布了4个版本。经历了3.8节,整体表现稳定。在第三个版本我们加入了版本检查升级,发布第四版本,用户就直接体验到了这个功能。
必要性
我们开发一个APP,应该是发布第一个版本之后,后续不断的更新迭代。现在大部分APP都是发布到各大应用市场市场,然后用户去搜索我们的应用并下载安装。如果有新的版本,你不可能让每个用户去应用市场重新下载新的版本,又或者用户没注意应用市场的更新提醒导致没有安装最新版本等,所以我们有必要让我们的应用自己检查是否有新的版本。
升级流程图
在APP首页自动触发向服务端请求最新的版本信息,如果服务端返回的版本信息中versionCode与当前版本不一致,就弹出升级提示框让用户选择。流程图如下:
检查更新流程图.png“立即更新”:直接启动下载
“稍后提醒”:什么都不处理
“忽略该版本”:当前版本不再提醒,如有更新版本还是要提醒。例如:用户版本是1.0,当前检查到的版本是2.0,用户选择了“忽略该版本”,则2.0的版本不再提示,到下个版本时,仍然要提示版本更新。
实现
首先看下app module的build.gradle
每次发布一个新版本时,一般都会修改versionCode
以及versionName
。
defaultConfig {
applicationId "com.jemlin.app"
minSdkVersion 15
targetSdkVersion 25
versionCode 1
versionName "1.0.0"
...
}
其中versionCode是整型,这里定义从1开始,每次迭代一个版本就加1;versionName是字符类型,从1.0.0开始,每次更新可以改为1.0.1、1.1.0、2.0.0等等。
接着判断是否有升级版本
通过和后端定好的http api从服务器端请求最新版本的versionCode,然后与当前版本的versionCode比较,如果服务端返回版本信息的versionCode大于当前版本的versionCode,就说明有新的版本需要更新。
1、http api检查版本更新接口response数据格式(仅供参考):
{
"data": {
"downloadUrl": "http://a5.pc6.com/cx3/weixin.pc6.apk",
"version": "1.0.1",
"versionCode": 2,
"versionDesc": "主要修改:\n1.增加多项新功能;\n2.修复已知bug。"
},
"errCode": 0,
"errMsg": "",
"success": true
}
其中,downloadUrl是最新版本的下载地址。
2、定义VersionInfo
模型,用于GSon
解析服务端返回的数据:
public class VersionInfo {
private int versionCode;
private String version;
private String downloadUrl;
private String versionDesc;
//......
}
3、如果有升级版本,随时弹窗提示用户。没有升级版本,就不用提示。
忽略更新
我的做法是把用户忽略更新的版本号versionCode存储到sharePreference
中,每次发现有升级版本时,在给用户提示之前,先取忽略版本号versionCode与最新版本号versionCode比较是否一样,如果一样就什么都不做,检查更新结束;如果不一样,还是照样给用户提示。
//取sp中保存的versionCode
int versionCode = mAppUpgradePersistent.getIgnoreUpgradeVersionCode(appContext);
if (versionCode == latestVersion.getVersionCode()) {
//用户之前已经选择"忽略该版本",不更新这个版本。
Timber.d("[AppUpgradeManager] ignore upgrade version====");
return;
}
稍后提醒
最简单,什么都不做。
立即更新
1、项目中我们直接使用系统提供的DownloadManager服务,同时注册两个广播:
下载完成广播DownloadManager.ACTION_DOWNLOAD_COMPLETE
以及
点击下载通知栏广播DownloadManager.ACTION_NOTIFICATION_CLICKED
代码片段如下:
public void init(Context context) {
Timber.d("[AppUpgradeManager] init====");
if (isInit) {
return;
}
appContext = context.getApplicationContext();
isInit = true;
mAppUpgradePersistent = new AppUpgradePersistent();
appContext.registerReceiver(downloaderReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
appContext.registerReceiver(notificationClickReceiver, new IntentFilter(DownloadManager.ACTION_NOTIFICATION_CLICKED));
}
public void unInit() {
Timber.d("[AppUpgradeManager] unInit====");
if (!isInit) {
return;
}
appContext.unregisterReceiver(downloaderReceiver);
appContext.unregisterReceiver(notificationClickReceiver);
isInit = false;
mAppUpgradePersistent = null;
appContext = null;
}
2、如果可以的话,在用户选择立即更新之后,您的应用应该判断当前的网络环境,如果是非wifi环境应该弹窗提示用户类似“您当前使用的不是wifi,更新会产生一些网络流量,是否继续下载?”
代码片段如下:
// 非wifi网络下,再次提示用户是否继续
MaterialDialog.Builder builder = new MaterialDialog.Builder(activity);
final MaterialDialog dialog = builder.title("流量提醒")
.theme(Theme.LIGHT)
.titleGravity(GravityEnum.CENTER)
.content("您当前使用的不是wifi,更新会产生一些网络流量,是否继续下载?")
.positiveText("确定")
.negativeText("取消")
.onPositive(new MaterialDialog.SingleButtonCallback() {
@Override
public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) {
dialog.dismiss();
//TODO
}
})
.onNegative(new MaterialDialog.SingleButtonCallback() {
@Override
public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) {
dialog.dismiss();
//TODO
}
})
.build();
dialog.show();
3、真正调用DownloadManager下载前,我们可以判断下本地是否已经过了最新版本,有就直接启动安装界面安装;下载的不是最新版本,就直接删除。
代码片段如下:
//先检查本地是否已经有需要升级版本的安装包,如有就不需要再下载
File targetApkFile = new File(downloadApkPath);
if (targetApkFile.exists()) {
PackageManager pm = appContext.getPackageManager();
PackageInfo info = pm.getPackageArchiveInfo(downloadApkPath, PackageManager.GET_ACTIVITIES);
if (info != null) {
String versionCode = String.valueOf(info.versionCode);
//比较已下载到本地的apk安装包,与服务器上apk安装包的版本号是否一致
if (String.valueOf(latestVersion.getVersionCode()).equals(versionCode)) {
//弹出框提示用户安装
mHandler.obtainMessage(WHAT_ID_INSTALL_APK, downloadApkPath).sendToTarget();
return;
}
}
}
//要检查本地是否有安装包,有则删除重新下
File apkFile = new File(downloadApkPath);
if (apkFile.exists()) {
boolean isDelSuc = apkFile.delete();
}
4、创建下载Reuqst,开始下载。
代码片段如下:
Request task = new Request(Uri.parse(latestVersion.getDownloadUrl()));
//定制Notification的样式
String title = "应用名称:" + latestVersion.getVersion();
task.setTitle(title);
task.setDescription(latestVersion.getVersionDesc());
//如果我们希望下载的文件可以被系统的Downloads应用扫描到并管理,我们需要调用Request对象的setVisibleInDownloadsUi方法,传递参数true
task.setVisibleInDownloadsUi(true);
//设置是否允许手机在漫游状态下下载
task.setAllowedOverRoaming(false);
//限定在WiFi下进行下载
task.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI | DownloadManager.Request.NETWORK_MOBILE);
task.setMimeType("application/vnd.android.package-archive");
// 在通知栏通知下载中和下载完成
// 下载完成后该Notification才会被显示
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.HONEYCOMB) {
// 3.0(11)以后才有该方法
//在下载过程中通知栏会一直显示该下载的Notification,在下载完成后该Notification会继续显示,直到用户点击该Notification或者消除该Notification
task.allowScanningByMediaScanner();
task.setNotificationVisibility(Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
}
// 可能无法创建Download文件夹,如无sdcard情况,系统会默认将路径设置为/data/data/com.android.providers.downloads/cache/xxx.apk
if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
String apkName = UpgradeHelper.downloadTempName(appContext.getPackageName());
task.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, apkName);
}
downloadTaskId = downloader.enqueue(task);
mAppUpgradePersistent.saveDownloadTaskId(appContext, downloadTaskId);
下载完成广播
在public void init(Context context)
方法中已经注册了监听下载完成广播,一旦我们知道下载完成的时机,就可以调用系统安装界面安装我们的APK啦~
切记,不可在广播的onReceive中做耗时操作,时间不能超过10秒,否则将ANR卡死!
下载完成广播定义如下:
/**
* 下载完成的广播
*/
class DownloadReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (downloader == null) {
return;
}
long completeId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0);
long downloadTaskId = mAppUpgradePersistent.getDownloadTaskId(context);
if (completeId != downloadTaskId) {
return;
}
Query query = new Query();
query.setFilterById(downloadTaskId);
Cursor cur = downloader.query(query);
if (!cur.moveToFirst()) {
return;
}
int columnIndex = cur.getColumnIndex(DownloadManager.COLUMN_STATUS);
if (DownloadManager.STATUS_SUCCESSFUL == cur.getInt(columnIndex)) {
String uriString = cur.getString(cur.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI));
mHandler.obtainMessage(WHAT_ID_INSTALL_APK, uriString).sendToTarget();
} else {
ToastHelper.showToast("xxxApp最新版本失败!");
}
// 下载任务已经完成,清除
mAppUpgradePersistent.removeDownloadTaskId(context);
cur.close();
}
}
点击通知栏响应广播
如果还未下载完成,点击后进入系统默认的下载界面;下载完成后再点击,就直接调用系统安装界面安装。
/**
* 点击通知栏下载项目,下载完成前点击都会进来,下载完成后点击不会进来。
*/
public class NotificationClickReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
long[] completeIds = intent.getLongArrayExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS);
//正在下载的任务ID
long downloadTaskId = mAppUpgradePersistent.getDownloadTaskId(context);
if (completeIds == null || completeIds.length <= 0) {
openDownloadsPage(appContext);
return;
}
for (long completeId : completeIds) {
if (completeId == downloadTaskId) {
openDownloadsPage(appContext);
break;
}
}
}
/**
* Open the Activity which shows a list of all downloads.
*
* @param context 上下文
*/
private void openDownloadsPage(Context context) {
Intent pageView = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
pageView.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(pageView);
}
}
大家会发现,在这个广播中我们并没有看到直接处理下载完成点击通知栏的代码。这个功能我一开始也是无法实现,下载完成后点击一直都是进入系统默认的下载页面。后面google查阅了一些资料,发现系统会调用View action根据mimeType去查询。所以我们要在一开始创建DownloadManager.Request时候调用Requset.setMimeType方法来设置文件类型。
request.setMimeType("application/vnd.android.package-archive");
ok,看到这边,想必让你来实现检查版本升级已然心中有数。
那接下来我把遇到的坑,以及是如何埋坑的一一列出来,一定要努力接着往下看哦。。。
坑一
需求:进入首页后,开启自动检测升级,检测到有升级的版本就随时弹框提示用户,但此时用户可能已经在操作APP进入其他页面,怎么保证弹框可以正常弹出?(APP不会出现异常或者崩溃)
升级提示弹框设置为:
dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
同时在AndroidAmanifest.xml加入权限:
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
我在华为P9上(7.0)上验证测试没有问题,找了谷歌Nexus 5(4.4)验证也没有问题,以为大功告成!
后面我谷歌上找了下关于WindowManager.LayoutParams.TYPE_SYSTEM_ALERT适配,竟然有很多文章爆出小米会有问题而且解决方案也是麻烦不是很靠谱,鬼知道是不是还有其他品牌机型会有适配问题,没办法android厂商太多了&系统各种定制!
靠谱的解决方案
项目中为了解决这个适配问题,达到一劳永逸的目的,我们设计了一个背景透明的UpgradeActivity
,当需要弹出升级提示框时,就启动这个UpgradeActivity
然后再显示这个弹框。
public class UpgradeActivity extends BaseActivity {
boolean isShowDialog = false;
public static void startInstance(Context context) {
Intent intent = new Intent(context, UpgradeActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_upgrade);
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus && !isShowDialog) {
isShowDialog = true;
//升级提示框
AppUpgradeManager.getInstance().foundLatestVersion(this);
}
}
//......
}
有个需要注意的地方就是弹出框关闭的时候要记得同时销毁UpgradeActivity
!!这里没有给出代码,相信你有办法自己解决(广播啊、接口listener啊、EventBus啊等等只要能及时正常关闭都可以)
坑二
我把下载的apk文件存放在sd卡下 Download目录里面,绝对路径变量命名为uriDownload
,启动安装界面安装,代码如下:
Intent installIntent = new Intent();
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
installIntent.setAction(Intent.ACTION_VIEW);
Uri apkFileUri = Uri.fromFile(apkFile);
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
installIntent.setDataAndType(apkFileUri, "application/vnd.android.package-archive");
try {
appContext.startActivity(installIntent);
} catch (ActivityNotFoundException e) {
Timber.d("installAPKFile exception:%s", e.toString());
}
一切完成后开始在真机上测试,7.0以下的真机测试都没有问题。我也以为任务完成可以交差了,刚好手头有一部华为P9已经升级到7.0,安装后测试下载完成升级版本然后调用系统安装界面安装直接崩溃了,查看log有这么一句:
android.os.FileUriExposedException: file:///storage/emulated/0/xxx exposed beyond app through Intent.getData()
认真一看,异常FileUriExposedException
之前从来没碰到过。赶紧google发现原来是Android N
之后,
Android 框架执行的 StrictMode,API 禁止向您的应用外公开 file://URI。如果一项包含文件 URI 的 Intent 离开您的应用,应用失败并出现 FileUriExposedException异常。
若要在应用间共享文件,您应发送一项 content://URI,并授予 URI 临时访问权限。进行此授权的最简单方式是使用 FileProvider类。 如需有关权限和共享文件的更多信息,请参阅共享文件。也就是说,对于应用间共享文件这块,Android N中做了强制性要求。
问题解决
既然我们知道了是什么问题,那就开始解决问题吧。
1、首先在你的manifest里面增加<provider>元素
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.jemlin.app">
<application
...>
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="com.jemlin.app.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
...
</application>
</manifest>
2、res下创建xml目录,在xml下创建provider_paths.xml资源文件
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!--path:需要临时授权访问的路径(.代表所有路径) name:就是你给这个访问路径起个名字-->
<external-path
name="external_files"
path="." />
</paths>
3、修改我们刚才调用安装界面的代码,最终如下:
Intent installIntent = new Intent();
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
installIntent.setAction(Intent.ACTION_VIEW);
Uri apkFileUri;
// 在24及其以上版本,解决崩溃异常:
// android.os.FileUriExposedException: file:///storage/emulated/0/xxx exposed beyond app through Intent.getData()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
apkFileUri = FileProvider.getUriForFile(appContext, BuildConfig.APPLICATION_ID + ".provider", apkFile);
} else {
apkFileUri = Uri.fromFile(apkFile);
}
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
installIntent.setDataAndType(apkFileUri, "application/vnd.android.package-archive");
try {
appContext.startActivity(installIntent);
} catch (ActivityNotFoundException e) {
Timber.d("installAPKFile exception:%s", e.toString());
}
抹一把汗水 囧!Android检查版本升级就介绍这些。如您有问题请留言;如您觉得好,就给个赞吧~~
网友评论
public static final String API_DOMAIN_RELEASE = "配置你自己的服务器基地址";
这两句话使用来干什么的?是接口吗?
可否给一个你们线上的APP的下载地址,我想试一下。。。
你们前一个版本的APP下载链接可以可给我吗?