安全且优雅地使用 NSAttributedString - Part.1

Swift 拥有可选类型且类型安全,这些特性帮助开发者避免了很多麻烦问题。

然而当涉及到字符串的时候,还是或多或少存在一些瑕疵。例如引用资源文件,图片文字等,一旦拼写错误,是没有办法在编译之前知道的,又或者是继续在代码中引用已经被删除的资源,也不容易被察觉。为了解决这个问题,一些知名的库应运而生,像是 R.swift, SwiftGen 之类。解决方案的其中一个核心是使得资源引用这个过程变得类型安全,以此来克服潜在的不明了性,例如:

1
2
3
4
5
// normal
let icon = UIImage(named: "settings-icon")

// R.swift
let icon = R.image.settingsIcon()

不过这次想讨论的并不是上述问题,而是在 NSAttributedString 的运用中同样存在着的潜在不安全因素。从我的日常体验来说,NSAttributedString 在无数情境之下都会被我们想起,无论是富文本支持还是图文混排,又或者是想实现一个 markdown 组件等等。

虽然我认为从 Objective-C 到 Swift,在安全问题上已经是迈进了一大步。以下面的代码为例,以往写 Objective-C 的时候,会注意避免一个预期外的 nil 被作为 str 参数传入,而引起不必要的崩溃。

1
- (instancetype)initWithString:(NSString *)str

对于 Swift 而言,事情会变得简单很多,可选值的声明让我们更容易做出正确的判断。

1
init(string str: String)

即便如此,关于潜在的不安全因素依然有很多话可以说,例如下面这个极其简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 1.0
paragraphStyle.lineBreakMode = .byTruncatingTail
paragraphStyle.alignment = .center

xxxxxx.attributedText = NSAttributedString(
string: "卮言春天 破碎秋千",
attributes: [
.font: UIFont.systemFont(ofSize: 12.0, weight: .regular),
.foregroundColor: UIColor.gray,
.paragraphStyle: paragraphStyle
])

看完这段代码,心中应该会很容易浮现几个直击灵魂的问题:

  1. 为什么为 foregroundColor 设置值的时候不能够省略 UIColor
  2. 如果传入的不是 UIColor 会发生什么事情吗?
  3. paragraphStyle 的初始化和赋值是不是可以写的更 swifty

第一个问题比较简单,看值类型各式各样五花八门就会知道这大概是 Any,如果将 attributes 的初始化分离出来,这个原因会更加一目了然。

1
2
3
4
5
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 12.0, weight: .regular),
.foregroundColor: UIColor.gray,
.paragraphStyle: paragraphStyle
]

第二个问题完全是第一问的副作用。对于会不会发生什么事,答案是会。曾经一时失神,为 paragraphStyle 传入了一个 CGColor 的值,最可怕的在于中文和日语都可以顺利呈现出所期待的颜色,但切换成英语后应用直接就崩溃了。若遭遇这个问题,我想为了探寻原因也还是需要花一点点时间的。

为了消解这不安全感,估计大家都会想要做点什么。要么是为 attributes 写一段类型检查的代码通过输出 log 来避免意外,或者是封装 NSAttributedString 以实现类型安全。方案一略显单薄,但上述最致命的问题二是可以被圆满解决的,唯一的缺憾是在编译后才能知道结果。综上所述,不如就来实现方案二。

比较直接的想法应该是避免 Any 的存在,这样一来,不妨索性抛弃字典,使各个属性成为一个相对独立的存在,可以选择创建一些函数然后链式调用,但更容易浮现在脑海中的应该是用枚举去做这件事:

1
2
3
4
5
6
7
enum StringAttribute {
case font(UIFont)
case foregroundColor(UIColor)
case underlineColor(UIColor?) // The default value is nil
case kern(CGFloat)
// ...
}

在定义了上述枚举之后,基本上类型不安全的问题就得到了解决。不过作为换取类型安全的代价,需要多一道工序把 StringAttribute 转回 [NSAttributedString.Key: Any]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
extension NSAttributedString {
convenience init(string str: String, stringAttributes attrs: [StringAttribute]) {
self.init(string: str, attributes: attrs.attributesDictionary)
}
}

enum StringAttribute {
case font(UIFont)
case foregroundColor(UIColor)
case underlineColor(UIColor?) // The default value is nil
case kern(CGFloat)
// ...

var keyAndValue: (NSAttributedString.Key, Any?) {
switch self {
case .font(let value):
return (.font, value)
case .foregroundColor(let value):
return (.foregroundColor, value)
case .underlineColor(let optionalValue):
return (.underlineColor, optionalValue)
case .kern(let value):
return (.kern, value as NSNumber)
}
// ...
}
}

extension Array where Element == StringAttribute {
// StringAttribute => [NSAttributedString.Key: Any]
var attributesDictionary: [NSAttributedString.Key: Any] {
var attributesDict: [NSAttributedString.Key: Any] = [:]

self.forEach { attribute in
let (key, value) = attribute.keyAndValue
attributesDict[key] = value
}

return attributesDict
}
}

到这里就大功告成了!我们可以尝试把上面的代码改写一下:

1
2
3
4
5
let attributes: [StringAttribute] = [
.font(.systemFont(ofSize: 12.0, weight: .regular)),
.foregroundColor(.gray),
.paragraphStyle(paragraphStyle)
]

如果完整实现了 StringAttribute 的话,一定会留意到 NSAttributedString 中有一个相对特殊的属性:.paragraphStyle。使用它之前需要先完成一系列的初始化赋值,正是上面代码块中出现的 paragraphStyle。作为一个代码风格优化的可选项,这里我选择用与 StringAttribute 相似的手段来封装 NSMutableParagraphStyle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
enum ParagraphAttribute {
case alignment(NSTextAlignment)
case lineSpacing(CGFloat)
case lineBreakMode(NSLineBreakMode)
}

enum StringAttribute {
case paragraphStyle([ParagraphAttribute])

var keyAndValue: (NSAttributedString.Key, Any?) {
switch self {
case .paragraphStyle(let value):
return (.paragraphStyle, self.configureParagraphStyle(value))
}
}

// [ParagraphAttribute] => NSMutableParagraphStyle
private func configureParagraphStyle(_ attributes: [ParagraphAttribute]) -> NSMutableParagraphStyle {

let paragraphStyle = NSMutableParagraphStyle()

attributes.forEach {
switch $0 {
case .alignment(let value):
paragraphStyle.alignment = value
case .lineSpacing(let value):
paragraphStyle.lineSpacing = value
case .lineBreakMode(let value):
paragraphStyle.lineBreakMode = value
}
}

return paragraphStyle
}
}

于是,最初的代码就可以用一种更 Swifty 的方式来重写了。
代码风格各有所爱,就我个人来说,安全与优雅的代码着实令人感动:P

1
2
3
4
5
6
7
8
9
10
11
12
xxxxxx.attributedText = NSAttributedString(
string: "卮言春天 破碎秋千",
stringAttributes: [
.font(.systemFont(ofSize: 12.0, weight: .regular)),
.foregroundColor(.gray),
.paragraphStyle([
.lineSpacing(1.0),
.lineBreakMode(.byTruncatingTail),
.alignment(.center)
])

])

至此,我们就拥有了一个类型安全的 NSAttributedString 初始化方法。不过条条大路通罗马,伴随着 SwiftUI 的诞生,Swift 5.1 中新增加了 Function Builder,用它来达成我们最初的目标也不失为一个好方法。读一读这篇 Create Your First Function Builder in 10 Minutes 应该会有不少启发。

TBC

(下一篇计划讲一讲在其它一些场景中,如何更优雅地使用 NSAttributedString)

Source Code:

https://github.com/Ckitakishi/PlayWithAttributedString