『碼』出高質量

語言: CN / TW / HK

本文從易理解、可維護、可擴展三個維度簡要介紹了對高質量代碼的理解。

同時,提出了一種新的 GUI 模式:MVVS。

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

Overview


個人認為,高質量的代碼首先應該是『簡單的』。

『簡單』又可以從以下三個維度去度量:

  • 易理解
  • 可維護
  • 可擴展

本文將從上述三點展開討論,談談我個人的理解。

易理解


高質量的代碼一定是易於理解的代碼,讀起來應該像言情小説,而不是詰詘的文言文。

高質量的代碼可以是優雅的,但一定不是炫技的。

高質量的代碼應該是深入淺出的,儘量用簡單、樸實的方式去表達。

易理解面向的是整個團隊,而不是寫代碼的那個人。

易理解是團隊高效、無縫協作的基礎。

影響代碼可理解性的因素有很多,比如:

『良好命名』

  • 好的命名即是成功的一半,也是最具挑戰性的事情之一;
  • 總的原則就是體現『做什麼』,而不是『怎麼做』;
  • 在閲讀優秀開源代碼以及系統 API 時多留意、多思考。

『清晰結構』

( 這裏指的是代碼的組織結構 )

  • 相關代碼組織在一起,如:initdeinit/dealloc、UIViewController 生命週期方法viewDidLoadviewWillAppearviewDidAppear等;
  • 不同組織間用空行隔開;
  • 或者通過 Category、Extension 的方式來組織。

『合理抽象』

  • 抽象的關鍵是隱藏細節(『細節是魔鬼』);
  • 在我們不關心具體細節時能很容易地忽略細節、抓住重點、抓住主幹;
  • 抽象存在於每個層級:變量、方法、類、模塊。

『線性邏輯』

  • 每個實體 (變量、方法、類、模塊) 在不同的層級上都只圍繞一件事展開 --『高內聚』;
  • 模塊內部的類、類內部的方法、方法內部的語句儘量都能通過線性的方式串連起來;
  • 而如果它們間形成的是一張網或離散的點都是壞代碼的味道 --『低內聚』;
  • 如,類內部存在大量不相關的方法,方法內部存在過多的ifswitch語句。一個類動輒幾千行、一個方法動輒幾百行,此時就值得我們高度警惕;

很明顯,線性的邏輯比網狀的邏輯更易於理解。

『最小依賴』

( 依賴可以從兩個角度來度量:依賴的多少以及強弱 )

  • 依賴肯定是越少越好 --『低耦合』;
  • 過多的依賴無疑會增加代碼的複雜性,往往也是代碼設計出問題的一個信號;
  • 提高內聚、面向接口編程等都是降低依賴的有效方式;
  • 同時,依賴關係越弱越好,繼承無疑是最先考慮到的代碼複用方式,但是繼承也是最強的一種依賴、耦合關係。
  • 代碼複用,應優先考慮組合。

『精簡體積』

簡單講,就是無用代碼及時刪除;

在閲讀代碼時經常會遇到一些莫名其妙的邏輯,經過一番調查,發現已是廢棄的代碼;

無形中增加了理解成本,並且隨着時間推移,後浪們也不敢去刪這些代碼,最終越積越多。

『樸實表述』

正如名言:代碼首先是寫給人看的,其次才是讓機器執行的;

因此,儘量用平易、樸實的方式去描述、去表達,讓大家都能『看得懂』;

總之,好的代碼一定是簡單的、清晰的。

易理解是高質量代碼最基礎的要求,上述只是影響代碼易理解性的幾個小點,更多的需要我們在實際編碼過程中不斷思考總結。

『 淺談高質量移動開發 』一文中對類的設計、方法的設計等有更具體的討論,感興趣的同學可以看看。

可維護性


移動端開發最主要的場景就是基於 GUI 的業務開發。

因此,本文談到的可維護性也主要是圍繞 GUI 流程展開。

關於 GUI 的設計模式 (MV*) 在近幾年也是老生常談的話題之一。

無論 GUI 模式如何演化,個人認為其核心原則未曾改變:

  • 單向數據流
  • 數據完整性
  • 數據驅動 UI

『單向數據流』

無論哪種 GUI 模式,從大的方向上都可以分為兩層:

  • Domain Layer:數據層(業務邏輯)
  • Presentation Layer:表現層(UI)

單向數據流是指『業務數據』一定是從『數據層』流向『表現層』,絕不允許反過來。

『表現層』可以響應 UI 事件並傳遞給『數據層』, 至於『數據層』如何處理這些事件純屬其『內政』,『表現層』無權干涉。

簡單概括,『 單向數據流 』背後有兩個『 流 』:

  • 數據流:從『 數據層 』流向『 表現層 』;
  • 事件流:從『 表現層 』流向『 數據層 』。

關於事件,很多響應式框架會將其定義為獨立的數據結構,如:Redux中的ActionBLoC中的Event

我個人認為這種設計有一個比較嚴重的問題:不能通過『 command + 單擊 』的方式『 鏈式 』地閲讀代碼,需要通過全局搜索。

這嚴重影響了開發效率。

個人覺得事件可以直接是『 數據層 』暴露給『 表現層 』的接口,即有事件需要處理時,直接調用相應的接口即可。

為什麼?

我相信任何一位移動開發者對於『單向數據流』都耳熟能詳,但其背後深層次的原因不見得都能説清楚。

首先,數據流不能從『表現層』流向『數據層』,説的到底是什麼?

其真正的含義是『表現層』不能直接修改『數據層』的數據。為什麼?

從設計的角度講,數據管理是『數據層』的職責,『表現層』不應越俎代庖。 否則,『表現層』就違反了『單一職責』原則,也違反了『高內聚』的設計理念。

從現實的角度講,『表現層』直接修改數據對於代碼維護性來説是一個『災難』。

首先,直接後果就是可能造成『不同步』:

  • UI 刷新與數據修改不同步,數據修改後 UI 沒有及時刷新;
  • 多個 UI 場景間不同步,有的數據可能被多個業務模塊所使用,如果由其中某一處直接修改,其他模塊很可能無法感知到這一修改,造成不同步。

如下圖,Scenes1 直接修改了底層數據,但並未通知 Scenes2、Scenes3 (Scenes1 可能根本不知道 Scenes2、Scenes3 的存在),造成數據不同步:

『不同步』常見的後果就是在使用 TableView 時數組越界。

對於此類問題,我們經常是對數組訪問加個保護,而很少也很難從根源上解決問題。

尤其是多業務場景共享數據時。因為你根本不知道是『 誰 』在『 什麼時候 』修改了數據源。

其次,還可能引起多線程問題:

  • 『數據層』可能有專職的線程去管理數據,如果『表現層』擅自修改數據,很可能引發多線程問題。

如何解

『數據層』一定不能向『表現層』直接暴露可修改 (mutable) 的屬性。

對於引用類型來説,情況可能更復雜一些。理想情況下,『數據層』返回給『表現層』的數據應該是final的或是深拷貝的。

總之,要做到『表現層』毫無直接修改底層數據的可能性。

此時,大家可能有疑問了,即使是『表現層』通過事件觸發『數據層』內部去修改數據,還是可能會引起不同步的問題。

對,這就需要通過『數據完整性』、『數據驅動 UI』來解決了。

『數據完整性』

數據完整性指的是數據不應該存在中間臨時狀態。 如上圖左則所示,可能會導致意想不到的結果。

在需要修改時,應該對數據作整體替換,這也是我們常説的『數據不可變性』。

在函數式編程中,所有數據都是不可變的,所有操作的結果都是生成新的數據,而不是在原有數據上作修改。

嚴格遵守『數據完整性』、『數據不可變性』原則,能很好地避免中間狀態問題。

『數據驅動 UI』

底層數據發生變化後,上層 UI 如何感知並刷新?

總的原則就是『數據驅動 UI』

直白點,就是『數據層』有渠道、有方法在數據變化時能主動通知到所有關注該數據的『表現層』對象。

看似很簡單?實則很多項目都沒能做到這一點。

有沒有聞到『響應式編程』的味道。 有同學一聽到『響應式編程』就覺得很複雜,難於接受。

其實,我個人認為『響應式編程』並非一定要使用諸如Rx*ReactiveCocoa或 iOS 原生的KVO這樣的框架或技術。(它們只是一種實現手段而以) 其核心在於:

  • 『數據層』主動通知,『表現層』被動響應;
  • 數據的所有使用方都能及時感知到數據的變化。

因此,像 Delegate、Callback 甚至 Notification 都可以用來實現『響應式編程』。

『響應式編程』其實就是『觀察者』設計模式的一種應用場景。

好了,我們來回顧總結一下:

  • 『單向數據流』:保證數據的修改僅發生在『數據層』內部,這也是『數據完整性』、『數據驅動 UI』得以實現的前提;
  • 『數據完整性』:任何數據的修改都是整體替換,實現『數據不可變』的語義,避免出現數據的中間狀態;
  • 『數據驅動 UI』:保證數據修改後能及時通知 UI,避免狀態不同步。

MVVS

基於『單向數據流』、『數據完整性』以及『數據驅動 UI』的原則,我們提出一種新的 GUI 模式:MVVS

  • M:Manager,處理業務邏輯,管理業務數據,將數據轉換為 ViewState 並通知上層 UI;
  • V:View,UIViewController/UIView,面向 ViewState 編程;
  • VS:ViewState,當前的 UI 狀態,將『數據驅動 UI』進一步拆分:數據驅動狀態、狀態驅動 UI。

MVVS vs. MVVM,相當於將 MVVM 中的 VIewModel 拆分為 MVVS 中的 Manager 和 ViewState,使得各自的職責更加清晰。

MVVS 中的 ViewState 受 Flutter 響應式框架 flutter_bloc 中 state 啟發。

Manager、View、ViewState 間的關係如下圖所示:

ViewState

ViewState 代表當前的 UI 狀態,為 UI 提供展示用的數據。

原則上,ViewState 與 View 一一對應。 對於過於簡單的 UI 也可以沒有與之對應的 ViewState。

如上圖,與 ViewController 對應的是 Root ViewState,代表當前該模塊的整體狀態。

根據不同的狀態,ViewState 可以是不同的子類型,如:LoadingViewState、ErrorViewState、EmptyViewState、LoadedViewState 等。ViewController 再根據不同的狀態作出不同的響應。

其他

上面我們主要講述了 GUI 架構上會影響代碼可維護性的幾個關鍵點 影響代碼可維護性的因素還有很多,如:

  • 最少狀態: 在代碼中經常會看到很多的狀態變量,如:is***(isFirstLoadisNewUser)、has***(hasLoaded)、***Count(userCount)等等。

    這些狀態本身的維護以及其對代碼整體的維護都是非常容易出問題的。

    是否在所有需要修改的地方都正確的修改了?

    修改後使用到這些狀態的地方都及時通知到了?

    因此,狀態要越少越好,能不加就不加。

    如,***Count是否可以從數據集上動態計算得到

  • 最小權限: 無論是模塊還是類暴露的接口都應遵守最小權限原則

    在具體開發過程中可以通過『 依賴倒置 』原則,讓接口需求方提出接口需求,避免由實現方直接提供接口而無意中暴露過多細節。

    具體可以參看『 面向對象設計原則『SOLID』在開發中的應用 』

  • 寫純函數: 純函數幾乎沒有外界依賴,其在可維護性、易理解上有天然優勢。

    一般類方法都有純函數特性,因此,能寫類方法的時候就不要寫實例方法。

    『 函數式思維 』一文中對函數式編程有過簡單討論

可擴展性


『唯一不變的就是變化』

面對變化,我們唯以積極心態對之。

因此,代碼的可擴展性也是我們需要重點思考的問題之一。

『 SOLID 』中的『 O — OCP,開放-封閉原則 』,就是用於指導可擴展性的原則之一。

OCP:『 對擴展開放,對修改封閉 』。

強調『可擴展性』就是要求我們寫『活代碼』。 在平時 Code Review 時,我經常開玩笑地説『你這個代碼寫的太死』。

23 種設計模式中的『 策略模式 』、『 模板方法 』都是用於指導提升可擴展性的方法。

『 策略模式 』

提高代碼可擴展性最有效的方式之一就是面向接口編程。

『 論面向接口編程 』一文中對此有詳細介紹,在此不再贅述。

在具體開發時如何寫出擴展性高的代碼呢?

『擴展性』面向的是未來,

因此,首先要思考的是『 什麼是可能會變的 』

再將『 可變部分 』隔離出來,並以接口的形式去抽象它。

熟悉設計模式的同學可能已經看出來了,這其實就是『策略模式,Strategy 』。

在之前的文章中,我們也提到過,如:登錄模塊、多 Tab 頁面都是典型的可通過『 策略模式 』來提高擴展性的場景。

登錄模塊的例子在『 面向對象設計原則『SOLID』在開發中的應用 』一文中有詳細介紹

多 Tab 頁面的例子在『 論面向接口編程 』一文中有詳細介紹

『 模板方法 』

『 策略模式 』的基礎是面向接口編程。

而『 模板方法,Template Method 』的基礎是繼承。

同樣,在『 面向對象設計原則『SOLID』在開發中的應用 』一文中對『 模板方法 』模式有過簡單的介紹。

『 iOS 高效開發解決方案 』一文中介紹過通過『 模板方法 』模式提高 UI 組件的擴展性。

提升代碼可擴展性的方法絕不僅上述 2 種模式,但其背後的思想非常重要,在平時開發時可靈活應用。

其他


除了上述提到的『 易理解 』、『 可維護 』、『 可擴展 』之外,還有很多點是值得我們去思考和關注的,如:

  • 錯誤處理: 如何優雅地處理錯誤其實非常重要,但往往被忽略。在設計接口時需要關注出錯的情況;

  • 關鍵路徑打 log: 錯誤是無法避免的,除了在出錯時給用户一個較好地提示外,我們也需要去了解出錯的原因,此時 log 就顯得尤為重要,要養成在關鍵路徑打 log 的習慣。否則,用户反饋問題後,兩眼一抹黑,無從下手;

  • 適時重構: 重構不一定是要對代碼做出『 翻天覆地 』的改變,小到對變量重命名、抽取一個方法等『 微小 』的優化都算是重構。總之,在當前代碼結構已不再適應新業務需要時,就需要及時重構,切不可在原有基礎上打補丁,代碼的惡化往往就是從此開始的。

    對待代碼我們同樣要有敬畏之心:『 勿以善小而不為,勿以惡小而為之 』。

小結


寫出高質量的代碼可謂『 功在當時,利在未來 』

每一位開發者都應為開發出高質量的代碼也努力

代碼設計能力的提升非一日之功,需要我們長期不斷地學習、實踐、思考、總結

優秀的書籍、優秀的開源代碼我們要學習

糟糕的代碼我們也要去反思,去總結

在代碼上同樣要做到『 勿以善小而不為,勿以惡小而為之 』

諸君共勉!