无密码解锁/上划锁屏场景 - DrawFrames 4267789 — 锁屏充电文案 AtlasTextOp 深度分析报告

结论

现象:DrawFrames 30978143 帧中单个 AtlasTextOp 耗时 5.2ms(占帧时间 24.4%),导致丢帧

后面每帧也会有KeyguardIndicationTextView AtlasTextOp耗时

根因

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

修复建议

  1. ★★★★★ 方案 A(setForceSDFT2):动画期间对 KeyguardBottomAreaView 调用 View.setForceSDFT(true),动画结束恢复 false。已 Frida 验证 0 帧高耗时,动画效果完整保留。
  2. 方案 B(Hardware Layer 保护):备选,动画期间设置 LAYER_TYPE_HARDWARE。效果略差(2 帧残留)。
  3. 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左右 下载链接

O8_0523周版本验证系统流畅度分析


STEP 1: 目标帧 AtlasTextOp 统计

帧内 AtlasTextOp 分布

AtlasTextOp slice耗时(ms)阶段父 onPrepare opid
id=49770.606onPrepare opid:2711字形光栅化
id=45570.144onPrepare opid:2684字形光栅化
id=49550.064onPrepare opid:2694字形光栅化
id=45970.010onExecute opid:2684GPU 绘制命令
id=50940.006onExecute opid:2694GPU 绘制命令
id=51140.004onExecute opid:2711GPU 绘制命令

总计: 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
KeyguardIndicationTextView5520.8771.056❌ 无
TextView (通知标题)2816.7860.733❌ 无
ClassicTextAreaView10.1460.146✅ drawLayer
MiuiClock10.0810.081✅ drawLayer

STEP 3: 前后帧对比与动画识别

帧间 AtlasTextOp 对比

AtlasTextOp 数总耗时(ms)最大单次(ms)备注
frame[-2] 426768640.0450.016正常
frame[-1] 426773040.0310.012正常
frame[0] 426778960.8330.606突增!
frame[+1] 426780320.6480.643持续高
frame[+2] 426782520.6940.689持续高
frame[+3] 426784740.6920.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,缺一不可:

#路径效果来源函数
1doScale()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
2setScaleX/Y + setTransitionAlpha(0.994926)setScaleX/Y 设置 SCALE_X dirty flag → pushStagingPropertiesChangesdamageSelf() → 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_X dirty flag → damageSelf → 子树区域被标脏 → 触发重绘。实际上仅 scale 变化就同时产生了 “cache miss” 和 “触发重绘” 两个效果。
  • 仅 TransitionAlpha(路径 2)mAlpha 变化产生 damage → 子树被重绘 → 但如果 scale 固定,CTM 不变 → SubRun canReuse()=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 每帧变化
3KeyguardPanelViewController.doScale() 在调用栈中由面板展开动画驱动
4KeyguardIndicationTextView 无 invalidate/setAlpha/setTranslationY 输出View 自身无属性变化,纯粹被父级连坐
5promotedToLayer: 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):

  1. setScaleX/Y() 更新 RenderNode 的 transform matrix → SCALE_X dirty → damage → 子树重绘
  2. SubRun 复用检查:VertexFiller::CanUseDirect() 精确比较 positionMatrix.getScaleX() → 每帧 scale 都不同 → canReuse=false → SubRun 重建
  3. 重建时调用 SkStrikeSpec::MakeMask(positionMatrix)MakeRecAndEffects() 将 CTM 的 scale 分量通过 sk_relax() 量化后写入 SkScalerContextRec.fPost2x2
  4. sk_relax(x) = round(x * 1024) / 1024.0f(精度 1/1024 ≈ 0.000977)
  5. Scale 从 1.0 → 0.999636 → 0.999272… 当每帧变化量 > 0.000977 时,每帧 sk_relax 值跳到新档位 → Strike Descriptor 变化
  6. 新的 Strike Descriptor 在缓存中不存在 → Strike miss → 该 Strike 下所有字形都需要重新光栅化
  7. ~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

关键证据:

#文件行号内容证明了什么
1SharedNotificationContainerBinder.kt275-277forceNoOverlappingRendering = state == KEYGUARDKeyguard 状态强制关闭 overlapping
2SharedNotificationContainer.kt131-133hasOverlappingRendering() = !injector.forceNoOverlappingRendering容器级别关闭
3ExpandableView.java748-754hasOverlappingRendering() { return false; } // MIUI MODMIUI 修改通知行始终返回 false
4AlphaOptimizedLinearLayout.java47-49hasOverlappingRendering() { return false; }HybridNotificationView 父类
5RenderNodeDrawable.cpp639-667if (alphaMultiplier < 1.0f) { AlphaFilterCanvas ... displayList->draw() }hwui 逐元素重绘路径
6RenderProperties.h650mHasOverlappingRendering → 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 https://gerrit.pt.mioffice.cn/c/platform/packages/apps/MiuiSystemUI/+/5010744/15/packages/SystemUI/miui/Notification/src/com/android/systemui/shade/MiuiNotificationPanelAnimControllerImpl.kt

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 更优

  1. 更通用:即使有文字内容更新 setText() 触发 invalidate 导致 SubRun 重建,SDF 模式的固定 Strike key 仍然命中(字形已在 atlas 中)
  2. 同时覆盖两个根因:对 KeyguardIndicationTextView(scale→Strike miss)和通知 TextView(AlphaFilterCanvas→SubRun 重建→Strike miss)均有效
  3. 底层已验证:systemui 团队已在 NotificationStackScrollLayout 中使用该方案
  4. 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.0
  • KeyguardBottomAreaView.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.0setTransitionAlpha < 1.0View.setForceSDFT(true)
  • 两者都恢复到 1.0 → View.setForceSDFT(false)
  • 不禁用 scale/alpha,动画效果完整保留

结果:47 帧全部 ≤14.5μs,0 帧超过 200μs。比禁用 scale+alpha 更彻底(0 残留 vs 2 帧残留)。

对比禁用 scale+alphasetForceSDFT2
高耗时帧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

└─ ①②③④⑤ 全部满足 产生高耗时(每帧全量字形重光栅化)

动画类型与文字渲染的关系矩阵

动画类型实现方式是否 invalidateDL 重建AtlasTextOp每帧开销
ObjectAnimator(ALPHA)RenderNode.setAlphaNONO~0
ObjectAnimator(TRANSLATION_X/Y)RenderNode.setTranslationX/YNONO~0
ObjectAnimator(SCALE_X/Y)RenderNode.setScaleX/YNONO~0
ViewPropertyAnimator同 ObjectAnimatorNONO~0
TranslateAnimationCanvas matrixYESYES每帧产生0.3-1.0ms
AlphaAnimationCanvas alphaYESYES每帧产生0.3-1.0ms
ScaleAnimationCanvas scaleYESYES每帧产生0.3-1.0ms
ValueAnimator + setTextSizePaint.textSize 变化YESYES每帧新 Strike0.6-1.5ms
ValueAnimator + setAlpha (legacy)onSetAlpha=trueYESYES每帧产生0.3-1.0ms
Folme (MIUI)RenderNode propertyNONO~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→CanvasPath A: View.mForceSDFT→子View继承→Paint→Skia

属性变化 × 模式 → 是否 Cache Miss

属性变化DefaultSDFT / SDFT2差异倍数
Scale 动画 (每帧 delta > 1/1024)❌ 每帧全 miss✅ 始终 hit200x
Rotation 动画❌ 每帧全 miss✅ 始终 hit200x
非整数 Translate⚠️ SubRun miss (低开销)✅ hit5x
整数 Translate✅ hit✅ hit1x
TextSize 动画 (桶内)❌ 每帧全 miss✅ 桶内 hit200x
TextSize 动画 (跨桶)❌ 每帧全 miss⚠️ 跨桶 miss1x
字重 (wght) 动画❌ 每帧全 miss❌ 每帧全 miss1x (无效!)
fontVariationSettings 变化❌ miss❌ miss1x (无效!)
字体切换 (setTypeface)❌ miss❌ miss1x (无效!)
纯 Alpha 变化✅ hit✅ hit1x
setText() 内容变化❌ DL 重建 + 新字形 miss❌ 同样 miss (但已缓存字形可复用)1x

更详细的对比

https://mi.feishu.cn/docx/XYNAdySaEoOM57xcHhmcscTUnLd