WWDC2018 session 221

该篇博客记录了观看WWDC Session221《TextKit Best Practices》的内容以及一些理解。

一、关键概念(Key concepts)

1. TextKit框架组成

TextKit框架无需导入,因为UIKitAppKit中所有文本控件都是建立在TextKit之上的。

TextKit还继承了很多技术,包括Core TextCore Graphics以及Foundation

TextKit

2. 选择正确的控件

TextKit框架中包含许多控件,那么如何选择使用正确的控件呢?

对于UIKit来说:

UIKit Choose Control

对于AppKit来说:

AppKit Choose Control

3. String的draw等方法

为了避免视图层级过多的问题,很多人会使用Stringdraw方法来避免创建控件,例如:

func draw(at: CGPoint) 

func draw(in: CGRect)

func draw(with: CGRect,
          options: NSStringDrawingOptions = [],
          context: NSStringDrawingContext?)

如果想要使用这种方法,需要注意以下几点:

  1. 用这种方法处理少量的静态文本。
  2. 限制调用draw方法的频率。如果频繁的调用draw方法,那么性能可能不如使用控件,因为控件可以提供展示缓存,尤其是在使用Auto Layout的情况下。
  3. 限制自定义属性的数量。文本的Attributes也会减低绘制文本的效率,因为系统在绘制之前需要对所有属性进行验证,所以为了保证最好的性能,应当只保留关于视觉表现的属性,比如字体或文字颜色。

如果坚持使用Stringdraw方法,那么就无法使用TextKit提供的额外功能,例如:

TextKit支持功能

4. 自定义文字显示

如果我们想要自定义文字显示,则需要基于TextKit进行自定义开发。

TextKit框架的设计模式类似于Cocoa框架,都是MVC设计模式。

TextKit设计

Storage

Storage模块由两个类别模块组成:NSTextStorageNSTextContainer

  • NSTextStorage

NSTextStorage存储着字符串数据和属性,它是NSMutableAttributedString的子类,可以使用NSMutableAttributedString中的方法来自定义它。

  • NSTextContainer

NSTextContainer保存着文本展示的几何区域模型,默认情况下是一个矩形,我们可以对该区域模型进行自定义设置。

Display

Display模块负责展示文本,该模块功能由TextKit所支持的控件完成,即UIKitAppKit中所对应控件:UILabelUITextFieldUITextViewNSTextFieldNSTextView以及NSSecureTextField

Layout

Layout模块功能由NSLayoutManager类负责,该类协调所有阶段变化以及控制整个布局过程。

布局过程发生在属性修复(Attribute Fixing)之后,属性修复是指消除不支持属性的过程,例如:

我们为以下字符串指定了Times New Roman字体:

属性修复前

但是该字体并不支持其中的日文字符以及emoji字符,这就需要进行属性修复,该过程由Storage模块负责,将适当的属性分配给不同的字符。修复之后结果如下:

属性修复后

接下来布局过程就开始了,这个过程分为两步:字形生成字形布局

那么什么是字形呢?

字形是一种代表一个或多个字符的视觉符号。但是字形与字符直接并不是一一对应的,例如:

  • 多个字符可以表示成多个字形,也可以表示成一个字形,例如"ffi":

字符FFI

  • 一个字形可以表示一个字符,也可以表示多个字符,例如:

字形

所以在布局过程中,就是要将字符处理为对应字形,进而进行显示:

  • 字形生成会将字符计算为需要绘制的字形。
  • 字形布局会将字形进行定位,计算出需要展示的位置。

布局过程

正确使用TextKit配置

当我们使用默认的text view时,配置都是默认的,此时组件配置关系如图:

默认配置

如果想要多个页面展示,则可以使用多对容器和文本视图,同时可以共享一个LayoutManager以及TextStorage

多页面展示

如果想要每个页面展示不同的布局,可以使用多个布局管理,同时共享一个TextStorage

多布局展示

以上所有布局以及容器都共享一份文本存储,所以更新文本存储时,所有布局和容器都会进行更新。

二、例子(Examples)

如果我们想要自定义TextKit的某个部分,我们需要选择正确的方式,现在有以下几种方式:

  1. Delegation:委托可以定制各种标准的功能,大多数时候委托可以满足需要。
  2. Notifications:通知更加专业化,可以完成特定的任务。
  3. 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代码段效果

现在市面上的Markdown编辑器都是左右两个视图,左侧负责编辑,右侧负责实时展示效果,我们可以在左侧NSTextView右侧再布置一个NSTextView,并关闭右侧的编辑选项。

此时,由于两个NSTextView展示的文本数据是一致的,但是他们所展示出来的效果不一致(左侧为用户原始输入,右侧为用户展示所得),所以我们可以采用以下结果来完成功能:

Markdown左右布局结果

由于左右两侧展示除了在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编辑器,同时也了解了三种方式的能力以及可以完成的功能:

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滚动到中间位置,我们需要计算从文本开始到此时显示的所有字形信息,但除了此时控件展示的内容,其余内容是我们看不见的,所以这种计算方式浪费了许多的性能。

为解决这个问题,苹果提供了不连续布局,该布局方式会将大量文本分割为多个布局,当对控件进行滚动时,系统不需要计算所有的字形及布局,只需要计算所需显示的那部分布局的字形,这就大大提高了性能。

那么如何开启不连续布局呢?

  1. 对于NSTextView来说,设置allowsNonContiguousLayout即可开启不连续布局。
  2. 对于UITextView来说,allowsNonContiguousLayout默认是开启的,当需要一个前提条件:由于UITextViewUIScrollView的子类,所以需要打开滚动属性才能激活不连续布局,这是由于当禁止滚动时,需要某部分的布局信息需要计算全部的布局信息进而进行筛选,所以这也提示我们,当使用不连续布局时,尽量避免一次获取全部布局信息或获取大多数文本的布局信息,比如调用以下方法:
// 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来说,我们可以使用UITextFieldDelegateUITextViewDelegate来对即将赋值给控件的文本内容进行验证。

对于AppKit来说,我们可以使用自定义的NSFormatter来自定义验证逻辑。

发布了71 篇原创文章 · 获赞 34 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/TuGeLe/article/details/86652153