!
本文整理自开发中与 Gemini 3.8 Flash 的对话,并完全由其生成(仅调整了开头的风格)。
在即时通讯(IM)应用的聊天页面中,输入框区域(Composer)在**软键盘(IME)与扩展面板(表情、附件等)**之间的平滑切换,是必备要求。
最近在为 imlog 项目实现该组件时,遇到了不少状态同步与动画交接的坑,甚至排查出了 Android 系统底层在特定打断时机下抛出错误 Inset 的行为。这里把完整的设计思路、实现方案与排坑细节完整记录下来。
一、 为什么不用系统的 Modifier.imePadding()?
很多初涉此场景的开发者第一反应是在输入框外层直接套用 Modifier.imePadding()。但在做“键盘 ⇋ 扩展面板”双向切换时,这行代码是闪烁与塌陷的根源:
- 生命周期割裂:
imePadding() 是单向跟随系统 IME Insets 的。当你从键盘切换到附件面板时,键盘开始下落,系统把 IME Inset 从约 300dp 缩小到 0dp;而你的面板从 0dp 升起。两个动画即便插值曲线一致,由于跨进程 IPC 时序差,底部容器在视觉上也是先缩回去、再弹出来,产生严重的断层感。 - 缺乏主权仲裁:底部空间到底由谁占有?是键盘、面板还是导航栏?
imePadding() 无法感知你的自定义面板存在。
核心解法:使用单一动态高度的占位 Box 实行“高度仲裁”
解决该问题的关键核心是:放弃 imePadding(),在输入条下方放置一个独立的、高度动态测量的占位 Box。
1
2
3
4
5
6
7
8
9
10
| ┌────────────────────────────────────────────────────────┐
│ 输入条 (Composer) │
├────────────────────────────────────────────────────────┤
│ 自适应高度占位容器 (Arbitration Box) │
│ │
│ finalHeight = maxOf(NavBar, SafeIme, PanelAnimation) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ AttachmentPanel (内容固定在 cachedHeight) │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
|
这个容器的最终高度遵循取最大值(Max-of)仲裁法则:
$$\text{finalContainerHeight} = \max(\text{NavigationBarPx}, \text{CurrentImePx}, \text{PanelAnimationPx})$$
- 键盘切面板:面板高度立刻置为当前键盘历史高度,键盘自己滑落,而底座总高度始终维持在键盘高度,键盘滑落的瞬间直接露出已就位的面板内容,形成丝滑交接。
- 面板切键盘:面板保持高度不变,等待键盘升起;当键盘高度冲破面板高度时无缝承接,待键盘顶满后再将面板高度清零。
二、 核心架构设计与优化策略
完整方案围绕以下三大支柱展开:
1. 性能优化:在 Layout 阶段消费 Insets,规避 120Hz 重组风暴
软键盘升起和降落是一个高频连续动画(在 120Hz 屏幕上每秒触发 120 次数值变动)。如果在 Composable 根节点直接读取 WindowInsets.ime.getBottom(density) 并赋值给 Modifier.height(dp),会导致整棵 UI 树在 300ms 内疯狂重组上百次,引发掉帧。
我们选择通过 Modifier.layout 在测量与布局阶段直接消费 Inset 像素:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| Box(
modifier = Modifier
.fillMaxWidth()
.layout { measurable, constraints ->
// 在 Layout 阶段直接按 Px 计算,每帧数值变动完全不触发 Recomposition
val navigationBarPx = navigationBars.getBottom(this)
val currentImePx = imeInsets.getBottom(this)
val safeCurrentImePx = imeJitterGuard.sanitizeImeHeight(currentImePx, currentCachedHeightPx)
val currentPanelPx = panelHeightAnimation.value.toInt()
// 高度仲裁:三者取最大,抹平所有落差
val finalContainerHeight = maxOf(navigationBarPx, safeCurrentImePx, currentPanelPx)
val placeable = measurable.measure(
constraints.copy(
minHeight = finalContainerHeight,
maxHeight = finalContainerHeight,
)
)
layout(placeable.width, finalContainerHeight) {
placeable.place(0, 0)
}
}
)
|
内部的 AttachmentPanel 始终固定为测量出的历史键盘标准高度(currentCachedHeightPx),依靠外层 Box 的 Layout 边界进行裁剪,防止内部组件在高度升降时被不断重新压缩、重新测绘。
2. 状态机驱动:IME 与 Panel 的转场决策表
面板和键盘的状态转场不是简单的布尔开关,我们整理了如下决策表:
| 当前态 | 触发操作 | 目标态 | 转场动作 | 关键技巧 |
|---|
| 无 (None) | 点附件按钮 | Panel | 平滑升起动画 (animateTo) | 常规弹簧动画向上展开 |
| 键盘 (IME) | 点附件按钮 | Panel | 瞬时就位 (snapTo) | 面板直接满高,隐藏 IME,让 IME 滑落露出面板 |
| Panel | 点输入框 | IME | 延时交接 (Wait & Snap) | 面板不动作,等 IME 顶满后再瞬间收回 (snapTo(0)) |
| Panel | 点击空白处 | None | 平滑降落动画 (animateTo(0)) | 空间平滑归还聊天列表 |
针对系统 IPC 延迟的时序对齐
当用户从面板切换回输入框时,系统向输入法发起请求到 WindowInsets.imeAnimationTarget 更新存在约 140ms~200ms 的 IPC 通信延迟。
如果仅监听 IME 状态,程序会误以为“键盘没有升起”,直接走关闭面板动画,导致底部瞬间塌陷。
解法:输入框的焦点变化(isTextFieldFocused)是比 IME Inset 更及时的意图信号。利用 Focus 状态开启一个 250ms 的黄金等待窗口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| val shouldKeepHeight = isTextFieldFocused
if (shouldKeepHeight) {
// 黄金窗口 200~250ms:覆盖系统的 IPC 延迟,等待键盘升起动画的 Target 出现
val targetHeight = withTimeoutOrNull(250.milliseconds) {
snapshotFlow { imeAnimationTarget.getBottom(density) }.first { it > 0 }
}
if (targetHeight != null && targetHeight > 0) {
// 等键盘真正升到顶后,面板再瞬间隐去,完成无缝交接
snapshotFlow {
val current = imeInsets.getBottom(density)
val target = imeAnimationTarget.getBottom(density)
current to target
}.first { (current, target) -> target == 0 || current >= target }
panelHeightAnimation.snapTo(0f)
} else {
// 超时(例如物理键盘/未调起软键盘),退场收起
panelHeightAnimation.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow))
}
}
|
三、 突破深水区:Android 系统级的 IME 打断竞态(Jitter)
当我们把上述框架搭建完成并进入极限压力测试时,出现了一个隐蔽的系统级 Bug:
1. 现象复现
- 点击附件按钮切换到面板(键盘开始下落,触发
clearFocus()); - 在软键盘尚未完全滑落归零的一瞬间,快速再次点击输入框;
- 键盘升起,但整个底部输入框剧烈往上顶起约 30dp,随后啪的一声猛跌回落。
2. 日志抓取与破案
排查系统日志后,抓到了这一串关键时序:
1
2
3
4
5
6
7
| 13:01:08.416 InsetsController: show(ime())
13:01:08.417 WindowInsets changed: ime:[0,0,0,955] <-- 正常是 872px,这里突然冒出 955px
13:01:08.442 ime px = 41, final = 872
...
13:01:08.727 ime px = 954, final = 954 <-- 容器跟随系统冲上了 954px
13:01:08.743 ImeTracker: onShown <-- 输入法进程完成排版上报
13:01:08.744 WindowInsets changed: ime:[0,0,0,872] <-- 瞬间掉回 872px(暴跌 82px!)
|
3. 系统底层原因深挖
从数据看:测试设备的密度为 2.875(872px 对应 303.3dp)。
$955\text{px} - 872\text{px} = 83\text{px} \approx \mathbf{28.87\text{dp}}$。
在 Android 系统源码中,输入法窗口有两个维度的尺寸概念:
- Content / Visible Insets(872px):输入法(如 Gboard)排版完成后,通过
onComputeInsets() 上报的有效内容交互区域; - Window Frame Bounds(955px):输入法窗口(
TYPE_INPUT_METHOD)在系统 WMS 登记的完整物理边界。多出来的 83px(约 28dp~32dp)是输入法顶部的投影阴影区(Elevation Shadow)或工具条预留区。
为什么会出现竞态?
- 切换到面板时调用了
clearFocus(),App 与 IME 的 InputConnection 被主动销毁; - 在滑落未完成时快速点回输入框,系统需要重新经历
startInput; - Android 系统为了响应速度,不等输入法进程重新计算完毕,直接抓取了残留的物理 Window Frame(955px)作为动画的初始 Target 强行起播;
- 动画跑到尾声(
onShown)时,输入法才将真实内容高度(872px)异步上报给系统,系统随即派发修正通知,造成高度在最后一帧发生断崖式跌落。
四、 优雅降级:设计 ImeJitterGuard
既然这是系统在特定竞态下的过渡行为,解决方案就是在应用层拦截这种脏状态:
- 记录失焦时刻:记录输入框每次失焦的时间点;
- 设定时间窗:如果输入框重新获焦的时间距离上次失焦非常近(处于竞态高危期),且当前已有合法的历史缓存高度,那么:
- 禁止写入缓存:不把动画期间的临时 Target(955)当成有效高度存起来;
- 高度防冒顶截断:Layout 阶段将系统送来的
imeInsets 截断在历史缓存高度内(minOf(currentImePx, cachedHeightPx))。
这里必须使用 Kotlin 标准库的单调时钟 TimeSource.Monotonic,避免使用系统墙上时钟(如 Instant 或 System.currentTimeMillis()),防止夏令时或网络对时(NTP)导致时间回拨算错。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| import androidx.compose.runtime.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
const val RACE_CONDITION_DEBOUNCE_WINDOW_SECONDS = 1.5
/**
* 输入法打断竞态保护器:
* 解决软键盘在未完全收齐前被快速打断唤起时,系统临时使用 Window Frame 导致的高度突跳及脏缓存问题。
*/
@Stable
internal class ImeJitterGuard(
private val debounceWindow: Duration = RACE_CONDITION_DEBOUNCE_WINDOW_SECONDS.seconds,
private val timeSource: TimeSource = TimeSource.Monotonic,
) {
private var lastUnfocusMark by mutableStateOf<TimeMark?>(null)
fun onFocusChanged(focused: Boolean) {
if (!focused) {
lastUnfocusMark = timeSource.markNow()
}
}
val isInRaceConditionWindow: Boolean
get() {
val mark = lastUnfocusMark ?: return false
return mark.elapsedNow() < debounceWindow
}
/** 核心防御 1:Layout 防冒顶 */
fun sanitizeImeHeight(currentImePx: Int, cachedHeightPx: Int): Int {
return if (isInRaceConditionWindow && cachedHeightPx > 0) {
minOf(currentImePx, cachedHeightPx)
} else {
currentImePx
}
}
/** 核心防御 2:禁止脏缓存写入 */
fun canUpdateCache(): Boolean = !isInRaceConditionWindow
}
@Composable
internal fun rememberImeJitterGuard(
debounceWindow: Duration = RACE_CONDITION_DEBOUNCE_WINDOW_SECONDS.seconds
): ImeJitterGuard = remember(debounceWindow) { ImeJitterGuard(debounceWindow) }
|
五、 完整实现代码
以下为整合了高度仲裁、Layout 优化、Focus 延迟预判以及 ImeJitterGuard 的完整实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
| package top.fseasy.imlog.features.home.topiclog.composer2.classic
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import top.fseasy.imlog.domain.model.ScreenOrientation
import top.fseasy.imlog.ui.util.toScreenConfiguration
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun ClassicComposerDock(
state: ComposerDockState,
textFieldState: TextFieldState,
onUpdateCachedImeHeightInDp: (ScreenOrientation, heightValueInDp: Float) -> Unit,
onToggleAttachment: () -> Unit,
onSendText: () -> Unit,
onTextFieldFocusChanged: (focused: Boolean) -> Unit,
onHoldToRecordAction: (ComposerHoldToRecordAction) -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val keyboardController = LocalSoftwareKeyboardController.current
val orientation = LocalConfiguration.current.toScreenConfiguration()
val focusManager = LocalFocusManager.current
var isTextFieldFocused by remember { mutableStateOf(false) }
val imeJitterGuard = rememberImeJitterGuard()
val currentCachedHeightPx = with(density) {
state.cachedImeHeightInDp.get(orientation).dp.roundToPx()
}
val imeAnimationTarget = WindowInsets.imeAnimationTarget
val imeInsets = WindowInsets.ime
val navigationBars = WindowInsets.navigationBars
// 1. 仅在键盘动画完全稳定(current == target)时采纳有效高度作为缓存,并通过 Guard 屏蔽竞态污染
LaunchedEffect(orientation, density) {
snapshotFlow {
val current = imeInsets.getBottom(density)
val target = imeAnimationTarget.getBottom(density)
if (target > 0 && current == target) target else null
}
.filterNotNull()
.distinctUntilChanged()
.collect { heightPx ->
if (!imeJitterGuard.canUpdateCache()) return@collect
val heightDp = with(density) { heightPx.toDp() }
onUpdateCachedImeHeightInDp(orientation, heightDp.value)
}
}
val panelHeightAnimation = remember { Animatable(0f) }
// 2. 状态机驱动的面板升降逻辑
LaunchedEffect(state.activePanel) {
when (state.activePanel) {
ActivePanel.ATTACHMENT -> {
val shouldSkipAnimate = imeInsets.getBottom(density) > 0f
if (shouldSkipAnimate) {
// 键盘在时切面板:高度瞬间到位,无动画交接
panelHeightAnimation.snapTo(currentCachedHeightPx.toFloat())
} else {
// 无键盘时打开面板:正常弹簧动画升起
panelHeightAnimation.animateTo(
targetValue = currentCachedHeightPx.toFloat(),
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
)
}
keyboardController?.hide()
focusManager.clearFocus()
}
ActivePanel.NONE -> {
if (panelHeightAnimation.value <= 1e-6f) return@LaunchedEffect
val shouldKeepHeight = isTextFieldFocused
if (shouldKeepHeight) {
// 面板切回键盘:保持当前高度,等待键盘顶起后 snap 清零
val targetHeight = withTimeoutOrNull(250.milliseconds) {
snapshotFlow { imeAnimationTarget.getBottom(density) }.first { it > 0 }
}
if (targetHeight != null && targetHeight > 0) {
snapshotFlow {
val current = imeInsets.getBottom(density)
val target = imeAnimationTarget.getBottom(density)
current to target
}.first { (current, target) ->
target == 0 || current >= target
}
panelHeightAnimation.snapTo(0f)
} else {
// 超时回退(例如物理键盘/未调起软键盘)
panelHeightAnimation.animateTo(
targetValue = 0f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
)
}
} else {
// 常规直接收起
panelHeightAnimation.animateTo(
targetValue = 0f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
)
}
}
}
}
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
) {
Column(modifier = Modifier.fillMaxWidth()) {
// 输入栏
DockFloatingContent(
mode = state.mode,
onAttachmentClick = onToggleAttachment,
onTextFieldFocusChanged = { focused ->
isTextFieldFocused = focused
imeJitterGuard.onFocusChanged(focused)
onTextFieldFocusChanged(focused)
},
textFieldState = textFieldState,
onSendText = onSendText,
onHoldToRecordAction = onHoldToRecordAction,
)
// 底部自适应高度占位仲裁容器
Box(
modifier = Modifier
.fillMaxWidth()
.layout { measurable, constraints ->
val navigationBarPx = navigationBars.getBottom(this)
val currentImePx = imeInsets.getBottom(this)
// 防御性过滤瞬态冒顶高度
val safeCurrentImePx = imeJitterGuard.sanitizeImeHeight(
currentImePx,
currentCachedHeightPx
)
val currentPanelPx = panelHeightAnimation.value.toInt()
// 高度仲裁
val finalContainerHeight = maxOf(
navigationBarPx,
safeCurrentImePx,
currentPanelPx
)
val placeable = measurable.measure(
constraints.copy(
minHeight = finalContainerHeight,
maxHeight = finalContainerHeight,
)
)
layout(placeable.width, finalContainerHeight) {
placeable.place(0, 0)
}
}
) {
val isPanelVisible = state.activePanel == ActivePanel.ATTACHMENT || panelHeightAnimation.value > 0f
if (isPanelVisible) {
AttachmentPanel(
modifier = Modifier
.fillMaxWidth()
.height(with(density) { currentCachedHeightPx.toDp() })
.navigationBarsPadding()
)
}
}
}
}
}
|
六、 总结与局限思考
回顾整个方案,关键点可以概括为以下四步:
- 单一占位 Box 实行高度最大值仲裁,替代不可控的系统
imePadding; - 在
Modifier.layout 阶段直接消费 Insets,彻底避免高刷重组引起的卡顿掉帧; - 基于 Focus 的 250ms 意图补偿,抹平跨进程 IPC 通信延迟导致的提前塌陷;
ImeJitterGuard 防冒顶机制,成功压制了 Android 系统快速打断时派发非标 Window Frame 的瞬态抖动。
仍存在的边界情况(Known Trade-offs):
冷启动初次打开:在 App 首次启动、从未打开过键盘时,currentCachedHeightPx 只能依赖系统预估默认值。如果首次直接点击附件面板,面板会以默认高度升起,需等待第一次真实弹起键盘后才能完成精确校准;
分屏模式与多窗口切换:在折叠屏分屏、自由窗口大小拖动时,WindowInsets 可能会发生大幅剧变,此时单纯依赖单调时间窗口的判定可能需要引入配置变化的重置逻辑。
手速极快时的一些边界情况:val shouldSkipAnimate = imeInsets.getBottom(density) > 0f 这里判定是否跳过 animate 是不充分的。
1
2
3
4
5
| 发生场景: 用户手速极快,刚点输入框(键盘还没从屏幕底部露头,屏幕底下还是平的),立刻就点加号切到了附件面板。
现象:
因为加了 Target > 0,代码认为“键盘已经在路上了”,于是果断执行了 snapTo(全高)。
但从用户肉眼来看:屏幕底部本来什么都没有,结果输入框和面板**“唰”的一下瞬间凭空瞬移到了 300dp 的高空**,没有任何升起动画!
用户的直观感觉是:“这 App 是不是卡顿掉帧了?怎么突然蹦出来一块,过渡动画被吞了吗?”
|