简介:这份资源面向使用Unity进行Android开发的工程师,聚焦Android 7.0系统下APK无法覆盖安装的典型问题。内容围绕版本码调整、签名一致性、AAR库集成、原生插件调用与热更新等思路展开,帮助开发者理解覆盖安装失败的成因并找到可行的更新策略。压缩包为7z格式,体积约144.17MB,上游未提供具体文件数量与类型明细,可结合描述中的AndroidManifest配置、签名证书与AAR库等方向自行查阅。目前已有633人学习下载,适合需要处理应用升级、版本迭代与安装兼容问题的中高级开发者参考。资源侧重排错思路与方案梳理,可帮助读者快速定位签名或版本码不一致导致的覆盖失败,并据此设计更顺畅的更新流程。
1. Android 7.0 安装 apk.7z:一个被低估的兼容性分水岭
如果你手里有一台跑着 Android 7.0 的旧设备,或者你的应用至今还要覆盖那批存量机型,那你大概率遇到过这样的场景:同一个 apk 包,在 Android 6.0 上双击就装,到了 7.0 却弹出一句“解析包时出现问题”,连安装界面都进不去。这不是包坏了,而是 Android 7.0 在安装机制上做了一次静默但影响深远的调整——它收紧了 file:// URI 的跨应用传递权限,同时把 apk 安装从“直接读文件”推向了“必须通过 FileProvider 授权”的模型。标题里的 apk.7z 其实是个信号:很多人拿到的是压缩包,解压出来的 apk 在 7.0 上装不上,第一反应是压缩损坏,实际根因往往在安装入口的 URI 暴露策略上。这篇文章面向的是还在维护旧机型兼容、做企业内部分发、或者需要批量部署 apk 的工程师,我会把 Android 7.0 安装 apk 的完整链路拆开,从权限模型讲到 FileProvider 配置,再到 7z 解压后的路径处理,每一步都给可复现的命令和参数。
2. Android 7.0 安装 apk 的权限模型与 FileProvider 选型
Android 7.0(API 24)引入的最关键变化是 StrictMode 对 file:// URI 的拦截。在 API 23 及以下,你可以用Uri.fromFile(new File(path))构造一个指向 apk 的 URI,然后通过 Intent 交给系统安装器,系统安装器以你的应用身份去读这个文件,权限是通的。到了 API 24,任何离开你应用进程的 file:// URI 都会触发FileUriExposedException,安装器拿不到读权限,于是安装流程在解析阶段就断了。这不是安装器坏了,而是 Android 把“跨应用文件共享”这件事强制收口到 content:// URI 加临时授权上。
2.1 为什么 FileProvider 是唯一稳妥的选型
常见做法有三种:一是把 apk 先复制到外部存储的公共目录,让安装器自己去扫;二是用MediaStore插入;三是用FileProvider生成 content:// URI 并授予FLAG_GRANT_READ_URI_PERMISSION。第一种在 Android 7.0 上依然会踩 file:// 的坑,因为安装器拿到的还是路径字符串;第二种适合媒体文件,对 apk 这种可执行包并不合适,而且 Android 10 之后分区存储进一步限制了直接路径访问;第三种是官方推荐且兼容性最好的方案。我一般会直接上 FileProvider,因为它的授权粒度是“一次一 URI”,安装完即失效,不会留下长期暴露的文件句柄。
选型确定后,要解决的是 apk 来源问题。标题里的 .7z 意味着你拿到的是压缩包,解压后 apk 可能落在应用私有目录、外部存储或者 assets 里。不同来源对应不同的 FileProvider 路径配置,这一步配错,后面全是IllegalArgumentException: Failed to find configured root。
2.2 在 AndroidManifest 中注册 FileProvider
FileProvider 是 androidx.core 提供的类,不需要自己写 ContentProvider。注册时关键在android:authorities和android:exported,前者建议用applicationId + ".fileprovider"避免多应用冲突,后者必须为 false,因为 FileProvider 本身不对外暴露,授权通过 Intent flag 完成。
<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>这里grantUriPermissions="true"是必须的,否则你没法通过 Intent 把读权限临时授给安装器。${applicationId}是 manifest 占位符,构建时会替换成实际包名,这样 debug 和 release 包不会因为 authority 相同而互相覆盖安装。
2.3 配置 file_paths.xml 覆盖 7z 解压后的真实路径
file_paths.xml决定了 FileProvider 能对外暴露哪些目录。很多人只配了external-path,结果 apk 解压到应用私有目录getFilesDir()下就找不到。下面这份配置覆盖了内部文件目录、外部文件目录、缓存目录和外部缓存目录,基本能应对 7z 解压后的各种落点。
<paths> <!-- 应用内部 files 目录,7z 解压到此处最安全 --> <files-path name="internal_files" path="." /> <!-- 应用外部 files 目录,适合大 apk --> <external-files-path name="external_files" path="." /> <!-- 缓存目录,临时解压用 --> <cache-path name="cache" path="." /> <external-cache-path name="external_cache" path="." /> </paths>name是 URI 里的路径段别名,path="."表示该根目录下所有子路径都允许。注意不要图省事写path="/",那会匹配到根,行为不可预期。配好之后,FileProvider.getUriForFile(context, authority, file)才能正确映射出 content:// 地址。
3. 从 7z 解压到 apk 安装的完整落地步骤
这一章把整条链路串起来:拿到 apk.7z 后怎么解、解到哪、怎么构造 Intent、怎么授权、怎么处理安装结果。每一步都有可抄的代码和参数说明,照着改包名就能跑。
3.1 用 commons-compress 在应用内解压 7z
Android 原生不支持 7z,常见做法是引入org.apache.commons:commons-compress配合org.tukaani:xz。7z 本质是容器格式,commons-compress 的SevenZFile可以逐条读取条目。下面这段代码把 7z 里的 apk 解到应用内部 files 目录,避免外部存储权限问题。
// 依赖:implementation 'org.apache.commons:commons-compress:1.26.0' // implementation 'org.tukaani:xz:1.9' public File extractApkFrom7z(Context ctx, File archive) throws IOException { File outDir = new File(ctx.getFilesDir(), "apk_stage"); if (!outDir.exists() && !outDir.mkdirs()) { throw new IOException("mkdir failed: " + outDir); } try (SevenZFile sz = SevenZFile.builder().setFile(archive).get()) { SevenZArchiveEntry entry; while ((entry = sz.getNextEntry()) != null) { if (entry.isDirectory()) continue; // 只取 apk,忽略 7z 里的说明文件 if (!entry.getName().toLowerCase().endsWith(".apk")) continue; File target = new File(outDir, new File(entry.getName()).getName()); try (FileOutputStream fos = new FileOutputStream(target)) { byte[] buf = new byte[8192]; int n; while ((n = sz.read(buf)) > 0) { fos.write(buf, 0, n); } } return target; // 单 apk 场景直接返回 } } throw new IOException("no apk entry found in archive"); }逻辑说明:SevenZFile.builder().setFile(archive).get()是 commons-compress 1.26 的推荐写法,旧版用构造函数也行但已标记过时。sz.read(buf)返回 -1 表示当前条目读完,注意它和read(buf, off, len)的返回值语义不同,这里用> 0判断即可。参数上,缓冲区 8192 是经验值,再大对解压速度提升有限,反而占内存。outDir选getFilesDir()而不是getExternalFilesDir(),因为内部目录不需要任何存储权限,Android 7.0 上更省心。
3.2 构造安装 Intent 并授予临时读权限
拿到 apk 文件后,用 FileProvider 生成 URI,再通过 Intent 交给系统安装器。关键点是FLAG_GRANT_READ_URI_PERMISSION和setDataAndType的组合,缺一个都会导致安装器读不到文件。
public void installApk(Context ctx, File apk) { String authority = ctx.getPackageName() + ".fileprovider"; Uri uri = FileProvider.getUriForFile(ctx, authority, apk); Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(uri, "application/vnd.android.package-archive"); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // Android 8.0 以上需要额外检查“安装未知应用”权限 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (!ctx.getPackageManager().canRequestPackageInstalls()) { // 引导用户去设置页开启,此处省略跳转代码 return; } } ctx.startActivity(intent); }逻辑说明:setDataAndType必须同时设置,只设 data 安装器无法识别 MIME,只设 type 没有 URI 指向。FLAG_GRANT_READ_URI_PERMISSION是临时授权的核心,它让安装器在本次 Intent 生命周期内能读该 URI。FLAG_ACTIVITY_NEW_TASK是因为从非 Activity 上下文启动。Android 8.0 的canRequestPackageInstalls检查是必须的,否则在 8.0 以上会静默失败,这个坑在 7.0 上不存在,但你的代码如果只测 7.0 就会在 8.0 翻车。
3.3 处理 7z 内多 apk 与版本校验
企业分发场景里,7z 里可能不止一个 apk,比如主包加插件包。这时不能拿到第一个就装,要先按文件名或 manifest 里的 versionCode 排序。下面这段用PackageManager.getPackageArchiveInfo读取 apk 的版本信息,避免装错。
public File pickLatestApk(Context ctx, List<File> apks) { File latest = null; long maxVersion = -1; PackageManager pm = ctx.getPackageManager(); for (File apk : apks) { PackageInfo info = pm.getPackageArchiveInfo( apk.getAbsolutePath(), 0); if (info == null) continue; // 解析失败,跳过 long v = info.getLongVersionCode(); // API 28+,低版本用 versionCode if (v > maxVersion) { maxVersion = v; latest = apk; } } return latest; }逻辑说明:getPackageArchiveInfo只读 apk 的 manifest,不执行代码,安全。getLongVersionCode()在 API 28 引入,Android 7.0 上要用info.versionCode,实际写的时候按Build.VERSION.SDK_INT分支。参数0表示不取额外信息,传GET_ACTIVITIES之类会拖慢解析。如果 7z 里 apk 文件名带版本号,也可以直接字符串排序,但不如读 manifest 可靠。
4. Android 7.0 安装 apk 的避坑与排查清单
这一章是我在旧机型适配里踩过的血泪经验,每条按“现象 → 原因 → 解决”写,你遇到问题时可以直接对号入座。
4.1 现象:安装界面弹出“解析包时出现问题”
原因:最常见的是 FileProvider 的 authority 和 manifest 里注册的不一致,或者file_paths.xml没覆盖 apk 实际所在目录,导致getUriForFile抛异常被吞掉,安装器拿到一个空 URI。另一个原因是 apk 本身在 7z 解压时被截断,文件大小对不上。
解决:先在getUriForFile外面加 try-catch 把异常打出来,确认是IllegalArgumentException还是别的。如果是路径问题,把 apk 移到files-path覆盖的目录下再试。如果是文件截断,用md5sum对比解压前后的大小,7z 解压时sz.read的循环条件写错(比如用!= -1但没处理返回 0)会导致少读。
4.2 现象:Android 7.0 上正常,8.0 以上点安装没反应
原因:Android 8.0 引入“安装未知应用”权限,默认关闭。你的代码没检查canRequestPackageInstalls,Intent 发出去被系统拦截,没有任何回调。
解决:在installApk里加版本判断,8.0 以上先检查权限,没有就跳Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES引导用户开启。注意这个设置页的 URI 要带包名:Uri.parse("package:" + ctx.getPackageName())。
4.3 现象:7z 解压到外部存储后安装失败,日志报 permission denied
原因:Android 7.0 虽然还没强制分区存储,但getExternalFilesDir()返回的路径在不同设备上可能挂载点不同,FileProvider 的external-files-path映射的是应用外部私有目录,如果你把 apk 放到了公共 Download 目录,FileProvider 根本映射不到。
解决:统一把 apk 解压到getFilesDir()或getExternalFilesDir(null)下,这两个路径分别对应files-path和external-files-path。不要用Environment.getExternalStoragePublicDirectory(),那个路径 FileProvider 默认不覆盖,需要额外配external-path,而且 Android 10 之后基本不可写。
4.4 现象:安装成功但打开闪退,报 ClassNotFound
原因:7z 里可能包含多个 apk,你装的是旧版本或者不匹配的 ABI 包。Android 7.0 对 split apk 的支持还不完善,如果 7z 里是 base.apk 加 split 包,单独装 base 会缺资源。
解决:用getPackageArchiveInfo读 versionCode 和 packageName,确认装的是最新且包名匹配的。如果是 split apk,7.0 上要用PackageInstaller的 session 机制,不能走ACTION_VIEW单包安装。这个场景比较少见,但企业分发里遇到过。
4.5 现象:FileProvider 报 “Failed to find configured root”
原因:file_paths.xml里的name属性重复,或者path写成了绝对路径。FileProvider 要求path是相对于根目录的相对路径,写/data/data/...会直接抛异常。
解决:检查每个<files-path>的name是否唯一,path用.或子目录名。改完 clean build 一次,因为 manifest 合并有时会缓存旧的 xml 资源。
5. 用 PackageInstaller 静默安装与批量部署的进阶技巧
如果你做的是企业内部分发,ACTION_VIEW每次都要弹安装界面,批量部署时体验很差。Android 7.0 开始,PackageInstaller的 session 机制已经可用,配合设备所有者(Device Owner)权限可以做到无界面安装。这一章讲怎么从“点一下装一个”走到“批量静默装”。
5.1 PackageInstaller session 的基本流程
PackageInstaller的核心是创建一个 session,把 apk 写进去,然后 commit。系统会在后台完成安装,不弹界面。前提是你的应用被设为 Device Owner,或者有系统签名。普通应用调commit依然会弹确认框,但流程比ACTION_VIEW更可控。
public void installSilently(Context ctx, File apk) throws IOException { PackageInstaller installer = ctx.getPackageManager().getPackageInstaller(); PackageInstaller.SessionParams params = new PackageInstaller.SessionParams( PackageInstaller.SessionParams.MODE_FULL_INSTALL); params.setAppPackageName("com.example.target"); // 可选,用于校验 int sessionId = installer.createSession(params); PackageInstaller.Session session = installer.openSession(sessionId); try (InputStream in = new FileInputStream(apk); OutputStream out = session.openWrite("base.apk", 0, apk.length())) { byte[] buf = new byte[65536]; int n; while ((n = in.read(buf)) != -1) { out.write(buf, 0, n); } session.fsync(out); } // 构造结果接收的 PendingIntent Intent result = new Intent(ctx, InstallResultReceiver.class); PendingIntent pi = PendingIntent.getBroadcast(ctx, sessionId, result, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); session.commit(pi.getIntentSender()); session.close(); }逻辑说明:openWrite的第二个参数是偏移量,第三个是总大小,必须和 apk 实际字节数一致,否则 commit 时校验失败。fsync确保数据落盘再 commit,不然可能装出损坏的包。FLAG_MUTABLE在 Android 12 以上是必须的,因为系统要往 PendingIntent 里填结果。InstallResultReceiver是你自己实现的 BroadcastReceiver,用来接收STATUS_PENDING_USER_ACTION或STATUS_SUCCESS。
5.2 批量部署时的版本比对与增量更新
批量场景下,每次全量推 apk 太浪费带宽。常见做法是先读设备上已装应用的 versionCode,和 7z 里 apk 的 versionCode 比对,只推有差异的。下面这个表格列出了比对时用到的字段和获取方式。
| 字段 | 设备已装获取方式 | 7z 内 apk 获取方式 |
|---|---|---|
| packageName | PackageInfo.packageName | getPackageArchiveInfo().packageName |
| versionCode | PackageInfo.getLongVersionCode() | 同上,从 archive info 取 |
| versionName | PackageInfo.versionName | 同上 |
| 签名摘要 | PackageInfo.signatures | getPackageArchiveInfo(..., GET_SIGNATURES) |
比对逻辑:packageName 相同且 archive 的 versionCode 大于已装的,才推安装。签名摘要必须一致,否则系统会拒绝覆盖安装,报INSTALL_FAILED_UPDATE_INCOMPATIBLE。这个校验在 7.0 上尤其重要,因为旧机型上残留的测试签名包很多。
5.3 安装结果回调与失败重试
PackageInstaller的结果通过 PendingIntent 异步返回,状态码在PackageInstaller.EXTRA_STATUS里。常见状态有STATUS_SUCCESS、STATUS_FAILURE、STATUS_PENDING_USER_ACTION。STATUS_PENDING_USER_ACTION表示需要用户确认,这时要从 Intent 里取出EXTRA_INTENT再 startActivity。
重试策略上,我一般对STATUS_FAILURE做一次重试,间隔 2 秒,因为部分旧机型在存储繁忙时会临时失败。重试前要abandonSession掉旧 session,否则 sessionId 泄漏。重试超过两次就上报日志,不再无限循环,避免把设备卡死。
5.4 一个容易忽略的细节:7z 解压后的文件权限
getFilesDir()下的文件默认权限是-rw-rw----,属主和属组都是应用自己,FileProvider 读没问题。但如果你用Runtime.exec("chmod 777 ...")去改权限,反而可能触发 SELinux 告警,在 7.0 上导致安装器拒绝读取。正确做法是不要动权限,FileProvider 的授权机制已经绕过了文件系统权限,走的是 content:// 的 URI 授权。这一点我在早期项目里翻过车,改了权限反而装不上,后来把 chmod 去掉就正常了。
5.5 验证安装是否真正成功
不要只依赖STATUS_SUCCESS,因为有些设备返回成功但应用实际没装上。稳妥做法是 commit 之后延迟 1 秒再查一次getPackageInfo,确认 versionCode 已经更新。下面这段是验证逻辑。
public boolean verifyInstalled(Context ctx, String pkg, long expectVersion) { try { PackageInfo info = ctx.getPackageManager().getPackageInfo(pkg, 0); long actual = Build.VERSION.SDK_INT >= 28 ? info.getLongVersionCode() : info.versionCode; return actual == expectVersion; } catch (PackageManager.NameNotFoundException e) { return false; } }参数说明:expectVersion来自 7z 内 apk 的 archive info,必须和安装前读到的一致。如果返回 false,先查是不是装到了其他用户空间(Android 7.0 开始支持多用户),getPackageInfo默认查当前用户,跨用户安装要用createPackageContextAsUser。
这套流程我在多个旧机型批量部署里跑过,最深的教训是:Android 7.0 的安装问题,九成出在 URI 暴露和路径映射上,剩下一成是版本比对没做导致覆盖安装失败。把 FileProvider 配好、把 7z 解压路径固定到files-path、把版本校验加上,基本就能稳定落地。希望帮到你。
本文还有配套的精品资源,点击获取