资讯详情

Android数学公式渲染优化:从WebView到预排版原生自绘

📅 2026/11/3 15:06:58 | 华诺云谱 👁 阅读
Android数学公式渲染优化:从WebView到预排版原生自绘
第一篇里我把公式塞进 WebView用一套 JavaScript 排版引擎把标记语言转成 HTML 画出来跑通那天确实挺爽。三天后测试同学甩过来一段录屏题库列表往下滑公式区域先是一片空白手指停下来之后才慢慢补上来帧率面板上红条一片。那一刻我就知道第一篇的活儿只算做完了一半——Android 展示数学公式这件事难点从来不在能不能显示出来而在能不能像普通文字一样顺畅地显示出来。这一篇接着往下聊讲的是把公式渲染从能看推到能用的过程长列表里怎么不掉帧、行内公式怎么和中文对齐基线、缓存该分几层来设计、以及从传统 View 迁到 Compose 之后那些没写在文档里的坑。如果你手上正在做题库、教育、理工科内容类的 App或者单纯被 WebView 的内存曲线折磨过这篇应该能省你不少时间。1. 第一篇的 WebView 方案在长列表里翻车的真实原因先把问题说透。很多人以为是WebView 太慢这个结论对了一半但模糊的结论没法指导改造。我当时做了一轮拆解把卡顿拆成了三个独立的问题每个问题的解法完全不同。1.1 三个被混在一起的性能问题第一个问题是实例创建成本。一个 WebView 从构造到能渲染第一帧中间要经历引擎初始化、JS 运行时启动、HTML 解析、样式计算、布局、绘制这一长串。我在中端机上打了点冷启动一个 WebView 到首帧完成普遍在 180ms 到 400ms 之间低端机上能冲到 600ms 以上。列表里一屏七八条公式每条一个 WebView等于每次滑动都在批量做这件事。第二个问题是内存占用。单个 WebView 实例的常驻内存我实测在 25MB 到 45MB 区间浮动取决于加载的 JS 引擎体积。RecyclerView 的复用池里躺着五六个实例就是一百多兆。这个数字在内存紧张的低端机上非常致命系统会开始频繁回收然后你看到的就是滑回去公式没了重新加载。第三个问题最隐蔽是复用冲突。WebView 是有状态的它记着自己的滚动位置、JS 上下文、加载进度。你把它从 position 3 复用给 position 11必须先把状态彻底重置再重新 loadData。这个重置过程是异步的而 RecyclerView 的 bindViewHolder 是同步的。结果就是你会看到公式短暂地串台——这一条的位置上先闪一下上一条的公式再刷成正确的。用户感知就是抖。注意这三个问题不能靠调参数解决。加大缓存池解决不了创建成本减小池子解决不了内存同步阻塞等待解决不了复用冲突只会更卡。它们指向同一个结论。1.2 一个更现实的判断什么时候该换路线不是所有场景都要换。我后来总结了一条还算好用的判断线如果公式出现在单页详情一次只显示一两条的位置WebView 方案完全够用别折腾。它的排版能力最完整复杂公式、矩阵、分段函数、化学式全都能吃下来你自研原生渲染要花的三个月它零成本给你。但如果是下面这几种情况就该考虑换公式出现在 RecyclerView / LazyColumn 的长列表里一屏超过 3 条公式需要和正文中文行内混排而不是独立成块居中公式需要跟随主题色、暗色模式、字号缩放实时变化页面要求秒开首帧预算在 100ms 以内。我当时的项目四条全中。题库列表、题干里夹着行内公式、要支持夜间模式、用户对加载转圈极其不耐烦。所以第二篇的主线就定了把渲染从运行时计算改成提前算好、运行时只画。这个思路值得展开说一下因为它是整篇文章的地基。传统做法是拿到公式串现场解析、现场排版、现场绘制。而预排版是把解析和排版这两步挪到构建期或者服务端产出一份描述每个符号画在哪个坐标、用什么字体、多大字号的数据客户端拿到的是一棵已经算好位置的盒子树它要做的只剩照着坐标画。相当于别人给你一张施工图你只需要按图钉钉子不需要自己重新做结构计算。这么一改客户端的活从排版引擎降级成绘制器代码量少一个数量级性能上限却高得多。代价是排版能力受限于你在构建期实现的那套解析器不过对绝大多数业务场景来说常用数学符号集合其实很有限。2. 原生绘制路线的能力边界与盒子树设计选定方向之后第一个要回答的问题是客户端到底要画什么。这一步的设计如果含糊后面所有优化都是空中楼阁。2.1 盒子树的字段设计我把预排版的输出定义成一棵递归的盒子树每个节点描述一个矩形区域和它的绘制指令。字段不多但每一个都有明确用途字段类型用途说明typeenum节点类型文本、分数线、根号、上下标容器、括号、空白textstring文本节点实际要画的字符已经过多字符映射处理x / yfloat相对父节点的偏移单位是 em 的千分之一避免浮点累积误差w / hfloat节点宽高同上baselinefloat该节点内部基线相对自身顶部的距离行内对齐全靠它scalefloat缩放系数上下标通常是 0.7 左右childrenlist子节点按绘制顺序排列有两个细节我当时纠结了很久后来证明纠结得值。第一坐标用整数而不是浮点。单位取 em 的千分之一也就是字号 1000 对应 1em。好处是跨设备、跨字号都能无损缩放而且整数运算没有浮点误差累积深层嵌套的根号不会出现半个像素的偏移。这个坑我在第一版踩过用 float 存相对偏移嵌套四层之后根号上横线和内容之间能差出 2px肉眼可见。第二基线单独存字段不要用 h 减 descent 推。数学排版里有太多视觉上悬空的节点比如分数线、根号的横线它们的 baseline 和常规文本的算法完全不一样。老老实实让排版器算好存下来客户端直接用比在客户端反推省心一百倍。2.2 哪些公式该走预排版哪些该降级预排版不是万能的。我那套解析器支持的是常用子集四则运算、分数、上下标、根号、求和求积、积分、希腊字母、常用关系符、矩阵和分段函数。遇到超出范围的比如复杂的三重积分嵌套或者自定义宏就得有兜底策略。我的兜底策略分三级按成本从低到高降级为纯文本把标记语言里的命令剥掉只留可读字符。比如\alpha显示成 α。这级最快但视觉效果差适合列表页的摘要区域。降级为图片服务端在构建期用完整排版器渲染成图随数据一起下发。适合详情页因为详情页对首帧要求没那么高。降级为 WebView只有极少数超复杂公式走这条详情页里单独开一个容器。这套分级让我不用追求100% 覆盖可以先把 95% 的常见公式用原生画好剩下 5% 慢慢补。工程上这种渐进式覆盖的思路比一次性做完靠谱得多前者能持续交付后者容易烂尾。2.3 绘制器本身的代码骨架绘制器非常薄。核心就是一个递归函数按节点类型分派累加变换矩阵。文本节点用canvas.drawText线段节点用canvas.drawLine容器节点只做canvas.translate和canvas.scale然后递归。整个类两百行出头。fun drawNode(canvas: Canvas, node: FormulaNode, paint: Paint) { canvas.save() canvas.translate(node.x / 1000f * baseSize, node.y / 1000f * baseSize) when (node.type) { NodeType.TEXT - { paint.textSize baseSize * node.scale paint.color currentColor canvas.drawText(node.text, 0f, node.baseline / 1000f * baseSize, paint) } NodeType.LINE - { paint.strokeWidth max(1f, baseSize * 0.05f) val y node.y / 1000f * baseSize canvas.drawLine(0f, y, node.w / 1000f * baseSize, y, paint) } NodeType.BOX - node.children.forEach { drawNode(canvas, it, paint) } } canvas.restore() }有一点必须提醒Paint 对象一定要复用别在递归里 new。一屏八条公式、每条平均三十个节点就是两百多次对象创建在 16ms 的帧预算里这是实打实的浪费。我一开始图省事在函数里新建滑动的帧率曲线明显比复用版本毛糙。3. 公式缓存的三层结构内存、磁盘与构建期产物预排版解决了算的成本但画和解码还有成本。尤其当你把公式渲染成位图缓存时解码本身就是个不小的开销。所以缓存得分层每层解决不同量级的问题。3.1 为什么不能只缓存 Bitmap最直觉的做法是把公式渲染成 Bitmap 存起来下次直接用。这招在短视频封面、头像这类场景很常见但在公式上有几个坑。第一个坑是缩放失真。用户把系统字号调大或者你在平板和大屏设备上跑原先按 16sp 渲染好的位图放大两倍就糊了。而公式里全是细线条和密集的小字符糊起来比普通图片明显得多。第二个坑是内存账算不过来。一条典型行内公式渲染出来假设是 300x80 像素ARGB_8888 就是 300×80×4 96000 字节约 94KB。一屏八条接近 750KB加上 RecyclerView 的双向预取实际占用轻松过 2MB。一条列表滑动十分钟LRU 里翻过的公式能有几百条光内存缓存就能吃掉三四十兆。第三个坑是主题切换全废。暗色模式一开所有位图的颜色都不对了缓存整批失效用户看到的是全屏重新渲染。所以我的结论是位图缓存只做最后一层且必须做尺寸分档真正的主力缓存应该是盒子树 绘制。3.2 一个能落地的缓存键设计缓存键设计错了后面全是脏数据。我踩过一次只用了公式串做 key结果用户切换字号之后拿到的全是旧尺寸的缓存公式大小和正文对不上看起来像排版错乱。正确的 key 至少要包含这几个维度fun buildCacheKey(source: String, sizePx: Int, style: Int, colorKey: Int): String { val raw buildString { append(RENDERER_VERSION).append(|) append(FONT_ASSET_VERSION).append(|) append(sizePx).append(|) append(style).append(|) append(colorKey).append(|) append(source) } return raw.md5() }逐项解释一下为什么都要带上渲染器版本你改了绘制逻辑、修了某个符号的偏移所有旧缓存必须作废。这一项如果漏了线上会出现同一条公式在 A 用户手机上是好的、B 用户手机上是错的排查起来极其痛苦。字体资源版本公式用的是专门的数学字体字体文件一换字符宽度全变旧的盒子树坐标全错。字号像素值注意是像素不是 sp因为 sp 会随系统缩放变化用 sp 做 key 会导致同一台设备上不同缩放级别撞 key。字重/样式粗体和常规体的字形宽度不同。颜色这一项看你的缓存粒度。如果缓存的是位图就必须带如果缓存的是盒子树就可以不带颜色在绘制时注入。最后那点很关键也是我推荐缓存盒子树而不是位图的核心理由盒子树和颜色无关一次缓存明暗主题通吃。位图缓存则必须按颜色分档缓存命中率直接腰斩。3.3 磁盘容量估算与清理策略盒子树序列化成二进制之后非常小。我那套格式里一个典型行内公式大约 15 到 40 个节点每节点压缩后平均 12 字节上下算下来单个公式 200 到 500 字节。一千条公式也就 500KB完全可以直接打进安装包。实际做法分两种场景数据来源存储位置容量估算内置题库构建期生成assets 打包1000 条约 500KB服务端下发接口返回磁盘缓存上限设 8MBLRU 淘汰用户输入本地实时排版内存缓存上限设 4MB仅本次会话磁盘缓存用 DiskLruCache 或者自己写个简单的目录管理都行。我设的阈值是 8MB超过就按最近最少使用淘汰。这个数字不是拍脑袋8MB 大约能存两万条公式覆盖绝大多数用户的浏览深度同时不会在存储空间紧张的低端机上触发系统的清理告警。还有一个容易忽略的点缓存目录要参与应用备份排除。缓存是可以重新生成的没必要跟用户数据一起备份加到备份排除规则里能省不少云端空间。4. 行内公式与中文混排时的基线对齐难题这是整个项目里最花时间的部分也是最容易被低估的部分。独立成块的公式你怎么画都行居中就完了。但只要公式要插在一段中文中间基线对齐问题立刻跳出来。4.1 基线偏差到底是怎么产生的中文的行高计算和西文、和数学符号都不是一套逻辑。TextView 在用 Spannable 排版时一段文字的整体行高由这一行里所有 span 的 FontMetrics 共同决定取的是 ascent 的最大值和 descent 的最大值。而数学公式的高度往往远超普通文字比如一个带上下限的求和符号它的视觉高度可能是普通字符的三倍。问题在于如果用默认方式把公式当图片塞进去系统会用图片的底边去对齐文字基线结果就是公式整个坐在基线上上半部分高高翘起看起来像飘在文字上方。而人眼期望的是公式以它的数学轴大致是分数线的位置对齐文字的视觉中心稍微偏上一点。我实测过几种错误对齐的偏差量在 16sp 正文、公式高度 44px 的情况下底边对齐会让公式中心比期望位置高出约 9px肉眼非常明显整行文字看起来像被公式顶起来了。4.2 用 ReplacementSpan 精确控制绘制位置解决办法是用 ReplacementSpan 自己接管测量和绘制。步骤分三步测量时告诉系统这个位置占多宽、行高要多高绘制时自己算偏移把公式画在正确位置。class FormulaSpan( private val layout: FormulaLayout, private val color: Int, private val basePaint: Paint ) : ReplacementSpan() { override fun getSize( paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt? ): Int { val width layout.widthPx(basePaint.textSize) val height layout.heightPx(basePaint.textSize) val baselineOffset layout.mathAxisPx(basePaint.textSize) fm?.let { val top -(baselineOffset height * 0.15f).toInt() val bottom (height - baselineOffset height * 0.15f).toInt() it.ascent top it.top (top * 1.2f).toInt() it.descent bottom it.bottom (bottom * 1.2f).toInt() } return width.toInt() } override fun draw( canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint ) { basePaint.textSize paint.textSize val baselineOffset layout.mathAxisPx(paint.textSize) canvas.save() canvas.translate(x, y - baselineOffset) layout.draw(canvas, basePaint, color) canvas.restore() } }几个参数值得单独说。mathAxisPx是预排版阶段就算好的数学轴位置也就是分数线的理想高度。用它作为对齐锚点比用底边或者几何中心都自然。这个值在排版器里通常是字号高度的 0.28 到 0.32 倍之间具体取决于字体的设计。上下各留 15% 的呼吸空间是为了避免带根号或者上标的公式和上一行、下一行贴得太近。这个比例我试过 10%、15%、20%15% 在 16sp 和 22sp 两种正文字号下都比较顺眼。还有一个隐藏的坑top和bottom要设成比ascent和descent更大。因为top/bottom影响的是行间距安卓在计算行距时会参考这两个值。如果只设 ascent 和 descent短行看起来没问题但一旦公式所在行和上下行靠得近就会出现视觉上的粘连。4.3 和首行缩进、两端对齐的配合中文排版里有首行缩进公式如果正好出现在缩进位置附近宽度计算要多加小心。Spannable 的缩进是通过 LeadingMarginSpan 实现的它和 ReplacementSpan 的交互是缩进先占位span 在剩余宽度里排。麻烦的是两端对齐。中文正文经常开JustificationMode系统会对一行里的空格和字符间距做拉伸。当一行里有 ReplacementSpan 时系统不会去拉伸公式内部而是把多余的空间全部分给文字部分可能出现文字被拉得很开、公式孤零零挤在右边的观感。我的处理方式是含公式的段落不开两端对齐改用左对齐。这个取舍虽然牺牲了一点排版的齐整感但换来的是稳定可预期的显示效果。教育类内容里公式的清晰度优先级本来就高于两端对齐的美观度。5. 从传统 View 迁移到 Compose 之后的几个新坑项目中途赶上界面重构列表从 RecyclerView 换成了 LazyColumn。我以为渲染逻辑是纯 Canvas 操作迁过去应该很轻松实际踩的坑比想象的多。5.1 AndroidView 包装的真实代价最省事的做法是用AndroidView把原来的自绘 View 包起来Composable fun FormulaView(layout: FormulaLayout, modifier: Modifier Modifier) { AndroidView( modifier modifier, factory { ctx - FormulaCanvasView(ctx) }, update { view - view.setLayout(layout) } ) }代码几行就写完了但性能账不好看。AndroidView本质是个宿主包装每个实例都要走一遍 View 的测量、布局、绘制流程还带一层 Compose 到 View 的桥接。在 LazyColumn 里滚动时即便列表项被复用AndroidView的 factory 在某些情况下仍会被重新调用我实测下来滑动时出现了轻微的周期性卡顿。更麻烦的是生命周期和状态同步。Compose 的重组频率比 View 的 invalidate 高update回调可能在一帧内被调用多次。如果setLayout里做了重新测量这种重活很容易把主线程占满。我的处理是在setLayout里做了一版脏检查新 layout 和旧的是同一个对象引用就直接 return不做任何事。5.2 直接用 Canvas 自绘的正确姿势后来我改成了纯 Compose 的Canvas实现性能明显好转Composable fun FormulaCanvas( layout: FormulaLayout, color: Color, modifier: Modifier Modifier ) { val paint remember { Paint().apply { isAntiAlias true; typeface mathTypeface } } Canvas(modifier modifier.size( width layout.widthDp(), height layout.heightDp() )) { paint.textSize layout.baseSizePx paint.color color.toArgb() layout.drawOn(this, paint) } }关键是remember里的 Paint。这里必须用remember而不是每次重组都 new 一个因为 Paint 内部持有字体渲染的缓存频繁新建会让底层反复做字体栅格化这个开销在 Compose 的高重组频率下会被放大得很厉害。还有一个细节是尺寸的单位。Compose 的Modifier.size用的是 Dp而预排版的数据是基于像素的。我一开始直接在 Canvas 的绘制里用layout.widthPx忘了 DrawScope 的坐标系本来就是像素结果尺寸算了两遍公式被缩放了。正确做法是让 layout 直接提供像素尺寸用Modifier.size的重载版本按像素设置。5.3 大列表里的预排版与预取Compose 的 LazyColumn 默认有预取机制会在可见区域外提前组合一部分列表项。这对公式渲染是个隐性成本用户可能只滑了半屏实际已经组合了十几条。我的优化是把解析标记串到盒子树这一步放到remember里并且带上明确的 keyval layout remember(item.id, fontSizePx) { FormulaParser.parse(item.formula, fontSizePx) }带上item.id和字号做 key列表项复用的时候不会重复解析字号变了又会正确失效。这一步看着不起眼但在滚动回归测试里贡献了大约 4 到 6 帧的提升——因为解析虽然是纯 CPU 计算但一条复杂公式在低端机上也要 1 到 3ms一屏七八条就是十几毫秒。6. 三种路线放在一起的实测数据与选型建议聊了这么多设计最后还是得落到数字上。我在一台中端机四核 2.0GHz、4GB 内存和一台低端机八核 1.8GHz、3GB 内存上做了对比测试样本是一个 200 条、每条含 1 到 2 个行内公式的列表。指标WebView 方案位图缓存方案预排版 自绘方案首屏可见内容耗时780ms420ms165ms快速滑动丢帧率38%21%4%单屏峰值内存增量约 96MB约 34MB约 11MB暗色模式切换需重新加载缓存全失效无需处理字号缩放响应需重新加载缓存全失效直接重绘复杂公式支持度完整完整常用子集客户端代码量约 300 行约 800 行约 1800 行数据背后有几个不那么直观的结论值得单独讲讲。丢帧率的差距远大于耗时差距。首屏耗时从 780ms 降到 165ms用户的感知是快了一点但丢帧率从 38% 降到 4%用户的感知是这个 App 是流畅的。滑动手感对体验评分的影响比启动速度大得多。这也是我建议把优化预算优先投在滚动链路上的原因。内存的边际收益会递减。从 96MB 降到 34MB 是质变从 34MB 降到 11MB 用户其实感知不到但能显著降低被系统回收的概率。如果你只做一步优化先做 WebView 到本地位图的替换收益最大预排版那一步是锦上添花但它顺带解决了暗色模式和字号缩放两个体验问题综合收益仍然值得。代码量涨了三倍但要算维护账。1800 行看着多实际分布是解析器 900 行、绘制器 250 行、缓存 300 行、Span 与 Compose 适配 350 行。解析器的 900 行是纯函数一次性写完基本不用改测试也好写真正需要长期维护的其实只有缓存的 key 策略和字体资源版本管理。相比之下WebView 方案的 300 行看着少但每次排版异常都要去调 JS 引擎的参数、看 HTML 输出排查成本反而更高。如果让我给不同团队一个建议我会这么分公式量小、以详情页为主、团队没有专门的排版人力——老老实实用 WebView别折腾。公式量大、有列表场景、能抽出一个人做两个月——走预排版加自绘长期收益最高。公式量大但不想写解析器——走位图缓存加预渲染成本居中效果能接受。最后再分享一个小技巧。预排版数据打包进 assets 的时候别一股脑全塞进去按业务模块分文件并且加一个顶层索引记录每个文件的公式 ID 范围和版本号。这样后续增量更新只需要替换单个文件用户在商店里的更新包体积能小一大截。这个改动我是在第三个版本才加的加完之后包体从 62MB 降到 54MB比任何一次代码优化都立竿见影。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑