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

想要追上最新的编程潮流吗?想要导入最新的 Flux 编程方法吗?这篇文章将手把手的带你无痛进入 Flux 与 RxJava 结合的领域。

这篇是 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 的配置显示如下:

<?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>

开发 UAT

原本范本中默认产生的 androidTest/java 的路径可以删除,另外要在 androidTest 之下增加一个 groovy 的文件夹,如果 build.gradle 有设定正确,在 groovy 文件夹上应该会出现代表测试源代码的底色。因为目前只有一个 Story 所以在 groovy 路径下配对的 Package 中增加一个 ManageTodoStory.groovy 的文件。

在这里就可以显现 Spock 所带来的优势,把之前的 Story 内容转成以下的源代码,与原本的 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 是用中文写成的,以上的套用方式还是适用的。有关 Spock 的使用方式在这里就不详细地说明,各位可以自行上网搜寻,或是参考我之前写的这一篇这一篇有关 Spock 的文章。接着就是把源代码填入,完成后的内容如下所示:

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

}

以上源代码中使用到的资源当然是要在编写之前就事件准备好,否则出现错误的讯息。完成后先运行一次测试,当然结果都是失败的,接下来就可以依照需求来逐项开发功能。

了解 RxBus

除了原本 IFluxBus 所定义的 Method 以外,为了善用 RxJava 所带来的优势,RxBus 提供一个 toObservable 的 Method,利用这个 Method 所传回的 Object,可以进行所有 RxJava 所提供的功能。

同时,在向 Observable 订阅时会取得 Subscription,RxBus 可以协助管理 Subscription。在 Subscriber 向 RxBus 取消注册时,一并更新所属的 Subscription 状态,以避免持续地收到通知。

如果是透过 registerunregister 来注册 Subscriber,则不需要特别处理 Subscription 的问题。但如果是由先前所提到 toObservable 的 Method 来订阅,则另外要呼叫 addSubscriptionremoveSubscription 来将 Subscription 列入与移除 RxBus 的管理机制。

准备 Model

这里的 Model 是二个 POJO,分别用来代表一笔 User 和 Todo 的数据内容。因为这部分并不是示范的重点,所以文件的内容请自行参考 User.javaTodo.java

定义常量

常量主要的作用是以不同的数值来区分不同的数据种类,以及每一个数据种类因应需求所必须提供的功能。如同以下所展示的源代码内容:

// 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;

在需求中提到需要处理二种类型的数据,所以就分别定义了 DATA_USERDATA_TODO 来代表用户及 Todo。以 User 的需求来看,在画面上只会有载入数据的要求,以提供切换用户的功能,所以 User 的动作只定义了 USER_LOAD。而 Todo 的需求就比较复杂,除了载入数据以外,还要可以新增、关闭 Todo。所以目前定义 TODO_LOADTODO_ADDTODO_CLOSE 等三个常量。

这些常量接下来会被用在 StoreMap 的键值及 Action 的 Type。在 FluxJava 中并没有限定只能使用数值型别来做为键值,可以根据每个项目的特性来设定,可以是字串、型别或是同一个型别不同的 Instance。

编写 Action 及 Store

UserAction 和 TodoAction 都是很直接地继承自 FluxAction。其中比较特别是:考量到一次可能会要处理多笔数据,所以在 Data 属性的泛型上使用 List 来做为承载数据的基础。这二个 Class 的内容请直接连上 Github 的 UserAction.javaTodoAction.java 二个文件查询。

Store 可以继承 FluxJava 内置的 RxStore,在 RxStore 中 registerunregister 是提供给前端的画面元件,做为向 Store 登记要接收到资料异动事件之用。与 RxBus 相同,RxStore 额外提供一个 toObservable,如果想要取得更多在使用 RxJava 上的弹性,可以改为使用 toObservable

当外部所有的调用都是使用 toObservable 来进行订阅,则不会使用到 IRxDataChange 的 Interface,这个介面是透过 register 订阅时才会需要实作。

Tag 则是考量到同一个 Store 有可能要产生多个 Instance 来服务不同的画面控件,所以仿照 Android 控件的方式,用 Tag 来识别不同的 Instance。像是在同一个画面中,可能会因为需求的关系,要使用不同条件所产生的清单来呈现图表。这时就有必要使用二个不同的 Instance 来提供数据,否则会造成画面上数据的混乱。

至于 getItemfindItemgetCount 都是很基本在呈现数据内容时需要使用到的功能。其中 getItem 之所以限定一次只取得一笔数据,而不是以 List 的方式传回,主要是为了符合 Flux 单向数据流的精神。如果 getItem 传回的是 List,前端很有可能意外地异动了清单的内容,根据 Java 的特性,这样的异动结果也会反应在 Store 所提供的信息上。也就等于数据的清单在 Store 以外,也有机会被异动,这就违反了 Flux 在设计上所想要达成的数据流动过程。

当然,就算是只提供一项数据,前端也许改不了整个清单,但还是可以修改所收到的这单一项目,其结果一样会反应回 Store 的内部。所以在示范的源代码中,在 getItem 所传回的是一个全新的 Instance。

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

在 RxStore 中有一个关键的 Method 是要覆写的,那就是 onAction,是用来接收前端所推送出来的 Action。而覆写 getActionType 可以用来指定特定的 Action 型别,避免收到不相关的 Action 通知。以 UserStore 为例,会有以下的内容:

@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 可以区分出前端所接收到的指令。在这个 Demo 中,Store 的定位只是用来管理清单,清单的数据会由 ActionCreator 传入,所以可以看到源代码中只是做很简单的载入工作,载入完即发出数据异动的事件。这个事件是定义在 Store 内部,每个 Store 都有定义自己的 Event,以便让前端控件判别与过滤所想收到的 Event 种类。

在以上的 Method 源代码中,使用了 RxStore 所提供的功能,在接收到 Action 的当下是以背景的 Thread 在运行,避免因为过长的数据处理时间导至前端画面冻结。Method 的参数则是用以过滤 Action,让指定的 Action 型别在 Bus 中被传递时才调用 Mehtod,减少源代码判断上的负担。如果是同一个 Store 有多个 Instace 同时存在,在接收到的 Action 中可以加入 Tag 的信息,以便让 Store 判别目前传入的 Action 是否为针对自己所发出来的。

而因为需求的关系,同样的 Method 在 TodoStore 中就相对地复杂了一点:

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

主要是多了二种数据处理的要求:在新增时,前端会把新增的内容传入,所以这里很简单地把收到的项目加入清单之中,就可以通知前端更新数据。至于在关闭 Todo 的部份,由于之前提到 Store 在 getItem 回传的都是全新的 Instance,所以要先进行比对找出数据在清单中的位置,因为是示范的缘故,很单纯地只写了个循环来比对。找到了对应的位置后,直接以新的内容取代原本清单中的项目,再通知前端更新画面。

如此,Action 与 Store 的编写工作就算完成了。同样地,在这个阶段的最后,运行写好的测试程序来确认目前为止的工作成果。

编写 ActionHelper

FluxJava 已经内置了一个负责 ActionCreator 的 Class,这个 ActionCreator 使用 ActionHelper 来注入自定义的程序逻辑。可自定义的内容分为二个部份,第一个是决定如何建立 Action 的 Instance,第二个是协助处理数据格式的转换。

以下是第一个部份的演示源代码:

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

内容的重点就是依照先前定义好的常量来指定所属的 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;
}

根据 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,内容如下:

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

重点工作是把之前步骤中准备好的 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.xmlitem_todo.xml 二个文件。

准备好了 Item 的 Layout 就可以进行 Adapter 的编写工作,以下是 UserAdapter 的完整内容:

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。使用的第一个参数就是之前在常量定义好的 USER_DATA,第二参数的 Tag 因为本次示范没有使用到所以传入 Null。最后一个参数是把 Adapter 本身的 Instance 传入,FluxContext 会把传入的 Instance 注册到 Store 中。当然,如果要在取回 Store 后再自行注册也是可以的。

之后部份就是 Adapter 的基本应用,需要提供数据有关的信息时,则是透过 Store 来取得。

在 Adapter 的 Constructor 中可以看到以 RxJava 的方式向 Store 进行订阅的程序,可以用来接收资料异动的事件。传入的 Action 型别参数是用来限定要收到的事件种类,被呼叫后的工作也很简单,就是转通知 Spinner 重刷画面。由于是要更新画面上的资讯,所以要回到 UI Thread 来执行,observeOn 被指定为 MainThread。如果同一个 Store 同时有多个 Instance 存在,和 Store 的 onAction 一样,可以在 Event 中加入 Tag 的资讯,以减少无用的重刷频繁地出现。

最后则是一个用来释放 Reference 的接口,主要之目的是避免 Memory Leak 的问题,大部份都是在 Activity 卸载时调用。

以下是另外一个 Adapter - TodoAdapter 的内容:

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 所产生的写法上之差异外,并没有太大的不同。重点是在接收事件的订阅多了一个,用来当资料异动的情境是修改时,只更新有异动的 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 传入对应的画面控件外,还有几个重点。第一个是在 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。 就如同以下源代码所示范:

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

}

而在 StubAppConfig 中就可以对 FluxContext 注入测试用的 Class 来转为提供测试用的数据:

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 做为切入点的演示并不是唯一的方法,在实际应用上还是应该选择适合自己项目的方式。

如果在运行 UAT 希望也使用测试的数据来进行,以 FluxJava 来说当然也不会是问题,达成的方式在本次的示范中也可以看得到。原理同样是和 Integration Test 相同,是使用取代原本 AppConfig 的方式。只是在 Espresso 里设定就会麻烦一点,首先要增加一个自定义的 JUnitRunner,接着 build.gradledefaultConfig 改成以下的内容:

defaultConfig {
    ...

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

}

同时调整 Android Studio 的 Configuration 中指定的 Instrumentation Runner 内容如下:


所以在运行 UAT 与正常启动的情况下,可以在画面中看到截然不同的数据内容,即代表 Store 代换的工作确实地达成目标。


Production

Test

编写新增 Todo 功能

在这次的演示中,达成新增 Todo 的功能就只是很简单地在 MainActivity 加上 Add Menu,透过用户按下 Add 后,显示一个 AlertDialog 取回用户新增的内容完成新增的程序。以下是 `menu_main.xml’ 的内容:

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

用来让用户输入数据的 AlertDialog 是用 DialogFragment 来达成,以下是 Layout 的内容:

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

运行所有的测试,看测试的结果没有通过的不多了,距完成只剩一步之遥。

编写关闭 Todo 的功能

从最后一次 UAT 运行的结果可以发现,仍未满足需求的项目只剩下关闭 Todo 最后一项。要达成这一项功能要回到 TodoAdapter,将 onBindViewHolder 改成以下的内容:

    @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,非常好,所有的需求都通过测试,打完收工!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,511评论 1 330
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,495评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,595评论 0 225
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,558评论 0 190
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,715评论 3 270
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,672评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,112评论 2 291
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,837评论 0 181
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,417评论 0 228
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,928评论 2 232
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,316评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,773评论 2 234
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,253评论 3 220
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,827评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,440评论 0 180
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,523评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,583评论 2 249

推荐阅读更多精彩内容

  • 想要追上最新的编程潮流吗?想要导入最新的 Flux 编程方法吗?这篇文章将手把手的带你无痛进入 Flux 与 Rx...
    _WZ_阅读 797评论 0 1
  • 想要追上最新的编程潮流吗?想要导入最新的 Flux 编程方法吗?这篇文章将手把手的带你无痛进入 Flux 的领域。...
    _WZ_阅读 4,192评论 0 3
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,048评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,471评论 25 707
  • 一直以来的青春啊,从来只在脑海里出现。 对于像我这种应届毕业生,脑海里想的不是美好的大学生活,也不是怎么面对残...
    苏Monstar阅读 159评论 0 0