
做了这么多年Android弹键盘这件事一直是个经典话题。你以为是简单几个属性调一调就完事真到多Fragment共存的页面里键盘一弹布局乱跳、输入框被顶出屏幕、切换页面后键盘还在那边“阴魂不散”各种问题就全冒出来了。特别是标题里这种诉求——弹出键盘只影响当前Fragment输入的窗口听着玄乎本质上是想让键盘的“副作用”只作用到当前正在输入的那个页面而不是拖累整个Activity窗口。这篇文章我会把这个问题的根源讲透然后给出一套我自己在项目里用了很久、屡试不爽的通用解决方案。它不是一个“土办法”而是一套可复用的思路加基类代码适用于聊天页、评论页、搜索页这类典型输入场景也能应对沉浸式状态栏、ViewPager2多Fragment之类的复杂布局。如果你是刚上手Fragment的中级开发者或者是被键盘适配折磨了两天的老开发这篇应该能帮你少走不少弯路。1. 先弄明白为什么弹出键盘会牵扯整个窗口而不是某个Fragment1.1 软键盘的真正身份系统窗口不是View很多人在排查这类问题时第一反应是“我这个Fragment里布局写错了”“是不是某个控件抢了焦点”。但真相往往很简单软键盘这东西压根就不是View层面的东西。它是由系统输入法服务InputMethodManager管理的一个系统窗口它的显示、隐藏、以及弹出时对应用布局的挤压作用对象是整个Window也就是我们通常说的Activity窗口。Fragment是什么它只是Activity内部一个可以动态切换的UI容器。系统唯一能感知到的“输入目标”是当前获得焦点的EditText所在的Window。所以无论你在Activity里挂了多少个Fragment对输入法来说它只认当前Activity的窗口不认Fragment。这就是为什么键盘一弹整个窗口都跟着动好像“波及”到了所有Fragment——其实Fragment们都是受害者真正的根源在Window配置上。理解了这一层很多问题就通了。比如你在FragmentA的输入框打字键盘弹出后按返回键收起键盘再切到FragmentB这时候如果FragmentB的布局里有一个EditText在底部你会发现键盘又自动弹出来了——不是因为FragmentB的代码有问题而是键盘状态跟Window绑定而FragmentB里刚好有焦点系统认为你“还需要输入”。1.2 windowSoftInputMode三个关键值adjustPan、adjustResize、adjustNothing既然问题在Window层那我们首先要掌握的就是Android提供给我们的三个软键盘调整模式。这个配置写在AndroidManifest.xml的activity节点里也可以动态调用Window.setSoftInputMode()去改。三个值的区别我直接整理成一张表模式行为表现典型痛点适用场景adjustPan键盘弹出时整个窗口上移保证当前焦点控件可见顶部标题栏、搜索栏会被顶出屏幕多Fragment时不可见页面也会跟着“平移”页面简单、输入框靠上、允许整体平移的场景adjustResize键盘弹出时窗口高度被压缩底部布局自然上移输入框跟着顶起来如果根布局是ScrollView可能出现滚动条被挤压状态栏沉浸式时需要额外适配聊天、评论、表单等输入框在底部的场景也是本文方案的地基adjustNothing键盘弹出时完全不动窗口所有避让逻辑由开发者自己处理工作量大需要自己监听键盘高度并调整布局高度自定义输入面板、复杂动画、全屏沉浸式页面这里需要多说一句adjustPan。它看起来最“省事”但实操里坑很多。它是以整个窗口为单位做平移如果Activity里有多个Fragment只要键盘弹出来所有还在视图树里的Fragment都会跟着平移哪怕那个Fragment在另一个Tab页只是不可见而已。等你切过去的时候布局已经是“歪”的状态了。所以做多Fragment页面我基本不碰adjustPan。adjustResize则不一样它改变的是窗口的可用高度而不是整体平移。这就意味着底部布局会自然往上顶顶部保持不动更符合移动端“输入框在页面底部”的主流交互。但注意adjustResize只负责“窗口高度变了”至于页面里的 RecyclerView要不要滚动到底部、输入框要不要避让这些逻辑它管不着需要我们自己在Fragment里做。1.3 “只影响当前Fragment”到底要解决什么把需求翻译成技术语言其实有三件事要做第一键盘弹出时只有当前正在操作的Fragment的输入区域做出正确避让。其他不可见的Fragment不能因为窗口被压缩就产生“隐藏的布局错乱”。第二Fragment切换时键盘要主动收起不能把上一个页面的输入状态带到下一个页面。第三对不可见Fragment的监听器要暂停或移除不要在后台Fragment里做滚动、避让这些无意义操作。这三件事缺一不可。如果你只做第一件会发现每次切换页面键盘都还挂着新页面一加载就被顶起如果你只做第二件键盘是收起了但当前页面的RecyclerView和输入框之间又容易出现避让不及时的情况。所以“只影响当前Fragment”这个需求本质上是一套组合拳Window配置管地基Fragment监听管避让生命周期管理管状态回收。2. 三种主流方案选型为什么我最终选了“基类监听”组合2.1 方案A直接adjustResize全局生效最省事但不完全满足需求新手最容易走的路就是管他三七二十一先把Activity的windowSoftInputMode设成adjustResize然后发现聊天页面竟然能用了。一个Fragment、只有一个输入框的Activity这招完全没问题。但一旦页面多了问题就来了。假设你的Activity里有三个Fragment通过底部Tab切换其中一个Fragment里是搜索框另一个Fragment里是聊天输入框。你切到搜索页弹出键盘搜索收起键盘后切到聊天页。这时候因为整个窗口经历过一次“被压缩再恢复”某些View的宽高、滚动位置可能会有偏差——尤其是在你用ScrollView或者动态添加View的时候偶尔会出来“页面底部多了一块空白”的诡异现象。而且更隐蔽的是这样全局设置之后所有的Fragment都会感知到窗口尺寸变化。假如你在每个Fragment里都有类似的布局监听逻辑那么后台Fragment也会跟着执行计算。这就是典型的“我只想要当前Fragment响应结果所有Fragment都在响应”。所以方案A只能作为地基不能作为完整答案。2.2 方案BadjustNothing 手动滚动能做精细控制但工作量大如果你追求极致的可控性把windowSoftInputMode设成adjustNothing然后自己监听键盘的显示和隐藏自己计算键盘高度自己把输入框顶上去自己滚动RecyclerView……说实话这套逻辑我早年也写过最后发现它就是把系统已经做过的事情重做了一遍而且做得还不一定有系统好。最大的问题在于兼容性。不同厂商的ROM对键盘高度、导航栏高度、状态栏沉浸式的处理都有差异你在自己手机上测没问题换一台机器就翻车。除非你的页面有非常特殊的交互需求比如输入面板里还有自定义的表情面板、录音面板需要和键盘做无缝联动否则我真不建议一上来就走这条重路。工作量大性价比低。2.3 方案CadjustResize作为地基 Fragment级键盘状态感知基类推荐这个方案是我在多个实际项目里打磨出来的核心思路只有一句话让系统去做它擅长的事窗口压缩让Fragment去做它擅长的事知道自己该不该滚动、该不该避让两边通过一个“键盘状态感知基类”连接起来。具体来说Activity保持adjustResize让键盘弹出时窗口高度正常被压缩。然后写一个BaseKeyboardFragment基类把“监听键盘显示/隐藏、计算键盘高度、提供回调方法”这些公共能力封装进去。哪个Fragment需要处理键盘就继承这个基类重写onKeyboardShow和onKeyboardHide两个方法处理自己的布局滚动或避让逻辑。不需要处理键盘的Fragment就保持普通Fragment完全不受到影响。这样下来代码侵入性很小也不用手动监听全局事件更不会出现“后台Fragment也在做无意义计算”的问题。因为每个Fragment只管自己的回调而且我们可以利用Fragment的生命周期来控制监听器的注册和注销保证只有前台的Fragment才会收到键盘状态变化。2.4 三种方案对比总结方案实现难度可控性兼容性适合场景全局adjustResize低低中单Fragment、简单页面adjustNothing 手动滚动高高低依赖机型适配高度自定义输入面板、全屏沉浸式场景adjustResize 基类监听中中高高各版本表现稳定多Fragment聊天页、评论页、搜索页我自己现在的默认选择基本都是方案C。它在可控性和工作量之间取了一个很好的平衡点。下面我会把这个方案的核心代码全部贴出来逐行讲清楚保证你能直接抄走用。3. 实操落地写一个BaseKeyboardFragment一行接入所有输入页3.1 先把AndroidManifest和根布局配置好在代码实现之前先把地基打好。在AndroidManifest.xml里给承载Fragment的Activity加上软键盘模式activity android:name.MainActivity android:windowSoftInputModeadjustResize|stateHidden /这里有两个关键点。第一个是adjustResize明确告诉系统窗口高度可以被压缩这是后面所有逻辑能成立的前提。第二个是stateHidden它的作用是Activity启动时不要自动弹出键盘。如果你不加这个一旦页面里有EditText在onCreate阶段拿到了焦点键盘就会自动弹出来尤其是在多Fragment切换的场景下体验会非常突兀。接下来是根布局的设计。这里有一个经常被忽略的坑不要在承载输入区域的Fragment根布局上用ScrollView。为什么因为adjustResize模式下窗口高度被压缩后ScrollView会主动收缩自己的可滚动区域这本来没什么问题但键盘弹出后ScrollView的高度已经变了你再想精准地把某个EditText滚动到可见区域计算会变得很麻烦而且容易出现“键盘收起了ScrollView却没能恢复”的布局错乱。如果你需要让内容区域可滚动我的建议是根布局用FrameLayout或LinearLayout高度match_parent内容区用RecyclerView来实现滚动。这是最贴合方案C的布局结构。下面是一个典型的聊天页面布局FrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent androidx.recyclerview.widget.RecyclerView android:idid/recyclerMessage android:layout_widthmatch_parent android:layout_heightmatch_parent android:clipToPaddingfalse android:paddingBottom8dp / EditText android:idid/etInput android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_gravitybottom android:hint请输入内容 / /FrameLayout注意RecyclerView的clipToPadding设为false这样即使底部有padding列表内容也可以顺畅滚动到最底部不会因为padding被裁剪而滚不到位。这个细节在键盘操作中非常实用。3.2 BaseKeyboardFragment核心代码逐行解读接下来就是重头戏。写一个BaseKeyboardFragment把键盘状态监听能力封装好。我先把完整代码放出来再逐段解释package com.example.framework; import android.content.Context; import android.graphics.Rect; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.inputmethod.InputMethodManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; public abstract class BaseKeyboardFragment extends Fragment { /** 屏幕高度的15%作为判断键盘是否弹出的阈值 */ private static final float KEYBOARD_VISIBLE_THRESHOLD 0.15f; private View rootView; private boolean isKeyboardVisible false; private ViewTreeObserver.OnGlobalLayoutListener globalLayoutListener; Nullable Override public View onCreateView(NonNull LayoutInflater inflater, Nullable ViewGroup container, Nullable Bundle savedInstanceState) { rootView inflater.inflate(getLayoutResId(), container, false); return rootView; } Override public void onViewCreated(NonNull View view, Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); registerKeyboardListener(view); } /** * 子类返回自己的布局id */ protected abstract int getLayoutResId(); /** * 注册全局布局监听器用来感知键盘状态 */ private void registerKeyboardListener(View view) { globalLayoutListener () - notifyKeyboardStateChanged(); view.getViewTreeObserver().addOnGlobalLayoutListener(globalLayoutListener); } /** * 根据窗口可见区域计算键盘是否弹出并分发回调 */ private void notifyKeyboardStateChanged() { if (rootView null) { return; } Rect visibleRect new Rect(); rootView.getWindowVisibleDisplayFrame(visibleRect); int screenHeight rootView.getRootView().getHeight(); int keyboardHeight screenHeight - visibleRect.bottom; if (keyboardHeight screenHeight * KEYBOARD_VISIBLE_THRESHOLD) { if (!isKeyboardVisible) { isKeyboardVisible true; onKeyboardShow(keyboardHeight); } } else { if (isKeyboardVisible) { isKeyboardVisible false; onKeyboardHide(); } } } /** * 键盘弹出回调子类只需关注这个方法 */ protected void onKeyboardShow(int keyboardHeight) { } /** * 键盘收起回调 */ protected void onKeyboardHide() { } Override public void onDestroyView() { super.onDestroyView(); if (rootView ! null globalLayoutListener ! null) { rootView.getViewTreeObserver().removeOnGlobalLayoutListener(globalLayoutListener); } globalLayoutListener null; rootView null; isKeyboardVisible false; } }这段代码看起来不长但里面有几个细节是我踩过坑后特意加进去的给你逐个拆第一keyboardHeight的计算逻辑。getWindowVisibleDisplayFrame返回的是当前窗口的可见区域当键盘弹出时visibleRect.bottom会变成键盘顶部所在的坐标。用根布局的屏幕高度减去这个值就是键盘大致占用的高度。为什么用rootView.getRootView().getHeight()而不是getResources().getDisplayMetrics().heightPixels因为前者在旋转、分屏、多窗口场景下更准确后者拿到的是整个屏幕的逻辑高度容易偏差。第二阈值判断。我用了屏幕高度的15%作为阈值而不是判断keyboardHeight 0。原因有二一是部分机型在导航栏切换隐藏/显示时visibleRect.bottom会变化但并不是键盘弹出二是某些ROM在键盘收起动画过程中会有一段“高度残留”阈值可以过滤掉这些噪声。实际测试下来15%这个值对绝大多数手机都有效。第三isKeyboardVisible标志位。这个标志位解决的是重复回调问题。OnGlobalLayoutListener不是只在键盘弹出时回调一次而是在布局每发生一次变化时都会回调键盘弹跳动画期间可能回调几十次。加了标志位之后只有“键盘从隐藏变成显示”那一刻才会执行一次onKeyboardShow。否则你的RecyclerView会频繁滚动体验非常差。第四onDestroyView里移除监听。这一步是防内存泄漏的关键。Fragment的视图销毁了但ViewTreeObserver如果还持有监听引用就会导致无法被GC回收。而且如果不移除切换到别的Fragment时这个监听器还会继续回调就会出现“后台Fragment也在执行滚动逻辑”的诡异现象。3.3 子页面接入示例聊天Fragment自动滚到最后一条消息基类写好了接入的成本就非常低了。拿最常见的聊天页举例Fragment继承BaseKeyboardFragment实现getLayoutResId方法然后在onKeyboardShow里让消息列表自动滚到底部同时在onViewCreated里把RecyclerView滑到最底public class ChatFragment extends BaseKeyboardFragment { private RecyclerView recyclerMessage; Override protected int getLayoutResId() { return R.layout.fragment_chat; } Override public void onViewCreated(NonNull View view, Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); recyclerMessage view.findViewById(R.id.recyclerMessage); // 刚进页面时直接滚到底部 recyclerMessage.scrollToPosition(adapter.getItemCount() - 1); } Override protected void onKeyboardShow(int keyboardHeight) { super.onKeyboardShow(keyboardHeight); // 键盘弹出时把列表滚动到最后一条消息 if (adapter ! null adapter.getItemCount() 0) { recyclerMessage.smoothScrollToPosition(adapter.getItemCount() - 1); } } Override protected void onKeyboardHide() { super.onKeyboardHide(); // 键盘收起时也可以做点什么比如重置输入框状态、收起表情面板等 } }这段代码的意图非常清晰页面只关心自己该做什么。键盘弹出列表滚到底键盘收起恢复状态。什么都不用管其他Fragment在干嘛也不用去处理Window层面的逻辑。这种“各管各的”思路正是方案C的精髓也让多个Fragment共用一个Activity时键盘的影响范围真正被限制到了“当前操作的页面”。3.4 进阶用WindowInsets监听IMEAPI 30的现代写法如果你的应用最低版本已经支持API 30以上或者你在做沉浸式状态栏适配那么还可以用更现代的WindowInsets方案来替代ViewTreeObserver监听。这种写法更贴近系统设计意图而且不需要计算keyboardHeight因为系统已经帮你把IME输入法的insets计算好了。public class ModernKeyboardFragment extends Fragment { private View rootView; Override public void onViewCreated(NonNull View view, Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); rootView view; ViewCompat.setOnApplyWindowInsetsListener(view, (v, insets) - { Insets imeInsets insets.getInsets(WindowInsetsCompat.Type.ime()); if (imeInsets.bottom 0) { onKeyboardShow(imeInsets.bottom); } else { onKeyboardHide(); } return insets; }); } protected void onKeyboardShow(int keyboardHeight) { } protected void onKeyboardHide() { } Override public void onDestroyView() { super.onDestroyView(); rootView null; } }这套写法有个前提WindowCompat.setDecorFitsSystemWindows(window, false)也就是让内容真正延伸到系统栏后面。如果你还是默认的true那么系统会在窗口层级帮你处理软键盘的resizeIME insets的变化不一定能直接反馈到Fragment的View上。所以要不要用这套方案取决于你的整体窗口策略如果项目还在用传统的fitsSystemWindows方案那我建议还是用前面的ViewTreeObserver版本兼容性更好逻辑也更统一。4. 这些坑我都帮你踩过了常见问题排查与实录4.1 沉浸式状态栏下adjustResize突然失效这是我遇到最多的问题也是最容易让新手崩溃的。明明已经设置了adjustResize键盘弹出来时窗口纹丝不动输入框被键盘死死挡住。如果你用了沉浸式状态栏也就是让布局延伸到了状态栏后面通常意味着你在Activity里调用了WindowCompat.setDecorFitsSystemWindows(window, false)或者给根布局设置了systemUiVisibility的沉浸样式。在这种配置下系统默认不再为软键盘调整窗口高度因为“可见区域”本来就是全屏延伸的键盘弹出相当于把一部分screen区域遮住了窗口高度在逻辑上并没有变化。解决办法有两种。如果你坚持用沉浸式布局那就必须用上一节讲的WindowInsets方案通过监听IME insets手动处理输入框避让。如果你只是想让页面不出问题更简单的方法是给Fragment根布局设置fitsSystemWindowstrue强制系统把状态栏区域让出来让adjustResize重新生效。但要注意fitsSystemWindows对子View的padding也会产生影响需要结合具体布局微调。4.2 OnGlobalLayoutListener重复回调与内存泄漏不少人在Fragment里加了全局布局监听后发现在键盘动画过程中方法被疯狂调用甚至退出了页面还在调用。这就是没有做好两件事一是没有用标志位过滤重复回调二是在onDestroyView里没有移除监听。标志位的问题我在前面的代码里已经处理过了isKeyboardVisible就是干这个的。内存泄漏的问题则需要你养成习惯只要在onCreateView或onViewCreated里注册了监听就必须在onDestroyView里注销。尤其是Fragment这种生命周期复杂的组件onDestroyView和onDestroy是两回事视图销毁不代表Fragment销毁监听器如果不移除就会一直持有已销毁的View引用。还有一种更隐蔽的情况你用了ViewPager2Fragment会被提前创建。假设当前显示的是第一页第二页虽然不可见但也被创建了并且注册了监听。键盘弹出时由于第二页的View已经在视图树里了它也会收到OnGlobalLayout回调。这时候你要么在onResume里再额外判断一下页面是否可见要么在setUserVisibleHint或onHiddenChanged里动态启停监听。我的经验是ViewPager2场景下最稳妥的做法是在onResume时注册、onPause时注销而不是只在onDestroyView里处理。4.3 Fragment切换时键盘残留导致新页面布局错乱这个问题出现的场景非常固定在A Fragment里输入文字然后不收起键盘直接切换Tab到B FragmentB页面顶部的搜索框自动获取焦点键盘顺势弹出来把B页面顶得乱七八糟。解决办法是在Fragment切换的时候主动收起键盘。推荐在两个地方处理一是在Fragment的onPause里二是在onHiddenChanged里。前者适合Tab切换后者适合add/hide或replace方式管理Fragment的情况。Override public void onPause() { super.onPause(); hideKeyboard(); } private void hideKeyboard() { InputMethodManager imm (InputMethodManager) requireActivity() .getSystemService(Context.INPUT_METHOD_SERVICE); if (imm ! null getActivity() ! null getActivity().getCurrentFocus() ! null) { imm.hideSoftInputFromWindow(getActivity().getCurrentFocus().getWindowToken(), 0); } }这里有一个常见的反面操作在切换Fragment时调用editText.clearFocus()。一定要注意clearFocus在某些ROM上会先触发一次focus变化紧接着又因为布局里另一个可输入控件而重新获取焦点造成键盘闪一下又弹出来的效果。我的做法是只调hideSoftInputFromWindow不做clearFocus让EditText保留焦点状态但键盘被主动隐藏。这样切走后就算系统因为某种原因重新弹键盘至少不会出现“焦点被清空后再恢复”的二次弹跳。4.4 常见问题速查表现象根本原因解决方案键盘弹出后窗口完全不动沉浸式布局覆盖了adjustResize效果改用WindowInsets方案或给根布局设置fitsSystemWindowstrue键盘弹出后输入框仍被遮挡根布局是ScrollView避让逻辑混乱改用RecyclerView实现滚动或监听键盘高度手动scrollTo键盘动画过程中逻辑执行多次OnGlobalLayoutListener频繁回调使用isKeyboardVisible标志位只在状态变化时执行退出Fragment后键盘还残留没有在生命周期里收起键盘onPause里调用hideSoftInputFromWindow切换Tab后新页面布局被顶起键盘状态没有在新页面产生前被清理Fragment切换前主动收起键盘并让新页面监听自己的键盘状态个别机型键盘收起后底部留白ROM对adjustResize处理不彻底在onKeyboardHide里调用rootView.requestLayout()强制重新布局这张表里的每一条都是我实际调试过程中一条条填进去的。尤其是“键盘收起后底部留白”这个问题以前在某个国产ROM上出现过最后不是靠调整布局属性解决的而是在键盘收起时强制触发了一次重绘。这种问题就是这样排查链路长但解法可能只是一行代码。5. 最后再分享一点我的实际体会这套BaseKeyboardFragment的方案我在最近两三个项目里一直在用。从最开始的聊天工具页面到后来接评论列表、搜索历史、编辑资料这类大大小小的输入场景基本都没有再为键盘适配问题发过愁。核心原因不是代码有多高级而是思路对了键盘是窗口层面的东西Fragment不要试图去“控制”键盘而是要管好自己的响应逻辑。如果你现在正在被Android软键盘和多Fragment共存的问题折磨我的建议是不要一开始就陷入各种厂商适配和黑科技里。先把windowSoftInputMode配置成adjustResize然后写一个自己的BaseKeyboardFragment基类把你的输入转场逻辑、滚动逻辑、生命周期管理都放进去后面所有页面都按这个模式去套。等这套地基稳了你再遇到沉浸式、分屏、多窗口之类的进阶需求心里也有底了。另外有个小技巧平时调试键盘相关的问题时可以在BaseKeyboardFragment里临时加一个Toast或者Log把每次onKeyboardShow和onKeyboardHide的keyboardHeight打出来看看。你会对“键盘高度到底是多少”“动画过程中回调了几次”有一个非常直观的感知排查问题的时候会快很多。这帮了我大忙分享给你。