Swift String编码

4,001 阅读11分钟

字符串通常是程序员最早接触的数据结构,但是很少人会真正深入了解字符串的本质,我也是其中一个。直到最近做了一个文本相关的需求,在实现字符串选区和光标定位时,遇到很多怪异的现象,倒逼着自己浏览了一些关于字符编码的文章。至此才发现,自己对字符串的了解竟还如此浅薄。

一、举个栗子

Swift 的字符串是String,对应可以直接桥接的 Object-C 类型是NSString,既然都能桥接了,那么两者是不是一样用呢?看下面两段代码,猜猜输出的是啥?

let string: String = "😋😄🙂✅❎"
print("nsstring length: \(string.count)")

let nsstring: NSString = "😋😄🙂✅❎"
print("nsstring length: \(nsstring.length)")

第一段简单,根据官方文档对String.count的描述:The number of characters in a string,其输出的是字符串中字符的个数,目之所及,字符串"😋😄🙂✅❎"包含 5 个字符,因此输出的是 5。

第二段估计大多数人会答错,答案是 8。官方文档对NSString.length的描述是:The number of UTF-16 code units in the receiver,也就是说length表示字符串包含的 UTF-16 码元个数。问题来了,字符串中都是 emoji 表情,为什么 UTF-16 码元数量不是字符数量的整数倍?这是因为 emoji 字符的 UTF-16编码可能包含 1 个码元(例如✅❎),或者 2 个码元(例如😋😄🙂),甚至是 3 个(例如#️⃣0️⃣1️⃣)。

怎么看字符串的 UTF-16 编码呢?可以在程序中加个断点,然后用 LLDB 调试命令po string.utf16即可打印出字符串的所有 UTF-16 码元:

(lldb) po string.utf16
 StringUTF16("😋😄🙂✅❎")
  - 0 : 55357
  - 1 : 56843
  - 2 : 55357
  - 3 : 56836
  - 4 : 55357
  - 5 : 56898
  - 6 : 9989
  - 7 : 10062

既然有出乎意料的点,就是存在从理所当然的想法出发写出程序Bug的风险。既然如此,接下来咱们开始扒一扒字符串编码的原理。

NOTE:为什么说大多数人会答错,是因为首先比较少开发者会需要注意到这些概念细节,其次知道原理的人之中,应该也很少有会去背每个 emoji 表情包含几个码元吧😄。附上Emoji 编码表

二、Swift的默认字符串编码

看完上面内容,估计看官中有很多人和我产生同样的疑问,既然 Objective-C 的NSString默认是使用 UTF-16 编码,那么 Swift 的String呢?它是默认使用什么编码方式?为什么它的count要设置为获取字符个数呢?为啥偏偏要和NSString做得不一样呢?

首先官方文档对String的描述:A Unicode string value that is a collection of characters,没有明确指出String的编码方式。不像对NSString的描述中A string object presents itself as a sequence of UTF–16 code units明确指出编码方式是 UTF-16。不过从unicodeScalars的定义不难推测出,String的默认编码方式是 UTF-32。

NOTE:A string’s unicodeScalars property is a collection of Unicode scalar values, the 21-bit codes that are the basic unit of Unicode. Each scalar value is represented by a Unicode.Scalarinstance and is equivalent to a UTF-32 code unit.

需要注意的是,大到码元占 4 个字节的 UTF-32 编码,尚且不能保证每个字符均能用单个码元表示。举个例子,#️⃣就包含了 3 个 UTF-32 码元。不过诸如😋😄🙂之流,“比较弱鸡的 Emoji 表情”还是可以一个码元轻松搞定的。按理说,UTF-32 码元的 4 个字节空间足以构成每个码元“单挑”单个字符的局面,之所以不是,应该是考虑了对 UTF-16 编码策略的兼容性。

(lldb) po "#️⃣".unicodeScalars
 StringUnicodeScalarView("#️⃣")
  - 0 : "#"
  - 1 : "\u{FE0F}"
  - 2 : "\u{20E3}"(lldb) 

po "😄".unicodeScalars
 StringUnicodeScalarView("😄")
  - 0 : "\u{0001F604}"

由于 UTF-32 编码码元数量可以非常趋近于字符串字面上所看到的的字符数量,而且count含义设置为字符数量,更符合所见即所得的直观原则,能对字符串做到更准确的抽象,像NSString.length返回 UTF-16 码元数量的设定,使NSString抽象类型耦合了具体编码类型,实际上是非常不合理的设计。

三、Unicode编码

Unicode 是一系列文字编码标准的统称,(官方描述:The World Standard for Text and Emoji),运用最广泛的包括 UTF-8、UTF-16、UTF-32,三者最大的区别是 UTF-8 的码元是 1 个字节、UTF-16 码元 2 个字节、UTF-32码元 4 个字节。截至目前,三者中运用最广的其实还是 UTF-8。

字符串的组成单元是字符,编码就是为每个字符指定唯一的“代号”,从而使文本数据可以在各种操作系统、平台中流通,不同的平台只要知道文本数据的编码方式,就可以还原出文本内容。Unicode 限定了编码空间为 21-bit(0x000000 ~ 0x10FFFF),可粗略估算其可编码字符数量为 221,百万级,足以覆盖所有语言的基本字符和其他特殊字符。

3.1 UTF-8

UTF-8 是应用最广的 Unicode 编码。其码元是 1 个字节,区区 1 个字节的码元想单挑字符显然是不可能的,像咱们中文动辄上万的字符显然单个码元远远“搞不定”。UTF-8 标准设定单个字符包含的码元数量可以从 1~4 个不等。例如基本 ASCII 字符可以用 1 个 UTF-8 码元表示,中文字符则需要三个。

像 UTF-8 这种可变长的编码方式,最重要的是要考虑“前缀”的问题。打个比方,如果用\u00表示'0'\uAA表示'M',用\u00 \uAA表示'❌',那么解码过程中遇到\u00 \u0A时,它既可以被解码为"0M"也可以解码为"❌",歧义由此产生。因此编码算法设计必须要避免类似的冲突以保证解码过程的正确性和快捷性。

那么 UTF-8 编码是如何设计编码空间以避免歧义的呢?看下面这张表就很好理解。

  • 第一行内容:单个码元的编码空间为0x000x7F,可编码 27 个字符。解码过程中遇到二进制最高位为0则将其判定为单码元字符;
  • 第二行内容:两个码元的编码空间为0x00800x07FF,可编码 211 - 27 个字符。解码过程中遇到二进制最高三位为110则将其判定为双码元字符;
  • 第三行内容:三个个码元的编码空间为0x08000xFFFF,可编码 216 - 211 个字符。解码过程中遇到二进制最高四位为1110则将其判定为三码元字符;
  • 第四行内容:四个码元的编码空间为0x1000000x10FFFF,可编码 221 - 216 个字符。解码过程中遇到二进制最高五位为11110则将其判定为四码元字符;

截屏2022-05-21 下午5.24.10.png

因此在 UTF-8 编码算法中,字节高位010110111011110均被赋予了特殊含义,确保每个字符编码均不存在歧义,编码为不存在歧义的以字节为单位的二进制数据流。在文本数据流解码过程中,逐字节读取数据,根据字节高位的值选择对应的处理策略,将二进制数据流还原为文本。

其实前面描述有点不准确的地方,将码元组合称为字符实际上是不准确的,码元的组合应该是 Unicode Scalar,同理长度不超过 4 个字节的是 Unicode scalar 而不是字符,字符可以由多个 Unicode scalar 构成,例如某些 Emoji 表情,例如前面提过“很能打”的#️⃣,包含 3 个 Unicode Scalar,其 UTF-8 编码占据 7 个字节。

参考文章:CodeDocs.org/UTF-8

3.2 UTF-16

UTF-16 的码元是 2 个字节。为避免编码歧义,仍然是采用的类似于 UTF-8 的设定特殊前缀的基本思想。其总的编码空间仍然是 21-bit(0x000000 ~ 0x10FFFF)。首先 0x0000 ~ 0xD7FF 以及 0xE0FF ~ 0xFFFF 编码是多语言平面,用于表示各国语言字符。也就是刨掉了 0xD800 ~ 0xDFFF 这段空间,不出意外的话,这块刨掉的数值空间会作为 UTF-16 编码的特殊前缀存在。

预留的 0xD800 ~ 0xDFFF 用于以两个 UTF-16 码元表示 0x10000 ~ 0x10FFFF 范围内的 Unicode Scalar。UTF-16 编码的策略是:由两个码元构成的 Unicode Scalar,其两个码元的值必定落在 0xD800 ~ 0xDFFF 的闭区间之内。关于如何用这些码元表示 0x10000 ~ 0x10FFFF 之间的 220 个编码值如下表所示。行标题为低位码元,列标题为高位码元值,行列相交的单元格则为两个码元所表示的 Unicode Scalar 编码值:

  • 高位码元的值在 0xD800 ~ 0xDBFF 的闭区间内,共 210
  • 低位码元的值在 0xDC00 ~ 0xDFFF 的闭区间内,共 210。高位码元数量和低位码元数量相乘刚好等于 0x10000 ~ 0x10FFFF 之间的编码值数量。所以直接从左到右,从上到下递增排列,正好排满整张表;

截屏2022-05-21 下午6.26.33.png

至此,有没注意到,既然 0xD800 ~ 0xDFFF 已经被定义为特殊前缀(或者说特殊编码值更加准确)用于组合 UTF-16 双码元的 Unicode Scaler,而这个区间之外的 0x0000 ~ 0xD7FF 以及 0xE0FF ~ 0xFFFF 是多语言字符,那它自己呢?0xD800 ~ 0xDFFF 之间的 Unicode Scalar 不就无法被 UTF-16 编码了么?没错,就是无法被编码,因为 Unicode 标准规定了 0xD800 ~ 0xDFFF 之间的编码值不能指定给任何字符,但由于 UTF-8、UTF-32 是可以编码这些 Unicode Scalar 值的,所以在 UTF-16 上免不了有人不按标准来。如果在编码端强行将这些值指定为某些字符,则在解码端也就需要相应的适配,这种做法在兼容性上是非常差的,应尽量避免。

参考文章:CodeDocs.org/UTF-16

3.3 UTF-32

UTF-32 编码几乎是对 UTF-16 编码的合成,例如包含两个 UTF-16 码元的😋😄🙂表情,用 UTF-32 编码则是分别对应一个 UTF-32 码元。简而言之,UTF-32 编码实现了百分百码元单挑 Unicode Scaler 的愿景。当然,像#️⃣这种“叶问级别字符”还是得三个 UTF-32 码元一起上。

四、再举个栗子

前面介绍这么多,貌似没看出对写 Swift 代码有什么用。这里设立个具体场景。APP 从上层页面带入一串字符串,填入当前界面的UITextView控件中。现在要 APP 在UITextView中选中指定的关键信息。

// 输入字符串
let text = "床前😋出来明❎月光,疑😄是莉莉地上🌹霜,举头喝✅酒望明月,低头🌊有妹子故🙂乡"
// 目标选中字符串
let target = "有妹子"

实现该功能的代码段如下。代码段中透露出关键信息是:UITextView.selectedRange的 Range 对应的是 UTF-16 编码的码元范围。此时如果不知道String编码相关细节 ,实现和调试过程中可能会有很多疑惑。而且一旦误用unicodeScalars等等与 UTF-32 编码关联性较大的属性,或者String.index等等对字符串的直观抽象的属性的话,会走多很多弯路。

let input = "床前😋出来明❎月光,疑😄是莉莉地上🌹霜,举头喝✅酒望明月,低头🌊有妹子故🙂乡"
let target = "有妹子"
textView.text = input
let interestRange = input.range(of: target)
guard let targetRange = interestRange else { return }
let location = targetRange.lowerBound.utf16Offset(in: input)
let length = targetRange.upperBound.utf16Offset(in: input) - location
textView.selectedRange = NSRange(location: location, length: length)

总结

String.unicodeScalar属性可以视为直接与 UTF-32 编码关联,因为返回数组的每个元素恰好是字符串的 UTF-32 编码码元。

String.countString.index系列等属性,是对字符串的直观特征的描述。count表示的是字符串的字符个数,index表示的是直观的“字符索引”。

例如以下代码返回的结果是#️⃣,其含义就是返回第 2 个字符,不是第 2 个 Unicode scalar,更不是第 2 个编码码元,另外Charater是对单个字符的抽象,#️⃣对应的是一个Character对象。

`"8️⃣#️⃣"["8️⃣#️⃣".index("8️⃣#️⃣".startIndex, offsetBy: 1)]`

如果上层输入的用于选中#️⃣的索引是字符串 UTF-16 编码码元的索引(这种情况并不少见,例如索引是来自UITextView.selectedRange的返回值),此时应该用这句代码来选中#️⃣:

"8️⃣#️⃣"["8️⃣#️⃣".utf16.index("8️⃣#️⃣".utf16.startIndex, offsetBy: 3)]

最后吐槽一句:Swift 的String虽然对字符串做了更合理的抽象,但是真的很难用!