淺談高質量移動開發

本文從移動端構架設計、類設計、方法設計以及最佳實踐等方面簡單討論瞭如何開發出高質量的代碼。git

本文同時發表於個人我的博客github

Overview


高質量代碼、架構設計以及重構等都是充滿智慧且須要深厚功底和實戰經驗的話題,本不敢隨意拿來討論。只是最近在項目中對兩個較大的模塊作了一次重構,再加上補習了一下《代碼大全》以及《重構》,所以嘗試着作一次這方面的分享。代碼設計自己也是一個仁者見仁、智者見智的話題,下述討論若有不正確之處,請指正。數據庫

首先須要回答的問題是什麼樣的代碼是高質量代碼,其次纔是如何設計出高質量代碼。編程

從宏觀上說,高質量代碼無外乎於:可擴展性強、可維護性高、可讀性好。 從實現的層面說,主要包含:劃分層次合理、數據流向簡潔明瞭、模塊間通訊合法、類有良好的封裝和一致的抽象、實體內部高內聚、實體間低耦合等。json

架構設計


對於終端開發來講,架構設計最經典莫過於 MVC,在此基礎上又衍生出了像 MVVM 之類的架構。 以前看過一篇文章討論是先分層再分模塊,仍是先分模塊再分層的問題。雖然,這兩種方式各有道理,但我的仍是建議先劃分模塊,再在模塊內部按 MVC 或其餘架構分層,由於對於終端來講模塊有更強的獨立性,一個模塊基本上也是由M、V、C 三部分構成。數組

關於 MVC、MVVM 等構架的文章已有不少,本文再也不贅述。安全

以 MVC 爲例,須要強調的是 M、V、C三者間的通訊規則: 微信

如上圖所示,Model 與 View 之間嚴格禁止直接通訊,這也是常常會犯的錯誤。 禁止 Model 與 View 間的通訊,也就將底層數據與 UI 隔離開,下降了耦合。

項目中,每每一個模塊由一我的單獨負責,這就致使不一樣的模塊可能採用不一樣的架構,MVC、MVP、MVVM 以及其餘變體構架可能在一個項目中同時存在,顯得有些混亂。最好是能統一一下,一個項目只用一種構架。多線程

類設計


在面向對象編程中,類的好壞直接影響代碼的質量,那麼什麼樣的類是設計良好的類? 一樣,從宏觀上說設計良好的類具備:良好的抽象與封裝。架構

抽象與封裝是兩個關係很是緊密的概念。

代碼大全對抽象的定義:抽象是一種能讓你在關注某一律唸的同時能夠放心地忽略其中一些細節的能力。

具體到類上,抽象主要指接口的抽象,即對外宣稱該類具備什麼樣的能力、能完成哪些工做。而類的具體實現對外界是個黑盒子,無需關心。 封裝更強調的是隱藏細節,迫使外界沒法瞭解類內部的細節信息。

良好的抽象

經過抽象接口能夠簡化外界對類的使用,使其無需關心類內部複雜的實現細節。 在設計接口時,須要注意的問題:

  • 接口須要展示一致的抽象層次

    該類接口最大的問題在於沒有隱藏內部使用數組的事實,很明顯在抽象接口中不該暴露這一細節。 這樣作的壞處在於,若從此修改了底層容器,再也不使用 Array 而改用 Dictionary,那麼這組接口就難以理解,漸漸地也沒法維護了。 正確的作法應該是:

  • 接口應隱藏細節 接口應該隱藏業務層無需關心的細節問題。在咱們項目中,支持遊客登陸和 QQ 登陸,而登陸模塊將這兩種登陸方式區分開來以不一樣的 notification 通知業務層相關的登陸信息。然而絕大多數業務邏輯並不關心具體的登陸方式,若在此基礎上添加微信等其餘登陸方式,接口將更加難以維護。 所以,在登陸模塊重構後將這些細節信息都隱藏在登陸模塊內部,對外提供統一的回調接口。

  • 高內聚 不管是在類層次仍是方法層次,高內聚一直是咱們追求的目標之一。對類來講,抽象與內聚關係十分緊密,類具備良好抽象的接口通常也意味着有很高的內聚性。

    在咱們 QQ 閱讀項目中,有一個很是重要的類:QRBookInfo,從名字看應該是一個用於表示書籍信息的類。其對外的接口應該有:id(惟一編號)、name(名稱)、author(做者)以及 format(格式)等。但在現實中 QRBookInfo類卻包含了不少不屬於它的信息,如:閱讀進度、閱讀時間、添加到書架時間以及在書架上分組 id 等,最終該類暴露給外界的屬性有將近40個,淪落爲一個大雜燴,很明顯已經不具有高內聚特徵了。

    若是要對該類進行重構,則能夠經過 Extract Class(提煉類)的手法進行,將不屬於該類的職責提煉到新的類中。從 QRBookInfo 至少能夠提煉出3個類:

    1. QRBook——用於描述書籍自己,抽象接口有:id(惟一編號)、name(名稱)、author(做者)以及 format(格式)等;
    2. QRBookShelfItem——用於描述書架上每一個項的信息,抽象接口有:id(書籍編號)、addTime(添加到書架時間)以及 categoryId(該書在書架上的分組編號)等;
    3. QRReadingItem——用於描述閱讀信息,抽象接口有:id(書籍編號)、readTime(閱讀時間)以及 readProgress(閱讀進度)等。

    項目中,還有個書架類:BookShelfViewController,有5000多行代碼,註冊了20多個通知,已經到了幾乎沒法維護的地步,其中不只包含了書架相關的邏輯,打開書的邏輯也所有扔在裏面。後來在一個新項目中須要使用這個類時,稍微作一點小改動都沒法正常工做。這次對書架進行重構時將打開書的邏輯所有移到其餘類中,QRBookShelfViewController 這個類只專一於處理書架相關的邏輯。

    高內聚做爲管理類複雜度的一個重要原則,咱們應該時刻把握這一利器。

  • 儘可能讓接口可編程,拒絕隱藏的語義

    來自代碼大全:每一個接口都由一個可編程(programmatic)部分和一個語義(semantic)部分組成。其中,可編程部分由接口中的數據類型和其餘屬性構成,編譯器能強制性地要求它們(在編譯時檢查錯誤),而語義部分則由『本接口將會被怎樣使用』的假定組成,而這些是沒法經過編譯器來強制實施的。

    好比:在調用 methodA 前必須先調用方法 methodB,這一要求則屬於 methodA 的語義部分。因爲沒有編譯器的強制檢查,這一隱藏語義極可能被調用者忽略,引發錯誤。

    所以,接口設計時儘可能不要包含語義部分,能夠經過抽取新接口或添加 Asserts 等方法將語義接口轉換爲編程接口,確實沒法避免也應在接口中經過註釋說明其中的語義。如在上述例子中,能夠添加新的接口methodC,在該接口中調用 methodB、methodA,從而消除 methodA 的語義。

  • 謹防在修改時破壞接口的抽象 從前面討論可知,一個類應該圍繞一箇中心職責,處理一個任務。在類設計之初可能有較高的內聚性,但在實際開發中,類會被不斷擴展,不斷加入新的功能和數據。類的內聚性和抽象性極可能在這個過程當中被破壞。 常常在代碼中能夠看到一個類有大相徑庭風格、不一樣抽象的接口,這每每是被"篡改"的結果。另外還會看到有2個、3個、4個甚至更多個相同功能,只是參數不同的接口。有的接口多出個 bool 型的參數,有的帶個 block 類型的參數,有的帶個 delegate 形式的參數,在這種粗暴式的背後每每隱藏着重複代碼的危機。 在咱們的登陸模塊中,登陸接口有10個之多,登陸回調也是五花八門,有 block、delegate 以及 notification。所以在業務層的類中常常看到這3種回調方式同時存在,維護成本極高。我相信,在設計之初絕非如此混亂,而是在往後開發過程當中慢慢引入的。 所以在修改已有類接口時必定要三思,切不可圖一時之便隨意添加修改,不然長此以往極可能致使類失控。若因業務須要必須修改,最好將需求提給類的做者,由他來修改。

  • 設計接口時儘可能不要給調用者留坑 咱們有一個基類 controller:QRBaseViewController,其定義了一個接口,做用是讓子類自定義 NavigationBar 上的item:

    你能看出來該接口有什麼問題嗎? 沒錯,問題就出在其最後一個block參數上,因爲類自己須要 hold 這個 block,而在 block 中每每須要經過 self 調用其餘子方法或屬性,這樣就出現了cycle retain。通過一番排查發現有六、7個類存在這樣的問題。 (ps: 上面的 actionBlock 屬性應該使用 copy 而不是 strong)

良好的封裝

抽象更可能是強調這個類是什麼,能作什麼,而封裝則是強制外界沒法瞭解實現的細節。 良好的封裝通常須要注意:

  • 儘量地限制類中各成員的可訪問性 使可訪問性儘量低是促成封裝的原則之一,在 Objective-C 中則是儘量將類的數據成員、屬性以及方法放到類的匿名分類中,使類的接口(.h文件)文件儘可能簡潔。

  • 不要暴露類的數據成員 在 Objective-C 中,屬性做爲特殊的數據成員,能夠暴露給外界,但在這麼作以前請認真思考是否真的須要這麼作。 可是,做爲容器類的數據成員(Array、Dictionary、Set 等)必定不要輕意暴露給外界,由於這屬於很底層的實現細節。一旦暴露了這些細節,封裝將被嚴重破壞。

    試想一下,如有一天須要將 Array 改成 Dictionary,影響範圍有多大? 若是暴露的是容器的mutable版本,那麼外界能夠任意對該容器進行操做,你已失去各類控制、錯誤檢查的能力,同時還可能有多線程問題。

  • 私有實現細節必定不要暴露在頭文件中 頭文件應該是簡潔明瞭的,僅用於向外界表達該類能作什麼。簡潔的頭文件也能減小類的使用者在使用該類時的成本。

  • 要格外警戒從語義上破壞封裝性 語法上的封裝能夠經過 private、匿名 category 等方式實現,然而語義上的封裝性更難以控制。如下是代碼大全中列舉的從語義上破壞封裝性的例子:

    1. 不去調用類 A 的 InitialixeOpertaions()子程序,由於你知道 A 類的 PerformFirstOperation()子程序會自動調用它;
    2. 不在調用 employee.Retrive(database)以前去調用 database.Connect()子程序,由於你知道在未創建數據庫鏈接時employee.Retrive(database)會去鏈接數據庫;
    3. 不去調用類 A 的 Terminate()子程序,由於你知道 A 類的 PerformFinalOperation()子程序已經調用了該方法。

    上述例子的問題在於,其調用方不是依賴類的抽象接口,而是依賴於類的內部實現。當經過查看類的內部實現得知能夠如何使用該類時,就不是針對接口編程,而是透過接口針對內部實現編程。這是一種十分危險的舉動,類的封裝性已被破壞,一旦類內部實現改變了,可能引發嚴重錯誤。

  • 低耦合 兩個類之間的關聯程度稱爲耦合,低耦合一直是咱們的追求。類的封裝性直接影響到耦合程度,若類過多的將細節信息暴露出來,無疑會增長類與使用方間的耦合度。 理想狀況下,類對於調用者來講是個黑盒子,調用者經過類的接口就能完成對該類的使用,而無需深刻類內部瞭解實現細節,固然這首先須要該類有很好的抽象與封裝。反之,若在使用一個類時須要瞭解其內部實現,則必然在二者之間造成很高的耦合。 抽象性與耦合間的關係也十分緊密,良好的抽象與封裝通常也會有較低的耦合度。

    項目中,有個用於表示書架分組的類:QRCategoryViewController,該類不只用於處理分組相關的業務邏輯,連用戶分組數據的讀取、存儲也全在這個類中。而書架在決定顯示哪些書時也須要知道當前分組信息,所以,在書架初始化時必須也初始化一個QRCategoryViewController實例,用於獲取當前分組信息。這裏,正是因爲QRCategoryViewController類在抽象與封裝上沒有處理好,致使本來幾乎沒有耦合關係的兩個類QRCategoryViewControllerBookShelfViewController間高度耦合。在重構時,將分組數據的管理移到一個新類QRCategoryManager中,使QRCategoryViewControllerBookShelfViewController間完全解耦。

慎用繼承,用好繼承

做爲面向對象三大特性之一的繼承,重要性不言而喻,用好了能簡化程序,反之會增長程序複雜性。 在決定使用繼承以前,須要認真思考基類與派生類之間是不是"is...a"的關係,如若不是,那繼承就不是正確的選擇,此時可考慮"has...a"(包含)關係是否更合適。 代碼大會中關於繼承的幾個經典描述:

  • 繼承的目的在於經過"定義能爲多個派生類提供共有元素的基類"的方式簡化代碼。
  • 基類即對派生類將會作什麼設定了預期,也對派生類能怎麼運做提出了限制。
  • 派生類必須能經過基類的接口而被使用,且使用者無須瞭解二者之間的差別。
  • 若是派生類不許備徹底遵照由基類定義的同一個接口契約,繼承就不是正確的選擇。 (你作到了嗎^-^)

項目中的書架類BookShelfViewController因爲要同時適配 iPhone 和 iPad 兩個版本,所以在代碼中隨處可見:if(IS_IPHOEN)...else這樣的語句。 在重構的時候,將 iPhone 與 iPad 的UI邏輯抽取到兩個子類,而它們共有的數據相關邏輯(如:雲書架、章節更新等)放在基類。

在登陸重構時,咱們有 QQ 登陸、微信登陸以及遊客登陸,3種登陸方式都有相同的操做:登陸、續期、獲取 accessToke 等。從表面上看,彷佛能夠生成一個基類,再派生出3個子類分別用於實現上述不一樣的登陸方式。但仔細一想,它們之間除有相同語義的接口,並無共享的數據或實現,所以繼承並不合適,而 Objective-C 支持的接口在這裏再合適不過了。 所以,咱們定義了一個 protocol QRAuthenticatorDelegate
QRQQAuthenticatorQRWeChatAuthenticator 以及 QRGuestAuthenticator實現了 QRAuthenticatorDelegate

因爲 Objective-C 語言的動態性,其成員函數天生就具備虛函數的特徵,當繼承體系過於複雜,函數重載將進一步加大問題的複雜性。 繼承在使用前必定要三思,或許包含、接口是更好的選擇,使用不當會增長程序複雜性。

隔離錯誤

船體外殼裝有隔離艙、建築物都有防火牆,其做用都是隔離危險。 在防護式編程中一樣須要隔離危險,在系統層面能夠有專門的類用於處理錯誤、隔離危險(來自代碼大全):

在此我更想從類的層面討論這一問題,曾經一直苦惱這樣一個問題: 在類的公開接口中檢查了傳入的參數,而後該參數又傳入到私有方法中,那麼在私有方法中應不該該再次檢查該參數的合法性。 根據上面的隔離理論,應該只要在公開接口中檢查參數的合法性便可,在私有方法中能夠認爲參數是通過篩選的、正確的。

在程序中錯誤又可分爲2類:

  • 毫不應該出現的、意料以外的錯誤(之因此會出現多是程序存在 bug);
  • 不常常出現的、非正常狀況。

對於上述2種錯誤應該分別使用斷言(Assertions)和錯誤處理。回到前面那個問題,在類的公開接口中能夠視狀況使用錯誤處理或斷言,而在類的私有方法中能夠直接使用斷言。 (ps: 斷言主要用於在開發期間快速檢測代碼錯誤)

方法設計


『代碼首先是寫給人看的,其次纔是讓機器執行的』,在方法設計時更應考慮這一點。

起個好名字

命名是個技術活,對代碼維護性、可讀性相當重要! 須要注意的是,方法名應該強調方法是作什麼的,而不是怎麼作的。 除此以外,方法名在內存管理上也有約束: 任何如下列名稱爲前綴的方法,若其返回值爲 object,則方法調用者持有該 object:alloc、new、copy以及mutableCopy。 還有一個更爲嚴格的規則:任何以 init 爲前綴的方法必須遵照下列規則:

  • 該方法必須是實例方法;
  • 該方法必須返回類型爲id或其所屬class、superclass、subclass 的對象;
  • 該方法返回的 object 不能是 autorelese,即方法調用者持有返回的 object。

尤爲是在 ARC 與 MRC 混用的項目中,必須遵照上述規則,詳情可參看我以前的文章:Inside Memory Management for iOS

高內聚

一個方法只作一件事!並經過方法名很優雅的表達出來! 但在實際中不少方法每每作了更多,有的方法長達100多行,甚至幾百上千行。 低內聚的方法帶來的後果有:

  • 難以維護;
  • 由於作的事太多,沒法取一個好的方法名;
  • 每每致使重複代碼,試想一下,若 methodA 作了 task1 和 task2,methodB 作了 task1 和 task3,那 task1 相關的代碼是否是就重複了。

將複合語句體抽取爲子方法

if...else...forswitch 等語句的 body 抽取爲子方法,並經過好的方法名提升可讀性。

這兩段代碼作了一樣的事情,都是根據用戶選擇加載 grid 或 line 模式的書架,但其可讀性差別仍是很大的。

複合語句體加大括號

常常會看到這樣的代碼:

還記得,iOS 系統上那個由 gotofail 引發的 SSL/TSL 安全漏洞嗎?
這就是引發問題的源碼,雖說這個 bug 不能全賴 if 沒有加"{}",但若是 if 加上了大括號該問題可能就避免了。 所以,不管 if 等複合語句的 body 是否只有一條語句都應加上"{}"。

另外,關於 if 語句的格式規範問題,雖各有各的習慣,但強烈不建議作成下面這樣(事實上大量 if 被寫成這樣,由於 Xcode默認代碼補全就是這樣的):

當 if...else...較複雜時,根本分不清 else 與哪一個 if 配對。
我的更推薦這種寫法,不只井井有條,也節省空間。

將複雜的條件表達式抽取爲子方法或命名良好的賦值語句

若是條件表達式過於複雜爲了提升可讀性,若該表達式重複出現應該抽取爲布爾方法,不然能夠將其賦值給一個良好命名的變量。 咱們書架有個規則,在 iPhone 上對於自定義的分組,若該分組下有書籍,則須要在書籍列表的最後面添加一個導入書的按鈕。

若是咱們把這些條件直接寫在 if 裏面:
下面是將其抽取到一個方法中:
對於不須要關心具體顯示規則的人來講,下面這種可讀性好不少。

中間變量

前面講到複雜的布爾表達式能夠轉換爲命名良好的中間變量,但並不意味着能夠隨意添加。多一箇中間變量就多一份複雜,多一種狀態,對於使用次數少的中間變量能夠直接將表達式內聯到語句中。

像這裏的 indexPath.row 含義清楚明確,不必再定義中間變量 row

對於類來講也是如此,爲了解決某些問題每每會引入一些狀態成員變量,這種作法無可厚非,但仍是要謹慎。每每狀態變量涉及何時 set、何時 reset,增長了類內部的複雜性,也增長了類內部方法間的耦合度。

優先處理錯誤狀況

若方法須要檢查參數或其餘條件,把檢查操做放在方法開始部分,條件不知足時當即返回。

第二種寫法,一看就知道在條件不知足時方法什麼也沒作。 而第一種寫法,尤爲是在 if body 很複雜、嵌套了多層 if、代碼超出一屏時就很難一眼看出在條件不成立時方法作了什麼。

switch 語句不要有 default 分支

尤爲是利用 switch 處理 enum 時,輕意不要寫 default 分支,這樣在漏掉哪一個枚舉值沒有處理時,編譯器會發出警告。但在有 default 分支時,是不會有警告的。

最佳實踐


該小節主要討論在開發過程一些值得注意的小點,不必定與設計有關。

充分利用泛型

LLVM 7.0從編譯器層面支持泛型,系統中經常使用的容器都增長了對泛型的支持,無疑是一個重大利好。

使用泛型容器時若類型不匹配編譯器會有 warning。 其實,我我的以爲泛型容器最大的意義並不是在類型不匹配時的 warning,而是泛型對接口的自我說明能力。 上面第個一代碼片斷,直到使用該容器時才能知道其中元素的類型,而第二個代碼片斷從容器定義中便能清楚地看到這點。

合理使用 nullable、nonnull

同泛型,nullability 也是編譯器 LLVM 支持的一個特性,用於描述接口中的值是否能夠爲 nil。 對於 nonnull 的接口,若傳入的是 nil,編譯器會發出 warning。一樣,nullability對接口的自我說明能力比 warning 更有意義。

用好 Objective-C 的動態性

Objective-C 語言的動態性給咱們帶來了無限的想象空間,『只有想不到,沒有作不到』。 典型的利用到 Objective-C 動態性的例子有:JSPatch、MJExtension、Swizzling、KVO 以及 AOP等等。

  • JSPatch 用於熱補丁,已經很是火,無須多言;
  • MJExtension 主要用於將 json 自動轉換成 Objective-C 的對象,能極大的提升開發效率;
  • Swizzling 的使用就更加常見了,咱們有一個典型的用途:經過 Swizzling UIViewController 的 viewWillAppear: 方法以及 UIButton 的 sendAction: 方法,記錄用戶的操做路徑,在 crash 時隨 crash log 一塊兒上報,經過該方法解決了很多疑難雜症的 crash;
  • KVO 用處很大,爭議也很大,用好了能夠簡化代碼,具體能夠參考我以前的文章:KVO漫談
  • AOP 主要用於將不相關的邏輯獨立出來,如打 log 等,在登陸模塊重構時使用到 AOP 思想,具體可參考我以前的文章:AOP漫談

統一管理隊列

GCD做爲實現多線程的方式之一,結合 block 使用時簡單方便。所以,在代碼中常常存在大量dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{這樣的用法。 雖然系統會控制 GCD 使用的線程數,但當線程被鎖住時,仍是會建立新的線程以供其餘 block 使用,所以某一時段系統可能會建立出大量線程,最終會搶佔主線程的資源,影響到主線程的執行。 所以,必要時能夠統一管理這些隊列,YYDispatchQueuePool 就是一個很不錯的實現。

類的子方法按功能排列

雖然一直追求高內聚的類,但 UI 交互的類尤爲是 UIViewController 通常功能都比較複雜,此時最好按功能排列方法,同一功能的方法放在一塊兒。 如UIViewController的這些方法應該固定排在最前面:

若是能配合使用 #pragma mark - 那就更好了。還能夠經過 category 將不一樣的功能分到不一樣的 category 中。 另外,也不要將多個類放到同一個文件中。

正確使用 category

經過 category 能夠擴展示有類的功能,在使用 category 時有幾個點須要注意:

  • category 獨立於主類而存在,也就是說刪除 category 不能影響主類(簡單說就是不能在主類中引入 category 頭文件);
  • 不能由於在 category 中須要使用成員變量或屬性,而將其添加到主類頭文件中,此時能夠經過 associated object實現;
  • 不能在 category 中重寫父類方法,當在主類與 category 中定義了同一個方法時,category 中的方法會覆蓋主類的方法。好比:在主類與 category 中都定義了 dealloc 方法,則主類中的 dealloc 方法將被覆蓋。尤爲是在沒有源碼的三方庫中,更加不能這樣作。

避免使用宏

首先須要代表的是,可以使用宏而不是硬編碼,是一件值得鼓勵的事情。 但宏的缺點也經常遭到詬病,主要有:非強類型、只是預編譯期的文本替換,由於宏定義沒有加括號而引起的錯誤家常便飯。 絕大多數狀況下,宏均可以用 const 或者子方法代替。看個有意思的問題,下面這個宏定義有問題嗎:

在使用時會報錯:
問題就出在的文本替換上面,上面這個調用最終的樣子是:

拒絕 warning

warning 表明程序存在病態,雖然有些 warning 看上去可有可無,但 warning 一旦多起來,一些重要的 warning 可能也隱藏其中,難以發現。

對於那些真的以爲可有可無的 warning,能夠經過#pragma clang diagnostic ignored將其隱藏(固然並不推薦這麼作)。

小結


在現在的移動互聯網時代,不少項目都是被需求推着往前走,都在追求敏捷開發、快速迭代、小步快跑,對代碼質量有所忽視。 俗話說:『磨刀不誤砍柴工』,在動手以前多一點思考,在開發過程當中少一點任性,就能寫出質量更高的代碼。 總之,高質量說來容易,作到難。不只要有豐富的實戰經驗,紮實的功底,更要有一顆執著的心!

相關文章
相關標籤/搜索