雪球 Android 客户端页面架构最佳实践

avatar
@雪球财经

图片

作者:孙泉

开发中常见问题

写出高质量的软件是困难和复杂的,雪球客户端团队在以前的开发中,经常遇到如下问题:

  • 可遵循的标准架构较少:传统开发方式往往会导致View层(Activity/Fragment)中存在大量重复代码。MVP模式中,由于V/P二层之间的相互耦合,从代码分层角度(层之间单向引用)来说并不完美,无法做到P层的业务复用;

  • 不符合职责单一原则:传统MVP模式,由于V和P是一对一的,如果业务很复杂的话,P会承担大量的责任;

  • 生命周期不易于管理:实际上大部分APP并不需要处理转屏等复杂应用场景,但即使这样,我们经常需要关注页面关闭后,运行中的网络请求是否需要停止,是否会造成空指针,甚至内存泄露;

  • 不利于单元测试:一般情况下,qa写的单元测试case是针对于业务逻辑的,但是如果没有独立的业务逻辑层,是非常不利于实施的;

  • 编码风格无法统一:如果编码风格得不到统一,每个人在做业务需求,或帮助其他人调试代码,亦或进行code review的时候,会非常困难,这时候一套能够让每个人都写成风格相似代码的框架显得尤为重要。

总体来讲,原有的MVP架构是一个优秀的代码整理方式,但是在开发大型软件和处理复杂业务逻辑时,还是会存在诸多问题,这时候需要一套高可用的页面架构来解决以上问题。

概述

雪球页面架构,使用一组开源库,结合函数响应式、MVVM思想,实现了一套可重用、可测试、生命周期安全、聚焦需求的开发框架。

同时,它也代表一组优秀的开发实践,用来开发任何软件应用都是一个不错的方式。

RxJava支持

架构思想的实现基于RxJava和其相关技术方案,例如:RxRelay、RxLifecycle等。

  • RxRelay:Observer和Subscriber的结合。为MVVM中可观察数据源提供了支持基础;

  • RxLifecycle:通过bindToLifecycle方法,实现RxJava流式API中如何安全的绑定/解绑生命周期,防止内存泄露引起的各种问题。

总体来讲,RxJava和LiveData都是Android Architecture Components推荐使用的库,LiveData出现较晚,相对来讲RxJava功能更强大些,比如对链式操作,stream操作符,以及异常处理的支持等,同时团队整体对于RxJava也有一定的技术沉淀,因此选择RxJava作为框架的技术支撑。

另外需要强调一点,框架的重点在于规范,而实现上可能会有多种技术方案。技术选型是一个非常重要的环节,但这不是本文的重点。

MVVM架构

  • View:和用户直接交互;

  • ViewModel:针对最小业务需求进行开发;

  • Model:根据业务类型分层,实现具体业务逻辑的地方;

  • Repository:数据源提供层,包括网络数据,本地数据,系统服务等。

最佳实践

接下来的部分会通过一个最佳实践,来说明如何遵循雪球架构规范实现一个具体的需求。

说明:很显然不可能存在一个固定的方案能实现所有需求。雪球架构规范的目的只是提供一个能解决大部分需求的方案,保持项目实现的大部分一致。

需求

以展示雪球正文评论详情的需求为例,评论的详细信息通过服务器提供的REST API返回,除了打开界面,用户还可以通过上拉加载更多评论信息:

图片

界面实现

UI层实现CommentsDetailActivity.kt,相应布局文件是activity_comments_detail.xml。另外,假设服务器返回的评论详情POJO是CommentsDetail.kt。做好这些准备后,我们就可以创建CommentsDetailViewModel.kt来为UI层提供数据、接受用户操作。

目前为止我们编写了4个文件:

  • CommentsDetail.kt

  • CommentsDetailActivity.kt

  • activity_comments_detail.xml

  • CommentsDetailViewModel.kt

部分代码片段如下:

`class CommentsDetailViewModelXQViewModel() {` 
 `fun loadCommentsDetail(articleId: String) {...}`
`}`
`class CommentsDetailActivityActivity() {`
 `//...`
 `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 CommentsDetailViewModelXQViewModel() {` 
 `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 CommentsDetailActivityActivity() {` 
 `//...` 
 `private fun bindViewModel() {` 
 `viewModel.commentsDetail.subscribe{comments -> updateCommentsUI(comments)}` 
 `viewModel.loadingError.subscribe{errorMessage -> showErrorMessage(errorMessage)}`
 `}` 
`}`

如果不同的异常需要做不同的异常展示,比如网络加载失败是使用Toast展示文案,但无权限可能需要关闭页面,那么接着设计更多的error信号量就好:

`class CommentsDetailViewModelXQViewModel() {` 
 `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 CommentsDetailActivityActivity() {` 
 `//...`
 `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 CommentsModelXQModel {`
 `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 CommentsDetailActivityRxActivity() {` 
 `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中有一些显而易见的好处:

  • 职责单一(SRP原则):逻辑更加清晰;

  • 可重用:相同的功能可以被重用到各种地方。这个例子就是一个很普遍的场景(APP的评论列表,收藏列表,正文等都会使用到“点赞”功能;类似还有APP的检查更新功能)。

最终架构

下图展示了雪球页面架构的各个模块以及它们之间是如何交互的:

图片

从另外一个角度来看整体的架构(如下图),重点并不是使用几个环,而是在于依赖原则,代码依赖是从外向内的,内层的代码不知道外层中的任何东西。换句话讲:越是在内层的越趋于稳定,改动会越小:

图片

因为整个架构看起来酷似“洋葱”形状,很好的秉承了“分离是为了更好的结合”的思想,因此雪球的页面架构也叫做“onion架构”。

xueqiu-onion 框架

为了便于业务使用,避免直接操作复杂的RxJava操作符,雪球 Android 团队对RxJava和整体架构进行抽象,开发了 xueqiu-onion 框架,简化使用成本,让开发者更多的去关注业务本身。框架主要包括如下内容:

  • 线程切换:自定义Transformer,包含IO线程,CPU密集运算线程,UI线程等;

  • 事件源订阅:业务异常和正常subscriber的统一处理,开发者只要在对应的subscriber中发射信号量即可;

  • 生命周期绑定:使开发者无需关注ViewModel的生命周期,内存泄露等问题;

  • 信号量:针对RxRelay进行二次封装;

  • DI容器:使用DI容器进行ViewModel和Model的创建。

  • ...

小结

内容回顾:

  • 客户端开发中一些常见问题;

  • 雪球架构规范概述以及相关技术介绍(RxJava,MVVM等);

  • 通过一个最佳实践说明如何遵循雪球架构规范实现具体需求;

  • 通过架构图展示雪球onion框架各个模块之间如何交互;

  • 简要介绍基于雪球架构规范开发的 xueqiu-onion 框架。

雪球客户端团队通过对页面架构进行改造,极大改善了现有工程代码混乱,分层不明确,代码复用和扩展性差等一系列问题,并且足够灵活以适应愈加庞大的工程和需求的不断变化。这就是雪球onion架构出现的原因。它代表一组优秀的最佳实践,在任何软件开发中,都是不错的选择。

当然,没有一个架构是“一劳永逸”的,在架构演进的道路上,还要继续不断的探索和优化。

参考

谷歌官方App开发架构指南

RxJava官方文档

Domain-Driven Design with Onion Architecture

还有一件事

雪球业务正在突飞猛进的发展,工程师团队期待牛人的加入。如果你对「做中国人首选的在线财富管理平台」感兴趣,希望你能一起来添砖加瓦,点击「阅读原文」查看热招职位,就等你了。

热招岗位:大前端架构师、Android/iOS/FE 工程师、推荐算法工程师、Java 开发工程师。