避讓:從adjustNothing到WindowInsets的實踐)
做Android開發(fā)這些年軟鍵盤絕對是最容易讓人血壓飆升的東西之一。尤其是當(dāng)頁面結(jié)構(gòu)從“一個Activity包一個頁面”變成“一個Activity裝了好幾個Fragment”之后麻煩就更明顯了你在當(dāng)前Fragment的輸入框里敲字系統(tǒng)直接按整個Activity的window來調(diào)整尺寸于是相鄰Fragment、底部操作欄、甚至頂部的標(biāo)題欄全都跟著鍵盤一起“跳”上去。這篇文章就是來聊這個經(jīng)典問題的根治思路讓彈出鍵盤只影響當(dāng)前正在輸入的Fragment其他兄弟Fragment紋絲不動。從底層原因、方案選型、手寫代碼到線上才能踩到的坑我盡量一次性拆干凈。適合正在改造Fragment化頁面、或者被鍵盤彈出“全局搬家”折磨過的Android開發(fā)參考。1. 問題復(fù)盤為什么鍵盤彈出后整個界面都跟著遭殃1.1 現(xiàn)象復(fù)現(xiàn)一個Fragment敲字整個Activity都被重新布局先描述一下最典型的場景。應(yīng)用主頁是底部Tab結(jié)構(gòu)MainActivity持有一個Fragment容器里面裝了首頁Fragment、消息Fragment、我的Fragment。每個Fragment都有自己的布局其中消息Fragment里有一個輸入框和一個RecyclerView。這時候你在消息Fragment的輸入框里打字軟鍵盤彈出來會發(fā)生什么如果你的Application沒有做過特殊處理默認情況下整個窗口會被壓縮。也就是說首頁Fragment、我的Fragment的布局也會被重新測量、重新布局整個Activity的content區(qū)域高度變小了底部Tab欄會被頂?shù)芥I盤上方其他Fragment里的RecyclerView也跟著“縮”了一截。等你收起鍵盤布局又變回來。整個過程中頁面瘋狂跳變視覺上就是“整個界面都在動”。很多開發(fā)者一開始會把鍋甩給Fragment以為是Fragment的問題其實這里有個關(guān)鍵認知要糾正軟鍵盤彈出時系統(tǒng)調(diào)整的是Activity的window也就是整個窗口不是某一個Fragment的布局。Fragment在Android里只是一個容器化的視圖管理層它沒有自己獨立的window也沒有自己的windowSoftInputMode。所以你在任何Fragment里輸入鍵盤彈出受影響的永遠是這個Activity的整體界面。1.2 追根究底windowSoftInputMode跟Fragment沒關(guān)系android:windowSoftInputMode這個屬性看名字就知道它作用于window。它放在Activity或主題的配置里由WindowManager負責(zé)解析和執(zhí)行。它支持的幾個值你們都眼熟adjustResize、adjustPan、adjustNothing、stateHidden、stateAlwaysHidden等控制的是軟鍵盤彈出和收起時整個window的尺寸或位置怎么變化。但Fragment并沒有類似的屬性位。你無法在Fragment上聲明“我這個Fragment用adjustResize那個用adjustNothing”因為所有Fragment都住在同一個window里。這也是問題的根源系統(tǒng)層面只認window不認Fragment。所以如果你在布局文件里給根布局設(shè)置了android:fitsSystemWindowstrue并且Activity用了adjustResize那么鍵盤彈出時整個window的可用高度變小系統(tǒng)bars區(qū)域和ime區(qū)域會通過WindowInsets通知下來所有子View都會跟著重新布局。沒有做過濾的話無論哪個Fragment在輸入所有Fragment都會響應(yīng)這次尺寸變化。結(jié)果是A Fragment在輸入B Fragment的布局也“縮水”了看起來特別奇怪。1.3 adjustResize和adjustPan的局限先說說大家最常用的兩種模式以及它們的局限。adjustResize的思路是鍵盤彈出后系統(tǒng)直接壓縮window的可用高度讓布局重新測量。這種方式在單個頁面的場景下挺好用輸入框被頂上去頁面底部內(nèi)容進入鍵盤上方區(qū)域用戶能看清自己輸入的內(nèi)容。但在Fragment多實例共存的場景下它是“一刀切”所有Fragment一起resize并不是只處理當(dāng)前輸入的那個。adjustPan則更粗暴鍵盤彈出后整個window向上平移直到焦點View可見為止。這種方式在頁面結(jié)構(gòu)簡單時也不錯但在ScrollView、RecyclerView、多Fragment組合布局下非常容易產(chǎn)生“頁面被頂飛”的問題而且輸入框可能在屏幕中間導(dǎo)致上半部分完全移出屏幕。也不適合精確控制。結(jié)論就是如果你想要鍵盤只影響當(dāng)前Fragment就必須跳出系統(tǒng)默認行為自己接管“鍵盤高度變化”的通知然后只對需要變化的Fragment做布局調(diào)整。2. 方案選型三種主流思路的對比與取舍2.1 用adjustPan硬扛治標(biāo)不治本最早我試過直接把Activity設(shè)置成adjustPan看看能不能“湊合”用。實測下來在簡單頁面還能忍一旦遇到多Fragment加嵌套滾動布局問題一堆。adjustPan做的是對整個window進行平移。假設(shè)當(dāng)前Fragment的EditText位于頁面下部鍵盤彈出后系統(tǒng)會把整個window往上推直到EditText可見。由于window整體平移其他Fragment也跟著移動而且如果Activity里有自定義的底部欄、懸浮按鈕它們會被一并推到鍵盤上方顯得非常違和。用adjustPan的時間越長越覺得這不是在解決問題只是把問題挪了個地方。2.2 用adjustResize加全局padding能用但誤傷隊友有人會想那我在根布局上監(jiān)聽WindowInsets然后統(tǒng)一給根布局加paddingBottom這樣不就把鍵盤高度讓出來了嗎可以但這同樣會作用于所有Fragment不是“只影響當(dāng)前Fragment”。如果你只有一個Fragment這個方案完全夠用沒必要搞復(fù)雜。但我們的前提是多個Fragment共存底部的Tab欄通常也需要保持固定。全局padding會導(dǎo)致Tab欄也被頂上去反而難以控制。所以在多Fragment場景下目標(biāo)要拆得很細全局window不變只讓當(dāng)前Fragment的內(nèi)容區(qū)域避開鍵盤。2.3 用adjustNothing加手動處理精準(zhǔn)、可控、不誤傷最終我選擇的路子是Activity設(shè)置adjustNothing完全關(guān)閉系統(tǒng)自動調(diào)整能力然后自己在Fragment層監(jiān)聽輸入法高度變化只對當(dāng)前可見、正在交互的Fragment做布局填充。好處很明顯其他Fragment完全不會感知到鍵盤彈出布局穩(wěn)定。當(dāng)前Fragment可以自由決定怎么處理鍵盤高度比如給根布局加padding、給RecyclerView加margin、或者滾動到指定位置。不會和fitsSystemWindows、狀態(tài)欄透明等復(fù)雜情況互相干擾。缺點也是有的所有事情都要自己寫而且需要處理Fragment可見性判斷、監(jiān)聽器的注冊和銷毀、低版本兼容這些細節(jié)。不過這些細節(jié)也就是今天文章的核心。2.4 三種方案直觀對比方案是否影響其他Fragment實現(xiàn)成本可控性適用場景adjustPan影響整體平移低低簡單頁面可臨時救急adjustResize 全局padding影響全部壓縮中中單一Fragment或全局都允許變化adjustNothing 手動監(jiān)聽不影響高高多Fragment共存、底部欄固定、鍵盤交互復(fù)雜3. 核心實現(xiàn)基于WindowInsets的精準(zhǔn)處理3.1 第一步在Manifest中關(guān)掉系統(tǒng)自動調(diào)整在AndroidManifest.xml里找到承載Fragment的Activity把windowSoftInputMode設(shè)置為adjustNothing|stateAlwaysHidden。activity android:name.MainActivity android:windowSoftInputModeadjustNothing|stateAlwaysHidden /stateAlwaysHidden的目的是進入頁面時不要自動彈出鍵盤避免啟動瞬間的布局抖動。如果你的頁面希望進入即聚焦輸入框可以去掉它但大多數(shù)業(yè)務(wù)場景還是建議保留。這一步做完之后你會發(fā)現(xiàn)鍵盤彈出時整個window完全不動了輸入框會被鍵盤蓋住。別慌這正是我們需要的“不被系統(tǒng)亂改”的初始狀態(tài)接下來所有控制都交給自己。3.2 第二步用WindowInsetsCompat監(jiān)聽輸入法高度Android 11API 30之后系統(tǒng)提供了穩(wěn)定的WindowInsets.Type.ime()可以直接拿到輸入法的高度。配合AndroidX里的WindowInsetsCompat可以在向后兼容的寫法里使用。這里要說明一下我在實際項目里最小支持到API 21一直用這套寫法沒有出現(xiàn)兼容性崩潰但低版本在個別ROM上拿不會ime insets需要配合第4節(jié)的兜底方案。核心代碼邏輯是在目標(biāo)Fragment的根布局上注冊一個OnApplyWindowInsetsListener從中取出ime類型的insets高度然后給根布局設(shè)置對應(yīng)的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 - // 只處理當(dāng)前可見且處于Resumed狀態(tài)的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復(fù)用后監(jiān)聽器還掛著 ViewCompat.setOnApplyWindowInsetsListener(binding.root, null) _binding null super.onDestroyView() } }這段代碼的核心就一行v.updatePadding(bottom imeInsets.bottom)。當(dāng)鍵盤彈出時imeInsets.bottom就是鍵盤高度當(dāng)前Fragment的根布局底部會增加一塊等于鍵盤高度的padding鍵盤收起時imeInsets.bottom變?yōu)?padding自動歸零。由于監(jiān)聽器只在當(dāng)前Fragment的根布局上注冊而且我們加了isVisible isResumed的判斷所以其他Fragment不會受任何影響。3.3 第三步為什么加isVisible和isResumed雙重判斷這一步很容易被忽略但恰恰是“只影響當(dāng)前Fragment”的關(guān)鍵。Fragment的onViewCreated注冊監(jiān)聽器后如果Fragment被切換走了比如被replace、hide、或者放進ViewPager2里切換到了別的頁它的View仍然存在監(jiān)聽器也不會自動移除。如果不做任何判斷鍵盤彈出時即使這個Fragment已經(jīng)不可見它的根布局也會跟著加padding。雖然用戶看不見但一旦切回來頁面底部會留一大塊空白體驗非常差。所以我在監(jiān)聽器里加了兩個條件isVisible判斷Fragment當(dāng)前是否處于Visible狀態(tài)包括它的View是否已經(jīng)attach且可見。isResumed判斷Fragment是否處于Resumed生命周期。在多Fragment疊加、或者需要和Activity交互的場景下這個判斷能過濾掉處于后臺、非交互狀態(tài)的Fragment。如果你用的是ViewPager2它默認會預(yù)加載相鄰Fragment這些Fragment處于STARTED或CREATED狀態(tài)isResumed為false所以不會被誤處理。這是我實測過的最穩(wěn)妥的雙保險。3.4 第四步處理底部導(dǎo)航欄和透明狀態(tài)欄如果你的頁面是沉浸式設(shè)計底部有NavigationBar區(qū)域直接updatePadding(bottom imeInsets.bottom)會漏掉導(dǎo)航欄的高度。上面代碼里我已經(jīng)做了處理val imeInsets insets.getInsets( WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars() )把ime和systemBars取或后取到的header就是兩個區(qū)域合并后的總高度imeInsets.bottom里就包含了在窗口底部出現(xiàn)的ime高度和系統(tǒng)導(dǎo)航欄高度。這樣即使你的根布局延伸到了導(dǎo)航欄后面padding也能正確覆蓋到底。這里有個經(jīng)驗之談不要單獨用WindowInsetsCompat.Type.ime()因為在高版本Android上如果頁面沒有延伸到系統(tǒng)欄后面ime insets可能不包含導(dǎo)航欄高度導(dǎo)致底部差一截。合并取一次更穩(wěn)妥。4. 兼容方案沒有WindowInsets時的兜底策略4.1 老版本和碎片化ROM用ViewTreeObserverWindowInsets.Type.ime()在API 30以上非常好用但在API 30以下尤其是一些深度定制的ROM上拿到的ime insets可能不準(zhǔn)或者根本收不到回調(diào)。這時候我建議用傳統(tǒng)方案兜底通過ViewTreeObserver.OnGlobalLayoutListener監(jiān)聽全局布局變化根據(jù)可見顯示區(qū)域的高度差算出鍵盤高度。核心邏輯是鍵盤彈出時window的可見顯示區(qū)域頂部不變底部被鍵盤遮擋所以可見區(qū)域高度變小。用根View的高度減去可見顯示區(qū)域的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() } }注意這里我用了一個閾值判斷有些執(zhí)行者喜歡寫成keyboardHeight screenHeight * 0.15才認為是鍵盤彈出避免導(dǎo)航欄、狀態(tài)欄變化導(dǎo)致誤判。具體閾值可以根據(jù)頁面實際元素調(diào)整我習(xí)慣用這個比例能在絕大多數(shù)機器上過濾掉底部導(dǎo)航條的干擾。這個方案的缺點是每次布局變化都會回調(diào)性能上略吃虧但只要監(jiān)聽器只掛在當(dāng)前Fragment頁面結(jié)構(gòu)不復(fù)雜實測完全沒問題。如果追求極致流暢還可以在回調(diào)里加一個防抖避免同一幀內(nèi)重復(fù)設(shè)置padding。4.2 鍵盤高度緩存避免每次重復(fù)測量無論用哪種監(jiān)聽方式鍵盤高度其實在同一個頁面上往往是固定的。與其每次鍵盤彈出都重新測量、重新計算不如緩存一下。我的做法是維護一個全局的KeyboardHeightProvider用ViewModel或者單例都行核心是保存鍵盤高度值。第一次測量到后存起來后續(xù)這次彈起直接使用緩存。object KeyboardHeightHolder { Volatile var keyboardHeight: Int 0 }在handleImeInsets或者globalLayout回調(diào)里每次拿到imeInsets.bottom時同步寫一下緩存if (imeInsets.bottom 0) { KeyboardHeightHolder.keyboardHeight imeInsets.bottom }這樣多個Fragment切換時新Fragment切入瞬間就可以先用緩存高度做布局調(diào)整等監(jiān)聽到最新高度再校正。鍵盤彈出動畫期間也不會出現(xiàn)明顯的延遲跳變。4.3 處理鍵盤動畫中途的“抖動”還有一種典型問題鍵盤彈出過程中高度是逐漸增加的。如果監(jiān)聽器每幀都觸發(fā)padding也就會跟著一幀一幀變理論上這其實能實現(xiàn)細膩的跟隨動畫。但在部分低端機上布局重新測量比鍵盤動畫慢會出現(xiàn)“界面一抖一抖”的情況。我踩過這個坑之后做了一個簡單策略以50毫秒為窗口做節(jié)流每次收到ime高度變化時記錄時間戳如果距離上次處理不到50毫秒就忽略超過就直接設(shè)置padding。這樣布局更新的頻率會明顯減少絕大多數(shù)設(shè)備上肉眼看不出差別但掉幀感會緩解很多。如果用WindowInsets方案其實系統(tǒng)已經(jīng)幫我們做了大部分幀同步這個問題不太明顯但在ViewTreeObserver兜底方案里節(jié)流是很有必要的。5. 實操中常見的坑與排查技巧5.1 問題速查表現(xiàn)象原因處理方法鍵盤彈出后所有Fragment都動Activity使用了adjustResize改為adjustNothing當(dāng)前Fragment根布局加padding后底部欄被頂起把監(jiān)聽器掛到了Activity的decorView改掛Fragment自己的根布局切換Fragment回來時底部有大塊空白監(jiān)聽器沒有正確移除或可見性判斷缺失加isVisible和isResumed判斷并在onDestroyView移除鍵盤高度不準(zhǔn)底部差一截沒合并systemBars insets用ime or systemBars合并取鍵盤收起后布局沒恢復(fù)只處理了非0高度沒處理0高度統(tǒng)一在回調(diào)里設(shè)置padding0時重置低版本收不到insets回調(diào)ROM的ime insets兼容問題用ViewTreeObserver兜底鍵盤彈出時輸入框仍被遮擋只加了padding沒做滾動在監(jiān)聽里同時調(diào)用scrollTo/scrollBy讓焦點可見5.2 Fragment切換時監(jiān)聽器失效怎么精準(zhǔn)控制如果你用的是show/hide來切換Fragment那么被hide的Fragment的isVisible會變?yōu)閒alse監(jiān)聽器里的判斷會阻止它做padding沒問題。如果你用的是replace舊的Fragment會走onDestroyView我們在onDestroyView里移除了監(jiān)聽器也清空了binding沒問題。如果你用的是ViewPager2相鄰頁面由于預(yù)加載機制存在它們處于可見狀態(tài)但不在Resumed狀態(tài)所以isResumed判斷會起到關(guān)鍵作用。這三種情況我都實際驗證過放心用。還有一個小坑是Fragment在onCreateView里創(chuàng)建View時如果它在后臺被重建bind到View上的監(jiān)聽器可能收不到最新的insets。這時候可以在onStart里主動調(diào)一下rootView.requestApplyInsets()強制系統(tǒng)重新下發(fā)一次insets。這個操作不會帶來明顯開銷能避免很多“切回來布局不對”的詭異問題。5.3 全屏模式和劉海屏的差異如果Activity是全屏模式或者decorView設(shè)置了systemUiVisibility為沉浸式WindowInsets的收取方式會發(fā)生變化。有些手機上ime insets會和系統(tǒng)欄insets分開返回合并獲取就更加必要。還有一些機型在全屏下用ViewTreeObserver方案測算時getWindowVisibleDisplayFrame返回的可見區(qū)域頂部可能不是0計算鍵盤高度時要用screenHeight - rect.bottom不要用rect.top參與計算否則會在劉海屏上多算一塊高度。這里不再展開所有case但記住一條原則先確保自己頁面的fitsSystemWindows和狀態(tài)欄處理方式在全App是統(tǒng)一的再疊加鍵盤邏輯不然很容易兩者相互干擾。5.4 監(jiān)聽器泄漏和重復(fù)注冊問題這個問題最常見的是把OnGlobalLayoutListener加到了Activity的rootView上然后Activity一直被ViewModel或單例引用導(dǎo)致無法釋放。我強烈建議監(jiān)聽器只注冊在Fragment自己的根布局上并且在onDestroyView里移除。如果某個流程需要在Activity級做統(tǒng)一處理記得也要在onDestroy里移除。還有一種重復(fù)注冊的場景Fragment的onCreateView被系統(tǒng)重建多次比如配置變更、進入后臺再回前臺時View重建。如果你沒有在onDestroyView里把監(jiān)聽器置空新的View會疊加舊的監(jiān)聽器導(dǎo)致同樣的padding被設(shè)置兩次。設(shè)置同一數(shù)值倒不至于崩但如果監(jiān)聽器里還有滾動邏輯就可能出現(xiàn)“每次彈鍵盤頁面都會跳一下”。所以必須養(yǎng)成在onDestroyView里統(tǒng)一清理綁定的習(xí)慣。5.5 用工具檢查布局層級如果實在排查不出來我教大家一個土辦法在鍵盤彈出前后用Android Studio的Layout Inspector抓兩次布局比對根View的高度和padding差異。這個方法能直接把某個Fragment的padding變化暴露出來快速定位是哪個監(jiān)聽器在作怪。我排查多個Fragment互相干擾的問題時基本靠這一招就能鎖定目標(biāo)。6. 最后分享一點我的使用體驗我目前把adjustNothing加WindowInsets改成默認方案并把ViewTreeObserver作為低版本兜底整個方法抽成了一個BaseImeFragment基類任何Fragment想支持鍵盤避讓只要繼承并傳入需要調(diào)整的根布局就行。實際跑了一段時間后最明顯的感受是以前那種鍵盤一彈、頁面整個“搬家”的體驗徹底消失了只有正在輸入的Fragment會做出響應(yīng)底部的Tab欄穩(wěn)定得一筆用戶幾乎感受不到其他頁面的存在。這套方案不依賴第三方庫也沒有魔法理解原理之后半小時就能寫完穩(wěn)定性反而比亂七八糟的開源庫更可控。如果你正被類似的Fragment鍵盤問題困擾可以按這個思路試一遍。我踩過的那些坑希望你不需要重新踩一遍。