問題背景
最近在項目中遇到一個問題:在檔口分享功能中,需要動態生成一個分享圖片。代碼是這樣寫的:
// 項目中的代碼
val shareView = LayoutInflater.from(this@StallMainActivityV1).inflate(R.layout.share_header_stall_main_layout, null)
這個寫法本身是正確的,但是在自定義的 AvatarView
中,頭像加載的代碼執行不到:
iv_avatar.post {// 這里的代碼執行不到!val w = if (iv_avatar.width > 0) iv_avatar.width else sizeval h = if (iv_avatar.height > 0) iv_avatar.height else sizeGlideKHelper.loadImageToBitmap(...) { ... }
}
LayoutInflater.inflate() 方法
基本語法
LayoutInflater.inflate()
最常用的重載方法是:
inflate(resource: Int, root: ViewGroup?, attachToRoot: Boolean): View
其中的attchToRoot缺省情況下:
root != null → attachToRoot = true
root == null → attachToRoot = false
參數詳解
1. resource (Int) - 布局資源ID
這個沒什么好說的,就是你要加載的XML布局文件的資源ID,比如 R.layout.my_layout
。
2. root (ViewGroup?) - 父容器
這個參數很關鍵,很多人容易搞錯:
- 可以傳 null:創建一個獨立的View
- 可以傳具體的ViewGroup:為新創建的View提供LayoutParams參數
重點來了:這個參數的作用主要是為了讓新創建的View知道自己的LayoutParams應該是什么樣的。
3. attachToRoot (Boolean) - 是否立即添加到父容器
true
:立即將新View添加到root中,返回的是rootfalse
:不添加到root中,但使用root的LayoutParams,返回的是新創建的View
總結
root參數 | attachToRoot | 返回值 | 說明 |
---|---|---|---|
null | false(默認) | 布局文件根View | 獨立View,使用XML中定義的LayoutParams |
ViewGroup | false | 布局文件根View | 獨立View,但使用parent的LayoutParams |
ViewGroup | true(默認) | parent | 布局文件根View已添加到parent中 |
常見的幾種用法
用法1:創建獨立View(分享、Dialog等場景)
val view = LayoutInflater.from(context).inflate(R.layout.my_layout, null)
適用場景:分享圖片生成、PopupWindow、Dialog等不需要添加到現有布局的場景。
用法2:為RecyclerView創建ViewHolder
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_layout, parent, false)
為什么要傳parent:讓item知道自己應該用什么樣的LayoutParams。
為什么是false:因為RecyclerView會自己管理添加時機。
用法3:直接添加到父容器
val view = LayoutInflater.from(context).inflate(R.layout.child_layout, parentView, true)
注意:這種情況下返回的是parentView,不是新創建的View!
我們項目中的用法
項目中的代碼:
val shareView = LayoutInflater.from(this@StallMainActivityV1).inflate(R.layout.share_header_stall_main_layout, null)
這個寫法是正確的:
- 傳入
null
作為parent參數 - 創建了一個獨立的View,適合分享圖片生成場景
- 符合分享場景的使用規范
但是,這樣的寫法雖然正確,卻引出了另一個問題:View.post()
執行不到。
View.post() 工作原理詳解
View.post() 是干什么的?
簡單來說,View.post()
就是把一個任務扔到主線程的消息隊列里,等合適的時候再執行。
執行條件
View.post()
要正常工作,需要滿足幾個條件:
- View必須attached to window(附加到窗口)
- View必須在主線程的消息隊列中
- View必須有有效的Handler
為什么分享場景下執行不到?
在我們的分享場景中:
val shareView = LayoutInflater.from(context).inflate(R.layout.share_header_stall_main_layout, null)// shareView沒有被添加到任何父容器中!
// 所以它沒有attached to window
// 因此View.post()不會執行
關鍵點:這個 shareView
只是一個孤立的View對象,它沒有被添加到Activity的視圖層次結構中。
View的生命周期
要理解這個問題,需要了解View的生命周期:
- 創建:通過LayoutInflater創建View對象
- 測量:measure() - 確定View的大小
- 布局:layout() - 確定View的位置
- 繪制:draw() - 把View畫出來
只有當View被添加到視圖層次結構中時,才會經歷完整的生命周期。
當前生命周期狀態
在上述分享場景中,通過 LayoutInflater.inflate()
創建的 shareView
僅僅完成了 創建 階段:
- ? 創建: View對象已通過
LayoutInflater.inflate()
實例化 - ? 測量: 由于沒有添加到視圖層次結構中,measure()未執行,width/height都是0
- ? 布局: 沒有父容器,layout()未執行,位置未確定
- ? 繪制: 沒有進入視圖層次結構,draw()未執行,無法顯示
這就是為什么此時調用 View.post()
會失效 - View還處于"孤立"狀態,沒有進入完整的生命周期流程。只有將View添加到Activity的視圖層次結構中(比如通過 addView()
方法),才會觸發后續的測量、布局和繪制過程。
實際的問題表現
在 StallAvatarView
中:
iv_avatar.post {// 這里執行不到的原因:// 1. iv_avatar沒有attached to window// 2. iv_avatar.width 和 iv_avatar.height 都是0val w = if (iv_avatar.width > 0) iv_avatar.width else sizeval h = if (iv_avatar.height > 0) iv_avatar.height else size// ...
}
解決方案
方案1:檢查View狀態(推薦)
使用isAttachedToWindow,也是我最后修復這個歷史問題所使用的解決方案:
isAttachedToWindow 的工作原理
isAttachedToWindow
是 View 類中的一個屬性,用于判斷當前 View 是否已經被添加到視圖層次結構中。它的工作原理如下:
1. 狀態變化時機
- 當 View 通過
addView()
等方法被添加到 Window 時,會調用onAttachedToWindow()
,此時isAttachedToWindow = true
- 當 View 通過
removeView()
等方法從 Window 中移除時,會調用onDetachedFromWindow()
,此時isAttachedToWindow = false
2. 生命周期流程
View 的生命周期狀態轉換過程如下:
- View 對象創建后,初始狀態
isAttachedToWindow = false
- 調用
Activity.setContentView()
或ViewGroup.addView()
時:- 觸發
onAttachedToWindow()
- 設置
isAttachedToWindow = true
- 開始 measure、layout、draw 等生命周期
- 觸發
- 調用
ViewGroup.removeView()
或 Activity 銷毀時:- 觸發
onDetachedFromWindow()
- 設置
isAttachedToWindow = false
- 停止生命周期,釋放資源
- 觸發
3. 實際應用價值
檢查 isAttachedToWindow
的主要作用:
- 避免無效操作 - 在 View 未添加到窗口時,很多操作(如
post()
)都無法正常執行 - 判斷時機 - 可以用來判斷是否可以進行需要 View 完成布局后才能執行的操作
- 防止內存泄漏 - 在
onDetachedFromWindow()
時及時釋放資源 - 控制生命周期 - 自定義 View 時在合適的時機執行初始化和清理
因此可以這樣調整代碼:
- 在進行 View 相關異步操作前,先檢查
isAttachedToWindow
狀態 - 對于未 attached 的情況,可以采用備選方案(如使用預設的默認值)
fun showAvatarOrFirstChar(supply_avatar: String,supply_name: String,// ... 其他參數avatarComplete: (() -> Unit)? = null
) {// ... 前面的代碼if (supply_avatar.isBlank()) {// 處理無頭像情況avatarComplete?.invoke()} else {// 設置View狀態tv_avatar.hide()iv_avatar.view()// 關鍵:檢查View是否已經attachedif (isAttachedToWindow) {// 正常情況:View已經在視圖層次結構中iv_avatar.post {val w = if (iv_avatar.width > 0) iv_avatar.width else sizeval h = if (iv_avatar.height > 0) iv_avatar.height else sizeloadAvatar(supply_avatar, w, h, avatarComplete)}} else {// 特殊情況:View還沒有attached(比如分享場景)// 直接使用傳入的size參數loadAvatar(supply_avatar, size, size, avatarComplete)}}
}private fun loadAvatar(supply_avatar: String,width: Int,height: Int,avatarComplete: (() -> Unit)?
) {GlideKHelper.loadImageToBitmap(context, supply_avatar,R.drawable.shape_circle_solid_f0f0f0,width, height) { bmp ->iv_avatar.setImageBitmap(bmp)avatarComplete?.invoke()}
}
方案2:手動測量View
可以設置為MeasureSpec.UNSPECIFIED之后,手動測量
在Android中,View.MeasureSpec.UNSPECIFIED的作用是告訴View它可以按照自己的意愿設置大小,不受任何限制。這是因為:
- MeasureSpec的組成
MeasureSpec是一個32位的整型值,高2位表示測量模式(mode),低30位表示測量大小(size)。測量模式有三種:
- UNSPECIFIED(0): 父容器不對View進行任何限制,要多大給多大
- EXACTLY(1): 父容器已經檢測出View所需要的精確大小
- AT_MOST(2): 父容器指定了一個最大值,View的大小不能超過這個值
默認情況下,測量模式取決于View的LayoutParams和父容器的MeasureSpec:
- 對于match_parent
- 父容器是EXACTLY: 子View也是EXACTLY,大小為父容器剩余空間
- 父容器是AT_MOST: 子View是AT_MOST,最大值為父容器剩余空間
- 對于wrap_content
- 父容器是EXACTLY/AT_MOST: 子View是AT_MOST,最大值為父容器剩余空間
- 父容器是UNSPECIFIED: 子View也是UNSPECIFIED
- 對于具體數值(如100dp)
- 不管父容器是什么模式,子View都是EXACTLY,大小為指定值
- 為什么使用UNSPECIFIED
當我們手動測量一個未添加到視圖層級的View時,使用UNSPECIFIED是最合適的,因為:
- View此時沒有父容器,不需要考慮父容器的限制
- 讓View按照自己的wrap_content邏輯來計算實際需要的尺寸
- 避免其他模式可能帶來的尺寸限制
- 測量過程
當使用UNSPECIFIED時:
- View會根據自己的內容大小來決定測量結果
- 對于ViewGroup,它會遞歸測量所有子View
- 最終得到的measuredWidth和measuredHeight就是View真實需要的尺寸
- 實際應用
在分享圖片生成等場景下,我們需要提前知道View的尺寸,此時使用UNSPECIFIED測量是最佳選擇:
// 對于沒有attached的View,手動觸發測量和布局
val measureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
shareView.measure(measureSpec, measureSpec)
shareView.layout(0, 0, shareView.measuredWidth, shareView.measuredHeight)// 現在可以獲取到正確的尺寸了
方案3:使用ViewTreeObserver
ViewTreeObserver.OnGlobalLayoutListener 是一個非常有用的回調接口,它會在布局發生變化時被觸發。具體來說,在以下情況下會觸發:(不過在目前的業務環境下,使用這個回調接口不太符合。)
- View的尺寸發生變化
- View的寬高改變
- View的padding改變
- View的margin改變
- View的位置發生變化
- View在父容器中的位置改變
- View的translation屬性改變
- View的scroll位置改變
- View層級發生變化
- 添加或刪除子View
- View的可見性改變(VISIBLE/GONE/INVISIBLE)
- 特殊時機
- Activity/Fragment首次布局完成
- 軟鍵盤彈出或收起
- 屏幕旋轉
- 系統窗口(如狀態欄)顯示或隱藏
需要注意的是:
- 一個布局變化可能會觸發多次回調
- 建議在獲取到需要的信息后立即移除監聽器
- 不要在回調中執行耗時操作
- 如果View已被移除,回調可能不會觸發
示例代碼:
shareView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {override fun onGlobalLayout() {shareView.viewTreeObserver.removeOnGlobalLayoutListener(this)// 現在布局完成了,可以安全地獲取View尺寸}
})
總結
LayoutInflater.inflate() 使用建議
- 分享、Dialog場景:傳
null
作為parent - RecyclerView ViewHolder:傳
parent, false
- 直接添加到容器:傳
parent, true
View.post() 使用建議
- 確保View已經attached:使用
isAttachedToWindow
檢查 - 分享等特殊場景:考慮直接執行,不使用post
- 需要View尺寸時:確保View已經經過測量和布局
最佳實踐
// ? 正確的分享View創建方式
val shareView = LayoutInflater.from(context).inflate(R.layout.share_layout, null)// ? 安全的View.post使用方式
if (view.isAttachedToWindow) {view.post { /* 執行需要View尺寸的操作 */ }
} else {// 直接執行或使用其他方式
}