自動布局在 OS X 10.7 中被引進,一年后在 iOS 6 中也可以用了。不久在 iOS 7 中的程序將會有望設置全局字體大小,因此除了不同的屏幕大小和方向,用戶界面布局也需要更大的靈活性。Apple 也在自動布局上花了很大功夫,所以如果你還沒做過這一塊,那么現在就是接觸這個技術的好時機。
很多開發(fā)者在第一次嘗試使用這個技術時都非常掙扎,因為用 Xcode 4 的 Interface Builder 建立 constraint-based layouts 體驗非常糟糕,但不要因為這個而灰心。自動布局其實比現在 Interface Builder 所支持的要好很多。Xcode 5 在這塊中將會帶來重要的變化。
這篇文章不是用來介紹 Auto Layout 的。如果你還沒用過它,那還是先去 WWDC 2012 看看基礎教程吧。(202 – Introduction to Auto Layout for iOS and OS X, 228 – Best Practices for Mastering Auto Layout, 232 – Auto Layout by Example)。
相反我們會專注于一些高級的使用技巧和方法,這將會讓你使用自動布局的時候效率更高,(開發(fā))生活更幸福。大多數內容在 WWDC 會議中都有提到,但它們都是在日常工作中容易被審查或遺忘的。
首先我們總結一下自動布局將視圖顯示到屏幕上的步驟。當你根據自動布局盡力寫出你想要的布局種類時,特別是高級的使用情況和動畫,這有利于后退一步,并回憶布局過程是怎么工作的。
和 springs,struts 比起來,在視圖被顯示之前,自動布局引入了兩個額外的步驟:更新約束 (updating constraints) 和布局視圖 (laying out views)。每一步都是依賴前一步操作的;顯示依賴于布局視圖,布局視圖依賴于更新約束。
第一步:更新約束,可以被認為是一個“計量傳遞 (measurement pass)”。這是自下而上(從子視圖到父視圖)發(fā)生的,它為布局準備好必要的信息,而這些布局將在實際設置視圖的 frame 時被傳遞過去并被使用。你可以通過調用 setNeedsUpdateConstraints 來觸發(fā)這個操作,同時,你對約束條件系統做出的任何改變都將自動觸發(fā)這個方法。無論如何,通知自動布局關于自定義視圖中任何可能影響布局的改變是非常有用的。談到自定義視圖,你可以在這個階段重寫 updateConstraints 來為你的視圖增加需要的本地約束。
第二步:布局,這是個自上而下(從父視圖到子視圖)的過程,這種布局操作實際上是通過設置 frame(在 OS X 中)或者 center 和 bounds(在 iOS 中)將約束條件系統的解決方案應用到視圖上。你可以通過調用 setNeedsLayout 來觸發(fā)一個操作請求,這并不會立刻應用布局,而是在稍后再進行處理。因為所有的布局請求將會被合并到一個布局操作中去,所以你不需要為經常調用這個方法而擔心。
你可以調用 layoutIfNeeded / layoutSubtreeIfNeeded(分別針對 iOS / OS X)來強制系統立即更新視圖樹的布局。如果你下一步操作依賴于更新后視圖的 frame,這將非常有用。在你自定義的視圖中,你可以重寫 layoutSubviews / layout 來獲得控制布局變化的所有權,我們稍后將展示使用方法。
最終,不管你是否用了自動布局,顯示器都會自上而下將渲染后的視圖傳遞到屏幕上,你也可以通過調用 setNeedsDisplay 來觸發(fā),這將會導致所有的調用都被合并到一起推遲重繪。重寫熟悉的 drawRect:能夠讓我們獲得自定義視圖中顯示過程的所有權。
既然每一步都是依賴前一步操作的,如果有任何布局的變化還沒實行的話,顯示操作將會觸發(fā)一個布局行為。類似地,如果約束條件系統中存在沒有實行的改變,布局變化也將會觸發(fā)更新約束條件。
需要牢記的是,這三步并不是單向的。基于約束條件的布局是一個迭代的過程,布局操作可以基于之前的布局方案來對約束做出更改,而這將再次觸發(fā)約束的更新,并緊接另一個布局操作。這可以被用來創(chuàng)建高級的自定義視圖布局,但是如果你每一次調用的自定義 layoutSubviews 都會導致另一個布局操作的話,你將會陷入到無限循環(huán)的麻煩中去。
當創(chuàng)建一個自定義視圖時,你需要知道關于自動布局的這些事情:具體指定一個恰當的固有內容尺寸 (intrinsic content size),區(qū)分開視圖的 frame 和 alignment rect,啟動 baseline-aligned 布局,如何 hook 到布局過程中,我們將會逐一了解這些部分。
固有內容尺寸是一個視圖期望為其顯示特定內容得到的大小。比如,UILabel 有一個基于字體的首選高度,一個基于字體和顯示文本的首選寬度。UIProgressView 僅有一個基于其插圖的首選高度,但沒有首選寬度。一個沒有格式的 UIView 既沒有首選寬度也沒有首選高度。
你需要根據想要顯示的內容來決定你的自定義視圖是否具有一個固有內容尺寸,如果有的話,它是在哪個尺度上固有。
為了在自定義視圖中實現固有內容尺寸,你需要做兩件事:重寫 intrinsicContentSize 為內容返回恰當的大小,無論何時有任何會影響固有內容尺寸的改變發(fā)生時,調用 invalidateIntrinsicContentSize。如果這個視圖只有一個方向的尺寸設置了固有尺寸,那么為另一個方向的尺寸返回 UIViewNoIntrinsicMetric / NSViewNoIntrinsicMetric。
需要注意的是,固有內容尺寸必須是獨立于視圖 frame 的。例如,不可能返回一個基于 frame 高度或寬度的特定高寬比的固有內容尺寸。
譯者注 我理解為壓縮阻力和內容吸附性,實在是想不到更貼切的名稱了。壓縮阻力是控制視圖在兩個方向上的收縮性,內容吸附性是當視圖的大小改變時,它會盡量讓視圖靠近它的固有內容尺寸
每個視圖在兩個方向上都分配有內容壓縮阻力優(yōu)先級和內容吸附性優(yōu)先級。只有當視圖定義了固有內容尺寸時這些屬性才能起作用,如果沒有定義內容大小,那就沒法阻止被壓縮或者吸附了。
在后臺中,固有內容尺寸和這些優(yōu)先值被轉換為約束條件。一個固有內容尺寸為 {100,30} 的 label,水平/垂直壓縮阻力優(yōu)先值為 750,水平/垂直的內容吸附性優(yōu)先值為 250,這四個約束條件將會生成:
H:[label(<=100@250)]
H:[label(>=100@750)]
V:[label(<=30@250)]
V:[label(>=30@750)]
如果你不熟悉上面約束條件所使用的可視格式語言,你可以到 Apple 文檔 中了解。記住,這些額外的約束條件對了解自動布局的行為產生了隱式的幫助,同時也更好理解它的錯誤信息。
自動布局并不會操作視圖的 frame,但能作用于視圖的 alignment rect。大家很容易忘記它們之間細微的差別,因為在很多情況下,它們是相同的。但是alignment rect 實際上是一個強大的新概念:從一個視圖的視覺外觀解耦出視圖的 layout alignment edges。
比如,一個自定義 icon 類型的按鈕比我們期望點擊目標還要小的時候,這將會很難布局。當插圖顯示在一個更大的 frame 中時,我們將不得不了解它顯示的大小,并且相應調整按鈕的 frame,這樣 icon 才會和其他界面元素排列好。當我們想要在內容的周圍繪制像 badges,陰影,倒影的裝飾時,也會發(fā)生同樣的情況。
我們可以使用 alignment rect 簡單的定義需要用來布局的矩形。在大多數情況下,你僅需要重寫 alignmentRectInsets 方法,這個方法允許你返回相對于 frame 的 edge insets。如果你需要更多控制權,你可以重寫 alignmentRectForFrame: 和 frameForAlignmentRect:。如果你不想減去固定的 insets,而是計算基于當前 frame 的 alignment rect,那么這兩個方法將會非常有用。但是你需要確保這兩個方法是互為可逆的。
關于這點,回憶上面提及到的視圖固有內容尺寸引用它的 alignment rect,而不是 frame。這是有道理的,因為自動布局直接根據固有內容尺寸產生壓縮阻力和內容吸附約束條件。
為了讓使用 NSLayoutAttributeBaseline 屬性的約束條件對自定義視圖奏效,我們需要做一些額外的工作。當然,這只有我們討論的自定義視圖中有類似基準線的東西時,才有意義。
在 iOS 中,可以通過實現 viewForBaselineLayout 來激活基線對齊。在這里返回的視圖底邊緣將會作為 基線。默認實現只是簡單的返回自己,然而自定義的實現可以返回任何子視圖。在 OS X 中,你不需要返回一個子視圖,而是重新定義 baselineOffsetFromBottom 返回一個從視圖底部邊緣開始的 offset,這和在 iOS 中一樣,默認實現都是返回 0。
在自定義視圖中,你能完全控制它子視圖的布局。你可以增加本地約束;根據內容變化需要,你可以改變本地約束;你可以為子視圖調整布局操作的結果;或者你可以選擇拋棄自動布局。
但確保你明智的使用這個權利。大多數情況下可以簡單地通過為你的子視圖簡單的增加本地約束來處理。
如果我們想用幾個子視圖組成一個自定義視圖,我們需要以某種方式布局這些子視圖。在自動布局的環(huán)境中,自然會想到為這些視圖增加本地約束。然而,需要注意的是,這將會使你自定義的視圖是基于自動布局的,這個視圖不能再被使用于未啟用自動布局的 windows 中。最好通過實現 requiresConstraintBasedLayout 返回 YES 明確這個依賴。
添加本地約束的地方是 updateConstraints。確保在你的實現中增加任何你需要布局子視圖的約束條件之后,調用一下 [super updateConstraints]。在這個方法中,你不會被允許禁用何約束條件,因為你已經進入上面所描述的布局過程的第一步了。如果嘗試著這樣做,將會產生一個友好的錯誤信息 “programming error”。
如果稍后一個失效的約束條件發(fā)生了改變的話,你需要立刻移除這個約束并調用 setNeedsUpdateConstraints。事實上,僅在這種情況下你需要觸發(fā)更新約束條件的操作。
如果你不能利用布局約束條件達到子視圖預期的布局,你可以進一步在 iOS 里重寫 layoutSubviews 或者在 OS X 里面重寫 layout。通過這種方式,當約束條件系統得到解決并且結果將要被應用到視圖中時,你便已經進入到布局過程的第二步。
最極端的情況是不調用父類的實現,自己重寫全部的 layoutSubviews / layout。這就意味著你在這個視圖里的視圖樹里拋棄了自動布局。從現在起,你可以按喜歡的方式手動放置子視圖。
如果你仍然想使用約束條件布局子視圖,你需要調用 [super layoutSubviews] / [super layout],然后對布局進行微調。你可以通過這種方式創(chuàng)建那些通過定于約束無法實現的布,比如,由到視圖大小之間的關系或是視圖之間間距的關系來定義的布局。
這方面另一個有趣的使用案例就是創(chuàng)建一個布局依賴的視圖樹。當自動布局完成第一次傳遞并且為自定義視圖的子視圖設置好 frame 后,你便可以檢查子視圖的位置和大小,并為視圖層級和(或)約束條件做出調整。WWDC session 228 – Best Practices for Mastering Auto Layout 有一個很好的例子。
你也可以在第一次布局操作完成后再決定改變約束條件。比如,如果視圖變得太窄的話,將原來排成一行的子視圖轉變成兩行。
- layoutSubviews
{
[super layoutSubviews];
if (self.subviews[0].frame.size.width <= MINIMUM_WIDTH)
{
[self removeSubviewConstraints];
self.layoutRows += 1; [super layoutSubviews];
}
}
- updateConstraints
{
// 根據 self.layoutRows 添加約束...
[super updateConstraints];
}
UILabel 和 NSTextField 對于多行文本的固有內容尺寸是模糊不清的。文本的高度取決于行的寬度,這也是解決約束條件時需要弄清的問題。為了解決這個問題,這兩個類都有一個叫做 preferredMaxLayoutWidth 的新屬性,這個屬性指定了行寬度的最大值,以便計算固有內容尺寸。
因為我們通常不能提前知道這個值,為了獲得正確的值我們需要先做兩步操作。首先,我們讓自動布局做它的工作,然后用布局操作結果的 frame 更新給首選最大寬度,并且再次觸發(fā)布局。
- (void)layoutSubviews
{
[super layoutSubviews];
myLabel.preferredMaxLayoutWidth = myLabel.frame.size.width;
[super layoutSubviews];
}
第一次調用 [super layoutSubviews] 是為了獲得 label 的 frame,而第二次調用是為了改變后更新布局。如果省略第二個調用我們將會得到一個 NSInternalInconsistencyException 的錯誤,因為我們改變了更新約束條件的布局操作,但我們并沒有再次觸發(fā)布局。
我們也可以在 label 子類本身中這樣做:
@implementation MyLabel
- (void)layoutSubviews
{
self.preferredMaxLayoutWidth = self.frame.size.width;
[super layoutSubviews];
}
@end
在這種情況下,我們不需要先調用 [super layoutSubviews],因為當 layoutSubviews 被調用時,label 就已經有一個 frame 了。
為了在視圖控制器層級做出這樣的調整,我們用掛鉤到 viewDidLayoutSubviews。這時候第一個自動布局操作的 frame 已經被設置,我們可以用它們來設置首選最大寬度。
- (void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
myLabel.preferredMaxLayoutWidth = myLabel.frame.size.width;
[self.view layoutIfNeeded];
}
最后,確保你沒有給 label 設置一個比 label 內容壓縮阻力優(yōu)先級還要高的具體高度約束。否則它將會取代根據內容計算出的高度。
說到根據自動布局的視圖動畫,有兩個不同的基本策略:約束條件自身動態(tài)化;以及改變約束條件重新計算 frame,并使用 Core Animation 將 frame 插入到新舊位置之間。
這兩種處理方法不同的是:約束條件自身動態(tài)化產生的布局結果總是符合約束條件系統。與此相反,使用 Core Animation 插入值到新舊 frame 之間會臨時違反約束條件。
直接使用約束條件動態(tài)化只是在 OS X 上的一種可行策略,并且這對你能使用的動畫有局限性,因為約束條件一旦創(chuàng)建后,只有其常量可以被改變。在 OS X 中你可以在約束條件的常量中使用動畫代理來驅動動畫,而在 iOS 中,你只能手動進行控制。另外,這種方法明顯比 Core Animation 方法慢得多,這也使得它暫時不適合移動平臺。
當使用 Core Animation 方法時,即使不使用自動布局,動畫的工作方式在概念上也是一樣的。不同的是,你不需要手動設置視圖的目標 frames,取而代之的是修改約束條件并觸發(fā)一個布局操作為你設置 frames。在 iOS 中,代替:
[UIView animateWithDuration:1 animations:^{
myView.frame = newFrame;
}];
你現在需要寫:
// 更新約束
[UIView animateWithDuration:1 animations:^{
[myView layoutIfNeeded];
}];
請注意,使用這種方法,你可以對約束條件做出的改變并不局限于約束條件的常量。你可以刪除約束條件,增加約束條件,甚至使用臨時動畫約束條件。由于新的約束只被解釋一次來決定新的 frames,所以更復雜的布局改變都是有可能的。
需要記住的是:Core Animation 和 Auto Layout 結合在一起產生視圖動畫時,自己不要接觸視圖的 frame。一旦視圖使用自動布局,那么你已經將設置 frame 的責任交給了布局系統。你的干擾將造成怪異的行為。
這也意味著,如果使用的視圖變換 (transform) 改變了視圖的 frame 的話,它和自動布局是無法一起正常使用的??紤]下面這個例子:
[UIView animateWithDuration:1 animations:^{
myView.transform = CGAffineTransformMakeScale(.5, .5);
}];
通常我們期望這個方法在保持視圖的中心時,將它的大小縮小到原來的一半。但是自動布局的行為是根據我們建立的約束條件種類來放置視圖的。如果我們將其居中于它的父視圖,結果便像我們預想的一樣,因為應用視圖變換會觸發(fā)一個在父視圖內居中新 frame 的布局操作。然而,如果我們將視圖的左邊緣對齊到另一個視圖,那么這個 alignment 將會粘連住,并且中心點將會移動。
不管怎么樣,即使最初的結果跟我們預想的一樣,像這樣通過約束條件將轉換應用到視圖布局上并不是一個好主意。視圖的 frame 沒有和約束條件同步,也將導致怪異的行為。
如果你想使用 transform 來產生視圖動畫或者直接使它的 frame 動態(tài)化,最干凈利索的技術是將這個視圖嵌入到一個視圖容器內,然后你可以在容器內重寫 layoutSubviews,要么選擇完全脫離自動布局,要么僅僅調整它的結果。舉個例子,如果我們在我們的容器內建立一個子視圖,它根據容器的頂部和左邊緣自動布局,當布局根據以上的設置縮放轉換后我們可以調整它的中心:
- (void)layoutSubviews
{
[super layoutSubviews];
static CGPoint center = {0,0};
if (CGPointEqualToPoint(center, CGPointZero)) {
// 在初次布局后獲取中心點
center = self.animatedView.center;
} else {
// 將中心點賦回給動畫視圖
self.animatedView.center = center;
}
}
如果我們將 animatedView 屬性暴露為 IBOutlet,我們甚至可以使用 Interface Builder 里面的容器,并且使用約束條件放置它的的子視圖,同時還能夠根據固定的中心應用縮放轉換。
當談到調試自動布局,OS X 比 iOS 還有一個重要的優(yōu)勢。在 OS X 中,你可以利用 Instrument 的 Cocoa Layout 模板,或者是 NSWindow 的 visualizeConstraints: 方法。而且 NSView 有一個 identifier 屬性,為了獲得更多可讀的自動布局錯誤信息,你可以在 Interface Builder 或代碼里面設置這個屬性。
如果我們在 iOS 中遇到不可滿足的約束條件,我們只能在輸出的日志中看到視圖的內存地址。尤其是在更復雜的布局中,有時很難辨別出視圖的哪一部分出了問題。然而,在這種情況下,還有幾種方法可以幫到我們。
首先,當你在不可滿足的約束條件錯誤信息中看到 NSLayoutResizingMaskConstraints 時,你肯定忘了為你某一個視圖設定 translatesAutoResizingMaskIntoConstraints 為 NO。Interface Builder 中會自動設置,但是使用代碼時,你需要為所有的視圖手動設置。
如果不是很明確是哪個視圖導致的問題,你就需要通過內存地址來辨認視圖。最簡單的方法是使用調試控制臺。你可以打印視圖本身或它父視圖的描述,甚至遞歸描述的樹視圖。這通常會提示你需要處理哪個視圖。
(lldb) po 0x7731880
$0 = 124983424 <UIView: 0x7731880; frame = (90 -50; 80 100);
layer = <CALayer: 0x7731450>>
(lldb) po [0x7731880 superview]
$2 = 0x07730fe0 <UIView: 0x7730fe0; frame = (32 128; 259 604);
layer = <CALayer: 0x7731150>>
(lldb) po [[0x7731880 superview] recursiveDescription]
$3 = 0x07117ac0 <UIView: 0x7730fe0; frame = (32 128; 259 604); layer = <CALayer: 0x7731150>>
| <UIView: 0x7731880; frame = (90 -50; 80 100); layer = <CALayer: 0x7731450>>
| <UIView: 0x7731aa0; frame = (90 101; 80 100); layer = <CALayer: 0x7731c60>>
一個更直觀的方法是在控制臺修改有問題的視圖,這樣你可以在屏幕上標注出來。比如,你可以改變它的背景顏色:
(lldb) expr ((UIView *)0x7731880).backgroundColor = [UIColor purpleColor]
確保重新執(zhí)行你的程序,否則改變不會在屏幕上顯示出來。還要注意將內存地址轉換為 (UIView *) ,以及額外的圓括號,這樣我們就可以使用點操作。另外,你當然也可以通過發(fā)送消息來實現:
(lldb) expr [(UIView *)0x7731880 setBackgroundColor:[UIColor purpleColor]]
另一種方法是使用 Instrument 的 allocation 模板,根據圖表分析。一旦你從錯誤消息中得到內存地址(運行 Instruments 時,你從 Console 應用中獲得的錯誤消息),你可以將 Instrument 的詳細視圖切換到 Objects List 頁面,并且用 Cmd-F 搜索那個內存地址。這將會為你顯示分配視圖對象的方法,這通常是一個很好的暗示(至少對那些由代碼創(chuàng)建的視圖來說是這樣的)。
你也可以通過改進錯誤信息本身,來更容易地在 iOS 中弄懂不可滿足的約束條件錯誤到底在哪里。我們可以在一個 category 中重寫 NSLayoutConstraint 的描述,并且將視圖的 tags 包含進去:
@implementation NSLayoutConstraint (AutoLayoutDebugging)
#ifdef DEBUG
- (NSString *)description
{
NSString *description = super.description;
NSString *asciiArtDescription = self.asciiArtDescription;
return [description stringByAppendingFormat:@" %@ (%@, %@)",
asciiArtDescription, [self.firstItem tag], [self.secondItem tag]];
}
#endif
@end
如果整數的 tag 屬性信息不夠的話,我們還可以得到更多新奇的東西,并且在視圖類中增加我們自己命名的屬性,然后可以打印到錯誤消息中。我們甚至可以在 Interface Builder 中,使用 identity 檢查器中的 “User Defined Runtime Attributes” 為自定義屬性分配值。
@interface UIView (AutoLayoutDebugging)
- (void)setAbc_NameTag:(NSString *)nameTag;
- (NSString *)abc_nameTag;
@end
@implementation UIView (AutoLayoutDebugging)
- (void)setAbc_NameTag:(NSString *)nameTag
{
objc_setAssociatedObject(self, "abc_nameTag", nameTag, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSString *)abc_nameTag
{
return objc_getAssociatedObject(self, "abc_nameTag");
}
@end
@implementation NSLayoutConstraint (AutoLayoutDebugging)
#ifdef DEBUG
- (NSString *)description
{
NSString *description = super.description;
NSString *asciiArtDescription = self.asciiArtDescription;
return [description stringByAppendingFormat:@" %@ (%@, %@)", asciiArtDescription, [self.firstItem abc_nameTag], [self.secondItem abc_nameTag]];
}
#endif
@end
通過這種方法錯誤消息變得更可讀,并且你不需要找出內存地址對應的視圖。然而,對你而言,你需要做一些額外的工作以確保每次為視圖分配的名字都是有意義。
Daniel 提出了另一個很巧妙的方法,可以為你提供更好的錯誤消息并且不需要額外的工作:對于每個布局約束條件,都需要將調用棧的標志融入到錯誤消息中。這樣就很容易看出來問題涉及到的約束了。要做到這一點,你需要 swizzle UIView 或者 NSView 的 addConstraint: / addConstraints: 方法,以及布局約束的 description 方法。在添加約束的方法中,你需要為每個約束條件關聯一個對象,這個對象描述了當前調用棧堆棧的第一個棧頂信息(或者任何你從中得到的信息):
static void AddTracebackToConstraints(NSArray *constraints)
{
NSArray *a = [NSThread callStackSymbols];
NSString *symbol = nil;
if (2 < [a count]) {
NSString *line = a[2];
// Format is
// 1 2 3 4 5
// 012345678901234567890123456789012345678901234567890123456789
// 8 MyCoolApp 0x0000000100029809 -[MyViewController loadView] + 99
//
// Don't add if this wasn't called from "MyCoolApp":
if (59 <= [line length]) {
line = [line substringFromIndex:4];
if ([line hasPrefix:@"My"]) {
symbol = [line substringFromIndex:59 - 4];
}
}
}
for (NSLayoutConstraint *c in constraints) {
if (symbol != nil) {
objc_setAssociatedObject(c, &ObjcioLayoutConstraintDebuggingShort,
symbol, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
objc_setAssociatedObject(c, &ObjcioLayoutConstraintDebuggingCallStackSymbols,
a, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
}
@end
一旦你為每個約束對象提供這些信息,你可以簡單的修改 UILayoutConstraint 的描述方法將其包含到輸出日志中。
- (NSString *)objcioOverride_description {
// call through to the original, really
NSString *description = [self objcioOverride_description];
NSString *objcioTag = objc_getAssociatedObject(self, &ObjcioLayoutConstraintDebuggingShort);
if (objcioTag == nil) {
return description;
}
return [description stringByAppendingFormat:@" %@", objcioTag];
}
檢出這個GitHub倉庫,了解這一技術的代碼示例。
另一個常見的問題就是有歧義的布局。如果我們忘記添加一個約束條件,我們經常會想為什么布局看起來不像我們所期望的那樣。UIView 和 NSView 提供三種方式來查明有歧義的布局:hasAmbiguousLayout,exerciseAmbiguityInLayout,和私有方法 _autolayoutTrace。
顧名思義,如果視圖存在有歧義的布局,那么 hasAmbiguousLayout 返回YES。如果我們不想自己遍歷視圖層并記錄這個值,可以使用私有方法 _autolayoutTrace。這將返回一個描述整個視圖樹的字符串:類似于 recursiveDescription 的輸出(當視圖存在有歧義的布局時,這個方法會告訴你)。
由于這個方法是私有的,確保正式產品里面不要包含調用這個方法的任何代碼。為了防止你犯這種錯誤,你可以在視圖的category中這樣做:
@implementation UIView (AutoLayoutDebugging)
- (void)printAutoLayoutTrace {
#ifdef DEBUG
NSLog(@"%@", [self performSelector:@selector(_autolayoutTrace)]);
#endif
}
@end
_autolayoutTrace 打印的結果如下:
2013-07-23 17:36:08.920 FlexibleLayout[4237:907]
*<UIWindow:0x7269010>
| *<UILayoutContainerView:0x7381250>
| | *<UITransitionView:0x737c4d0>
| | | *<UIViewControllerWrapperView:0x7271e20>
| | | | *<UIView:0x7267c70>
| | | | | *<UIView:0x7270420> - AMBIGUOUS LAYOUT
| | <UITabBar:0x726d440>
| | | <_UITabBarBackgroundView:0x7272530>
| | | <UITabBarButton:0x726e880>
| | | | <UITabBarSwappableImageView:0x7270da0>
| | | | <UITabBarButtonLabel:0x726dcb0>
正如不可滿足約束條件的錯誤消息一樣,我們仍然需要弄明白打印出的內存地址所對應的視圖。
另一個標識出有歧義布局更直觀的方法就是使用 exerciseAmbiguityInLayout。這將會在有效值之間隨機改變視圖的 frame。然而,每次調用這個方法只會改變 frame 一次。所以當你啟動程序的時候,你根本不會看到改變。創(chuàng)建一個遍歷所有視圖層級的輔助方法是一個不錯的主意,并且讓所有的視圖都有一個歧義的布局“晃動 (jiggle)”。
@implementation UIView (AutoLayoutDebugging)
- (void)exerciseAmiguityInLayoutRepeatedly:(BOOL)recursive {
#ifdef DEBUG
if (self.hasAmbiguousLayout) {
[NSTimer scheduledTimerWithTimeInterval:.5
target:self
selector:@selector(exerciseAmbiguityInLayout)
userInfo:nil
repeats:YES];
}
if (recursive) {
for (UIView *subview in self.subviews) {
[subview exerciseAmbiguityInLayoutRepeatedly:YES];
}
}
#endif
} @end
有幾個有用的 NSUserDefault 選項可以幫助我們調試、測試自動布局。你可以在代碼中設定,或者你也可以在 scheme editor 中指定它們作為啟動參數。
顧名思義,UIViewShowAlignmentRects和 NSViewShowAlignmentRects 設置視圖可見的 alignment rects。NSDoubleLocalizedStrings 簡單的獲取并復制每個本地化的字符串。這是一個測試更長語言布局的好方法。最后,設置 AppleTextDirection 和 NSForceRightToLeftWritingDirection 為 YES,來模擬從右到左的語言。
編者注 如果你不知道怎么在 scheme 中設置類似 `NSDoubleLocalizedStrings`,這里有一張圖來說明; 
當在代碼中設置視圖和它們的約束條件時候,一定要記得將 translatesAutoResizingMaskIntoConstraints 設置為 NO。如果忘記設置這個屬性幾乎肯定會導致不可滿足的約束條件錯誤。即使你已經用自動布局一段時間了,但還是要小心這個問題,因為很容易在不經意間發(fā)生產生這個錯誤。
當你使用 可視化結構語言 (visual format language, VFL) 設置約束條件時, constraintsWithVisualFormat:options:metrics:views: 方法有一個很有用的 option 參數。如果你還沒有用過,請參見文檔。這不同于格式化字符串只能影響一個視圖,它允許你調整在一定范圍內的視圖。舉個例子,如果用可視格式語言指定水平布局,那么你可以使用 NSLayoutFormatAlignAllTop 排列可視語言里所有視圖為上邊緣對齊。
還有一個使用可視格式語言在父視圖中居中子視圖的小技巧,這技巧利用了不均等約束和可選參數。下面的代碼在父視圖中水平排列了一個視圖:
UIView *superview = theSuperView;
NSDictionary *views = NSDictionaryOfVariableBindings(superview, subview);
NSArray *c = [NSLayoutConstraint
constraintsWithVisualFormat:@"V:[superview]-(<=1)-[subview]"]
options:NSLayoutFormatAlignAllCenterX
metrics:nil
views:views];
[superview addConstraints:c];
這利用了 NSLayoutFormatAlignAllCenterX 選項在父視圖和子視圖間創(chuàng)建了居中約束。格式化字符串本身只是一個虛擬的東西,它會產生一個指定的約束,通常情況下只要子視圖是可見的,那么父視圖底部和子視圖頂部邊緣之間的空間就應該小于等于1點。你可以顛倒示例中的方向達到垂直居中的效果。
使用可視格式語言另一個方便的輔助方法就是我們在上面例子中已經使用過的 NSDictionaryFromVariableBindings 宏指令,你傳遞一個可變數量的變量過去,返回得到一個鍵為變量名的字典。
為了布局任務,你需要一遍一遍的調試,你可以方便的創(chuàng)建自己的輔助方法。比如,你想要垂直地排列一系列視圖,想要它們垂直方向間距一致,水平方向上所有視圖以它們的左邊緣對齊,用下面的方法將會方便很多:
@implementation UIView (AutoLayoutHelpers)
+ leftAlignAndVerticallySpaceOutViews:(NSArray *)views
distance:(CGFloat)distance
{
for (NSUInteger i = 1; i < views.count; i++) {
UIView *firstView = views[i - 1];
UIView *secondView = views[i];
firstView.translatesAutoResizingMaskIntoConstraints = NO;
secondView.translatesAutoResizingMaskIntoConstraints = NO;
NSLayoutConstraint *c1 = constraintWithItem:firstView
attribute:NSLayoutAttributeBottom
relatedBy:NSLayoutRelationEqual
toItem:secondView
attribute:NSLayoutAttributeTop
multiplier:1
constant:distance];
NSLayoutConstraint *c2 = constraintWithItem:firstView
attribute:NSLayoutAttributeLeading
relatedBy:NSLayoutRelationEqual
toItem:secondView
attribute:NSLayoutAttributeLeading
multiplier:1
constant:0];
[firstView.superview addConstraints:@[c1, c2]];
}
}
@end
同時也有許多不同的自動布局的庫采用了不同的方法來簡化約束條件代碼。
自動布局是布局過程中額外的一個步驟。它需要一組約束條件,并把這些約束條件轉換成 frame。因此這自然會產生一些性能的影響。你需要知道的是,在絕大數情況下,用來解決約束條件系統的時間是可以忽略不計的。但是如果你正好在處理一些性能關鍵的視圖代碼時,最好還是對這一點有所了解。
例如,有一個 collection view,當新出現一行時,你需要在屏幕上呈現幾個新的 cell,并且每個 cell 包含幾個基于自動布局的子視圖,這時你需要注意你的性能了。幸運的是,我們不需要用直覺來感受上下滾動的性能。啟動 Instruments 真實的測量一下自動布局消耗的時間。當心 NSISEngine 類的方法。
另一種情況就是當你一次顯示大量視圖時可能會有性能問題。將約束條件轉換成視圖的 frame 時,用來計算約束的算法是超線性復雜的。這意味著當有一定數量的視圖時,性能將會變得非常低下。而這確切的數目取決于你具體使用情況和視圖配置。但是,給你一個粗略的概念,在當前 iOS 設備下,這個數字大概是 100。你可以讀這兩個博客帖子了解更多的細節(jié)。
記住,這些都是極端的情況,不要過早的優(yōu)化,并且避免自動布局潛在的性能影響。這樣大多數情況便不會有問題。但是如果你懷疑這花費了你決定性的幾十毫秒,從而導致用戶界面不完全流暢的話,分析你的代碼,然后你再去考慮用回手動設置 frame 有沒有意義。此外,硬件將會變得越來越能干,并且Apple也會繼續(xù)調整自動布局的性能。所以現實世界中極端情況的性能問題也將隨著時間減少。
自動布局是一個創(chuàng)建靈活用戶界面的強大功能,這種技術不會消失。剛開始使用自動布局時可能會有點困難,但總會有柳暗花明的一天。一旦你掌握了這種技術,并且掌握了排錯的小技巧,便可庖丁解牛,恍然大悟:這太符合邏輯了。