Jun 6, 2020

|

安全且优雅地使用 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
])

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

  1. 为什么为 foregroundColor 设置值的时候不能够省略 UIColor
  2. 如果传入的不是 UIColor 会发生什么事情吗?
  3. 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