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

以云看科技 2024-05-13 00:13:40

开发中常见问题

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

可遵循的标准架构较少:传统开发方式往往会导致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.ktCommentsDetailActivity.ktactivity_comments_detail.xmlCommentsDetailViewModel.kt

部分代码片段如下:

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中有一些显而易见的好处:

职责单一(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 研发-孙泉

来源-微信公众号:雪球工程师团队

出处:https://mp.weixin.qq.com/s/FZ2CXIFtS3ZxfUnQ0eAdmA

0 阅读:9

以云看科技

简介:感谢大家的关注