Skip to content

Compose UI 迁移设计

v4 版本将键盘 UI 迁移到 Jetpack Compose,利用其声明式范式简化 UI 代码,同时利用 Compose 1.8 的新特性(AutoSize、智能省略号、触觉反馈等)提升 IME 的用户体验。

键盘区域的三层面板架构(GestureInputPanel / GestureFeedbackPanel / KeyGridPanel)详见020-三层面板分离。本节仅描述 Compose 组件架构和迁移设计,不重复三层面板的设计细节。


1. Compose 组件架构

1.1 KeyboardPanel(叠加模式)

KeyboardViewModel 的完整设计见 060-KeyboardViewModel。以下仅展示集成组件与 ViewModel 的交互方式。

kotlin
@Composable
fun KeyboardPanel(viewModel: KeyboardViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val feedbackState = viewModel.feedbackState
    var keyPanelLayout by remember { mutableStateOf(KeyGridPanelLayoutInfo()) }

    KeyboardTheme(themeType = state.config.ui.themeType) {
        Column(modifier = Modifier) {
            // 候选栏
            CandidateListPanel(
                state = state.candidateList,
                onCandidateSelected = { candidate ->
                    viewModel.handleIntent(ImeIntent.SelectCandidate(candidate))
                },
            )

            // 输入栏
            InputListPanel(
                state = state.inputList,
                onGapTapped = { index ->
                    viewModel.handleIntent(ImeIntent.MoveCursorTo(index))
                },
            )

            // 三层面板叠加区域
            Box {
                // 底层:按键面板
                KeyGridPanel(
                    keyboardType = state.keyboardType,
                    keyGrid = state.keyGrid,
                    keyboardState = state.keyboardState,
                    onLayoutInfoChanged = { keyPanelLayout = it },
                )

                // 中层:反馈面板
                GestureFeedbackPanel(
                    elements = GestureFeedbackPanelSet.OverlaySet.allElements,
                    feedbackState = feedbackState,
                    keyPanelLayout = keyPanelLayout,
                )

                // 顶层:输入面板
                GestureInputPanel(
                    keyPanelLayout = keyPanelLayout,
                    keyboardType = state.keyboardType,
                    feedbackState = feedbackState,
                    onGesture = { viewModel.handleGesture(it) },
                )
            }

            // 工具栏
            Toolbar(
                keyboardType = state.keyboardType,
                config = state.config,
                onSwitchKeyboard = { viewModel.handleIntent(ImeIntent.SwitchKeyboard(it)) },
            )
        }
    }
}

注意KeyboardPanel 是 UI 库的叠加模式完整输入法组件,包含候选栏、输入栏、工具栏和三层面板叠加区域(GestureInputPanel / GestureFeedbackPanel / KeyGridPanel 直接叠加,无中间包装层)。KeyboardScreen 是全屏模式完整输入法组件。两者均为完整输入法组件,只是形式和交互不同。

1.2 ComposeView 桥接

:app 模块的 IMEService 负责创建引擎、挂载桥梁、注入 ViewModel。完整设计见 060-KeyboardViewModel §4。

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

    override fun onCreate() {
        super.onCreate()
        // 创建引擎
        engine = ImeEngine.create(
            config = ImeConfig(),
            dictProvider = ImeSqliteDictProvider(this),
        )
        // 创建并挂载输出桥梁(与 ViewModel 无关)
        bridge = InputConnectionBridge { currentInputConnection }
        engine?.attachOutputBridge(bridge!!)
    }

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

    override fun onDestroy() {
        // 断开桥梁并销毁引擎
        engine?.detachOutputBridge()
        engine = null
        bridge = null
        composeView?.disposeComposition()
        composeView = null
        super.onDestroy()
    }
}

1.3 键盘视图

键盘视图在 v4 中拆分为三层面板(详见020-三层面板分离):KeyGridPanel(纯展示,不处理触摸)、GestureInputPanel(透明手势层)、GestureFeedbackPanel(透明反馈绘制层)。KeyGridPanel 中的 KeyView 不处理触摸事件,触摸由 GestureInputPanel 统一拦截。以下仅展示 KeyGridPanel 中的按键渲染逻辑。

kotlin
// KeyGridPanel:纯展示层,不处理触摸事件
@Composable
fun KeyGridPanel(
    keyboardType: KeyboardType,
    keyGrid: List<List<InputKey>>,
    keyboardState: KeyboardState,
    onLayoutInfoChanged: (KeyGridPanelLayoutInfo) -> Unit,
    modifier: Modifier = Modifier,
) {
    when (keyboardType) {
        KeyboardType.Pinyin, KeyboardType.Latin, KeyboardType.Number,
        KeyboardType.Symbol, KeyboardType.Editor, KeyboardType.Math,
        -> StandardKeyGridPanel(keyGrid, keyboardState, onLayoutInfoChanged, modifier)

        KeyboardType.Emoji -> EmojiKeyGridPanel(keyGrid, keyboardState, onLayoutInfoChanged, modifier)
        KeyboardType.Candidate -> CandidateKeyGridPanel(keyGrid, onLayoutInfoChanged, modifier)
        KeyboardType.CommitOption -> CommitOptionKeyGridPanel(keyGrid, onLayoutInfoChanged, modifier)
    }
}

三层面板直接在 KeyboardPanel 中叠加(无中间包装层),KeyboardPanel 同时包含候选栏、输入栏和工具栏,构成完整的输入法组件。

1.4 按键视图

KeyView 在 v4 中是纯展示组件,不处理触摸事件,也不绘制手势反馈。触摸由 GestureInputPanel 统一拦截,手势反馈由 GestureFeedbackPanel 绘制。KeyView 仅渲染按键的常规状态(标签、背景、激活 / 禁用等持续性状态)。

kotlin
/**
 * 按键视图(纯展示,无触摸处理,无手势反馈)。
 *
 * 按键的"按下"视觉状态由 keyboardState 驱动(持续性状态),
 * 手势触发的临时高亮由 GestureFeedbackPanel 绘制。
 */
@Composable
fun KeyView(
    key: InputKey,
    isActive: Boolean,
    modifier: Modifier = Modifier,
) {
    Box(
        modifier = modifier
            .height(48.dp)
            .clip(RoundedCornerShape(4.dp))
            .background(if (isActive) activeKeyColor else keyBackgroundColor),
        contentAlignment = Alignment.Center,
    ) {
        when (key) {
            is InputKey.Char -> CharKeyContent(key)
            is InputKey.Ctrl -> CtrlKeyContent(key)
            is InputKey.Candidate -> CandidateKeyContent(key)
            is InputKey.MathOp -> MathOpKeyContent(key)
            is InputKey.Symbol -> SymbolKeyContent(key)
            is InputKey.XPad -> XPadKeyContent(key)
            is InputKey.Null -> { /* 空占位 */ }
        }
    }
}

@Composable
fun CharKeyContent(key: InputKey.Char) {
    BasicText(
        text = key.label,
        maxLines = 1,
        autoSize = TextAutoSize.StepBased(
            minFontSize = 10.sp,
            maxFontSize = 18.sp,
            stepSize = 1.sp,
        ),
        style = TextStyle(color = keyForegroundColor),
    )
}

1.5 候选项栏

kotlin
@Composable
fun CandidateListPanel(
    state: CandidateList,
    onCandidateSelected: (InputWord) -> Unit,
) {
    if (state.candidateList.isEmpty()) return

    LazyRow(
        modifier = Modifier
            .fillMaxWidth()
            .height(40.dp)
            .background(candidatePanelBackgroundColor),
        horizontalArrangement = Arrangement.spacedBy(4.dp),
        contentPadding = PaddingValues(horizontal = 8.dp),
    ) {
        items(items = state.candidateList.candidates, key = { it.id }) { candidate ->
            CandidateItem(
                candidate = candidate,
                onClick = { onCandidateSelected(candidate) },
            )
        }
    }
}

@Composable
fun CandidateItem(candidate: InputWord, onClick: () -> Unit) {
    Box(
        modifier = Modifier
            .wrapContentWidth()
            .fillMaxHeight()
            .clip(RoundedCornerShape(4.dp))
            .background(candidateChipBackgroundColor)
            .clickable(onClick = onClick)
            .padding(horizontal = 8.dp),
        contentAlignment = Alignment.Center,
    ) {
        BasicText(
            text = candidate.text,
            maxLines = 1,
            autoSize = TextAutoSize.StepBased(
                minFontSize = 12.sp,
                maxFontSize = 16.sp,
                stepSize = 1.sp,
            ),
            overflow = TextOverflow.Ellipsis,
            style = TextStyle(color = candidateForegroundColor),
        )
    }
}

1.6 输入栏

kotlin
@Composable
fun InputListPanel(
    state: InputList,
    onGapTapped: (Int) -> Unit,
) {
    LazyRow(
        modifier = Modifier
            .fillMaxWidth()
            .height(36.dp)
            .background(inputListPanelBackgroundColor),
        contentPadding = PaddingValues(horizontal = 4.dp),
    ) {
        itemsIndexed(state.inputs, key = { _, it -> it.id }) { index, item ->
            when (item) {
                is InputItem.Char -> CharInputItem(item)
                is InputItem.Gap -> GapInputItem(
                    isCursor = index == state.gapIndex,
                    onTap = { onGapTapped(index) },
                )
                is InputItem.MathExpr -> MathExprInputItem(item)
            }
        }
    }
}

2. X-Pad Compose 迁移

2.1 Canvas 绘制

kotlin
@Composable
fun XPadView(
    zones: List<XPadZone>,
    currentSpell: String,
    onZoneSelected: (XPadZone) -> Unit,
    modifier: Modifier = Modifier,
) {
    var center by remember { mutableOffsetOf(Offset.Zero) }

    Box(modifier = modifier.fillMaxSize()) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            // 绘制六边形网格
            zones.forEach { zone ->
                drawHexagon(zone, center)
                drawZoneLabel(zone, center)
            }
        }

        // 当前输入的拼音显示在中心
        Text(
            text = currentSpell,
            modifier = Modifier.align(Alignment.Center),
        )
    }
}

private fun DrawScope.drawHexagon(zone: XPadZone, center: Offset) {
    val path = Path().apply {
        val hexCenter = calculateHexCenter(zone.index, center)
        val vertices = calculateHexVertices(hexCenter, zone.radius)
        moveTo(vertices[0])
        for (i in 1..5) lineTo(vertices[i])
        close()
    }
    drawPath(path, color = zoneColor, style = Stroke(width = 2f))
}

2.2 手势交互

kotlin
Modifier.pointerInput(zones) {
    awaitEachGesture {
        val down = awaitFirstDown()
        val startZone = findZoneAt(down.position, zones)

        val path = mutableListOf<Offset>(down.position)
        var currentZone = startZone

        do {
            val event = awaitPointerEvent()
            val position = event.changes.first().position
            val zone = findZoneAt(position, zones)

            if (zone != currentZone) {
                // 进入新区域
                currentZone = zone
                onZoneSelected(zone)
            }
            path.add(position)
        } while (event.changes.any { it.pressed })

        // 手势结束
        haptics.performHapticFeedback(HapticFeedbackType.GestureEnd)
    }
}

3. 滑行输入手势处理

v4 的滑行手势检测统一由 GestureInputPanel 处理(详见020-三层面板分离 §3),手势轨迹绘制由 GestureFeedbackPanel 处理(详见020-三层面板分离 §4)。以下仅列出 Compose 手势 API 的基本用法参考。

3.1 Compose 手势 API 参考

kotlin
// GestureInputPanel 中的手势检测核心逻辑
Modifier.pointerInput(keyPanelLayout, keyboardType) {
    awaitEachGesture {
        val down = awaitFirstDown(requireUnconsumed = false)
        // 根据 keyPanelLayout 查找触摸位置对应的按键
        // 识别手势类型(点击/长按/滑行/翻转)
        // 输出 InputGesture → ViewModel
        // 同步更新 GestureFeedbackState → GestureFeedbackPanel
    }
}

3.2 Compose Canvas 绘制参考

kotlin
// GestureFeedbackPanel 中的轨迹绘制
Canvas(modifier = modifier.fillMaxSize()) {
    if (touchTrailPoints.size >= 2) {
        val path = Path().apply {
            moveTo(touchTrailPoints.first())
            for (i in 1 until touchTrailPoints.size) {
                quadraticBezierTo(
                    touchTrailPoints[i - 1],
                    midpoint(touchTrailPoints[i - 1], touchTrailPoints[i]),
                )
            }
        }
        drawPath(path, color = gestureTrailColor, style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round))
    }
}

4. 性能验证计划

4.1 关键指标

指标目标测量方式
按键响应延迟< 16ms(60fps)Systrace / Perfetto
键盘切换帧率稳定 60fpsFrameMetrics
内存占用不超过 Java 版本 1.2 倍Android Profiler
滑行输入延迟< 8ms手势事件时间戳
候选列表滚动无掉帧FrameMetrics

4.2 降级方案

如果 Compose 在 IME 环境中无法满足性能要求:

  1. 方案 A:键盘区域使用传统 View,其余 UI 使用 Compose
  2. 方案 B:使用 Compose 但关闭动画和部分特效
  3. 方案 C:完全回退到传统 View(最后手段)