我在七猫做阅读器——排版篇

1、引言

随着产品不断迭代,阅读器作为一个占据用户99%使用时长的模块,承载了愈加复杂的业务功能。开发一个能供人看书的阅读软件并不困难,但是如何打造一个高可用的阅读器却是门道颇深。 本篇文章结合本人阅读器新架构实操经验,为大家阐述开发设计中的诸多细节,希望能对大家有所帮助。

2、什么是排版?

所谓排版,即是在固定页面内,将内容以适合的方式层现
对于客户端来讲,文本显示到屏幕上的过程,大体可以分为解析->排版->渲染三个步骤,其中解析是多格式以及跨平台的基础,排版重在方案与设计,部分API需要使用平台特性,而渲染过程则是主要依赖原生平台提供的API方法。
本文主要介绍文本排版过程中一些设计特性与策略抉择,以帮助读者建立对排版工作的基础认知。
(以下内容适用于iOS及安卓双端,本文仅以iOS举例阐述,尽量忽略平台及语言特性,有什么写得不清楚的地方请多多包涵。)

3、文本布局Text Layout基础概念

我们先看一下字体UIFont排版相关的基本属性:

// Font attributes
open var familyName: String { get } //字体家族的名字
open var fontName: String { get } //字体名称
open var pointSize: CGFloat { get } //字体大小
open var ascender: CGFloat { get } //升部,基准线以上的高度
open var descender: CGFloat { get } //降部,基准线以下的高度(获取时通常为负数)
open var capHeight: CGFloat { get } //大写字母的高度
open var xHeight: CGFloat { get } //小写x的高度
open var lineHeight: CGFloat { get } //当前字体下的文本行高
open var leading: CGFloat { get } //行间距

graphogram
相信iOS童鞋对这张字形图应该很熟悉了,从字形图中我们可以获知:
纯字符高度计算公式为: pointSize = ascender + | descender |
文本行高计算公式为: lineHeight = ascender + | descender | + leading
其中leading为行间距,在单行时为0,在两行及以上为正值

3.1 高度计算

在排版的时候为了美观考虑,我们需要另行添加额外的行间距lineSpace以及段落间距paragraphSpace
对于同一种字体,如果一段文字有多行(row),高度如何计算?

singleParagraphHeight = lineHeight * row + lineSpace *(row-1)

如果有两段文字,总高度又如何计算?

doubleParagraphHeight = singleParagraphHeight1 + paragraphSpace + singleParagraphHeight2

多段(paragraph)依此类推:

mutileParagraphHeight = (singleParagraphHeight1 + singleParagraphHeight2 + ...) + paragraphSpace * (paragraph - 1)

当然,最后一段的段间距也是可以加到总高度中的,但必须在排版的时候明确此特性,如果最后一段后有其他附加内容,需要另行调节。
上面列出的是比较理想的排版情况,实际上,行数row是排版计算完成后的一个结果,与设备显示/绘制宽度以及字体、字号大小有关,事先是无法确定的。

明白了文字高度的计算方法,我们就可以定义统一的文本行信息数据结构:

class TextLineInfo {
    ... // 其他文字元素相关的信息已省略
    var leftIndent: Int = 0 // 该行文字的向左缩进宽度
    var width: CGFloat = 0 // 该行文字的宽度
    var height: CGFloat = 0 // 该行文字的高度(已包含行距)
    var vSpaceBefore: CGFloat = 0 // 段落首行与上段文字额外间距,一般为0,会与vSpaceAfter共同作用于高度计算
    var vSpaceAfter: CGFloat = 0 // 段间距:段落末行与下段文字额外间距,非最后一行时此数值为0   
}

那么每一行的数据信息又从哪里来?在这里我先简单介绍一下数据获取的的方式,在事先需要依赖换行标识符将所有文字按段落拆分,然后在需要的时候填充每一段的数据信息。

3.2 文本定位

一个字符所在文本位置可以用3个参数表示:

var paragraphIndex: Int = 0 // 段落索引,文本第几段
var elementIndex: Int = 0 // 词索引,本段第几个字
var charIndex: Int = 0 // 字母索引:本单词中第几个字母

其中字母索引默认为0,在中文中此值不会变化,只有在英文单词中才有意义。
如果一个文本文件比较大,通常我们需要对起进行分章或是分节,以优化文本读取及显示性能。结合以上参数,加上章节序号ord,由此我们定位到了任意文本的具体位置坐标(ord, paragraphIndex, elementIndex, charIndex)。

3.3 段落管理

每一段的数据信息依靠段落游标来进行管理,通过以下数据结构可以灵活的填充数据以及获取指定元素:

protocol ParagraphCursorDatasource: NSObjectProtocol {
    func getParagraphCursor(_ index:Int) -> TextParagraphCursor? // 获取指定段落数据模型
    func getTextModel() -> TextModel // 获取文本数据模型
}

class TextParagraphCursor {
    weak var delegate: ParagraphCursorDatasource?
    /// 段落序号:标明是第几段
    private(set) var index: Int = 0 
    /// 存储每个段落的元素
    private(set) var myElements = [TextElement]()
    
    /// 填充元素,核心方法
    func fill() {
        // 为myElements填充元素...
    }
    /// 移除所有元素
    func clear() {
       myElements.removeAll()
    }
    /// 是否为第一段段落
    func isFirst() -> Bool {
        return index == 0
    }
    /// 是否为最后一个段落
    func isLast() -> Bool {
        guard let model = delegate?.getTextModel() else { return false }
        return index + 1 >= model.getParagraphsNumber()
    }
    /// 获取当前段落的元素个数
    func getParagraphLength() -> Int {
        return myElements.count
    }
    /// 获取前一个段落的游标
    func previous() -> TextParagraphCursor? {
        return isFirst() ? nil : delegate?.getParagraphCursor(index - 1)
    }
    /// 获取下一个段落的游标
    func next() -> TextParagraphCursor? {
        return isLast() ? nil : delegate?.getParagraphCursor(index + 1)
    }
    /// 获取当前段落的第几个元素
    func getElement(_ index: Int) -> TextElement? {
        if index > (myElements.count - 1) {
            return nil
        }
        return myElements[index]
    }
}

对于任意文本文件,在解析其编码格式后,我们可以获知其内容信息,目前市面上最通用的就是geometer大神的FBReader(FBReader有多厉害我就不多做赘述了)的解析方案,这也是众多主流阅读类产品早期的参考方案,其底层是C++书写的所以支持跨平台,可以将多种格式的数据(如txt、epub、mobi、html、doc、fb2等)统一转换成同一种数据模型,开发者可以完全不依赖其上层代码进行二次开发。

4、排版特性

4.1 计算单个字符宽高信息

文字排版的前提是要知道每一个字符(汉字、字母、数字、符号等)元素的宽高信息,如此才能决定最终的排版情况。
首先我们要知道,计算字符宽高是一个相对耗时的操作,如果每个字符都需要计算,那么一页显示文字越多,则计算耗时也就越长。
为了优化此场景,我们经过测验,同一字体和字号下的汉字字符,它们的宽度与高度是一致的;但是对于英文字母、数字以及符号,全角及半角下的宽度是不一致的。
基于以上结论,我们可以根据Unicode编码判断文字是否是中文字符,如果是,只需算出相同字体下其中一个汉字的宽高度并缓存即可;而其他元素,我们可以维护一个缓存池,将宽高缓存起来,在下次命中缓存时取出宽高即可减少重复计算。

class PaintContext {
    /// 存储富文本字体字号、文字颜色等信息
    private var attributes = [NSAttributedString.Key : Any]()
    /// chinese character width cache
    private var ccwCache = [CGFloat:CGFloat]()
    /// other character width cache
    private var ocwCache = [String:CGFloat]()
    
    func getStringWidth(_ subString: String) -> CGFloat {
        if subString.count == 1, let pointSize = (attributes[.font] as? UIFont)?.pointSize {
            if "\u{4E00}" <= subString && subString <= "\u{9FA5}" {
                if let cache = ccwCache[pointSize] {
                    return cache
                }
                let size = (subString as NSString).size(withAttributes: attributes)
                ccwCache[pointSize] = size.width
                return size.width
            } else {
                // 防止同一页有多个不同字号的相同字符串,拼接字号大小作为键值
                let cacheKey = "\(subString)_\(pointSize)"
                if let cache = ocwCache[cacheKey] {
                    return cache
                }
                let size = (subString as NSString).size(withAttributes: attributes)
                ocwCache[cacheKey] = size.width
                return size.width
            }
        }
        let size = (subString as NSString).size(withAttributes: attributes)
        return size.width
    }
}

以上并不是最完美的方案,但对于项目的优化已经非常明显了,有兴趣的小伙伴可以进一步优化上述判断减少宽高计算频次,或者有其他更好的方案也欢迎多多指教哦~

4.2 动态调整单行文字水平间距

大部分语言文本布局都是从左往右从上到下排列的,在文字排列的过程中,左端文字的起始位置固定,由于符号宽度不定导致每一行的文字数量不尽相等,所以会出现右端不对齐的情况。
出于美观考虑,我们希望每一行文字都右侧对齐,对此我们的做法是在每一行排版完成后,将最右端字符到右端绘制区域的距离均匀分配到字符间距中,这样就不会显得很突兀了。
先看一张没有调整水平间距时的显示图,由于标点符号不能在一行开头的排版规则,虽然第一行剩余的空间足够再放下一个字符,但“然”及其后的标点符号在只能放到第二行显示。
horizontal_nospace
为了保持两端对齐,在下图中第一行人为增加了字符之间的间距,而第二行由于是段落的最后一行则无需增加字间距。(由于使用的是模拟器截图所以引号看起来是半角的,真机是全角字符所占宽度会更大一些)
horizontal_space
计算过程如下:

  1. 获取实际显示宽度realWidth,初始值为屏幕宽度减去左右边距 realWidth = screenWidth - leftMargin - rightMargin
  2. 获取第一个中文全角字符宽度fullWidth,第一个半角字符宽度halfWidth
  3. 如果是第一行文字,减去首行缩进 realWidth -= 2 * fullWidth
  4. 扣除“青...当”12个字符(其中11个全角字符1个半角字符)的总宽度 realWidth -= fullWidth * 11 + halfWidth
  5. 单个字符字间距 wordSpace = realWidth / 11

动态调整字间距的时机在单行信息计算完成之后、存储单行字符位置信息之前,在渲染时直接根据存储的排版数据进行绘制,所以它不会影响其前后一行的排版结果

4.3 动态调整单页多行文字行、段间距

在实际显示过程中,每一页首行文字的纵坐标是固定的,当文字行、段间距高度不相等时,就会导致底部剩余高度不对齐,如下图所示:
unalignment-1
为了优化显示效果,实现了动态调整行、段间距的方案(很遗憾当前由于业务因素此特性已被移除),以保证最后一行文字到底部的距离为固定值。
alignent-1
动态调整字间距方案一样,动态调整行、段间距方案并不会影响当前页展示的总行数

Question

有朋友问了,以上示例是左右翻页模式下的排版情况,换成上下滑动翻页方式是怎么处理的?
这个问题的秘密就在距离底部的固定距离上。当最后一行文字非段落最后一行时,它等于行间距高度;反之则等于段间距高度;但假如是文末/章末则为0,表示无需调整。
vertical_space-1
这一排版特性不关心外界使用的到底是哪种翻页方式,保证页与页之间衔接自然、均衡。
值得一提的是,上下排布也是有页的概念的,每一页都以图片方式添加到了一个可复用的视图上,还可以根据实际需要对其裁剪,以保证视图的连贯性及滑动性能。

4.4 版面灰度分布均匀

虽然我们已经有了动态调整字距、行距、段距的方案,但是对于整体排版仍然还有很多细节可以优化。
uniform_distribution-1
上图来源于李泽磊《我在百度做阅读器》的主题分享,通过挤压全角字符宽度的方式优化文字展示效果,很值得我们团队探索及学习。

还有一些更复杂的场景,比如超大字体下的英文排版,如果遇到某些过长的英文单词,会导致页面分布比较离散。
遵循均匀分布原则,我们可以在长单词内添加连字符(中划线-)将其拆分,使其跨行显示达成目的。

5、排版策略:全排版 vs 动态排版

全排版

所谓全排版,其实就是当获取到一章数据(一本书需要先按章节分割)后,直接对其全部内容从前往后进行排版计算。与Word软件排版方式相同,当前页内容放不下的时候会自动添加下一页,直到所有文字均显示后我们就知道了总页数以及展示所有内容需要的总高度(用于计算最后一行文字到章末的剩余距离),然后缓存每一页的展示信息,接下来只需要再记录当前处于第几页(相对首页)即可。

大部分通用的阅读器用的即是此方案,由于提前将分页区间数据算出并存储了,只需操作页码计数变化,再利用获取到的文本数据调用绘制方法生成Bitmap位图即可,此过程基本不会出现明显卡顿问题。
虽然使用全排版计算非常方便,但是此方案却存在一些问题:

  1. 如果我们需要调整字体或间距等样式信息,就需要重新排版计算,一般当前页的内容都会发生改变,以改变前的第一个字定位(相距首字偏移量),需要在分页后重新计算此文字处于第几页然后跳转到这一页,在调整前后内容会出现不确定性的变化,无法在第一时间找到之前在读的位置。同类产品中微信读书、🍅小说就是用的此方案,如下图所示,每次调整字体后内容将会发生显著变化:
    wereader
  2. 如果章节内容比较长,就需要先花费不定的时间计算出全部的排版结果,打开阅读器及跳章就会变慢;
  3. 页面内容是固定的,且缺乏更精密的元素信息,假如我们需要在内容视图中实时插入其他内容,如“神段评”或者是“文字环绕广告”,就需要提前计算位置并预留出占位空间,扩展性很差。
    综上,全排版无法应对复杂的业务需求,故在重构时我们选用了更灵活的方案,即为文字动态排版方案

动态排版

动态排版实际上是根据任意字符所在位置作为起始坐标(即起始游标StartCursor),逐字排版直到绘制完本页最后一个字为止。它的动态特性在于这一页能绘制多少内容不是固定的,需要粗排版(不进行对齐修正)一次才能决定当前页结束的位置坐标(又称终止游标EndCursor),如果要绘制下一页,则需要以当前页的终止游标作为下一页起始游标往后推算,依此类推直至文本结束;反之如果是要绘制上一页,则以当前页的起始游标作为上一页的终止游标,倒着排直到放不下某一行文字时,以其下一行行首文字作为起始游标,标记检索完成,依此类推直至文本开始。

刚才全排版列了这么多问题,那么动态排版是如何解决上述问题的呢?

  1. 动态排版保持当前页的起始游标(即第一个字符)不变,当字符字体、字号或间距等发生改变时,重新排版计算结束游标,如下图所示,切换字体“只”字所在的位置并不会发生改变;
    qmreader
  2. 不需要提前将所有排版结果都计算出来,只需要根据上次记录的起始游标位置直接排版即可,自然会节省消耗;(可以对相邻页做异步预加载,但无需等待排版完成)
  3. 只需要在页面展示时判断是否需要插入对应内容即可。(如果有做页面缓存,情况会更复杂一些,在页面切换的时候需要确定缓存是否需要更新)

动态排版需要结合缓存策略使用,每次单页计算完成成缓存页信息,以节省用户来回翻页的性能开销。
由于动态排版无需计算出所有内容的排版结果,所以初始时是不知道这一章总共有多少页及当前处于全部内容的第几页的,只有开启异步任务递归算出当前页之前以及之后所有的内容才可确定其页码位置。

预加载

为了提升翻页速度及滑动流畅性,需要在当前页文字计算完成后,附加计算其前后一页内容排版情况,此为预加载过程。
其中当前若是本章第一页则需要预加载上一章最后一页,如果是最后一页则还要额外预加载下一章首页以优化切章速度,实际开发过程中需要充分利用LRU算法缓存最近看过的内容以减少重复计算量,并在异步线程中完成此计算过程。
如果是在短时间内快速滑动翻页呢?更进一步的优化是及时cancel掉不需要显示在屏幕上的任务,方案可以参考YYAsyncLayer异步绘制。

内容重复是如何产生的?

不少用户反馈了一个问题,为什么翻着翻着会发现有内容重复?
其实这是因为前翻算法的局限性,触发了补字逻辑

前翻算法的局限性

我们团队在实际开发中发现,当设置了段间距时,就会出现前后翻页数据不一致的情况。当段间距越大于标准行间距时,偏差会更加明显,这来源于我们排版算法层面的问题。
在正向(后翻)排版计算过程中,我们采用的是最大化使用剩余空间的策略,即如果最后剩余距离小于文字高度加段间距时,会去除段间距保证文字排列上去;然而在逆向(前翻)排版计算过程时,由于是倒着算就优先去除了文字的段间距,就可能会出现最上一行文字放不下的情况,即比正向计算少一行,再继续前翻则可能使此误差放大,累积下来直到第一页(第一页一定是正向排版),导致了第一页与第二页内容间的重复。

补字逻辑

左右翻页模式下,当前一页内容无法占满所有绘制空间时,为了排版的美观性,我们会将下一页的部分文字补齐到前一页中占位,这样就出现了文字重复现象。举个🌰:
某一章节第一页第二页展示结果如下:
normal-1
我们前往第二页,调整减小字号,首字位置不发生改变并重新排版,此时第一页第二页的结果如下:
duplicate-1
由于第一页的文字缩小后会空出一部分区域,所以将第二页的文字往第一页末尾补齐直到放不下下一行,而多出来的这些字就会与原第二页的文字重复。

去重

触发补字的情况下,我们其中一个解决思路是做去重处理。具体方案是比对第一页与第二页的内容,如果有交叉,则先将重复的文字去除,然后调整文字行间距与段间距,以保证最后一行文字到底部的距离固定。
remove_duplicate
去重方案仍然具有局限性。当重复内容只有一两行时,动态调整间距可能看不出来,效果也比较美观;但是如果碰到极端情况,比如这一页只有几行的情况,间距可能就会非常大,那么去重就不合适了。

重排版

重排版主要用于左右翻页方式。当往前翻页时,排版完成后发现与下一页数据有重复,则可以重新进行排版。即删除当前页之后所有的缓存结果,重新执行预加载逻辑,这样后一页一定是以当前页往后排版生成的,就不会出现重复内容了。
由于重排版会清除后续缓存,所以会造成一定的资源浪费

裁剪

裁剪只能用于上下翻页方式。在左右翻页模式下,每一页文字的最大显示区域是相同的,但是在上下翻页模式下,我们可以任意指定每一页的最大高度。为了保持统一,我们设置所有翻页模式下的单页绘制高度为一个固定值。当回翻的时候检测到重复内容,我们就可以将重复的行删除,剪掉这部分绘制区域,这样也可以达成目的。

解决方案

前翻补字导致内容重复问题我们团队也一直在探索更好的解决方案,仅靠前翻算法层面无法解决此问题,所以需要结合以上额外的特性做调整。
对于左右翻页方式我们可以依靠查重+重排版的方式,一旦发现重复内容,就可清除后置页面的所有内容缓存,使其重新开始计算;
而对于上下翻页方式可以依靠去重+裁剪的方式过滤重复内容,也就是说每一页的高度不是固定值,以实际展示内容需要的高度为基准布局。

未来规划与设计思路

在终端场景,正常看书过程并不会出现频繁切换阅读设置及回翻的行为,对于方案取舍需要灵活把握。
无限制的滥用缓存并不符合我们的预期。我们虽然可以将所有正翻计算过的信息页缓存,在不改变字体设置信息的情况下,每次翻页尝试复用缓存的结果,但缺点是此方案会增加一定的内存开销,在章节大小不明或者内容经常需要变化时会产生额外开销。
当前我们团队使用的是LRUCache缓存前后相邻多页的方式,大小固定,在后续需要调整更佳的方案,比如可以根据章节大小或字数区分长章节与短章节,动态调整缓存区大小,以维持阅读体验与性能的平衡。

6、复杂排版设计

以上介绍了文本排版过程中的一些技巧与考量,但是在电子书排版过程中我们将面临更多的挑战,比如在文稿排版中常常需要加入图片甚至是音视频,需要支持超链接跳转等,由于业务需要可能还要在内容之间插入广告、评论视图等,这都会对我们排版的结果造成影响。
对此,我们需要在设计上将这些都包含进去,将所有内容都当成一个个子元素是最佳的抽象设计方法。
以下是基于FBReader设计阅读器文本元素设计类图:
textelement
在解析过程,我们需要对源文件进行一次预处理,在序列化过程中为内容插入特定的标签,这样在反序列化时我们就可以根据标签信息将之后内容交由对应的处理类来处理。
目前的设计上主要定义了文本元素、文本样式、控制符、图片元素类型,额外提供了音视频元素类型的扩展,此外还提供了支持业务扩展的元素类型(目前可用于“神段评、作者说”排版),保证了底层结构的稳定性。

7、结语

相信读完本文,你对阅读器排版已经有了一定的了解,我们会继续将更多有关阅读器的知识整理成文,敬请关注。
有兴趣的童鞋还可以去看一下李哥的《我在百度做阅读器》主题分享,基于CoreText框架衍生的设计也是干货多多。
瑞思拜~

展示评论