歡迎光臨
每天分享高質量文章

美團貓眼電影Android模塊化實戰總結

來自:何俊林(微信號:smartyuge),作者:陳文超

https://www.jianshu.com/u/485dc78851d0

1、寫這篇博客的初衷

首先一句話概括:我想把這幾個月做的事情記錄下來,並且希望儘量詳細,希望讀者讀了這篇文章能夠知道專案進行模塊化,專案改業務框架可能會遇到哪些問題,具體每個步驟都做什麼,而不是大致的瞭解。

現在很多人都在談模塊化,網上有一大堆的博客實踐都在講這個。很多談的只是模塊與模塊之間的解耦,並且大部分講的是通過router路由進行解耦,其他談的不多,而且不乏泛泛而談。但將一個app真正做到解耦,運行。需要解決的事情遠遠不止解耦。業務架構、行程間通信、資源等處理、解耦方式等都需要解決。恰好對於貓眼模塊化整個過程的實施,從頭到尾,分析解決各種問題,我陸陸續續的做了幾個月。貓眼app的歷史版本是一個耦合度很高的一個工程。從這樣的一個歷史版本到最終的各個業務模塊能夠獨立運行並且能夠做行程間通信,會涉及到各個方面的解耦和一些其他東西。我今天我就以該app為例(其他的app進行解耦可能會遇到不同的問題,這點註意一下),完整的講下貓眼模塊化的整個過程。每一個方面沒有照搬網絡的一些做法,而是分析對比,採用更好的設計方式。比如解耦使用serviceloader,而不是路由進行;比如架構使用更適合我們業務的一種帶生命周期的mvp變種。我還會說下具體的花費時間和一些經驗,這樣大家以後做模塊時也心中有數。(提示一下,其實模塊化過程所涉及的東西除了文章提及的還有很多。有些未提及,是因為之前已經完成,比如網絡庫的快取由資料庫->文本,這點讀者註意一下。如果還有遺漏的地方,可以交流~)。

主要內容:serviceloader解耦,mvp變種框架,模塊通信,lib獨立運行,多端復用。

2、為什麼做模塊化

首先要說一點:做模塊化不是為了炫技。如果沒有業務場景需求,不建議做。
為什麼要做模塊化,網上已經闡述了很多原因了。這裡我簡單說下貓眼為什麼要做:

  • 貓眼需要快速移植到其他app(美團,點評..)。

  • 解耦首頁,減少冷啟動時間。

  • 開發時減少build時間,代碼責任制。

  • 服務快捷替換

3、解耦到什麼程度?

首先說下,模塊化究竟是什麼呢?這個大家肯定都耳熟能詳了:能夠將不同的業務分離成不同的lib module。那麼做完模塊化,我們的某個業務lib 具有哪些功能呢?我認為是:

總結一句話:無溝通成本,快速,傻瓜式的在任何app上運行。具體就是:這個lib不耦合具體app的服務,不耦合具體app的activity。只要給我一個app(或假的app殼子),通過它的baseActivity,和他們的服務,我就可以非常快速的將這個lib在那個app上運行。停!你可能會說這個服務是什麼東西,讓我詳細的說下吧~

3.1 可以無侵入式的配置各種服務

我們知道每個app都會提供賬戶信息,設備信息,網絡服務,圖片加載服務,打點服務,下拉掃清樣式,錯誤狀態等。每一個app的這些服務可能都不一樣,比如美團使用的網絡服務是okhttp,而點評使用的是長連接。所以我們的業務邏輯lib不能耦合這些具體的服務。只能耦合服務抽象而來的接口。在具體app使用的時候,我們再把app的服務提供給這個lib。那麼這些服務怎麼給呢?如果當需要服務時,我都留了一個傳參的口子,這樣我就需要把app的服務一個個塞到lib中需要的地方。這樣成本太大了。我不希望這麼麻煩,我希望的方式是直接把服務實現作為txt文本放在app的某個檔案夾,你這個lib就能給我運行。這樣我幾乎不用管lib裡面是什麼東西。你只要給我一個業務lib,我添加一個txt文本,就能運行了。

3.2 lib快速便捷多端使用

說下不耦合activity。我們知道每個app有自己的baseAtivity,在裡面做統計,處理異常、某些庫的初始化等功能。除此之外,每個app的actionBar也不一樣,每個頁面在不用的app中manifest的schema也不一樣。所以在lib中的業務,如果是一個業務,我們不能直接寫成Activity,而應該是一個view/fragment,這樣對於任何一個app,我們直接新建一個activty,然後把lib中的頁面放到那個activity中即可。同樣,考慮的是協同合作的成本問題,我不希望在放這個頁面的時候,我需要處理很多其他的東西,比如資料加載。我希望你給我這個業務頁面pager(其實是一個view),我放到Activity onCreate的setContentView中即可,它就能運行。別讓我做其他處理生命周期,資料系結,銷毀等的事,那都是你pager內部需要做的。

3.3 demo示例

前面這兩點說的可能雲里霧裡的。最近我寫一個貓眼問答需求,涉及5個頁面,所以做成了一個lib。那麼就結合我最近寫的lib來圖文闡述一下。

這個lib就是問答的業務lib。這不耦合具體的服務,只耦合服務接口。裡面的頁面(page包下)不是activity,而是view。那麼,這時候另外一個同事想把這個lib用到貓眼app上。怎麼做呢?

在貓眼app的build.gradle下添加這個lib的依賴:

 compile ‘com.maoyan.android.business:movie:1.0.2.3’


在貓眼宿主app中添加一個lib需要的服務配置:服務實現txt文本(因為是宿主app,之前其實已經存在)。

txt裡面是:

在宿主app中創建activity,並放置lib中的頁面,填寫manifest,比如(可能有時候需要在裡面寫入actionBar的交互邏輯)

這就完成了,就運行了。所使用的各種服務,下拉掃清等都是這個app提供的。是不是快速、無需溝通寫作、傻瓜。如果我們想測試這個app,那麼也很簡單。隨便建一個app殼子,新建activity,把lib中的頁面page放進去。然後添加所需要的服務實現txt文本(因為是測試,所以服務實現可以自由一些,可隨意配置),就大功告成了。這種方式來修bug調ui,比啟動宿主app修改代碼節省很多時間。我們看下我隨意寫了一個app來測試lib:

我們可以看到下拉掃清,狀態服務等和貓眼app中的都不一樣,都可以定製。如果都這麼寫,其實所有的模塊我們都可以快速,傻瓜,可定製的做成app這種解耦程度是不是更好呢

如果感覺還不錯的話,那麼我們開始工作吧~

4、開始模塊化之旅

4.1 原專案耦合結構

開始模塊化工作,我首先得給大家呈現下之前未模塊時高度耦合的貓眼app。我們這裡以電影詳情頁為例,看看他的耦合情況:

電影詳情頁是建立在一層層的基類之上,這些基類耦合了具體的網絡加載等各種服務。因為詳情頁有想看、評分、點贊等可編輯狀態,所以還耦合了greendao資料庫(以前網絡加載也耦合了這個資料庫,後來換成了retrofit+rxjava,所以替換到了這層耦合,謝天謝地)。該頁面因為需要和其他頁面互動(比如跳轉、評分同步等),所以也同時耦合了其他頁面的類。除此之外,還有utils,view,model等。如果想把電影詳情頁抽離出來,這些所有的耦合都要剝離。具體需要解決的問題,如下:

4.2 準備工作
– 4.2.1 工作量評估

首先我們說下解耦時需要做的準備工作。因為這些工作是解耦拆分的基礎。有兩點需要做,如下所示:

首先說明一下,並不是我喜歡打五顆星。確實是這部分工作量比較大~~~

– 4.2.2 公共資源,model,utils等的拆分
– – 4.2.2.1 耦合情況示例

第一點是公共資源,model,utils等的拆分。這些事情雖然不用考慮太多事情,但是很繁瑣。在做模塊化的時候,這個地方耗費了不少時間。很大一部分原因是,之前的貓眼歷史版本代碼不夠規範,對代碼耦合這些事情不夠敏感。舉幾個例子吧:

  • 我們之前的utils基本都寫在一個類MovieUtils裡面了。這個類就像大染缸。什麼都向裡面放。在傳入的引數方面也不夠規範,甚至MaoyanBaseFragment這種業務代碼都作為引數傳入。導致這個東西及其難拆。

  • utils的方法不傳context。前人寫的時候圖省事,在專案中統一加了一個靜態的context,導致幾乎所有的utils都沒有傳入context,這樣的後果是這些工具方法直接以來宿主app。

  • 之前寫的common view 不夠獨立。既然想寫common view,那麼就儘量讓這個view能夠獨立,不要耦合其他第三方庫,儘量使用android 官方庫。

– – 4.2.2.2 資源拆分經驗

對於資源的拆分,其實是非常繁瑣的。尤其是如果string, color,dimens這些資源分佈在了代碼的各個角落,一個個去拆,非常繁瑣。其實大可不必這麼做。因為android在build時,會進行資源的merge和shrink。res/values下的各個檔案(styles.xml需註意)最後都只會把用到的放到intermediate/res/merged/../valus.xml,無用的都會自動刪除。並且最後我們可以使用lint來自動刪除。所以這個地方不要耗費太多的時間。剛纔說了,styles.xml需註意。那麼需要註意什麼呢?這個東西是這麼寫的:

我們在寫屬性名字的時候,一定要加上前綴限定詞。如果不加的話,你這個lib打包成aar後給其他app使用的時候,會出現屬性名名字重覆的衝突,為什麼呢?因為BezelImageView這個名字根本不會出現在intermediate/res/merged/../valus.xml里, 所以不要以為這是屬性的限定詞!

– 4.2.3 集成式vs組合式(選做)

前面說了資源utils等的拆分,那麼接下來說下第二點,基類的處理。我們看到電影詳情頁是建立在一堆的基類之上。每一層的基類都做了一些事情。(當時這麼寫是為了頁面的快速開發)如果我們想將電影詳情頁獨立出來,就需要把這些基類打包成一個aar,下沉到基礎庫,供所有頁面使用。但是我們以前的這種基類耦合了很多貓眼的東西,像下拉掃清,頁面狀態什麼的都是寫死的,並且如果我需要寫個頁面,我就需要繼承那麼一大堆的fragment。當然這種改一改也可以移植。但對以後的代碼迭代肯定是不好的(修改,添加業務)。因為它靈活性差。

比如如果點評app上需要貓眼某個頁面的一部分而不是整個頁面,原來那種改起來就不是很方便。我希望的方式是這些頁面都是view,而不是fragment。並且也不是這種繼承方式,而是組合方式。即如果我想要一個帶下拉掃清的串列view,那麼我直接build出來這麼一個view,需要什麼配置就set進來,它就能夠使用。這個view你可以放到任何一個view,fragment中和其他view進行組合。即:

這個MovieRcPagePullToRefreshStatusBlock是一個view,可以用在任何頁面進行view的組合。

– – 4.2.3.1 組件的插拔式,組合式設計

其實我的做法更大膽,或者更“懶”一些。我希望我這個MovieRcPagePullToRefreshStatusBlock build成功以後,放到頁面中就能顯示運行,自動加載資料了。就像小時候玩積木那樣,組件與組件都是插拔即用式的。至於這個block是怎麼加載資料的,使用者無需關係。使用者只需要拿到這個block,然後build時set進去需要的東西。放到頁面中就可以運行了。可以參考這個作為示例:

我們可以看到這個頁面,我只是build出來了兩個view,然後放到這個page中,並沒有關心資料加載什麼的,資料加載是在這個block內部完成的。然後這個page就像前面說的那樣,放到某個app的activty中就可以運行了。插拔式、傻瓜式的思想,可能我這個人比較“懶”~~

那這種架構怎麼實現呢?,接下來粗略的看看這種框架大體的實現思路吧(具體的可以看下我寫的這一篇android 官方mvp框架優化:lifecycle-mvp,像前端那樣組合式寫頁面)。其實這個框架大體也是mvp框架的思想,不過同時解決了業務場景的一些問題,比如,生命周期,移植性,溝通成本,使用方便與否等。既然要說下實現思路,那麼從開始說起,對自己是個總結,對讀者們可能有有些許幫助。先說下mvp框架的含義:

– – 4.2.3.2 mvp框架含義

mvp框架總體來說適用於android的場景需求。m代表model,提供資料;v即view,提供的是供presenter呼叫的view相關的方法;p 即presenter,提供的是頁面里觸發動作的邏輯方法。

– – 4.2.3.3 官方mvp框架的缺點

mvp框架網上有很多,官方也推薦了mvp框架。和一般的區別是:用contract來承載view和presenter的接口定義。用fragment來實現view接口。不過官方使用fragment來實現view,也有它的無奈。為什麼說它無奈呢?對於view層的接口,使用fragment來進行實現,主要是因為fragment有生命周期。但fragment太笨重了。試想一下,我有一個頁面,裡面有四五塊內容。為了以後的各塊內容的移動、去除、移植更方面,我希望每一塊內容都做成mvp形式,塊與塊之間不耦合。那麼官方的這個mvp框架就不適用了。因為你不可能在一個頁面寫5個fragment把。android的activity中不建議寫那麼多的fragment,fragment典型的使用場景是ViewPager。

– – 4.2.3.4 常規變通

那麼變通一下,5塊內容的view層,不再用fragment實現,而只是一個個普通的view,每個view監聽事件的響應還是在view中進行(呼叫各自的presenter方法)。而對於整個頁面的初始化加載或者下拉掃清加載,這5塊內容共用一個fragment,在這個fragment的onStart()和下拉掃清的監聽回呼中加載5塊內容對應的presenter的方法。然後在fragment的onCreateView()中把5塊內容的view填充進來。5塊內容之間可能還需要通信,資料交流,這些借助presenter在fragment中進行。

– – 4.2.3.5 帶生命周期的mvp:lifecycle-mvp

上面那麼做完全沒有問題,並且上面那種做法也存在於我們的專案中。但通過幾個版本的迭代,我發現了一些問題:presenter太亂,太散。fragment需要持有所有的presenter,在onStart()時load()資料。各自的view也需要持有各自的presenter。並且view和presenter之間需要互相set()。你還需要在activty或者fragment的onDetroy()方法中管理presenter。總體讓人覺得很亂。尤其是如果你的組件需要被別人使用,或組件用需要用到其他app時,其他人拿到你的組件,你要關心兩個東西view和presenter,他得知道這兩個東西裡面的方法,並且他需要在activty/fragment的生命周期中關聯他們並呼叫一些方法。嗯。這個過程肯定存在的大量溝通成本~


所以才想到了前面講的那種build方式來實體化組件,然後用pager組合組件。特點是(具體可以看下android 官方mvp框架優化:lifecycle-mvp,像前端那樣組合式寫頁面):

  • 使用lifecycle-component這個組件提供生命周期。

  • presenter被view層內部持有,不向外暴露。

  • build創建view實體時,提供TypeFactory,用於業務的擴展。

  • 業務代碼分層。

  • 用這種mvp的變種框架改寫專案的原代/寫新業務,就可以使頁面更容易移植、拓展,頁面內的模塊也可以移動改變。當然,這種框架是建立在我們的業務基礎之上,框架還是需要因專案而已,沒有最好,只有更適合~

4.3 接口的抽離

前面已經闡述了模塊化的準備工作,接下來我們需要做什麼呢?根據前面介紹的原專案耦合結構,我們知道我們以前的專案直接依賴了各種service的具體實現。我們接下來要做的是把這些具體service實現用接口來剝離:

4.3.1 使用servieloader進行解耦—非顯式的呼叫服務實現類
– – 4.3.1.1 官方serviceloader

從圖上可以看到,我們的實現類都被對應的接口所代替。但就這一步本身來說,並沒有太大的難度:找到以前服務呼叫的地方,然後換成接口呼叫。無非就是有些服務用的比較多,換起來繁瑣一些。但我們現在需要考慮一個問題:服務的實現,我們怎麼給?首先想到的是,我們留一個引數來傳入。但這種方式會導致將來使用lib的時候,溝通成本太大:你需要告訴別人哪裡哪裡我需要傳入什麼型別的引數。不然你這個lib就沒法運行。我不希望別人在使用你lib的時候,還需要去內部查下你的代碼是什麼,應該怎麼傳引數。我希望 別人在使用的時候,對他們來說,lib是儘量透明的。不需要知道lib內部寫的是什麼,只需要在外部配置一個txt的文本就可以運行lib!那應該怎麼做呢?


其實java很早就提供了這種類似的功能:在資源目錄META-INF/services中放置提供者配置檔案,然後在app運行時,遇到Serviceloader.load(XxxInterface.class)時,會到META-INF/services的配置檔案中尋找這個接口對應的實現類全路徑名,然後使用反射去生成一個無參的實體。


我們大體的使用方式也是基於java提供的這種功能:

 – 4.3.1.2 對官方serviceloader改造
– – – 4.3.1.2.1 官方serviceloader缺陷

從前面的闡述來看,java官方提供的serviceloader至少有三個地方需要改進。

  • serviceloader沒有快取功能。因為對於服務來說,大部分我們都需要使用單例樣式,而不會頻繁的生成新的實體。

  • serviceloader使用無參的構造方法進行構建實體。這點不用多說,肯定需要改進。誰的服務構建的時候不需要傳入引數呢?

  • serviceloader沒有預檢查等問題。因為在運行時,需要在配置檔案中去尋找接口對應的實現類名。那麼肯定會遇到接口名寫錯了,類名寫錯了,配置方式寫錯了,找不到接口實現類等,這些錯誤在編譯器是發現不了的。同時,使用serviceloader是一種非顯式的呼叫服務實現類方式,如果不在proguard中保護這些實現類,那麼肯定會被shrink掉。除了proguard問題外,配置檔案寫在資源目錄META-INF/services下對於一些手機(三星)也有兼容問題。最後,考慮servic配置檔案手動註冊的缺點,serviceloader需要提供自動註冊功能。

對於上面三種情況的處理,第一點很容易解決。提供一個快取就可以了,不多說。

– – – 4.3.1.2.2 serviceloader構造實體

第二點我們是這麼解決的:我們讓所有使用serviceloader加載服務的接口都實現Iprovider接口。Iprovider接口提供了一個init(Context context)方法。這樣所有的服務實現類都需要實現init(Context)方法,在裡面做原構造方法里需要做的初始化邏輯。因此,我們在呼叫serviceloader加載服務的時候就類似這樣:

ImageLoader  imageLoader = MovieServiceLoader.getService(context, ImageLoader.class);


在MovieServiceLoader內部,生成的實體會呼叫一下init(Context)方法。這樣我們就解決了第二個問題。這裡可能也會有一些朋友有些疑問(比如和美團平臺的童鞋就此事討論過):為什麼只傳入context引數。如果一個服務實現類還需要其他引數怎麼辦?就我們的服務和而言,我認為只需要傳入context,基本上通過context能夠獲得android絕大部分的引數。並且對於服務來說,既然它是一種服務,按理說不會依賴你專案一個具體的一個組件。所以我認為傳入context就夠了,而不是傳入不定格式的object引數:

MovieServiceLoader.getService(Object… paramsImageLoader.class);


這種方式固然能夠解決所有問題。但是這種設計的思想已經違背了接口和實現的隔離概念。比如說,我想使用圖片加載服務,按理說我只需要呼叫一下

imageLoader = MovieServiceLoader.getService(context, ImageLoader.class);


就ok了,你這個具體的服務是Picasso還是glide別讓我知道,我也不想知道。如果使用第二種方案,我難道還要知道你這個具體服務需要哪些引數,然後傳入嗎?這感覺太不友好了。使用Iprovider還有一個好處,那就是我們只需要在MovieServiceLoader倉庫的proguard中添加

就可以了。其他的地方在使用或新建服務接口時,都不用再考慮proguard問題了。

– – – 4.3.1.2.3 serviceloader預處理—gradle插件

第三個需要解決的問題是serviceloader的預檢查等。這個解決方法就是寫一個gradle插件。插件的大體流程是:

  • 我們在build的某個階段拿到所有編譯後的class檔案(夾)和jar包。

  • 使用javassit確定哪些類被@autoService修飾,配置檔案中如果不存在,在其添加。

  • 查看serviceConfig配置檔案裡面的格式是不是正確。

  • 通過javassit來確定serviceConfig配置檔案裡面的類是不是在專案中存在,接口類是不是實現了Iprovider接口。

– – – 4.3.1.2.4 需要用到的知識:build流程,javassit,groovy

本來這裡不想說太多東西,但是考慮到這三樣很多讀者可能不熟悉。直接去網上google這三方面的東西,單就這些東西,可能還需要學上一學。那麼我還是把我的一些經驗寫上(為了切題,就不詳細展開了),讀者可以參考參考,些許能夠事半功倍。

因為你需要拿到編譯後的class檔案和jar包,你需要知道build的大體流程,各個task的輸入輸出是什麼,是以檔案夾的形式還是jar包的形式。比如說拿所有class的時機,可以在assembleXxx這個task時(dex task已經完成了),從dex task的輸入檔案夾/jar包中拿到所有的class。同理javac task的輸入也可以。但javac task的輸出就不可以,因為javac task輸出的intermediate/class檔案夾只包含專案中的class檔案,不含有aar對應的intermediate/exploded-aar檔案裡面的class檔案。當然transform也是一種實現方式。transform的輸入,輸出檔案路徑已經給好了,輸入的class為所有的class。

除build流程之外,你還可能使用groovy來寫插件邏輯。不過如果你實在不想用groovy,那麼也可以用java,兩者兼容,只是groovy的很多特性像迴圈等就沒法用了。這裡有個小經驗:寫goovy,ide不能很好提示錯誤,比如你使用了一個變數或一個方法,如果方法用錯了,變數沒定義。也不會給你提示找不到。所以最好還是使用先寫到.java裡面,然後再移動到.groovy裡面吧。

最後你還需要知道javassit的一些知識,這個是處理class檔案的工具。很強大,和java很像,大部分的使用都會落腳到ctClass的使用。所以這個類最好熟悉。這裡有個小經驗:有時候需要ctClass->class的轉化,記得使用靜態變數儲存這個class物件,不然會報 classloader多次加載同一個路徑的異常。

ok,使用serviceloader來進行解耦的原理,改進,好處已經說完了。

– 4.3.2 serviceloader解耦 vs 路由方式解耦

網上有關模塊化的博客,大部分使用的是路由的方式解耦。路由的方式解耦是怎麼一回事呢?

– – 4.3.2.1 路由方式解耦闡述

我們看下大體的路由框架圖 

那麼這個路由框架是怎麼工作的呢?這裡的action是一個服務,provider是一個map集合,盛放一個lib里的所有(action名字:action實體)鍵值對。在宿主app中註冊各個lib的provider。這樣module A請求moduleB的服務時,通過(代碼來源):

即通過提供provider的名字,action的名字,引數名,值,到註冊的map中尋找對應的action實體,然後呼叫其對應的方法。核心就是使用字串來匹配對應的實體進行解耦。

– – – 4.3.2.1.1 路由方式解耦優點

這種方式的最大好處是,新建一個服務時,不需要寫接口,所有的都用字串來進行標誌,進行匹配,兩個model之間不需要耦合任何東西,甚至接口宣告都不需要耦合。如果一個lib中有很多需要被外界呼叫的服務,並且呼叫的次數不多,或者我不僅僅對服務解耦,那麼用這種路由的方式很好,因為不用寫接口了。

– – – 4.3.2.1.2 路由方式在服務解耦方面不適用性的討論

但為什麼沒選擇這種解耦方式呢?因為這種方式,對於android整體的服務解耦來說,我還是提出瞭如下的顧慮(僅代表自己的觀點,可能比較粗鄙,並不是說人家的專案不夠優秀):

  • 對於大面積的解耦,肯定大部分是app界別的服務進行解耦。特點是大量使用,這時候我寫幾個接口,下沉到base庫,無傷大雅。這樣我在使用的時候,serviceloader好處就突出來了:使用服務的時候,我不需要關心實現類的類名,包名是什麼,需要傳入什麼引數,呼叫的方法的名字是什麼。如果使用路由方式接口,我需要關心的事情就多了,如果我需要關心這麼多東西,它就不應該叫服務了。如果另一個lib在你不知情的情況下改了名字怎麼辦?並且在代碼移植到其他app或獨立運行時,配置方式也不夠友好。serviceloader只需要寫個配置txt檔案放在apk中即可,並且每一個lib的服務寫到自己的serviceCinfig即可,不需要宿主app關心。使用路由方式,即使action可以自動註冊,也需要在application處理一些註冊的事情。

  • 路由這種服務框架和serviceloader,本質來說,並不能進行真正意義上的模塊間的通信。說的通俗點,路由框架能做的是:b lib可以在不依賴a lib專案的情況下,b可以new 出來a中一個類的實體(或提前new好),然後呼叫那個實體的方法。這並非通信,只是能夠呼叫其他倉庫的方法。而通信指的是監聽狀態,回呼。serviceloader同樣也做不到真正意義上的通信。模塊間通信只能通過非顯式的監聽機制才能進行,比如eventbus,廣播,contentprovider等來進行。為什麼要說這一點呢?因為我看到很多模塊化的博客都在說使用路由框架進行模塊間通信。但就前面提到的這種路由框架,確實做不到真正意義上的模塊間通信。

ok,serviceloader解耦 vs 路由方式解耦就到這裡。

4.4 解耦方面的其他工作
– 4.4.1 工作評估

前面很大一個篇幅都在講使用serviceloader進行服務的解耦。那麼除了這個,還需要做什麼?這裡我先大體總結一下,再逐個闡述:

– 4.4.2 服務實現的抽離

第一點的後半句需要註意一下:如果你希望所有模塊都能夠獨立打包運行,那麼需要把所有的服務實現也抽離出來。如果不想獨立運行,只是想進行解耦,那麼還是留在宿主app中即可。雖然說這麼一句話很輕鬆。但是抽離一個服務實現,真正實施起來卻需要花費很多的時間,因為一個服務可能耦合了很多的東西,不留神不好拆。這一點讀者們心裡要先有個數。 不過能抽離就儘量抽離吧,不只是lib的獨立運行。對之後服務的替換也有很大的好處。比如網絡加載庫,以前使用的是retrofi+okhttp,後來升級成了retrofit+長連接。替換的時候只是在服務配置檔案中改一句話的事情。如果打算抽離,要註意接口的定義,不要耦合具體某個庫的類,考慮要全面,設計要合理。比如INet庫,接口定義為:

public interface INetService extends IProvider {
create(final Class service, String getdataPolicy, String cacheTime);
}


雖然retrofit是一個很棒的庫,但接口也沒有耦合這個庫。說不定哪一天就替換了。

– 4.4.3 資料庫的抽離

第二點說起來很痛苦,資料庫的抽離真的是很麻煩。不知道在哪個版本開始,貓眼耦合了greendao。這個資料庫本身來說挺優秀的,但是架不住它太大!如果我想把一個lib給別人用,難道我這個lib還得耦合一個大的第三方資料庫?!!因為之前沒有考慮過模塊化,所以基本所有的網絡資料,敏感資料等都進行了grrendao的儲存。所以解耦的時候每每看到daossion,我都是虎軀一震。網絡資料使用檔案儲存且對業務代碼透明。敏感資料使用資料庫儲存,但用接口隔離,並且資料庫建議使用官方的資料庫sqlite或者room。

– 4.4.4 和butterKnife說再見

第三點的意思是如果你想將業務代碼獨立模塊化,那麼就得跟像butterknife框架的view註入功能說拜拜了。因為Android ADT14開始,library的R資源不再是final型別的了,所以在library中你不能使用R.id.xx,需要使用findViewById()來代替;也不能使用switch(R.id.xx),需要使用if..else來代替。


第四點是第一點的後續工作。不存在多少工作量。

– 4.4.5 頁面跳轉
– – 4.4.5.1 頁面跳轉需要做的事情

頁面跳轉也是app中需要重視的一個事情,因為它是模塊化的門戶,涉及到頁面與頁面,其他app、i版到頁面之間的通信問題。雖然看起來簡單,但如果設計不合理,那麼模塊化入口的代碼優雅度,crash數量,頁面降級,運營協作等方面都會受到影響。


對於頁面間的跳轉,我們的一般做法:

  • 如果這個類頁面沒有隱式跳轉功能:

    • 那麼直接在其他頁面首先
      獲取intent(getContext(),TargetActivity.class(,然後intent添加引數。
      最後starActivity(getContext(),intent)。

    • 在標的activty的onCreate()裡面getIntent().getString(xx_key,defaultValue)等獲取引數;

    • 如果xx_key對應的value不合法或者解析錯誤,比如movieId=0,或者等於“”。那麼應該跳轉到一個其他頁面或者跳轉失敗。

  • 如果這個頁面配置了隱式跳轉功能:

    • 那麼在其他頁面你首先得創建一個createXxxActivityIntent()的utils方法,在裡面傳入落地頁的path,引數key,引數value。

    • 在manifest中宣告。

    • 在標的activty的onCreate()裡面getIntent().getData().parseBoolean(xx_key,defaultValue)…等獲取引數

    • 如果xx_key對應的value不合法或者解析錯誤,比如movieId=0,或者等於“”。那麼應該跳轉到一個其他頁面或者跳轉失敗。

– – 4.4.5.2 android原生頁面跳轉存在的問題

下麵說下這種使用原生頁面跳轉存在的問題~

  • 在獲取引數的時候,需要寫一大推的intent.get(xx),如果這個頁面既含有隱式跳轉,又含有顯示跳轉,那麼肯定上面那個過程都需要,這樣在onCreate()裡面就會非常的亂。要進行if else

  • 如果想進行隱式跳轉,那麼都需要在manifest進行註冊intent-filter。一是麻煩,二是我需要在另外一個地方去配置某一個activity的東西,管理不方便。

  • 需要另外寫一個utils獲取隱式intent。

  • 沒有降級策略,如果運營配錯了,那麼只能到錯誤頁面,而無法進行一個補救措施,比如進入i版頁面。

  • 開發人員或者後臺配置錯誤引數的時候,我們需要寫兜底邏輯。每一個頁面解析都需要寫一段相同的邏輯。

  • 如果一個頁面需要登錄用戶才可以打開的權限,那麼我們經常會寫if(isLogin()){//跳轉頁面}else{//跳轉到登錄頁面} ,每次操作都要寫這些個相同的邏輯。

如果覺得在這方面沒那麼多要求,針對頁面間的跳轉,為了不耦合其他的模塊的類,所有頁面都可以採用隱式跳轉機制來進行。這基本已經可以滿足情況了。但我這裡還是想說下阿裡推出的開源框架Arouter。其具有攔截功能,這樣跳轉失敗可以有降級處理(比如呈現i版頁面),讓頁面具有登錄用戶可打開權限;獲取引數方式統一等。還是挺不錯的。基本解決了上面所面臨的問題。具體就不展開了,具體可以看開源最佳實踐:《Android平臺頁面路由框架ARouter》

4.5 模塊間/頁面間通信
– 4.5.0 使用ViewModel來進行頁面間資料共享

這一段是新加的內容。我覺得放到這裡比較合適。ViewModel是google新推出的lifecycle-component中的類,官方文件中闡述使用ViewModel可以解決頁面旋轉等配置改變時資料儲存的問題。我思考了下,覺得它在解耦頁面內資料共享的問題也能發揮作用。

舉一個我以前遇到過的例子:一個頁面做完了,pm找我做頁面的埋點。埋點需要頁面的movieId信息,但是需要埋點的那個block中並沒有movieId。並且我這個block層級很深。如果想拿到movieId,我需要從activity頁面層級一層層傳到我這個block中,免不了中間層級的耦合和方法的創建。當時覺得這件事真是讓人頭大。那時候多麼需要有個像事件監聽形式的eventbus那樣的東西,我只需要把資料放到bus裡面,然後這個頁面的任何一個地方都能很方便的獲取。總結一下:直白點說就是頁面block/fragment之間需要使用對方的據/view時,無需之間硬性的取用,只需要activity的context引數就可以獲取對方的資料/view,從而進行資料交流、view訪問。而頁面的context是系統型別且是很容易獲取的,並不存在耦合。


具體使用可以參考我之前寫的一篇文章使用ViewModel共享頁面內的資料:ActivityDataBus

– 4.5.1 為什麼要去掉eventbus,使用廣播

如果已經到了這一步,那麼大體上一個頁面已經抽離出來了,剩下的是與其他模塊、其他頁面間的互動了。


前面說了serviceloader和路由方式都沒辦法做這些事情。我們首先想到的是使用eventbus來做這些事情。使用eventbus的前提是,需要定義一些Event事件。比如:

但如果你將業務代碼各自模塊化之後,就有一個尷尬的問題擺在面前:Event事件放在哪裡?因為很多庫都需要收聽這個Event事件,所以只能將Event下沉到基礎庫。這樣導致的結果是基礎庫越來越大,還無法拆分。關於這點《微信Android模塊化架構重構實踐》也提到了這件事情,並且自創性的使用了一種叫“.api”化的方式來解決這件事情。原理是在編譯期將公用接口下沉到基礎庫,供其他module使用,而這段代碼的維護仍然放到非基礎庫中。這種base庫不會膨脹,代碼維護的責任制更明確,確定挺不錯。可惜最近沒有那麼多時間來寫這個gradle插件了。不知道哪個讀者有時間和興趣可以實現這個插件。意義還是很大的,基礎庫的代碼不會越來越膨脹。eventbus除了使基礎庫膨脹之外,還有一個問題是,不能進行app間的行程通信。我們使用廣播來取代eventbus。android推出的LocalBroadcast實現機制簡單來說是looper-handler並維護一個全域性的map。性能上和eventbus類似,使用字串而不是Event model來匹配事件。我們如果使用一個接口來包裝BroadcastManager,那麼我們在app內部可以使用域內廣播進行,對於模塊化後的lib,我們可以使用域外廣播來進行app間的通信。

– 4.5.2 不要亂髮廣播

如果你專案中大量的使用eventbus,那麼你會看到一個類中有大量的onEventMainThread()方法,寫起來很爽,閱讀起來很痛苦。如果專案中發送這個Event的地方非常多,接收這個Event的地方也很多。在進行代碼拆分時,你都不敢輕舉妄動,生怕哪些事件沒有被接收。廣播和eventbus類似,如果專案中存在同一事件的大量發送和接收,那麼專案的可讀性和可維護性就會變得相當差。這種情況在敏感資料的同步問題上尤為突出:

其實對於敏感資料的同步,不需要發送廣播或eventbus來進行同步。可以借助資料庫將想看資料本地化來完成同步。大體的思想就是我們從網絡中獲取的資料都同步到資料庫。在進行敏感資料填充view時,採用的資料都來自資料庫。在頁面傳回時,如果頁面不觸發填充敏感資料view的邏輯,那麼在onResume()手動呼叫,即:

那麼模塊間/頁面間通信大體的就講完了,這裡需要做的工作不多:

4.6 lib獨立運行
– 4.6.1 為什麼需要與宿主app行程通信

到這一步,一個業務模塊既可以作為library放在宿主app中,也可以作為application獨立運行了。作為library很容易理解,和文章前面的問答模塊闡述的一樣,做宿主app中添加幾個activty的殼子,然後添加上lib中的page,然後在manifest中註冊即可,即:

當然如果還需要做一些actionBar的交互,需要在宿主activty中寫入相應的邏輯。整個app的框架圖如:

當業務lib需要除錯時,我們需要讓這個lib獨立運行,就如同文章前面的問答業務模塊demo所示。那這時候就有一個問題,我們lib獨立運行時,賬戶的資料從何而來,和app相關的地理位置,城市等等這些資料怎麼得到?讀者可能會說這些不是服務嗎?服務的話,不應該和網絡加載,圖片加載的服務一樣使用serviceloader加載嗎?按道理講是這樣的,但賬戶等一些信息的服務實現類並不是那麼容易從宿主app中抽離出來,因為那麼服務實現類需要application中進行初始化,還要考慮很多其他東西。所以真實的賬戶信息並不那麼容易通過以前的那種方式獲取,那怎麼辦呢?最簡單的辦法是製作假資料,比如造一個我自己賬戶的信息,作為服務實現類使用。但這樣的話,賬戶信息只能是一個人的,對賬戶信息的修改不可行,賬戶也不能退出登錄。所以還得想新的辦法。

– 4.6.2 與宿主app行程通信過程

最後發現如果我們獨立運行的lib能夠監聽宿主app的賬戶,位置,城市,登錄型別,設備等信息並能夠進行同步,那麼獨立運行的lib中的這些信息就都是真實信息了,並且是動態的。當宿主app退出登錄,lib中也是無登錄狀態。具體的操作是:

  • 在宿主app中,我們提供一些contentProvider,各方法提供的內容就是宿主app真實的的賬戶等資料。當對宿主app賬戶等信息改變時,通知contentProvider的監聽者,比如:

public void onEventMainThread(LoginEvent loginEvent{
getContext().getContentResolver().notifyChange(Uri.parse(“content://com.maoyan.android.runenv/loginsession”),null);
}


  • 在獨立app中,其扮演contentProvider的監聽者:

mContentResolver.registerContentObserver(Uri.parse(“content://com.maoyan.android.runenv/devicesession”), falsenew ContentObserver(null) {
@Override
public void onChange(boolean selfChange) {
super.onChange(selfChange);
reloadEnviroment();
}
});


這樣的話,lib中賬戶等資料就和宿主app的資料保持一致了。我們使用服務接口包一層,這樣使用方式和之前的服務使用方式就一致了。


大體的示意圖如:

當宿主app退出登錄,lib中也是無登錄狀態,我們看下demo:

最後,按照慣例,當一個模塊要獨立運行時,需要做的事情評估:

5、最後的話

整個流程終於結束了,希望讀者看完後,對模塊化有個整體的認識,對每一步需要做什麼,耗時多少都有個大致的瞭解。進行模塊化並不是為了炫技,表明自己多厲害。如果只是這樣,那大可不必這麼做。因為模塊化是一個繁瑣,枯燥,耗費時間長,你做了大量的工作,但是在錶面功能上,老闆們可能看不到。還不如花一點時間,引入一個第三方庫看著花哨。很大一部分工作量是為以前欠設計的代碼邏輯買單。我做這件事件也是為了業務服務,因為貓眼電影需要服務的客戶端不少。所示做業務解耦,業務進行模塊化是必然的事情。通過模塊化後,可以很方便的將代碼移植到其他端,app內頁面的調整也變得簡單。最後的最後,在整個模塊化的過程中,有一些經驗感悟可以分享給大家,道理都很簡單,更重要的是落實:

6、參考資料

  • 微信Android模塊化架構重構實踐

  • 開源最佳實踐:Android平臺頁面路由框架ARouter

  • Android組件化之通信

  • ServiceLoader

  • AutoService

  • Android-Architecture-Components

  • 關於Android業務組件化的一些思考

 

原創作者:陳文超,美團貓眼Android開發工程師,擅長組件化,專案解耦,跟隨技術前沿,歡迎大家,點擊【閱讀原文】,關註他

    已同步到看一看
    赞(0)

    分享創造快樂