首页 / 资讯中心 / 文章详情

点中了 emoji,高亮却偏到下一个字:HarmonyOS 7 TextEncoding 与 UTF-8/UTF-16 偏移映射实战

点中了 emoji,高亮却偏到下一个字:HarmonyOS 7 TextEncoding 与 UTF-8/UTF-16 偏移映射实战 ★ FEATURED ARTICLE
点中了 emoji高亮却偏到下一个字HarmonyOS 7 TextEncoding 与 UTF-8/UTF-16 偏移映射实战菜谱标题写成“红烧牛肉面”后点“面”字弹出的营养说明却高亮了后面的 emoji搜索服务返回一段 UTF-8 字节范围页面直接拿它调用选择接口中文越多偏得越远换成厨师 emoji “‍”后光标甚至可能落进组合序列中间。这类问题通常不是坐标算错而是三套索引混在了一起触摸坐标属于像素空间服务端范围常按 UTF-8 字节计数ArkTS 字符串切片和很多界面选择接口又以 UTF-16 码元为边界。ASCII 文本里三者看起来相同一旦出现汉字、补充平面字符、变体选择符或 ZWJ 组合错误才暴露出来。HarmonyOS 26.0.0 Release 的 ArkUI API 差异清单为 LayoutManager 的三组方法增加了可选 TextEncoding 参数getCharacterPositionAtCoordinate、getGlyphRangeForCharacterRange、getCharacterRangeForGlyphRange。当前文本公共接口文档说明encoding 可选默认 TEXT_ENCODING_UTF8指定 TEXT_ENCODING_UTF16 后位置和范围改用 UTF-16 码元计数。文档还明确要求文本内容变化后要等布局完成才能读取最新布局信息。本文处理两个不同场景第一个把触摸坐标稳定映射到 UTF-16 选择范围避免拆开 emoji 代理对第二个把服务端 UTF-8 字节范围转换为本地 UTF-16 范围再交给 LayoutManager 校正为实际字形边界。文末给出 72 组编码回归矩阵、纯逻辑断言和一份可以直接落盘的证据 JSON。当前本机只有 API 24 SDK且没有连接 HDC 设备。本文的宿主断言验证字节数、UTF-16 边界、范围往返和矩阵完整性不声称已经完成 API 26 编译、LayoutManager 真机布局、emoji 字形聚类、触摸坐标精度或设备字体渲染验证。先分清四个容易混用的量以“红烧面”为例量计数对象典型用途像素坐标x、y 像素点击、拖拽、长按UTF-8 偏移编码后的字节网络协议、后端标注、文件范围UTF-16 偏移16 位码元ArkTS 字符串下标、部分选择接口字形范围排版后的 glyph高亮矩形、命中字形、视觉选择一个中文 BMP 字符在 UTF-8 中通常占 3 字节在 UTF-16 中占 1 个码元补充平面的 emoji 在 UTF-8 中常占 4 字节在 UTF-16 中占 2 个码元。ZWJ 组合看起来可能是一个视觉符号却由多个码点和多个 UTF-16 码元组成。因此不能写出这种代码constserverRange{start:12,end:15}// “面”的 UTF-8 字节范围textController.setTextSelection(serverRange.start,serverRange.end)如果本地选择接口使用 UTF-16这段代码在英文上可能正常在中文和 emoji 上必然存在偏移风险。API 26 到底改变了什么Release 差异清单给出的三个新增重载是getCharacterPositionAtCoordinate(x:number,y:number,encoding?:TextEncoding):PositionWithAffinity|undefinedgetGlyphRangeForCharacterRange(charRange:TextRange,encoding?:TextEncoding):ArrayTextRange|undefinedgetCharacterRangeForGlyphRange(glyphRange:TextRange,encoding?:TextEncoding):ArrayTextRange|undefined三点必须一起记住新重载从 26.0.0 开始提供只能用于 Stage 模型。encoding 省略时默认是 TEXT_ENCODING_UTF8不是 ArkTS String.length 常见的 UTF-16 语义。LayoutManager 没有绑定组件时可能返回 undefined文本变化后立即读取也可能拿到旧布局。官方文本公共接口还给出“世界Hello”的例子UTF-8 下“世”对应 [0, 3]UTF-16 下对应 [0, 1]。这正是中文越多、错误越大的原因。先做一个编码边界工具不要在页面里到处散落 slice、length 和 TextEncoder。先把转换集中到一个工具中并且只接受明确标注的范围。import{util}fromkit.ArkTSinterfaceOffsetRange{start:numberend:number}interfaceOffsetPoint{utf16Start:numberutf16End:numberutf8Start:numberutf8End:numbertext:string}classTextOffsetMap{privateencoder:util.TextEncoderutil.TextEncoder.create(utf-8)privatebyteLength(value:string):number{returnthis.encoder.encodeInto(value).length}build(text:string):OffsetPoint[]{constresult:OffsetPoint[][]letutf160letutf80for(constsymbolofArray.from(text)){constutf16Lengthsymbol.lengthconstutf8Lengththis.byteLength(symbol)result.push({text:symbol,utf16Start:utf16,utf16End:utf16utf16Length,utf8Start:utf8,utf8End:utf8utf8Length})utf16utf16Length utf8utf8Length}returnresult}utf8RangeToUtf16(text:string,range:OffsetRange):OffsetRange|undefined{constpointsthis.build(text)constfirstpoints.find((item)item.utf8Startrange.startrange.startitem.utf8End)constlastpoints.find((item)item.utf8Startrange.endrange.enditem.utf8End)if(!first||!last)returnundefinedreturn{start:first.utf16Start,end:last.utf16End}}utf16RangeToUtf8(text:string,range:OffsetRange):OffsetRange|undefined{constpointsthis.build(text)constfirstpoints.find((item)item.utf16Startrange.startrange.startitem.utf16End)constlastpoints.find((item)item.utf16Startrange.endrange.enditem.utf16End)if(!first||!last)returnundefinedreturn{start:first.utf8Start,end:last.utf8End}}}这里用 Array.from 按 Unicode 码点遍历至少不会拆开单个补充平面字符的代理对。它不等于完整的“用户可见字素簇”分词像“‍”这样的 ZWJ 序列仍可能包含多个数组元素所以最终视觉范围还要交给 LayoutManager 校正。util.TextEncoder 官方文档说明create(‘utf-8’) 会创建把字符串编码为字节数组的编码器encodeInto 返回 Uint8Array。不要用 encodeIntoUint8Array 的 written 值代替完整字节长度除非目标缓冲区已确保足够大。案例一点击菜名后高亮当前字形目标不是“估算第几个字符”而是让 LayoutManager 完成坐标到字符位置、字符范围到字形范围的两次转换。import{TextEncoding}fromkit.ArkUIinterfaceHighlightEvidence{xPx:numberyPx:numberrequestedUtf16:OffsetRange actualUtf16?:OffsetRange glyphRange?:OffsetRange layoutRevision:number}classRecipeTitleHitTester{privaterevision:number0markLayoutChanged():void{this.revision1}hit(manager:LayoutManager,xPx:number,yPx:number):HighlightEvidence|undefined{consthitmanager.getCharacterPositionAtCoordinate(xPx,yPx,TextEncoding.TEXT_ENCODING_UTF16)if(!hit)returnundefinedconstrequested{start:hit.position,end:hit.position1}constmappedmanager.getGlyphRangeForCharacterRange(requested,TextEncoding.TEXT_ENCODING_UTF16)if(!mapped||mapped.length!2)returnundefinedreturn{xPx,yPx,requestedUtf16:requested,glyphRange:mapped[0],actualUtf16:mapped[1],layoutRevision:this.revision}}}getGlyphRangeForCharacterRange 返回两个范围第一个是字形范围第二个是实际字符范围。如果请求范围落在一个不可拆分字符内部实际字符范围可以扩大到完整边界。页面应使用 actualUtf16 作为后续选择依据而不是坚持原始的 start 1。文本变化后不要立刻读取旧布局搜索词高亮或字号改变会触发布局更新。官方文档明确要求等待布局完成后再读取。工程里应把“内容版本”和“布局版本”分开记录interfaceLayoutTicket{contentRevision:numberlayoutRevision:numbertextHash:string}functioncanApplyHighlight(requested:LayoutTicket,current:LayoutTicket):boolean{returnrequested.contentRevisioncurrent.contentRevisionrequested.layoutRevisioncurrent.layoutRevisionrequested.textHashcurrent.textHash}如果标题已从“红烧牛肉面”变为“番茄牛腩面”旧坐标即使仍在组件范围内也不应该套到新文本上。不把组合 emoji 强行拆成一个码元“”是补充平面字符在 UTF-16 中占两个码元。请求 [5, 6] 可能落进代理对内部LayoutManager 返回的 actualUtf16 应扩到完整范围。对于“‍”这种 ZWJ 序列单个码点边界仍不等于视觉字素簇边界必须把最终高亮矩形和目标系统渲染结果一并验收。案例二服务端 UTF-8 字节标注落到本地选区搜索服务可能返回{text:红烧牛肉面,match:{encoding:utf-8,start:12,end:15}}这里的 12 和 15 是字节不是 ArkTS 字符串下标。处理流程应当显式写成四步校验服务端声明的 encoding。用 TextOffsetMap 把 UTF-8 字节范围转换成 UTF-16 范围。用 LayoutManager 把字符范围校正到实际字形边界。保存原范围、转换范围、实际范围和文本版本便于复盘。interfaceServerTextRange{encoding:utf-8start:numberend:numbertextVersion:string}interfaceMappedHighlight{source:ServerTextRange utf16:OffsetRange glyph:OffsetRange actualUtf16:OffsetRange}functionmapServerHighlight(text:string,source:ServerTextRange,manager:LayoutManager,mapper:TextOffsetMap):MappedHighlight|undefined{constutf16mapper.utf8RangeToUtf16(text,source)if(!utf16)returnundefinedconstmappedmanager.getGlyphRangeForCharacterRange(utf16,TextEncoding.TEXT_ENCODING_UTF16)if(!mapped||mapped.length!2)returnundefinedreturn{source,utf16,glyph:mapped[0],actualUtf16:mapped[1]}}先拒绝越界和半字符范围服务端如果把结束位置放到一个 UTF-8 多字节字符中间工具可以选择“扩到完整字符”或“拒绝”。面向用户高亮时扩到完整字符更直观涉及签名、审计或协议字段时应该拒绝并记录输入错误不能静默修正。typeBoundaryPolicyexpand-code-point|reject-partialinterfaceMappingDecision{accepted:booleanexpanded:booleanreason?:OUT_OF_RANGE|PARTIAL_CODE_POINT|TEXT_VERSION_MISMATCH}文章示例采用 expand-code-point是界面展示策略不应直接套到安全敏感协议。返回给服务端时也要反向转换用户拖动选区后本地拿到 UTF-16 范围。如果接口契约仍要求 UTF-8 字节范围就用 utf16RangeToUtf8 反向转换并附上 textVersion。只发送 start/end 而不带文本版本会在标题更新后造成“范围仍合法、内容已经变化”的隐蔽错误。72 组回归矩阵矩阵使用 6 组文本、2 种编码、3 种入口和 2 个布局方向维度取值文本纯中文、中文ASCII、单 emoji、补充平面汉字、组合附加符、ZWJ emoji编码UTF-8、UTF-16入口坐标点击、服务端范围、复制菜单方向LTR、RTL 容器6 × 2 × 3 × 2 72。每条记录都要包含原文本、编码、原始范围、转换范围、实际字符范围、字形范围、布局版本和截图名。interfaceEncodingMatrixCase{id:stringsample:stringencoding:utf8|utf16entry:tap|server-range|copy-menudirection:ltr|rtl}functionbuildEncodingMatrix():EncodingMatrixCase[]{constsamples[葱油面,A套餐,牛肉面,家常菜,Café,‍今日推荐]constrows:EncodingMatrixCase[][]samples.forEach((sample){;([utf8,utf16]asconst).forEach((encoding){;([tap,server-range,copy-menu]asconst).forEach((entry){;([ltr,rtl]asconst).forEach((direction){rows.push({id:[sample,encoding,entry,direction].join(__),sample,encoding,entry,direction})})})})})returnrows}RTL 容器不改变字符串编码规则但会改变视觉位置与 affinity 的解释所以仍应进入目标环境检查。宿主断言能证明什么配套断言覆盖72 个 caseId 全部唯一。每种样本文本各有 12 条记录。UTF-8 与 UTF-16 各 36 条。三种入口各 24 条。中文 UTF-8 字节数大于 UTF-16 码元数。单 emoji 的 UTF-8 长度为 4UTF-16 长度为 2。补充平面汉字不会被拆成两个独立字符。每个码点边界的 UTF-8 - UTF-16 - UTF-8 往返一致。落入多字节字符内部的范围会扩到完整码点。文本版本或布局版本变化后旧高亮证据不能继续应用。这些断言没有 ArkUI 渲染器也无法证明目标系统如何聚合 ZWJ 字素簇。目标环境复现步骤在 API 26 Stage 模型工程中创建 TextController并从 Text 组件获取 LayoutManager。使用“葱油面”“红烧牛肉面”“家常菜”“Caf锓‍今日推荐”五类边界文本。文本首次布局完成后记录 contentRevision、layoutRevision、字体、字号、方向和设备宽度。对每段文本执行 getCharacterPositionAtCoordinate分别指定 TEXT_ENCODING_UTF8 与 TEXT_ENCODING_UTF16。把返回位置扩成范围再调用 getGlyphRangeForCharacterRange记录 glyphRange 与 actualCharacterRange。用 getCharacterRangeForGlyphRange 做反向映射检查往返后是否保持同一视觉字形。修改文本后立即查询一次再在布局完成后查询一次确认旧布局证据不会被误用。输入服务端 UTF-8 字节范围先通过 TextEncoder 建表再按 UTF-16 调 LayoutManager。对越界、半个中文字节、半个 emoji 代理对和过期 textVersion 分别记录拒绝或扩边策略。在 Phone、Tablet 和 PC/2in1 至少各完成一轮截图不把单设备结果外推到全部形态。建议落盘的证据 JSON{environment:{systemVersion:待填写,sdkVersion:待填写,device:待填写,fontScale:null,direction:ltr},text:红烧牛肉面,textVersion:sha256:待填写,layoutRevision:null,input:{kind:server-range,encoding:utf-8,start:null,end:null},mapped:{utf16Range:null,glyphRange:null,actualUtf16Range:null},visual:{screenshot:null,expectedGlyph:面,actualGlyph:null},result:pending}没有执行的字段保持 null不能用 0、空数组或 false 冒充已经验证。常见误区误区一String.length 就是字符数ArkTS 字符串长度遵循 UTF-16 码元语义补充平面字符通常占 2。它既不是 UTF-8 字节数也不等于用户看到的字素簇数量。误区二省略 encoding 会沿用页面的 UTF-16API 26 新重载的默认值是 TEXT_ENCODING_UTF8。需要和本地选择范围对接时必须显式传 TEXT_ENCODING_UTF16。误区三字符范围与字形范围可以直接互换排版塑形、连字、组合字符和 emoji 序列都会让两者不再一一对应。使用 LayoutManager 提供的双向转换并保留实际字符范围。误区四文本更新后旧坐标仍然有效官方文档要求等待布局完成。内容、宽度、字号、字体或方向改变时都应让旧布局票据失效。误区五宿主往返测试等于真机高亮正确宿主测试没有字体塑形、Affinity、触摸命中和目标设备渲染只能验证编码转换模型。文件级检查表API 26 新重载和默认 UTF-8 行为已核对。每个范围都携带 encoding 与 textVersion。坐标、UTF-8、UTF-16、字形四种量没有共用一个字段名。本地选择路径显式使用 TEXT_ENCODING_UTF16。服务端字节范围先转换再进入 LayoutManager。半个中文字节和半个代理对有明确策略。使用 LayoutManager 返回的实际字符范围不强行保留非法边界。文本变化后等待新布局并递增 layoutRevision。LayoutManager 未绑定和 undefined 分支已处理。ZWJ、组合附加符和补充平面汉字进入回归矩阵。Phone、Tablet、PC/2in1 结果分开记录。宿主断言、API 26 编译、模拟器和真机证据分层保存。证据边界与官方资料API 26 Release 差异清单证明三组 LayoutManager 方法增加了 TextEncoding 参数。文本公共接口参考说明了 UTF-8/UTF-16 的计数单位、默认 UTF-8、Stage 模型约束、undefined 返回和布局完成后再读取的要求。Text 组件参考说明 TextController.getLayoutManager 的接入位置。util 参考说明 TextEncoder.create(‘utf-8’) 与 encodeInto 的字节数组语义。HarmonyOS 26.0.0 ReleaseArkUI API 差异文本组件公共接口LayoutManager 与 TextEncodingText 组件TextController 与 getLayoutManagerutil 工具TextEncoder
阅读完成 · 觉得有帮助?
咨询建站