1. 从最近任务里的一张缩略图倒推整条快照链路你按下最近任务键卡片上呈现的是几秒前那个 App 的画面——不是白屏也不是 App 图标加一个纯色底。这个体验看起来平平无奇但它背后跑着 Android 里一条相当长的链路AMS 判断该不该抓、什么时候抓、抓完怎么封包、谁来落盘、Launcher 侧怎么读、什么时候删。在 Android T 上这条链路又被拆得更细和 transition 动画、WMShell 的关系也更紧了。这篇就围绕TaskSnapshot 的创建与移除走一遍从触发条件一直讲到磁盘回收最后给一套我自己排查缩略图问题时常用的定位方法。先明确一下讨论范围免得后面对不上号。这里说的 TaskSnapshot指的是任务进入后台后系统为主窗口内容留下的那份图形快照加上围绕它的元数据旋转、任务尺寸、内容 inset、色域、是否降采样以及磁盘上的持久化文件。它和 App 图标的持久化是两套东西和「最近任务列表本身的数据」也是两套东西三者经常被混着说排查问题时如果搞混会浪费很多时间。下面所有内容以 AOSP 主线为参照厂商分支通常会有裁剪、也会有额外的 vendor hook所以类名、常量值、甚至调用顺序都可能有出入——这一点我在后面会给几种自己验证的方法你手上的源码才是最终答案。1.1 快照不是一张 Bitmap而是「图形 元数据 落盘文件」三件套很多人第一次接触这块代码会以为 TaskSnapshot 就是个Bitmap毕竟早期 API 里确实能看到getSnapshot()返回位图。但真正在跑的版本里图形数据早就换成了更省内存的形式AMS 抓到的是一份 GPU 侧可用的图形缓冲新版本走HardwareBuffer早期是Bitmap配套带上一组描述信息包括任务 ID、旋转角度、任务原始尺寸、内容 inset、色域、是不是降采样版本、是不是「真实截图」等。这些元数据不是可有可无的装饰它们决定了这张图在屏幕上以什么比例、什么裁剪、什么方向画出来。举个很常见的例子横屏应用切到后台你在竖屏的最近任务里看到的卡片依然是横向的而且比例正确、没有拉伸。这不是因为 Launcher 会猜而是因为快照里带了方向与尺寸信息展示时按元数据做等比缩放和裁剪。如果元数据丢了或者不匹配现象就很典型——卡片被拉扁、或者内容只显示左上角一小块。定位这类问题的第一步通常是先把 proto 文件拉下来看一眼元数据而不是先怀疑渲染代码。另一个容易忽略的点是「真实截图」这个标记。系统在某些情况下会抓到一份没有有效内容的缓冲比如 surface 还没提交、或者窗口禁止截屏此时标记会是 false配套可能是一张纯色或透明图。展示侧看到这个标记就会走「给个背景色/占位」的分支而不是硬画一张黑图。所以「最近任务里卡片一黑」的问题很多时候根因在抓图那一刻而不是在展示那一刻。1.2 为什么 Android 要存两份分辨率落盘目录里你会看到成对出现的图片文件一份是正常尺寸一份名字里带 reduced 后缀、尺寸和体积都明显更小。这不是冗余而是一个很朴素的性能取舍最近任务列表滚动的时候屏幕上同时有好多张卡片如果每张都按全分辨率解码内存和带宽都吃不消而当某张卡片被选中、要做放大动画进前台时又需要清晰度。于是策略就变成了「常态用低分辨率版本需要时再换成高分辨率版本」。这个设计有一个副作用值得做性能的同学记住滚动卡顿和「点开卡片瞬间闪一下」是两个不同的问题前者看低分辨率版本的大小与解码方式后者看高分辨率版本的加载时机和缓存命中率。我自己遇到过一次列表滑动明显掉帧最后发现是 reduced 版本的质量参数被改高了单张图体积翻了几倍解码线程排队。这种参数类的改动一定要用真实数据量级去过一遍而不是「看起来更清晰就好」。还有一点两份文件是「同一时刻的同一份内容」只是缩放和质量不同。如果两份看起来内容不一致比如一份是上上个界面那基本能断定写入过程中出现了时序问题或者读的时候两份来自不同的任务。这种情况在老版本上偶发排查时可以把两次写入的时间戳都打出来对照。2. AMS 侧TaskSnapshot 在什么时机被创建出来抓图这件事发生在系统服务里不在 App 进程也不在 Launcher 进程。触发点挂在「任务即将离开可见状态」这条路径上任务从前台退到后台、被停止、或者被移除时系统会走一遍判定决定这个任务要不要留一份快照。为什么选这个时机因为这个时候窗口内容还是完整的、渲染也刚结束能拿到最贴近用户记忆的画面。如果等任务彻底不可见之后再抓surface 可能已经被释放或者尺寸归零抓到的东西就没意义了。2.1 抓图时机被挂在「任务离开可见状态」这条路径上需要说清楚的是抓图不是「一退后台就立刻抓」。Android 12 之后 transition 相关的改造引入了统一的过渡框架动画的收尾和状态切换被拆成若干阶段抓图必须等到能够拿到有效缓冲的那一刻。抓早了buffer 还没提交拿到空内容抓晚了窗口已经进入不可见状态尺寸和缩放都可能变。所以实际代码里你会看到「抓图」这一步和过渡结束的收尾动作是耦合的这也是为什么在 T 上排查快照问题时日志里经常同时出现 transition 相关和 snapshot 相关的 TAG。我自己的经验是如果你在做自定义动画或者改过渡逻辑务必确认改动没有把抓图这一步挤掉。见过一次在收尾阶段提前做了资源释放导致抓图拿到的是一个尺寸正确但内容全黑的缓冲标记是 false界面上就退化成占位色块。表现很像「快照功能坏了」其实是时序被改了。2.2 一份判定清单哪些任务会被跳过判定逻辑分散在快照控制器、任务对象和 Activity 记录里不同版本的条件顺序会调整但大体上会覆盖下面这些维度。把它整理成清单排查「为什么这个任务没有快照」时非常好用。判定维度典型要求不满足时的现象任务类型只处理标准类型的任务Launcher/Home、最近任务自身、助手类任务通常跳过主屏界面没有快照属于预期行为窗口模式全屏单一窗口才会抓分屏、画中画、浮动窗口一般不抓分屏里的应用切后台没有卡片图可见性变化需要从可见变为不可见这一事件单纯刷新不会重复抓同一任务反复进出只有真正退后台那一次更新设备形态低内存设备默认关闭或大幅降级低端机上卡片长期是图标占位用户状态需要用户已解锁、CE 存储可写锁屏状态下退出应用重启后看不到旧卡片任务描述需要有可用的任务描述与组件信息异常任务显示空白卡片截屏许可窗口设置了禁止截屏标志时会被打码或不抓金融类应用在最近任务里显示空白这是有意为之表里最后一条特别值得说。很多同学第一次遇到「某个 App 在最近任务里永远是空白」会觉得是系统 bug实际上这是应用主动设置的安全标志生效了属于设计预期。真正需要关注的是你如果做的是系统侧定制改动了这块判定的优先级可能让本该被保护的窗口被抓出内容那就是安全事故级别的改动了上线前一定要过一遍安全用例。另外低内存设备的降级策略也值得注意。它不是简单的开关有些分支里会保留「记录元数据但不落图片」的能力表现就是卡片有正确的比例和标题但没有画面内容。所以看到「有框没图」时先确认设备是不是被判定为低内存而不是直接去查文件有没有写成功。2.3 从抓图到封装成 TaskSnapshot 的过程抓图本身依赖窗口层的截图能力把目标任务的图层按尺寸合成到一块缓冲里随后要做几件事按屏幕旋转和任务自身旋转算出最终方向记录原始任务尺寸算出内容 inset哪些是装饰性的边缘锁定色域信息然后把这堆东西打包成一个可跨进程传递的对象。AMS 侧还会维护一份任务到快照的映射供快速查询使用。// 示意代码抓图并封装的骨架字段名以本地源码为准 private void captureSnapshotIfNeeded(Task task) { if (!shouldSnapshot(task)) { // 类型、窗口模式、内存、用户状态等判定 return; } // 在窗口层合成目标任务的图层拿到图形缓冲 GraphicBuffer buffer captureLayers(task.getSurfaceControl()); if (buffer null || isEmpty(buffer)) { notifySnapshotChanged(task.mTaskId, null); // 抓不到就明确告知下游没有 return; } TaskSnapshot snapshot new TaskSnapshot( task.mTaskId, buffer, task.getRotation(), // 方向 task.getBounds(), // 任务原始尺寸 computeContentInsets(task), // 内容 inset colorSpaceOf(buffer), false /* isReduced */, true /* isRealSnapshot */); mCache.put(snapshot); notifySnapshotChanged(task.mTaskId, snapshot); // 通知已注册的监听方 persisterQueue.addItem(persistItem(task, snapshot)); }这里有个细节容易被忽略抓不到的时候代码不是「什么都不做」而是明确下发一个空快照通知。下游收到空表示「这个任务的快照不可用」要清掉旧的缓存并回退到占位显示。如果这一路通知丢了就会出现「任务已经变了卡片还显示旧图」的幽灵现象这是后面第 6 节要重点讲的一类故障。2.4 Android T 上多出来的应用侧开关在 T 上应用获得了一个更直接的表达方式可以主动声明「不要为我的界面生成最近任务快照」而不再需要依赖排除最近任务之类的间接手段。这个开关的出现解决了一个长期存在的困境——有些应用既要出现在最近任务里用户能切回来又不希望自己的界面内容被留一张图。以前只能靠安全标志或者干脆不进最近任务体验都很别扭。对系统侧同学的启示是判定清单里多了一条「应用是否显式关闭」。写调试脚本或者做自动化用例时如果发现某个应用死活没有快照先确认它是不是自己关掉了再去翻服务端日志。我见过有团队为了这个问题查了两天服务端代码最后发现是应用层一行调用。3. 落盘snapshots 目录里的文件长什么样快照最终会落到每个用户各自的存储目录下路径形态大致是/data/system_ce/userId/snapshots/。为什么放在 CE凭据加密空间而不是 DE因为快照内容属于用户数据锁屏未解锁时不应该可读这是隐私边界。理解了这一点就能理解为什么用户没解锁时抓图会被跳过——文件根本写不进去。3.1 文件名规则与配套文件一个任务的快照通常由三类文件组成理解它们的对应关系是排查的基础。文件作用缺失时的表现taskId.proto元数据任务信息、尺寸、旋转、inset、是否为真实截图等有图但无法正确摆放退化成占位taskId.jpg全分辨率图像需要清晰图时模糊或走占位taskId_reduced.jpg降采样图像供列表滚动使用列表阶段解码压力大或显示占位排查时我习惯先看数量关系三类文件数量是否大致匹配、有没有孤儿 proto、有没有只有图片没有元数据。孤儿 proto 通常意味着图片写入失败或中途被杀孤儿图片则意味着元数据被删但图片没删干净。这两种不一致都会导致加载侧走异常分支。还有一点图片用的是 JPEG 这种有损格式这是刻意的选择为的是控制体积和写入耗时。全分辨率与降采样版本的质量参数不同前者高、后者低。如果你在做体积优化调整这两个参数是最直接的手段但一定要配合真实场景量级去测——1080P 级别的任务画面全质量 JPEG 通常在几百 KB 量级降采样版本大致是面积的四分之一到三分之一这个经验值只能当量级参考具体随内容和质量参数浮动很大。3.2 proto 里的字段都代表什么元数据用 protobuf 序列化好处是增删字段兼容性好坏处是纯文本编辑器看不了得用工具解。字段结构大体上分成「任务身份」和「几何信息」两块签名示意如下简化过具体字段编号请以你本地的定义文件为准。// 简化示意仅用于说明字段语义 message SnapshotProto { message Task { int32 id 1; // 任务 ID // 组件名、用户 ID 等身份信息 } Task task 1; message WindowBounds { int32 left 1; int32 top 2; int32 right 3; int32 bottom 4; } WindowBounds window_bounds 2; // 任务窗口尺寸 int32 rotation 3; // 旋转方向 bool is_real_snapshot 4; // 是否为有效内容 // 内容 inset、色域、窗口模式等随版本增减 }is_real_snapshot是排错时最该看的字段。它为 false 时说明抓图当时没有拿到有效内容展示侧会回退到占位显示。所以「卡片是空的」这类问题判定链应该是先看这个字段再看图片文件是否存在最后才怀疑展示代码。顺序反了会绕远路。rotation和窗口尺寸要一起看。旋转值来自抓图时刻窗口尺寸也是那一刻的两者必须自洽。我遇到过横竖屏快速切换时抓住了一个「旋转值已更新、窗口尺寸还是旧的」的组合结果卡片被拉伸。这种问题是时序竞争靠加日志比靠读代码快得多。3.3 写磁盘不是同步的落盘走的是一个串行的写入队列带一点延迟批量提交。为什么要这么设计因为抓图可能短时间集中发生比如用户连续切好几个应用如果每次都同步写磁盘主线程方向的操作会被 I/O 拖住。排队加延迟能把多次小写入合并、也能错开高峰代价是「文件不是立刻出现」。这一点在排查时非常关键如果你在划掉任务后立刻去ls目录看到文件还在不一定代表删除逻辑坏了可能只是队列还没处理完。同理写入侧也有「写入失败就清理」的逻辑。磁盘空间不足、目录不可写、进程被杀都会导致半成品状态。所以一个成熟的排查流程里应该包含「等一小会儿再确认」这一步而不是看到中间态就下结论。4. 读取与展示从磁盘 proto 到屏幕上的那张卡片服务端把文件写好了接下来是消费侧的事。展示侧要做三件事知道有哪些任务、按需把对应快照读进来、在合适的时机把图换上去。这三件事分别对应任务列表的获取、快照的加载与缓存、以及视图层的切换。4.1 加载路径与内存缓存加载的逻辑大致是先查内存缓存命中就直接用没命中就去读 proto判断是不是真实截图、拿到尺寸旋转等信息再解码对应的图片文件列表场景优先用降采样版本最后封成一个内存中的快照对象放进缓存。缓存有容量上限超了就按最近最少使用的策略淘汰。这个上限不是越大越好因为一张全分辨率的图在内存里的占用远大于磁盘上的 JPEG缓存太大反而会挤压其他模块。一个实操建议在做内存问题定位时把「快照缓存占了多少」单独统计出来很有价值。我见过内存曲线呈阶梯上升的案例追下去是缓存对象里间接持有了全分辨率的图形缓冲而淘汰策略只按条目数算没按实际字节数算结果几条大图就把水位顶上去了。4.2 为什么缓存 key 不能只用 taskId这是我在实际项目里踩过的坑值得单独讲。缓存的 key 一般由任务 ID、窗口模式和用户 ID 共同构成而不是单纯的 taskId。原因是同一个任务 ID 在不同窗口模式下可能对应完全不同的画面尺寸和内容——同一个应用从全屏切到分屏任务 ID 可能没变但快照的几何信息全变了。如果只用 taskId 做 key就会出现「分屏卡片的图上叠着全屏的内容」这种错乱。这个坑的隐蔽之处在于它只在特定操作序列下复现全屏退出、再分屏进入、再退出。单次操作看不出问题所以一定要在自动化用例里覆盖多窗口之间的来回切换。另外要注意缓存淘汰和磁盘清理是两套机制缓存被淘汰不代表磁盘文件被删反过来也一样。排查时要把内存和磁盘分别确认。5. 移除流程谁在什么时候把文件删掉创建链路讲完了移除链路其实更考验理解。因为「删除」有多个触发源每一个走的路径、清理的范围都不一样混在一起想很容易乱。我把它整理成下面这张表先建立整体印象。触发源处理范围内存缓存磁盘文件用户划掉最近任务单个任务清删任务已结束但仍在最近列表单个任务保留保留用户切换 / 用户被清理该用户全部清整目录删系统内存压力全局或部分清保留设备重启按最近任务重建空只保留仍有效的任务对应文件磁盘空间不足变更清理视策略可能提前删旧文件5.1 任务被真正移除时缓存和磁盘一起删用户划掉一张卡片这个动作最终会走到任务移除逻辑然后由快照控制器执行「删磁盘」的变体。注意这里的关键词是「真正移除」调用了结束 Activity 的接口、但任务记录还留在最近列表里的情况快照是会保留的。这解释了一个常见疑问——为什么我明明把应用关掉了最近任务里还能看到它、点进去还能看到旧画面。因为任务没被移除只是结束了。想验证这条链路最直接的办法是划掉卡片后同时观察日志和目录日志里会有移除快照的记录目录里对应三个文件会在短暂延迟后消失。如果日志有记录、文件还在那就是写入队列或者删除任务出了问题如果日志压根没有记录那就说明移除动作没走到快照清理这一步方向要往任务移除逻辑那边查。5.2 幽灵文件与过期清理即使单个任务的删除路径都正常磁盘上依然可能残留文件原因是「任务列表变化」和「快照文件变化」是两条独立的线。为了收尾系统会在合适的时机做一次全集比对把当前仍有效的任务 ID 集合拿出来跟目录里的文件比对不在集合里的文件删掉。这个机制保证了最终一致但它不是实时的所以在比对发生之前你会看到短暂的不一致状态。这个「短暂不一致」在自动化测试里是老大难。很多测试写成了「划掉卡片后立刻断言文件不存在」结果在慢速设备上偶发失败。正确的做法是加轮询等待或者干脆断言「在合理时间内会消失」而不是瞬时断言。这条经验花了我不少时间才总结出来写在这里希望能帮你省点调试时间。5.3 用户切换、锁屏与内存压力下的回收用户维度的清理是整目录级别的。当用户被切换走、或者用户数据被清理时属于该用户的快照目录会被整体清掉。这一条对多用户设备很重要如果你在做多用户相关的功能测试用例必须覆盖「用户 A 产生的快照在切到用户 B 后不可见、切回来仍然可用」这种场景同时也要覆盖用户被删除后的清理。内存压力下的回收只动内存缓存不动磁盘。这个区分很重要因为它意味着「卡片图短暂变模糊或重新加载但不会消失」。用户感知上是「最近任务列表滚动时偶尔重新解码了一下」属于可接受范围。如果压力回收把磁盘也删了那体验就崩了——用户切回来发现卡片变成占位图会以为系统丢了东西。锁屏场景则要回到 CE 存储那条线上用户未解锁时快照不可读也不可写所以这段时间内新产生的任务不会有持久化快照重启后也就看不到。这个行为在安全上是正确的但如果你在做「重启后最近任务要有图」的验收就必须把解锁状态作为前置条件写进用例否则一定跑不通。6. 排查实战三件套定位法讲了这么多机制落到实操其实就一套组合拳日志、状态转储、文件系统。我把它叫做三件套因为它能覆盖绝大多数「快照不对」的问题而且不依赖任何额外工具。6.1 日志、状态转储、文件系统先看日志。抓图、落盘、加载、移除这几步都有对应的日志点过滤关键字即可# 抓图与移除相关的关键字实际 TAG 以本地代码为准 adb logcat -v threadtime | grep -iE snapshot|persister|recents再看文件系统。adb root # 查看当前用户的快照目录注意 CE 空间在未解锁时不可访问 adb shell ls -l /data/system_ce/0/snapshots/想确认 proto 内容可以拉下来用工具解adb pull /data/system_ce/0/snapshots/123.proto . protoc --decode_raw 123.proto--decode_raw不需要你自己有 proto 定义文件直接按字段编号打印看is_real_snapshot、旋转、尺寸这些值足够用了。这是我用得最多的一个小技巧尤其是在客户现场没有源码环境的时候。最后是状态转储用来看系统当前认为「哪些任务是最近的、当前有哪些快照缓存」。不同版本支持程度不一样通常用dumpsys activity系列命令配合关键字过滤就能拿到有用的信息。6.2 几类典型故障的排查链路现象优先怀疑第一步动作卡片全黑抓图时的真实截图标记为 false解 proto 看该字段卡片空白但比例正确图片文件缺失元数据还在检查三类文件是否齐全卡片显示旧内容空快照通知丢失缓存未失效在日志里找是否有下发空快照卡片比例错误旋转与尺寸不自洽对照 proto 的旋转与窗口尺寸卡片一直不出现判定清单里被跳过逐条核对类型、窗口模式、用户状态文件删了又回来写入队列延迟或任务仍在列表等一段时间后再确认这张表的价值在于「优先怀疑」这一列。很多同学一遇到问题就从展示代码查起而实际上展示侧只是消费方它的问题大多是「上游给了什么就显示什么」。按表里的顺序排查能省下大量时间。6.3 我踩过的坑和一个收尾建议说两个印象最深的。第一个是前面提过的缓存 key 问题只用 taskId 做 key在多窗口切换时出现图上叠图。这个问题的教训是凡是跟窗口几何相关的缓存key 里一定要带上窗口模式不能想当然地认为任务 ID 唯一。第二个是「空快照通知丢失」导致卡片显示旧内容。当时的现象是划掉一个应用后相邻卡片的图变成了刚划掉的那个应用。追下去发现是一个中间层在转发通知时做了「忽略空值」的过滤本意是减少无意义回调结果把「快照已失效」这个语义也一起过滤掉了。这个坑提醒我在设计回调协议时「空」往往是一个有意义的状态不能当成噪音。最后给一个实用建议如果你要在自己的设备上验证快照逻辑优先用「单一任务反复进出后台」这种最小场景把三类文件的出现与消失时间点全部打出来形成一条时间线。有了这条基准时间线后面遇到复合场景的异常你立刻就能判断是慢了一拍还是彻底没走。这比一上来就扑到复杂场景里高效得多。