電腦的組裝在生活中并不陌生,大家都有電腦,當然需求不一樣配置也不一樣。以Macbook Pro為例,像UI設計對圖像模塊GPU要求比較高,跑IDEA的對內存要求就比較高,可能會加裝32G內存更高的就是64G了。如果是對付日常的辦公,刷劇那默認的配置就已經足夠了,如8G標配。類似的在軟件開發過程中,需要根據需要自定義搭配不同的選擇來構建對象的一般能夠用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模式或建造者模式。
根據需要配置對應的文檔系統并導出文件,例如導出純文本內容.text、.html等文件。
public interface Element {
//no method
}
1.為提供默認的方法,由實現類自行決定實現何種細節。
/**
* 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;
}
}
/**
* 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;
}
}
/**
* Created by Sai
* on: 25/01/2022 00:52.
* Description:
*/
public interface DocumentBuilder {
void addText(Text text);
void addImage(Image image);
String getResult();
}
1.分別提供了兩個添加內容的方法,addText、addImage。
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.其中純文本內容不支持添加圖片,添加圖片的方法則未實現,為空方法。
public class HtmlElement {
//empty method
}
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);
}
}
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);
}
}
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();
}
}
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();
}
}
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();
}
}
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.html和sai.text文件。
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%使用時長的模塊,承載了愈加復雜的業務功能。開發一個能供人看書的閱讀軟件并不困難,但是如何打造一個高可用的閱讀器卻是門道頗深。 本篇文章結合本人閱讀器新架構實操經驗,為大家闡述開發設計中的諸多細節,希望能對大家有所幫助。
所謂排版,即是在固定頁面內,將內容以適合的方式層現。
對于客戶端來講,文本顯示到屏幕上的過程,大體可以分為解析->排版->渲染三個步驟,其中解析是多格式以及跨平臺的基礎,排版重在方案與設計,部分API需要使用平臺特性,而渲染過程則是主要依賴原生平臺提供的API方法。
本文主要介紹文本排版過程中一些設計特性與策略抉擇,以幫助讀者建立對排版工作的基礎認知。
(以下內容適用于iOS及安卓雙端,本文僅以iOS舉例闡述,盡量忽略平臺及語言特性,有什么寫得不清楚的地方請多多包涵。)
我們先看一下字體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,在兩行及以上為正值
在排版的時候為了美觀考慮,我們需要另行添加額外的行間距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個參數表示:
var paragraphIndex: Int=0 // 段落索引,文本第幾段
var elementIndex: Int=0 // 詞索引,本段第幾個字
var charIndex: Int=0 // 字母索引:本單詞中第幾個字母
其中字母索引默認為0,在中文中此值不會變化,只有在英文單詞中才有意義。
如果一個文本文件比較大,通常我們需要對起進行分章或是分節,以優化文本讀取及顯示性能。結合以上參數,加上章節序號ord,由此我們定位到了任意文本的具體位置坐標(ord, paragraphIndex, elementIndex, charIndex)。
每一段的數據信息依靠段落游標來進行管理,通過以下數據結構可以靈活的填充數據以及獲取指定元素:
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等)統一轉換成同一種數據模型,開發者可以完全不依賴其上層代碼進行二次開發。
文字排版的前提是要知道每一個字符(漢字、字母、數字、符號等)元素的寬高信息,如此才能決定最終的排版情況。
首先我們要知道,計算字符寬高是一個相對耗時的操作,如果每個字符都需要計算,那么一頁顯示文字越多,則計算耗時也就越長。
為了優化此場景,我們經過測驗,同一字體和字號下的漢字字符,它們的寬度與高度是一致的;但是對于英文字母、數字以及符號,全角及半角下的寬度是不一致的。
基于以上結論,我們可以根據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
}
}
以上并不是最完美的方案,但對于項目的優化已經非常明顯了,有興趣的小伙伴可以進一步優化上述判斷減少寬高計算頻次,或者有其他更好的方案也歡迎多多指教哦~
大部分語言文本布局都是從左往右從上到下排列的,在文字排列的過程中,左端文字的起始位置固定,由于符號寬度不定導致每一行的文字數量不盡相等,所以會出現右端不對齊的情況。
出于美觀考慮,我們希望每一行文字都右側對齊,對此我們的做法是在每一行排版完成后,將最右端字符到右端繪制區域的距離均勻分配到字符間距中,這樣就不會顯得很突兀了。
先看一張沒有調整水平間距時的顯示圖,由于標點符號不能在一行開頭的排版規則,雖然第一行剩余的空間足夠再放下一個字符,但“然”及其后的標點符號在只能放到第二行顯示。
為了保持兩端對齊,在下圖中第一行人為增加了字符之間的間距,而第二行由于是段落的最后一行則無需增加字間距。(由于使用的是模擬器截圖所以引號看起來是半角的,真機是全角字符所占寬度會更大一些)
計算過程如下:
動態調整字間距的時機在單行信息計算完成之后、存儲單行字符位置信息之前,在渲染時直接根據存儲的排版數據進行繪制,所以它不會影響其前后一行的排版結果。
在實際顯示過程中,每一頁首行文字的縱坐標是固定的,當文字行、段間距高度不相等時,就會導致底部剩余高度不對齊,如下圖所示:
為了優化顯示效果,實現了動態調整行、段間距的方案(很遺憾當前由于業務因素此特性已被移除),以保證最后一行文字到底部的距離為固定值。
同動態調整字間距方案一樣,動態調整行、段間距方案并不會影響當前頁展示的總行數。
Question
有朋友問了,以上示例是左右翻頁模式下的排版情況,換成上下滑動翻頁方式是怎么處理的?
這個問題的秘密就在距離底部的固定距離上。當最后一行文字非段落最后一行時,它等于行間距高度;反之則等于段間距高度;但假如是文末/章末則為0,表示無需調整。
這一排版特性不關心外界使用的到底是哪種翻頁方式,保證頁與頁之間銜接自然、均衡。
值得一提的是,上下排布也是有頁的概念的,每一頁都以圖片方式添加到了一個可復用的視圖上,還可以根據實際需要對其裁剪,以保證視圖的連貫性及滑動性能。
4.4 版面灰度分布均勻
雖然我們已經有了動態調整字距、行距、段距的方案,但是對于整體排版仍然還有很多細節可以優化。
上圖來源于李澤磊《我在百度做閱讀器》的主題分享,通過擠壓全角字符寬度的方式優化文字展示效果,很值得我們團隊探索及學習。
還有一些更復雜的場景,比如超大字體下的英文排版,如果遇到某些過長的英文單詞,會導致頁面分布比較離散。
遵循均勻分布原則,我們可以在長單詞內添加連字符(中劃線-)將其拆分,使其跨行顯示達成目的。
所謂全排版,其實就是當獲取到一章數據(一本書需要先按章節分割)后,直接對其全部內容從前往后進行排版計算。與Word軟件排版方式相同,當前頁內容放不下的時候會自動添加下一頁,直到所有文字均顯示后我們就知道了總頁數以及展示所有內容需要的總高度(用于計算最后一行文字到章末的剩余距離),然后緩存每一頁的展示信息,接下來只需要再記錄當前處于第幾頁(相對首頁)即可。
大部分通用的閱讀器用的即是此方案,由于提前將分頁區間數據算出并存儲了,只需操作頁碼計數變化,再利用獲取到的文本數據調用繪制方法生成Bitmap位圖即可,此過程基本不會出現明顯卡頓問題。
雖然使用全排版計算非常方便,但是此方案卻存在一些問題:
動態排版實際上是根據任意字符所在位置作為起始坐標(即起始游標StartCursor),逐字排版直到繪制完本頁最后一個字為止。它的動態特性在于這一頁能繪制多少內容不是固定的,需要粗排版(不進行對齊修正)一次才能決定當前頁結束的位置坐標(又稱終止游標EndCursor),如果要繪制下一頁,則需要以當前頁的終止游標作為下一頁起始游標往后推算,依此類推直至文本結束;反之如果是要繪制上一頁,則以當前頁的起始游標作為上一頁的終止游標,倒著排直到放不下某一行文字時,以其下一行行首文字作為起始游標,標記檢索完成,依此類推直至文本開始。
剛才全排版列了這么多問題,那么動態排版是如何解決上述問題的呢?
動態排版需要結合緩存策略使用,每次單頁計算完成成緩存頁信息,以節省用戶來回翻頁的性能開銷。
由于動態排版無需計算出所有內容的排版結果,所以初始時是不知道這一章總共有多少頁及當前處于全部內容的第幾頁的,只有開啟異步任務遞歸算出當前頁之前以及之后所有的內容才可確定其頁碼位置。
為了提升翻頁速度及滑動流暢性,需要在當前頁文字計算完成后,附加計算其前后一頁內容排版情況,此為預加載過程。
其中當前若是本章第一頁則需要預加載上一章最后一頁,如果是最后一頁則還要額外預加載下一章首頁以優化切章速度,實際開發過程中需要充分利用LRU算法緩存最近看過的內容以減少重復計算量,并在異步線程中完成此計算過程。
如果是在短時間內快速滑動翻頁呢?更進一步的優化是及時cancel掉不需要顯示在屏幕上的任務,方案可以參考YYAsyncLayer異步繪制。
不少用戶反饋了一個問題,為什么翻著翻著會發現有內容重復?
其實這是因為前翻算法的局限性,觸發了補字邏輯。
我們團隊在實際開發中發現,當設置了段間距時,就會出現前后翻頁數據不一致的情況。當段間距越大于標準行間距時,偏差會更加明顯,這來源于我們排版算法層面的問題。
在正向(后翻)排版計算過程中,我們采用的是最大化使用剩余空間的策略,即如果最后剩余距離小于文字高度加段間距時,會去除段間距保證文字排列上去;然而在逆向(前翻)排版計算過程時,由于是倒著算就優先去除了文字的段間距,就可能會出現最上一行文字放不下的情況,即比正向計算少一行,再繼續前翻則可能使此誤差放大,累積下來直到第一頁(第一頁一定是正向排版),導致了第一頁與第二頁內容間的重復。
左右翻頁模式下,當前一頁內容無法占滿所有繪制空間時,為了排版的美觀性,我們會將下一頁的部分文字補齊到前一頁中占位,這樣就出現了文字重復現象。舉個:
某一章節第一頁第二頁展示結果如下:
我們前往第二頁,調整減小字號,首字位置不發生改變并重新排版,此時第一頁第二頁的結果如下:
由于第一頁的文字縮小后會空出一部分區域,所以將第二頁的文字往第一頁末尾補齊直到放不下下一行,而多出來的這些字就會與原第二頁的文字重復。
去重
觸發補字的情況下,我們其中一個解決思路是做去重處理。具體方案是比對第一頁與第二頁的內容,如果有交叉,則先將重復的文字去除,然后調整文字行間距與段間距,以保證最后一行文字到底部的距離固定。
去重方案仍然具有局限性。當重復內容只有一兩行時,動態調整間距可能看不出來,效果也比較美觀;但是如果碰到極端情況,比如這一頁只有幾行的情況,間距可能就會非常大,那么去重就不合適了。
重排版主要用于左右翻頁方式。當往前翻頁時,排版完成后發現與下一頁數據有重復,則可以重新進行排版。即刪除當前頁之后所有的緩存結果,重新執行預加載邏輯,這樣后一頁一定是以當前頁往后排版生成的,就不會出現重復內容了。
由于重排版會清除后續緩存,所以會造成一定的資源浪費。
裁剪只能用于上下翻頁方式。在左右翻頁模式下,每一頁文字的最大顯示區域是相同的,但是在上下翻頁模式下,我們可以任意指定每一頁的最大高度。為了保持統一,我們設置所有翻頁模式下的單頁繪制高度為一個固定值。當回翻的時候檢測到重復內容,我們就可以將重復的行刪除,剪掉這部分繪制區域,這樣也可以達成目的。
前翻補字導致內容重復問題我們團隊也一直在探索更好的解決方案,僅靠前翻算法層面無法解決此問題,所以需要結合以上額外的特性做調整。
對于左右翻頁方式我們可以依靠查重+重排版的方式,一旦發現重復內容,就可清除后置頁面的所有內容緩存,使其重新開始計算;
而對于上下翻頁方式可以依靠去重+裁剪的方式過濾重復內容,也就是說每一頁的高度不是固定值,以實際展示內容需要的高度為基準布局。
在終端場景,正常看書過程并不會出現頻繁切換閱讀設置及回翻的行為,對于方案取舍需要靈活把握。
無限制的濫用緩存并不符合我們的預期。我們雖然可以將所有正翻計算過的信息頁緩存,在不改變字體設置信息的情況下,每次翻頁嘗試復用緩存的結果,但缺點是此方案會增加一定的內存開銷,在章節大小不明或者內容經常需要變化時會產生額外開銷。
當前我們團隊使用的是LRUCache緩存前后相鄰多頁的方式,大小固定,在后續需要調整更佳的方案,比如可以根據章節大小或字數區分長章節與短章節,動態調整緩存區大小,以維持閱讀體驗與性能的平衡。
以上介紹了文本排版過程中的一些技巧與考量,但是在電子書排版過程中我們將面臨更多的挑戰,比如在文稿排版中常常需要加入圖片甚至是音視頻,需要支持超鏈接跳轉等,由于業務需要可能還要在內容之間插入廣告、評論視圖等,這都會對我們排版的結果造成影響。
對此,我們需要在設計上將這些都包含進去,將所有內容都當成一個個子元素是最佳的抽象設計方法。
以下是基于FBReader設計閱讀器文本元素設計類圖:
在解析過程,我們需要對源文件進行一次預處理,在序列化過程中為內容插入特定的標簽,這樣在反序列化時我們就可以根據標簽信息將之后內容交由對應的處理類來處理。
目前的設計上主要定義了文本元素、文本樣式、控制符、圖片元素類型,額外提供了音視頻元素類型的擴展,此外還提供了支持業務擴展的元素類型(目前可用于“神段評、作者說”排版),保證了底層結構的穩定性。
相信讀完本文,你對閱讀器排版已經有了一定的了解,我們會繼續將更多有關閱讀器的知識整理成文,敬請關注。
有興趣的童鞋還可以去看一下李哥的《我在百度做閱讀器》主題分享,基于CoreText框架衍生的設計也是干貨多多。
作者:彭章錕
出處:https://tech.qimao.com/reader/
*請認真填寫需求信息,我們會在24小時內與您取得聯系。