Swift 中的字符串插值

Jul 02, 2022 • 预计阅读时间 6 分钟

字符串插值是在 Swift 3 中面向开发者推出的,因为太拉垮被标记为废弃。到了 Swift 5 经过重写之后又回归了。

可用性:iOS 8.0+, macOS 10.10+, Mac Catalyst 13.0+, tvOS 9.0+, watchOS 2.0+, Xcode 10.2+

在没有字符串插值之前,只能使用字符串格式化这一种方式:

let name = "Jack"
let age = 14
let text = String(format: "My name is %@, and %ld years old", name, age)

传统的字符串格式化需要记住每个类型所对应的格式,完整的字符串格式表可以在这里查看:String Format Specifiers

使用字符串插值以后,以上代码书写就简化了不少,而且能正确处理好格式化类型:

let name = "Jack"
let age = 14
let text = "My name is \(name), and \(age) years old"

可以理解为:字符串插值是字符串格式化的语法糖,而且提供了更强大的能力。

字符串插值协议

protocol ExpressibleByStringInterpolation

遵守了这个协议就可以在字符串字面量中使用插值,在原生类型中 String 已经实现了这个协议。

前面提到了,以前的字符串格式化需要正确的格式化类型才不会在运行的时候出错,字符串插值可以自动处理。

这是因为协议 ExpressibleByStringInterpolation 约定了两个方法:

protocol ExpressibleByStringInterpolation {

  init(literalCapacity: Int, interpolationCount: Int)

  mutating func appendLiteral(_ literal: String)

  mutating func appendInterpolation<T>(_ literal: T)
}

初始化方法 init(literalCapacity: Int, interpolationCount: Int) 指明了字符串常量和插值的数目,这样可以在一开始就分配好所需要的内存空间, appendLiteral(_:) 负责处理字符串常量,appendInterpolation(...) 负责处理插值。

例如:

let text: String = "My name is \(name), and \(age) years old"

在运行期间就会被分解为:

String.init(literalCapacity: 3, interpolationCount: 2)
String.appendLiteral("My name is ")
String.appendInterpolation(name)
String.appendLiteral(", and ")
String.appendInterpolation(age)
String.appendLiteral(" years old")

自动处理格式化类型的奥秘就在于 appendInterpolation(..),它并不是一个有具体参数类型的方法,查看官方文档的协议定义也不会找到对这个方法的定义。

因为它是只约定了函数的名称必须是 appendInterpolation 就可以,参数类型参数数目都没有约束,所有无法给出它的具体定义。这也是保证了插值的灵活性。

Swift 会在编译期间自己匹配最佳的插值函数 appendInterpolation,如果找不到匹配的实现就会报错。

String 实现了常用的几个插值支持:


// 对应的插值: \(10)
mutating func appendInterpolation(_ literal: Int)

// 对应的插值: \(3.14)
mutating func appendInterpolation(_ literal: Double)

// 做为容错,使用 %@ 进行格式化
mutating func appendInterpolation(_ literal: Any)

扩展插值协议

当传入的插值类型为 Date 时,我们希望能够在格式化之前先自定义一下再转为字符串:

let date = Date()
let text: String = "date: \(date: date, dateFormat: "yyyy/MM/dd HH:mm:ss")"

// 输出:"date: 2022/07/02 22:58:59"

和之前不一样的是,插值看起来像是一个函数调用,实现上也确实如此,调用插值协议中的方法是:

mutating func appendInterpolation(date: Date, dateFormat: String = "yyyy/M/d")

我们也可以增加一个自定义实现,支持把 Date 格式化为 ISO8601 的形式:

extension String.StringInterpolation {

  @available(iOS 10.0, *)
  mutating func appendInterpolation(iso8601 date: Date, formatOptions: ISO8601DateFormatter.Options = [ .withInternetDateTime ]) {
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = formatOptions
    appendInterpolation(formatter.string(from: date))
  }
}

使用的时候指定使用这个插值方法:

let date = Date()
let text: String = "date: \(iso8601: date)"

// 输出:"date: 2022-07-02T15:16:02Z"

本地化支持

在 Swift 中,使用 NSLocalizedString 系列方法中使用字符串插值无法进行正确的本地化,因为读取不到语言文件中正确的 key,而且 genstrings 工具也不能正确的提取含有插值本地字符串。

但是 SwiftUI 可以使用插值正确的处理本地化:

Text("...")

Text() 方法先会查找本地化语言中的 key 是否存在,如果有则显示本地化后的内容。

值得一提的是,genstrings 能够正确的提取 Swift 源文件中的 Text() 调用的字符串:

genstrings -SwiftUI -o zsxq/zh-Hans.lproj

不用介意参数的名称是 -SwiftUI ,对于一般的 Swift 源码也能提取,只要方法名是 Text()

Text 是一个结构体类型,它的初始化方法定义是 init(_:tableName:bundle:comment:)

struct Text {
  init(_ key: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil, comment: StaticString? = nil)
}

关键的是第一个参数 key,类型是 LocalizedStringKey ,也是一个结构体类型 LocalizedStringKey

struct LocalizedStringKey: Equatable,
                           ExpressibleByExtendedGraphemeClusterLiteral,
                           ExpressibleByStringInterpolation,
                           ExpressibleByStringLiteral,
                           ExpressibleByUnicodeScalarLiteral {
}

是一个实现了字符串插值协议的结构体类型,由于 TextLocalizedStringKey 均只属于 SwiftUI,而这个库是苹果闭源实现的。

在 Swift 中需要实现支持字符串插值的本地化,只能自己模仿实现了。

模仿实现 LocalizedStringKey

根据文档,这个类除了遵守插值协议之外还有其它与字符串字面量相关的协议,为了简单现在只实现插值协议。

struct LocalizedStringKey: ExpressibleByStringInterpolation {

  struct StringInterpolation: StringInterpolationProtocol {

    init(literalCapacity: Int, interpolationCount: Int)

    // 处理常量类型
    mutating func appendLiteral(_ literal: String)

    // 处理插值类型
    mutating func appendInterpolation(_ literal: Any)
  }

  init(stringLiteral value: String)

  init(stringInterpolation value: LocalizedStringKey.StringInterpolation)
}

以上就是这个类型的最简单定义,对于字符串的处理实际上是由内部的 struct StringInterpolation 类型负责,这样设计的好处是让插值处理的逻辑可以单独存放。

struct StringInterpolation 要做的事情比较简单,就是实现字符串插值协议 StringInterpolationProtocol,把字符串常量和插值存储起来。

struct StringInterpolation: StringInterpolationProtocol {

  var values: [String]

  var args: [CVarArg]

  init(literalCapacity: Int, interpolationCount: Int) {
    values = [String]()
    values.reserveCapacity(literalCapacity)

    args = [CVarArg]()
    args.reserveCapacity(interpolationCount)
  }

  // 处理常量类型
  mutating func appendLiteral(_ literal: String) {
    values.append(literal)
  }

  // 处理插值类型
  mutating func appendInterpolation(_ literal: Any) {
    args.append(String(describing: literal))
    values.append("%@")
  }
}

把所有的插值类型都当做 Any 处理,并使用 %@ 进行格式化。在本地化的时候,Int 类型会被提取为 %ldDouble 类型会被提取为 %fString 类型会被提取为 %@, 再增加一些方法:

  mutating func appendInterpolation(_ literal: Int) {
    args.append(literal)
    values.append("%ld")
  }

  mutating func appendInterpolation(_ literal: Double) {
    args.append(literal)
    values.append("%f")
  }

  mutating func appendInterpolation(_ literal: String) {
    args.append(literal)
    values.append("%@")
  }

以上几具方法都是有规律的,只需要指定数据类型和所对应的格式化就可以,所以可以使用范型来避免重复实现:

  public mutating func appendInterpolation<T>(_ literal: T, specifier: String) where T: CVarArg {
    args.append(literal)
    values.append(specifier)
  }

extension LocalizedStringKey.StringInterpolation {

  public mutating func appendInterpolation(_ literal: Int) {
    appendInterpolation(literal, specifier: "%ld")
  }

  public mutating func appendInterpolation(_ literal: Double) {
    appendInterpolation(literal, specifier: "%f")
  }

  public mutating func appendInterpolation(_ literal: String) {
    appendInterpolation(literal, specifier: "%@")
  }
}

把以上几个特化类型移到扩展中实现,可以进一步解耦合。

再模仿基本类型 String 中的插值扩展,添加对 FloatDate 类型的格式化支持:

extension LocalizedStringKey.StringInterpolation {

  public mutating func appendInterpolation(number: Float, minimumFractionDigits: Int = 0, maximumFractionDigits: Int = 2) {
    let formatter = NumberFormatter()
    formatter.minimumFractionDigits = minimumFractionDigits
    formatter.maximumFractionDigits = maximumFractionDigits

    guard let arg = formatter.string(from: NSNumber(value: number)) else { return }

    appendInterpolation(arg)
  }

  public mutating func appendInterpolation(date: Date, dateFormat: String = "yyyy/M/d") {
    let formatter = DateFormatter()
    formatter.dateFormat = dateFormat
    appendInterpolation(formatter.string(from: date))
  }
}

对于其它的类型,需要增加插值支持时候,比较直接的是添加一个新的扩展方法。

在 SwiftUI 中,使用的方法是使用协议 FormatSpecifiable

这也是一个 SwiftUI 专有的协议,而且是私有类型,不公开的,名称前带有下划线前缀 _FormatSpecifiable。 但是我们可以模仿实现并且让它可以在普通的 Swift 源码中使用。

protocol FormatSpecifiable {

  associatedtype _Arg: CVarArg

  var _arg: _Arg { get }

  var _specifier: String { get }
}

extension LocalizedStringKey.StringInterpolation {

  public mutating func appendInterpolation<T>(_ literal: T) where T: FormatSpecifiable {
    appendInterpolation(literal._arg, specifier: literal._specifier)
  }
}

只需要让自定义类型遵守 FormatSpecifiable 就可以了,比如对于 Bool 数据类型:

extension Bool: FormatSpecifiable {
  public var _arg: String { self ? "true" : "false" }
  public var _specifier: String { "%@" }
}

let isAdmin = true
let text = LocalizedStringKey("isAdmin: \(isAdmin)")
// "isAdmin: true"

以上,StringInterpolation 类型就已经实现完成了,接下来实现 LocalizedStringKey

struct LocalizedStringKey: ExpressibleByStringInterpolation {

  struct StringInterpolation: StringInterpolationProtocol { // 省略... }

  var stringInterpolation: StringInterpolation

  var key: String { stringInterpolation.values.reduce("", { $0 + $1 }) }

  var args: [CVarArg] { stringInterpolation.args }

  var description: String { args.isEmpty ? key : String(format: key, arguments: args) }

  init(stringLiteral value: String) {
    stringInterpolation = StringInterpolation(literalCapacity: 0, interpolationCount: 0)
    stringInterpolation.appendLiteral(value)
  }

  init(stringInterpolation value: Self.StringInterpolation) {
    stringInterpolation = value
  }
}

以上就是完整的实现,比较简单。只需要按照协议 ExpressibleByStringInterpolation 实现两个初始化方法就可以了。

最后实现 Text(...) 方法,在 SwiftUI 里面它是一个 struct Text 结构体,为了方便在普通的 Swift 源码中使用,把它改为普通函数:

func Text(_ text: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil, comment: StaticString? = nil) -> String

首先从输入的字符串字面量中分离中出 key,然后查找语言文件中是否有对应的 value,有的话就使用 value 进行格式化输出。

func Text(_ localizableString: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil, comment: StaticString? = nil) -> String {
  var bdl = bundle
  if bdl == nil {
    bdl = .main
  }

  let placeholder = "\u{fffe}\u{fcfb}"
  let key = localizableString.key
  let value = bdl!.localizedString(forKey: key, value: placeholder, table: tableName)

  guard !value.elementsEqual(placeholder) else {
    return localizableString.description
  }

  let args = localizableString.args

  return args.isEmpty ? value : String(format: value, arguments: args)
}

现在在 UIKit/AppKit 中使用的时候可以像 SwiftUI 一样了:

let text = Text("date: \(Date())")

完整源码

https://github.com/cntrump/Localizable

Swift
版权声明:如果转发请带上本文链接和注明来源。

lvv.me

iOS/macOS Developer

在 macOS 上使用 Finder 安装 IPCC (运营商配置文件)

C 和 C++ 中的结构体(struct)有和不同?