云端 Mac 上验收 iOS 文本高亮:UTF-16 与组合字符边界

云端 Mac 上验收 iOS 文本高亮:UTF-16 与组合字符边界

搜索列表里的姓名,英文正常,遇到带音标的文字或肤色表情,高亮却偏移了一截。问题通常不在字体,而在范围契约:服务端给字节偏移,界面接收 UTF-16 偏移,业务代码又按 Swift 的字符数截取。在云端 Mac 上先跑一组纯文本检查,比反复点远程桌面更容易定位。

这套方法适用于搜索摘要、富文本标记和聊天内容选区。只需要已有的 Xcode 命令行工具,不需要联网服务;在 XcNode 的独享物理节点上,也应把测试输入与工具链记录一起保留。

先写清偏移单位

Swift String.count 统计扩展字素簇,也就是 Character;utf16.count 统计 UTF-16 码元;utf8.count 统计字节。三者不能互换。一个表情可能由基础图形、肤色修饰符和零宽连接符组成,看起来只有一个字符,内部却有多个码元。

Foundation 的 NSRange 用于字符串接口时通常采用 UTF-16 单位。不要把 text.count 当成合法范围上限,也不要用整数直接推进 String.Index。接口文档至少约定单位、原文版本、空范围策略,以及匹配不到时的表示方法。

转换成功只说明索引可以建立,不保证范围两端都落在完整的用户可见字符边界。显示高亮还需要第二层校验。

原文一旦修改,旧范围就应失效。不要拿旧搜索结果去标记经过替换、大小写转换或删减后的新字符串。

用小脚本拒绝半个字符

保存以下内容为 highlight-check.swift。校验先处理不存在和越界,再转换索引,最后检查两端是否属于 Character 边界。减法形式的长度判断还能避免先计算起点加长度导致整数溢出。

import Foundation

func highlightRange(
    _ value: NSRange,
    in text: String
) -> Range<String.Index>? {
    let total = text.utf16.count
    guard value.location != NSNotFound,
          value.location >= 0,
          value.length >= 0,
          value.location <= total,
          value.length <= total - value.location,
          let range = Range(value, in: text) else {
        return nil
    }

    var boundaries = Set(text.indices)
    boundaries.insert(text.endIndex)
    guard boundaries.contains(range.lowerBound),
          boundaries.contains(range.upperBound) else {
        return nil
    }
    return range
}

let text = "A👩🏽‍💻e\u{301}Z"
let emoji = (text as NSString).range(of: "👩🏽‍💻")
precondition(highlightRange(emoji, in: text) != nil)
precondition(highlightRange(NSRange(location: 1, length: 1),
                           in: text) == nil)

let accent = "e\u{301}"
precondition(highlightRange(NSRange(location: 0, length: 1),
                           in: accent) == nil)
precondition(highlightRange(NSRange(location: 0, length: 2),
                           in: accent) != nil)
precondition(highlightRange(NSRange(location: NSNotFound, length: 0),
                           in: text) == nil)
precondition(highlightRange(NSRange(location: text.utf16.count,
                                   length: 1),
                           in: text) == nil)

运行 xcrun swift highlight-check.swift,退出码为零表示这些断言通过;失败会终止执行。不要使用关闭检查的优化选项。进入正式测试目标后,把断言改成带样本编号的测试断言,便于定位失败输入。

这里允许字符边界上的空范围,因为编辑器插入点可能需要它。只做搜索高亮的调用方应另外拒绝零长度匹配,避免循环搜索无法前进。

搜索时保留原文坐标

为了忽略音标而先创建简化副本,再把副本偏移套回原文,是常见错误。例如预组合的带音标字符与字母加组合音标可以表达相同文字,但 UTF-16 长度未必相同。

如果产品确实要求忽略大小写或音标,可以直接在原文搜索,让返回范围仍属于原字符串:

let source = "Cafe\u{301}"
let match = source.range(
    of: "cafe",
    options: [.caseInsensitive, .diacriticInsensitive],
    locale: Locale(identifier: "en_US_POSIX")
)
guard let match else {
    fatalError("Expected a search match")
}
let nsRange = NSRange(match, in: source)
precondition(highlightRange(nsRange, in: source) != nil)
precondition(String(source[match]) == source)

固定区域设置是为了让这个样本可重复,不是所有语言搜索的统一答案。姓名搜索是否忽略音标、土耳其语大小写如何处理,都应由产品定义,再分别加样本。需要分词或转写时,应维护显式原文映射,不能靠长度相减猜位置。

把失败样本变成门禁

至少覆盖普通汉字、代理对、组合音标、肤色表情、连接表情和旗帜序列。每类同时检查完整命中与内部截断;再补首尾空范围、超出末尾和不存在的结果。

对每个合法范围做往返检查:转成 Swift 范围后,再用 NSRange(range, in: text) 转回,结果必须一致。还要断言选中的实际文本,防止“范围合法但命中了错误位置”漏过。

长文本不宜为每个匹配重复扫描全部边界。可在同一份不可变原文上建立一次边界集合,多处匹配共享使用;文本更新后重新生成。缓存不能跨不同字符串复用。

UI 验收只补显示层证据

纯文本门禁通过后,再在模拟器打开固定搜索样本,检查高亮没有切开音标,表情序列没有出现局部着色,换行后标记仍对应原文。视觉检查解决渲染问题,不能替代范围断言。

异步搜索返回时,先核对请求对应的原文版本,再应用高亮。输入框已经变化就丢弃旧结果。还应区分搜索范围与最终显示范围:如果显示层必须扩展到完整字符,保留原始匹配用于业务判断,扩展后的范围只负责绘制。

最终验收标准不是“没有崩溃”,而是单位明确、范围合法、边界完整、命中文本正确。把真实失败字符串加入固定样本,下次调整搜索算法时就能立即知道哪些行为发生了变化。

常见问题

Range(nsRange, in: text) 成功就能安全高亮吗?

不能。转换成功不代表范围两端都位于完整 Character 的边界;还应将起止索引与 text.indices 及 endIndex 核对。

能否在去音标后的副本上搜索,再直接使用偏移?

不能直接复用。变换可能改变 UTF-16 长度;应在原文上使用约定的搜索选项,或维护明确的原文索引映射。

为开发任务选一台独享云端 Mac

Apple Silicon 物理节点,非虚拟机。按天、周、月或季租用,机型与价格公开可查,实际可用状态以控制台实时返回为准。

立即租用云端 Mac 查看机型与租期