手把手教你如何用 FluxJava

想要追上最新的編程潮流嗎勘天?想要導(dǎo)入最新的 Flux 編程方法嗎泥技?這篇文章將手把手的帶你無(wú)痛進(jìn)入 Flux 的領(lǐng)域华坦。

這篇是 FluxJava: 給 Java 使用的 Flux 庫(kù) 的延續(xù)间驮,會(huì)透過(guò)建構(gòu)一個(gè)演示的 Todo App 的過(guò)程來(lái)說(shuō)明如何使用 FluxJava驾凶,所有演示的源代碼都可以在 Github 上找到尘奏。

Flux 簡(jiǎn)介

為了方便不熟悉 Flux 的讀者滩褥,一開(kāi)始會(huì)先簡(jiǎn)短地說(shuō)明這個(gè)架構(gòu)。以下是借用 Facebook 在 Flux 官網(wǎng)上的原圖:


從圖上可以看到所有箭頭都是單向的炫加,且形成一個(gè)封閉的循環(huán)瑰煎。這一個(gè)循環(huán)代表的是數(shù)據(jù)在 Flux 架構(gòu)中流動(dòng)的過(guò)程,整個(gè)流程以 Dispatcher 為集散中心俗孝。

Action 大多是由與用戶互動(dòng)的畫(huà)面控件所發(fā)起酒甸,在透過(guò)某種 Creator 的方法產(chǎn)生之后被送入 Dispatcher。此時(shí)赋铝,已經(jīng)有跟 Dispatcher 注冊(cè)過(guò)的 Store 都會(huì)被調(diào)用插勤,并且經(jīng)由預(yù)先在 Store 上定義好的 Method 接收到 Action。Store 會(huì)在這個(gè)接收到 Action 的 Method 中革骨,將數(shù)據(jù)的狀態(tài)依照 Action 的內(nèi)容來(lái)進(jìn)行調(diào)整农尖。

前端的畫(huà)面控件或是負(fù)責(zé)控制畫(huà)面的控件,會(huì)收到由 Store 在處理完數(shù)據(jù)后所送出的異動(dòng)事件良哲,異動(dòng)的事件就是用來(lái)代表在數(shù)據(jù)層的數(shù)據(jù)狀態(tài)已經(jīng)不一樣了盛卡。這些前端控件在自行訂義的事件處理方法中聆聽(tīng)、截收這些事件臂外,并且在事件收到后由 Store 獲取最新的數(shù)據(jù)狀態(tài)窟扑。最后前端控件觸發(fā)自己內(nèi)部的更新畫(huà)面程序,讓畫(huà)面上所有階層子控件都能夠反應(yīng)新的數(shù)據(jù)狀態(tài)漏健,如此完成一個(gè)數(shù)據(jù)流動(dòng)的循環(huán)嚎货。

以上是一個(gè)很簡(jiǎn)單的說(shuō)明,如果需要了解更進(jìn)一步的內(nèi)容蔫浆,可以自行上網(wǎng)搜尋殖属,現(xiàn)在網(wǎng)絡(luò)上應(yīng)該已經(jīng)有為數(shù)不少的文章可以參考。

以 BDD 來(lái)做為需求的開(kāi)端

為了能夠更清楚的說(shuō)明源代碼的細(xì)節(jié)瓦盛,所以文章中會(huì)依照 BDD 的概念來(lái)逐步解說(shuō)源代碼洗显。所以首先是要先列出需求:

  • 顯示 Todo 清單
  • 在不同用戶間切換 Todo 清單
  • 新增 Todo
  • 關(guān)閉/重啟 Todo

接著下來(lái)就要把需求轉(zhuǎn)更明確的敘述內(nèi)容,因?yàn)橹皇茄菔驹罚袃H做出一個(gè) Story 做為目標(biāo):

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

也因?yàn)橹皇茄菔居媚铀簦琒tory 的內(nèi)容并沒(méi)有很?chē)?yán)謹(jǐn),而演示所使用的文字雖然是英文嘱吗,但在實(shí)際的案例上用自己習(xí)慣的文字即可玄组。

決定測(cè)試的方略

既然是采用 BDD 來(lái)做演示,當(dāng)然在程序編寫(xiě)的過(guò)程中會(huì)希望能夠有適切的工具的輔助,畢竟工欲善其事俄讹、必先利其器哆致。所以在編寫(xiě)測(cè)試源代碼時(shí),不使用 Android 范本中的 JUnit患膛,改為使用在“使用 Android Studio 開(kāi)發(fā) Web 程序 - 測(cè)試”提到的 Spock Framework摊阀,并且全部以 Groovy 來(lái)編寫(xiě)。因?yàn)?Spock Framework 內(nèi)置了支援 BDD 的功能踪蹬,把之前做好的 Story 轉(zhuǎn)成測(cè)試源代碼的工作也會(huì)簡(jiǎn)化很多胞此。

接下來(lái)要決定如何在 Android 項(xiàng)目中定位與分配測(cè)試源代碼。Espresso 是 Android 開(kāi)發(fā)環(huán)境中內(nèi)置的延曙,使用 Espresso 來(lái)開(kāi)發(fā)測(cè)試程序還是有一定的必要性豌鹤。只不過(guò) Espresso 必須要跑在實(shí)機(jī)或模擬的環(huán)境上亡哄,運(yùn)行效率問(wèn)題是無(wú)法被忽視的一個(gè)因素枝缔,而且 androidTest 下的源代碼其運(yùn)行結(jié)果也沒(méi)有辦法在 Android Studio 中檢視 Code Coverage 的狀態(tài),所以用 Espresso 編寫(xiě)的測(cè)試程序并不適合用來(lái)做為 Unit Test蚊惯。

再加上新版的 Android Studio 提供了錄制測(cè)試步驟的功能愿卸,最后會(huì)被轉(zhuǎn)成 Espresso 的源代碼。所以看起來(lái) Espresso 比較適合用來(lái)做為開(kāi)發(fā)程流后段的一些測(cè)試工作截型,像是 UAT趴荸、壓力測(cè)試、穩(wěn)定度測(cè)試宦焦。依據(jù)這樣的定位发钝,之前寫(xiě)好的 Story 會(huì)在 Espresso 上轉(zhuǎn)成測(cè)試源代碼,來(lái)驗(yàn)證程序的功能是否有達(dá)到 Story 描述的內(nèi)容波闹。

單元測(cè)試的部份沒(méi)有疑問(wèn)地應(yīng)該是寫(xiě)在 Android 項(xiàng)目范本所提供的 test 的路徑之下酝豪,要解決的是 Android 控件如何在 JVM 的環(huán)境中運(yùn)行測(cè)試。大部份人目前的選擇應(yīng)該都會(huì)是 Robolectric精堕,只不過(guò)測(cè)試源代碼要使用 Spock 來(lái)開(kāi)發(fā)孵淘,所以這二個(gè)包必須要做個(gè)整合。RoboSpock就是提供此一解決方案的包歹篓,可以讓 Robolectric 在基于 Spock 所開(kāi)發(fā)的 Class 中能夠被直接使用瘫证。

使用 Robolectric 雖然能夠?qū)?Android 組件在 JVM 中進(jìn)行測(cè)試,但畢竟這類(lèi)的組件相互之間的藕合性還是有點(diǎn)高庄撮,尤其是提供畫(huà)面的控件背捌。所以這個(gè)部分在歸類(lèi)上我定位成 Integration Test,但在數(shù)據(jù)的供給上洞斯,拜 Flux 架構(gòu)之賜毡庆,可以依照情境來(lái)進(jìn)行代換,只測(cè)試 Android 組件與組件之間的整合度,這個(gè)部份在接下來(lái)的內(nèi)容會(huì)進(jìn)行說(shuō)明扭仁。附帶一提垮衷,有關(guān)測(cè)試上的一些想法我有寫(xiě)成一篇文章,可以參考:“軟件測(cè)試雜談”乖坠。

以下列出本次使用的測(cè)試組件清單:

  • Groovy
  • Spock Framework
  • RoboSpock
  • Espresso

設(shè)定 Spock 與 Espresso搀突、Robolectric 時(shí)會(huì)有一些細(xì)節(jié)需要注意,相關(guān)的說(shuō)明請(qǐng)參考“配置 build.gradle 來(lái)用 Spock 對(duì) Android 組件進(jìn)行測(cè)試”熊泵。最后的 build.gradle 設(shè)定結(jié)果仰迁,可以在 Github 上的文件內(nèi)容中看到。

建立畫(huà)面配置

在產(chǎn)生完 Android 項(xiàng)目空殼后顽分,首先修改 MainActivity 的內(nèi)容徐许。在 MainActivity 畫(huà)面中加上 RecyclerView 及 Spinner 來(lái)顯示 Todo 清單以及提供切換用戶的功能。Layout 的配置顯示如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    android:orientation="vertical"
    tools:context="com.example.fluxjava.eventbus.MainActivity">

    <Spinner
        android:id="@+id/spinner"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="LinearLayoutManager"
        tools:listitem="@layout/item_todo" />

</LinearLayout>

開(kāi)發(fā) UAT

原本范本中默認(rèn)產(chǎn)生的 androidTest/java 的路徑可以刪除卒蘸,另外要在 androidTest 之下增加一個(gè) groovy 的文件夾雌隅,如果 build.gradle 有設(shè)定正確,在 groovy 文件夾上應(yīng)該會(huì)出現(xiàn)代表測(cè)試源代碼的底色缸沃。因?yàn)槟壳爸挥幸粋€(gè) Story 所以在 groovy 路徑下配對(duì)的 Package 中增加一個(gè) ManageTodoStory.groovy 的文件恰起。

在這里就可以顯現(xiàn) Spock 所帶來(lái)的優(yōu)勢(shì),把之前的 Story 內(nèi)容轉(zhuǎn)成以下的源代碼趾牧,與原本的 Story 比對(duì)并沒(méi)有太大的差距检盼。

@Title("Manage todo items")
@Narrative("""
As a user
I want to manage todo items
So I can track something to be done
""")
class ManageTodoStory extends Specification {

    def "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"
    }

    def "Add a todo, but cancel the action"() {
        when: "I tap the add menu on main activity"
        and: "I press cancel button"
        then: "Nothing happen"
    }

    def "Switch user"() {
        when: "I select a different user"
        then: "I see the list changed"
    }

    def "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 是用中文寫(xiě)成的,以上的套用方式還是適用的翘单。有關(guān) Spock 的使用方式在這里就不詳細(xì)地說(shuō)明吨枉,各位可以自行上網(wǎng)搜尋,或是參考我之前寫(xiě)的這一篇這一篇有關(guān) Spock 的文章哄芜。接著就是把源代碼填入貌亭,完成后的內(nèi)容如下所示:

@Title("Manage todo items")
@Narrative("""
As a user
I want to manage todo items
So I can track something to be done
""")
class ManageTodoStory extends Specification {

    @Rule
    public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<>(MainActivity.class)
    private RecyclerView mRecyclerView

    def "Add a todo"() {
        when: "I tap the add menu on main activity"
        this.mRecyclerView = (RecyclerView)this.mActivityRule.activity.findViewById(R.id.recyclerView)
        onView(withId(R.id.add)).perform(click())

        then: "I see the add todo screen"
        onView(withText(R.string.dialog_title))
                .inRoot(isDialog())
                .check(matches(isDisplayed()))
        onView(withText(R.string.title))
                .inRoot(isDialog())
                .check(matches(isDisplayed()))
        onView(withId(R.id.title))
                .inRoot(isDialog())
                .check(matches(isDisplayed()))
        onView(withText(R.string.memo))
                .inRoot(isDialog())
                .check(matches(isDisplayed()))
        onView(withId(R.id.memo))
                .inRoot(isDialog())
                .check(matches(isDisplayed()))
        onView(withText(R.string.due_date))
                .inRoot(isDialog())
                .check(matches(isDisplayed()))
        onView(withId(R.id.dueText))
                .inRoot(isDialog())
                .check(matches(isDisplayed()))
        onView(withId(android.R.id.button1))
                .inRoot(isDialog())
                .check(matches(withText(R.string.add)))
                .check(matches(isDisplayed()))
        onView(withId(android.R.id.button2))
                .inRoot(isDialog())
                .check(matches(withText(android.R.string.cancel)))
                .check(matches(isDisplayed()))

        when: "I input todo detail and press ADD button"
        onView(withId(R.id.title))
                .perform(typeText("Test title"))
        onView(withId(R.id.memo))
                .perform(typeText("Sample memo"))
        onView(withId(R.id.dueText))
                .perform(typeText("2016/1/1"), closeSoftKeyboard())
        onView(withId(android.R.id.button1)).perform(click())

        then: "I see a new entry in list"
        onView(withText(R.string.dialog_title))
                .check(doesNotExist())
        this.mRecyclerView.getAdapter().itemCount == 5
        onView(withId(R.id.recyclerView)).perform(scrollToPosition(4))
        onView(withId(R.id.recyclerView))
                .check(matches(hasDescendant(withText("Test title"))))
    }

    def "Add a todo, but cancel the action"() {
        when: "I tap the add menu on main activity"
        this.mRecyclerView = (RecyclerView)this.mActivityRule.activity.findViewById(R.id.recyclerView)
        onView(withId(R.id.add)).perform(click())

        and: "I press cancel button"
        onView(withId(android.R.id.button2)).perform(click())

        then: "Nothing happen"
        this.mRecyclerView.getAdapter().itemCount == 4
    }

    def "Switch user"() {
        when: "I select a different user"
        this.mRecyclerView = (RecyclerView)this.mActivityRule.activity.findViewById(R.id.recyclerView)
        onView(withId(R.id.spinner)).perform(click())
        onView(allOf(withText("User2"), isDisplayed()))
                .perform(click())

        then: "I see the list changed"
        this.mRecyclerView.getAdapter().itemCount == 5
    }

    def "Mark a todo as done"() {
        when: "I mark a todo as done"
        TextView target

        this.mRecyclerView = (RecyclerView)this.mActivityRule.activity.findViewById(R.id.recyclerView)
        onView(withRecyclerView(R.id.recyclerView).atPositionOnView(2, R.id.closed))
                .perform(click())
        target = (TextView)this.mRecyclerView
                .findViewHolderForAdapterPosition(2)
                .itemView
                .findViewById(R.id.title)

        then: "I see the todo has a check mark and strike through on title"
        target.getText().toString().equals("Test title 2")
        (target.getPaintFlags() & Paint.STRIKE_THRU_TEXT_FLAG) > 0
    }

    static RecyclerViewMatcher withRecyclerView(final int recyclerViewId) {
        return new RecyclerViewMatcher(recyclerViewId)
    }

}

以上源代碼中使用到的資源當(dāng)然是要在編寫(xiě)之前就事件準(zhǔn)備好,否則出現(xiàn)錯(cuò)誤的訊息忠烛。完成后先運(yùn)行一次測(cè)試属提,當(dāng)然結(jié)果都是失敗的,接下來(lái)就可以依照需求來(lái)逐項(xiàng)開(kāi)發(fā)功能美尸。

編寫(xiě) Bus

依照 Flux 架構(gòu)冤议,需要為整個(gè)數(shù)據(jù)循環(huán)建立 Dispatcher。但是在 FluxJava 中 Dispatcher 的功能是以 Bus 的方式實(shí)現(xiàn)师坎,所以實(shí)際上是要先準(zhǔn)備 Bus 的 Class恕酸。在這次的示范中使用 greenrobotEventBus 來(lái)簡(jiǎn)化開(kāi)發(fā)工作,并且包裝在實(shí)現(xiàn) IFluxBus 的 Interface 內(nèi)胯陋,以便整合進(jìn) FluxJava 的 Framework 內(nèi)蕊温。源代碼的內(nèi)容如下:

public class Bus implements IFluxBus {

    private EventBus mBus = EventBus.getDefault();

    @Override
    public void register(final Object inSubscriber) {
        this.mBus.register(inSubscriber);
    }

    @Override
    public void unregister(final Object inSubscriber) {
        this.mBus.unregister(inSubscriber);
    }

    @Override
    public void post(final Object inEvent) {
        this.mBus.post(inEvent);
    }

}

與 Bus 搭配的測(cè)試用 Class 的內(nèi)容如下:

class BusSpec extends Specification {

    public static class Subscriber {
        Object actualEvent;

        @Subscribe
        public onEvent(String inEvent) {
            this.actualEvent = inEvent;
        }
    }

    def "Test register"() {
        given:
        def target = new Bus()
        def expected = "Test"
        def subscriber = new Subscriber()
        def constants = new Constants()

        target.register(subscriber)

        when: "post an event with unexpected type"
        target.post(0)

        then: "will not get the event"
        subscriber.actualEvent == null
        constants != null

        when: "post an event with expected type"
        target.post(expected)

        then: "get the event"
        subscriber.actualEvent == expected
    }

    def "Test unregister"() {
        given:
        def target = new Bus()
        def expected = "Test"
        def subscriber = new Subscriber()

        target.register(subscriber)

        when: "post an event"
        target.post(expected)

        then: "get the event"
        subscriber.actualEvent == expected

        when: "unregister"
        subscriber.actualEvent = null
        target.unregister(subscriber)
        target.post(expected)

        then: "will not get any event"
        subscriber.actualEvent == null
    }

}

運(yùn)行以上 Class 之后袱箱,確認(rèn)測(cè)試通過(guò)并檢視 Code Coverage。如果測(cè)得到的源代碼都有被涵蓋义矛,就可以確認(rèn)目前完成的程序有一定的穩(wěn)定度发笔,可以繼續(xù)往下進(jìn)行接下來(lái)的工作。接下來(lái)的幾個(gè)小節(jié)都會(huì)采用這樣的工作節(jié)奏來(lái)逐步推進(jìn)凉翻,以其望在程序完成時(shí)能夠有一定基礎(chǔ)的質(zhì)量了讨。

準(zhǔn)備 Model

這里的 Model 是二個(gè) POJO,分別用來(lái)代表一筆 User 和 Todo 的數(shù)據(jù)內(nèi)容制轰。因?yàn)檫@部分并不是示范的重點(diǎn)前计,所以文件的內(nèi)容請(qǐng)自行參考 User.javaTodo.java

定義常量

常量主要的作用是以不同的數(shù)值來(lái)區(qū)分不同的數(shù)據(jù)種類(lèi)垃杖,以及每一個(gè)數(shù)據(jù)種類(lèi)因應(yīng)需求所必須提供的功能男杈。如同以下所展示的源代碼內(nèi)容:

// constants for data type
public static final int DATA_USER = 10;
public static final int DATA_TODO = 20;

// constants for actions of user data
public static final int USER_LOAD = DATA_USER + 1;

// constants for actions of todo data
public static final int TODO_LOAD = DATA_TODO + 1;
public static final int TODO_ADD = DATA_TODO + 2;
public static final int TODO_CLOSE = DATA_TODO + 3;

在需求中提到需要處理二種類(lèi)型的數(shù)據(jù),所以就分別定義了 DATA_USERDATA_TODO 來(lái)代表用戶及 Todo调俘。以 User 的需求來(lái)看伶棒,在畫(huà)面上只會(huì)有載入數(shù)據(jù)的要求,以提供切換用戶的功能脉漏,所以 User 的動(dòng)作只定義了 USER_LOAD苞冯。而 Todo 的需求就比較復(fù)雜,除了載入數(shù)據(jù)以外侧巨,還要可以新增、關(guān)閉 Todo鞭达。所以目前定義 TODO_LOAD司忱、TODO_ADDTODO_CLOSE 等三個(gè)常量畴蹭。

這些常量接下來(lái)會(huì)被用在 StoreMap 的鍵值及 Action 的 Type坦仍。在 FluxJava 中并沒(méi)有限定只能使用數(shù)值型別來(lái)做為鍵值,可以根據(jù)每個(gè)項(xiàng)目的特性來(lái)設(shè)定叨襟,可以是字串繁扎、型別或是同一個(gè)型別不同的 Instance。

編寫(xiě) Action 及 Store

UserAction 和 TodoAction 都是很直接地繼承自 FluxAction糊闽。其中比較特別是:考量到一次可能會(huì)要處理多筆數(shù)據(jù)梳玫,所以在 Data 屬性的泛型上使用 List 來(lái)做為承載數(shù)據(jù)的基礎(chǔ)。這二個(gè) Class 的內(nèi)容請(qǐng)直接連上 Github 的 UserAction.javaTodoAction.java 二個(gè)文件查詢右犹。

Store 可以繼承 FluxJava 內(nèi)置的 FluxStore提澎,或是自行實(shí)現(xiàn) IFluxStore 的 Interface。在 IFluxStore 中 registerunregister 是提供給前端的畫(huà)面控件念链,做為向 Store 登記要接收到數(shù)據(jù)異動(dòng)事件之用盼忌。

Tag 則是考量到同一個(gè) Store 有可能要產(chǎn)生多個(gè) Instance 來(lái)服務(wù)不同的畫(huà)面控件积糯,所以仿照 Android 控件的方式,用 Tag 來(lái)識(shí)別不同的 Instance谦纱。像是在同一個(gè)畫(huà)面中看成,可能會(huì)因?yàn)樾枨蟮年P(guān)系,要使用不同條件所產(chǎn)生的清單來(lái)呈現(xiàn)圖表跨嘉。這時(shí)就有必要使用二個(gè)不同的 Instance 來(lái)提供數(shù)據(jù)绍昂,否則會(huì)造成畫(huà)面上數(shù)據(jù)的混亂。

至于 getItem偿荷、findItem窘游、getCount 都是很基本在呈現(xiàn)數(shù)據(jù)內(nèi)容時(shí)需要使用到的功能。其中 getItem 之所以限定一次只取得一筆數(shù)據(jù)跳纳,而不是以 List 的方式傳回忍饰,主要是為了符合 Flux 單向數(shù)據(jù)流的精神。如果 getItem 傳回的是 List寺庄,前端很有可能意外地異動(dòng)了清單的內(nèi)容艾蓝,根據(jù) Java 的特性,這樣的異動(dòng)結(jié)果也會(huì)反應(yīng)在 Store 所提供的信息上斗塘。也就等于數(shù)據(jù)的清單在 Store 以外赢织,也有機(jī)會(huì)被異動(dòng),這就違反了 Flux 在設(shè)計(jì)上所想要達(dá)成的數(shù)據(jù)流動(dòng)過(guò)程馍盟。

當(dāng)然于置,就算是只提供一項(xiàng)數(shù)據(jù),前端也許改不了整個(gè)清單贞岭,但還是可以修改所收到的這單一項(xiàng)目八毯,其結(jié)果一樣會(huì)反應(yīng)回 Store 的內(nèi)部。所以在示范的源代碼中瞄桨,在 getItem 所傳回的是一個(gè)全新的 Instance话速。

@Override
public User getItem(final int inIndex) {
    return new User(this.mList.get(inIndex));
}

在 Store 中有一個(gè)關(guān)鍵的 Method 是 FluxStore 中沒(méi)有、要自行增加的芯侥,那就是用來(lái)接收前端所推送出來(lái)的 Action泊交。由于目前使用的是 EventBus,以 UserStore 為例柱查,會(huì)有以下的內(nèi)容:

@Subscribe(threadMode = ThreadMode.BACKGROUND)
public void onAction(final UserAction inAction) {
    // base on input action to process data
    // in this sample only define one action
    switch (inAction.getType()) {
        case USER_LOAD:
            this.mList.clear();
            this.mList.addAll(inAction.getData());
            super.emitChange(new ListChangeEvent());
            break;
    }
}

可以看到之前定義的常量在這里派上用場(chǎng)了廓俭,利用 Action 的 Type 可以區(qū)分出前端所接收到的指令。在這個(gè) Demo 中物赶,Store 的定位只是用來(lái)管理清單白指,清單的數(shù)據(jù)會(huì)由 ActionCreator 傳入,所以可以看到源代碼中只是做很簡(jiǎn)單的載入工作酵紫,載入完即發(fā)出數(shù)據(jù)異動(dòng)的事件告嘲。這個(gè)事件是定義在 Store 內(nèi)部错维,每個(gè) Store 都有定義自己的 Event,以便讓前端控件判別與過(guò)濾所想收到的 Event 種類(lèi)橄唬。

在以上的 Method 源代碼中赋焕,使用了 EventBus 所提供的功能,在接收到 Action 的當(dāng)下是以背景的 Thread 在運(yùn)行仰楚,避免因?yàn)檫^(guò)長(zhǎng)的數(shù)據(jù)處理時(shí)間導(dǎo)至前端畫(huà)面凍結(jié)隆判。Method 的參數(shù)則是用以過(guò)濾 Action,讓指定的 Action 型別在 Bus 中被傳遞時(shí)才調(diào)用 Mehtod僧界,減少源代碼判斷上的負(fù)擔(dān)侨嘀。如果是同一個(gè) Store 有多個(gè) Instace 同時(shí)存在,在接收到的 Action 中可以加入 Tag 的信息捂襟,以便讓 Store 判別目前傳入的 Action 是否為針對(duì)自己所發(fā)出來(lái)的咬腕。

使用 EventBus 的 Annotation 規(guī)格聲明 Method 時(shí),在 Android Studio 上會(huì)有一個(gè)即時(shí)語(yǔ)法檢查的警告出現(xiàn)葬荷,相關(guān)的處理細(xì)節(jié)可以參考這一篇文章涨共。

而因?yàn)樾枨蟮年P(guān)系,同樣的 Method 在 TodoStore 中就相對(duì)地復(fù)雜了一點(diǎn):

@Subscribe(threadMode = ThreadMode.BACKGROUND)
public void onAction(final TodoAction inAction) {
    // base on input action to process data
    switch (inAction.getType()) {
        case TODO_LOAD:
            this.mList.clear();
            this.mList.addAll(inAction.getData());
            super.emitChange(new ListChangeEvent());
            break;
        case TODO_ADD:
            this.mList.addAll(inAction.getData());
            super.emitChange(new ListChangeEvent());
            break;
        case TODO_CLOSE:
            for (int j = 0; j < inAction.getData().size(); j++) {
                for (int i = 0; i < this.mList.size(); i++) {
                    if (this.mList.get(i).id == inAction.getData().get(j).id) {
                        this.mList.set(i, inAction.getData().get(j));
                        super.emitChange(new ItemChangeEvent(i));
                        break;
                    }
                }
            }
            break;
    }
}

主要是多了二種數(shù)據(jù)處理的要求:在新增時(shí)宠漩,前端會(huì)把新增的內(nèi)容傳入举反,所以這里很簡(jiǎn)單地把收到的項(xiàng)目加入清單之中,就可以通知前端刷新數(shù)據(jù)扒吁。至于在關(guān)閉 Todo 的部份火鼻,由于之前提到 Store 在 getItem 回傳的都是全新的 Instance,所以要先進(jìn)行比對(duì)找出數(shù)據(jù)在清單中的位置瘦陈,因?yàn)槭鞘痉兜木壒誓#軉渭兊刂粚?xiě)了個(gè)循環(huán)來(lái)比對(duì)。找到了對(duì)應(yīng)的位置后晨逝,直接以新的內(nèi)容取代原本清單中的項(xiàng)目,再通知前端更新畫(huà)面懦铺。

如此捉貌,Action 與 Store 的編寫(xiě)工作就算完成了。同樣地冬念,在這個(gè)階段的最后趁窃,運(yùn)行寫(xiě)好的測(cè)試程序來(lái)確認(rèn)目前為止的工作成果。

編寫(xiě) ActionHelper

FluxJava 已經(jīng)內(nèi)置了一個(gè)負(fù)責(zé) ActionCreator 的 Class急前,這個(gè) ActionCreator 使用 ActionHelper 來(lái)注入自定義的程序邏輯醒陆。可自定義的內(nèi)容分為二個(gè)部份裆针,第一個(gè)是決定如何建立 Action 的 Instance刨摩,第二個(gè)是協(xié)助處理數(shù)據(jù)格式的轉(zhuǎn)換寺晌。

以下是第一個(gè)部份的示范源代碼:

public Class<?> getActionClass(final Object inActionTypeId) {
    Class<?> result = null;

    if (inActionTypeId instanceof Integer) {
        final int typeId = (int)inActionTypeId;

        // return action type by pre-define id
        switch (typeId) {
            case USER_LOAD:
                result = UserAction.class;
                break;
            case TODO_LOAD:
            case TODO_ADD:
            case TODO_CLOSE:
                result = TodoAction.class;
                break;
        }
    }

    return result;
}

內(nèi)容的重點(diǎn)就是依照先前定義好的常量來(lái)指定所屬的 Action 型別。

第二個(gè)部分就會(huì)有比較多的工作需要完成:

public Object wrapData(final Object inData) {
    Object result = inData;

    // base on data type to convert data into require form
    if (inData instanceof Integer) {
        result = this.getRemoteData((int)inData, -1);
    }
    if (inData instanceof String) {
        final String[] command = ((String)inData).split(":");
        final int action;
        final int position;

        action = Integer.valueOf(command[0]);
        position = Integer.valueOf(command[1]);
        result = this.getRemoteData(action, position);
    }
    if (inData instanceof Todo) {
        final ArrayList<Todo> todoList = new ArrayList<>();

        this.updateRemoteTodo();
        todoList.add((Todo)inData);
        result = todoList;
    }

    return result;
}

根據(jù) Flux 文件的說(shuō)明澡刹,ActionCreator 在建立 Action 的時(shí)候是調(diào)用外部 API 取得數(shù)據(jù)的切入點(diǎn)呻征。所以 ActionHelper 提供了一個(gè) wrapData 來(lái)讓使用 FluxJava 的程序有機(jī)會(huì)在此時(shí)取得外部的數(shù)據(jù)。在以上的程序中罢浇,還另外示范了另一種 wrapData 可能的用途陆赋。由于在前端會(huì)接收到的信息有可能有多種變化,像是在示范中嚷闭,要求載入 User 時(shí)只需要一個(gè)數(shù)值攒岛、在載入 Todo 時(shí)則要額外告知此時(shí)選擇的 User、在新增或修改 Todo 時(shí)則是要把修改的結(jié)果傳入胞锰。這時(shí) wrapData 就可以適時(shí)地把這些不同型式的信息轉(zhuǎn)成 Store 要的內(nèi)容放在 Action 中灾锯,讓 Store 做后續(xù)的處理。

如果想要使用自定義的 ActionCreator胜蛉,可以在初始化 FluxContext 時(shí)將自定義的 ActionCreator Instance 傳入挠进,只是這個(gè)自定義的 ActionCreator 要繼承自內(nèi)置的 ActionCreator,以覆寫(xiě)原本的 Method 來(lái)達(dá)到自定義的效果誊册。

組合控件

這次演示中领突,F(xiàn)lux 的架構(gòu)橫跨整個(gè) App 的生命周期。所以最合理的切入位置是自定義的 Application案怯,這里增加了一個(gè)名為 AppConfig 的 Class 做為初始化 Flux 架構(gòu)的進(jìn)入點(diǎn)君旦,同時(shí)修改 AndroidManifest.xml 讓 AppConfig 可以在 App 啟動(dòng)時(shí)被調(diào)用。

在 AppConfig 內(nèi)增加一個(gè) setupFlux 的 Method嘲碱,內(nèi)容如下:

private void setupFlux() {
    HashMap<Object, Class<?>> storeMap = new HashMap<>();

    storeMap.put(DATA_USER, UserStore.class);
    storeMap.put(DATA_TODO, TodoStore.class);

    // setup relationship of components in framework
    FluxContext.getBuilder()
            .setBus(new Bus())
            .setActionHelper(new ActionHelper())
            .setStoreMap(storeMap)
            .build();
}

重點(diǎn)工作是把之前步驟中準(zhǔn)備好的 Bus金砍、ActionHelper、StoreMap 傳入 FluxContext 的 Builder 之中麦锯,并且透過(guò) Builder 建立 FluxContext 的 Instance恕稠。截至目前為止,后端準(zhǔn)備的工作算是完成了扶欣,在文件夾的結(jié)構(gòu)上各位應(yīng)該可以看出來(lái)鹅巍,我把以上的 Class 都?xì)w類(lèi)在 Domain 的范疇之中。

編寫(xiě) Adapter

Adapter 是用來(lái)供給 Spinner 及 RecyclerView 數(shù)據(jù)的 Class料祠,同時(shí)在這次的演示中也是與 FluxJava 介接的關(guān)鍵角色骆捧,代表的是在 Flux 流程圖中的 View。在 MainActivity 中 Spinner 是用來(lái)顯示 User 清單髓绽,而 RecyclerView 是用來(lái)顯示 Todo 清單敛苇,所以各自對(duì)應(yīng)的 Adapter 分別是 UserAdapter 及 TodoAdapter。

雖然這二個(gè) Adapter 繼承自不同的 Base Class顺呕,但是都需要提供 Item 的 Layout 以便展示數(shù)據(jù)枫攀。所以先產(chǎn)生 item_user.xmlitem_todo.xml 二個(gè)文件括饶。

準(zhǔn)備好了 Item 的 Layout 就可以進(jìn)行 Adapter 的編寫(xiě)工作,以下是 UserAdapter 的完整內(nèi)容:

public class UserAdapter extends BaseAdapter {

    private UserStore mStore;

    public UserAdapter() {
        // get the instance of store that will provide data
        this.mStore = (UserStore)FluxContext.getInstance().getStore(DATA_USER, null, this);
    }

    @Override
    public int getCount() {
        return this.mStore.getCount();
    }

    @Override
    public Object getItem(final int inPosition) {
        return this.mStore.getItem(inPosition);
    }

    @Override
    public long getItemId(final int inPosition) {
        return inPosition;
    }

    @Override
    public View getView(final int inPosition, final View inConvertView, final ViewGroup inParent) {
        View itemView = inConvertView;

        // bind data into item view of Spinner
        if (itemView == null) {
            itemView = LayoutInflater
                    .from(inParent.getContext())
                    .inflate(R.layout.item_user, inParent, false);
        }

        if (itemView instanceof TextView) {
            ((TextView)itemView).setText(this.mStore.getItem(inPosition).name);
        }

        return itemView;
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onEvent(final UserStore.ListChangeEvent inEvent) {
        super.notifyDataSetChanged();
    }

    public void dispose() {
        // Clear object reference to avoid memory leak issue
        FluxContext.getInstance().unregisterStore(this.mStore, this);
    }

}

在 UserAdapter 的 Constructor 中脓豪,使用 FluxContext 來(lái)取得 Store 的 Instance巷帝。使用的第一個(gè)參數(shù)就是之前在常量定義好的 USER_DATA,第二參數(shù)的 Tag 因?yàn)楸敬问痉稕](méi)有使用到所以傳入 Null扫夜。最后一個(gè)參數(shù)是把 Adapter 本身的 Instance 傳入楞泼,F(xiàn)luxContext 會(huì)把傳入的 Instance 注冊(cè)到 Store 中。當(dāng)然笤闯,如果要在取回 Store 后再自行注冊(cè)也是可以的堕阔。

之后部份就是 Adapter 的基本應(yīng)用,需要提供數(shù)據(jù)有關(guān)的信息時(shí)颗味,則是透過(guò) Store 來(lái)取得超陆。

在 Adapter 的尾端可以看到有一個(gè)和 Store 類(lèi)似的 Method,因?yàn)橥瑯邮鞘褂?EventBus 來(lái)傳送信息浦马,所以使用相同的方式來(lái)接收數(shù)據(jù)異動(dòng)的事件时呀。同樣地,在 Method 的參數(shù)上以型別來(lái)限定要收到的事件種類(lèi)晶默,被調(diào)用后的工作也很簡(jiǎn)單谨娜,就是轉(zhuǎn)通知 Spinner 重刷畫(huà)面。由于是要更新畫(huà)面上的信息磺陡,所以要回到 UI Thread 來(lái)運(yùn)行趴梢,threadMode 被指定為 MainThread。如果同一個(gè) Store 同時(shí)有多個(gè) Instance 存在币他,和 Store 的 onAction 一樣,可以在 Event 中加入 Tag 的信息蝴悉,以減少無(wú)用的重刷頻繁地出現(xiàn)硝枉。

最后則是一個(gè)用來(lái)釋放 Reference 的接口,主要之目的是避免 Memory Leak 的問(wèn)題倦微,大部份都是在 Activity 卸載時(shí)調(diào)用。

以下是另外一個(gè) Adapter - TodoAdapter 的內(nèi)容:

public class TodoAdapter extends RecyclerView.Adapter<TodoAdapter.ViewHolder> {

    static class ViewHolder extends RecyclerView.ViewHolder {
        TextView title;
        TextView dueDate;
        TextView memo;
        CheckBox closed;

        ViewHolder(final View inItemView) {
            super(inItemView);
            this.title = (TextView)inItemView.findViewById(R.id.title);
            this.dueDate = (TextView)inItemView.findViewById(R.id.dueDate);
            this.memo = (TextView)inItemView.findViewById(R.id.memo);
            this.closed = (CheckBox)inItemView.findViewById(R.id.closed);
        }
    }

    private TodoStore mStore;

    public TodoAdapter() {
        // get the instance of store that will provide data
        this.mStore = (TodoStore)FluxContext.getInstance().getStore(DATA_TODO, null, this);
    }

    @Override
    public ViewHolder onCreateViewHolder(final ViewGroup inParent, final int inViewType) {
        final View itemView = LayoutInflater
                .from(inParent.getContext()).inflate(R.layout.item_todo, inParent, false);

        // use custom ViewHolder to display data
        return new ViewHolder(itemView);
    }

    @Override
    public void onBindViewHolder(final ViewHolder inViewHolder, final int inPosition) {
        final Todo item = this.mStore.getItem(inPosition);

        // bind data into item view of RecyclerView
        inViewHolder.title.setText(item.title);
        inViewHolder.dueDate.setText(item.dueDate);
        inViewHolder.memo.setText(item.memo);
        inViewHolder.closed.setOnCheckedChangeListener(null);
        inViewHolder.closed.setChecked(item.closed);
    }

    @Override
    public int getItemCount() {
        return this.mStore.getCount();
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onEvent(final TodoStore.ListChangeEvent inEvent) {
        super.notifyDataSetChanged();
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onEvent(final TodoStore.ItemChangeEvent inEvent) {
        super.notifyItemChanged(inEvent.position);
    }

    public void dispose() {
        // Clear object reference to avoid memory leak issue
        FluxContext.getInstance().unregisterStore(this.mStore, this);
    }

}

除了因?yàn)槭抢^承自不同 Base Class 所產(chǎn)生的寫(xiě)法上之差異外正压,并沒(méi)有太大的不同欣福。重點(diǎn)是在接收事件的 Method 多了一個(gè),用來(lái)當(dāng)數(shù)據(jù)異動(dòng)的情境是修改時(shí)焦履,只更新有異動(dòng)的 Item拓劝,以增加程序運(yùn)作的效率雏逾。

接下來(lái)的工作就是把 Adapter 整合到 MainActivity 的源代碼中:

public class MainActivity extends AppCompatActivity {

    private UserAdapter mUserAdapter;
    private TodoAdapter mTodoAdapter;

    @Override
    protected void onCreate(final Bundle inSavedInstanceState) {
        super.onCreate(inSavedInstanceState);
        super.setContentView(R.layout.activity_main);
        this.setupRecyclerView();
        this.setupSpinner();
    }

    @Override
    protected void onStart() {
        super.onStart();
        // ask to get the list of user
        FluxContext.getInstance().getActionCreator().sendRequestAsync(USER_LOAD, USER_LOAD);
    }

    @Override
    protected void onStop() {
        super.onStop();

        // release resources
        if (this.mUserAdapter != null) {
            this.mUserAdapter.dispose();
        }
        if (this.mTodoAdapter != null) {
            this.mTodoAdapter.dispose();
        }
    }

    private void setupSpinner() {
        final Spinner spinner = (Spinner)super.findViewById(R.id.spinner);

        if (spinner != null) {
            // configure spinner to show data
            this.mUserAdapter = new UserAdapter();
            spinner.setAdapter(this.mUserAdapter);
            spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
                @Override
                public void onItemSelected(final AdapterView<?> inParent, final View inView,
                                           final int inPosition, final long inId) {
                    // when user change the selection of spinner, change the list of recyclerView
                    FluxContext.getInstance()
                            .getActionCreator()
                            .sendRequestAsync(TODO_LOAD, TODO_LOAD + ":" + inPosition);
                }

                @Override
                public void onNothingSelected(final AdapterView<?> inParent) {
                    // Do nothing
                }
            });
        }
    }

    private void setupRecyclerView() {
        final RecyclerView recyclerView = (RecyclerView)super.findViewById(R.id.recyclerView);

        if (recyclerView != null) {
            // configure recyclerView to show data
            this.mTodoAdapter = new TodoAdapter();
            recyclerView.setAdapter(this.mTodoAdapter);
        }
    }

}

除了把 Adapter 傳入對(duì)應(yīng)的畫(huà)面控件外,還有幾個(gè)重點(diǎn)郑临。第一個(gè)是在 onStop 時(shí)要調(diào)用 Adapter 的 dispose 以避免之前提到的 Memory Leak 的問(wèn)題栖博。另外一個(gè)是在 onStart 時(shí)會(huì)以非同步的方式要求提供 User 的清單數(shù)據(jù),在畫(huà)面持續(xù)在前景運(yùn)作的同時(shí)厢洞,UserStore 完成數(shù)據(jù)載入就會(huì)觸發(fā) UserAdapter仇让、UserAdapter 再觸發(fā) Spinner、Spinner 觸發(fā) TodoStore 的載入躺翻、TodoStore 觸發(fā) TodoAdapter丧叽、TodoAdapter 觸發(fā) RecyclerView 等一連串?dāng)?shù)據(jù)更新的動(dòng)作。所以可以在 Spinner 的 OnItemSelectedListener 中看到要求送出 TODO_LOAD 的 Action公你。

會(huì)選在 onStart 都做一次數(shù)據(jù)載入的要求是考量到 Activity 被推入背景后踊淳,有可能會(huì)出現(xiàn)數(shù)據(jù)的異動(dòng),所以強(qiáng)制進(jìn)行一次畫(huà)面的刷新陕靠。

寫(xiě)到這里除了運(yùn)行所有已完成的單元測(cè)試外迂尝,其實(shí)可以再回去運(yùn)行一次 UAT,這時(shí)可以發(fā)現(xiàn)已經(jīng)開(kāi)始有測(cè)試結(jié)果轉(zhuǎn)為通過(guò)了剪芥。

編寫(xiě) Integration Test

在繼續(xù)完成需求之前垄开,先插入一個(gè)有關(guān)測(cè)試上的說(shuō)明,使用 Flux 的其中一個(gè)重要原因就是希望提高源代碼的可測(cè)試性粗俱。所以在這次的示范之中说榆,選擇以 Integration Test 來(lái)展示 FluxJava 可以達(dá)到的效果。

就像一開(kāi)始提到的寸认,用 Robolectric 來(lái)測(cè)試 MainActivity 被定位成 Integration Test签财。主要的測(cè)試目標(biāo)是要確認(rèn)整合起來(lái)后 UI 的行為符合設(shè)計(jì)的內(nèi)容,此時(shí)當(dāng)然不希望使用真實(shí)的數(shù)據(jù)來(lái)測(cè)試偏塞,簡(jiǎn)單的說(shuō)就是要把 Store 給隔離開(kāi)來(lái)唱蒸。

要達(dá)到這個(gè)目的可以由 FluxContext 的初始化做為切入點(diǎn),以 Robolectric 來(lái)說(shuō)灸叼,他提供了一個(gè)方便的功能神汹,就是可以在測(cè)試運(yùn)行時(shí)以 Annotation 中設(shè)定的 Applicaton Class 取代原本的 Class。 就如同以下源代碼所示范:

@Config(constants = BuildConfig, sdk = 21, application = StubAppConfig)
class MainActivitySpec extends GradleRoboSpecification {

}

而在 StubAppConfig 中就可以對(duì) FluxContext 注入測(cè)試用的 Class 來(lái)轉(zhuǎn)為提供測(cè)試用的數(shù)據(jù):

public class StubAppConfig extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        this.setupFlux();
    }

    private void setupFlux() {
        HashMap<Object, Class<?>> storeMap = new HashMap<>();

        storeMap.put(DATA_USER, StubUserStore.class);
        storeMap.put(DATA_TODO, StubTodoStore.class);

        FluxContext.getBuilder()
                .setBus(new Bus())
                .setActionHelper(new StubActionHelper())
                .setStoreMap(storeMap)
                .build();
    }

}

這里使用 StubAppConfig 做為切入點(diǎn)的演示并不是唯一的方法古今,在實(shí)際應(yīng)用上還是應(yīng)該選擇適合自己項(xiàng)目的方式屁魏。

如果在運(yùn)行 UAT 希望也使用測(cè)試的數(shù)據(jù)來(lái)進(jìn)行,以 FluxJava 來(lái)說(shuō)當(dāng)然也不會(huì)是問(wèn)題捉腥,達(dá)成的方式在本次的示范中也可以看得到氓拼。原理同樣是和 Integration Test 相同,是使用取代原本 AppConfig 的方式。只是在 Espresso 里設(shè)定就會(huì)麻煩一點(diǎn)桃漾,首先要增加一個(gè)自定義的 JUnitRunner坏匪,接著 build.gradledefaultConfig 改成以下的內(nèi)容:

defaultConfig {
    ...

    // replace "android.support.test.runner.AndroidJUnitRunner" with custom one
    testInstrumentationRunner "com.example.fluxjava.eventbus.CustomJUnitRunner"

}

同時(shí)調(diào)整 Android Studio 的 Configuration 中指定的 Instrumentation Runner 內(nèi)容如下:


所以在運(yùn)行 UAT 與正常啟動(dòng)的情況下,可以在畫(huà)面中看到截然不同的數(shù)據(jù)內(nèi)容撬统,即代表 Store 代換的工作確實(shí)地達(dá)成目標(biāo)适滓。


Production

Test

編寫(xiě)新增 Todo 功能

在這次的演示中,達(dá)成新增 Todo 的功能就只是很簡(jiǎn)單地在 MainActivity 加上 Add Menu恋追,透過(guò)用戶按下 Add 后凭迹,顯示一個(gè) AlertDialog 取回用戶新增的內(nèi)容完成新增的程序。以下是 `menu_main.xml’ 的內(nèi)容:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto">

    <item
        android:id="@+id/add"
        android:title="@string/add"
        app:showAsAction="always" />

</menu>

接著在 MainActivity.java 中加上以下的 Method:

@Override
public boolean onCreateOptionsMenu(final Menu inMenu) {
    super.getMenuInflater().inflate(R.menu.menu_main, inMenu);

    return true;
}

用來(lái)讓用戶輸入數(shù)據(jù)的 AlertDialog 是用 DialogFragment 來(lái)達(dá)成几于,以下是 Layout 的內(nèi)容:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/title" />

        <EditText
            android:id="@+id/title"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_marginLeft="8dp"
            android:layout_marginStart="8dp"
            android:inputType="text" />

    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/memo" />

        <EditText
            android:id="@+id/memo"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_marginLeft="8dp"
            android:layout_marginStart="8dp"
            android:inputType="text" />

    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/due_date" />

        <EditText
            android:id="@+id/dueText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_marginLeft="8dp"
            android:layout_marginStart="8dp"
            android:inputType="text" />

    </LinearLayout>

</LinearLayout>

源代碼則是如下所示:

public class AddDialogFragment extends AppCompatDialogFragment {

    @Nullable
    @Override
    public View onCreateView(final LayoutInflater inInflater,
                             @Nullable final ViewGroup inContainer,
                             @Nullable final Bundle inSavedInstanceState) {
        return inInflater.inflate(R.layout.dialog_add, inContainer);
    }

    @NonNull
    @Override
    public Dialog onCreateDialog(final Bundle inSavedInstanceState) {
        final AlertDialog.Builder builder = new AlertDialog.Builder(super.getActivity());
        final LayoutInflater inflater = super.getActivity().getLayoutInflater();
        final ViewGroup nullParent = null;

        // display an alertDialog for input a new todo item
        builder.setView(inflater.inflate(R.layout.dialog_add, nullParent))
                .setTitle(R.string.dialog_title)
                .setPositiveButton(R.string.add, new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(final DialogInterface inDialog, final int inId) {
                        final AlertDialog alertDialog = (AlertDialog)inDialog;
                        final Todo todo = new Todo();
                        final EditText title = (EditText)alertDialog.findViewById(R.id.title);
                        final EditText memo = (EditText)alertDialog.findViewById(R.id.memo);
                        final EditText dueDate = (EditText)alertDialog.findViewById(R.id.dueText);

                        if (title != null) {
                            todo.title = title.getText().toString();
                        }
                        if (memo != null) {
                            todo.memo = memo.getText().toString();
                        }
                        if (dueDate != null) {
                            todo.dueDate = dueDate.getText().toString();
                        }
                        // the input data will be sent to store by using bus
                        FluxContext.getInstance()
                                .getActionCreator()
                                .sendRequestAsync(TODO_ADD, todo);
                    }
                })
                .setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
                    public void onClick(final DialogInterface inDialog, final int inId) {
                        // Do nothing
                    }
                });

        return builder.create();
    }

}

再來(lái)就是讓 MainActivity 可以在用戶按下 Menu 時(shí)彈出 AlertDialog蕊苗,所以新增如下的 Method:

@Override
public boolean onOptionsItemSelected(MenuItem item) {
    boolean result;

    switch (item.getItemId()) {
        case R.id.add:
            AddDialogFragment addDialog = new AddDialogFragment();

            // display an input dialog to get a new todo
            addDialog.show(super.getSupportFragmentManager(), "Add");
            result = true;
            break;
        default:
            result = super.onOptionsItemSelected(item);
            break;
    }

    return result;
}

運(yùn)行所有的測(cè)試,看測(cè)試的結(jié)果沒(méi)有通過(guò)的不多了沿彭,距完成只剩一步之遙朽砰。

編寫(xiě)關(guān)閉 Todo 的功能

從最后一次 UAT 運(yùn)行的結(jié)果可以發(fā)現(xiàn),仍未滿足需求的項(xiàng)目只剩下關(guān)閉 Todo 最后一項(xiàng)喉刘。要達(dá)成這一項(xiàng)功能要回到 TodoAdapter瞧柔,將 onBindViewHolder 改成以下的內(nèi)容:

    @Override
    public void onBindViewHolder(final ViewHolder inViewHolder, final int inPosition) {
        final Todo item = this.mStore.getItem(inPosition);

        // bind data into item view of RecyclerView
        inViewHolder.title.setText(item.title);
        if (item.closed) {
            inViewHolder.title.setPaintFlags(
                    inViewHolder.title.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
        } else {
            inViewHolder.title.setPaintFlags(
                    inViewHolder.title.getPaintFlags() & (~ Paint.STRIKE_THRU_TEXT_FLAG));
        }
        inViewHolder.dueDate.setText(item.dueDate);
        inViewHolder.memo.setText(item.memo);
        inViewHolder.closed.setOnCheckedChangeListener(null);
        inViewHolder.closed.setChecked(item.closed);
        inViewHolder.closed.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(final CompoundButton inButtonView, final boolean inIsChecked) {
                item.closed = inIsChecked;
                FluxContext.getInstance()
                        .getActionCreator()
                        .sendRequestAsync(TODO_CLOSE, item);
            }
        });
    }

最后,運(yùn)行最開(kāi)始寫(xiě)好的 UAT睦裳,非常好造锅,所有的需求都通過(guò)測(cè)試,打完收工廉邑!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末哥蔚,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子蛛蒙,更是在濱河造成了極大的恐慌糙箍,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,126評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件牵祟,死亡現(xiàn)場(chǎng)離奇詭異深夯,居然都是意外死亡信殊,警方通過(guò)查閱死者的電腦和手機(jī)辆飘,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)瓮具,“玉大人收奔,你說(shuō)我怎么就攤上這事掌呜。” “怎么了坪哄?”我有些...
    開(kāi)封第一講書(shū)人閱讀 152,445評(píng)論 0 341
  • 文/不壞的土叔 我叫張陵站辉,是天一觀的道長(zhǎng)呢撞。 經(jīng)常有香客問(wèn)我,道長(zhǎng)饰剥,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,185評(píng)論 1 278
  • 正文 為了忘掉前任摧阅,我火速辦了婚禮汰蓉,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘棒卷。我一直安慰自己顾孽,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,178評(píng)論 5 371
  • 文/花漫 我一把揭開(kāi)白布比规。 她就那樣靜靜地躺著若厚,像睡著了一般。 火紅的嫁衣襯著肌膚如雪蜒什。 梳的紋絲不亂的頭發(fā)上测秸,一...
    開(kāi)封第一講書(shū)人閱讀 48,970評(píng)論 1 284
  • 那天,我揣著相機(jī)與錄音灾常,去河邊找鬼霎冯。 笑死,一個(gè)胖子當(dāng)著我的面吹牛钞瀑,可吹牛的內(nèi)容都是我干的沈撞。 我是一名探鬼主播,決...
    沈念sama閱讀 38,276評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼雕什,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼缠俺!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起贷岸,我...
    開(kāi)封第一講書(shū)人閱讀 36,927評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤壹士,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后凰盔,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體墓卦,經(jīng)...
    沈念sama閱讀 43,400評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,883評(píng)論 2 323
  • 正文 我和宋清朗相戀三年户敬,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了落剪。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 37,997評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡尿庐,死狀恐怖忠怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情抄瑟,我是刑警寧澤凡泣,帶...
    沈念sama閱讀 33,646評(píng)論 4 322
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響鞋拟,放射性物質(zhì)發(fā)生泄漏骂维。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,213評(píng)論 3 307
  • 文/蒙蒙 一贺纲、第九天 我趴在偏房一處隱蔽的房頂上張望航闺。 院中可真熱鬧,春花似錦猴誊、人聲如沸潦刃。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,204評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)乖杠。三九已至,卻和暖如春澄成,著一層夾襖步出監(jiān)牢的瞬間胧洒,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,423評(píng)論 1 260
  • 我被黑心中介騙來(lái)泰國(guó)打工环揽, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留略荡,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,423評(píng)論 2 352
  • 正文 我出身青樓歉胶,卻偏偏與公主長(zhǎng)得像汛兜,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子通今,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,722評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容

  • 想要追上最新的編程潮流嗎粥谬?想要導(dǎo)入最新的 Flux 編程方法嗎?這篇文章將手把手的帶你無(wú)痛進(jìn)入 Flux 與 Rx...
    _WZ_閱讀 850評(píng)論 0 1
  • 想要追上最新的編程潮流嗎辫塌?想要導(dǎo)入最新的 Flux 編程方法嗎漏策?這篇文章將手把手的帶你無(wú)痛進(jìn)入 Flux 與 Rx...
    _WZ_閱讀 2,406評(píng)論 0 9
  • Spring Cloud為開(kāi)發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見(jiàn)模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn)臼氨,斷路器掺喻,智...
    卡卡羅2017閱讀 134,599評(píng)論 18 139
  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 171,498評(píng)論 25 707
  • 對(duì)于轟隆運(yùn)轉(zhuǎn)的政治和經(jīng)濟(jì)機(jī)器而言,我如此微不足道而無(wú)關(guān)緊要 但對(duì)于我自己的個(gè)體生命而言 只能活一次的我如此的珍貴唯...
    林青禾閱讀 318評(píng)論 0 0