手把手教你如何一起用 FluxJava 與 RxJava

想要追上最新的編程潮流嗎疙咸?想要導(dǎo)入最新的 Flux 編程方法嗎尤蛮?這篇文章將手把手的帶你無痛進(jìn)入 Flux 與 RxJava 結(jié)合的領(lǐng)域媳友。

這篇是 FluxJava: 給 Java 使用的 Flux 庫 的延續(xù),會透過建構(gòu)一個演示的 Todo App 的過程來說明如何使用 FluxJava产捞,所有演示的源代碼都可以在 Github 上找到醇锚。

大部份的內(nèi)容其實都已經(jīng)寫在另一篇中,但考量到有一些性急的朋友對于要開另外一篇文章,來看與想找的主題無關(guān)的內(nèi)容會感到不耐焊唬,所以在這篇文章中還是會呈現(xiàn)完整的步驟及相關(guān)的內(nèi)容恋昼。

Flux 簡介

為了方便不熟悉 Flux 的讀者,一開始會先簡短地說明這個架構(gòu)赶促。以下是借用 Facebook 在 Flux 官網(wǎng)上的原圖:


從圖上可以看到所有箭頭都是單向的液肌,且形成一個封閉的循環(huán)。這一個循環(huán)代表的是數(shù)據(jù)在 Flux 架構(gòu)中流動的過程鸥滨,整個流程以 Dispatcher 為集散中心嗦哆。

Action 大多是由與用戶互動的畫面控件所發(fā)起,在透過某種 Creator 的方法產(chǎn)生之后被送入 Dispatcher婿滓。此時老速,已經(jīng)有跟 Dispatcher 注冊過的 Store 都會被調(diào)用,并且經(jīng)由預(yù)先在 Store 上定義好的 Method 接收到 Action凸主。Store 會在這個接收到 Action 的 Method 中橘券,將數(shù)據(jù)的狀態(tài)依照 Action 的內(nèi)容來進(jìn)行調(diào)整。

前端的畫面控件或是負(fù)責(zé)控制畫面的控件卿吐,會收到由 Store 在處理完數(shù)據(jù)后所送出的異動事件旁舰,異動的事件就是用來代表在數(shù)據(jù)層的數(shù)據(jù)狀態(tài)已經(jīng)不一樣了。這些前端控件在自行訂義的事件處理方法中聆聽嗡官、截收這些事件箭窜,并且在事件收到后由 Store 獲取最新的數(shù)據(jù)狀態(tài)。最后前端控件觸發(fā)自己內(nèi)部的更新畫面程序谨湘,讓畫面上所有階層子控件都能夠反應(yīng)新的數(shù)據(jù)狀態(tài)绽快,如此完成一個數(shù)據(jù)流動的循環(huán)。

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

以 BDD 來做為需求的開端

為了能夠更清楚的說明源代碼的細(xì)節(jié)活孩,所以文章中會依照 BDD 的概念來逐步解說源代碼。所以首先是要先列出需求:

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

接著下來就要把需求轉(zhuǎn)更明確的敘述內(nèi)容乖仇,因為只是演示憾儒,所列僅做出一個 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

也因為只是演示用,Story 的內(nèi)容并沒有很嚴(yán)謹(jǐn)乃沙,而演示所使用的文字雖然是英文起趾,但在實際的案例上用自己習(xí)慣的文字即可。

決定測試的方略

既然是采用 BDD 來做演示警儒,當(dāng)然在程序編寫的過程中會希望能夠有適切的工具的輔助训裆,畢竟工欲善其事眶根、必先利其器。所以在編寫測試源代碼時边琉,不使用 Android 范本中的 JUnit属百,改為使用在“使用 Android Studio 開發(fā) Web 程序 - 測試”提到的 Spock Framework,并且全部以 Groovy 來編寫变姨。因為 Spock Framework 內(nèi)置了支援 BDD 的功能族扰,把之前做好的 Story 轉(zhuǎn)成測試源代碼的工作也會簡化很多。

接下來要決定如何在 Android 項目中定位與分配測試源代碼定欧。Espresso 是 Android 開發(fā)環(huán)境中內(nèi)置的渔呵,使用 Espresso 來開發(fā)測試程序還是有一定的必要性。只不過 Espresso 必須要跑在實機(jī)或模擬的環(huán)境上忧额,運行效率問題是無法被忽視的一個因素厘肮,而且 androidTest 下的源代碼其運行結(jié)果也沒有辦法在 Android Studio 中檢視 Code Coverage 的狀態(tài)愧口,所以用 Espresso 編寫的測試程序并不適合用來做為 Unit Test睦番。

再加上新版的 Android Studio 提供了錄制測試步驟的功能,最后會被轉(zhuǎn)成 Espresso 的源代碼耍属。所以看起來 Espresso 比較適合用來做為開發(fā)程流后段的一些測試工作托嚣,像是 UAT、壓力測試厚骗、穩(wěn)定度測試示启。依據(jù)這樣的定位,之前寫好的 Story 會在 Espresso 上轉(zhuǎn)成測試源代碼领舰,來驗證程序的功能是否有達(dá)到 Story 描述的內(nèi)容夫嗓。

單元測試的部份沒有疑問地應(yīng)該是寫在 Android 項目范本所提供的 test 的路徑之下,要解決的是 Android 組件如何在 JVM 的環(huán)境中運行測試冲秽。大部份人目前的選擇應(yīng)該都會是 Robolectric舍咖,只不過測試源代碼要使用 Spock 來開發(fā),所以這二個包必須要做個整合锉桑。RoboSpock就是提供此一解決方案的包排霉,可以讓 Robolectric 在基于 Spock 所開發(fā)的 Class 中能夠被直接使用。

使用 Robolectric 雖然能夠?qū)?Android 組件在 JVM 中進(jìn)行測試民轴,但畢竟這類的組件相互之間的藕合性還是有點高攻柠,尤其是提供畫面的控件。所以這個部分在歸類上我定位成 Integration Test后裸,但在數(shù)據(jù)的供給上瑰钮,拜 Flux 架構(gòu)之賜,可以依照情境來進(jìn)行代換微驶,只測試 Android 組件與組件之間的整合度浪谴,這個部份在接下來的內(nèi)容會進(jìn)行說明。附帶一提,有關(guān)測試上的一些想法我有寫成一篇文章较店,可以參考:“軟件測試雜談”士八。

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

  • Groovy
  • Spock Framework
  • RoboSpock
  • Espresso

設(shè)定 Spock 與 Espresso、Robolectric 時會有一些細(xì)節(jié)需要注意梁呈,相關(guān)的說明請參考“配置 build.gradle 來用 Spock 對 Android 組件進(jìn)行測試”婚度。最后的 build.gradle 設(shè)定結(jié)果,可以在 Github 上的文件內(nèi)容中看到官卡。

建立畫面配置

在產(chǎn)生完 Android 項目空殼后蝗茁,首先修改 MainActivity 的內(nèi)容。在 MainActivity 畫面中加上 RecyclerView 及 Spinner 來顯示 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.rx.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>

開發(fā) UAT

原本范本中默認(rèn)產(chǎn)生的 androidTest/java 的路徑可以刪除哮翘,另外要在 androidTest 之下增加一個 groovy 的文件夾,如果 build.gradle 有設(shè)定正確毛秘,在 groovy 文件夾上應(yīng)該會出現(xiàn)代表測試源代碼的底色饭寺。因為目前只有一個 Story 所以在 groovy 路徑下配對的 Package 中增加一個 ManageTodoStory.groovy 的文件。

在這里就可以顯現(xiàn) Spock 所帶來的優(yōu)勢叫挟,把之前的 Story 內(nèi)容轉(zhuǎn)成以下的源代碼艰匙,與原本的 Story 比對并沒有太大的差距。

@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 是用中文寫成的抹恳,以上的套用方式還是適用的员凝。有關(guān) Spock 的使用方式在這里就不詳細(xì)地說明,各位可以自行上網(wǎng)搜尋奋献,或是參考我之前寫的這一篇這一篇有關(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)然是要在編寫之前就事件準(zhǔn)備好瓶蚂,否則出現(xiàn)錯誤的訊息糖埋。完成后先運行一次測試,當(dāng)然結(jié)果都是失敗的扬跋,接下來就可以依照需求來逐項開發(fā)功能阶捆。

了解 RxBus

除了原本 IFluxBus 所定義的 Method 以外,為了善用 RxJava 所帶來的優(yōu)勢钦听,RxBus 提供一個 toObservable 的 Method洒试,利用這個 Method 所傳回的 Object,可以進(jìn)行所有 RxJava 所提供的功能朴上。

同時垒棋,在向 Observable 訂閱時會取得 Subscription,RxBus 可以協(xié)助管理 Subscription痪宰。在 Subscriber 向 RxBus 取消注冊時叼架,一并更新所屬的 Subscription 狀態(tài)畔裕,以避免持續(xù)地收到通知。

如果是透過 registerunregister 來注冊 Subscriber乖订,則不需要特別處理 Subscription 的問題扮饶。但如果是由先前所提到 toObservable 的 Method 來訂閱,則另外要呼叫 addSubscriptionremoveSubscription 來將 Subscription 列入與移除 RxBus 的管理機(jī)制乍构。

準(zhǔn)備 Model

這里的 Model 是二個 POJO甜无,分別用來代表一筆 User 和 Todo 的數(shù)據(jù)內(nèi)容。因為這部分并不是示范的重點哥遮,所以文件的內(nèi)容請自行參考 User.javaTodo.java岂丘。

定義常量

常量主要的作用是以不同的數(shù)值來區(qū)分不同的數(shù)據(jù)種類,以及每一個數(shù)據(jù)種類因應(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;

在需求中提到需要處理二種類型的數(shù)據(jù)奥帘,所以就分別定義了 DATA_USERDATA_TODO 來代表用戶及 Todo。以 User 的需求來看仪召,在畫面上只會有載入數(shù)據(jù)的要求寨蹋,以提供切換用戶的功能,所以 User 的動作只定義了 USER_LOAD返咱。而 Todo 的需求就比較復(fù)雜钥庇,除了載入數(shù)據(jù)以外牍鞠,還要可以新增咖摹、關(guān)閉 Todo。所以目前定義 TODO_LOAD难述、TODO_ADD萤晴、TODO_CLOSE 等三個常量。

這些常量接下來會被用在 StoreMap 的鍵值及 Action 的 Type胁后。在 FluxJava 中并沒有限定只能使用數(shù)值型別來做為鍵值店读,可以根據(jù)每個項目的特性來設(shè)定,可以是字串攀芯、型別或是同一個型別不同的 Instance屯断。

編寫 Action 及 Store

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

Store 可以繼承 FluxJava 內(nèi)置的 RxStore年鸳,在 RxStore 中 registerunregister 是提供給前端的畫面元件趴久,做為向 Store 登記要接收到資料異動事件之用。與 RxBus 相同搔确,RxStore 額外提供一個 toObservable彼棍,如果想要取得更多在使用 RxJava 上的彈性灭忠,可以改為使用 toObservable

當(dāng)外部所有的調(diào)用都是使用 toObservable 來進(jìn)行訂閱座硕,則不會使用到 IRxDataChange 的 Interface弛作,這個介面是透過 register 訂閱時才會需要實作。

Tag 則是考量到同一個 Store 有可能要產(chǎn)生多個 Instance 來服務(wù)不同的畫面控件华匾,所以仿照 Android 控件的方式缆蝉,用 Tag 來識別不同的 Instance。像是在同一個畫面中瘦真,可能會因為需求的關(guān)系刊头,要使用不同條件所產(chǎn)生的清單來呈現(xiàn)圖表。這時就有必要使用二個不同的 Instance 來提供數(shù)據(jù)诸尽,否則會造成畫面上數(shù)據(jù)的混亂原杂。

至于 getItemfindItem您机、getCount 都是很基本在呈現(xiàn)數(shù)據(jù)內(nèi)容時需要使用到的功能穿肄。其中 getItem 之所以限定一次只取得一筆數(shù)據(jù),而不是以 List 的方式傳回际看,主要是為了符合 Flux 單向數(shù)據(jù)流的精神咸产。如果 getItem 傳回的是 List,前端很有可能意外地異動了清單的內(nèi)容仲闽,根據(jù) Java 的特性脑溢,這樣的異動結(jié)果也會反應(yīng)在 Store 所提供的信息上。也就等于數(shù)據(jù)的清單在 Store 以外赖欣,也有機(jī)會被異動屑彻,這就違反了 Flux 在設(shè)計上所想要達(dá)成的數(shù)據(jù)流動過程。

當(dāng)然顶吮,就算是只提供一項數(shù)據(jù)社牲,前端也許改不了整個清單,但還是可以修改所收到的這單一項目悴了,其結(jié)果一樣會反應(yīng)回 Store 的內(nèi)部搏恤。所以在示范的源代碼中,在 getItem 所傳回的是一個全新的 Instance湃交。

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

在 RxStore 中有一個關(guān)鍵的 Method 是要覆寫的熟空,那就是 onAction,是用來接收前端所推送出來的 Action巡揍。而覆寫 getActionType 可以用來指定特定的 Action 型別痛阻,避免收到不相關(guān)的 Action 通知。以 UserStore 為例腮敌,會有以下的內(nèi)容:

@Override
protected <TAction extends IFluxAction> void onAction(TAction inAction) {
    final UserAction action = (UserAction)inAction;

    // base on input action to process data
    // in this sample only define one action
    switch (action.getType()) {
        case USER_LOAD:
            this.mList.clear();
            this.mList.addAll(action.getData());
            super.emitChange(new ListChangeEvent());
            break;
    }
}

可以看到之前定義的常量在這里派上用場了阱当,利用 Action 的 Type 可以區(qū)分出前端所接收到的指令俏扩。在這個 Demo 中,Store 的定位只是用來管理清單弊添,清單的數(shù)據(jù)會由 ActionCreator 傳入录淡,所以可以看到源代碼中只是做很簡單的載入工作,載入完即發(fā)出數(shù)據(jù)異動的事件油坝。這個事件是定義在 Store 內(nèi)部嫉戚,每個 Store 都有定義自己的 Event,以便讓前端控件判別與過濾所想收到的 Event 種類澈圈。

在以上的 Method 源代碼中彬檀,使用了 RxStore 所提供的功能,在接收到 Action 的當(dāng)下是以背景的 Thread 在運行瞬女,避免因為過長的數(shù)據(jù)處理時間導(dǎo)至前端畫面凍結(jié)窍帝。Method 的參數(shù)則是用以過濾 Action,讓指定的 Action 型別在 Bus 中被傳遞時才調(diào)用 Mehtod诽偷,減少源代碼判斷上的負(fù)擔(dān)坤学。如果是同一個 Store 有多個 Instace 同時存在,在接收到的 Action 中可以加入 Tag 的信息报慕,以便讓 Store 判別目前傳入的 Action 是否為針對自己所發(fā)出來的深浮。

而因為需求的關(guān)系,同樣的 Method 在 TodoStore 中就相對地復(fù)雜了一點:

@Override
protected <TAction extends IFluxAction> void onAction(final TAction inAction) {
    final TodoAction action = (TodoAction)inAction;

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

主要是多了二種數(shù)據(jù)處理的要求:在新增時眠冈,前端會把新增的內(nèi)容傳入飞苇,所以這里很簡單地把收到的項目加入清單之中,就可以通知前端更新數(shù)據(jù)洋闽。至于在關(guān)閉 Todo 的部份玄柠,由于之前提到 Store 在 getItem 回傳的都是全新的 Instance,所以要先進(jìn)行比對找出數(shù)據(jù)在清單中的位置诫舅,因為是示范的緣故,很單純地只寫了個循環(huán)來比對宫患。找到了對應(yīng)的位置后刊懈,直接以新的內(nèi)容取代原本清單中的項目,再通知前端更新畫面娃闲。

如此虚汛,Action 與 Store 的編寫工作就算完成了。同樣地皇帮,在這個階段的最后卷哩,運行寫好的測試程序來確認(rèn)目前為止的工作成果。

編寫 ActionHelper

FluxJava 已經(jīng)內(nèi)置了一個負(fù)責(zé) ActionCreator 的 Class属拾,這個 ActionCreator 使用 ActionHelper 來注入自定義的程序邏輯将谊±淙埽可自定義的內(nèi)容分為二個部份,第一個是決定如何建立 Action 的 Instance尊浓,第二個是協(xié)助處理數(shù)據(jù)格式的轉(zhuǎn)換逞频。

以下是第一個部份的演示源代碼:

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)容的重點就是依照先前定義好的常量來指定所屬的 Action 型別。

第二個部分就會有比較多的工作需要完成:

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 文件的說明栋齿,ActionCreator 在建立 Action 的時候是調(diào)用外部 API 取得數(shù)據(jù)的切入點亚兄。所以 ActionHelper 提供了一個 wrapData 來讓使用 FluxJava 的程序有機(jī)會在此時取得外部的數(shù)據(jù)默蚌。在以上的程序中,還另外演示了另一種 wrapData 可能的用途。由于在前端會接收到的信息有可能有多種變化饿自,像是在演示中,要求載入 User 時只需要一個數(shù)值杰刽、在載入 Todo 時則要額外告知此時選擇的 User购城、在新增或修改 Todo 時則是要把修改的結(jié)果傳入。這時 wrapData 就可以適時地把這些不同型式的信息轉(zhuǎn)成 Store 要的內(nèi)容放在 Action 中刨疼,讓 Store 做后續(xù)的處理泉唁。

如果想要使用自定義的 ActionCreator,可以在初始化 FluxContext 時將自定義的 ActionCreator Instance 傳入揩慕,只是這個自定義的 ActionCreator 要繼承自內(nèi)置的 ActionCreator亭畜,以覆寫原本的 Method 來達(dá)到自定義的效果。

組合控件

這次演示中迎卤,F(xiàn)lux 的架構(gòu)橫跨整個 App 的生命周期拴鸵。所以最合理的切入位置是自定義的 Application,這里增加了一個名為 AppConfig 的 Class 做為初始化 Flux 架構(gòu)的進(jìn)入點蜗搔,同時修改 AndroidManifest.xml 讓 AppConfig 可以在 App 啟動時被調(diào)用劲藐。

在 AppConfig 內(nèi)增加一個 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();
}

重點工作是把之前步驟中準(zhǔn)備好的 Bus樟凄、ActionHelper聘芜、StoreMap 傳入 FluxContext 的 Builder 之中,并且透過 Builder 建立 FluxContext 的 Instance缝龄。截至目前為止汰现,后端準(zhǔn)備的工作算是完成了,在文件夾的結(jié)構(gòu)上各位應(yīng)該可以看出來叔壤,我把以上的 Class 都?xì)w類在 Domain 的范疇之中瞎饲。

編寫 Adapter

Adapter 是用來供給 Spinner 及 RecyclerView 數(shù)據(jù)的 Class,同時在這次的演示中也是與 FluxJava 介接的關(guān)鍵角色炼绘,代表的是在 Flux 流程圖中的 View嗅战。在 MainActivity 中 Spinner 是用來顯示 User 清單,而 RecyclerView 是用來顯示 Todo 清單俺亮,所以各自對應(yīng)的 Adapter 分別是 UserAdapter 及 TodoAdapter驮捍。

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

準(zhǔn)備好了 Item 的 Layout 就可以進(jìn)行 Adapter 的編寫工作萨醒,以下是 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);
        this.mStore.toObservable(UserStore.ListChangeEvent.class)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(
                        new Action1<UserStore.ListChangeEvent>() {
                            @Override
                            public void call(UserStore.ListChangeEvent inEvent) {
                                UserAdapter.super.notifyDataSetChanged();
                            }
                        },
                        new Action1<Throwable>() {
                            @Override
                            public void call(Throwable inThrowable) {
                                // put error handle here
                            }
                        });
    }

    @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;
    }

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

}

在 UserAdapter 的 Constructor 中,使用 FluxContext 來取得 Store 的 Instance苇倡。使用的第一個參數(shù)就是之前在常量定義好的 USER_DATA富纸,第二參數(shù)的 Tag 因為本次示范沒有使用到所以傳入 Null。最后一個參數(shù)是把 Adapter 本身的 Instance 傳入旨椒,F(xiàn)luxContext 會把傳入的 Instance 注冊到 Store 中晓褪。當(dāng)然,如果要在取回 Store 后再自行注冊也是可以的综慎。

之后部份就是 Adapter 的基本應(yīng)用涣仿,需要提供數(shù)據(jù)有關(guān)的信息時,則是透過 Store 來取得示惊。

在 Adapter 的 Constructor 中可以看到以 RxJava 的方式向 Store 進(jìn)行訂閱的程序好港,可以用來接收資料異動的事件。傳入的 Action 型別參數(shù)是用來限定要收到的事件種類米罚,被呼叫后的工作也很簡單钧汹,就是轉(zhuǎn)通知 Spinner 重刷畫面。由于是要更新畫面上的資訊录择,所以要回到 UI Thread 來執(zhí)行拔莱,observeOn 被指定為 MainThread。如果同一個 Store 同時有多個 Instance 存在隘竭,和 Store 的 onAction 一樣塘秦,可以在 Event 中加入 Tag 的資訊,以減少無用的重刷頻繁地出現(xiàn)动看。

最后則是一個用來釋放 Reference 的接口尊剔,主要之目的是避免 Memory Leak 的問題,大部份都是在 Activity 卸載時調(diào)用弧圆。

以下是另外一個 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);
        this.mStore.toObservable(TodoStore.ListChangeEvent.class)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(
                        new Action1<TodoStore.ListChangeEvent>() {
                            @Override
                            public void call(final TodoStore.ListChangeEvent inEvent) {
                                TodoAdapter.super.notifyDataSetChanged();
                            }
                        },
                        new Action1<Throwable>() {
                            @Override
                            public void call(Throwable inThrowable) {
                                // put error handle here
                            }
                        });
        this.mStore.toObservable(TodoStore.ItemChangeEvent.class)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(
                        new Action1<TodoStore.ItemChangeEvent>() {
                            @Override
                            public void call(final TodoStore.ItemChangeEvent inEvent) {
                                TodoAdapter.super.notifyItemChanged(inEvent.position);
                            }
                        },
                        new Action1<Throwable>() {
                            @Override
                            public void call(Throwable inThrowable) {
                                // put error handle here
                            }
                        });
    }

    @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();
    }

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

}

除了因為是繼承自不同 Base Class 所產(chǎn)生的寫法上之差異外赋兵,并沒有太大的不同。重點是在接收事件的訂閱多了一個搔预,用來當(dāng)資料異動的情境是修改時,只更新有異動的 Item叶组,以增加程序運作的效率拯田。

接下來的工作就是把 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 傳入對應(yīng)的畫面控件外,還有幾個重點甩十。第一個是在 onStop 時要調(diào)用 Adapter 的 dispose 以避免之前提到的 Memory Leak 的問題船庇。另外一個是在 onStart 時會以非同步的方式要求提供 User 的清單數(shù)據(jù)吭产,在畫面持續(xù)在前景運作的同時,UserStore 完成數(shù)據(jù)載入就會觸發(fā) UserAdapter鸭轮、UserAdapter 再觸發(fā) Spinner臣淤、Spinner 觸發(fā) TodoStore 的載入、TodoStore 觸發(fā) TodoAdapter窃爷、TodoAdapter 觸發(fā) RecyclerView 等一連串?dāng)?shù)據(jù)更新的動作邑蒋。所以可以在 Spinner 的 OnItemSelectedListener 中看到要求送出 TODO_LOAD 的 Action。

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

寫到這里除了運行所有已完成的單元測試外逮京,其實可以再回去運行一次 UAT卿堂,這時可以發(fā)現(xiàn)已經(jīng)開始有測試結(jié)果轉(zhuǎn)為通過了。

編寫 Integration Test

在繼續(xù)完成需求之前懒棉,先插入一個有關(guān)測試上的說明草描,使用 Flux 的其中一個重要原因就是希望提高源代碼的可測試性。所以在這次的演示之中策严,選擇以 Integration Test 來展示 FluxJava 可以達(dá)到的效果穗慕。

就像一開始提到的,用 Robolectric 來測試 MainActivity 被定位成 Integration Test享钞。主要的測試目標(biāo)是要確認(rèn)整合起來后 UI 的行為符合設(shè)計的內(nèi)容揍诽,此時當(dāng)然不希望使用真實的數(shù)據(jù)來測試,簡單的說就是要把 Store 給隔離開來栗竖。

要達(dá)到這個目的可以由 FluxContext 的初始化做為切入點暑脆,以 Robolectric 來說,他提供了一個方便的功能狐肢,就是可以在測試運行時以 Annotation 中設(shè)定的 Applicaton Class 取代原本的 Class添吗。 就如同以下源代碼所示范:

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

}

而在 StubAppConfig 中就可以對 FluxContext 注入測試用的 Class 來轉(zhuǎn)為提供測試用的數(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 做為切入點的演示并不是唯一的方法,在實際應(yīng)用上還是應(yīng)該選擇適合自己項目的方式份名。

如果在運行 UAT 希望也使用測試的數(shù)據(jù)來進(jìn)行碟联,以 FluxJava 來說當(dāng)然也不會是問題,達(dá)成的方式在本次的示范中也可以看得到僵腺。原理同樣是和 Integration Test 相同鲤孵,是使用取代原本 AppConfig 的方式。只是在 Espresso 里設(shè)定就會麻煩一點辰如,首先要增加一個自定義的 JUnitRunner普监,接著 build.gradledefaultConfig 改成以下的內(nèi)容:

defaultConfig {
    ...

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

}

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


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


Production

Test

編寫新增 Todo 功能

在這次的演示中毙玻,達(dá)成新增 Todo 的功能就只是很簡單地在 MainActivity 加上 Add Menu,透過用戶按下 Add 后廊散,顯示一個 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;
}

用來讓用戶輸入數(shù)據(jù)的 AlertDialog 是用 DialogFragment 來達(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();
    }

}

再來就是讓 MainActivity 可以在用戶按下 Menu 時彈出 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;
}

運行所有的測試运准,看測試的結(jié)果沒有通過的不多了,距完成只剩一步之遙擂找。

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

從最后一次 UAT 運行的結(jié)果可以發(fā)現(xiàn)戳吝,仍未滿足需求的項目只剩下關(guān)閉 Todo 最后一項。要達(dá)成這一項功能要回到 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);
            }
        });
    }

最后听哭,運行最開始寫好的 UAT,非常好塘雳,所有的需求都通過測試陆盘,打完收工!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末败明,一起剝皮案震驚了整個濱河市隘马,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌妻顶,老刑警劉巖酸员,帶你破解...
    沈念sama閱讀 206,126評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異讳嘱,居然都是意外死亡幔嗦,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評論 2 382
  • 文/潘曉璐 我一進(jìn)店門沥潭,熙熙樓的掌柜王于貴愁眉苦臉地迎上來邀泉,“玉大人,你說我怎么就攤上這事钝鸽』阈簦” “怎么了?”我有些...
    開封第一講書人閱讀 152,445評論 0 341
  • 文/不壞的土叔 我叫張陵拔恰,是天一觀的道長因谎。 經(jīng)常有香客問我,道長颜懊,這世上最難降的妖魔是什么蓝角? 我笑而不...
    開封第一講書人閱讀 55,185評論 1 278
  • 正文 為了忘掉前任阱穗,我火速辦了婚禮饭冬,結(jié)果婚禮上使鹅,老公的妹妹穿的比我還像新娘。我一直安慰自己昌抠,他們只是感情好患朱,可當(dāng)我...
    茶點故事閱讀 64,178評論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著炊苫,像睡著了一般裁厅。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上侨艾,一...
    開封第一講書人閱讀 48,970評論 1 284
  • 那天执虹,我揣著相機(jī)與錄音,去河邊找鬼唠梨。 笑死袋励,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的当叭。 我是一名探鬼主播茬故,決...
    沈念sama閱讀 38,276評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼蚁鳖!你這毒婦竟也來了磺芭?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,927評論 0 259
  • 序言:老撾萬榮一對情侶失蹤醉箕,失蹤者是張志新(化名)和其女友劉穎钾腺,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體讥裤,經(jīng)...
    沈念sama閱讀 43,400評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡放棒,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,883評論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了坞琴。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片哨查。...
    茶點故事閱讀 37,997評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖剧辐,靈堂內(nèi)的尸體忽然破棺而出寒亥,到底是詐尸還是另有隱情,我是刑警寧澤荧关,帶...
    沈念sama閱讀 33,646評論 4 322
  • 正文 年R本政府宣布溉奕,位于F島的核電站,受9級特大地震影響忍啤,放射性物質(zhì)發(fā)生泄漏加勤。R本人自食惡果不足惜仙辟,卻給世界環(huán)境...
    茶點故事閱讀 39,213評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望鳄梅。 院中可真熱鬧叠国,春花似錦、人聲如沸戴尸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,204評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽孙蒙。三九已至项棠,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間挎峦,已是汗流浹背香追。 一陣腳步聲響...
    開封第一講書人閱讀 31,423評論 1 260
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留坦胶,地道東北人透典。 一個月前我還...
    沈念sama閱讀 45,423評論 2 352
  • 正文 我出身青樓,卻偏偏與公主長得像迁央,于是被迫代替她去往敵國和親掷匠。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,722評論 2 345

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

  • 想要追上最新的編程潮流嗎岖圈?想要導(dǎo)入最新的 Flux 編程方法嗎讹语?這篇文章將手把手的帶你無痛進(jìn)入 Flux 與 Rx...
    _WZ_閱讀 850評論 0 1
  • 想要追上最新的編程潮流嗎?想要導(dǎo)入最新的 Flux 編程方法嗎蜂科?這篇文章將手把手的帶你無痛進(jìn)入 Flux 的領(lǐng)域顽决。...
    _WZ_閱讀 4,355評論 0 3
  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn)导匣,斷路器才菠,智...
    卡卡羅2017閱讀 134,599評論 18 139
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 171,498評論 25 707
  • 一直以來的青春啊,從來只在腦海里出現(xiàn)贡定。 對于像我這種應(yīng)屆畢業(yè)生赋访,腦海里想的不是美好的大學(xué)生活,也不是怎么面對殘...
    蘇Monstar閱讀 172評論 0 0