Model-View-Intent 构建的响应式应用(二)View & Intent

JerryXia 发表于 , 阅读 (29)
不明,不名

Model-View-Intent 构建的响应式应用(二)View & Intent

翻译自 REACTIVE APPS WITH MODEL-VIEW-INTENT - PART2 - VIEW AND INTENT

第一部分 ,我们讨论了许多与 Model 相关的内容,Model 的真正含义、它与状态之间的关系、一个完善的 Model 是如何解决 Android 开发中的问题的。接下来,我们继续向前,看看到底怎样去构建一个响应式应用。

如果你还没有看过 第一部分 的文章,那你最好还是先去看一看再说。我总结一下上一篇的内容:不要这么写代码(传统的 MVP 的写法):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class PersonsPresenter extends Presenter<PersonsView> {
public void load() {
getView().showLoading(true); // 显示一个进度条
backend.loadPersons(new Callback() {
public void onSuccess(List<Person> persons) {
getView().showPersons(persons); // 展示用户列表
}
public void onError(Throwable error) {
getView().showError(error); // 展示错误信息
}
});
}
}

我们应该创建一个 Model 来表示 状态

1
2
3
4
5
6
7
8
9
10
11
12
13
class PersonsModel {
// 在实际应用中,这些字段应当是 private
// 并且生成 getter
final boolean loading;
final List<Person> persons;
final Throwable error;
public PersonsModel(boolean loading, List<Person> persons, Throwable error) {
this.loading = loading;
this.persons = persons;
this.error = error;
}
}

然后,Presenter 应该这么实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class PersonsPresenter extends Presenter<PersonsView> {
public void load() {
getView().render(new PersonsModel(true, null, null)); // 显示一个进度条
backend.loadPersons(new Callback() {
public void onSuccess(List<Person> persons) {
getView().render(new PersonsModel(false, persons, null)); // 展示用户列表
}
public void onError(Throwable error) {
getView().render(new PersonsModel(false, null, error)); // 展示错误信息
}
})
}
}

现在,View 就有了可以用来渲染的 Model 了,然后简单地调用 render(personsModel) 就行了。除此以外,上一篇文章还说明了单向数据流的重要性,Model 应该由业务逻辑来驱动。在我把这些点联系起来之前,还是赶快来说一说 MVI 吧。

Model-View-Intent (MVI)

这个模式是 André Medeiros (Staltz) 在写一个 JavaScript 框架 cycle.js 的时候提出的,从一个假设(也可以说是理论)的角度来看,我们可以这样描述 MVI

  • intent() :这个方法获取用户的输入(诸如点击之类的 UI 事件),将之转换为“某种事物”,并作为参数传入 model() 方法。这个事物可以是一个简单的字符串,也可以是一个复杂的对象。这个过程可以比喻为,通过某种意图(intent)来改变模型(model)。
  • model()model() 方法接收 intent() 的输出,用以操作 Model 。这个方法的输出是一个全新的 Model (因为状态改变了),它并不是去更新那个已经存在的 ModelModel 是不可变的! 我在 第一部分 给了一个具体的例子——一个计数应用。再强调一遍,我们不会去改变那个已经存在的 Model 对象实例,我们是根据 intent 描述的内容重新创建了一个 Model 。重中之重,model() 方法是你的代码中唯一可以创建 Model 实例的地方。然后,这个崭新的、不可变的Model 就是这个方法的输出。从根本上来说,model() 方法就是调用了应用中的业务逻辑代码(可以是 InteractorUsecaseRepository 等等,随你的代码怎么描述的),最终输出一个新的 Model
  • view() :这个方法接收了model() 输出的 Model 对象,然后以某种方式展示这个 Model 。这其实和上面的 view.render(model) 一模一样。

但是,我们想要的是一个“响应式应用”, MVI 哪里体现“响应式”了?这儿的“响应式”到底又意味着什么?我们说的“响应式”,指的是 UI 响应状态的变化,而“状态”又是由 Model 体现的, 所以从本质上来说,就是我们的业务逻辑“响应”用户的输入(intents ),然后生成对应的 Model ,由 view() 负责将 Model 渲染到屏幕上。

使用 RxJava 实战

我们希望我们的数据流向是单向的,接下来就看一看 RxJava 的写法吧。不过话说回来,我们用 RxJava ,就必须要构建一个基于单向数据流和 MVI 架构的响应式应用吗?当然不是,你还是可以写命令式代码的。但是,RxJava 非常擅长基于事件的编程模型,既然 UI 编程本身就是基于事件的,那使用 RxJava 就再合适不过了。

通过这篇文章,我们会写出一个简单的在线商店应用。我们通过 http 请求从后端获取商品数据,然后显示到手机上。我们当然也要能够搜索商品,往购物车添加商品。看一看这个应用的最终效果吧:

点击查看视频

应用源码可以从 github 上获取。那我们就先从简单的开始吧:实现搜索功能。先定义一个 Model 吧,View 需要用它来展示。在这一系列的文章中,我们所有的 Model 类都会加上 ViewState 后缀 。比如,在这个例子中, Model 需要用来表示商品的搜索结果,我们将其命名为 SearchViewState ,这是因为 Model 反映了 State 。像其他的类名,SearchModel 看起来有点奇怪,SearchViewModel 可能又会让人误解为 MVVM 。起名字真难!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public interface SearchViewState {
/**
* 还没有开始搜索的时候
*/
final class SearchNotStartedYet implements SearchViewState {}
/**
* Loading: 现在是等待搜索结果
*/
final class Loading implements SearchViewState {}
/**
* 没有搜索到内容
*/
final class EmptyResult implements SearchViewState {
private final String searchQueryText;
public EmptyResult(String searchQueryText) {
this.searchQueryText = searchQueryText;
}
public String getSearchQueryText() {
return searchQueryText;
}
}
/**
* 有效的搜索结果,包含了符合搜索条件的商品列表
*/
final class SearchResult implements SearchViewState {
private final String searchQueryText;
private final List<Product> result;
public SearchResult(String searchQueryText, List<Product> result) {
this.searchQueryText = searchQueryText;
this.result = result;
}
public String getSearchQueryText() {
return searchQueryText;
}
public List<Product> getResult() {
return result;
}
}
/**
* 表示搜索时产生的错误
*/
final class Error implements SearchViewState {
private final String searchQueryText;
private final Throwable error;
public Error(String searchQueryText, Throwable error) {
this.searchQueryText = searchQueryText;
this.error = error;
}
public String getSearchQueryText() {
return searchQueryText;
}
public Throwable getError() {
return error;
}
}
}

既然 Java 是一门强类型语言,那我们不妨为我们的 Model 类选择一种类型安全的构造方法——通过“子状态”将每个状态分开。我们的业务逻辑可以返回 SearchViewState 类型的实例,这个实例就可能是 SearchViewState.Error 类型的。这只是个人偏好,我们也可以用完全不同的方式去构造:

1
2
3
4
5
6
class SearchViewState {
Throwable error; // 如果不为null,就是产生了错误
boolean loading; // 如果为true,就是正在加载数据
List<Product> result; // 如果不为null,这就是搜索结果
boolean searchNotStartedYet; // true,就是还没开始搜索
}

再次声明,怎样构造你的 Model 类只是个人偏好,如果你使用了 Kotlin ,那么 sealed class 非常好用。

下面来看一看业务逻辑。我们用 SearchInteractor 来表示搜索动作的执行,它的输出,就是一个 SearchViewState 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SearchInteractor {
final SearchEngine searchEngine; // 网络访问
public Observable<SearchViewState> search(String searchString) {
// 如果是空串,不搜索
if (searchString.isEmpty()) {
return Observable.just(new SearchViewState.SearchNotStartedYet());
}
// 搜索商品
return searchEngine.searchFor(searchString)
.map(products -> {
if (products.isEmpty()) {
return new SearchViewState.EmptyResult(searchString);
} else {
return new SearchViewState.SearchResult(searchString, products);
}
})
.startWith(new SearchViewState.Loading())
.onErrorReturn(error -> new SearchViewState.Error(searchString, error));
}
}

来看看 SearchInteractor.search() 的方法签名:String searchString 作为输入,Observable<SearchViewState> 作为输出。这已经说明,随着时间的推移,我们向 Observable 流不断发射 SearchViewState 类型的各种实例。startWith() 意味着,在没有开始搜索(通过 SearchEngine 去执行 http 请求)的时候,会发射一个 SearchViewState.Loading 的实例,这会使得 View 在执行搜索过程的时候显示进度框。

onErrorReturn() 会捕捉整个搜索过程中的所有异常,然后发射一个 SearchViewState.Error 实例。我们不可以在 subscribe 的时候直接使用那个 onError 回调吗?这又是使用 RxJava 时的一个误区:这个错误回调的设计初衷,是希望,在整个 observable 流遇到了不可挽回的错误,导致 observable 流终止的时候,才去使用它。而在这里,像没有联网之类的异常并非不可挽回的错误,它也是一种状态,也可以用我们的 Model 表示。更进一步说,这样我们也能更加方便地进入到下一个状态,比如,网络从断开到连接,我们立马就能走到下一个由 SearchViewState.Loading 表示的“加载状态”。我们从业务逻辑到页面视图建立了一个 observable 流,只要状态发生了变化,就会发射一条数据。我们当然不希望因为网络断开而终止这条 observable 流,因此,这样的错误就可以作为一个状态(而不是会终止 observable 流的致命错误),发射给 observable 流。在 MVI 中,Model Observable 通常是不会终止的(也就是永远不会调用 subscriberonComplete()onError() )。

总结下来就是:SearchInteractor (业务逻辑)提供了一条 observableObservable<SearchViewState> ,状态每变化一次,就发射一条新的 SearchViewState 数据。

下面看看 View 层该怎么实现。View 该干些什么呢?很显然, view 应该展示 Model 。我们希望 View 有一个类似 render(model) 的方法,除此之外,view 还应当为其他层提供一个用以表示用户输入事件的方法,这在 MVI 中就叫做 intents 。在这个例子中,只有一个意图(intent):用户可以通过输入字符串来搜索商品。我们用了一个类似 MVP 的方式来实现了 MVI ,在 MVP 中,最好为 View 层定义一个接口,所以:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface SearchView {
/**
* 搜索意图
*
* @return 一个 observable 包裹的搜索文字
*/
Observable<String> searchIntent();
/**
* 渲染页面
*
* @param viewState 当前要显示的状态
*/
void render(SearchViewState viewState);
}

在这个例子中,View 只提供了一个 intent ,但在大多数情况下,一个 View 会提供多个 intents 。在 第一部分 ,我们讨论了为什么单个的 render() 方法很好,如果你还是不明白,那就再看一看第一部分,或者在下面留言(译注:英文原文下留言),也可以看一看里面的评论。在开始具体的实现之前,我们再来看一看效果:

点击查看视频

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public class SearchFragment extends Fragment implements SearchView {
@BindView(R.id.searchView) android.widget.SearchView searchView;
@BindView(R.id.container) ViewGroup container;
@BindView(R.id.loadingView) View loadingView;
@BindView(R.id.errorView) TextView errorView;
@BindView(R.id.recyclerView) RecyclerView recyclerView;
@BindView(R.id.emptyView) View emptyView;
private SearchAdapter adapter;
@Override public Observable<String> searchIntent() {
return RxSearchView.queryTextChanges(searchView) // Thanks Jake Wharton :)
.filter(queryString -> queryString.length() > 3 || queryString.length() == 0)
.debounce(500, TimeUnit.MILLISECONDS);
}
@Override public void render(SearchViewState viewState) {
if (viewState instanceof SearchViewState.SearchNotStartedYet) {
renderSearchNotStarted();
} else if (viewState instanceof SearchViewState.Loading) {
renderLoading();
} else if (viewState instanceof SearchViewState.SearchResult) {
renderResult(((SearchViewState.SearchResult) viewState).getResult());
} else if (viewState instanceof SearchViewState.EmptyResult) {
renderEmptyResult();
} else if (viewState instanceof SearchViewState.Error) {
Timber.e(((Se