ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android T TaskSnapshot 创建与移除全链路解析

Android T TaskSnapshot 创建与移除全链路解析 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管内存 LRUTaskSnapshotPersister管后台写盘和删盘TaskSnapshotLoader管从磁盘读回来。这么拆的直接收益有三个。第一是线程模型干净了Controller 只在 WMS 主线程持有mService.mGlobalLock里跑Persister 有自己的 HandlerThread两边靠队列交接主线程永远不会等 IO。第二是可测试性上来了缓存和持久化都能脱离 WindowManager 单独验证。第三是为后续在其他场景复用这套快照机制铺了路基类的存在本身就说明设计上留了口子。注意不同厂商分支在这块的改动幅度差别很大有些分支会为了省内存把内存缓存压得很小有些会加自己的降采样策略。看代码时先确认你手上的是 AOSP 主线还是定制分支。1.3 关键组件与数据结构速览在往下钻流程之前先把几个核心角色和数据字段过一遍后面的分析会反复用到。组件所在位置职责TaskSnapshotControllersystem_serverwm 包决定何时拍、拍谁构造 TaskSnapshotTaskSnapshotCache同上内存 LRU保存最近任务的快照对象TaskSnapshotPersister同上独立线程后台写盘、删盘串行处理队列TaskSnapshotLoader同上从磁盘读回位图与元数据ActivityManager.TaskSnapshotframework 层跨进程传递的快照载体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|GLdumpsys 输出里重点看三块内存缓存里当前有哪些 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 的独立线程上完成主线程只做队列交接。我见过为了简化流程把写盘挪到主线程的分支表现是系统在高频切换任务时出现明显掉帧而且在低端机上更严重回滚成本很高。第二条是任何新增的快照字段都要考虑序列化兼容。磁盘上可能躺着上个版本写下的元数据文件新版本读到老格式时如果直接抛异常会导致升级后一段时间内所有快照都加载失败。稳妥的做法是给新字段一个安全的默认值让老数据也能解析出可用的结果哪怕信息不全。最后分享一个我个人在实际排查中形成的习惯遇到快照相关的诡异问题先把问题归类到没生成生成了但没存住存住了但读不出来读出来了但被错误清理这四种状态中的一种然后再顺着对应的链路去看。这套分类比按现象分类更接近系统实际的执行路径定位速度会快不少。这个模块后续还可以往自动化验证的方向扩展写个脚本定时去比对内存缓存和磁盘文件的一致性能提前发现很多潜在问题。
返回列表