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

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

从一次文本交互的需求说起

在 HarmonyOS NEXT 开发中,如果我们希望用户选中一段文本后,能弹出"翻译"、"搜索"、"复制"等自定义操作菜单,一般会怎么处理?

很多人会想到监听手势,获取光标位置,然后自己实现一个浮动菜单。这样做的成本不止是 UI 层------还需要处理文本选中事件的回调、菜单的位置计算、以及在不同设备上的适配。

但 HarmonyOS 提供了一套更接近系统级的方案:划词服务(SelectionInput)。它允许开发者在系统默认的长按选中弹窗中,注入自定义的操作项,直接获取用户选中的文本内容,并作出响应。

这套 API 本身不复杂,但实际开发中,很多人在生命周期管理和菜单交互上踩了坑。官方示例虽然能运行,但放到真实项目里,至少还要处理两个问题:

  1. 页面返回时划词服务没有被正常释放,导致其他页面也受影响。
  2. 菜单点击后,选中状态和 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')
  }
}

这段代码有几个重点:

  1. aboutToAppear 中注入,aboutToDisappear 中释放 。这是生命周期管理的标准做法。如果只在 aboutToAppear 注入但不释放,当页面返回到上一页时,划词服务仍然在后台生效,可能会导致其他页面也弹出了我们的自定义菜单。

  2. selectedText 使用了普通属性而非 @State 。因为 action 是同步回调,不需要触发 UI 更新。翻译请求的结果使用了 @State,这样才能更新 UI。

  3. copyOptionselectable 缺一不可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 只能获取选中的文本内容,无法获取位置信息。如果你需要自定义菜单根据选中位置弹窗,建议使用手势监听 + 自定义弹窗方案。

相关推荐
tyqtyq2218 小时前
HarmonyOS AI 应用开发实战:简历项目经历改写系统
人工智能·学习·华为·生活·harmonyos
2301_7681034919 小时前
HarmonyOS趣味相机实战第16篇:实时识别采样、Busy锁与Generation并发治理
harmonyos·arkts·并发控制·camerakit·corevisionkit
xd18557855519 小时前
表情包配文-基于鸿蒙的AI表情包配文生成应用开发实践
人工智能·华为·harmonyos·鸿蒙
AD022720 小时前
39-设置开关退出就复原-用偏好写队列和回滚状态兜住
harmonyos·arkts·鸿蒙开发
不羁的木木20 小时前
HarmonyOS技术精讲-Connectivity Kit:蓝牙基础——经典蓝牙与BLE入门
华为·harmonyos
不羁的木木20 小时前
HarmonyOS技术精讲-Connectivity Kit:自动设备发现与一键配网
华为·harmonyos
雪芽蓝域zzs21 小时前
HarmonyOS开发 指纹验证和ESDSA加密
华为·harmonyos
念雨思21 小时前
HarmonyOS AI 应用开发实战:短视频选题灵感 —— AI 驱动的内容创作引擎
人工智能·学习·华为·harmonyos·鸿蒙
tyqtyq221 天前
HarmonyOS AI 应用开发实战:考研择校分析系统
人工智能·学习·考研·华为·生活·harmonyos