前置知识:理解内存管理策略(第 10 章),了解渲染引擎和字体系统(第 9 章),熟悉 Activity 框架(第 8 章)。 本章目标:拆解 CrossPoint Reader 如何在 380KB 内存中完成从”打开一本 EPUB 文件”到”在墨水屏上显示一页排版精美的文字”的全过程。
一本电子书在你手机上打开只需要一秒钟——你可能从来没想过这件事有什么难的。但在一颗 160MHz、380KB 内存的芯片上,打开一本书是一场精心编排的战役:ZIP 要流式解压、XML 要增量解析、排版要动态规划、渲染要多遍执行。这一章,我们逐一拆解。
11.1 EPUB 文件结构拆解
在开始写代码之前,先搞清楚我们要处理的对象长什么样。 EPUB 是目前最流行的电子书格式。它的本质出奇地简单——一个改了扩展名的 ZIP 压缩包。你可以把任何.epub 文件的扩展名改成 .zip,然后用解压软件打开,会看到这样的目录结构:
- container.xml — 找到
.opf文件的路径(它是”目录的目录”) - content.opf — 获取书名、作者等元数据,以及
<spine>标签定义的章节阅读顺序 - toc.ncx 或 nav.xhtml — 构建用户可见的目录结构
- 各个 .xhtml 文件 — 真正的书籍内容,是标准的 HTML
11.2 EPUB 解析流水线
理解了文件结构,接下来看 CrossPoint 是怎么把它加载到内存中的。11.3 ZIP 流式解压:不能全部展开
在手机或电脑上,处理 ZIP 文件很简单:把整个文件解压到临时目录,然后随意读取。但在 380KB 内存的芯片上,这是不可能的——一本普通 EPUB 可能有几十 MB,解压后更大。 CrossPoint 采用流式解压策略:不把文件展开到内存中,而是在需要读取某个文件时,直接在 ZIP 内部定位到该文件的位置,边读边解压。Deflate 压缩和 zlib
ZIP 文件内部使用的压缩算法叫 deflate,它是 LZ77 + 哈夫曼编码的组合。你不需要深入理解算法细节,只需要知道:- 压缩(deflate):把重复的数据模式替换为更短的”引用”,减小文件体积
- 解压(inflate):把这些”引用”还原为原始数据
11.4 自定义二进制缓存格式
XML 解析很慢,但解析结果可以缓存。CrossPoint 设计了两种二进制缓存文件。book.bin:整本书的元数据缓存
section.bin:单章的排版缓存
当你打开某一章时,阅读器需要把 HTML 内容排版为一页一页的显示数据。这个过程(解析 HTML、文本断行、计算分页)非常耗时。所以排版结果也会缓存:缓存校验:参数变了就重建
这里有一个关键问题:如果用户换了字体或调了行距,之前的排版缓存就作废了——因为字体不同,每行能容纳的字数不同,分页结果完全不同。11.5 HTML 解析:增量式,1KB 缓冲区
EPUB 的章节内容是 XHTML(一种更严格的 HTML)。CrossPoint 使用 Expat 库来解析 XML。为什么选 Expat
Expat 是一个事件驱动的 XML 解析器——你把 XML 数据一块一块地喂给它,它每遇到一个标签就回调你的函数。这种模式天然适合流式处理:支持的 HTML 标签
CrossPoint 不是一个完整的浏览器,它只支持电子书中常用的标签子集:| 标签 | 用途 |
|---|---|
h1 - h6 | 标题(不同级别有不同字号) |
p | 段落 |
li, ol, ul | 列表 |
div | 容器 |
blockquote | 引用块 |
img | 图片 |
table, tr, td | 表格 |
a | 链接(用于脚注跳转) |
em, strong, i, b | 斜体、粗体 |
br | 换行 |
sup | 上标(常见于脚注标记) |
UTF-8 安全截断
当 1KB 缓冲区满了需要”切一刀”时,有一个容易忽略的陷阱:UTF-8 编码中,一个中文字符占 3 个字节,一个表情符号占 4 个字节。如果恰好从多字节字符的中间切断,就会产生乱码。 CrossPoint 在截断时会检查最后几个字节是否是多字节序列的一部分,如果是,就往前退到完整字符的边界再切。这个小细节保证了中文、日文等多字节文本的正确处理。11.6 文本排版:让每一行都好看
HTML 解析完成后,得到的是一串带格式标记的文本流。下一步是排版——决定每行显示多少字、在哪里换行、怎么对齐。动态规划换行算法
最简单的换行策略是”贪心法”:从左往右填字,放不下了就换行。但贪心法的结果往往参差不齐——某些行塞得满满的,紧接着下一行只有几个字。 CrossPoint 使用了一种更聪明的方法:动态规划换行(类似 Knuth-Plass 算法)。它会综合考虑多行的排列效果,通过最小化”丑陋度”(badness)来找到全局最优的换行方案。两端对齐(Justified)的像素分配
在两端对齐模式下,每一行的左右两端都要对齐。多出来的空白需要均匀分配到单词之间的间隔中。 这里的关键是像素级别的精确分配。如果一行有 5 个间隔,需要分配 13 个像素的额外空白,简单地13 / 5 = 2 余 3——前 3 个间隔各多分 1 个像素(各得 3px),后 2 个间隔各得 2px。在 800 像素宽的墨水屏上,1 个像素的差异肉眼几乎看不出,但如果不做这种精细分配,累积误差会让右边缘参差不齐。
CSS text-indent 支持
EPUB 中的段落通常带有首行缩进(text-indent)。CrossPoint 会解析这个 CSS 属性,并在排版时将其转换为像素偏移量。中文排版的”每段开头空两格”就是通过这个机制实现的。
11.7 多遍渲染流水线
排版完成后,终于到了”画到屏幕上”的环节。但在墨水屏上渲染文字并非一蹴而就——CrossPoint 采用了多达四遍的渲染流水线。为什么需要这么多遍?
- 第 1 遍(预热):字体文件存在 Flash 中,随机访问很慢。预热遍先收集所有需要的字形编号,然后批量加载,把随机读取变成顺序读取,速度提升数倍。这在第 9 章已经详细介绍过。
- 第 2 遍(黑白):墨水屏的基本模式是 1-bit 黑白。这一遍产生清晰的黑白文字,适用于快刷模式。
- 第 3-4 遍(灰度):如果开启了抗锯齿,需要额外两遍来计算 2-bit 灰度数据。灰度抗锯齿可以让文字边缘更平滑,但代价是多两遍渲染和额外的刷屏时间。
Page 对象中),不需要重新计算布局。而且字体预热带来的加速效果远大于多一遍扫描的开销。
11.8 阅读进度保存:6 个字节记住你读到哪了
用户合上书,下次打开要能回到上次的位置。这个功能看似简单,但在断电就丢失 RAM 的嵌入式系统上需要持久化到存储中。 CrossPoint 用了极其精简的格式——只需 6 个字节:字体变更后的页码换算
这里有一个容易被忽略的问题:如果用户换了字体大小,每页能显示的字数变了,之前保存的”第 42 页”就不准了。 CrossPoint 的做法是按比例换算:11.9 脚注三层跳转
学术类书籍中脚注很常见。点击正文中的脚注标记(如[1]),跳转到脚注内容;阅读完脚注后,再跳回原来的位置。有些脚注里还有链接,可能还需要再跳一层。
CrossPoint 支持最多 3 层嵌套跳转:
11.10 断字算法(Liang/TeX)
英文排版中有一个经典问题:如果一个长单词 “internationalization” 恰好出现在行末,塞不下整个单词又不想留太大的空白,怎么办? 答案是断字(Hyphenation)——在合适的位置把单词拆开,加上连字符:Liang 断字算法
CrossPoint 使用的是 Liang 断字算法,与 TeX 排版系统完全相同。这个算法由 Franklin Liang 在 1983 年提出,至今仍是排版领域的标准。 它的核心思想是用模式匹配来确定断字点。每种语言有一套预定义的模式(pattern),例如英文的.ach4 表示”在 ach 之前倾向于断开”。
预编译的二叉 Trie
所有断字模式被预编译为一棵二叉 Trie(前缀树的二叉版本),存放在 Flash 中。这个设计有两个好处:- 零堆分配:查找断字点时不需要分配任何堆内存,只需要在 Flash 中沿着 Trie 的指针走。对于内存紧张的系统来说,这一点至关重要。
- 多语言支持:每种语言一棵 Trie,存放在 Flash 的不同位置。支持英语、法语、德语、俄语、西班牙语、意大利语等多种语言。
11.11 TXT 流式分页
不是所有电子书都是 EPUB。纯文本(TXT)文件虽然简单,但也不能一股脑加载到内存里——一本百万字的小说可能有好几 MB。 CrossPoint 采用 8KB 缓冲区逐块读取 的方式处理 TXT 文件:- 从 index.bin 中查到第 N 页的字节偏移量
- 用
fseek()跳到文件的对应位置 - 读取一页大小的数据
- 排版并渲染
11.12 XTC 漫画渲染
XTC 是 CrossPoint 支持的一种漫画/图片书格式。和文字排版不同,漫画渲染的挑战在于图片数据量大。1-bit 模式:每字节 8 像素
在纯黑白模式下,每个像素只需要 1 个 bit——“黑”或”白”。所以一个字节可以存 8 个像素:800 × 480 / 8 = 48,000 字节 = 约 47KB。
2-bit 模式:4 遍渲染实现 4 级灰度
如果漫画需要灰度(4 级:白、浅灰、深灰、黑),每个像素需要 2 个 bit。一个完整的 2-bit 帧缓冲区需要47KB × 2 = 94KB——快占满 1/4 的 RAM 了。
CrossPoint 的技巧是分 4 遍渲染,复用同一块缓冲区:
本章要点
- EPUB 本质是 ZIP 包,内部是 HTML + CSS + 元数据 XML。解析流程:container.xml → content.opf → toc → 各章 XHTML。
- ZIP 流式解压使用 512 字节缓冲区边读边解压,无论书有多大,内存消耗恒定。
- 二进制缓存(book.bin / section.bin)将耗时的 XML 解析和排版结果序列化保存,第二次打开同一本书可以瞬间加载。缓存会校验所有渲染参数,任何变化都触发重建。
- HTML 增量解析使用 Expat 库的事件驱动模型,配合 1KB 缓冲区逐块处理,并注意 UTF-8 多字节字符的安全截断。
- 动态规划换行比贪心法产生更均匀的排版效果,配合像素级的两端对齐分配,在 800px 宽的墨水屏上实现高质量的文字排版。
- 四遍渲染流水线依次完成字体预热、黑白渲染、灰度抗锯齿,在有限的帧缓冲区中实现最佳显示效果。
- 阅读进度仅用 6 字节保存(spine 索引 + 页码 + 总页数),字体变更时按比例换算页码。
- 脚注跳转使用 3 层位置栈实现压栈/弹栈导航,类似浏览器的前进/后退。
- Liang 断字算法与 TeX 排版系统相同,使用预编译的二叉 Trie 实现零堆分配的多语言断字。
- TXT 流式分页用 8KB 缓冲区逐块处理,缓存每页的字节偏移量到 index.bin,实现大文件的恒定时间翻页。
- XTC 漫画在 2-bit 灰度模式下通过 4 遍渲染复用缓冲区,节省 47KB 内存。
下一章预告:第 12 章我们将探索 CrossPoint Reader 的网络功能——如何在芯片上运行 Web 服务器、实现 WebDAV 协议让 Calibre 无线传书、浏览 OPDS 在线书库,以及与 KOReader 跨设备同步阅读进度。