
做Android开发这些年软键盘绝对是最容易让人血压飙升的东西之一。尤其是当页面结构从“一个Activity包一个页面”变成“一个Activity装了好几个Fragment”之后麻烦就更明显了你在当前Fragment的输入框里敲字系统直接按整个Activity的window来调整尺寸于是相邻Fragment、底部操作栏、甚至顶部的标题栏全都跟着键盘一起“跳”上去。这篇文章就是来聊这个经典问题的根治思路让弹出键盘只影响当前正在输入的Fragment其他兄弟Fragment纹丝不动。从底层原因、方案选型、手写代码到线上才能踩到的坑我尽量一次性拆干净。适合正在改造Fragment化页面、或者被键盘弹出“全局搬家”折磨过的Android开发参考。1. 问题复盘为什么键盘弹出后整个界面都跟着遭殃1.1 现象复现一个Fragment敲字整个Activity都被重新布局先描述一下最典型的场景。应用主页是底部Tab结构MainActivity持有一个Fragment容器里面装了首页Fragment、消息Fragment、我的Fragment。每个Fragment都有自己的布局其中消息Fragment里有一个输入框和一个RecyclerView。这时候你在消息Fragment的输入框里打字软键盘弹出来会发生什么如果你的Application没有做过特殊处理默认情况下整个窗口会被压缩。也就是说首页Fragment、我的Fragment的布局也会被重新测量、重新布局整个Activity的content区域高度变小了底部Tab栏会被顶到键盘上方其他Fragment里的RecyclerView也跟着“缩”了一截。等你收起键盘布局又变回来。整个过程中页面疯狂跳变视觉上就是“整个界面都在动”。很多开发者一开始会把锅甩给Fragment以为是Fragment的问题其实这里有个关键认知要纠正软键盘弹出时系统调整的是Activity的window也就是整个窗口不是某一个Fragment的布局。Fragment在Android里只是一个容器化的视图管理层它没有自己独立的window也没有自己的windowSoftInputMode。所以你在任何Fragment里输入键盘弹出受影响的永远是这个Activity的整体界面。1.2 追根究底windowSoftInputMode跟Fragment没关系android:windowSoftInputMode这个属性看名字就知道它作用于window。它放在Activity或主题的配置里由WindowManager负责解析和执行。它支持的几个值你们都眼熟adjustResize、adjustPan、adjustNothing、stateHidden、stateAlwaysHidden等控制的是软键盘弹出和收起时整个window的尺寸或位置怎么变化。但Fragment并没有类似的属性位。你无法在Fragment上声明“我这个Fragment用adjustResize那个用adjustNothing”因为所有Fragment都住在同一个window里。这也是问题的根源系统层面只认window不认Fragment。所以如果你在布局文件里给根布局设置了android:fitsSystemWindowstrue并且Activity用了adjustResize那么键盘弹出时整个window的可用高度变小系统bars区域和ime区域会通过WindowInsets通知下来所有子View都会跟着重新布局。没有做过滤的话无论哪个Fragment在输入所有Fragment都会响应这次尺寸变化。结果是A Fragment在输入B Fragment的布局也“缩水”了看起来特别奇怪。1.3 adjustResize和adjustPan的局限先说说大家最常用的两种模式以及它们的局限。adjustResize的思路是键盘弹出后系统直接压缩window的可用高度让布局重新测量。这种方式在单个页面的场景下挺好用输入框被顶上去页面底部内容进入键盘上方区域用户能看清自己输入的内容。但在Fragment多实例共存的场景下它是“一刀切”所有Fragment一起resize并不是只处理当前输入的那个。adjustPan则更粗暴键盘弹出后整个window向上平移直到焦点View可见为止。这种方式在页面结构简单时也不错但在ScrollView、RecyclerView、多Fragment组合布局下非常容易产生“页面被顶飞”的问题而且输入框可能在屏幕中间导致上半部分完全移出屏幕。也不适合精确控制。结论就是如果你想要键盘只影响当前Fragment就必须跳出系统默认行为自己接管“键盘高度变化”的通知然后只对需要变化的Fragment做布局调整。2. 方案选型三种主流思路的对比与取舍2.1 用adjustPan硬扛治标不治本最早我试过直接把Activity设置成adjustPan看看能不能“凑合”用。实测下来在简单页面还能忍一旦遇到多Fragment加嵌套滚动布局问题一堆。adjustPan做的是对整个window进行平移。假设当前Fragment的EditText位于页面下部键盘弹出后系统会把整个window往上推直到EditText可见。由于window整体平移其他Fragment也跟着移动而且如果Activity里有自定义的底部栏、悬浮按钮它们会被一并推到键盘上方显得非常违和。用adjustPan的时间越长越觉得这不是在解决问题只是把问题挪了个地方。2.2 用adjustResize加全局padding能用但误伤队友有人会想那我在根布局上监听WindowInsets然后统一给根布局加paddingBottom这样不就把键盘高度让出来了吗可以但这同样会作用于所有Fragment不是“只影响当前Fragment”。如果你只有一个Fragment这个方案完全够用没必要搞复杂。但我们的前提是多个Fragment共存底部的Tab栏通常也需要保持固定。全局padding会导致Tab栏也被顶上去反而难以控制。所以在多Fragment场景下目标要拆得很细全局window不变只让当前Fragment的内容区域避开键盘。2.3 用adjustNothing加手动处理精准、可控、不误伤最终我选择的路子是Activity设置adjustNothing完全关闭系统自动调整能力然后自己在Fragment层监听输入法高度变化只对当前可见、正在交互的Fragment做布局填充。好处很明显其他Fragment完全不会感知到键盘弹出布局稳定。当前Fragment可以自由决定怎么处理键盘高度比如给根布局加padding、给RecyclerView加margin、或者滚动到指定位置。不会和fitsSystemWindows、状态栏透明等复杂情况互相干扰。缺点也是有的所有事情都要自己写而且需要处理Fragment可见性判断、监听器的注册和销毁、低版本兼容这些细节。不过这些细节也就是今天文章的核心。2.4 三种方案直观对比方案是否影响其他Fragment实现成本可控性适用场景adjustPan影响整体平移低低简单页面可临时救急adjustResize 全局padding影响全部压缩中中单一Fragment或全局都允许变化adjustNothing 手动监听不影响高高多Fragment共存、底部栏固定、键盘交互复杂3. 核心实现基于WindowInsets的精准处理3.1 第一步在Manifest中关掉系统自动调整在AndroidManifest.xml里找到承载Fragment的Activity把windowSoftInputMode设置为adjustNothing|stateAlwaysHidden。activity android:name.MainActivity android:windowSoftInputModeadjustNothing|stateAlwaysHidden /stateAlwaysHidden的目的是进入页面时不要自动弹出键盘避免启动瞬间的布局抖动。如果你的页面希望进入即聚焦输入框可以去掉它但大多数业务场景还是建议保留。这一步做完之后你会发现键盘弹出时整个window完全不动了输入框会被键盘盖住。别慌这正是我们需要的“不被系统乱改”的初始状态接下来所有控制都交给自己。3.2 第二步用WindowInsetsCompat监听输入法高度Android 11API 30之后系统提供了稳定的WindowInsets.Type.ime()可以直接拿到输入法的高度。配合AndroidX里的WindowInsetsCompat可以在向后兼容的写法里使用。这里要说明一下我在实际项目里最小支持到API 21一直用这套写法没有出现兼容性崩溃但低版本在个别ROM上拿不会ime insets需要配合第4节的兜底方案。核心代码逻辑是在目标Fragment的根布局上注册一个OnApplyWindowInsetsListener从中取出ime类型的insets高度然后给根布局设置对应的paddingBottom。// ViewEditFragment.kt class ViewEditFragment : Fragment() { private var _binding: FragmentViewEditBinding? null private val binding get() _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding FragmentViewEditBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) handleImeInsets(view) } private fun handleImeInsets(view: View) { ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets - // 只处理当前可见且处于Resumed状态的Fragment if (isVisible isResumed) { val imeInsets insets.getInsets( WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars() ) v.updatePadding(bottom imeInsets.bottom) } insets } } override fun onDestroyView() { // 避免View复用后监听器还挂着 ViewCompat.setOnApplyWindowInsetsListener(binding.root, null) _binding null super.onDestroyView() } }这段代码的核心就一行v.updatePadding(bottom imeInsets.bottom)。当键盘弹出时imeInsets.bottom就是键盘高度当前Fragment的根布局底部会增加一块等于键盘高度的padding键盘收起时imeInsets.bottom变为0padding自动归零。由于监听器只在当前Fragment的根布局上注册而且我们加了isVisible isResumed的判断所以其他Fragment不会受任何影响。3.3 第三步为什么加isVisible和isResumed双重判断这一步很容易被忽略但恰恰是“只影响当前Fragment”的关键。Fragment的onViewCreated注册监听器后如果Fragment被切换走了比如被replace、hide、或者放进ViewPager2里切换到了别的页它的View仍然存在监听器也不会自动移除。如果不做任何判断键盘弹出时即使这个Fragment已经不可见它的根布局也会跟着加padding。虽然用户看不见但一旦切回来页面底部会留一大块空白体验非常差。所以我在监听器里加了两个条件isVisible判断Fragment当前是否处于Visible状态包括它的View是否已经attach且可见。isResumed判断Fragment是否处于Resumed生命周期。在多Fragment叠加、或者需要和Activity交互的场景下这个判断能过滤掉处于后台、非交互状态的Fragment。如果你用的是ViewPager2它默认会预加载相邻Fragment这些Fragment处于STARTED或CREATED状态isResumed为false所以不会被误处理。这是我实测过的最稳妥的双保险。3.4 第四步处理底部导航栏和透明状态栏如果你的页面是沉浸式设计底部有NavigationBar区域直接updatePadding(bottom imeInsets.bottom)会漏掉导航栏的高度。上面代码里我已经做了处理val imeInsets insets.getInsets( WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars() )把ime和systemBars取或后取到的header就是两个区域合并后的总高度imeInsets.bottom里就包含了在窗口底部出现的ime高度和系统导航栏高度。这样即使你的根布局延伸到了导航栏后面padding也能正确覆盖到底。这里有个经验之谈不要单独用WindowInsetsCompat.Type.ime()因为在高版本Android上如果页面没有延伸到系统栏后面ime insets可能不包含导航栏高度导致底部差一截。合并取一次更稳妥。4. 兼容方案没有WindowInsets时的兜底策略4.1 老版本和碎片化ROM用ViewTreeObserverWindowInsets.Type.ime()在API 30以上非常好用但在API 30以下尤其是一些深度定制的ROM上拿到的ime insets可能不准或者根本收不到回调。这时候我建议用传统方案兜底通过ViewTreeObserver.OnGlobalLayoutListener监听全局布局变化根据可见显示区域的高度差算出键盘高度。核心逻辑是键盘弹出时window的可见显示区域顶部不变底部被键盘遮挡所以可见区域高度变小。用根View的高度减去可见显示区域的bottom差值就是键盘高度。class CompatEditFragment : Fragment() { private var _binding: FragmentCompatEditBinding? null private val binding get() _binding!! private val globalLayoutListener ViewTreeObserver.OnGlobalLayoutListener { handleGlobalLayoutChange() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) view.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) } private fun handleGlobalLayoutChange() { if (!isVisible || !isResumed) return val rootView binding.root val rect Rect() rootView.getWindowVisibleDisplayFrame(rect) val screenHeight rootView.rootView.height val keyboardHeight screenHeight - rect.bottom if (keyboardHeight 0) { binding.root.updatePadding(bottom keyboardHeight) } else { binding.root.updatePadding(bottom 0) } } override fun onDestroyView() { binding.root.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener) _binding null super.onDestroyView() } }注意这里我用了一个阈值判断有些执行者喜欢写成keyboardHeight screenHeight * 0.15才认为是键盘弹出避免导航栏、状态栏变化导致误判。具体阈值可以根据页面实际元素调整我习惯用这个比例能在绝大多数机器上过滤掉底部导航条的干扰。这个方案的缺点是每次布局变化都会回调性能上略吃亏但只要监听器只挂在当前Fragment页面结构不复杂实测完全没问题。如果追求极致流畅还可以在回调里加一个防抖避免同一帧内重复设置padding。4.2 键盘高度缓存避免每次重复测量无论用哪种监听方式键盘高度其实在同一个页面上往往是固定的。与其每次键盘弹出都重新测量、重新计算不如缓存一下。我的做法是维护一个全局的KeyboardHeightProvider用ViewModel或者单例都行核心是保存键盘高度值。第一次测量到后存起来后续这次弹起直接使用缓存。object KeyboardHeightHolder { Volatile var keyboardHeight: Int 0 }在handleImeInsets或者globalLayout回调里每次拿到imeInsets.bottom时同步写一下缓存if (imeInsets.bottom 0) { KeyboardHeightHolder.keyboardHeight imeInsets.bottom }这样多个Fragment切换时新Fragment切入瞬间就可以先用缓存高度做布局调整等监听到最新高度再校正。键盘弹出动画期间也不会出现明显的延迟跳变。4.3 处理键盘动画中途的“抖动”还有一种典型问题键盘弹出过程中高度是逐渐增加的。如果监听器每帧都触发padding也就会跟着一帧一帧变理论上这其实能实现细腻的跟随动画。但在部分低端机上布局重新测量比键盘动画慢会出现“界面一抖一抖”的情况。我踩过这个坑之后做了一个简单策略以50毫秒为窗口做节流每次收到ime高度变化时记录时间戳如果距离上次处理不到50毫秒就忽略超过就直接设置padding。这样布局更新的频率会明显减少绝大多数设备上肉眼看不出差别但掉帧感会缓解很多。如果用WindowInsets方案其实系统已经帮我们做了大部分帧同步这个问题不太明显但在ViewTreeObserver兜底方案里节流是很有必要的。5. 实操中常见的坑与排查技巧5.1 问题速查表现象原因处理方法键盘弹出后所有Fragment都动Activity使用了adjustResize改为adjustNothing当前Fragment根布局加padding后底部栏被顶起把监听器挂到了Activity的decorView改挂Fragment自己的根布局切换Fragment回来时底部有大块空白监听器没有正确移除或可见性判断缺失加isVisible和isResumed判断并在onDestroyView移除键盘高度不准底部差一截没合并systemBars insets用ime or systemBars合并取键盘收起后布局没恢复只处理了非0高度没处理0高度统一在回调里设置padding0时重置低版本收不到insets回调ROM的ime insets兼容问题用ViewTreeObserver兜底键盘弹出时输入框仍被遮挡只加了padding没做滚动在监听里同时调用scrollTo/scrollBy让焦点可见5.2 Fragment切换时监听器失效怎么精准控制如果你用的是show/hide来切换Fragment那么被hide的Fragment的isVisible会变为false监听器里的判断会阻止它做padding没问题。如果你用的是replace旧的Fragment会走onDestroyView我们在onDestroyView里移除了监听器也清空了binding没问题。如果你用的是ViewPager2相邻页面由于预加载机制存在它们处于可见状态但不在Resumed状态所以isResumed判断会起到关键作用。这三种情况我都实际验证过放心用。还有一个小坑是Fragment在onCreateView里创建View时如果它在后台被重建bind到View上的监听器可能收不到最新的insets。这时候可以在onStart里主动调一下rootView.requestApplyInsets()强制系统重新下发一次insets。这个操作不会带来明显开销能避免很多“切回来布局不对”的诡异问题。5.3 全屏模式和刘海屏的差异如果Activity是全屏模式或者decorView设置了systemUiVisibility为沉浸式WindowInsets的收取方式会发生变化。有些手机上ime insets会和系统栏insets分开返回合并获取就更加必要。还有一些机型在全屏下用ViewTreeObserver方案测算时getWindowVisibleDisplayFrame返回的可见区域顶部可能不是0计算键盘高度时要用screenHeight - rect.bottom不要用rect.top参与计算否则会在刘海屏上多算一块高度。这里不再展开所有case但记住一条原则先确保自己页面的fitsSystemWindows和状态栏处理方式在全App是统一的再叠加键盘逻辑不然很容易两者相互干扰。5.4 监听器泄漏和重复注册问题这个问题最常见的是把OnGlobalLayoutListener加到了Activity的rootView上然后Activity一直被ViewModel或单例引用导致无法释放。我强烈建议监听器只注册在Fragment自己的根布局上并且在onDestroyView里移除。如果某个流程需要在Activity级做统一处理记得也要在onDestroy里移除。还有一种重复注册的场景Fragment的onCreateView被系统重建多次比如配置变更、进入后台再回前台时View重建。如果你没有在onDestroyView里把监听器置空新的View会叠加旧的监听器导致同样的padding被设置两次。设置同一数值倒不至于崩但如果监听器里还有滚动逻辑就可能出现“每次弹键盘页面都会跳一下”。所以必须养成在onDestroyView里统一清理绑定的习惯。5.5 用工具检查布局层级如果实在排查不出来我教大家一个土办法在键盘弹出前后用Android Studio的Layout Inspector抓两次布局比对根View的高度和padding差异。这个方法能直接把某个Fragment的padding变化暴露出来快速定位是哪个监听器在作怪。我排查多个Fragment互相干扰的问题时基本靠这一招就能锁定目标。6. 最后分享一点我的使用体验我目前把adjustNothing加WindowInsets改成默认方案并把ViewTreeObserver作为低版本兜底整个方法抽成了一个BaseImeFragment基类任何Fragment想支持键盘避让只要继承并传入需要调整的根布局就行。实际跑了一段时间后最明显的感受是以前那种键盘一弹、页面整个“搬家”的体验彻底消失了只有正在输入的Fragment会做出响应底部的Tab栏稳定得一笔用户几乎感受不到其他页面的存在。这套方案不依赖第三方库也没有魔法理解原理之后半小时就能写完稳定性反而比乱七八糟的开源库更可控。如果你正被类似的Fragment键盘问题困扰可以按这个思路试一遍。我踩过的那些坑希望你不需要重新踩一遍。