iOS 元件化方案探索

發表於2016-03-21

看了 Limboy(文章1 文章2) 和 Casa (文章) 對 iOS 元件化方案的討論,寫篇文章梳理下思路。

首先我覺得”元件”在這裡不太合適,因為按我理解元件是指比較小的功能塊,這些元件不需要多少元件間通訊,沒什麼依賴,也就不需要做什麼其他處理,物件導向就能搞定。而這裡提到的是較大粒度的業務功能,我們習慣稱為”模組”。為了方便表述,下面模組和元件代表同一個意思,都是指較大粒度的業務模組。

一個 APP 有多個模組,模組之間會通訊,互相呼叫,例如微信讀書有 書籍詳情 想法列表 閱讀器 發現卡片 等等模組,這些模組會互相呼叫,例如 書籍詳情要調起閱讀器和想法列表,閱讀器要調起想法列表和書籍詳情,等等,一般我們是怎樣呼叫呢,以閱讀器為例,會這樣寫:

 

看起來挺好,這樣做簡單明瞭,沒有多餘的東西,專案初期推薦這樣快速開發,但到了專案越來越龐大,這種方式會有什麼問題呢?顯而易見,每個模組都離不開其他模組,互相依賴粘在一起成為一坨:

component1

這樣揉成一坨對測試/編譯/開發效率/後續擴充套件都有一些壞處,那怎麼解開這一坨呢。很簡單,按軟體工程的思路,下意識就會加一箇中間層:

component2

叫他 Mediator Manager Router 什麼都行,反正就是負責轉發資訊的中間層,暫且叫他 Mediator。

看起來順眼多了,但這裡有幾個問題:

  1. Mediator 怎麼去轉發元件間呼叫?
  2. 一個模組只跟 Mediator 通訊,怎麼知道另一個模組提供了什麼介面?
  3. 按上圖的畫法,模組和 Mediator 間互相依賴,怎樣破除這個依賴?

方案1

對於前兩個問題,最直接的反應就是在 Mediator 直接提供介面,呼叫對應模組的方法:

然後在閱讀模組裡:

這就是一開始架構圖的實現,看起來顯然這樣做並沒有什麼好處,依賴關係並沒有解除,Mediator 依賴了所有模組,而呼叫者又依賴 Mediator,最後還是一坨互相依賴,跟原來沒有 Mediator 的方案相比除了更麻煩點其他沒區別。

那怎麼辦呢。

怎樣讓Mediator解除對各個元件的依賴,同時又能調到各個元件暴露出來的方法?對於OC有一個法寶可以做到,就是runtime反射呼叫:

這下 Mediator 沒有再對各個元件有依賴了,你看已經不需要 #import 什麼東西了,對應的架構圖就變成:

component3

只有呼叫其他元件介面時才需要依賴 Mediator,元件開發者不需要知道 Mediator 的存在。

等等,既然用runtime就可以解耦取消依賴,那還要Mediator做什麼?元件間呼叫時直接用runtime介面調不就行了,這樣就可以沒有任何依賴就完成呼叫:

這樣就完全解耦了,但這樣做的問題是:

  1. 呼叫者寫起來很噁心,程式碼提示都沒有,每次呼叫寫一坨。
  2. runtime方法的引數個數和型別限制,導致只能每個介面都統一傳一個 NSDictionary。這個 NSDictionary裡的key value是什麼不明確,需要找個地方寫文件說明和檢視。
  3. 編譯器層面不依賴其他元件,實際上還是依賴了,直接在這裡呼叫,沒有引入呼叫的元件時就掛了

把它移到Mediator後:

  1. 呼叫者寫起來不噁心,程式碼提示也有了。
  2. 引數型別和個數無限制,由 Mediator 去轉就行了,元件提供的還是一個 NSDictionary 引數的介面,但在Mediator 裡可以提供任意型別和個數的引數,像上面的例子顯式要求引數 NSString *bookIdNSInteger type
  3. Mediator可以做統一處理,呼叫某個元件方法時如果某個元件不存在,可以做相應操作,讓呼叫者與元件間沒有耦合。

到這裡,基本上能解決我們的問題:各元件互不依賴,元件間呼叫只依賴中介軟體Mediator,Mediator不依賴其他元件。接下來就是優化這套寫法,有兩個優化點:

  1. Mediator 每一個方法裡都要寫 runtime 方法,格式是確定的,這是可以抽取出來的。
  2. 每個元件對外方法都要在 Mediator 寫一遍,元件一多 Mediator 類的長度是恐怖的。

優化後就成了 casa 的方案,target-action 對應第一點,target就是class,action就是selector,通過一些規則簡化動態呼叫。Category 對應第二點,每個元件寫一個 Mediator 的 Category,讓 Mediator 不至於太長。這裡有個demo

總結起來就是,元件通過中介軟體通訊,中介軟體通過 runtime 介面解耦,通過 target-action 簡化寫法,通過 category 感官上分離元件介面程式碼。

方案2

回到 Mediator 最初的三個問題,蘑菇街用的是另一種方式解決:登錄檔的方式,用URL表示介面,在模組啟動時註冊模組提供的介面,一個簡化的實現:

這樣同樣做到每個模組間沒有依賴,Mediator 也不依賴其他元件,不過這裡不一樣的一點是元件本身和呼叫者都依賴了Mediator,不過這不是重點,架構圖還是跟方案1一樣。

各個元件初始化時向 Mediator 註冊對外提供的介面,Mediator 通過儲存在記憶體的表去知道有哪些模組哪些介面,介面的形式是 URL->block

這裡拋開URL的遠端呼叫和本地呼叫混在一起導致的問題,先說只用於本地呼叫的情況,對於本地呼叫,URL只是一個表示元件的key,沒有其他作用,這樣做有三個問題:

  1. 需要有個地方列出各個元件裡有什麼 URL 介面可供呼叫。蘑菇街做了個後臺專門管理。
  2. 每個元件都需要初始化,記憶體裡需要儲存一份表,元件多了會有記憶體問題。
  3. 引數的格式不明確,是個靈活的 dictionary,也需要有個地方可以查引數格式。

第二點沒法解決,第一點和第三點可以跟前面那個方案一樣,在 Mediator 每個元件暴露方法的轉介面,然後使用起來就跟前面那種方式一樣了。

拋開URL不說,這種方案跟方案1的共同思路就是:Mediator 不能直接去呼叫元件的方法,因為這樣會產生依賴,那我就要通過其他方法去呼叫,也就是通過 字串->方法 的對映去呼叫。runtime 介面的 className + selectorName -> IMP 是一種,登錄檔的 key -> block 是一種,而前一種是 OC 自帶的特性,後一種需要記憶體維持一份登錄檔,這是不必要的。

現在說回 URL,元件化是不應該跟 URL 扯上關係的,因為元件對外提供的介面主要是模組間程式碼層面上的呼叫,我們先稱為本地呼叫,而 URL 主要用於 APP 間通訊,姑且稱為遠端呼叫。按常規思路者應該是對於遠端呼叫,再加個中間層轉發到本地呼叫,讓這兩者分開。那這裡這兩者混在一起有什麼問題呢?

如果是 URL 的形式,那元件對外提供介面時就要同時考慮本地呼叫和遠端呼叫兩種情況,而遠端呼叫有個限制,傳遞的引數型別有限制,只能傳能被字串化的資料,或者說只能傳能被轉成 json 的資料,像 UIImage 這類物件是不行的,所以如果元件介面要考慮遠端呼叫,這裡的引數就不能是這類非常規物件,介面的定義就受限了。

用理論的話來說就是,遠端呼叫是本地呼叫的子集,這裡混在一起導致元件只能提供子集功能,無法提供像方案1那樣提供全集功能。所以這個方案是天生有缺陷的,對於遺漏的這部分功能,蘑菇街使用了另一種方案補全,請看方案3。

方案3

蘑菇街為了補全本地呼叫的功能,為元件多加了另一種方案,就是通過 protocol-class 登錄檔的方式。首先有一個新的中介軟體:

然後有一個公共Protocol檔案,定義了每一個元件對外提供的介面:

再在模組裡實現這些介面,並在初始化時呼叫 registerProtocol 註冊。

最後呼叫者通過 protocol 從 ProtocolMediator 拿到提供這些方法的 Class,再進行呼叫:

這種思路有點繞,這個方案跟剛才兩個最大的不同就是,它不是直接通過 Mediator 呼叫元件方法,而是通過 Mediator 拿到元件物件,再自行去呼叫元件方法。

結果就是元件方法的呼叫是分散在各地的,沒有統一的入口,也就沒法做元件不存在時的統一處理。元件1呼叫了元件2的方法,如果用前面兩種方式,元件間是沒有依賴的,元件1+Mediator可以單獨抽離出來,只需要在Mediator裡做好呼叫元件2方法時的異常處理就行。而這種方法元件1對元件2的呼叫分散在各個地方,沒法做這些處理,在不修改元件1程式碼的情況下,元件1和元件2是分不開的。

當然你也可以在這上面跟方案1一樣在 Mediator 對每一個元件介面 wrapper 一層,那這樣這種方案跟方案1比除了更復雜點,其他沒什麼區別。

在 protocol-class 這個方案上,主要存在的問題就是分散呼叫導致耦合,另外實現上會有一些繞,其他就沒什麼了。casa 說的 “protocol對業務產生了侵入,且不符合黑盒模型。” 其實並沒有這麼誇張,實際上 protocol 對外提供元件方法,跟方案1在 Mediator wrapper 對外提供元件方法是差不多的。

最後

蘑菇街在一個專案裡同時用了方案2和方案3兩種方式,會讓寫元件的人不知所措,新增一個介面時不知道該用方案2的方式還是方案3的方式,可能這個在蘑菇街內部會通過一些文件規則去規範,但其實是沒有必要的。可能是蘑菇街作為電商平臺一開始就注重APP頁面間跳轉的概念,每個模組已經有一個對應的URL,於是元件化時自然想到通過URL的方式表示元件,後續發現URL方式的限制,於是加上方案3的方式,這也是正常的探索過程。

上面論述下方案1確實比方案2+方案3簡單明瞭,沒有 登錄檔常駐記憶體/引數傳遞限制/呼叫分散 這些缺點,方案1多做的一步是需要對所有元件方法進行一層 wrapper,但若想要明確提供元件的方法和引數型別,解耦統一處理,方案2和方案3同樣需要多加這層。

實際上我沒有元件化相關的實踐,這裡僅從 limboy 和 casa 提供的這幾個方案對比分析,我還對元件化帶來的收益是否大於元件化增加的成本這點存疑,相信真正實踐起來還會碰到很多坑,繼續探索中。

相關文章