前置知识:了解 Activity 页面框架的基本概念(第 8 章),理解帧缓冲区的概念(第 5 章)。 本章目标:理解 CrossPoint Reader 如何在极有限的内存中实现高质量的中英文混排渲染,包括分块帧缓冲、字体架构、字形查找、字距调整、连字替换和两遍渲染优化。
9.1 GfxRenderer 概览
CrossPoint Reader 的渲染引擎集中在一个文件里——GfxRenderer.cpp,大约 46KB 的源码。对于一个嵌入式项目来说,这算是一个”大文件”了。它承担了所有把像素画到屏幕上的职责:
- 画线、画矩形:UI 元素的基础
- 画文字:中文、英文、日文等多语言渲染
- 画图片:书籍封面、插图
- 管理帧缓冲:在内存中维护一块”画布”
- 处理旋转:支持横屏和竖屏
- 灰度处理:在黑白屏上模拟灰度效果
9.2 分块帧缓冲——对抗内存碎片
问题:连续内存找不到
第 5 章提到,800x480 分辨率的墨水屏需要一块约 48KB 的帧缓冲区。在电脑上,48KB 不值一提。但在 ESP32-C3 的 380KB RAM 里,这是一件棘手的事。 原因不是”内存不够”,而是内存碎片化。经过反复的malloc 和 free,RAM 就像一块被反复挖洞的瑞士奶酪——空洞加起来有 50KB,但没有哪个单独的洞超过 8KB。你需要一块连续的 48KB,但系统告诉你:“抱歉,找不到。“
解决方案:拆成小块
CrossPoint Reader 的做法是把 48KB 的大缓冲区拆成多个 8KB 的小块,分散分配:- 按 8KB 一块遍历所有分块
- 为每块调用
malloc申请内存——这些小块不需要连续,可以散落在 RAM 的各个角落 - 如果某一块分配失败,说明连 8KB 都凑不出来了,此时”全军撤退”——释放已经分配的所有块
- 分配成功后把帧缓冲的数据复制进小块
类比:这就像搬家——你有一个大衣柜要搬出去,但电梯太小放不下。怎么办?把衣柜拆成几块小零件,分几次搬运。到了新家再按顺序拼起来。虽然多了拆装的步骤,但至少能搬得动。渲染时要访问帧缓冲的某个位置,只需要做一次简单的除法:
9.3 字体架构——15 个字体变体
字体家族
CrossPoint Reader 提供三个字体家族,四种尺寸,再加上 UI 用字体和小字体:字体存储
15 个字体变体包含大量的字形数据(汉字动辄几千个),全部放在 RAM 里是不可能的。CrossPoint Reader 把所有字体压缩后存储在 Flash(16MB)中,运行时按需解压到 RAM。这就像一个图书馆:所有书都存放在书架上(Flash),你只把当前要读的几本搬到桌上(RAM)。9.4 字形查找——Unicode 区间与二分搜索
问题:怎么从几万个字形中找到一个?
Unicode 字符集非常庞大。光常用汉字就有几千个,加上拉丁字母、日文假名、标点符号等,一个字体可能包含上万个字形。如何快速找到某个字符对应的字形?解决方案:区间索引 + 二分搜索
CrossPoint Reader 不是用一个巨大的数组来存所有字形,而是把字符编码按连续区间组织。例如:- 用
std::upper_bound做二分搜索,找到第一个”起始编码大于目标字符”的区间 - 往前退一步,就是目标字符可能所在的区间
- 检查目标字符是否在这个区间的范围内(
cp <= interval.last) - 如果在,用简单的减法
cp - interval.first计算出它在区间内的偏移量 - 如果不在任何区间里,返回 Unicode 替换字符 U+FFFD(就是你经常见到的那个 ”�” 方块)
9.5 字距调整(Kerning)
什么是 Kerning?
如果你仔细观察印刷品上的 “AV” 和 “AB”,会发现 “AV” 两个字母靠得更近。这不是错误,而是刻意的设计——因为 A 的右侧是斜面,V 的左侧也是斜面,中间会留出一个三角形的空白。如果不调整间距,看起来就会像 A 和 V 之间有一条白缝。 Kerning(字距调整)就是根据相邻字符的形状微调它们之间的距离。CrossPoint Reader 的实现
- 将所有字符分成若干”左类”和”右类”——形状相似的字符归为一类(比如 A 和 Lambda 可能同属一个左类)
- 用一个二维矩阵存储每对”左类-右类”之间的间距调整值
- 查找时先确定两个字符各自的类别,再到矩阵里查值
定点数:用整数模拟小数
注意返回值是 4.4 定点数,这是什么? 在嵌入式系统中,浮点运算(float、double)通常比整数运算慢得多(ESP32-C3 没有硬件浮点单元,所有浮点运算都是软件模拟的)。但是 kerning 的精度需要小数——字母间距调整 0.5 个像素是常见的需求。
解决办法是定点数(Fixed-Point Number):用一个 8 位整数来表示小数。高 4 位是整数部分,低 4 位是小数部分。
9.6 连字替换(Ligature)
什么是连字?
在英文排版中,某些相邻字母组合在一起会变成一个特殊的符号。最常见的例子:- “fi” 合并成 “fi”——因为 f 的顶部横杠和 i 的圆点会碰撞
- “fl” 合并成 “fl”
- “ffi” 合并成 “ffi”
CrossPoint Reader 的实现
- 拿到当前字符
cp,再读取下一个字符nextCp - 查找这两个字符是否构成连字
- 如果构成,把连字结果作为新的
cp,继续尝试和下一个字符合并 - 这样 “ffi” 的处理过程是:
f+f=ff(连字),ff+i=ffi(连字),最终变成一个字形
text = saved 把指针回退——“看了一下,不需要合并,假装没看过。“
9.7 两遍渲染——核心优化
这是 CrossPoint Reader 渲染系统中最精妙的设计,值得详细拆解。问题:几千个汉字字形放不进内存
一页中文文本可能包含几百个不同的汉字。每个汉字字形压缩存储在 Flash 中,要显示就得解压到 RAM。但解压所有可能用到的字形(几千个)需要的内存远超系统可用量。 一个朴素的方案是”用到一个解压一个”——但这样每个字形要单独启动一次解压流程,效率很低,因为字形在 Flash 中是按分组压缩的:一组可能包含几十个字形,解压一组才能拿到其中一个,下次需要同组的另一个字形又得重新解压整组。解决方案:先扫描,再批量解压,最后渲染
CrossPoint Reader 把渲染过程分成两遍:renderPage(),但渲染引擎处于”假装在画”的状态——所有 drawText() 调用都不会真正画像素,而是把文本内容记录到 scanText_ 字符串里。这一遍的目的是收集购物清单。
中间步骤(批量解压):endScanAndPrewarm() 拿到了完整的文本列表,然后:
- 去重:一页文本里 “的” 可能出现 20 次,但只需要解压一次
- 分组:确定这些字形分布在 Flash 中的哪些压缩分组里
- 解压:对每个相关的分组只解压一次,从中提取需要的字形
- 缓存:把字形数据存入缓存,按索引排序以便后续二分搜索
renderPage(),这次是真正地画像素。所有需要的字形已经在缓存里了,每次 drawText() 直接从缓存中获取字形数据,不再需要访问 Flash 或做任何解压。
类比:想象你要做一桌年夜饭。 朴素方案:做一道菜、发现缺葱,跑一趟超市;做下一道菜、发现缺酱油,又跑一趟超市。来回跑十几趟。 两遍方案:先把所有菜谱过一遍,列出完整的购物清单(第一遍)。去超市一次性买齐所有食材(批量解压)。回家安心做饭(第二遍)。这个设计的代价是渲染过程要执行两次,但第一遍只做字符串拼接,开销很小。换来的好处是:
- 每个压缩分组只解压一次,大幅减少 Flash 读取和解压次数
- 内存峰值可控——只缓存当前页需要的字形(最多 512 个)
- 解压完成后,第二遍的渲染速度非常快
9.8 抗锯齿原理
为什么文字边缘会有锯齿?
墨水屏的每个像素只有”黑”和”白”两种状态。当一条斜线或曲线经过像素网格时,像素要么整个变黑,要么整个留白——没有”半黑”的选项。这就导致了阶梯状的锯齿。灰度抗锯齿:模拟”部分覆盖”
如果能显示灰度(介于黑和白之间的颜色),就可以用灰色像素来”模糊”锯齿边缘:4-bit 灰度:性价比之选
CrossPoint Reader 使用 4-bit(16 级)灰度来做抗锯齿。为什么不用 8-bit(256 级)?因为:- 墨水屏本身只能显示有限的灰度级别(通常 4-16 级)
- 16 级灰度对字体渲染来说已经足够——人眼很难区分更多级别
- 4-bit 比 8-bit 节省一半存储空间
小字号的例外
不是所有情况都适合抗锯齿。小字号(像素高度 16px 以下)不宜开启抗锯齿。原因很直觉:字号越小,每个笔画占的像素越少。一个只有 1-2 像素宽的笔画如果再被灰度”模糊”,就会变得发虚、看不清楚。这时候,“锐利的锯齿”反而比”模糊的平滑”更好读。 这是排版领域的一条经验法则:大字号追求平滑,小字号追求清晰。CrossPoint Reader 对不同字号使用不同的抗锯齿策略,体现了在细节上的打磨。9.9 把这一切串起来
一次完整的页面渲染,串联了本章介绍的所有子系统:本章要点
- GfxRenderer 是 CrossPoint Reader 的核心渲染引擎,负责所有像素级别的绘制工作。
- 分块帧缓冲把 48KB 的大缓冲区拆成 8KB 小块分散分配,解决了内存碎片化导致的大块连续内存分配失败问题。
- 字体架构包含 3 个字体家族、4 种尺寸共 15 个变体。字体压缩存储在 Flash 中,按需解压到 RAM。
- 字形查找采用 Unicode 区间索引加二分搜索,实现 O(log n) 的快速定位。找不到的字符回退到替换字符 U+FFFD。
- **字距调整(Kerning)**使用类矩阵方案和 4.4 定点数,以纯整数运算实现亚像素精度的字间距微调。
- **连字替换(Ligature)**支持链式合并,如 “ffi” 可以一步步合并成单个字形。
- 两遍渲染是最关键的优化:第一遍扫描收集所有需要的字形,批量解压后第二遍真正渲染。避免了重复解压和内存峰值过高的问题。
- 抗锯齿使用 4-bit(16 级)灰度模拟边缘平滑,但小字号下会关闭以保持清晰度。
下一章预告:第 10 章将跳出渲染系统,从全局视角审视 CrossPoint Reader 的内存管理策略——380KB 的 RAM 是如何被精密分配的,六大优化策略如何协同工作,让一个完整的电子书阅读器在极小的内存空间里运转自如。