在Android 11上实现完整的应用内更新功能,开发者会遇到三个主要技术难点:后台下载的稳定性、作用域存储的文件访问限制,以及静默安装的权限适配。我去年在开发一款企业级应用时就深有体会,当时为了适配Android 11的存储策略改动,整整花了两周时间重构下载模块。
先说后台下载,很多开发者习惯用传统线程池+HttpURLConnection的方案,但在Android 11上这种方案会遇到两个坑:一是后台服务限制导致下载容易被系统终止,二是无法利用系统级的下载恢复机制。实测下来,使用DownloadManager是最稳妥的方案,它自带断点续传功能,即使应用进程被杀死也能继续下载。
作用域存储带来的影响更大。以前我们可以随意访问SD卡任意目录,现在只能通过特定API访问应用专属目录。这就意味着下载APK时必须精确指定存储位置,我推荐使用Context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)获取应用专属下载路径,这个目录不需要申请存储权限也能读写。
安装环节的适配最复杂。从Android 8.0开始,直接使用file:// URI会触发FileUriExposedException,必须改用FileProvider。而Android 11进一步收紧了安装权限,必须满足三个条件:
创建DownloadManager.Request对象时,有几个关键参数需要特别注意。以下是我在电商App中实际使用的配置代码:
java复制private DownloadManager.Request buildDownloadRequest(String url) {
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
// 设置仅在WIFI环境下下载
request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);
// 不显示系统下载通知
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
// 设置文件保存路径
File downloadDir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
request.setDestinationUri(Uri.fromFile(new File(downloadDir, "update.apk")));
// 允许在计量网络下下载(默认不允许)
request.setAllowedOverMetered(false);
// 允许漫游时下载(默认不允许)
request.setAllowedOverRoaming(false);
return request;
}
这里有个实际开发中的经验:如果应用需要支持Android 10及以下版本,记得在Manifest中添加WRITE_EXTERNAL_STORAGE权限。但在Android 11上,即使声明了这个权限,也无法访问非应用专属目录。
通过DownloadManager.Query可以实时获取下载进度,我通常会用Handler将进度回调到主线程更新UI。下面是核心代码片段:
java复制private void trackDownloadProgress(long downloadId) {
new Thread(() -> {
DownloadManager.Query query = new DownloadManager.Query();
query.setFilterById(downloadId);
boolean downloading = true;
while (downloading) {
try (Cursor cursor = downloadManager.query(query)) {
if (cursor != null && cursor.moveToFirst()) {
int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS));
switch (status) {
case DownloadManager.STATUS_SUCCESSFUL:
downloading = false;
handler.sendEmptyMessage(MSG_DOWNLOAD_COMPLETE);
break;
case DownloadManager.STATUS_RUNNING:
int total = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
int current = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
Message msg = handler.obtainMessage(MSG_UPDATE_PROGRESS,
(int) (current * 100f / total));
handler.sendMessage(msg);
break;
case DownloadManager.STATUS_FAILED:
downloading = false;
handler.sendEmptyMessage(MSG_DOWNLOAD_FAILED);
break;
}
}
}
Thread.sleep(1000); // 每秒查询一次
}
}).start();
}
在实际项目中,我发现两个需要特别注意的地方:
Android 11强制要求通过FileProvider共享文件,配置步骤如下:
xml复制<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>
xml复制<paths>
<external-files-path
name="download"
path="Download/" />
<!-- 兼容旧版本 -->
<root-path
name="root"
path="." />
</paths>
这里有个坑我踩过:如果path属性值写错,会导致FileProvider无法找到文件。比如写成"Download"(不带斜杠)就会解析失败。
针对不同Android版本,需要采用不同的文件访问方式。我封装了一个工具类来处理这个逻辑:
java复制public class ApkFileHelper {
public static Uri getApkUri(Context context, File file) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return FileProvider.getUriForFile(
context,
context.getPackageName() + ".fileprovider",
file);
} else {
return Uri.fromFile(file);
}
}
public static File getDownloadApkFile(Context context) {
File downloadsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
return new File(downloadsDir, "update.apk");
}
}
在音乐播放器项目中,我发现有些厂商的ROM对存储访问有特殊限制,所以额外加了异常处理:
java复制try {
Uri apkUri = ApkFileHelper.getApkUri(this, apkFile);
} catch (IllegalArgumentException e) {
// 处理某些厂商ROM的兼容问题
Log.e(TAG, "FileProvider路径配置错误", e);
showToast("文件访问失败,请检查存储权限");
}
从Android 8.0开始,需要用户授权安装未知来源应用的权限。可以在代码中检查并引导用户开启:
java复制private boolean checkInstallPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (!getPackageManager().canRequestPackageInstalls()) {
// 跳转到设置界面
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
.setData(Uri.parse("package:" + getPackageName()));
startActivityForResult(intent, REQUEST_CODE_INSTALL_PERMISSION);
return false;
}
}
return true;
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_INSTALL_PERMISSION) {
if (checkInstallPermission()) {
// 用户已授权,继续安装流程
startInstall();
}
}
}
在智能家居App中,我发现有些用户会忽略这个权限设置,所以加了二次提醒:
java复制if (!checkInstallPermission()) {
new AlertDialog.Builder(this)
.setTitle("安装权限提醒")
.setMessage("需要允许安装未知来源应用才能完成更新")
.setPositiveButton("去设置", (d, w) -> checkInstallPermission())
.setNegativeButton("取消", null)
.show();
}
完整的安装Intent构建应该考虑所有Android版本的兼容性:
java复制private void installApk(File apkFile) {
Intent install = new Intent(Intent.ACTION_VIEW);
install.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
Uri apkUri;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
apkUri = FileProvider.getUriForFile(
this,
getPackageName() + ".fileprovider",
apkFile);
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
} else {
apkUri = Uri.fromFile(apkFile);
}
install.setDataAndType(apkUri, "application/vnd.android.package-archive");
startActivity(install);
}
在金融类App中,我们还加了文件完整性校验:
java复制private boolean verifyApk(File apkFile) {
try {
PackageManager pm = getPackageManager();
PackageInfo info = pm.getPackageArchiveInfo(apkFile.getPath(), 0);
return info != null;
} catch (Exception e) {
return false;
}
}
网络不稳定的情况下,建议实现自动重试逻辑。我的做法是:
java复制private void downloadWithRetry(String url, int maxRetry) {
int retryCount = 0;
while (retryCount < maxRetry) {
try {
long downloadId = startDownload(url);
trackDownloadProgress(downloadId);
return;
} catch (Exception e) {
retryCount++;
if (retryCount >= maxRetry) {
handler.sendEmptyMessage(MSG_DOWNLOAD_FAILED);
} else {
handler.sendEmptyMessage(MSG_RETRY_DOWNLOAD);
}
}
}
}
在海外项目中,我们还根据HTTP状态码区分处理:
安装完成后,通常需要执行一些清理工作。可以通过广播接收器监听安装结果:
java复制public class InstallReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals(Intent.ACTION_PACKAGE_REPLACED)) {
String packageName = intent.getData().getSchemeSpecificPart();
if (packageName.equals(context.getPackageName())) {
// 删除临时APK文件
File apkFile = ApkFileHelper.getDownloadApkFile(context);
if (apkFile.exists()) {
apkFile.delete();
}
}
}
}
}
在Manifest中注册这个接收器:
xml复制<receiver android:name=".InstallReceiver">
<intent-filter>
<action android:name="android.intent.action.PACKAGE_REPLACED" />
<data android:scheme="package" />
</intent-filter>
</receiver>
在不同Android版本上测试时,要特别注意这些场景:
我在测试矩阵中通常会包含这些设备: