SharedUserId 的废弃,多 APK 共享数据怎么办
SharedUserId 的废弃,多 APK 共享数据怎么办
上个月给一款老牌工具类应用做 Android 14 适配时,踩到了一个预期之外的坑。这套应用采用多 APK 架构已经跑了七八年:一个主应用负责界面和账户体系,两个插件 APK 各自处理不同的文件格式解析,彼此间通过 android:sharedUserId 挂在同一个 UID 下,直接互相读取私有目录里的 SQLite 数据库和 XML 配置。在 Android 13 上这套机制尚且能正常工作,只要把 targetSdkVersion 压在 33 以下,PackageManager 顶多吐几条警告。但到了 Android 14,事情彻底变了。我在 Pixel 7 上通过 adb 安装插件 APK 时,logcat 直接刷出一条 W PackageManager: sharedUserId is deprecated and may be removed in a future release,随后 pm install 返回 INSTALL_FAILED_SHARED_USER_INCOMPATIBLE。虽然这次安装失败最终查明确认是签名证书链的细微差异导致的,但它逼着我重新审视一个问题:Google 从 Android 10 开始就在文档里画横线的 sharedUserId,现在已经不是“建议不用”,而是到了“随时可能炸”的阶段。如果手头真有多 APK 共享数据的刚需,退路到底在哪?
PackageManager 的警告与安装失败的真实边界
先厘清一个技术细节:sharedUserId 本身在 Android 10(API 29)被标记为 deprecated,但真正的杀伤力是逐步释放的。Android 14(API 34)的 PackageManager 源码里,PackageInstallerService 对声明了 android:sharedUserId 的应用增加了更严格的校验。如果你在 AndroidManifest.xml 里还固执地留着 android:sharedUserId="com.example.shared",同时把 targetSdkVersion 设到 34,系统在解析包体时会进入一条新的兼容性检查路径。
我做过一次对照实验:拿两个仅包名不同、证书完全相同、都声明了同一 sharedUserId 的 APK,分别把 targetSdkVersion 设为 33 和 34,在 Android 14 模拟器上安装。target 33 的那个装上了,系统只给了一条 warning;target 34 的那个同样装上了,但 dumpsys package 里可以看到 sharedUserId 字段被显式忽略,两个应用被分配了不同的 UID。这意味着你以为它们在共享 UID,实际上系统根本没认。更隐蔽的是,如果你两个 APK 的签名证书链存在任何差异——比如主应用用了新版签名方案 v4,插件包只到了 v2,Android 14 的 Signature Scheme Version 校验会直接抛 INSTALL_FAILED_SHARED_USER_INCOMPATIBLE。这个错误码在 Android 13 上并不那么容易出现,因为旧的 PackageManager 对证书链的比对没那么苛刻。
所以第一个结论很现实:还在依赖 sharedUserId 的应用,现在不是“要不要迁移”的问题,而是“还能活几个版本”的问题。Android 15 的开发者预览里已经有 commit 在讨论彻底移除对 sharedUserId 的安装期支持,虽然正式版未必会真删,但再继续赌下去,维护成本只会越来越高。
为什么 Google 非要拿掉这个机制
很多人把 sharedUserId 的废弃简单理解为 Google 又在搞安全洁癖,但背后的架构逻辑值得细说。Android 的安全模型基于 UID 隔离,每个应用在独立沙箱里跑,内核通过 UID/GID 控制对文件系统的访问。sharedUserId 打破了这个边界,让多个 APK 共享同一个 Linux UID,进而共享 /data/data/<package> 下的私有目录。这在早期 Android 时代是个“方便法门”,但它带来了两个难以修复的原生缺陷。
第一是权限的不可控扩散。如果 APK A 和 APK B 共享 UID,APK A 申请了的 READ_CONTACTS 权限,APK B 即便没有在 manifest 里声明,也能通过共享 UID 直接访问联系人数据库。虽然高版本 Android 有运行时权限管理,但很多旧权限(比如 INTERNET、ACCESS_NETWORK_STATE,或者自定义的 signature 级权限)仍然是安装时授予的。共享 UID 等于在系统层制造了一个权限黑洞。
第二是应用更新的原子性噩梦。当两个共享 UID 的应用同时更新,PackageManager 必须在安装阶段保证它们的签名一致、版本兼容、native library 架构匹配。任何一个环节出错,都会导致两个应用全部无法安装。我在实际维护中遇到过不止一次:主应用升级了签名证书,插件包没同步更新,用户侧直接触发“应用未安装”的弹窗,且必须同时卸载两个应用才能恢复。这种耦合度在如今的动态化、插件化需求下,已经显得极其笨拙。
Google 的替代思路很清晰:把“基于 UID 的隐式信任”改成“基于组件的显式授权”。你不是想共享数据吗?那就走 ContentProvider、FileProvider 或者 AIDL,显式声明权限、显式授权 URI、显式绑定服务。这确实增加了开发者的代码量,但从系统架构上看,它把安全边界重新拉回了可审计的层面。
createPackageContext 的幻觉与真实限制
在寻找退路的过程中,我第一时间想到的替代方案是 Context.createPackageContext()。这个 API 在官方文档里被描述为可以创建另一个应用的上下文,看起来似乎能绕过 UID 隔离,直接拿到对方的 ApplicationContext。我试着在插件 APK 里写了这样一段代码:
Context targetContext = createPackageContext(
"com.example.mainapp",
Context.CONTEXT_INCLUDE_CODE | Context.CONTEXT_IGNORE_SECURITY
);
SharedPreferences targetSp = targetContext.getSharedPreferences("config", Context.MODE_PRIVATE);这段代码在 Android 9 的设备上确实能跑通,targetSp 可以读到主应用的 SharedPreferences。但在 Android 14 上,情况完全不同。createPackageContext 本身不会抛异常,返回的 Context 对象也不是 null,但当你真的尝试通过它去访问对方的私有文件时,会收到一条 SecurityException:java.lang.SecurityException: Permission Denial: opening provider com.example.mainapp.configprovider from ProcessRecord{...} requires com.example.READ_CONFIG or grantUriPermission()。注意,这个异常不是来自 createPackageContext,而是来自后续的 IO 操作。更深层的限制在于 Android 10 引入的 Scoped Storage 和私有目录访问收紧:即使两个应用同签名,系统也不再允许你直接穿过沙箱边界去读另一个应用的 /data/data/.../shared_prefs/ 下的 XML 文件。
我还测试了通过 createPackageContext 加载对方资源(getResources())的场景。这个倒是还能工作,因为资源包本质上是通过 AssetManager 映射的只读数据,系统允许跨包访问资源。但如果你指望靠 createPackageContext 继续实现“共享数据库”或者“共享配置文件”,在 targetSdkVersion 34 配合 Android 14 的环境下,这条路已经被彻底堵死。它成了一个只能用来加载资源的半残 API。
签名级权限与 ContentProvider:最正统但最繁琐的替代
既然直接穿透沙箱不行,那就只能走官方推荐的跨进程通信路径。对于多 APK 且同签名的场景,最稳妥的架构是自定义 signature 级别的权限,配合 ContentProvider 暴露数据接口。这个方案在 Android 14 上完全可行,且不受 sharedUserId 废弃的影响。
具体实现上,主应用作为数据持有方,在 manifest 里声明一个自定义权限:
<permission
android:name="com.example.permission.ACCESS_DATA"
android:protectionLevel="signature" />
<provider
android:name=".DataProvider"
android:authorities="com.example.mainapp.data"
android:permission="com.example.permission.ACCESS_DATA"
android:exported="true" />插件应用在 manifest 里声明 <uses-permission android:name="com.example.permission.ACCESS_DATA" />,由于 protectionLevel 是 signature,系统会在安装时比对双方签名,只有同签名的应用才能获得此权限。这样就复现了旧时代 sharedUserId 的“同签名互信”效果,但粒度更细——只开放数据接口,不开放整个文件系统。
我基于这个方案重构了原先直接读写对方数据库的逻辑。原架构里,插件包会直接打开主应用目录下的 records.db,用 SQLiteDatabase 直接操作;新架构下,主应用暴露一个 ContentProvider,把关键表映射成 URI。插件包通过 ContentResolver 查询:
Cursor cursor = getContentResolver().query(
Uri.parse("content://com.example.mainapp.data/records"),
null, "status = ?", new String[]{"active"}, null
);性能方面,我抓了一次 Trace。在 Pixel 7 上,单次跨进程 query 返回 50 条记录(每条 4 个字段),耗时大约在 8ms 到 14ms 之间。如果是批量插入,用 ContentProviderOperation 组装 100 条数据做一次 applyBatch,耗时约 120ms。作为对比,旧架构里直接在本进程操作 SQLite,同样 100 条插入只要 15ms。开销确实明显增加,但对于非高频同步场景完全可接受。如果数据量极大,可以考虑在 ContentProvider 里暴露一个 openFile 接口,把大文件序列化后通过 ParcelFileDescriptor 传过去,避免 IPC 里反复序列化 Cursor。
这里有一个 Android 11 引入的 Visibility 陷阱。如果你的主应用和插件应用 targetSdkVersion 都大于等于 30,插件包必须在 manifest 里加 <queries> 声明,否则 getContentResolver().query() 会因为看不到对方包而直接抛 SecurityException:
<queries>
<package android:name="com.example.mainapp" />
</queries>这个细节在官方文档里写得不算显眼,我踩坑后是通过抓 dumpsys package 的查询日志才定位到是 package visibility 过滤导致的。
FileProvider 与 URI 权限:适合大文件,不适合持久化
对于图片、视频、二进制缓存这类大文件共享,ContentProvider 里的 Blob 字段显然不合适。Android 的 FileProvider 是另一个标准答案,但它的设计哲学是“一次性授权”,天然不适合多 APK 之间长期