应用发布后,要实现灰度升级控制,如果只依赖各家应用市场是不够的,需要自己在应用中控制升级逻辑。并且每家应用市场上架审核也是一件很麻烦的事情,尤其像至简网格这样的应用,没在应用市场上架,更不可能依赖它们了。所以需要在应用中实现自动升级功能。
网络上有很多介绍,他们摸索的结果对有很大帮助,可能是因为版本关系,或者关注点不同,照着做会有很多过时的或错误的地方,所以我将摸索过程记录在此,防止忘记。
大致步骤如下:
- AndroidManifest设置;
- 申请外部存储读写权限;
- 申请安装应用;
- 向服务端查询是否有可升级版本,下载版本,执行安装;
各个安卓版本差异较大,测试日期为(2023.5.29),测试环境为小米8(安卓10)。我不考虑兼容安卓7之前的版本,所以代码中也无相关实现。.
一、AndroidManifest设置
AndroidManifest中增加以下权限:
<!-- 网络权限,不用在程序中动态申请 -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<!-- 外部存储读写,需要在程序中动态申请,用于存储运行日志,以及下载的升级版本-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- 安装APK权限,需要在程序中动态申请,并且不同于外部存储读写权限申请 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name="cn.net.zhijian.mesh.client.MeshClientApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MeshClient"
android:requestLegacyExternalStorage="true"
android:windowSoftInputMode="stateHidden|adjustResize">
......
<!-- fileprovider名称在安装时传递给系统安装程序 -->
<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/autoupdate" />
</provider>
</application>
有以下四点需要注意:
-
application中需要增加属性android:requestLegacyExternalStorage="true";
- provider属性android:authorities="${applicationId}.fileprovider",这个名称可以自己定,但是在执行安装时必须保持一致,后面会再次提到;
-
provider中meta-data->android:resource="@xml/autoupdate"名称可以自己定,在res/xml/下必须要有同名的xml文件,Android7.0及以上版本需要通过FileProvider方式进行安装;
- 在res中新建一个xml目录,创建autoupdate.xml,内容如下,注意其中的注释:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 如果不设置root,将会发生“Failed to find configured root that contains...”错误 -->
<root-path name="root_path" path="."/>
<!-- name与path,好像并无太多限制,请了解的同学指正以下 -->
<external-path name="autoupdate" path="download/" />
</paths>
二、申请外部存储读写权限
安卓5.0之后,申请权限发生过一次大变更,以下实现没有考虑兼容老版本。以下代码是MainActivity中片段。为了方便,requestPermissions返回一个CompletableFuture,来支持流式处理。如果觉得没有必要用CompletableFuture,可以直接在onRequestPermissionsResult中处理,这个只是个人喜好。在MainActivity.onCreate函数中调用它来申请权限。
requestPermissions(new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}).thenComposeAsync(r -> { if(r.getInt(HandleResult.CODE) == RetCode.OK) {...} });
private static final Map<Integer, MainFuture> MainFutures = new ConcurrentHashMap<>();
private CompletableFuture<Bundle> requestPermissions(String[] permissions) {
List<String> notGranted = new ArrayList<>();
for(String p : permissions) { //检查权限,已经有的不必再申请
int perm = ContextCompat.checkSelfPermission(MainActivity.this, p);
if (perm != PackageManager.PERMISSION_GRANTED) {
notGranted.add(p);
}
}
if(notGranted.size() == 0) {
return CompletableFuture.completedFuture(MainFuture.ok());
}
//如果还缺少权限,则需要弹出窗口向用户申请
int requestCode = MainFuture.getCode();
ActivityCompat.requestPermissions(MainActivity.this, notGranted.toArray(new String[]{}), requestCode);
MainFuture mf = new MainFuture();
MainFutures.put(requestCode, mf);
return mf.resp;
}
/**
* 申请权限的回调
* @param requestCode 在调用ActivityCompat.requestPermissions时指定
* @param permissions 待申请权限名称列表
* @param grantResults 申请的结果,PackageManager.PERMISSION_GRANTED为成功
*/
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
MainFuture future = MainFutures.get(requestCode);
for (int i = 0; i < permissions.length; i++) {
LOG.debug("Grant {},result is {}", permissions[i], grantResults[i]);
}
if (future == null) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
return;
}
int errNo = -1;
for (int i = 0; i < permissions.length; i++) {
if(grantResults[i] != PackageManager.PERMISSION_GRANTED) {
errNo = i;
break;
}
}
Bundle resp;
if(errNo < 0) {
resp = MainFuture.ok();
} else {
resp = MainFuture.response(RetCode.NO_RIGHT, permissions[errNo] + " not granted");
}
future.resp.complete(resp);
MainFutures.remove(requestCode);
}
private static class MainFuture {
static final AtomicInteger sn = new AtomicInteger(0);
final CompletableFuture<Bundle> resp = new CompletableFuture<>();
public static Bundle response(int code, String info) {
Bundle resp = new Bundle();
resp.putInt(HandleResult.CODE, code);
resp.putString(HandleResult.INFO, info);
return resp;
}
public static Bundle ok() {
return response(RetCode.OK, RetCode.INFO_SUCCESS);
}
public static int getCode() {
return sn.getAndIncrement();
}
}
三、申请安装应用
与申请外部存储读写权限不同,申请安装权限的实现不能用申请其他权限公用,并且registerForActivityResult必须在onCreate中调用,为了避免一安装就申请安装权限,在MainActivity中增加了两个奇怪的成员变量,在真正需要调用requestIntallPermission申请权限时要用到它们。
private int requestInstallCode;
private ActivityResultLauncher<Intent> intentActivityResultLauncher;
/*
* registerForActivityResult必须在onCreate中调用,
* 否则会报错:LifecycleOwners must call register before they are STARTED.
* 所以MainActivity中出现了两个奇怪的成员变量,requestInstallCode与intentActivityResultLauncher,
* intentActivityResultLauncher在requestIntallPermission中会使用。
* 如果不这样实现,就会刚安装就需要申请安装应用的权限,把用户都吓退了。
* 在MainActivity.onCreate中需要调用以下方法初始化它们。
*/
requestInstallCode = MainFuture.getCode();
intentActivityResultLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
(result) -> {
Bundle resp;
//此处是跳转的result回调方法
if (result.getResultCode() != Activity.RESULT_OK) {
resp = MainFuture.response(RetCode.NO_RIGHT, "No right to install app");
} else {
resp = MainFuture.ok();
}
MainFuture mf = MainFutures.get(requestInstallCode);
if(mf != null) {
mf.resp.complete(resp);
MainFutures.remove(requestInstallCode);
}
});
private CompletableFuture<Bundle> requestIntallPermission() {
boolean haveInstallPermission = getPackageManager().canRequestPackageInstalls();
if(!haveInstallPermission) {
MainFuture mf = new MainFuture();
MainFutures.put(requestInstallCode, mf);
runOnUiThread(() -> {
Uri packageURI = Uri.parse("package:" + BuildConfig.APPLICATION_ID);
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, packageURI);
intentActivityResultLauncher.launch(intent);
});
return mf.resp;
}
return CompletableFuture.completedFuture(MainFuture.ok());
}
申请外部存储读写权限与申请安装权限需要在MainActivity.onCreate调用,以下是将两个合起来的实现,使用CompletableFuture的好处就是看起来紧凑一点,一个处理过程不会分布在多个Activity的回调函数中:
/**
* 调用Environment.getExternalStoragePublicDirectory等函数,
* 必须具备外部存储读写权限,
* 除了在manifest中要声明权限,同时在application中设置
* android:requestLegacyExternalStorage="true"
* 并且,还需要在代码中动态申请
*/
String[] permissions = {
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE
};
requestPermissions(permissions).thenComposeAsync(r -> {
LOG.debug("Request {},code:{},info:{}",
permissions, r.getInt(HandleResult.CODE),
r.getString(HandleResult.INFO));
if(r.getInt(HandleResult.CODE) == RetCode.OK) {
return requestIntallPermission();
}
return CompletableFuture.completedFuture(r);
}, IThreadPool.Pool).whenCompleteAsync((r, e) -> {
if(e != null) {
LOG.error("Fail to request permissions {}", permissions, e);
return;
}
if(r.getInt(HandleResult.CODE) == RetCode.OK) {
Updater updater = new Updater(this);
updater.checkVersion();
} else {
LOG.error("Request Install,code:{},info:{}",
r.getInt(HandleResult.CODE),
r.getString(HandleResult.INFO));
}
});
四、升级安装
在上面代码片段中,有一个Updater类,这是我的实现,每个应用可以不同,特别是显示的提醒升级对话框、下载用的库都不一样,但是安装操作是相同的,所以只贴这部分代码。
注意其中的String authority = BuildConfig.APPLICATION_ID + ".fileprovider";前面提到过,必须与provider中保持一致。否则会提示Couldn't find meta-data for provider with authority...错误。
installApk(File apkFile, String digest)
- apkFile:已经下载成功的安装文件,我指定的路径是context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)+"/app.apk",似乎autoupdate.xml中的设置在此并未起什么作用;
- digest:从我的服务器上查到的文件md5值,安装前会比较校验码是否相同,不同则拒绝安装。
/**
* 安装apk
* @param apkFile apk文件完整路径
* @param digest 校验码
*/
private void installAPK(File apkFile, String digest) {
try {
if (!apkFile.exists()) {
LOG.error("Update apk file `{}` not exists", apkFile);
return;
}
String localDigest = FileUtil.digest(apkFile);
if(!localDigest.equals(digest)) {
LOG.error("Invalid apk file `{}` digest({}!={})", apkFile, localDigest, digest);
return;
}
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);//安装完成后打开新版本
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); // 给目标应用一个临时授权
//Build.VERSION.SDK_INT >= 24,使用FileProvider兼容安装apk
//packageName也可以通过context.getApplicationContext().getPackageName()获取
String authority = BuildConfig.APPLICATION_ID + ".fileprovider";
Uri apkUri = FileProvider.getUriForFile(context, authority, apkFile);
intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
context.startActivity(intent);
//安装完之后会提示”完成” “打开”。
android.os.Process.killProcess(android.os.Process.myPid());
} catch (Exception e) {
LOG.error("Fail to install apk {}", apkFile, e);
}
}
希望以上内容对你有点帮助,如果有什么问题,欢迎留言评论,我尽量完善它。