深入 Android 自定义输入法全链路:从 InputMethodService 进程架构到候选词引擎的 IME 工程实践

做语音输入功能时,我踩过一个大坑:在 InputMethodService 里拉起一个语音识别 Activity,结果键盘直接消失了,没有任何异常日志。排查了整整一天才发现,问题根源在于 IME 的进程模型和窗口层级管理。

写完一个能打字的 Demo 不难,但要做一个生产级输入法,你得理解它背后那套独立的进程体系、双向 IPC 通道和键盘 UI 的渲染机制。这篇文章沿着我实际项目的踩坑路径,把整条链路串起来讲。

IME 的独立进程模型

Android 输入法运行在一个独立进程中,跟宿主 App 完全隔离开。这不是可选项,是系统强制要求的。

背后有两个硬约束:安全性——IME 能捕获所有按键输入,包括密码。独立进程天然隔离了宿主 App 通过内存扫描窃取数据的路径,这是 Android 安全模型刻意设计的防线。稳定性——输入法 crash 不能拖垮前台 App。我在低端机上实测过,IME 进程被 kill 后宿主 App 完全不受影响,系统会自动重启输入法服务。

<!-- AndroidManifest.xml -->
<service
    android:name=".MyIME"
    android:permission="android.permission.BIND_INPUT_METHOD"
    android:process=":ime">
    <intent-filter>
        <action android:name="android.view.InputMethod" />
    </intent-filter>
    <meta-data
        android:name="android.view.im"
        android:resource="@xml/method" />
</service>

android:process=":ime" 指定私有进程名。permission 声明了 BIND_INPUT_METHOD,系统只允许持有该权限的组件绑定到这个 Service,普通 App 无法直连。

进程隔离的代价也明显:任何跨进程操作必须走 Binder 通道。你没法直接访问宿主 App 的 View 树,不能调用它的内存数据,所有交互都依赖 InputConnection 协议。

InputMethodService 的生命周期陷阱

InputMethodService 的生命周期比普通 Service 复杂得多。它不是简单的 onCreate → onDestroy,而是围绕「输入焦点」设计了一套精细的状态机。

关键回调的执行顺序:

class MyIME : InputMethodService() {
    // Service 创建时调用一次,做全局初始化
    override fun onCreate() {
        super.onCreate()
        loadDictionary() // 加载词库,只做一次
    }

    // 每次获得输入焦点时调用
    override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
        super.onStartInput(attribute, restarting)
        // attribute 包含输入框类型、IME options 等
        configureForInputType(attribute)
    }

    // 键盘 View 真正创建时
    override fun onCreateInputView(): View {
        return layoutInflater.inflate(R.layout.keyboard, null)
    }
}

最容易出问题的是 onStartInput 和 onCreateInputView 的调用时序。实际顺序是:onStartInput → onCreateInputView → onStartInputView。如果在 onStartInput 里操作了还没创建的 View,直接 NPE。

我之前那个语音输入 Bug 就在这:语音 Activity 的 Window 类型没设为 TYPE_INPUT_METHOD_DIALOG,WindowManagerService 把它当成普通 Activity 处理,IME 窗口层级被打断,键盘脱焦消失。修正只需要一行:

// 在语音 Activity 的 onCreate 中
window.setType(WindowManager.LayoutParams.TYPE_INPUT_METHOD_DIALOG)

IME 进程内的所有窗口必须使用 TYPE_INPUT_METHOD_* 系列类型,这样 WMS 才能把它们正确挂在输入法窗口层级里。窗口类型错了,键盘消失、层级错乱都是常态。

InputConnection:双向通信的协议层

InputConnection 是 IME 与宿主 App 通信的唯一通道。它是一个运行在宿主 App 进程中的 Binder 接口代理,IME 通过它发起所有的文本操作请求。

常用操作:

val ic = currentInputConnection

// 在光标处插入文本,第二个参数是新光标偏移位置
ic?.commitText("你好", 1)

// 删除光标前后字符
ic?.deleteSurroundingText(1, 0) // 删光标前 1 个字符

// 获取上下文文本(做联想输入的关键)
val before = ic?.getTextBeforeCursor(100, 0)
val after = ic?.getTextAfterCursor(50, 0)

// 选中指定范围
ic?.setSelection(0, 5)

getTextBeforeCursor 不是万能的。它的返回值取决于宿主 App 的 Editor 实现——如果宿主用的是自定义 View 而非 EditText,返回值可能是空字符串或截断数据。做联想输入时不能完全依赖它。

InputConnection 还有一个易踩的坑:每次焦点切换,系统都可能返回一个新的实例。不要缓存 currentInputConnection,每次操作前重新获取。实际项目中我封装了一层:

class ImeConnection(private val service: InputMethodService) {
    val ic: InputConnection?
        get() = service.currentInputConnection

    fun safeCommit(text: String) {
        ic?.commitText(text, 1) ?: run {
            // 降级处理:WebView 场景下 InputConnection 可能为 null
        }
    }
}

候选词引擎:延迟是最核心的指标

候选词区域看起来只是个 RecyclerView,但背后的引擎设计直接影响输入体验的「跟手感」。

核心流程:按键事件 → 拼音切分 → 词库检索 → 候选排序 → UI 刷新。每个环节的延迟叠加起来,超过 200ms 用户就能感知到卡顿。

我采用双缓冲策略处理高频刷新:

class CandidateEngine {
    private var displayList = listOf<Candidate>()
    private var computeList = listOf<Candidate>()
    private val lock = Any()

    fun onKeyPress(prefix: String) {
        threadPool.execute {
            val result = search(prefix)
            synchronized(lock) { computeList = result }
            mainHandler.post { swapAndNotify() }
        }
    }

    private fun swapAndNotify() {
        synchronized(lock) { displayList = computeList }
        adapter.submitList(displayList)
    }
}

双缓冲避免了「正在更新 Adapter 时新数据到达」导致的并发修改或 UI 闪烁。用户手速通常在 100-300ms/次,词库检索必须控制在 50ms 以内。

词库数据结构的选择很关键。小型输入法用 SQLite + FTS 做前缀匹配够用,百万级词条下延迟会明显上升。实测中文拼音场景,Trie 树比 SQLite 快 3-5 倍,代价是内存占用翻倍。折中方案用双数组 Trie(Double-Array Trie),查询 O(n) 且空间效率高。

键盘 UI 渲染与触摸分发

键盘 View 绘制本身不复杂,复杂的是触摸事件处理。输入法键盘需要区分点击和滑动,View 默认的 onClick/onLongClick 机制不够精细,必须自己接管 onTouchEvent:

override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            pressedKey = findKeyAt(event.x, event.y)
            invalidateKey(pressedKey) // 高亮反馈
        }
        MotionEvent.ACTION_MOVE -> {
            if (distanceFromDown(event) > SLOP) {
                enterSwipeMode() // 进入滑动输入
            }
        }
        MotionEvent.ACTION_UP -> {
            if (!isSwipeMode) onKeyClicked(pressedKey)
            clearPressedState()
        }
    }
    return true
}

过度绘制是另一个要盯紧的性能点。一个 40 键的键盘,每个键有背景、文字、阴影、按压态四层,叠加起来 160+ 层绘制。我的优化策略是把静态键盘预渲染到 Bitmap,只在按键区域变化时局部刷新——用 ViewGroup.drawChild 覆盖,未按压的键直接跳过 invalidate,省了约 70% 的重复绘制。

几条实践建议

窗口类型优先排查。IME 弹窗、候选词悬浮窗、语音界面,所有窗口必须用 TYPE_INPUT_METHOD_* 系列。错了类型,各种奇怪行为都可能是它引起的。

InputConnection 是瞬时快照,不要缓存。每次操作前重新 getCurrentInputConnection(),尤其在 getTextBeforeCursor 这类依赖宿主状态的方法上。

触摸事件完全自己控制。不要依赖 View 的 click/longClick,输入法键盘对手势的精细度要求远超默认机制提供的范围。

输入法的核心指标是延迟。从按键到文字上屏,全链路超过 200ms 用户就能感知。优化就两个方向:词库检索做内存索引,别查磁盘;UI 刷新做脏区域局部绘制,别整块 invalidate。