Android T 上 Recents 里那张任务缩略图,看着只是一张图,背后其实是一条从 WMS 主线程一直延伸到后台写盘线程的完整链路。TaskSnapshot 这套机制在 Android 13(也就是 Android T)上做过一次不小的重构,原来一个 TaskSnapshotController 大包大揽的写法被拆成了 Controller、Cache、Persister、Loader 四个协作组件,职责边界清晰了很多,但排查问题时如果不清楚这条链路的走向,光是看 dumpsys 输出就会一头雾水。这篇文章就围绕 Android T 的 TaskSnapshot 创建与移除流程展开,把触发时机、截图实现、缓存策略、落盘细节、移除队列和常见坑都摊开讲一遍。适合做系统 UI、Recents、Launcher 或者 WMS 相关开发的同行参考,做应用层但被缩略图黑屏问题折磨过的同学也能找到排查思路。文中涉及 AOSP 主线行为的描述基于我对 Android 13 代码结构的理解,具体常量数值以你手上分支为准。
1. TaskSnapshot 到底是什么,Android T 为什么要动它
1.1 从 Recents 里那张缩略图说起
先把这个东西的定位说清楚。TaskSnapshot 是系统对某个 Task 界面状态的一次"画面存档",它不只是位图数据,还带了一堆描述这次存档在什么条件下拍的元信息:任务尺寸、屏幕旋转角度、内容区域的内边距、窗口模式、Activity 的外观配置、是否真实截图、是否半透明等等。你在最近任务界面看到的那张卡片预览图,就是它。任务切换时的过渡动画、某些启动流程里的占位画面,也都会用到它。
为什么系统要提前把画面存下来,而不是等用户打开 Recents 的瞬间再截?因为截一张 Task 级别的图并不便宜:要拿到 SurfaceControl、要等 GPU 合成、要把结果从图形内存里读出来,这一套放在用户手指已经划出导航栏之后再跑,就是明显的卡顿。提前拍、提前存、需要时直接读,才是流畅体验的前提。
所以 TaskSnapshot 本质上是一个"用空间换流畅度"的设计:内存里留最近几个任务的快照,磁盘上留全部最近任务的快照,冷启动时从磁盘恢复,热切换时直接命中内存。Android T 的重构,动的就是这套"存哪儿、怎么存、什么时候删"的实现。
1.2 一次重构解决的是哪几个老问题
Android 11 及更早的版本,TaskSnapshotController 这个类里同时塞着三类逻辑:截屏并构造快照、维护内存 LRU、把快照序列化写盘。单文件上千行,任何一处改动都要重新理解整条链路,测试也很难写——你没法单独测"缓存淘汰策略对不对",因为缓存逻辑和 WindowManager 的全局锁耦合在一起。
Android 12 开始引入、Android T 上基本成型的做法是抽出AbsAppSnapshotController这个泛型基类,把"截图 → 建对象 → 入缓存 → 排队落盘"的通用骨架固化下来,让TaskSnapshotController只负责 Task 特有的部分(比如从 Task 里找 top visible window、处理 captionInsets)。同时把三个附属职责拆出去:TaskSnapshotCache管内存 LRU,TaskSnapshotPersister管后台写盘和删盘,TaskSnapshotLoader管从磁盘读回来。
这么拆的直接收益有三个。第一是线程模型干净了:Controller 只在 WMS 主线程(持有mService.mGlobalLock)里跑,Persister 有自己的 HandlerThread,两边靠队列交接,主线程永远不会等 IO。第二是可测试性上来了,缓存和持久化都能脱离 WindowManager 单独验证。第三是为后续在其他场景复用这套快照机制铺了路,基类的存在本身就说明设计上留了口子。
注意:不同厂商分支在这块的改动幅度差别很大,有些分支会为了省内存把内存缓存压得很小,有些会加自己的降采样策略。看代码时先确认你手上的是 AOSP 主线还是定制分支。
1.3 关键组件与数据结构速览
在往下钻流程之前,先把几个核心角色和数据字段过一遍,后面的分析会反复用到。
| 组件 | 所在位置 | 职责 |
|---|---|---|
| TaskSnapshotController | system_server(wm 包) | 决定何时拍、拍谁,构造 TaskSnapshot |
| TaskSnapshotCache | 同上 | 内存 LRU,保存最近任务的快照对象 |
| TaskSnapshotPersister | 同上,独立线程 | 后台写盘、删盘,串行处理队列 |
| TaskSnapshotLoader | 同上 | 从磁盘读回位图与元数据 |
| ActivityManager.TaskSnapshot | framework 层 | 跨进程传递的快照载体 |
ActivityManager.TaskSnapshot里比较关键的字段有:HardwareBuffer mSnapshot(位图本体,Android S 之后从 GraphicBuffer 换成了 HardwareBuffer,跨进程传递靠它)、ColorSpace、rotation、taskSize、contentInsets、captionInsets、windowingMode、appearance、isRealSnapshot、isTranslucent,以及一个容易被忽略但非常关键的id——这是快照自己的唯一标识,专门用来防 taskId 复用导致的错图问题。
内存缓存那一层是 LRU,容量在代码里以常量形式硬编码,Android T 主线这个值很小(个位数级别),因为一个全分辨率快照在图形内存里占的空间并不小。磁盘那一层没有容量上限,靠任务移除事件来清理,这也是后面"空间占用异常"问题的根源。
2. 创建流程:一次快照从触发到落盘要经过哪些关卡
2.1 三种典型的触发时机
快照不是随便什么时候都能拍的,拍早了画面还没画出来,拍晚了用户已经看到别的东西了。Android T 上主要有这么几个触发点。
第一种是 Activity 真正进入 STOPPED 状态之后。用户在应用里按了 Home,或者切到了另一个任务,当前任务里的 Activity 会一路走到 stopped,窗口不再可见。这个时候系统判断这个 Task 已经"离场",可以给它留一张最后的画面。判断条件里有个关键点:必须确认任务里所有 Activity 都不再可见,只要还有一个窗口露出一点边角,这次截图就可能截到花屏或者半透明的中间态。
第二种是任务即将被移除时。任务被 finish 或者被从 Recents 里划掉,退出动画需要一张"遗照"来平滑过渡,否则画面会直接跳变。这类快照的生成时机要求更紧,因为动画不等人。
第三种是启动过渡需要跨进程传递的时候。系统在拉起任务前会先把快照交给 Shell 侧做过渡动画,这时候走的是snapshotTask(task, isLowResolution, forTransition)这条带 forTransition 标记的路径,语义上明确告诉下游"这张图是给动画用的,别当普通缓存处理"。
这里插一句我的观察:这三类触发的判定条件在代码里是分层的,最外层还有一个全局开关控制快照功能是否启用(通常和系统属性、低内存状态相关)。排查"为什么没生成快照"的时候,建议从最外层开关往内层逐级看,比直接钻createTaskSnapshot高效得多。
2.2 createTaskSnapshot 内部到底干了什么
这是整条链路里最核心的方法。它的实现可以拆成准备、构造、校验、收尾四段,我按执行顺序讲。
准备阶段第一件事是遍历 Task 下的所有 Activity,把正在做动画的那些挑出来,对它们调pauseKeyDispatchingLocked()。为什么要暂停按键分发?因为截图是异步的,从发起截屏到拿到 buffer 之间有窗口期,如果这个期间有按键事件注入,可能导致界面状态和截到的画面不一致,甚至触发动画中途重绘。暂停按键、截完再resumeKeyDispatchingLocked(),是一个成本很低但很有效的保护。这一批暂停的 Activity 会放进一个栈结构里,在 finally 块里逐个恢复,保证异常路径下也不会卡死按键。
接下来是找截图目标:getTopVisibleAppMainWindow(),取 Task 里最上面那个可见应用主窗口。这里有个容易被忽略的细节——如果这个 Task 的窗口没有硬件加速(isHardwareAccelerated返回 false),系统会把快照标记成isRealSnapshot = false。这类快照的内容通常不可信,可能是纯色或者残缺的,上层拿到之后应该退化用应用图标或者纯色背景,而不是硬把这张图铺上去。
构造阶段是往ActivityManager.TaskSnapshot.Builder里填字段。尺寸来自 Task 的 bounds,旋转角度取自显示配置,内容内边距来自 Task 的getContentInsets,另外还有 captions 相关的内边距(视频类应用的字幕区域需要单独处理,否则缩略图里会带一条黑边)。填充过程中还会记录当前 Task 的窗口模式和 Activity 外观配置,这些信息在恢复快照时用来判断"这张图和当前显示环境还匹配不匹配"。
然后是真正的截屏动作:通过 SurfaceControl 对 Task 的 surface 做一次截图,拿到ScreenshotHardwareBuffer。这个对象里装着 HardwareBuffer 和对应的 ColorSpace,直接塞进 Builder 即可,不需要再拷贝一次像素数据,这也是 Android S 之后把 GraphicBuffer 换成 HardwareBuffer 的收益之一——跨进程传递时的拷贝开销更可控。
校验阶段看着琐碎但很重要:buffer 为空、宽高为 0、宽高比明显异常、颜色空间拿不到,这些情况都会导致这次快照直接放弃,不写入任何队列。这一步的意义在于挡住脏数据,一旦让一张尺寸为 0 的快照进了缓存,后面加载时Bitmap.wrapHardwareBuffer会直接抛异常,排查起来比在这里返回 null 麻烦十倍。
2.3 低分辨率快照与硬件加速判定的取舍
isLowResolution这个参数值得单独拎出来说。Recents 界面里的卡片尺寸通常只有屏幕的几分之一,全分辨率快照存下来再缩小显示,纯属浪费带宽和内存。所以在很多路径上系统会主动请求降采样版本,Persister 写盘时也会用不同的文件名前缀区分两类文件,全分辨率和降采样版本在磁盘上是两份独立的数据,加载时按需取用。
硬件加速判定则是另一个维度的取舍。判定为"非真实快照"之后,系统并不是直接把快照丢掉,而是仍然保留尺寸、旋转这些元信息,只是把位图内容标记为不可信。这么做的好处是上层可以拿这些元信息去算动画的形变矩阵,同时用占位图代替真实画面,视觉上不会跳得很突兀。这个设计在早期版本里是没有的,早期版本遇到截不到的情况会直接不生成快照,导致动画层拿不到尺寸信息,过渡效果明显更差。
实操心得:如果你在自定义 Recents 里发现某些应用的缩略图长期是一块纯色,先别急着怀疑自己的加载代码,用调试手段确认一下
isRealSnapshot的值。这个字段为 false 时,正确的做法就是显示占位内容,而不是反复尝试重新加载。
2.4 从内存缓存到磁盘:TaskSnapshotPersister 的写入策略
主线程做完构造之后,快照不会直接写盘,而是先落到内存缓存里,同时把任务 ID 丢进 Persister 的队列。Persister 内部有一个独立的 HandlerThread 和一个串行队列,所有写盘、删盘操作都在这条线程上按顺序执行。
为什么必须串行?因为同一个 taskId 的快照可能被连续更新多次,如果并发写,可能出现位图是新版本、元数据是旧版本这种撕裂组合,加载时就会读到一张尺寸对不上内容的图。串行队列天然避免了这个问题,代价是极端情况下队列会积压,所以 Persister 在入队时会做一些合并和丢弃——同一任务短时间内多次更新,前面的版本可以直接被顶掉。
写盘时是两份文件:位图本体和元数据。位图按配置的压缩格式编码(不同版本用过 JPEG 和 WEBP,具体看你手上的分支常量),元数据是一个轻量的 proto 结构,记录宽高、旋转、内边距、isRealSnapshot 这些字段。加载流程是从元数据文件入手的,元数据不存在或者解析失败,就直接判定这张快照不可用,不会去尝试读位图。
这里有一个我认为很巧妙的设计:磁盘上的快照分为"全分辨率"和"降采样"两类,用文件名前缀区分,加载时优先找降采样版本(因为 Recents 场景下够用了),需要大图时再找全分辨率版本。这种分层存储让磁盘占用和加载速度都更好控制,比只存一份全分辨率然后运行时缩放要合理得多。
3. 移除流程:内存与磁盘这两条线是怎么协同的
3.1 三个移除入口分别对应什么场景
快照的移除入口不止一个,搞清楚它们分别对应什么场景,排查问题时才能快速定位是哪条路径没走到。
第一个入口是任务被移除时的回调。任务从 Activity 栈里消失(被 finish、被划掉、或者所在进程崩溃导致栈被清理),WindowManager 会在合适的位置通知 TaskSnapshotController,把对应的缓存条目标记为待移除,磁盘文件也一并清掉。这是最主要的清理路径。
第二个入口是按 taskId 精确移除。这条路径通常在系统内部使用,比如某个任务的快照被判定为过期或者不可信,需要主动丢弃重拍。
第三个入口是用户清空最近任务。这种情况下会走批量移除,把某个用户维度下的所有快照一次性清干净,包括内存缓存和磁盘目录。多用户设备上这里要特别注意 userId 的隔离,快照目录是按用户分的,清理时漏掉 userId 会导致另一个用户的数据残留。
还有一个容易被忽略的场景:应用被卸载或者包被替换时,对应的历史任务快照也应该失效。这条路径和前面几条不一样,它不是由任务生命周期驱动的,而是由包管理事件触发的,如果处理得不干净,就会出现"应用都卸了,Recents 里还留着它的缩略图"这种诡异现象,虽然点进去会失败,但体验很脏。
3.2 延迟移除队列 mPendingRemove 的设计意图
任务移除和快照移除不是同时发生的,中间特意留了一个缓冲,这就是待移除队列存在的意义。
原因不难理解:任务被移除的瞬间,退出动画往往还在跑,动画需要那张快照作为纹理来源。如果任务移除的回调里立即把快照从缓存里摘掉、磁盘文件也删了,动画跑到一半就会拿不到 buffer,轻则画面闪一下,重则动画直接中断。所以系统先把任务放进待移除队列,等过渡动画真正结束、相关资源都释放之后,再统一执行清理。
这个队列还承担了另一层职责:区分"任务被移除"和"任务还在运行但需要清快照"这两种情况。代码里会用不同的容器分别记录,前者是普通的待移除列表,后者是正在运行但被标记的集合,避免误删一个还活着的任务的快照。
注意事项:如果你在改这块代码时图省事,直接在任务移除回调里同步删缓存,短期内可能看不出问题,但在低端机或者动画较长的设备上,会偶发退出动画闪烁。这类问题很难稳定复现,别给自己挖坑。
3.3 内存淘汰和磁盘清理不是一回事
这是很多同学第一次读这块代码时最容易搞混的地方:内存缓存的淘汰和磁盘文件的删除,是两条独立的线。
内存缓存的 LRU 淘汰是被动的——插新条目时容量超了,最旧的那条被挤出去,但仅仅是从内存里移除引用,磁盘上的文件原封不动。这样设计的好处是,用户过一会儿又切回那个任务,系统还能从磁盘把快照读回来,体验上是连续的,代价只是多一次磁盘 IO。
磁盘文件的删除是主动的——只有任务真的被移除、被用户清空、或者包被卸载时才会触发。换句话说,磁盘上永远保留着"最近 N 个任务"的快照,N 由系统记住的任务数量决定,而不是由内存缓存容量决定。
理解了这个分离,就能解释一个常见现象:用 dumpsys 看内存缓存里只有两三条记录,但去翻磁盘目录发现躺着十几个文件,这不是 bug,是设计。真正需要警惕的是磁盘文件数量持续增长且不见回落,那才说明清理路径有问题。
3.4 一个绕不开的坑:taskId 复用导致的错图
taskId 是系统分配的整数,会被回收复用。任务 A 被移除,它的 taskId 过一段时间可能被分配给新创建的任务 B。如果 A 的快照还没来得及清理干净,B 在某些加载路径上就可能拿到 A 的缩略图——用户看到的就是一张风马牛不相及的图。
AOSP 对这个问题的处理方式是给每张快照加一个独立的自增 ID,和 taskId 分开。生成快照时记录这个 ID,加载时做比对,ID 对不上就判定为脏数据直接丢弃。这个机制在正常流程下工作得很好,但在定制分支上如果被改动过(比如为了省事直接删掉了 ID 字段),就会出现上面说的错图现象。
我实际遇到过的一个变体是:修改了快照的落盘逻辑但没有同步更新元数据里的 ID,导致某些情况下 ID 校验恒成立,快照看起来"总是能加载",但偶尔内容不对。排查思路很简单,把 taskId 和快照 ID 都打出来对照一下,很快就能看出问题。
4. 常见问题与排查技巧实录
4.1 缩略图黑屏或者纯色
这是最常见的一类反馈。按出现频率从高到低排,原因大致有这么几种。
一是应用使用了 SurfaceView 或者 TextureView 承载主要内容(视频播放器、相机、游戏)。这类内容走的是独立的合成通道,通过 SurfaceControl 做 Task 级截图时不一定能拿到,硬件加速判定为 false 之后快照就被标记为非真实。这种情况下正确的处理是显示占位内容,而不是死磕截图路径。
二是截图时机偏早,Task 的第一帧还没合成完,拿到的是空 buffer。这个在冷启动任务上比较常见,通常靠"截到空 buffer 就放弃这次、下次再拍"来缓解。
三是内容受保护。某些受保护的内容在图形层就截不到,拿到的区域是黑的,这属于预期行为,不需要修。
四是内存压力下的降级。系统在低内存状态可能会跳过某些快照生成,或者用很早以前的旧快照顶上。
排查手段上,先看快照的isRealSnapshot和实际尺寸,这两个字段能覆盖大部分情况。如果isRealSnapshot是 true 但画面还是黑的,那就往应用侧的渲染路径去找,问题多半不在系统这一层。
4.2 快照不更新,一直是旧图
另一类典型问题:应用界面明明变了,Recents 里的缩略图还是老样子。
第一嫌疑是任务没有真正进入不可见状态。有些应用会在后台维持一个可见的窗口(比如画中画的变体、悬浮窗),这种情况下任务不满足拍快照的条件,自然不会更新。用 dumpsys 看任务的可见性状态就能确认。
第二嫌疑是缓存命中导致没走重拍路径。快照生成逻辑里通常有个判断:如果任务当前不可见但缓存里已有快照,可能会直接复用而不重拍。这个判断是为了省性能,但如果任务在两次快照之间发生了旋转、分屏尺寸变化这类结构性变化,复用的旧图就会对不上。这时候需要靠快照 ID 或者尺寸校验来兜底。
第三嫌疑是内存缓存里的旧条目没被新条目顶掉。LRU 的插入逻辑理论上不会有这个问题,但如果你的分支上对缓存做了自定义扩展,就要检查一下键的设计是否包含了足够的信息(只拿 taskId 做键是不够的,至少要加上快照 ID 或者版本号)。
4.3 磁盘空间异常增长
现象是/data/system_ce/<userId>/snapshots/目录下文件越积越多,用户反馈存储空间被占了一大块。
根因通常是清理路径没走到。常见的情况有这么几种:应用频繁创建销毁任务,每个 taskId 都留下了一份快照;任务移除时的回调因为某些异常被跳过;多用户场景下清理只处理了当前用户。排查时先数一下文件数量和总大小,再按文件名的 taskId 去对照当前还活着的任务列表,对不上的那些就是残留。
处理上,临时手段是手动清理目录后重启,长期手段是排查清理回调为什么没触发。这里有个经验:凡是这类"应该被删但没删"的问题,八成是因为回调链中某一环被条件判断挡住了,把每个条件分支加日志打一遍,比读代码快得多。
4.4 问题速查表
| 现象 | 优先排查点 | 常见根因 |
|---|---|---|
| 缩略图纯黑 | isRealSnapshot 字段 | 未硬件加速、内容受保护 |
| 缩略图是旧图 | 缓存命中逻辑、任务可见性 | 复用旧快照、任务未真正不可见 |
| 点击任务卡顿 | 磁盘文件是否存在、加载耗时 | 冷加载触发、降采样文件缺失 |
| 磁盘占用异常 | 快照目录文件数量 | 清理回调未触发、多用户残留 |
| 退出动画闪烁 | 待移除队列处理时序 | 过早同步删除快照 |
| 缩略图张冠李戴 | 快照 ID 校验 | taskId 复用、ID 校验被绕过 |
5. 调试手段与实操验证
5.1 用 dumpsys 把状态摸清楚
TaskSnapshotController 的状态会挂在 ActivityTaskManagerService 的 dump 里,这是最直接的观察窗口。
# 查看快照控制器的整体状态,包括缓存条目和待移除队列 adb shell dumpsys activity | grep -A 40 -i "snapshot" # 查看当前所有任务及其可见性,用来判断某个任务是否满足拍快照条件 adb shell dumpsys activity activities | grep -E "Task|visible|state=" # 直接看磁盘上的快照文件,注意 userId 要换成实际值 adb shell ls -l /data/system_ce/0/snapshots/ # 看 system_server 里图形内存的占用,快照泄漏会体现在这里 adb shell dumpsys meminfo system_server | grep -i -E "graphic|GL"dumpsys 输出里重点看三块:内存缓存里当前有哪些 taskId、待移除队列里积压了哪些任务、每个条目记录的快照 ID 和尺寸。这三块信息结合在一起,基本能定位九成以上的问题。
5.2 手动复现一条创建到移除的完整链路
想验证自己对流程的理解是否正确,最简单的方式是手动走一遍完整链路,边操作边观察。
第一步,打开一个内容比较丰富的应用(能明显看出界面变化的,比如带图片列表的),等它完全渲染完成。第二步,按 Home 回到桌面,再用最近任务键打开 Recents,观察缩略图。第三步,立刻回到终端执行一次dumpsys activity | grep -A 40 -i snapshot,这时候应该能看到这个任务出现在内存缓存里,磁盘目录里也多了一个对应 taskId 的文件。
第四步,把任务从 Recents 里划掉,再执行一次同样的命令,正常情况下这个条目应该从缓存里消失,磁盘文件也应该被删掉。如果缓存里没了但文件还在,那就是清理路径里的文件删除环节有问题;如果两边都在,说明待移除队列根本没被消费。
这个手动流程我建议在改动这块代码之前先跑一遍作为基线,改完之后再跑一遍做对比,比对着代码空想可靠得多。
5.3 抓 trace 看动画和快照的配合
涉及动画时序的问题,光看日志很难看出因果。这时候需要上 trace。
# 抓一段包含窗口管理、图形、调度的 trace adb shell atrace -t 5 -b 8000 wm gfx view sched freq -o /data/local/tmp/trace.txt adb pull /data/local/tmp/trace.txt # 也可以用 perfetto 抓更长时间、更细粒度的数据 adb shell perfetto -o /data/misc/perfetto-traces/trace -t 10s \ sched freq idle am wm gfx view binder_driver adb pull /data/misc/perfetto-traces/trace分析的时候重点看两个时间点:快照生成的那次 SurfaceControl 截图调用落在哪一帧,以及任务移除后清理动作发生在动画的第几帧。理想情况是清理动作严格晚于动画结束,如果发现清理早于动画的最后几帧,那就是待移除队列的消费时机有问题,通常和过渡动画的完成回调绑定有关系。
5.4 修改代码时值得守住的两条纪律
第一条是不要在持有全局锁的时候做 IO。快照的写盘和读盘必须在 Persister 的独立线程上完成,主线程只做队列交接。我见过为了"简化流程"把写盘挪到主线程的分支,表现是系统在高频切换任务时出现明显掉帧,而且在低端机上更严重,回滚成本很高。
第二条是任何新增的快照字段都要考虑序列化兼容。磁盘上可能躺着上个版本写下的元数据文件,新版本读到老格式时如果直接抛异常,会导致升级后一段时间内所有快照都加载失败。稳妥的做法是给新字段一个安全的默认值,让老数据也能解析出可用的结果,哪怕信息不全。
最后分享一个我个人在实际排查中形成的习惯:遇到快照相关的诡异问题,先把问题归类到"没生成""生成了但没存住""存住了但读不出来""读出来了但被错误清理"这四种状态中的一种,然后再顺着对应的链路去看。这套分类比按现象分类更接近系统实际的执行路径,定位速度会快不少。这个模块后续还可以往自动化验证的方向扩展,写个脚本定时去比对内存缓存和磁盘文件的一致性,能提前发现很多潜在问题。