一、命中了“上海”,高亮却落在“过上”
CaptionRangeLab的文搜图接口一直很稳定:查询“夜游🚄上海”,返回图片IMG-7408,标题是“雨夜🚄穿过上海虹桥,玻璃上有霓虹倒影”,相似度 0.913。直到我把服务端返回的命中区间接到 ArkUISpan,才发现最前面的“夜🚄”能显示,后面的“上海”和“霓虹”全部向右错位;某些包含家庭 Emoji 的标题还会出现半个符号被染色、另一半保持黑色的怪相。
接口没有算错。它返回的是 UTF-8 字节区间:[3,10)、[16,22)、[43,49);ArkTS 字符串切片使用的却是 UTF-16 code unit 下标。中文通常占三个 UTF-8 字节,火车 Emoji 占四个字节、两个 UTF-16 code unit。拿字节偏移直接喂给substring(),短标题里只是错两个字,遇到 ZWJ 组合 Emoji 就可能从字素中间切开。
这篇不讨论向量召回、分数排序或缩略图加载,只处理结果已经确定之后的一件小事:如何把服务端 byte range 安全地变成用户看到的高亮片段。Demo 任务固定为RANGE-2313,项目CaptionRangeLab,页面SearchHighlightPage,目标状态RANGE_READY;验收要求三段命中都落到正确文本、重叠区间合并为一次渲染、破损字素数保持 0。
二、偏移不是数字,是编码合同
最初实现只有一行:caption.substring(hit.start, hit.end)。这行代码隐含了“双方使用同一种索引单位”,而接口文档只写了 start/end,没有写单位。修复不是把中文长度乘三,因为 UTF-8 每个码点长度不同,UTF-16 对补充平面字符又使用代理对。正确办法是从字符串头部逐个 Unicode code point 编码,建立 byte boundary 到 UTF-16 boundary 的映射。
第一段代码负责严格换算。它不接受落在某个 UTF-8 多字节序列中间的 offset;服务端若给出[7,10),7 就位于 🚄 的四字节编码内部,客户端会记录RANGE_NOT_BOUNDARY,而不是猜测用户想高亮哪个字符。
interface ByteRange { start: number; end: number } interface Utf16Range { start: number; end: number } function utf8Width(codePoint: number): number { if (codePoint <= 0x7F) return 1; if (codePoint <= 0x7FF) return 2; if (codePoint <= 0xFFFF) return 3; return 4; } function byteToUtf16(caption: string, range: ByteRange): Utf16Range { const boundaries = new Map<number, number>(); let byteOffset = 0; let utf16Offset = 0; boundaries.set(0, 0); for (const scalar of caption) { byteOffset += utf8Width(scalar.codePointAt(0)!); utf16Offset += scalar.length; // Emoji 为 2,普通汉字为 1 boundaries.set(byteOffset, utf16Offset); } const start = boundaries.get(range.start); const end = boundaries.get(range.end); if (start === undefined || end === undefined || start >= end) { throw new Error(`RANGE_NOT_BOUNDARY:${range.start}-${range.end}`); } return { start, end }; }对IMG-7408,三段 byte range 会稳定换成 UTF-16 的[1,4)、[6,8)、[15,17),分别对应“夜🚄”“上海”“霓虹”。这里特意使用半开区间,才能让相邻区间[6,7)和[7,8)无歧义地合并。空区间、倒序区间、超出总字节数的区间都属于接口数据错误,不进入渲染层。
三、合法的 UTF-16 边界仍可能切碎一个字
代理对问题解决后,亲子👨👩👧👦夜游外滩仍会失败。家庭 Emoji 由多枚 Emoji 与零宽连接符组成,每个 code point 的 UTF-8 和 UTF-16 边界都合法,但用户把整个序列看成一个字素。服务端命中其中一个成员时,如果客户端按 code point 染色,视觉上仍是一个被拆开的图形。
我没有手写一套 Emoji 规则,而是引入grapheme-splitter 1.0.4。它按 Unicode 默认扩展字素簇规则拆分用户感知字符。第二段代码先扫描每个字素在 UTF-16 字符串中的起止位置,再把命中区间向外扩到最近的字素边界。扩展只改变展示范围,不回写服务端得分,也不参与排序。
import GraphemeSplitter from 'grapheme-splitter'; const splitter = new GraphemeSplitter(); function protectGrapheme(caption: string, hit: Utf16Range): Utf16Range { const clusters = splitter.splitGraphemes(caption); let cursor = 0; let safeStart = hit.start; let safeEnd = hit.end; for (const cluster of clusters) { const next = cursor + cluster.length; if (hit.start > cursor && hit.start < next) safeStart = cursor; if (hit.end > cursor && hit.end < next) safeEnd = next; cursor = next; } return { start: safeStart, end: safeEnd }; } function mergeRanges(ranges: Utf16Range[]): Utf16Range[] { const sorted = ranges.sort((a, b) => a.start - b.start || a.end - b.end); const merged: Utf16Range[] = []; sorted.forEach((r) => { const tail = merged[merged.length - 1]; if (tail === undefined || r.start > tail.end) merged.push({ ...r }); else tail.end = Math.max(tail.end, r.end); }); return merged; }mergeRanges()必须放在字素保护之后。若先合并再扩边界,两段分别位于同一个 ZWJ 字素内部的命中可能被当成两个视觉片段,最终生成相邻但样式重复的Span。本任务原始命中 5 段,其中两段相交、一段与前一段相邻,保护后合并为 3 段,日志记为raw=5 merged=3 broken=0。
另一个边界是 Unicode 规范版本。三方库不是“装上就永久正确”的黑盒;升级时要带着固定样本跑回归,包括代理对、肤色修饰符、旗帜、ZWJ 家庭序列和组合音标。项目把这些样本放在entry/src/ohosTest/RangeNormalizer.test.ets,并锁定依赖版本。没有这层用例,库升级后即使 API 不变,也可能改变边界结果。
四、Span 只消费规范化片段
ArkUI 的Span适合在一个Text中显示不同样式的行内文本,但它不应该知道 UTF-8、代理对和字素簇。页面拿到的应当是已经排好序的普通片段与命中片段;这样渲染函数既不重复换算,也不会在组件重建时改变结果。
第三段代码把字符串切成HighlightPart。它还用requestId阻止旧查询迟到:用户把查询从“夜游🚄上海”改成“雨夜虹桥”时,旧请求的范围即使合法,也不能覆盖新标题列表。数据层的RANGE_READY只在所有结果都完成规范化后一次性提交。
interface HighlightPart { text: string; hit: boolean } function buildParts(caption: string, ranges: Utf16Range[]): HighlightPart[] { const parts: HighlightPart[] = []; let cursor = 0; ranges.forEach((r) => { if (cursor < r.start) parts.push({ text: caption.substring(cursor, r.start), hit: false }); parts.push({ text: caption.substring(r.start, r.end), hit: true }); cursor = r.end; }); if (cursor < caption.length) parts.push({ text: caption.substring(cursor), hit: false }); return parts; } @Builder function CaptionText(parts: HighlightPart[]) { Text() { ForEach(parts, (part: HighlightPart) => { Span(part.text) .fontColor(part.hit ? '#C53A2E' : '#1F2329') .fontWeight(part.hit ? FontWeight.Medium : FontWeight.Regular) .backgroundColor(part.hit ? '#FFF1D6' : Color.Transparent) }) }.fontSize(16).lineHeight(24) }实际工程里,ForEach的 key 不能只用part.text,同一句话可能两次出现“上海”。Demo 使用assetId + start + end + hit生成稳定 key。Span没有独立宽高,点击行为也不适合用它承担大面积命中,因此整行跳转挂在外层结果卡片上,高亮只表达语义,不抢交互职责。
页面离开时不需要释放grapheme-splitter实例,它不持有系统资源;需要失效的是requestId和在途网络回调。若缓存规范化结果,key 必须包含 caption 的内容摘要与服务端offsetUnit=utf8,不能只用 assetId。照片标题被编辑后沿用旧 range,比完全不高亮更危险。
五、把错误数据留在调试页里
修复后的手机页显示查询“夜游🚄上海”,任务RANGE-2313,资源IMG-7408,相似度 0.913,原始 5 段合并为 3 段,状态RANGE_READY。三段高亮依次是“夜🚄”“上海”“霓虹”,brokenGraphemes=0、invalidRanges=0、renderParts=7。页面上还保留 UTF-8[3,10)→ UTF-16[1,4)的审计行,方便接口联调时确认双方单位。
我曾经想在生产界面遇到非法 range 时直接整句标黄,让用户至少看到一个结果。最后没有这么做:整句高亮会把接口错误伪装成低精度命中,反而让产品难以定位。现在的策略是丢弃单个非法区间,正常显示标题,并在 HiLog 输出任务、assetId、captionDigest、byte range 和错误码;同一条结果的非法率超过 20% 时,调试页把它标为RANGE_DEGRADED,线上卡片仍保持可读。
这套验收不看“肉眼差不多”。用例逐一断言总字节数 55、UTF-16 长度 19、三段映射结果、合并数量、字素保护前后区间和最终拼接还原原文。最后一条尤其重要:所有HighlightPart.text拼起来必须严格等于原 caption,少一个代理项或重复一个 code unit 都会被发现。
六、范围协议应当随接口一起版本化
最彻底的改进不是客户端永远猜对,而是接口显式返回offsetUnit: "utf8_byte"、rangeMode: "half_open"、normalization: "NFC"和schemaVersion: 2。如果服务端未来改成 Unicode scalar index,客户端按版本选择换算器;缺少单位的旧响应只走兼容路径并记录告警。
对于纯中文、没有 Emoji 的短标题,这套流程看起来比substring()重。但文搜图结果天然来自多语言文本、文件名、地点标签和用户描述,编码边界迟早会出现。把偏移换算、字素保护、区间合并和 ArkUI 渲染分开以后,问题从“某台设备偶尔高亮错了”变成四个可单测、可记录、可回放的步骤。
参考资料:ArkUI Text/Span 文本显示、grapheme-splitter 项目与用法。