ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

鸿蒙RichEditor实现@用户名:Span模型与踩坑全记录

鸿蒙RichEditor实现@用户名:Span模型与踩坑全记录 RichEditor做用户名我是真把鸿蒙的文档翻了个底朝天又踩了几天的坑才理顺的。这个功能看着不起眼——不就是输入弹个列表选人嘛。但真做起来牵扯到富文本Span模型、光标定位、软键盘联动、输入法组合态还有回显时那一堆结构化数据怎么存每一步都有讲究。这篇我就把完整思路、核心代码、还有我实际踩过的坑全部捋一遍给要做类似需求的朋友当个参考。1. 功能拆解用户名到底要解决哪些问题1.1 从一次用户体验谈起功能边界咱们不妨先回想一下微信或者钉钉里的人体验输入一个符号浮层弹出候选联系人选中的瞬间屏幕上多了一个高亮的人名块这个块是一个整体你想把名字拆开改一个字是做不到的光标跳过这个块继续往后输入想删除时按一次退格整块消失最后发送时后台收到的是一串文本加一组被人的ID。这四点就是功能的全部技术边界触发、回填、整体性、结构化提交。很多人实现时只关注了“弹列表选人”忽略了“整体删除”和“结构化提交”结果做到一半就发现用户能把高亮块里的字改掉或者提交的时候根本拿不到被人的ID。所以动手写代码之前一定要把这四个边界想清楚——它们会直接决定你后面用普通字符串还是用Span方案。1.2 RichEditor的Span模型为什么它能承载块鸿蒙的RichEditor是ArkUI里的富文本编辑组件核心概念是Span。你可以把它想象成HTML里的一堆span标签一片文本被拆成很多个有独立样式、独立内容的片段每个片段在整篇文本里有一个range起止位置。RichEditor同时支持文本Span、图片Span、符号Span以及最重要的——通过customSpan字段挂载业务数据的自定义Span。这里的关键在于我们完全可以把一个用户名功能做成一个自定义文本Span。显示上它是张三这三个字字体颜色变成品牌蓝数据上它的customSpan塞进了一整个JSON字符串里面存了用户ID、用户名、头像链接等结构化信息。这样“显示”和“数据”就绑定了而不是像普通字符串那样只有一串字还得靠正则去猜哪个是人名。1.3 完整功能清单与数据流设计我在项目里整理过一张内部的功能清单照着这个列表开发基本不会漏项功能点核心依赖备注输入弹出候选onChange回调 正则匹配需要处理中文输入法组合态候选列表展示与选择自定义浮层 List建议用Stack层级管理回填高亮块controller.addTextSpancustomSpan挂载用户JSON光标跳过块controller.setCaretOffset插入后定位到块尾整体删除块onDelete onChange差值判断防止逐字删散提交提取用户IDcontroller.getRichText parse解析从customSpan还原结构数据历史回显富文本字符串 mentions数组双份数据存储方案整体数据流是这样的用户输入字符onChange触发后我用正则扫描全文看光标前是不是有一个待完成的也就是后面还没有被回填成Span的纯文本如果有就弹出浮层用户选中某个联系人后先把当前这段纯文本从输入区“拿走”再插入一个带customSpan的张三文本Span光标被推到Span末尾浮层关闭。提交时调用controller的富文本导出接口把RichEditor里所有Span解析出来凡是customSpan里type为mention的就还原成用户对象随正文一起传给服务端。2. 关键API选型与设计取舍2.1 为什么坚持用自定义文本Span而不是普通字符串加正则我先说一个反面方案。有一种偷懒做法输入框里纯文本存储用户选了人之后拼一个字符串张三进去靠空格切割再查表还原用户。这个方案的缺点会在三个地方爆炸编辑时用户把光标移到“张”和“三”中间删掉一个“三”整个块就变成了张后台拿到这个残缺字符串根本没法反查用户。删除时按一次退格通常只能删掉一个字符块无法整体消失体验很割裂。样式时你没法只给张三这一段染蓝色要么全文变蓝要么用一个Text组件拼接复杂度不降反升。而自定义Span方案样式是Span级绑定的数据是跟随Span走的删除是整体删除的可维护性完全不在一个量级。我的建议是主流程从一开始就走Span方案别在老路上试错。2.2 controller操作与光标精细化必须掌握的五个方法RichEditor的能力大部分集中在RichEditorController上。我实际操作里用得最频繁的五个方法列出来给大家参考方法作用我的使用场景addTextSpan(span, range)插入文本Span回填用户、回显内容deleteSpans(range)按范围删除Span清理未完成的占位符setCaretOffset(offset)设置光标位置回填后把光标挪到块尾部getCaretOffset()获取光标位置判断触发的起始偏移量getRichText()导出富文本字符串提交时解析所有Span数据这里想特别说明addTextSpan的第二个参数spanRange。它不是让你随便传的传入的range决定了这个Span插入在哪个位置。我在插入块时会先把原来的纯文本用deleteSpans删掉再以删除位置的偏移量为基准插入Span这样能保证文本不会越攒越长。2.3 候选用户浮层bindSheet与自定义浮层的取舍功能的候选列表很多人第一反应是用bindSheet弹一个半屏面板。我实际试过之后放弃了原因是软键盘弹出时bindSheet的避让逻辑会跟输入框产生非常诡异的位置跳动——键盘弹出来面板跟着往上顶输入框下方区域被遮住候选列表反而看不到。而且bindSheet适合一次性选择交互不适合这种每敲一个字母都要动态刷新列表的持续交互场景。我的最终方案是在RichEditor外层包一个Stack候选列表作为Stack里的一个子组件通过Visibility控制显示/隐藏。这样做最大的好处是候选浮层完全跟随输入区域走不会被键盘避让逻辑干扰而且刷新列表只需要维护一个数组数据驱动非常直接。缺点是需要自己处理点击外部关闭浮层的逻辑但这点工作量对比交互稳定性来说完全值得。3. 核心实现从触发到回填的完整链路3.1 数据模型与全局用户池动手写代码前先把用户的数据结构定义好。这是整个功能的地基后面所有逻辑都围绕这个模型展开// 用户数据模型 interface MentionUser { userId: string; // 用户唯一ID提交时用 userName: string; // 展示名比如“张三” avatar?: string; // 头像URL deptName?: string; // 部门或备注作为候选列表副标题 } // 全局用户池项目里一般从通讯录接口拉取 const userPool: MentionUser[] [ { userId: u1001, userName: 张三, deptName: 研发部 }, { userId: u1002, userName: 张三丰, deptName: 设计部 }, { userId: u1003, userName: 李四, deptName: 产品部 } ];用户池的构建有几个注意点。第一搜索的关键字建议支持拼音首字母因为移动端用户打中文的效率远低于搜首字母这一点在鸿蒙上可以用pinyin-pro这类库做拼音索引第二候选列表要做去重防止同一个用户在池子里出现多次导致选择歧义第三用户池数据量大时建议一次性拉取后缓存而不是每次弹浮层都请求接口否则每一次输入都会造成网络抖动。3.2 监听触发与候选浮层渲染接下来是核心的触发逻辑。我在RichEditor的onChange回调里维护全文状态每次文本变化后用正则检测当前是否处于“待选状态”。onChange((text: string) { // 更新全文文本 this.editorText text; // 用正则匹配从当前光标往前找“最后一个后未回填的内容” const matchResult this.matchPendingAt(); if (matchResult) { // 当前确实处于待选态弹出候选浮层并过滤用户池 this.showCandidateList(matchResult.keyword); } else { // 不在待选态关闭浮层 this.hideCandidateList(); } }) /** * 提取最后一个后面的未回填关键字 */ matchPendingAt(): { start: number, keyword: string } | null { const text this.editorText; // 从光标位置往前找最后一个 const lastIndex text.lastIndexOf(); if (lastIndex 0) { return null; } const keyword text.substring(lastIndex 1); // 如果后面已经包含空格或换行说明块已经结束 if (/[\s\n]/.test(keyword)) { return null; } return { start: lastIndex, keyword }; }这里有个细节容易被忽略lastIndexOf()找的是全文最后一个但如果前面已经有一个回填好的张三Span它的显示文本也是以开头的从纯文本里lastIndexOf就会匹配到它导致逻辑错乱。所以正则匹配前必须先拿到光标位置只匹配光标前面的文本。在实际项目中我会用controller.getCaretOffset()拿到光标然后截取从光标往前的文本再做正则匹配这就不会误伤已回填的块。候选浮层我用List渲染样式上做一个圆角卡片视觉层级比输入区域高即可。这里附上浮层过滤逻辑showCandidateList(keyword: string) { const kw keyword.toLowerCase(); this.candidateUsers userPool.filter(user { return user.userName.includes(keyword) || this.pinyinInitial(user.userName).includes(kw); }); this.candidateVisible true; }过滤时建议用Array.filter配合includes不要用indexOf -1这种老旧写法可读性差而且容易出边界错误。关键词为空时直接展示全部用户关键词非空时按匹配度排序把精确匹配的放前面。3.3 插入块与光标联动用户从候选列表点选一个人之后就到了回填环节。这个环节最容易翻车因为RichEditor的Span操作不是“先删再插”这么简单的中间涉及位置偏移量计算。我的实现逻辑如下/** * 选中候选人后的回填处理 */ handleSelectUser(user: MentionUser) { // 1. 获取当前光标位置这个位置往前就是那个待完成的纯文本“” const caretOffset this.controller.getCaretOffset(); // 2. 往前找到最后一个“”的位置 const atIndex this.editorText.lastIndexOf(, caretOffset - 1); if (atIndex 0) { return; } // 3. 先删除这个纯文本 占位符 const placeholderRange: RichEditorSpanRange new RichEditorSpanRange(atIndex, caretOffset); this.controller.deleteSpans(placeholderRange); // 4. 构造自定义文本Span const userJson JSON.stringify({ type: mention, value: user.userId, userName: user.userName, avatar: user.avatar ?? }); const options: RichEditorTextSpanOptions { style: { fontColor: this.mentionColor(), fontSize: 16, fontWeight: FontWeight.Medium, baselineOffset: 3, }, customSpan: userJson }; // 5. 在原先 的起始位置插入“张三”Span const mentionSpan new RichEditorTextSpan(${user.userName} , options); this.controller.addTextSpan(mentionSpan, new RichEditorSpanRange(atIndex, atIndex)); // 6. 光标移动到Span之后的空档处 const newOffset atIndex user.userName.length 2; this.controller.setCaretOffset(newOffset); // 7. 关闭候选浮层 this.hideCandidateList(); // 8. 强制刷新本地文本缓存 this.editorText this.controller.getFullText(); }代码里有两个容易踩坑的点我单独拎出来说第一个是删除占位符的时机。如果你不删掉那个用户手输的纯文本直接插入张三屏幕上就会出现两个符号看起来是张三非常诡异。由于Span插入和纯文本删除是两个独立操作必须先定位再删删完再插顺序不能反。第二个是光标偏移量的计算。张三在字符串里的实际长度是用户名的长度 1位符号 1位空格。我把光标定位到atIndex加上这个偏移之后的位置这个位置刚好在块和后续文字之间。空格的作用是提供一个“缝隙”让光标有一个自然的停留点避免光标紧贴在想输入的中文字符上导致输入法弹窗错乱。3.4 提交时如何提取用户列表提交时最重要的两个产物是给服务端的正文和被人的用户ID数组。RichEditor的getRichText()返回的字符串里自定义Span会以带标记的形式存在。我的处理方式是解析富文本字符串把类型为mention的Span的数据还原成对象/** * 提交时解析RichEditor内容 */ handleSubmit() { const richText this.controller.getRichText(); // 使用SDK中的解析工具把富文本拆解成一个一个的RichEditorTextSpan const spanList parseRichEditorText(richText); const mentionedUsers: MentionUser[] []; let contentStr ; for (const span of spanList) { if (span.customSpan) { const customData JSON.parse(span.customSpan); if (customData.type mention) { mentionedUsers.push({ userId: customData.value, userName: customData.userName, avatar: customData.avatar ?? }); // 正文里也保留“张三 ”这种展示文本方便服务端做富文本渲染 contentStr span.text; continue; } } contentStr span.text; } // 组装提交数据 const payload { content: contentStr, mentions: mentionedUsers.map(user ({ userId: user.userId, userName: user.userName })) }; // TODO: 调接口发送 console.log(提交的用户列表, JSON.stringify(payload.mentions)); }如果你的项目用的SDK版本里parseRichEditorText这个方法名不一样去SDK里搜“RichEditor”相关的解析工具类即可思路完全一致把一整段富文本字符串按Span边界拆开逐个检查customSpan字段。提交数据组装时还需要注意一个问题服务端是按正文里的符号来渲染还是按mentions数组来渲染。我推荐两个都给正文里保留张三这种可读文本mentions数组提供精确的用户ID和偏移量。如果服务端有专门的富文本解析能力还可以把你持久化的富文本原串一起传过去这样正好能还原成客户端显示的样式。4. 高频问题排查与避坑指南4.1 软键盘弹起导致Span错位/消失这个是我在开发中遇到过的真实问题正常输入时一切都好一旦软键盘弹起尤其是中文输入法候选词区域出现之后原本已经回填好的张三Span偶尔会出现显示错位甚至直接从屏幕上消失但只要键盘收起来又恢复正常。出现这个问题的原因有两个层面第一RichEditor在键盘弹起时会触发expandImmersive避让逻辑页面的可视高度瞬间变化Span的重排计算偶发性地没有刷新过来第二如果你把候选浮层用bindSheet实现浮层的避让区间和输入框重叠Span会被浮层遮挡住一部分。排查和规避的方法我整理成三步监听软键盘的高度变化事件windowStage的keyboardHeightChange或ArkUI的onKeyboardHeightChange在键盘高度变化的帧里对RichEditor强制触发一次重绘可以先调用controller.updateSpan更新最后一个Span的样式值触发内部重排。如果Span真的消失了不要直接重新插入。先判断getRichText()里是否还存在这个Span的customSpan标记存在就只是渲染问题刷新即可不存在说明数据真丢了这时候需要回滚操作把用户刚输入的退回到纯文本避免数据丢失。候选浮层千万别用bindSheet改用Stack自绘浮层从根源上规避避让冲突。4.2 中文输入法状态下的误触发这是国产机上的老问题。你在中国大陆的营销服务区用户用搜狗、百度、华为自带输入法这几种主流中文输入法时拼音的组合输入阶段会让onChange的行为变得非常诡异。举个例子用户在群里想输入“张三你好”他打字很快先敲了一个“”然后紧接着输入“zhangsan”的拼音还没从候选词里选择“张三”时输入法在组合态的拼音串可能就已经触发onChange了。此时正则扫描会把“z”甚至“zhang”当成后面的关键字弹出候选列表并过滤出一个空结果用户还没选人列表就已经开始乱跳。我的处理办法是引入一个“组合输入状态”标记在输入法组合阶段不响应触发逻辑等组合结束输入法的确定事件再重新扫描文本。private isComposing false; // 在输入法约束区域配置输入法监听时把isComposing置为true // 在组合结束、文本确定后把isComposing置为false并重新触发一次onChange扫描 onChange((text: string) { // 如果处于组合输入状态跳过触发逻辑只是记录文本 if (this.isComposing) { this.editorText text; return; } // 正常的触发检测逻辑 this.handleNormalChange(text); })线索还在于如果你的用户输入很快触发和拼音组合同时进行光靠标志位还不够我建议同时做一个临时阻止弹窗的300ms防抖在触发候选列表前要求从最后一次非组合态的字符变化起至少停滞300ms再弹窗。这样输入法候选词还没选完时列表不会频繁刷新选完确定后才根据最终的文本做一次正确扫描。4.3 删除块的三种异常表现与处理块的整体删除是个魔鬼细节。用户按删除键时RichEditor的行为和普通文本框不太一样。我遇到过三种异常表现第一种逐字删除。用户按退格键先删掉块里的“三”再按一次删掉“张”最后删掉符号Span被拆得稀碎。这个问题的根源在于插入Span时没有正确处理onWillChange拦截。解决办法是在插入Span时用onWillChange回调拦截对Span内部文本的修改onWillChange((value: string) { // 如果本次变化是想在Span内部删字或改字直接返回false阻止 const oldText this.controller.getFullText(); if (this.isSplittingSpan(oldText, value)) { return false; } return true; })isSplittingSpan的实现思路是对比变化前后的文本如果发现原本的张三变了而且变的不是整体删除而是中间某个字被替换或删掉了就说明用户正在拆Span。这种情况下直接阻止操作保证Span的整体性。第二种整体删除了但光标位置不对。用户按一次退格块整体消失了但光标跑到了很前面。解决办法是删除后主动设置光标比如删除块之后光标应该落在块起始的位置用setCaretOffset修正。第三种删除后onChange没有触发。这个比较坑因为RichEditor对Span的整体删除有时不会引发常规的onChange回调导致你的editorText缓存和真实内容不一致。我的解法是在onDelete回调里手动同步一次全文并刷新缓存。onDelete(() { // 手动同步文本 this.editorText this.controller.getFullText(); // 重新判断是否还处于待选态必要的话关闭候选浮层 if (!this.matchPendingAt()) { this.hideCandidateList(); } })4.4 深色模式与基线对齐问题块的样式如果处理不当在深色模式下会非常难看。我最初用的是一种固定蓝色#0A59F7在浅色模式下看起来还行一旦切到深色模式这个蓝色在暗色背景上显得刺眼且对比度不足。我最终的做法是把颜色做成动态资源浅色模式用品牌主色深色模式用提亮后的品牌色。ArkUI里可以通过$r(app.color.mention_color)配置一份颜色资源并在页面里根据onColorModeChange切换代码里mentionColor()这个函数返回的就是动态计算后的颜色。另一个非常容易被忽略的问题是垂直对齐。默认情况下Span里加粗的文字会让视觉重心上移导致张三和相邻的普通文字不在一条水平中线上看起来参差不齐。我在3.3的代码里设置了baselineOffset: 3这个3是我反复调整出来的经验值在用户名的字号是16vp时3~5vp的偏移能保证视觉上基本对齐。如果你的字号更大偏移量也要相应加大这个没有标准值建议按实际效果微调。4.5 富文本回显与数据持久化功能做到后面一定会面临历史消息回显问题。最开始我天真地只存RichEditor导出的富文本字符串结果回显时发现Span的customSpan信息丢失了因为很多纯文本渲染组件根本没有富文本解析能力只拿到了一个带标记的字符串没法还原用户ID。我最终稳定运行的双份存储方案是存储字段内容用途content纯文本包含张三这样的展示文本列表页预览、不支持富文本的地方richContentRichEditor导出的富文本字符串进入编辑页时回显原始样式mentions结构化用户数组提交时校验、点击跳转用户主页回显流程是这样的先加一个空Span占位然后遍历mentions数组里的每个用户用addTextSpan依次插入张三Span再插入普通文本。这里的关键是不能用富文本字符串直接重建一定要用结构化mentions数据来重建否则回显出来就是一堆乱码标。回显时还有一个顺序问题必须先插入Span再插入普通文本否则Span和普通文本你追我赶的插入顺序会导致光标和文本错乱。我建议初始化时用一个异步队列模拟顺序插入每个块插完再插它后面的普通文本全插完后再统一刷新光标位置这样体验最稳。5. 实操心得与扩展建议5.1 从API版本差异聊兼容适配鸿蒙的RichEditor能力是分版本逐步完善的如果你的应用需要兼容老设备在功能实现上要做一些降级处理。我项目里踩过的版本差异主要有三个早期版本对updateSpan的支持不完整更新Span样式时只能靠删除重建来实现排查时可能Data有但显示没变要检查SDK版本。不同版本的getRichText()返回的字符串格式有差异尤其是自定义Span的标记分隔符解析时不能写死一个版本的分隔符去兼容所有版本。光标跳转的精确性在低版本上表现差一些插入Span后光标可能落在块中间需要二次修正。我在项目里的做法是建立了一个RichEditorCompat工具类封装所有版本差异逻辑业务层只调工具类的接口这样升级SDK时只需要改这个类里的适配代码不会散落到各个业务模块里。5.2 性能优化长文本输入时的onChange防抖功能在短文评论场景下性能压力不大但是一旦用在做长文编辑器、会议纪要这类长篇输入场景onChange的频繁触发就会导致候选浮层反复刷新甚至出现输入卡顿。我试过的方案里最有效的是防抖加“必要检测”两步走。防抖控制候选浮层的刷新频率比如300ms内最多刷新一次过滤结果必要检测是每次onChange先做一个快速判断——当前文本里有没有符号没有就直接return避免无效的深正则扫描。不过防抖也会带来一个小问题输入过快时用户可能已经打了好几个字母候选浮层才刷新一次所以“300ms”这个值我会根据实际设备调节不是固定不变的。如果你在长文档里用了大量Span还需要注意Span数量膨胀的问题。每次回填用户时插入一个Span如果文档有几百个块光Span管理就会消耗不少内存。这种情况下建议对Span的customSpan做精简只存用户ID不存整个用户对象的JSON展示信息从全局用户池里查既减小了持久化体积也降低了解析成本。5.3 扩展话题、表情、链接等多元素复用功能做扎实之后你其实已经掌握了一套通用的富文本交互范式触发关键字 → 候选浮层 → 回填自定义Span → 结构化提交。这条链路可以复用去实现很多类似功能话题功能输入#弹出热门话题选中后回填#鸿蒙开发#Span提交时spans里存话题ID和的逻辑几乎一模一样。自定义表情输入[弹出表情面板回填ImageSpan提交时用表情code替换。站内链接输入任意文本时本地正则扫描URL历史数据回显时自动包一层链接Span点击可跳转。我做话题功能时把的代码几乎原样抄了一遍只改了触发关键字、候选数据源、Span类型名。所以如果你现在正在做功能建议把这套代码抽象成独立的MentionEditorController把“哪个符号触发”“候选列表数据源”做成配置项后面再接新需求就非常轻松了。5.4 最后提一个真香技巧开发调试时RichEditor相关信息没法像普通控件那样直观看到内部结构。我后来发现一个很实用的调试路子在页面侧边留一个隐藏面板实时展示controller.getRichText()的原始输出和解析后的Span列表。每完成一步操作看面板里的数据结构变化很多疑难杂症比如Span位置算错、customSpan丢失当场就能看出来比打印日志直观太多了。这个配合DevEco Studio的断点调试一起用排查效率翻倍。用户名这个功能核心在于吃透RichEditor的Span模型然后把触发、回填、删除、提交这四个环节的数据流打通。只要主链路设计正确后面加话题、加表情都是顺手的事。如果你在实现过程中也遇到了其他怪问题欢迎拿本文这套思路对照排查大概率能少走点弯路。
返回列表