在你剛開始開發 iOS 應用時,最難避免或者是除錯的就是和佈局相關的問題。通常這種問題發生的原因就是對於 view 何時真正更新的錯誤理解。想理解 view 在何時是如何更新的,需要對 iOS RunLoop 和相關的 UIView
方法有深刻的理解。這篇文章會介紹這些關聯,希望能幫你澄清如何用 UIView
的方法來獲得正確的行為。
一個 iOS 應用的主 RunLoop
一個 iOS 應用的主 RunLoop 負責處理所有的使用者輸入事件並觸發相應的響應。所有的使用者互動都會被加入到一個事件佇列中。下圖中的 Application
object 會從佇列中取出事件並將它們分發到應用中的其他物件上。本質上它會解釋這些來自使用者的輸入事件,然後呼叫在應用中的 Core objects 相應的處理程式碼,而這些程式碼再呼叫開發者寫的程式碼。當這些方法呼叫返回後,控制流回到主 RunLoop 上,然後開始 update cycle(更新週期)。Update cycle 負責佈局並且重新渲染檢視們(接下來會講到)。下面的圖片展示了應用是如何和裝置互動並且處理使用者輸入的。
developer.apple.com/library/con…
Update Cycle
Update cycle 是當應用完成了你的所有事件處理程式碼後控制流回到主 RunLoop 時的那個時間點。正是在這個時間點上系統開始更新佈局、顯示和設定約束。如果你在處理事件的程式碼中請求修改了一個 view,那麼系統就會把這個 view 標記為需要重畫(redraw)。在接下來的 Update cycle 中,系統就會執行這些 view 上的更改。使用者互動和佈局更新間的延遲幾乎不會被使用者察覺到。iOS 應用一般以 60 fps 的速度展示動畫,就是說每個更新週期只需要 1/60 秒。這個更新的過程很快,所以使用者在和應用互動時感覺不到 UI 中的更新延遲。但是由於在處理事件和對應 view 重畫間存在著一個間隔,RunLoop 中的某時刻的 view 更新可能不是你想要的那樣。如果你的程式碼中的某些計算依賴於當下的 view 內容或者是佈局,那麼就有在過時 view 資訊上操作的風險。理解 RunLoop、update cycle 和 UIView
中具體的方法可以幫助避免或者可以除錯這類問題。下面的圖展示出了 update cycle 發生在 RunLoop 的尾部。
佈局
一個檢視的佈局指的是它在螢幕上的的大小和位置。每個 view 都有一個 frame 屬性,用來表示在父 view 座標系中的位置和具體的大小。UIView
給你提供了用來通知系統某個 view 佈局發生變化的方法,也提供了在 view 佈局重新計算後呼叫的可重寫的方法。
layoutSubviews()
這個 UIView
方法處理對檢視(view)及其所有子檢視(subview)的重新定位和大小調整。它負責給出當前 view 和每個子 view 的位置和大小。這個方法很開銷很大,因為它會在每個子檢視上起作用並且呼叫它們相應的 layoutSubviews
方法。系統會在任何它需要重新計算檢視的 frame 的時候呼叫這個方法,所以你應該在需要更新 frame 來重新定位或更改大小時過載它。然而你不應該在程式碼中顯式呼叫這個方法。相反,有許多可以在 run loop 的不同時間點觸發 layoutSubviews
呼叫的機制,這些觸發機制比直接呼叫 layoutSubviews
的資源消耗要小得多。
當 layoutSubviews
完成後,在 view 的所有者 view controller 上,會觸發 viewDidLayoutSubviews
呼叫。因為 viewDidLayoutSubviews
是 view 佈局更新後會被唯一可靠呼叫的方法,所以你應該把所有依賴於佈局或者大小的程式碼放在 viewDidLayoutSubviews
中,而不是放在 viewDidLoad
或者 viewDidAppear
中。這是避免使用過時的佈局或者位置變數的唯一方法。
自動重新整理觸發器
有許多事件會自動給檢視打上 “update layout” 標記,因此 layoutSubviews
會在下一個週期中被呼叫,而不需要開發者手動操作。這些自動通知系統 view 的佈局發生變化的方式有:
- 修改 view 的大小
- 新增 subview
- 使用者在
UIScrollView
上滾動(layoutSubviews
會在UIScrollView
和它的父 view 上被呼叫) - 使用者旋轉裝置
- 更新檢視的 constraints
這些方式都會告知系統 view 的位置需要被重新計算,繼而會自動轉化為一個最終的 layoutSubviews
呼叫。當然,也有直接觸發 layoutSubviews
的方法。
setNeedsLayout()
觸發 layoutSubviews
呼叫的最省資源的方法就是在你的檢視上呼叫 setNeedsLaylout
方法。呼叫這個方法代表向系統表示檢視的佈局需要重新計算。setNeedsLayout
方法會立刻執行並返回,但在返回前不會真正更新檢視。檢視會在下一個 update cycle 中更新,就在系統呼叫檢視們的 layoutSubviews
以及他們的所有子檢視的 layoutSubviews
方法的時候。即使從 setNeedsLayout
返回後到檢視被重新繪製並佈局之間有一段任意時間的間隔,但是這個延遲不會對使用者造成影響,因為永遠不會長到對介面造成卡頓。
layoutIfNeeded()
layoutIfNeeded
是另一個會讓 UIView
觸發 layoutSubviews
的方法。 當檢視需要更新的時候,與 setNeedsLayout()
會讓檢視在下一週期呼叫 layoutSubviews
更新檢視不同,layoutIfNeeded
會立即呼叫 layoutSubviews
方法。但是如果你呼叫了 layoutIfNeeded
之後,並且沒有任何操作向系統表明需要重新整理檢視,那麼就不會呼叫 layoutsubview
。如果你在同一個 run loop 內呼叫兩次 layoutIfNeeded
,並且兩次之間沒有更新檢視,第二個呼叫同樣不會觸發 layoutSubviews
方法。
使用 layoutIfNeeded
,則佈局和重繪會立即發生並在函式返回之前完成(除非有正在執行中的動畫)。這個方法在你需要依賴新佈局,無法等到下一次 update cycle 的時候會比 setNeedsLayout
有用。除非是這種情況,否則你更應該使用 setNeedsLayout
,這樣在每次 run loop 中都只會更新一次佈局。
當對希望通過修改 constraint 進行動畫時,這個方法特別有用。你需要在 animation block 之前對 self.view 呼叫 layoutIfNeeded
,以確保在動畫開始之前傳播所有的佈局更新。在 animation block 中設定新 constrait 後,需要再次呼叫 layoutIfNeeded
來動畫到新的狀態。
顯示
一個檢視的顯示包含了顏色、文字、圖片和 Core Graphics 繪製等檢視屬性,不包括其本身和子檢視的大小和位置。和佈局的方法類似,顯示也有觸發更新的方法,它們由系統在檢測到更新時被自動呼叫,或者我們可以手動呼叫直接重新整理。
draw(_:)
UIView
的 draw
方法(本文使用 Swift,對應 Objective-C 的 drawRect
)對檢視內容顯示的操作,類似於檢視佈局的 layoutSubviews
,但是不同於 layoutSubviews
,draw
方法不會觸發後續對檢視的子檢視方法的呼叫。同樣,和 layoutSubviews
一樣,你不應該直接呼叫 draw
方法,而應該通過呼叫觸發方法,讓系統在 run loop 中的不同結點自動呼叫。
setNeedsDisplay()
這個方法類似於佈局中的 setNeedsLayout
。它會給有內容更新的檢視設定一個內部的標記,但在檢視重繪之前就會返回。然後在下一個 update cycle 中,系統會遍歷所有已標標記的檢視,並呼叫它們的 draw
方法。如果你只想在下次更新時重繪部分檢視,你可以呼叫 setNeedsDisplay(_:)
,並把需要重繪的矩形部分傳進去(setNeedsDisplayInRect
in OC)。大部分時候,在檢視中更新任何 UI 元件都會把相應的檢視標記為“dirty”,通過設定檢視“內部更新標記”,在下一次 update cycle 中就會重繪,而不需要顯式的 setNeedsDisplay
呼叫。然而如果你有一個屬性沒有繫結到 UI 元件,但需要在每次更新時重繪檢視,你可以定義他的 didSet
屬性,並且呼叫 setNeedsDisplay
來觸發檢視合適的更新。
有時候設定一個屬性要求自定義繪製,這種情況下你需要重寫 draw
方法。在下面的例子中,設定 numberOfPoints
會觸發系統系統根據具體點數繪製檢視。在這個例子中,你需要在 draw
方法中實現自定義繪製,並在 numberOfPoints
的 property observer 裡呼叫 setNeedsDisplay
。
class MyView: UIView {
var numberOfPoints = 0 {
didSet {
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
switch numberOfPoints {
case 0:
return
case 1:
drawPoint(rect)
case 2:
drawLine(rect)
case 3:
drawTriangle(rect)
case 4:
drawRectangle(rect)
case 5:
drawPentagon(rect)
default:
drawEllipse(rect)
}
}
}
複製程式碼
檢視的顯示方法裡沒有類似佈局中的 layoutIfNeeded
這樣可以觸發立即更新的方法。通常情況下等到下一個更新週期再重新繪製檢視也無所謂。
約束
自動佈局包含三步來佈局和重繪檢視。第一步是更新約束,系統會計算並給檢視設定所有要求的約束。第二步是佈局階段,佈局引擎計算檢視和子檢視的 frame 並且將它們佈局。最後一步完成這一迴圈的是顯示階段,重繪檢視的內容,如實現了 draw
方法則呼叫 draw
。
updateConstraints()
這個方法用來在自動佈局中動態改變檢視約束。和佈局中的 layoutSubviews()
方法或者顯示中的 draw
方法類似,updateConstraints()
只應該被過載,絕不要在程式碼中顯式地呼叫。通常你只應該在 updateConstraints
方法中實現必須要更新的約束。靜態的約束應該在 interface builder、檢視的初始化方法或者 viewDidLoad()
方法中指定。
通常情況下,設定或者解除約束、更改約束的優先順序或者常量值,或者從檢視層級中移除一個檢視時都會設定一個內部的標記 “update constarints”,這個標記會在下一個更新週期中觸發呼叫 updateConstrains
。當然,也有手動給檢視打上“update constarints” 標記的方法,如下。
setNeedsUpdateConstraints()
呼叫 setNeedsUpdateConstraints()
會保證在下一次更新週期中更新約束。它通過標記“update constraints”來觸發 updateConstraints()
。這個方法和 setNeedsDisplay()
以及 setNeedsLayout()
方法的工作機制類似。
updateConstraintsIfNeeded()
對於使用自動佈局的檢視來說,這個方法與 layoutIfNeeded
等價。它會檢查 “update constraints”標記(可以被 setNeedsUpdateConstraints
或者 invalidateInstrinsicContentSize
方法自動設定)。如果它認為這些約束需要被更新,它會立即觸發 updateConstraints()
,而不會等到 run loop 的末尾。
invalidateIntrinsicContentSize()
自動佈局中某些檢視擁有 intrinsicContentSize
屬性,這是檢視根據它的內容得到的自然尺寸。一個檢視的 intrinsicContentSize
通常由所包含的元素的約束決定,但也可以通過過載提供自定義行為。呼叫 invalidateIntrinsicContentSize()
會設定一個標記表示這個檢視的 intrinsicContentSize
已經過期,需要在下一個佈局階段重新計算。
它們是如何連線起來的
佈局、顯示和約束都遵循著相似的模式,例如他們更新的方式以及如何在 run loop 的不同時間點上強制更新。任一元件都有一個實際去更新的方法(layoutSubviews
, draw
, 和 updateConstraints
),你可以重寫來手動操作檢視,但是任何情況下都不要顯式呼叫。這個方法只在 run loop 的末端會被呼叫,如果檢視被標記了告訴系統該檢視需要被更新的標記的話。有一些操作會自動設定這個標誌,但是也有一些方法允許您顯式地設定它。對於佈局和約束相關的更新,如果你等不到在 run loop 末端才更新(例如:其他行為依賴於新佈局),有方法可以讓你立即更新,並保證 “update layout” 標記被正確標記。下面的表格列出了任意元件會怎樣更新及其對應方法。
下面的流程圖總結了 update cycle 和 event loop 之間的互動,並指出了上文提到的方法在 run loop 執行期間的位置。你可以在 run loop 中的任意一點顯式地呼叫 layoutIfNeeded 或者 updateConstraintsIfNeeded,需要記住,這開銷會很大。在迴圈的末端是 update cycle,如果檢視被設定了特定的 “update constraints”,“update layout” 或者 “needs display” 標記,在這節點會更新約束、佈局以及展示。一旦這些更新結束,runloop 會重新啟動。