Artorius

Swift 5字符串内部编码变化

April 08, 2019 | 6 Minute Read

TLDR:

  1. 不要假设String实例里的编码是UTF-16 / UTF-8
  2. 不要把Range实例和相关联的String实例分开使用

这篇文章假设你已经知道Swift Range<String.Index> 对emoji或其他ligature的处理,和Objective-C NSRange是不同的!这边文章不是探讨这个问题的。

下面详细说说来龙去脉,试着先说清楚我们遇到的实际问题,如果你没兴趣了解太多实际问题的上下文,可以直接跳到下文「实验」部分。

遇到的bug

遇到一个Crash,我们有一个功能,是在TextView删去一段字符(比如@Arthur )的最后一个字符(空格)的时候,删去整段字符(@Arthur ),类似微信里你删除一个@的人的名字的时候的体验。

我实现的方式是:

  1. textView(_:shouldChangeTextIn:replacementText:) 回调中,检查如果用户在尝试删除 @Arthur 中那个空格的时候,找到@Arthur的Range,把Range记录下来。
  2. 之后这个Range会被传来传去,最后在另一个地方拿着这个Range去删除 textView.attributedText.string 里的对应字符。当然我可以确保这个过程中 textView.attributedText.string 并没有发生变化。

两者都会把Swift Range<String.Index>转为NSRange,但转的时候基于的String是相同内容的不同实例。以上是前提。

在Swift4.2以及之前的版本里,都没有问题。升级到Swift5之后发生了Crash,表现是步骤 1 和 2 中拿到的 NSRange 并不相同。比如 1 的时候找到的是 {2, 6},到了 2 的操作时候变成了 {6, 16},这都是 NSRange(range, in: string) 来转的结果,如前面所说这个string是内容相同的不同实例。

这里真正值得注意的就是这两个不同的实例,1里的string是一个在Swift初始化的 String 实例,而2里是textView.attributedText.string

看到这里可能nb的你已经注意到了,前者是一个swift String,在5的内部编码(native encoding)被改成了UTF-8;而后者textView.attributedText.string虽然在编译时是一个swift String类型,但原先是一个Foundation的 NSString,所以他的内部编码是UTF-16。

Range是在UTF-8上找到的,再应用到UTF-16的字符串上自然是有问题了。(比如ASCII在前者是单个Byte,后者则两个Bytes等)

实验

得到这个结论后,写了个例子

import Foundation

let string1 = "汉字Let's try to reproduce this bug.."
let string2 = String(NSString(string: "汉字Let's try to reproduce this bug.. "))
let substring = "Let"
if let range1 = string1.range(of: substring, options: [], range: string1.startIndex..<string1.endIndex, locale: nil),
    let range2 = string2.range(of: substring, options: [], range: string2.startIndex..<string2.endIndex, locale: nil) {

    let nsRange1 = NSRange(range1, in: string1) // {2, 3}
    let nsRange2 = NSRange(range1, in: string2) // {6, 3}

//    let nsRange3 = NSRange(range2, in: string1)
//    let nsRange4 = NSRange(range2, in: string2)
}

这是一个运行时的问题,string2的内部编码仍是之前的UTF-16,但string1则完全是一个(Swift5改动后)UTF-8的字符串。

这就是为什么string1中找到的range1,在用string2转成NSRange的时候会错。

(有意思的是如果把注释打开,nsRange3那一行会在运行时报错EXC_BAD_INSTRUCTION,你根本无法把range2string1转成NSRange?)

所以……

所以第一反应是为什么语言层或者Foundation不帮忙做这个转换,但仔细想想,其实没人敢保证String/NSString里是什么编码的,没法自动完成这个转换。虽然NSString默认是UTF-16,swift String默认是UTF-8,但运行时谁也说不好操作的字符串是默认编码的,我们的代码也不该在任何时候做这样的假设。

所以把开头那两条东西再说一遍。

  1. 不要假设String实例里的编码是UTF-16/UTF-8;
  2. 不要把Range实例和相关联的String实例分开使用。

当然有很多转换的方法,Swift 5还deprecate了一堆比如encodedOffset这样的方法,开发者必须明确知道自己操作的String是什么编码的,可以用utf16Offset<S>(in: S)之类的完成之前的工作。不过这不是本文的重点了,比较直接的做法:

let utf8String = String(utf8String: string2.cString(using: .utf8)!)

总之从此之后,针对Range/NSRange的操作,除了以前要注意的emoji/ligature的不同处理以外,还要更细致的处理操作的String/NSString的编码,他们不再是无脑UTF-16了。