写出高质量的软件是困难和复杂的,雪球客户端团队在以前的开发中,经常遇到如下问题:
总体来讲,原有的MVP架构是一个优秀的代码整理方式,但是在开发大型软件和处理复杂业务逻辑时,还是会存在诸多问题,这时候需要一套高可用的页面架构来解决以上问题。
雪球页面架构,使用一组开源库,结合函数响应式、MVVM思想,实现了一套可重用、可测试、生命周期安全、聚焦需求的开发框架。
同时,它也代表一组优秀的开发实践,用来开发任何软件应用都是一个不错的方式。
架构思想的实现基于RxJava和其相关技术方案,例如:RxRelay、RxLifecycle等。
总体来讲,RxJava和LiveData都是Android Architecture Components推荐使用的库,LiveData出现较晚,相对来讲RxJava功能更强大些,比如对链式操作,stream操作符,以及异常处理的支持等,同时团队整体对于RxJava也有一定的技术沉淀,因此选择RxJava作为框架的技术支撑。
另外需要强调一点,框架的重点在于规范,而实现上可能会有多种技术方案。技术选型是一个非常重要的环节,但这不是本文的重点。
接下来的部分会通过一个最佳实践,来说明如何遵循雪球架构规范实现一个具体的需求。
说明:很显然不可能存在一个固定的方案能实现所有需求。雪球架构规范的目的只是提供一个能解决大部分需求的方案,保持项目实现的大部分一致。
以展示雪球正文评论详情的需求为例,评论的详细信息通过服务器提供的REST API返回,除了打开界面,用户还可以通过上拉加载更多评论信息:
UI层实现CommentsDetailActivity.kt,相应布局文件是activity_comments_detail.xml。另外,假设服务器返回的评论详情POJO是CommentsDetail.kt。做好这些准备后,我们就可以创建CommentsDetailViewModel.kt来为UI层提供数据、接受用户操作。
目前为止我们编写了4个文件:
部分代码片段如下:
class CommentsDetailViewModel: XQViewModel() {
fun loadCommentsDetail(articleId: String) {...}
}
class CommentsDetailActivity: Activity() {
//...
var articleId: String
var viewModel: CommentsDetailViewModel
var refreshLayout: RefreshLayout
override fun onCreate(savedInstanceState: Bundle?) {
//...
articleId = getIntent().getString("ARTICLE_ID")
viewModel = CommentsDetailViewModel()
viewModel.loadCommentsDetail(articleId)
refreshLayout.setOnLoadMoreListener {
viewModel.loadCommentsDetail(articleId)
}
}
}
接着要做的就是将CommentsDetailViewModel和CommentsDetailActivity连接起来:我们需要在ViewModel里写一个信号量获取评论详情:当数据加载成功后,给这个属性设置值;界面层监听这个值的变化,当值改变时,刷新界面,此时就需要用到RxRelay了。
RxRelay可以用RxJava原生的Subject替代,正常情况下二者并没有明显区别。但如果因为编码疏忽,无意间接收了一个Error信号,使用Subject会导致后续永远无法接收到信号。
RxRelay提供了各种类型的Relay(大部分情况下使用PublishRelay就可以解决问题),他们既是生产者,也是消费者,基于这个特性可以作为MVVM信号量的实现。
接着,CommentsDetailViewModel的代码就变成了:
class CommentsDetailViewModel: XQViewModel() {
val commentsDetail = XQSignal.create<CommentsDetail>()
fun loadCommentsDetail(articleId: String) {
commentsModel.loadCommentsDetail(articleId)
.subscribe{comments -> commentsDetail.call(comments)}
}
}
其中,commentsModel就是我们说的业务逻辑层,这里的loadCommentsDetail负责加载评论数据。而CommentsDetailActivity的代码也就相应变成:
class CommentsDetailActivity: Activity() {
//...
override fun onCreate(savedInstanceStatus: Bundle?) {
// ...
bindViewModel()
viewModel.loadCommentsDetail(articleId)
refreshLayout.setOnLoadMoreListener {
viewModel.loadCommentsDetail(articleId)
}
}
private fun bindViewModel() {
viewModel.commentsDetail.subscribe{comments -> updateCommentsUI(comments)}
}
}
接下来,加载过程很可能会出现网络失败,或者各种权限相关的问题(比如用户没有权限查看某些大V评论)产生的业务异常。
和加载评论的逻辑一样,我们设计这些异常的信号量:
class CommentsDetailViewModel: XQViewModel() {
val commentsDetail = XQSignal<CommentsDetail>.create()
val loadingError = XQSignal<String>.create()
fun loadCommentsDetail(articleId: String) {
commentsModel.loadCommentsDetail(articleId).subscribe(
{comments -> commentsDetail.modify(comments)},
{throwable ->
if(throwable is ApiException) loadingError.modify(throwable.getMessage())// 服务端返回的异常文案
else loadingError.modify("网络异常")})
}
}
class CommentsDetailActivity: Activity() {
//...
private fun bindViewModel() {
viewModel.commentsDetail.subscribe{comments -> updateCommentsUI(comments)}
viewModel.loadingError.subscribe{errorMessage -> showErrorMessage(errorMessage)}
}
}
如果不同的异常需要做不同的异常展示,比如网络加载失败是使用Toast展示文案,但无权限可能需要关闭页面,那么接着设计更多的error信号量就好:
class CommentsDetailViewModel: XQViewModel() {
val commentsDetail = XQSignal<CommentsDetail>.create()
val loadingError = XQSignal<String>.create()
val loadingErrorNoPermission = XQSignal<String>.create()
fun loadCommentsDetail(articleId: String) {
commentsModel.loadCommentsDetail(articleId).subscribe(
{comments -> commentsDetail.modify(comments)},
{throwable ->
if(throwable is ApiNoPermissionException) loadingErrorNoPermission.modify(throwable.getMessage())// 无权限访问
else if(throwable is ApiException) loadingError.modify(throwable.getMessage())// 服务端返回的异常文案
else loadingError.modify("网络异常")})
}
}
这里理解的关键是:把“异常”本身当成一种“正常”的业务逻辑看待:
class CommentsDetailActivity: Activity() {
//...
private fun bindViewModel() {
viewModel.commentsDetail.subscribe{comments -> updateCommentsUI(comments)}
viewModel.loadingError.subscribe{errorMessage -> showErrorMessage(errorMessage)}
viewModel.loadingErrorNoPermission.subscribe{errorMessage -> showErrorMessage(errorMessage)}
}
}
这样一来,View和ViewModel层的内容就完成了。
目前为止,View和ViewModel之间已经被我们很好的组合到了一起。接下来我们看看CommentsModel内部的实现。
目前大部分服务端接口都兼容了Restful设计原则,因此推荐使用Retrofit处理网络请求:
interface ApiRepository {
@GET(/article/{articleId})
fun loadCommentsDetail(@Path("articleId") articleId: String): Observable<CommentsDetail>
}
Model中主要负责业务逻辑实现,常见的例如数据缓存等。
在ViewModel中提到了一个思路:把“异常”本身当成一种“正常”的业务逻辑看待。而具体如何把所有“正常”或是“异常”转换成一种“业务逻辑”,这也是model层的工作:
class CommentsModel: XQModel {
val apiRepo = ApiRepository.getInstance()
val cacheDao = CommentsCacheDao.getInstance()
fun loadCommentsDetail(articleId: String): Observable<CommentsDetail>
= cacheDao.getComments(articleId).concatWith(apiRepo.loadCommentsDetail(articleId))
}
由于View(Activity/Fragment)与ViewModel之间是使用RxRelay耦合的,因此我们可以利用RxLifecycle在需要的时候(关闭界面/旋转屏幕等)解绑之间的耦合:
class CommentsDetailActivity: RxActivity() {
//...
private fun bindViewModel() {
viewModel.commentsDetail
.compose(bindToLifecycle())
.subscribe{comments -> updateCommentsUI(comments)}
viewModel.loadingError
.compose(bindToLifecycle())
.subscribe{errorMessage -> showErrorMessage(errorMessage)}
viewModel.loadingErrorNoPermission
.compose(bindToLifecycle())
.subscribe{errorMessage -> showErrorMessage(errorMessage)}
}
}
在雪球页面架构中,一个View可以灵活对接多个ViewModel。
有这么一个需求:在某个迭代中增加了“点赞评论”功能:
此时我们推荐新建一个FabulousViewModel来处理这个需求,在CommentsDetailActivity中:
class CommentsDetailActivity: RxActivity() {
var commentsDetailViewModel: CommentsDetailViewModel
var fabulousViewModel: FabulousViewModel
var btnFabulousButton: Button
override fun onCreate(savedInstanceStatus: Bundle?) {
// ...
bindViewModel()
btnFabulousButton.setOnClickListener {
fabulousViewModel.fabulousComment(commentId)
}
//...
}
private fun bindViewModel() {
commentsDetailViewModel.commentsDetail.subscribe{comments -> updateCommentsUI(comments)}
commentsDetailViewModel.loadingError.subscribe{errorMessage -> showErrorMessage(errorMessage)}
commentsDetailViewModel.loadingErrorNoPermission.subscribe{errorMessage -> showErrorMessage(errorMessage)}
fabulousViewModel.fabulousSuccess.subscribe{success -> showMessage(success.result)}
fabulousViewModel.fabulousFailure.subscribe{errorMessage -> fabulousError(success.result)}
}
}
推荐将不同功能写在不同ViewModel中有一些显而易见的好处:
下图展示了雪球页面架构的各个模块以及它们之间是如何交互的:
从另外一个角度来看整体的架构(如下图),重点并不是使用几个环,而是在于依赖原则,代码依赖是从外向内的,内层的代码不知道外层中的任何东西。换句话讲:越是在内层的越趋于稳定,改动会越小:
因为整个架构看起来酷似“洋葱”形状,很好的秉承了“分离是为了更好的结合”的思想,因此雪球的页面架构也叫做“onion架构”。
为了便于业务使用,避免直接操作复杂的RxJava操作符,雪球 Android 团队对RxJava和整体架构进行抽象,开发了 xueqiu-onion 框架,简化使用成本,让开发者更多的去关注业务本身。框架主要包括如下内容:
内容回顾:
雪球客户端团队通过对页面架构进行改造,极大改善了现有工程代码混乱,分层不明确,代码复用和扩展性差等一系列问题,并且足够灵活以适应愈加庞大的工程和需求的不断变化。这就是雪球onion架构出现的原因。它代表一组优秀的最佳实践,在任何软件开发中,都是不错的选择。
当然,没有一个架构是“一劳永逸”的,在架构演进的道路上,还要继续不断的探索和优化。
谷歌官方App开发架构指南
RxJava官方文档
Domain-Driven Design with Onion Architecture
雪球业务正在突飞猛进的发展,工程师团队期待牛人的加入。如果你对「做中国人首选的在线财富管理平台」感兴趣,希望你能一起来添砖加瓦,点击「阅读原文」查看热招职位,就等你了。
热招岗位:大前端架构师、Android/iOS/FE 工程师、推荐算法工程师、Java 开发工程师。