字体 TTF 到 GPU 图像的完整渲染流程
从字体文件中的矢量轮廓,到屏幕上像素的完整链路。覆盖 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] │
│ 输出: 屏幕上的像素 │
└─────────────────────────────────────────────────────────────────┘
Phase 1: 文本排版 (Shaping)
目标
将 Unicode 字符串转换为字形 ID + 精确位置数组。这一步解决”用哪个字形、放在哪里”的问题。
调用链
TextView.onDraw(Canvas) [TextView.java:9314]
→ mLayout.draw(canvas) [Layout.java:793]
→ canvas.drawText(buf, start, end, x, y, paint) [Layout.java:959]
→ BaseCanvas.nDrawText() [JNI] [Canvas.java:2386]
→ Canvas::drawText() [hwui/Canvas.cpp:89]
→ MinikinUtils::doLayout() [MinikinUtils.cpp:76]
Minikin + HarfBuzz 内部流程
MinikinUtils::doLayout(paint, bidiFlags, typeface, text, ...)
│
├─ 1. ICU BiDi 分析
│ → 确定文本方向 (LTR/RTL/混合)
│ → 按方向分割成 BiDi Run
│
├─ 2. Script Run 分割
│ → 按 Unicode Script 分段 (Latin / CJK / Arabic / ...)
│ → 每段独立进入 HarfBuzz
│
├─ 3. 字体回退 (Font Fallback)
│ → 从字体链中选择能覆盖该字符的字体
│ → 例: "Hello你好" → Roboto(Hello) + NotoSansCJK(你好)
│
├─ 4. HarfBuzz Shaping
│ → 输入: Unicode 码点 + 字体
│ → 查 cmap 表: 码点 → glyph ID
│ → 查 GSUB 表: 字形替换 (连字、变体)
│ → 查 GPOS 表: 字形定位 (字距调整、标记附着)
│ → 输出: (glyphID, xOffset, yOffset, xAdvance) per glyph
│
└─ 5. 输出 minikin::Layout
→ nGlyphs(): 字形总数
→ getGlyphId(i): 第i个字形 ID
→ getX(i), getY(i): 相对基线位置
→ getFont(i): 使用的字体
关键数据转换
输入: "Hello你好" (Unicode 码点序列: U+0048 U+0065 U+006C U+006C U+006F U+4F60 U+597D)
↓
输出: [(glyph:43, x:0, y:0, font:Roboto),
(glyph:72, x:12.5, y:0, font:Roboto),
(glyph:79, x:22.3, y:0, font:Roboto),
(glyph:79, x:28.1, y:0, font:Roboto),
(glyph:82, x:33.9, y:0, font:Roboto),
(glyph:1247, x:44.0, y:0, font:NotoSansCJK),
(glyph:2891, x:68.0, y:0, font:NotoSansCJK)]
Phase 2: 录制 (Recording)
目标
将排版结果封装为 SkTextBlob,记入 DisplayList,等待 RenderThread 回放。
调用链
Canvas::drawText() [排版完成后]
→ DrawTextFunctor::operator()(start, end) [DrawTextFunctor.h:119]
→ canvas->drawGlyphs(glyphFunc, count, paint) [SkiaCanvas.cpp:850]
→ SkTextBlobBuilder::allocRunPos(font, count) 构建 TextBlob
→ glyphFunc(buffer.glyphs, buffer.pos) 填充 glyph IDs + 位置
→ builder.make() → sk_sp<SkTextBlob> 冻结为不可变对象
→ mCanvas->drawTextBlob(blob, 0, 0, paint)
→ DisplayListData::drawTextBlob() [RecordingCanvas.cpp:1786]
→ push<DrawTextBlob>(blob, x, y, paint) 写入 DisplayList
SkTextBlob 结构
SkTextBlob (不可变对象, 可跨帧复用)
├── Run 0: {SkFont(Roboto, 14sp), glyphs:[43,72,79,79,82], positions:[(0,0),(12.5,0),...]}
└── Run 1: {SkFont(NotoSansCJK, 14sp), glyphs:[1247,2891], positions:[(44,0),(68,0)]}
UI Thread 结束点
此时 UI Thread 的工作完成。文本以 DrawTextBlob Op 的形式冻结在 DisplayList 中,等待 RenderThread 处理。
Phase 3: 回放 + Op 创建 (Replay)
目标
RenderThread 回放 DisplayList,将 TextBlob 转换为 GPU Op (AtlasTextOp) 加入渲染队列。
调用链
DrawFrameTask::run() [DrawFrameTask.cpp:123]
→ syncFrameState() → prepareTree() 同步 DL 到 RenderThread
→ CanvasContext::draw() [CanvasContext.cpp:1137]
→ SkiaPipeline::renderFrame() [SkiaPipeline.cpp:379]
→ renderFrameImpl() [SkiaPipeline.cpp:464]
→ RenderNodeDrawable::drawContent() 回放 display list
→ DrawTextBlob::draw(SkCanvas* c) 回放 TextBlob Op
→ c->drawTextBlob(blob, x, y, paint)
→ SkCanvas::onDrawTextBlob() [SkCanvas.cpp:2886]
→ blobToGlyphRunList() TextBlob → GlyphRunList
→ topDevice()->drawGlyphRunList() 派发到 GPU Device
Skia GPU Device 处理
ganesh::Device::drawGlyphRunList() [Device.cpp:1771]
→ SurfaceDrawContext::drawGlyphRunList() [SurfaceDrawContext.cpp:375]
→ TextBlobRedrawCoordinator::drawGlyphRunList()
│
├─ 缓存查找: findOrCreateBlob(positionMatrix, glyphRunList)
│ → Key = (blob uniqueID, positionMatrix 2x2部分, paint属性)
│ → 命中: 复用已有 SubRun (快速路径, ~5us)
│ → 未命中: TextBlob::Make() → SubRunContainer::MakeInAlloc()
│
├─ SubRun 类型选择 (SubRunControl::isSDFT):
│ ├─ DirectMaskSubRun: 小字号, 位图方式, 精确像素对齐
│ ├─ SDFTSubRun: 中等字号, SDF 方式, 容忍 scale 变化
│ ├─ TransformMaskSubRun: 大字号, 固定大小光栅化 + GPU 变换
│ └─ PathSubRun: 超大字号, 矢量路径渲染
│
└─ atlasDelegate → AtlasTextOp::Make() 创建 GPU Op
→ SurfaceDrawContext::addDrawOp(op) 加入 OpsTask
SubRun 类型决策
字号 (approximateDeviceTextSize) :
│
├─ < 32px (且无 forceSDFT): DirectMask
│ → 以实际 CTM scale 光栅化, 像素完美
│ → 缺点: scale 变化 = 缓存失效
│
├─ 32px ~ 162px (或 forceSDFT=true): SDFT (Signed Distance Field Text)
│ → 以固定桶字号(32/72/162px)光栅化
│ → GPU shader 插值缩放
│ → 优点: 容忍 scale 变化, 缓存稳定
│
├─ > 162px: TransformMask
│ → 以固定大小光栅化, GPU 做缩放变换
│
└─ > 阈值: PathSubRun
→ 直接发送矢量路径到 GPU
Phase 4: 字形光栅化 (Glyph Rasterization)
目标
将 TTF 中的矢量轮廓转换为像素位图,上传到 GPU Atlas 纹理。这是性能瓶颈所在。
触发时机
在 GPU flush 阶段,OpsTask::execute() 调用各 Op 的 prepare:
OpsTask::execute()
→ AtlasTextOp::onPrepareDraws(target) [AtlasTextOp.cpp:481]
→ 遍历每个 geometry (每段文字)
→ GlyphData::regenerateAtlas() 核心光栅化入口
光栅化详细流程
AtlasTextOp::onPrepareDraws(GrMeshDrawTarget* target)
│
├─ 1. 获取 Atlas 管理器
│ GrAtlasManager* atlasManager = target->atlasManager()
│ const GrSurfaceProxyView* views = atlasManager->getViews(maskFormat)
│
├─ 2. 遍历每个字形
│ for (auto& geo : geometries) {
│ GlyphData::regenerateAtlas(subRunCursor, end, atlasManager, ...)
│ │
│ ├─ 2a. 在 Atlas 中查找该字形
│ │ GrAtlasLocator locator = glyph.atlasLocator()
│ │ if (atlasManager->hasGlyph(locator)) → 命中, 跳过光栅化
│ │
│ ├─ 2b. Atlas 未命中 → 触发光栅化
│ │ SkStrike* strike = strikeCache->findOrCreateStrike(descriptor)
│ │ │
│ │ │ ┌─── Strike 查找/创建 ───────────────────────────────┐
│ │ │ │ Key = SkDescriptor (由 SkScalerContextRec 序列化) │
│ │ │ │ 包含: typeface + textSize + fPost2x2 + flags + ... │
│ │ │ │ │
│ │ │ │ sk_relax(scale) → 量化到 1/1024 精度 → 进入 key │
│ │ │ │ fontVariationSettings → 进入 typeface fingerprint │
│ │ │ └──────────────────────────────────────────────────────┘
│ │ │
│ │ SkGlyph* glyph = strike->getGlyph(packedGlyphID)
│ │ if (glyph->image() == nullptr) {
│ │ // ★★★ 调用 FreeType 光栅化 ★★★
│ │ strike->generateImage(glyph)
│ │ }
│ │
│ └─ 2c. 上传到 Atlas 纹理
│ atlasManager->addGlyphToAtlas(glyph, ...)
│ → GrDrawOpAtlas::addToAtlas(width, height, imageData, locator)
│ → Plot::addSubImage() → 写入 CPU staging buffer
│ → 标记 Plot dirty → 后续 flush 时上传到 GPU
│ }
│
└─ 3. 填充顶点数据 (Phase 5 的输入)
FreeType 光栅化内部
SkScalerContext_FreeType::generateImage(glyph)
│
├─ 1. 加载字形轮廓
│ FT_Load_Glyph(face, glyphID, loadFlags)
│ → 从 TTF 文件读取贝塞尔曲线控制点
│ → 应用 hinting (网格适配)
│ → 应用 变换矩阵 (fPost2x2)
│
├─ 2. 光栅化为位图
│ FT_Render_Glyph(glyph, renderMode)
│ │
│ │ renderMode 选择:
│ │ ├─ FT_RENDER_MODE_NORMAL → A8 灰度 (最常用)
│ │ ├─ FT_RENDER_MODE_LCD → LCD 子像素 (水平 3x 过采样)
│ │ ├─ FT_RENDER_MODE_MONO → 1-bit 黑白
│ │ └─ FT_RENDER_MODE_SDF → SDF 距离场
│ │
│ └─ FreeType 内部:
│ → 将贝塞尔曲线分解为边 (decompose outline)
│ → 扫描线填充 (scanline rasterizer)
│ → 应用 Gamma 校正
│ → 输出: width × height 的像素数组
│
└─ 3. 后处理
→ Skia 应用 MaskFilter (如模糊)
→ Skia 应用 PreBlend (Gamma 预混合)
→ 写入 glyph->fImage buffer
光栅化耗时模型
单个字形光栅化耗时:
├─ Latin 字母 (简单轮廓, ~20个控制点): ~5-15 us
├─ CJK 汉字 (复杂轮廓, ~100-300个控制点): ~50-150 us
├─ Emoji (彩色位图, 需解码 PNG/CBDT): ~100-300 us
└─ 可变字体 (需实时插值轮廓): ~80-200 us
批量光栅化示例:
"充电中" (3个CJK) → 3 × 150us ≈ 450us
通知正文 (50个混合字符) → ~600us
锁屏时钟 "12:34" (5个数字) → 5 × 30us ≈ 150us
Phase 5: Atlas 纹理管理 + 顶点填充 + GPU 绘制
5.1 Glyph Atlas 纹理结构
GrDrawOpAtlas (字形纹理图集)
│
├── Page 0: GPU Texture (2048 × 1024, A8 格式)
│ ├── Plot[0] (512×256) ─── 内部用 RectanizerSkyline 打包字形
│ │ ├── 'A' (12×14 px) @ (0, 0)
│ │ ├── 'B' (11×14 px) @ (12, 0)
│ │ ├── '你' (24×24 px) @ (23, 0)
│ │ └── ... (按需填充)
│ ├── Plot[1] (512×256)
│ │ └── ...
│ └── Plot[N]
│
├── Page 1: (按需激活)
├── Page 2: (按需激活)
└── Page 3: (最多 4 页)
特点:
- 最多 4 页 × 2048×1024 = 8MB (A8格式)
- 每页分为多个 Plot, Plot 是驱逐的最小单位
- 使用 LRU Token 追踪 Plot 使用时间
- Atlas 满时: 驱逐最久未用的 Plot → 回收空间
5.2 字形上传到 Atlas
GrDrawOpAtlas::addToAtlas(width, height, imageData, &locator)
│
├─ 尝试 1: 在已有 Page 的 Plot 中找空间
│ for (page : activePages) {
│ if (page.uploadToPage(width, height, image, &locator)) → 成功
│ }
│
├─ 尝试 2: 激活新 Page (如果 < 4 页)
│ activateNewPage(resourceProvider) → 分配 GPU 纹理内存
│
├─ 尝试 3: 驱逐 LRU Plot (如果已达 4 页)
│ 找到 lastUseToken 最小的 Plot
│ processEvictionAndResetRects(plot) → 清空该 Plot
│ 重新上传到回收的 Plot 空间
│
└─ 失败: "Atlas full" → 触发 flush 并重试
上传方式:
→ CPU staging buffer 中拼装
→ flush 时批量上传到 GPU (Texture upload slice in trace)
→ Vulkan: vkCmdCopyBufferToImage
→ OpenGL: glTexSubImage2D
5.3 顶点数据填充
每个字形 → 一个四边形 (4 顶点 + 6 索引)
Mask2DVertex 结构 (最常用, 16 bytes/顶点):
┌──────────────────────────────────────────┐
│ devicePos (float x, y) │ 8 bytes │ 屏幕上的位置
│ color (uint32_t RGBA) │ 4 bytes │ 文字颜色
│ atlasPos (uint16_t u, v) │ 4 bytes │ Atlas 纹理中的 UV 坐标
└──────────────────────────────────────────┘
四个顶点覆盖字形的矩形区域:
LT ────── RT UV 从 Atlas 中采样对应字形位图
│ glyph │
LB ────── RB
填充过程:
VertexFiller::fillVertexData(vertices, offset, count, ...)
→ 遍历每个字形
→ 计算设备空间四角坐标 (应用 CreationMatrix → DrawMatrix 变换)
→ 从 GrAtlasLocator 获取 UV 坐标 (包含 Page 索引编码)
→ 写入顶点 buffer
5.4 GPU Draw Call 提交
AtlasTextOp::onExecute(GrOpFlushState* flushState) [AtlasTextOp.cpp:654]
│
├─ 1. 创建 Pipeline
│ GrPipeline = {BlendMode, RenderTarget, Stencil, ...}
│
├─ 2. 绑定 GeometryProcessor
│ ├─ GrBitmapTextGeoProc: 位图文本 (DirectMask)
│ │ → Vertex Shader: passthrough position + UV
│ │ → Fragment Shader: texture2D(atlas, uv) * color
│ │
│ └─ GrDistanceFieldA8TextGeoProc: SDF 文本
│ → Fragment Shader:
│ float distance = texture2D(atlas, uv).a
│ float alpha = smoothstep(0.5 - edge, 0.5 + edge, distance)
│ fragColor = vec4(color.rgb, color.a * alpha)
│
├─ 3. 绑定 Atlas 纹理
│ flushState->bindTextures(gp, atlas[pageIndex], pipeline)
│ (如果字形跨多页, 需要多次 draw call)
│
└─ 4. 提交 Indexed Draw
flushState->drawIndexed(indexCount, baseVertex, ...)
→ Vulkan: vkCmdDrawIndexed(indexCount, 1, firstIndex, vertexOffset, 0)
→ 6 indices per glyph × N glyphs = 一次批量绘制
完整数据流转图
TTF 文件中的 "你" (U+4F60)
│
│ [Phase 1: 排版]
│ HarfBuzz 查 cmap: U+4F60 → glyphID=1247
│ HarfBuzz 查 GPOS: advance=24, xOffset=0
▼
(glyphID=1247, x=44.0, y=0, font=NotoSansCJK)
│
│ [Phase 2: 录制]
│ 写入 SkTextBlob Run buffer
│ TextBlob → DrawTextBlob Op → DisplayList
▼
DisplayList[Op: DrawTextBlob(blob, 0, 0, paint)]
│
│ [Phase 3: 回放]
│ RenderThread replay → drawGlyphRunList
│ SubRunControl 判定: DirectMask (字号 < 32px)
│ TextBlobRedrawCoordinator: cache miss → 创建新 SubRun
│ AtlasTextOp::Make() → 加入 OpsTask
▼
OpsTask[AtlasTextOp(subRun=[glyphID=1247,...], matrix, paint)]
│
│ [Phase 4: 光栅化]
│ StrikeCache 查找: Descriptor(NotoSansCJK, 14sp, matrix=[1,0,0,1]) → miss
│ 创建 SkScalerContext_FreeType
│ FT_Load_Glyph(face, 1247, flags) → 加载贝塞尔轮廓
│ FT_Render_Glyph → 扫描线光栅化 → 24×24 A8 位图
│ atlasManager->addGlyphToAtlas → 写入 Page0/Plot2 @ (100, 50)
▼
Atlas 纹理 Page 0 中 (100, 50) 位置有 24×24 的 "你" 灰度位图
│
│ [Phase 5: 顶点 + 绘制]
│ 填充 4 个 Mask2DVertex:
│ LT: pos=(44, -24), uv=(100, 50)
│ LB: pos=(44, 0), uv=(100, 74)
│ RT: pos=(68, -24), uv=(124, 50)
│ RB: pos=(68, 0), uv=(124, 74)
│
│ GPU Draw: vkCmdDrawIndexed
│ → Vertex Shader: 变换到 NDC 坐标
│ → Fragment Shader: sample(atlas, uv) * textColor
▼
屏幕上 (44, baseline-24) ~ (68, baseline) 区域显示 "你" 的像素
缓存层级与失效条件
┌─────────────────────────────────────────────────────────────────────┐
│ 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 重录 + 排版)
性能关键路径总结
最佳情况 (所有缓存命中):
drawTextBlob replay → Slug cache hit → Atlas hit → 填充顶点 → draw
耗时: ~3-5 us
最差情况 (首次渲染 / 动画中 scale 每帧变化):
invalidate → DL 重建 → drawText → Minikin shaping → TextBlob 构建
→ Slug miss → SubRun 重建 → Strike miss → FreeType 逐字形光栅化
→ Atlas 上传 → 顶点填充 → draw
耗时: 600-3000 us (取决于字形数量和复杂度)
缓存的跨 TextView 复用能力
各层复用范围
| 层级 | 是否跨 TextView | 缓存归属 | 原因 |
|---|---|---|---|
| Level 1: MeasuredText | ❌ | Per-TextView | 每个 TextView 持有自己的 mLayout,排版结果绑定到具体文本内容 |
| Level 2: DisplayList | ❌ | Per-View (RenderNode) | 每个 View 有独立 RenderNode + DisplayList,TextBlob 录制在各自 DL 中 |
| Level 3: Slug/SubRun | ❌ | Per-TextBlob | Key 含 TextBlob.uniqueID,不同 TextView 产生不同 blob,ID 不同 |
| Level 4: StrikeCache | ✅ | 全局 (进程级) | Key = (typeface + textSize + fPost2x2 + flags),与哪个 TextView 无关 |
| Level 5: GrDrawOpAtlas | ✅ | 全局 (进程级) | Atlas 是共享 GPU 纹理,按 (Strike + glyphID) 索引,任何 View 可读取 |
跨 View 复用的条件
Level 4 (StrikeCache) 和 Level 5 (Atlas) 是全局共享的。只要满足以下条件,不同 TextView 的相同字形只需光栅化一次:
- 同一 Typeface (同一字体文件)
- 同一 TextSize (像素级相同)
- 同一 fPost2x2 矩阵 (
sk_relax(scaleX/Y)量化后相同) - 同一渲染 flags (hinting, subpixel, edging 等)
实际示例
TextView A: "你好世界" (NotoSansCJK, 14sp, matrix=[1,0,0,1])
→ Strike key = (NotoSansCJK, 14sp, fPost2x2=[1,0,0,1])
→ 光栅化 "你" "好" "世" "界" → 写入 Atlas
TextView B: "你好朋友" (NotoSansCJK, 14sp, matrix=[1,0,0,1]) ← 同字体同字号同 matrix
→ Strike key 相同 → Strike 命中!
→ "你" "好" → Atlas 命中 (直接复用 A 光栅化的结果, 0 开销)
→ "朋" "友" → Atlas miss → 只需光栅化这 2 个新字形
典型场景分析
场景 1: 通知列表 (多个 TextView 显示不同通知内容)
→ 字体/字号/matrix 相同 → Strike 共享
→ 第一条通知光栅化了常用汉字后, 后续通知的相同字形直接 Atlas hit
→ 实际耗时: 第1条 ~600us, 第2条 ~200us, 第3条 ~50us (递减)
场景 2: 同一帧中锁屏时钟 "12:34" + 状态栏时钟 "12:34"
→ 如果字体/字号不同 → 不同 Strike → 各自独立光栅化
→ 如果字体/字号相同 → 共享 Strike → 后者 0 光栅化开销
场景 3: 父容器有 scale=0.97 → 子 TextView 们共享同一个 CTM
→ 所有子 TextView 的 fPost2x2 相同 → 落入同一个 Strike → 字形互相复用
→ 但与 scale=1.0 时的 Strike 不同 → 那一套缓存用不上
Trace 中递减趋势的解释
100帧 AtlasTextOp 数据:
Frame 1: 1056us ← 首帧, 大量字形首次光栅化
Frame 5: 800us ← 部分字形已被其他 TextView 光栅化过, 复用
Frame 20: 600us ← 常用字形大多已缓存
Frame 50: 300us ← 只剩少量新字形
Frame 80: 50us ← 几乎全部 Atlas hit
原因: 同一帧或相邻帧中, 多个 TextView 共享 StrikeCache + Atlas
→ 先渲染的 View 为后渲染的 View "预热" 了缓存
一句话总结:Level 1-3 是 per-View 的私有缓存,Level 4-5 是进程级全局共享的。只要字体属性一致,不同 TextView 的相同字形只需光栅化一次,后续全部走 Atlas hit 快速路径。
源码文件索引
| 阶段 | 文件 | 关键函数/行号 |
|---|---|---|
| 排版 | hwui/hwui/Canvas.cpp | drawText() L89 |
| 排版 | hwui/hwui/MinikinUtils.cpp | doLayout() L76 |
| 排版 | hwui/hwui/DrawTextFunctor.h | operator() L119 |
| 录制 | hwui/SkiaCanvas.cpp | drawGlyphs() L850 |
| 录制 | hwui/RecordingCanvas.cpp | drawTextBlob() L1786 |
| 同步 | hwui/renderthread/DrawFrameTask.cpp | run() L123 |
| 回放 | hwui/pipeline/skia/SkiaPipeline.cpp | renderFrameImpl() L464 |
| SubRun | skia/src/text/gpu/SubRunContainer.cpp | MakeInAlloc() L1402 |
| SubRun | skia/src/text/gpu/SubRunControl.cpp | isSDFT() L71 |
| Blob 缓存 | skia/src/text/gpu/TextBlobRedrawCoordinator.cpp | findOrCreateBlob() L58 |
| Op 创建 | skia/src/gpu/ganesh/ops/AtlasTextOp.cpp | Make() L161 |
| 光栅化 | skia/src/gpu/ganesh/ops/AtlasTextOp.cpp | onPrepareDraws() L481 |
| Strike | skia/src/core/SkStrikeCache.cpp | findOrCreateStrike() |
| Strike Key | skia/src/core/SkScalerContext.cpp | MakeRecAndEffects() L1085 |
| sk_relax | skia/src/core/SkScalerContext.cpp | sk_relax() L1046 |
| FreeType | skia/src/ports/SkScalerContext_FreeType.cpp | generateImage() |
| Atlas | skia/src/gpu/ganesh/GrDrawOpAtlas.cpp | addToAtlas() |
| 顶点 | skia/src/text/gpu/VertexFiller.cpp | fillVertexData() |
| GPU 绘制 | skia/src/gpu/ganesh/ops/AtlasTextOp.cpp | onExecute() L654 |