《HarmonyOS技术精讲-Basic Services Kit》划词服务:构建系统级文本选取能力

《HarmonyOS技术精讲-Basic Services Kit》划词服务:构建系统级文本选取能力
《HarmonyOS技术精讲-Basic Services Kit》划词服务构建系统级文本选取能力从一次文本交互的需求说起在 HarmonyOS NEXT 开发中如果我们希望用户选中一段文本后能弹出“翻译”、“搜索”、“复制”等自定义操作菜单一般会怎么处理很多人会想到监听手势获取光标位置然后自己实现一个浮动菜单。这样做的成本不止是 UI 层——还需要处理文本选中事件的回调、菜单的位置计算、以及在不同设备上的适配。但 HarmonyOS 提供了一套更接近系统级的方案划词服务SelectionInput。它允许开发者在系统默认的长按选中弹窗中注入自定义的操作项直接获取用户选中的文本内容并作出响应。这套 API 本身不复杂但实际开发中很多人在生命周期管理和菜单交互上踩了坑。官方示例虽然能运行但放到真实项目里至少还要处理两个问题页面返回时划词服务没有被正常释放导致其他页面也受影响。菜单点击后选中状态和 UI 更新不同步。这篇文章就从实现一个“划词翻译”功能入手把这些细节一次性讲清楚。它解决了什么适合什么场景划词服务的作用很简单让你的应用在文本被选中时弹出一个自定义菜单项。用户点击这个菜单项你的代码就可以直接拿到selectedText选中的文本然后做任何自定义处理——比如调用翻译 API、打开搜索引擎、保存到笔记等。和传统方案的对比方案实现复杂度交互一致性适用场景手动监听选中事件 自定义浮动菜单高需要处理手势、位置更新、多端适配差容易被系统键盘或弹窗遮挡非常灵活的自定义场景使用系统划词服务注入菜单低仅需配置菜单项和回调好菜单集成在系统原生弹窗中标准化的文本操作翻译、搜索、复制等划词服务适合的场景比较明确你需要用户在文本选中后执行一个固定的操作。如果操作逻辑不是固定的比如依赖其他页面状态就需要额外处理状态同步。不适合的场景需要大量自定义 UI 交互比如文本标注、划词备注、或者菜单需要响应式变化比如根据文本内容动态增删菜单项。环境说明DevEco Studio 版本DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本HarmonyOS 6.1.0(23) 及以上 目标设备手机核心实现创建一个划词翻译功能我们来实现一个功能用户在任意 Text 组件上长按选中文本弹窗中会多出一个“翻译”菜单项点击后调用远程翻译 API并显示翻译结果。Step 1配置权限和依赖翻译功能需要网络请求先配置ohos.permission.INTERNET权限。// entry/src/main/module.json5{module:{requestPermissions:[{name:ohos.permission.INTERNET}]}}然后引入基础服务及相关 API// 没有额外的依赖需要手动引入相关模块已经在 HarmonyOS SDK 中内置import{SelectionInput}fromkit.BasicServicesKit;这一步需要注意的是kit.BasicServicesKit是 HarmonyOS NEXT 的统一包名。如果你在旧版本如 API 9中使用路径可能会变成ohos.base。如果是新项目直接使用新包名即可。Step 2创建自定义菜单组件划词服务的核心是配置一个SelectionInputOptions对象其中包含你要注入的菜单项。每个菜单项需要有label菜单显示的文本。action用户点击后的回调函数参数是选中的文本。icon可选菜单项的图标。我们先把菜单组件独立出来方便复用// src/main/ets/components/SelectionMenuConfig.tsimport{SelectionInput}fromkit.BasicServicesKit;// 定义菜单项配置exportinterfaceMenuItemConfig{label:string;action:(selectedText:string)void;}// 创建划词服务的配置对象exportfunctioncreateSelectionMenuConfig(menuItems:MenuItemConfig[]):SelectionInput.SelectionInputOptions{// 将你的菜单项转换为 SelectionMenu 所需的格式constmenu:SelectionInput.SelectionMenu[]menuItems.map(item({label:item.label,// 注意这里的 action 必须是 SyncCallback 类型不能是异步函数action:(text:string){// 实际开发中我们通常需要在 action 中做异步操作// 但是 SelectionInput 要求 action 是同步回调// 所以这里只能先存储文本交给外部处理item.action(text);},// 图标是可选的这里省略}));return{menu,// 是否在系统菜单之后显示我们的菜单// 设置为 true 表示在系统菜单后面追加appendBreak:true,};}这里有一个非常关键的点action必须是同步回调。官方的 API 规定SelectionInput.SelectionMenu.action的类型是SyncCallbackstring, void。这意味着你在回调内不能直接调用await或者长时间的网络请求。如果你需要在点击菜单后发起网络请求比如翻译就必须把文本暂存到一个外部状态中然后在生命周期合适的时机发起请求。这一点很容易被忽略很多人直接把httpRequest塞进action里然后发现菜单点击后没有任何响应——因为异步操作被吞了。Step 3在页面中注入划词服务接下来在页面中使用SelectionInput.injectMenu方法将配置注入。// src/main/ets/pages/TranslatePage.etsimport{SelectionInput}fromkit.BasicServicesKit;import{createSelectionMenuConfig,MenuItemConfig}from../components/SelectionMenuConfig;import{BusinessError}fromkit.BasicServicesKit;EntryComponentstruct TranslatePage{// 存储翻译结果StatetranslationResult:string;// 当前选中的文本privateselectedText:string;// 划词服务实例用于释放资源privateselectionInputInstance:SelectionInput.SelectionInputOptions|nullnull;aboutToAppear():void{// 配置菜单constmenuItems:MenuItemConfig[][{label:翻译英 - 中,action:(text:string){// 保存选中的文本this.selectedTexttext;// 发起翻译请求this.performTranslation(text);}}];constoptionscreateSelectionMenuConfig(menuItems);// 将划词服务注入到当前页面上下文try{SelectionInput.injectMenu(options).then((instance){// 保存实例用于后续释放this.selectionInputInstanceinstance;console.info(划词服务注入成功);}).catch((err:BusinessError){console.error(划词服务注入失败:${err.message});});}catch(err){console.error(调用 injectMenu 异常:${err});}}aboutToDisappear():void{// 页面销毁时必须释放划词服务否则会影响到其他页面this.releaseSelectionInput();}privatereleaseSelectionInput():void{if(this.selectionInputInstance){try{// 调用 unregister 方法释放SelectionInput.unregister(this.selectionInputInstance);}catch(err){console.error(释放划词服务异常:${err});}finally{this.selectionInputInstancenull;}}}privateasyncperformTranslation(text:string):Promisevoid{if(!text){return;}try{// 这里调用一个翻译 API示例中使用一个模拟的 HTTP 请求constresponseawaitfetch(https://api.example.com/translate,{method:POST,headers:{Content-Type:application/json},body:JSON.stringify({q:text,source:en,target:zh})});constdataawaitresponse.json();// 假设返回的格式是 { translatedText: 你好 }this.translationResultdata.translatedText||翻译失败;}catch(error){console.error(翻译请求失败:${error});this.translationResult网络请求异常请检查网络连接;}}build(){Column(){// 展示翻译结果if(this.translationResult){Text(翻译结果${this.translationResult}).fontSize(16).padding(12).backgroundColor(#f0f0f0).borderRadius(8).margin({bottom:20});}// 测试文本用户可选中Text(Hello, this is a test text for the selection feature. You can long press any word to see the custom menu.).fontSize(14).lineHeight(22).padding(16).backgroundColor(#ffffff).borderRadius(8).border({width:1,color:#e0e0e0}).width(100%)// 关键参数允许文本选中.copyOption(CopyOptions.InApp)// 启用划词服务需要 Text 组件支持选中.selectable(true)}.width(100%).height(100%).padding(16).backgroundColor(#f5f5f5)}}这段代码有几个重点aboutToAppear中注入aboutToDisappear中释放。这是生命周期管理的标准做法。如果只在aboutToAppear注入但不释放当页面返回到上一页时划词服务仍然在后台生效可能会导致其他页面也弹出了我们的自定义菜单。selectedText使用了普通属性而非State。因为action是同步回调不需要触发 UI 更新。翻译请求的结果使用了State这样才能更新 UI。copyOption和selectable缺一不可。selectable(true)让文本可以被选中copyOption(CopyOptions.InApp)让应用内部可以处理复制事件。如果只设置selectable系统可能不会触发划词服务。Step 4完整的页面入口// src/main/ets/pages/Index.etsimport{router}fromkit.ArkUI;EntryComponentstruct Index{build(){Column(){Button(进入翻译页面).onClick((){router.pushUrl({url:pages/TranslatePage});})}.width(100%).height(100%).justifyContent(FlexAlign.Center)}}常见问题 1菜单点击后没有响应现象长按文本后自定义菜单项确实显示出来了但点击后没有任何效果。原因正如前面提到的SelectionInput.SelectionMenu.action必须是同步回调。如果你在action中写了await或者返回了一个Promise系统会忽略这个回调不执行任何操作。解决方案把异步操作放到action外部去执行。上面的代码中我们在action里只做了两件事保存文本、调用一个被标记为async的外部方法performTranslation。注意action本身并没有await performTranslation而是直接调用了它这样action本身仍然是同步的。常见问题 2页面返回后划词菜单还在其他页面生效现象从 A 页面跳转到 B 页面在 A 页面注入的划词菜单仍然在 B 页面出现。原因SelectionInput.injectMenu的效果是全局的它不会自动跟随页面生命周期。如果你不在aboutToDisappear中调用SelectionInput.unregister这个菜单会一直存在影响所有文本组件。解决方案在aboutToDisappear中释放实例。上面的代码已经展示了释放逻辑。需要注意的是如果在injectMenu成功之前就返回了页面selectionInputInstance可能还是null所以释放时需要判空。最佳实践1. 不要在build()中动态配置菜单如果你把SelectionInput.injectMenu写在build()中每次组件重建都会重新注入导致菜单被重复注册出现多个相同的菜单项。务必在aboutToAppear中执行一次即可。2. 菜单项不宜过多系统菜单弹窗的显示空间有限如果自定义菜单项超过 4 个可能会出现显示不全或布局异常。建议控制在 2-3 个以内保持和系统菜单的平衡。3. 异步操作的状态管理action是同步回调但翻译等操作是异步的。推荐做法是在action中通过State更新一个“加载中”状态然后在异步回调完成后更新结果状态。这样用户点击菜单后能看到即时反馈避免误以为没有生效。// 在组件中添加一个加载状态StateisTranslating:booleanfalse;action:(text:string){this.selectedTexttext;this.isTranslatingtrue;// 立即显示“翻译中”this.performTranslation(text);// 异步执行}// 在翻译完成后privateasyncperformTranslation(text:string):Promisevoid{// ...this.isTranslatingfalse;this.translationResultresult;}FAQQ为什么真机上长按文本能看到自定义菜单但模拟器上不行A模拟器对文本选中和长按手势的支持不够完善部分模拟器版本可能无法正确触发划词服务。建议在真机上进行测试。真机测试时需要确认设备系统版本不低于 HarmonyOS 6.1.0。Q同一个页面可以注入多个菜单吗A可以。SelectionInput.injectMenu可以多次调用每次注入的菜单会合并到系统菜单弹窗中。但需要注意的是同一个页面内重复调用injectMenu会创建多个实例必须分别释放。建议只调用一次将所有菜单项放在一个配置中。Q点击自定义菜单后能获取到选中的坐标位置吗A当前版本不支持获取选中区的坐标。SelectionInput.SelectionMenu.action只能获取选中的文本内容无法获取位置信息。如果你需要自定义菜单根据选中位置弹窗建议使用手势监听 自定义弹窗方案。