人人車Android客戶端架構演進實錄

前言

對於大多數創業公司而言, 初版開發時採用的簡單架構,在歷經數次快速迭代後,已經成爲了一個”大泥球”(源於Brian Footer和Joseph Yonder的論文《大泥球》, 定義: 一大片隨意構造,雜亂無章,凌亂,任意拼接,毫無頭緒的代碼叢林), 如下問題存在於當前的架構中:

  1. 業務邏輯混雜在平臺實體中,造就了代碼量龐大的Activity和Fragment。
  2. 本應是全局級別獨立存在的功能模塊,卻被封鎖在某個特定視圖領域內,生命週期和模塊層級規劃不當。
  3. 功能拆分不夠細緻和代碼重複,在模塊和函數級別均有體現。
  4. 部分模塊之間高度耦合,使用沒有經過合理規劃的接口來實現通信。
  5. 部分第三方庫沒有做隔離,造成和第三方庫的高度耦合,還存在庫的誤用和濫用。
  6. 分層化缺失,領域邊界基本不存在,比如數據層和業務層共享同一套數據結構。
  7. 缺乏內部規範,部分概念或實體的表述混亂零碎,作爲workaround的謎之代碼比較多。

總之,初始搭建的架構已經不足以支撐長期的持續性開發,泥球最終會把開發者推入代碼深淵。重構是必須的,但是從哪裏開始需要好好考慮。

筆者的觀點是,對於大中型項目,短時間內不太可能建立起對項目需求和現行邏輯的大局觀,並且在重構的同時還要保證項目的及時發佈(想想那個經典的比喻,爲一輛高速奔馳的汽車換輪胎),那麼從次級業務模塊進行改進和重構是一條穩妥之路,在重構的同時一點一點的豐富細節認知和整體佈局,最終倒逼整體架構的變革。

下面分爲兩個階段對人人車Android客戶端架構演進的方案和階段進行闡述:

第一階段演進

1. 業務視圖模塊

就人人車App來說,重構前,所有的子業務邏輯都寫在頁面載體中,從數據提取,視圖配置到交互都混雜在一起,每個頁面都是泥球,導致作爲頁面載體的Activity和Fragment體量極爲龐大,代碼的閱讀和後續修改都很費勁。

上述問題在結構設計層面上體現爲:邊界缺失。每種子業務,都應該有邊界,有了邊界纔有所謂的內聚性,才能區分外部使用者和內部實現者。

既然是因爲邊界缺失導致了結構問題,那麼增加邊界即可,業務視圖模塊是我們採用的解決方案,業務功能級視圖模塊的實現很簡單,可以理解爲代碼的類級別隔離帶來了邊界,進而從頁面載體(Activity/Fragment)剝離了業務。


上圖左半部分是人人車App的車輛詳情頁,可以看到,詳情頁的不同視圖部分對應到了不同的Module(注意,這裏僅僅是一個示例性質的劃分),每個Module負責了不同部分的視圖配置和業務交互(有些情況下,甚至視圖生成也是由Module負責的)。頁面上通常承載複數項子業務,以子業務爲維度進行劃分視圖層,就得到複數個的視圖切片,於是在視圖層和業務邏輯層之間產生了以子業務爲粒度的映射,業務視圖模塊封裝了這類映射,每個模塊封裝了特定子業務的視圖切片配置和業務邏輯,如上圖一般,詳情頁的各個子業務被不同的模塊承包。

視圖模塊本身還可以再進行拆分,如果有邏輯再分層需要的話,比如Module A在內部又將功能分包給了SubModule A和SubModule B。業務視圖模塊的另外一個特點是不依賴於視圖的真正的佈局細節,比如上圖的Module F, 其負責了車輛詳情頁的對比子功能,那麼,車輛標題右邊的增加對比按鈕和懸浮的對比按鈕就都是其應該管轄的視圖片段,而不會意味兩者處於不同的佈局層就分模塊處理,這也反映了視圖模塊拆分以業務爲粒度的原則。

業務視圖模塊以類似於插件的形式和Activity/Fragment進行聯動,Activity/Fragment本身不再承載任何視圖業務邏輯,僅僅需要維護內部寄生的模塊,同時針對Android平臺本身的Activity/Fragment生命週期特性,Activity/Fragment還需要負責將自己的生命週期回調分發給相應的模塊,以保證某些依賴平臺生命週期特性的業務可以被封裝在模塊內。Activity/Fragment的職責退化爲模塊容器和數據媒介,數據媒介的作用體現在,Activity/Fragment在某些情況下會扮演頁面數據的入口和分發者,如上圖下方所示,Data到達Activity後,Activity要將Data再次分發給內部那些真正需要數據的模塊。

總的來看業務視圖模塊承擔了兩個職責:

  1. 是對外封裝業務本身的實現,包括視圖配置和業務邏輯。
  2. 對內模擬了寄生容器的某些特性(這裏是生命週期),從而使得依賴容器特性的業務也得以運行。

視圖業務模塊顯得簡單直白,但是其效果和收益相當可觀,具有結構性意義,因爲這一步劃分了邊界,邊界促進了頁面結構的明晰化和職責的封裝拆分,同時限制了代碼的污染性溢出,有可能模塊內部的實現還是以前的醜陋代碼,但這時候其已經退化爲一個小泥球,在邊界的約束力下對架構本身的影響力被限制在最小。同時,以插件思路設計的模塊也能實現一定程度的代碼複用(但這一點不是模塊化的目的,因爲業務級別的複用非常依賴產品和設計,可以看作是錦上添花)。

另外,模塊化帶來的副作用就是通信的問題,模塊化帶來的邊界割裂了一體化,需要額外的通信手段,這裏我們增加一個Mediator來實現了模塊之間的通信。實際上,一個良好的業務體系,子業務之間不應該有太多的通信,所以Mediator的複雜度和方法數並不會爲因爲模塊數量的增加而指數級增長。

2. 數據邊界

數據邊界的宗旨是隔離外部原始數據和內部業務數據,在數據結構的維度實現邊界和職責劃分,以此來緩衝外部污染數據對內部業務邏輯的衝擊力。

我們應該有這樣的認識:外部原始數據永遠是不可靠的。對於內層業務邏輯來說,外部數據來源有很多種,網絡,文件,數據庫等等,這些數據來源都是不可靠的,網絡不穩定,服務器故障,磁盤文件被誤寫都是不可避免的。如果任由這部分污染數據直接傳遞到業務層邏輯,業務層邏輯在沒有足夠防禦的情況下就會運轉失常甚至於崩潰。退一步講,即使業務層邏輯做了足夠的防禦,可以抑制污染數據的破壞力,從職責劃分上講已經出現了問題: 業務邏輯承擔了它不應該承擔的數據防禦職責。另一個設計上的缺陷是:業務邏輯直接套用了外部原始數據的數據載體,這樣導致業務層數據處理邏輯和外部原始數據的格式等信息發生了強耦合,在數據表示層兩者沒有得到區分,這其實是一個在數據結構上職責沒有劃分的問題。

上述設計缺陷的解決方案最終形成了數據邊界。解決方案有這麼幾個要點:

  1. 數據校驗的職責從業務邏輯中剝離,形成單獨的職能模塊。
  2. 外部原始數據的數據載體要和業務邏輯的數據載體做到完全隔離。
  3. 所有的外部數據入口要得到有效的控制。

上述幾點互相關聯:只有控制了所有外部數據入口,才能對所有外部數據做校驗,而數據載體隔離需要的數據轉換也需要依託對所有外部數據入口的控制,數據校驗可以作爲數據轉換的前置步驟。數據邊界的示意圖如下:


每一個外部數據入口都有一道關卡: Data Mapper, 這就是上面第一點提到的獨立職能模塊,Data Mapper扮演外部數據運輸到內部的關卡,Data Mapper承載了兩個職責: 數據校驗和數據轉換(因爲這兩個職責本身是前後關聯的,沒有必要再次進行模塊級的拆分)

  1. 數據校驗: 按照業務特性或者規範約定,對外部原始數據進行一次全面排查:如果數據在整體上已經被污染,那麼該數據會被徹底放棄,但會被轉換爲空數據或者約定好的無效數據(比如Java中的null)傳遞給業務層(數據到達本身也是一個信息,需要業務層知曉。比如加載數據會顯示加載中界面,即使返回了污染數據,也是需要停止顯示加載中界面而顯示空頁面或者錯誤頁面的,如果直接忽略此次數據的到達,那麼就會一直顯示加載中)如果數據只是部分污染,那麼會嘗試剔除被污染的部分,以實現力所能及的信息傳遞。總結來說,數據校驗將數據洗白,保證數據的安全性,至於數據的功能性則由數據轉換來保證。
  2. 數據轉換: 相對數據校驗要複雜一些,需要根據外部原始數據組裝出可用的業務層數據,除了單純的數據結構調整外,還有可能會有數據裁剪,數據拼接等複合操作。數據轉換還有一個作用是保持整體業務數據的規範性,比如對於車輛價格這類數據,在業務層面會對有一個精度方面的規範約束(最多兩位小數), 如果得到的數據沒有遵循這個規範,我們稱其爲違規數據,違規數據和污染數據是有區別的,前者可以在數據轉換中得到矯正,從而保證了傳遞給業務層的數據的規範性。

如上圖中Data Mapper示意圖所示: 通過數據校驗和數據轉換的協力,各種各樣正常的,殘缺的,污染的,違規的外部原始數據最終被轉化爲安全的,規範的,可用的業務層數據。數據邊界猶如一個數據沙盒,在業務層來看,它感知到的是一個絕對可靠的業務數據環境。

數據邊界的思想內核其實是分層化,職責下放到不同的層,每一層向上一層提供特定的服務,上一層不必關心下面的細節,每一層功能和職責專一化。

3. 引入RxJava

先談一下筆者對第三方庫的使用觀點:

  1. 謹慎,剋制的引入新庫,加法容易減法難,庫越多,衝突和妥協就越多,開發和發佈成本也會增加。
  2. 隨着業務本身的不斷演化,基於通用性目的設計的庫遲早會和特定業務需求之間產生衝突。
  3. 組件級的庫要比框架級的庫安全。因爲前者只封裝了單點功能,後者則封裝了流程。
  4. 在引入或者替換庫前要清楚你的需求和要付出的代價,不要追逐風潮,人云亦云。

經過對響應式編程的學習理解和RxJava庫的源碼解析,我們引入了RxJava以期以下收益:

  1. Observable實現了回調方式的歸一化。
  2. Operator讓回調的傳遞和處理靈活而富有組合性。
  3. 線程調度非常方便。

從實際應用上講,RxJava基於信息流模型對一些常見操作和場景進行了模擬封裝,使得開發快捷,實現優雅,如:

  1. Observable模擬了一條單向信道。
  2. Subject模擬了一個自發信息源。
  3. BehaviorSubject作爲Subject的擴展模擬了一個會緩存上次發出信息的信息源。
  4. Map模擬了形態切換。
  5. FlatMap模擬了複雜結構信息的降維。
  6. Merge模擬了信息流的合併。
  7. CombineLatest和Zip模擬了信息流的同步。
  8. 等等不再贅述。

上述功能和特性在我們的整個開發和重構過程中發揮了很大的助力,讓實現更加簡潔優雅。本文在這裏不再做更多展開,在後面的重構項目中會穿插介紹對Rxjava特性或功能的使用。

4. 數據源和數據隧道

數據源和數據隧道是我們對提取數據-刷新頁面這個常規流程的重新建模,以適應人人車App的頁面刷新場景: 人人車App是一個重呈現的App,比如“我要買車”頁面,用戶在提交篩選條件後,需要從服務器獲取數據然後刷新頁面,將新的數據呈現給用戶,顯然,這個頁面會有非常頻繁的提取數據-刷新頁面行爲。


上圖上半部分描述了之前我們對 提取數據-刷新頁面 這一流程的建模: Module(界面)向Data Fetcher發起fetch調用,在調用中要傳遞一個回調,在Data Fetcher獲取新數據以後,執行回調來觸發Module刷新界面。這個模型在初期可以正常工作,不過在使用上已經暴露了其笨重的地方,每次想要請求新數據時,都必須攜帶一個回調,每次的請求數據和刷新都是一次性行爲,使用起來比較麻煩。到了中期,新的需求出現了:在本Module之外的其他操作(比如用戶在其他Module的行爲)也要能觸發該頁面的 提取數據-刷新頁面 流程,很顯然,當前的one-shot模型必不能很優雅的實現這個需求。

示意圖下半部分描述了我們對 提取數據-刷新頁面 的重新建模:

  1. 引入數據源(Data Source),數據源和Data Fetcher的區別在於,數據源是獨立存在的主動數據提供者,Data Fetcher則只能算是一個功能性的包裝。數據源獨立存在的意義在於將其從頁面Module的功能性附屬(Data Fetcher其實就是一個功能性附屬,只提供了靜態級功能,沒有自己的核)中剝離爲單獨的實體,從此數據是數據,頁面是頁面。數據源作爲主動數據提供者體現在它可以提供數據隧道給使用者。
  2. 引入數據隧道(Data Tunnel), 數據隧道本質上講很簡單,就是一個固定的觀察者罷了,但是從設計角度看,它有截然不同的意義。它將原來主動“拉”新數據轉換爲了數據源“推”新數據過來,這樣就可以滿足我們在上面遇到的新需求了,頁面只需要建立一條到數據源的數據隧道,在外界觸發了數據源的更新後,數據源會主動的將新數據通過數據隧道推到頁面。上圖展示了這個流程以及其擴展: 一個數據源可以同複數個使用者建立數據隧道,只要數據源更新了數據(注意,這個數據源本身不具有自主更新的能力,需要外界來觸發,比如上圖的Module或者Trigger)就會將新數據推送到所有的數據對端。使用者不再需要數據隧道時可以方便的進行拆卸銷燬。

數據源-數據隧道模型建立得益於RxJava的BehaviorSubject,實現的非常優雅,並且還解決了一個數據界面同步的問題,比如會有這樣的場景:

頁面啓動和數據到來之間沒有固定的先後順序,頁面啓動完畢後需要數據,如果數據之前沒有到來,那麼頁面等待數據到來即可,但是如果之前數據已經到來,鑑於數據到來時頁面還沒有啓動完畢,感知不到數據到來,所以需要數據源能夠緩存上一次成功發出的數據,BehaviorSubject的特性完美的契合了這個場景: BehaviorSubject內部會緩存消息流的最近一個條息, 在後續有Subscriber訂閱時,會直接將緩存的消息投遞給Subscriber。另外RxJava的onTerminateDetach優雅的處理了銷燬數據隧道時的內存泄漏風險。

數據源-數據隧道模型化被動爲主動,適應需求的同時也在架構上反映了數據層獨立的趨勢。

5. 數據源的分層和組合

該重構是上一步數據源模型的延伸擴展,在數據層進行的一次增強。

考慮這樣的應用場景:數據源A提供的數據被模塊B使用,數據源C提供的數據被模塊D使用,有了新的需求導致數據源C的部分數據替換爲數據源A提供的數據,這個時候有兩種需求實現思路:

  1. 模塊D直接使用數據源A和數據源C,但這樣必然導致模塊D邏輯代碼的改動,除了要引入數據源A之外,還需要對原先處理數據的邏輯進行修改來適應這個變化。和我們希望的最理想方案有差距:理想方案是是模塊D不需要修改,因爲從本質上講,模塊D的功能在新需求中沒有任何變化,這次的變化是一個數據層的變化,不應該讓其影響數據層之上的模塊,模塊不應該承擔數據整合的職責。
  2. 數據源A和數據源C進行組合,數據源C作爲和模塊D對口的數據源不變,這樣就可以保證模塊D的數據提取邏輯不變,爲了適應新的數據需求,數據源C化身爲數據源A的一個使用者,和數據源A建立數據通道後,就能獲取數據源A的數據以及對A數據變化的感知,有了A的數據,就可以在內部使用A的數據替換自己內部要被替換的部分,經過聚合的數據就是滿足這次需求的數據。

顯然第二種方式在結構和職責上更加合理,而這種對數據源A和C的組合應用就是是數據源功能擴展的一個例子。


剛纔的例子展示了數據源之間組合的靈活性,出於內部規範的要求,我們對數據源進行了一次概括性分層,如上圖所示分爲兩層,業務級數據源和單點數據源:

  1. 單點級數據源和服務器接口等外部數據提供者一一對應,功能是最純粹的,就是外部數據提供者在架構中的化身封裝,只提供單一類型的數據,如上圖的Data Source A,B,C,D。
  2. 業務級數據源一般自己沒有產生數據的能力,其作用主要體現在對單點級數據源的聚合和管理上,只對接業務級模塊。該類數據源的引入是作爲一箇中間層來抹平實際數據和業務數據需求之間的溝壑,如上圖的Data Source E,F, 兩者的數據生成依賴於Data Source A, B, C,但是A, B, C的數據也需要經過E, F的適配才能滿足Module的數據需求。

數據源組合和涉及到一個數據同步的問題: 以Data Source F爲例,F依賴於Data Source A和B的數據,F只有在A和B的數據都就緒的情況下,才能構造完整的業務數據進行投遞,具體的細節如上圖下半部分所示:在A和B的數據沒有全部到位時,F不會發送數據 ,後續A和B有任何數據更新,F也需要同步的刷新數據併發送(該圖參考了ReactX的CombineLatest示意圖),得益於RxJava的CombineLatest功能,數據同步的實現極爲簡便。業務層數據源對業務模塊屏蔽了數據源聚合的細節,作爲中間層出色的完成了任務。

分層和組合還有一些要點是,業務級數據源之間可以也可以組合以適應需求,前面所說的兩層是一個概念上的分層,實際實現中可以嵌套多層,一個業務級數據源在更上一層來看也可以認爲是一個單點級數據源,只要在數據的分佈上合理即可。另外業務模塊不強制使用業務級數據源,因爲某些業務所需要的數據一個單點數據源足以覆蓋,沒有必要引入多餘層,如上圖的Module D就直接使用了單點數據源 D。

數據源的分層和組合解決了實際服務端接口提供的數據和業務場景需要的數據之間的矛盾,可能會覺得這和數據邊界的Data Mapper功能重疊,但其實兩者處於不同的數據層次,業務級數據源的數據整合生成可以被認爲是數據源內部的數據整合,而Data Mapper則是數據源外部的下游數據加工者。

6. 通用功能的聚合

通用功能的聚合是重構之路的必經階段,將一個通用功能原來零散分佈在代碼中的實現全部聚合提取爲單獨的功能性模塊,是一次功能級邊界的確立,下圖是一個簡單示意:


人人車App的通用功能聚合涉及的功能較多,比如車輛篩選,砍價,預約等特定業務功能。這裏大致總結下聚合的思路和手法: 全局的歸全局,局部的歸局部,功能聚合爲功能模塊。

功能聚合之前的現狀是: 一些功能在實現時侷限於產品設計,被限制在特定的頁面子功能中,比如車輛篩選被限制在“我要買車”頁中,砍價被限制在“車輛詳情頁”中,究其原因,是因爲這個功能在提出時只存在於某個頁面中,這樣在實現時也不由的被限制了思路。但是,以砍價功能爲例,從領域劃分上講,砍價功能和“車輛詳情頁”不在一個領域內,兩者之間只有簡單的使用關係罷了,從變化上講,砍價功能和“車輛詳情頁”不是緊耦合的,在其他可以提供足夠信息的頁面,砍價功能理論上都可以被加上(後面果然在其他的頁面增加了此功能)。因此砍價功能本身聚合爲一個功能性模塊是由必要的,在代碼上避免了代碼重複,也有了自己的領域。

上述思路是進行功能聚合的一個指導思想,在被提升爲全局功能模塊後,使用者只需創建功能模塊,然後使用即可,不過對於一些依賴於Android生命週期的功能,使用者還需要保證生命週期回調。

通用功能聚合和前面的業務視圖模塊類似,兩者的思路一致,不同的是處於的領域和獨立性。

第一階段演進總結

  1. 業務級頁面得益於業務視圖模塊,在內部細節層面已經變的邊界分明,結構清晰,部分視圖模塊得到了高度複用。
  2. 數據邊界使得外界非法數據對內部邏輯基本不能造成影響,數據魯棒性得到了提高,並且進一步的業務數據體系和外部數據體系實現了完全隔離,儘管有所冗餘,但是卻爲了兩端數據結構的靈活變化留夠了餘地,數據預處理和規範化職責也被明確的抽取到專門的實體,數據層內部的邊界明確,功能粒度細化。
  3. 數據源及其擴展初步構建了獨立的數據層,新的 提取數據-刷新頁面 模型很好的適應了數據頁面之間新的聯動需求,數據源之間的靈活組合也提供了客戶端對服務端接口變化的良好適應力。
  4. 通用功能聚合,減少代碼重複,加快了項目的開發,確立了通用功能的層次和邊界。

總結: 第一階段的重構偏向於模塊化和層次化,多是對一些次級領域進行改進。

第二階段演進

7. 錨點系統

錨點系統引入的初衷,是爲了得到當前顯示在前臺的Activity,充其量是一個Activity全局信息維護系統,不過隨着後續的持續強化擴展,潛力被慢慢發掘,最終演化爲骨幹級的系統框架。先闡述一下錨點的概念: Android的特性決定了大多數視圖相關操作都需要Activity的介入,比如顯示一個對話框,必須要提供一個Activity才能進行顯示,Activity的角色就是錨點,錨定了上下文,通過錨點就可以獲得需要的上下文信息,從而進行基於上下文的操作。

Android中有很常見的異步操作場景,該異步操作在執行過程中會需要一個Activity,常規的思路就是讓異步操作持有Activity,不過限於異步操作會導致activity內存延遲釋放甚至泄露,需要使用一定手段來進行規避,MVP,弱引用等都是解決方案。不過MVP在使用上不夠靈活,弱引用則不夠優雅,因此引入了錨點系統來提供一個更好的解決方案。

錨點系統的思路是在系統內部通過獨一無二的輕量級標識(一個數值類標識, PageId)來對應和識別Activity/Fragment等系統界面組件。外界使用者對界面組件的引用使用輕量級標識來避免直接引用Activity/Fragment。好比每個Activity/Fragment都會在錨點系統內登記,向錨點系統提供其特殊的標識,外界通過此標識藉助錨點系統即可獲得對應的Activity/Fragment實體,另外,鑑於Activity/Fragment擁有自己的生命週期,因此Activity/Fragment在自己的生命週期回調中都需要通知錨點系統,錨點系統根據中這些回調動態增刪添改內部維護的實體信息,外部如果使用一個已經銷燬的界面組件的標識會被告知標識已經無效。

我們在規劃錨點系統時,除了賦予其上面描述的頁面組件標識功能外,還通過制定Activity/Fragment頁面規範來支撐了錨點系統的當前展示頁面信息:


先引入PageView接口,作爲業務級頁面的表徵,如上圖右下角的人人車App“車輛詳情頁”: Android的視圖展現一般都是採用Activity或者Activity內部使用Fragment來實現,Activity/Fragment各代表了不同層的業務頁面,在這個例子中,Fragment D代表的是“車輛詳情頁”PageView,但是車輛詳情頁本身有可以被細分爲不同的領域,Fragment F代表的是“車輛詳情頁”的“車輛參數”PageView,Activity C本身只作爲Fragment的載體,是一個“無形”的PageView。在上圖中,當前“呈現”的PageView是“車輛參數”。

錨點系統的另一個功能就是可以自動維護當前呈現的PageView信息,會對外提供一個查詢接口來返回當前呈現的是哪個PageView,以及其對應的業務頁面的類型,這個功能可以簡化一些依賴當前展示界面類型的功能的實現,比如,在按鈕點擊的統計參數上報中需要增加當前處於哪個頁面,如果沒有錨點系統提供獨立的查詢服務,就要在在每個按鈕點擊上報的邏輯寫死按鈕處於的頁面類型,而藉助於錨點系統,只需要在提交上報請求時統一補充上當前頁面類型即可,因爲按鈕點擊上報時,當前呈現的頁面一定是按鈕所在的頁面。

以上圖來系統性的說明錨點系統如何運作,其描述了這樣的場景:

用戶退出Activity A進入Activity B(Activity B內部則包含了Fragment A,E),在從Activity B進入了Activity C(包含了Fragment C,D,F,G),用戶繼續進行操作,馬上會進入Activity D。

首先上面的Activity/Fragment都以PageView的形態(即實現了PageView接口)註冊在錨點系統中,在Activity/Fragment創建和銷燬時會自動登記和釋放。除了創建和銷燬,其他的界面組件生命週期回調也會被通告給錨點系統,錨點系統根據這些變化維護當前哪個PageView是處於前臺的。如上圖所示的那樣,Activity B以及其內部包含的Fragment都被切到了後臺,處於凍結狀態。

此時在前臺的是Activity C和其包含的Fragment,但僅僅知道這一步不足以細化到業務頁面層面,還需要在Activity C和Fragment集合中尋找真正在前臺的PageView,錨點系統內部將Activity/Fragment按照預設的層級進行了分層管理,Activity一般會承載Fragment, 那麼如果同時存在前臺Activity和Fragment的話,前臺Fragment代表的纔是真正呈現的PageView(比如上圖,Activity C雖然處於運行狀態,但是不算前臺PageView), 再進一步,Fragment之間也有層級劃分,如上圖的Fragment F和Fragment D, Fragment D的層級最深,也是展示在最前的頁面,那麼Fragment D就勝過了Fragment F成爲了真正的前臺PageView,直到即將到來的Activity D切到前臺爲止。

錨點系統還提供了對全局頁面狀態變化的監聽服務,任何實體都可以向其註冊來感知所有頁面的狀態變化。

錨點系統的前提是所有頁面級的Activity/Fragment都遵循PageView接口規範,這個統一性的要求其實不難,因爲一般Android開發時,都會繼承原生的Activity/Fragment生成項目使用的BaseActivity/BaseFragment,集中控制已經提前做好了,PageView只需在這裏實現即可。

8. 網絡概念層

網絡通信是一個APP的基礎需求,除去少數特例,實際項目開發都會使用成熟的第三方網絡庫來着構建自己的網絡功能實現,我們項目中使用的網絡庫是Volley,人人車App對於網絡性能指標沒有非常的要求,功能夠用即可。

在重構網絡層之前,項目對Volley做的是相對簡單的功能層封裝,即提供一個函數可以根據給出的請求相關參數構造一個Volley Request,並投遞給Volley庫來發起請求,這個做法在項目初期並沒有什麼太大的問題。

但是隨着功能需求的演進,除了單純的網絡通信功能外,項目還增加了很多作用於網絡請求本身的需求,比如,對網絡請求或者回復的附加處理(Request Processor),網絡認證模塊(Authenticate)會要求攔截認證失敗的請求並自動重發等,隨着這些需求的疊加,項目代碼和Volley產生了緊耦合,因爲我們需要基於Volley提供的各種類或函數來發起請求和實現其他附加網絡業務需求。


上圖上半部是重構前的網絡層架構,可以看到,一些業務需求的實現邏輯已經部分或者全部的位於了Volley的領域。這個情況初看是不可避免的,因爲你要使用一個庫,必然要使用其提供的類或者函數。但是,對網絡通信這種底層服務機制來說,現在的情形是底層具體實現(Volley)綁架了上層(項目代碼),上層需要根據Volley的類和功能來實現網絡請求處理邏輯,兩者之間正確的關係應該是底層遵循依賴上層提供的需求接口來適配自己的功能。

從一個更抽象的維度看,項目邏輯需要一個“概念”上的網絡層,這個”概念”網絡層的接口和結構均由上層按照自己對網絡通信的需求和理解制定,下層的具體實現反過來則需要遵循或者適配這套上層“協議”。

基於上述思路,引入網絡概念層,新的網絡層架構如上圖下半部分所示: Custom Request是上層對網絡請求這一概念的封裝和落地,成爲項目代碼中網絡請求的唯一表現形式和載體,第三方庫提供的具體請求類型(有的庫甚至連請求類都沒有)被隔離到最底層,只在Sender(負責對接第三方庫的適配者)的適配實現中可見。Custom Request成爲整體架構中的一個對外網關協議,所有的網絡請求在內部均以Custom Request的形式創建和維護,最後由當前的Sender實現來翻譯/適配爲對應網絡庫的網絡請求對象。

Custom Request採取注入式的方式和第三方庫的具體請求對象協作,本身只提供對請求相關信息的查詢(比如請求的地址,參數等)和回調接口,不會維持對第三方具體實現的引用,也感知不到第三方庫。第三方網絡請求會維護一個到通用Request的單向引用,以擴展的方式來回調驅動Custom Request,然後Custom Request進一步的將回調消息通過Network Callback反饋給上層使用者,組成了一條單向底層網絡回調反饋鏈條。Custom Request的主要職責是網絡請求信息的載體,基本不包含邏輯,像Authenticate之類網絡擴展功能的實現會放在Interceptor中。

Interceptor負責攔截Custom Request的各個回調點,並將回調廣播給Interceptor內部的所有的Processor,每個附加功能對應一個Processor。Processor在合適的回調點對請求和回覆進行攔截處理來實現自己負責的功能,同以前基本所有的處理邏輯直接混雜在Volley Request內部相比,實現了職責分離。更關鍵的是: 所有網絡附加處理邏輯現在都作用於Custom Request上,而不是Volley Request上,處理邏輯徹底和Volley劃清了界限。

網絡概念層的另一個收益是加強了對網絡的控制,因爲Custom Request處於項目領域內,每個發出的Custom Request完全可以被項目代碼進行維護和管理(如果還使用Volley Request的話,第三方庫是黑盒實現,很難說外部維護一個Volley Request沒有潛在風險,這還會導致之前所述的業務邏輯和Volley產生了耦合),對網絡請求狀態就有一個大局觀,比如當前有哪些請求正在進行中以及處於何種狀態等。

網絡概念層對第三方網絡庫也有功能延展性,畢竟是作爲中間層存在。比如網絡庫本身不支持取消請求的話,網絡概念層完全可以擴展Custom Request來增加一個canceled標記,上層想取消請求時設置此標記,儘管底層網絡庫的請求不能被取消,最終還是會回調到Custom Request,但是Custom Request完全可以檢查canceled標記來將此回調忽略不向上傳遞,那麼在上層來看,這個請求就是被成功取消的,更多的擴展應用限於篇幅,不再敘述。

總結來說,我們自己制定了一套網絡層規範和流程,內部以這套準則來操作和管理網絡請求,外部(第三方網絡庫)需要通過適配來提供底層運作。

9. 功能模塊進化爲功能服務

在功能模塊化推進到一定程度後,開發效率確實得到了提升,新頁面需要添加以前的功能時直接使用功能模塊即可。不過在使用中還存在一些痛點:

  1. 使用者需要自己創建和維護功能模塊,並將所在的Activity/Fragment生命週期變化傳遞給模塊。
  2. 某些模塊在創建時還需要提供Activity/Fragment作爲基點,這樣就限制了模塊使用的靈活性,在Activity本身承載的業務被分拆爲不同的業務視圖模塊後,業務視圖模塊如果想要使用功能模塊,必須也能提供基點,這爲業務視圖模塊增加了額外的需求。

因此考慮將功能模塊升級爲功能服務來緩解上述痛點,這裏對功能服務的定義是: 不需要使用者顯式的去創建服務以及維護服務狀態,只需要單純的使用其提供的功能即可。就像一個靜態函數一樣。

上面描述的功能服務將使用功能模塊的額外開銷封裝在自己內部,使用者的職責得到簡化。藉助於之前介紹的錨點系統,這個構想是在一定程度上可以實現:

  1. 基於這樣一個前提: 絕大多數情況下,功能的發起都是由用戶在界面上觸發的,比如砍價,預約等。這意味着,在功能被觸發時,其所在的Activity/Fragment就是錨點系統維護的前臺PageView,那麼,功能性模塊所需要的基點通過查詢錨點系統即可輕鬆獲得(錨點系統是全局服務,可以在任何地方調用),功能模塊創建需要的基點獲取限制就不存在了。
  2. 功能模塊的創建和維護封裝在功能服務內部,功能模塊的創建則基於這樣的現狀: 一個功能即使在一個頁面模塊(Activity/Fragment)上有多個觸發點,功能模塊實例也只需要一個,功能模塊和頁面是一對一的。不過因爲功能服務會同時爲複數個頁面提供服務,那麼,在功能服務內部就需要同樣數量的功能模塊,功能模塊的索引正好通過頁面的PageId實現。另外爲了讓功能模塊可以感知生命週期變化,功能服務還通過錨點系統監聽了所有頁面的生命週期狀態,在收到頁面狀態變化時使用頁面PageId找到對應的功能模塊實例,繼而將變化傳遞給功能模塊。

至此,使用的功能模塊所有額外操作都被功能服務包辦了。外界使用功能服務變得極其快捷,只需要獲得功能服務的全局實例,調用服務接口即可。功能服務對功能模塊進行了包裝,功能實現的主體依然在功能模塊中,生命週期等管理則放在了功能服務包裝層中。

10. 全局網絡響應處理機制

全局網絡響應處理機制的其實是網絡層Interceptor中的一個Processor,在這裏專門列出來是因爲它是框架之間良性協作的成果,兩套單獨的機制基於不同的目的被開發出來,兩兩之間產生規模化效應,衍生出新的機制或者演化方向。全局網絡響應處理機制是網絡概念層和錨點系統配合的一個案例。

全局網絡響應處理機制現在只內置了一個功能: 全局的驗證碼彈層。驗證碼彈層是人人車App的基礎功能,所有的線索提交均有可能收到特定的回覆,要求輸入驗證碼後再重新提交,並且支持驗證碼的刷新(也需要重新發送請求)和驗證錯誤提示。彈層使用Android的Dialog實現。

驗證碼彈層的特殊之處在於: 用戶提交請求後得到服務器回覆要求輸入驗證碼,是一個異步網絡過程,但是Dialog需要Activity作基點。 在最初的實現裏,需要在回調對象中保存Activity的弱引用,回調對象中還包含了對驗證碼各種分支邏輯的處理,這種方式有幾個不足之處:

  1. 弱引用不夠優雅以及限制了使用場景(你需要一個能獲得Activity的場景)。
  2. 在每個可能觸發驗證碼的請求發起點,都要顯式的使用這個驗證碼處理回調類,遇到一些對響應有特殊處理的情況,還必須繼承這個回調類來保證驗證碼和特殊處理邏輯的兼顧。

上述缺陷從職責層面上講,是提交請求者承擔了驗證碼的處理職責。但是驗證碼機制和具體的請求響應處理之間是不應該有什麼關聯的,驗證碼是請求響應的一個全局前置步驟,在下游具體的請求響應處理不應該感知到驗證碼。

理想的驗證碼處理應該爲下游的請求回覆處理屏蔽掉驗證碼的存在,下游可以認爲這是一個不需要驗證碼的世界。要實現這個效果,必然要對所有網絡回覆進行前置攔截,網絡層 Interceptor正好提供這樣一個切面,驗證碼機制可以作爲其中的一個Processor存在。這樣驗證碼機制在架構中的位置和層級就確定了。

如前所述,驗證碼機制一旦發現驗證碼相關回復就予以攔截,根據回覆內容展示或者刷新驗證碼彈層,這就回到上面描述的基點獲取問題,驗證碼彈層展現需要的Activity怎麼獲得? 驗證碼機制在Interceptor這一層只能看到網絡請求和網絡回覆,得不到發起請求時的Activity。 簡單的想,就會試圖把Activity保存在網絡請求對像中,這是一個糟糕的做法,導致內存泄露,也破壞了網絡請求對象的結構,在一個網絡請求對象中維護一個Activity是比較違和的,兩者在層次上不匹配。我們需要一種更優雅輕量的方式來讓驗證碼機制可以獲取Activity。使用錨點系統提供提供的PageId就是個不錯的輕量級方案,PageId保存在請求對象中並不顯得突兀,因爲它和請求對象處於同一個抽象層次。

驗證碼機制要滿足同時處理複數個網絡請求的場景,而這複數個網絡請求又可能來自不同的頁面,所以驗證碼的處理要先以頁面爲維度進行區分,再以網絡請求爲維度進行細分(得益於網絡概念層對網絡請求的封裝,區分網絡請求可以使用Custom Request的RequestId),網絡回覆會被驗證碼機制分發到對應的發起頁面的網絡回覆處理接口中(藉助於網絡請求攜帶的PageId和錨點系統,我們可以得到合適的頁面實體,當然了,對於對應頁面已經被銷燬的網絡請求,就沒有進一步處理的必要,直接放行任其消逝即可)。

頁面隨後承擔起驗證碼處理的後續邏輯,因爲每個請求都需要自己的驗證碼彈層,因此需要爲每個請求單獨維護一個驗證碼彈層的Handler,Handler承接了驗證碼彈層的顯示和驗證重發(驗證重發得益於對網絡請求的建模,非常簡單,並且重發不會改變RequestId,這樣使得重發後的回覆可以繼續被相應的處理器處理)等邏輯, 可以使用請求的RequestId作爲key組織Handler映射表來管理複數個請求的Handler。請求成功或者失敗都代表着請求的終結,此時可以釋放其Handler。

只有非驗證碼相關回復才能被驗證碼機制放行,使得驗證碼機制對下游的回覆處理邏輯是透明的。這樣,藉助於複數個已有機制的特性和少許的擴展,我們將原來的分散零碎的驗證碼處理邏輯聚合上升成爲一個獨立透明的中間層。

第二階段演進總結

  1. 錨點系統提供了輕量頁面索引和頁面全局信息查詢功能,解放了一部分原先顯式依賴Activity/Fragment的實現,也爲某些依賴接頁面狀態的需求提供了更快捷的功能接口。
  2. 網絡概念層的建立將項目代碼和第三方網絡庫解耦,所有的上層機制都基於項目本身對網絡的概括理解來實現,同時還獲得了對網絡請求全局信息的掌控。
  3. 功能服務進一步分化了職責,功能使用開銷得到進一步壓縮。
  4. 全局網絡響應處理機制將散落在各處的驗證碼處理邏輯集中爲一個對下游透明的中間層。

總結: 第二階段的重構偏向於框架化和服務化,通過引入全局性的機制在新的維度實現需求和改良架構,機制之間的良性協作效應開始顯現。

結語

人人車Android客戶端開發週期已近兩年,迭代30多個版本,歷經數次規模不等的重構,篳路藍縷,終有小成。筆者有幸參與並主導了App的從萌芽到成長。儘管受限於技術水平和經驗視野,我們的架構演進並沒有實現最優解。但於我,這是一次偉大的朝聖之旅。


轉載用於學習交流,如冒犯,請聯繫刪除。

原文


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章