
一、命中了“上海”高亮却落在“过上”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 Mapnumber, 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 段日志记为raw5 merged3 broken0。另一个边界是 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 的内容摘要与服务端offsetUnitutf8不能只用 assetId。照片标题被编辑后沿用旧 range比完全不高亮更危险。五、把错误数据留在调试页里修复后的手机页显示查询“夜游上海”任务RANGE-2313资源IMG-7408相似度 0.913原始 5 段合并为 3 段状态RANGE_READY。三段高亮依次是“夜”“上海”“霓虹”brokenGraphemes0、invalidRanges0、renderParts7。页面上还保留 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 项目与用法。