PendingIntent 的可变性声明,Android 12 的强制要求

PendingIntent 的可变性声明,Android 12 的强制要求

PendingIntent 的可变性声明,Android 12 的强制要求


「PendingIntent 的可变性声明,Android 12 的强制要求」


Android 12 的 targetSdkVersion 31 强制要求 PendingIntent 必须显式声明可变性,这个改动在 2021 年正式发布时看起来只是文档里的一行字,实际迁移起来却牵扯出不少隐蔽问题。我去年把一个老项目从 targetSdk 30 升到 33 的过程中,被这个改动卡了将近一周,排查过程远比想象中曲折。


从崩溃日志说起


升级后的第一个测试包在 Android 12 设备上直接崩溃,堆栈信息很干净:


java.lang.IllegalArgumentException: com.example.app: Targeting S+ (version 31 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent.
    at android.app.PendingIntent.checkFlags(PendingIntent.java:382)
    at android.app.PendingIntent.getBroadcastAsUser(PendingIntent.java:660)
    at android.app.PendingIntent.getBroadcast(PendingIntent.java:647)

当时项目里 PendingIntent 的调用点有四十多处,分散在推送模块、桌面小部件、闹钟提醒、快捷方式几个完全不同的业务里。最麻烦的是,崩溃不是发生在创建 PendingIntent 的代码位置,而是延迟到系统真正尝试使用它时才抛出,这让定位具体哪一行出问题变得异常困难。


Android 12 之前,PendingIntent 的 mutability 是隐式的。如果你不传任何 flag,系统默认按可变处理,允许接收方通过 fillIn() 方法修改其中的 Intent 数据。这个设计从 API 1 时代延续下来,很多老代码甚至根本不记得自己用过 PendingIntent,只是调用 AlarmManager.set() 或者 NotificationManager.notify() 时顺手传了一个。


为什么 Google 突然收紧


官方文档里的安全通告写得很明确:隐式可变的 PendingIntent 可以被恶意应用利用,通过 intent hijacking 读取或篡改原本受保护的数据。攻击场景需要几个条件同时满足——你的 PendingIntent 发给了一个可被外部访问的组件,且内部包裹的 Intent 带有敏感 extras——但 Google 在 Android 12 选择了最严格的修复策略:直接禁止不声明可变性。


我仔细看了 AOSP 的变更记录。frameworks/base/core/java/android/app/PendingIntent.java 里新增了一个 checkFlags() 方法,逻辑很直接:


private static void checkFlags(int flags, String creatorPackage) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        if ((flags & (FLAG_IMMUTABLE | FLAG_MUTABLE)) == 0) {
            throw new IllegalArgumentException(
                creatorPackage + ": Targeting S+ (version 31 and above) requires that one of "
                + "FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent.");
        }
    }
}

这段代码在 getActivity()、getActivities()、getBroadcast()、getService()、getForegroundService() 五个工厂方法的多个重载里都被调用了。没有版本兼容的灰色地带,targetSdkVersion >= 31 就直接抛异常。


迁移中的第一个陷阱:FLAG_IMMUTABLE 不是万能药


我最初的修复策略很简单:全局搜索 PendingIntent 的创建点,全部加上 PendingIntent.FLAG_IMMUTABLE。这个 flag 在 API 23 引入,在 Android 12 之前是可选的,现在变成了必选之一。


问题很快出现了。推送模块里有一段代码,点击通知后需要把用户 ID 透传到 Activity:


Intent intent = new Intent(context, MainActivity.class);
intent.putExtra("user_id", userId);
PendingIntent pendingIntent = PendingIntent.getActivity(
    context, 
    requestCode, 
    intent, 
    PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);

这段代码在 Android 12 之前工作正常,因为 FLAG_UPDATE_CURRENT 会更新同 requestCode 的 PendingIntent 的 extras。但加上 FLAG_IMMUTABLE 后,点击通知时 MainActivity 收到的 user_id 永远是第一次创建时的值,后续推送的新通知无法覆盖。


根本原因是 FLAG_IMMUTABLE 的含义:系统不允许接收方修改 PendingIntent 的内容,而这个"接收方"不仅指外部应用,也包括你自己的后续更新操作。FLAG_UPDATE_CURRENT 试图用新 Intent 替换旧数据,但 IMMUTABLE 锁死了这个通道。


解决办法是改用 FLAG_MUTABLE,但只在确实需要修改的场景下:


PendingIntent pendingIntent = PendingIntent.getActivity(
    context,
    requestCode,
    intent,
    PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE
);

这个改动让我重新审阅了全部四十多处调用。最终大约 30% 需要保持 MUTABLE,主要是通知点击跳转、AppWidget 的 pending intent template、以及闹钟的 extra 数据传递。其余 70% 用 IMMUTABLE 更安全,比如定时任务的精确唤醒、前台服务的启动控制,这些场景 Intent 内容创建后就不再变化。


第二个坑:requestCode 的区分策略


桌面小部件模块遇到了更隐蔽的问题。我们的小部件支持点击不同区域跳转到不同页面,之前用相同的 requestCode 配合不同的 Intent action 来区分:


// 之前的工作代码
PendingIntent pi1 = PendingIntent.getActivity(ctx, 0, intent1, FLAG_UPDATE_CURRENT);
PendingIntent pi2 = PendingIntent.getActivity(ctx, 0, intent2, FLAG_UPDATE_CURRENT);

这段代码能工作是因为隐式可变时,系统允许通过 Intent 的 filterEquals() 逻辑来区分两个 PendingIntent。但当我把两个都改成 FLAG_IMMUTABLE FLAG_UPDATE_CURRENT 后,第二个 PendingIntent 实际上覆盖了第一个,因为它们的 requestCode 相同,而 IMMUTABLE 阻止了 Intent 数据的参与比较。

Android 文档里对 PendingIntent 的匹配逻辑描述得很清楚:系统通过三元组 (creator UID, creator package, requestCode) 来标识一个 PendingIntent,如果这三项相同,后续的创建操作会返回已存在的实例。Intent 的内容是否参与匹配取决于 flag 组合。


修复方案是给小部件的不同区域分配独立的 requestCode:


PendingIntent pi1 = PendingIntent.getActivity(ctx, REQUEST_CODE_WIDGET_LEFT, intent1, 
    PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
PendingIntent pi2 = PendingIntent.getActivity(ctx, REQUEST_CODE_WIDGET_RIGHT, intent2,
    PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);

这个改动看似 trivial,但 requestCode 的分配策略之前完全没有文档化,是靠代码历史传承下来的约定。迁移过程中我发现三处类似的 requestCode 复用问题,都是 Android 12 之前能跑、升级后行为异常的 case。


第三个坑:fillIn() 的合法使用场景


项目里有一个功能允许其他应用通过 PendingIntent 回调我们的结果。A 应用启动我们的选择器,用户选择完成后,我们调用 A 应用预先传过来的 PendingIntent.send(),并填入用户选择的 URI:


// 我们的代码
Intent fillInIntent = new Intent();
fillInIntent.setData(selectedUri);
pendingIntent.send(context, 0, fillInIntent);

这个模式在 Android 的文档里被称为"fill-in intent",是 PendingIntent 设计的核心能力之一。但如果 A 应用创建这个 PendingIntent 时用了 FLAG_IMMUTABLE,我们的 fillInIntent 会被系统忽略,selectedUri 传不过去。


这里涉及到 Android 12 的一个关键设计:FLAG_MUTABLE 不是无条件允许任意修改,而是允许 fillIn() 操作填入那些在原始 Intent 中未明确指定的字段。如果原始 Intent 已经设置了 action 或 data,fillIn() 不能覆盖这些已有值。


我测试了几个边界情况。原始 Intent 有 action 为 "android.intent.action.VIEW",fillIn() 试图改成 "android.intent.action.SEND"——在 FLAG_MUTABLE 下也会失败,因为 action 已被占用。fillIn() 真正能填的是那些"空白"字段:没有指定 data 时可以填 data,没有指定 extras 时可以补 extras。


这个行为在官方文档里叫 "fill in only unspecified fields",但描述分散在 PendingIntent 类和 Intent 类的多个方法注释里,没有集中说明。我实际调试时用了反射跟踪 PendingIntent 内部的 mIntent 变化,才确认了这个规则。


版本兼容的写法


项目最低支持到 API 21,所以需要一个兼容层。我最初写了这样的工具方法:


public static PendingIntent getActivity(Context ctx, int reqCode, Intent intent, int flags) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        if ((flags & (PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_MUTABLE)) == 0) {
            flags |= PendingIntent.FLAG_IMMUTABLE; // 默认保守策略
        }
    }
    return PendingIntent.getActivity(ctx, reqCode, intent, flags);
}

这个默认策略很快暴露出问题。如前所述,大量场景需要 MUTABLE,默认 IMMUTABLE 会导致静默的功能退化而不是崩溃。我后来改成了更严格的检查:在 debug 构建中,如果不显式声明可变性,直接抛异常强制开发者选择:


public static PendingIntent getActivity(Context ctx, int reqCode, Intent intent, int flags) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        if ((flags & (PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_MUTABLE)) == 0) {
            throw new IllegalArgumentException(
                "Must specify FLAG_IMMUTABLE or FLAG_MUTABLE for Android 12+");
        }
    }
    return PendingIntent.getActivity(ctx, reqCode, intent, flags);
}

这个工具类最终覆盖了全部五个工厂方法。对于 API < 31 的情况,FLAG_IMMUTABLE 和 FLAG_MUTABLE 在编译期就存在,只是系统层面不强制检查,所以直接透传即可,不需要额外的版本分支。


与通知渠道的配合问题


推送模块的迁移还牵扯到 Notification.Builder 的一个历史包袱。Android 8.0 引入通知渠道后,通知的优先级和行为由渠道控制,但 PendingIntent 的可变性是独立于渠道的。


我们有一个渠道专门用于"实时活动"类通知,需要用户点击后跳转到具体页面并携带动态生成的活动 ID。这个渠道的代码在 Android 12 之前是这样的:


Notification.Builder builder = new Notification.Builder(ctx, CHANNEL_ID_LIVE)
    .setContentTitle(title)
    .setContentText(content)
    .setContentIntent(PendingIntent.getActivity(ctx, 0, intent, FLAG_UPDATE_CURRENT));

迁移时我直接加了 FLAG_IMMUTABLE,结果测试发现点击通知后活动 ID 永远是过期的。这个 bug 在线上环境会表现为用户点击通知跳转到已结束的活动页,体验很差。


最终修复是结合渠道特性来判断:如果通知内容每次都需要更新 extras,PendingIntent 必须用 MUTABLE;如果通知只是固定跳转(比如打开设置页),用 IMMUTABLE 更安全。这个判断逻辑写进了我们的 NotificationHelper 里,按渠道类型做默认配置,允许业务代码覆盖。


测试覆盖的盲点


PendingIntent 的问题在单元测试里很难暴露。Robolectric 4.9 开始模拟了 Android 12 的 checkFlags 行为,但模拟器和真机之间仍有差异。我遇到的一个真机特有问题是:某些 OEM 系统(具体是某品牌的 Android 12 定制版)对 FLAG_MUTABLE 的 fillIn() 支持不完整,导致 extras 数据丢失。


这个 OEM 问题的排查花了两天。最终通过对比 Pixel 设备和该品牌设备的 logcat,发现系统服务在处理 PendingIntent.send() 时,该品牌的 ActivityManagerService 实现里对 fillInIntent 的 extras 做了额外的权限校验,把没有显式声明 FLAG_GRANT_READ_URI_PERMISSION 的 URI extra 直接过滤掉了。


这个案例说明,FLAG_IMMUTABLE/FLAG_MUTABLE 的声明只是第一道关卡,实际行为还受系统实现对 Intent 填充逻辑的解读影响。我们的修复是在需要传递 URI 的 PendingIntent 上补全权限 flag:


intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

但这个 flag 本身又带来安全问题——它把 URI 的读取权限临时授予了 PendingIntent 的接收方。在 FLAG_IMMUTABLE 的场景下这个权限授予不会扩散,但 FLAG_MUTABLE 时需要额外确认接收方的可信度。


AlarmManager 的特殊性


项目里的闹钟提醒功能用到了 AlarmManager.setExactAndAllowWhileIdle(),配套的 PendingIntent 是 getBroadcast()。这个场景有一个容易忽略的 detail:AlarmManager 在设备重启后,如果应用没有被主动启动,系统可能以不同的 UID 上下文来派发闹钟。


我查过 Android 12 的 AlarmManagerService 源码,发现它在派发闹钟时会重新校验 PendingIntent 的创建者身份。如果 PendingIntent 是 IMMUTABLE 且包裹的 Intent 带有 component 明确指向应用内部组件,派发通常正常。但如果 Intent 是 implicit 的(比如只设了 action),且 IMMUTABLE 锁死了后续修改,某些系统版本会出现派发失败且没有回调的情况。


这个发现来自一个线上反馈:部分 Android 12 用户设置提醒后,到时间不响。我们最终把闹钟的 PendingIntent 改成了显式 component + FLAG_IMMUTABLE,同时保留了一个 MUTABLE 的备用 PendingIntent 用于动态修改提醒内容。两个 PendingIntent 用不同的 requestCode 管理,通过 AlarmManager 的 cancel() 精确控制生命周期。


对现有架构的反思


这次迁移让我注意到一个长期被忽视的设计问题:PendingIntent 在我们的代码库里被当作"延迟执行的 Intent 包装器"随意传递,很少有模块关心它的可变性约束。推送模块创建的通知 PendingIntent 可能被用户长按后以另一种方式触发,AppWidget 的 PendingIntent template 被系统框架填充数据,这些跨边界的场景在 Android 12 之前靠隐式可变蒙混过关,现在必须显式设计。


我后来给团队写了一份内部规范,核心就两条:创建 PendingIntent 时必须显式选择 IMMUTABLE 或 MUTABLE,禁止用工具类的默认值;MUTABLE 的 PendingIntent 必须注释说明需要修改的字段和修改来源。这个规范执行半年后,新代码里 PendingIntent 相关的问题基本归零。


一个未解决的边缘 case


最后提一个我至今没找到完美方案的场景:分享功能的 ChooserTarget。Android 10 废弃了 DirectShare 的旧 API,但我们的分享模块还保留了兼容代码。旧 API 里 ChooserTarget 需要传一个 PendingIntent,这个 PendingIntent 在系统分享面板被点击时触发。


这个 PendingIntent 的特殊之处在于:它的创建者是系统分享界面(com.android.intentchooser 或各 OEM 的等价组件),而不是我们的应用。Android 12 之前,系统组件创建的 PendingIntent 不受 targetSdkVersion 约束。但我们在测试中发现,某些 Android 12 设备上,如果 ChooserTarget 的 PendingIntent 用了 FLAG_IMMUTABLE,点击后系统会直接忽略,没有任何回调到我们的应用。


目前的 workaround 是检测系统版本,在 Android 12+ 时对这个特定场景用 FLAG_MUTABLE,并尽量减少 Intent 中携带的敏感数据。这个做法不够干净,但受限于系统分享界面的实现细节,暂时没有找到更优雅的替代方案。


迁移工作完成后,我统计了一下改动量:涉及 12 个文件,47 处 PendingIntent 创建点,其中 33 处改为 FLAG_IMMUTABLE,14 处保持 FLAG_MUTABLE,新增了 3 个 requestCode 常量,删除了 2 处已经废弃的 DirectShare 兼容代码。整个过程中最耗时的不是改代码,而是理解每个 PendingIntent 在业务流中的实际使用方式,确认它是否真的需要被修改、被谁修改、修改哪些字段。


Android 12 这个改动的设计意图是清晰的,执行也是严格的。它强迫开发者把之前隐式的假设变成显式的声明,这在安全层面是进步。但对于承载着多年技术债务的存量项目,迁移成本确实不低,尤其是当 PendingIntent 的创建和使用分散在不同模块、甚至跨越应用边界时。我的建议是不要依赖全局搜索替换,逐个场景分析可变性需求,虽然慢,但能避免后续更难排查的静默 bug。

Kotlin 的 Inline Class 和 Value Class,字节码层面看什么 2026-07-01
Gradle 依赖版本统一管理方案对比 2026-07-02

评论区