从零开发一个 HarmonyOS 输入法——KikaInputMethod 完整拆解

文章目录

      • 先搞清楚输入法的本质
      • 整体架构图
      • [第一层:InputMethodExtensionAbility 注册](#第一层:InputMethodExtensionAbility 注册)
        • [module.json5 配置](#module.json5 配置)
        • [ServiceExtAbility.ets --- 输入法入口](#ServiceExtAbility.ets — 输入法入口)
      • [第二层:KeyboardController 初始化键盘面板](#第二层:KeyboardController 初始化键盘面板)
      • [第三层:InputHandler 文本操作封装](#第三层:InputHandler 文本操作封装)
      • 第四层:监听注册
      • [第五层:键盘 UI 组件使用 InputHandler](#第五层:键盘 UI 组件使用 InputHandler)
      • 踩坑记录
      • 写在最后

自定义输入法是 HarmonyOS 里相对复杂的能力,因为它不是一个普通页面,而是一个系统级的扩展能力(ExtensionAbility)。KikaInputMethod 这个 demo 把整个架构做得很完整,值得深入学习。

先搞清楚输入法的本质

普通 App 的 UIAbility 就是"有界面的进程",而输入法本质上是一个 InputMethodExtensionAbility,它:

  1. 没有独立的 Launch 入口(用户无法直接打开它)
  2. 只有当某个应用有输入框聚焦时,系统才会召唤它
  3. 它显示的键盘是一个 Panel(面板),由系统管理位置

用大白话说:输入法就是一个"被系统召唤的 UI 插件",它自己没有 main 函数。

整体架构图

第一层:InputMethodExtensionAbility 注册

module.json5 配置
json5 复制代码
{
  "module": {
    "extensionAbilities": [
      {
        "name": "InputMethodExtAbility",
        "srcEntry": "./ets/InputMethodExtensionAbility/InputMethodService.ets",
        "type": "inputMethod",           // 必须是 inputMethod 类型
        "exported": true,
        "label": "$string:app_name",
        // 指向键盘 UI 页面
        "metadata": [
          {
            "name": "ohos.extension.input_method",
            "resource": "$profile:input_method_config"
          }
        ]
      }
    ]
  }
}
ServiceExtAbility.ets --- 输入法入口
typescript 复制代码
import { InputMethodExtensionAbility } from '@kit.IMEKit';
import { keyboardController } from './model/KeyboardController';
import { Want } from '@kit.AbilityKit';

export default class ServiceExtAbility extends InputMethodExtensionAbility {
  onCreate(want: Want): void {
    // 输入法被系统激活(第一次调起时执行一次)
    keyboardController.onCreate(this.context);
  }

  onDestroy(): void {
    // 输入法被卸载或系统要求销毁
    keyboardController.onDestroy();
  }
}

这个文件很薄,核心逻辑都在 KeyboardController 里,职责分离很干净。

第二层:KeyboardController 初始化键盘面板

typescript 复制代码
import { inputMethodEngine, InputMethodExtensionContext } from '@kit.IMEKit';
import { display } from '@kit.ArkUI';
import { StyleConfiguration } from '../../common/StyleConfiguration';

const inputMethodAbility = inputMethodEngine.getInputMethodAbility();

class KeyboardController {
  private panel: inputMethodEngine.Panel | undefined;
  private mContext: InputMethodExtensionContext | undefined;

  public onCreate(context: InputMethodExtensionContext): void {
    this.mContext = context;
    this.initWindow();    // 创建键盘面板
    this.registerListener(); // 注册所有监听
  }

  private initWindow(): void {
    // 1. 获取屏幕信息,计算键盘高度
    let dis = display.getDefaultDisplaySync();
    let dWidth = dis.width;
    let dHeight = dis.height;

    // 2. 根据设备类型、横竖屏计算键盘高度比例
    let keyHeightRate = KEYBOARD_HEIGHT_RATE_DEFAULT; // 默认 0.43
    let isLandscape = dWidth > dHeight;

    if (dWidth === 1344 && dHeight === 2772) {
      // 标准手机竖屏:键盘占 38% 高度
      keyHeightRate = KEYBOARD_HEIGHT_RATE_PHONE; // 0.38
    } else if (dWidth === 2772 && dHeight === 1344) {
      // 手机横屏:键盘占 50% 高度
      keyHeightRate = KEYBOARD_HEIGHT_RATE_PHONE_LAND; // 0.5
    }
    // ... 其他设备适配

    let keyHeight = dHeight * keyHeightRate;

    // 3. 计算 StyleConfiguration(键盘 UI 样式)
    let inputStyle = StyleConfiguration.getInputStyle(
      isLandscape, isRkDevice, deviceInfo.deviceType
    );
    AppStorage.setOrCreate('inputStyle', inputStyle); // 全局共享给键盘 UI

    // 4. 创建键盘面板
    let panelInfo: inputMethodEngine.PanelInfo = {
      type: inputMethodEngine.PanelType.SOFT_KEYBOARD, // 软键盘类型
      flag: inputMethodEngine.PanelFlag.FLG_FIXED     // 固定在底部
    };

    inputMethodAbility.createPanel(this.mContext, panelInfo)
      .then((panel: inputMethodEngine.Panel) => {
        this.panel = panel;
        // 5. 设置面板尺寸
        panel.resize(dWidth, keyHeight).then(() => {
          // 6. 加载键盘 UI 页面
          panel.setUiContent('InputMethodExtensionAbility/pages/Index');
        });
      });
  }
}

关键理解 :Panel 是系统提供的"浮动窗口容器",setUiContent 把你的键盘 UI 页面加载进去。这个 Panel 的位置由系统控制(固定在屏幕底部),你只能控制高度。


第三层:InputHandler 文本操作封装

InputHandler 是单例,封装了所有对文本框的操作:

typescript 复制代码
import { inputMethodEngine } from '@kit.IMEKit';

export class InputHandler {
  private mTextInputClient: inputMethodEngine.InputClient | undefined;
  private mKbController: inputMethodEngine.KeyboardController | undefined;

  // 单例模式,存在 AppStorage 里
  public static getInstance(): InputHandler {
    let instance = AppStorage.get<InputHandler>('inputHandler');
    if (instance === undefined) {
      instance = new InputHandler();
      AppStorage.setOrCreate('inputHandler', instance);
    }
    return instance;
  }

  // 系统调起输入法时触发,拿到 KeyboardController 和 InputClient
  public onInputStart(
    kbController: inputMethodEngine.KeyboardController,
    textInputClient: inputMethodEngine.InputClient
  ): void {
    this.mKbController = kbController;
    this.mTextInputClient = textInputClient;

    // 获取编辑框属性(Enter 键类型、输入模式等)
    textInputClient.getEditorAttribute().then((attr) => {
      AppStorage.setOrCreate('enterKeyType', attr.enterKeyType);
      AppStorage.setOrCreate('inputPattern', attr.inputPattern);
    });
  }

  // 插入文字(同步接口,更快)
  public insertText(text: string): void {
    if (this.mTextInputClient) {
      this.mTextInputClient.insertTextSync(text);
    }
  }

  // 向前删除 n 个字符
  public deleteForward(length: number): void {
    if (this.mTextInputClient) {
      this.mTextInputClient.deleteForward(length);
    }
  }

  // 向后删除 n 个字符
  public deleteBackward(length: number): void {
    if (this.mTextInputClient) {
      this.mTextInputClient.deleteBackward(length);
    }
  }

  // 移动光标
  public moveCursor(direction: inputMethodEngine.Direction): void {
    if (this.mTextInputClient) {
      this.mTextInputClient.moveCursor(direction);
    }
  }

  // 隐藏键盘
  public hideKeyboardSelf(): void {
    if (this.mKbController) {
      this.mKbController.hide();
    }
  }

  // 发送 Enter 键功能(搜索/换行/完成等)
  public sendKeyFunction(): void {
    if (this.mTextInputClient && this.mEditorAttribute) {
      // 根据 enterKeyType 发送对应功能
      this.mTextInputClient.sendKeyFunction(this.mEditorAttribute.enterKeyType);
      // 结束预上屏
      this.mTextInputClient.finishTextPreview();
    }
  }
}

第四层:监听注册

typescript 复制代码
private registerListener(): void {
  // 1. 屏幕旋转时重新计算键盘尺寸
  display.on('change', () => {
    this.resizePanel();
  });

  // 2. 有输入框聚焦时触发
  inputMethodAbility.on('inputStart', (kbController, textInputClient) => {
    this.inputHandle.onInputStart(kbController, textInputClient);
  });

  // 3. 输入法切换子类型(比如中文/英文切换)
  inputMethodAbility.on('setSubtype', (subtype) => {
    if (subtype.id === 'InputMethodExtAbility') {
      AppStorage.setOrCreate('subtypeChange', 0);
    }
  });

  // 4. 输入框失焦/应用关闭时触发
  inputMethodAbility.on('inputStop', () => {
    this.onDestroy();
    this.mContext?.destroy();
  });

  // 5. 物理键盘按键事件(外接键盘或实体键盘设备)
  this.mKeyboardDelegate = inputMethodEngine.getKeyboardDelegate();
  this.mKeyboardDelegate.on('keyDown', (keyEvent) => {
    return this.onKeyDown(keyEvent); // 返回 true 表示消费此事件
  });
  this.mKeyboardDelegate.on('keyUp', (keyEvent) => {
    return this.onKeyUp(keyEvent);
  });

  // 6. 光标位置变化
  this.mKeyboardDelegate.on('cursorContextChange', (x, y, height) => {
    this.inputHandle.setCursorInfo({ x, y, height });
  });
}

第五层:键盘 UI 组件使用 InputHandler

键盘上每个键点击时,都通过 InputHandler.getInstance() 调用文本操作:

typescript 复制代码
// KeyItem.ets --- 普通字母键
@Component
export struct KeyItem {
  keyValue: string = '';

  build() {
    Stack() {
      Text(this.keyValue)
    }
    .onClick(() => {
      InputHandler.getInstance().insertText(this.keyValue);
    })
  }
}

// DeleteItem.ets --- 删除键
@Component
export struct DeleteItem {
  build() {
    Stack() {
      Image($r('app.media.back'))
    }
    .onClick(() => {
      InputHandler.getInstance().deleteForward(1);
    })
  }
}

// ReturnItem.ets --- 确认/换行键
@Component
export struct ReturnItem {
  build() {
    Stack() {
      Image($r('app.media.return'))
    }
    .onClick(() => {
      InputHandler.getInstance().sendKeyFunction();
    })
  }
}

// SpaceItem.ets --- 空格键
@Component
export struct SpaceItem {
  spaceWith: Resource | undefined = undefined;
  // 从 AppStorage 读取当前样式
  @StorageLink('inputStyle') inputStyle: KeyStyle = StyleConfiguration.getSavedInputStyle();

  build() {
    Stack() {
      Text('space')
        .fontSize(this.inputStyle.symbol_fontSize) // 样式自适应
    }
    .onClick(() => {
      InputHandler.getInstance().insertText(' ');
    })
  }
}

踩坑记录

坑1:Panel 只能在 initWindow 里创建一次

不要在 inputStart 回调里创建 Panel,每次有输入框聚焦都会触发 inputStart,重复创建会报错。Panel 应该在 onCreateinitWindow 时创建一次,之后复用。

坑2:InputHandler 必须用单例

键盘 UI 组件和 KeyboardController 在不同的调用链里,它们需要共享同一个 InputClient 引用。用 AppStorage 存单例是这个 demo 的正确做法。

坑3:屏幕旋转必须重新 resize

监听 display.on('change') 然后调 this.panel.resize(newWidth, newHeight) 是必须的,否则键盘在旋转后会错位或大小不对。

坑4:物理键盘 onKeyDown 返回值决定是否消费

返回 true 表示输入法消费了这个按键,系统不再处理;返回 false 表示交给系统。删除键(KEYCODE_DEL)要返回 true,否则系统也会处理一次,删两个字符。

写在最后

开发自定义输入法比普通 App 复杂不少,主要是多了一层系统框架的理解:Panel 是系统给的容器,InputClient 是系统给的文本操作接口,你不能直接操作被编辑的文本框。但 KikaInputMethod 这个 demo 的架构分层很清晰,照着写一遍就能明白整个机制。

相关推荐
Python私教9 小时前
鸿蒙 NEXT 也能接 MCP?用 ArkTS 跑通 AI Agent 工具链
人工智能·华为·harmonyos
Swift社区12 小时前
分布式能力在鸿蒙 PC 上到底怎么用?
分布式·华为·harmonyos
nashane21 小时前
HarmonyOS 6学习:外接键盘CapsLock与长截图功能的实战调试与完整解决方案
学习·华为·计算机外设·harmonyos
aqi001 天前
一文理清 HarmonyOS 6.0.2 涵盖的十个升级点
android·华为·harmonyos·鸿蒙·harmony
环信即时通讯云1 天前
环信Flutter UIKit适配鸿蒙实战指南
flutter·华为·harmonyos
Swift社区1 天前
鸿蒙 PC 应用启动优化全解析
华为·harmonyos
richard_yuu1 天前
鸿蒙本地数据存储实战|Preferences 封装、数据隔离与隐私合规存储方案
android·华为·harmonyos
Lynnb1 天前
Mac电脑烧录 RK3588 鸿蒙开发板固件教程
华为·harmonyos·鸿蒙系统