每次在最近任务里划掉一个应用system_server 里其实都有一串动作在悄悄收尾——不只是把 Task 从栈里摘掉还包括一张早就备好的缩略图要被从内存和磁盘上清干净。这张图就是 TaskSnapshot它是 Android 多任务体验里最容易被忽视、但排查起来最费劲的一块。做系统定制、最近任务界面优化或者单纯想搞清楚「为什么我的缩略图老是黑屏、是旧图、是白块」的同学都绕不开 TaskSnapshot 的创建和移除流程。Android T 上这块做了一轮结构性调整客户端开始参与快照提供过渡动画的显示路径也换成了 TaskSnapshotSurface老的那套「全靠 system_server 截屏」的经验不再完全适用。下面我按照真实的源码调用顺序把创建和移除两条链路完整走一遍。涉及的文件路径、关键方法、参数含义我都会点出来方便你边看边对照 AOSP 翻代码。中间会穿插几个我在改机和抓日志时踩过的坑最后给一张问题速查表遇到现象可以直接查。1. 先搞清楚 TaskSnapshot 在系统里的定位1.1 它和普通截图的本质区别很多人第一次听到 TaskSnapshot会下意识地以为它就是「给任务拍个照」。这个理解只对了一半而且容易带偏后面的排查方向。普通截图是什么用户按下电源键加音量减系统通过 SurfaceFlinger 合成当前屏幕把一整帧像素编码成 PNG 存到图库。它的输入是当前屏幕的物理合成结果输出是给用户看的一张图中间没有任何状态语义截完就跟系统没关系了。TaskSnapshot 完全是另一回事。它的输入是一个 Task也就是一个任务栈输出是一份既包含像素、又包含尺寸、旋转角、内容内边距、是否真实快照等元数据的对象。这份对象会被 RecentTasks 拿去渲染最近任务卡片也会被 TaskSnapshotSurface 在过渡动画里临时顶上去做占位。也就是说它不只服务于「看」还服务于「过渡和状态保持」。这个差异直接决定了几件事。第一它不是全屏截图而是按任务的实际 bounds 去截分屏、小窗、画中画场景下尺寸都不一样。第二它必须带旋转信息因为用户在横屏划出最近任务时卡片不能歪着或者被拉伸。第三它要有真假标记系统在拿不到真实快照时会塞一个占位快照进去避免卡片直接消失造成闪烁。理解了这三点后面看到各种「尺寸不对」「方向不对」「颜色不对」的现象就大概知道该往哪个方向查了。1.2 Android T 上的模块分工TaskSnapshot 这块的代码主要落在 system_server 的窗口管理侧包路径在frameworks/base/services/core/java/com/android/server/wm/。核心角色有这么几个我把它们的关系理一理。TaskSnapshotController是总调度。它决定什么时候该给哪个 Task 快照、什么时候该删所有外部调用最终都落到它身上。TaskSnapshotPersister负责磁盘持久化写文件、删文件、读元数据都归它而且它跑在自己独立的工作线程上。TaskSnapshotLoader是从磁盘把快照读回内存的小工具类被 Persister 和 Controller 共用。TaskSnapshotCache是内存里的 LRU 缓存键是 taskId 加 userId值是ActivityManager.TaskSnapshot。Android T 上还多了一个TaskSnapshotSurface专门负责在任务过渡期间把快照渲染出来。这个类在你调最近任务动画、排查「闪一下白」或者「旧图残留」时非常关键因为很多时候你看到的画面其实不是 RecentTasks 画的而是它画的。分工清晰带来的好处是职责单一Controller 只关心时机Persister 只关心 IOCache 只关心内存命中率。代价是链路变长一个快照从产生到可见要跨至少三个线程——调用线程、Persister 的 IO 线程、渲染线程。排查问题时如果不清楚自己卡在哪一段日志会看得一头雾水。我个人习惯是先确认快照有没有被正确创建再看有没有正确落盘最后才看渲染按这个顺序走基本不会乱。2. 创建流程一张快照从诞生到落盘2.1 谁在什么时候按下拍照键快照不是随时都在拍的它有明确的触发点。Android 里主要有这么几类时机每一类走的代码路径都不太一样。第一类是任务退到后台。用户按 Home 键或者上划顶层 Activity 进入 paused 或 stoppedTask 从 focused 状态退下来这时候系统需要给最近任务准备一张图。对应的入口通常在任务状态变化的地方最终调到TaskSnapshotController.snapshotTask()。第二类是任务正在关闭。比如用户按返回键退出应用或者应用自己调 finishTask 会被标记为 closing。这个时候如果不赶紧拍一张等窗口真的销毁了就拍不到了所以走的是处理 closing app 的特殊路径而且这个路径下往往还会配合动画做处理。第三类是屏幕旋转。旋转会改变任务 bounds老的快照尺寸和方向都不对了必须重拍。这条路径在TaskSnapshotController监听配置变化的地方。第四类是显式请求。比如某些系统界面场景下主动调ActivityTaskManagerService.takeTaskSnapshot()来拿一份快照。这四类时机对应的分支各不相同但最终都会汇聚到 Controller 内部的几个核心方法上。理解这一点很重要如果你发现某个场景下快照没更新先确定它属于哪一类时机再去查那一条分支比盲目加日志高效得多。我见过不少同学一上来就在snapshotTask入口打日志结果发现这个方法压根没被调用问题其实在触发条件那一层。2.2 snapshotTask 内部到底做了什么我按源码顺序把snapshotTask()的主干拆一下这一段是理解整个创建流程的关键。进来先做前置检查。Controller 会看这个 Task 是不是已经销毁看它有没有顶层 Activity看SnapshotConfiguration是否允许快照。这里有个容易忽略的点如果顶层 Activity 是 noDisplay 的或者任务本身被标记为不参与最近任务会直接 return后面什么都不做。很多「快照死活不出来」的问题就卡在这一步。接着是关键分支先看客户端有没有提供快照。Android T 之后如果 Task 用的是 TaskOrganizer 那套机制分屏、嵌入式 Activity 常见系统会尝试从客户端拿快照避免自己再截一遍。这是个性能优化因为客户端自己知道自己窗口的内容状态由它提供比系统截屏更快、更准。拿不到客户端快照就退回系统截屏路径。这一步会调用到窗口管理侧的截屏能力本质上是对 Task 的 SurfaceControl 树做一次 layer 捕获拿到一个 GraphicBuffer。这条路径依赖 SurfaceFlinger 的合成结果所以它对时机很敏感——窗口如果还没画完截出来就是黑块或旧帧。顺着往下走Controller 会组装ActivityManager.TaskSnapshot对象。要填的东西不少GraphicBuffer 本体、色彩空间、旋转角、任务尺寸、内容内边距、背景色、顶层组件名、是否半透明。填完之后如果这个 Task 是真实可见的就把mIsRealSnapshot标成 true。这里插一句我的经验mIsRealSnapshot这个字段在排查问题时特别有用。你如果看到最近任务卡片是一块纯色或者纯黑先看 dumpsys 里的这个标记。如果是 false说明系统压根没截到真实内容问题在截屏环节而不是渲染环节能帮你省掉一大半排查时间。最后一步Controller 把快照交给缓存和持久化并通知最近任务的状态变了。2.3 客户端优先应用自己提供的快照这一段单独拎出来说因为它是 Android T 里我认为最值得关注的变化。以前的逻辑很直接任务退到后台system_server 自己截一张完事。问题是分屏和小窗场景下一个 Task 可能只占屏幕一小块系统去截整个 surface 树再裁切既慢又容易出边界问题。更麻烦的是有些内容比如视频画面、受保护的 surface系统根本截不到只能拿到黑块。Android T 的做法是让有能力的一方自己提供快照。走 TaskOrganizer 的任务客户端在收到任务状态变化的通知后可以把自己的快照信息回传过来系统优先采用。这个机制的好处有三个一是快客户端可以直接复用自己的合成结果不需要跨进程再抓一次二是准尺寸、旋转、内边距信息由来源方自己算误差小三是能覆盖系统截不到的内容因为客户端可以拿到自己的 buffer。实际改机的时候如果你的设备上大量使用分屏或嵌入式 Activity建议优先确认这条路径是否打通。判断方法很简单在快照创建的相关日志里看有没有走客户端分支。如果一直走系统截屏而业务又确实是分屏场景那大概率是客户端回调没接上或者版本不匹配。提示客户端优先不等于客户端必须。系统始终保留系统截屏这条路作为兜底所以即使客户端不提供功能也不会坏只是效果和性能差一些。排查时别把它当成硬依赖。2.4 从 GraphicBuffer 到磁盘文件快照在内存里活不了多久系统内存紧张的时候随时可能被回收所以必须落盘。这一步由TaskSnapshotPersister负责它做的事情可以拆成三块。第一块是编码。内存里拿到的是 GraphicBuffer需要压缩成 JPEG。这里有个参数很关键压缩质量。质量太高文件大、写盘慢质量太低卡片糊。系统在SnapshotConfiguration里给了默认值同时区分了「全尺寸」和「降低分辨率」两种模式。降低分辨率的模式主要用在内存压力大或者快照数量多的场景牺牲一点清晰度换内存和 IO。第二块是写文件。快照文件走的是系统数据目录按用户维度隔离。文件名里带任务 id 和旋转角这样同一个任务在不同方向下有各自的文件读的时候按当前旋转角去找避免方向错乱。除了 JPEG 本体还会写一份元数据记录尺寸、内边距、颜色空间、顶层组件这些信息。为什么要单独存元数据因为读回内存时需要还原成一个完整的ActivityManager.TaskSnapshot对象光有像素是不够的。第三块是队列化。写盘是异步的Persister 内部维护一个队列实际写操作在专门的线程里做。这样设计是为了不阻塞主线程因为快照创建往往发生在任务切换的关键路径上卡一下用户就能感知到。代价是写盘有延迟如果你在创建后立刻去磁盘找文件可能找不到——这一点在做自动化测试时经常被坑。2.5 内存缓存与状态回传落盘之后快照还会进一份内存缓存也就是TaskSnapshotCache。它是个 LRU 结构容量有限满了就把最久没用的踢出去。缓存的存在是为了让最近任务界面打开时能秒出图不用每次都去读磁盘。缓存条目里同时保存了快照对象和它对应的任务标识。这里有个细节值得注意缓存里存的和磁盘上存的可能是不同分辨率的版本。系统在内存压力大时会主动把缓存里的高清版本替换成低清版本甚至直接清掉等要用的时候再从磁盘读回来。所以你在调试时看到的现象可能是「第一次打开很清晰过一会变模糊了」这不是 bug是设计如此。状态回传是最后一步。快照准备好之后Controller 会通知相关方最近任务列表会知道自己该刷新哪一项。Android T 上这一步还牵扯到过渡动画如果任务正在做关闭动画快照会被交给TaskSnapshotSurface去渲染保证动画过程中画面不闪。注意GraphicBuffer 是有限资源用完必须正确释放。如果你在定制代码里手动持有快照对象务必确认释放路径否则很容易出现 buffer 泄漏表现为用一段时间后拍不出新快照。3. 移除流程快照是怎么被清理干净的3.1 任务被移出最近列表的那一刻移除的触发点比创建要集中得多最主要的一条就是任务被用户划掉。用户在最近任务里往上一划RecentTasks 会把这个任务标记为移除接着调用到TaskSnapshotController的移除入口。这里有个容易混淆的地方任务被划掉和任务被销毁是两个不同的时机。划掉只是从最近任务列表里移除任务本身可能还在后台跑而销毁是任务真的没了。快照的清理主要挂在「划掉」这个时机上因为它对应的是「用户不想再看到这个任务了」。除了划掉还有几条触发路径。应用被卸载时会顺带清理它的快照用户被删除时会清掉该用户下所有快照系统内存极度紧张时也会主动丢一部分。这几条路径的实现方式不太一样后面几节分别说。我实际遇到过一种情况某些定制 ROM 在划掉任务时只清了列表项没触发快照清理结果磁盘上的快照文件越积越多几个月后 system_ce 目录能涨到几个 G。这种问题很隐蔽因为功能看起来完全正常只有查磁盘占用才会发现。3.2 removeSnapshot 的完整调用链我把这条链路顺着走一遍。入口拿到 taskId 之后第一件事是清内存缓存。缓存是按 taskId 加 userId 索引的直接删掉对应条目。这一步是同步的删完立刻生效所以如果你在划掉之后马上调接口查缓存应该查不到了。第二件事是标记磁盘文件待删除。注意这里是「标记」不是「立即删」。Persister 会把这个 taskId 加入一个待删除集合然后交给 IO 线程异步处理。为什么要异步因为文件删除涉及磁盘 IO同步做会卡住调用线程而划掉任务这个动作对流畅度要求很高不能在这里掉链子。第三件事是处理正在进行的写操作。这里有个并发问题如果某个任务刚好在「正在写快照」的时候被划掉会出现写和删打架的情况。系统的处理方式是先把任务 id 记进待删除集合等写操作完成后Persister 在收尾阶段检查这个集合发现该任务已经被标记删除就把刚写好的文件立即干掉。这个逻辑保证了不会出现「删了又被写回来」的脏文件。这三步走完之后对上层来说移除就完成了。上层不关心磁盘上文件到底删没删干净因为缓存已经清了读也读不到。3.3 磁盘文件的异步删除磁盘删除这一段值得展开说因为它是最容易出问题的地方。Persister 的删除逻辑跟在一个专门的 IO 线程上跑。它维护一个待删除任务 id 的集合每次处理时把集合里的任务挨个找出来删除对应的所有文件——包括 JPEG 本体和元数据文件。注意是「所有文件」因为同一个任务在不同旋转角下可能有多个文件漏删一个就会留下垃圾。删完之后Persister 还要处理一件事更新它的内部记录。如果它曾经读过某个任务的快照并缓存在自己这边也要一起清掉不然下次读的时候可能拿到已经被删的数据。这里有个我认为很关键的细节删除操作最终会去校验文件是否真的删掉了。如果删除失败比如文件被占用、权限异常会记日志。所以排查「快照删不掉」的问题正确做法是抓 Persister 相关日志看有没有删除失败的记录而不是去看目录还在不在。目录会一直存在删的是目录里的文件。3.4 用户切换、内存压力下的兜底清理除了任务级别的清理还有几类批量清理场景做系统定制的同学必须清楚。用户被删除时整个用户目录下的快照都要清。这条路径在用户管理的生命周期回调里会遍历该用户的所有快照文件删除。这里要注意用户隔离的问题快照是按用户分开存的删除时必须带对 userId删错用户目录会导致另一个用户的最近任务空白。系统内存紧张时TaskSnapshotController会收到内存回调按策略丢弃一部分内存缓存。这里的策略是优先丢最久没用的同时可能把高清缓存替换成低清。丢缓存不删磁盘文件因为磁盘文件是「持久化」的下次要用还能读回来。所以在内存压力下功能不会坏只是会变慢。设备重启时还有一次全量清理。这套机制保证了即使前面某次删除失败了重启后也能有一些兜底的机会。提示如果你要验证移除流程是否彻底别只看内存要同时看三处——缓存条目、磁盘文件、Persister 的待删除集合。三者都干净了才算真的移除完成。4. 关键数据结构与参数4.1 ActivityManager.TaskSnapshot 字段逐一说明这个类是所有快照信息的载体字段不多但每个都有用我挑几个重点说。快照像素本体是个 GraphicBuffer。它是整个对象里最占资源的用完必须回收。是否真实快照这个布尔值前面提过用于区分真实内容占位内容。旋转角决定了卡片该怎么摆放读的时候会跟当前方向比对不一致就重新拍。任务尺寸和内容内边距一起决定了卡片里内容区域的裁剪范围这两个值算错就会导致卡片被拉伸或者留黑边。顶层组件名用于在卡片上显示应用信息也用于判断快照是否还有效。色彩空间影响色彩还原尤其是广色域设备上配错会让卡片偏色。窗口模式和背景色在分屏和小窗场景下比较重要用来还原快照周围的填充区域。实际调试时我建议用 dumpsys 把这份信息打出来看一眼。字段对不对一眼就能判断问题出在创建阶段还是渲染阶段。4.2 SnapshotConfiguration 与压缩取舍这个配置类控制了快照的开关和压缩策略参数不多但每一个都直接影响体验。默认缩放比例控制正常情况下的快照分辨率一般是不缩放保证清晰度。高分辨率缩放比例在某些需要更清晰卡片的场景下使用。是否启用快照这个总开关在省内存版本或者低端设备上可能被关掉。降低分辨率的阈值决定了在什么内存水位下开始降质。这几个参数之间的关系是内存越紧缩放比越小分辨率越低压缩率越高。这是个典型的空间换时间、清晰度换内存的取舍。做定制时如果你遇到「低端机最近任务卡片特别糊」先去看这台机器上这些参数是不是被改过。4.3 持久化目录与文件命名快照文件存在按用户隔离的数据目录下具体路径在系统数据分区的快照子目录里。命名规则是任务 id 加旋转角再带扩展名。降低分辨率的版本会带一个特殊后缀跟全尺寸版本区分开。元数据文件跟 JPEG 文件同名只是扩展名不同。这里有个实操中踩过的坑有些工具或者脚本会去遍历这个目录做统计但如果没考虑低分辨率版本的文件统计出来的数量会翻倍。正确做法是按任务 id 聚合而不是按文件数量算。5. 调试与问题排查实录5.1 常见现象速查表现象可能原因排查方向卡片全黑截屏时窗口未绘制完成或受保护内容无法截取检查是否真实快照标记看截屏时机卡片是旧图快照未按新状态重拍或读到了旧文件检查旋转角与任务状态确认是否触发重拍卡片被拉伸任务尺寸或内边距计算错误对照实际 bounds 检查元数据划掉后磁盘不释放移除链路未触发或删除失败查 Persister 删除日志确认待删除集合快速切换时闪白快照未及时就绪占位快照缺失检查过渡动画使用的快照来源分屏下卡片内容错位客户端快照未启用或元数据不匹配确认是否走客户端提供路径5.2 用 dumpsys 和日志定位问题排查这类问题我通常按固定的顺序来效率最高。先看内存里的快照状态。把窗口管理相关的 dumpsys 信息拉出来重点看快照缓存里有哪些条目、是否真实快照标记是什么、尺寸和旋转角对不对。这一步能判断快照本身有没有正确创建。再看磁盘。去对应的数据目录按任务 id 找文件看文件是不是存在、大小是不是正常、修改时间是不是最新的。大小异常小往往是截屏失败或者编码失败。最后看日志。跟快照相关的日志关键字主要是 Persister 的读写删除记录和 Controller 的创建记录。我习惯先 grep 任务 id把跟这个任务相关的所有日志串起来看比从头翻快得多。有一点要提醒日志量会很大尤其是频繁切换任务时。建议在复现问题的前后打时间戳缩小范围。5.3 几个我踩过的坑第一个坑是关于 GraphicBuffer 释放的。我在定制一个快照复用的功能时手动把快照对象缓存在了自己的结构里结果忘了在移除时释放。表现是用一段时间后新快照拍不出来日志里能看到 buffer 分配失败的迹象。这类问题的教训是只要涉及 GraphicBuffer就必须成对考虑获取和释放。第二个坑是异步删除的时间差。我写过一个自动化脚本划掉任务后立即检查磁盘文件是否删除结果频繁误报。原因就是删除是异步的脚本跑得比 IO 线程还快。后来改成轮询等待加了超时重试才稳定。第三个坑是关于分屏场景的。早期我没意识到客户端可以提供快照一直在系统截屏那条路上调参数怎么调都是黑块或者错位。后来确认了客户端路径后改成让客户端提供元数据问题一次解决。这个经历让我养成了一个习惯遇到分屏、小窗问题先确认走的是哪条快照路径再往下查。第四个坑是低分辨率版本的读取。我在做一个快照统计功能时按文件数量统计结果数量总是对不上后来发现是低分辨率版本也被算进去了。改成按任务 id 聚合后才准确。6. 可扩展的定制思路如果你在系统层做定制快照这块有几个值得动手的方向。一个是自定义压缩策略。默认的压缩质量和缩放比是全局的你可以结合设备内存等级做动态调整比如在低内存设备上默认就用更小的缩放比减少 OOM 风险。另一个是快照的过期策略。默认策略是按 LRU 淘汰缓存磁盘文件则一直留到任务被划掉。如果你希望磁盘上的快照也定期清理可以在 Persister 那一层加一个定期扫描的逻辑把超过一定时间的快照文件清掉避免长期使用后占用过多空间。再一个是客户端快照的接入。如果你的产品有自己的窗口体系比如分屏或者嵌入式 Activity把客户端提供快照这条路径接上效果提升会非常明显尤其是那些系统截不到的内容。要动手前我建议先在标准流程上把创建和移除两条链路的日志打好改完之后对比日志这样每一步改动的效果都能量化出来比凭感觉调参靠谱得多。