WWDC 2018 Session 221: TextKit Best Practices
引言
文字內容在 app 內隨處可見,展示文字的方式也是多種多樣。關注過效能提升的同學會發現,文字控制元件的高效使用對於整個頁面效能的提升至關重要。為此,蘋果和開發者都在不斷努力。比如蘋果日漸完善的文字框架,以及第三方文字框架的代表 YYText。
這個 session 旨在指導開發者如何正確地使用 TextKit
進行文字內容的展示,循序漸進分為三個部分:
- 核心理論
- 用以演示理論的小例子
- 綜合運用的優秀實戰案例
一、核心理論
1.1 什麼是 TextKit ?
和平時使用的框架有些不同,我們不需要使用 import
關鍵字來匯入 TextKit
。包含 UILabel
、UITextField
等控制元件的 UIKit
框架(用於 iOS),以及包含 NSTextView
等控制元件的 AppKit
框架(用於 Mac OS),都是基於 TextKit
構建。在使用上面的文字控制元件時,其實就是在使用 TextKit
,它協同 Core Text
、Core Graphics
以及 Foundation
,一起為我們的 app 提供強大的文字展示能力。
利用 TextKit
的能力,你可以非常容易地展示下面風格各異的文字。
1.2 選擇正確的控制元件
對於不同型別的文字,我們需要選擇合適的文字控制元件。那麼該如何決定呢?蘋果為我們提供了比較明確的指導,如下圖所示。在使用 UIKit
和 AppKit
時,情形會稍有不同,所以分開進行描述。
UIKit
的選擇路徑:
AppKit
的選擇路徑:
圖中的描述非常清晰易讀。需要注意的是,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
方法的頻率(儘量減少呼叫次數) - 限制定製化屬性的數量(儘量減少定製化屬性)
為什麼這種使用方式不被推薦呢?首先是因為 UILabel
、UITextView
等控制元件提供了良好的快取機制,所以在合適的時候選擇這些控制元件,反而可以獲得更好的效能(相較 string drawing 而言),特別是在使用自動佈局的時候。
繪製 attributed string
時,如果過多地呼叫 draw
方法,會明顯地降低效能。因為系統在每次繪製之前需要釋放之前所有的 attribute
物件。因此,對於額外的 attribute
,請儘量在確定它們的視覺效果(例如字型、顏色)時才進行繪製。
最後,蘋果還是不忘強調,如果使用了 string drawing,就會失去下圖所示的文字控制元件提供的所有特性。因此,請儘可能地使用文字控制元件。
1.3 選擇正確的定製要點
1.3.1 TextKit
的架構組成
像 Cocoa
下的許多元件一樣,TextKit
也是基於 “model - view - controller” 設計結構的。並且這三層又各自包含 storage、layout、和 display 模組:
Storage
深入瞭解一下各個部分的組成,首先是與 Model
層通訊的 Storage
模組,它包含的 NSTextStorage
持有字串的資料和屬性資訊。值得注意的是,它是MutableAttributedString
的子類,因此使用方式和我們熟知的 AttributedString
一致。而 NSTextContainer
則負責模型化文字佈局的地理位置、區域資訊。
Display
接下來是 Display
模組,它和 View
層通訊。這個模組我們通常關注的是文字控制元件的正確選擇問題。
Layout
最後是 Layout
模組,它和 Controller
層進行通訊。 NSLayoutManager
是這個模組唯一的組成部分。它的強大讓蘋果用“野獸”來形容。它是整個展示過程的“大腦”,控制自己的佈局過程。
1.3.2 佈局過程
這是文字佈局過程的概覽圖:
- 屬性修正
文字佈局發生在 TextStorage
進行屬性修正之後。對於這個過程中的工作,舉個例子,確保這段文字所選擇的字型支援顯示文字中的所有字元,如果發現不支援的字元,則進行相應替換。比如上圖中的 Tempura (天麩羅) is a tasty Japanese food. ?
這段文字,字型指定了 Times New Roman
。然而,這個字型是不支援日語字元和 emoji 字元的。因此,在屬性修正過程中,日語字元被指定了支援日語的 Hiragino Mincho ProN
字型,而 emoji 字元則被指定了 Apple Color Emoji
字型。
glyph
和character
屬性修正完成後,佈局過程就開始了。這裡對上述概念的含義做一些說明。
character
中文譯為“字元”,字元是可以轉換為二進位制儲存的通用資料,而 glyph
可以譯為字元的視覺表示符號。同一個 character
呈現在螢幕上,可以表現為不同的字型、視覺風格。而這些各異的視覺風格,就是由 glyph
來負責呈現,glyph
的生成,就是為指定了視覺效果(如字型)的字元確定展示所需的 glyph
的過程。下圖是一個示例:
可以看到,character
和 glyph
的對應關係不總是一對一的。圖中的字串 “ffi” 由三個字元組成,但整個字串可以由一個 glyph
表示。再看下圖的例子,一個單獨的字元 “n”,也可以由兩個 glyph
來表示。
關於這部分概念,提供一篇參考資料:iOS 排版概念
再回到佈局過程的圖示中來,glyph
佈局,就是 NSLayoutManager
在檢視上擺放 glyph
的過程。
1.4 選擇正確的配置
如下圖是 一個完整 TextKit
元件的標準配置結構:Text Container
持有 Text View
的弱引用,而 Text View
通過根 Text Storage
持有整個佈局樹結構。
如果有多個文字頁面或者文字行需要佈局,可以使用成對的 Text Container
和 Text View
組合,每一對組合對應一個頁面或者一行。在這種情況下,我們可以 hook 同樣的 container 和 text view 來共享佈局資訊。
文字內容被新增之後,它鋪滿由第一個 text container 定義的區域。文字在 text view 上和 text container 成對展示。 當沒有剩餘空間時,新的 container 連同 text view 一起被新增,並且文字在第二個頁面或者文字行進行展示。
多個 layout manager 允許你對同樣的文字有多種不同的顯示效果。這個文字在不同的檢視上可以有彼此不同且獨立的佈局和分組,下圖是這種模式下的結構示意和效果示意。方框內的文字內容相同,但展示效果是不同的。
1.5 選擇正確的定製實現方式
就像錘子在工具箱中的重要地位一樣,我們在開發時也有一些地位等同於錘子的工具。
代理
就像基本的錘子,大多數時候,它可以很好地完成工作。通知
也是一個有效的工具。- 最後,
子類化
同樣是一把利器。它幾乎可以作任何事。
對於這些方式的使用場景,在第二部分會運用具體例子進行闡述。
二、具體示例
文字元件在 app 中是無處不在的。在這部分,蘋果使用了 iOS 的 Apple News
和 Mac OS 的TextEdit
、Our Journal
三個 app 中的具體頁面作為示例來對前面所述的核心理論進行講解。
2.1 Apple News on iOS
這部分內容比較簡單。主要用來示意 Choosing the right control
這條理論。裡面主要的知識點如下:
- 對於一行顏色不一樣的文字,可以使用兩個
UILabel
進行展示,也可以藉助NSAttributedString
來實現。 UITextView
是UIScrollView
的子類,預設支援滑動,如果想讓它與自動佈局良好協作地話,需要禁用滑動。
2.2 TextEdit on macOS
這部分主要用來示意 Choosing the right configuration
這條理論。
TextEdit
這個 app 支援富文字的展示、編輯,文字編輯部分的特性很像一個 textview,自然,它符合前面講述的標準配置結構。值得注意的是,文字編輯部分支援分頁展示,可以看到頁面下滑時,textcontainer 被重新設定了尺寸,文字從第一頁跳到了第二頁。很自然,這是使用了多個 textcontainer 的 textview,但是依然由同一個 textstorge、layoutmanager 管理,他們允許文字自由地從一個 textcontainer 跳到另一個。下圖即是它的配置結構圖:
2.3 Our Journal App on macOS
這部分主要用來示意 Choosing the right customization approach
這條理論。
2.3.1 文字計數功能
從圖中可以看到,在介面底部新增了一個 TextField 來顯示鍵入文字的數量。app 執行時,我們希望底部的文字計數隨著鍵入的數量變化。為了實現這個效果,我們選擇一個比較“輕巧”的工具 - 通知。通過接收 NSTextStorage 發出的通知,可以從 NSTextStorage 獲得文字的數量。收到通知後,更新計數 TextField 中的數字。
2.3.2 自動轉化粗體字
當我們想強調一部分文字時,可以使用鍵盤快捷鍵或者選單設定這部分字型為粗體。但是如果想支援例如 markdown
的標記語言,通過特定字元來指定特殊的格式,比如在文字前後加入一對雙星號來使文字變化為粗體,該如何實現呢?在這個情景中,需要獲取文字改變的時機和位置,通知機制並不便於提供足夠的資訊。所以這次使用“一記重錘” - 代理。遵守 NSTextStorageDelegate
協議,實現 textStorage(_:didProcessEditing:range:changeInLength:)
方法。在方法的實現中定義一個粗體字的 attribute ,新增給應該被粗體化的文字。這樣一來,只要輸入了一對雙星號,就可以立馬使文字變為粗體。
2.3.3 程式碼片段文字
粗體標記完美實現了。那麼如何展示一個程式碼片段呢?像圖中所示,完成鍵入最後一個點符號,就可以生成一個程式碼塊文字,同時還會被標示為 Swift
程式碼。對於這樣一個複雜的情形,我們需要兩把工具:
- 子類化
NSTextStorage
子類繼承 NSTextStorage
,實現四個強制實現的方法,特別是 replaceCharacters(in:with:)
方法。內部實現是將 NSTextBlock
賦值給 ParagraphStyle
然後把這個 ParagraphStyle
作為一個 attribute
新增到一個 NSTextStorage
中,注意對應的範圍是程式碼塊文字。
對於上面所述的 NSTextBlock
,需要了解的是 NSTextBlock
不會去定製化繪製它自己,所以我們需要一個它的子類去完成這件事: CodeBlock
類繼承自 NSTextBlock
,在它的初始化方法中設定背景的襯墊,或者通過覆寫 drawBackground
方法,使用 StringDrawing 去繪製 “Swift Code” 這個標題。
這樣一來這個文字塊看起來就像一個程式碼塊了。再回到繼承自 TextStorage
的 CustomTextStorage
,我們可以把 TextBlocks
屬性賦值為剛剛新增的 CodeBlock
。
最後,我們需要讓 textview 使用全新的CustomTextStorage
,所以我們為 LayoutManager
替換 storage。
2.3.4 markdown效果預覽檢視
這樣一來,基本完成了一個支援 markdown 格式的編輯器。除此之外,一般 markdown 編輯器還有一個很實用的功能 - 兩個並排佈局的檢視,一個用來輸入文字,一個預覽效果,如圖所示:
我們可以使用兩個並排的 textview 來實現,只需要禁用用於預覽的 textview 的文字編輯功能。它們展示一樣的內容,但是右邊的樣式會特別一些。使用的配置如圖:
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
在這個例子中,我們需要完成一個如上圖的文字展示。它當前的字型是 24 號的 Comic Sans MS
。給 don't
這部分文字設定粗體的 attribute 之後,我們發現剩餘的文字(即 hate
)丟失了原本的字型設定。這是因為初始化 AttributedString
時,沒有提供 attribute 設定引數,那麼系統便會使用預設的設定。在這個案例中,使用預設設定初始化了文字,然後對 don't
部分進行了單獨設定,自然 hate
部分就使用了預設的設定。
我們有兩種方式來解決這件事。一種是避免將整個文字同時進行設定,而是對於 don‘t
設定粗體,對於 hate
設定 Comic Sans MS
,但這樣比較繁瑣。所以另一種是初始化 AttributedString 時,附帶原有字型的引數,然後對 don‘t
部分再行設定。
除了字型外,我們還需要了解其他屬性的預設值。
3.2 使用準確的屬性描述
- 避免將全部或部分文字重置為預設屬性的操作。
- 在更新你的 app 以支援即將到來的黑暗模式時,確保在這個模式下你的文字顏色正確。對於 appkit 開發者,這是非常重要的。
這裡特別注意上圖示記出的 ParagraphStyle
屬性。一個反面案例是: 為了截掉 hate
部分的文字,給這部分文字單獨設定了 ParagraphStyle
的屬性。然而展示的結果卻不符合預期。這是因為在 layout 之前,會進行 attribute fixing,這在前文有述。一個文字段落,卻有多個 ParagraphStyle
的屬性值,這是違反一致性的,所以系統在 fix attribute 時,會選擇第一個 ParagraphStyle 屬性,也就是預設風格,並且把它應用於整個段落。
3.3 效能表現:使用間斷的佈局
為了理解它,回到我們的老朋友 - 佈局過程。glyph 生成之後進行 glyph 佈局。對於大段文字,如果使用整體的佈局,那麼 LayoutManager 必須完成所有的 glyph 生成、佈局過程,這樣一來,如果有大段文字的話你就需要長時間地等待。
對於 NSTextView
,你可以通過設定 allowsNonContiguousLayout
屬性來支援間斷佈局。
對於 UITextView
,它是預設開啟的。需要注意的是,UITextView
是UIScrollView
的子類,allowsNonContiguousLayout
屬性要求 UITextView
的 Scroll Enabled
屬性是開啟的。因為如果不支援滑動的話,間斷佈局也就失去了意義。
這就引出了一個重要的問題。使用間斷佈局時,避免一次請求整個文字的佈局。所以如果你只有一個 textcontainer 的話,避免一次請求完整的佈局。
3.4 安全性
這裡蘋果給出了一個形象的例子:開發者就像武裝的士兵,而 iOS、Mac OS 就像堅固的堡壘,士兵和堡壘共同組成了堅固的安全性防禦工事。這就意味著,iOS 應用的安全性需要開發者和蘋果共同協作。
為此,蘋果為開發者提供了一條準則:
- 為文字輸入設定限制
所有的文字輸入都被認為是潛在的風險。當你允許文字輸入時,你就開放了複製和貼上,但是你並不能預知什麼文字會被貼上在那裡。它可能是一段普通的文字,但也有可能是極其長的文字,而這將會導致你的 app 出現不可預知的問題。
如何完成對文字的輸入進行驗證呢?在 UIKit
下,使用 UITextFieldDelegate
,在 AppKit
下通過 NSFormatter
。
值得期待的是,蘋果預告了關於安全性提升的內容即將到來。
總結
最後,用一張圖來總結這個 session 的內容:
檢視更多 WWDC 18 相關文章請前往 老司機x知識小集xSwiftGG WWDC 18 專題目錄