WWDC 2018 Session 221: TextKit Best Practices
作者簡介:@halohily,網易有道 iOS 開發工程師。掘金首頁:halohily
引言
文本内容在 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
的架構組成
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 專題目錄