无密码解锁/上划锁屏场景 - DrawFrames 4267789 — 锁屏充电文案 AtlasTextOp 深度分析报告
结论
现象:DrawFrames 30978143 帧中单个 AtlasTextOp 耗时 5.2ms(占帧时间 24.4%),导致丢帧

后面每帧也会有KeyguardIndicationTextView AtlasTextOp耗时

根因:
| View | 根因 | 贡献耗时 | 修复优先级 |
|---|---|---|---|
| KeyguardIndicationTextView | doScale() → setScaleX/Y(0.999636) CTM scale 分量变化 → sk_relax 量化后 fPost2x2 跳变 → Strike Descriptor 变化 → 整个 Strike cache miss → 所有字形重新光栅化; | 20.877ms | P0 |
| TextView (HybridNotificationView) | SharedNotificationContainer 在面板联动中被 doScale(0.97) → 无 HW Layer → CTM scale 导致 Strike miss → 50个字形全量重光栅化 | 16.786ms | P0 |

修复建议:
- ★★★★★ 方案 A(setForceSDFT2):动画期间对
KeyguardBottomAreaView调用View.setForceSDFT(true),动画结束恢复 false。已 Frida 验证 0 帧高耗时,动画效果完整保留。 - 方案 B(Hardware Layer 保护):备选,动画期间设置
LAYER_TYPE_HARDWARE。效果略差(2 帧残留)。 - SharedNotificationContainer — 在
sncTransitionAlpha动画期间临时启用 hardware layer 或 setForceSDFT2
预期收益:帧绘制时间减少 ~0.37ms/帧(15.4%),全 trace 节省 37.7ms。90 FPS 场景下帧时间从 ~2.4ms 降至 ~2.0ms。
基本信息
| 项目 | 值 |
|---|---|
| Trace 文件 | perfetto_1780036731.pftrace |
| 目标帧 | DrawFrames 30978143 |
| 进程 | com.android.systemui (PID: 8543) |
| 帧耗时 | 25.27ms |
| 场景 | 锁屏面板滑动动画(NotificationPanelView expand/collapse) |
| 帧率 | 90.9 FPS (平均帧间隔 11ms) |
| Trace 总时长 | 1099.8ms (101 帧) |
现象:应用上划首帧三个比较长的op耗时,单次耗时5ms左右 下载链接

STEP 1: 目标帧 AtlasTextOp 统计
帧内 AtlasTextOp 分布
| AtlasTextOp slice | 耗时(ms) | 阶段 | 父 onPrepare opid |
|---|---|---|---|
| id=4977 | 0.606 | onPrepare opid:2711 | 字形光栅化 |
| id=4557 | 0.144 | onPrepare opid:2684 | 字形光栅化 |
| id=4955 | 0.064 | onPrepare opid:2694 | 字形光栅化 |
| id=4597 | 0.010 | onExecute opid:2684 | GPU 绘制命令 |
| id=5094 | 0.006 | onExecute opid:2694 | GPU 绘制命令 |
| id=5114 | 0.004 | onExecute opid:2711 | GPU 绘制命令 |
总计: 6 个 slice,0.833ms
帧结构耗时分解
DrawFrames 4267789 (7.979ms)
├── syncFrameState (0.412ms)
│ └── prepareTree 1 (0.380ms)
├── Drawing (7.431ms)
│ ├── renderFrame (5.108ms)
│ │ ├── RootRenderNode (0.377ms) ← View 遍历
│ │ ├── drawLayer [FrameLayout] (0.190ms) ← 时钟 hardware layer
│ │ ├── drawLayer [KeyguardClockContainer] (0.092ms)
│ │ ├── flush layers (3.692ms) ← GPU 命令提交
│ │ ├── renderFrameImpl (1.076ms) ← 主渲染实现
│ │ └── Vulkan finish frame (1.951ms) ← 包含 AtlasTextOp!
│ └── flush commands (1.953ms)
│ └── GrRenderTask::prepare (0.661ms)
│ └── OpsTask::onPrepare opid:2711 (0.608ms) ← 最大 AtlasTextOp
└── queueBuffer (0.312ms)
STEP 2: AtlasTextOp → View 定位
TOP3 opid 完整层级追踪
opid:2711 — 0.608ms(最大)
DrawFrames 4267789
└── Drawing
└── renderFrame
└── renderFrameImpl
└── RootRenderNode
└── NotificationShadeWindowView
└── SharedNotificationContainer
└── NotificationStackScrollLayout
└── ExpandableNotificationRow
└── NotificationChildrenContainer
└── ExpandableNotificationRow
└── NotificationContentView
└── HybridNotificationView
└── TextView ← 通知标题文字
opid:2684 — 0.146ms
DrawFrames 4267789
└── Drawing
└── renderFrame
└── drawLayer [FrameLayout] ← hardware layer 内
└── FrameLayout
└── AllInOneMinuteClock
└── ConstraintLayout
└── ClassicTextAreaView ← 时钟日期文字
opid:2694 — 0.066ms
DrawFrames 4267789
└── Drawing
└── renderFrame
└── renderFrameImpl
└── RootRenderNode
└── NotificationShadeWindowView
└── HyperOSKeyguardRootView
└── KeyguardBottomAreaView
└── LinearLayout
└── KeyguardIndicationTextView ← 锁屏底部提示文字
全 Trace View 耗时统计
| View | 高耗时帧数(/101) | 总耗时(ms) | 单次最大(ms) | Hardware Layer |
|---|---|---|---|---|
| KeyguardIndicationTextView | 55 | 20.877 | 1.056 | ❌ 无 |
| TextView (通知标题) | 28 | 16.786 | 0.733 | ❌ 无 |
| ClassicTextAreaView | 1 | 0.146 | 0.146 | ✅ drawLayer |
| MiuiClock | 1 | 0.081 | 0.081 | ✅ drawLayer |
STEP 3: 前后帧对比与动画识别
帧间 AtlasTextOp 对比
| 帧 | AtlasTextOp 数 | 总耗时(ms) | 最大单次(ms) | 备注 |
|---|---|---|---|---|
| frame[-2] 4267686 | 4 | 0.045 | 0.016 | 正常 |
| frame[-1] 4267730 | 4 | 0.031 | 0.012 | 正常 |
| frame[0] 4267789 | 6 | 0.833 | 0.606 | 突增! |
| frame[+1] 4267803 | 2 | 0.648 | 0.643 | 持续高 |
| frame[+2] 4267825 | 2 | 0.694 | 0.689 | 持续高 |
| frame[+3] 4267847 | 4 | 0.692 | 0.606 | 持续高 |
关键发现:从 frame[0] 开始 AtlasTextOp 突然从 <0.05ms 跳到 >0.6ms,且后续帧持续高耗时。
场景确认:锁屏面板滑动动画
NotificationPanelViewController$$ExternalSyntheticLambda2每帧触发 (54次)draw:NotificationPanelView/draw:KeyguardPanelView/draw:HyperOSKeyguardRootView每帧执行alpha caused saveLayer每帧 4 个(KeyguardClockContainer/FrameLayout/KeyguardInfoLayerView/NotificationNumStateView)- 目标帧 View 数量突增:frame[-1] 73 unique views → frame[0] 105 unique views(+32)
交替模式分析
KeyguardIndicationTextView 呈现 高低交替 模式:
- 高帧(~0.6ms):只有 KeyguardIndicationTextView 触发
- 低帧(~0.07ms):KeyguardIndicationTextView 和 TextView 同时触发
说明两个 View 的字形有部分重叠(如相同字体大小的汉字),先光栅化的一方会为后者提供 atlas 缓存。chain heads 交替(1/2)的原因:不是 Strike hit/miss 的交替,而是不同 View 被重绘的频率不同 — SharedNotificationContainer 的 transitionAlpha 动画与 KeyguardBottomAreaView 的 scale 动画驱动频率存在差异。
根因分析(Frida 已验证)
4.1 KeyguardIndicationTextView — 双路径驱动 AtlasTextOp cache miss
根因判定:面板上划动画期间,updateKeyguardElementsExpansionInternal 同一调用链的两个分支同时作用于 KeyguardBottomAreaView,缺一不可:
| # | 路径 | 效果 | 来源函数 |
|---|---|---|---|
| 1 | doScale() → setScaleX/Y(0.999636) | CTM scale 分量精确值每帧变化 → SubRun canReuse()=false(精确比较 scaleX)→ SubRun 重建 → SkStrikeSpec::MakeMask(positionMatrix) → sk_relax(scale) 量化到 1/1024 精度写入 fPost2x2 → Strike Descriptor 变化 → Strike cache miss → 所有字形重新光栅化 | updateKeyguardElementsExpansionInternal |
| 2 | setScaleX/Y + setTransitionAlpha(0.994926) | setScaleX/Y 设置 SCALE_X dirty flag → pushStagingPropertiesChanges → damageSelf() → damage region 传播 → 子树区域被标脏 → 渲染时触发文字重绘(注:promotedToLayer 仅在 hasOverlappingRendering=true 时触发自动 layer 提升,此时不会 replay display list;真正驱动重绘的是 scale 变化产生的 damage) | updateKeyguardElementsExpansionInternal |

完整触发路径(Frida 堆栈确认):
用户上划手势 (TouchEvent)
→ NotificationPanelViewController$TouchHandler.handleTouch$1
→ setExpandedHeightInternalAosp
→ NotificationShadeWindowControllerImpl.batchApplyWindowLayoutParams
→ updateExpansionAndVisibility
→ updateKeyguardElementsExpansion
→ KeyguardPanelViewController.updateKeyguardElementsExpansionInternal
├── doScale()
│ → KeyguardBottomAreaView.setScaleX(0.999636) ← 每帧微变
│ → KeyguardBottomAreaView.setScaleY(0.999636)
│ → [View.java:20175] invalidateViewProperty + mRenderNode.setScaleX
│ → [RenderProperties.h:374] mMatrixOrPivotDirty = true
│ → [RenderNode.h:165] setPropertyFieldsDirty(SCALE_X)
│ → [RenderNode.cpp:665] pushStagingPropertiesChanges
│ → damageSelf() × 2 (旧/新 bounds)
│ → 子树区域被标脏 → 触发文字重绘
│ 渲染时:
│ → [SubRunContainer.cpp:1530] SkStrikeSpec::MakeMask(positionMatrix)
│ → [SkScalerContext.cpp:1106] fPost2x2[0][0] = sk_relax(scaleX)
│ → sk_relax: round(x*1024)/1024.0f [精度 1/1024 ≈ 0.000977]
│ → Strike Descriptor 变化 → Strike cache miss
│ → 所有 ~50 个字形重新光栅化 → AtlasTextOp.onPrepare() ~0.6ms
│
└── EffectivelyVisibleFlowLayout.setTransitionAlpha(0.994926)
→ ViewGroup.setTransitionAlpha → View.setTransitionAlpha
→ mRenderNode.setAlpha(getFinalAlpha())
→ mPrimitiveFields.mAlpha = 0.927409
→ (hasOverlappingRendering=true 时) promotedToLayer → 自动 layer
→ alpha 变化也产生 damage,协同 scale 确保子树每帧被重绘
为什么两条路径缺一不可:
- 仅 Scale(路径 1):scale 变化导致 Strike Descriptor 变化 → 字形需要重光栅化。同时
setScaleX会设置SCALE_Xdirty flag →damageSelf→ 子树区域被标脏 → 触发重绘。实际上仅 scale 变化就同时产生了 “cache miss” 和 “触发重绘” 两个效果。 - 仅 TransitionAlpha(路径 2):
mAlpha变化产生 damage → 子树被重绘 → 但如果 scale 固定,CTM 不变 → SubRuncanReuse()=true→ 复用已有 SubRun → Strike key 不变 → 字形缓存命中 → AtlasTextOp 耗时极低 - 两者同时:scale 变化确保每帧 Strike miss(cache 失效)+ scale/alpha 双重 damage 确保子树每帧被重绘(触发 AtlasTextOp 执行)→ 每帧 cache miss → 600us 光栅化开销
Frida 验证”缺一不可”的修正解释:
- 禁用 Scale 后 AtlasTextOp 仍存在(证据 6):这是因为 alpha 变化仍然产生 damage 驱动重绘,但此时 scale 固定 → Strike key 不变 → 字形缓存命中 → 耗时从 ~600μs 降至极低(仅 display list replay 开销)
- 同时禁用 Scale+Alpha 后仅 2 帧残留(证据 7):两者都不变 → 无 damage → 子树不重绘 → AtlasTextOp 不执行
Frida 验证证据链:
| # | 证据 | 证明了什么 |
|---|---|---|
| 1 | [SCALE_X] KeyguardBottomAreaView.setScaleX(0.999636) 每帧调用 | 路径 1: 父级 scale 每帧变化 |
| 2 | [setTransitionAlpha] KeyguardBottomAreaView.setTransitionAlpha(0.994926) 每帧调用 | 路径 2: transitionAlpha 每帧变化 |
| 3 | KeyguardPanelViewController.doScale() 在调用栈中 | 由面板展开动画驱动 |
| 4 | KeyguardIndicationTextView 无 invalidate/setAlpha/setTranslationY 输出 | View 自身无属性变化,纯粹被父级连坐 |
| 5 | promotedToLayer: mPrimitiveFields.mAlpha = 0.927409 在 prepareTree 中 | 路径 2 在 RenderThread 层面的体现 |
| 6 | 禁用 Scale 后 AtlasTextOp 仍存在 | 证明 Scale 不是唯一根因 |
| 7 | 同时禁用 Scale+Alpha 后仅 2/100 帧残留 | 证明两条路径缺一不可 |
Trace 交叉验证:
- 91/103 帧触发 → 与上划手势持续时间匹配(面板展开期间每帧 doScale + setTransitionAlpha)
- 耗时递减趋势 → 动画后期 scale 变化速率减小(缓动曲线),部分帧
sk_relax未跳变 → Strike 命中 - 高低交替模式(chain heads 在 1 和 2 之间交替)→ 不是 Strike hit/miss 的交替,而是哪些 View 被重绘的交替:奇帧只有 KeyguardIndicationTextView,偶帧额外包含通知 TextView(SharedNotificationContainer alpha 动画驱动频率不同)
- KeyguardIndicationTextView 本身无 Java 层属性变化 → 纯粹被父级 scale+alpha 连坐
机制详解:为什么 atlas 缓存无法命中
Skia GPU 文字渲染使用两级缓存:
Level 1: Strike (SkStrike)
Key = SkScalerContextRec (包含 typeface, textSize, fPost2x2[2x2矩阵], flags...)
一个 Strike = 特定变换下的全部字形集合
Level 2: Glyph (SkPackedGlyphID)
Key = (glyphID, 2-bit subpixelX, 2-bit subpixelY)
在同一 Strike 内,按亚像素位置区分
Scale 变化导致 cache miss 的完整链路(Strike 级别 miss):
setScaleX/Y()更新RenderNode的 transform matrix →SCALE_Xdirty → damage → 子树重绘- SubRun 复用检查:
VertexFiller::CanUseDirect()精确比较positionMatrix.getScaleX()→ 每帧 scale 都不同 →canReuse=false→ SubRun 重建 - 重建时调用
SkStrikeSpec::MakeMask(positionMatrix)→MakeRecAndEffects()将 CTM 的 scale 分量通过sk_relax()量化后写入SkScalerContextRec.fPost2x2 sk_relax(x) = round(x * 1024) / 1024.0f(精度 1/1024 ≈ 0.000977)- Scale 从 1.0 → 0.999636 → 0.999272… 当每帧变化量 > 0.000977 时,每帧
sk_relax值跳到新档位 → Strike Descriptor 变化 - 新的 Strike Descriptor 在缓存中不存在 → Strike miss → 该 Strike 下所有字形都需要重新光栅化
- ~50 个中文字形 × ~12μs/字形 ≈ 600μs
关键区别:这是 Strike 级别的 miss(整个字体配置不匹配),不是 glyph 级别的 subpixel position miss。后者只影响个别字形(2-bit 量化仅 4 种状态),不会导致 ~600μs 的整体开销。
为什么单独禁用 Scale 不够:
- 禁用 scale 后 CTM scale 分量固定 →
canReuse()=true→ SubRun 复用 → Strike key 不变 → 字形缓存命中 - 但 transitionAlpha 仍每帧变化 → alpha dirty → damage → 子树区域被标脏 → AtlasTextOp 仍被执行
- 此时虽然 Strike 命中(字形不需要重光栅化),但 display list replay + glyph 查找本身仍有少量开销(~几十 μs)
- 这解释了 Frida 验证中”禁用 Scale 后 AtlasTextOp 仍存在但耗时大幅降低”的现象
4.2 TextView (HybridNotificationView) — hasOverlappingRendering=false 导致 AlphaFilterCanvas 逐元素重绘
根因判定: MIUI 强制 hasOverlappingRendering()=false + 面板 transitionAlpha 渐变 → AlphaFilterCanvas 遍历子树逐元素重绘
触发路径:
KeyguardPanelViewController.updateKeyguardElementsExpansionInternal(fraction)
└─ [KeyguardPanelViewController.kt:4391]
notificationInteractor.updateKeyguardInfoLayerAlpha(alpha)
└─ [NotificationInteractorImpl.kt:35]
sncTransitionAlpha = minOf(keyguardInfoLayerAlpha, blurRatioAlpha, keyguardPanelMoveAlpha)
└─ [SharedNotificationContainerBinder.kt:271]
view.transitionAlpha = alpha (0 < alpha < 1)
└─ [View.java:20549] invalidateViewProperty → mRenderNode.setAlpha(getFinalAlpha())
└─ [RenderNodeDrawable.cpp:639-642]
// because !properties.getHasOverlappingRendering()
AlphaFilterCanvas alphaCanvas(canvas, alphaMultiplier)
displayList->draw(&alphaCanvas) ← 逐元素重绘整棵子树
└─ [GPU] AtlasTextOp for each text glyph
关键证据:
| # | 文件 | 行号 | 内容 | 证明了什么 |
|---|---|---|---|---|
| 1 | SharedNotificationContainerBinder.kt | 275-277 | forceNoOverlappingRendering = state == KEYGUARD | Keyguard 状态强制关闭 overlapping |
| 2 | SharedNotificationContainer.kt | 131-133 | hasOverlappingRendering() = !injector.forceNoOverlappingRendering | 容器级别关闭 |
| 3 | ExpandableView.java | 748-754 | hasOverlappingRendering() { return false; } // MIUI MOD | MIUI 修改通知行始终返回 false |
| 4 | AlphaOptimizedLinearLayout.java | 47-49 | hasOverlappingRendering() { return false; } | HybridNotificationView 父类 |
| 5 | RenderNodeDrawable.cpp | 639-667 | if (alphaMultiplier < 1.0f) { AlphaFilterCanvas ... displayList->draw() } | hwui 逐元素重绘路径 |
| 6 | RenderProperties.h | 650 | mHasOverlappingRendering → promotedToLayer | 不会被自动提升为 layer |
Trace 交叉验证:
- 28/101 帧触发 → 面板动画 alpha 从 0→1 约 460ms / 16.6ms ≈ 28 帧,完美匹配
- 与 KeyguardIndicationTextView 交替出现 → 两者受不同动画属性驱动
- 帧间隔约 2 帧一次(奇帧出现) → sncTransitionAlpha 更新频率
基础基础知识
https://mi.feishu.cn/docx/ID0nd4qY9otgyrxrWiLccARonue
从字体文件中的矢量轮廓,到屏幕上像素的完整链路。覆盖 Android Framework → HWUI → Skia → GPU 全栈。
全链路总览
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ TTF/OTF 字体文件 │
│ ┌─────────────────────────────────────────────────────────────────────────────────┐│
│ │ 存储内容: 矢量轮廓(贝塞尔曲线控制点) + 排版表(GSUB/GPOS/kern/cmap) ││
│ │ 比喻: 每个字符是一张 SVG (可缩放矢量图), 不是像素位图 ││
│ └─────────────────────────────────────────────────────────────────────────────────┘│
└──────────────────────────────────────────┬──────────────────────────────────────────┘
│
┌────────────────────────────────┼────────────────────────────────┐
│ ▼ │
│ Phase 1: 文本排版 (Shaping) │
│ Unicode 字符 → Glyph IDs + 位置 │
│ [HarfBuzz + Minikin + ICU BiDi] │
│ 输出: (glyphID, x, y) 数组 │
├─────────────────────────────────────────────────────────────────┤
│ ▼ │
│ Phase 2: 录制 (Recording) │
│ Glyph 数组 → SkTextBlob → DisplayList │
│ [UI Thread, HWUI RecordingCanvas] │
│ 输出: DisplayListData 中的 DrawTextBlob Op │
├─────────────────────────────────────────────────────────────────┤
│ ▼ │
│ Phase 3: 回放 + Op 创建 (Replay) │
│ DisplayList → SubRun 分类 → AtlasTextOp │
│ [RenderThread, Skia Ganesh Device] │
│ 输出: OpsTask 中排队的 AtlasTextOp │
├─────────────────────────────────────────────────────────────────┤
│ ▼ │
│ Phase 4: 字形光栅化 (Glyph Rasterization) │
│ 矢量轮廓 → 像素位图 → GPU Atlas 纹理 │
│ [RenderThread, FreeType + StrikeCache + GrDrawOpAtlas] │
│ 输出: Atlas 纹理中的字形位图区域 │
├─────────────────────────────────────────────────────────────────┤
│ ▼ │
│ Phase 5: 顶点填充 + GPU 绘制 (Vertex Fill + Draw) │
│ Atlas UV + 位置 → 四边形顶点 → GPU Draw Call │
│ [RenderThread → GPU, Vulkan/GL] │
│ 输出: 屏幕上的像素 │
└─────────────────────────────────────────────────────────────────┘缓存层级与失效条件
┌─────────────────────────────────────────────────────────────────────┐
│ Level 1: Java MeasuredText / StaticLayout │
│ 缓存内容: 排版结果 (glyph IDs + positions) │
│ 失效条件: setText() / setTextSize() / 字体变化 │
│ 命中时: 跳过 HarfBuzz shaping │
├─────────────────────────────────────────────────────────────────────┤
│ Level 2: DisplayList (SkTextBlob) │
│ 缓存内容: 冻结的 TextBlob Op │
│ 失效条件: View.invalidate() → mRecreateDisplayList=true │
│ 命中时: 跳过整个 Phase 2 (不重录 DL) │
├─────────────────────────────────────────────────────────────────────┤
│ Level 3: TextBlobRedrawCoordinator (Slug) │
│ 缓存内容: SubRun (已分类的字形组) │
│ 失效条件: positionMatrix 2x2 部分变化 (精确浮点比较!) │
│ 命中时: 跳过 SubRun 重建 │
├─────────────────────────────────────────────────────────────────────┤
│ Level 4: StrikeCache (字形缓存) │
│ 缓存内容: 已光栅化的字形位图 │
│ 失效条件: Strike Descriptor 变化 (textSize/fPost2x2/typeface/...) │
│ → sk_relax(scale) 跳变 (1/1024 精度) │
│ → fontVariationSettings 变化 │
│ 命中时: 跳过 FreeType 光栅化 (最大节省) │
├─────────────────────────────────────────────────────────────────────┤
│ Level 5: GrDrawOpAtlas (GPU 纹理) │
│ 缓存内容: 字形位图在 GPU 纹理中的位置 │
│ 失效条件: Atlas generation 变化 (Plot 被驱逐) │
│ 命中时: 只需填充顶点, 无 CPU→GPU 上传 │
└─────────────────────────────────────────────────────────────────────┘
性能差距:
全部命中: AtlasTextOp ~3-5us (仅顶点填充 + draw call)
Level 4 miss: AtlasTextOp ~100-600us (FreeType 光栅化 + Atlas 上传)
Level 3 miss: 额外 ~20-50us (SubRun 重建)
Level 2 miss: 额外 ~50-200us (DL 重录 + 排版)STEP 5: 修复方案
方案 A:setForceSDFT2 — SDF 文字渲染(★★★★★ 推荐方案)
来源:底层 Framework 团队提供,在 NotificationStackScrollLayout 的面板动画 beginAnimator/completeAnimator 中已实现。
原理
传统光栅化 vs SDF 渲染的核心区别:
传统 DirectMask 光栅化
─────────────────────────────
缓存层级:
Level 1: Strike key = (typeface, textSize, fPost2x2[sk_relax(CTM scale)], flags...)
Level 2: Glyph key = SkPackedGlyphID(glyphID, 2-bit subpixelX, 2-bit subpixelY)
SubRun 复用: 精确比较 positionMatrix 的 2x2 部分(scaleX/Y, skewX/Y)
→ 任何 scale 微变 → canReuse=false → SubRun 重建
Strike 命中: sk_relax(scale) 量化到 1/1024 精度后比较
→ 每帧 scale 变化 > 0.000977 → 每帧 Strike Descriptor 变化 → Strike miss
→ 整个 Strike 下所有字形重新光栅化 ~600us
SDF (Signed Distance Field) 渲染
─────────────────────────────────
缓存层级:
Strike key = (typeface, dfMaskSize[固定值:32/72/162], identity matrix, ...)
→ 字体 size 固定,不含 CTM scale → 与动画无关
SubRun 复用: SDFTMatrixRange::matrixInRange() 范围检查(非精确比较)
→ scale 只要在 [floor/textSize, ceil/textSize] 范围内就复用
→ 动画中 scale 微变远不会超出范围 → SubRun 始终复用
结果: Strike key 固定 → 始终 cache hit → ~3us
→ SDFT2 是 Skia 的第二代 SDF 实现,改进了小字号清晰度
SDF 不受 scale 影响的根本原因:getSDFFont() 将字体 size 固定为预定义值(32/72/162px),并设置 dfFont.setSubpixel(false)。实际的缩放在 GPU shader 中通过距离场插值实现 → Strike Descriptor 与 CTM 的 scale 完全解耦 → 动画期间 Strike 永远命中。
因果链对比
修复前 (DirectMask 路径):
setScaleX(0.999636) + setTransitionAlpha(0.994926)
→ SCALE_X dirty → damage → 子树区域标脏 → 触发重绘
→ SubRun canReuse() = false (精确比较 scaleX 失败)
→ SubRun 重建 → MakeMask(positionMatrix)
→ fPost2x2[0][0] = sk_relax(0.999272) = 0.999023 ≠ 上帧 1.0
→ Strike Descriptor 变化 → Strike cache MISS
→ 所有 ~50 个字形重新光栅化 → ~600us
修复后 (setForceSDFT2=true, SDF 路径):
setScaleX(0.999636) + setTransitionAlpha(0.994926)
→ SCALE_X dirty → damage → 子树区域标脏 → 触发重绘(仍然发生)
→ SubRun canReuse(): SDFTMatrixRange::matrixInRange() 范围检查通过
→ SubRun 复用 → Strike key = (typeface, dfMaskSize=72, identity) 不变
→ Strike cache HIT → 字形全部在缓存中 → ~3us
实现代码
// 文件: NotificationStackScrollLayout.kt (面板展开/折叠动画)
// 位置: ViewAnimatorListener
object : ViewAnimatorListener {
override fun beginAnimator() {
startedAnimSize++
ambientState.panelExpansionCount++
// ★ 动画开始时启用 SDF 文字渲染
NotificationUtil.setForceSDFT2(view, true)
// 同时对通知行启用 Hardware Layer(解决第二个根因)
if (panelAnimSessionActive
&& view is ExpandableNotificationRow
&& panelAnimLayerViews.add(view)) {
view.privateLayout?.setLayerType(View.LAYER_TYPE_HARDWARE, null)
view.attachedChildren?.let {
it.forEach { child ->
child.privateLayout?.setLayerType(View.LAYER_TYPE_HARDWARE, null)
}
}
}
}
override fun completeAnimator() {
// ★ 动画结束时恢复普通文字渲染
NotificationUtil.setForceSDFT2(view, false)
executedExpansionAnimCount.inc()
ambientState.panelExpansionCount--
}
}setForceSDFT2 内部机制
传统 LCD/Grayscale 光栅化:
glyph_key = (glyphID, fontSize, subpixelX, subpixelY)
→ 不同亚像素位 = 不同 key = cache miss = 重新光栅化 ~600us
SDF (Signed Distance Field) 渲染:
glyph_key = (glyphID, fontSize) ← 无亚像素位置!
→ scale/translate/alpha 变化不影响 key = 始终 cache hit = ~3us
源码位置:NotificationUtil.java:314
public static void setForceSDFT2(View view, boolean isForceSDFT) {
if (view == null) return;
try {
Class viewCl = Class.forName("android.view.View");
Method setForceSDFT = viewCl.getMethod("setForceSDFT", boolean.class);
setForceSDFT.invoke(view, isForceSDFT);
} catch (Exception e) {
Log.d(TAG, "ViewRootImpl setForceSDFT2 Method not found!");
}
}调用点:MiuiNotificationPanelAnimControllerImpl.kt
- 第 245 行:
beginAnimator()→NotificationUtil.setForceSDFT2(view, true) - 第 248 行:
completeAnimator()→NotificationUtil.setForceSDFT2(view, false) - 第 331 行:
beginAnimator()(changeVisible) →NotificationUtil.setForceSDFT2(view, true) - 第 335 行:
completeAnimator()(changeVisible) →NotificationUtil.setForceSDFT2(view, false)

Change-Id: Idfa8c8faefcd8fdb7d778c0c55fb917bf2458d66 Signed-off-by: zhouzhong zhouzhong@xiaomi.com
android.view.View 调用实现
/**
* @hide
* set true if app wants to use "SDFT" font render method in scale animation.
*/
public void setForceSDFT(boolean forceSDFT) {
Log.d(VIEW_LOG_TAG, "View setForceSDFT: " + forceSDFT);
mForceSDFT = true;
}
完整调用链(Java → JNI → Skia Native)
NotificationUtil.setForceSDFT2(view, true)
→ [反射] View.setForceSDFT(true) // View.java:20502
→ mForceSDFT = true
→ 子 View draw() 时继承: mPreForceSDFT = parent.getForceSDFT() // View.java:27025
→ TextView.onDraw(): mTextPaint.setForceSDFT(getForceSDFT()) // TextView.java:9418
→ Paint.setForceSDFT(true) // Paint.java:2082
→ [JNI] nSetForceSDFT → SkPaint.setForceSDFT(true) // Paint.cpp:1196
同时通过路径 B (RenderThread 全局):
→ ViewRootImpl.setForceSDFT(true)
→ HardwareRenderer.nSetForceSDFT(true) // HardwareRenderer.java:807
→ [JNI] RenderProxy::setForceSDFT(true) // android_graphics_HardwareRenderer.cpp:1305
→ CanvasContext::setForceSDFT(true) // CanvasContext.h:305
→ mRenderPipeline->setForceSDFT(true) // CanvasContext.cpp:1349
→ SkiaPipeline::mForceSDFT = true // SkiaPipeline.h:195
→ canvas->setForceSDFT(true) // SkiaVulkanPipeline.cpp:108
Skia 渲染时判断:
TextBlobRedrawCoordinator.cpp:48
→ if (canvas->getForceSDFT()) { skPaint.setForceSDFT(true); }
SubRunControl.cpp:74,86-87
→ const bool forceSDFT = matrix.hasScale() && paint.getForceSDFT();
→ 强制使用 SDF 渲染路径(绕过 minDistanceFieldFontSize/maxDistanceFieldFontSize 限制)
SubRunControl.cpp:91-142 (getSDFFont):
→ dfFont.setSize(dfMaskSize) // 固定为 32/72/162 之一
→ dfFont.setSubpixel(false) // 关闭亚像素定位
→ Strike key = (typeface, dfMaskSize, identity) — 与 CTM scale 完全解耦!
SubRunControl.cpp:145-148 (SDFTMatrixRange::matrixInRange):
→ SubRun 复用条件: fMatrixMin < maxScale <= fMatrixMax (范围检查,非精确比较)
→ 动画期间 scale 微变不超出范围 → SubRun 始终复用 → Strike 永远命中
MIUI Feature Tag: SF_BugFixUpstream_3
方案 B:动画期间 Hardware Layer 保护(★★★★ 备选方案)
原理:Scale + TransitionAlpha 均属于”外部属性动画”(View 自身不 invalidate)。设置 Hardware Layer 后,属性变化仅触发 GPU 纹理变换(缩放 + alpha 混合),子树内容不重绘。
// 文件: KeyguardPanelViewController.kt
// 位置: updateKeyguardElementsExpansionInternal() 调用处
private var mBottomAreaLayerEnabled = false
private fun ensureBottomAreaLayer(expanding: Boolean) {
val bottomArea = mKeyguardBottomArea ?: return
if (expanding && !mBottomAreaLayerEnabled) {
bottomArea.setLayerType(View.LAYER_TYPE_HARDWARE, null)
mBottomAreaLayerEnabled = true
} else if (!expanding && mBottomAreaLayerEnabled) {
bottomArea.setLayerType(View.LAYER_TYPE_NONE, null)
mBottomAreaLayerEnabled = false
}
}为什么有效:
- Hardware Layer 缓存
KeyguardBottomAreaView子树渲染结果为 GPU 纹理 setScaleX/Y变化 → GPU 对纹理做 scale 变换,不重绘内容setTransitionAlpha变化 → GPU 对纹理做 alpha 混合,不重绘内容KeyguardIndicationTextView不再每帧执行 display list → AtlasTextOp 消除
方案对比
| 维度 | 方案 B (Hardware Layer) | 方案 A (setForceSDFT2) |
|---|---|---|
| 解决层面 | 避免子树重绘(GPU 纹理变换) | 允许重绘,但 Strike key 与 CTM scale 解耦 → 消除 Strike miss |
| 对 scale 有效 | ✓(纹理缩放) | ✓(SDF key 不含位置) |
| 对 transitionAlpha 有效 | ✓(纹理混合) | ✓(SDF key 不含位置) |
| 对 self-invalidate 有效 | ✗(layer 被 dirty) | ✓(即使重绘也命中) |
| 内存开销 | ~1-2MB GPU 纹理 | 无额外内存 |
| 文字质量 | 不变 | SDF 小字号可能略模糊(SDFT2 已改善) |
| 适用范围 | 仅外部属性动画 | 所有动画场景 |
| 副作用 | 动画期间 View 内容更新延迟一帧 | 无 |
| 实现复杂度 | 低(加 layer 管理) | 低(调用 setForceSDFT2) |
| 已有基础设施 | 无 | 底层已提供 NotificationUtil API |
为什么方案 A 更优:
- 更通用:即使有文字内容更新
setText()触发 invalidate 导致 SubRun 重建,SDF 模式的固定 Strike key 仍然命中(字形已在 atlas 中) - 同时覆盖两个根因:对 KeyguardIndicationTextView(scale→Strike miss)和通知 TextView(AlphaFilterCanvas→SubRun 重建→Strike miss)均有效
- 底层已验证:systemui 团队已在 NotificationStackScrollLayout 中使用该方案
- SDF SubRun 复用更宽容:
SDFTMatrixRange::matrixInRange()做范围检查(非精确比较),SubRun 不会每帧重建
Trace 实测验证数据
验证结论(2025-05-29 确认)
| 指标 | 修复前 (perfetto_1780060169) | 禁用 Scale+Alpha (perfetto_1780061672) | 方案 A: setForceSDFT2 (perfetto_1780062764) |
|---|---|---|---|
| KV AtlasTextOp 每帧耗时 | 70~750μs(高低交替) | ≤51μs(2帧残留 395μs) | ≤14.5μs(全部缓存命中) |
| KV 高耗时帧 (>200μs) | 8/20 帧 | 2/100 帧 | 0/47 帧 |
| KV 全 trace 总耗时 | ~20.9ms | ~0.8ms | ~0.4ms |
| 动画视觉效果 | ✓ 完整 | ✗ 无缩放+淡出 | ✓ 完整保留 |
| scale/alpha 动画 | ✓ | ✗ 被禁用 | ✓ 不禁用 |
验证 1:禁用 Scale + TransitionAlpha
验证 trace: perfetto_1780061672.pftrace,使用 Frida hook hook_disable_scale_and_alpha.js 同时禁用:
KeyguardBottomAreaView.setScaleX/Y→ 固定 1.0KeyguardBottomAreaView.setTransitionAlpha→ 固定 1.0
结果:100 帧中仅 2 帧残留高耗时(395μs),其余 97 帧 ≤51μs。残留为偶发文字内容更新(setText)触发的首次绘制。
副作用:上划时底部区域无缩放 + 无淡出效果。
验证 2:setForceSDFT2 — SDF 文字渲染(★ 推荐方案)
验证 trace: perfetto_1780062764.pftrace,使用 Frida hook hook_force_sdft2.js:
- 检测到
setScaleX < 1.0或setTransitionAlpha < 1.0→View.setForceSDFT(true) - 两者都恢复到 1.0 →
View.setForceSDFT(false) - 不禁用 scale/alpha,动画效果完整保留
结果:47 帧全部 ≤14.5μs,0 帧超过 200μs。比禁用 scale+alpha 更彻底(0 残留 vs 2 帧残留)。
| 对比 | 禁用 scale+alpha | setForceSDFT2 |
|---|---|---|
| 高耗时帧 | 2/100 (max 395μs) | 0/47 |
| 动画效果 | ✗ 无缩放+淡出 | ✓ 完整保留 |
| 原理 | 阻止 damage 产生 → 不触发重绘 | 允许重绘但 SDF Strike key 与 CTM scale 解耦 → Strike 永远命中 |
残留问题:通知 TextView 的 AtlasTextOp 仍存在(来自 SharedNotificationContainer transitionAlpha → AlphaFilterCanvas),需在 NotificationStackScrollLayout 动画中也调用 setForceSDFT2(底层已实现,见方案 F 代码)。
Scale不一定会导致耗时,Scale 导致 AtlasTextOp 高耗判定决策树
Scale 导致 AtlasTextOp 高耗时?
│
├─ ① scale 是否在变化(每帧不同)?
│ └─ 否 → ❌ 不产生耗时(固定 scale 只有首帧光栅化,后续帧缓存命中)
│
├─ ② sk_relax(scale) 是否跳到新档位?(1/1024 精度量化)
│ └─ 否 → ❌ Strike key 相同,缓存命中
│
├─ ③ 该 View 是否有 HW Layer 保护?
│ └─ 是 → ❌ Layer 缓存子树渲染结果,scale 作为 GPU transform 应用于 texture
│
├─ ④ 是否开启了 forceSDFT?
│ └─ 是 → ❌ SDF 模式用固定字号光栅化,GPU shader 做缩放,Strike key 不含 scale
│
├─ ⑤ 该 View 是否在这一帧的 damage region 中(即是否被渲染)?
│ └─ 否 → ❌ 不参与渲染,不触发 AtlasTextOp
│
└─ ①②③④⑤ 全部满足 → ✅ 产生高耗时(每帧全量字形重光栅化)动画类型与文字渲染的关系矩阵
| 动画类型 | 实现方式 | 是否 invalidate | DL 重建 | AtlasTextOp | 每帧开销 |
|---|---|---|---|---|---|
| ObjectAnimator(ALPHA) | RenderNode.setAlpha | NO | NO | 无 | ~0 |
| ObjectAnimator(TRANSLATION_X/Y) | RenderNode.setTranslationX/Y | NO | NO | 无 | ~0 |
| ObjectAnimator(SCALE_X/Y) | RenderNode.setScaleX/Y | NO | NO | 无 | ~0 |
| ViewPropertyAnimator | 同 ObjectAnimator | NO | NO | 无 | ~0 |
| TranslateAnimation | Canvas matrix | YES | YES | 每帧产生 | 0.3-1.0ms |
| AlphaAnimation | Canvas alpha | YES | YES | 每帧产生 | 0.3-1.0ms |
| ScaleAnimation | Canvas scale | YES | YES | 每帧产生 | 0.3-1.0ms |
| ValueAnimator + setTextSize | Paint.textSize 变化 | YES | YES | 每帧新 Strike | 0.6-1.5ms |
| ValueAnimator + setAlpha (legacy) | onSetAlpha=true | YES | YES | 每帧产生 | 0.3-1.0ms |
| Folme (MIUI) | RenderNode property | NO | NO | 无 | ~0 |
DirectMask、SDFT、SDFT2三种对比
目前字体的渲染有3种DirectMask、SDFT、SDFT2 他们的对比如下
模式对比
| 维度 | Default (DirectMask) | SDFT (forceSDFT via ViewRootImpl) | SDFT2 (forceSDFT via View) |
|---|---|---|---|
| 来源 | AOSP 原生 | MIUI 扩展 (第一版) | MIUI 扩展 (第二版,当前推荐) |
| 作用域 | 默认行为 | 整个窗口 (ViewRootImpl 级) | 单个 View 及其子树 |
| API | 无 (默认) | ViewRootImpl.setForceSDFT(true) | View.setForceSDFT(true) |
| SystemUI 调用 | — | NotificationUtil.setForceSDFT(view, true) | NotificationUtil.setForceSDFT2(view, true) |
| 光栅化方式 | 按实际 CTM scale 光栅化为位图 | 按固定桶字号光栅化为距离场 | 同 SDFT |
| 缩放方式 | 无缩放 (1:1 像素映射) | GPU shader 插值缩放 | 同 SDFT |
| Atlas 格式 | A8 (灰度覆盖率) | A8 (有符号距离值) | 同 SDFT |
| 传递路径 | — | Path B: ViewRootImpl→RenderProxy→CanvasContext→Canvas | Path A: View.mForceSDFT→子View继承→Paint→Skia |
属性变化 × 模式 → 是否 Cache Miss
| 属性变化 | Default | SDFT / SDFT2 | 差异倍数 |
|---|---|---|---|
| Scale 动画 (每帧 delta > 1/1024) | ❌ 每帧全 miss | ✅ 始终 hit | 200x |
| Rotation 动画 | ❌ 每帧全 miss | ✅ 始终 hit | 200x |
| 非整数 Translate | ⚠️ SubRun miss (低开销) | ✅ hit | 5x |
| 整数 Translate | ✅ hit | ✅ hit | 1x |
| TextSize 动画 (桶内) | ❌ 每帧全 miss | ✅ 桶内 hit | 200x |
| TextSize 动画 (跨桶) | ❌ 每帧全 miss | ⚠️ 跨桶 miss | 1x |
| 字重 (wght) 动画 | ❌ 每帧全 miss | ❌ 每帧全 miss | 1x (无效!) |
| fontVariationSettings 变化 | ❌ miss | ❌ miss | 1x (无效!) |
| 字体切换 (setTypeface) | ❌ miss | ❌ miss | 1x (无效!) |
| 纯 Alpha 变化 | ✅ hit | ✅ hit | 1x |
| setText() 内容变化 | ❌ DL 重建 + 新字形 miss | ❌ 同样 miss (但已缓存字形可复用) | 1x |
更详细的对比