简介:在Android 7.0及更高版本中,系统通过Scoped Installers机制收紧了APK自动安装限制,普通开发者需要借助PackageInstaller与更细粒度的权限模型完成静默升级。这份资源以InstallAPKDemo示例工程为主线,讲解从运行时申请INSTALL_PACKAGES权限、创建PackageInstaller会话并设置模式、将APK流式写入会话到最终提交验签和接收安装状态回调的完整流程,同时针对权限不可授予时如何改用MDM或侧载方式分发给出说明,适合处理应用内升级、批量部署等需求的移动开发工程师。压缩包共862个文件,以xml、java、json、gradle为主,其中xml与java分别覆盖界面布局和核心逻辑,gradle与apk可直接构建和运行调试;资源连同构建产物共66.03MB,目录结构贴近真实Android工程。已有13391人学习,示例中包含清晰的BroadcastReceiver处理逻辑,注释与代码组织便于迁移,可帮助读者避开常见权限坑点,快速实现适配Android 7.0+的自动安装方案。
1. 从崩掉的“安装”按钮说起:Android 7.0的APK自动安装难点
某次在模拟项目X的升级模块里,我把targetSdk从23抬到25,结果真机上点击“立即安装”直接崩,日志只剩一句“FileUriExposedException”。在Android 7.0上,从DownloadManager下载APK到调起系统安装器这段链路,被系统安全模型拦腰截断:跨应用共享文件不允许再用file://,必须换成content://并通过FileProvider授权。这篇笔记把整条链路拆开讲清楚——Provider怎么配、安装Intent怎么带权限、7.0那颗“未知来源”开关藏在哪、以及五个容易让安装流程翻车的地方。适合正在做应用内自动更新,或正准备抬targetSdk的Android开发照着复现。
2. FileProvider配置:Android 7.0安装APK的第一个硬门槛
2.1 从崩溃日志认识FileUriExposedException
targetSdk提升到24之后,通过ACTION_VIEW方式拉起系统安装器时,如果Intent里携带的是file://路径,系统会直接抛android.os.FileUriExposedException。这不是包安装器本身拒绝安装,而是在Activity.startActivity阶段就被系统的“URI暴露检查”拦下来。因为7.0开始,系统默认应用不应当把自己的文件路径直接暴露给其他进程,哪怕这个进程是系统的packageinstaller。
常见的错误写法是这样:
File apk = new File(Environment.getExternalStoragePublicDirectory (Environment.DIRECTORY_DOWNLOADS), "update.apk"); Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(Uri.fromFile(apk), "application/vnd.android.package-archive"); startActivity(intent);这段代码在Android 6.0或targetSdk 23时能正常弹出安装界面,在Android 7.0加targetSdk 24的组合下就会崩溃。触发条件是“系统版本 >= 7.0”和“targetSdk >= 24”同时满足,缺一个它都不会崩——这也是很多老项目一直没暴露问题的原因:系统升到7.0但targetSdk还没动,代码表面上是好的,只等抬targetSdk的那一天一起爆发。
正确做法是让文件通过FileProvider暴露成content:// URI,再通过Intent临时授权给系统安装器读取。FileProvider是系统自带的一个ContentProvider,在manifest里注册时绑定authority,运行时把apk的File路径转换成content URI。
2.2 Manifest注册与file_paths.xml路径映射
先在AndroidManifest.xml的application节点下注册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/file_paths" /> </provider>这里有两个细节要盯紧。exported必须为false,provider不允许被其他应用直接访问,访问权限靠grantUriPermissions临时授予;${applicationId}.fileprovider这个authorities要在后续getUriForFile时保持一致,如果项目里applicationId做过后缀定制,不能用packageName替代,否则getUriForFile会因authority对不上而抛IllegalArgumentException。
接下来在res/xml/file_paths.xml里声明代码能暴露哪些路径:
<paths> <external-path name="external_download" path="Download/" /> <external-cache-path name="external_cache" path="." /> <cache-path name="internal_cache" path="." /> <files-path name="internal_files" path="." /> </paths>path是“允许暴露的目录”,.表示整个目录树都可以映射。external-path对应的是Environment.getExternalStorageDirectory(),比如放在Download目录下的APK就由它管;external-cache-path对应context.getExternalCacheDir();cache-path对应context.getCacheDir();files-path对应context.getFilesDir()。文件放哪一层,对应写哪一条,漏写或多写都会在运行时出问题。
不做路径映射的替代做法是把APK从外部存储复制进应用cache再安装,但DownloadManager默认目标目录更省事,所以多数场景还是把external路径配好。
2.3 把File转成content URI并临时授权
配置完成后,安装Intent的代码变成这样:
File apkFile = new File(Environment.getExternalStoragePublicDirectory (Environment.DIRECTORY_DOWNLOADS), "update.apk"); Uri apkUri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", apkFile); Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(apkUri, "application/vnd.android.package-archive"); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); context.startActivity(intent);getUriForFile会把真实文件路径映射成content://格式,它做的只是“转换”,真正允许安装器读取文件要靠FLAG_GRANT_READ_URI_PERMISSION。这个flag告诉系统本次启动的Activity可以临时读取这个URI指向的文件,不需要申请存储权限,也不需要安装器显式持有读权限。注意setDataAndType必须同时设置MIME类型,缺失时包安装器可能无法识别这是APK,直接打开文件管理器或者不响应。从公共下载目录用external-path映射,从外部缓存用external-cache-path映射,映射错了运行时会直接抛异常,到那一环再处理真不如配置阶段多核对一遍。
3. 下载完成到自动调起安装:完整代码链路
3.1 用DownloadManager下载APK并监听完成广播
使用系统DownloadManager下载的好处是无需自己维护下载线程和通知栏进度,下载完成的结果通过广播推送。先构造DownloadManager.Request:
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(downloadUrl)); request.setDestinationInExternalPublicDir( Environment.DIRECTORY_DOWNLOADS, "update_" + System.currentTimeMillis() + ".apk"); request.setMimeType("application/vnd.android.package-archive"); request.setNotificationVisibility( DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); long downloadId = downloadManager.enqueue(request);setDestinationInExternalPublicDir指定文件落在公共Download目录,第一个参数是子目录类型,第二个参数是文件名。如果希望文件尽量不易被用户误删,可以改用setDestinationInExternalCacheDir,但对应FileProvider映射要同步改成external-cache-path。setMimeType在这里很重要,DownloadManager下载完成后靠它决定通知栏点击行为和广播携带的信息,HTTP直链的服务端没带Content-Type时,建议在Request里强制写application/vnd.android.package-archive。
然后注册完成广播:
BroadcastReceiver receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { long id = intent.getLongExtra( DownloadManager.EXTRA_DOWNLOAD_ID, -1); if (id == downloadId) { installApk(context, filePath); } } }; context.registerReceiver(receiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));广播只负责任知“下载完成”,并不能保证该downloadId一定成功,需要自行读取DownloadManager状态确认status为SUCCESS。另一个容易被忽略的点是registerReceiver要在合适的生命周期解除注册,在Activity里注册要在onDestroy里unregisterReceiver,否则会有泄漏提示;用Application级Context注册则要保证Receiver实例只有一个副本,避免下载完成时重复回调弹多个安装页。
3.2 安装Intent:MIME类型、flags和Activity检测
收到广播后进入installApk,这一步的关键是把File转成content URI,并确保目标Activity存在:
private void installApk(Context context, File apkFile) { Uri apkUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", apkFile); Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(apkUri, "application/vnd.android.package-archive"); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); if (context.getPackageManager().queryIntentActivities( intent, PackageManager.MATCH_DEFAULT_ONLY).size() > 0) { context.startActivity(intent); } else { // 系统没有能处理这个Intent的包安装器,提示用户手动安装 openFileByFileManager(context, apkFile); } }queryIntentActivities在部分定制ROM上表现不一,但它的价值在于把“装不了”的情况提前拦截下来,而不是让startActivity抛ActivityNotFoundException。设置FLAG_ACTIVITY_NEW_TASK是因为这里的context可能是Application或Service上下文,不带这个flag启动Activity会被系统拒绝;FLAG_GRANT_READ_URI_PERMISSION授权给接收方读取URI,这两个flag都不能省。
3.3 Android 7.0的“未知来源”开关引导
Android 7.0上,如果没开启“设置—安全—未知来源”,点击安装后系统会弹“安装被阻止”之类的提示,而不是直接进入安装界面。代码里要做的是探测未知来源是否允许,再把用户引导到对应的设置页。Android 8.0以后系统给了独立入口ACTION_MANAGE_UNKNOWN_APP_SOURCES,可以直接带本应用包名跳到该应用自己的“允许此来源”开关页;7.0没有这么细的入口,只有一个全局开关藏在Security Settings里。
对应的引导代码可以这样写:
if (Build.VERSION.SDK_INT >= 26) { Intent intent = new Intent( Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())); startActivity(intent); } else { startActivity(new Intent(Settings.ACTION_SECURITY_SETTINGS)); }7.0用户跳过去之后需要手动找到“未知来源”并打开开关,8.0+则可以直接看到针对本应用的开关。对于7.0,App本身拿不到开关状态的直接读取API,常见的做法是在Activity.onResume里重新走一次安装流程或弹提示“请在设置中允许安装未知来源应用”,让用户开完开关后系统能回归到安装流程。这一节是整个自动安装链路上“自动”和“手动”的分界线,真正的系统级静默安装需要系统签名或root权限,常规第三方应用能做的只是“引导用户少点两步”。
4. 兼容边界与版本判断:targetSdk、系统版本和厂商定制
4.1 触发安装行为差异的是targetSdk,不是运行系统版本
Android 7.0的FileUriExposedException触发条件要再强调一遍:只有当targetSdkVersion>=24同时设备版本>=7.0时,file:// URI才会被封。很多项目在适配时只判断Build.VERSION.SDK_INT >= 24,反而把一个不必要的问题引入了targetSdk 23的老代码。反过来,targetSdk抬到28但测试机是6.0,也不会崩——是targetSdk定义了App承诺遵循的规则,设备版本决定系统手里有没有执行这条规则的哨兵。
这个边界在写兼容代码时很有用,如果希望保留老版本Android的兼容性,判断语句应该围绕当前App的targetSdk而非Build.VERSION.SDK_INT:
if (Build.VERSION.SDK_INT >= 24 && getApplicationInfo().targetSdkVersion >= 24) { // 走FileProvider + content URI } else { // 老版本仍然可以使用file:// URI }但现实情况是绝大多数新发布App的targetSdk都在28以上,所以这条分支在实际工程里往往只剩“防御”意义。它在排查问题时价值更高:如果一个老项目在7.0上跑得好好的,升级targetSdk后突然安装崩溃,第一反应先核对targetSdk是否跨过24这条线。
4.2 API 26之后的REQUEST_INSTALL_PACKAGES权限
Android 8.0对“安装未知来源应用”又做了一层收紧:目标应用必须声明android.permission.REQUEST_INSTALL_PACKAGES,并且需要用户在“允许安装其他来源的应用”列表里为它打开开关,否则调用startActivity进入安装界面时,系统的拦截页可能直接出现“不允许安装应用”,或者干脆没有任何响应。manifest里要补上:
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />然后判断并引导:
PackageManager pm = getPackageManager(); if (Build.VERSION.SDK_INT >= 26 && !pm.canRequestPackageInstalls()) { // 进入应用自己的“允许此来源安装”设置页 } else { // 直接开始安装 }canRequestPackageInstalls是API 26新增方法,7.0的设备没有这个方法,所以必须先判断版本再调用,直接用Build.VERSION.SDK_INT >= 26包住整个判断。权限声明加在manifest里不会影响老版本行为,老版本系统会忽略这个未知权限,所以可以直接写在主manifest里,不用做value-v26拆分。
4.3 部分厂商ROM的安装白名单与设置页差异
不同厂商ROM对“未知来源”的魔改主要体现在三处:把设置入口从安全设置挪到应用详情页、把安装器Activity换成自家包名、ACTION_SECURITY_SETTINGS跳转后白屏或找不到开关。这些差异在企业应用分发的场景下最常见,不能说代码写错了,只能说目标设备不走普通路。
兜底策略是“能跳则跳,跳了不管”。App只负责把入口打开,不要试图监听开关是否真的被打开,因为7.0上没有任何官方API可以读取未知来源开关状态,部分ROM上连ACTION_MANAGE_UNKNOWN_APP_SOURCES都不认。如果用户跳了设置还没装成功,可以让安装引导页停留在一个状态位,等用户回到App后再次触发安装,而不是无限弹同一个设置页把用户卡死在循环里。
还有个隐藏的版本差异:7.0上“未知来源”是全局开关,打开后对所有App生效;8.0以后变成按应用维度管理,每个应用需要单独授权。体现在代码上,8.0的引导需要特意带上package参数跳转,7.0则无法直接跳到应用维度。如果App同时需要兼容两代系统,引导逻辑就要按系统版本分叉,这也是后面容易翻车的地方。
5. APK自动安装常见问题排查:五个翻车现场
5.1 起步就崩:FileUriExposedException
现象:targetSdk抬到24后,点击安装按钮瞬间崩溃,logcat里能看到FileUriExposedException。 原因:Intent里setData用的是Uri.fromFile()生成的file:// URI,7.0+不再允许通过隐式Intent暴露文件路径。 解决:换FileProvider.getUriForFile生成content:// URI,并在Intent上带FLAG_GRANT_READ_URI_PERMISSION。怀疑路径映射有问题时,在getUriForFile后打点日志确认URI前缀是content://,同时确认file_paths.xml里的path能覆盖目标文件目录。
5.2 系统一直提示“不允许安装未知来源”
现象:代码没有任何异常,但安装界面不出来,或者在系统拦截页提示“未知来源”被禁止。 原因:Android 7.0的全局“未知来源”开关没开,8.0+的“允许此来源”未授权,manifest里也漏了REQUEST_INSTALL_PACKAGES权限。 解决:调startActivity之前先做权限探测——API 26+用canRequestPackageInstalls,7.x只能跳Settings.ACTION_SECURITY_SETTINGS。7.0没有读取开关状态的API,只能靠“跳设置页—返回—重试安装”的体验流程兜住。
5.3 文件名带中文,内容解析失败
现象:DownloadManager广播完成,文件也在Download目录里能看到,但getUriForFile返回URI正常,安装器拿到手后报FileNotFound或解析包失败。 原因:下载地址或设置的文件名里包含中文、空格、特殊符号,部分ROM的ContentResolver对非UTF-8 URI处理不一致,导致安装器按另一种编码去读路径。 解决:下载文件名统一用"update_" + System.currentTimeMillis() + ".apk"这种纯ASCII命名,不要在文件名里拼接应用名中文或带空格的版本号。这条方案看起来简单,但能避开一整个数据乱码的坑。
5.4 安装界面调起来了,但点击“完成”后应用没有后续
现象:安装过程结束,系统回到桌面,但宿主App没有收到任何安装回调;或downloadId判断失败,导致重复弹安装页。 原因:安装结果不会通过DownloadManager广播返回,需要注册PackageInstaller回调或直接用ACTION_VIEW后自行感知时机;downloadId判断失败多数是因为处理了上一次下载的广播。 解决:不要依赖“下载完成广播”做安装结果分支,下载完成只负责启动安装Intent。需要监听安装成功不一定要接入PackageInstaller,简单方案是安装完成后让用户在通知栏里点开新装应用,应用侧等下一次启动时做版本对比。
5.5 FileProvider路径映射对不上,getUriForFile抛异常
现象:getUriForFile这行直接抛“Failed to find configured root”,或文件明明存在却提示路径无法访问。 原因:APK所在目录和file_paths.xml中声明的path节点不匹配。比如文件在Download目录里,配置里写的是external-cache-path,系统会认为该路径未登记。 解决:先确认文件物理位置,再对照配置里的路径标签——公共Download用external-path,getExternalFilesDir用external-files-path,cache用cache-path。最稳妥的做法是只保留一条路:下载和读取都走同一个目录,减少两套路径映射带来的不确定性。
五条排错顺序建议从logcat关键字倒推:FileUriExposedException先查配置,PackageInstaller相关拒绝先查权限,IllegalArgumentException直接看堆栈定位到具体目录。
6. 把“自动安装”流程验证成肌肉记忆的三个动作
6.1 adb直接验证FileProvider和URI授权
FileProvider配置完后,我习惯先用adb把安装Intent拉起来,排除代码上下文的影响。命令行里需要手动带上类型参数:
adb shell am start -a android.intent.action.VIEW \ -d "content://com.example.app.fileprovider/external_download/update_123.apk" \ -t "application/vnd.android.package-archive"这条命令能拉起安装界面,说明映射、URI、文件位置全对;静默失败或报错,优先查file_paths.xml的path和文件真实路径有没有对上一套。
6.2 targetSdk对照测试
出一个打包targetSdk 23的debug包和当前targetSdk 25+的release包,在7.0设备上分别跑一次安装流程。targetSdk 23那个包仅用于验证“老系统下链路本身没坏”,targetSdk高的那个用来验证是否触发URI权限变化。两包行为差异一旦出现,定位范围立刻缩小到安全模型适配而不是下载逻辑。
6.3 看logcat关键字,别只盯崩溃堆栈
安装流程异常时,常见输出包括“PackageInstaller: Unknown source”“ActivityTaskManager: Start activity”。出现Unknown source字样,直接去检查未知来源开关与REQUEST_INSTALL_PACKAGES;出现ComponentInfo找不到安装器,则去检查queryIntentActivities判断逻辑。把这两个关键字写进监控过滤规则里,企业应用大批量上真机跑安装时会格外省心。
有一段时间我在这类问题上反复吃同样的亏,后来强制自己每次发布新版本前都把这三步走一遍:adb验证URI、双targetSdk对照、logcat关键词过滤。这半小时不会多想,但在发版后能省下不知道多少个回滚的深夜。希望这条完整链路和这些踩坑记录能帮到你。
本文还有配套的精品资源,点击获取