背景
目前,開源社區和業界內已經存在一些 iOS 導航欄轉場的解決方案,但對於歷史包袱沉重的美團 App 而言,這些解決方案並不完美。有的方案不能滿足復雜的頁面跳轉場景,有的方案遷移成本較大,為此我們提出了一套解決方案並開發了相應的轉場庫,目前該轉場庫已經成為美團點評多個 App 的基礎組件之一。
在美團 App 開發的早期,涉及到導航欄樣式改變的需求時,經常會遇到轉場效果不佳或者與預期樣式不符的“小問題”。在業務體量較小的情況下,為了滿足快速的業務迭代,通常會使用硬編碼的方式來解決這一類“小問題”。但隨着美團 App 業務的高速發展,這種硬編碼的方式遇到了以下的挑戰:
- 業務模塊的不斷增加,導致使用硬編碼方式編寫的代碼維護成本增加,代碼質量迅速下降。
- 大型 App 的路由系統使得頁面間的跳轉變得更加自由和靈活,也使得導航欄相關的問題激增,不但增加了問題的排查難度,還降低了整體的開發效率。
- App 中的導航欄屬於各個業務方的公用資源,由於缺乏相應的約束機制和最佳實踐,導致業務方之間的代碼耦合程度不斷增加。
從各個角度來看,硬編碼的方式已經不能很好的解決此類問題,美團 App 需要一個更加合理、更加持久、更加簡單易行的解決方案來處理導航欄轉場問題。
本文將從導航欄的概念入手,通過講解轉場過程中的狀態管理、轉換時機和樣式變化等內容,引出了在大型應用中導航欄轉場的三種常見解決方案,並對美團點評的解決方案進行剖析。
重新認識導航欄
導航欄里的 MVC
在 iOS 系統中, 蘋果公司不僅建議開發者遵循 MVC 開發框架,在它們的代碼里也可以看到 MVC 的影子,導航欄組件的構成就是一個類似 MVC 的結構,讓我們先看看下面這張圖:

在這張圖里,我們可以將 UINavigationController 看做是 C,UINavigationBar 看做是 V,而 UIViewController 和 UINavigationItem 組成的 Stack 可以看做是 M。這里要說明的是,每個 UIViewController 都有一個屬於自己的 UINavigationItem,也就是說它們是一一對應的。
UINavigationController 通過驅動 Stack 中的 UIViewController 的變化來實現 View 層級的變化,也就是 UINavigationBar 的改變。而 UINavigationBar 樣式的數據就存儲在 UIViewController 的 UINavigationItem 中。這也就是為什么我們在代碼里只要設置 self.navigationItem 的相關屬性就可以改變 UINavigationBar 的樣式。
很多時候,國內的開發者會將 UINavigationBar 和 UINavigationController 混在一起叫導航欄,這樣的做法不僅增加了開發者之間的溝通成本,也容易導致誤解。畢竟它們是兩個完全不一樣的東西。
所以本文為了更好的闡明問題,會采用英文區分不同的概念,當需要描述籠統的導航欄概念時,會使用導航欄組件一詞。
通過這一節的回顧,我們應該明確了 NavigationItem、ViewController、NavigationBar 和 NavigationController 在 MVC 框架下的角色。下面我們會重新梳理一下導航欄的生命周期和各個相關方法的調用順序。
導航欄組件的生命周期
大家可以通過下圖獲得更為直觀的感受,進而了解到導航欄組件在 push 過程中各個方法的調用順序。

值得注意的地方有兩點:
第一個是 UINavigationController 作為 UINavigationBar 的代理,在沒有特殊需求的情況下,不應該修改其代理方法,這里是通過符號斷點獲取它們的調用順序。如果我們創建了一個自定義的導航欄組件系統,它的調用順序可能會與此不同。
第二個是用虛線圈起來的方法,它們也有可能不被調用,這與 ViewController 里的布局代碼相關,假設跳轉到新頁面后,新舊頁面中的控件位置會發生變化,或者由於數據改變驅動了控件之間的約束關系發生變化,這就會帶來新一輪的布局,進而觸發 viewWillLayoutSubview 和 viewDidLayoutSubview 這兩個方法。當然,具體的調用順序會與業務代碼緊密相關,如果我們發現順序有所不同,也不必驚慌。
下面這張圖展示了導航欄在 pop 過程中各個方法的調用順序:

除了上面說到的兩點,pop 過程中還需要注意一點,那就是從 B 返回到 A 的過程中,A 視圖控制器的 viewDidLoad 方法並不會被調用。關於這個問題,只要提醒一下,大多數人都會反應過來是為什么。不過在實際開發過程中,總會有人忘記這一點。
通過這兩個圖,我們已經基本了解了導航欄組件的生命周期和相關方法的調用順序,這也是后面章節的理論基礎。
導航欄組件的改變與革新
導航欄組件在 iOS 11 發布時,獲得了重大更新,這個更新可不是增加了一個大標題樣式(Large Title Display Mode)那么簡單,需要注意的地方大概有兩點:
-
導航欄全面支持 Auto Layout 且 NavigationBar 的層級發生了明顯的改變,關於這一點可以閱讀 UIBarButtonItem 在 iOS 11 上的改變及應對方案 。
-
由於引進了 Safe Area 等概念,
topLayoutGuide和bottomLayoutGuide等屬性會逐漸廢棄,雖然變化不大,但如果我們的導航欄在轉場過程中總是出現視圖上下移動的現象,不妨從這個方面思考一下,如果想深究可以查看 WWDC 2017 Session 412。
導航欄組件到底怎么了?
經常有人說 iOS 的原生導航欄組件不好使用,抱怨主要集中在導航欄組件的狀態管理和控件的布局問題上。
控件的布局問題隨着 iOS 11 的到來已經變得相對容易處理了不少,但導航欄組件的狀態管理仍然讓開發者頭疼不已。
可能已經有朋友在思考導航欄組件的狀態管理到底是什么東西?不要着急,下面的章節就會做相關的介紹。
導航欄的狀態管理
雖然導航欄組件的 push 和 pop 動畫給人一種每次操作后都會創建一遍導航欄組件的錯覺,但實際上這些 ViewController 都是由一個 NavigationController 所管理,所以你看到的 NavigationBar 是唯一的。

在 NavigationController 的 Stack 存儲結構下,每當 Stack 中的 ViewController 修改了導航欄,勢必會影響其他 ViewController 展示的效果。
例如下圖所示的場景,如果 NavigationBar 原先的顏色是綠色,但之后進入 Stack 里的 ViewController 將 NavigationBar 顏色修改為紫色后,在此之后 push 的 ViewController 會從默認的綠色變為紫色,直到有新的 ViewController 修改導航欄顏色才會發生變化。

雖然在 push 過程中,NavigationBar 的變化聽起來合情合理,但如果你在 NavigationBar 為綠色的 ViewController 里設置不當的話,那么當你 pop 回這個 ViewController 時,NavigationBar 可就不一定是綠色了,它還會保持為紫色的狀態。

通過這個例子,我們大概會意識到在導航欄里的 Stack 中,每個 ViewController 都可以永久的影響導航欄樣式,這種全局性的變化要求我們在實際開發中必須堅持“誰修改,誰復原”的原則,否則就會造成導航欄狀態的混亂。這不僅僅是樣式上的混亂,在一些極端狀況下,還有可能會引起 Stack 混亂,進而造成 Crash 的情況。
導航欄樣式轉換的時機
我們剛才提到了“誰修改,誰復原”的原則,但何時修改,何時復原呢?
對於那些存儲在 Stack 中的 ViewController 而言,它其實就是在不斷的經歷 appear 和 disappear 的過程,結合 ViewController 的生命周期來看,viewWillAppear: 和 viewWillDisappear: 是兩個完美的時間節點,但很多人卻對這兩個方法的調用存在疑惑。
蘋果公司在它的 API 文檔中專門用了一段文字來解答大家的疑惑,這段文字的標題為《Handling View-Related Notifications》,在這里我們直接引用原文:
When the visibility of its views changes, a view controller automatically calls its own methods so that subclasses can respond to the change. Use a method like viewWillAppear: to prepare your views to appear onscreen, and use the viewWillDisappear: to save changes or other state information. Use other methods to make appropriate changes.
Figure 1 shows the possible visible states for a view controller’s views and the state transitions that can occur. Not all ‘will’ callback methods are paired with only a ‘did’ callback method. You need to ensure that if you start a process in a ‘will’ callback method, you end the process in both the corresponding ‘did’ and the opposite ‘will’ callback method.

這里很好的解釋了所有的 will 系列方法和 did 系列方法的對應關系,同時也給我們吃了一個定心丸,那就是在 appearing 和 disappearing 狀態之間會由 will 系列方法進行銜接,避免了狀態中斷。這對於連續 push 或者連續 pop 的情況是及其重要的,否則我們無法做到 “誰修改,誰復原”的原則。
通常來說,如果只是一個簡單的導航欄樣式變化,我們的代碼結構大體會如下所示:
- (void)viewWillAppear:(BOOL)animated{ [super viewWillAppear:animated]; // MARK: change the navigationbar style } - (void)viewWillDisappear:(BOOL)animated{ [super viewWillDisappear:animated]; // MARK: restore the navigationbar style }
現在,我們明確了修改時機,接下來要明確的就是導航欄的樣式會進行怎樣的變化。
導航欄的樣式變化
對於不同 ViewController 之間的導航欄樣式變化,大多可以總結為兩種情況:
- 導航欄的顯示與否
- 導航欄的顏色變化
導航欄的顯示與否
對於顯示與否的問題,可以在上一節提到的兩個方法里調用 setNavigationBarHidden:animated: 方法,這里需要提醒的有兩點:
- 在導航欄轉場的過程中,不要天真的以為
setNavigationBarHidden:和setNavigationBarHidden:animated:的效果是一樣的,直接使用setNavigationBarHidden:會造成導航欄轉場過程中的閃現、背景錯亂等問題,這一現象在使用手勢驅動轉場的場景中十分常見,所以正確的方式是使用帶有 animated 參數的 API。 - 在 push 和 pop 的方法里也會帶有 animated 參數,盡量保證與
setNavigationBarHidden:animated:中的 animated 參數一致。
導航欄的顏色變化
顏色變化的問題就稍微復雜一些,在 iOS 7 后,導航欄增加了 translucent 效果,這使得導航欄背景色的變化出現了兩種情況:
translucent屬性值為 YES 的前提下,更改導航欄的背景色。translucent屬性值為 NO 的前提下,更改導航欄的背景色。
對於第一種情況,我們需要調用 UINavigationBar 的 setBackgroundColor: 方法。
對於第二種情況我們需要調用 UINavigationBar 的 setBackgroundImage:forBarMetrics: 方法。
對於第二種情況,這里有三點需要提示:
- 在設置透明效果時,我們通常可以直接設置一個
[UIImage new]創建的對象,無須創建一個顏色為透明色的圖片。 - 在使用
setBackgroundImage:forBarMetrics:方法的過程中,如果圖像里存在alpha值小於 1.0 的像素點,則translucent的值為 YES,反之為 NO。也就是說,如果我們真的想讓導航欄變成純色且沒有translucent效果,請保證所有像素點的alpha值等於 1。 - 如果設置了一個完全不透明的圖片且強行將 NavigationBar 的
translucent屬性設置為 YES 的話,系統會自動修正這個圖片並為它添加一個透明度,用於模擬translucent效果。 - 如果我們使用了一個帶有透明效果的圖片且導航欄的
translucent效果為 NO 的話,那么系統會在這個帶有透明效果的圖片背后,添加一個不透明的純色圖片用於整體效果的合成。這個純色圖片的顏色取決於barStyle屬性,當屬性為UIBarStyleBlack時為黑色,當屬性為UIBarStyleDefault時為白色,如果我們設置了barTintColor,則以設置的顏色為基准。
分清楚 transparent,translucent,opaque,alpha 和 opacity 也挺重要
在剛接觸導航欄 API 時,許多人經常會把文檔里的這些英文詞搞混,也不太明白帶有這些詞的變量為什么有的是布爾型,有的是浮點型,總之一切都讓人很困惑。
在這里將做了一個總結,這對於理解 Apple 的 API 設計原則十分有幫助。
transparent, translucent, opaque 三個詞經常會用在一起,它用於描述物體的透光強度,為了讓大家更好的理解這三個詞,這里做了三個比喻:
transparent是指透明,就好比我們可以透過一面干凈的玻璃清楚的看到外面的風景。translucent是指半透明,就好比我們可以透過一面有點磨砂效果的塑料牆看外面的風景,不能說看不見,但我們肯定看不清。opaque是指不透明,就好比我們透過一個堵石牆是看不見任何外面的東西,眼前看到的只有這面牆。
這三個詞更多的是用來表述一種狀態,不需要量化,所以這與這三個詞相關的屬性,一般都是 BOOL 類型。

alpha 和 opacity 經常會在一起使用,它要表示的就是透明度,在 Web 端這兩個屬性有着明顯的區別。
在 Web 端里,opacity 是設定整個元素的透明值,而 alpha 一般是放在顏色設置里面,所以我們可以做到對特定對元素的某個屬性設定 alpha,比如背景、邊框、文字等。
div { width: 100px; height: 100px; background: rgba(0,0,0,0.5); border: 1px solid #000000; opacity: 0.5; }
這一概念同樣適用於 iOS 里的概念,比如我們可以通過 alpha 通道單獨的去設置 backgroudColor、borderColor,它們互不影響,且有着獨立的 alpha 通道,我們也可以通過 opacity 統一設置整個 view 的透明度。
但與 Web 端不一致的是,iOS 里面的 view 不光擁有獨立的 alpha 屬性,同時也是基於 CALayer,所以我們可以看到任意 UIView 對象下面都會有一個 layer 的屬性,用於表明 CALayer 對象。view 的 alpha 屬性與 layer 里面的 opacity 屬性是一個相等的關系,需要注意的是 view 上的 alpha 屬性是 Web 端並不具備的一個能力,所以筆者認為:在 iOS 中去說 alpha 時,要區分是在說 view 上的屬性,還是在說顏色通道里的 alpha。
由於這兩個詞都是在描述程度,所以我們看到它們都是 CGFloat 類型:

轉場過程中需要注意的問題和細節
說完了導航欄的轉場時機和轉場方式,其實大體上你已經能處理好不同樣式間的轉換,但還有一些細節需要你去考慮,下面我們來說說其中需要你關注的兩點。
translucent 屬性帶來的布局改變
translucent 會影響導航欄組件里 ViewController 的 View 布局,這里需要大家理清 5 個 API 的使用場景:
edgesForExtendedLayoutextendedLayoutIncluedsOpaqueBarsautomaticallyAdjustScrollViewInsetscontentInsetAdjustmentBehavioradditionalSafeAreaInsets
前三個 API 是 iOS 11 之前的 API,它們之間的區別和聯系在 Stack Overflow 上有一個比較精彩的回答 - Explaining difference between automaticallyAdjustsScrollViewInsets, extendedLayoutIncludesOpaqueBars, edgesForExtendedLayout in iOS7,我在這里就不做詳細闡述,總結一下它的觀點就是:
如果我們先定義一個 UINavigationController,它里面包含了多個 UIViewController,每個 UIViewController 里面包含一個 UIView 對象:
- 那么
edgesForExtendedLayout是為了解決 UIViewController 與 UINavigationController 的對齊問題,它會影響 UIViewController 的實際大小,例如edgesForExtendedLayout的值為UIRectEdgeAll時,UIViewController 會占據整個屏幕的大小。 - 當 UIView 是一個 UIScrollView 類或者子類時,
automaticallyAdjustsScrollViewInsets是為了調整這個 UIScrollView 與 UINavigationController 的對齊問題,這個屬性並不會調整 UIViewController 的大小。 - 對於 UIView 是一個 UIScrollView 類或者子類且導航欄的背景色是不透明的狀態時,我們會發現使用
edgesForExtendedLayout來調整 UIViewController 的大小是無效的,這時候你必須使用extendedLayoutIncludesOpaqueBars來調整 UIViewController 的大小,可以認為extendedLayoutIncludesOpaqueBars是基於automaticallyAdjustsScrollViewInsets誕生的,這也是為什么經常會看到這兩個 API 會同時使用。
這些調整布局的 API 背后是一套基於 topLayoutGuide 和 bottomLayoutGuide 的計算而已,在 iOS 11 后,Apple 提出了 Safe Area 的概念,將原先分裂開來的 topLayoutGuide 和 bottomLayoutGuide 整合到一個統一的 LayoutGuide 中,也就是所謂的 Safe Area,這個改變看起來似乎不是很大,但它的出現確實方便了開發者。

如果想對 Safe Area 帶來的改變有更全面的認識,十分推薦閱讀 Rosberry 的工程師 Evgeny Mikhaylov 在 Medium 上的文章 iOS Safe Area,這篇文章基本涵蓋了 iOS 11 中所有與 Safe Area 相關的 API 並給出了真正合理的解釋。
這里只說一下 contentInsetAdjustmentBehavior 和 additionalSafeAreaInsets 兩個 API。
對於 contentInsetAdjustmentBehavior 屬性而言,它的誕生也意味着 automaticallyAdjustsScrollViewInsets 屬性的失效,所以我們在那些已經適配了 iOS 11 的工程里能看到如下類似的代碼:
if (@available(iOS 11.0, *)) { self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; } else { self.automaticallyAdjustsScrollViewInsets = NO; }
此處的代碼片段只是一個示例,並不適用所有的業務場景,這里需要着重說明幾個問題:
-
關於
contentInsetAdjustmentBehavior中的UIScrollViewContentInsetAdjustmentAutomatic的說明一直很“模糊”,通過 Evgeny Mikhaylov 的文章,我們可以了解到他在大多數情況下會與UIScrollViewContentInsetAdjustmentScrollableAxes一致,當且僅當滿足以下所有條件時才會與UIScrollViewContentInsetAdjustmentAlways相似:- UIScrollView 類型的視圖在水平軸方向是可滾動的,垂直軸是不可滾動的。
- ViewController 視圖里的第一個子控件是 UIScrollView 類型的視圖。
- ViewController 是 navigation 或者 tab 類型控制器的子視圖控制器。
- 啟用
automaticallyAdjustsScrollViewInsets。
-
iOS 11 后,通過
contentInset屬性獲取的偏移量與 iOS 10 之前的表現形式並不一致,需要獲取adjustedContentInset屬性才能保證與之前的contentInset屬性一致,這樣的改變需要我們在代碼里對不同的版本進行適配。
對於 additionalSafeAreaInsets 而言,如果系統提供的這幾種行為並不能滿足我們的布局要求,開發者還可以考慮使用 additionalSafeAreaInsets 屬性做調整,這樣的設定使得開發者可以更加靈活,更加自由的調整視圖的布局。
backIndicator 上的動畫
蘋果提供了許多修改導航欄組件樣式的 API,有關於布局的,有關於樣式的,也有關於動畫的。backIndicatorImage 和 backIndicatorTransitionMaskImage 就是其中的兩個 API。
backIndicatorImage 和 backIndicatorTransitionMaskImage 操作的是 NavigationBar 里返回按鈕的圖片,也就是下圖紅色圓圈所標注的區域。

想要成功的自定義返回按鈕的圖標樣式,我們需要同時設置這兩個 API ,從字面上來看,它們一個是返回圖片本身,另一個是返回圖片在轉場時用到的 mask 圖片,看起來不怎么難,我們寫一段代碼試試效果:
self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"]; self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrowMask"];
代碼里的圖片如下所示:

也許大多數人在這里會都認為,mask 圖片會遮擋住文字使其在遇到返回按鈕右邊緣的時候就消失。但實際的運行效果是怎么樣子的呢?我們來看一下:

在上面的圖片中,我們可以看到返回按鈕的文字從返回按鈕的圖片下面穿過並且文字被圖片所遮擋,這種動畫看起來十分奇怪,這是無法接受的。我們需要做點修改:
self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"]; self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrow"];
這一次我們將 backIndicatorTransitionMaskImage 改為 indicatorImage 所用的圖片。

到這里,可能大多數人都會好奇,這代碼也能行?讓我們看下它實際的效果:

在上面的圖中,我們看到文字在到達圖片的右邊緣時就從下方穿過並被完全遮蓋住了,這種動畫效果雖然比上面好一些,但仍然有改進的空間,不過這里我們先不繼續優化了,我們先來討論一下它們背后的運作原理。
iOS 系統會將 indicatorImage 中不透明的顏色繪制成返回按鈕的圖標, indicatorTransitionMaskImage 與 indicatorImage 的作用不同。indicatorTransitionMaskImage 將自身不透明的區域像 mask 一樣作用在 indicatorImage 上,這樣就保證了返回按鈕中的文字像左移動時,文字只出現在被 mask 的區域,也就是 indicatorTransitionMaskImage 中不透明的區域。
掌握了原理,我們來解釋下剛才的兩種現象:
在第一種實現中,我們提供的 indicatorTransitionMaskImage 覆蓋了整個返回按鈕的圖標,所以我們在轉場過程中可以清晰的看到返回按鈕的文字。
在第二種實現中,我們使用 indicatorImage 作為 indicatorTransitionMaskImage,記住文字是只能出現在 indicatorTransitionMaskImage 里不透明的區域,所以顯然返回按鈕中的文字會在圖標的最右邊就已經被遮擋住了,因為那片區域是透明的。
那么前面提到的進一步優化指的是什么呢?
讓我們來看一下下面這個示例圖,為了更好的區分,我們將 indicatorTransitionMaskImage 用紅色進行標注。黑色仍然是 indicatorImage。

按照剛才介紹的原理,我們應該可以理解,現在文字只會出現在紅色區域,那么它的實際效果是什么樣子的呢,我們可以看下圖:

現在,一個完美的返回動畫,誕生啦!
此節所用的部分效果圖出自 Ray Wenderlich 的文章 UIAppearance Tutorial: Getting Started
導航欄的跳轉或許可以這么玩兒...
前兩章的鋪墊就是為了這一章的內容,所以現在讓我們開始今天的大餐吧。
這樣真的好么?
剛才我們說了兩個頁面間 NavigationBar 的樣式變化需要在各自的 viewWillAppear: 和 viewWillDisappear: 中進行設置。那么問題就來了:這樣的設置會帶來什么問題呢?
試想一下,當我們的頁面會跳到不同的地方時,我們是不是要在 viewWillAppear: 和 viewWillDisappear: 方法里面寫上一堆的判斷呢?如果應用里還有 router 系統的話,那么頁面間的跳轉將變得更加不可預知,這時候又該如何在 viewWillAppear: 和 viewWillDisappear: 里做判斷呢?
現在我們的問題就來了,如何讓導航欄的轉場更加靈活且相互獨立呢?
常見的解決方案如下所示:
-
重新實現一個類似 UINavigationController 的容器類視圖管理器,這個容器類視圖管理器做好不同 ViewController 間的導航欄樣式轉換工作,而每個 ViewController 只需要關心自身的樣式即可。

-
將系統原有導航欄的背景設置為透明色,同時在每個 ViewController 上添加一個 View 或者 NavigationBar 來充當我們實際看到的導航欄,每個 ViewController 同樣只需要關心自身的樣式即可。

-
在轉場的過程中隱藏原有的導航欄並添加假的 NavigationBar,當轉場結束后刪除假的 NavigationBar 並恢復原有的導航欄,這一過程可以通過 Swizzle 的方式完成,而每個 ViewController 只需要關心自身的樣式即可。

這三種方案各有優劣,我們在網上也可以看到很多關於它們的討論。
例如方案一,雖然看起來工作量大且難度高,但是這個工作一旦完成,我們就會將處理導航欄轉場的主動權牢牢抓在手里。但這個方案的一個弊端就是,如果蘋果修改了導航欄的整體風格,就好比 iOS 11 的大標題特效,那么工作量就來了。
對於方案二而言,雖然看起來簡單易用,但這需要一個良好的繼承關系,如果整個工程里的繼承關系混亂或者是歷史包袱比較重,后續的維護就像“打補丁”一樣,另外這個方案也需要良好的團隊代碼規范和完善的技術文檔來做輔助。
對於方案三而言,它不需要所謂的繼承關系,使用起來也相對簡單,這對於那些繼承關系和歷史包袱比較重的工程而言,這一個不錯的解決方案,但在解決 Bug 的時候,Swizzle 這種方式無疑會增加解決問題的時間成本和學習成本。
我們的解決方案
在美團 App 的早期,各個業務方都想充分利用導航欄的能力,但對於導航欄的狀態維護缺乏理解與關注,隨着業務方的增加和代碼量的上升,與導航欄相關的問題逐漸暴露出來,此時我們才意識到這個問題的嚴重性。
大型 App 的導航欄問題就像一個典型的“公地悲劇”問題。在軟件行業,公用代碼的所有權可以被視作“公地”,因為不注重長期需求而容易遭到消耗。如果開發人員傾向於交付“價值”,而以可維護性和可理解性為代價,那么這個問題就特別普遍了。如果是這種情況,每次代碼修改將大大減少其總體質量,最終導致軟件的不可維護。
所以解決這個問題的核心在於:明確公用代碼的所有權,並在開發期施加約束。
明確公用代碼的所有權,可以理解為將導航欄相關的組件抽離成一個單獨的組件,並交由特定的團隊維護。而在開發期施加約束,則意味着我們要提供一套完整的解決方案讓各個業務方遵守。
這一節我們會以美團內部的解決方案為例,講解如何實現一個流暢的導航欄跳轉過程和相關使用方法。
設計理念
使用者只用關心當前 ViewController 的 NavigationBar 樣式,而不用在 push 或者 pop 的時候去處理 NavigationBar 樣式。
舉個例子來說,當從 A 頁面 push 到 B 頁面的時候,轉場庫會保存 A 頁面的導航欄樣式,當 pop 回去后就會還原成以前的樣式,因此我們不用考慮 pop 后導航欄樣式會改變的情況,同時我們也不必考慮 push 后的情況,因為這個是頁面 B 本身需要考慮的。
使用方法
轉場庫的使用十分簡單,我們不需要 import 任何頭文件,因為它在底層通過 Method Swizzling 進行了處理,只需要在使用的時候遵循下面 4 點即可:
- 當需要改變導航欄樣式的時候,在視圖控制器的
viewDidLoad或者viewWillAppear:方法里去設置導航欄樣式。 - 用
setBackgroundImage:forBarMetrics:方法和shadowImage屬性去修改導航欄的背景樣式。 - 不要在
viewWillDisappear:里添加針對導航欄樣式修改的代碼。 - 不要隨意修改 translucent 屬性,包括隱式的修改和顯示的修改。
隱式修改是指使用
setBackgroundImage:forBarMetrics:方法時,如果 image 里的像素點沒有alpha通道或者alpha全部等於 1 會使得translucent變為 NO 或者 nil。
基本原理
以上,我們講完了設計理念和使用方法,那么我們來看看美團的轉場庫到底做了什么?
從大方向上來看,美團使用的是前面所說的第三種方案,不過它也有一些自己獨特的地方,為了更好的讓大家理解整個過程,我們設計這樣一個場景,從頁面 A push 到頁面 B,結合之前探討過的方法調用順序,我們可以知道幾個核心方法的調用順序大致如下:
- 頁面 A 的
pushViewController:animated: - 頁面 B 的
viewDidLoadorviewWillAppear: - 頁面 B 的
viewWillLayoutSubviews - 頁面 B 的
viewDidAppear:
在 push 過程的開始,轉場庫會在頁面 A 自身的 view 上添加一個與導航欄一模一樣的 NavigationBar 並將真的導航欄隱藏。之后這個假的導航欄會一直存在頁面 A 上,用於保留 A 離開時的導航欄樣式。
等到頁面 B 調用 viewDidLoad 或者 viewWillAppear: 的時候,開發者在這里自行設置真的導航欄樣式。轉場庫在這里會對頁面布局做一些修正和輔助操作,但不會影響導航欄的樣式。
等到頁面 B 調用 viewWillLayoutSubviews 的時候,轉場庫會在頁面 B 自身的 view 上添加一個與真的導航欄一模一樣的 NavigationBar,同時將真的導航欄隱藏。此時不論真的導航欄,還是假的導航欄都已經與 viewDidLoad 或者 viewWillAppear: 里設置的一樣的。
當然,這一步也可以放在
viewWillAppear:里並在 dispatch main queue 的下一個 runloop 中處理。
等到頁面 B 調用 viewDidAppear: 的時候,轉場庫會將假的導航欄樣式設置到真的導航欄中,並將假的導航欄從視圖層級中移除,最終將真的導航欄顯示出來。
為了讓大家更好地理解上面的內容,請參考下圖:

說完了 push 過程,我們再來說一下從頁面 B pop 回頁面 A 的過程,幾個核心方法的調用順序如下:
- 頁面 B 的
popViewControllerAnimated: - 頁面 A 的
viewWillAppear: - 頁面 A 的
viewDidAppear:
在 pop 過程的開始,轉場庫會在頁面 B 自身的 view 上添加一個與導航欄一模一樣的 NavigationBar 並將真的導航欄隱藏,雖然這個假的導航欄會一直存在於頁面 B 上,但它自身會隨着頁面 B 的 dealloc 而消亡。
等到頁面 A 調用 viewWillAppear: 的時候,開發者在這里自行設置真的導航欄樣式。當然我們也可以不設置,因為這時候頁面 A 還持有一個假的導航欄,這里還保留着我們之前在 viewDidLoad 里寫的導航欄樣式。
等到頁面 A 調用 viewDidAppear: 的時候,轉場庫會將假的導航欄樣式設置到真的導航欄中,並將假的導航欄從視圖層級中移除,最終將真的導航欄顯示出來。
同樣,我們可以參考下面的圖來理解上面所說的內容:

現在,大家應該對我們美團的解決方案有了一定的認識,但在實際開發過程中,還需要考慮一些布局和適配的問題。
最佳實踐
在維護這套轉場方案的時間里,我們總結了一些此類方案的最佳實踐。
判斷導航欄問題的基本准則
如果發現導航欄在轉場過程中出現了樣式錯亂,可以遵循以下幾點基本原則:
- 檢查相應 ViewController 里是否有修改其他 ViewController 導航欄樣式的行為,如果有,請做調整。
- 保證所有對導航欄樣式變化的操作出現在
viewDidLoad和viewWillAppear:中,如果在viewWillDisappear:等方法里出現了對導航欄的樣式修改的操作,如果有,請做調整。 - 檢查是否有改動
translucent屬性,包括顯示修改和隱式修改,如果有,請做調整。
只關心當前頁面的樣式
永遠記住每個 ViewController 只用關心自己的樣式,設置的時機點在 viewWillAppear: 或者 viewDidLoad 里。
透明樣式導航欄的正確設置方法
如果需要一個透明效果的導航欄,可以使用如下代碼實現:
[self.navigationController.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault]; self.navigationController.navigationBar.shadowImage = [UIImage new];
導航欄的顏色漸變效果
如果需要導航欄實現隨滾動改變整體 alpha 值的效果,可以通過改變 setBackgroundImage:forBarMetrics: 方法里 image 的 alpha 值來達到目標,這里一般是使用監聽 scrollView.contentOffset 的手段來做。請避免直接修改 NavigationBar 的 alpha 值。
還有一點需要注意的是,在頁面轉場的過程中,也會觸發 contentOffset 的變化,所以請盡量在 disappear 的時候取消監聽。否則會容易出現導航欄透明度的變化。
導航欄背景圖片的規范
請避免背景圖里的像素點沒有 alpha 通道或者 alpha 全部等於 1,容易觸發 translucent 的隱式改變。
如果真的要隱藏導航欄
如果我們需要隱藏導航欄,請保證所有的 ViewController 能堅持如下原則:
- 每個 ViewController 只需要關心當前頁面下的導航欄是否被隱藏。
- 在
viewWillAppear:中,統一設置導航欄的隱藏狀態。 - 使用
setNavigationBarHidden:animated:方法,而不是setNavigationBarHidden:。
轉場動畫與導航欄隱藏動畫的一致性
如果在轉場的過程中還會顯示或者隱藏導航欄的話,請保證兩個方法的動畫參數一致。
- (void)viewWillAppear:(BOOL)animated{ [self.navigationController setNavigationBarHidden:YES animated:animated]; }
viewWillAppear:里的 animated 參數是受 push 和 pop 方法里 animated 參數影響。
導航欄固有的系統問題
目前已知的有兩個系統問題如下:
- 當前后兩個 ViewController 的導航欄都處於隱藏狀態,然后在后一個 ViewController 中使用返回手勢 pop 到一半時取消,再連續 push 多個頁面時會造成導航欄的 Stack 混亂或者 Crash。
- 當頁面的層級結構大體如下所示時,在紅色導航欄的 Stack 中,返回手勢會大概率的出現跨層級的跳轉,多次后會導致整個導航欄的 Stack 錯亂或者 Crash。

導航欄內置組件的布局規范
導航欄里的組件布局在 iOS 11 后發生了改變,原有的一些解決方案已經失效,這些內容不在本篇文章的討論范圍之內,推薦閱讀UIBarButtonItem 在 iOS 11 上的改變及應對方案,這篇文章詳細的解釋了 iOS 11 里的變化和可行的應對方案。
總結
本文涉及內容較多,從 iOS 系統下的導航欄概念到大型應用里的最佳實踐,這里我們總結一下整篇文章的核心內容:
- 理解導航欄組件的結構和相關方法的生命周期。
- 導航欄組件的結構留有 MVC 架構的影子,在解決問題時,要去相應的層級處理。
- 轉場問題的關鍵點是方法的調用順序,所以了解生命周期是解決此類問題的基礎。
- 狀態管理,轉換時機和樣式變化是導航欄里常見問題的三種表現形式,遇到實際問題時需要區分清楚。
- 狀態管理要堅持“誰修改,誰復原”的原則。
- 轉換時機的設定要做到連續可執行。
- 樣式變化的核心點是導航欄的顯示與否與顏色變化。
- 為了更好的配合大型應用里的路由系統,導航欄轉場的常見解決方案有三種,各有利弊,需要根據自身的業務場景和歷史包袱做取舍。
- 解決方案1:自定義導航欄組件。
- 解決方案2:在原有導航欄組件里添加 Fake Bar。
- 解決方案3:在導航欄轉場過程中添加 Fake Bar。
- 美團在實際開發過程中采用了第三種方案,並給出了適合美團 App 的最佳實踐。
特別感謝莫洲騏在此項目里的貢獻與付出。
參考鏈接
作者簡介
思琦,美團點評 iOS 工程師。2016 年加入美團,負責美團平台的業務開發及 UI 組件的維護工作。
招聘
美團平台誠招 iOS、Android、FE 高級/資深工程師和技術專家,Base 北京、上海、成都,歡迎有興趣的同學投遞簡歷到zhangsiqi04@meituan.com。
發現文章有錯誤、對內容有疑問,都可以關注美團技術團隊微信公眾號(meituantech),在后台給我們留言。我們每周會挑選出一位熱心小伙伴,送上一份精美的小禮品。快來掃碼關注我們吧!
