移除字符串中的�
一个非常非常非常常见的需求,一个限制输入长度的输入框,同时不限制用户输入字符的类型。
这里我们先假定一个需求:
一个最多输入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 舍掉。
一些其他的想法
其实其他方法还有很多,比如:
- 通过文档可知,
init?(_ codeUnits: Substring.UTF16View)
当codeUnits
非法时返回nil
。所以也可以通过这点来编写递归/循环,递减endIndex
。 - 提前判断所要截取的字符是否是 emoji,如果是,则整个 emoji 截掉。
- 从
" 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,最后才能判断成功。