一个非常非常非常常见的需求,一个限制输入长度的输入框,同时不限制用户输入字符的类型。

这里我们先假定一个需求:

一个最多输入6个字符的输入框,不限制用户输入的字符类型,即可以输入空格、中文英文、标点符号、数字以及 Emoji。

在这个需求下考虑下面这个场景:

输入框上已经有了文字:" g,5就",用户即将输入一个 🉑️。

这时,你的程序很可能就会出问题了。

字符长度

我们大家应该都知道 Swift.String 的 count 属性和 ObjC 的 length 属性获取的结果不同。

比如下面的代码,实际输出结果如注释所述。

let string = " g,5就🉑️"

print(string.count) // 6
print((string as NSString).length) // 8

回到需求和场景上,我相信你早就知道,在做这种截取的时候,不能直接用 string.count 判断长度。

这种时候我们一般会用 utf16 编码来获取长度:

let string = " g,5就🉑️"

print(string.count) // 6
print(string.utf16.count) // 8
print((string as NSString).length) // 8

直接截取

搞定了长度,剩下的就是截取了:

extension String {
    func prefixed(_ maxLength: Int) -> String {
        let sequence = utf16
        
        guard sequence.count > maxLength else { return self }
        
        let startIndex = sequence.startIndex
        let endIndex = sequence.index(startIndex, offsetBy: maxLength)
        
        let result = String(sequence[startIndex ..< endIndex])
        
        // 以防万一,借助 Objective-C 的能力进行截取
        return result ?? (self as NSString).substring(to: maxLength)
    }
}

因为 String 的 init?(_ codeUnits: Substring.UTF16View) 构造器返回可能为空,保险起见最后还是借用了一下 ObjC 的方法来做截取。

好了,到目前为止是我写这篇博客之前的理解和做法。后面开始说问题

半个 emoji

对上述的场景应用该方法后,我们得到的截取结果其实是有问题的:

" g,5就🉑️".prefixed(6) //  g,5就�

6的长度限制正好卡在 🉑️ 的编码上,导致最后遗留下来半个 emoji。

如果仅仅是展示还好,但是如果拿这个字符串在 iOS 13 上,使用 JSONEncoder 去编码,就会造成闪退,而且不论是通过 try catch 也好,try? 也好,都无法捕获到该异常(EXC_BAD_ACCESS)。

解决问题

通过断点可知,原本方法的 result 属性是 nil,最终是通过 ObjC 的 substring 方法得到了目标字符串。

很明显这个问题是 emoji 截取不全导致的,这种情况下我们应该删除整个 emoji,而不是残留半个。

进一步搜索,我找到了这篇文章:Swift 字符串 截取 半个表情emoji \u0000fffd 的处理

原文解决方法如下所示,通过实践,该方法确实可以解决问题。

// text:Optional("123456789😒")
var newText = (text as NSString).substring(to: 10)
newText = (text as NSString).substring(to: maxLength)

// 有可能会截取到半个表情,所以这里剔除掉半个表情的情况
if let data = newText.data(using: .utf8),
    let temp = NSString(data: data, encoding: String.Encoding.utf8.rawValue),
   temp.contains("\u{0000fffd}") {
    newText = temp.replacingOccurrences(of: "\u{0000fffd}", with: "") as String
}

进一步优化

其实上面的方法无需将 data 转为 NSString,直接使用 String 去 contains 也是可以的,这个暂且按下不表。

但是对于无法预测的用户输入,每一次截取都要先将 text 转为 data,再转回 string,最后才能做判断,我是不太想接受的。

所以我直接将这个答案丢给了 ChatGPT,结果一系列的调教和实践,最终得到了如下版本的方法:

extension String {
    func prefixed(_ maxLength: Int) -> String {
        let sequence = utf16
        
        guard sequence.count > maxLength else { return self }
        
        let startIndex = sequence.startIndex
        var endIndex = sequence.index(startIndex, offsetBy: maxLength)
        
        while endIndex > startIndex && UTF16.isTrailSurrogate(sequence[endIndex]) {
            endIndex = sequence.index(before: endIndex)
        }
        
        let result = String(sequence[..<endIndex])
        
        return result ?? (self as NSString).substring(to: maxLength)
    }
}

一些 UTF-16 的概念

如果你对 “为什么” 不感兴趣,那么现在你已经得到了答案,可以关闭掉这个页面了。

下面的内容将先介绍一些和 UTF-16 有关的概念。

UTF-16

UTF-16编码的字符可以是1个或2个16位码元。
那些只需要1个16位码元的字符被称为BMP(基本多文本平面)字符,如英文字母,数字,标点符号等。
而那些需要2个16位码元的字符被称为非BMP字符,如某些emoji。

Surrogate Pairs

在 Unicode标准中,还包含 "Low surrogate" 和 "high surrogate" 两个概念,或者也称为前导代理(lead surrogate)和 尾随代理(trail surrogate)。

它们共同组成了 "surrogate pairs",即一个代理对。
它被用来表示在 UTF-16 编码中,不能用单个16位编码单元所表示的字符,取值范围在 U+10000 至 U+10FFFF 之间。

"High surrogate" 是一个代理对中的第一个16位编码单元,其范围从 U+D800 到 U+DBFF。
"Low surrogate" 是一个代理对中的第二个16位编码单元,其范围从 U+DC00 到 U+DFFF。

例如,emoji "🉑️" 在UTF-16编码下,是由两个16位码元组成的:0xD83D 和 0xDD91。这两个码元就是一个代理对,其中 0xD83D 是前导代理,0xDD91 是尾随代理。

回到需求和场景

对于字符串 " g,5就🉑️",当我们的最大长度为 6 位时,截取的为止恰好在 0xD83D 和 0xDD91 的中间。此时 endIndex 指向的是第7位,即 0xDD91 尾随代理。

所以我们用 UTF16.isTrailSurrogate 方法判断,如果截取的末尾是一个尾随代理,则向前移动一位,将整个 emoji 舍掉。

一些其他的想法

其实其他方法还有很多,比如:

  1. 通过文档可知,init?(_ codeUnits: Substring.UTF16View)codeUnits 非法时返回 nil。所以也可以通过这点来编写递归/循环,递减 endIndex
  2. 提前判断所要截取的字符是否是 emoji,如果是,则整个 emoji 截掉。
  3. " g,5就�" 中判断是否包含 这个字符,如果有就全局替换。

其中第三个方法我还具体实践了一下。

首先我们没有办法通过 utf16 直接得到 String 对象,因为 这个符号会导致初始化失败。所以只能通过 NSString 来拿到目标字符串。

其次,当我们拿到了 NSString 对象后,直接调用 .contains("�"),返回值居然是 false

let string = " g,5就🉑️"
let original = (string as NSString).substring(to: 6)
print(original.contains("�")) // false

此时,只要向文中提到的第一种方法那样,对 string 转一次 data 即可判断成功。

let string = " g,5就🉑️"

let original = (string as NSString).substring(to: 6)
let utf8Data = original.data(using: .utf8)!

if let test = String(data: utf8Data, encoding: .utf8) {
    print(test.contains("�")) // true
}

但是这一套下来确实有点得不偿失了。

但是,为什么是 false?

经过翻阅资料,怀疑这是因为 NSString 和 String 的底层处理不同导致的。

对于 NSString,允许存在无效字符,所以 " g,5就�" 中的 和我们手写的 并非是统一个含义,后者代表一个 “Unicode替换字符”,它用来替换无法识别或无效的Unicode字符。而 NSString 中的 就只是一个无效的 Unicode,所以这两个才会不匹配。

这其实也能回答最开始的那个方案,为什么需要先转 data,再转一次 string,最后才能判断成功。