Android 的剪贴板读取限制,Android 13 后的变化

Android 的剪贴板读取限制,Android 13 后的变化

Android 的剪贴板读取限制,Android 13 后的变化


Android 的剪贴板读取限制,Android 13 后的变化


去年把 App 的 targetSdkVersion 升到 33 之后,线上开始零星出现用户反馈:复制了分享口令,打开 App 却怎么都触发不了自动跳转。我本地拿一台 Pixel 6 复现,发现事情比我想象的复杂。问题根本不在解析逻辑,而在 ClipboardManager.getPrimaryClip() 这个我们调用了好多年的 API,在 Android 13 上悄悄变了脾气。


从一次线上崩溃说起


事情的起因是一条看起来毫不相关的崩溃日志。某个灰度版本在 Android 13 设备上报了 NullPointerException,堆栈定位到 SplashActivity 里一段读取剪贴板判断是否有推广口令的代码。那段代码写得很老派:


val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = clipboard.primaryClip
val text = clip?.getItemAt(0)?.text

在 Android 12 及之前的设备上跑了很多年,从来没在这里抛过 NPE。因为即便剪贴板是空的,primaryClip 顶多是 null,我们加了 ?. 处理,按说不该崩。真正的问题是,在 Android 13 的某些场景下,clip 并不是 null,但 clip.itemCount 返回了 0,而我们后续有一个没做空判断的 getItemAt(0),在 Kotlin 里这不会崩,但那段老代码里混着 Java 遗留的调用,直接硬解引用,就炸了。


修复这个 NPE 只需要五分钟。但让我困惑的是,为什么这段代码在 Android 13 上被调用的时机变了?因为 SplashActivity 在启动时,按生命周期来说确实在前台。进一步测试才发现,真正的问题在后台。很多用户习惯先复制口令,然后回到桌面,再从桌面图标冷启动 App。这个过程中,如果应用在之前没有被彻底杀死,一个新的 Intent 进来时,旧的进程在后台可能已经尝试了一次预读取。Android 13 对后台剪贴板访问的限制,让这次预读取拿到的行为发生了质变——不是异常,不是权限拒绝,而是系统层面直接给你看一个"空的"或者不可信的 ClipData。


Android 10 埋下的伏笔


其实系统对剪贴板的收紧从 Android 10(API 29)就开始了。那时候 Google 引入了一条核心规则:只有默认输入法编辑器(IME),或者是当前拥有输入焦点的应用,才能读取剪贴板内容。也就是说,如果你的 App 在前台,但当前焦点在一个 EditText 上,你的确可以读到剪贴板;可如果你在一个没有焦点变化的页面,靠一个 OnPrimaryClipChangedListener 想在用户复制后立即做出响应,就已经不太靠谱了。


我当时在 Android 10 的模拟器上测过这个 listener。注册很简单:


clipboard.addPrimaryClipChangedListener {
    val clip = clipboard.primaryClip
    Log.d("ClipTest", "changed: ${clip?.getItemAt(0)?.text}")
}

在 Android 9 上,切到别的 App 复制一段文字,这个回调立马触发,log 打印得欢。到了 Android 10,应用只要退到后台,这个回调就像进了黑洞,系统不再往你的进程里派这个事件。不过那时候好歹有个退路:只要用户切回你的 App,你再去主动 getPrimaryClip() 一把,还是能拿到数据的。所以大多数开发者没把这当回事,顶多就是剪贴板监听从"实时推送"降级成了"主动轮询"。


Android 13 的硬边界


Android 13(API 33)把这个限制推向了极致。它不是简单地不给你回调,而是在系统服务层直接对调用者的身份做了校验。如果你的应用不在前台,或者虽然在前台但不是当前焦点窗口所属的 UID,调用 ClipboardManager.getPrimaryClip() 时,系统会直接在 ClipboardService 里拒绝访问。


我在 Pixel 6 的原生系统上抓过 logcat。当你从一个后台 Service 或者 WorkManager 任务里尝试读取剪贴板时,虽然不会收到 SecurityException,但 logcat 里能看到系统级别的拒绝信息,类似 Denying clipboard access to com.example.app, application is not in focus(具体 wording 在不同版本略有差异,但意思一致)。而从 API 层面返回给你的,就是一个 null。


这带来了一个非常阴险的调试陷阱。以前我们写代码,剪贴板读出来是 null,直觉就是"用户没复制东西"。现在 Android 13 上,用户明明复制了,你在后台读也是 null。如果你不做严格的版本对照测试,很容易把问题归结为"用户操作失误"或者"rom 问题"。


我还专门写了一个测试 Service,在 onStartCommand 里延迟 5 秒后读剪贴板。在 Android 12(API 31)的真机上测试,Service 在后台运行时 getPrimaryClip() 仍然能返回正确的 ClipData。同一套代码,在 Android 13 上,返回的就是 null。没有崩溃,没有回调,没有权限弹窗,就是静默地失败。这种"软性拒绝"比抛异常更难排查。


那个 Toast 到底从哪来


很多人把 Android 13 和剪贴板访问提示("App pasted from your clipboard")混为一谈。其实这个 Toast 最早是 Android 12(API 31)引入的,Android 13 继续沿用,但行为更精细。系统在每次非当前写入者读取剪贴板时,会在屏幕底部弹一个轻提示。


这里有个细节我在测试中被坑过一次:如果你的 App 自己写了剪贴板,然后马上自己读,Android 13 会识别出 UID 一致,不会弹这个 Toast。但如果你在 Application 的 onCreate 或者 Activity 的 onResume 里一进去就读,而用户上一次复制操作是另一个 App 执行的,这个 Toast 就会弹出来。某些国内定制 Rom 把这个提示改得非常频繁,甚至前台读一次弹一次,导致用户误以为 App 在疯狂偷窥剪贴板。


更麻烦的是,Google Play 的隐私政策审核现在会关注你是否在隐私协议里声明了剪贴板的使用。虽然 Android 没有一个面向普通应用的 `android.permission.READ_CLIPBOARD` 运行时权限(这个权限标记为 `signature privileged`,普通 App 无法申请),但系统通过这种方式变相把"知情权"交还给了用户。作为开发者,你没法阻止这个 Toast 出现,唯一能做的就是确保只在真正必要的时候读取,减少弹出频率。

监听器的死亡与假死


回到 addPrimaryClipChangedListener。Android 13 对这个监听器的处理,比 Android 10 更决绝。在 Android 10 上,后台 listener 只是收不到事件,但对象还在;Android 13 上,当应用进入后台,这个 listener 实际上被系统忽略了,而且当你切回前台时,它不会自动补发你在后台期间错过的剪贴板变更事件。


我做了一个连续的测试:在 Activity 的 onStart 里注册 listener,打印日志。然后按 Home 键退到后台,打开微信复制一句话,再切回原 App。在 Android 12 上,切回前台时 listener 有时会立即触发一次,拿到刚才复制的文字;在 Android 13 上,十次测试里有八次是什么都不发生。除非你退出 Activity 重新进入,或者再次触发一次新的复制操作,否则 listener 就像睡着了一样。


这直接干掉了一大批"智能识别剪贴板内容"的交互设计。很多电商或社交 App 习惯在首页监听剪贴板,发现有淘宝口令、抖音链接或者邀请码,就弹一个横幅引导用户。Android 13 之后,这个机制必须从"全局监听"改成"用户显式触发",比如在搜索框旁边放一个"识别剪贴板"的按钮。这不是体验降级的问题,而是根本没得选。


targetSdkVersion 33 的连带伤害


升到 targetSdk 33 之后,除了行为变更,还有一层隐形的限制:如果你试图用一些非正规手段绕过系统检查,Android 13 的非 SDK 接口限制(greylist)会更严格。我查过 AOSP 的源码,ClipboardService 里对调用者的校验是在 getPrimaryClip 的 Binder 接口层直接做的,它检查的是 WindowManagerService 里的焦点窗口和调用者的 UID。


有人可能会想,能不能反射调用 IClipboard.Stub 的接口,直接给系统服务发请求?这条路在 Android 13 上基本死了。因为即便你拿到了底层的 IClipboard 代理,系统侧在 ClipboardServicegetPrimaryClip 实现里,会调 isFocusApplication 或者检查 AppOpsManagerREAD_CLIPBOARD op。这个 op 不是通过权限声明获取的,而是系统根据焦点状态动态赋权的。反射绕过客户端的限制没用,系统服务端那道坎你迈不过去。


我还见过一种偏方:在后台启动一个透明的 Activity

国内安卓生态的碎片化治理,Google 无能为力了吗 2026-09-08
Ktor 客户端替代 Retrofit,协程原生支持 2026-09-08

评论区