想要追上最新的编程潮流吗?想要导入最新的 Flux 编程方法吗?这篇文章将手把手的带你无痛进入 Flux 的领域。
这篇是 FluxJava: 给 Java 使用的 Flux 库 的延续,会透过建构一个演示的 Todo App 的过程来说明如何使用 FluxJava,所有演示的源代码都可以在 Github 上找到。
Flux 简介
为了方便不熟悉 Flux 的读者,一开始会先简短地说明这个架构。以下是借用 Facebook 在 Flux 官网上的原图:
从图上可以看到所有箭头都是单向的,且形成一个封闭的循环。这一个循环代表的是数据在 Flux 架构中流动的过程,整个流程以 Dispatcher 为集散中心。
Action 大多是由与用户互动的画面控件所发起,在透过某种 Creator 的方法产生之后被送入 Dispatcher。此时,已经有跟 Dispatcher 注册过的 Store 都会被调用,并且经由预先在 Store 上定义好的 Method 接收到 Action。Store 会在这个接收到 Action 的 Method 中,将数据的状态依照 Action 的内容来进行调整。
前端的画面控件或是负责控制画面的控件,会收到由 Store 在处理完数据后所送出的异动事件,异动的事件就是用来代表在数据层的数据状态已经不一样了。这些前端控件在自行订义的事件处理方法中聆听、截收这些事件,并且在事件收到后由 Store 获取最新的数据状态。最后前端控件触发自己内部的更新画面程序,让画面上所有阶层子控件都能够反应新的数据状态,如此完成一个数据流动的循环。
以上是一个很简单的说明,如果需要了解更进一步的内容,可以自行上网搜寻,现在网络上应该已经有为数不少的文章可以参考。
以 BDD 来做为需求的开端
为了能够更清楚的说明源代码的细节,所以文章中会依照 BDD 的概念来逐步解说源代码。所以首先是要先列出需求:
- 显示 Todo 清单
- 在不同用户间切换 Todo 清单
- 新增 Todo
- 关闭/重启 Todo
接着下来就要把需求转更明确的叙述内容,因为只是演示,所列仅做出一个 Story 做为目标:
Story: Manage todo items
Narrative:
As a user
I want to manage todo items
So I can track something to be done
Scenario 1: Add a todo
When I tap the add menu on main activity
Then I see the add todo screen
When I input todo detail and press ADD button
Then I see a new entry in list
Scenario 2: Add a todo, but cancel the action
When I tap the add menu on main activity
And I press cancel button
Then Nothing happen
Scenario 3: Switch user
When I select a different user
Then I see the list changed
Scenario 4: Mark a todo as done
When I mark a todo as done
Then I see the todo has a check mark and strike through on title
也因为只是演示用,Story 的内容并没有很严谨,而演示所使用的文字虽然是英文,但在实际的案例上用自己习惯的文字即可。
决定测试的方略
既然是采用 BDD 来做演示,当然在程序编写的过程中会希望能够有适切的工具的辅助,毕竟工欲善其事、必先利其器。所以在编写测试源代码时,不使用 Android 范本中的 JUnit,改为使用在“使用 Android Studio 开发 Web 程序 - 测试”提到的 Spock Framework,并且全部以 Groovy 来编写。因为 Spock Framework 内置了支援 BDD 的功能,把之前做好的 Story 转成测试源代码的工作也会简化很多。
接下来要决定如何在 Android 项目中定位与分配测试源代码。Espresso 是 Android 开发环境中内置的,使用 Espresso 来开发测试程序还是有一定的必要性。只不过 Espresso 必须要跑在实机或模拟的环境上,运行效率问题是无法被忽视的一个因素,而且 androidTest
下的源代码其运行结果也没有办法在 Android Studio 中检视 Code Coverage 的状态,所以用 Espresso 编写的测试程序并不适合用来做为 Unit Test。
再加上新版的 Android Studio 提供了录制测试步骤的功能,最后会被转成 Espresso 的源代码。所以看起来 Espresso 比较适合用来做为开发程流后段的一些测试工作,像是 UAT、压力测试、稳定度测试。依据这样的定位,之前写好的 Story 会在 Espresso 上转成测试源代码,来验证程序的功能是否有达到 Story 描述的内容。
单元测试的部份没有疑问地应该是写在 Android 项目范本所提供的 test
的路径之下,要解决的是 Android 控件如何在 JVM 的环境中运行测试。大部份人目前的选择应该都会是 Robolectric,只不过测试源代码要使用 Spock 来开发,所以这二个包必须要做个整合。RoboSpock就是提供此一解决方案的包,可以让 Robolectric 在基于 Spock 所开发的 Class 中能够被直接使用。
使用 Robolectric 虽然能够对 Android 组件在 JVM 中进行测试,但毕竟这类的组件相互之间的藕合性还是有点高,尤其是提供画面的控件。所以这个部分在归类上我定位成 Integration Test,但在数据的供给上,拜 Flux 架构之赐,可以依照情境来进行代换,只测试 Android 组件与组件之间的整合度,这个部份在接下来的内容会进行说明。附带一提,有关测试上的一些想法我有写成一篇文章,可以参考:“软件测试杂谈”。
以下列出本次使用的测试组件清单:
- Groovy
- Spock Framework
- RoboSpock
- Espresso
设定 Spock 与 Espresso、Robolectric 时会有一些细节需要注意,相关的说明请参考“配置 build.gradle 来用 Spock 对 Android 组件进行测试”。最后的 build.gradle
设定结果,可以在 Github 上的文件内容中看到。
建立画面配置
在产生完 Android 项目空壳后,首先修改 MainActivity 的内容。在 MainActivity 画面中加上 RecyclerView 及 Spinner 来显示 Todo 清单以及提供切换用户的功能。Layout 的配置显示如下:
开发 UAT
原本范本中默认产生的 androidTest/java
的路径可以删除,另外要在 androidTest
之下增加一个 groovy
的文件夹,如果 build.gradle
有设定正确,在 groovy
文件夹上应该会出现代表测试源代码的底色。因为目前只有一个 Story 所以在 groovy
路径下配对的 Package 中增加一个 ManageTodoStory.groovy
的文件。
在这里就可以显现 Spock 所带来的优势,把之前的 Story 内容转成以下的源代码,与原本的 Story 比对并没有太大的差距。
@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 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)
}
}
以上源代码中使用到的资源当然是要在编写之前就事件准备好,否则出现错误的讯息。完成后先运行一次测试,当然结果都是失败的,接下来就可以依照需求来逐项开发功能。
编写 Bus
依照 Flux 架构,需要为整个数据循环建立 Dispatcher。但是在 FluxJava 中 Dispatcher 的功能是以 Bus 的方式实现,所以实际上是要先准备 Bus 的 Class。在这次的示范中使用 greenrobot 的 EventBus 来简化开发工作,并且包装在实现 IFluxBus
的 Interface 内,以便整合进 FluxJava 的 Framework 内。源代码的内容如下:
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 搭配的测试用 Class 的内容如下:
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
}
}
运行以上 Class 之后,确认测试通过并检视 Code Coverage。如果测得到的源代码都有被涵盖,就可以确认目前完成的程序有一定的稳定度,可以继续往下进行接下来的工作。接下来的几个小节都会采用这样的工作节奏来逐步推进,以其望在程序完成时能够有一定基础的质量。
准备 Model
这里的 Model 是二个 POJO,分别用来代表一笔 User 和 Todo 的数据内容。因为这部分并不是示范的重点,所以文件的内容请自行参考 User.java
及 Todo.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_USER
及 DATA_TODO
来代表用户及 Todo。以 User 的需求来看,在画面上只会有载入数据的要求,以提供切换用户的功能,所以 User 的动作只定义了 USER_LOAD。而 Todo 的需求就比较复杂,除了载入数据以外,还要可以新增、关闭 Todo。所以目前定义 TODO_LOAD
、TODO_ADD
、TODO_CLOSE
等三个常量。
这些常量接下来会被用在 StoreMap 的键值及 Action 的 Type。在 FluxJava 中并没有限定只能使用数值型别来做为键值,可以根据每个项目的特性来设定,可以是字串、型别或是同一个型别不同的 Instance。
编写 Action 及 Store
UserAction 和 TodoAction 都是很直接地继承自 FluxAction。其中比较特别是:考量到一次可能会要处理多笔数据,所以在 Data 属性的泛型上使用 List 来做为承载数据的基础。这二个 Class 的内容请直接连上 Github 的 UserAction.java
及 TodoAction.java
二个文件查询。
Store 可以继承 FluxJava 内置的 FluxStore,或是自行实现 IFluxStore 的 Interface。在 IFluxStore 中 register
及 unregister
是提供给前端的画面控件,做为向 Store 登记要接收到数据异动事件之用。
Tag 则是考量到同一个 Store 有可能要产生多个 Instance 来服务不同的画面控件,所以仿照 Android 控件的方式,用 Tag 来识别不同的 Instance。像是在同一个画面中,可能会因为需求的关系,要使用不同条件所产生的清单来呈现图表。这时就有必要使用二个不同的 Instance 来提供数据,否则会造成画面上数据的混乱。
至于 getItem
、findItem
、getCount
都是很基本在呈现数据内容时需要使用到的功能。其中 getItem
之所以限定一次只取得一笔数据,而不是以 List 的方式传回,主要是为了符合 Flux 单向数据流的精神。如果 getItem
传回的是 List,前端很有可能意外地异动了清单的内容,根据 Java 的特性,这样的异动结果也会反应在 Store 所提供的信息上。也就等于数据的清单在 Store 以外,也有机会被异动,这就违反了 Flux 在设计上所想要达成的数据流动过程。
当然,就算是只提供一项数据,前端也许改不了整个清单,但还是可以修改所收到的这单一项目,其结果一样会反应回 Store 的内部。所以在示范的源代码中,在 getItem
所传回的是一个全新的 Instance。
@Override
public User getItem(final int inIndex) {
return new User(this.mList.get(inIndex));
}
在 Store 中有一个关键的 Method 是 FluxStore 中没有、要自行增加的,那就是用来接收前端所推送出来的 Action。由于目前使用的是 EventBus,以 UserStore 为例,会有以下的内容:
@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;
}
}
可以看到之前定义的常量在这里派上用场了,利用 Action 的 Type 可以区分出前端所接收到的指令。在这个 Demo 中,Store 的定位只是用来管理清单,清单的数据会由 ActionCreator 传入,所以可以看到源代码中只是做很简单的载入工作,载入完即发出数据异动的事件。这个事件是定义在 Store 内部,每个 Store 都有定义自己的 Event,以便让前端控件判别与过滤所想收到的 Event 种类。
在以上的 Method 源代码中,使用了 EventBus 所提供的功能,在接收到 Action 的当下是以背景的 Thread 在运行,避免因为过长的数据处理时间导至前端画面冻结。Method 的参数则是用以过滤 Action,让指定的 Action 型别在 Bus 中被传递时才调用 Mehtod,减少源代码判断上的负担。如果是同一个 Store 有多个 Instace 同时存在,在接收到的 Action 中可以加入 Tag 的信息,以便让 Store 判别目前传入的 Action 是否为针对自己所发出来的。
使用 EventBus 的 Annotation 规格声明 Method 时,在 Android Studio 上会有一个即时语法检查的警告出现,相关的处理细节可以参考这一篇文章。
而因为需求的关系,同样的 Method 在 TodoStore 中就相对地复杂了一点:
@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;
}
}
主要是多了二种数据处理的要求:在新增时,前端会把新增的内容传入,所以这里很简单地把收到的项目加入清单之中,就可以通知前端刷新数据。至于在关闭 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 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
重点工作是把之前步骤中准备好的 Bus、ActionHelper、StoreMap 传入 FluxContext 的 Builder 之中,并且透过 Builder 建立 FluxContext 的 Instance。截至目前为止,后端准备的工作算是完成了,在文件夹的结构上各位应该可以看出来,我把以上的 Class 都归类在 Domain 的范畴之中。
编写 Adapter
Adapter 是用来供给 Spinner 及 RecyclerView 数据的 Class,同时在这次的演示中也是与 FluxJava 介接的关键角色,代表的是在 Flux 流程图中的 View。在 MainActivity 中 Spinner 是用来显示 User 清单,而 RecyclerView 是用来显示 Todo 清单,所以各自对应的 Adapter 分别是 UserAdapter 及 TodoAdapter。
虽然这二个 Adapter 继承自不同的 Base Class,但是都需要提供 Item 的 Layout 以便展示数据。所以先产生 item_user.xml
及 item_todo.xml
二个文件。
准备好了 Item 的 Layout 就可以进行 Adapter 的编写工作,以下是 UserAdapter 的完整内容:
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 来取得 Store 的 Instance。使用的第一个参数就是之前在常量定义好的 USER_DATA
,第二参数的 Tag 因为本次示范没有使用到所以传入 Null。最后一个参数是把 Adapter 本身的 Instance 传入,FluxContext 会把传入的 Instance 注册到 Store 中。当然,如果要在取回 Store 后再自行注册也是可以的。
之后部份就是 Adapter 的基本应用,需要提供数据有关的信息时,则是透过 Store 来取得。
在 Adapter 的尾端可以看到有一个和 Store 类似的 Method,因为同样是使用 EventBus 来传送信息,所以使用相同的方式来接收数据异动的事件。同样地,在 Method 的参数上以型别来限定要收到的事件种类,被调用后的工作也很简单,就是转通知 Spinner 重刷画面。由于是要更新画面上的信息,所以要回到 UI Thread 来运行,threadMode 被指定为 MainThread。如果同一个 Store 同时有多个 Instance 存在,和 Store 的 onAction
一样,可以在 Event 中加入 Tag 的信息,以减少无用的重刷频繁地出现。
最后则是一个用来释放 Reference 的接口,主要之目的是避免 Memory Leak 的问题,大部份都是在 Activity 卸载时调用。
以下是另外一个 Adapter - TodoAdapter 的内容:
public class TodoAdapter extends RecyclerView.Adapter {
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);
}
}
除了因为是继承自不同 Base Class 所产生的写法上之差异外,并没有太大的不同。重点是在接收事件的 Method 多了一个,用来当数据异动的情境是修改时,只更新有异动的 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
这里使用 StubAppConfig 做为切入点的演示并不是唯一的方法,在实际应用上还是应该选择适合自己项目的方式。
如果在运行 UAT 希望也使用测试的数据来进行,以 FluxJava 来说当然也不会是问题,达成的方式在本次的示范中也可以看得到。原理同样是和 Integration Test 相同,是使用取代原本 AppConfig 的方式。只是在 Espresso 里设定就会麻烦一点,首先要增加一个自定义的 JUnitRunner,接着 build.gradle
中 defaultConfig
改成以下的内容:
defaultConfig {
...
// replace "android.support.test.runner.AndroidJUnitRunner" with custom one
testInstrumentationRunner "com.example.fluxjava.eventbus.CustomJUnitRunner"
}
同时调整 Android Studio 的 Configuration 中指定的 Instrumentation Runner 内容如下:
所以在运行 UAT 与正常启动的情况下,可以在画面中看到截然不同的数据内容,即代表 Store 代换的工作确实地达成目标。
编写新增 Todo 功能
在这次的演示中,达成新增 Todo 的功能就只是很简单地在 MainActivity 加上 Add Menu,透过用户按下 Add 后,显示一个 AlertDialog 取回用户新增的内容完成新增的程序。以下是 `menu_main.xml’ 的内容:
接着在 MainActivity.java
中加上以下的 Method:
@Override
public boolean onCreateOptionsMenu(final Menu inMenu) {
super.getMenuInflater().inflate(R.menu.menu_main, inMenu);
return true;
}
用来让用户输入数据的 AlertDialog 是用 DialogFragment 来达成,以下是 Layout 的内容:
源代码则是如下所示:
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,非常好,所有的需求都通过测试,打完收工!