Android專案架構設計深入淺出

前言:本文結合個人在架構設計上的思考和理解,介紹如何從0到1設計一個大型Android專案架構。

一  引導

本文篇幅較長,可結合下表引導快速瞭解全文主脈絡。
章節標題
章節簡介
專案架構演進
總結一個普通Android專案從0到1再到N的架構演進歷程。
專案架構拆解
對一個架構完整的大型Android專案中各層級及其核心模組進行展開講解。
核心原理總結
對各框架SDK中一些共性的底層原理進行抽象總結。
通用設計方案
梳理一些架構設計和專案開發中遇到的通用問題及其解決方案。
總結
總結架構設計的適用範圍。

二  專案架構演進

該章節主要對一個Android專案架構從0到1再到N的演進歷程做出總結(由於專案的開發受業務、團隊和排期等各方面因素影響,因此該總結不會嚴格匹配每一步的演進歷程,但足以說明專案發展階段的一般性規律)。

1  單專案階段

對於一個新開啟專案而言,每端的開發人員通常非常有限,往往只有1-2個。這時候比專案的架構設計和各種開發細節更重要的是開發週期,快速將idea進行落地是該階段最重要的目標。現階段專案的架構往往是這樣
此時專案中幾乎所有的程式碼都會寫在一個獨立的app模組中,在時間為王的背景下,最原始的開發模式往往就是最好最高效的。

2  抽象基礎庫階段

隨著專案最小化MVP已經開發完成,接下來打算繼續完善App。此時大機率會遇到以下幾個問題
  1. 程式碼的版本控制問題,為保證專案加快迭代,團隊新招1-3名開發同學,多人同時在一個專案上開發時,Git程式碼合併總會出現衝突,非常影響開發效率;
  2. 專案的編譯構建問題,隨著專案程式碼量逐漸增多,執行App都是基於原始碼編譯,以至於首次整包編譯構建的速度逐漸變慢,甚至會出現為了驗證一行程式碼的改動而需要等待大幾分鐘或者更久時間的現象;
  3. 多應用的程式碼複用問題,公司可能同時在進行多個App的開發,同樣的程式碼總是需要透過複製貼上的方式進行復用,維持同一個功能在多個App之間的邏輯一致性也會存在問題;
基於以上的一種或多種原因,我們往往會把那些相對於整個專案而言,一旦開發完成後就很少再改動的功能進行模組化封裝。
我們把原本只包含一個應用層的專案,向下抽取了一個包含網路庫、圖片載入庫和UI庫等眾多原子能力庫的基礎層。這樣做之後,對於協同開發、整包構建和程式碼複用都起到了很大的改善作用。

3  拓展核心能力階段

業務初具規模之後,App已經投入到線上並且有持續穩定的DAU。
在這個時候往往非常關鍵,隨著業務增長、客戶使用量增大、迭代需求增多等各方面挑戰。如果專案沒有一套良性的架構設計,開發的人效會隨著團隊規模的擴大而反向降低,之前單位時間內1個人能開發5個需求,現在10個人用同樣的時間甚至連20個需求都開發不完,單純的依靠加人是很難徹底解決這個問題的。這時候著重需要做的兩件事
  1. 開發職責分離,團隊成員需要分為兩部分,分別對應業務開發和基礎架構。業務開發組負責完成日常業務迭代的支撐,以業務交付為目標;基礎架構組負責底層基礎核心能力建設,以提升效率、效能和核心能力拓展為目標; 
  2. 專案架構最佳化,基於1,要在應用層和基礎層之間,抽象出核心架構層,並將其連帶基礎層一起交由基礎架構組負責,如圖; 
該層會涉及到很多核心能力的建設,這裡不做過多贅述,下文會對以上各個模組做詳細展開。
注:從全域性視角來看,基礎層和核心層也能作為一個整體,共同支撐上層業務。這裡將其分為兩層,主要考慮到前者是必選項,是整體架構的必要組成部分;後者是可選項,但同時也是衡量一個App中臺能力的核心指標。

4  模組化階段

隨著業務規模繼續擴大,App的產品經理(下簡稱PD)會從一個變為多個,每個PD負責獨立的一條業務線,比如App中包含首頁、商品和我的等多個模組,則每個PD會對應這裡的一個模組。但該調整會帶來一個很嚴重的問題
專案的版本迭代時間是確定的,只有一個PD的時候,每個版本會提一批需求,開發能按時交付就上線,不能交付就把這個迭代適當順延,這樣不會有什麼問題;
但如今多個業務線並行,很難在絕對意義上保證各個業務線的需求迭代都能正常交付,就好像你組織一個活動約定了幾點集合,但總會有人會遇到一些特殊的情況不能及時趕到。同理,這種難以完全保持一致的情況在專案開發中也會遇到。在當前的專案架構下,業務上雖然拆分了業務線,但我們工程專案的業務模組還是一個整體,內部包含著各種錯綜複雜的依賴關係網,即使每個業務線按分支區分,也很難規避這個問題。
這時候我們需要在架構層面做專案的模組化,使得多業務線不相互依賴,如圖
業務層中,可以按照開發人員或者小組進行更細粒度的劃分,以保證業務間的解耦合和開發職責的界定。

5  跨平臺開發階段

業務規模和使用者體量繼續擴大,為了應對隨之而來的是業務需求暴增,整個端側團隊開始考慮研發成本問題。
為什麼每個業務需求都至少需要Android和iOS兩端都實現一遍?有沒有什麼方案能夠滿足一份程式碼能執行在多個平臺?這樣豈不是既降低了溝通成本,又提升了研發效率。答案當然是肯定的,此時端側部分業務開始進入了跨平臺開發的階段。
至此,一個相對完整的端側系統架構已經初具雛形了。後續業務上會繼續有著更多的迭代,但專案的整體結構基本都不會偏離太多,更多的是針對於當前架構中的某些節點做更深層次的改進和完善。
以上是對Android專案架構迭代過程的總結,接下來我會對最終的架構圖按照自下而上的層級順序進行逐一展開,並對每層中涉及到的核心模組和可能遇到的問題進行分析和總結。

三  專案架構拆解

1  基礎層

基礎UI模組

抽取出基礎的UI模組,主要有兩個目的:
統一App全域性基礎樣式
比如App的主色調、普通正文的文字顏色和大小、頁面的內外邊距、網路載入失敗的預設提示文案、空列表的預設UI等等,尤其是在下文提到專案模組化之後這些基礎的UI樣式統一會變得非常重要。
複用基礎UI元件
在專案和團隊規模逐漸發展擴大時,為了提高上層業務的開發效率,秉承DRY的開發原則,我們有必要對一些高頻UI元件進行統一封裝,以供給業務上層呼叫;另外一個角度來看,必要的抽象封裝還能夠降低最終構建的安裝包大小,以免一份語義的資原始檔在多處出現。
基礎UI元件通常包含內部開發和外部引用兩部分,內部開發無可厚非,根據業務需求進行開發和封裝即可;外部引用要著重強調一下,Github上有大量可複用、經過很多專案驗證過的優秀UI元件庫,如果是為了快速滿足業務開發訴求,這些都將不失為一種很不錯的選擇。
選擇一個合適的UI庫,會給整個開發程序帶來很大的加速,自己手動去實現也許沒問題,但會非常花費時間和精力,如果不是為了研究實現原理或深度定製,建議優先選擇成熟的UI庫。

網路模組

絕大多數的App應用都需要聯網,網路模組幾乎成為了所有App必不可少的部分。
框架選擇
基礎框架的選擇往往參考幾個大原則:
  1. 維護團隊和社群比較大,遇到問題後能夠有足夠多的自助解決空間;
  2. 底層功能強大,支撐儘可能多的上層應用場景;
  3. 拓展能力靈活,支援在框架基礎上做能力拓展和AOP處理;
  4. Api側友好,降低上層的理解和使用成本;
這裡不做具體展開,如果不是基礎層對網路層有自己額外的定製,則推薦直接使用Retrofit2作為網路庫首選,上層Java Interface風格的Api,面向開發者非常友好;下層依賴功能強大的Okhttp框架也幾乎能夠滿足絕大多數場景的業務訴求。官網的用例參考
// 0. 初始化Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://api.github.com/") .build();// 1. 宣告服務介面publicinterfaceGitHubService{@GET("users/{user}/repos") Call<List<Repo>> listRepos(@Path("user") String user);}// 2. 透過Retrofit獲取服務介面例項GitHubService service = retrofit.create(GitHubService.class);// 3. 業務層呼叫Call<List<Repo>> repos = service.listRepos("octocat");
用例中對Retorfit宣告式介面的優勢做了很好的展現,不需要手動實現介面,宣告即可使用,其背後的原理是基於Java的動態代理來做的。
統一攔截處理
無論上一步選擇的是什麼網路庫,都需要考慮到該網路庫對於統一攔截的能力支援。比如我們想在App的整個執行過程中,列印所有請求的日誌,就需要有一個支援配置類似Interceptor這樣的全域性攔截器。
舉一個具體的例子,在現如今服務端很多分散式部署的場景,傳統的session方式已經無法滿足對客戶端狀態記錄的訴求。有一個比較公認的解決方案是JWT(JSON WEB TOKEN),它需要客戶端側在登入認證之後,把包含使用者狀態的請求頭資訊傳遞給服務端,此時就需要在網路層做類似於下面的統一攔截處理。
Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://xxx.xxxxxx.xxx") .client(new OkHttpClient.Builder() .addInterceptor(new Interceptor() {@NonNull@Overridepublic Response intercept(@NonNull Chain chain)throws IOException {// 新增統一請求頭 Request newRequest = chain.request().newBuilder() .addHeader("Authorization", "Bearer " + token) .build();return chain.proceed(newRequest); } }) .build() ) .build();
此外還有一點需要額外說明,如果應用中有一些跟業務強相關的資訊,也建議根據實際業務情況考慮直接透過請求頭進行統一傳遞。比如社群App的社群Id、門店App的門店Id等,這類引數有個普遍性特點,一旦切換過來之後,接下來的很多業務網路請求都會需要該引數資訊,而如果每個介面都手動傳入將會降低開發效率,也更容易引發一些不必要的人為錯誤。

圖片模組

圖片庫和網路庫不同的是,目前行業裡比較流行的幾個庫差異性並沒有那麼大,這裡建議根據個人喜好和熟悉度自行選擇。以下是我從各個圖片庫官網整理出來的使用示例。
Picasso
Picasso.get().load("http://i.imgur.com/DvpvklR.png").into(imageView);
Fresco
Uriuri = Uri.parse("https://raw.githubusercontent.com/facebook/fresco/main/docs/static/logo.png");SimpleDraweeViewdraweeView = (SimpleDraweeView) findViewById(R.id.my_image_view);draweeView.setImageURI(uri);
Glide
Glide.with(fragment).load(myUrl).into(imageView);
另外,這裡附上各個庫在Github上的star,供參考。
圖片庫的選型比較靈活,但是它的基礎原理我們需要弄清楚,以便在圖片庫出問題時有足夠的應對解決策略。
另外需要著重提出來的是,對於圖片庫最核心的是對圖片快取的設計,有關該部分的延伸可以參考下文的「核心原理總結」章節。

非同步模組

在Android開發中非同步會使用的非常之多,同時其中也包含很多知識點,因此這裡將該部分單獨抽出來講解。
1)Android中的非同步定理
總結下來一句話就是,主執行緒處理UI操作,子執行緒處理耗時任務操作。如果反其道而行之就會出現以下問題
  1. 主執行緒做網路請求,會出現NetworkOnMainThreadException異常;
  1. 主執行緒做耗時任務,很可能會出現ANR(全稱Application Not Responding,指應用無響應);
  1. 子執行緒做UI操作,會出現CalledFromWrongThreadException異常(這裡只做一般性討論,實際上在滿足某些條件下子執行緒也能更新UI,參《Android 中子執行緒真的不能更新 UI 嗎?》,本文不討論該情況);
2)子執行緒呼叫主執行緒
如果當前在子執行緒,想要呼叫主執行緒的方法,一般有以下幾種方式
  1. 透過主執行緒Handlerpost方法 
privatestaticfinal Handler UI_HANDLER = new Handler(Looper.getMainLooper());@WorkerThreadprivatevoiddoTask()throws Throwable { Thread.sleep(3000); UI_HANDLER.post(new Runnable() {@Overridepublicvoidrun(){ refreshUI(); } });}
  1. 透過主執行緒HandlersendMessage方法 
privatefinal Handler UI_HANDLER = new Handler(Looper.getMainLooper()) {@OverridepublicvoidhandleMessage(@NonNull Message msg){if (msg.what == MSG_REFRESH_UI) { refreshUI(); } }};@WorkerThreadprivatevoiddoTask()throws Throwable { Thread.sleep(3000); UI_HANDLER.sendEmptyMessage(MSG_REFRESH_UI);}
  1. 透過Activity的runOnUiThread方法 
publicclassMainActivityextendsActivity{// ...@WorkerThreadprivatevoiddoTask()throws Throwable { Thread.sleep(3000); runOnUiThread(new Runnable() {@Overridepublicvoidrun(){ refreshUI(); } }); }}
  1. 透過Viewpost方法 
private View view;@WorkerThreadprivatevoiddoTask()throws Throwable { Thread.sleep(3000); view.post(new Runnable() {@Overridepublicvoidrun(){ refreshUI(); } });}
3)主執行緒呼叫子執行緒
如果當前在子執行緒,想要呼叫主執行緒的方法,一般也對應幾種方式,如下
  1. 透過新開執行緒 
@UiThreadprivatevoidstartTask(){new Thread() {@Overridepublicvoidrun(){ doTask(); } }.start();}
  1. 透過ThreadPoolExecutor
privatefinal Executor executor = Executors.newFixedThreadPool(10);@UiThreadprivatevoidstartTask(){ executor.execute(new Runnable() {@Overridepublicvoidrun(){ doTask(); } });}
  1. 透過AsyncTask
@UiThreadprivate void startTask() { new AsyncTask<Void, Void, Void>() {@OverrideprotectedVoid doInBackground(Void... voids) { doTask();returnnull; } }.execute();}
非同步程式設計痛點
Android開發使用的是JavaKotlin這兩種語言,如果我們的專案中引入了Kotlin當然是最好,對於非同步呼叫時只需要按照如下方式進行呼叫即可。
Kotlin方案
val one = async { doSomethingUsefulOne() }val two = async { doSomethingUsefulTwo() }println("The answer is ${one.await() + two.await()}")
這裡適當延伸一下,類似於async + await的非同步呼叫方式,在其他很多語言都已經得到了支援,如下
Dart方案
Future<String> fetchUserOrder() => Future.delayed(const Duration(seconds: 2), () =>'Large Latte');Future<String> createOrderMessage() async {var order = await fetchUserOrder();return'Your order is: $order';}
JavaScript方案
functionresolveAfter2Seconds(x) {returnnewPromise(resolve => { setTimeout(() => { resolve(x); }, 2000); });}asyncfunctionf1() {var x = await resolveAfter2Seconds(10);console.log(x); // 10}f1();
但是如果我們的專案中還是純Java專案,在複雜的業務互動場景下,常常會遇到序列非同步的業務邏輯,此時我們的程式碼可讀性會變得很差,一種可選的應對方案是透過引入RxJava來解決,參考如下
RxJava方案
source.operator1().operator2().operator3().subscribe(consumer)

2  核心層

動態配置

業務開關、ABTest
對於線上功能的動態配置
背景
  1. AndroidNative開發)不同於Web能夠隨時釋出上線,Android釋出幾乎都要走應用平臺的稽核;
  1. 業務上很多時候需要做AB測試或一些配置開關,以滿足業務的多樣性;
基於以上幾點,就決定了我們在Android開發過程中,對程式碼邏輯有動態配置的訴求。
基於這個最基本的模型單元,業務上可以演化出非常豐富的玩法,比如配置啟動頁停留時長、配置商品中是否展示大圖、配置每頁載入多少條資料、配置要不要是否允許使用者進入某個頁面等等。
分析
客戶端獲取配置資訊通常有兩種方案,分別對應推和拉。
推是指透過建立客戶端與服務端的長連線,服務端一旦有配置發生變化,就將變化的資料推到客戶端以進行更新;
拉是指客戶端每次透過主動請求來讀取最新配置;
基於這兩種模式,還會演化出推拉結合的方式,其本質就是兩種方式都使用,技術層面沒有新變化,這裡不做贅述。下面將推拉兩種方式進行對比
綜合來看,如果業務上對時效性要求沒有非常高的情況下,我個人還是傾向於選擇拉的方式,主要原因更改配置是低頻事件,為了這個低頻事件去做C-S的長連線,會有種牛刀殺雞的感覺。
實現
推配置的實現思考相對清晰,有配置下發客戶端更新即可,但需要做好長連線斷開後的重連邏輯。
拉配置的實現,這裡有些需要我們思考的地方,這裡總結以下幾點
  1. 按照namespace進行多模組劃分配置,避免全域性一個大而全的配置;
  1. 每個namespace在初始化和每次改動時都會有個flag標識,以標識當前版本;
  1. 客戶端每個業務請求都在請求頭處統一拉上各flag或他們共同組合的md5等標識,為了在服務端統一攔截時進行flag時效性校驗;
  1. 服務端時效性檢驗結果透過統一響應頭下發,與業務介面隔離,上層業務方不感知;
  1. 客戶端收到時效性不一致結果時,再根據具體的namespace進行拉取,而不是每次全量拉取;

全域性攔截

背景
App與使用者聯絡最緊密的就是互動,它是我們的App產品與使用者之間溝通的橋樑。
使用者點選一個按鈕之後要執行什麼動作,進入一個頁面之後要展示什麼內容,某個操作之後要執行什麼請求,請求之後要執行什麼提示,這些都是使用者最直觀能看到的東西。全域性攔截就是針對於這些使用者能接觸到的最高頻的互動邏輯做出可支援透過前面動態配置來進行定製的技術方案。
互動結構化
具體的互動響應(如彈出一個ToastDialog,跳轉到某個頁面)是需要透過程式碼邏輯來控制的,但該部分要做到的就是在App釋出之後還能實現這些互動,因此我們需要將一些基礎常見的互動進行結構化處理,然後在App中提前做出通用的預埋邏輯。
我們可以做出以下約定,定義出Action的概念,每個Action就對應著App中能夠識別的一個具體互動行為,比如
  1. 彈出Toast
{"type": "toast","content": "您好,歡迎來到XXX","gravity": "<這裡填寫toast要展示的位置, 可選項為(center|top|bottom), 預設值為center>"}
  1. 彈出Dialog
    這裡值得注意的是,DialogAction中嵌套了Toast的邏輯,多種Action的靈活組合能給我們提供豐富的互動能力。 
{"type": "dialog","title": "提示","message": "確定退出當前頁面嗎?","confirmText": "確定","cancelText": "取消","confirmAction": {"type": "toast","content": "您點選了確定" }}
  1. 關閉當前頁面 
{"type": "finish"}
  1. 跳轉到某個頁面 
{"type": "route","url": "https://www.xxx.com/goods/detail?id=xxx"}
  1. 執行某個網路請求 同2,這裡也做了多Action的巢狀組合。 
{"type": "request","url": "https://www.xxx.com/goods/detail","method": "post","params": {"id": "xxx" },"response": {"successAction": {"type": "toast","content": "當前商品的價格為${response.data.priceDesc}元" },"errorAction": {"type": "dialog","title": "提示","message": "查詢失敗, 即將退出當前頁面","confirmText": "確定","confirmAction": {"type": "finish" } } }}
統一攔截
互動結構化的資料協議約定了每個Action對應的具體事件,客戶端對結構化資料的解析和封裝,進而能夠將資料協議轉化為與使用者的產品互動,接下來要考慮的就是如何讓一個互動資訊生效。參考如下邏輯
  1. 提供根據頁面和事件標識來獲取服務端下發的Action的能力,這裡用到的DynamicConfig即為前面提到的動態配置。 
@Nullableprivatestatic Action getClickActionIfExists(String page, String event) {// 根據當前頁面和事件確定動作標識String actionId = String.format("hook/click/%s/%s", page, event);// 解析動態配置中, 是否有需要下發的ActionString value = DynamicConfig.getValue(actionId, null);if (TextUtils.isEmpty(value)) {returnnull; }try {// 將下發Action解析為結構化資料returnJSON.parseObject(value, Action.class); } catch (JSONException ignored) {// 格式錯誤時不做處理 (供參考) }returnnull;}
  1. 提供包裝點選事件的處理邏輯(performAction為對具體Action的解析邏輯,功能比較簡單,這裡不做展開) 
/** * 包裝點選事件的處理邏輯 * * @param page 當前頁面標識 * @param event 當前事件標識 * @param clickListener 點選事件的處理邏輯 */publicstatic View.OnClickListener handleClick(String page, String event, View.OnClickListener clickListener) {// 這裡返回一個OnClickListener物件, 降低上層業務方的理解成本和程式碼改動難度returnnew View.OnClickListener() {@OverridepublicvoidonClick(View v){// 取出當前事件的下發配置 Action action = getClickActionIfExists(page, event);if (action != null) {// 有配置, 則走配置邏輯 performAction(action); } elseif (clickListener != null) {// 無配置, 則走預設處理邏輯 clickListener.onClick(v); } } };}
有了上面的基礎,我們便能夠快速實現支援遠端動態改變App互動行為的功能,下面對比一下上層業務方在該能力前後的程式碼差異。
// 之前addGoodsButton.setOnClickListener(new View.OnClickListener() {@OverridepublicvoidonClick(View v){ Router.open("https://www.xxx.com/goods/add"); }});// 之後addGoodsButton.setOnClickListener(ActionManager.handleClick("goods-manager", "add-goods", new View.OnClickListener() {@OverridepublicvoidonClick(View v){ Router.open("https://www.xxx.com/goods/add"); } }));
可以看到,業務側多透傳一些對於當前上下文的標識引數,除此之外沒有其他更多的改動。
截止目前,我們對於addGoodsButton這一按鈕的點選事件就已經完成了遠端的hook能力,如果現在突然出現了一些原因導致新增商品頁不可用,則只需要在遠端動態配置裡新增如下配置即可。
{"hook/click/goods-manager/add-goods": {"type": "dialog","title": "提示","message": "由於XX原因,新增商品頁面暫不可用","confirmText": "確定","confirmAction": {"type": "finish" } }}
此時使用者再點選新增商品按鈕,就會出現如上的提示資訊。
上面介紹了對於點選事件的遠端攔截思路,與點選事件對應的,還有跳轉頁面、執行網路請求等常見的互動,它們的原理都是一樣的,不再一一列舉。

本地配置

在App開發測試階段,通常需要新增一些本地化配置,從而實現一次編譯構建允許相容多種邏輯。比如,在與服務端介面聯調過程中,App需要做出常見的幾種環境切換(日常、預發和線上)。
理論上,基於前面提到的動態配置也能實現這個訴求,但動態配置主要面向的是線上使用者,而如果選擇產研階段使用該種能力,無疑會增加線上配置的複雜度,而且還會依賴網路請求的結果才能實現。
因此,我們需要抽象出一套支援本地化配置的方案,該套方案需要儘可能滿足以下能力
  1. 對本地配置提供預設值支援,未做任何配置時,配置返回預設值。比如,預設環境為線上,如果沒有更改配置,就不會讀取到日常和預發環境。
  1. 簡化配置的讀寫介面,讓上層業務方儘可能少感知實現細節。比如,我們不需要讓上層感知到本地配置的持久化資訊寫入的是SharedPreferences還是SQLite,而只需提供一個寫入的API即可。
  1. 向上層暴露進入本地配置頁的API方式,以滿足上層選擇性進入的空間。比如,上層透過我們暴露出去的API可以選擇實際使用App過程中,是透過點選頁面的某個操作按鈕、還是手機的音量鍵或者是搖一搖來進入配置頁面。
  1. 對於App中是否擁有本地配置能力的控制,儘可能放到編譯構建級別,保證線上使用者不會進入到配置頁面。比如,如果線上使用者能夠進入到預發環境,那很可能會醞釀著一場安全事故即將發生。

版本管理

在移動客戶端中,Android應用不同於iOS只能在AppStore進行釋出,Android構建的產物.apk檔案支援直接安裝,這就給App靜默升級提供了可能。基於該特性,我們可以實現使用者在不透過應用市場即可直接檢測和升級新版本的訴求,縮短了使用者App升級的路徑,進而能夠提升新版本釋出時的覆蓋率。
我們需要在應用中考慮抽象出來版本檢測和升級的能力支援,這裡需要服務端提供檢測和獲取新版本App的介面。客戶端基於某種策略,如每次剛進入App、或手動點選新版本檢測時,呼叫服務端的版本檢測介面,以判斷當前App是否為最新版本。如果當前是新版本,則提供給App側最新版本的apk檔案下載連結,客戶端在後臺進行版本下載。下面總結出核心步驟的流程圖

日誌監控

環境隔離、本地持久化、日誌上報
客戶端的日誌監控主要用來排查使用者在使用App過程中出現的Crash等異常問題,對於日誌部分總結幾項值得注意的點
  1. 環境隔離,release包禁止log輸出;
  1. 本地持久化,對於關鍵重要日誌(如某個位置錯誤引起的Crash)要做本地持久化儲存;
  1. 日誌上報,在使用者授權允許的情況下,將暫存使用者本地的日誌進行上傳,並分析具體的操作鏈路;
這裡推薦兩個開源的日誌框架:
logger
timber

埋點統計

服務端能查詢到的是客戶端介面呼叫的次數和頻率,但無法感知到使用者具體的操作路徑。為了能夠更加清晰的瞭解使用者,進而分析分析產品的優劣勢和瓶頸點,我們可以將使用者在App上的核心操作路徑進行收集和上報。
比如,下面是一個電商App的使用者成交漏斗圖,透過客戶端的埋點統計能夠獲取到漏斗各層的資料,然後再透過資料製作做出視覺化報表。
分析以下漏斗,我們可以很明顯的看出成交流失的關鍵節點是在「進入商品頁」和「購買」之間,因此接下來就需要思考為什麼「進入商品頁」的使用者購買意願降低?是因為商品本身問題還是商品頁的產品互動問題?會不會是因為購買按鈕比較難點選?還是因為商品頁圖片太大導致商品介紹沒有展示?這些流失流量的頁面停留時長又是怎樣的?對於這些問題的思考,會進一步促使我們去在商品頁新增更多的ABTest和更細粒度的埋點統計分析。總結下來,埋點統計為使用者行為分析和產品最佳化提供了很重要的指引意義。
在技術側,對於該部分做出以下關鍵點總結
  1. 客戶端埋點一般分為P點(頁面級別)、E點(事件級別)和C點(自定義點);
  1. 埋點分為收集和上報兩個步驟,使用者量級較大時要注意對上報埋點進行合併、壓縮等最佳化處理;
  1. 埋點邏輯是輔邏輯,產品業務是主邏輯,客戶端資源緊張時要做好資源分配的權衡;

熱修復

熱修復(Hotfix)是一種對已釋出上線的App在不進行應用升級的情況下進行動態更新原始碼邏輯的技術方案。主要同於以下場景
  1. 應用出現重大缺陷並嚴重影響到使用者使用時,比如,在某些系統定製化較強的機型(如小米系列)上一旦進入商品詳情頁就出現應用Crash;
  1. 應用出現明顯阻塞問題並影響到使用者正常互動時,比如,在某些極端場景下,使用者無法關閉頁面對話方塊;
  1. 應用出現資損、客訴、輿論風波等產品形態問題,比如將價格單位“元”誤顯示為“分”;
有關熱修復相關的技術方案探究,可以延展出很大篇幅,本文的定位是Android專案整體的架構,因此不做詳細展開。

3  應用層

抽象和封裝

對於抽象和封裝,主要取決於我們日常Coding過程中對一些痛點和冗餘編碼的感知和思考能力。
比如,下面是一段Android開發過程中常寫的列表頁面的標準實現邏輯
publicclassGoodsListActivityextendsActivity{privatefinal List<GoodsModel> dataList = new ArrayList<>();private Adapter adapter;@OverrideprotectedvoidonCreate(@Nullable Bundle savedInstanceState){super.onCreate(savedInstanceState); setContentView(R.layout.activity_goods_list); RecyclerView recyclerView = findViewById(R.id.goods_recycler_view); recyclerView.setLayoutManager(new LinearLayoutManager(this)); adapter = new Adapter(); recyclerView.setAdapter(adapter);// 載入資料 dataList.addAll(...); adapter.notifyDataSetChanged(); }privateclassAdapterextendsRecyclerView.Adapter<ViewHolder> {@NonNull@Overridepublic ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int position){ LayoutInflater inflater = LayoutInflater.from(parent.getContext()); View view = inflater.inflate(R.layout.item_goods, parent, false);returnnew ViewHolder(view); }@OverridepublicvoidonBindViewHolder(@NonNull ViewHolder holder, int position){ GoodsModel model = dataList.get(position); holder.title.setText(model.title); holder.price.setText(String.format("%.2f", model.price / 100f)); }@OverridepublicintgetItemCount(){return dataList.size(); } }privatestaticclassViewHolderextendsRecyclerView.ViewHolder{privatefinal TextView title;privatefinal TextView price;publicViewHolder(View itemView){super(itemView); title = itemView.findViewById(R.id.item_title); price = itemView.findViewById(R.id.item_price); } }}
這段程式碼看上去沒有邏輯問題,能夠滿足一個列表頁的功能訴求。
面向RecyclerView框架層,為了提供框架的靈活和拓展能力,所以把API設計到足夠原子化,以支撐開發者千差萬別的開發訴求。比如,RecyclerView要做對多itemType的支援,所以內部要做根據itemType開分組快取vitemView的邏輯。
但實際業務開發過程中,就會拋開很多特殊性,我們頁面要展示的絕大多數列表都是單itemType的,在連續寫很多個這種單itemType的列表之後,我們就開始去思考一些問題
  1. 為什麼每個列表都要寫一個ViewHolder
  1. 為什麼每個列表都要寫一個Adapter
  1. 為什麼Adapter中對itemView的建立和資料繫結要在onCreateViewHolderonBindViewHolder兩個方法中分開進行?
  1. 為什麼Adapter每次設定資料之後,都要呼叫對應的notifyXXX方法?
  1. 為什麼Android上實現一個簡單的列表需要大幾十行程式碼量?這其中有多少是必需的,又有多少是可以抽象封裝起來的?
對於以上問題的思考最終引導我封裝了RecyclerViewHelper的輔助類,相對於標準實現而言,使用方可以省去繁瑣的AdapterViewGolder宣告,省去一些高頻且必需的程式碼邏輯,只需要關注最核心的功能實現,如下
publicclassGoodsListActivityextendsActivity{private RecyclerViewHelper<GoodsModel> recyclerViewHelper;@OverrideprotectedvoidonCreate(@Nullable Bundle savedInstanceState){super.onCreate(savedInstanceState); setContentView(R.layout.activity_goods_list); RecyclerView recyclerView = findViewById(R.id.goods_recycler_view); recyclerViewHelper = RecyclerViewHelper.of(recyclerView, R.layout.item_goods, (holder, model, position, itemCount) -> { TextView title = holder.getView(R.id.item_title); TextView price = holder.getView(R.id.item_price); title.setText(model.title); price.setText(String.format("%.2f", model.price / 100f)); });// 載入資料 recyclerViewHelper.addData(...); }}
上面只是一個引子,實際開發過程中我們會遇到非常多類似的情況,還有一些常見的封裝。比如,封裝全域性統一的BaseActivityBaseFragment,包含但不限於以下能力
  1. 頁面埋點,基於前面提到的埋點統計,在使用者進入、離開頁面等時機,將使用者頁面的互動情況進行收集上報;
  1. 公共UI,頁面頂部狀態列和ActionBar、頁面常用的下拉重新整理能力實現、頁面耗時操作時的載入進度條;
  1. 許可權處理,進入當前頁面所需要的許可權申請,使用者授權後的回撥邏輯和拒絕後的異常處理邏輯;
  1. 統一攔截,結合前面提到的統一攔截對進入頁面後新增支援動態配置互動的定製能力;

模組化

背景
這裡提到的模組化是指,基於App的業務功能對專案工程進行模組化拆分,主要為了解決大型複雜業務專案的協同開發困難問題。
在專案結構的改造如上圖,將原來承載所有業務的 app 模組拆分為 homegoodsmine等多個業務模組。
通用能力下沉
前面「抽象和封裝」章節提到的 BaseActivityBaseFragment 等通用業務能力在專案模組化之後,也需要同步做改造,要下沉到業務層中單獨的一個 base 模組中,以便提供給其他業務模組引用。
隱式路由改造
模組化之後,各模組間沒有相互依賴關係,此時跨模組進行頁面跳轉時不能直接引用其他模組的類。
比如,在首頁展示某一個商品推薦,點選之後要跳轉到商品詳情頁,在模組化之前的寫法是
Intent intent = new Intent(this, GoodsActivity.class);intent.putExtra("goodsId", model.goodsId);startActivity(intent);
但在模組化之後,在首頁模組無法引用 GoodsActivity 類,因此頁面跳轉不能再繼續之前的方式,需要對頁面進行隱式路由改造,如下
  1. 註冊 Activity 標識,在 AndroidManifest.xml 中註冊 Activity 的地方新增 action 標識 
<activityandroid:name=".GoodsActivity"><intent-filter><actionandroid:name="https://www.xxx.com/goods/detail" /><categoryandroid:name="android.intent.category.DEFAULT" /></intent-filter></activity>
  1. 替換跳轉邏輯,程式碼中根據上一步註冊的 Activity 標識進行隱式跳轉 
Intent intent = new Intent("https://www.xxx.com/goods/detail");intent.putExtra("goodsId", model.goodsId);startActivity(intent);
基於這兩步的改造, 便能夠達到模組化之後仍能正常跳轉業務頁面的目的。
更進一步,我們將隱式跳轉的邏輯進行抽象和封裝,提煉出一個專門提供隱式路由能力的靜態方法,參考如下程式碼
publicclass Router {/** * 根據url跳轉到目標頁面 * * @param context 當前頁面上下文 * @param url 目標頁面url */publicstaticvoid open(Context context, String url) {// 解析為Uri物件 Uri uri = Uri.parse(url);// 獲取不帶引數的urlString urlWithoutParam = String.format("%s://%s%s", uri.getScheme(), uri.getHost(), uri.getPath()); Intent intent = new Intent(urlWithoutParam);// 解析url中的引數, 並透過Intent傳遞至下個頁面for (String paramKey : uri.getQueryParameterNames()) {String paramValue = uri.getQueryParameter(paramKey); intent.putExtra(paramKey, paramValue); }// 執行跳轉操作 context.startActivity(intent); }}
此時外部頁面跳轉時,只需要透過如下一句呼叫即可
Router.open(this, "https://www.xxx.com/goods/detail?goodsId=" + model.goodsId);
這次封裝可以
  1. 抽象統一方法,降低外部編碼成本;
  1. 統一收口路由邏輯,便於結合前面的「動態配置」和「統一攔截」章節進行線上App路由邏輯的動態更改;
  1. 標準化 Android 端頁面跳轉引數格式,統一使用 String 型別,解除目標頁面解析引數時的型別判斷歧義;
  1. 對跳轉頁面所需要的資料做了標準化支援,iOS 端再配合同步改造後,頁面跳轉邏輯將支援完全由業務服務端進行下發;
模組通訊
模組化後另一個需要解決是模組通訊問題,沒有直接依賴關係的模組間是拿不到任何對方的 API 進行直接呼叫的。對於該問題往往會按照如下類別進行分析和處理
  1. 通知式通訊,只需要將事件告知對方,並不關注對方的響應結果。對於該種通訊,一般採用如下方式實現 
    1. 藉助 framework 層提供的透過 Intent + BroadcastReceiver (或 LocalBroadcastManager)傳送事件;
    2. 藉助框架 EventBus 傳送事件;
    3. 基於觀察者模式自實現訊息轉發器來發送事件;
  1. 呼叫式通訊,將事件告知對方,同時還關注對方的事件響應結果。對於該種通訊,一般採用如下方式實現 
    1. 定義出 biz-service 模組,將業務介面 interface 檔案收口到該模組,再由各介面對應語義的業務模組進行介面的實現,然後再基於某種機制(手動註冊或動態掃描)完成實現類的註冊; 
    2. 抽象出 Request => Response 的通訊協議,協議層負責完成 
    1. 先將透過呼叫方傳遞的 Request 路由到被呼叫方的協議實現層;
    2. 再將實現層返回結果轉化為泛化的 Response物件;
    3. 最後將 Response 返回給呼叫方;
相對於 biz-service,該方案的中間層不包含任何業務語義,只定義泛化呼叫所需要的關鍵引數。 

4  跨平臺層

跨平臺層,主要是為了提高開發人效,一套程式碼能夠在多平臺執行。
跨平臺一般有兩個接入的時機,一個是在最開始的前期專案調研階段,直接技術選型為純跨平臺技術方案;另一個是在已有Native工程上需要整合跨平臺能力的階段,此時App屬於混合開發的模式,即Native + 跨平臺相結合。
有關更多跨平臺的選型和細節不在本文範疇內,具體可以參考《移動跨平臺開發框架解析與選型》,文中對於整個跨平臺技術的發展、各框架原理及優劣勢講得很詳細。參跨平臺技術演進圖
對於目前主流方案的對比可參考下表
型別
Weex
React Native
Uniapp
Flutter
效能
較高
語言
JavaScript
JavaScript
JavaScript
Drat
社群

活躍度中

目前託管Apache

活躍度高

Facebook

維護

活躍度高

Dcloud

維護

活躍度高

Google

維護
支援平臺
Android
、iOS
、Web
Android
、iOS
、Web
Android
、iOS
、Web
小程式
、快應用
Android
、iOS
、Web
MacOS
、Linux
Windows
、Fuchsia
上面對專案架構中各層的主要模組進行了逐一的拆解和剖析,接下來會重點對架構設計和實際開發中用到的一些非常核心的原理進行總結和梳理。

四  核心原理總結

在Android開發中,我們會接觸到的框架不計其數,並且這些框架還還在不斷的更新迭代,因此我們很難對每個框架都能瞭如指掌。
但這並不影響我們對Android中核心技術學習和研究,如果你有嘗試過深入剖析這些框架的底層原理,就會發現它們中很多原理都是相通的。一旦我們掌握了這些核心原理,就會發現絕大多數框架只不過是利用這些原理,再結合框架要解決的核心問題,進而包裝出來的通用技術解決方案。
下面我把在SDK框架和實際開發中一些高頻率使用的核心原理進行梳理和總結。

1  雙快取

雙快取是指在透過網路獲取一些資源時,為提高獲取速度而在記憶體和磁碟新增雙層快取的技術方案。該方案最開始主要用於上文「圖片模組」提到的圖片庫中,圖片庫利用雙快取來極大程度上提高了圖片的載入速度。一個標準雙快取方案如下圖示
雙快取方案的核心思想就是,對時效性低或更改較少的網路資源,儘可能採取用空間換時間的方式。我們知道一般的資料獲取效率:記憶體 > 磁碟 > 網路,因此該方案的本質就是將獲取效率低的渠道向效率高的取到進行資源複製。
基於該方案,我們在實際開發中還能拓展另一個場景,對於業務上一些時效性低或更改較少的介面資料,為了提高它們的載入效率,也可以結合該思路進行封裝,這樣就將一個依賴網路請求頁面的首幀渲染時長從一般的幾百ms降到幾十ms以內,最佳化效果相當明顯。

2  執行緒池

執行緒池在Android開發中使用到的頻率非常高,比如
  1. 在開發框架中,網路庫和圖片庫獲取網路資源需用到執行緒池;
  1. 在專案開發中,讀寫SQLite和本地磁碟檔案等IO操作需要用到執行緒池;
  1. 在類似於AsyncTask這種提供任務排程的API中,其底層也是依賴執行緒池來完成的;
如此多的場景會用到執行緒池,如果我們希望對專案的全域性觀把握的更加清晰,熟悉執行緒池的一些核心能力和內部原理是尤為重要的。
就其直接暴露出來的API而言,最核心的方法就兩個,分別是執行緒池構造方法和執行子任務的方法。
// 構造執行緒池ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, keepAliveTimeUnit, workQueue, threadFactory, rejectedExecutionHandler);// 提交子任務executor.execute(new Runnable() {@Overridepublicvoidrun(){// 這裡做子任務操作 }});
其中,提交子任務就是傳入一個 Runnable 型別的物件例項不做贅述,需要重點說明也是執行緒池中最核心的是構造方法中的幾個引數。
// 核心執行緒數int corePoolSize = 5;// 最大執行緒數int maximumPoolSize = 10;// 閒置執行緒保活時長int keepAliveTime = 1;// 保活時長單位TimeUnit keepAliveTimeUnit = TimeUnit.MINUTES;// 阻塞佇列BlockingDeque<Runnable> workQueue = new LinkedBlockingDeque<>(50);// 執行緒工廠ThreadFactory threadFactory = new ThreadFactory() {@Overridepublic Thread newThread(Runnable r){returnnew Thread(r); }};// 任務溢位的處理策略RejectedExecutionHandler rejectedExecutionHandler = new ThreadPoolExecutor.AbortPolicy();
網上有關執行緒池的文章和教程有很多,這裡不對每個具體引數做重複表述;但我下面要把對理解執行緒池內部原理至關重要的——子任務提交後的扭轉機制進行單獨說明。
上圖表明的是往執行緒池中不斷提交子任務且任務來不及執行時執行緒池內部對任務的處理機制,該圖對理解執行緒池內部原理和配置執行緒池引數尤為重要。

3  反射和註解

反射和註解都是Java語言裡一種官方提供的技術能力,前者用來在程式執行期間動態讀寫物件例項(或靜態)屬性、執行物件(靜態)方法;後者用來在程式碼處往類、方法、方法入參、類成員變數和區域性變數等指定域新增標註資訊。
透過反射和註解技術,再結合對程式碼的抽象和封裝思維,我們可以非常靈活的實現很多泛化呼叫的訴求,比如
  1. 前面「熱修復」章節,基於ClassLoader 的方案,其內部實現幾乎全是透過反射進行 dex 更改的;
  1. 前面「網路模組」章節,Retorfit 只需要宣告一個介面再添加註解即可使用,其底層也是利用了反射註解和下面要介紹的動態代理技術;
  1. 依賴注入框架 dagger 和 androidannotations 利用 JavaAPT 預編譯技術再結合編譯時註解做注入程式碼生成;
  1. 如果瞭解 Java 服務端開發,主流的開發框架 SpringBoot 其內部大量運用了注射和註解的技術;
反射和註解在開發中適用的場景有哪些?下面列舉幾點
依賴注入場景
普通方式
publicclassDataManager {private UserHelper userHelper = new UserHelper();private GoodsHelper goodsHelper = new GoodsHelper();private OrderHelper orderHelper = new OrderHelper();}
注入方式
publicclassDataManager{@Injectprivate UserHelper userHelper;@Injectprivate GoodsHelper goodsHelper;@Injectprivate OrderHelper orderHelper;publicDataManager(){// 注入物件例項 (內部透過反射+註解實現) InjectManager.inject(this); }}
注入方式的優勢是,對使用方遮蔽依賴物件的例項化過程,這樣方便對依賴物件進行統一管理。
呼叫私有或隱藏API場景
有個包含私有方法的類。
publicclassManager {privatevoiddoSomething(String name) {// ... }}
我們拿到 Manager 的物件例項後,希望呼叫到 doSomething 這個私有方法,按照一般的呼叫方式如果不更改方法為 public 就是無解的。但利用反射可以做到
try { Class<?> managerType = manager.getClass(); Method doSomethingMethod = managerType.getMethod("doSomething", String.class); doSomethingMethod.setAccessible(true); doSomethingMethod.invoke(manager, "<name引數>");} catch (Exception e) { e.printStackTrace();}
諸如此類的場景在開發中會有很多,可以說熟練掌握反射和註解技術,既是掌握 Java 高階語言特性的表現,也能夠讓我們在對一些通用能力進行抽象封裝時提高認知和視角。

4  動態代理

動態代理是一種能夠在程式執行期間為指定介面提供代理能力的技術方案。
在使用動態代理時,通常都會伴隨著反射和註解的應用,但相對於反射和註解而言,動態代理的作用相對會比較晦澀難懂。下面結合一個具體的場景來看動態代理的作用。

背景

專案開發過程中,需要呼叫到服務端介面,因此客戶端封裝一個網路請求的通用方法。
publicclassHttpUtil{/** * 執行網路請求 * * @param relativePath url相對路徑 * @param params 請求引數 * @param callback 回撥函式 * @param <T> 響應結果型別 */publicstatic <T> voidrequest(String relativePath, Map<String, Object> params, Callback<T> callback){// 實現略.. }}
由於業務上有多個頁面都需要查詢商品列表資料,因此需要封裝一個 GoodsApi 的介面。
publicinterfaceGoodsApi{/** * 分頁查詢商品列表 * * @param pageNum 頁面索引 * @param pageSize 每頁資料量 * @param callback 回撥函式 */voidgetPage(int pageNum, int pageSize, Callback<Page<Goods>> callback);}
並針對於該介面新增 GoodsApiImpl 實現類。
publicclassGoodsApiImplimplementsGoodsApi { @OverridepublicvoidgetPage(int pageNum, int pageSize, Callback<Page<Goods>> callback) { Map<String, Object> params = new HashMap<>();params.put("pageNum", pageNum);params.put("pageSize", pageSize); HttpUtil.request("goods/page", params, callback); }}
基於當前封裝,業務便能夠直接呼叫。
GoodsApi goodsApi = new GoodsApiImpl();goodsApi.getPage(1, 50, new Callback<Page<Goods>>() {@OverridepublicvoidonSuccess(Page<Goods> data){// 成功回撥 }@OverridepublicvoidonError(Error error){// 失敗回撥 }});

問題

業務需要再新增如下的查詢商品詳情介面。
/** * 查詢商品詳情 * * @param id 商品ID * @param callback 回撥函式 */voidgetDetail(long id, Callback<Goods> callback);
我們需要在實現類新增實現邏輯。
@OverridepublicvoidgetDetail(long id, Callback<Goods> callback) { Map<String, Object> params = new HashMap<>();params.put("id", id); HttpUtil.request("goods/detail", params, callback);}
緊接著,又需要新增 create update 介面,我們繼續實現。
@Overridepublicvoidcreate(Goods goods, Callback<Goods> callback) { Map<String, Object> params = new HashMap<>();params.put("goods", goods); HttpUtil.request("goods/create", params, callback);}@Overridepublicvoidupdate(Goods goods, Callback<Void> callback) { Map<String, Object> params = new HashMap<>();params.put("goods", goods); HttpUtil.request("goods/update", params, callback);}
不僅如此,接下來還要加 OrderApiContentApiUserApi 等等,並且每個類都需要這些列表。我們會發現業務每次需要新增新介面時,都得寫一遍對 HttpUtil#request 方法的呼叫,並且這段呼叫程式碼非常機械化。

分析

前面提到介面實現程式碼的機械化,接下來我們嘗試著將這段機械化的程式碼,抽象出一個虛擬碼的呼叫模板,然後進行分析。
Map<String, Object> params = new HashMap<>();// 遍歷當前方法引數, 執行以下語句 params.put("<引數名>", <引數值>); HttpUtil.request("<介面路徑>", params, callback);
透過每個方法內部程式碼實現的現象看其核心的本質,我們可以抽象歸納為以上的“模板”邏輯。
有沒有一種技術可以讓我們只需要寫網路請求所必需的請求協議相關引數,而不需要每次都要做以下幾步重複瑣碎的編碼?
  1. 手動寫一個 Map
  1. 往 Map 中塞引數鍵值對;
  1. 呼叫 HttpUtil#request 執行網路請求;
此時動態代理便能解決這個問題。

封裝

分別定義路徑和引數註解。
@Target({ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)public @interface Path {/** * @return 介面路徑 */Stringvalue();}
@Target({ElementType.PARAMETER})@Retention(RetentionPolicy.RUNTIME)public @interface Param {/** * @return 引數名稱 */Stringvalue();}
基於這兩個註解,便能封裝動態代理實現(以下程式碼為了演示核心鏈路,忽略引數校驗和邊界處理邏輯)。
@SuppressWarnings("unchecked")publicstatic <T> T getApi(Class<T> apiType) {return (T) Proxy.newProxyInstance(apiType.getClassLoader(), new Class[]{apiType}, new InvocationHandler() { @Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {// 解析介面路徑 String path = method.getAnnotation(Path.class).value();// 解析介面引數 Map<String, Object> params = new HashMap<>(); Parameter[] parameters = method.getParameters();// 注: 此處多偏移一位, 為了跳過最後一項callback引數for (int i = 0; i < method.getParameterCount() - 1; i++) { Parameter parameter = parameters[i]; Param param = parameter.getAnnotation(Param.class);params.put(param.value(), args[i]); }// 取最後一項引數為回撥函式 Callback<?> callback = (Callback<?>) args[args.length - 1];// 執行網路請求 HttpUtil.request(path, params, callback);returnnull; } });}

效果

此時需要透過註解在介面宣告處新增網路請求所需要的必要資訊。
publicinterfaceGoodsApi{@Path("goods/page") void getPage(@Param("pageNum") int pageNum, @Param("pageNum") int pageSize, Callback<Page<Goods>> callback);@Path("goods/detail") void getDetail(@Param("id") long id, Callback<Goods> callback);@Path("goods/create") void create(@Param("goods") Goods goods, Callback<Goods> callback);@Path("goods/update") void update(@Param("goods") Goods goods, Callback<Void> callback);}
外部透過 ApiProxy 獲取介面例項。
// 之前GoodsApi goodsApi = new GoodsApiImpl();// 現在GoodsApi goodsApi = ApiProxy.getApi(GoodsApi.class);
相比之前,上層的呼叫方式只有極小的調整;但內部的實現卻有了很大的改進,直接省略了所有的介面實現邏輯,參考如下程式碼對比圖。
前面講了架構設計過程中涉及到的核心框架原理,接下來會講到架構設計裡的通用設計方案。

五  通用設計方案

我們進行架構設計的場景下通常是不同的,但有些問題的底層設計方案是相通的,這一章節會對這些相通的設計方案進行總結。

通訊設計

一句話概括,通訊的本質就是解決 和 B 之間如何呼叫的問題,下面按抽象出來的 AB 模型依賴關係進行逐一分析。

直接依賴關係

關係正規化:A => B
這是最常見的關聯關係,A 類中直接依賴 B,只需要透過最基本的方法呼叫和設定回撥即可完成。
場景
頁面  Activity (A)與按鈕 Button (B)的關係。
參考程式碼
publicclassMainActivityextendsActivity{@OverrideprotectedvoidonCreate(Bundle savedInstanceState){super.onCreate(savedInstanceState);// 略...// A呼叫B button.setText(""); button.setOnClickListener(new View.OnClickListener() {@OverridepublicvoidonClick(View v){// B回撥A } }); }}

間接依賴關係

關係正規化:A => C => B
通訊方式同直接依賴,但需要新增中間層進行透傳。
場景
頁面 Activity(A)中有商品卡片檢視 GoodsCardView(C),商品卡片中包含關注按鈕 Button(B)
參考程式碼C 與 B 通訊
publicclassGoodsCardViewextendsFrameLayout{privatefinal Button button;private OnFollowListener followListener;publicGoodsCardView(Context context, AttributeSet attrs){super(context, attrs);// 略... button.setOnClickListener(new View.OnClickListener() {@OverridepublicvoidonClick(View v){if (followListener != null) {// C回撥B followListener.onFollowClick(); } } }); }publicvoidsetFollowText(String followText){// C呼叫B button.setText(followText); }publicvoidsetOnFollowClickListener(OnFollowListener followListener){this.followListener = followListener; }}
A 與 C 通訊
publicclassMainActivityextendsActivity{private GoodsCardView goodsCard;@OverrideprotectedvoidonCreate(Bundle savedInstanceState){super.onCreate(savedInstanceState);// 略...// A呼叫C goodsCard.setFollowText("點選商品即可關注"); goodsCard.setOnFollowClickListener(new OnFollowListener() {@OverridepublicvoidonFollowClick(){// C回撥A } }); }}

組合關係

關係正規化:A <= C => B
通訊方式和間接依賴類似,但其中一方的呼叫順序需要倒置。
場景
頁面 Activity(C)中包含列表 RecyclerView(A)和置頂圖示 ImageView(B),點選置頂時,列表需要滾動到頂部。
參考程式碼
publicclassMainActivityextendsActivity{private RecyclerView recyclerView;private ImageView topIcon;@OverrideprotectedvoidonCreate(Bundle savedInstanceState){super.onCreate(savedInstanceState);// 略... topIcon.setOnClickListener(new View.OnClickListener() {@OverridepublicvoidonClick(View v){// B回撥C onTopIconClick(); } }); recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {@OverridepublicvoidonScrollStateChanged(RecyclerView recyclerView, int newState){// A回撥Cif (newState == RecyclerView.SCROLL_STATE_IDLE) { LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager(); onFirstItemVisibleChanged(layoutManager.findFirstVisibleItemPosition() == 0); } } }); }privatevoidonFirstItemVisibleChanged(boolean visible){// C呼叫B topIcon.setVisibility(visible ? View.GONE : View.VISIBLE); }privatevoidonTopIconClick(){// C呼叫A recyclerView.scrollToPosition(0);// C呼叫B topIcon.setVisibility(View.GONE); }}

深依賴/組合關係

關係正規化:A => C => ··· => BA <= C => ··· => B
當依賴關係隔了多層時,直接使用普通的呼叫和設定回撥這種通訊方式,程式碼會變得非常冗餘,中間層大多都是做資訊透傳邏輯。此時採取另一種方式,透過事件管理器進行事件的分發。
場景
頁面元件化之後,元件 A 需要通知元件 B 某個事件。
參考程式碼
事件管理器
publicclassEventManagerextendsObservable<EventManager.OnEventListener> {publicinterfaceOnEventListener{voidonEvent(String action, Object... args); }publicvoiddispatch(String action, Object... args){synchronized (mObservers) {for (OnEventListener observer : mObservers) { observer.onEvent(action, args); } } }}
A 呼叫 X
publicclassAComponent{publicstaticfinal String ACTION_SOMETHING = "a_do_something";privatefinal EventManager eventManager;publicAComponent(EventManager eventManager){this.eventManager = eventManager; }publicvoidsendMessage(){// A呼叫X eventManager.dispatch(ACTION_SOMETHING); }}
X 分發 B
publicclassBComponent{privatefinal EventManager eventManager;publicBComponent(EventManager eventManager){this.eventManager = eventManager; eventManager.registerObserver(new EventManager.OnEventListener() {@OverridepublicvoidonEvent(String action, Object... args){if (AComponent.ACTION_SOMETHING.equals(action)) {// X分發B } } }); }}

無關係

關係正規化:AB
這裡指的是狹義概念的無關係,因為廣義概念上如果兩者之間沒有任何關聯關係,那它們是永遠無法通訊的。
該種關係的通訊也是藉助於事件管理器,唯一不同點是對於 EventManager 物件例項的獲取方式不同了,不再是直接由當前上下文獲取到的,而是來源於全域性唯一的例項物件,比如從單例中獲取到。

可拓展回撥函式設計

背景

當我們封裝一個SDK,需要對外部添加回調函式,如下。
回撥函式
publicinterfaceCallback {voidonCall1();}
SDK核心類
publicclassSDKManager {private Callback callback;publicvoidsetCallback(Callback callback) {this.callback = callback; }privatevoiddoSomething1() {// 略...if (callback != null) { callback.onCall1(); } }}
外部客戶呼叫
SDKManager sdkManager = new SDKManager();sdkManager.setCallback(new Callback() {@OverridepublicvoidonCall1(){ }});

問題

以上是很常見的一種回撥設定方式,如果僅僅是做業務開發,這種寫法沒有任何問題,但如果是做成給外部客戶使用的SDK,這種做法就會存在瑕疵。
按照這種寫法,假如SDK已經提供出去給外部客戶使用了,此時需要增加一些回撥給外面。
publicinterfaceCallback {voidonCall1();voidonCall2();}
如果這樣添加回調,外部升級時就無法做到無感知升級,下面程式碼就會報錯需要新增額外實現。
sdkManager.setCallback(new Callback() {@OverridepublicvoidonCall1(){ }});
不想讓外部感知,另一個方案就是新建一個介面。
publicinterfaceCallback2 {voidonCall2();}
然後在SDK中新增對該方法的支援。
publicclassSDKManager {// 略..private Callback2 callback2;publicvoidsetCallback2(Callback2 callback2) {this.callback2 = callback2; }privatevoiddoSomething2() {// 略...if (callback2 != null) { callback2.onCall2(); } }}
對應的,外部呼叫時需要添加回調函式的設定。
sdkManager.setCallback2(new Callback2() {@OverridepublicvoidonCall2(){ }});
這種方案確實能解決外部無法靜默升級SDK的問題,但卻會帶來另外的問題,隨著每次介面升級,外部設定回撥函式的程式碼將會越來越多。

對外最佳化

對於該問題,我們可以設定一個空的回撥函式基類。
publicinterfaceCallback {}
SDK回撥函式都繼承它。
publicinterfaceCallback1extendsCallback{voidonCall1();}publicinterfaceCallback2extendsCallback{voidonCall2();}
SDK內部設定回撥時接收基類回撥函式,回撥時根據型別判斷。
publicclassSDKManager{private Callback callback;publicvoidsetCallback(Callback callback){this.callback = callback; }privatevoiddoSomething1(){// 略...if ((callback instanceof Callback1)) { ((Callback1) callback).onCall1(); } }privatevoiddoSomething2(){// 略...if ((callback instanceof Callback2)) { ((Callback2) callback).onCall2(); } }}
再向外部提供一個回撥函式的空實現類。
publicclassSimpleCallbackimplementsCallback1, Callback2{@OverridepublicvoidonCall1(){ }@OverridepublicvoidonCall2(){ }}
此時,外部可以選擇透過單介面、組合介面和空實現類等多種方式設定回撥函式。
// 單介面方式設定回撥sdkManager.setCallback(new Callback1() {@OverridepublicvoidonCall1(){// .. }});// 組合介面方式設定回撥interfaceCombineCallbackextendsCallback1, Callback2{} sdkManager.setCallback(new CombineCallback() {@OverridepublicvoidonCall1(){// .. }@OverridepublicvoidonCall2(){// ... }});// 空實現類方式設定回撥sdkManager.setCallback(new SimpleCallback() {@OverridepublicvoidonCall1(){// .. }@OverridepublicvoidonCall2(){//.. }});
現在如果SDK再拓展回撥,只需要新增新回撥介面。
publicinterfaceCallback3extendsCallback{voidonCall3();}
內部新增新回撥邏輯。
privatevoiddoSomething3(){// 略...if ((callback instanceof Callback3)) { ((Callback3) callback).onCall3(); }}
這時候再升級SDK,對外部客戶之前的呼叫邏輯沒有任何影響,能夠很好的做到向前相容。

對內最佳化

經過前面的最佳化,外部已經做到不感知SDK變化了;但是內部有些程式碼還比較冗餘,如下。
privatevoiddoSomething1(){// 略...if ((callback instanceof Callback1)) { ((Callback1) callback).onCall1(); }}
SDK每次對外部回撥的時候都要新增這種判斷著實麻煩,我們接下來將這段判斷邏輯單獨封裝起來。
publicclassCallbackProxyimplementsCallback1, Callback2, Callback3{private Callback callback;publicvoidsetCallback(Callback callback){this.callback = callback; }@OverridepublicvoidonCall1(){if (callback instanceof Callback1) { ((Callback1) callback).onCall1(); } }@OverridepublicvoidonCall2(){if (callback instanceof Callback2) { ((Callback2) callback).onCall2(); } }@OverridepublicvoidonCall3(){if (callback instanceof Callback3) { ((Callback3) callback).onCall3(); } }}
接下來SDK內部就可以直接呼叫對應方法而不需要各種冗餘的判斷邏輯了。
publicclassSDKManager{privatefinal CallbackProxy callbackProxy = new CallbackProxy();publicvoidsetCallback(Callback callback){ callbackProxy.setCallback(callback); }privatevoiddoSomething1(){// 略... callbackProxy.onCall1(); }privatevoiddoSomething2(){// 略... callbackProxy.onCall2(); }privatevoiddoSomething3(){// 略... callbackProxy.onCall3(); }}
六  總結
做好專案的架構設計需要我們考慮到技術選型、業務現狀、團隊成員、未來規劃等很多方面,並且伴隨著業務的發展,還需要在專案不同階段對工程和程式碼進行持續化重構。
業務領域千差萬別,可能是電商專案,可能是社交專案,還有可能是金融專案;開發技術也一直在快速迭代,也許在用純 Native 開發模式,也許在用 FlutterRN 開發模式,也許在用混合開發模式;但無論如何,這些專案在架構設計方面的底層原理和設計思路都是萬變不離其宗的,這些東西也正是我們真正要學習和掌握的核心能力。
相關連結:
《Android 中子執行緒真的不能更新 UI 嗎?》:https://juejin.cn/post/6844904131136618510
《移動跨平臺開發框架解析與選型》:https://segmentfault.com/a/1190000039122907

大資料Impala教程

本課程講解了大資料分散式計算的發展及Impala的應用場景,對比Hive、MapReduce、Spark等類似框架講解了記憶體式計算原理,以及如何基於Impala構建高效能互動式SQL分析平臺。點選閱讀原文檢視詳情。

相關文章