淺談一種Android客戶端架構設計

技術發展日新月異,業界各種Android客戶端架構設計,五花八門,但我們不能簡單地說哪種架構更好,因為脫離業務談架構是沒有任何意義的,適合業務的才是好架構。而架構也不是一成不變的,隨著業務的發展,也許當初設計的架構已不足以支撐目前的業務,那麼就需要改變之前的架構。接下來將分享下我們Android客戶端的架構設計,在App的某個業務發展階段或許有一些參考意義。

分層化與模組化

分層化與模組化應該是任何軟體開發的共識。

分層化

在Android應用開發中通常可以分為如下幾層:

這裡寫圖片描述

  • SDK層:主要是Android SDK及第三方的SDK(可能基於Android SDK或為獨立的SDK),這些SDK為上層框架提供核心功能的支援。
  • 基礎框架層:這裡所謂的基礎框架,指多數App都必需的基礎功能,是具體業務邏輯實現的基礎。主要有網路請求功能、圖片載入與快取功能、SQLite資料庫管理功能、Log管理功能等,當然根據對業務邏輯支援的不同,基礎框架層的功能支援也不一定相同,上述幾個應該是大部分App都要支援的,當然Crash監控與常用工具類也可歸為該層次。 
    具體到每個基礎框架的實現則沒有任何限制,如網路功能可以使用Volley、OkHttp或者自己封裝實現網路請求邏輯;對於圖片管理功能則可以使用Glide、Fresco、Picasso,亦或自己實現……總之每個基礎框架都要遵循一定的實現原則,保持功能模組的獨立性,與具體業務解耦並對外提供良好的互動介面。
  • 業務邏輯層:如果把App架構比作高層建築,那麼上述兩層就是地基。地基打好之後,就可以在上面任意發揮了,至於如何發揮,那就必須結合實際的業務需求,不同的應用往往有不同的業務功能模組。 
    另一方面,業務功能模組也並非完全是並列的級別,有一些業務邏輯也是可以抽象出來的,作為通用的功能模組,比如登入、分享、掃描、統計等,其他的業務模組可能會呼叫到這些功能。

這裡需要注意的是SDK層與基礎框架層並不是一成不變的,但它們的變化週期往往是比較長的,一般來說當基礎功能不能滿足最上層的業務邏輯時,就需要對其做擴充套件。由於基礎框架層的功能模組已經是功能級別的粒度劃分,因此擴充套件往往是模組級別的擴充套件,通常是新增基礎功能框架而不是修改原有基礎功能框架,這也符合“開放-閉合”原則。

模組化

至於模組化,對於分層化來說則是更細粒度的劃分,即將每一層細分為不同的模組,各功能模組儘可能遵循“高內聚、低耦合”的原則,功能模組之間僅提供必要的互動介面。

對於基礎框架層,由上圖可見,往往是根據功能來劃分。這裡的基礎框架層細分為網路支援功能、圖片庫、日誌系統、資料庫支援等模組,如果不足以支撐業務發展,可能會新增其他基礎功能模組。

而業務邏輯層則主要由業務需求來決定,如分為掃描功能、電商、快遞查詢等模組。業務邏輯層的模組化還有一種驅動因素,那就是通用功能的封裝,這一點大家應該都有體會,隨著App業務邏輯的增加,不同業務功能之間可能會用到相同的功能,如使用者登入、分享功能等,我們不希望在每個需要的地方都複寫一遍相關程式碼,於是就需要把通用功能抽取成獨立於具體業務需求的模組,如登入模組、分享模組,在模組內部實現通用的業務邏輯,同時對外暴露呼叫介面,不同的業務只需呼叫通用模組即可。

業務資料流程設計

由於業務邏輯、資料處理邏輯或網路框架的不同,相信各家應用都有自己的一套資料請求流程。最直接的就是從Activity或Fragment中呼叫網路請求的方法,然後通過回撥將結果返回到Activity或Fragment中,雖然流程最清晰,但這種方式存在幾個嚴重的問題:

  • 網路資料直接返回到Activity或Fragment中,後續需要對資料進行解析、過濾、轉換、快取等操作,這些工作將會大大加重Activity或Fragment的負擔。
  • Activity或Fragment的程式碼量猛增,邏輯繁雜(不僅包含了View的邏輯還包含了資料處理的邏輯)
  • 從整個應用的角度來看,每個頁面甚至每個介面都需要重複上述相同的冗餘工作,完全可以抽象出來。

上述設計思路是需要摒棄的,結合自身業務及架構演化,我們沒有跟風MVP、MVVM,而是設計了下面一套業務資料請求流程:

這裡寫圖片描述

首先,檢視層通常表現為Activity或Fragment,並由檢視層發起資料請求,與上述不同,檢視層並不直接跟網路框架打交道,而是先將資料請求傳送到資料代理層DataAgent。需要注意到是,檢視層與資料代理層之間沒有采用直接通訊的方式,而是插入了一個訊息排程器MessageScheduler中轉。這樣做的好處是將檢視層與資料代理層解耦,檢視層無需關注資料代理層的具體實現,有了MessageScheduler,檢視層所要做的就是發出一個資料請求的訊息而已,然後就可以靜靜等待一個回覆訊息,該回復訊息會附帶最終需要的資料物件,這樣在檢視層就免除了資料處理的邏輯,拿到結果直接展示到UI上即可。使用這種方式,一般來講Activity或Fragment三五百行程式碼即可搞定,UI邏輯或介面邏輯(如一個頁面有多個介面)比較複雜的程式碼量基本也能控制在1000行左右,邏輯非常清爽。

訊息排程器將檢視層的請求訊息轉發到資料代理層後,DataAgent解析出資料請求型別DataType(該型別對應著具體資料物件模型)、必要引數(介面引數、是否需要快取結果、分頁頁碼等),然後再執行具體的操作:

  • 如果要取快取的資料,則DataAgent直接向快取模組傳送請求。快取的資料可以是初始JSON資料,也可以是解析處理後得到的資料物件Model,可根據具體需求配置。如果從快取中取到的是JSON,則DataAgent先要解析處理得到對應Model;如果從快取中取到的是Model,則不做處理,然後將Model封裝發回到訊息排程器,再由MessageScheduler分發給具體的請求者,如Activity或Fragment。
  • 由於Android的資料來源有多種,如果資料來自持久化儲存,如SQLite或File等,仍然統一由DataAgent來跟它們通訊,獲取資料並加工後通過MessageScheduler發回檢視層。
  • 最常見的是從伺服器獲取資料,此種場景下,DataAgent將與網路框架互動,將從MessageScheduler中獲取的引數提供給網路框架構造請求url。至於網路框架使用Volley或OkHttp或者其他都沒關係,網路框架負責向Server請求資料,資料通常以JSON格式返回。DataAgent收到返回的JSON資料後,根據DataType將JSON資料校驗後拋給解析器,解析器會將JSON解析為檢視層需要的Model。當然資料解析過程可能伴隨資料的過濾、轉換等邏輯。另外需要注意的是,還需要根據檢視層需求對資料進行是否快取的操作,可選擇快取JSON還是Model。經過一系列操作,得到最終Model後,DataAgent將其通過MessageScheduler發回檢視層。

當然,由於資料請求流程是耗時的,因此上述步驟都是走的執行緒池,這點上圖中並未註明。

資料代理層

DataAgent在上文中已簡單提及,它的主要作用是對資料的一系列操作,包括實際的資料請求、資料解析處理、資料快取等邏輯。下圖為從服務端介面獲取JSON資料並處理的流程:

這裡寫圖片描述

從上圖可知,DataAgent的大致工作流程為:

  1. DataAgent將真正的資料請求傳送給各資料來源,資料來源可能為快取、SQLite或檔案,但通常是從服務端獲取資料,因此DataAgent會將資料請求發到網路框架層,然後等待資料返回。
  2. 由於資料來源不同,返回資料也可能不同,這裡簡化為兩種:原始JSON或Model。
  3. DataAgent拿到資料後,則開始資料處理流程。以從網路請求的JSON資料為例,先對返回的JSON進行資料校驗,檢查資料的有效性與正確性,如果資料校驗通過,接下來根據需求來決定要不要寫入快取,然後再進行資料加工(如精度處理、資料拼接、資料裁剪等),最後進行資料解析得到檢視層需要的Model。如果資料校驗沒有通過,則嘗試從快取中讀取,從快取中讀取後也需要校驗(檢查資料的時效性、有效性、正確性),校驗通過後同樣進行資料處理、解析等流程。如果快取中讀取得到的就是Model,那麼則可以省略資料處理和解析的流程。得到最終的Model後,DataAgent將其包裝傳送給MessageScheduler。另外DataAgent還要具有一定的容錯功能,因為任何資料來源都無法保證能夠返回合法的資料,如果不對資料錯誤進行容錯處理,那麼就可能無法解析為對應的Model,從而導致檢視層無資料甚至異常。如果介面及快取都無法返回正確的資料,DataAgent需要做特殊處理,以保證檢視層能給使用者以反饋。

業務檢視邏輯

雖然不同的業務頁面有不同的檢視邏輯,這裡以一個應用中最常見的頁面為例來說明,假設該頁面有一個列表。大家都知道ListView(此處為泛指,可能大家都在用RecyclerView了)的工作方式,它需要ViewHolder來填充檢視,需要Adapter來填充資料,如果每個需要ListView的介面都維護各自的一套ViewHolder及Adapter,那麼頁面邏輯又將變得臃腫。

我們在實踐中是這樣做的:

  • 封裝一個Adapter公共處理類,提供多種建構函式,其中有一個type引數,用來標明需要使用哪個ViewHolder。
  • 封裝一個ViewHolder抽象類,定義資料設定的邏輯,並交由具體的ViewHolder實現。
  • 構建一個叫做ViewHolderFactory的類,顧名思義該類主要作用是用來構建ViewHolder,它主要提供兩個方法createViewHolder()與createConvertView(),其中createConvertView()是個中間方法,用於生成ViewHolder。
  • 在Adapter的getView方法中,根據上述type引數,獲取具體的ViewHolder實現,呼叫設定資料的邏輯。

經過上述封裝之後,檢視層只需要向Adapter公共處理類傳入一個type引數即可得到對應的Adapter;等資料返回到檢視層後,再將資料傳給Adapter公共處理類,其他什麼都不用管,就可以展示列表資料了。原本需要很多程式碼實現的邏輯從檢視層抽離之後,檢視層只需要幾行程式碼就能夠完成一個列表展示了。

Hybrid框架

自Android誕生以來,就有Native App與Web App之爭,這兩種開發方式雖然各有優缺點,但Native App一直佔據上風。近一兩年來,移動應用中的Web頁面越來越多,而純Native的應用則相對越來越少。但是純Web App由於其渲染效率、效能問題、對硬體的呼叫限制導致其也並未廣泛地應用。於是一種折中的方案成為主流,即Hybrid App。

所謂Hybrid App,即混合開發方式,部分功能使用Native開發,部分功能使用H5開發。為了充分利用Web開發的優點並避開其缺點,並非所有業務功能都適合使用Web方式來開發。在我們的應用中,主要將H5用於以下方面:

  • 節日活動或遊戲頁、秒殺或團購頁等具有時效性的頁面。
  • 使用說明、公告等偏展示、少互動的頁面。
  • 經常更新、互動較少且不涉及硬體呼叫的頁面或模組,如電商商品首頁展示、積分兌換模組。

截止到目前,我們App中的Web頁所佔比重是上升的,大概佔到所有功能的25%左右。使用Web開發的優勢非常明顯,可以支援多變的UI檢視效果、節省開發人力(Android、iOS共用)、Bug的線上修復而不用App發版等。

為了滿足App的Web頁面需求,於是我們在基礎框架層擴充套件了一個Hybrid功能模組。該框架主要是自行封裝了Android原生的WebView控制元件,且分為不同層級的封裝,可根據需要靈活使用,核心功能及特性如下:

  • 支援完整的Web頁面,即整個頁面的內容全部是H5實現,外部容器為Activity或Fragment。
  • 支援區域性的Web頁面,即部分頁面的內容是H5實現,可單獨使用自定義的WebView或者嵌入Fragment使用。
  • 定義了一套較為完整的互動協議,支援Native與JS的互相呼叫,典型的場景如H5頁面點選跳轉Native功能頁面(支援傳參)、JS喚起Native對話方塊或Toast等,同時Java也能呼叫JS函式。基於此套互動協議,基本能夠滿足日常App中Web開發需求。
  • 避免了JS注入漏洞。
  • 支援同一個Web頁面中Http與Https混合的場景。
  • 向業務邏輯層暴露介面,可根據需求定製WebViewClient與WebChromeClient。
  • 對外提供介面,可根據需求控制縮放、Cookie管理、快取管理、硬體加速等。
  • 經過試驗與摸索,相容多種Android裝置及版本。

雖然後來出現了React Native,但由於學習成本及其Android版本的侷限性,結合我們自己團隊的人力資源原因,我們尚未在應用中正式使用。目前仍然以Hybrid開發為主,且其在整個應用中的比重越來越大,因此Hybrid框架是我們架構中重要的一個組成部分。

訊息排程中心

前面業務資料流程的設計中,在檢視層與資料代理層之間插入了一個訊息排程器——MessageScheduler,MessageScheduler主要功能就是管理訊息及訊息排程。

MessageScheduler核心原理是維護了一個雜湊表,當收到檢視層的資料請求時就使用唯一的key將發起者儲存到雜湊表中,以便稍後收到DataAgent的返回資料後,能夠找到發起者。儲存好訊息發起者的資訊後,即向DataAgent傳送資料請求,多個資料請求是可以並行的,主要在於執行緒池的執行緒數控制機制。DataAgent返回資料之後,MessageScheduler根據唯一key找到初始的請求者,同樣利用訊息機制將請求結果返回給檢視層,同時在雜湊表中清除該元素。其示意圖如下:

這裡寫圖片描述

訊息分發器

既然有了訊息排程機制,就需要訊息分發器MessageDispatcher,來負責傳送訊息。

MessageDispatcher本質上是利用了Android的訊息機制來對業務需求進行封裝和擴充套件。看過Android Framework層原始碼就會發現其實Android框架本身就有很多地方使用了訊息機制來進行通訊,Android訊息機制可以在模組頁面間、執行緒間通訊,甚至可以在程序間使用Messenger通訊(Messenger方式是利用了訊息機制,當然還有其他程序間通訊方式)。

MessageDispatcher功能比較簡單,支援兩種方式:

  • 點對點的通訊,如兩個頁面之間,通訊目標唯一,如上文提到的從檢視層傳送資料請求訊息到訊息排程器。
  • 點對面的通訊,類似於廣播,也有點像EventBus,一條訊息發出,凡是註冊(或叫訂閱)過的頁面都能收到通知;也可以進一步通過Tag控制達到一對一傳送。

其示意圖如下:

這裡寫圖片描述

模組路由中心

一個完整的應用中,免不了模組之間、功能頁面之間的跳轉。當然在需要的地方通過Intent可以實現跳轉,但這不是一個好的方案,很明顯不同模組或頁面之間的耦合度增加了。而我們的原則是模組和頁面之間儘可能解耦,於是設計了一個模組路由(Module Routing)中心,App中所有的頁面跳轉均由其控制。

模組路由的核心原理是給功能頁面進行唯一編碼,編碼的邏輯可以跟隨產品版本定義到應用中,並保證相容之前版本。這樣就可以在應用的任何地方只需要向模組路由中心傳送對應模組頁面的編碼即可,由模組路由負責開啟目標頁面。

以下幾點需要注意:

  • 整個應用中的功能頁編碼都必須保證唯一
  • 如開啟某些功能頁面除了具體編碼外,還可能需要額外引數。如開啟商品詳情頁,除了知道商品詳情頁的編碼外,還需要商品ID,模組路由需要對附加引數提供支援。
  • 模組路由支援開啟Web頁面,即Hybrid頁面也支援上述特定編碼,所以在Web頁面上點選跳轉Native頁面使用的協議也是由模組路由支援的。

使用模組路由的好處有:

  • 大量減少應用中的跳轉Intent
  • 模組之間、頁面之間解耦
  • 適配變化,統一管理,修改方便

其他

日誌系統

在開發過程中,甚至執行過程中,日誌都是很重要的一部分。當然Android提供了Log相關的API,但不建議這一行那一行地零星使用,否則如果想統一控制Tag或關閉Log時非常麻煩。建議對Log API進行簡單封裝或者使用現有第三方Log庫,將Log功能獨立出來,提供統一的呼叫介面、級別控制、開關控制,這樣既方便除錯也方便管理,同時也能為整個應用程式碼的清晰做出一點貢獻。

線上崩潰監控

對線上應用的Crash監控是提高應用穩定性、優化應用效能的一個重要方法。我們構建了一個小型的全域性監控系統,主要由以下功能特性:

  • 對使用者不可見,使用者無感知
  • 全域性註冊即可開啟監控
  • 捕捉線上崩潰,儲存到本地檔案
  • 線上崩潰資訊按一定策略上傳伺服器,上傳後同時刪除本地檔案
  • 崩潰資訊主要包括Android裝置資訊(如手機型號、系統版本等)、App版本號、異常資訊等

伺服器收到上傳的線上崩潰資訊後,也按一定策略通過郵件方式通知到開發者,以便開發者及時修復異常。線上崩潰監測系統雖然小而簡單,但作用非常重要,利用線上崩潰反饋可以有效地提高應用的穩定性,建議在應用設計中務必給它留出一個位置。

統計系統

相信大部分應用都有統計分析後臺,可以統計應用的日活、PV、UV或其他使用者行為,也可能有一部分應用是使用的第三方統計功能,如友盟等。結合公司BI部門的統計需求,我們客戶端自行設計了一套統計方案,用於Android與iOS兩個客戶端。之所以不用第三方統計,主要是因為我們無法根據需求自由定製且資料不在自家伺服器,另一方面也有些許資料洩露的風險。

基於客戶端的統計系統主要包括三個方面的功能:

  • 資料採集
  • 資料儲存
  • 資料上傳

對於資料採集,主要針對統計部門的需求,如採集裝置資訊、定位資訊、App啟動時間次數、PV、UV、甚至使用者行為,如點選、切換Tab、頁面流向跟蹤等。

為了避免每次採集完資料後就即時上傳,因此需要資料儲存,將採集的統計資料暫存到本地,一般使用SQLite。然後採用一定策略進行上傳,如資料累積到50條或者應用切換到後臺時進行上傳。

對於資料上傳,除了上傳時機的選擇策略外,還要遵循一定的結構欄位,該結構可以根據資料統計部門的需求來定義。資料上傳的流程同樣可以使用之前的資料請求框架,只不過返回值可能為一個成功提示而已。

基於上述功能,我們自定義的統計功能模組提供了方便的呼叫介面,並支援靈活擴充套件,目前可以完美支援日常的統計需求,呼叫也非常簡單,只需要在需要統計的地方插入一行程式碼即可。

域名劫持應對策略

最近遇到域名劫持的問題,真是頭疼,另一方面也說明我們的流量引起運營商注意了。目前主流的有幾下幾種方案:

  • 向運營商投訴。此方法非常被動且效果不佳,完全掌控在運營商手中。
  • 使用httpDNS。此方法使用http的方式直接獲取最優IP,繞過localDNS的解析,可謂徹底解決了域名劫持。
  • 先使用域名嘗試,域名失敗後再使用IP嘗試。此方案屬於容災方案,並不能避免域名劫持。

理論上講第二種是最佳方案,但由於httpDNS為第三方服務,也無法保證效果,外加上付費及接入成本等因素,我們暫時採用了第三種容災方案,主要實施邏輯如下:

  1. 應用預先內建IP。
  2. 每次啟動應用時獲取最新IP,並儲存到應用本地。
  3. 請求資料時,先使用域名走正常的邏輯,一旦遇到疑似劫持的問題後,使用本地的IP進行直連嘗試。

上述步驟其實是有漏洞的,比如啟動時獲取最新IP的介面如果被劫持了,那麼就無法獲取最新IP,假如剛好同時伺服器IP也改變了,因此預先內建的IP已經失效,此時就徹底沒辦法了。不過上述兩個條件同時滿足的概率比較小,因此可以使用該方案解決很大一部分域名劫持問題。另外從服務端獲取的IP,如果有多個的話,還需要增加一些策略,即考慮到負載均衡、訪問速度、穩定性、網路運營商等因素,如何確定客戶端拿到的哪一個是最優IP,當然這點可以優化,但首先能保證使用者看到頁面資料或許更加重要。

上述應對域名劫持的策略本身並不能獨立成一個模組,我們把它整合為網路框架的擴充套件。

總結

上文提到的是我們Android應用架構中的核心部分,可能你發現並沒有什麼花哨的、潮流的玩意兒,沒有MVP,沒有RxAndroid,沒有外掛化,也沒有熱修復……但就是這樣它仍然支撐起了上億的使用者量。世上沒有完美的架構,只有符合自身業務的架構,上述架構還有很多缺點,我們也在有選擇、有步驟地重構,而隨著業務需求的擴充套件,架構也會不斷演化,最後希望本文能給大家帶來一點參考意義(發現文章不錯,很系統,很全面,望大家共勉之)。