最近在做 HarmonyOS Next 上的输入法迁移,整个过程可以说是“步步惊心”。
起初我以为只是换个 IMEKit 的 API,把 InputMethodAbility 改成 InputMethodExtensionAbility 就完事了。结果提交给 QA 一测,报上来一个致命 Bug:手机重启后,在不输入锁屏密码的情况下,直接点击紧急呼叫或密码框,键盘起不来,应用直接 Crash。
查了两天日志,才明白鸿蒙 Next 在安全侧的改动有多大。这不仅仅是“基础模式”和“完整模式”的简单切换,其底层涉及到了文件系统的 加密分区隔离(Direct Boot 模式)。
借此机会复盘下这次架构重构的血泪史,顺便聊聊怎么打造一个既安全又好用的“双模”输入法。
一、 崩溃的真相:你以为的 filesDir 已经不是你的了
在老版本的安卓或鸿蒙开发中,我们习惯把配置文件、皮肤图片、核心词库都扔进 context.filesDir 或者 context.databaseDir。
但在鸿蒙 Next 中,文件系统被严格切割为两个加密等级:
- EL1 (Device Encrypted):设备级加密。
- 状态:只要手机开机(哪怕没解锁),这个区域就能读写。
- 用途:存放应用的核心启动组件、兜底资源。
- EL2 (User Encrypted):用户级加密。
- 状态:只有当用户第一次解锁屏幕,使得密钥环(Keyring)解密后,这个区域才挂载。
- 用途:存放用户的隐私数据、下载的皮肤、聊天联想词库。
我的崩溃原因就在这:系统默认的 context.filesDir 指向的是 EL2 区域。
当手机重启处于“基础模式(Basic Mode)”时,EL2 还是锁死的。这时候输入法启动,代码里那行 FileIo.open(context.filesDir + '/skin.jpg') 直接抛出 IO 异常,主线程未捕获,当场暴毙。
二、 架构重构:从“大单体”到“双核驱动”
既然环境如此恶劣,我们的架构就得变。不能再写一套逻辑跑到底了,必须设计一套**“双核驱动”**架构。
1. 资源分级策略
我把输入法的资源强行拆分:
-
Level 0(保命资源):
-
内容:纯色默认皮肤(代码绘制或极小图片)、基础拼音/全拼DAG库、按键布局配置。
-
存储:必须放在 EL1 目录 (
/data/app/el1/bundle/public/...) 或者直接打包在 RawFile 里(RawFile 属于安装包只读资源,天然可读)。 -
Level 1(体验资源):
-
内容:用户下载的动态皮肤、云端热词、用户自造词。
-
存储:放在 EL2 目录。
2. 状态机接管生命周期
别在 onCreate 里写面条代码。我封装了一个 SecurityModeManager,在 Ability 启动最早的时刻介入。
import { inputMethod } from '@kit.IMEKit';
import { common } from '@kit.AbilityKit';
export class SecurityModeManager {
private static instance: SecurityModeManager;
// 默认为基础模式,做最坏的打算
private currentMode: inputMethod.SecurityMode = 'basic';
private ctx: common.InputMethodExtensionContext;
// 核心:动态切换上下文
// 基础模式下,我们要强行把 Context 的基目录切到 EL1
private updateContextArea() {
if (this.currentMode === 'basic') {
// 这一步至关重要!告诉系统我现在只操作 EL1
this.ctx.area = common.AreaMode.EL1;
console.info('[IME] 切换至 EL1 区域,进入生存模式');
} else {
this.ctx.area = common.AreaMode.EL2;
console.info('[IME] 切换至 EL2 区域,挂载用户数据');
}
}
public async init(context: common.InputMethodExtensionContext) {
this.ctx = context;
// 1. 同步获取一次当前模式(API 12+ 支持同步或异步,建议异步不卡主线程)
this.currentMode = await inputMethod.getSecurityMode();
this.updateContextArea();
// 2. 注册监听
inputMethod.on('securityModeChange', (mode) => {
console.warn(`[IME] 警戒:安全模式变更 -> ${mode}`);
this.currentMode = mode;
this.handleModeSwitch(mode);
});
}
private handleModeSwitch(mode: inputMethod.SecurityMode) {
this.updateContextArea();
if (mode === 'basic') {
// 紧急避险逻辑
Engine.switchToMinimal();
NetworkStack.killAll(); // 物理断网
} else {
// 恢复逻辑
// 注意:这里由于是从 Basic 切过来,EL2 可能刚挂载,建议延迟 100ms 再读
setTimeout(() => {
Engine.switchToFull();
UserDict.sync();
}, 100);
}
}
}
三、 实战中的“暗坑”与“骚操作”
代码写好了,跑起来还是有坑。以下是几个文档里没写,全靠肉身试出来的经验。
坑一:WebView 的“见光死”
我们在做“键盘内搜索”功能时,用了一个隐藏的 WebView 来预加载数据。
现象:在 Full 模式下好好的,一锁屏(切回 Basic 模式),输入法进程直接被系统守护进程(Watchdog)杀掉。
原因:鸿蒙的安全策略规定,Basic 模式下严禁 Web 内核驻留。这属于高危违规。
解法:监听 securityModeChange。一旦变更为 basic,必须调用 webviewController.destroy(),不能只是 hide,必须销毁!
坑二:内存泄露导致的“二次启动慢”
从 Full 切回 Basic(比如用户锁屏),是 GC 的最佳时机。
在 Full 模式下,我们加载了大量皮肤图片。切回 Basic 时,UI 会强制降级为默认皮肤。此时如果不手动释放 Full 模式下的 Bitmap 缓存,这些内存在锁屏期间会一直占着。
系统在锁屏状态下对后台进程的内存容忍度极低,一旦内存吃紧,你的输入法就会被杀。下次用户解锁唤起键盘,就要走“冷启动”流程,出现明显的黑屏延迟。
骚操作:切回 Basic 时,手动触发一次资源释放,甚至可以调用 global.gc()(慎用,视情况而定),保持进程“瘦身”过冬。
坑三:剪贴板的“薛定谔状态”
Basic 模式下,读取剪贴板接口 pasteboard.getSystemPasteboard().getData() 会失败或返回空。
误区:很多开发者会在 onWindowShow 里去轮询剪贴板做“淘口令”识别。
后果:在密码框场景下,这个轮询会疯狂报错,虽然不 Crash,但日志很难看,且耗电。
解法:加个判断锁。if (mode === 'basic') return;。
四、 到底能不能在 Basic 模式下用“热词”?
这是产品经理最爱问的问题:“用户重启手机没解锁,想打字,难道就不能联想出他的名字吗?”
从技术上讲:可以,但很麻烦。
既然 EL2 读不到,那我们可以在 Full 模式下,把用户最高频的 500 个词,定期 Copy 到 EL1 的文件里。
- 写入时机:每次输入法退出、或者夜间闲时。
- 读取时机:Basic 模式启动时,读取 EL1 下的这个“高频缓存文件”。
这样既遵守了系统“不动用 EL2”的规矩,又解决了用户重启后打字“变笨”的痛点。这就是所谓的“空间换体验”。
五、 写在最后
适配鸿蒙 Next 的输入法,本质上是在做**“戴着镣铐跳舞”**的事。
不要抱怨系统的限制,这些限制本质上是为了保护用户数据(防止手机丢了被强行读数据)。作为开发者,我们的价值就在于:在 EL1 的方寸之地里把基础体验做到极致流畅,在 EL2 的广阔天地里把功能做到极致丰富。
当把这套逻辑跑通,看着日志里 basic 和 full 模式丝滑切换,内存水位涨落有序,那种掌控感,才是做开发最大的乐趣。