Skip to content

键盘视图模型

KeyboardViewModel 划归 :ui 模块的 viewmodel/ 包,作为 UI 层的协调中心,桥接 Compose UI 组件与 :engine 引擎。ViewModel 将 UI 手势(InputGesture)转换为引擎意图(ImeIntent),暴露引擎状态(StateFlow<ImeState>)供 Compose 订阅,管理手势反馈状态(GestureFeedbackState),提供运行时布局模式切换(KeyboardLayoutMode),以及集成输入动作播放器(InputActionPlayer)。ViewModel 仅依赖引擎核心模型和公开 API,平台级职责由 :app 模块承担,确保 :ui 作为纯 UI 库可被第三方应用即插即用。


1 KeyboardViewModel 定位

1.1 模块归属

KeyboardViewModel 属于 :ui 模块的 viewmodel/ 包,是 UI 层(UI Layer + ViewModel Layer)的核心组件。

归属理由

理由说明
职能本质是 UI 协调ViewModel 将 UI 手势(InputGesture)转换为引擎意图(ImeIntent),暴露引擎状态(StateFlow<ImeState>)供 Compose 订阅,管理 KeyboardLayoutMode 切换和布局状态缓存——这些职能完全属于 UI 层
仅依赖引擎公开 APIViewModel 仅持有 ImeEngine 引用,使用其 handleIntent()stateeffectupdateConfig() 等公开 API,不依赖引擎内部实现
第三方应用需要任何引入 :engine + :ui 的第三方应用都需要 KeyboardViewModel 来驱动 UI。如果 ViewModel 在 :app,第三方应用必须自行实现等价组件,违背「即插即用」的设计目标
集成组件的直接搭档KeyboardHostKeyboardInputActionPlayerHost 等集成组件均以 ViewModel 为交互入口,二者同属 UI 层、同生同灭

1.2 不属于 ViewModel 的职责

以下职责由 :app 模块承担,KeyboardViewModel 不参与:

职责承担者说明
创建 ImeEngine:appIMEServiceImeEngine.create() 需要 ImeDictProvider(Android Context 依赖),由应用层创建后传入 ViewModel
管理 InputConnectionBridge:appIMEService桥梁直接挂载到 ImeEngine,与 ViewModel 无关;IMEServiceonStartInput/onDestroy 时管理桥梁生命周期
配置持久化:appConfigDataStoreDataStore 读写是应用层职责;ViewModel 仅通过 engine.updateConfig() 修改运行时配置
系统输入法服务生命周期:appIMEServiceInputMethodServiceonCreate/onDestroy/onStartInput 回调属于平台层

KeyboardViewModel:app 模块的交互仅通过构造参数注入的 ImeEngine 完成。ViewModel 暴露的 API 分为三类:第一类是状态读取(stateconfiglayoutModefeedbackStateactionPlayer),由集成组件通过 collectAsState() 订阅;第二类是意图分发(handleGesture()handleIntent()),由集成组件的用户交互回调触发;第三类是运行时配置(updateConfig()setKeyboardLayoutMode()updateKeyLayoutState() 等),由集成组件的布局回调触发。IMEServiceonCreateInputView() 中通过 KeyboardViewModel.Factory(engine) 注入预创建的引擎,ViewModel 不感知桥梁、持久化和服务生命周期。


2 完整类定义

kotlin
package org.crazydan.studio.ime.ui.viewmodel

/**
 * 键盘视图模型,`:ui` 模块的 UI 协调中心。
 *
 * 桥接 Compose UI 组件与 `:engine` 引擎:
 * - 将 UI 手势(InputGesture)转换为引擎意图(ImeIntent)
 * - 暴露引擎状态(StateFlow<ImeState>)供 Compose 订阅
 * - 管理手势反馈状态(GestureFeedbackState)
 * - 管理运行时布局模式(KeyboardLayoutMode)
 * - 维护本地工具列表状态(StateFlow<ToolListState>)
 * - 维护弹出提示状态(StateFlow<PopupTipState?>),订阅引擎 ImeEffect 通道
 * - 派生 isInputting 状态,控制 ToolListPanel/InputListPanel 互斥切换
 * - 提供输入动作播放器(InputActionPlayer)
 * - 缓存面板布局状态供播放器坐标解析
 * - 在 gestureToIntent() 中直接处理音效、触觉和按键弹出提示
 *
 * ViewModel 仅依赖引擎核心模型和播放器接口。
 * 平台级职责(ImeEngine 创建、InputConnectionBridge 管理、配置持久化)
 * 均由 `:app` 模块承担。
 */
class KeyboardViewModel(
    private val engine: ImeEngine,
    private val audioPlayer: AudioPlayer? = null,
    private val hapticPlayer: HapticPlayer? = null,
) : ViewModel() {

    // ─── 状态暴露 ────────────────────────────────────────────────

    /** 引擎状态,供 Compose 订阅 */
    val state: StateFlow<ImeState> = engine.state

    /** 当前 ImeConfig 快照,便于 UI 组件快速访问 */
    val config: ImeConfig get() = state.value.config

    // ─── 工具列表状态 ────────────────────────────────────────────

    /**
     * 工具列表状态,由 ViewModel 本地维护。
     *
     * 根据 keyboard.type、keyboard.state 和收藏功能门控(favoriteInputEnabled / favoriteClipEnabled)动态配置工具项。
     * 不属于 ImeState,避免引擎维护 UI 层的展示状态。
     * 当 keyboard.type 或 keyboard.state 变更时,自动重新计算工具列表。
     */
    private val _toolListState = MutableStateFlow(ToolListState(emptyList()))
    val toolListState: StateFlow<ToolListState> = _toolListState.asStateFlow()

    // ─── 弹出提示状态 ────────────────────────────────────────────

    /**
     * 弹出提示状态,由 ViewModel 本地维护。
     *
     * 订阅引擎的 ImeEffect 通道,收到 ImeEffect.PopupTip 效果后更新此状态,
     * 并启动自动 dismiss 定时器。
     * 弹出提示属于一次性效果而非持续性状态,
     * 因此不应由 ImeState 管理。
     *
     * 支持 Message 和 Action 两种类型:
     * - Message:纯文本信息提示,超时后自动消失
     * - Action:可点击提示,含操作按钮,点击后触发 ImeIntent
     */
    private val _popupTipState = MutableStateFlow<PopupTipState?>(null)
    val popupTipState: StateFlow<PopupTipState?> = _popupTipState.asStateFlow()

    /**
     * 是否正在输入,由 inputList.hasPending 直接派生。
     *
     * 不属于 ImeState,避免引擎维护 UI 层的展示状态。
     * 控制 ToolListPanel 和 InputListPanel 的互斥切换。
     */
    val isInputting: Boolean
        get() = state.value.inputList.hasPending

    // ─── 布局模式 ────────────────────────────────────────────────

    /**
     * 运行时布局模式。
     *
     * 由 ViewModel 管理,KeyboardHost 订阅此状态决定组件部署方式。
     * 支持 Stacked(堆叠)和 Separated(分离)两种模式,
     * 可在运行时通过 setKeyboardLayoutMode() 动态切换。
     * 切换时组件按实例策略表重新部署,所有状态通过
     * GestureFeedbackState 和 ImeState 保持连续。
     */
    private val _layoutMode = MutableStateFlow<KeyboardLayoutMode>(KeyboardLayoutMode.Stacked)
    val layoutMode: StateFlow<KeyboardLayoutMode> = _layoutMode.asStateFlow()

    fun setKeyboardLayoutMode(mode: KeyboardLayoutMode) {
        _layoutMode.value = mode
    }

    // ─── 手势反馈状态 ────────────────────────────────────────────

    /**
     * 手势反馈状态,独立于 ImeState,使用归一化坐标。
     *
     * 由 GestureInputPanel 的手势事件驱动更新,
     * 供 GestureFeedbackPanel 消费渲染。
     */
    val feedbackState = GestureFeedbackState()

    // ─── 输入动作播放器 ──────────────────────────────────────────

    /**
     * 输入动作播放器,坐标无关。
     *
     * 通过 viewModel.handleIntent() 驱动引擎状态转换,
     * 通过 feedbackState 驱动手势反馈动画。
     */
    val actionPlayer = InputActionPlayer(
        viewModel = this,
        feedbackState = feedbackState,
        positionResolver = ComposeInputActionPositionResolver(
            keyboardLayoutStateProvider = { _currentKeyLayoutState },
            candidateLayoutStateProvider = { _currentCandidateLayoutState },
            inputListLayoutStateProvider = { _currentInputListLayoutState },
        ),
        scope = viewModelScope,
    )

    // ─── 布局状态缓存 ────────────────────────────────────────────

    /**
     * 当前面板布局状态缓存,供 ComposeInputActionPositionResolver 使用。
     *
     * 布局状态由各面板组件的 onLayoutStateChanged 回调更新。
     */
    private var _currentKeyLayoutState: KeyLayoutState? = null
    private var _currentCandidateLayoutState: CandidateListLayoutState? = null
    private var _currentInputListLayoutState: InputListLayoutState? = null

    fun updateKeyLayoutState(state: KeyLayoutState) {
        _currentKeyLayoutState = state
    }

    fun updateCandidateLayoutState(state: CandidateListLayoutState) {
        _currentCandidateLayoutState = state
    }

    fun updateInputListLayoutState(state: InputListLayoutState) {
        _currentInputListLayoutState = state
    }

    // ─── 手势与意图处理 ──────────────────────────────────────────

    /**
     * 处理输入手势。
     *
     * 1. 同步处理交互反馈(按键弹出提示、音效、触觉)
     * 2. 将 InputGesture(UI 面板的输出)转换为 ImeIntent(引擎的输入)
     * 3. 委托引擎执行 reduce
     *
     * 交互反馈在 gestureToIntent() 中同步处理,不经过 engine → SharedFlow → collect
     * 的异步路径,确保反馈与手势同步、低延迟。
     */
    fun handleGesture(gesture: InputGesture) {
        val intent = gestureToIntent(gesture)
        engine.handleIntent(intent)
    }

    /**
     * 处理意图。
     *
     * 直接发送 ImeIntent 到引擎,适用于:
     * - UI 组件的直接操作(如候选选择、光标移动、键盘切换)
     * - InputActionPlayer 的程序化输入
     * - PopupTipState.Action 的操作按钮点击
     * - 配置变更(UpdateConfig)
     */
    fun handleIntent(intent: ImeIntent) {
        engine.handleIntent(intent)
    }

    // ─── 配置修改 ────────────────────────────────────────────────

    /**
     * 更新运行时配置。
     *
     * 委托引擎更新 ImeConfig,UI 通过 ImeState.config 自动同步。
     * 注意:此方法仅修改运行时配置,不做持久化。
     * 持久化由 `:app` 模块的 ConfigDataStore 负责。
     */
    fun updateConfig(transform: (ImeConfig) -> ImeConfig) {
        engine.updateConfig(transform)
    }

    // ─── 生命周期 ────────────────────────────────────────────────

    init {
        // 订阅引擎副作用通道(仅处理 PopupTip 领域事件)
        viewModelScope.launch {
            engine.effect.collect { effect ->
                when (effect) {
                    is ImeEffect.PopupTip.Message -> {
                        _popupTipState.value = PopupTipState.Message(
                            message = effect.message,
                            timeoutMs = effect.timeoutMs,
                        )
                        dismissPopupTipAfter(effect.timeoutMs)
                    }
                    is ImeEffect.PopupTip.Action -> {
                        _popupTipState.value = PopupTipState.Action(
                            message = effect.message,
                            actionLabel = effect.actionLabel,
                            action = effect.action,
                            persistent = effect.persistent,
                            timeoutMs = effect.timeoutMs,
                        )
                        if (!effect.persistent) {
                            dismissPopupTipAfter(effect.timeoutMs)
                        }
                    }
                }
            }
        }

        // 订阅引擎状态变更,动态更新工具列表
        viewModelScope.launch {
            engine.state
                .map { state -> state.keyboard.type }
                .distinctUntilChanged()
                .collect { keyboardType ->
                    _toolListState.value = computeToolList(keyboardType)
                }
        }
    }

    override fun onCleared() {
        super.onCleared()
        actionPlayer.stop()           // 停止播放器动画
        feedbackState.clear()          // 清理手势反馈状态
    }

    // ─── 内部实现 ────────────────────────────────────────────────

    private var popupTipDismissJob: Job? = null

    /**
     * 启动自动 dismiss 定时器。
     *
     * 新的定时器会取消前一个定时器,确保同时只显示一条提示。
     */
    private fun dismissPopupTipAfter(timeoutMs: Long) {
        popupTipDismissJob?.cancel()
        popupTipDismissJob = viewModelScope.launch {
            delay(timeoutMs)
            _popupTipState.value = null
        }
    }

    /**
     * 将手势转换为引擎意图,同步处理交互反馈。
     *
     * 在转换意图的同时,直接同步处理音效、触觉和按键弹出提示。
     * 这些反馈完全由手势类型决定,无需等待引擎的 reduce 结果。
     * 引擎的 ImeEffect 通道仅承载 PopupTip 领域事件。
     */
    private fun gestureToIntent(gesture: InputGesture): ImeIntent {
        // 1. 处理按键弹出提示
        handleKeyPopupTip(gesture)

        // 2. 处理音效和触觉反馈
        when (gesture) {
            is InputGesture.Tap -> {
                playAudio(AudioType.KeyPress)
                playHaptic(HapticType.LightTap)
            }
            is InputGesture.LongPress -> {
                playHaptic(HapticType.HeavyTap)
            }
            is InputGesture.Swipe -> {
                playAudio(AudioType.Slip)
                playHaptic(HapticType.MediumTap)
            }
            is InputGesture.Flip -> {
                playAudio(AudioType.PageFlip)
                playHaptic(HapticType.MediumTap)
            }
            is InputGesture.CandidateTap -> {
                playAudio(AudioType.CandidateSelect)
                playHaptic(HapticType.LightTap)
            }
        }

        // 3. 转换为引擎意图
        return when (gesture) {
            is InputGesture.Tap -> ImeIntent.PressKey(gesture.key, KeyGesture.Tap)
            is InputGesture.LongPress -> ImeIntent.PressKey(gesture.key, KeyGesture.LongPress)
            is InputGesture.Swipe -> ImeIntent.PressKey(gesture.endKey, KeyGesture.Swipe)
            is InputGesture.Flip -> ImeIntent.PressKey(
                gesture.startKey,
                KeyGesture.Flip(gesture.direction),
            )
            is InputGesture.CandidateTap -> ImeIntent.SelectCandidate(
                /* 根据 gesture.candidateIndex 从当前候选列表中获取 */
            )
        }
    }

    // ─── 交互反馈 ────────────────────────────────────────────────

    /**
     * 处理按键弹出提示。
     *
     * 仅 Tap 和 LongPress 手势触发按键弹出提示,受 UiConfig.keyPopupTipsEnabled
     * 和 RuntimeConfig.keyPopupTipsEnabled 双重控制。
     */
    private fun handleKeyPopupTip(gesture: InputGesture) {
        if (gesture !is InputGesture.Tap && gesture !is InputGesture.LongPress) return
        if (!config.ui.keyPopupTipsEnabled) return
        if (config.runtime.keyPopupTipsEnabled == false) return

        _keyPopupTipState.value = KeyPopupTipState(
            key = gesture.key,
            show = true,
        )
    }

    private fun playAudio(type: AudioType) {
        if (config.ui.audioFeedbackEnabled) {
            audioPlayer?.play(type)
        }
    }

    private fun playHaptic(type: HapticType) {
        if (config.ui.hapticFeedbackEnabled) {
            hapticPlayer?.play(type)
        }
    }

    private fun computeToolList(keyboardType: KeyboardType): ToolListState {
        val tools = when (keyboardType) {
            KeyboardType.Pinyin, KeyboardType.Latin -> listOf(
                // 全选、复制、粘贴、剪贴板、撤销、重做
            )
            KeyboardType.Editor -> listOf(
                // 全选、复制、剪切、粘贴、撤销
            )
            KeyboardType.Symbol, KeyboardType.Emoji -> listOf(
                // 全选、复制、粘贴
            )
            KeyboardType.Number, KeyboardType.Math -> listOf(
                // 全选、复制、粘贴
            )
            else -> emptyList()
        }
        return ToolListState(tools)
    }

    // ─── 工厂 ────────────────────────────────────────────────────

    class Factory(
        private val engine: ImeEngine,
        private val audioPlayer: AudioPlayer? = null,
        private val hapticPlayer: HapticPlayer? = null,
    ) : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            return KeyboardViewModel(engine, audioPlayer, hapticPlayer) as T
        }
    }
}

onCleared() 确保 ViewModel 被清除时释放所有资源:停止 InputActionPlayer 的动画帧循环(通过取消其内部协程)、清理 GestureFeedbackState 的临时状态。由于 InputActionPlayer 的 scope 基于 viewModelScope,其协程在 ViewModel 清除时自动取消——onCleared() 中的 stop() 调用确保播放器内部状态标记已清理,而非仅依赖协程取消的隐式行为。

ViewModel 清除 → onCleared() → actionPlayer.stop() → FrameTimer 协程取消 → feedbackState.clear() → 手指指示器/轨迹清除。每一步都是显式调用,不依赖最终化或 GC。


3 PopupTipState 弹出提示状态

PopupTipState 是弹出提示的状态模型,由 KeyboardViewModel 本地维护,对应引擎 ImeEffect.PopupTip 副作用通道中的 MessageAction 两种类型。

kotlin
/**
 * 弹出提示状态,由 KeyboardViewModel 管理。
 *
 * 引擎通过 ImeEffect.PopupTip 发出一次性效果信号,
 * ViewModel 订阅后更新此状态,驱动 PopupTipPanel 显示。
 *
 * 两种类型:
 * - Message:纯文本信息提示,超时后自动消失
 * - Action:可点击提示,含操作按钮,点击后触发 ImeIntent
 */
sealed class PopupTipState {

    /**
     * 纯文本信息提示。
     *
     * 用于展示短暂的反馈信息,如:
     * - 输入字符反馈(如输入了某个拼音字母)
     * - 键盘切换提示(如"已切换到拉丁键盘")
     * - 编辑操作反馈(如"已复制")
     *
     * 超时后自动消失,无需用户操作。
     */
    data class Message(
        /** 提示消息内容 */
        val message: String,
        /** 自动消失超时时间(毫秒),默认 3000ms */
        val timeoutMs: Long = 3000L,
    ) : PopupTipState()

    /**
     * 可点击操作提示。
     *
     * 用于展示带操作按钮的提示信息,如:
     * - 粘贴内容提示(检测到剪贴板内容,点击"粘贴"触发粘贴操作)
     * - 保存收藏提示(输入内容可收藏,点击"收藏"触发保存操作)
     *
     * persistent=false 时超时后自动消失,persistent=true 时
     * 保持显示直到用户开始输入(由 isInputting 变更触发清除)。
     * 点击操作按钮后触发关联的 ImeIntent。
     */
    data class Action(
        /** 提示消息内容 */
        val message: String,
        /** 操作按钮标签 */
        val actionLabel: String,
        /** 点击操作按钮后触发的 ImeIntent */
        val action: ImeIntent,
        /** 是否持久显示(直到用户开始输入) */
        val persistent: Boolean = false,
        /** 自动消失超时时间(毫秒),默认 5000ms;persistent=true 时此参数不生效 */
        val timeoutMs: Long = 5000L,
    ) : PopupTipState()
}

PopupTipState 与引擎 ImeEffect.PopupTip 的类型一一对应。Message 类型用于短暂的反馈信息,展示后按 timeoutMs 自动消失(默认 3000ms),适用于输入字符反馈、键盘切换提示、编辑操作反馈等场景。Action 类型用于带操作按钮的提示信息,用户可以点击操作按钮触发关联的 ImeIntent,适用于粘贴内容提示、保存收藏提示等需要用户主动操作的场景。

Action 类型的 persistent 参数控制提示的显示策略:persistent=false(默认)时,提示在 timeoutMs(默认 5000ms)后自动消失,与 Message 类型的行为一致但超时时间更长;persistent=true 时,提示持续显示直到用户开始输入(isInputting 变为 true),适用于重要的操作提示(如检测到剪贴板内容)不应被忽略的场景。PopupTipPanel 在渲染 Action 类型提示时,显示消息文本和操作按钮,按钮点击回调调用 viewModel.handleIntent(action) 将关联的 ImeIntent 发送到引擎。


4 ToolListState 工具列表

ToolListState 是工具列表的状态模型,由 KeyboardViewModel 本地维护,不属于 ImeState

kotlin
/**
 * 工具列表状态,由 KeyboardViewModel 维护。
 *
 * 工具栏内容根据 keyboard.type 和收藏功能门控(favoriteInputEnabled / favoriteClipEnabled)动态配置。
 * 不属于 ImeState,避免引擎维护 UI 层的展示状态。
 */
data class ToolListState(
    /** 工具项列表 */
    val tools: List<ToolItem>,
)

/**
 * 工具项。
 *
 * 每个工具项对应一个可点击的功能按钮,
 * 点击后发送关联的 ImeIntent。
 */
data class ToolItem(
    /** 按钮标签 */
    val label: String,
    /** 按钮图标 */
    val icon: ImageVector?,
    /** 点击时发送的意图 */
    val intent: ImeIntent,
    /** 是否禁用 */
    val disabled: Boolean = false,
)

KeyboardViewModel 通过 computeToolList(keyboardType: KeyboardType) 方法根据当前键盘类型动态计算工具列表。工具列表的内容随 keyboard.type 和收藏功能门控(EngineConfig.favoriteInputEnabled / EngineConfig.favoriteClipEnabled)变化:拼音和拉丁键盘提供完整的编辑功能键(全选、复制、粘贴、剪贴板、撤销、重做)和键盘切换键;编辑键盘专注于编辑操作(全选、复制、剪切、粘贴、撤销);符号、表情、数字和数学键盘提供基本编辑功能(全选、复制、粘贴)和返回主键盘的切换键。ToolItem.disabled 字段用于在某些状态下禁用特定工具(如输入列表为空时禁用复制),ToolListPanel 渲染禁用工具时降低视觉权重并阻止点击。

toolListState 仅依赖 keyboard.type 而非完整 ImeState。使用 distinctUntilChanged 过滤后,仅在键盘类型切换时才重算工具列表,避免每次按键输入都触发不必要的重算。


5 ImeEffect 订阅与处理

ViewModel 直接通过 engine.effect.collect 订阅 SharedFlow,仅处理 PopupTip 领域事件。音效和触觉反馈已完全移至 gestureToIntent() 中同步处理,不再经由 ImeEffect 通道。

kotlin
// 在 KeyboardViewModel init 中
viewModelScope.launch {
    engine.effect.collect { effect ->
        when (effect) {
            is ImeEffect.PopupTip.Message -> {
                _popupTipState.value = PopupTipState.Message(...)
                dismissPopupTipAfter(effect.timeoutMs)
            }
            is ImeEffect.PopupTip.Action -> {
                _popupTipState.value = PopupTipState.Action(...)
                if (!effect.persistent) {
                    dismissPopupTipAfter(effect.timeoutMs)
                }
            }
        }
    }
}

由于不再需要处理高频的 PlayAudio/PlayHaptic 信号,channelFlow conflate 模式已去除,订阅逻辑简化为直接的 collect 调用。ViewModel 在独立的协程中消费 ImeEffect,不参与 ImeStateStateFlow 订阅链——只有 PopupTipStateMutableStateFlow)被更新后,弹出提示面板才会局部重组。

5.1 PopupTip.Message 处理

当 ViewModel 收到 ImeEffect.PopupTip.Message 时,将 _popupTipState 更新为 PopupTipState.Message,并启动自动 dismiss 定时器(timeoutMs 默认 3000ms)。定时器超时后将 _popupTipState 置为 nullPopupTipPanel 检测到 null 后隐藏提示。新的 PopupTip 效果到来时,取消前一个定时器并启动新的定时器,确保同时只显示一条提示。

Message 类型用于短暂的反馈信息,如输入字符反馈(引擎在 reduce 中检测到按键输入后发出)、键盘切换提示(引擎在 SwitchKeyboard reduce 中发出"已切换到拉丁键盘")、编辑操作反馈(引擎在 ClipboardPaste reduce 中发出"已粘贴")。这些提示不需要用户操作,仅作为输入反馈短暂显示后自动消失。

5.2 PopupTip.Action 处理

当 ViewModel 收到 ImeEffect.PopupTip.Action 时,将 _popupTipState 更新为 PopupTipState.Action,保存 messageactionLabelaction: ImeIntentpersistent 参数。persistent=false 时启动自动 dismiss 定时器(timeoutMs 默认 5000ms),persistent=true 时不启动定时器,提示保持显示直到用户开始输入。

PopupTipPanel 在渲染 Action 类型提示时,显示消息文本和操作按钮。操作按钮的点击回调调用 viewModel.handleIntent(action),将关联的 ImeIntent 发送到引擎。例如,引擎检测到剪贴板有新内容时,发出 ImeEffect.PopupTip.Action(message="检测到剪贴板内容", actionLabel="粘贴", action=ImeIntent.PasteClipboard, persistent=true)PopupTipPanel 显示提示和"粘贴"按钮,用户点击按钮后触发 ImeIntent.PasteClipboard,引擎在 reduce 中执行粘贴操作并输出结果到编辑器。

5.3 收藏确认处理

收藏确认通过 PopupTip.Action 实现,而非独立的 ImeEffect 类型。当 ImeIntent.CommitInput 处理完成后,引擎检查已提交文本是否已在收藏列表中。若未收藏,引擎发射 ImeEffect.PopupTip.Action(message="可收藏内容", actionLabel="收藏", action=ImeIntent.SaveFavorite(InputFavorite(text=committedText)), persistent=false),ViewModel 收到后显示带「收藏」按钮的提示条,用户点击即可保存。已收藏的内容不触发任何提示。这种设计将收藏确认统一到 PopupTip.Action 体系中,无需额外的 ImeEffect 子类型。


6 GestureFeedbackState 生产者-消费者

GestureFeedbackState 采用生产者-消费者模式,多个组件写入(生产)反馈数据,GestureFeedbackPanel 读取(消费)并渲染。所有坐标数据使用归一化形式存储,确保同一份数据可以在不同尺寸的面板实例上正确渲染。

状态字段生产者消费者坐标类型
touchTrailPointsGestureInputPanel(用户手势,实时积累触摸点)、InputActionPlayer(程序化播放,含 InputActionPathInterpolator 插值路径)GestureFeedbackPanel(反归一化后绘制轨迹)归一化 [0,1]x[0,1]
pressedKeysGestureInputPanel(手势过程中更新)、InputActionPlayer(播放时更新)GestureFeedbackPanel(通过 KeyLayoutState 映射按键位置后绘制高亮)语义标识,非坐标
fingerIndicatorInputActionPlayer(播放时驱动)GestureFeedbackPanel(反归一化后绘制手指指示器)、CandidateListPanel/InputListPanel/ToolListPanel(内建指示器)归一化 [0,1]x[0,1]

touchTrailPoints 有两种写入来源:GestureInputPanel 在用户手势过程中将触摸事件坐标归一化后逐点追加(addTouchTrailPoint()),InputActionPlayer 在播放滑行动作时通过 InputActionPathInterpolator 生成完整插值路径后一次性写入(setTouchTrailPoints())。pressedKeysGestureInputPanelInputActionPlayer 共同写入,在手势或动作开始时设置按下的按键集合,结束时清除。fingerIndicator 仅由 InputActionPlayer 写入,用于在程序化播放期间显示虚拟手指的位置和状态,GestureFeedbackPanel 订阅后在 Row 3 区域绘制,CandidateListPanel 等面板通过各自的 indicatorState 参数在内部绘制。

GestureFeedbackStateImeState 的分离确保了反馈状态的高频更新不会触发 ImeState 的变更,避免不必要的按键面板重组。反馈状态的更新频率可达每帧(16ms@60fps),而 ImeState 的更新频率仅在用户操作或引擎 reduce 时触发,两者解耦后互不影响。


7 KeyboardHost 集成组件

KeyboardHost 是顶层集成组件,通过 viewModel.layoutMode 统一两种布局模式的入口。KeyboardHost 订阅 viewModel.stateviewModel.layoutModeviewModel.feedbackStateviewModel.popupTipStateviewModel.toolListState,根据布局模式选择 StackedLayoutSeparatedLayout

kotlin
/**
 * 键盘宿主组件,顶层集成组件。
 *
 * 通过 KeyboardLayoutMode 参数统一两种布局模式的入口。
 * 支持运行时动态切换 KeyboardLayoutMode,切换时按实例策略表重新部署组件。
 */
@Composable
fun KeyboardHost(
    viewModel: KeyboardViewModel,
    modifier: Modifier = Modifier,
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val layoutMode by viewModel.layoutMode.collectAsState()
    val feedbackState = viewModel.feedbackState
    val popupTipState by viewModel.popupTipState.collectAsState()
    val toolListState by viewModel.toolListState.collectAsState()
    var keyLayoutState by remember { mutableStateOf(KeyLayoutState()) }

    KeyboardTheme(type = state.config.ui.keyboardThemeType) {
        when (layoutMode) {
            is KeyboardLayoutMode.Stacked -> StackedLayout(
                viewModel, state, feedbackState,
                keyLayoutState, popupTipState, toolListState,
            )
            is KeyboardLayoutMode.Separated -> SeparatedLayout(
                viewModel, state, feedbackState,
                keyLayoutState, popupTipState, toolListState,
                layoutMode as KeyboardLayoutMode.Separated,
            )
        }
    }
}

StackedLayout 中,所有组件集中在 Zone B,三层面板叠加共享 Row 3 空间。KeyLayoutPanel 部署在 Zone B Row 3,GestureFeedbackPanelGestureInputPanel 叠加在其上方。Row 1 承载 CandidateListPanelPopupTipPanel 的叠加,PopupTipPanel 根据 popupTipState 显示 MessageAction 类型的提示。Row 2 根据 ViewModel 的 isInputting 派生状态互斥切换 ToolListPaneltoolListState)和 InputListPanel

SeparatedLayout 中,Zone A 承载 KeyLayoutPanelGestureFeedbackPanel 的叠加,Zone B 包含三行结构。Row 3 被进一步划分为三列:左列和右列放置功能按钮,中列承载 GestureFeedbackPanelGestureInputPanel 的叠加。

PopupTipPanel 的渲染逻辑:当 popupTipStatePopupTipState.Message 时,显示文本信息并在超时后自动隐藏;当 popupTipStatePopupTipState.Action 时,显示文本信息和操作按钮(actionLabel),按钮点击回调调用 viewModel.handleIntent(action)persistent=trueAction 提示保持显示直到 isInputting 变为 truePopupTipPanel 叠加在 CandidateListPanel 之上,提示可见时候选列表不响应交互。

各面板的用户交互均通过 viewModel.handleGesture()viewModel.handleIntent() 分发意图。KeyLayoutPanelonLayoutStateChanged 回调更新 keyLayoutState 局部变量,同时调用 viewModel.updateKeyLayoutState() 更新 ViewModel 的布局状态缓存,供 InputActionPlayer 的坐标解析器使用。


8 IMEService 装配流程

:app 模块的 IMEService 负责创建引擎、挂载桥梁、注入 ViewModel,是 ViewModel 与平台层交互的唯一入口。

kotlin
class IMEService : InputMethodService() {
    private var engine: ImeEngine? = null
    private var bridge: InputConnectionBridge? = null
    private var audioPlayer: AndroidAudioPlayer? = null
    private var hapticPlayer: AndroidHapticPlayer? = null
    private var composeView: ComposeView? = null

    override fun onCreate() {
        super.onCreate()
        // 创建引擎(需要 Android Context 和字典提供者)
        engine = ImeEngine.create(
            config = ImeConfig(),
            dictProvider = ImeSqliteDictProvider(this),
        )
        // 创建并挂载编辑器桥梁(与 ViewModel 无关)
        bridge = InputConnectionBridge { currentInputConnection }
        engine?.attachEditorBridge(bridge!!)
        // 创建感官反馈播放器
        audioPlayer = AndroidAudioPlayer(this)
        hapticPlayer = AndroidHapticPlayer(this)
    }

    override fun onCreateInputView(): View {
        val engine = this.engine ?: error("Engine not initialized")
        val audio = this.audioPlayer
        val haptic = this.hapticPlayer
        return ComposeView(this).also { composeView = it }.apply {
            setViewCompositionStrategy(
                ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
            )
            setContent {
                val viewModel: KeyboardViewModel = viewModel(
                    factory = KeyboardViewModel.Factory(engine, audio, haptic)
                )
                // KeyboardHost 已包含候选栏 + 输入栏 + 工具列表 + 三层面板叠加 + 弹出提示
                KeyboardHost(viewModel = viewModel)
            }
        }
    }

    override fun onStartInputView(editorInfo: EditorInfo?, restarting: Boolean) {
        super.onStartInputView(editorInfo, restarting)
        engine?.start(StartupConfig(
            imeSubtype = IMESubtype.from(this),
            screenOrientation = ScreenOrientation.from(this),
            editorInputType = EditorInputType.from(editorInfo),
        ))
    }

    override fun onCurrentInputMethodSubtypeChanged(subtype: InputMethodSubtype?) {
        super.onCurrentInputMethodSubtypeChanged(subtype)
        engine?.start(StartupConfig(
            imeSubtype = IMESubtype.from(this),
            screenOrientation = ScreenOrientation.from(this),
            editorInputType = null,
        ))
    }

    override fun onFinishInputView(finishingInput: Boolean) {
        super.onFinishInputView(finishingInput)
        engine?.close()
    }

    override fun onDestroy() {
        audioPlayer?.release()
        audioPlayer = null
        hapticPlayer = null
        engine?.detachEditorBridge(bridge!!)
        engine?.destroy()
        engine = null
        bridge = null
        composeView?.disposeComposition()
        composeView = null
        super.onDestroy()
    }
}

装配流程的关键步骤:

  1. 引擎创建IMEService.onCreate() 中调用 ImeEngine.create() 创建引擎实例,传入 ImeConfigImeSqliteDictProvider。引擎创建是应用层职责,因为 ImeDictProvider 需要 Android Context 访问 SQLite 数据库。

  2. 桥梁挂载IMEService.onCreate() 中创建 InputConnectionBridge,传入 currentInputConnection 的提供者,然后通过 engine.attachEditorBridge() 挂载。桥梁的挂载与 ViewModel 无关——桥梁直接与引擎交互,将 EditorAction 分发到 InputConnection

  3. 播放器创建IMEService.onCreate() 中创建 AndroidAudioPlayerAndroidHapticPlayer,传入 Context 用于加载音频资源和获取 Vibrator 服务。播放器的生命周期与 IMEService 相同,不随 InputConnection 变更而重建。

  4. ViewModel 注入IMEService.onCreateInputView() 中通过 KeyboardViewModel.Factory(engine, audio, haptic) 创建 ViewModel 工厂,将预创建的引擎和播放器注入 ViewModel。ViewModel 通过引擎的公开 API(handleIntent()stateeffectupdateConfig())与引擎交互,通过播放器接口与平台层交互,不感知桥梁和平台生命周期。

  5. Compose 渲染setContent {} 中使用 KeyboardHost(viewModel = viewModel) 作为根组件,KeyboardHost 订阅 ViewModel 的所有状态,驱动 Compose 渲染完整的输入法界面。

  6. 启动输入IMEService.onStartInputView() 中调用 engine.start(startupConfig) 启动输入法,传入从 EditorInfo 解析的 StartupConfigonCurrentInputMethodSubtypeChanged() 中同样调用 engine.start(),但 editorInputTypenull 以确保不覆盖已识别的编辑器类型。

  7. 关闭与销毁IMEService.onFinishInputView() 中调用 engine.close() 关闭输入法(仅隐藏面板,状态保持不变)。IMEService.onDestroy() 中的清理遵循严格的顺序:先释放播放器资源,再断开桥梁,最后销毁引擎。detachEditorBridge(bridge) 必须在 destroy() 前显式调用,而非依赖 destroy() 自动注销,这是基于桥梁所有权原则——桥梁由 IMEService 创建和拥有,attach/detach 是宿主模块的对称操作,引擎仅是桥梁的消费者。destroy() 内部对 _editorBridges 仅执行防御性 clear(),不调用 detachEditorBridge() 的注销逻辑。ViewModel 的 onCleared() 仅清理自身资源(actionPlayer.stop() + feedbackState.clear()),不负责销毁引擎和播放器——引擎和播放器的生命周期由 :app 管理,比 ViewModel 更长(引擎和播放器在 onCreate() 中创建,ViewModel 在 onCreateInputView() 中创建)。