整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

「设计模式(六) - Builder模式」

「设计模式(六) - Builder模式」

、可定制化的

电脑的组装在生活中并不陌生,大家都有电脑,当然需求不一样配置也不一样。以Macbook Pro为例,像UI设计对图像模块GPU要求比较高,跑IDEA的对内存要求就比较高,可能会加装32G内存更高的就是64G了。如果是对付日常的办公,刷剧那默认的配置就已经足够了,如8G标配。类似的在软件开发过程中,需要根据需要自定义搭配不同的选择来构建对象的一般能够用Builder模式很好的解释,看看具体的定义是怎样的。

二、Builder模式

维基百科

The builder pattern is a design pattern designed to provide a flexible solution to various object creation problems in object-oriented programming. The intent of the Builder design pattern is to separate the construction of a complex object from its representation. It is one of the Gang of Four design patterns.

通俗地表示:即将一个复杂对象的构建与表示相分离开,同样的构建过程根据需要可以创建不同的表示。称之为Builder模式建造者模式

三、结构组成

  • 结构类图:

  • 构造器接口Builder:通常会包含构建部件模块的抽象方法,以及对象创建完成后的结果方法getResult()
  • 构造器具体实现类ConcreteBuilder:对Builder接口的具体实现,构建产品Product各个部件,组装成完整的产品对象,并通过getResult()返回具体的对象。
  • 产品的抽象接口Product:需要构造的对象,一般包含多个组成部分,构成较为复杂的对象。
  • 定制者(指挥者)Director:决定了Product的具体创建组成,例如包含何种组件。指导出Product复杂对象的创建,类图表示较为清晰;一般使用Builder来完成创建过程。

四、代码实现

1.设计一个简单的文件导出系统

根据需要配置对应的文档系统并导出文件,例如导出纯文本内容.text.html等文件。

  • 元素Element对应这里的Product,即产品的接口
public interface Element {
//no method
}

1.为提供默认的方法,由实现类自行决定实现何种细节。

  • 元素Element具体实现类-文本
/**
* Created by Sai
* on: 25/01/2022 00:48.
* Description:
*/
public class Text implements Element {
private final String content;
public Text(String content) {
this.content=content;
}
public String getContent() {
return content;
}
}
  • 元素Element具体实现类-图片
/**
* Created by Sai
* on: 25/01/2022 00:49.
* Description:
*/
public class Image implements Element {
private final String source;
public Image(String source) {
this.source=source;
}
public String getSource() {
return source;
}
}
  • 建造器接口Builder,这里既然是文档类型的建造器,命名规范一点的话则为DocumentBuilder,下同:
/**
* Created by Sai
* on: 25/01/2022 00:52.
* Description:
*/
public interface DocumentBuilder {
void addText(Text text);
void addImage(Image image);
String getResult();
}

1.分别提供了两个添加内容的方法,addTextaddImage

2.提供一个返回具体内容的方法,getResult

  • 建造器的具体实现-纯文本文档建造器
public class TextDocumentBuilder implements DocumentBuilder {
private final StringBuilder builder=new StringBuilder();
@Override
public void addText(Text text) {
builder.append(text.getContent());
}
@Override
public void addImage(Image image) {
//empty implements
}
@Override
public String getResult() {
return builder.toString();
}
}

1.其中纯文本内容不支持添加图片,添加图片的方法则未实现,为空方法。

  • 由于html结构相对于纯文本来说较为复杂,包括标签,段落等等,如果完全依赖于Element不利于扩展,实现起来比较麻烦,因此对于html类型的文档文件,单独实现自己的一套元素组件。以Element为媒介动态添加进去。
  • 单独实现html特有的元素HtmlElement
public class HtmlElement {
//empty method
}
  • Html图片元素组件HtmlImage继承自HtmlElement
public class HtmlImage extends HtmlElement {
private final String source;
public HtmlImage(String source) {
this.source=source;
}
@Override
public String toString() {
return String.format("<img src=\"%s\" />", source);
}
}
  • Html段落文本组件HtmlParagraph同样继承自HtmlElement
public class HtmlParagraph extends HtmlElement {
private final String text;
public HtmlParagraph(String text) {
this.text=text;
}
@Override
public String toString() {
return String.format("<p>%s</p>", text);
}
}
  • 一份完整的Html文档可能包含多种HtmlParagraphHtmlImage的集合,定义html文档文件类-HtmlDocument
public class HtmlDocument {
private final List<HtmlElement> elements=new ArrayList<>();
public void add(HtmlElement element) {
elements.add(element);
}
@Override
public String toString() {
var builder=new StringBuilder();
builder.append("<html>");
for (HtmlElement element : elements)
builder.append(element.toString());
builder.append("</html>");
return builder.toString();
}
}
  • 对于html建造器的实现:由于html文档的特殊性,虽然依据类图的实现流程需要实现DocumentBuilder接口,显然仅仅DocumentBuilder中方法并不能很好的满足需求的定制,但是搭配重新定义一套html文档组装规则(HtmlDocument、HtmlElement、HtmlImage、HtmlParagraph)则能够很好的完成扩展:
public class HtmlDocumentBuilder implements DocumentBuilder {

private final HtmlDocument document=new HtmlDocument();
@Override
public void addText(Text text) {
document.add(new HtmlParagraph(text.getContent()));
}
@Override
public void addImage(Image image) {
document.add(new HtmlImage(image.getSource()));
}
@Override
public String getResult() {
return document.toString();
}
}
  • 完整的文档导出类-Document
public class Document {
private final List<Element> elements=new ArrayList<>();
public void add(Element element) {
elements.add(element);
}
public void export(DocumentBuilder builder, String fileName) throws IOException {
for (Element element : elements) {
if (element instanceof Text)
builder.addText((Text) element);
else if (element instanceof Image)
builder.addImage((Image) element);
}
var writer=new FileWriter(fileName);
writer.write(builder.getResult());
writer.close();
}
}
  • 测试Demo
public class Demo {
public static void show() throws IOException {
var document=new Document();
document.add(new Text("\n\n\n\n快乐二狗\nphp才是最好的语言\n"));
document.add(new Image("pic1.jpg"));
document.export(new HtmlDocumentBuilder(), "sai.html");
//文档不添加图片
document.export(new TextDocumentBuilder(), "sai.txt");
}
public static void main(String[] args) throws IOException {
show();
}
}

1.简单的添加了一个文本内容与一个“图片内容”,分别组成了纯文本的文档Html的文档,但文本的文档是没有添加图片的。

2.导出的文件在同包下,sai.htmlsai.text文件。

  • “纯文本文档”内容

  • “html”文档

2.优缺点与局限性思考

1.Builder模式很好的将构建与表现相分离,客户端或者Director可以根据需要灵活的选择,在同一套构建算法下产生不同的产品,使得表现形式与生产的耦合程度降低。

2.具体的构建细节包含在系统内部,客户端仅仅只需要通过对Builder接口的调用即可完成创建所需要的对象,降低了系统出错的风险。

3.加强了代码的复用性。

4.当然缺点也是很明显的,如果对象太过于复杂,组装的配件过多往往不好掌控,造成臃肿,结构也不是很清晰。

5.如果改变产品原有的实现,那么整套流程都需要做出相应的调整,假设产品本身过于复杂,那么对于后期的维护是很不利的。在考虑使用时应根据实际情况,对于复杂且变化频繁的对象并不适合使用。

五、实际应用中的变形

实际使用过程中往往简化了标准化的构建流程,当然也是根据具体的业务场景,一般会省略了Director指导类,Builder接口以及具体的ConcreteBuilder实现类,而直接将Builder作为内部类实现在了目标产品类之中。根据调用者的选择得到不同的产品,当然这种比较单一,可以说是最为简单的一种。仅仅是一种思想上的应用,或者说也是一种“取巧”的做法。

  • 自身项目中,对于简单筛选对象的定制:
public class FilterGroup {
private int id;
private String title;
private boolean supportMultiSelected;
private List<FilterOrderDTO> filterFactors;
private FilterGroup(Builder builder) {
if (builder==null) {
return;
}
this.id=builder.id;
this.title=builder.title;
this.supportMultiSelected=builder.supportMultiSelected;
this.filterFactors=builder.filterFactors;
}
public int getId() {
return id;
}
public String getTitle() {
return title;
}
public boolean isSupportMultiSelected() {
return supportMultiSelected;
}
public List<FilterOrderDTO> getFilterFactors() {
return filterFactors;
}
public static class Builder {
private int id;
private String title;
private boolean supportMultiSelected;
private List<FilterOrderDTO> filterFactors;
public Builder setId(int id) {
this.id=id;
return this;
}
public Builder setTitle(String title) {
this.title=title;
return this;
}
public Builder setSupportMultiSelected(boolean supportMultiSelected) {
this.supportMultiSelected=supportMultiSelected;
return this;
}
public Builder setFilterFactors(List<FilterOrderDTO> filterFactors) {
this.filterFactors=filterFactors;
return this;
}
public FilterGroup build() {
return new FilterGroup(this);
}
}
}

1.Builder以静态内部类的形式存在于产品的内部,而产品的特征则是:不同的构建组成所对应的产品不同,表现出来的性质也是不同的。其次Builder中只提供了set方法,而没有get方法。

2.目标产品FilterGroup的构造方法是私有的,并且以Builder为参数。另外只提供了get方法而没有set方法。

3.即对于目标的产品的构建是通过Builder来完成的,对于用户它仅仅是“可读的”。

4.像实际开发中,这种分部分构建产品与整体相分离的可以考虑使用Builder模式,核心思想即为整体构建与部件之间的分离,松散产品构建产品表示之间的耦合。


近工作需要一直在和浏览器打交道。每天都为如何解决那些浏览器间的兼容性而困扰。时间长了自然而然对浏览器也产生了感情。准备学习学习,自己写个浏览器。为此开始学习了 Rust,一门用于写底层,但看上去又像高级语言的语言。希望 Rust 能有美好的明天,我也跟着受益。

想了想,要写浏览器,首先应该了解一下浏览器内部机制。今天先放下代码,带大家一起走进浏览器,看看浏览器是如何将网页呈现给您的。


绑定:使用系统级别的 API,将内存中位图绘制到指定窗口(标签对应的网页视图)上。

渲染:解析 html 和 css 生成渲染树,将合并后,将渲染树绘制到屏幕上呈现给用户。

平台:兼容(适配)到不同的操作系统

javascript VM :以后单讲,准备写个demo


首先将 HTML 和 CSS 解析为一定的数据结构(渲染对象),然后再将渲染对象按一定规则(就是将 style 树 合并到 dom 树上)形成渲染树,接下来对生成渲染树各个节点进行布局(也就是按 dom 节点的位置信息进行排版),最后读取渲染树,绘制成图片放到屏幕上。

HTML 的解析


首先浏览器是以超强纠错形式来解析 html,即便 html 有错误,浏览器也相对智能地将 html 进行解析,所以说对 html 的解析不是一般简单解析工作,html 解析要相对复杂。在解析过程是可以被 js 或其他原因所中断的。例如网络不畅通,link 和 style 标签加载,相对高级的浏览器为提高效率,提供一定进程进行预解析,也可以加载图这样耗时的工作可以另一个进程中完成


Parser 和 Tokenizer 其实只是把无意义的字符流变得有某种意义而已。Parse 这个词其实可以用在很多的地方,比如说只要你能在一个字符流中标识出所有的字符 a,你就在做 Tokenize 和 Parse。你可以看得出,Parse 和 Tokenize 有多难实际是针对编程的人的目的来说的。

一般解析完了这种形式

html
|-----head
-----body
|--- p. wat
|        #text
---- div
---- span
---- #text
HTMLHtmlElement
|-----HTMLHeadElement
-----HTMLBodyElement
|--- HTMLParagraphElement
|      ----Text
        ---- HTMLDivElement
                ---- HTMLSpanElement
                      ---- Text

下面例子只为说明浏览器解析 html 时的纠错能力,html 中错误显而易见,我就不一一指出了。




javascript 是可以介入 html 解析过程中,如下图。

、引言

随着产品不断迭代,阅读器作为一个占据用户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 } //行间距



相信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 动态调整单行文字水平间距

大部分语言文本布局都是从左往右从上到下排列的,在文字排列的过程中,左端文字的起始位置固定,由于符号宽度不定导致每一行的文字数量不尽相等,所以会出现右端不对齐的情况。
出于美观考虑,我们希望每一行文字都右侧对齐,对此我们的做法是在每一行排版完成后,将最右端字符到右端绘制区域的距离均匀分配到字符间距中,这样就不会显得很突兀了。
先看一张没有调整水平间距时的显示图,由于
标点符号不能在一行开头的排版规则,虽然第一行剩余的空间足够再放下一个字符,但“然”及其后的标点符号在只能放到第二行显示。


为了保持两端对齐,在下图中第一行人为增加了字符之间的间距,而第二行由于是段落的最后一行则无需增加字间距。(由于使用的是模拟器截图所以引号看起来是半角的,真机是全角字符所占宽度会更大一些)


计算过程如下:

  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 动态调整单页多行文字行、段间距

在实际显示过程中,每一页首行文字的纵坐标是固定的,当文字行、段间距高度不相等时,就会导致底部剩余高度不对齐,如下图所示:


为了优化显示效果,实现了
动态调整行、段间距的方案(很遗憾当前由于业务因素此特性已被移除),以保证最后一行文字到底部的距离为固定值。


动态调整字间距方案一样,动态调整行、段间距方案并不会影响当前页展示的总行数

Question

有朋友问了,以上示例是左右翻页模式下的排版情况,换成上下滑动翻页方式是怎么处理的?
这个问题的秘密就在距离底部的固定距离上。当最后一行文字非段落最后一行时,它等于行间距高度;反之则等于段间距高度;但假如是文末/章末则为0,表示无需调整。


这一排版特性不关心外界使用的到底是哪种翻页方式,保证页与页之间衔接自然、均衡。
值得一提的是,上下排布也是有页的概念的,每一页都以图片方式添加到了一个可复用的视图上,还可以根据实际需要对其裁剪,以保证视图的连贯性及滑动性能。

4.4 版面灰度分布均匀

虽然我们已经有了动态调整字距、行距、段距的方案,但是对于整体排版仍然还有很多细节可以优化。


上图来源于李泽磊《我在百度做阅读器》的主题分享,通过挤压全角字符宽度的方式优化文字展示效果,很值得我们团队探索及学习。

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

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

全排版

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

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

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

动态排版

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

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

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

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

预加载

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

内容重复是如何产生的?

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

前翻算法的局限性

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

补字逻辑

左右翻页模式下,当前一页内容无法占满所有绘制空间时,为了排版的美观性,我们会将下一页的部分文字补齐到前一页中占位,这样就出现了文字重复现象。举个:
某一章节第一页第二页展示结果如下:


我们前往第二页,调整减小字号,
首字位置不发生改变并重新排版,此时第一页第二页的结果如下:


由于第一页的文字缩小后会空出一部分区域,所以将第二页的文字往第一页末尾补齐直到放不下下一行,而多出来的这些字就会与原第二页的文字重复。

去重

触发补字的情况下,我们其中一个解决思路是做去重处理。具体方案是比对第一页与第二页的内容,如果有交叉,则先将重复的文字去除,然后调整文字行间距与段间距,以保证最后一行文字到底部的距离固定。


去重方案仍然具有局限性。当重复内容只有一两行时,动态调整间距可能看不出来,效果也比较美观;但是如果碰到极端情况,比如这一页只有几行的情况,间距可能就会非常大,那么去重就不合适了。

重排版

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

裁剪

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

解决方案

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

未来规划与设计思路

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

6、复杂排版设计

以上介绍了文本排版过程中的一些技巧与考量,但是在电子书排版过程中我们将面临更多的挑战,比如在文稿排版中常常需要加入图片甚至是音视频,需要支持超链接跳转等,由于业务需要可能还要在内容之间插入广告、评论视图等,这都会对我们排版的结果造成影响。
对此,我们需要在设计上将这些都包含进去,将所有内容都当成一个个子元素是最佳的抽象设计方法。
以下是基于FBReader设计阅读器文本元素设计类图:


在解析过程,我们需要对源文件进行一次预处理,在序列化过程中为内容插入特定的标签,这样在反序列化时我们就可以根据标签信息将之后内容交由对应的处理类来处理。
目前的设计上主要定义了文本元素、文本样式、控制符、图片元素类型,额外提供了音视频元素类型的扩展,此外还提供了支持业务扩展的元素类型(目前可用于“神段评、作者说”排版),保证了底层结构的稳定性。

7、结语

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

作者:彭章锟

出处:https://tech.qimao.com/reader/