這篇是「FluxJava: 給 Java 使用的 Flux 函式庫」的延續,會透過建構一個示範的 Todo App 的過程來說明如何使用 FluxJava,所有示範的原始碼都可以在 Github 上找到。
Flux 簡介
為了方便不熟悉 Flux 的讀者,一開始會先簡短地說明這個架構。以下是借用 Facebook 在 Flux 官網上的原圖:
從圖上可以看到所有箭頭都是單向的,且形成一個封閉的循環。這一個循環代表的是資料在 Flux 架構中流動的過程,整個流程以 Dispatcher 為集散中心。
Action 大多是由與使用者互動的畫面元件所發起,在透過某種 Creator 的方法產生之後被送入 Dispatcher。此時,已經有跟 Dispatcher 註冊過的 Store 都會被呼叫,並且經由預先在 Store 上定義好的 Method 接收到 Action。Store 會在這個接收到 Action 的 Method 中,將資料的狀態依照 Action 的內容來進行調整。
前端的畫面元件或是負責控制畫面的元件,會收到由 Store 在處理完資料後所送出的異動事件,異動的事件就是用來代表在資料層的資料狀態已經不一樣了。這些前端元件在自行訂義的事件處理方法中聆聽、截收這些事件,並且在事件收到後由 Store 獲取最新的資料狀態。最後前端元件觸發自己內部的更新畫面程序,讓畫面上所有階層子元件都能夠反應新的資料狀態,如此完成一個資料流動的循環。
以上是一個很簡單的說明,如果需要了解更進一步的內容,可以自行上網搜尋,現在網路上應該已經有為數不少的文章可以參考。
以 BDD 來做為需求的開端
為了能夠更清楚的說明程式碼的細節,所以文章中會依照 BDD 的概念來逐步解說程式碼。所以首先是要先列出需求:
- 顯示 Todo 清單
- 在不同使用者間切換 Todo 清單
- 新增 Todo
- 關閉/重啟 Todo
接著下來就要把需求轉更明確的敘述內容,因為只是示範,所列僅做出一個 Story 做為目標:
Story: Manage todo items Narrative: As a user I want to manage todo items So I can track something to be done Scenario 1: Add a todo When I tap the add menu on main activity Then I see the add todo screen When I input todo detail and press ADD button Then I see a new entry in list Scenario 2: Add a todo, but cancel the action When I tap the add menu on main activity And I press cancel button Then Nothing happen Scenario 3: Switch user When I select a different user Then I see the list changed Scenario 4: Mark a todo as done When I mark a todo as done Then I see the todo has a check mark and strike through on title
也因為只是示範用,Story 的內容並沒有很嚴謹,而示範所使用的文字雖然是英文,但在實際的案例上用自己習慣的文字即可。
規劃測試的方略
既然是採用 BDD 來做示範,當然在程式撰寫的過程中會希望能夠有適切的工具的輔助,畢竟工欲善其事、必先利其器。所以在撰寫測試程式碼時,不使用 Android 範本中的 JUnit,改為使用在「使用 Android Studio 開發 Web 程式 - 測試」提到的 Spock Framework,並且全部以 Groovy 來撰寫。因為 Spock Framework 內建了支援 BDD 的功能,把之前做好的 Story 轉成測試程式碼的工作也會簡化很多。
接下來要決定如何在 Android 專案中定位與分配測試程式碼。Espresso 是 Android 開發環境中內建的,使用 Espresso 來開發測試程式還是有一定的必要性。只不過 Espresso 必須要跑在實機或模擬的環境上,執行效率問題是無法被忽視的一個因素,而且 androidTest 下的程式碼其執行結果也沒有辦法在 Android Studio 中檢視 Code Coverage 的狀態,所以用 Espresso 撰寫的測試程式並不適合用來做為 Unit Test。
再加上新版的 Android Studio 提供了錄製測試步驟的功能,最後會被轉成 Espresso 的程式碼。所以看起來 Espresso 比較適合用來做為開發程流後段的一些測試工作,像是 UAT、壓力測試、穩定度測試。依據這樣的定位,之前寫好的 Story 會在 Espresso 上轉成測試程式碼,來驗證程式的功能是否有達到 Story 描述的內容。
單元測試的部份沒有疑問地應該是寫在 Android 專案範本所提供的 test 的路徑之下,要解決的是 Android 元件如何在 JVM 的環境中執行測試。大部份人目前的選擇應該都會是 Robolectric,只不過測試程式碼要使用 Spock 來開發,所以這二個套件必須要做個整合。RoboSpock 就是提供此一解決方案的套件,可以讓 Robolectric 在基於 Spock 所開發的 Class 中可以被直接使用。
使用 Robolectric 雖然能夠對 Android 元件在 JVM 中進行測試,但畢竟這類的元件相互之間的藕合性還是有點高,尤其是提供畫面的元件。所以這個部分在歸類上我定位成 Integration Test,但在資料的供給上,拜 Flux 架構之賜,可以依照情境來進行代換,只測試 Android 元件與元件之間的整合度,這個部份在接下來的內容會進行說明。附帶一提,有關測試上的一些想法我有寫成一篇文章,可以參考:「軟體測試經驗分享」。
以下列出本次使用的測試套件清單:
- Groovy
- Spock Framework
- RoboSpock
- Espresso
設定 Spock 與 Espresso、Robolectric 時會有一些細節需要注意,相關的說明請參考「設定 build.gradle 來用 Spock 對 Android 元件進行測試」。最後的 build.gradle 設定結果,可以在 Github 上的檔案內容中看到。
建立畫面配置
在產生完 Android 專案空殼後,首先修改 MainActivity 的內容。在 MainActivity 畫面中加上 RecyclerView 及 Spinner 來顯示 Todo 清單以及提供切換使用者的功能。Layout 的配置顯示如下:
開發 UAT
原本範本中預設產生的 androidTest/java 的路徑可以刪除,另外要在 androidTest 之下增加一個 groovy 的目錄,如果 build.gradle 有設定正確,在 groovy 目錄上應該會出現代表測試程式碼的底色。因為目前只有一個 Story 所以在 groovy 路徑下配對的 Package 中增加一個 ManageTodoStory.groovy 的檔案。
在這裡就可以顯現 Spock 所帶來的優勢,把之前的 Story 內容轉成以下的程式碼,與原本的 Story 比對並沒有太大的差距。
如果 Story 是用中文寫成的,以上的套用方式還是適用的。有關 Spock 的使用方式在這裡就不詳細地說明,各位可以自行上網搜尋,或是參考我之前寫的這一篇及這一篇有關 Spock 的文章。接著就是把程式碼填入,完成後的內容如下所示:
以上程式碼中使用到的資源當然是要在撰寫之前就事件準備好,否則出現錯誤的訊息。完成後先執行一次測試,當然結果都是失敗的,接下來就可以依照需求來逐項開發功能。
撰寫 Bus
依照 Flux 架構,需要為整個資料循環建立 Dispatcher。但是在 FluxJava 中 Dispatcher 的功能是以 Bus 的方式實作,所以實際上是要先準備 Bus 的 Class。在這次的示範中使用 greenrobot 的 EventBus 來簡化開發工作,並且包裝在實作 IFluxBus 的 Interface 內,以便整合進 FluxJava 的 Framework 內。程式碼的內容如下:
與 Bus 搭配的測試用 Class 的內容如下:
執行以上 Class 之後,確認測試通過並檢視 Code Coverage。如果測得到的程式碼都有被涵蓋,就可以確認目前完成的程式有一定的穩定度,可以繼續往下進行接下來的工作。接下來的幾個小節都會採用這樣的工作節奏來逐步推進,以其望在程式完成時能夠有一定基礎的品質。
準備 Model
定義常數
常數主要的作用是以不同的數值來區分不同的資料種類,以及每一個資料種類因應需求所必須提供的功能。如同以下所展示的程式碼內容:
在需求中提到需要處理二種類型的資料,所以就分別定義了 DATA_USER 及 DATA_TODO 來代表使用者及 Todo。以 User 的需求來看,在畫面上只會有載入資料的要求,以提供切換使用者的功能,所以 User 的動作只定義了 USER_LOAD。而 Todo 的需求就比較複雜,除了載入資料以外,還要可以新增、關閉 Todo。所以目前定義 TODO_LOAD、TODO_ADD、TODO_CLOSE 等三個常數。
這些常數接下來會被用在 StoreMap 的鍵值及 Action 的 Type。在 FluxJava 中並沒有限定只能使用數值型別來做為鍵值,可以根據每個專案的特性來設定,可以是字串、型別或是同一個型別不同的 Instance。
撰寫 Action 及 Store
UserAction 和 TodoAction 都是很直接地繼承自 FluxAction。其中比較特別是:考量到一次可能會要處理多筆資料,所以在 Data 屬性的泛型上使用 List 來做為承載資料的基礎。這二個 Class 的內容請直接連上 Github 的 UserAction.java 及 TodoAction.java 二個檔案查詢。
Store 可以繼承 FluxJava 內建的 FluxStore,或是自行實作 IFluxStore 的 Interface。在 IFluxStore 中 register 及 unregister 是提供給前端的畫面元件,做為向 Store 登記要接收到資料異動事件之用。
Tag 則是考量到同一個 Store 有可能要產生多個 Instance 來服務不同的畫面元件,所以仿照 Android 元件的方式,用 Tag 來識別不同的 Instance。像是在同一個畫面中,可能會因為需求的關係,要使用不同條件所產生的清單來呈現圖表。這時就有必要使用二個不同的 Instance 來提供資料,否則會造成畫面上資料的混亂。
至於 getItem、findItem、getCount 都是很基本在呈現資料內容時需要使用到的功能。其中 getItem 之所以限定一次只取得一筆資料,而不是以 List 的方式傳回,主要是為了符合 Flux 單向資料流的精神。如果 getItem 傳回的是 List,前端很有可能意外地異動了清單的內容,根據 Java 的特性,這樣的異動結果也會反應在 Store 所提供的資訊上。也就等於資料的清單在 Store 以外,也有機會被異動,這就違反了 Flux 在設計上所想要達成的資料流動過程。
當然,就算是只提供一項資料,前端也許改不了整個清單,但還是可以修改所收到的這單一項目,其結果一樣會反應回 Store 的內部。所以在示範的程式碼中,在 getItem 所傳回的是一個全新的 Instance。
在 Store 中有一個關鍵的 Method 是 FluxStore 中沒有、要自行增加的,那就是用來接收前端所推送出來的 Action。由於目前使用的是 EventBus,以 UserStore 為例,會有以下的內容:
可以看到之前定義的常數在這裡派上用場了,利用 Action 的 Type 可以區分出前端所接收到的指令。在這個 Demo 中,Store 的定位只是用來管理清單,清單的資料會由 ActionCreator 傳入,所以可以看到程式碼中只是做很簡單的載入工作,載入完即發出資料異動的事件。這個事件是定義在 Store 內部,每個 Store 都有定義自己的 Event,以便讓前端元件判別與過濾所想收到的 Event 種類。
在以上的 Method 程式碼中,使用了 EventBus 所提供的功能,在接收到 Action 的當下是以背景的 Thread 在執行,避免因為過長的資料處理時間導至前端畫面凍結。Method 的參數則是用以過濾 Action,讓指定的 Action 型別在 Bus 中被傳遞時才呼叫 Mehtod,減少程式碼判斷上的負擔。如果是同一個 Store 有多個 Instace 同時存在,在接收到的 Action 中可以加入 Tag 的資訊,以便讓 Store 判別目前傳入的 Action 是否為針對自己所發出來的。
使用 EventBus 的 Annotation 規格宣告 Method 時,在 Android Studio 上會有一個即時語法檢查的警告出現,相關的處理細節可以參考這一篇文章。
而因為需求的關係,同樣的 Method 在 TodoStore 中就相對地複雜了一點:
主要是多了二種資料處理的要求:在新增時,前端會把新增的內容傳入,所以這裡很簡單地把收到的項目加入清單之中,就可以通知前端更新資料。至於在關閉 Todo 的部份,由於之前提到 Store 在 getItem 回傳的都是全新的 Instance,所以要先進行比對找出資料在清單中的位置,因為是示範的緣故,很單純地只寫了個迴圈來比對。找到了對應的位置後,直接以新的內容取代原本清單中的項目,再通知前端更新畫面。
如此,Action 與 Store 的撰寫工作就算完成了。同樣地,在這個階段的最後,執行寫好的測試程式來確認目前為止的工作成果。
撰寫 ActionHelper
FluxJava 已經內建了一個負責 ActionCreator 的 Class,這個 ActionCreator 使用 ActionHelper 來注入自訂的程式邏輯。可自訂的內容分為二個部份,第一個是決定如何建立 Action 的執行個體,第二個是協助處理資料格式的轉換。
以下是第一個部份的示範程式碼:
內容的重點就是依照先前定義好的常數來指定所屬的 Action 型別。
第二個部分就會有比較多的工作需要完成:
根據 Flux 文件的說明,ActionCreator 在建立 Action 的時候是呼叫外部 API 取得資料的切入點。所以 ActionHelper 提供了一個 wrapData 來讓使用 FluxJava 的程式有機會在此時取得外部的資料。在以上的程式中,還另外示範了另一種 wrapData 可能的用途。由於在前端會接收到的資訊有可能有多種變化,像是在示範中,要求載入 User 時只需要一個數值、在載入 Todo 時則要額外告知此時選擇的 User、在新增或修改 Todo 時則是要把修改的結果傳入。這時 wrapData 就可以適時地把這些不同型式的資訊轉成 Store 要的內容放在 Action 中,讓 Store 做後續的處理。
如果想要使用自訂的 ActionCreator,可以在初始化 FluxContext 時將自訂的 ActionCreator Instance 傳入,只是這個自訂的 ActionCreator 要繼承自內建的 ActionCreator,以覆寫原本的 Method 來達到自訂的效果。
組合元件
這次示範中,Flux 的架構橫跨整個 App 的生命週期。所以最合理的切入位置是自訂的 Application,這裡增加了一個名為 AppConfig 的 Class 做為初始化 Flux 架構的進入點,同時修改 AndroidManifest.xml 讓 AppConfig 可以在 App 啟動時被呼叫。
在 AppConfig 內增加一個 setupFlux 的 Method,內容如下:
重點工作是把之前步驟中準備好的 Bus、ActionHelper、StoreMap 傳入 FluxContext 的 Builder 之中,並且透過 Builder 建立 FluxContext 的 Instance。截至目前為止,後端準備的工作算是完成了,在目錄的結構上各位應該可以看出來,我把以上的 Class 都歸類在 Domain 的範疇之中。
撰寫 Adapter
Adapter 是用來供給 Spinner 及 RecyclerView 資料的 Class,同時在這次的示範中也是與 FluxJava 介接的關鍵角色,代表的是在 Flux 流程圖中的 View。在 MainActivity 中 Spinner 是用來顯示 User 清單,而 RecyclerView 是用來顯示 Todo 清單,所以各自對應的 Adapter 分別是 UserAdapter 及 TodoAdapter。
雖然這二個 Adapter 繼承自不同的 Base Class,但是都需要提供 Item 的 Layout 以便展示資料。所以先產生 item_user.xml 及 item_todo.xml 二個檔。
準備好了 Item 的 Layout 就可以進行 Adapter 的撰寫工作,以下是 UserAdapter 的完整內容:
在 UserAdapter 的 Constructor 中,使用 FluxContext 來取得 Store 的 Instance。使用的第一個參數就是之前在常數定義好的 USER_DATA,第二參數的 Tag 因為本次示範沒有使用到所以傳入 Null。最後一個參數是把 Adapter 本身的 Instance 傳入,FluxContext 會把傳入的 Instance 註冊到 Store 中。當然,如果要在取回 Store 後再自行註冊也是可以的。
之後部份就是 Adapter 的基本應用,需要提供資料有關的資訊時,則是透過 Store 來取得。
在 Adapter 的尾端可以看到有一個和 Store 類似的 Method,因為同樣是使用 EventBus 來傳送資訊,所以使用相同的方式來接收資料異動的事件。同樣地,在 Method 的參數上以型別來限定要收到的事件種類,被呼叫後的工作也很簡單,就是轉通知 Spinner 重刷畫面。由於是要更新畫面上的資訊,所以要回到 UI Thread 來執行,threadMode 被指定為 MainThread。如果同一個 Store 同時有多個 Instance 存在,和 Store 的 onAction 一樣,可以在 Event 中加入 Tag 的資訊,以減少無用的重刷頻繁地出現。
最後則是一個用來釋放 Reference 的接口,主要之目的是避免 Memory Leak 的問題,大部份都是在 Activity 卸載時呼叫。
以下是另外一個 Adapter - TodoAdapter 的內容:
除了因為是繼承自不同 Base Class 所產生的寫法上之差異外,並沒有太大的不同。重點是在接收事件的 Method 多了一個,用來當資料異動的情境是修改時,只更新有異動的 Item,以增加程序運作的效率。
接下來的工作就是把 Adapter 整合到 MainActivity 的程式碼中:
除了把 Adapter 傳入對應的畫面元件外,還有幾個重點。第一個是在 onStop 時要呼叫 Adapter 的 dispose 以避免之前提到的 Memory Leak 的問題。另外一個是在 onStart 時會以非同步的方式要求提供 User 的清單資料,在畫面持續在前景的同時,UserStore 完成資料載入就會觸發 UserAdapter、UserAdapter 再觸發 Spinner、Spinner 觸發 TodoStore 的載入、TodoStore 觸發 TodoAdapter、TodoAdapter 觸發 RecyclerView 等一連串資料更新的動作。所以可以在 Spinner 的 OnItemSelectedListener 中看到要求送出 TODO_LOAD 的 Action。
會選在 onStart 都做一次資料載入的要求是考量到 Activity 被推入背景後,有可能會出現資料的異動,所以強制進行一次畫面的刷新。
寫到這裡除了執行所有已完成的單元測試外,其實可以再回去執行一次 UAT,這時可以發現已經開始有測試結果轉為通過了。
撰寫 Integration Test
在繼續完成需求之前,先插入一個有關測試上的說明,使用 Flux 的其中一個重要原因就是希望提高程式碼的可測試性。所以在這次的示範之中,選擇以 Integration Test 來展示 FluxJava 可以達到的效果。
就像一開始提到的,用 Robolectric 來測試 MainActivity 被定位成 Integration Test。主要的測試目標是要確認整合起來後 UI 的行為符合設計的內容,此時當然不希望使用真實的資料來測試,簡單的說就是要把 Store 給隔離開來。
要達到這個目的可以由 FluxContext 的初始化做為切入點,以 Robolectric 來說,他提供了一個方便的功能,就是可以在測試執行時以 Annotation 中設定的 Applicaton Class 取代原本的 Class。 就如同以下程式碼所示範:
而在 StubAppConfig 中就可以對 FluxContext 注入測試用的 Class 來轉為提供測試用的資料:
這裡使用 StubAppConfig 做為切入點的示範並不是唯一的方法,在實際應用上還是應該選擇適合自己專案的方式。
如果在執行 UAT 希望也使用測試的資料來進行,以 FluxJava 來說當然也不會是問題,達成的方式在本次的示範中也可以看得到。原理同樣是和 Integration Test 相同,是使用取代原本 AppConfig 的方式。只是在 Espresso 裡設定就會麻煩一點,首先要增加一個自訂的 JUnitRunner,接著 build.gradle 中 defaultConfig 改成以下的內容:
同時調整 Android Studio 的 Configuration 中指定的 Instrumentation Runner 內容如下:
所以在執行 UAT 與正常啟動的情況下,可以在畫面中看到截然不同的資料內容,即代表 Store 代換的工作確實地達成目標。
Production | Test |
撰寫新增 Todo 功能
在這次的示範中,達成新增 Todo 的功能就只是很簡單地在 MainActivity 加上 Add Menu,透過使用者按下 Add 後,顯示一個 AlertDialog 取回使用者新增的內容完成新增的程序。以下是 menu_main.xml 的內容:
接著在 MainActivity.java 中加上以下的 Method:
用來讓使用者輸入資料的 AlertDialog 是用 DialogFragment 來達成,以下是 Layout 的內容:
程式碼則是如下所示:
再來就是讓 MainActivity 可以在使用者按下 Menu 時彈出 AlertDialog,所以新增如下的 Method:
執行所有的測試,看測試的結果沒有通過的不多了,距完成只剩一步之遙。
撰寫關閉 Todo 的功能
從最後一次 UAT 執行的結果可以發現,仍未滿足需求的項目只剩下關閉 Todo 最後一項。要達成這一項功能要回到 TodoAdapter,將 onBindViewHolder 改成以下的內容:
最後,執行最開始寫好的 UAT,非常好,所有的需求都通過測試,打完收工!
0 意見:
張貼留言