WWDC 2018:TextKit 最佳實踐

知識小集發表於2018-06-18

WWDC 2018 Session 221: TextKit Best Practices

作者簡介:@halohily,網易有道 iOS 開發工程師。掘金主頁:halohily

引言

文字內容在 app 內隨處可見,展示文字的方式也是多種多樣。關注過效能提升的同學會發現,文字控制元件的高效使用對於整個頁面效能的提升至關重要。為此,蘋果和開發者都在不斷努力。比如蘋果日漸完善的文字框架,以及第三方文字框架的代表 YYText

這個 session 旨在指導開發者如何正確地使用 TextKit 進行文字內容的展示,循序漸進分為三個部分:

  • 核心理論
  • 用以演示理論的小例子
  • 綜合運用的優秀實戰案例

一、核心理論

1.1 什麼是 TextKit ?

和平時使用的框架有些不同,我們不需要使用 import 關鍵字來匯入 TextKit。包含 UILabelUITextField 等控制元件的 UIKit 框架(用於 iOS),以及包含 NSTextView 等控制元件的 AppKit 框架(用於 Mac OS),都是基於 TextKit 構建。在使用上面的文字控制元件時,其實就是在使用 TextKit,它協同 Core TextCore Graphics 以及 Foundation,一起為我們的 app 提供強大的文字展示能力。

WWDC 2018:TextKit 最佳實踐

利用 TextKit 的能力,你可以非常容易地展示下面風格各異的文字。

WWDC 2018:TextKit 最佳實踐

1.2 選擇正確的控制元件

對於不同型別的文字,我們需要選擇合適的文字控制元件。那麼該如何決定呢?蘋果為我們提供了比較明確的指導,如下圖所示。在使用 UIKitAppKit 時,情形會稍有不同,所以分開進行描述。

  • UIKit 的選擇路徑:

WWDC 2018:TextKit 最佳實踐

  • AppKit 的選擇路徑:

WWDC 2018:TextKit 最佳實踐

圖中的描述非常清晰易讀。需要注意的是,UILabel 用來展示較少的文字內容或者較少的行數,然而,在 AppKit 框架下是沒有 Label 控制元件的,這時可以選擇 NSTextField 控制元件,通過禁用文字編輯屬性,來獲得和 UILabel 一樣的特性。

1.2.1 文字繪製(string drawing)的正確使用

有的時候,大家可能為了獲得更優的效能(避免生成過多的檢視物件例項),通過呼叫如下方法來使用文字繪製:

func draw(at: CGPoint)

func draw(in: CGRect)

func draw(with: CGRect,
options: NSStringDrawingOptions = [],
context: NSStringDrawingContext?)
複製程式碼

然而,蘋果並不推薦經常這樣使用。如果你依然需要使用的話,蘋果也貼心地給出了一些建議:

  • 儘量用於數量較少的文字
  • 限制呼叫 draw 方法的頻率(儘量減少呼叫次數)
  • 限制定製化屬性的數量(儘量減少定製化屬性)

為什麼這種使用方式不被推薦呢?首先是因為 UILabelUITextView 等控制元件提供了良好的快取機制,所以在合適的時候選擇這些控制元件,反而可以獲得更好的效能(相較 string drawing 而言),特別是在使用自動佈局的時候。

繪製 attributed string時,如果過多地呼叫 draw 方法,會明顯地降低效能。因為系統在每次繪製之前需要釋放之前所有的 attribute 物件。因此,對於額外的 attribute,請儘量在確定它們的視覺效果(例如字型、顏色)時才進行繪製。

最後,蘋果還是不忘強調,如果使用了 string drawing,就會失去下圖所示的文字控制元件提供的所有特性。因此,請儘可能地使用文字控制元件。

WWDC 2018:TextKit 最佳實踐

1.3 選擇正確的定製要點

1.3.1 TextKit 的架構組成

Cocoa 下的許多元件一樣,TextKit 也是基於 “model - view - controller” 設計結構的。並且這三層又各自包含 storage、layout、和 display 模組:

WWDC 2018:TextKit 最佳實踐

  • Storage

深入瞭解一下各個部分的組成,首先是與 Model 層通訊的 Storage 模組,它包含的 NSTextStorage 持有字串的資料和屬性資訊。值得注意的是,它是MutableAttributedString 的子類,因此使用方式和我們熟知的 AttributedString 一致。而 NSTextContainer 則負責模型化文字佈局的地理位置、區域資訊。

WWDC 2018:TextKit 最佳實踐

  • Display

接下來是 Display 模組,它和 View 層通訊。這個模組我們通常關注的是文字控制元件的正確選擇問題。

  • Layout

最後是 Layout 模組,它和 Controller 層進行通訊。 NSLayoutManager 是這個模組唯一的組成部分。它的強大讓蘋果用“野獸”來形容。它是整個展示過程的“大腦”,控制自己的佈局過程。

WWDC 2018:TextKit 最佳實踐

1.3.2 佈局過程

這是文字佈局過程的概覽圖:

WWDC 2018:TextKit 最佳實踐

  • 屬性修正

文字佈局發生在 TextStorage 進行屬性修正之後。對於這個過程中的工作,舉個例子,確保這段文字所選擇的字型支援顯示文字中的所有字元,如果發現不支援的字元,則進行相應替換。比如上圖中的 Tempura (天麩羅) is a tasty Japanese food. ? 這段文字,字型指定了 Times New Roman。然而,這個字型是不支援日語字元和 emoji 字元的。因此,在屬性修正過程中,日語字元被指定了支援日語的 Hiragino Mincho ProN 字型,而 emoji 字元則被指定了 Apple Color Emoji 字型。

  • glyphcharacter

屬性修正完成後,佈局過程就開始了。這裡對上述概念的含義做一些說明。

character 中文譯為“字元”,字元是可以轉換為二進位制儲存的通用資料,而 glyph 可以譯為字元的視覺表示符號。同一個 character 呈現在螢幕上,可以表現為不同的字型、視覺風格。而這些各異的視覺風格,就是由 glyph 來負責呈現,glyph 的生成,就是為指定了視覺效果(如字型)的字元確定展示所需的 glyph 的過程。下圖是一個示例:

WWDC 2018:TextKit 最佳實踐

可以看到,characterglyph 的對應關係不總是一對一的。圖中的字串 “ffi” 由三個字元組成,但整個字串可以由一個 glyph 表示。再看下圖的例子,一個單獨的字元 “n”,也可以由兩個 glyph 來表示。

WWDC 2018:TextKit 最佳實踐

關於這部分概念,提供一篇參考資料:iOS 排版概念

再回到佈局過程的圖示中來,glyph 佈局,就是 NSLayoutManager 在檢視上擺放 glyph 的過程。

1.4 選擇正確的配置

如下圖是 一個完整 TextKit 元件的標準配置結構:Text Container 持有 Text View 的弱引用,而 Text View 通過根 Text Storage持有整個佈局樹結構。

WWDC 2018:TextKit 最佳實踐

如果有多個文字頁面或者文字行需要佈局,可以使用成對的 Text ContainerText View 組合,每一對組合對應一個頁面或者一行。在這種情況下,我們可以 hook 同樣的 container 和 text view 來共享佈局資訊。

WWDC 2018:TextKit 最佳實踐

文字內容被新增之後,它鋪滿由第一個 text container 定義的區域。文字在 text view 上和 text container 成對展示。 當沒有剩餘空間時,新的 container 連同 text view 一起被新增,並且文字在第二個頁面或者文字行進行展示。

多個 layout manager 允許你對同樣的文字有多種不同的顯示效果。這個文字在不同的檢視上可以有彼此不同且獨立的佈局和分組,下圖是這種模式下的結構示意和效果示意。方框內的文字內容相同,但展示效果是不同的。

WWDC 2018:TextKit 最佳實踐

1.5 選擇正確的定製實現方式

就像錘子在工具箱中的重要地位一樣,我們在開發時也有一些地位等同於錘子的工具。

  • 代理 就像基本的錘子,大多數時候,它可以很好地完成工作。
  • 通知 也是一個有效的工具。
  • 最後,子類化 同樣是一把利器。它幾乎可以作任何事。

對於這些方式的使用場景,在第二部分會運用具體例子進行闡述。

二、具體示例

文字元件在 app 中是無處不在的。在這部分,蘋果使用了 iOS 的 Apple News 和 Mac OS 的TextEditOur Journal 三個 app 中的具體頁面作為示例來對前面所述的核心理論進行講解。

2.1 Apple News on iOS

這部分內容比較簡單。主要用來示意 Choosing the right control 這條理論。裡面主要的知識點如下:

  • 對於一行顏色不一樣的文字,可以使用兩個 UILabel 進行展示,也可以藉助 NSAttributedString 來實現。
  • UITextViewUIScrollView 的子類,預設支援滑動,如果想讓它與自動佈局良好協作地話,需要禁用滑動。

2.2 TextEdit on macOS

這部分主要用來示意 Choosing the right configuration 這條理論。

WWDC 2018:TextKit 最佳實踐

TextEdit 這個 app 支援富文字的展示、編輯,文字編輯部分的特性很像一個 textview,自然,它符合前面講述的標準配置結構。值得注意的是,文字編輯部分支援分頁展示,可以看到頁面下滑時,textcontainer 被重新設定了尺寸,文字從第一頁跳到了第二頁。很自然,這是使用了多個 textcontainer 的 textview,但是依然由同一個 textstorge、layoutmanager 管理,他們允許文字自由地從一個 textcontainer 跳到另一個。下圖即是它的配置結構圖:

WWDC 2018:TextKit 最佳實踐

2.3 Our Journal App on macOS

這部分主要用來示意 Choosing the right customization approach 這條理論。

WWDC 2018:TextKit 最佳實踐

2.3.1 文字計數功能

從圖中可以看到,在介面底部新增了一個 TextField 來顯示鍵入文字的數量。app 執行時,我們希望底部的文字計數隨著鍵入的數量變化。為了實現這個效果,我們選擇一個比較“輕巧”的工具 - 通知。通過接收 NSTextStorage 發出的通知,可以從 NSTextStorage 獲得文字的數量。收到通知後,更新計數 TextField 中的數字。

2.3.2 自動轉化粗體字

當我們想強調一部分文字時,可以使用鍵盤快捷鍵或者選單設定這部分字型為粗體。但是如果想支援例如 markdown 的標記語言,通過特定字元來指定特殊的格式,比如在文字前後加入一對雙星號來使文字變化為粗體,該如何實現呢?在這個情景中,需要獲取文字改變的時機和位置,通知機制並不便於提供足夠的資訊。所以這次使用“一記重錘” - 代理。遵守 NSTextStorageDelegate 協議,實現 textStorage(_:didProcessEditing:range:changeInLength:) 方法。在方法的實現中定義一個粗體字的 attribute ,新增給應該被粗體化的文字。這樣一來,只要輸入了一對雙星號,就可以立馬使文字變為粗體。

WWDC 2018:TextKit 最佳實踐

2.3.3 程式碼片段文字

粗體標記完美實現了。那麼如何展示一個程式碼片段呢?像圖中所示,完成鍵入最後一個點符號,就可以生成一個程式碼塊文字,同時還會被標示為 Swift 程式碼。對於這樣一個複雜的情形,我們需要兩把工具:

  • 子類化 NSTextStorage

子類繼承 NSTextStorage ,實現四個強制實現的方法,特別是 replaceCharacters(in:with:) 方法。內部實現是將 NSTextBlock 賦值給 ParagraphStyle 然後把這個 ParagraphStyle 作為一個 attribute 新增到一個 NSTextStorage 中,注意對應的範圍是程式碼塊文字。

WWDC 2018:TextKit 最佳實踐

對於上面所述的 NSTextBlock ,需要了解的是 NSTextBlock 不會去定製化繪製它自己,所以我們需要一個它的子類去完成這件事: CodeBlock 類繼承自 NSTextBlock,在它的初始化方法中設定背景的襯墊,或者通過覆寫 drawBackground 方法,使用 StringDrawing 去繪製 “Swift Code” 這個標題。

WWDC 2018:TextKit 最佳實踐

這樣一來這個文字塊看起來就像一個程式碼塊了。再回到繼承自 TextStorageCustomTextStorage,我們可以把 TextBlocks 屬性賦值為剛剛新增的 CodeBlock

WWDC 2018:TextKit 最佳實踐

最後,我們需要讓 textview 使用全新的CustomTextStorage,所以我們為 LayoutManager 替換 storage。

WWDC 2018:TextKit 最佳實踐

2.3.4 markdown效果預覽檢視

這樣一來,基本完成了一個支援 markdown 格式的編輯器。除此之外,一般 markdown 編輯器還有一個很實用的功能 - 兩個並排佈局的檢視,一個用來輸入文字,一個預覽效果,如圖所示:

WWDC 2018:TextKit 最佳實踐

我們可以使用兩個並排的 textview 來實現,只需要禁用用於預覽的 textview 的文字編輯功能。它們展示一樣的內容,但是右邊的樣式會特別一些。使用的配置如圖:

WWDC 2018:TextKit 最佳實踐

storage 是同一個,因為展示一樣的內容。但是其他的部分都是兩套,並且用左邊 view 的 textstorage 為右邊 view 的 layoutmanager 的 replaceTextStorage 賦值。這樣的效果是什麼呢?一旦在一邊編輯了文字,效果會在兩邊同時展示。但是一般在預覽檢視內我們是不希望顯示 markdown 格式控制相關字元的,比如雙星號 ** 和 引用符號 > 等。由於是共享的同一個 textstorge,這就意味著我們必須在後面的過程中(佈局過程)隱藏這些字元。為了完成這個操作,就有了一個自然而然的選項--代理:遵守 NSLayoutManagerDelegate 代理協議,實現 layoutManager(_:shouldGenerateGlyphs:properties: characterIndexes:font:forGlyphRange:) 代理方法,我們可以獲取到將要被佈局的 glyphs,如果它是用來表示 markdown 字元的 glyph,把它賦值為空。最後,把處理過的 glyphs 回傳。這樣一來,左邊展示可編輯的包含 markdown 控制字元的文字,右邊展示去除了 markdown 控制字元的效果文字。雖然事實上一個 markdown 編輯器並不是這樣處理,但這是一個定製 TextKit 的很好的例子。

三、最佳實戰案例

在這部分中,蘋果給出了幾個指導性原則。

3.1 熟知預設 attribute

WWDC 2018:TextKit 最佳實踐

在這個例子中,我們需要完成一個如上圖的文字展示。它當前的字型是 24 號的 Comic Sans MS。給 don't 這部分文字設定粗體的 attribute 之後,我們發現剩餘的文字(即 hate)丟失了原本的字型設定。這是因為初始化 AttributedString 時,沒有提供 attribute 設定引數,那麼系統便會使用預設的設定。在這個案例中,使用預設設定初始化了文字,然後對 don't 部分進行了單獨設定,自然 hate 部分就使用了預設的設定。

WWDC 2018:TextKit 最佳實踐

我們有兩種方式來解決這件事。一種是避免將整個文字同時進行設定,而是對於 don‘t 設定粗體,對於 hate 設定 Comic Sans MS,但這樣比較繁瑣。所以另一種是初始化 AttributedString 時,附帶原有字型的引數,然後對 don‘t 部分再行設定。

WWDC 2018:TextKit 最佳實踐

除了字型外,我們還需要了解其他屬性的預設值。

WWDC 2018:TextKit 最佳實踐

3.2 使用準確的屬性描述

  • 避免將全部或部分文字重置為預設屬性的操作。
  • 在更新你的 app 以支援即將到來的黑暗模式時,確保在這個模式下你的文字顏色正確。對於 appkit 開發者,這是非常重要的。

這裡特別注意上圖示記出的 ParagraphStyle 屬性。一個反面案例是: 為了截掉 hate 部分的文字,給這部分文字單獨設定了 ParagraphStyle 的屬性。然而展示的結果卻不符合預期。這是因為在 layout 之前,會進行 attribute fixing,這在前文有述。一個文字段落,卻有多個 ParagraphStyle 的屬性值,這是違反一致性的,所以系統在 fix attribute 時,會選擇第一個 ParagraphStyle 屬性,也就是預設風格,並且把它應用於整個段落。

3.3 效能表現:使用間斷的佈局

為了理解它,回到我們的老朋友 - 佈局過程。glyph 生成之後進行 glyph 佈局。對於大段文字,如果使用整體的佈局,那麼 LayoutManager 必須完成所有的 glyph 生成、佈局過程,這樣一來,如果有大段文字的話你就需要長時間地等待。

WWDC 2018:TextKit 最佳實踐

對於 NSTextView,你可以通過設定 allowsNonContiguousLayout 屬性來支援間斷佈局。

對於 UITextView,它是預設開啟的。需要注意的是,UITextViewUIScrollView 的子類,allowsNonContiguousLayout 屬性要求 UITextViewScroll Enabled 屬性是開啟的。因為如果不支援滑動的話,間斷佈局也就失去了意義。

這就引出了一個重要的問題。使用間斷佈局時,避免一次請求整個文字的佈局。所以如果你只有一個 textcontainer 的話,避免一次請求完整的佈局。

3.4 安全性

這裡蘋果給出了一個形象的例子:開發者就像武裝的士兵,而 iOS、Mac OS 就像堅固的堡壘,士兵和堡壘共同組成了堅固的安全性防禦工事。這就意味著,iOS 應用的安全性需要開發者和蘋果共同協作。

WWDC 2018:TextKit 最佳實踐

為此,蘋果為開發者提供了一條準則:

  • 為文字輸入設定限制

所有的文字輸入都被認為是潛在的風險。當你允許文字輸入時,你就開放了複製和貼上,但是你並不能預知什麼文字會被貼上在那裡。它可能是一段普通的文字,但也有可能是極其長的文字,而這將會導致你的 app 出現不可預知的問題。

如何完成對文字的輸入進行驗證呢?在 UIKit 下,使用 UITextFieldDelegate,在 AppKit 下通過 NSFormatter

值得期待的是,蘋果預告了關於安全性提升的內容即將到來

總結

最後,用一張圖來總結這個 session 的內容:

WWDC 2018:TextKit 最佳實踐

檢視更多 WWDC 18 相關文章請前往 老司機x知識小集xSwiftGG WWDC 18 專題目錄