安全且优雅地使用 NSAttributedString - Part.1
Swift 拥有可选类型且类型安全,这些特性帮助开发者避免了很多麻烦问题。
然而当涉及到字符串的时候,还是或多或少存在一些瑕疵。例如引用资源文件,图片文字等,一旦拼写错误,是没有办法在编译之前知道的,又或者是继续在代码中引用已经被删除的资源,也不容易被察觉。为了解决这个问题,一些知名的库应运而生,像是 R.swift, SwiftGen 之类。解决方案的其中一个核心是使得资源引用这个过程变得类型安全,以此来克服潜在的不明了性,例如:
// normal
let icon = UIImage(named: "settings-icon")
// R.swift
let icon = R.image.settingsIcon()
不过这次想讨论的并不是上述问题,而是在 NSAttributedString
的运用中同样存在着的潜在不安全因素。从我的日常体验来说,NSAttributedString
在无数情境之下都会被我们想起,无论是富文本支持还是图文混排,又或者是想实现一个 markdown 组件等等。
虽然我认为从 Objective-C 到 Swift,在安全问题上已经是迈进了一大步。以下面的代码为例,以往写 Objective-C 的时候,会注意避免一个预期外的 nil
被作为 str
参数传入,而引起不必要的崩溃。
- (instancetype)initWithString:(NSString *)str
对于 Swift 而言,事情会变得简单很多,可选值的声明让我们更容易做出正确的判断。
init(string str: String)
即便如此,关于潜在的不安全因素依然有很多话可以说,例如下面这个极其简单的例子:
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
])
看完这段代码,心中应该会很容易浮现几个直击灵魂的问题:
- 为什么为
foregroundColor
设置值的时候不能够省略UIColor
? - 如果传入的不是
UIColor
会发生什么事情吗? paragraphStyle
的初始化和赋值是不是可以写的更swifty
?
第一个问题比较简单,看值类型各式各样五花八门就会知道这大概是 Any
,如果将 attributes
的初始化分离出来,这个原因会更加一目了然。
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 12.0, weight: .regular),
.foregroundColor: UIColor.gray,
.paragraphStyle: paragraphStyle
]
第二个问题完全是第一问的副作用。对于会不会发生什么事,答案是会。曾经一时失神,为 paragraphStyle
传入了一个 CGColor
的值,最可怕的在于中文和日语都可以顺利呈现出所期待的颜色,但切换成英语后应用直接就崩溃了。若遭遇这个问题,我想为了探寻原因也还是需要花一点点时间的。
为了消解这不安全感,估计大家都会想要做点什么。要么是为 attributes
写一段类型检查的代码通过输出 log 来避免意外,或者是封装 NSAttributedString
以实现类型安全。方案一略显单薄,但上述最致命的问题二是可以被圆满解决的,唯一的缺憾是在编译后才能知道结果。综上所述,不如就来实现方案二。
比较直接的想法应该是避免 Any
的存在,这样一来,不妨索性抛弃字典,使各个属性成为一个相对独立的存在,可以选择创建一些函数然后链式调用,但更容易浮现在脑海中的应该是用枚举去做这件事:
enum StringAttribute {
case font(UIFont)
case foregroundColor(UIColor)
case underlineColor(UIColor?) // The default value is nil
case kern(CGFloat)
// ...
}
在定义了上述枚举之后,基本上类型不安全的问题就得到了解决。不过作为换取类型安全的代价,需要多一道工序把 StringAttribute
转回 [NSAttributedString.Key: Any]
:
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
}
}
到这里就大功告成了!我们可以尝试把上面的代码改写一下:
let attributes: [StringAttribute] = [
.font(.systemFont(ofSize: 12.0, weight: .regular)),
.foregroundColor(.gray),
.paragraphStyle(paragraphStyle)
]
如果完整实现了 StringAttribute
的话,一定会留意到 NSAttributedString
中有一个相对特殊的属性:.paragraphStyle
。使用它之前需要先完成一系列的初始化赋值,正是上面代码块中出现的 paragraphStyle
。作为一个代码风格优化的可选项,这里我选择用与 StringAttribute
相似的手段来封装 NSMutableParagraphStyle
。
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
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