前言
《iOS應用架構談 開篇》出來之後,很多人來催我趕緊出第二篇。這一篇文章出得相當艱難,因為公司裡的破事兒特別多,我自己又有點私事兒,以至於能用來寫博客的時間不夠充分。
現在好啦,第二篇出來了。
當我們開始設計View層的架構時,往往是這個App還沒有開始開發,或者這個App已經發過幾個版本了,然後此時需要做非常徹底的重構。
一般也就是這兩種時機會去做View層架構,基於這個時機的特殊性,我們在這時候必須清楚認識到:View層的架構一旦實現或定型,在App發版後可修改的余地就已經非常之小了。因為它跟業務關聯最為緊密,所以哪怕稍微動一點點,它所引發的蝴蝶效應都不見得是業務方能夠hold住的。這樣的情況,就要求我們在實現這個架構時,代碼必須得改得勤快,不能偷懶。也必須抱著充分的自我懷疑態度,做決策時要拿捏好尺度。
View層的架構非常之重要,在我看來,這部分架構是這系列文章涉及4個方面最重要的一部分,沒有之一。為什麼這麼說?
View層架構是影響業務方迭代周期的因素之一
產品經理產生需求的速度會非常快,尤其是公司此時仍處於創業初期,在規模稍大的公司裡面,產品經理也喜歡挖大坑來在leader面前刷存在感,比如阿裡。這就導致業務工程師任務非常繁重。正常情況下讓產品經理砍需求是不太可能的,因此作為架構師,在架構裡有一些可做可不做的事情,最好還是能做就做掉,不要偷懶。這可以幫業務方減負,編寫代碼的時候也能更加關注業務。
我跟一些朋友交流的時候,他們都會或多或少地抱怨自己的團隊迭代速度不夠快,或者說,迭代速度不合理地慢。我認為迭代速度不是想提就能提的,迭代速度的影響因素有很多,一期PRD裡的任務量和任務復雜度都會影響迭代周期能達到什麼樣的程度。拋開這些外在的不談,從內在可能導致迭代周期達不到合理的速度的原因來看,其中有一個原因很有可能就是View層架構沒有做好,讓業務工程師完成一個不算復雜的需求時,需要處理太多額外的事情。當然,開會多,工程師水平爛也屬於迭代速度提不上去的內部原因,但這個不屬於本文討論范圍。還有,加班不是優化迭代周期的正確方式,嗯。
一般來說,一個不夠好的View層架構,主要原因有以下五種:
代碼混亂不規范
過多繼承導致的復雜依賴關系
模塊化程度不夠高,組件粒度不夠細
橫向依賴
架構設計失去傳承
這五個地方會影響業務工程師實現需求的效率,進而拖慢迭代周期。View架構的其他缺陷也會或多或少地產生影響,但在我看來這裡五個是比較重要的影響因素。如果大家覺得還有什麼因素比這四個更高的,可以在評論區提出來我補上去。
對於第五點我想做一下強調:架構的設計是一定需要有傳承的,有傳承的架構從整體上看會非常協調。但實際情況有可能是一個人走了,另一個頂上,即便任務交接得再完整,都不可避免不同的人有不同的架構思路,從而導致整個架構的流暢程度受到影響。要解決這個問題,一方面要盡量避免單點問題,讓架構師做架構的時候再帶一個人。另一方面,架構要設計得盡量簡單,平緩接手人的學習曲線。我離開安居客的時候,做過保證:凡是從我手裡出來的代碼,終身保修。所以不要想著離職了就什麼事兒都不管了,這不光是職業素養問題,還有一個是你對你的代碼是否足夠自信的問題。傳承性對於View層架構非常重要,因為它距離業務最近,改動余地最小。
所以當各位CTO、技術總監、TeamLeader們覺得迭代周期不夠快時,你可以先不忙著急吼吼地去招新人,《人月神話》早就說過加人不能完全解決問題。這時候如果你可以回過頭來看一下是不是View層架構不合理,把這個弄好也是優化迭代周期的手段之一。
嗯,至於本系列其他三項的架構方案對於迭代周期的影響程度,我認為都不如View層架構方案對迭代周期的影響高,所以這是我認為View層架構是最重要的其中一個理由。
View層架構是最貼近業務的底層架構
View層架構雖然也算底層,但還沒那麼底層,它跟業務的對接面最廣,影響業務層代碼的程度也最深。在所有的底層都牽一發的時候,在View架構上牽一發導致業務層動全身的面積最大。
所以View架構在所有架構中一旦定型,可修改的空間就最小,我們在一開始考慮View相關架構時,不光要實現功能,還要考慮更多規范上的東西。制定規范的目的一方面是防止業務工程師的代碼腐蝕View架構,另一方面也是為了能夠有所傳承。按照規范來,總還是不那麼容易出差池的。
還有就是,架構師一開始考慮的東西也會有很多,不可能在第一版就把它們全部實現,對於一個尚未發版的App來說,第一版架構往往是最小完整功能集,那麼在第二版第三版的發展過程中,架構的迭代任務就很有可能不只是你一個人的事情了,相信你一個人也不見得能搞定全部。所以你要跟你的合作者們有所約定。另外,第一版出去之後,業務工程師在使用過程中也會產生很多修改意見,哪些意見是合理的,哪些意見是不合理的,也要通過事先約定的規范來進行篩選,最終決定如何采納。
規范也不是一成不變的,什麼時候槍斃意見,什麼時候改規范,這就要靠各位的技術和經驗了。
以上就是前言。
這篇文章講什麼?
View代碼結構的規定
關於view的布局
何時使用storyboard,何時使用nib,何時使用代碼寫View
是否有必要讓業務方統一派生ViewController?
方便View布局的小工具
MVC、MVVM、MVCS、VIPER
本門心法
跨業務時View的處理
留給評論區各種補
總結
View代碼結構的規定
架構師不是寫SDK出來交付業務方使用就沒事兒了的,每家公司一定都有一套代碼規范,架構師的職責也包括定義代碼規范。按照道理來講,定代碼規范應該是屬於通識,放在這裡講的原因只是因為我這邊需要為View添加一個規范。
制定代碼規范嚴格來講不屬於View層架構的事情,但它對View層架構未來的影響會比較大,也是屬於架構師在設計View層架構時需要考慮的事情。制定View層規范的重要性在於:
提高業務方View層的可讀性可維護性
防止業務代碼對架構產生腐蝕
確保傳承
保持架構發展的方向不輕易被不合理的意見所左右
在這一節裡面我不打算從頭開始定義一套規范,蘋果有一套Coding Guidelines,當我們定代碼結構或規范的時候,首先一定要符合這個規范。
然後,相信大家各自公司裡面也都有一套自己的規范,具體怎麼個規范法其實也是根據各位架構師的經驗而定,我這邊只是建議各位在各自規范的基礎上再加上下面這一點。
viewController的代碼應該差不多是這樣:
要點如下:
所有的屬性都使用getter和setter
不要在viewDidLoad裡面初始化你的view然後再add,這樣代碼就很難看。在viewDidload裡面只做addSubview的事情,然後在viewWillAppear裡面做布局的事情(勘誤1),最後在viewDidAppear裡面做Notification的監聽之類的事情。至於屬性的初始化,則交給getter去做。
比如這樣:
#pragma mark - life cycle - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor whiteColor]; [self.view addSubview:self.firstTableView]; [self.view addSubview:self.secondTableView]; [self.view addSubview:self.firstFilterLabel]; [self.view addSubview:self.secondFilterLabel]; [self.view addSubview:self.cleanButton]; [self.view addSubview:self.originImageView]; [self.view addSubview:self.processedImageView]; [self.view addSubview:self.activityIndicator]; [self.view addSubview:self.takeImageButton]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; CGFloat width = (self.view.width - 30) / 2.0f; self.originImageView.size = CGSizeMake(width, width); [self.originImageView topInContainer:70 shouldResize:NO]; [self.originImageView leftInContainer:10 shouldResize:NO]; self.processedImageView.size = CGSizeMake(width, width); [self.processedImageView right:10 FromView:self.originImageView]; [self.processedImageView topEqualToView:self.originImageView]; CGFloat labelWidth = self.view.width - 100; self.firstFilterLabel.size = CGSizeMake(labelWidth, 20); [self.firstFilterLabel leftInContainer:10 shouldResize:NO]; [self.firstFilterLabel top:10 FromView:self.originImageView]; ... ... }
這樣即便在屬性非常多的情況下,還是能夠保持代碼整齊,view的初始化都交給getter去做了。總之就是盡量不要出現以下的情況:
- (void)viewDidLoad { [super viewDidLoad]; self.textLabel = [[UILabel alloc] init]; self.textLabel.textColor = [UIColor blackColor]; self.textLabel ... ... self.textLabel ... ... self.textLabel ... ... [self.view addSubview:self.textLabel]; }
這種做法就不夠干淨,都扔到getter裡面去就好了。關於這個做法,在唐巧的技術博客裡面有一篇文章和我所提倡的做法不同,這個我會放在後面詳細論述。
getter和setter全部都放在最後
因為一個ViewController很有可能會有非常多的view,就像上面給出的代碼樣例一樣,如果getter和setter寫在前面,就會把主要邏輯扯到後面去,其他人看的時候就要先劃過一長串getter和setter,這樣不太好。然後要求業務工程師寫代碼的時候按照順序來分配代碼塊的位置,先是life cycle,然後是Delegate方法實現,然後是event response,然後才是getters and setters。這樣後來者閱讀代碼時就能省力很多。
每一個delegate都把對應的protocol名字帶上,delegate方法不要到處亂寫,寫到一塊區域裡面去
比如UITableViewDelegate的方法集就老老實實寫上#pragma mark - UITableViewDelegate。這樣有個好處就是,當其他人閱讀一個他並不熟悉的Delegate實現方法時,他只要按住command然後去點這個protocol名字,Xcode就能夠立刻跳轉到對應這個Delegate的protocol定義的那部分代碼去,就省得他到處找了。
event response專門開一個代碼區域
所有button、gestureRecognizer的響應事件都放在這個區域裡面,不要到處亂放。
關於private methods,正常情況下ViewController裡面不應該寫
不是delegate方法的,不是event response方法的,不是life cycle方法的,就是private method了。對的,正常情況下ViewController裡面一般是不會存在private methods的,這個private methods一般是用於日期換算、圖片裁剪啥的這種小功能。這種小功能要麼把它寫成一個category,要麼把他做成一個模塊,哪怕這個模塊只有一個函數也行。
ViewController基本上是大部分業務的載體,本身代碼已經相當復雜,所以跟業務關聯不大的東西能不放在ViewController裡面就不要放。另外一點,這個private method的功能這時候只是你用得到,但是將來說不定別的地方也會用到,一開始就獨立出來,有利於將來的代碼復用。
為什麼要這樣要求?
我見過無數ViewController,代碼布局亂得一塌糊塗,這裡一個delegate那裡一個getter,然後ViewController的代碼一般都死長死長的,看了就讓人頭疼。
定義好這個規范,就能使得ViewController條理清晰,業務方程序員很能夠區分哪些放在ViewController裡面比較合適,哪些不合適。另外,也可以提高代碼的可維護性和可讀性。
關於View的布局
業務工程師在寫View的時候一定逃不掉的就是這個命題。用Frame也好用Autolayout也好,如果沒有精心設計過,布局部分一定慘不忍睹。
直接使用CGRectMake的話可讀性很差,光看那幾個數字,也無法知道view和view之間的位置關系。用Autolayout可讀性稍微好點兒,但生成Constraint的長度實在太長,代碼觀感不太好。
Autolayout這邊可以考慮使用Masonry,代碼的可讀性就能好很多。如果還有使用Frame的,可以考慮一下使用這個項目。
這個項目裡面提供了Frame相關的方便方法(UIView+LayoutMethods),裡面的方法也基本涵蓋了所有布局的需求,可讀性非常好,使用它之後基本可以和CGRectMake說再見了。因為天貓在最近才切換到支持iOS6,所以之前天貓都是用Frame布局的,在天貓App中,首頁,范兒部分頁面的布局就使用了這些方法。使用這些方便方法能起到事半功倍的效果。
這個項目也提供了Autolayout方案下生產Constraints的方便方法(UIView+AEBHandyAutoLayout),可讀性比原生好很多。我當時在寫這系列方法的時候還不知道有Masonry。知道有Masonry之後我特地去看了一下,發現Masonry功能果然強大。不過這系列方法雖然沒有Masonry那麼強大,但是也夠用了。當時安居客iPad版App全部都是Autolayout來做的View布局,就是使用的這個項目裡面的方法。可讀性很好。
讓業務工程師使用良好的工具來做View的布局,能提高他們的工作效率,也能減少bug發生的幾率。架構師不光要關心那些高大上的內容,也要多給業務工程師提供方便易用的小工具,才能發揮架構師的價值。
何時使用storyboard,何時使用nib,何時使用代碼寫View
這個問題唐巧的博客裡這篇文章也提到過,我的意見和他是基本一致的。
在這裡我還想補充一些內容:
具有一定規模的團隊化iOS開發(10人以上)有以下幾個特點:
同一份代碼文件的作者會有很多,不同作者同時修改同一份代碼的情況也不少見。因此,使用Git進行代碼版本管理時出現Conflict的幾率也比較大。
需求變化非常頻繁,產品經理一時一個主意,為了完成需求而針對現有代碼進行微調的情況,以及針對現有代碼的部分復用的情況也比較多。
復雜界面元素、復雜動畫場景的開發任務比較多。
如果這三個特點你一看就明白了,下面的解釋就可以不用看了。如果你針對我的傾向願意進一步討論的,可以先看我下面的解釋,看完再說。
同一份代碼文件的作者會有很多,不同作者同時修改同一份代碼的情況也不少見。因此,使用Git進行代碼版本管理時出現Conflict的幾率也比較大。
iOS開發過程中,會遇到最蛋疼的兩種Conflict一個是project.pbxproj,另外一個就是StoryBoard或XIB。因為這些文件的內容的可讀性非常差,雖然蘋果在XCode5(現在我有點不確定是不是這個版本了)中對StoryBoard的文件描述方式做了一定的優化,但只是把可讀性從非常差提升為很差。
然而在StoryBoard中往往包含了多個頁面,這些頁面基本上不太可能都由一個人去完成,如果另一個人在做StoryBoard的操作的時候,出於某些目的動了一下不屬於他的那個頁面,比如為了美觀調整了一下位置。然後另外一個人也因為要添加一個頁面,而在Storyboard中調整了一下某個其他頁面的位置。那麼針對這個情況我除了說個呵呵以外,我就只能說:祝你好運。看清楚哦,這還沒動具體的頁頁面內容呢。
但如果使用代碼繪制View,Conflict一樣會發生,但是這種Conflict就好解很多了,你懂的。
需求變化非常頻繁,產品經理一時一個主意,為了完成需求而針對現有代碼進行微調的情況,以及針對現有代碼的部分復用的情況也比較多。
我覺得產品經理一時一個主意不是他的錯,他說不定也是被逼的,比如誰都會來摻和一下產品的設計,公司裡的所有人,上至CEO,下至基層員工都有可能對產品設計評頭論足,只要他個人有個地方用得不爽(極大可能是個人喜好)然後又正好跟產品經理比較熟悉能夠搭得上話,都會提出各種意見。產品經理躲不起也惹不起,有時也是沒辦法,嗯。
但落實到工程師這邊來,這種情況就很蛋疼。因為這種改變有時候不光是UI,UI所對應的邏輯也有要改的可能,工程師就會兩邊文件都改,你原來link的那個view現在不link了,然後你的outlet對應也要刪掉,這兩部分只要有一個沒做,編譯通過之後跑一下App,一會兒就crash了。看起來這不是什麼大事兒,但很影響心情。
另外,如果出現部分的代碼復用,比如說某頁面下某個View也希望放在另外一個頁面裡,相關的操作就不是復制粘貼這麼簡單了,你還得重新link一遍。也很影響心情。
復雜界面元素,復雜動畫交互場景的開發任務比較多。
要是想在基於StoryBoard的項目中做一個動畫,很煩。做幾個復雜界面元素,也很煩。有的時候我們掛Custom View上去,其實在StoryBoard裡面看來就是一個空白View。然後另外一點就是,當你的layout出現問題需要調整的時候,還是挺難找到問題所在的,尤其是在復雜界面元素的情況下。
所以在針對View層這邊的要求時,我也是建議不要用StoryBoard。實現簡單的東西,用Code一樣簡單,實現復雜的東西,Code比StoryBoard更簡單。所以我更加提倡用code去畫view而不是storyboard。
是否有必要讓業務方統一派生ViewController
有的時候我們出於記錄用戶操作行為數據的需要,或者統一配置頁面的目的,會從UIViewController裡面派生一個自己的ViewController,來執行一些通用邏輯。比如天貓客戶端要求所有的ViewController都要繼承自TMViewController。這個統一的父類裡面針對一個ViewController的所有生命周期都做了一些設置,至於這裡都有哪些設置對於本篇文章來說並不重要。在這裡我想討論的是,在設計View架構時,如果為了能夠達到統一設置或執行統一邏輯的目的,使用派生的手段是有必要的嗎?
我覺得沒有必要,為什麼沒有必要?
使用派生比不使用派生更容易增加業務方的使用成本
不使用派生手段一樣也能達到統一設置的目的
這兩條原因是我認為沒有必要使用派生手段的理由,如果兩條理由你都心領神會,那麼下面的就可以不用看了。如果你還有點疑惑,請看下面我來詳細講一下原因。
為什麼使用了派生,業務方的使用成本會提升?
其實不光是業務方的使用成本,架構的維護成本也會上升。那麼具體的成本都來自於哪裡呢?
集成成本
這裡講的集成成本是這樣的:如果業務方自己開了一個獨立demo,快速完成了某個獨立流程,現在他想把這個現有流程集合進去。那麼問題就來了,他需要把所有獨立的UIViewController改變成TMViewController。那為什麼不是一開始就立刻使用TMViewController呢?因為要想引入TMViewController,就要引入整個天貓App所有的業務線,所有的基礎庫,因為這個父類裡面涉及很多天貓環境才有的內容,所謂拔出蘿卜帶出泥,你要是想簡單繼承一下就能搞定的事情,搭環境就要搞半天,然後這個小Demo才能跑得起來。
對於業務層存在的所有父類來說,它們是很容易跟項目中的其他代碼糾纏不清的,這使得業務方開發時遇到一個兩難問題:要麼把所有依賴全部搞定,然後基於App環境(比如天貓)下開發Demo,要麼就是自己Demo寫好之後,按照環境要求改代碼。這裡的兩難問題都會帶來成本,都會影響業務方的迭代進度。
我不確定各位所在公司是否會有這樣的情況,但我可以在這裡給大家舉一個我在阿裡的真實的例子:我最近在開發某濾鏡Demo和相關頁面流程,最終是要合並到天貓這個App裡面去的。使用天貓環境進行開發的話,pod install完所有依賴差不多需要10分鐘,然後打開workspace之後,差不多要再等待1分鐘讓xcode做好索引,然後才能正式開始工作。在這裡要感謝一下則平,因為他在此基礎上做了很多優化,使得這個1分鐘已經比原來的時間短很多了。但如果天貓環境有更新,你就要再重復一次上面的流程,否則 就很有可能編譯不過。
拜托,我只是想做個Demo而已,不想搞那麼復雜。
上手接受成本
新來的業務工程師有的時候不見得都記得每一個ViewController都必須要派生自TMViewController而不是直接的UIViewController。新來的工程師他不能直接按照蘋果原生的做法去做事情,他需要額外學習,比如說:所有的ViewController都必須繼承自TMViewController。
架構的維護難度
盡可能少地使用繼承能提高項目的可維護性,具體內容我在《跳出面向對象思想(一) 繼承》裡面說了,在這裡我想偷懶不想把那篇文章裡說過的東西再說一遍。
其實對於業務方來說,主要還是第一個集成成本比較蛋疼,因為這是長痛,每次要做點什麼事情都會遇到。第二點倒還好,短痛。第三點跟業務工程師沒啥關系。
那麼如果不使用派生,我們應該使用什麼手段?
我的建議是使用AOP。
在架構師實現具體的方案之前,必須要想清楚幾個問題,然後才能決定采用哪種方案。是哪幾個問題?
方案的效果,和最終要達到的目的是什麼?
在自己的知識體系裡面,是否具備實現這個方案的能力?
在業界已有的開源組件裡面,是否有可以直接拿來用的輪子?
這三個問題按照順序一一解答之後,具體方案就能出來了。
我們先看第一個問題:方案的效果,和最終要達到的目的是什麼?
方案的效果應該是:
業務方可以不用通過繼承的方法,然後框架能夠做到對ViewController的統一配置。
業務方即使脫離框架環境,不需要修改任何代碼也能夠跑完代碼。業務方的ViewController一旦丟入框架環境,不需要修改任何代碼,框架就能夠起到它應該起的作用。
其實就是要實現不通過業務代碼上對框架的主動迎合,使得業務能夠被框架感知這樣的功能。細化下來就是兩個問題,框架要能夠攔截到ViewController的生命周期,另一個問題就是,攔截的定義時機。
對於方法攔截,很容易想到Method Swizzling,那麼我們可以寫一個實例,在App啟動的時候添加針對UIViewController的方法攔截,這是一種做法。還有另一種做法就是,使用NSObject的load函數,在應用啟動時自動監聽。使用後者的好處在於,這個模塊只要被項目包含,就能夠發揮作用,不需要在項目裡面添加任何代碼。
然後另外一個要考慮的事情就是,原有的TMViewController(所謂的父類)也是會提供額外方法方便子類使用的,Method Swizzling只支持針對現有方法的操作,拓展方法的話,嗯,當然是用Category啦。
我本人不贊成Category的過度使用,但鑒於Category是最典型的化繼承為組合的手段,在這個場景下還是適合使用的。還有的就是,關於Method Swizzling手段實現方法攔截,業界也已經有了現成的開源庫:Aspects,我們可以直接拿來使用。
我這邊有個非常非常小的Demo可以放出來給大家,這個Demo只是一個點睛之筆,有一些話我也寫在這個Demo裡面了,各位架構師們你們可以基於各自公司App的需求去拓展。
這個Demo不包含Category,畢竟Category還是得你們自己去寫啊~然後這套方案能夠完成原來通過派生手段所有可以完成的任務,但同時又允許業務方不必添加任何代碼,直接使用原生的UIViewController。
然後另外要提醒的是,這方案的目的是消除不必要的繼承,雖然不限定於UIViewController,但它也是有適用范圍的,在適用繼承的地方,還是要老老實實使用繼承。比如你有一個數據模型,是由基本模型派生出的一整套模型,那麼這個時候還是老老實實使用繼承。至於拿捏何時使用繼承,相信各位架構師一定能夠處理好,或者你也可以參考我前面提到的那篇文章來控制拿捏的尺度。
關於MVC、MVVM等一大堆思想
其實這些都是相對通用的思想,萬變不離其宗的還是在開篇裡面我提到的那三個角色:數據管理者,數據加工者,數據展示者。這些五花八門的思想,不外乎就是制訂了一個規范,規定了這三個角色應當如何進行數據交換。但同時這些也是爭議最多的話題,所以我在這裡來把幾個主流思想做一個梳理,當你在做View層架構時,能夠有個比較好的參考。
MVC
MVC(Model-View-Controller)是最老牌的的思想,老牌到4人幫的書裡把它歸成了一種模式,其中Model就是作為數據管理者,View作為數據展示者,Controller作為數據加工者,Model和View又都是由Controller來根據業務需求調配,所以Controller還負擔了一個數據流調配的功能。正在我寫這篇文章的時候,我看到InfoQ發了這篇文章,裡面提到了一個移動開發中的痛點是:對MVC架構劃分的理解。我當時沒能夠去參加這個座談會,也沒辦法發表個人意見,所以就只能在這裡寫寫了。
在iOS開發領域,我們應當如何進行MVC的劃分?
這裡面其實有兩個問題:
為什麼我們會糾結於iOS開發領域中MVC的劃分問題?
在iOS開發領域中,怎樣才算是劃分的正確姿勢?
為什麼我們會糾結於iOS開發領域中MVC的劃分問題?
關於這個,每個人糾結的點可能不太一樣,我也不知道當時座談會上大家的觀點。但請允許我猜一下:是不是因為UIViewController中自帶了一個View,且控制了View的整個生命周期(viewDidLoad,viewWillAppear...),而在常識中我們都知道Controller不應該和View有如此緊密的聯系,所以才導致大家對劃分產生困惑?,下面我會針對這個猜測來給出我的意見。
在服務端開發領域,Controller和View的交互方式一般都是這樣,比如Yii:
/* ... 數據庫取數據 ... 處理數據 ... */ // 此處$this就是Controller $this->render("plan",array( 'planList' => $planList, 'plan_id' => $_GET['id'], ));
這裡Controller和View之間區分得非常明顯,Controller做完自己的事情之後,就把所有關於View的工作交給了頁面渲染引擎去做,Controller不會去做任何關於View的事情,包括生成View,這些都由渲染引擎代勞了。這是一個區別,但其實服務端View的概念和Native應用View的概念,真正的區別在於:從概念上嚴格劃分的話,服務端其實根本沒有View,拜HTTP協議所賜,我們平時所討論的View只是用於描述View的字符串(更實質的應該稱之為數據),真正的View是浏覽器。。
所以服務端只管生成對View的描述,至於對View的長相,UI事件監聽和處理,都是浏覽器負責生成和維護的。但是在Native這邊來看,原本屬於浏覽器的任務也逃不掉要自己做。那麼這件事情由誰來做最合適?蘋果給出的答案是:UIViewController。
鑒於蘋果在這一層做了很多艱苦卓絕的努力,讓iOS工程師們不必親自去實現這些內容。而且,它把所有的功能都放在了UIView上,並且把UIView做成不光可以展示UI,還可以作為容器的一個對象。
看到這兒你明白了嗎?UIView的另一個身份其實是容器!UIViewController中自帶的那個view,它的主要任務就是作為一個容器。如果它所有的相關命名都改成ViewContainer,那麼代碼就會變成這樣:
- (void)viewContainerDidLoad { [self.viewContainer addSubview:self.label]; [self.viewContainer addSubview:self.tableView]; [self.viewContainer addSubview:self.button]; [self.viewContainer addSubview:self.textField]; } ... ...
僅僅改了個名字,現在是不是感覺清晰了很多?如果再要說詳細一點,我們平常所認為的服務端MVC是這樣劃分的:
但事實上,整套流程的MVC劃分是這樣:
由圖中可以看出,我們服務端開發在這個概念下,其實只涉及M和C的開發工作,浏覽器作為View的容器,負責View的展示和事件的監聽。那麼對應到iOS客戶端的MVC劃分上面來,就是這樣:
唯一區別在於,View的容器在服務端,是由Browser負責,在整個網站的流程中,這個容器放在Browser是非常合理的。在iOS客戶端,View的容器是由UIViewController中的view負責,我也覺得蘋果做的這個選擇是非常正確明智的。
因為浏覽器和服務端之間的關系非常松散,而且他們分屬於兩個不同陣營,服務端將對View的描述生成之後,交給浏覽器去負責展示,然而一旦view上有什麼事件產生,基本上是很少傳遞到服務器(也就是所謂的Controller)的(要傳也可以:AJAX),都是在浏覽器這邊把事情都做掉,所以在這種情況下,View容器就適合放在浏覽器(V)這邊。
但是在iOS開發領域,雖然也有讓View去監聽事件的做法,但這種做法非常少,都是把事件回傳給Controller,然後Controller再另行調度。所以這時候,View的容器放在Controller就非常合適。Controller可以因為不同事件的產生去很方便地更改容器內容,比如加載失敗時,把容器內容換成失敗頁面的View,無網絡時,把容器頁面換成無網絡的View等等。
在iOS開發領域中,怎樣才算是MVC劃分的正確姿勢?
這個問題其實在上面已經解答掉一部分了,那麼這個問題的答案就當是對上面問題的一個總結吧。
M應該做的事:
給ViewController提供數據
給ViewController存儲數據提供接口
提供經過抽象的業務基本組件,供Controller調度
C應該做的事:
管理View Container的生命周期
負責生成所有的View實例,並放入View Container
監聽來自View與業務有關的事件,通過與Model的合作,來完成對應事件的業務。
V應該做的事:
響應與業務無關的事件,並因此引發動畫效果,點擊反饋(如果合適的話,盡量還是放在View去做)等。
界面元素表達
我通過與服務端MVC劃分的對比來回答了這兩個問題,之所以這麼做,是因為我知道有很多iOS工程師之前是從服務端轉過來的。我也是這樣,在進安居客之前,我也是做服務端開發的,在學習iOS的過程中,我也曾經對iOS領域的MVC劃分問題產生過疑惑,我疑惑的點就是前面開篇我猜測的點。如果有人問我iOS中應該怎麼做MVC的劃分,我就會像上面這麼回答。
MVCS
蘋果自身就采用的是這種架構思路,從名字也能看出,也是基於MVC衍生出來的一套架構。從概念上來說,它拆分的部分是Model部分,拆出來一個Store。這個Store專門負責數據存取。但從實際操作的角度上講,它拆開的是Controller。
這算是瘦Model的一種方案,瘦Model只是專門用於表達數據,然後存儲、數據處理都交給外面的來做。MVCS使用的前提是,它假設了你是瘦Model,同時數據的存儲和處理都在Controller去做。所以對應到MVCS,它在一開始就是拆分的Controller。因為Controller做了數據存儲的事情,就會變得非常龐大,那麼就把Controller專門負責存取數據的那部分抽離出來,交給另一個對象去做,這個對象就是Store。這麼調整之後,整個結構也就變成了真正意義上的MVCS。
關於胖Model和瘦Model
我在面試和跟別人聊天時,發現知道胖Model和瘦Model的概念的人不是很多。大約兩三年前國外業界曾經對此有過非常激烈的討論,主題就是Fat model, skinny controller。現在關於這方面的討論已經不多了,然而直到今天胖Model和瘦Model哪個更好,業界也還沒有定論,所以這算是目前業界懸而未解的一個爭議。我很少看到國內有討論這個的資料,所以在這裡我打算補充一下什麼叫胖Model什麼叫瘦Model。以及他們的爭論來源於何處。
什麼叫胖Model?
胖Model包含了部分弱業務邏輯。胖Model要達到的目的是,Controller從胖Model這裡拿到數據之後,不用額外做操作或者只要做非常少的操作,就能夠將數據直接應用在View上。舉個例子:
Raw Data: timestamp:1234567 FatModel: @property (nonatomic, assign) CGFloat timestamp; - (NSString *)ymdDateString; // 2015-04-20 15:16 - (NSString *)gapString; // 3分鐘前、1小時前、一天前、2015-3-13 12:34 Controller: self.dateLabel.text = [FatModel ymdDateString]; self.gapLabel.text = [FatModel gapString];
把timestamp轉換成具體業務上所需要的字符串,這屬於業務代碼,算是弱業務。FatModel做了這些弱業務之後,Controller就能變得非常skinny,Controller只需要關注強業務代碼就行了。眾所周知,強業務變動的可能性要比弱業務大得多,弱業務相對穩定,所以弱業務塞進Model裡面是沒問題的。另一方面,弱業務重復出現的頻率要大於強業務,對復用性的要求更高,如果這部分業務寫在Controller,類似的代碼會灑得到處都是,一旦弱業務有修改(弱業務修改頻率低不代表就沒有修改),這個事情就是一個災難。如果塞到Model裡面去,改一處很多地方就能跟著改,就能避免這場災難。
然而其缺點就在於,胖Model相對比較難移植,雖然只是包含弱業務,但好歹也是業務,遷移的時候很容易拔出蘿卜帶出泥。另外一點,MVC的架構思想更加傾向於Model是一個Layer,而不是一個Object,不應該把一個Layer應該做的事情交給一個Object去做。最後一點,軟件是會成長的,FatModel很有可能隨著軟件的成長越來越Fat,最終難以維護。
什麼叫瘦Model?
瘦Model只負責業務數據的表達,所有業務無論強弱一律扔到Controller。瘦Model要達到的目的是,盡一切可能去編寫細粒度Model,然後配套各種helper類或方法來對弱業務做抽象,強業務依舊交給Controller。舉個例子:
Raw Data: { "name":"casa", "sex":"male", } SlimModel: @property (nonatomic, strong) NSString *name; @property (nonatomic, strong) NSString *sex; Helper: #define Male 1; #define Female 0; + (BOOL)sexWithString:(NSString *)sex; Controller: if ([Helper sexWithString:SlimModel.sex] == Male) { ... }
由於SlimModel跟業務完全無關,它的數據可以交給任何一個能處理它數據的Helper或其他的對象,來完成業務。在代碼遷移的時候獨立性很強,很少會出現拔出蘿卜帶出泥的情況。另外,由於SlimModel只是數據表達,對它進行維護基本上是0成本,軟件膨脹得再厲害,SlimModel也不會大到哪兒去。
缺點就在於,Helper這種做法也不見得很好,這裡有一篇文章批判了這個事情。另外,由於Model的操作會出現在各種地方,SlimModel在一定程度上違背了DRY(Don't Repeat Yourself)的思路,Controller仍然不可避免在一定程度上出現代碼膨脹。
我的態度?嗯,我會在本門心法這一節裡面說。
說回來,MVCS是基於瘦Model的一種架構思路,把原本Model要做的很多事情中的其中一部分關於數據存儲的代碼抽象成了Store,在一定程度上降低了Controller的壓力。
MVVM
MVVM去年在業界討論得非常多,無論國內還是國外都討論得非常熱烈,尤其是在ReactiveCocoa這個庫成熟之後,ViewModel和View的信號機制在iOS下終於有了一個相對優雅的實現。MVVM本質上也是從MVC中派生出來的思想,MVVM著重想要解決的問題是盡可能地減少Controller的任務。不管MVVM也好,MVCS也好,他們的共識都是Controller會隨著軟件的成長,變很大很難維護很難測試。只不過兩種架構思路的前提不同,MVCS是認為Controller做了一部分Model的事情,要把它拆出來變成Store,MVVM是認為Controller做了太多數據加工的事情,所以MVVM把數據加工的任務從Controller中解放了出來,使得Controller只需要專注於數據調配的工作,ViewModel則去負責數據加工並通過通知機制讓View響應ViewModel的改變。
MVVM是基於胖Model的架構思路建立的,然後在胖Model中拆出兩部分:Model和ViewModel。關於這個觀點我要做一個額外解釋:胖Model做的事情是先為Controller減負,然後由於Model變胖,再在此基礎上拆出ViewModel,跟業界普遍認知的MVVM本質上是為Controller減負這個說法並不矛盾,因為胖Model做的事情也是為Controller減負。
另外,我前面說MVVM把數據加工的任務從Controller中解放出來,跟MVVM拆分的是胖Model也不矛盾。要做到解放Controller,首先你得有個胖Model,然後再把這個胖Model拆成Model和ViewModel。
那麼MVVM究竟應該如何實現?
這很有可能是大多數人糾結的問題,我打算憑我的個人經驗試圖在這裡回答這個問題,歡迎大家在評論區交流。
在iOS領域大部分MVVM架構都會使用ReactiveCocoa,但是使用ReactiveCocoa的iOS應用就是基於MVVM架構的嗎?那當然不是,我覺得很多人都存在這個誤區,我面試過的一些人提到了ReactiveCocoa也提到了MVVM,但他們對此的理解膚淺得讓我忍俊不禁。嗯,在網絡層架構我會舉出不使用ReactiveCocoa的例子,現在舉我感覺有點兒早。
MVVM的關鍵是要有View Model!而不是ReactiveCocoa(勘誤2)
ViewModel做什麼事情?就是把RawData變成直接能被View使用的對象的一種Model。舉個例子:
Raw Data: { ( (123, 456), (234, 567), (345, 678) ) }
這裡的RawData我們假設是經緯度,數字我隨便寫的不要太在意。然後你有一個模塊是地圖模塊,把經緯度數組全部都轉變成MKAnnotation或其派生類對於Controller來說是弱業務,(記住,胖Model就是用來做弱業務的),因此我們用ViewModel直接把它轉變成MKAnnotation的NSArray,交給Controller之後Controller直接就可以用了。
嗯,這就是ViewModel要做的事情,是不是覺得很簡單,看不出優越性?
安居客Pad應用也有一個地圖模塊,在這裡我設計了一個對象叫做reformer(其實就是ViewModel),專門用來干這個事情。那麼這麼做的優越性體現在哪兒呢?
安居客分三大業務:租房、二手房、新房。這三個業務對應移動開發團隊有三個API開發團隊,他們各自為政,這就造成了一個結果:三個API團隊回饋給移動客戶端的數據內容雖然一致,但是數據格式是不一致的,也就是相同value對應的key是不一致的。但展示地圖的ViewController不可能寫三個,所以肯定少不了要有一個API數據兼容的邏輯,這個邏輯我就放在reformer裡面去做了,於是業務流程就變成了這樣:
這麼一來,原本復雜的MKAnnotation組裝邏輯就從Controller裡面拆分了出來,Controller可以直接拿著Reformer返回的數據進行展示。APIManager就屬於Model,reformer就屬於ViewModel。具體關於reformer的東西我會放在網絡層架構來詳細解釋。Reformer此時扮演的ViewModel角色能夠很好地給Controller減負,同時,維護成本也大大降低,經過reformer產出的永遠都是MKAnnotation,Controller可以直接拿來使用。
然後另外一點,還有一個業務需求是取附近的房源,地圖API請求是能夠hold住這個需求的,那麼其他地方都不用變,在fetchDataWithReformer的時候換一個reformer就可以了,其他的事情都交給reformer。
那麼ReactiveCocoa應該扮演什麼角色?
不用ReactiveCocoa也能MVVM,用ReactiveCocoa能更好地體現MVVM的精髓。前面我舉到的例子只是數據從API到View的方向,View的操作也會產生"數據",只不過這裡的"數據"更多的是體現在表達用戶的操作上,比如輸入了什麼內容,那麼數據就是text、選擇了哪個cell,那麼數據就是indexPath。那麼在數據從view走向API或者Controller的方向上,就是ReactiveCocoa發揮的地方。
我們知道,ViewModel本質上算是Model層(因為是胖Model裡面分出來的一部分),所以View並不適合直接持有ViewModel,那麼View一旦產生數據了怎麼辦?扔信號扔給ViewModel,用誰扔?ReactiveCocoa。
在MVVM中使用ReactiveCocoa的第一個目的就是如上所說,View並不適合直接持有ViewModel。第二個目的就在於,ViewModel有可能並不是只服務於特定的一個View,使用更加松散的綁定關系能夠降低ViewModel和View之間的耦合度。
那麼在MVVM中,Controller扮演什麼角色?
大部分國內外資料闡述MVVM的時候都是這樣排布的:View <-> ViewModel <-> Model,造成了MVVM不需要Controller的錯覺,現在似乎發展成業界開始出現MVVM是不需要Controller的。的聲音了。其實MVVM是一定需要Controller的參與的,雖然MVVM在一定程度上弱化了Controller的存在感,並且給Controller做了減負瘦身(這也是MVVM的主要目的)。但是,這並不代表MVVM中不需要Controller,MMVC和MVVM他們之間的關系應該是這樣:
(來源:http://www.sprynthesis.com/2014/12/06/reactivecocoa-mvvm-introduction/)
View <-> C <-> ViewModel <-> Model,所以使用MVVM之後,就不需要Controller的說法是不正確的。嚴格來說MVVM其實是MVCVM。從圖中可以得知,Controller夾在View和ViewModel之間做的其中一個主要事情就是將View和ViewModel進行綁定。在邏輯上,Controller知道應當展示哪個View,Controller也知道應當使用哪個ViewModel,然而View和ViewModel它們之間是互相不知道的,所以Controller就負責控制他們的綁定關系,所以叫Controller/控制器就是這個原因。
前面扯了那麼多,其實歸根結底就是一句話:在MVC的基礎上,把C拆出一個ViewModel專門負責數據處理的事情,就是MVVM。然後,為了讓View和ViewModel之間能夠有比較松散的綁定關系,於是我們使用ReactiveCocoa,因為蘋果本身並沒有提供一個比較適合這種情況的綁定方法。iOS領域裡KVO,Notification,block,delegate和target-action都可以用來做數據通信,從而來實現綁定,但都不如ReactiveCocoa提供的RACSignal來的優雅,如果不用ReactiveCocoa,綁定關系可能就做不到那麼松散那麼好,但並不影響它還是MVVM。
在實際iOS應用架構中,MVVM應該出現在了大部分創業公司或者老牌公司新App的iOS應用架構圖中,據我所知易寶支付旗下的某個iOS應用就整體采用了MVVM架構,他們抽出了一個Action層來裝各種ViewModel,也是屬於相對合理的結構。
所以Controller在MVVM中,一方面負責View和ViewModel之間的綁定,另一方面也負責常規的UI邏輯處理。
VIPER
VIPER(View,Interactor,Presenter,Entity,Routing)。VIPER我並沒有實際使用過,我是在objc.io上第13期看到的。
但凡出現一個新架構或者我之前並不熟悉的新架構,有一點我能夠非常肯定,這貨一定又是把MVC的哪個部分給拆開了(壞笑,做這種判斷的理論依據在第一篇文章裡面我已經講過了)。事實情況是VIPER確實拆了很多很多,除了View沒拆,其它的都拆了。
我提到的這兩篇文章關於VIPER都講得很詳細,一看就懂。但具體在使用VIPER的時候會有什麼坑或者會有哪些爭議我不是很清楚,硬要寫這一節的話我只能靠YY,所以我想想還是算了。如果各位讀者有誰在實際App中采用VIPER架構的或者對VIPER很有興趣的,可以評論區裡面提出來,我們交流一下。
本門心法
重劍無鋒,大巧不工。 ---- 《神雕俠侶》
這是楊過在挑劍時,玄鐵重劍旁邊寫的一段話。對此我深表認同。提到這段話的目的是想告訴大家,在具體做View層架構的設計時,不需要拘泥於MVC、MVVM、VIPER等規矩。這些都是招式,告訴你你就知道了,然後怎麼玩都可以。但是心法不是這樣的,心法是大巧,說出來很簡單,但是能不能在實際架構設計時牢記心法,並且按照規矩辦事,就都看個人了。
拆分的心法
天下功夫出少林,天下架構出MVC。 ---- Casa Taloyum
MVC其實是非常高Level的抽象,意思也就是,在MVC體系下還可以再衍生無數的架構方式,但萬變不離其宗的是,它一定符合MVC的規范。這句話不是我說的,是我在某個英文資料上看到的,但時過境遷,我已經找不到出處了,我很贊同這句話。我采用的架構嚴格來說也是MVC,但也做了很多的拆分。根據前面幾節的洗禮,相信各位也明白了這樣的道理:拆分方式的不同誕生了各種不同的衍生架構方案(MVCS拆胖Controller,MVVM拆胖Model,VIPER什麼都拆),但即便拆分方式再怎麼多樣,那都只是招式。而拆分的規范,就是心法。這一節我就講講我在做View架構時,做拆分的心法。
第一心法:保留最重要的任務,拆分其它不重要的任務
在iOS開發領域內,UIViewController承載了非常多的事情,比如View的初始化,業務邏輯,事件響應,數據加工等等,當然還有更多我現在也列舉不出來,但是我們知道有一件事情Controller肯定逃不掉要做:協調V和M。也就是說,不管怎麼拆,協調工作是拆不掉的。
那麼剩下的事情我們就可以拆了,比如UITableView的DataSource。唐巧的博客有一篇文章提到他和另一個工程師關於是否要拆分DataSource爭論了好久。拆分DataSource這個做法應該也算是通用做法,在不復雜的應用裡面,它可能確實看上去只是一個數組而已,但在復雜的情況下,它背後可能涉及了文件內容讀取,數據同步等等復雜邏輯,這篇文章的第一節就提倡了這個做法,我其實也蠻提倡的。
前面的文章裡面也提了很多能拆的東西,我就不搬運了,大家可以進去看看。除了這篇文章提到的內容以外,任何比較大的,放在ViewController裡面比較髒的,只要不是Controller的核心邏輯,都可以考慮拆出去,然後在架構的時候作為一個獨立模塊去定義,以及設計實現。
第二心法:拆分後的模塊要盡可能提高可復用性,盡量做到DRY
根據第一心法拆開來的東西,很有可能還是強業務相關的,這種情況有的時候無法避免。但我們拆也要拆得好看,拆出來的部分最好能夠歸成某一類對象,然後最好能夠抽象出一個通用邏輯出來,使他能夠復用。即使不能抽出通用邏輯,那也盡量抽象出一個protocol,來實現IOP。這裡有篇關於IOP的文章,大家看了就明白優越性了。
第三心法:要盡可能提高拆分模塊後的抽象度
也就是說,拆分的粒度要盡可能大一點,封裝得要透明一些。唐巧說一切隱藏都是對代碼復雜性的增加,除非它帶來了好處,這在一定程度上有點道理,沒有好處的隱藏確實都不好(笑)。提高抽象度事實上就是增加封裝的力度,將一個負責的業務抽象成只需要很少的輸入就能完成,就是高度抽象。嗯,繼承很多層,這種做法雖然也提高了抽象程度,但我不建議這麼玩。我不確定唐巧在這裡說的隱藏跟我說的封裝是不是同一個概念,但我在這裡想提倡的是盡可能提高抽象程度。
提高抽象程度的好處在於,對於業務方來說,他只需要收集很少的信息(最小充要條件),做很少的調度(Controller負責大模塊調度,大模塊裡面再去做小模塊的調度),就能夠完成任務,這才是給Controller減負的正確姿勢。
如果拆分出來的模塊抽象程度不夠,模塊對外界要求的參數比較多,那麼在Controller裡面,關於收集參數的代碼就會多了很多。如果一部分參數的收集邏輯能夠由模塊來完成,那也可以做到幫Controller減輕負擔。否則就感覺拆得不太干淨,因為Controller裡面還是多了一些不必要的參數收集邏輯。
如果拆分出來的粒度太小,Controller在完成任務的時候調度代碼要寫很多,那也不太好。導致拆分粒度小的首要因素就是業務可能本身就比較復雜,拆分粒度小並不是不好,能大就大一點,如果小了,那也沒問題。針對這種情況的處理,就需要采用strategy模式。
針對拆分粒度小的情況,我來舉個實際例子,這個例子來源於我的一個朋友他在做聊天應用的消息發送模塊。當消息是文字時,直接發送。當消息是圖片時,需要先向服務器申請上傳資源,獲得資源ID之後再上傳圖片,上傳圖片完成之後拿到圖片URL,後面帶著URL再把信息發送出去。
這時候我們拆模塊,可以拆成:數據發送(叫A模塊),上傳資源申請(叫B模塊),內容上傳(叫C模塊)。那麼要發送文字消息,Controller調度A就可以了。如果要發送圖片消息,Controller調度B->C->A,假設將來還有上傳別的類型消息的任務,他們又要依賴D/E/F模塊,那這個事情就很蛋疼,因為邏輯復雜了,Controller要調度的東西要區分的情況就多了,Controller就膨脹了。
那麼怎麼處理呢?可以采用Strategy模式。我們再來分析一下,Controller要完成任務,它初始情況下所具有的條件是什麼?它有這條消息的所有數據,也知道這個消息的類型。那麼它最終需要的是什麼呢?消息發送的結果:發送成功或失敗。
上面就是我們要實現的最終結果,Controller只要把消息丟給MessageSender,然後讓MessageSender去做事情,做完了告訴Controller就好了。那麼MessageSender裡面怎麼去調度邏輯?MessageSender裡面可以有一個StrategyList,裡面存放了表達各種邏輯的Block或者Invocation(Target-Action)。那麼我們先定義一個Enum,裡面規定了每種任務所需要的調度邏輯。
typedef NS_ENUM (NSUInteger, MessageSendStrategy) { MessageSendStrategyText = 0, MessageSendStrategyImage = 1, MessageSendStrategyVoice = 2, MessageSendStrategyVideo = 3 }
然後在MessageSender裡面的StrategyList是這樣:
@property (nonatomic, strong) NSArray *strategyList; self.strategyList = @[TextSenderInvocation, ImageSenderInvocation, VoiceSenderInvocation, VideoSenderInvocation]; // 然後對外提供一個這樣的接口,同時有一個delegate用來回調 - (void)sendMessage:(BaseMessage *)message withStrategy:(MessageSendStrategy)strategy; @property (nonatomic, weak) iddelegate; @protocol MessageSenderDelegate @required - (void)messageSender:(MessageSender *)messageSender didSuccessSendMessage:(BaseMessage *)message strategy:(MessageSendStrategy)strategy; - (void)messageSender:(MessageSender *)messageSender didFailSendMessage:(BaseMessage *)message strategy:(MessageSendStrategy)strategy error:(NSError *)error; @end
Controller裡面是這樣使用的:
[self.messageSender sendMessage:message withStrategy:MessageSendStrategyText];
MessageSender裡面是這樣的:
[self.strategyList[strategy] invoke];
然後在某個Invocation裡面,就是這樣的:
[A invoke]; [B invoke]; [C invoke];
這樣就好啦,即便拆分粒度因為客觀原因無法細化,那也能把復雜的判斷邏輯和調度邏輯從Controller中抽出來,真正為Controller做到了減負。總之能夠做到大粒度就盡量大粒度,實在做不到那也行,用Strategy把它hold住。這個例子是小粒度的情況,大粒度的情況太簡單,我就不舉了。
設計心法
針對View層的架構不光是看重如何合理地拆分MVC來給UIViewController減負,另外一點也要照顧到業務方的使用成本。最好的情況是業務方什麼都不知道,然後他把代碼放進去就能跑,同時還能獲得框架提供的種種功能。
比如天安門廣場上的觀眾看台,就是我覺得最好的設計,因為沒人會注意到它。
第一心法:盡可能減少繼承層級,涉及蘋果原生對象的盡量不要繼承
繼承是罪惡,盡量不要繼承。就我目前了解到的情況看,除了安居客的Pad App沒有在框架級針對UIViewController有繼承的設計以外,其它公司或多或少都針對UIViewController有繼承,包括安居客iPhone app(那時候我已經對此無能為力,可見View的架構在一開始就設計好有多麼重要)。甚至有的還對UITableView有繼承,這是一件多麼令人發指,多麼慘絕人寰,多麼喪心病狂的事情啊。雖然不可避免的是有些情況我們不得不從蘋果原生對象中繼承,比如UITableViewCell。但我還是建議盡量不要通過繼承的方案來給原生對象添加功能,前面提到的Aspect方案和Category方案都可以使用。用Aspect+load來實現重載函數,用Category來實現添加函數,當然,耍點手段用Category來添加property也是沒問題的。這些方案已經覆蓋了繼承的全部功能,而且非常好維護,對於業務方也更加透明,何樂而不為呢。
不用繼承可能在思路上不會那麼直觀,但是對於不使用繼承帶來的好處是足夠頂得上使用繼承的壞處的。順便在此我要給Category正一下名:業界對於Category的態度比較暧昧,在多種場合(講座、資料文檔)都宣揚過盡可能不要使用Category。它們說的都有一定道理,但我認為Category是蘋果提供的最好的使用集合代替繼承的方案,但針對Category的設計對架構師的要求也很高,請合理使用。而且蘋果也在很多場合使用Category,來把一個原本可能很大的對象,根據不同場景拆分成不同的Category,從而提高可維護性。
不使用繼承的好處我在這裡已經說了,放到iOS應用架構來看,還能再多額外兩個好處:1. 在業務方做業務開發或者做Demo時,可以脫離App環境,或花更少的時間搭建環境。2. 對業務方來說功能更加透明,也符合業務方在開發時的第一直覺。
第二心法:做好代碼規范,規定好代碼在文件中的布局,尤其是ViewController
這主要是為了提高可維護性。在一個文件非常大的對象中,尤其要限制好不同類型的代碼在文件中的布局。比如在寫ViewController時,我之前給團隊制定的規范就是前面一段全部是getter setter,然後接下來一段是life cycle,viewDidLoad之類的方法都在這裡。然後下面一段是各種要實現的Delegate,再下面一段就是event response,Button的或者GestureRecognizer的都在這裡。然後後面是private method。一般情況下,如果做好拆分,ViewController的private method那一段是沒有方法的。後來隨著時間的推移,我發現開頭放getter和setter太影響閱讀了,所以後面改成全放在ViewController的最後。
第三心法:能不放在Controller做的事情就盡量不要放在Controller裡面去做
Controller會變得龐大的原因,一方面是因為Controller承載了業務邏輯,MVC的總結者(在正式提出MVC之前,或多或少都有人這麼設計,所以說MVC的設計者不太准確)對Controller下的定義也是承載業務邏輯,所以Controller就是用來干這事兒的,天經地義。另一方面是因為在MVC中,關於Model和View的定義都非常明確,很少有人會把一個屬於M或V的東西放到其他地方。然後除了Model和View以外,還會剩下很多模稜兩可的東西,這些東西從概念上講都算Controller,而且由於M和V定義得那麼明確,所以直覺上看,這些東西放在M或V是不合適的,於是就往Controller裡面塞咯。
正是由於上述兩方面原因導致了Controller的膨脹。我們再細細思考一下,Model膨脹和View膨脹,要針對它們來做拆分其實都是相對容易的,Controller膨脹之後,拆分就顯得艱難無比。所以如果能夠在一開始就盡量把能不放在Controller做的事情放到別的地方去做,這樣在第一時間就可以讓你的那部分將來可能會被拆分的代碼遠離業務邏輯。所以我們要稍微轉變一下思路:模稜兩可的模塊,就不要塞到Controller去了,塞到V或者塞到M或者其他什麼地方都比塞進Controller好,便於將來拆分。
所以關於前面我按下不表的關於胖Model和瘦Model的選擇,我的態度是更傾向於胖Model。客觀地說,業務膨脹之後,代碼規模肯定少不了的,不管你技術再好,經驗再豐富,代碼量最多只能優化,該膨脹還是要膨脹的,而且優化之後代碼往往也比較難看,使用各種奇技淫巧也是有代價的。所以,針對代碼量優化的結果,往往要麼就是犧牲可讀性,要麼就是犧牲可移植性(通用性),Every magic always needs a pay, you have to make a trade-off.。
那麼既然膨脹出來的代碼,或者將來有可能膨脹的代碼,不管放在MVC中的哪一個部分,最後都是要拆分的,既然遲早要拆分,那不如放Model裡面,這樣將來拆分胖Model也能比拆分胖Cotroller更加容易。在我還在安居客的時候,安居客Pad app承載最復雜業務的ViewController才不到600行,其他多數Controller都是在300-400行之間,這就為後面接手的人降低了非常多的上手難度和維護復雜度。拆分出來的東西都是可以直接遷移給iPhone app使用的。現在看天貓的ViewControler,動不動就幾千行,看不了多久頭就暈了,問了一下,大家都表示很習慣這樣的代碼長度,攤手。
第四心法:架構師是為業務工程師服務的,而不是去使喚業務工程師的
架構師在公司裡的職級和地位往往都是要高於業務工程師的,架構師的技術實力和經驗往往也都是高於業務工程師的。所以你值得在公司裡獲得較高的地位,但是在公司裡的地位高不代表在軟件工程裡面的角色地位也高。架構師是要為業務工程師服務的,是他們使喚你而不是你使喚他們。另外,制定規范一方面是起到約束業務工程師的代碼,但更重要的一點是,這其實是利用你的能力幫助業務工程師避免他無法預見的危機,所以地位高有一定的好處,畢竟夏蟲不可語冰,有的時候不見得能夠解釋得通,因此高地位隨之而來的就是說服力會比較強。但在軟件工程裡,一定要保持謙卑,一定要多為業務工程師考慮。
一個不懂這個道理的架構師,設計出來的東西往往復雜難用,因為他只願意做核心的東西,周邊不願意做的都期望交給業務工程師去做,甚至有的時候就只做了個Demo,然後就交給業務工程師了,業務工程師變成給他打工的了。但是一個懂得這個道理的架構師,設計出來的東西會非常好用,業務方只需要扔很少的參數然後拿結果就好了,這樣的架構才叫好的架構。
舉一個保存圖片到本地的例子,一種做法是提供這樣的接口:- (NSString *)saveImageWithData:(NSData *)imageData,另一種是- (NSString *)saveImage:(UIImage *)image。後者更好,原因自己想。
你的態度越謙卑,就越能設計出好的架構,這是我設計心法裡的最後一條,也是最重要的一條。即使你現在技術實力不是業界大牛級別的,但只要保持這個心態去做架構,去做設計,就已經是合格的架構師了,要成為業界大牛也會非常快。
小總結
其實針對View層的架構設計,還是要做好三點:代碼規范,架構模式,工具集。
代碼規范對於View層來說意義重大,畢竟View層非常重業務,如果代碼布局混亂,後來者很難接手,也很難維護。
架構模式具體如何選擇,完全取決於業務復雜度。如果業務相當相當復雜,那就可以使用VIPER,如果相對簡單,那就直接MVC稍微改改就好了。每一種已經成為定式的架構模式不見得都適合各自公司對應的業務,所以需要各位架構師根據情況去做一些拆分或者改變。拆分一般都不會出現問題,改變的時候,只要別把MVC三個角色搞混就好了,M該做啥做啥,C該做啥做啥,V該做啥做啥,不要亂來。關於大部分的架構模式應該是什麼樣子,這篇文章裡都已經說過了,不過我認為最重要的還是後面的心法,模式只是招術,熟悉了心法才能大巧不工。
View層的工具集主要還是集中在如何對View進行布局,以及一些特定的View,比如帶搜索提示的搜索框這種。這篇文章只提到了View布局的工具集,其它的工具集相對而言是更加取決於各自公司的業務的,各自實現或者使用CocoaPods裡現成的都不是很難。
對於小規模或者中等規模iOS開發團隊來說,做好以上三點就足夠了。在大規模團隊中,有一個額外問題要考慮,就是跨業務頁面調用方案的設計。
跨業務頁面調用方案的設計
跨業務頁面調用是指,當一個App中存在A業務,B業務等多個業務時,B業務有可能會需要展示A業務的某個頁面,A業務也有可能會調用其他業務的某個頁面。在小規模的App中,我們直接import其他業務的某個ViewController然後或者push或者present,是不會產生特別大的問題的。但是如果App的規模非常大,涉及業務數量非常多,再這麼直接import就會出現問題。
可以看出,跨業務的頁面調用在多業務組成的App中會導致橫向依賴。那麼像這樣的橫向依賴,如果不去設法解決,會導致什麼樣的結果?
當一個需求需要多業務合作開發時,如果直接依賴,會導致某些依賴層上端的業務工程師在前期空轉,依賴層下端的工程師任務繁重,而整個需求完成的速度會變慢,影響的是團隊開發迭代速度。
當要開辟一個新業務時,如果已有各業務間直接依賴,新業務又依賴某個舊業務,就導致新業務的開發環境搭建困難,因為必須要把所有相關業務都塞入開發環境,新業務才能進行開發。影響的是新業務的響應速度。
當某一個被其他業務依賴的頁面有所修改時,比如改名,涉及到的修改面就會特別大。影響的是造成任務量和維護成本都上升的結果。
當然,如果App規模特別小,這三點帶來的影響也會特別小,但是在阿裡這樣大規模的團隊中,像天貓/淘寶這樣大規模的App,一旦遇上這裡面哪怕其中一件事情,就特麼很坑爹。
那麼應該怎樣處理這個問題?
讓依賴關系下沉。
怎麼讓依賴關系下沉?引入Mediator模式。
所謂引入Mediator模式來讓依賴關系下沉,實質上就是每次呼喚頁面的時候,通過一個中間人來召喚另外一個頁面,這樣只要每個業務依賴這個中間人就可以了,中間人的角色就可以放在業務層的下面一層,這就是依賴關系下沉。
當A業務需要調用B業務的某個頁面的時候,將請求交給Mediater,然後由Mediater通過某種手段獲取到B業務頁面的實例,交還給A就行了。在具體實現這個機制的過程中,有以下幾個問題需要解決:
設計一套通用的請求機制,請求機制需要跟業務剝離,使得不同業務的頁面請求都能夠被Mediater處理
設計Mediater根據請求如何獲取其他業務的機制,Mediater需要知道如何處理請求,上哪兒去找到需要的頁面
這個看起來就非常像我們web開發時候的URL機制,發送一個Get或Post請求,CGI調用腳本把請求分發給某個Controller下的某個Action,然後返回HTML字符串到浏覽器去解析。蘋果本身也實現了一套跨App調用機制,它也是基於URL機制來運轉的,只不過它想要解決的問題是跨App的數據交流和頁面調用,我們想要解決的問題是降低各業務的耦合度。
不過我們還不能直接使用蘋果原生的這套機制,因為這套機制不能夠返回對象實例。而我們希望能夠拿到對象實例,這樣不光可以做跨業務頁面調用,也可以做跨業務的功能調用。另外,我們又希望我們的Mediater也能夠跟蘋果原生的跨App調用兼容,這樣就又能幫業務方省掉一部分開發量。
就我目前所知道的情況,AutoCad旗下某款iOS應用(時間有點久我不記得是哪款應用了,如果你是AutoCad的iOS開發,可以在評論區補充一下。)就采用了這種頁面調用方式。天貓裡面目前也在使用這套機制,只是這一塊由於歷史原因存在新老版本混用的情況,因此暫時還沒能夠很好地發揮應有的作用。
嗯,想問我要Demo的同學,我可以很大方地告訴你,沒有。不過我打算抽時間寫一個出來,現在除了已經想好名字叫Summon以外,其它什麼都沒做,哈哈。
關於Getter和Setter?
我比較習慣一個對象的"私有"屬性寫在extension裡面,然後這些屬性的初始化全部放在getter裡面做,在init和dealloc之外,是不會出現任何類似_property這樣的寫法的。就是這樣:
@interface CustomObject() @property (nonatomic, strong) UILabel *label; @end @implement #pragma mark - life cycle - (void)viewDidLoad { [super viewDidLoad]; [self.view addSubview:self.label]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; self.label.frame = CGRectMake(1, 2, 3, 4); } #pragma mark - getters and setters - (UILabel *)label { if (_label == nil) { _label = [[UILabel alloc] init]; _label.text = @"1234"; _label.font = [UIFont systemFontOfSize:12]; ... ... } return _label; } @end
唐巧說他喜歡的做法是用_property這種,然後關於_property的初始化通過[self setupProperty]這種做法去做。從剛才上面的代碼來看,就是要在viewDidLoad裡面多調用一個setup方法而已,然後我推薦的方法就是不用多調一個setup方法,直接走getter。
嗯,怎麼說呢,其實兩種做法都能完成需求。但是從另一個角度看,蘋果之所以選擇讓[self getProperty]和self.property可以互相通用,這種做法已經很明顯地表達了蘋果的傾向:希望每個property都是通過getter方法來獲得。
早在2003年,Allen Holub就發了篇文章《Why getter and setter methods are evil》,自此之後,業界就對此產生了各種爭議,雖然是從Java開始說的,但是發展到後面各種語言也參與了進來。然後雖然現在關於這個問題討論得少了,但是依舊屬於沒有定論的狀態。setter的情況比較復雜,也不是我這一節的重點,我這邊還是主要說getter。我們從objc的設計來看,蘋果的設計者更加傾向於getter is not evil。
認為getter is evil的原因有非常之多,或大或小,隨著爭論的進行,大家慢慢就聚焦到這樣的一個原因:Getter和Setter提供了一個能讓外部修改對象內部數據的方式,這是evil的,正常情況下,一個對象自己私有的變量應該是只有自己關心。
然後我們回到iOS領域來,objc也同樣面臨了這樣的問題,甚至更加嚴重:objc並沒有像Java那麼嚴格的私有概念。但在實際工作中,我們不太會去操作頭文件裡面沒有的變量,這是從規范上就被禁止的。
認為getter is not evil的原因也可以聚焦到一個:高度的封裝性。getter事實上是工廠方法,有了getter之後,業務邏輯可以更加專注於調用,而不必擔心當前變量是否可用。我們可以想一下,假設一個ViewController有20個subview要加入view中,這20個subview的初始化代碼是肯定逃不掉的,放在哪裡比較好?放在哪裡都比放在addsubview的地方好,我個人認為最好的地方還是放在getter裡面,結合單例模式之後,代碼會非常整齊,生產的地方和使用的地方得到了很好的區分。
所以放到iOS來說,我還是覺得使用getter會比較好,因為evil的地方在iOS這邊基本都避免了,not evil的地方都能享受到,還是不錯的。
總結
要做一個View層架構,主要就是從以下三方面入手:
制定良好的規范
選擇好合適的模式(MVC、MVCS、MVVM、VIPER)
根據業務情況針對ViewController做好拆分,提供一些小工具方便開發
當然,你還會遇到其他的很多問題,這時候你可以參考這篇文章裡提出的心法,在後面提到的跨業務頁面調用方案的設計中,你也能夠看到我的一些心法的影子。
對於iOS客戶端來說,它並不像其他語言諸如Python、PHP他們有那麼多的非官方通用框架。客觀原因在於,蘋果已經為我們做了非常多的事情,做了很多的努力。在蘋果已經做了這麼多事情的基礎上,架構師要做針對View層的方案時,最好還是盡量遵守蘋果已有的規范和設計思想,然後根據自己過去開發iOS時的經驗,盡可能給業務方在開發業務時減負,提高業務代碼的可維護性,就是View層架構方案的最大目標。
2015-04-28 09:28補:關於AOP
AOP(Aspect Oriented Programming),面向切片編程,這也是面向XX編程系列術語之一哈,但它跟我們熟知的面向對象編程沒什麼關系。
什麼是切片?
程序要完成一件事情,一定會有一些步驟,1,2,3,4這樣。這裡分解出來的每一個步驟我們可以認為是一個切片。
什麼是面向切片編程?
你針對每一個切片的間隙,塞一些代碼進去,在程序正常進行1,2,3,4步的間隙可以跑到你塞進去的代碼,那麼你寫這些代碼就是面向切片編程。
為什麼會出現面向切片編程?
你要想做到在每一個步驟中間做你自己的事情,不用AOP也一樣可以達到目的,直接往步驟之間塞代碼就好了。但是事實情況往往很復雜,直接把代碼塞進去,主要問題就在於:塞進去的代碼很有可能是跟原業務無關的代碼,在同一份代碼文件裡面摻雜多種業務,這會帶來業務間耦合。為了降低這種耦合度,我們引入了AOP。
如何實現AOP?
AOP一般都是需要有一個攔截器,然後在每一個切片運行之前和運行之後(或者任何你希望的地方),通過調用攔截器的方法來把這個jointpoint扔到外面,在外面獲得這個jointpoint的時候,執行相應的代碼。
在iOS開發領域,objective-C的runtime有提供了一系列的方法,能夠讓我們攔截到某個方法的調用,來實現攔截器的功能,這種手段我們稱為Method Swizzling。Aspects通過這個手段實現了針對某個類和某個實例中方法的攔截。
另外,也可以使用protocol的方式來實現攔截器的功能,具體實現方案就是這樣:
@protocol RTAPIManagerInterceptor@optional - (void)manager:(RTAPIBaseManager *)manager beforePerformSuccessWithResponse:(AIFURLResponse *)response; - (void)manager:(RTAPIBaseManager *)manager afterPerformSuccessWithResponse:(AIFURLResponse *)response; - (void)manager:(RTAPIBaseManager *)manager beforePerformFailWithResponse:(AIFURLResponse *)response; - (void)manager:(RTAPIBaseManager *)manager afterPerformFailWithResponse:(AIFURLResponse *)response; - (BOOL)manager:(RTAPIBaseManager *)manager shouldCallAPIWithParams:(NSDictionary *)params; - (void)manager:(RTAPIBaseManager *)manager afterCallingAPIWithParams:(NSDictionary *)params; @end @interface RTAPIBaseManager : NSObject @property (nonatomic, weak) id interceptor; @end
這麼做對比Method Swizzling有個額外好處就是,你可以通過攔截器來給攔截器的實現者提供更多的信息,便於外部實現更加了解當前切片的情況。另外,你還可以更精細地對切片進行劃分。Method Swizzling的切片粒度是函數粒度的,自己實現的攔截器的切片粒度可以比函數更小,更加精細。
缺點就是,你得自己在每一個插入點把調用攔截器方法的代碼寫上(笑),通過Aspects(本質上就是Mehtod Swizzling)來實現的AOP,就能輕松一些。
2015-4-29 14:25 補:關於在哪兒寫Constraints?
文章發出來之後,很多人針對勘誤1有很多看法,以至於我覺得很有必要在這裡做一份補。期間過程很多很復雜,這篇文章也已經很長了,我就直接說結果了哈。
蘋果在文檔中指出,updateViewConstraints是用來做add constraints的地方。
但是在這裡有一個回答者說updateViewConstraints並不適合做添加Constraints的事情。
綜合我自己和評論區各位關心這個問題的兄弟們的各種測試和各種文檔,我現在覺得還是在viewDidLoad裡面開一個layoutPageSubviews的方法,然後在這個裡面創建Constraints並添加,會比較好。就是像下面這樣:
- (void)viewDidLoad { [super viewDidLoad]; [self.view addSubview:self.firstView]; [self.view addSubview:self.secondView]; [self.view addSubview:self.thirdView]; [self layoutPageSubviews]; } - (void)layoutPageSubviews { [self.view addConstraints:xxxConstraints]; [self.view addConstraints:yyyConstraints]; [self.view addConstraints:zzzConstraints]; }
最後,要感謝評論區各位關心這個問題,並提出自己意見,甚至是自己親自測試然後告訴我結果的各位兄弟:@fly2never,@Wythe,@wtlucky,@lcddhr,@李新星,@Meigan Fang,@匿名,@Xiao Moch。
這個做法是目前我自己覺得可能比較合適的做法,當然也歡迎其他同學繼續拿出自己的看法,我們來討論。
勘誤
我的前同事@ddaajing看了這篇文章之後,給我提出了以下兩個勘誤,和很多行文上的問題。在這裡我對他表示非常感謝:
勘誤1:其實在viewWillAppear這裡改變UI元素不是很可靠,Autolayout發生在viewWillAppear之後,嚴格來說這裡通常不做視圖位置的修改,而用來更新Form數據。改變位置可以放在viewWilllayoutSubview或者didLayoutSubview裡,而且在viewDidLayoutSubview確定UI位置關系之後設置autoLayout比較穩妥。另外,viewWillAppear在每次頁面即將顯示都會調用,viewWillLayoutSubviews雖然在lifeCycle裡調用順序在viewWillAppear之後,但是只有在頁面元素需要調整時才會調用,避免了Constraints的重復添加。
勘誤2:MVVM要有ViewModel,以及ReactiveCocoa帶來的信號通知效果,在ReactiveCocoa裡就是RAC等相關宏來實現。另外,使用ReactiveCocoa能夠比較優雅地實現MVVM模式,就是因為有RAC等相關宏的存在。就像它的名字一樣Reactive-響應式,這也是區分MVVM的VM和MVC的C和MVP的P的一個重要方面。
本文遵守CC-BY。 請保持轉載後文章內容的完整,以及文章出處。本人保留所有版權相關權利。