去年接手一个模拟项目X,需求很简单——聊天页面底部一个输入框,点一下唤起安卓软键盘,输入框要跟着键盘向上移动。iOS端没费什么劲就调好了,换到安卓测试机,输入框直接被键盘盖住。网上搜 uni.onKeyboardHeightChange,相关帖子不少,但真正把“打开键盘输入框向上移动”从头到尾讲透的没几篇,多数只是贴一段代码,评论区全在问“为什么我的没反应”“为什么键盘收回去又弹出来”。这篇文章就把我踩过的坑、试过的布局方案、以及最终沉淀下来的封装方式整理出来,给同样被这个需求折磨过的朋友做个参考,尤其是正在做聊天、评论、表单这类页面的,应该能直接消化。
需要先说明的是,这个API主要解决的是 uni-app 在安卓 App 端的键盘适配问题。iOS 端不用管,系统自己会处理;H5 端也不需要管,浏览器天生把输入框顶在键盘上方。所以后面所有内容默认都是针对安卓 App 端来聊的。
1. 安卓键盘和iOS键盘,行为逻辑天生不一样
1.1 一个让我半夜改代码的需求
我做那个聊天项目的时候,一开始真的很天真。页面结构也简单:上面一个消息列表,下面一个固定底部的输入框,iOS上测试,聚焦、弹键盘、输入框自动上移,一气呵成。结果打包到安卓真机,完了,输入框纹丝不动,直接被键盘下半部分盖住,用户打几个字全靠盲打。我当时第一反应是“是不是 CSS 定位的问题”,于是把输入框从 fixed 改成 absolute,又改成 flex 布局,折腾了一晚上毫无起色。
后来才明白,这根本不是布局方式的问题,而是安卓系统在键盘弹出时,根本不保证你的页面容器会跟着调整。你用的 position 也好、flex 也好,都是相对于当前 WebView 渲染窗口来定位的,而 WebView 的窗口在键盘弹出后,很多时候压根没有被系统重新计算。页面还是那个全屏页面,输入框自然就停留在原来的位置,被键盘当作“背景”盖掉。
1.2 iOS不用管、安卓必须管的根源
两个系统对软键盘的处理模型完全不同。iOS 的软键盘是悬浮在应用窗口之上的,弹出时系统会主动把当前页面的安全区域上边界抬升,底部固定元素天然跑到键盘上方,几乎不需要开发者介入。这一点你用原生开发、用 Flutter、用 uni-app,体验都差不多。
安卓则复杂得多。安卓的窗口系统会按照配置决定键盘弹出后的行为,常见两种模式:adjustPan 会把整个窗口往上平移,让聚焦的输入框尽量可见;adjustResize 是直接压缩可绘制区域的尺寸。但问题在于,uni-app 的 App 端页面跑在 WebView 里,WebView 内部 HTML 布局是否跟着系统 resize 一起重新计算,取决于很多因素。实测中大部分页面的根容器高度仍然保持整个屏幕的高度,键盘把屏幕底部盖住之后,你的输入框并不会自动逃离键盘区域。
这就是 uni.onKeyboardHeightChange 存在的原因:让 JS 层能够拿到真实的键盘高度,再由开发者根据这个高度去调整布局。它不是万能的,但没有它,App 端做键盘适配连入口都找不到。
1.3 这个API的适用范围先对齐
我在项目里第一次接这个 API 的时候,看到它名字里有“KeyboardHeightChange”,以为所有平台都可以统一用。后来查文档才发现,这个 API 实际上主要面向 App-Android 平台。在 iOS 上强行用的话,虽然不至于报错,但可能引发无意义的布局变化,因为 iOS 原生已经帮你处理好了。在 H5 上更没必要,浏览器键入框会自动浮在键盘上。
所以我的第一个建议是:在写任何键盘适配代码之前,先用条件编译把平台限定住。后面我在封装 mixin 时也用了这套思路,避免同一套逻辑在不同的端上帮倒忙。
2. 先把uni.onKeyboardHeightChange这个API吃透
2.1 基本用法和回调参数
这个 API 的用法极简:
uni.onKeyboardHeightChange((res) => { console.log('键盘高度变化:', res.height); });回调参数里只有一个 height,单位是逻辑像素 px。height 大于 0 说明键盘处于弹起状态,等于 0 说明键盘已收起。听起来是不是很简单?但就是这么一个简单的 API,用不好照样翻车。
首先,这个 height 是逻辑像素,不是 rpx,也不是物理像素。如果你要把这个值直接绑定到样式里,比如给底部输入框设置 bottom 值,那就直接绑定,不用做任何换算。如果你非要先换算成 rpx,那就得用px / 屏幕宽度 * 750这种公式,增加了计算量还可能因为屏幕宽度不同产生误差。没必要,底部的布局偏移用 px 是最自然的。
其次,键盘弹起和收起的回调时机不完全对称。键盘弹起时,回调会跟随动画连续触发多次;键盘收起时,理论上最后会有一个 height 为 0 的通知,但有些定制 ROM 上这个“归零”通知可能迟到,甚至不触发。这一点我后面会详细讲。
2.2 别把回调当成“弹起瞬间通知”
这个 API 最容易让人误解的地方就是:大家总以为它是在键盘弹起那一刻通知你一声,然后你去执行一些一次性动作,比如“滚动到底部”“把发送按钮亮起来”。实际上,它更像是一个持续更新的“键盘高度状态流”。
在安卓上,软键盘弹出是一个渐进的动画过程,回调值可能是 0、80、150、240、300 这样一路涨上来的。如果你的回调里做的是频繁且昂贵的操作,比如每次 height 一变就重新计算列表高度、重新渲染整个页面,那用户会明显感觉到卡顿,甚至在键盘还没弹完的时候页面就已经抖了好几轮。
正确的思路是:这个 API 负责把“当前键盘高度”持续告诉你,你的职责是把高度反映到布局上。至于“弹起之后我要额外做什么”,不要在回调里直接做,而是等 height 稳定下来后再做一次。比如滚动到底部,我会在判断 height 大于某个阈值之后,用 this.$nextTick 加一个短暂延时再滚,这样键盘动画已经进行了一大半,滚动的视觉错位感会小很多。
2.3 生命周期管理:回调叠加是最隐蔽的bug
这里必须重点说一个坑,也是我一度被坑得最惨的地方。最初我把监听写在输入框的 focus 事件里,想着“聚焦时监听,失焦时移除”,逻辑上听起来更精准。结果用户每次点击输入框,focus 触发一次,uni.onKeyboardHeightChange 就注册一次回调。连续点几次之后,同一个键盘高度变化会同时触发多个回调,页面里的键盘高度变量被反复赋值,输入框直接在键盘上方抖来抖去,像得了帕金森一样。
正确的做法是把回调注册放在页面的 onLoad 或 onShow 里,只注册一次;在 onUnload 里移除。而且 off 的时候必须传入同一个函数引用。如果你用箭头函数写匿名回调,off 的时候再写一个一模一样的箭头函数,那其实是两个完全不同的引用,根本移除不掉。我后来干脆把回调函数在 onLoad 里先赋值给 this,注册和移除都引用同一个。
export default { onLoad() { // 先移除,再注册,双保险 this.handleKbHeight = (res) => { // 具体的键盘高度处理逻辑 }; uni.offKeyboardHeightChange(this.handleKbHeight); uni.onKeyboardHeightChange(this.handleKbHeight); }, onUnload() { uni.offKeyboardHeightChange(this.handleKbHeight); } };提示:uni.offKeyboardHeightChange 是可以不传参数的,不传会移除所有键盘监听。但为了精确管理,强烈建议传入同一个函数引用,避免页面销毁后旧回调还在触发。
3. 输入框随键盘上移的两种主流量产做法
3.1 方案一:底部固定条 + 动态bottom,聊天样式的首选
最直接、也是最适合聊天页面的做法,就是把输入框所在的容器用 position: fixed 固定在屏幕底部,然后用监听得到的键盘高度动态修改它的 bottom 值。
模板层大概是这样的:
<view class="chat-page"> <scroll-view class="msg-list" :scroll-into-view="scrollIntoView" :scroll-y="true" > <view :id="'msg-' + index" class="msg-item" v-for="(item, index) in msgList" :key="index" > <text>{{ item.content }}</text> </view> </scroll-view> <view class="input-bar" :style="{ bottom: keyboardHeight + 'px' }"> <input class="input-box" v-model="draft" placeholder="请输入消息" confirm-type="send" /> <button class="send-btn" size="mini" @click="sendMsg">发送</button> </view> </view>关键样式和逻辑:
.input-bar { position: fixed; left: 0; right: 0; bottom: 0; display: flex; align-items: center; padding: 12rpx 20rpx; background: #fff; z-index: 100; }uni.onKeyboardHeightChange((res) => { const h = res.height || 0; this.keyboardHeight = h > 30 ? h : 0; });一个很容易忽略的点:动态绑定 bottom 时,不要给 input-bar 加 transition 属性。很多人为了让布局看起来平滑,给 bottom 加了 0.3s 的过渡动画。结果就是键盘已经弹到一半甚至完全弹起来了,输入框还在慢悠悠地往上飘,视觉上非常难受,总感觉慢半拍。正确的做法是不要加过渡,让输入框严格跟随键盘高度更新的节奏走。你想想真实的原生键盘弹出效果,从来都是键盘和输入框同步动,没有谁滞后。
还有一个细节是底部背景问题。input-bar 被顶到键盘上方后,它原来的位置会空出来,键盘动画期间,那个区域会短暂露出页面底部的背景色,看起来像一块闪空的缺口。解决办法是给聊天页面最外层容器设置和 input-bar 一样的背景色,或者把整块底部区域做成一个统一背景,避免穿帮。这个问题很多人不注意,等用户截图给你的时候才尴尬。
3.2 方案二:内容区动态paddingBottom,表单页面的兜底做法
但底部固定条并不是所有场景都适用。如果你做的是一个内容比较长的表单页面,输入框在中间偏下位置,还有一大段内容和它并列,那更适合的方案是给页面根容器动态加 paddingBottom,把整个页面的可用空间向下扩展。
做法很简单,页面根容器绑定 paddingBottom:
<view class="form-page" :style="{ paddingBottom: keyboardHeight + 'px' }"> <!-- 表单内容 --> <view class="comment-box"> <textarea v-model="content" placeholder="写下你的评论" /> </view> </view>这个方案的好处是,不管输入框在哪个位置,只要页面本身可以滚动,键盘弹起时底部就有了额外的留白空间,用户手动滚动也能让输入框露出来。配合 uni.pageScrollTo 把页面滚到合适位置,体验可以做到接近原生。
我在实测中发现,方案二的关键在于“滚动时机”。键盘弹起后页面可用高度变小,你得主动把输入框滚到视口内。可以先通过 uni.createSelectorQuery 取到输入框的位置,再计算目标 scrollTop。代码大概是这样:
let query = uni.createSelectorQuery(); query.select('.comment-box').boundingClientRect((rect) => { let h = this.keyboardHeight; let windowInfo = uni.getWindowInfo(); // 把输入框滚到未被键盘遮挡的区域内 let targetY = rect.top - (windowInfo.windowHeight - h - rect.height); if (targetY > 0) { uni.pageScrollTo({ scrollTop: targetY, duration: 0 }); } }).exec();为什么 duration 要设成 0?因为我希望滚动和键盘弹起完全同步,不要出现“键盘还在飞、页面已经在滚”的错位感。真机上 0 毫秒就是瞬间到位,用户看着键盘和页面一起动,体感反而更跟手。
3.3 两种方案的取舍思路
我的经验是:聊天、客服、评论回复这类“输入框永远在底部”的页面,用方案一,把输入条直接 fixed 在底部,简单粗暴且效果好;网上购物评价、后台表单这类“页面内容长、输入框位置不固定”的页面,用方案二,给页面加 paddingBottom,再用滚动把输入框带出来。
但不管哪种方案,都不建议每个页面复制粘贴同一套监听代码。我最初在四五个页面里各写了一份 uni.onKeyboardHeightChange,后来发现问题越来越多:有的页面忘了在 onUnload 里移除,有的页面把回调写成了箭头函数导致 off 失效,有的页面居然在 tabBar 页面里也监听了键盘,页面切换时旧监听还残留。把这些公共逻辑抽成 mixin 或独立工具模块是必然的,封装内容放在第 5 节。
4. 安卓机型适配实战:状态栏、全面屏、动画回调这些“意外”
4.1 键盘动画期间的渐增回调:别乱加防抖
安卓的软键盘弹出来不是一个瞬间动作,而是从屏幕底部往上滑的动画,时长通常在 200ms 到 400ms 之间。uni.onKeyboardHeightChange 在这段时间里会连续回调,height 值从十几、几十一直涨到最终的键盘高度。
很多开发者看到回调这么密集,第一反应是“要不要防抖一下,只取最终值”。我的答案是:不要。防抖意味着你要舍弃中间的状态,而键盘动画期间输入框不跟着键盘动,用户看到的画面就是“键盘一直在涨,输入框原地不动,最后再跳一下”。这同样很掉价。正确做法是让布局完全跟随这个渐增的高度值,输入框和键盘一起向上移动,就像被键盘顶上去一样。如果项目里因为某些原因必须做节流,也把间隔控制在 50ms 以内,不要狠狠截断。
真正该做防抖的地方只有一个:键盘收起时。因为部分 ROM 在键盘收起过程中会先回传一个很小的 height 值,比如十几二十像素,然后又跳回 0;甚至有些输入法在“收起动画”中会短暂触发一次 height 为 0,然后再次触发一个小高度,又归零。我的做法是统一做阈值判断:只有 height 大于 30px 才认为是弹起,否则一律当成 0。这个 30px 经过好几台机器验证,低一点高一点影响不大,但能过滤掉大部分“假收起”和“假弹起”。
4.2 状态栏高度、导航栏和底部安全区带来的换算误差
不少开发者在拿到 res.height 之后,直接拿它当布局的 bottom 值,大多数机型是没问题的。但在全面屏、带虚拟按键、带手势条的机型上,偶尔会出现输入框被顶得偏高或者偏低。我的排查经验是:问题通常不在这个 API 本身,而在页面布局有没有额外的偏移。
如果你的页面是沉浸式状态栏,整块屏幕从上到下都是可绘制区域,那么 res.height 直接等于需要上移或垫高的距离,没有多余换算。但如果你的页面外面还套了一层原生的导航栏,或者输入条本身又做了 safe-area-inset-bottom 的计算,这两部分就容易和 res.height 重复计算,导致输入框高出键盘一截。
最稳妥的做法是先用一个基础版本跑真机,观察输入框底部边缘和键盘顶部边缘是否贴合。如果不贴合,再考虑通过 uni.getSystemInfoSync 获取 statusBarHeight 或 safeAreaInsets.bottom 做加减修正。不要一上来就盲改公式,因为不同 ROM 的返回值差异很小,多数情况下默认值就是对的。
至于全面屏底部的横条(手势条),我在真机上验证过,uni.onKeyboardHeightChange 返回的 height 实测已经包含了手势条区域,直接用它设置 bottom,输入框和键盘贴得刚刚好。如果我额外再补一个 env(safe-area-inset-bottom),反而会多出一截,输入框看得人以为它悬空了。这条经验的本质是在提醒我:手势条的安全区,这个 API 已经替你考虑了,不要再画蛇添足。
4.3 输入法厂商的“隐藏模式”:悬浮键盘和手写切换
安卓上的输入法有好几种形态,最常见的坑来自“悬浮键盘”。部分品牌输入法允许用户把键盘设置成悬浮在屏幕中间偏下的位置,此时系统上报的回调里 height 值会变得不稳定:有时变成 0,有时给一个奇怪的完整键盘高度。在悬浮键盘模式下,靠 res.height 去做底部上移基本会失效。
我的处理原则是:这个场景属于用户主动修改输入法形态,应用层很难做到完美适配,只要保证“键盘不遮住输入框”这条底线不破就行。所以当 height 突然从大于 0 跳到 0,但输入框依然处于焦点状态时,不要立刻把所有布局恢复原状,而是等几百毫秒,如果输入法真的是收起了再归零。这样在悬浮键盘模式下至少不会出现输入框反复跳动。
另一个常见情况是键盘在手写、拼音、笔画之间切换时,height 在切换瞬间会跳变一下,比如从 300px 跳到 200px 再回到 300px。这种跳变是真实的键盘高度变化,底部布局理应跟着调整,不需要特殊处理。真正需要留意的是不要在切换过程中去滚动页面,否则用户视角里页面会自己跳来跳去。我一般在 height 变化稳定 100ms 之后,才触发 scrollIntoView 之类的滚动动作,这样用户不会看到页面瞬移。
4.4 manifest里的softinputMode:调错方向会双重位移
这个配置最容易被忽略,一旦错了后果也最严重。uni-app 在 App 端有一个 softinputMode 配置,位于 manifest.json 的 app-plus 节点下,默认值是 adjustResize,也可以改成 adjustPan。
{ "app-plus": { "softinputMode": "adjustResize" } }这两者的区别前面讲过:adjustPan 是系统把整个窗口往上平移,让输入框露出来;adjustResize 是压缩窗口高度,让页面容器重新计算。在 uni-app 的多数页面里,建议保持默认的 adjustResize,然后完全交给 uni.onKeyboardHeightChange 来做上移。如果你把 softinputMode 改成 adjustPan,同时又在回调里手动把输入条往上推,两个机制会叠加,输入框会被推得比键盘顶部还高一截,这就是标准的“双重位移”。
反过来,如果遇到“键盘弹起来时输入框已经被系统顶起来了,但我的监听又加了一次 height”的情况,第一件事就是去确认 manifest 里的 softinputMode 是不是被改成了别的值。我认识的一位开发者就是页面怎么调都不对,折腾了大半天,最后发现是手动改了配置文件加了 adjustPan。这属于配置上的低级错误,但确实非常隐蔽。
注意:softinputMode 是全局配置,一旦设置会作用于所有 App 页面。如果你在大部分页面都要自己接管键盘位移,就保持 adjustResize;如果个别特殊页面需要系统默认行为,也别为了它去改全局配置,宁可那个页面多写一点自定义逻辑。
5. 从踩坑到稳定:测试验证清单与通用封装
5.1 一个能让你少被用户骂的实测清单
键盘适配做完之后,建议至少按下面清单过一遍,很多问题是开发环境暴露不出来的:
- 真机优先,模拟器为辅。模拟器上的键盘行为与真实 ROM 差异很大,尤其悬浮键盘和安全区,模拟器基本测不出来。
- 至少覆盖三类机型:一台带虚拟三键的老机型、一台全面屏带手势条的机型、一台刘海屏机型。键盘高度、安全区、状态栏的差异集中在这三类机器上。
- 切换输入法。如果设备装了多个输入法,每个都试一遍,重点试有“悬浮键盘”模式的输入法。
- 在键盘弹起状态下点击发送或提交,观察输入框位置在数据刷新后是否还正常。
- 从包含输入框的页面返回上一页或切 tab,确认页面销毁后有没有残留的键盘监听导致报错。
我一般会在每台测试机上开一个简单的测试页面,把 onKeyboardHeightChange 回调的 height 值实时显示在屏幕上,然后手动弹起、收起键盘,记录最大值、最小值、归零时机。数据一对比,哪些机器回调稳定、哪些机器会多回几次 0,心里就完全有数了。
下面这张表是我自己积累下来的问题现象、原因以及处理方向的汇总,先给出来,方便你排查时对照:
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 输入框被键盘完全盖住 | 布局未跟随键盘高度 | 使用 uni.onKeyboardHeightChange 动态偏移 |
| 输入框高于键盘一截 | 输入条自带安全区且与回调高度叠加 | 去掉 safe-area-inset-bottom 或适当减高 |
| 键盘收回后输入框不回位 | 收起回调未触发或阈值过滤失效 | 增加归零判断与延时兜底 |
| 连续点击输入框后页面抖动 | 焦点事件里重复注册监听 | 改到 onLoad/onShow 注册,onUnload 移除 |
| 输入框被顶到屏幕外 | softinputMode 配置与手动偏移叠加 | 保持 adjustResize,取消手动平移逻辑 |
| 悬浮键盘模式下高度不稳 | 输入法自定义形态,系统上报异常 | 延迟归零、阈值过滤,保证输入框不被遮 |
5.2 我沉淀下来的通用封装:mixin + 页面钩子
最后把这套经验收敛成一个公共 mixin,后续新页面直接引入就能用。它的职责很纯粹:管理好键盘监听的生命周期,把 height 值经过阈值清洗后暴露给页面,再提供一个可选的回调钩子,让页面自己决定怎么响应。
// mixins/with-keyboard.js export default { data() { return { keyboardHeight: 0, keyboardShow: false, __kbHeightTimer: null }; }, onLoad() { // #ifdef APP-PLUS this.__kbHandler = (res) => { const raw = res.height || 0; if (this.__kbHeightTimer) { clearTimeout(this.__kbHeightTimer); } this.__kbHeightTimer = setTimeout(() => { const h = raw > 30 ? raw : 0; this.keyboardHeight = h; this.keyboardShow = h > 0; if (typeof this.onKeyboardHeightChange === 'function') { this.onKeyboardHeightChange(h, this.keyboardShow); } }, 30); }; uni.offKeyboardHeightChange(this.__kbHandler); uni.onKeyboardHeightChange(this.__kbHandler); // #endif }, onUnload() { // #ifdef APP-PLUS if (this.__kbHeightTimer) { clearTimeout(this.__kbHeightTimer); this.__kbHeightTimer = null; } uni.offKeyboardHeightChange(this.__kbHandler); // #endif } };这里加了一个 30ms 的轻度节流,不是为了防抖,主要是降低高频回调对页面渲染造成的压力。30ms 的延迟用户完全感知不到,但渲染频率会降低不少,动画也不会因此卡顿。
页面里这样使用:
import withKeyboard from '@/mixins/with-keyboard.js'; export default { mixins: [withKeyboard], methods: { // 覆盖 mixin 里定义的可选钩子 onKeyboardHeightChange(height, isShow) { // 页面自己决定怎么响应 } } };封装完这套以后,我再也没有在业务页面里直接写过 uni.onKeyboardHeightChange。可以说,90% 的页面只需要关心 onKeyboardHeightChange 这个方法里传来的 height 值该怎么用,剩下的监听注册、清理、异常过滤,mixin 都替我接管了。
最后再分享一点真实体会:键盘适配这件事本身没有什么高深的技术含量,难的是它横跨了系统机制、布局方案、机型差异三个层面,每个层面都有若干不起眼的坑。把方案确定下来并封装成公共能力之后,后面的业务开发几乎无感,但这套能力的价值恰恰体现在你不需要再为每个新页面重新踩一遍我踩过的坑。如果你正被安卓键盘折磨,不妨先按我给的思路搭一版最简方案,再逐步加机型适配,应该会比你一上来就搜各种碎片代码要顺得多。