简介:在Android 7.0引入Scoped Installers安全特性后,普通应用无法像旧版本那样直接触发系统安装器完成APK安装。这份资源提供了一套基于PackageInstaller API的完整示例工程,面向需要实现下载后自动安装、应用内更新或静默安装场景的Android开发者。工程详细演示了从清单声明INSTALL_PACKAGES权限、运行时动态请求用户授权,到创建安装会话、以流方式写入APK文件、提交会话并借助BroadcastReceiver监听安装成功或失败状态的全流程,并附带InstallAPKDemo可直接运行与调试的源码。压缩包内共862个文件,约66MB,主要包含XML布局与配置、Java业务逻辑、JSON资源文件、PNG图片素材及Gradle构建脚本,也包含编译好的APK和依赖JAR包,目录结构清晰,便于按模块研读。目前已有13349人学习下载,适合从事Android系统适配、应用分发或企业设备管理的开发者参考。
1. 7.0这道坎:为什么file://突然就不能用了
做Android的老开发应该都对7.0那次改动印象深刻。2016年Android 7.0(Nougat)发布之后,很多App在“下载APK -> 点击通知 -> 跳转安装”这条链路上突然大面积崩溃,日志无一例外都指向同一个异常:FileUriExposedException。我当时排查的第一个线上问题就是这个,用户一升级系统,应用商店下载的APK装不上了,反馈像雪片一样飞过来。
这个崩溃的根本原因是系统对“App对外暴露文件URI”这件事的态度变了。Android 7.0之前,你拿到一个file:///storage/emulated/0/Download/xxx.apk路径,直接扔给Intent就能唤起系统安装器。但从7.0开始,系统强制禁止一个App通过file://URI向另一个App共享文件,因为这种URI暴露的是文件在文件系统中的绝对路径,任何拿到路径的进程都能直接访问,既没法做细粒度的权限控制,也不安全。等于说,原本“把家门钥匙直接递给别人”的做法被系统彻底叫停了。
替代方案是FileProvider,一种基于content://URI的共享机制。content://不暴露真实路径,只暴露一个抽象的URI,由系统通过ContentProvider的机制来授权访问。App在创建URI时可以对目标应用临时授予读写权限(FLAG_GRANT_READ_URI_PERMISSION),权限会在接收方任务栈结束后自动回收,这种“临时授权”的设计从源头解决了路径泄漏问题。
很多没经历过那个时期的Android开发者现在看FileProvider已经是标配了,但你要知道,这套适配是在7.0那次被“强制”出来的。而且它影响的绝不仅仅是“下载安装”这一个场景,凡是涉及App之间共享文件的,比如唤起相机拍照(ACTION_IMAGE_CAPTURE)、打开附件、分享文件,全部都要走这套机制。
2. FileProvider配置与路径映射:从崩溃到跑通
解决7.0的崩溃,第一步就是在AndroidManifest.xml里注册FileProvider。
<application> <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>这里有三个关键点。第一,authorities由${applicationId}动态拼接,保证不同App之间不会撞车,在自己代码里取的时候也要用同一个规则拼。第二,exported="false"是必须的,FileProvider本身的导出要关掉,它的开放能力是通过grantUriPermissions+Intent临时授权来体现的。第三,meta-data指向一个XML文件,用来声明“我可以暴露哪些目录给外部”。
接下来是res/xml/file_paths.xml的配置:
<?xml version="1.0" encoding="utf-8"?> <paths> <external-path name="external_storage" path="." /> <external-files-path name="external_files" path="Download" /> </paths>我把external-path配成了.,意思是整个外置存储卡根目录都允许暴露。实际生产环境出于安全考虑,不建议这样全量开放,最好是精确到目录。但这里有个容易踩的坑:如果你用DownloadManager下载后文件最终落在/storage/emulated/0/Download/,而你在file_paths.xml里配的<external-files-path>对应的是/storage/emulated/0/Android/data/包名/files/,两边对不上,Uri.fromFile和FileProvider.getUriForFile的映射就会失败,导致IllegalArgumentException: Failed to find configured root。做FileProvider映射,脑子里始终要绷着一根弦:代码里传的File路径,必须落在file_paths.xml某个配置项声明的根目录下。
我自己比较根治的做法是,APK下载不一定非要用系统DownloadManager(关于这个我后面会详细说),而是自己指定App专属目录,比如context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),然后配置对应的<external-files-path>,目录是自己创建的,永远可控,不用去猜系统下载目录的路径。不过很多产品和运营的诉求是APK要出现在“下载”文件夹里,用户找得到,那你就得和DownloadManager的目录做好映射,这个心情我能理解,所以后面两章我把两条路都走一遍。
3. 从DownloadManager到安装器:一条完整的下载安装链路
先说“下载”这个环节。Android里下载APK最正统的姿势是DownloadManager,系统级服务,不需要自己维护下载任务线程,自带断点续传和通知栏进度展示。核心思路是发起下载请求、注册广播接收下载完成通知、把下载好的文件通过FileProvider转成content URI唤起安装。
3.1 发起下载任务
val request = DownloadManager.Request(Uri.parse(downloadUrl)) request.setTitle("应用更新") request.setDescription("正在下载最新版本") request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "app_update_v$versionCode.apk") val manager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val downloadId = manager.enqueue(request)setDestinationInExternalPublicDir搭配Environment.DIRECTORY_DOWNLOADS,文件会落在/storage/emulated/0/Download/下。这里有个操作细节:如果同名文件已经存在,DownloadManager会直接把新下载的任务标记为失败(STATUS_FAILED,reason是ERROR_FILE_ALREADY_EXISTS),所以要么下载前先删旧文件,要么给文件名加上时间戳或版本号。我在代码里偏好用“包名+版本号+时间戳”拼文件名,从根源上避免冲突。
3.2 监听下载完成
DownloadManager完成通知是个广播,动态注册即可:
private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) if (id != downloadId) return val query = DownloadManager.Query().setFilterById(id) val cursor = manager.query(query) if (cursor.moveToFirst()) { val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) if (status == DownloadManager.STATUS_SUCCESSFUL) { val uri = manager.getUriForDownloadedFile(id) openInstaller(uri) } else { val reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)) // 处理失败 } } cursor.close() } }getUriForDownloadedFile从DownloadManager返回的默认就是content://URI,听起来好像可以直接跳过FileProvider的步骤了?这里有一个非常隐蔽的坑,后面专门说。
3.3 唤起安装器
这是“自动安装”的核心,Android 7.0下的标准做法:
fun openInstaller(uri: Uri) { val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } startActivity(intent) }FLAG_GRANT_READ_URI_PERMISSION意图非常明确:把读权限临时共享给系统安装器,安装器在它自己的任务结束之后,这个授权就自动失效了。这个Flag在这一步是绝对不能漏掉的,漏了就会报Permission Denial: opening provider。
但这里要特别强调getUriForDownloadedFile和FileProvider两条路线之间的差异。对同一份APK文件:
- 用
manager.getUriForDownloadedFile(id)拿到的URI是DownloadManager内部自己定的一个content URI,由系统DownloadManager的机制来处理授权。看起来能用,但实测在部分厂商ROM上有兼容性问题。 - 用
FileProvider.getUriForFile(context, "包名.fileprovider", File)拿到的是你自己Provider的content URI,配合FLAG_GRANT_READ_URI_PERMISSION,是官方推荐、兼容性最稳的做法。
我在开发中始终选择后者。做法是拿到DownloadManager的下载路径之后,从COLUMN_LOCAL_URI里解析出真实File路径,再丢给FileProvider重新生成URI。
4. 安装环节:Intent构建、未知来源权限与7.0/8.0边界
严格说,“未知来源安装”这个开关是Android 8.0(O)才大改的,但因为很多7.0时代的App后来升级targetSdk会连着踩这个雷,我干脆放在一起讲清楚。
4.1 Android 7.0到8.0之间发生了什么
Android 7.0时,用户安装未知来源APK时,系统会弹一个“允许安装未知来源应用”的全局总开关,开关一开,所有应用都可以装APK。到了Android 8.0,这个“一刀切”的总开关被废除了,改成按应用维度授权:哪个App要装APK,用户得单独给这个App开权限。做法是在你的安装入口之前检查Settings.canDrawOverlays那种类型的权限,这里对应的是Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES。
所以如果你处理的场景是“targetSdkVersion >= 26同时在8.0+设备上”,需要在唤起安装器前加上权限判断:
fun installApk(context: Context, uri: Uri) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val canInstall = context.packageManager.canRequestPackageInstalls() if (!canInstall) { // 跳转到本应用的未知来源安装授权页 val intent = Intent( Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}") ) context.startActivity(intent) return } } // 7.0及以上的标准安装Intent val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(intent) }需要注意,canRequestPackageInstalls()返回false不代表安装被永久禁止,你跳到授权页让用户打开开关,从授权页返回后要再走一遍安装逻辑。比较常见的做法是在onResume或onActivityResult里重新检查权限并触发安装。
4.2 安装Intent的目标系统行为
ACTION_VIEW加application/vnd.android.package-archiveMIME类型,系统会把APK交给PackageInstaller,也就是用户看到的那个带“安装”“取消”按钮的界面。这一步没法做到真正意义上的“静默安装”,除非你是设备所有者(Device Owner)或者有系统签名,普通App走到这里必须经过用户确认。所以标题里说的“自动安装”,业内共识是“下载完成后自动唤起安装界面并带好所有授权”,更精确地说叫“下载后自动引导安装”。
我在实际项目里会把“点击通知栏”这一步也弱化掉:下载完成后广播里直接唤起安装界面,用户看到安装弹窗时手指只需要点一下“安装”就行。这个体验比“下载完 -> 用户再点通知 -> 再点安装”少了两跳,对移动端转化率是有直接帮助的。
4.3 从7.0到6.0的兼容写法
如果App还在兼容Android 6.0及以下,判断逻辑不能按7.0一刀切:
fun getInstallIntent(file: File): Intent { val intent = Intent(Intent.ACTION_VIEW) intent.setDataAndType( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { FileProvider.getUriForFile(context, "$packageName.fileprovider", file) } else { Uri.fromFile(file) }, "application/vnd.android.package-archive" ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } return intent }5.0、6.0还可以用file://,7.0开始必须切到FileProvider。如果targetSdk设得高而系统版本低,系统会有弹窗提示“为旧版系统打造,可能无法正常运行”,这种属于厂商兼容策略,不用慌。
5. 实测最容易翻车的几个坑与排查清单
这一章全部来自真实踩坑记录。有些问题只在特定ROM上出现,有些是所有机型通吃,我按频率排个序。
5.1 权限声明不全
很多人只记得写WRITE_EXTERNAL_STORAGE和REQUEST_INSTALL_PACKAGES,漏掉了最关键的:
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />这个权限负责“不弹通知也能下载”。如果你的下载任务设置了VISIBILITY_HIDDEN但没声明这个权限,任务一发起就直接SecurityException。另外运行时权限的申请顺序也很重要:Android 6.0+在发起DownloadManager之前必须已经把存储读写权限拿到手,否则下载会失败,而且失败后的错误信息藏在COLUMN_REASON里,不看日志很难定位。
5.2 DownloadManager拿到的URI直接扔给Intent报FileUriExposedException
前面说过getUriForDownloadedFile理论上返回的是content URI,不会崩。但如果你自己拼File路径(有的项目里会用COLUMN_LOCAL_URI拿到文件地址后重新new File),再通过Uri.fromFile转成File URI扔给Intent,那在7.0上直接撞FileUriExposedException。这类崩溃线上量往往还不小,因为很多开发不知道DownloadManager返回的路径还要再走一道FileProvider映射。
5.3 FileProvider的authorities与代码不一致
FileProvider.getUriForFile(context, "你的applicationId.fileprovider", file)这里第二个参数是字符串,如果和Manifest里android:authorities拼的不一致,崩溃信息会提示Unable to find specified provider。如果你用了applicationId后缀方式,但模块混淆或buildTypes里配置了applicationIdSuffix(比如.debug、.dev),这段字符串就会对不上。最稳的写法是先在代码里动态取一次,或直接用BuildConfig.APPLICATION_ID + ".fileprovider"拼出来,不要把字符串写死在上百个地方。
5.4 下载目录被清理/不可写
部分国产ROM对/storage/emulated/0/Download/的写入限制比较任性,尤其当App没有用户可见的下载行为时,系统可能把下载目录清掉,或者DownloadManager写不了这个目录。我实测过的最严重的一次:某品牌手机在用户清理垃圾时,把Download目录里的APK连同下载任务记录一起干掉了,但用户侧看到的反馈只是“下载完了点通知没反应”。后来我把下载目录迁到getExternalFilesDir下,App私有外部目录,即便用户清垃圾,系统也不会动自己私有的数据目录,问题才消停。代价是用户去文件管理器看不到下载的APK,但更新完成率比“看得到”重要得多。
5.5 厂商ROM对“8.0未知来源安装”的魔改
Google原生8.0是“首次唤起安装器时弹权限申请”,但不少国产ROM把“允许安装未知应用”这个开关默认设成了“询问”或“禁止”,而且藏在设置的很深处,用户根本找不到。这种情况下你能做的就是引导页做得足够清晰:告诉用户点“去授权”,跳转到ACTION_MANAGE_UNKNOWN_APP_SOURCES后,用户开了再回来。实测中拿不到这个权限时,就算startActivity抛异常前你做了try-catch,用户也只会看到一个“无法打开安装界面”的Toast,体验很糟。所以我会在进安装页之前先检查权限,没权限就先引导,不要等用户点了下载完了再引导。
5.6 下载ID丢失
downloadId最好直接SharedPreferences持久化。不然App进程被杀重建之后,广播里的EXTRA_DOWNLOAD_ID和内存里的ID对不上,下载完成的广播怎么等都等不到。有些国产ROM后台清理非常激进,App在下载过程中被杀掉,然后系统把下载任务挂在后台完成了,广播发出来却被系统连带着一起干掉了,用户看到通知栏100%但点击后无反应。折中方案是启动时把DownloadManager里未完成的任务全量查一遍,找到目标文件直接判断是否已下载完成,若已完成就补一次安装引导。
5.7 覆盖安装时版本号判断
下载前一定要校验服务器返回的versionCode大于当前versionCode,否则用户下载完,安装时系统告诉你“已安装相同版本”,体验极其尴尬。更细一点,因为APK可能在多个市场渠道分发,我建议把下载接口返回的versionCode同时写在本地下载任务的文件名里,更新检测时先比版本再比文件大小,能少走很多坑。
6. 关于“下载器”选型的一个个人建议
最后展开说一个我反复提到的东西——很多项目不一定要用DownloadManager。如果你要精细控制下载过程,比如断点续传、进度条回调到UI、多任务并发、加密/校验APK完整性,DownloadManager的封装其实并不方便。它的DownloadManager.Query只能轮询,进度粒度是bytes,返回状态偶尔还会抽风(部分ROM上任务明明完成了状态还是STATUS_RUNNING)。
我自己后期做的更新模块,下载这块是直接用OkHttp+ 协程自己拉的,下载到App私有目录,算好MD5,再用同一个FileProvider URI去安装。优点是全程逻辑在自己手里,权限、异常、进度都能兜住;缺点是断点续传得自己实现(Range头 + RandomAccessFile),展示通知栏要自己发通知。两套方案没有绝对的优劣,如果产品对下载过程没有强要求,用DownloadManager省心;如果下载是本App的高频核心路径,尤其是需要上报下载状态、失败重试策略的,建议自己写。
这句话我也送给刚开始做这块的人:不要让“自动安装”变成“装不上之后用户只能卸载重装”,下载路径、FileProvider映射、权限引导这三件事,上线前挨个在7.0、8.0、10.0、13.0各找一台真机过一遍,比看任何文章都管用。
本文还有配套的精品资源,点击获取