[iOS] Auto Layout學習筆記

以下是有關iOS 7之後推出的Auto Layout的學習筆記。原文可參考objc.io。以下譯文會不定時的翻新。

Layout過程

在螢幕顯示之前,Auto Layout多做了兩個步驟:
  • 更新限制(Update constraints)
  • 佈局視圖(laying out views)
這幾個步驟都是前後相關的,畫面顯示跟視圖佈局(layout view)有關,視圖佈局(layout view)跟更新限制(constraint updating)有關。

第一步,Update Constraints

此步驟是由下至上的,亦即,由sub-view到super-view。會準備好layout時所需的資訊,到時傳遞去設定views的frame。透過呼叫setNeedsUpdateConstraints來觸發,你可以自己手動呼叫,或是當constraints系統接收到任何改變時也會自動呼叫他。但不管怎樣,setNeedsUpdateConstraints就是用來通知auto layout關於會影響視圖佈局的改變。提到客製化視圖,你可以覆寫客製化視圖的updateConstraints來加入視圖此ㄧ所需要的局域constraint。

官方文件內對此方法的說明如下:
setNeedsUpdateConstraints方法是當你客製的view的屬性改變可能會影響到它的constraints時,你可以呼叫這個方法來指出未來某個時間點需要去更新constraints。 
接著,系統會呼叫updateConstraints,這是它正常layout pass的一部分。於constraints需要前全部一次更新是為了避免視圖在layout pass時有多次的改變而須對constraints進行不必要的重複計算。

這個方法是給程序員去告知說在Auto layout runtime時要更新constraints,以便讓Auto layout在適當的時候去進行更新。(參考此文)

再來看看updateConstraints這個方法。自定義view要自行定義constraints時可透過覆寫此方法來達到這個目標。當自定義view通知有某個constraint已經被取消時,應該要立刻移除這個constraint,並且呼叫setNeedsUpdateConstraints通知說需要去更新constraint。在執行layout前,你所實作的updateConstraints必須被調用,讓你確認你內容的所有必要的constraints就位是在你的自定義view的屬性並沒有改變的時候。

Constraints更新時你不可以取消任何constraints,也不可以去調用layout或是繪製(drawing)。

第二步,Layout

此步驟是由上至下(super-到sub-view)。Layout pass會將constraints系統的解決方案提供給view,讓view去設定它們的frame(OS X)或是設定它們的center與bound(iOS)。啟動此pass的做法是呼叫setNeedsLayout。當你要調整你的view的subview時可以在主線程呼叫這個方法。此方法會記錄你的要求並立刻回傳,但它不會迫使更新即刻發生,而是等待下一次的update cycle。你可以在這些views更新前用它來讓它們的layout無效。為了讓效能更好,這樣的作法可讓你將所有的layout更新集中在一次的update cycle中去執行。

如果你接下來要做的事情跟view的frame更新有關,那你可以呼叫layoutIfNeeded/layoutSubtreeIfNeeded(iOS / OS X)方法強制系統立刻更新view tree的layout。在你的自定義view中,你可以覆寫layoutSubviews/layout以便獲得對於layout pass完全的控制。

Display pass

最後,display pass會將view渲染到螢幕上,而且它跟你是否有使用Auto layout無關。它是上到下(top-down)的操作,你可以透過setNeedsDisplay來觸發它,這方法會導致延遲重繪,它會收集所有呼叫了此方法的呼叫(有點不知道怎麼翻譯好,假設view a和view b都有呼叫此方法,他們會統一執行重繪,而不是分開作)(跟setNeedsLayout的機制一樣)。覆寫drawRect: 方法可讓你獲得你的自定義view的顯示程序的所有權。

每個步驟都跟前一個步驟有關,比方說如果還有layout改變沒執行,display pass就會觸發layout pass。同樣的,如果constraint system還有未執行的改變,layout pass就會觸發更新constraint。

值得注意的是,這三個步驟並非單向的。基於constraint的layout是遞迴的程序,基於之前的layout方案layout pass可以去改變constraints,而這會再次觸發constraints更新,然後緊接著另一次的layout pass。這可用於製作進階的自定義view,但如果你每次自定義的實作layoutSubviews 的呼叫都會導致layout pass的話,你可能會陷入無限迴圈中。

啟動自定義View的Auto Layout(原文:Enabling Custom Views for Auto Layout)

翻譯註:原文我看了很久,我認為它的意思是,讓你的自定義view可以使用auto layout,就是啟動自定義view的auto layout能力,有人直接翻成:為了自定義視圖啟動auto layout,但我覺得不是很正確,如果有別的意見,歡迎提供給我。

實作自定義View時,須注意以下幾件跟Auto layout有關的事情:

  • 明定恰當的固有內容尺寸(appropriate intrinsic content size) 
  • 區分view的frame和alignment rect
  • 啟動基準線對準(baseline-aligned) layout, 
  • 如何hook 到 layout process內

固有內容尺寸(Intrinsic Content Size)

Intrinsic Content Size是指view根據它要顯示的內容而需要的尺寸。例如,UILabel 的高度會跟他字型有關,寬度則跟字型與它所需顯示的文字內容有關。UIProgressView 的高度會與他的插圖有關,寬度則無此限。一般的View則沒有首選的寬與高。

對於那些有intrinsic content size的自定義View,你得就它所必須顯示的內容去決定它的尺寸。


實作自定義View的intrinsic content size時,你有兩件事情得做:
  1. 覆寫intrinsicContentSize:回傳適當的尺寸以顯示內容。(註:這是OS)
  2. 呼叫invalidateIntrinsicContentSize:ㄉ任何會影響到intrinsic content size的東西改變時。(註:這是OS)
若自定義View只有一個維度是intrinsic size,另一個維度就回傳UIViewNoIntrinsicMetric/NSViewNoIntrinsicMetric。(註:這是OS)

注意intrinsic content size不可以與view的frame相關。比方說,intrinsic content size的比率跟view的frame的寬或高有關的話,是無法回傳的。

Compression Resistance and Content Hugging

每個View的各個維度都有內容的Compression resistance和hugging優先權設定。這些優先權只會影響有定義intrinsic content size的View,有定義content size才需要去抵抗壓縮或是被hug。

在後端,intrinsic content size和這些優先權會被轉譯成constraints。對於一個intrinsic content size為(100, 30)、水平/垂直的Compression resistance優先權為750且水平/垂直的Content hugging優先權為250的標籤來說,會產生如下四個constraints:
H:[label(<=100@250)]
H:[label(>=100@750)]
V:[label(<=30@250)]
V:[label(>=30@750)]
上面的寫法請參考官方文章

Frame vs. Alignment Rect

Auto layout不會對view的frame進行操作,但是會對Alignment rect進行操作。這兩者大部分的情形下是一樣的東西,這使人很容易忘記它們之間細微的差異。但是Alignment rect帶來強大的新觀念,他從視覺上的外觀解構出view的layout alignment edge。

舉例來說,自定義icon型式的按鈕的觸碰面積若小於我們需要的大小,佈局上就會遇到困難。我們會希望知道顯示在較大frame中插圖的尺寸,並據此調整按鈕的frame,如此,這個icon才能與其他介面元素進行排列。如果我們想要對內容繪製自定義的裝飾(badges、陰影和倒影)也會遇到一樣情況。

使用Alignment rect可以輕鬆的定義出矩形,用來作布局。大多數的情形下只要覆寫alignmentRectInsets就可以了,這可以返回相對於frame的edge insets。若你需要更大的控制權,你可以覆寫方法 alignmentRectForFrame:frameForAlignmentRect:。如果你不想僅只是減去固定的insets,而是基於當前frame值去計算Alignment rect,這兩個方法會很有幫助,但你必須要確定他們互為可逆(你光看名稱就知道這兩個是相反的)。

回憶一下前面提到View的intrinsic content size和Alignment rect有關,而不是和frame有關,這是有道理的,因為Auto layout是直接由intrinsic content size來產生compression resistance 和 content hugging constraints

Baseline Alignment

為了要在自定義上View使用屬性 NSLayoutAttributeBaseline 的 constraints,有一些額外的工作要作。前提是我們討論的自定義view有類似基準線(baseline)的東西時這才有意義。

在iOS上要啟動基準線對準(baseline alignment)需要實作viewForBaselineLayout。你在此索回傳的view底端邊緣會被當作基準線。預設的實作僅會回傳自己(self),但自定義的實作可以回傳任何subview。在OSX中你不會返回subview,而是透過覆寫baselineOffsetFromBottom來回傳自view底部邊緣的offset,這跟它的iOS內相應的方法預設實作是一樣的,返回0。

控制Layout

在自定義View中你對其中的subview有完全的控制權。你可以加入局部constraints,或是因應內容變化的需要改變局部constraints,你可以為subview微調layout pass的結果,或是你可以捨棄Auto Layout。

你要確保你是明智的使用這權力。大多數情況僅需加入局部constraint給subview即可處理。

Local Constraints

如果我們想要用幾個subviews來組合成一個view,那必須要透過某種方式去布局這些subviews。在Auto layout環境中,給這些view加上local constraints是很自然的事,只是,這會讓你的自定義view依賴於Auto layout,並且不能用於沒有Auto layout的視窗內,所以你最好要實作requiresConstraintBasedLayout,讓他回傳YES。

local constraints是在updateConstraints裡面所加入,實作時要確認在你已加入了你對subviews布局的constraints後,呼叫[super updateConstraints]。在這方法內,你不能去取消任何的constraints,因為你已在之前所描述的layout process的第一步驟內了。若你仍試著這樣做,將會產生錯誤訊息,告訴你你有個programming error.

若稍後有constraint失效的話,你必須立刻移除此constraint並且呼叫setNeedsUpdateConstraints,事實上,這是唯一你應該必須去觸發constraint更新pass的情況。(譯註,此段原文我琢磨之後是翻成這樣,跟對岸網友翻譯的不大一樣,主要是此段開頭這句:If something changes later on that invalidates one of your constraints......,我認為後面that的子句是修飾something,所謂的something changes是指你有個constraints被取消了,所以我忽略掉something changes的部分)

Control Layout of Subviews

若你無法透過布局constraint的使用達到你所希望的subviews的布局,你可以更進一步的覆寫layoutSubviews(iOS)/layout(OS X)。當constraint system已經解決並且該結果被應用到view上時,這樣的話,你就進入了layout process的第二步驟了。

最極端的做法是不呼叫super class的實作去覆寫layoutSubviews/layout,這表示你選擇不在此view的view tree內使用Auto layout,在此之後,你可以隨你喜好手動放置subviews。

若你仍希望使用constraint來佈局subviews,你必須呼叫[super layoutSubviews]/[super layout],然後對布局進行微調,你可以透過這做法來創建那些無法使用constraints去定義的布局,比方說,和尺寸(size)與views之間的間隙(spacing)有關的布局。

另一個有趣的使用情況是創建與佈局有關的(layout-dependent) view tree。在Auto layout完成他第一次的pass並且設定你自定義view的subviews的frames後,你可以監測這些subviews的位置和尺寸,並且改變view hierarchy和/或constraints。(WWDC session 228 – Best Practices for Mastering Auto Layout 有對此提供了了很好的例子,如果subviews被切割(clip),在第一次的pass之後,這些subviews就被移除)。

在第一次layout pass後,你也可以決定去改變constraints。舉例來說,若view太窄,你可以把對齊於一行的subviews改成兩行:

(void)layoutSubviews
{
    [super layoutSubviews];
    if (self.subviews[0].frame.size.width <= MINIMUM_WIDTH) {
        [self removeSubviewConstraints];
        self.layoutRows += 1;
        [super layoutSubviews];
    }
}

- (void)updateConstraints
{
    // add constraints depended on self.layoutRows...
    [super updateConstraints];
}


Intrinsic Content Size of Multi-Line Text

多行文本的 UILabel 和 NSTextField 的固有內容尺寸(intrinsic content size)不是很明確,文本的高度和行間寬度有關,當在求解constraints時這尚未被決定。為了解決這個問題,這兩個類別有一個新的屬性叫做preferredMaxLayoutWidth,這明確的指出了最大行寬以便計算固有內容尺寸。

因為我們事前不知道這個值,我們需要兩個步驟才能得到正確的這個值,第一步是,我們讓Auto layout完成他的工作,然後我們使用在layout pass中所產生frame再次更新首選最大寬度和觸發布局。

- (void)layoutSubviews
{
    [super layoutSubviews];
    myLabel.preferredMaxLayoutWidth = myLabel.frame.size.width;
    [super layoutSubviews];
}

[super layoutSubviews]的第一次呼叫對於標籤是必要的,這樣才能取得它的frame set,而第二次呼叫是為了在改變後更新布局,也是必要的。如果省略第二次呼叫,會得到NSInternalInconsistencyException 錯誤,這是因為我們在需要更新constraints的layout pass中做了改變,但卻沒有再次觸發布局。

我們也能在標籤的sub-class本身中做這件事:

@implementation MyLabel
- (void)layoutSubviews
{
    self.preferredMaxLayoutWidth = self.frame.size.width;
    [super layoutSubviews];
}
@end

在這情況下,就不需要先呼叫 [super layoutSubviews] 了,因為當layoutSubviews 被呼叫時,我們已經有了標籤自己的frame了。

- (void)viewDidLayoutSubviews
{
    [super viewDidLayoutSubviews];
    myLabel.preferredMaxLayoutWidth = myLabel.frame.size.width;
    [self.view layoutIfNeeded];
}

最後,要確定你沒有給label設置一個比標籤的內容壓縮阻抗(content compression resistance)的優先級還要有更高的優先級的具體的高度constraint。否則,它會代替所計算出的內容高度。

Animation

當要去動態化(animating)使用Auto layout布局的views時,基本上有兩個不同的策略:動態化constraints本身,以及,改變constaints去重新計算frame並且使用Core Animation在新舊位置之間插入frames。

這兩個作法的差異是,對constraints動態化會讓布局一直都遵守constraints系統。同時,使用Core Animation去在新舊frames之間插入frame會暫時違反constraints。

直接動態化constraints真的只是在OS X上的一個可行策略,他會受限於你可以動態化那些,因為constraint一但被創建後,只有constraint的常數可以被改變。在iOS上你就必須手動的去驅動動態,而在OS X上你可以在constraint的常數上使用animator proxy。此外,這個做法明顯的比使用Core Animation還慢,這也讓它暫時不適合行動平台。

當你使用Core Animation方法時,概念上,動畫在沒有Auto Layout時也是一樣的,差別是你不需要手動設定views的目標frames,取而代之的,你修改constraints並且觸發layout pass來為你設定frames。在iOS上,取代:

[UIView animateWithDuration:1 animations:^{
    myView.frame = newFrame;
}];

你要寫的是:

// update constraints
[UIView animateWithDuration:1 animations:^{
    [myView layoutIfNeeded];
}];

注意到使用此作法的話,你可以對constraints做的改變不會受限於constraints的常數,你可以移除constraints、加入constraints,甚至是使用暫時性的動畫constraints。因為新的constraints只會為了得出新的frames而被計算一次,所以要做出更複雜的布局改變也不是不可能的。

當你要在Auto layout裡面使用Core Animation去對views做動態化,最重要的事情是不要自己去觸碰views的frames。一但views透過Auto layout布局完成後,你就將設定它的frame的責任轉移到layout系統上了。

這表示,如果View變換(transform)改變了view的frame的話,那view變換是無法和Auto layout一起使用的,請看下面的例子:

[UIView animateWithDuration:1 animations:^{
    myView.transform = CGAffineTransformMakeScale(.5, .5);
}];

我們通常期待的是,在中心點不變動的情況下,尺寸會縮小一半,但是當你使用Auto layout時,實際情況則是會跟我們對view的位置的constraint設定有關,如果我們讓它保持在它super view的中心,那結果就一如預期,這是因為施予變換(transform)會觸發layout pass讓新的frame處在super view中心,然而,如果我們把該view的左邊邊緣對齊另一個view,那會因此而使中心點移動。

總之,像這樣對那些透過constraints布局的views施以變換(transform)並不是一個好主意,即使一開始的結果符合我們的期待,view的frame並沒有和view的constraints同步,這會導致奇怪的行為。

如果你要使用變換(transform)去對view動態化或是直接對其frame作動態化,最簡潔的做法是把view鑲嵌到container view裡面去,然後你在container上覆寫layoutSubviews,完全退出Auto layout或是僅調整它的結果。舉例來說,如果我們在container內設定一個在其左上方邊緣處使用Auto layout布局的subview,我們可以在布局後調整中心點以啟動上述的縮放變換(scale transform)。(譯註:最後一句的翻譯我思考很久,原文是we can correct its center after the layout happens to enable the scale transform from above,到底要不要把...happens to...連作一起看?後來我看後面的寫法,因為是布局開始後,就是在layoutSubviews裡面去校正中心點,那校正中心點的目的不就是讓縮放可以正常執行?所以我翻譯成這樣,跟對岸網友翻得不大一樣)

- (void)layoutSubviews
{
    [super layoutSubviews];
    static CGPoint center = {0,0};
    if (CGPointEqualToPoint(center, CGPointZero)) {
        // grab the view's center point after initial layout
        center = self.animatedView.center;
    } else {
        // apply the previous center to the animated view
        self.animatedView.center = center;
    }
}

如果我們把animatedView特性作為IBOutlet,那我們甚至可以在Interface Builder內使用container並且使用constraint來放置它的subview,同時仍然可以於中心點固定的情況下使用縮放變換。

Debugging

當要對Auto layout作debugging時,OS X比起iOS有更明顯的優勢。在OS X上你可以用Instrument的Cocoa Layout template,以及NSWindow的visualizeConstraints:方法。此外NSView 有一個identifier特性,你可以從Interface Builder或是code內設定它,以便得到更有可讀性的 Auto Layout error messages。

Unsatisfiable Constraints

當你在iOS內遇到無法滿足的constraint(unsatisfiable constraints),我們只能在printout內看到views的記憶體位址(memory addresses),特別是在更加複雜的布局中,並不容易識別views哪部份發生問題,然而,在這種情況下還是有方法可以幫助我們。

首先,當你在unsatisfiable constraints error message裡面看到NSLayoutResizingMaskConstraints時,你幾乎可以確定你忘了將你其中一個View的translatesAutoResizingMaskIntoConstraints設定成NO。雖然Interface Builder會自動幫你做這步驟,但你還是必須對所有在code內創建的views手動設定之。

如果說還是不清楚是哪個View發生錯誤,你必須透過它的記憶體位址去識別view,最直觀的選擇是使用debugger console。你可以把view本身或它的super view的描述都印出,甚或是view tree的遞迴(recursive)描述。這會給你相當多的線索去識別你要處理的view。

(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>>

一個更加視覺上的做法是直接在console上修改view,這樣你可以直接在螢幕上把它標示出來,舉例來說,你可以改變它的背景顏色:

(lldb) expr ((UIView *)0x7731880).backgroundColor = [UIColor purpleColor]

確定你稍後有重新啟動你的App,不然你做的改變是不會呈現在螢幕上的。注意要轉換memory addresses成(UIView *),以及額外的小括號,這樣我們可以使用dot語法。另外,你也可以使用訊息傳送通知(message sending notation):

(lldb) expr [(UIView *)0x7731880 setBackgroundColor:[UIColor purpleColor]]

另一個作法是用Instrument’s allocations template來profile你的App。一旦你由error message獲得記憶體位址(當你運行設備時,你需要由Console app上取得錯誤訊息),你可以將設備的detail view轉換成Objects List並且使用Cmd-F來搜尋位址,這可以告訴你是哪個方法去分配view object的,這提示了你應該去處理哪個view(至少對於那些在Code內創建的views很好用)。

你也可以藉由改進錯誤訊息本身讓在iOS中難解的unsatisfiable constraints errors變得更好懂。我們可以在category中覆寫NSLayoutConstraint的描述方法以包含views的標籤:

@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的資訊不夠完整,我們也可以加入我們自己的名稱標籤特性(nametag property)到view類別中,我們之後可以在錯誤訊息中將之印出。我們甚至可以在Interface Builder中透過在identity inspector裡面的“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

這樣的話錯誤訊息變得更易讀,而且你不用去找說哪個view是屬於哪個記憶體位址,然而,這需要你額外去對Views賦予一致性的名稱。

另一個不須做額外的工就可以給你更好的錯誤訊息的簡潔技巧(via Daniel)是,把call stack symbols整合進每一個布局constraint的錯誤訊息內。這可以讓你更容易看到問題中的constraint是在那裡被創建的,做法是,將UIView 或 NSView的addConstraint: 以及 addConstraints:方法,以及布局constraint的description方法混合在一起,這可以描述目前的call stack backtrace的第一個frame(或是任何你想由它獲得的資訊):

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

一旦你可以在每個constraint object上獲得這個資訊,你就可以修改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內有此技巧的詳細程式碼。

譯註:後面的翻譯我會嘗試以正常的中文表達方式去寫(因為現在精神比較好一點),會省略掉比較沒意義的句字,我認為看起來會比較好懂,雖然這樣翻譯可能會有人認為我翻譯的不完全,可是我希望的是好讀,而不是事後讀起來很饒口不好懂,大家如果有參考我的翻譯,可能不會把每個英文字都翻譯出來,會讓翻譯上的參考價值降低,就請多多見諒了。

Ambiguous Layout

另一個常見的問題是ambiguous layout。如果我們有個constraint忘了加進去,布局就會和我們期望的樣子不同。 針對這個問題,UIView 和 NSView提供了三種偵測方法:


如同字面上的含意,hasAmbiguousLayout是在此view是ambiguous layout的時候會回傳YES。如果我們不想自己去遍歷view hierarchy並記錄這個值,只要使用私密方法_autolayoutTrace就可以了,這個方法會回傳描述整個view tree的字串,這和recursiveDescription打印出來的結果是類似的,而這個方法會在view有ambiguous layout時告知你。

因為這個方法是私密的,所以你要確定code內沒有包含這個呼叫,有一個簡單的安全防護作是在view 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>

如同unsatisfiable constraints錯誤訊息一樣,我們也要去搞懂哪一個view屬於打印出來的記憶體位址。

另一個更視覺化的作法是使用exerciseAmbiguityInLayout來標示出ambiguous layouts。這將會在合法值的範圍內隨機的改變View的frame,然而,呼叫這個方法一次也將只會改變frame一次,所以,可能你啟動app時你完全不會看到改變。建立一個輔助方法去遍歷整個view hierarchy,並且讓所有有 ambiguous layout 的 views "晃動"(jiggle)可說是個不錯的想法。

@implementation UIView (AutoLayoutDebugging)
- (void)exerciseAmbiguityInLayoutRepeatedly:(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 Options

有幾個有幫助的選項可以協助偵錯(debugging)以及測試Auto Layout。你可以在code中設置它們,或是在scheme editor裡面指明以作為啟動參數(launch arguments)。

如同字面上的涵義,UIViewShowAlignmentRects 和 NSViewShowAlignmentRects讓所有的views的對準矩形可視,NSDoubleLocalizedStrings會把每個本地字串長度增為兩倍(譯註:簡單說,假設你的Button的title是Sunday is good,那開啟這個選項後,你的Button的title會變成Sunday is good Sunday is good,看起來好像很蠢,但這目的是可以測試你的每個UI,在裡面的String增長時會怎麼去顯示,便可知道你的設定有沒有問題),對於測試你的布局是否能處理大量文字來說這是很棒的做法。最後,設定AppleTextDirection 和 NSForceRightToLeftWritingDirection為YES來模擬由右到左的語言。

Constraint Code

當你要在code內設立Views以及它們的Constraints時,第一件你要注意的事情是,一定要把translatesAutoResizingMaskIntoConstraints 設為NO,如果忘記的話將會不可避免地導致unsatisfiable constraint 錯誤。這是一個很容易漏掉的事情,即使你已經使用Auto layout一陣子了,所以請小心這個不易察覺的問題吧。

當你使用visual format language去設立Constraints時,方法constraintsWithVisualFormat:options:metrics:views:有一個非常有用的options參數,如果你還沒使用它,請看一下文件吧,它允許你以一個維度去對齊除了受到格式字串影響的views,。舉例來說,如果格式(此處格式應意指visual format language)指明水平布局,你可以使用NSLayoutFormatAlignAllTop 將所有包含在格式(此處格式應意指visual format language)字串內的views沿著他們頂端邊緣對齊。

還有一個簡潔的小技巧可以使用visual format language讓view置於它的super view的中心,它使用了inequality constraints和options參數的優點,以下的Code將view水平的對齊於他所屬的super view內:

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 在super view 和 subview之間創建確實的centering constraint,format string本身僅只是一個虛擬的東西,它會產生一個constraint,一般情況下只要subview是可視的,它會指明super view的底部和subview的頂部之間的空間應該要小於1個點,你可以將此例子內的方向顛倒讓它可以在垂直方向上置中。

使用visual format language時另一個輔助做法是NSDictionaryFromVariableBindings macro,這我們在上面的例子中有使用,你可以給他一個或一個以上的變數,它會回傳一個Dictionary,鍵的名稱是那些變數的名稱。

對於那些你必須一再重複進行的布局任務,你可以製作自己的輔助方法,舉例來說,如果你經常必須要垂直的固定同類型的views間的間隔距離,同時將它們水平對對齊於前緣,以下的方法可以讓你的code不會太過冗長:

@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

同時,也有許多不同的Auto Layout helper libraries採用不同的做法去簡化constraints code。

Performance

Auto Layout在layout process中是額外的步驟,它要一組constraints,然後將他們換算成frames,因此,它必然會遇到效能上的影響,絕大多數的情況下,花在constraint system上的時間是可以忽略的,然而,當你要處理在效能上很關鍵的view code時,最好還是要了解一下。

舉例來說,假設你有一個collection view,當新增一行時,它必須要在螢幕上呈現多個新的cells,而且每個cell包含了許多個由Auto Layout布局的subviews。幸運的是,當上下捲動時,我們不須依賴直覺,而是啟用Instruments並且確實的測量Auto Layout所花費的時間,注意NSISEngine 類別的方法。

另一個你使用Auto Layout可能會遇到的效能問題的情況是你要一次呈現大量的views。Constraint solving algorithm會把constraints轉譯成frames,而這具有超線性的複雜度,意即,當有一定數量的views時,效能會變得非常差。確切的數量跟你使用的情況和view的設定(configuration)有關,粗略的概念是,在目前的iOS設備上,這個數字大概是100(譯註:原文是2013年寫的,現在的數量可能可以更多吧?)。若想知道更多的細節,可以參考這兩個部落格文章(第一篇第二篇)。

請記住,這些都是極端的情形,不要過早的優化和避免Auto Layout它潛在的效能影響,大多數使用的情況都不會有問題,但如果你懷疑它可能耗費了你幾十毫秒而導致UI不順暢,分析你的code,然後再決定手動設置frame有沒有意義。此外,硬體將會越來越強,Apple也會繼續調整Auto Layout的效能,所以現實中會發生效能問題的極端情況將會隨時間而減少。

結論

Auto Layout是強大的技術,它讓你可以創造具有彈性的UI,而且它不會消失,剛開始使用Auto Layout可能會有點困難,但總會有柳暗花明的一天,一但你熟練這技術,並掌握了偵錯修護的小技巧,它就會變得很有邏輯性。

譯尾:終於翻譯完成,翻了好久,原先是想說這技巧遲早要學就邊看邊翻,沒想到原文長到這種程度,不過總算是翻完了,從本文建立日期開始算也翻譯了10個工作天









留言