知乎++Markdown渲染性能优化实战:滚动延迟341ms降到27ms,LaTeX公式与延迟布局全解
【免费下载链接】zhihu-plus-plusZhihu++ | 知乎++: Ad-free, low cost, AI powered zhihu android 3rd-party client. 去广告、占用低、AI大模型的新时代知乎安卓端体验项目地址: https://gitcode.com/gh_mirrors/zh/zhihu-plus-plus
知乎++(Zhihu++)是一个去广告、低占用、支持 AI 大模型的知乎第三方客户端。其中最能体现工程实力的,是它的 Markdown 渲染性能优化:在一篇包含 80 条最长公式的知乎真实压力文章中,首次向前滚动的中位延迟从 341.7ms 降到 27.7ms(-91.9%),普通 16 类场景的中位数全部低于 30ms。本文将带你完整看懂这套「LaTeX 公式异步准备 + 视口延迟布局」方案的思路与成果。
📄 核心资料:docs/markdown-renderer-performance-report.md 与 docs/markdown-issue-495-performance-report.md 记录了全部测试口径与数据。
优化前的问题:滚动为什么会卡?
卡顿的直接感受是:打开一篇公式密集的长回答,往下滚一下,页面「顿」一下才跟手。
根因排查后发现,问题不在解析,而在布局:
- LaTeX 组件只在后台解析,主线程仍同步测量公式。长文滚动时,每个新进入视口的公式都会触发一次主线程布局,滚出视口再进入还可能重复计算。
- 大量公式同时启动后台任务,占满 CPU,与输入、Compose 的 layout 和 draw 争抢调度。
- 全量 eager 布局一篇真实知乎回答(406 个 AST 节点、148 个公式节点),首帧耗时高达6,310ms。
其中独立公式块约占首帧时间的 49.6%,是最大的单项成本,但只优化公式仍会剩下约 3.18 秒——所以最终方案是「所有屏外块统一延迟布局」,公式自然被覆盖。
核心方案一:视口延迟布局(Deferred Layout)
这是本次优化的灵魂。核心实现位于 MarkdownLayouts.kt:
- 完整 AST 不裁剪:HTML 一次性转为完整文档结构,首帧即可用于脚注跳转、图片预览和标题导航,数据完整性不打折(AST 构建实测仅约 2.4ms~9.3ms,根本不是瓶颈)。
- 只有视口内的块才真实布局:基于
SubcomposeLayout,屏外块只生成一个「估高占位」,进入视口上下各1.5 个视口(普通正文为 0.5 个)的预取范围后,才调用真实渲染。 - 滚出预取范围即回收:块离开后恢复占位;已经真实布局过的块会记住实测高度,不再退回粗略估算,滚动位置和进度条不会跳动。
- 递归覆盖容器子块:一个 300 项的列表或 200 行的表格,子项也逐条延迟物化,不会出现「单个超长块被整体布局」。
一个巧妙细节:高度预估不靠猜。段落按中文字符宽度和换行估算行数,公式块按分式、根号、求和等高结构加成,代码块按行数估算。实测首帧预测的全文滚动范围 38,301px,与实际布局后的 37,955px 仅差0.91%,进度条从第一帧就准。
核心方案二:LaTeX 公式的后台准备与缓存
针对公式这个最大单项成本,latex-parser 与 latex-renderer 做了三层改造:
- 单一受限后台通道:解析和完整布局都放到一个受限制的后台 lane 完成,在解析与布局边界检查取消并主动让出,避免后台任务挤占 CPU。
- 页面级 LRU 缓存:每个 Markdown 文档持有可缓存活跃公式的 LRU,公式离屏回收后,滚回来时直接复用不可变的准备结果,不重复计算。
- 行内公式保守占位:先按保守尺寸占位,准备完成后只刷新该公式的真实尺寸,彻底删除了主线程的同步预测量。
配合这两套方案,压力文章双向各 40 个滚动样本全部低于 100ms,其中 79/80 低于 50ms。
优化前后数据一览 📊
以下数据来自相同的 80 条最长公式文章与相同的 700px 分步滚动边界(摘自 docs/markdown-renderer-performance-report.md):
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 首次向前滚动中位数 | 341.7 ms | 27.7 ms | -91.9% |
| 首次向前滚动 P90 | 523.1 ms | 41.2 ms | -92.1% |
| 返回滚动中位数 | 81.6 ms | 22.6 ms | -72.3% |
| 300 项列表首屏中位数 | 100.5 ms | 22.8 ms | -77.3% |
| 200 行表格首屏中位数 | 99.3 ms | 25.6 ms | -74.2% |
另外两个关键数字:
- issue #495 的 36,460 字符真实知乎 HTML,转完整 AST 中位数仅9.27ms——证明「先分阶段计时、找准瓶颈」的重要性;
- 3,703 条真实知乎公式的 parser-only 中位总耗时20.67ms。
被拒绝的方案:踩过的坑同样值得学
项目复盘文档(docs/markdown-issue-495-performance-report.md)诚实地列出了被否决的方向:
- ❌拆 HTML、逐段生成 AST:首帧看似降到 1.27s,但脚注表、图片预览、跨块结构都不完整,破坏数据契约;
- ❌只延迟公式块:独立公式块虽是最大成本,单独处理仍剩约 3.18s 首帧;
- ❌只保留已访问块、不回收:长文浏览越深,Compose 树越接近全量布局,内存和延迟随深度恶化;
- ❌保留可关闭的「eager 逃生开关」:开关留着就有误退回全量布局的风险,最终被删除,静态与可选择正文始终延迟布局。
如何验证:固定回归门 🧪
性能优化的最大敌人是「悄悄退化」。知乎++把回归门槛写进了测试(MarkdownPerformanceTest.kt):
- 全部稳定 JVM 样本低于 100ms,至少 70% 低于 50ms,全部场景中位数低于 30ms;
- 压力文章直接读取真实公式语料(
shared/src/jvmTest/resources/zhihu-formula-corpus/formulas.json),去重后按长度取最长 80 条,不使用缩短公式或预构造布局绕开瓶颈; - 功能回归覆盖公式真实像素、行内公式尺寸更新、缓存跨组合回收、脚注跳转与返回、300 项递归列表尾部物化等场景。
复现命令见 docs/markdown-renderer-performance-report.md 的「复现」章节,日常回归优先跑 Compose JVM 测试,真机 AVD 只做量级校准。
总结:三个可复用的性能思路
- 先分阶段计时,再动手:解析、组合、测量、布局、绘制各自归因,别优化错误阶段;
- 懒而不失完整:数据层完整 AST 首帧可用,渲染层按视口物化,兼顾功能与性能;
- 用实测数据自校准:占位高度只是起点,实测高度会持续回填预测,并让回收后的滚动体验保持丝滑。
这套方案让知乎++在保留文本选择、脚注跳转、真实公式绘制的前提下,把公式长文的滚动体验从「明显卡顿」提升到了「满帧跟手」。想深入了解实现细节,推荐直接阅读:
- 渲染布局核心:MarkdownLayouts.kt
- 应用侧渲染接入:RenderMarkdown.kt
- 公式解析性能测试:LatexParserPerformanceTest.kt
- 真实测试样本:issue-495-answer.html
【免费下载链接】zhihu-plus-plusZhihu++ | 知乎++: Ad-free, low cost, AI powered zhihu android 3rd-party client. 去广告、占用低、AI大模型的新时代知乎安卓端体验项目地址: https://gitcode.com/gh_mirrors/zh/zhihu-plus-plus
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考