Kotlin核心编程 遨游篇:kotlin实战(第12章)
第12章 基于Kotlin的Android架构
在移动端发展的早期,我们通常会提及App的架构,此时总会有些大材小用的感觉,因为移动端并没有复杂的业务处理、高并发等场景,甚至我们需要做的只是简单地“将数据展示在屏幕上”。但是随着移动端的飞速发展,产生了一些问题:
• 移动端App中业务逻辑越来越复杂,用户渴望更好的体验及更新颖的功能;
• 不断地迭代让项目结构复杂化,维护成本越来越高。
所以,我们需要一个良好的架构模式,拆分视图和数据,解除模块之间的耦合,提高模块内部的聚合度,让系统更稳健。本章谈论的架构,即是对客户端的代码组织/职责进行的划分。
我们知道,自从在Google IO大会被提名后,Kotlin就在Android中迅速发展。作为Kotlin的学习者,相信你也对其在Android架构中发挥的作用十分感兴趣。在本章中,我们将会以传统的MVC及当下流行的MVP、MVVM架构为例,为读者展现Kotlin在实现这些架构时的魅力。
除此之外,本章还会为大家介绍一种比较新颖的事物——基于单向数据流的Android架构。有前端经验的读者已经对其有所体会,在iOS中,这种架构也已经获得较高的关注。我们会基于一个名为ReKotlin的开源项目来实现一个完整的Android架构。
12.1 架构方式的演变
首先我们来回顾一下近几年内移动端架构模式的演变。本章将其分为MV*与单向数据流两大类。由于Kotlin在Android中的特殊地位,以下内容我们都将以Android架构为例。
12.1.1 经典的MVC问题
Android架构的鼻祖,自然是经典的MVC了。在用户界面比业务逻辑更容易发生变化的时候,客户端和后端开发需要一种分离用户界面功能的方式,这时候,MVC模式应运而生。MVC对应Model、View、Controller,如图12-1所示。

• Model(数据层):负责管理业务逻辑和处理网络或数据库API。
• View(视图层):让数据层的数据可视化。在Android中对应用户交互、UI绘制等。
• Controller(逻辑层):获得用户行为的通知,并根据需要更新Model。
很多人对于经典的MVC架构中的Model一直存在误解,认为其代表的只是一个实体模型。其实,准确来说它还应该包含大量的业务逻辑处理。相对而言,Controller只是在View和Model层之间建立一个桥梁而已。
我们将以上结构细分如下。
• Model层:数据访问(数据库、文件、网络等)、缓存(图片、文件等)、配置文件(shared perference)等;
• View层:数据展示与管理、用户交互、UI组件的绘制、列表Adapter等;
• Controller层:初始化配置(定义全局变量等)、数据加工(加工成UI层需要的数据)、数据变化的通知机制等。
当你在Stack Overflow中搜索类似“如何在Android应用中使用Activity”的问题时,你会发现最高频的答案就是:一个Activity既是View又是Controller。这看起来好像对新手非常不友好,但是当时解决的重点问题是使Model可测试。这也让很多开发者在项目结构中出现了很多Free Style的代码,导致Activity中代码量庞大并且难以维护。
经过大量时间与项目的验证,我们更加明确:Activities、Fragments和Views都应该被划分到MVC的View层中,而不是Controller或Model中。
1.MVC的优势
Model类没有对Android类的任何引用,因此可以直接进行单元测试。Controller不会扩展或实现任何Android类,并且应该引用View的接口类。通过这种方式,也可以对控制器进行单元测试。如果View遵循单一责任原则,那么它们的角色就是为每个用户事件更新Controller,只显示Model中的数据,而不实现任何业务逻辑。在这种理想的作用下,UI测试应该足以覆盖所有的View的功能。
总结以上介绍我们发现,MVC模式高度支持职责的分离。这种优势不仅增加了代码的可测试性,而且使其更容易扩展,从而可以相当容易地实现新功能。
2.MVC容易产生的问题
代码相对冗余。我们知道,MVC模式中View对Model是有着强依赖的。当View非常复杂的时候,为了最小化View中的逻辑,Model应该能够为要显示的每个视图提供可测试的方法——这将增加大量的类和方法。
灵活性较低。
由于View依赖于Controller和Model,UI逻辑中的一个更改可能导致需要修改很多类,这降低了灵活性,并且导致UI难以测试。
可维护性低。Android的视图组件中,有着非常明显的生命周期,如Activity、Fragment等。对于MVC模式,我们有时不得不将处理视图逻辑的代码都写在这些组件中,造成它们十分臃肿。
所以,Android中最初的MVC架构问题显而易见:过于臃肿的Controller层大大降低了工程的可维护性及可测试性。
12.1.2 MVP
直到MVP架构模式的出现,传统MVC架构才从真正意义上得到解脱。MVP分别对应Model、View、Presenter,如图12-2所示。

• Model(数据层)。负责管理业务逻辑和处理网络或数据库API。
• View(视图层)。显示数据并将用户操作的信息通知给Presenter。
• Presenter(逻辑层)。从Model中检索数据,应用UI逻辑并管理View的状态,决定显示什么,以及对View的事件做出响应。
相对于MVC,MVP模式设计思路的核心是提出了Presenter层,它是View层与Model层沟通的桥梁,对业务逻辑进行处理。这更符合了我们理想中的单一职责原则。
1.传统MVP
如果你是一名Android开发者,你一定非常熟悉Android架构蓝图中的todo-app(https://github.com/googlesamples/android-architecture),它允许用户创建、读取、更新和删除“待办事项”任务,以及对任务列表进行分类显示。
在处理Model的时候,我们一般都会使用远程和本地数据源来获取和保存数据。以获取待办事项列表为例:当我们请求列表数据时,Model优先尝试从本地获取,如果为空,则查询网络更新本地数据并返回。部分代码如下:
fun getTasks(callback: TasksDataSource.LoadTasksCallback) {
// 如果本地有缓存并且缓存正常,则直接返回缓存
if (cachedTasks.isNotEmpty() && !cacheIsDirty) {
callback.onTasksLoaded(ArrayList(cachedTasks.values))
return
}
if (cacheIsDirty) {
// 如果缓存过期或被污染,则需要从服务端获取最新的数据
getTasksFromRemoteDataSource(callback)
} else {
// 如果本地存在缓存数据则从本地获取,否则从服务端获取
tasksLocalDataSource.getTasks(object : TasksDataSource.LoadTasks Callback {
override fun onTasksLoaded(tasks: List<Task>) {
refreshCache(tasks)
callback.onTasksLoaded(ArrayList(cachedTasks.values))
}
override fun onDataNotAvailable() {
getTasksFromRemoteDataSource(callback)
}
})
}
}
它接收通用回调类型TasksDataSource.LoadTasksCallback作为参数,使其完全独立于任何Android类,因此易于使用JUnit进行单元测试。例如,如果我们要模拟本地数据不准确的情况,可以这么实现:
private lateinit var tasksRepository: TasksRepository
@Mock private lateinit var loadTasksCallback: TasksDataSource.LoadTasksCallback
@Mock private lateinit var tasksRemoteDataSource: TasksDataSource
@Mock private lateinit var tasksLocalDataSource: TasksDataSource
private val TASKS = Lists.newArrayList(Task(TASK_TITLE_1, TASK_GENERIC_DESCRIPTION),
Task(TASK_TITLE_2, TASK_GENERIC_DESCRIPTION))
@Before fun setupTasksRepository() {
MockitoAnnotations.initMocks(this)
tasksRepository = TasksRepository.getInstance(tasksRemoteDataSource,
tasksLocalDataSource)
}
@Test fun getTasksWithLocalDataSourceUnavailable_tasksAreRetrievedFromRemote() {
tasksRepository.getTasks(loadTasksCallback)
setTasksNotAvailable(tasksLocalDataSource)
setTasksAvailable(tasksRemoteDataSource, TASKS)
verify(loadTasksCallback).onTasksLoaded(TASKS)
}
在界面展示数据的时候,View通过Presenter来发送获取数据的指令。在MVP模式中,Activity、Fragment和自定义视图都被归为View中。在Todo项目中,所有View都实现了允许设置Presenter的BaseView接口。
interface BaseView<T> {
var presenter: T
}
View模块通常在生命周期函数onResume()中,调用subscribe()方法通知Presenter:“嘿,哥们,我准备好被更新了,请随时下达指令。”然后在onPause()中调用subscribe()解除绑定。而在Kotlin中,我们通常的做法是在View中声明一个延迟初始化presenter(此处View以TaskFragment为例):
// TasksContract 契约类,我们通常把View和Presenter的接口写在其中,便于维护
interface TasksContract {
interface View : BaseView<Presenter> {
fun showTasks(tasks: List<Task>)
fun showTaskDetailsUi(taskId: String)
fun showLoadingTasksError()
fun showNoTasks()
......
}
interface Presenter : BasePresenter {
fun loadTasks(forceUpdate: Boolean)
......
}
}
class TasksFragment : Fragment(), TasksContract.View {
override lateinit var presenter: TasksContract.Presenter
......
override fun onResume() {
super.onResume()
presenter.start() // 请求加载当前视图初始化需要的数据
}
}
在承载视图的TasksActivity上,我们初始化视图TasksFragment以及TasksPresenter:
class TasksActivity : AppCompatActivity() {
......
private lateinit var tasksPresenter: TasksPresenter
override fun onCreate(savedInstanceState: Bundle?) {
val tasksFragment = supportFragmentManager.findFragmentById(R.id.contentFrame)
as TasksFragment? ?: TasksFragment.newInstance().also {
replaceFragmentInActivity(it, R.id.contentFrame)
}
// 创建 presenter
tasksPresenter = TasksPresenter(Injection.provideTasksRepository(applicationContext), tasksFragment).apply {
// 加载历史数据
......
}
}
你或许会感到奇怪,上述操作中好像并没有将Presenter和View绑定的操作。其实,TasksPresenter中另有玄机:
class TasksPresenter(val tasksRepository: TasksRepository, val tasksView: TasksContract. View)
: TasksContract.Presenter {
init {
tasksView.presenter = this
}
override fun start() {
loadTasks(false)
}
......
}
原来,得益于init(),在TasksPresenter初始化的同时,我们也对View中的Presenter进行赋值,这样就不必每次都写subscribe和unsubscribe方法了。当然,我们利用依赖注入也能实现这样的需求,比如dagger。
当页面结束的时候会终止网络请求,我们应该及时释放Presenter中的引用,防止内存泄漏。通常使用的是RxLifeCycle(https://github.com/trello/RxLifecycle),有兴趣的读者可以自行研究。
这便是基于Kotlin创建的MVP架构模式中的一种。除此之外,我们还能通过结合很多框架,如dagger、rxKotlin,来让工程更加通透。
2.MVP容易产生的问题
1)接口粒度难以掌控。MVP模式将模块职责进行了良好的分离。但在开发小规模App或原型时,这似乎增加了开销——对于每个业务场景,我们都要写Activity-View-Presenter-Contract这4个类。为了缓解这种情况,一些开发者删除了Contract接口类和Presenter的接口。另外,Presenter与View的交互是通过接口实现的,如果接口粒度过大,解耦程度就不高,反之会造成接口数量暴增的情况。
从工程的严谨角度来说,这或许并不是缺点,只是创造一个良好工程架构带来的额外工作量。
2)Presenter逻辑容易过重。当我们将UI的逻辑移动到Presenter中时,Presenter变成了有数千行代码的类,或许会难以维护。要解决这个问题,我们只可能更多地拆分代码,创建便于单元测试的单一职责的类。
3)Presenter和View相互引用。我们在Presenter和View中都会保持一份对方的引用,所以需要用subscribe和unsubscribe来绑定和解除绑定。在操作UI的时候,我们需要判断UI生命周期,否则容易造成内存泄露。
当然,以上的“缺点”我们都可以通过良好的编码习惯及严谨的设计来规避。如果我们想要一个基于事件且View会对此事件变化做出反应的架构,该怎么实现呢?
12.1.3 MVVM
相较于MVC和MVP模式,MVVM在定义上就明确得多。维基百科上对其是这么介绍的:
MVVM有助于将图形用户界面的开发与业务逻辑或后端逻辑(数据模型)的开发分离开来,这是通过置标语言或GUI代码实现的。MVVM的视图模型是一个值转换器,这意味着视图模型负责从模型中暴露(转换)数据对象,以便轻松管理和呈现对象。在这方面,视图模型比视图做得更多,并且处理大部分视图的显示逻辑。视图模型可以实现中介者模式,组织对视图所支持的用例集的后端逻辑的访问。
MVVM也被称为model-view-binder,如图12-3所示。它的主要构成如下。

• Model(数据模型):与ViewModel配合,可以获取和保存数据。
• View(视图):即将用户的动作通知给ViewModel(视图模型)。
• ViewModel(视图模型):暴露公共属性与View相关的数据流,通常为Model和View的绑定关系。
作为MV*家族的一员,它看起来与MVP模式有所相似:它们都擅长抽象视图行为和状态。
如果MVP模式意味着Presenter直接告诉View要显示的内容,那么在MVVM中,ViewModel会公开Views可以绑定的事件流。这样,ViewModel不再需要保持对View的引用,但发挥了Presenter一样的作用。这也意味着MVP模式所需的所有接口现在都被删除了。这对介意接口数量过多的开发者来说是个福音。
View还会通知ViewModel进行不同的操作。因此,MVVM模式支持View和ViewModel之间的双向数据绑定,并且View和ViewModel之间存在多对一关系。View具有对ViewModel的引用,但ViewModel没有关于View的信息。因为数据的使用者应该知道生产者,但生产者ViewModel不需要知道,也不关心谁使用数据。
光有概念部分读者可能还不能感受到MVVM的特点。我们以官方的todo-app中的addTask模块为例,先来看看它的布局addtask_frag.xml:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View"/>
<variable
name="viewmodel"
type="com.example.android.architecture.blueprints.todoapp.addedittask.AddEditTask ViewModel"/>
</data>
<com.example.android.architecture.blueprints.todoapp.ScrollChildSwipeRefresh Layout
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:enabled="@{viewmodel.dataLoading}"
app:refreshing="@{viewmodel.dataLoading}">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
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:visibility="@{viewmodel.dataLoading ? View.GONE : View.VISIBLE}">
<EditText
android:id="@+id/add_task_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/title_hint"
android:singleLine="true"
android:text="@={viewmodel.title}"
android:textAppearance="@style/TextAppearance.AppCompat.Title"/>
<EditText
android:id="@+id/add_task_description"
android:layout_width="match_parent"
android:layout_height="350dp"
android:gravity="top"
android:hint="@string/description_hint"
android:text="@={viewmodel.description}"/>
</LinearLayout>
</ScrollView>
</com.example.android.architecture.blueprints.todoapp.ScrollChildSwipeRefresh Layout>
</layout>
之前没有接触过MVVM模式的读者,应该会对<data>标签感到疑惑。这其实是Data Binding(https://developer.android.com/topic/libraries/data-binding/)的一种特性:
使用Data Binding让xml绑定数据,我们需要以<layout>为根布局,并且声明<data>,其中type对应Model(需要指定完整类名),name相当于Model在当前视图中对应的对象,我们在xml中就可以用android:text="@={viewmodel.description}"实现绑定。当viewmodel.description变化的时候,EditText也会改变;反之,当我们编辑EditText的时候,viewmodel.description的值也会相应变化。
这个时候你可能会对viewmodel的结构感到好奇,让我们一起来看看AddEditTaskView Model.kt:
class AddEditTaskViewModel
internal constructor(context: Context, private val mTasksRepository: TasksRepository)
: TasksDataSource.GetTaskCallback {
val title = ObservableField<String>()
val description = ObservableField<String>()
val dataLoading = ObservableBoolean(false)
val snackbarText = ObservableField<String>()
private val mContext: Context // 避免内存泄漏,我们应该使用Application的context
private var mTaskId: String? = null
private var isNewTask: Boolean = false
private var mIsDataLoaded = false
private var mAddEditTaskNavigator: AddEditTaskNavigator? = null
init {
mContext = context.applicationContext // 强制使用Application的context
}
fun onActivityCreated(navigator: AddEditTaskNavigator) {
mAddEditTaskNavigator = navigator
}
fun onActivityDestroyed() {
// 释放不需要的引用
mAddEditTaskNavigator = null
}
fun start(taskId: String?) {
if (dataLoading.get()) {
return
}
mTaskId = taskId
if (taskId == null) {
isNewTask = true
return
}
if (mIsDataLoaded) {
return
}
isNewTask = false
dataLoading.set(true)
mTasksRepository.getTask(taskId, this)
}
override fun onTaskLoaded(task: Task) {
title.set(task.title)
description.set(task.description)
dataLoading.set(false)
mIsDataLoaded = true
// 这里我们不需要像MVP模式那样主动改变View,因为我们已经使用ObservableField绑定了视图
}
override fun onDataNotAvailable() {
dataLoading.set(false)
}
fun saveTask() {
if (isNewTask) {
createTask(title.get(), description.get())
} else {
updateTask(title.get(), description.get())
}
}
fun getSnackbarTextString(): String {
return snackbarText.get()
}
private fun createTask(title: String, description: String) {
val newTask = Task(title, description)
if (newTask.isEmpty) {
snackbarText.set(mContext.getString(R.string.empty_task_message))
} else {
mTasksRepository.saveTask(newTask)
navigateOnTaskSaved()
}
}
private fun updateTask(title: String, description: String) {
if (isNewTask) {
throw RuntimeException("updateTask() was called but task is new.")
}
mTasksRepository.saveTask(Task(title, description, mTaskId))
navigateOnTaskSaved() // 编辑完成,返回task列表界面
}
private fun navigateOnTaskSaved() {
if (mAddEditTaskNavigator != null) {
mAddEditTaskNavigator!!.onTaskSaved()
}
}
}
可以看到,我们将大部分操作数据的逻辑都放这个类中,在维护的时候就能体会到这其中的优势。我们再看一下绑定View的另一部分,AddTaskFragment.kt:
class AddEditTaskFragment : Fragment() {
private var mViewModel: AddEditTaskViewModel? = null
private lateinit var mViewDataBinding: AddtaskFragBinding
......
override fun onResume() {
super.onResume()
if (arguments != null) {
mViewModel?.start(arguments.getString(ARGUMENT_EDIT_TASK_ID))
} else {
mViewModel?.start(null)
}
}
fun setViewModel(viewModel: AddEditTaskViewModel) {
mViewModel = viewModel
}
......
override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
val root = container?.inflate(R.layout.addtask_frag)
mViewDataBinding = AddtaskFragBinding.bind(root)
mViewDataBinding.viewmodel = mViewModel
setHasOptionsMenu(true)
retainInstance = false
return mViewDataBinding.root
}
companion object {
val ARGUMENT_EDIT_TASK_ID = "EDIT_TASK_ID"
fun newInstance(): AddEditTaskFragment {
return AddEditTaskFragment()
}
}
}
从以上代码我们不难看出:
1)通过View来创建一个ViewDataBinding的对象:
2)将mViewModel赋值到XML文件<data>里进行声明的ViewModel的具体对象当中,从而使ViewModel和XML文件创建关联:
mViewDataBinding.viewmodel = mViewModel
除此之外,与MVP类似,我们在Fragment的各个生命周期中,调用mViewModel对应的方法来响应View的变化。不同的是,在MVVM中我们只需要改变viewModel中的数据,View的响应已经自动完成了(比如通过Data Binding)。
这样代码的结构比之前更加通透,我们核心关注的就是数据的改变——这简直太让人身心愉悦了。如此便捷的背后,依旧存在一些问题。
MVVM容易造成的问题如下:
1)需要更多精力定位Bug。由于双向绑定,视图中的异常排查起来会比较麻烦,你需要检查View中的代码,还需要检查Model中的代码。另外你可能多处复用了Model,一个地方导致的异常可能会扩散到其他地方,定位错误源可能并不会太简单。
2)通用的View需要更好的设计。当一个View要变成通用组件时,该View对应的Model通常不能复用。在整体架构设计不够完善时,我们很容易创建一些冗余的Model。
如果说双向数据流这种“自动管理状态”的特性会给我们造成困扰,除了在编码上规避,还有其他的解决方案吗?答案是肯定的,这里我们推荐使用谷歌官方的Android Architecture Components,感兴趣的读者可以自行了解。
12.2 单向数据流模型
既然有双向数据绑定的架构MVVM,那自然少不了单向数据流。如果你接触过前端,你肯定听说过Flux,它是最经典的单向数据流架构之一。我们可以通过它来了解单向数据流模型,如图12-4所示。

Flux组成通常分为以下4个部分。
• View(视图):显示UI。
• Action(动作):用户操作界面时,视图层发出的消息(比如用户点击按钮、输入文字等)。
• Dispatcher(分发器):用来接收Actions,执行回调函数。
• Store(数据层):类似于MV*的Model层。用来存放应用的状态,一旦发生变动,就提醒View更新页面。
用户通过与view交互或者外部产生一个Action,Dispatcher接收到Action并执行那些已经注册的回调,向所有Store分发Action。通过注册的回调,Store响应那些与它们所保存的状态有关的Action。然后Store会触发一个change事件,来提醒对应的View数据已经发生了改变。View监听这些事件并重新从Store中获取数据。这些View调用它们自己的setState()方法,重新渲染自身及相关联的组件。
除了Flux,当前Web前端比较常用的React也是比较典型的单向数据流框架,它也是基于Redux模型实现的。
12.2.1 Redux
Redux作为Flux模型一个友好简洁的实现,它基于一个严格的单向数据流:应用中的所有数据都是通过组件在一个方向上流动。Redux希望确保应用的视图是根据确定的状态来呈现的——即在任何阶段,应用的状态总是确定、有效的,并且可以转换到另一个可预测、有效的状态,视图将根据所处的状态来进行对应的展示。
1.Redux基本概念
Redux的核心为3个部分。
• Store:保存应用的状态并提供方法来存取对应的状态,分发状态,并注册监听。
• Actions:与Flux类似。包含要传递给Store的信息,表明我们希望怎样改变应用的状态。比如,在Kotlin中我们可以定义如下action:
data class AddTodoAction(val title: String, val content: String)
然后由store进行分发:
store.dispatch(AddTodoAction("Finish your homeWork", "English And Math"))
• Reducers:Store收到Action以后,必须给出一个新的State,这样View才会发生变化。这种State的计算过程就叫作Reducer。如下所示:
fun reduce(oldState: AppState, action: Action): AppState {
return when (action) {
is AddToDoAction -> {
oldState.copy(todo = ...)
}
else -> oldState
}
}
Redux数据流图如图12-5所示。

对比Flux,我们可以发现一些不同点,如表12-1所示。

经过以上对单向数据流模型的介绍,相信你应该对其有了一定的了解。但是很多读者可能还是没有足够的理由说服自己使用这个架构:单向数据流到底有什么好处呢?让我们看看下一节。
12.2.2 单向数据流的优势
单向数据流架构的最大优势在于整个应用中的数据流以单向流动的方式,从而使得拥有更好的可预测性与可控性,这样可以保证应用各个模块之间的松耦合性。
1.优秀的数据追溯能力
在MVVM中,数据变动时由框架自动帮我们实现视图的同步变更,更改一个地方的数据,可能会影响很多地方的状态,并且它是不可预期的,很难维护和调试。而单向数据流的架构中,整个应用状态是可预测的,我们可以监听到数据变动,从而采取自定义的操作。
对于一个组件来说,数据入口只有唯一一个。当数据发生改变时,UI也会发生改变。反之UI的变化并不会直接变动数据。这不仅使得程序更直观、更容易理解,而且更有利于应用的可维护性。
2.更简洁的单元测试
因为Dispatcher是所有Action的处理中心,即使没有对应的事件发生,我们也可以“伪造”一个出来,只需要用Action对象向Dispatcher描述当前的事件,就可以执行对应的逻辑。在Redux中,由于Reducer是纯函数而没有内部状态,对于给定的输入状态和操作,它们将始终返回相同的输出状态。因为State和Action相对是轻量级的,所以我们可以把测试重点放在Reducer上。在Kotlin中代码可能是这样的:
class TodoReducer {
fun reduce(state: AppState, action: Action) : AppState {
// todo 逻辑操作
}
}
data class TodoAction(val text: String)
val todoReducer = TodoReducer()
val originalState = AppState({
// todo 初始状态
})
val todoAction = TodoAction(text = 'just haha'})
val newState = todoReducer.reduce(originalState, todoAction)
// 判断newState与预期是否一致
assert(newState …)
如果数据需要从多个地方获取(比如,本地存储或网络中获取),我们可以改变Action的结构:
class TodoAction(val dataOfLocal: String, val dataOfApi: String) {
companion object {
fun create(localStore: LocalStore, apiResponse: ApiResponse): TodoAction {
val dataOfLocal = localStore.targetData
val dataOfApi = apiResponse.targetData
TodoAction(dataOfLocal = dataOfLocal, dataOfApi = dataOfApi)
}
}
}
这样测试起来也是非常容易:
...
val todoAction = TodoAction(dataOfLocal = "i'm from sqlite", dataOfApi = "i'm from network)
val newState = reducer.reduce(originalState, todoAction)
......
3.单向数据流遇上Kotlin
因为Redux是基于Flux的思想产生的,所以在Redux架构中构造组件,通常也会产生许多样板代码。对于JavaScript来说,这可能难以优化。而使用Kotlin,我们能更加方便地管理样板代码。
当我们在Reducer中匹配不同类型的Action时,按照Java的套路可能会这样写:
AppState reduce(Action action, oldState: AppState) {
switch (action.type) {
case TodoAction.TYPE.ADD_TODO_ITEM:
...
return addTodoAction(oldState, action);
case TodoAction.TYPE.CHANGE_STATE:
return changeAction(oldState, action);
default:
return oldState;
}
return oldState;
}
或者当Action结构相对比较复杂时,我们不想再添加一个type字段,而是直接判断Action属于什么类:
AppState reduce(Action action, oldState: AppState) {
if (action instanceof AddTodoAction) {
return addTodoAction(oldState, action);
} else if (action instanceof ChangeTodoAction) {
return changeAction(oldState, action);
} else if (...) {
......
}
return oldState;
}
这个时候,如果action非常多,就会给开发者带来巨大的痛苦。但是别着急,我们可以用Kotlin的when来拯救它:
fun reduce(Action action, oldState: AppState): AppState {
return when (action) {
is AddTodoAction -> reduceAddTodoAction(oldState, action)
is RemoveTodoAction -> reduceRemoveTodoAction(oldState, action)
else -> oldState
}
}
并且,我们还能利用Smart Casts,在数据处理的同时避免不必要的判断。当然,这里只是用Kotlin提升Redux架构便捷性的冰山一角,更多内容将在下一节呈现。
虽然Redux起源于Web端,但从它的构建中,我们可以看到很多非常好的想法,这都是值得学习并可以尝试引入Android的。即使我们的平台、语言和工具可能不同,但在架构层面,我们面对着许多相同的基本问题,比如,尽可能降低View和业务逻辑代码的耦合度等。
12.3 ReKotlin
如果你是一名Android开发者,你应该知道:在国内的项目中,鲜有单向数据流架构的痕迹。甚至一些经验不够丰富的Android开发者,可能都不知道“单向数据流”。
在iOS中,有一个著名的单向数据流框架:ReSwift(https://github.com/ReSwift/ReSwift),它在github上的被关注度还不错。随着Kotlin在Android中的地位不断提高,利用其优秀的语言特性,也派生出了类似的框架:ReKotlin(https://github.com/ReKotlin/ReKotlin)。它的出现,也宣布了Android即将“跨入单向数据流时代”。
12.3.1 初见ReKotlin
基于经典的Redux模型,ReKotlin也奉行以下设计。
1)The Store:以单一数据结构管理整个App的状态,状态只能通过dispatching Actions来进行修改。每当Store中的状态改变了,它就会通知所有的Observers。
2)Actions:通过陈述的形式来描述一次状态变更,操作中不包含任何代码,通过Store转发给Reducers。Reducers会接收这些Actions,然后进行相应的状态逻辑变更。
3)Reducers:基于当前的Action和App状态,通过纯函数来返回一个新的App状态。
因为核心思想与Redux基本是一致的,所以这里我们就尽可能简单地对其进行概括:单向数据流意味着应用程序的State不应该保存在许多不同的地方。相反,存储组件将所有State保持在中心位置。View会对此State的更改做出反应,而不是在内部处理它。Action是触发State更改的唯一方法,它不会通过它们自己来更改状态,而更像是一些指令——表示某些内容将发生变化。这些“指令”是针对使用执行实际状态更改的Reducers的Store对象发出的。另外,还有Middleware(中间件),它主要用来处理副作用,这会在后面介绍。
12.3.2 创建基于ReKotlin的项目
前面看到许多概念,也许你已经跃跃欲试了。现在我们就来看看如何基于ReKotlin创建一个Android工程。
1.引入Rekotlin
在Gradle中集成ReKotlin,这里我们以1.0.0版本为例,同时我们加上一些日常需要的框架(版本仅供参考,引入实际项目时可酌情调整)。
dependencies {
implementation 'com.android.support:recyclerview-v7:27.1.1'
implementation 'com.android.support:cardview-v7:27.1.1'
implementation 'com.android.support:design:27.1.1'
// reKotlin
implementation "org.rekotlin:rekotlin:1.0.0"
// http
implementation 'com.squareup.retrofit2:retrofit:2.3.0'
implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
// imageLoader
implementation 'com.squareup.picasso:picasso:2.5.2'
// json
implementation 'com.google.code.gson:gson:2.8.2'
// log
implementation 'com.jakewharton.timber:timber:4.6.0'
}
2.整体结构
我们本次的示例主要是做一个电影列表。这里我们从一个开源API中获取数据,然后将其显示到App中。我们先来看一下实例项目的文件清单:
- actions
- MovieListActions.kt
- middlewares
- MovieMiddleWare.kt
- NetworkMiddleWare.kt
- model
- Movie.kt
- network
- Api.kt
- HttpClient.kt
- reducers
- AppReducer.kt
- MovieListReducer.kt
- states
- AppState.kt
- MovieListState.kt
- ui
- BaseActivity.kt
- MovieDetailActivity.kt
- MovieListAdapter.kt
- MovieListFragment.kt
- MainActivity.kt
- utils
- ImageLoder.kt
- Logger.kt
- MovieApplication.kt
其中model、network、ui、utils文件夹与我们平常的项目结构类似,由于篇幅限制,这些目录我们将不会详细讲解,读者可以在https://github.com/DiveIntoKotlin/DiveIntoKotlinSamples查看完整的项目代码。
然后我们把目光聚焦在新增加的actions、middlewares、reducer、states目录上。我们在12.3.1节中已经对它们进行过介绍。结合示例项目,它们发挥的作用如下。
• actions:所有更新State的行为我们都可以抽象成Action,并且根据不同的场景分布在不同文件下。
• reducers:不同Action对应的响应中心,会返回一个新的Reducer。
• middlewares:由于Action的接收方Reducer都是纯函数,不会也不能产生副作用,如果我们想加入一些额外的操作,例如打印日志、操作SQLite数据库等,我们可以将这些操作放到该文件夹中。
• states:所有状态的声明都放在这个目录下。
了解目录结构后,让我们一起来看看ReKotlin集成的主要步骤。
在12.3.1节中我们介绍过:在ReKotlin中,每个App对应只有一个数据管理中心(Store)。所以,我们可以在Application中将其初始化,项目中我们使用的自定义的Application名为MovieApplication。
MovieApplication.kt:
import android.app.Application
import dripower.rekotlinsimpleexample.middlewares.movieMiddleWare
import dripower.rekotlinsimpleexample.middlewares.networkMiddleWare
import dripower.rekotlinsimpleexample.ruducer.appReducer
import org.rekotlin.Store
val store = Store(
reducer = ::appReducer,
state = null
)
class MovieApplication : Application() {
...
}
View部分,示例采用了Activity+Fragment的常规组合。
MainActivity.kt:
import android.support.v4.app.FragmentTransaction
import android.support.v7.app.AppCompatActivity
abstract class BaseActivity : AppCompatActivity() {
inline fun BaseActivity.transFragment(action: FragmentTransaction.() -> Unit) {
supportFragmentManager.beginTransaction().apply {
action()
}.commit()
}
}
MainActivity.kt:
import android.os.Bundle
import android.support.v4.app.Fragment
import dripower.rekotlinsimpleexample.R
import dripower.rekotlinsimpleexample.ui.movieList.MovieListFragment
class MainActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
showFragment(MovieListFragment())
}
private fun showFragment(fragment: Fragment) {
transFragment {
replace(R.id.container, fragment)
}
}
}
以上逻辑即在MainActivity渲染出MovieListFragment,就不做详细介绍。重点来看看MovieListFragment.kt:
class MovieListFragment : Fragment(), StoreSubscriber<MovieListState?> {
private lateinit var movieListAdapter: MovieListAdapter
override fun newState(state: MovieListState?) {
state?.movieObjects?.let {
initializeAdapter(it)
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.fragment_movie_list, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
store.dispatch(LoadTop250MovieList())
}
private fun initializeAdapter(movieData: List<Subject>) {
val activity = this.activity as MainActivity
movieListAdapter = MovieListAdapter(movieData, {id -> movieListToDetail(id, activity)})
movieList.layoutManager = GridLayoutManager(context, 2)
movieList.adapter = movieListAdapter
}
private fun movieListToDetail(subject: Subject, activity: MainActivity) {
val intent = Intent(activity, MovieDetailActivity::class.java)
intent.putExtra("id", subject.id)
intent.putExtra("title", subject.title)
startActivity(intent)
}
override fun onStart() {
super.onStart()
store.subscribe(this) {
it.select {
it.movieListState
}.skipRepeats()
}
}
override fun onStop() {
super.onStop()
store.unsubscribe(this)
}
}
通常,我们在需要与数据打交道的界面中,都会实现StoreSubscriber<TState>接口(这是Rekotlin中实现的,我们可以直接使用),并且分别在生命周期中做以下事情:
1)override fun newState(state:MovieListState?)。这里相当于一个数据流管道的出口,当数据state变化时,我们可以对其做一些相应的操作,有点类似前端中的Watch机制。示例中,每次数据变化我们都更新列表数据,重新渲染。
2)onViewCreated。通常我们在这个生命周期里发起数据请求(网络或者本地数据库)。示例中,我们仅做了网络请求。
3)onStart。我们通常在这里进行store的绑定。
4)onStop。相应地,我们需要在视图不显示的时候,解除store绑定,防止内存泄漏等问题。
其余的代码为列表适配器绑定以跳转MovieDetailActivity,有Android经验的读者应该能很容易地看懂。
MovieListAdapter.kt:
class MovieListAdapter(private val movieData: List<Subject>, private val image-ClickCallBack: (Subject) -> Unit) :
RecyclerView.Adapter<MovieListAdapter.MovieItemHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieItemHolder {
return MovieItemHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_movie,
parent, false), imageClickCallBack)
}
override fun onBindViewHolder(holder: MovieItemHolder, position: Int) {
val movieItem = movieData[position]
holder.apply {
movieRating.text = movieItem.rating.average.toString()
movieTitle.text = movieItem.title
movieImage.loadImage(movieItem.images.large)
}
}
override fun getItemCount(): Int = movieData.size
inner class MovieItemHolder(itemView: View?, imageClickCallBack: (Subject) -> Unit) : RecyclerView.ViewHolder(itemView) {
lateinit var movieRating: TextView
lateinit var movieTitle: TextView
lateinit var movieImage: ImageView
init {
itemView?.apply {
movieRating = findViewById(R.id.movie_rating)
movieTitle = findViewById(R.id.movie_title)
movieImage = findViewById(R.id.movie_image)
movieImage.setOnClickListener {
movieData[adapterPosition].apply {
imageClickCallBack(movieData[adapterPosition])
onBindViewHolder(this@MovieItemHolder, adapterPosition)
}
}
}
}
}
}
MovieDetailActivity的界面很简单:包含一个返回按钮,两个文本框分别显示传入的movie name及id。
MovieDetailActivity.kt:
import android.os.Bundle
import dripower.rekotlinsimpleexample.R
import dripower.rekotlinsimpleexample.ui.BaseActivity
import kotlinx.android.synthetic.main.activity_movie_detail.*
class MovieDetailActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_movie_detail)
val id = intent.extras?.get("id")
val title = intent.extras?.get("title")
tv_movie_id.text = id as String
tv_movie_title.text = title as String
btn_back.setOnClickListener { this.finish() }
}
}
然后让我们来看看单向数据流部分。既然称为单向数据流,我们最核心的地方肯定都是围绕数据展开的。对于某个场景来说,我们的数据为该场景的状态(State)服务,而状态直接决定了该场景视图显示的内容。所以我们需要先确定好这些状态。对于当前示例,我们需要显示movieList,所以可以进行如下创建。
MovieListState.kt:
import dripower.rekotlinsimpleexample.model.Subject
import org.rekotlin.StateType
data class MovieListState(
var movieObjects: List<Subject>? = null
) : StateType
一个App中会对应很多场景,我们同样需要进行统一管理。当前示例中我们将其放入AppState。
AppState.kt:
data class AppState(
var movieListState: MovieListState? = null
) : StateType
以上,我们已经确定了movieList界面的最终显示效果。这时候,我们可以打开数据流的开关,并且控制它们的流向,我们可以为当前场景的操作定义不同的动作(Action)。
MovieListActitions.kt:
class InitMovieList(val movieData: List<Subject>) : Action
class LoadTop250MovieList : Action
class ShowMovieList(val movieData: List<Subject>) : Action
我们将所有对MovieList的操作都放入Action中,以方便管理。当我们调用store.dispatch(Action())之后,才能让Reducer处理State的变化。
AppReducer.kt:
fun appReducer(action: Action, appState: AppState?): AppState =
AppState(movieListState = movieListReducer(action, appState?.movieListState))
MovieListReducer.kt
fun movieListReducer(action: Action, movieListState: MovieListState?): MovieListState {
var state = movieListState ?: MovieListState()
when (action) {
is ShowMovieList -> {
state = state.copy(movieObjects = action.movieData)
}
}
return state
}
我们知道,Reducer是一个没有副作用的处理,所以如果需要对数据进行中间加工或者打印日志等,都需要放到中间件Middleware中。
如果你要使用Middleware,需要在初始化Store的时候传入Middleware参数。本示例中我们在MovieApplication中初始化Store,需要进行如下更改:
val store = Store(
......
middleware = listOf(networkMiddleware, movieMiddleware)
)
本示例中,我们将网络请求获取MovieList的逻辑放在networkMiddleware中,当返回正确的结果时,我们进行渲染movieList的操作,否则打印错误日志。
NetworkMiddleware.kt:
internal val networkMiddleware: Middleware<AppState> = { dispatch, _ ->
{ next ->
{ action ->
when (action) {
is LoadTop250MovieList -> {
getTop250MovieList(dispatch)
}
}
next(action)
}
}
}
// 这里即获取movie数据的核心逻辑
private fun getTop250MovieList(dispatch: DispatchFunction) {
val apiService = HttpClient.client?.create(Api::class.java)
val call = apiService?.getTop250MovieList()
call?.enqueue(object : Callback<MovieResponse> {
override fun onFailure(call: Call<MovieResponse>?, t: Throwable?) {
Logger.error(t)
}
override fun onResponse(call: Call<MovieResponse>?, response: Response <MovieResponse>?) {
val movieObjects = response?.body()?.subjects
movieObjects?.let {
dispatch(InitMovieList(it))
}
}
})
}
同时,我们在初始化MovieList的时候(即发送InitMovieListAction),让Action进入Middleware中。
MovieMiddleware.kt:
import dripower.rekotlinsimpleexample.actions.InitMovieList
import dripower.rekotlinsimpleexample.actions.ShowMovieList
import dripower.rekotlinsimpleexample.model.Subject
import dripower.rekotlinsimpleexample.states.AppState
import org.rekotlin.DispatchFunction
import org.rekotlin.Middleware
internal val movieMiddleware: Middleware<AppState> = { dispatch, _ ->
{ next ->
{ action ->
when (action) {
is InitMovieList -> {
processMovies(action.movieData, dispatch)
}
}
next(action)
}
}
}
private fun processMovies(movieObjects: List<Subject>, dispatch: DispatchFunction) {
// 你可以在这里对movieList进行一些有副作用的操作,例如:打印日志、存储数据到本地等
dispatch(ShowMovieList(movieObjects))
}
当MovieListReducer接收到ShowMovieList的action时,将会更新state中的movieObjects。还记得我们在MovieListFragment中实现了StoreSubscriber<MovieListState?>接口吗?当MovieListState发生变化时,将会触发newState(state:MovieListState?)方法,这样就会重新渲染movieList。
如果你的代码正确,此时一个完整的列表界面就呈现出来了。如果你还是不太清楚,让我们再通过一张图来理一理整个项目的逻辑,如图12-6所示。

这样,一个单向数据流架构的App就完成了。相对传统App的架构,你是否有新的感受?可以访问链接https://github.com/DiveIntoKotlin/DiveIntoKotlinSamples,查看完整代码。
当然,这样还不是最理想的使用方式。在单元测试的时候,可能会被一个问题所烦恼:我们在单元测试的时候,依旧局限于单个视图下的数据操作,即我们只能保证数据流的验证(虽然在单向数据流中,这已经足够验证我们的视图正确显示了——除非你的UI显示逻辑显示错误)。要是我们能够对视图进行测试,那该多好啊!
12.4 解耦视图导航
经过以上的介绍,相信你已经能够掌控数据流了。现在,我们要解决另一个问题:如何解耦视图导航。
12.4.1 传统导航的问题
在移动端,我们需要借助视图导航来完成页面切换及数据传递。随着App的业务不断复杂化,传统的视图导航存在着许多不便之处,让我们一起来看看。
1.高耦合的Activity.class
在传统的Android开发中,显示跳转Activity我们一般这样写:
val intent = Intent()
intent.setClass(this, TargetActivity::class.java)
startActivity(intent)
这是绝大多数Android开发者的首选做法。所以这看上去非常和谐,不存在任何问题。但是实际上造成了很高的耦合性:当前Activity如果要跳转到TargetActivity,就一定要引用TargetActivity。这衍生出了两个问题:
• 如果项目中存在多个Module,杂底层Module中Activity不能跳转到上层的Activity。
• 如果TargetActivity类名变化,调用的地方需要相应改动。
2.难以管理的intent-filter
在Android中,我们通常用intent-filter来隐式启动/跳转Activity:
val intent = Intent()
intent.action = Intent.ACTION_SENDTO
intent.data = Uri.parse("smsto:10000")
context.startActivity(intent)
3.不友好的Hybird
在React Native、Weex、Flutter大行其道的现实环境下,我们难免会与混合开发打交道。当H5页面需要跳转到Native,并且需要把相关数据传递过去时,通常情况下,我们会采取两种做法:
• 直接根据目标Activity的Action中的Schemel跳过去。
• Native维护一个<关键字,Activity>的Map,H5传过来Activiy的「关键字」,Native在Map中查到后进行跳转。
第1种情况下,Action命名需要符合iOS和Android两个平台的规范,如果当前版本的Native不支持该Action,还需要进行跳转失败的处理。
第2种情况下,维护<关键字,Activity>的Map比较麻烦。另外,Activity的存储及生命周期的处理都会存在问题。并
且在两种情况下,我们都可能难以获取到Context的引用,这时候需要使用Application的Context。
12.4.2 rekotlin-router
以上几种都是传统导航中存在的问题。作为国内开发者,我们应该接触过著名的开源框架ARouter(https://github.com/alibaba/ARouter),它能够给以上问题一个很好的解决方案,并且还能解决其他额外的很多问题。但是对于我们来说,这也许有些“重”,我们可以使用与ReKotlin配套的rekotlin-router(https://github.com/ReKotlin/rekotlin-router)。
ReKotlin的主要贡献者对rekotlin-router是这么阐述的:ReKotlin的声明式路由,允许开发者以Web上使用URL类似的方式声明路由。
我们可以在项目Gradle中直接引入:
implementation 'org.rekotlinrouter:rekotlin-router:0.1.9'
然后将原有AppState扩展出导航的状态:
...
import org.rekotlinrouter.HasNavigationState
import org.rekotlinrouter.NavigationState
data class AppState(
...
override var navigationState: NavigationState
) : StateType, HasNavigationState
在初始化AppState之后,我们需要创建Router的实例。需要传入关联的store与根Routable:
router = Router(store = mainStore,
rootRoutable = RootRoutable(context = applicationContext),
stateTransform = { subscription ->
subscription.select { stateType ->
stateType.navigationState
}
})
然后我们封装一个跳转Route的Action:
import org.rekotlin.Action
import org.rekotlinrouter.Route
import org.rekotlinrouter.StandardAction
import org.rekotlinrouter.StandardActionConvertible
class SetRouteAction(private var route: Route,
private var animated: Boolean = true,
action: StandardAction? = null): StandardActionConvertible {
companion object {
const val type = "RE_KOTLIN_ROUTER_SET_ROUTE"
}
init {
if (action != null) {
route = action.payload?.keys?.toTypedArray() as Route
animated = action.payload!!["animated"] as Boolean
}
}
override fun toStandardAction(): StandardAction {
val payloadMap: HashMap<String,Any> = HashMap()
payloadMap.put("route",this.route)
payloadMap.put("animated",this.animated)
return StandardAction(type = type,
payload = payloadMap,
isTypedAction = true)
}
}
class SetRouteSpecificData ( val route: Route, val data: Any): Action
综上,我们就可以这样调用:
private fun movieListToDetailRoute() {
val routes = arrayListOf(Routers.mainActivityRoute, Routers.movieDetailActivityRoute)
val action = SetRouteAction(route = routes)
store.dispatch(action)
}
这样是否比之前优雅了很多?就算在复杂的项目中,我们也能很好地管理页面跳转。
12.5 本章小结
(1)主流的客户端架构
目前比较主流的客户端架构即MV*家族:MVC、MVP、MVVM。其中MVC适合小而简单的App,而MVP和MVVM的选择需要从App具体业务场景出发。从MVC到MVP的演变完成了View与Model的解耦,改进了职责分配与可测试性。而从MVP到MVVM,添加了View与ViewModel之间的数据绑定,使得View完全无状态化。
(2)从MV*到单向数据流
单向数据流在前端页面中是一种非常流行的架构方式,在React和Vue中其优点得到极致的体现。从MV*到单向数据流的变迁采用了消息队列式的数据流驱动的架构,并且以Redux为代表的方案将原本MV*中碎片化的状态管理变为统一的状态管理,保证了状态的有序性与可回溯性。
(3)ReKotlin
Kotlin的崛起,让Android能够顺滑地支持ReSwift的思想。利用ReKotlin,我们可以在Android中更容易地实现单向数据流架构,在强有力的状态的有序性与可回溯性前提下,我们能够提供比MV*架构更详尽的单元测试。并且在复杂的业务场景下,也不易出现难以排查的问题和冗杂的代码。
更多推荐



所有评论(0)