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

从一次文本交互的需求说起
在 HarmonyOS NEXT 开发中,如果我们希望用户选中一段文本后,能弹出"翻译"、"搜索"、"复制"等自定义操作菜单,一般会怎么处理?
很多人会想到监听手势,获取光标位置,然后自己实现一个浮动菜单。这样做的成本不止是 UI 层------还需要处理文本选中事件的回调、菜单的位置计算、以及在不同设备上的适配。
但 HarmonyOS 提供了一套更接近系统级的方案:划词服务(SelectionInput)。它允许开发者在系统默认的长按选中弹窗中,注入自定义的操作项,直接获取用户选中的文本内容,并作出响应。
这套 API 本身不复杂,但实际开发中,很多人在生命周期管理和菜单交互上踩了坑。官方示例虽然能运行,但放到真实项目里,至少还要处理两个问题:
- 页面返回时划词服务没有被正常释放,导致其他页面也受影响。
- 菜单点击后,选中状态和 UI 更新不同步。
这篇文章就从实现一个"划词翻译"功能入手,把这些细节一次性讲清楚。
它解决了什么,适合什么场景
划词服务 的作用很简单:让你的应用在文本被选中时,弹出一个自定义菜单项。用户点击这个菜单项,你的代码就可以直接拿到 selectedText(选中的文本),然后做任何自定义处理------比如调用翻译 API、打开搜索引擎、保存到笔记等。
和传统方案的对比
| 方案 | 实现复杂度 | 交互一致性 | 适用场景 |
|---|---|---|---|
| 手动监听选中事件 + 自定义浮动菜单 | 高,需要处理手势、位置更新、多端适配 | 差,容易被系统键盘或弹窗遮挡 | 非常灵活的自定义场景 |
| 使用系统划词服务注入菜单 | 低,仅需配置菜单项和回调 | 好,菜单集成在系统原生弹窗中 | 标准化的文本操作(翻译、搜索、复制等) |
划词服务适合的场景比较明确:你需要用户在文本选中后执行一个固定的操作。如果操作逻辑不是固定的(比如依赖其他页面状态),就需要额外处理状态同步。
不适合的场景:需要大量自定义 UI 交互(比如文本标注、划词备注)、或者菜单需要响应式变化(比如根据文本内容动态增删菜单项)。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
核心实现:创建一个划词翻译功能
我们来实现一个功能:用户在任意 Text 组件上长按选中文本,弹窗中会多出一个"翻译"菜单项,点击后调用远程翻译 API,并显示翻译结果。
Step 1:配置权限和依赖
翻译功能需要网络请求,先配置 ohos.permission.INTERNET 权限。
json
// entry/src/main/module.json5
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
然后引入基础服务及相关 API:
typescript
// 没有额外的依赖需要手动引入,相关模块已经在 HarmonyOS SDK 中内置
import { SelectionInput } from '@kit.BasicServicesKit';
这一步需要注意的是:@kit.BasicServicesKit 是 HarmonyOS NEXT 的统一包名。如果你在旧版本(如 API 9)中使用,路径可能会变成 @ohos.base。如果是新项目,直接使用新包名即可。
Step 2:创建自定义菜单组件
划词服务的核心是配置一个 SelectionInputOptions 对象,其中包含你要注入的菜单项。每个菜单项需要有:
label:菜单显示的文本。action:用户点击后的回调函数,参数是选中的文本。icon:可选,菜单项的图标。
我们先把菜单组件独立出来,方便复用:
typescript
// src/main/ets/components/SelectionMenuConfig.ts
import { SelectionInput } from '@kit.BasicServicesKit';
// 定义菜单项配置
export interface MenuItemConfig {
label: string;
action: (selectedText: string) => void;
}
// 创建划词服务的配置对象
export function createSelectionMenuConfig(menuItems: MenuItemConfig[]): SelectionInput.SelectionInputOptions {
// 将你的菜单项转换为 SelectionMenu 所需的格式
const menu: 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 的类型是 SyncCallback<string, void>。这意味着你在回调内不能直接调用 await 或者长时间的网络请求。
如果你需要在点击菜单后发起网络请求(比如翻译),就必须把文本暂存到一个外部状态中,然后在生命周期合适的时机发起请求。这一点很容易被忽略,很多人直接把 httpRequest 塞进 action 里,然后发现菜单点击后没有任何响应------因为异步操作被吞了。
Step 3:在页面中注入划词服务
接下来在页面中使用 SelectionInput.injectMenu 方法将配置注入。
typescript
// src/main/ets/pages/TranslatePage.ets
import { SelectionInput } from '@kit.BasicServicesKit';
import { createSelectionMenuConfig, MenuItemConfig } from '../components/SelectionMenuConfig';
import { BusinessError } from '@kit.BasicServicesKit';
@Entry
@Component
struct TranslatePage {
// 存储翻译结果
@State translationResult: string = '';
// 当前选中的文本
private selectedText: string = '';
// 划词服务实例,用于释放资源
private selectionInputInstance: SelectionInput.SelectionInputOptions | null = null;
aboutToAppear(): void {
// 配置菜单
const menuItems: MenuItemConfig[] = [
{
label: '翻译(英 -> 中)',
action: (text: string) => {
// 保存选中的文本
this.selectedText = text;
// 发起翻译请求
this.performTranslation(text);
}
}
];
const options = createSelectionMenuConfig(menuItems);
// 将划词服务注入到当前页面上下文
try {
SelectionInput.injectMenu(options).then((instance) => {
// 保存实例,用于后续释放
this.selectionInputInstance = instance;
console.info('划词服务注入成功');
}).catch((err: BusinessError) => {
console.error(`划词服务注入失败: ${err.message}`);
});
} catch (err) {
console.error(`调用 injectMenu 异常: ${err}`);
}
}
aboutToDisappear(): void {
// 页面销毁时,必须释放划词服务,否则会影响到其他页面
this.releaseSelectionInput();
}
private releaseSelectionInput(): void {
if (this.selectionInputInstance) {
try {
// 调用 unregister 方法释放
SelectionInput.unregister(this.selectionInputInstance);
} catch (err) {
console.error(`释放划词服务异常: ${err}`);
} finally {
this.selectionInputInstance = null;
}
}
}
private async performTranslation(text: string): Promise<void> {
if (!text) {
return;
}
try {
// 这里调用一个翻译 API,示例中使用一个模拟的 HTTP 请求
const response = await fetch('https://api.example.com/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
q: text,
source: 'en',
target: 'zh'
})
});
const data = await response.json();
// 假设返回的格式是 { translatedText: "你好" }
this.translationResult = data.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:完整的页面入口
typescript
// src/main/ets/pages/Index.ets
import { router } from '@kit.ArkUI';
@Entry
@Component
struct 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 更新一个"加载中"状态,然后在异步回调完成后更新结果状态。这样用户点击菜单后能看到即时反馈,避免误以为没有生效。
typescript
// 在组件中添加一个加载状态
@State isTranslating: boolean = false;
action: (text: string) => {
this.selectedText = text;
this.isTranslating = true; // 立即显示"翻译中"
this.performTranslation(text); // 异步执行
}
// 在翻译完成后
private async performTranslation(text: string): Promise<void> {
// ...
this.isTranslating = false;
this.translationResult = result;
}
FAQ
Q:为什么真机上长按文本能看到自定义菜单,但模拟器上不行?
A:模拟器对文本选中和长按手势的支持不够完善,部分模拟器版本可能无法正确触发划词服务。建议在真机上进行测试。真机测试时需要确认设备系统版本不低于 HarmonyOS 6.1.0。
Q:同一个页面可以注入多个菜单吗?
A:可以。SelectionInput.injectMenu 可以多次调用,每次注入的菜单会合并到系统菜单弹窗中。但需要注意的是,同一个页面内重复调用 injectMenu 会创建多个实例,必须分别释放。建议只调用一次,将所有菜单项放在一个配置中。
Q:点击自定义菜单后,能获取到选中的坐标位置吗?
A:当前版本不支持获取选中区的坐标。SelectionInput.SelectionMenu.action 只能获取选中的文本内容,无法获取位置信息。如果你需要自定义菜单根据选中位置弹窗,建议使用手势监听 + 自定义弹窗方案。