標籤:

談談 MVX 中的 View

由於知乎不支持 Markdown,所以文中只是大概排了下版,可以訪問 談談 MVX 中的 View 以獲得更好的閱讀體驗。如果對文章內容有疑問,可以點擊原文鏈接在 Blog 中留言。

  • 談談 MVX 中的 Model
  • 談談 MVX 中的 View
  • 談談 MVX 中的 Controller
  • 淺談 MVC、MVP 和 MVVM 架構模式

Follow GitHub: Draveness

這是談談 MVX 系列的第二篇文章,上一篇文章中對 iOS 中 Model 層的設計進行了簡要的分析;而在這裡,我們會對 MVC 中的視圖層進行討論,談一談現有的視圖層有著什麼樣的問題,如何在框架的層面上去改進,同時與服務端的視圖層進行對比,分析它們的差異。

UIKit

UIKit 是 Cocoa Touch 中用於構建和管理應用的用戶界面的框架,其中幾乎包含著與 UI 相關的全部功能,而我們今天想要介紹的其實是 UIKit 中與視圖相關的一部分,也就是 UIView 以及相關類。

UIView 可以說是 iOS 中用於渲染和展示內容的最小單元,作為開發者能夠接觸到的大多數屬性和方法也都由 UIView 所提供,比如最基本的布局方式 frame 就是通過 UIView 的屬性所控制,在 Cocoa Touch 中的所有布局系統最終都會轉化為 CFRect 並通過 frame 的方式完成最終的布局。

UIView 作為 UIKit 中極為重要的類,它的 API 以及設計理念決定了整個 iOS 的視圖層該如何工作,這也是理解視圖層之前必須要先理解 UIView 的原因。

UIView

在 UIKit 中,除了極少數用於展示的類不繼承自 UIView 之外,幾乎所有類的父類或者或者祖先鏈中一定會存在 UIView。

我們暫且拋開不繼承自 UIView 的 UIBarItem 類簇不提,先通過一段代碼分析一下 UIView 具有哪些特性。

UIImageView *backgroundView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"backgoundImage"]];nUIImageView *logoView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"logo"]];nnUIButton *loginButton = [[UIButton alloc] init];n[loginButton setTitle:@"登錄" forState:UIControlStateNormal];n[loginButton setTitleColor:UIColorFromRGB(0xFFFFFF) forState:UIControlStateNormal];n[loginButton.titleLabel setFont:[UIFont boldSystemFontOfSize:18]];n[loginButton setBackgroundColor:UIColorFromRGB(0x00C3F3)];nn[self.view addSubview:backgroundView];n[backgroundView addSubview:logoView];n[backgroundView addSubview:loginButton];n

UIView 作為視圖層大部分元素的根類,提供了兩個非常重要的特性:

  • 由於 UIView 具有 frame 屬性,所以為所有繼承自 UIView 的類提供了絕對布局相關的功能,也就是在 Cocoa Touch 中,所有的視圖元素都可以通過 frame 設置自己在父視圖中的絕對布局;
  • UIView 在介面中提供了操作和管理視圖層級的屬性和方法,比如 superview、subviews 以及 -addSubview: 等方法;

@interface UIView (UIViewHierarchy) @property (nullable, nonatomic, readonly) UIView *superview;n @property (nonatomic, readonly, copy) NSArray<__kindof UIView *> *subviews;nn - (void)addSubview:(UIView *)view;nn ...nn @end n

也就是說 UIView 和它所有的子類都可以擁有子視圖,成為容器並包含其他 UIView 的實例

[self.view addSubview:backgroundView];n [backgroundView addSubview:logoView];n [backgroundView addSubview:loginButton];n

這種使用 UIView 同時為子類提供默認的 frame 布局以及子視圖支持的方式在一定程度上能夠降低視圖模型的複雜度:因為所有的視圖都是一個容器,所以在開發時不需要區分視圖和容器,但是這種方式雖然帶來了一些方便,但是也不可避免地帶來了一些問題。

UIView 與布局

在早期的 Cocoa Touch 中,整個視圖層的布局都只是通過 frame 屬性來完成的(絕對布局),一方面是因為在 iPhone5 之前,iOS 應用需要適配的屏幕尺寸非常單一,完全沒有適配的兼容問題,所以使用單一的 frame 布局方式完全是可行的。

但是在目前各種屏幕尺寸的種類暴增的情況下,就很難使用 frame 對所有的屏幕進行適配,在這時蘋果就引入了 Auto Layout 採用相對距離為視圖層的元素進行布局。

不過,這算是蘋果比較失敗的一次性嘗試,主要是因為使用 Auto Layout 對視圖進行布局實在太過複雜,所以剛出來的時候也不溫不火,很少有人使用,直到 Masonry 的出現使得編寫 Auto Layout 代碼沒有那麼麻煩和痛苦才普及起來。

但是由於 Auto Layout 的工作原理實際上是解 N 元一次方程組,所以在遇到複雜視圖時,會遇到非常嚴重的性能問題,如果想要了解相關的問題的話,可以閱讀 從 Auto Layout 的布局演算法談性能 這篇文章,在這裡就不再贅述了。

然而 Auto Layout 的相對布局雖然能夠在一定程度上解決適配屏幕大小和尺寸接近的適配問題,比如 iPhone4s、iPhone5、iPhone6 Plus 等移動設備,或者iPad 等平板設備。但是,Auto Layout 不能通過一套代碼打通 iPhone 和 iPad 之間布局方式的差異,只能通過代碼中的 if 和 else 進行判斷。

在這種背景下,蘋果做了很多的嘗試,比如說 Size-Class-Specific Layout,Size Class 將屏幕的長寬分為三種:

  • Compact
  • Regular
  • Any

這樣就出現了最多 3 x 3 的組合,比如屏幕寬度為 Compact 高度為 Regular 等等,它與 Auto Layout 一起工作省去了一些 if 和 else 的條件判斷,但是從實際效果上來說,它的用處並不是特別大,而且使用代碼來做 Size Class 的相關工作依然非常困難。

除了 Auto Layout 和 Size Class 之外,蘋果在 iOS9 還推出了 UIStackView 來增加 iOS 中的布局方式和手段,這是一種類似 flexbox 的布局方式。

雖然 UIStackView 可以起到一定的作用,但是由於大多數 iOS 應用都要求對設計稿進行嚴格還原並且其 API 設計相對啰嗦,開發者同時也習慣了使用 Auto Layout 的開發方式,在慣性的驅動下,UIStackView 應用的也不是非常廣泛。

不過現在很多跨平台的框架都是用類似 UIStackView 的方式進行布局,比如 React Native、Weex 等,其內部都使用 Facebook 開源的 Yoga。

由於 flexbox 以及類似的布局方式在其他平台上都有類似的實現,並且其應用確實非常廣泛,筆者認為隨著工具的完善,這種布局方式會逐漸進入 iOS 開發者的工具箱中。

三種布局方式 frame、Auto Layout 以及 UIStackView 其實最終布局都會使用 frame,其他兩種方式 Auto Layout 和 UIStackView 都會將代碼描述的布局轉換成 frame 進行。

布局機制的混用

Auto Layout 和 UIStackView 的出現雖然為布局提供了一些方便,但是也增加了布局系統的複雜性。

因為在 iOS 中幾乎所有的視圖都繼承自 UIView,這樣也同時繼承了 frame 屬性,在使用 Auto Layout 和 UIStackView 時,並沒有禁用 frame 布局,所以在混用卻沒有掌握技巧時可能會有一些比較奇怪的問題。

其實,在混用 Auto Layout 和 frame 時遇到的大部分奇怪的問題都是因為 translatesAutoresizingMaskIntoConstraints 屬性沒有被正確設置的原因。

If this property』s value is true, the system creates a set of constraints that duplicate the behavior specified by the view』s autoresizing mask. This also lets you modify the view』s size and location using the view』s frame, bounds, or center properties, allowing you to create a static, frame-based layout within Auto Layout.

在這裡就不詳細解釋該屬性的作用和使用方法了。

對動畫的影響

在 Auto Layout 出現之前,由於一切布局都是使用 frame 工作的,所以在 iOS 中完成對動畫的編寫十分容易。

UIView.animate(withDuration: 1.0) { n view.frame = CGRect(x: 10, y: 10, width: 200, height: 200)n}n

而當大部分的 iOS 應用都轉而使用 Auto Layout 之後,對於視圖大小、位置有關的動畫就比較麻煩了:

topConstraint.constant = 10nleftConstraint.constant = 10nheightConstraint.constant = 200nwidthConstraint.constant = 200 UIView.animate(withDuration: 1.0) {n view.layoutIfNeeded()n}n

我們需要對視圖上的約束對象一一修改並在最後調用 layoutIfNeeded 方法才可以完成相同的動畫。由於 Auto Layout 對動畫的支持並不是特別的優秀,所以在很多時候筆者在使用 Auto Layout 的視圖上,都會使用 transform 屬性來改變視圖的位置,這樣雖然也沒有那麼的優雅,不過也是一個比較方便的解決方案。

frame 的問題

每一個 UIView 的 frame 屬性其實都是一個 CGRect 結構體,這個結構體展開之後有四個組成部分:

  • origin
    • x
    • y
  • size
    • width
    • height

當我們設置一個 UIView 對象的 frame 屬性時,其實是同時設置了它在父視圖中的位置和它的大小,從這裡可以獲得一條比較重要的信息:

iOS 中所有的 UIView 對象都是使用 frame 布局的,否則 frame 中的 origin 部分就失去了意義。

但是如果為 UIStackView 中的視圖設置 frame 的話,這個屬性就完全沒什麼作用了,比如下面的代碼:

UIStackView *stackView = [[UIStackView alloc] init];nstackView.frame = self.view.frame;n[self.view addSubview:stackView];nnUIView *greenView = [[UIView alloc] init];ngreenView.backgroundColor = [UIColor greenColor];ngreenView.frame = CGRectMake(0, 0, 100, 100);n[stackView addArrangedSubview:greenView];nnUIView *redView = [[UIView alloc] init];nredView.backgroundColor = [UIColor redColor];nredView.frame = CGRectMake(0, 0, 100, 100);n[stackView addArrangedSubview:redView];n

frame 屬性在 UIStackView 上基本上就完全失效了,我們還需要使用約束來控制 UIStackView 中視圖的大小,不過如果你要使用 frame 屬性來查看視圖在父視圖的位置和大小,在恰當的時機下是可行的。

談談 origin

但是 frame 的不正確使用會導致視圖之間的耦合,如果內部視圖設置了自己在父視圖中的 origin,但是父視圖其實並不會使用直接 frame 布局該怎麼辦?比如,父視圖是一個 UIStackView,它就會重寫子視圖的 origin 甚至是沒有正確設置的 size 屬性。

最重要的是 UIView 上 frame 的設計導致了視圖之間可能會有較強的耦合,因為子視圖不應該知道自己在父視圖中的位置,它應該只關心自己的大小。

也就是作為一個簡單的 UIView 它應該只能設置自己的 size 而不是 origin,因為父視圖可能是一個 UIStackView 也可能是一個 UITableView 甚至是一個扇形的視圖也不是不可能,所以位置這一信息並不是子視圖應該關心的

如果視圖設置了自己的 origin 其實也就默認了自己的父視圖一定是使用 frame 進行布局的,而一旦依賴於外部的信息,它就很難進行復用了。

再談 size

關於視圖大小的確認,其實也是有一些問題的,因為視圖在布局時確實可能依賴於父視圖的大小,或者更確切的說是需要父視圖提供一個可供布局的大小,然後讓子視圖通過這個 CGSize 返回一個自己需要的大小給父視圖。

這種計算視圖大小的方式,其實比較像 Texture 也就是原來的 AsyncDisplayKit 中對於布局系統的實現。

父視圖通過調用子視圖的 -layoutSpecThatFits: 方法獲取子視圖布局所需要的大小,而子視圖通過父視圖傳入的 CGSizeRange 來設置自己的大小。

- (ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSizen ...n}n

通過這種方式,子視圖對父視圖一無所知,它不知道父視圖的任何屬性,只通過 -layoutSpecThatFits: 方法傳入的參數進行布局,實現了解耦以及代碼復用。

小結

由於確實需要對多尺寸的屏幕進行適配,蘋果推出 Auto Layout 和 UIStackView 的初衷也沒有錯,但是在筆者看來,因為絕大部分視圖都繼承自 UIView,所以在很多情況下並沒有對開發者進行強限制,比如在使用 UIStackView 時只能使用 flexbox 式的布局,在使用 Auto Layout 時也只能使用約束對視圖進行布局等等,所以在很多時候會帶來一些不必要的問題。

同時 UIView 中的 frame 屬性雖然在一開始能夠很好的解決的布局的問題,但是隨著布局系統變得越來越複雜,使得很多 UI 組件在與非 frame 布局的容器同時使用時產生了衝突,最終破壞了良好的封裝性。

到目前為止 iOS 中的視圖層的問題主要就是 UIView 作為視圖層中的上帝類,提供的 frame 布局系統不能良好的和其他布局系統工作,在一些時候 frame 屬性完全成為了擺設。

其他平台對視圖層的設計

在接下來的文章中,我們會介紹和分析其他平台 Android、Web 前端以及後端是如何對視圖層進行設計的。

Android 與 View

與 iOS 上使用命令式的風格生成界面不同,Android 使用聲明式的 XML 對界面進行描述,在這裡舉一個最簡單的例子:

<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width_="match_parent" android:layout_height="match_parent" tools:context="com.example.draveness.myapplication.DisplayMessageActivity"> <TextView android:id="@+id/textView" android:layout_width_="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="TextView" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> </android.support.constraint.ConstraintLayout> n

整個 XML 文件同時描述了視圖的結構和樣式,而這也是 Android 對於視圖層的設計方式,將結構和樣式混合在一個文件中。

我們首先來分析一下上述代碼的結構,整個 XML 文件中只有兩個元素,如果我們去掉其中所有的屬性,整個界面的元素就是這樣的:

<ConstraintLayout> <TextView/> </ConstraintLayout> n

由一個 ConstraintLayout 節點包含一個 TextView 節點。

View 和 ViewGroup

我們再來看一個 Android 中稍微複雜的視圖結構:

<LinearLayout> <RelativeLayout> <ImageView/> <LinearLayout> <TextView/> <TextView/> </LinearLayout> </RelativeLayout> <View/> </LinearLayout> n

上面的 XML 代碼描述了一個更加複雜的視圖樹,這裡通過一張圖更清晰地展示該視圖表示的結構:

我們可以發現,Android 的視圖其實分為兩類:

  • 一類是不能有子節點的視圖,比如 View、ImageView 和 TextView 等;
  • 另一類是可以有子節點的視圖,比如 LinearLayout 和 RelativeLayout 等;

在 Android 中,這兩類的前者都是 View 的子類,也就是視圖;後者是 ViewGroup 的子類,它主要充當視圖的容器,與它的子節點以樹形的結構形成了一個層次結構。

這種分離視圖和容器的方式很好的分離了職責,將管理和控制子視圖的功能劃分給了 ViewGroup,將顯示內容的職責拋給了 View 對各個功能進行了合理的拆分。

子視圖的布局屬性只有在父視圖為特定 ViewGroup 時才會激活,否則就會忽略在 XML 中聲明的屬性。

混合的結構與樣式

在使用 XML 或者類 XML 的這種文本來描述視圖層的內容時,總會遇到一種無法避免的爭論:樣式到底應該放在哪裡?上面的例子顯然說明了 Android 對於這一問題的選擇,也就是將樣式放在 XML 結構中。

這一章節中並不會討論樣式到底應該放在哪裡這一問題,我們會在後面的章節中具體討論,將樣式放在 XML 結構中和單獨使用各自的優缺點。

Web 前端

隨著 Web 前端應用變得越來越複雜,在目前的大多數 Web 前端項目的實踐中,我們已經會使用前後端分離方式開發 Web 應用,而 Web 前端也同時包含 Model、View 以及 Controller 三部分,不再通過服務端直接生成前端的 HTML 代碼了。

現在最流行的 Web 前端框架有三個,分別是 React、Vue 和 Angular。不過,這篇文章會以最根本的 HTML 和 CSS 為例,簡單介紹 Web 前端中的視圖層是如何工作的。

<div> <h1 class="text-center">Header</h1> </div>nn.text-center {n text-align: center;n}n

在 HTML 中其實並沒有視圖和容器這種概念的劃分,絕大多數的元素節點都可以包含子節點,只有少數的無內容標籤,比如說 br、hr、img、input、link 以及 meta 才不會解析自己的子節點。

分離的結構與樣式

與 Android 在定義視圖時,使用混合的結構與樣式不同,Web 前端在視圖層中,採用 HTML 與 CSS 分離,即結構與樣式分離的方式進行設計;雖然在 HTML 中,我們也可以使用 style 將 CSS 代碼寫在視圖層的結構中,不過在一般情況下,我們並不會這麼做。

<body stylex="background-color:powderblue;"> </body> n

結構與樣式

在這一章節中,我們會對結構與樣式組織方式之間的優劣進行簡單的討論。

Android 和 Web 前端使用不同的方式對視圖層的結構和樣式進行組織,前者使用混合的方式,後者使用分離的結構和樣式。

相比於分離的組織方式,混合的組織方式有以下的幾個優點:

  • 不需要實現元素選擇器,降低視圖層解析器實現的複雜性;
  • 元素的樣式是內聯的,對於元素的樣式的定義一目了然,不需要考慮樣式的繼承等複雜特性;

分離的組織方式卻正相反:

  • 元素選擇器的實現,增加了 CSS 樣式代碼的復用性,不需要多次定義相同的樣式;
  • 將 CSS 代碼從結構中抽離能夠增強 HTML 的可讀性,可以非常清晰、直觀的了解 HTML 的層級結構;

對於結構與樣式,不同的組織方式能夠帶來不同的收益,這也是在設計視圖層時需要考慮的事情,我們沒有辦法在使用一種組織方式時獲得兩種方式的優點,只能儘可能權衡利弊,選擇最合適的方法。

後端的視圖層

這一章節將會研究一下後端視圖層的設計,不過在真正開始分析其視圖層設計之前,我們需要考慮一個問題,後端的視圖層到底是什麼?它有客戶端或者 Web 前端中的用於展示內容視圖層么?

這其實是一個比較難以回答的問題,不過嚴格意義上的後端是沒有用於展示內容的視圖層的,也就是為客戶端提供 API 介面的後端,它們的視圖層,其實就是用於返回 JSON 的模板。

json.extract! user, :id, :mobile, :nickname, :gender, :created_at, :updated_atnjson.url user_url user, format: :json n

在 Ruby on Rails 中一般都是類似於上面的 jbuilder 代碼。擁有視圖層的後端應用大多都是使用了模板引擎技術,直接為 HTTP 請求返回渲染之後的 HTML 和 CSS 等前端代碼。

總而言是,使用了模板引擎的後端應用其實是混合了 Web 前端和後端,整個服務的視圖層其實就是 Web 前端的代碼;而現在的大多數 Web 應用,由於遵循了前後端分離的設計,兩者之間的通信都使用約定好的 API 介面,所以後端的視圖層其實就是單純的用於渲染 JSON 的代碼,比如 Rails 中的 jbuilder。

理想中的視圖層

iOS 中理想的視圖層需要解決兩個最關鍵的問題:

  1. 細分 UIView 的職責,將其分為視圖和容器兩類,前者負責展示內容,後者負責對子視圖進行布局;
  2. 去除整個視圖層對於 frame 屬性的依賴,不對外提供 frame 介面,每個視圖只能知道自己的大小;

解決上述兩個問題的辦法就是封裝原有的 UIView 類,使用組合模式為外界提供合適的介面。

細分 UIView 的職責

Node 會作為 UIView 的代理,同時也作為整個視圖層新的根類,它將屏蔽掉外界與 UIView 層級操作的有關方法,比如說:-addSubview: 等,同時,它也會屏蔽掉 frame 屬性,這樣每一個 Node 類的實例就只能設置自己的大小了。

public class Node: Buildable {n public typealias Element = Noden public let view: UIView = UIView()nn @discardableResult n public func size(_ size: CGSize) -> Element {n view.size = sizen return selfn } n}n

上面的代碼簡單說明了這一設計的實現原理,我們可以理解為 Node 作為 UIView 的透明代理,它不提供任何與視圖層級相關的方法以及 frame 屬性。

容器的實現

除了添加一個用於展示內容的 Node 類,我們還需要一個 Container 的概念,提供為管理子視圖的 API 和方法,在這裡,我們添加了一個空的 Container 協議:

public protocol Container { }n

利用這個協議,我們構建一個 iOS 中最簡單的容器 AbsoluteContainer,內部使用 frame 對子視圖進行布局,它應該為外界提供添加子視圖的介面,在這裡就是 build(closure:) 方法:

public class AbsoluteContainer: Node, Container {n typealias Element = AbsoluteContainern @discardableResult n public func build(closure: () -> Node) -> Relation<AbsoluteContainer> {n let node = closure()n view.addSubview(node.view)n return Relation<AbsoluteContainer>(container: self, node: node)n }n}n

該方法會在調用後返回一個 Relation 對象,這主要是因為在這種設計下的 origin 或者 center 等屬性不再是 Node 的一個介面,它應該是 Node 節點出現在 AbsoluteContainer 時的產物,也就是說,只有在這兩者同時出現時,才可以使用這些屬性更新 Node 節點的位置:

public class Relation<Container> {n public let container: Containern public let node: Nodenn public init(container: Container, node: Node) {n self.container = containern self.node = noden }n}nnpublic extension Relation where Container == AbsoluteContainer {n @discardableResult n public func origin(_ origin: CGPoint) -> Relation {n node.view.origin = originn return selfn }n}n

這樣就完成了對於 UIView 中視圖層級和位置功能的剝離,同時使用透明代理以及 Relation 為 Node 提供其他用於設置視圖位置的介面。

這一章節中的代碼都來自於 Mineral,如果對代碼有興趣的讀者,可以下載自行查看。

總結

Cocoa Touch 中的 UIKit 對視圖層的設計在一開始確實是沒有問題的,主要原因是在 iOS 早期的布局方式並不複雜,只有單一的 frame 布局,而這種方式也恰好能夠滿足整個平台對於 iOS 應用開發的需要,但是隨著屏幕尺寸的增多,蘋果逐漸引入的其它布局方式與原有的體系發生了一些衝突,導致在開發時可能遇到奇怪的問題,而這也是本文想要解決的,將原有屬於 UIView 的職責抽離出來,提供更合理的抽象。

References

  • 從 Auto Layout 的布局演算法談性能
  • Understanding Auto Layout
  • Size-Class-Specific Layout
  • translatesAutoresizingMaskIntoConstraints

推薦閱讀:

有哪些適合 iPhone 5 使用的優質主屏幕壁紙?
iOS 平台越來越多標榜全手勢操作的應用,有何利弊?如何解決弊端?
為什麼iPhone的系統更新會自動下載?怎麼避免或關閉自動下載?
iOS 開發中,單款應用程序的最大可用內存是多少?
為什麼蘋果的東西軟體 BUG 少?

TAG:MVC | iOS | iOS开发 |