从零开发一个 HarmonyOS 输入法——KikaInputMethod 完整拆解
文章目录
- 先搞清楚输入法的本质
- 整体架构图
- 第一层:InputMethodExtensionAbility 注册
- module.json5 配置
- ServiceExtAbility.ets — 输入法入口
- 第二层:KeyboardController 初始化键盘面板
- 第三层:InputHandler 文本操作封装
- 第四层:监听注册
- 第五层:键盘 UI 组件使用 InputHandler
- 踩坑记录
- 写在最后
自定义输入法是 HarmonyOS 里相对复杂的能力,因为它不是一个普通页面,而是一个系统级的扩展能力(ExtensionAbility)。KikaInputMethod这个 demo 把整个架构做得很完整,值得深入学习。
先搞清楚输入法的本质
普通 App 的 UIAbility 就是"有界面的进程",而输入法本质上是一个InputMethodExtensionAbility,它:
- 没有独立的 Launch 入口(用户无法直接打开它)
- 只有当某个应用有输入框聚焦时,系统才会召唤它
- 它显示的键盘是一个Panel(面板),由系统管理位置
用大白话说:输入法就是一个"被系统召唤的 UI 插件",它自己没有 main 函数。
整体架构图
第一层:InputMethodExtensionAbility 注册
module.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 — 输入法入口
import{InputMethodExtensionAbility}from'@kit.IMEKit';import{keyboardController}from'./model/KeyboardController';import{Want}from'@kit.AbilityKit';exportdefaultclassServiceExtAbilityextendsInputMethodExtensionAbility{onCreate(want:Want):void{// 输入法被系统激活(第一次调起时执行一次)keyboardController.onCreate(this.context);}onDestroy():void{// 输入法被卸载或系统要求销毁keyboardController.onDestroy();}}这个文件很薄,核心逻辑都在KeyboardController里,职责分离很干净。
第二层:KeyboardController 初始化键盘面板
import{inputMethodEngine,InputMethodExtensionContext}from'@kit.IMEKit';import{display}from'@kit.ArkUI';import{StyleConfiguration}from'../../common/StyleConfiguration';constinputMethodAbility=inputMethodEngine.getInputMethodAbility();classKeyboardController{privatepanel:inputMethodEngine.Panel|undefined;privatemContext:InputMethodExtensionContext|undefined;publiconCreate(context:InputMethodExtensionContext):void{this.mContext=context;this.initWindow();// 创建键盘面板this.registerListener();// 注册所有监听}privateinitWindow():void{// 1. 获取屏幕信息,计算键盘高度letdis=display.getDefaultDisplaySync();letdWidth=dis.width;letdHeight=dis.height;// 2. 根据设备类型、横竖屏计算键盘高度比例letkeyHeightRate=KEYBOARD_HEIGHT_RATE_DEFAULT;// 默认 0.43letisLandscape=dWidth>dHeight;if(dWidth===1344&&dHeight===2772){// 标准手机竖屏:键盘占 38% 高度keyHeightRate=KEYBOARD_HEIGHT_RATE_PHONE;// 0.38}elseif(dWidth===2772&&dHeight===1344){// 手机横屏:键盘占 50% 高度keyHeightRate=KEYBOARD_HEIGHT_RATE_PHONE_LAND;// 0.5}// ... 其他设备适配letkeyHeight=dHeight*keyHeightRate;// 3. 计算 StyleConfiguration(键盘 UI 样式)letinputStyle=StyleConfiguration.getInputStyle(isLandscape,isRkDevice,deviceInfo.deviceType);AppStorage.setOrCreate('inputStyle',inputStyle);// 全局共享给键盘 UI// 4. 创建键盘面板letpanelInfo: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是单例,封装了所有对文本框的操作:
import{inputMethodEngine}from'@kit.IMEKit';exportclassInputHandler{privatemTextInputClient:inputMethodEngine.InputClient|undefined;privatemKbController:inputMethodEngine.KeyboardController|undefined;// 单例模式,存在 AppStorage 里publicstaticgetInstance():InputHandler{letinstance=AppStorage.get<InputHandler>('inputHandler');if(instance===undefined){instance=newInputHandler();AppStorage.setOrCreate('inputHandler',instance);}returninstance;}// 系统调起输入法时触发,拿到 KeyboardController 和 InputClientpubliconInputStart(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);});}// 插入文字(同步接口,更快)publicinsertText(text:string):void{if(this.mTextInputClient){this.mTextInputClient.insertTextSync(text);}}// 向前删除 n 个字符publicdeleteForward(length:number):void{if(this.mTextInputClient){this.mTextInputClient.deleteForward(length);}}// 向后删除 n 个字符publicdeleteBackward(length:number):void{if(this.mTextInputClient){this.mTextInputClient.deleteBackward(length);}}// 移动光标publicmoveCursor(direction:inputMethodEngine.Direction):void{if(this.mTextInputClient){this.mTextInputClient.moveCursor(direction);}}// 隐藏键盘publichideKeyboardSelf():void{if(this.mKbController){this.mKbController.hide();}}// 发送 Enter 键功能(搜索/换行/完成等)publicsendKeyFunction():void{if(this.mTextInputClient&&this.mEditorAttribute){// 根据 enterKeyType 发送对应功能this.mTextInputClient.sendKeyFunction(this.mEditorAttribute.enterKeyType);// 结束预上屏this.mTextInputClient.finishTextPreview();}}}第四层:监听注册
privateregisterListener():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)=>{returnthis.onKeyDown(keyEvent);// 返回 true 表示消费此事件});this.mKeyboardDelegate.on('keyUp',(keyEvent)=>{returnthis.onKeyUp(keyEvent);});// 6. 光标位置变化this.mKeyboardDelegate.on('cursorContextChange',(x,y,height)=>{this.inputHandle.setCursorInfo({x,y,height});});}第五层:键盘 UI 组件使用 InputHandler
键盘上每个键点击时,都通过InputHandler.getInstance()调用文本操作:
// KeyItem.ets — 普通字母键@Componentexportstruct KeyItem{keyValue:string='';build(){Stack(){Text(this.keyValue)}.onClick(()=>{InputHandler.getInstance().insertText(this.keyValue);})}}// DeleteItem.ets — 删除键@Componentexportstruct DeleteItem{build(){Stack(){Image($r('app.media.back'))}.onClick(()=>{InputHandler.getInstance().deleteForward(1);})}}// ReturnItem.ets — 确认/换行键@Componentexportstruct ReturnItem{build(){Stack(){Image($r('app.media.return'))}.onClick(()=>{InputHandler.getInstance().sendKeyFunction();})}}// SpaceItem.ets — 空格键@Componentexportstruct 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 应该在onCreate→initWindow时创建一次,之后复用。
坑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 的架构分层很清晰,照着写一遍就能明白整个机制。
