業務背景
豆瓣在 2014 年聚合了移動端業務,推出了一款叫“豆瓣”的App。隨著豆瓣App 的發展,豆瓣越來越多的業務線被納入其中。豆瓣App 代碼量越來越多,功能越來越復雜,體積越來越龐大。為了更從容地應對這種狀況,使整個項目更健康,我們實施了模塊化。模塊化的最終目的是獨立出幾個業務模塊,使得各個業務模塊互不干擾,可以獨立開發。但其實在當前的豆瓣,豆瓣App 的開發仍然由是一個團隊負責,並沒有以業務線劃分工程團隊。所以,業務模塊的獨立並不是一個對應公司組織構架上的工程要求。而是我們基於項目發展健康做出的考慮。雖然,整個過程伴隨著一些痛苦。但結果還是不錯的,最後我們得到了以下好處:
一個更清晰的項目結構;
一些可以復用的公共組件;
幾個相互隔離的業務模塊。
開源成果
我們在模塊化過程中,也產出了一些庫和工具:
FRDIntent,處理頁面間跳轉的庫。
FRDModuleManager,簡單的模塊管理庫。
Rexxar,移動混合開發框架。
我們將這些工具開源。一方面,是為了給大家提供一些借鑒的方向;另一方面,也是為了提高項目本身的質量。我們知道還存在不少問題。所以,會悉心接受大家的意見和建議。
工程環境
在描述豆瓣的模塊化實踐之前,先簡單介紹一下豆瓣的移動開發中相關的工作環境。
在 iOS 開發中,我們版本管理工具是 Git。公司所有的項目代碼都托管在內網搭建的 github enterprise 上。github enterprise 的使用體驗和 github.com 基本一致。這受到了工程師的普遍歡迎。
我們管理項目依賴的工具是 Cocoapods。
Git 和 Cocoapods 這兩項現在基本上業界的事實標准。本文中,也只有這兩個工具和模塊化過程有關。
模塊化的實施
項目結構
我們首先刻畫了模塊化之後最終我們希望得到的項目結構。然後向著這個目標,一步步靠近。
Frodo, 是豆瓣App 的項目名,豆瓣有使用魔戒人物命名項目的傳統。
Leaf,需要獨立的業務模塊在沒有拆分之前都放在 Frodo 的 Leaf 文件夾下。
Timeline,Group,Subjects,是三個拆分出來模塊。
Fangorn,是 Frodo 的公共底層庫的項目名。包括了業務相關公共 UI 組件,數據 Model,以及例如 FrodoRexxar 這樣的對外部庫的封裝。
Library,是我們自己拆分出去的庫,或者第三方庫。後面會介紹到的 Rexxar,FRDIntent, FRDModuleManager 都屬於 Library。
實施步驟
模塊化是一個浩大的工程,對於項目有著重大影響。我們在確定目標之後,接著制定了有一個詳細的計劃。然後按計劃一步步實施。
我們把模塊化分為四個步驟,下面會分別介紹每個步驟。
文件夾隔離
我們首先需要改變項目的文件組織結構。之前項目簡單地以 View / Controller / Model 劃分各類文件。現在改為首先按模塊劃分,每個模塊再劃分出自己 View / Controller / Model / Network 等文件夾。 比如,廣播 Timeline 有自己的 View / Controller / Model 文件夾;小組 Group 也有自己的 View / Controller / Model 文件夾。雖然改為按模塊組織項目的文件結構,但此時,所有模塊仍然還在一個倉庫裡。這其實只是做到了文件夾隔離,代碼並沒有被真正隔離。我們會查看各個文件的 #import 部分,減少業務模塊間的相互依賴。幾個業務模塊都用到的文件,則會沉入到公共層。
這個階段是沒法做到徹底隔離的。因為,理清依賴的過程有賴於程序員自己查看代碼。文件位置看上去是分隔開了,但是由於還在一個倉庫裡,代碼依賴肯定沒有辦法完全處理干淨。不過,沒有關系,文件夾隔離只是一個過渡階段。需要這樣一個過渡階段的原因是,產品開發不會因為我們要重構項目就停止。文件夾隔離避免直接拆分一次性集中付出很長時間解決編譯錯誤。而使得團隊可以將處理依賴的時間分攤到每個版本中去。一個業務模塊在經歷文件夾隔離階段之後,正真需要拆出去時所需要處理的未解決的依賴應該已經所剩不多了。拆分為獨立產品時的時間壓力就小了很多。
文件夾隔離也為團隊提供了一個轉變開發方式的緩沖期。業務模塊的劃分首先需要在全體組員間達成共識。從此,大家開始有了模塊化的意識。這表現為,新增文件會被放入對應的模塊之中;code review 時會提出不應該引用其他業務模塊的要求和建議。文件夾隔離使得組員逐步適應模塊化的思維,後續的產品功能也被歸入到對應的模塊之中。
文件夾隔離,使產品開發和模塊化得以並行推進。在不影響產品開發進度的情況下,較從容地推進模塊化。
抽象出業務無關的庫
我們同時也鼓勵將一些業務無關的代碼抽象成一個個獨立的庫。這類庫應該是與產品無關,與業務無關的。這就意味著,它們在一定時期內可以保持穩定,不會隨著產品和業務的頻繁變化而變化。將這些底層邏輯抽象出來,拆分成一個個獨立的庫,有不少好處:
每個庫都有了自己清晰的邊界。未拆分之前各個庫的代碼混在項目中,就存在相互干擾的可能性。獨立出庫使得項目代碼中有了更多的隔離,項目質量得到了提升;
拆分獨立的庫使得復用成為了可能,我們可以在新項目中使用它們,甚至將其開源,供其他開發者使用。FRDIntent, FRDModuleManager, Rexxar 都是這種情況;
為拆分出公共底層模塊 Fangorn 打下了基礎。這是因為,拆分出去的模塊,必須先處理好它的依賴,它將只能依賴已經拆分出去的組件和第三方庫。
拆分出公共底層模塊
在 Frodo 中,公共底層模塊叫 Fangorn。Fangorn 包括了業務模塊所需要的一些公共代碼,但是要麼是和業務關系較大,要麼就是還沒到可以抽象成一個庫的程度。
在拆分獨立出很多業務無關的組件和庫之後。我們仍然有一部分代碼是公共的,為多個業務模塊所使用的,但卻和業務有一定的關系。這部分代碼由於和業務相關,拆分出去也沒有復用的可能。但卻是拆分業務模塊的前提條件。將 Fangorn 獨立為一個倉庫之後,我們才能著手業務模塊的拆分。
在拆分公共模塊時,有兩種方法:
一種是,將 Fangorn 劃分為一個個子模塊。將這些子模塊一個個拆分出去;
一種是,將 Fangorn 作為一個整體先摘出來。Fangorn 內部各子模塊之間的依賴關系先按文件夾隔離的方式運作一段時間。如果,發現一個子模塊確實可以拆出去了就拆出去。
第一種方式更優雅一些。如果完成,各個業務模塊可以選擇自己需要的依賴,而不是將 Fangorn 作為一個整體全部依賴。但是也更困難一些。因為,這需要花很長時間理清楚 Fangorn 各個子模塊之間的依賴關系。
第二種方式更簡單一些。將 Fangorn 作為一個整體摘出來,就只要處理好 Fangorn 和外部其他模塊的依賴關系。這個工作量就小很多了。
為了早一點將業務模塊獨立,我們選擇了第二種方式。
業務模塊獨立
在 Frodo 中,獨立的業務模塊在文件夾隔離階段都放在一個叫 Leaf 的文件夾下。我們的目標是拆出廣播 Timeline,小組 Group,條目 Subjects 三個模塊。
這一部分的工作是解除業務模塊之間的依賴。使得這三個模塊都只依賴 Fangorn,拆分出去的庫,和第三方庫。最終的目的是,這三個業務模塊獨立,並拆分到單獨的庫中。在經歷了相當長時間的文件夾隔離,拆分出不少業務無關的庫,並獨立了 Fangorn 之後,我們開始按由容易到困難的順序拆分各個業務模塊。第一個待拆分的業務模塊是廣播 Timeline,而後會是小組 Group,再接著會是條目 Subjects。
最後我們為這三個業務模塊都單獨建立了可以運行的應用 Demo 項目。這樣,它們就可以真正獨立開發地開發運行。我們的開發流程就變為:先在這三個模塊庫中開發新的產品功能,使用模塊自有的 Demo 查看結果。完成之後在主項目 Frodo 中升級三個業務模塊庫的版本,在 Frodo 中驗證測試集成效果。
FRDIntent: View controller 間的解耦
在 iOS 項目,存在大量頁面跳轉。但 iOS 系統並不存在像 Android 的 Intent 一樣的統一的頁面跳轉方法。在 iOS 中,處理頁面跳轉,需要依賴代表跳轉目的頁面的類,需要知道它的初始化方法。這樣各個頁面就需要相互依賴。這種情況對解除耦合,拆分模塊很不利。為了解決這個問題,我們做了一個專門處理 iOS 中頁面跳轉的庫:FRDIntent,並將其開源。
Github地址:https://github.com/douban/FRDIntent
FRDIntent 有兩個部分:FRDIntent/Intent 用於解決應用內的頁面跳轉;FRDIntent/URLRoutes,用於解決外部應用對本應用的頁面調用。在使用了 FRDIntent 之後,我們很好地解除了 view controller 之間的耦合。並且為內部調用和外部調用提供了一套清晰統一的解決方案。解決了 iOS 項目中了很大一部分耦合問題:view controller 之間的耦合。為我們順利推進模塊化奠定了堅實基礎。
FRDIntent/Intent
FRDIntent/Intent 是一個消息傳遞對象,用於啟動 UIViewController。可以認為它是對 Android 系統中的 Intent 的模仿。當然,FRDIntent/Intent 做了極度簡化。這是因為 FRDIntent/Intent 的使用場景更為簡單:只處理應用內的 view controller 間跳轉。
直接使用 iOS 系統方法完成各 view controller 之間的跳轉,各 view controller 代碼會耦合得很緊。跳轉時,一個 view controller 需要知道下一個 view controller 是如何創建的各種細節。這造成了 view controller 之間的依賴。使用 FRDIntent/Intent 傳遞 view controller 跳轉信息,可以解除 view controller 之間的代碼耦合。
FRDIntent/Intent 有如下優勢:
充分解耦。調用者和被調用者完全隔離,調用者只需要依賴協議:FRDIntentReceivable。一個 UIViewControlller 符合該協議即可被啟動。
對於“啟動一個頁面,並從該頁面獲取結果”這種較普遍的需求提供了一個通用的解決方案。具體查看方法:startControllerForResult。這是對 Android 中 startActivityForResult 的模仿和簡化。
支持自定義轉場動畫。
支持傳遞復雜數據對象。
FRDIntent/URLRoutes
FRDIntent/URLRoutes 是一個 URL Router。通過 FRDIntent/URLRoutes 可以用 URL 調起一個注冊過的 block。
iOS 系統為各個應用間的相互調用提供了一種基於 URL 的處理方案。即應用可以聲明自己可以處理某些有特定 scheme 和 host 的 URL。其他應用就可以通過調用這些 URL 而跳轉到該應用的某些頁面。
FRDIntent/URLRoutes 是為了讓 iOS 系統中這種基於 URL 的應用間調用的處理更為簡單。所以 FRDIntent/URLRoutes 和社區已經存在的諸多 URL Routers 的功能和目的差別不大。FRDIntent 再次造了輪子是為了使 FRDIntent/URLRoutes 可以和 FRDIntent/Intent 配合一起解決應用內和應用外的頁面調用。
FRDIntent/Intent 和 FRDIntent/URLRoutes
FRDIntent/URLRoutes 和 FRDIntent/Intent 可以配合使用。Intent 處理內部頁面跳轉;URLRoutes 負責來自外部的頁面調用。在 FRDIntent/URLRoutes 的實現中,FRDIntent/URLRoutes 只是起了暴露外部調用入口,接收外部調用的作用。在應用內,仍然是通過 FRDIntent/Intent 啟動 view controller。
這麼做在隔離了外部調用和內部調用的同時,統一了外部調用和內部調用的實現方法。外部調用最終使用內部調用落地,是自然地復用代碼的結果。隔離外部和內部調用則會帶來以下這些好處:
iOS 系統提供的通過 URL 調用另外一個應用功能本身就是使用在應用之間的。iOS 系統中應用之間的隔離是清晰而明確的,通過 URL 在應用之間傳遞信息是合適的。但是,如果應用內部調用也使用 URL 傳遞信息,就會帶來諸多限制。Intent 更適合內部調用的場景。通過 Intent,可以傳遞復雜數據對象,可以很容易地自定義轉場動畫。這些在 URL 方案中都很難做到。
區分了外部調用和內部調用,我們就可以選擇是否要將一個內部調用給暴露外部使用。這就避免了在 URL 的方案中,無法區分內部調用和外部調用,將本應只給內部使用的調用也暴露給應用外部了這種問題。
FRDModuleManager:為 AppDelegate 減負
在開發的過程中,我們發現項目中實現了 UIApplicationDelegate 協議的 AppDelegate 變得越來越臃腫。為了使 AppDelegate 更為健康,各模塊可以更容易地獲知應用的生命周期事件。我們開發了一個簡單的模塊管理小工具:FRDModuleManager。這個小工具異常簡單,只有一個 .m 文件。我們也將其開源了。
Github地址:https://github.com/lincode/FRDModuleManager
FRDModuleManager 是一個簡單的 iOS 模塊管理工具。FRDModuleManager 可以減小 AppDelegate 的代碼量,把很多職責拆分至各個模塊中去。使得 AppDelegate 會變得容易維護。
如果你發現自己項目中實現了 UIApplicationDelegate 協議的 AppDelegate 變得越來越臃腫,你可能會需要這樣一個類似的小工具;或者如果你的項目實施了組件化或者模塊化,你需要為各個模塊在 UIApplicationDelegate 定義的各個方法中留下鉤子(hook),以便模塊可以知曉整個應用的生命周期,你也可能會需要這樣一個小工具,以便更好地管理模塊在 UIApplicationDelegate 協議各個方法中留下的鉤子。
FRDModuleManager 可以使得留在 AppDelegate 的鉤子方法被統一管理。實現了 UIApplicationDelegate 協議的 AppDelegate 是我知曉應用生命周期的重要途徑。如果某個模塊需要在應用啟動時初始化,那麼我們就需要在 AppDelegate 的application:didFinishLaunchingWithOptions: 調用一個該模塊的初始化方法。模塊多了,調用的初始化方法也會增多。最後,AppDelegate 會越來越臃腫。FRDModuleManager 提供了一個統一的接口,讓各模塊知曉應用的生命周期。這樣將使 AppDelegate 得以簡化。
嚴格來說,AppDelegate 除了通知應用生命周期之外就不應該擔負其他的職責。對 AppDelegate 最常見的一種不太好的用法是,把全局變量掛在 AppDelegate 上。這樣就獲得了一個應用內可以使用的全局變量。如果你需要對項目實施模塊化的話,掛了太多全局變量的 AppDelegate 將會成為一個棘手的麻煩。因為,這樣的 AppDelegate 成為了一個依賴中心點。它依賴了很多模塊,這一點還不算是一個問題。但是,由於對全局變量的訪問需要通過 AppDelegate,這就意味著很多模塊也同時依賴著 AppDelegate,這就是一個大問題了。這是因為,AppDelegate 可以依賴要拆分出去的模塊;但反過來,要拆分出去的模塊卻不能依賴 AppDelegate。
這個問題,首先要將全局變量從 AppDelegate 上剔除。各個類應該自己直接提供對其的全局訪問方法,最簡單的實現方法是將類實現為單例。變量也可以掛在一個能提供全局訪問的對象上。當然,這個對象不應該是 AppDelegate。
其次,對於 AppDelegate 僅應承擔的責任:提供應用生命周期變化的通知。就可以通過使用 FRDModuleManager 更優雅地解決。
這兩步之後,AppDelegate 的依賴問題可以很好地解決,使得 AppDelegate 不再是項目的依賴中心點。
Rexxar:混合開發
豆瓣在混合開發方面做了不少實踐工作。我們將這個過程的主要產出:Rexxar 開源了。這篇文章詳細介紹了 Rexxar。
我們推進混合開發的主要目的是提高工程效率。但同時也有一個副產物:由於使用 Rexxar 實現的頁面是使用 Web 技術實現整個業務,所以,在效果上,實現 Rexxar 頁面的 Web 代碼和項目其余的 Native 代碼是完全隔離的。我們在業務層使用的前端框架是 React。由於 React 本身組件化的特性,在前端代碼內部,項目代碼的模塊化也做得不錯。
所以,從效果上看,混合開發在我們實施模塊化的過程中也起了作用。使用 Rexxar 開發的頁面除了 Rexxar 這個庫,不依賴於項目的其他 Native 代碼。
普通對象間的解耦
在處理頁面跳轉時使用 FRDIntent,已經解決了大部分的模塊間調用問題。但這並沒有解決所有的耦合問題,項目中模塊間除了頁面跳轉之外,還會有其他對象間的相互依賴。除了跳轉到模塊內某個頁面這種接口之外,一個模塊還需暴露某些對象或者數據的話,我們怎麼處理這種依賴呢?例如,要暴露一個 UI 組件,或者一個計算結果。用具體的廣播 Timeline 模塊舉例:如果項目的其他模塊需要展示一條廣播,或者要知道某用戶發了多少條廣播這種計算結果。這些當然已經在廣播模塊裡實現過了。那麼,我們如何提供個一條廣播這個 UI 組件,和用戶發了多少條廣播這個計算結果呢?我們首先采用了兩類稍顯簡單粗糙的方法:
如果它們真的完全一樣,而且多個模塊中用到了。那我們就將其沉入公共底層模塊 Fangorn。這樣各個模塊都依賴公共底層模塊,而不用相互依賴;
如果它們並不完全一樣,我們就可能會拷貝一份代碼。為了防止沖突,會重新命名類。由於實施了模塊化,一定程度的代碼冗余是應該付出的代價,並可以忍受的。
除此之外,可能的解決方案是類似於 Java 社區中常用到的依賴注入容器。例如,Spring 就是一個著名的依賴注入容器。引入依賴注入容器之後,可以動態地創建對象,並為對象注入依賴。而所有依賴都在容器中注冊,業務對象只需要依賴接口,而無需依賴具體的類型。這是一種通用的解耦方法。適用於幾乎所有的依賴管理場景。
但在我們的實踐中,考慮到避免復雜性,並沒有引入一個依賴注入容器。使用依賴注入容器意味著項目中很多對象的創建都需要交托給容器。這對於整個項目的代碼運作方式做了很大的改變。對於這類基礎性的改變,我們都會比較慎重。而且我們對於在移動平台中,找到或者自己實現這樣的一個堅實的基礎容器也並不樂觀。
遇到的問題
Swift
我們在項目中使用了不少 Swift。這其中遇到了一些問題:
現階段 Swift 本身並不穩定。Swift 2 和 Swift 3 較前一版本都有較大變化。雖然,有 Xcode 提供的自動轉換工具,但仍然需要投入精力檢查和測試。在一個大型項目中,安全和穩定一般都是首要要求。引入一種處於發展過程中並不穩定的語言對於項目質量是一種威脅。但是,Swift 的情況稍有不同。對於 iOS 開發,我們並沒有太多選擇。只有 Objective-C 和 Swift,而可以確定的是 Swift 會是未來,Objective-C 將會是歷史。那麼,我們其實只能選擇何時,以何種方式切換到 Swift 而已。我們的經驗是先小塊實驗,再謹慎推進。每次升級現有代碼的 Swift 版本都一小塊一小塊做,不一次性轉換所有的代碼。
使用包含 Swift 代碼的庫對 iOS 系統版本有要求。如果要使用一個包含了 Swift 代碼的庫,需要以動態庫的形式引入。動態庫對 iOS 的系統版本有要求:iOS 8 以及以上版本。我們在 iOS 10 發布時,放棄了對 iOS 7 的支持。這使得我們可以使用包含 Swift 代碼的庫。如果項目對於低版本 iOS 的支持有要求,那麼現階段就不能使用包含 Swift 代碼的庫。通過 Cocoapods 將庫以動態庫形式使用的方法很簡單:開啟 Cocoapods 的 !use_framework標識即可。
Swift 在工程效率上確實優於 Objective-C。和 Objective-C 相比,Swift 可以用更少的代碼,更清晰的方式完成相同的功能。當然,混合使用 Swift 和 Objective-C 存在一定的工程成本。所以,這裡就需要權衡:是保持簡單,只使用 Objective-C 呢?還是忍受一定的不便,使用一些 Swift,帶來效率上的提升呢?
我們在項目中使用 Swift 的體會是:有快樂,當然也伴隨著一些不便。總體而言,不便都可以克服。
總結
相信我們在整個模塊化實踐過程中取得的經驗,對於一個成長中的移動項目應該會有某些借鑒意義。我們產出的一些開源庫和工具,也可能會對大家有幫助,或者啟發意義。也歡迎大家提出反饋和建議,幫助我們改進和提高。