该篇博客记录了观看WWDC Session221《TextKit Best Practices》的内容以及一些理解。
一、关键概念(Key concepts)
1. TextKit框架组成
TextKit
框架无需导入,因为UIKit
和AppKit
中所有文本控件都是建立在TextKit
之上的。
TextKit
还继承了很多技术,包括Core Text
、Core Graphics
以及Foundation
。
2. 选择正确的控件
TextKit
框架中包含许多控件,那么如何选择使用正确的控件呢?
对于UIKit
来说:
对于AppKit
来说:
3. String的draw等方法
为了避免视图层级过多的问题,很多人会使用String
的draw
方法来避免创建控件,例如:
func draw(at: CGPoint)
func draw(in: CGRect)
func draw(with: CGRect,
options: NSStringDrawingOptions = [],
context: NSStringDrawingContext?)
如果想要使用这种方法,需要注意以下几点:
- 用这种方法处理少量的静态文本。
- 限制调用
draw
方法的频率。如果频繁的调用draw
方法,那么性能可能不如使用控件,因为控件可以提供展示缓存,尤其是在使用Auto Layout
的情况下。 - 限制自定义属性的数量。文本的
Attributes
也会减低绘制文本的效率,因为系统在绘制之前需要对所有属性进行验证,所以为了保证最好的性能,应当只保留关于视觉表现的属性,比如字体或文字颜色。
如果坚持使用String
的draw
方法,那么就无法使用TextKit
提供的额外功能,例如:
4. 自定义文字显示
如果我们想要自定义文字显示,则需要基于TextKit
进行自定义开发。
TextKit
框架的设计模式类似于Cocoa
框架,都是MVC
设计模式。
Storage
Storage
模块由两个类别模块组成:NSTextStorage
和NSTextContainer
。
- NSTextStorage
NSTextStorage
存储着字符串数据和属性,它是NSMutableAttributedString
的子类,可以使用NSMutableAttributedString
中的方法来自定义它。
- NSTextContainer
NSTextContainer
保存着文本展示的几何区域模型,默认情况下是一个矩形,我们可以对该区域模型进行自定义设置。
Display
Display
模块负责展示文本,该模块功能由TextKit
所支持的控件完成,即UIKit
和AppKit
中所对应控件:UILabel
、UITextField
、UITextView
、NSTextField
、NSTextView
以及NSSecureTextField
。
Layout
Layout
模块功能由NSLayoutManager
类负责,该类协调所有阶段变化以及控制整个布局过程。
布局过程发生在属性修复(Attribute Fixing)
之后,属性修复是指消除不支持属性的过程,例如:
我们为以下字符串指定了Times New Roman
字体:
但是该字体并不支持其中的日文字符以及emoji字符,这就需要进行属性修复,该过程由Storage
模块负责,将适当的属性分配给不同的字符。修复之后结果如下:
接下来布局过程就开始了,这个过程分为两步:字形生成
和字形布局
。
那么什么是字形呢?
字形是一种代表一个或多个字符的视觉符号。但是字形与字符直接并不是一一对应的,例如:
- 多个字符可以表示成多个字形,也可以表示成一个字形,例如"ffi":
- 一个字形可以表示一个字符,也可以表示多个字符,例如:
所以在布局过程中,就是要将字符处理为对应字形,进而进行显示:
- 字形生成会将字符计算为需要绘制的字形。
- 字形布局会将字形进行定位,计算出需要展示的位置。
正确使用TextKit配置
当我们使用默认的text view时,配置都是默认的,此时组件配置关系如图:
如果想要多个页面展示,则可以使用多对容器和文本视图,同时可以共享一个LayoutManager
以及TextStorage
:
如果想要每个页面展示不同的布局,可以使用多个布局管理,同时共享一个TextStorage
:
以上所有布局以及容器都共享一份文本存储,所以更新文本存储时,所有布局和容器都会进行更新。
二、例子(Examples)
如果我们想要自定义TextKit
的某个部分,我们需要选择正确的方式,现在有以下几种方式:
- Delegation:委托可以定制各种标准的功能,大多数时候委托可以满足需要。
- Notifications:通知更加专业化,可以完成特定的任务。
- Subclassing:子类功能最强大,可以使用在任何情况下,但有可能造成大材小用情况。
接下来通过一系列例子来展示如何使用合适的方式完成对TextKit
的自定义。
如果我们要开发一个类似下面界面的一个程序:
如果我们需要实现在输入文字的同时及时更新右下角字数统计Label,那么我们可以选用通知的方式,此处可以使用NSTextStorage
的通知didProcessEditingNotification
:
func registerObservers() {
NotificationCenter.default.addObserver(self,
selector: #selector(updateWordCount),
name: NSTextStorage.didProcessEditingNotification,
object: nil)
}
@objc func updateWordCount() {
guard let textStorage = textView.textStorage else { fatalError() }
let wordCount = textStorage.words.count
wordCountLabel.stringValue = String(wordCount)
}
接下来我们要实现一个类似于Markdown
的功能:将被**
包围的文本自动加粗,此时由于通知无法给我们提供更多的关于文本信息改变的内容,所以此时需要使用代理,代理可以为我们提供更多的信息:
func textStorage(_ textStorage: NSTextStorage,
didProcessEditing editedMask: NSTextStorageEditActions,
range editedRange: NSRange,
changeInLength delta: Int) {
let boldRange = // Range including start and end **
let start = boldRange.location
guard let font = textStorage.attribute(.font, at: start, effectiveRange: nil) as? NSFont else { fatalError() }
let boldFont = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask)
textStorage.addAttribute(.font, value: boldFont, range: boldRange)
}
既然实现了Markdown
的加粗功能,那我们就继续实现一下Markdown
中插入代码段的功能,由于这个功能需要将代码段设置为特定的布局(代码段需预留边距、代码段需设置特殊的背景色),那么我们单纯的改变文本的属性就不能满足此时的需求了,此时我们需要使用子类的方式,在该例中,我们首先要对NSTextStorage
进行自定义:
NSTextStorage
的子类可以重写的方法有以下四个:
class CustomTextStorage: NSTextStorage {
override var string: String {
// Return string
}
override func attributes(at: Int,
effectiveRange: NSRangePointer?) -> [NSAttributedString.Key : Any] {
// Return attributes
}
override func replaceCharacters(in range: NSRange,
with str: String) {
// Replace characters
}
override func setAttributes(_: [NSAttributedString.Key : Any]?,
range: NSRange) {
// Set attributes
}
}
在本例中,我们需要做如下操作:
class CustomTextStorage: NSTextStorage {
override func replaceCharacters(in range: NSRange, with str: String) {
// Replace characters
let codeBlockRange = // Range including start and end ```
let paragraphStyle = // Get existing or new paragraph style
let mutableStyle = NSMutableParagraphStyle()
mutableStyle.setParagraphStyle(paragraphStyle)
mutableStyle.textBlocks = [NSTextBlock()]
addAttribute(.paragraphStyle, value: mutableStyle, range: codeBlockRange)
}
}
此时我们将NSTextBlock()
添加到我们需要显示为代码段的文本格式中了,NSTextBlock()
只是一个抽象类,它并不会做任何自定义绘制,我们还需要对NSTextBlock
进行子类化,进而实现自定义绘制:
class CodeBlock: NSTextBlock {
override init() {
super.init()
setWidth(15.0, type: .absoluteValueType, for: .padding)
setWidth(45.0, type: .absoluteValueType, for: .padding, edge: .minY)
backgroundColor = NSColor(white: 0.95, alpha: 1.0)
}
override func drawBackground(withFrame frameRect: NSRect,
in controlView: NSView,
characterRange charRange: NSRange,
layoutManager: NSLayoutManager) {
let adjustedFrame: NSRect = // Padded rect inside frameRect
super.drawBackground(withFrame: adjustedFrame, in: controlView, characterRange: charRange, layoutManager: layoutManager)
let drawPoint: NSPoint = // Point inset from adjustedFrame origin
let drawString = NSString(string: "Swift Code")
let attributes = [NSAttributedString.Key.font: font, .foregroundColor: .blue]
drawString.draw(at: drawPoint, withAttributes: attributes)
}
}
在NSTextBlock
子类中,我们设置了默认的边距以及背景色,然后在drawBackground
方法中绘制类背景色以及Swift Code
文字。
接下来我们需要使用我们自定义的子类:
//使用自定义的NSTextBlock
mutableStyle.textBlocks = [CodeBlock()]
//为textView的NSLayoutManager使用自定义的NSTextStorage
guard let layoutManager = textView.layoutManager else { fatalError() }
let customTextStorage = CustomTextStorage()
layoutManager.replaceTextStorage(customTextStorage)
至此我们就是想了类似Markdown
中代码段功能:
现在市面上的Markdown
编辑器都是左右两个视图,左侧负责编辑,右侧负责实时展示效果,我们可以在左侧NSTextView
右侧再布置一个NSTextView
,并关闭右侧的编辑选项。
此时,由于两个NSTextView
展示的文本数据是一致的,但是他们所展示出来的效果不一致(左侧为用户原始输入,右侧为用户展示所得),所以我们可以采用以下结果来完成功能:
由于左右两侧展示除了在Markdown
的字体加粗以及代码段上展示略微不同之外,其余展示都一致,所以我们可以先将右侧的NSLayoutManager
设置为与左侧NSLayoutManager
一致:
guard let leftTextStorage = leftTextView.textStorage,
let rightLayoutManager = rightTextView.layoutManager else { fatalError() }
rightLayoutManager.replaceTextStorage(leftTextStorage)
接下来我们就需要为右侧视图适配Markdown
字体加粗以及代码段展示了,我们可以通过NSLayoutManagerDelegate
代理来干预NSLayoutManager
生成字形的过程,进而避免生成Markdown
字体加粗以及代码段标志的字形:
func layoutManager(_ layoutManager: NSLayoutManager,
shouldGenerateGlyphs glyphs: UnsafePointer<CGGlyph>,
properties props: UnsafePointer<NSLayoutManager.GlyphProperty>,
characterIndexes charIndexes: UnsafePointer<Int>,
font aFont: NSFont,
forGlyphRange glyphRange: NSRange) -> Int {
var controlCharProps: UnsafeMutablePointer<NSLayoutManager.GlyphProperty>? = nil
for index in 0..<glyphRange.length {
if charIsMarkdownCharacter { // Determine if charIndexes[index] is markdown character
controlCharProps?[index] = .null
}
}
if let newProps = controlCharProps {
layoutManager.setGlyphs(glyphs,
properties: newProps,
characterIndexes: charIndexes,
font: aFont,
forGlyphRange: glyphRange)
return glyphRange.length
} else { return 0 }
}
至此,我们完成了一个简单的Markdown
编辑器,同时也了解了三种方式的能力以及可以完成的功能:
三、最佳实践(Best practices)
1.正确性问题
在该话题中,首先讨论的是默认属性设置带来的正确性问题。
首先我们看一个例子:
假如我们此时有一个使用Comic Sans
字体展示的Don't hate
字符串,若我们想要将Don't
的字体设置为Comic Sans Bold
类型,我们经常会这么做:
guard let originalFont = UIFont(name: "Comic Sans MS” size: 24.0) else { return }
let boldFontDescriptor = originalFont.fontDescriptor.withSymbolicTraits(.traitBold)
let boldFont = UIFont(descriptor: boldFontDescriptor!, size: (originalFont.pointSize))
let newText = NSMutableAttributedString(string: myTextView.text)
newText.addAttribute(.font, value: boldFont, range: NSRange(location: 0, length: 5))
myTextView.attributedText = newText
但这样做会带来一个问题:我们确实将Don't
的字体改为了我们想要的,但是此时hate
的字体却丢失了Comic Sans
字体,造成问题的原因在于初始化属性字符串时是采用的纯文本来进行的,由于纯文本中不带有任何属性,所以此时属性字符串会采用默认属性,所以最终导致未进行自定义属性的hate
字段丢失了本应该属于它的属性。
为了解决这个问题,可以从以下两方面入手:
1.避免混合使用纯文本和属性字符串,即在初始化属性字符串时应避免与纯文本混用。
guard let originalFont = UIFont(name: "Comic Sans MS” size: 24.0) else { return }
let boldFontDescriptor = originalFont.fontDescriptor.withSymbolicTraits(.traitBold)
let boldFont = UIFont(descriptor: boldFontDescriptor!, size: (originalFont.pointSize))
let newText = NSMutableAttributedString(attributedString: myTextView.attributedText)
newText.addAttribute(.font, value: boldFont, range: NSRange(location: 0, length: 5))
myTextView.attributedText = newText
2.创建属性字符串时,明确的指出所需要的默认属性。
guard let originalFont = UIFont(name: "Comic Sans MS” size: 24.0) else { return }
let boldFontDescriptor = originalFont.fontDescriptor.withSymbolicTraits(.traitBold)
let boldFont = UIFont(descriptor: boldFontDescriptor!, size: (originalFont.pointSize))
let newText = NSMutableAttributedString(string: myTextView.text,
attributes: [.font : originalFont])
newText.addAttribute(.font, value: boldFont, range: NSRange(location: 0, length: 5))
myTextView.attributedText = newText
2.性能问题
当我们在处理大量文本时,由于文本的布局过程包括字形生成和字形布局,所以对于连续布局来说,NSLayoutManager
必须从头到尾计算文本所需要的所有字形以及所有布局信息,这就意味着,如果我们将一个TextView
滚动到中间位置,我们需要计算从文本开始到此时显示的所有字形信息,但除了此时控件展示的内容,其余内容是我们看不见的,所以这种计算方式浪费了许多的性能。
为解决这个问题,苹果提供了不连续布局
,该布局方式会将大量文本分割为多个布局,当对控件进行滚动时,系统不需要计算所有的字形及布局,只需要计算所需显示的那部分布局的字形,这就大大提高了性能。
那么如何开启不连续布局呢?
- 对于
NSTextView
来说,设置allowsNonContiguousLayout
即可开启不连续布局。 - 对于
UITextView
来说,allowsNonContiguousLayout
默认是开启的,当需要一个前提条件:由于UITextView
是UIScrollView
的子类,所以需要打开滚动属性才能激活不连续布局,这是由于当禁止滚动时,需要某部分的布局信息需要计算全部的布局信息进而进行筛选,所以这也提示我们,当使用不连续布局时,尽量避免一次获取全部布局信息或获取大多数文本的布局信息,比如调用以下方法:
// With only one container, this effectively requests layout for all the text
func ensureLayout(for: NSTextContainer)
// Avoid asking for a large range that includes the end of the text
func ensureLayout(forCharacterRange: NSRange)
// Avoid asking for a large range that includes the end of the text
func boundingRect(forGlyphRange: NSRange, in: NSTextContainer)
3.安全问题
TextKit
所涉及的许多控件都允许输入,一旦有输入,就会有安全性问题。
TextKit
所支持的输入控件都是允许进行复制粘贴的,所以当我们只想进行简短文字输入选择使用UITextField
时,用户可能在其他地方复制成千上万的文本过来;又例如用户可能从其他地方复制一些恶意代码进行粘贴。这些都有可能对我们程序造成隐患。
苹果在TextKit
中做了许多输入的验证保护,但是我们最好在自己的App中增加输入保护,这样可以形成多重保护,减低我们App出现错误的情况:
对于UIKit
来说,我们可以使用UITextFieldDelegate
和UITextViewDelegate
来对即将赋值给控件的文本内容进行验证。
对于AppKit
来说,我们可以使用自定义的NSFormatter
来自定义验证逻辑。