[关闭]
@pockry 2017-01-09T19:02:46.000000Z 字数 15383 阅读 2657

人人车Android客户端架构演进实录

移动


本文回顾总结了人人车公司Android客户端的架构演进历程。人人车App随着公司在业务和规模上的飙升,持续集成业务需求的同时,架构也不断的重构演化,从模块化,分层化,到框架化,服务化,对Android客户端架构设计和改进有一定的参考意义。

前言

对于大多数创业公司而言, 初版开发时采用的简单架构,在历经数次快速迭代后,已经成为了一个”大泥球”(源于Brian Footer和Joseph Yonder的论文《大泥球》, 定义: 一大片随意构造,杂乱无章,凌乱,任意拼接,毫无头绪的代码丛林), 如下问题存在于当前的架构中:

  1. 业务逻辑混杂在平台实体中,造就了代码量庞大的Activity和Fragment。
  2. 本应是全局级别独立存在的功能模块,却被封锁在某个特定视图领域内,生命周期和模块层级规划不当。
  3. 功能拆分不够细致和代码重复,在模块和函数级别均有体现。
  4. 部分模块之间高度耦合,使用没有经过合理规划的接口来实现通信。
  5. 部分第三方库没有做隔离,造成和第三方库的高度耦合,还存在库的误用和滥用。
  6. 分层化缺失,领域边界基本不存在,比如数据层和业务层共享同一套数据结构。
  7. 缺乏内部规范,部分概念或实体的表述混乱零碎,作为workaround的谜之代码比较多。

总之,初始搭建的架构已经不足以支撑长期的持续性开发,泥球最终会把开发者推入代码深渊。重构是必须的,但是从哪里开始需要好好考虑。

笔者的观点是,对于大中型项目,短时间内不太可能建立起对项目需求和现行逻辑的大局观,并且在重构的同时还要保证项目的及时发布(想想那个经典的比喻,为一辆高速奔驰的汽车换轮胎),那么从次级业务模块进行改进和重构是一条稳妥之路,在重构的同时一点一点的丰富细节认知和整体布局,最终倒逼整体架构的变革。

下面分为两个阶段对人人车Android客户端架构演进的方案和阶段进行阐述:

第一阶段演进

1. 业务视图模块

就人人车App来说,重构前,所有的子业务逻辑都写在页面载体中,从数据提取,视图配置到交互都混杂在一起,每个页面都是泥球,导致作为页面载体的Activity和Fragment体量极为庞大,代码的阅读和后续修改都很费劲。

上述问题在结构设计层面上体现为:边界缺失。每种子业务,都应该有边界,有了边界才有所谓的内聚性,才能区分外部使用者和内部实现者。

既然是因为边界缺失导致了结构问题,那么增加边界即可,业务视图模块是我们采用的解决方案,业务功能级视图模块的实现很简单,可以理解为代码的类级别隔离带来了边界,进而从页面载体(Activity/Fragment)剥离了业务。

image

上图左半部分是人人车App的车辆详情页,可以看到,详情页的不同视图部分对应到了不同的Module(注意,这里仅仅是一个示例性质的划分),每个Module负责了不同部分的视图配置和业务交互(有些情况下,甚至视图生成也是由Module负责的)。页面上通常承载复数项子业务,以子业务为维度进行划分视图层,就得到复数个的视图切片,于是在视图层和业务逻辑层之间产生了以子业务为粒度的映射,业务视图模块封装了这类映射,每个模块封装了特定子业务的视图切片配置和业务逻辑,如上图一般,详情页的各个子业务被不同的模块承包。

视图模块本身还可以再进行拆分,如果有逻辑再分层需要的话,比如Module A在内部又将功能分包给了SubModule A和SubModule B。业务视图模块的另外一个特点是不依赖于视图的真正的布局细节,比如上图的Module F, 其负责了车辆详情页的对比子功能,那么,车辆标题右边的增加对比按钮和悬浮的对比按钮就都是其应该管辖的视图片段,而不会意味两者处于不同的布局层就分模块处理,这也反映了视图模块拆分以业务为粒度的原则。

业务视图模块以类似于插件的形式和Activity/Fragment进行联动,Activity/Fragment本身不再承载任何视图业务逻辑,仅仅需要维护内部寄生的模块,同时针对Android平台本身的Activity/Fragment生命周期特性,Activity/Fragment还需要负责将自己的生命周期回调分发给相应的模块,以保证某些依赖平台生命周期特性的业务可以被封装在模块内。Activity/Fragment的职责退化为模块容器和数据媒介,数据媒介的作用体现在,Activity/Fragment在某些情况下会扮演页面数据的入口和分发者,如上图下方所示,Data到达Activity后,Activity要将Data再次分发给内部那些真正需要数据的模块。

总的来看业务视图模块承担了两个职责:

  1. 是对外封装业务本身的实现,包括视图配置和业务逻辑。
  2. 对内模拟了寄生容器的某些特性(这里是生命周期),从而使得依赖容器特性的业务也得以运行。

视图业务模块显得简单直白,但是其效果和收益相当可观,具有结构性意义,因为这一步划分了边界,边界促进了页面结构的明晰化和职责的封装拆分,同时限制了代码的污染性溢出,有可能模块内部的实现还是以前的丑陋代码,但这时候其已经退化为一个小泥球,在边界的约束力下对架构本身的影响力被限制在最小。同时,以插件思路设计的模块也能实现一定程度的代码复用(但这一点不是模块化的目的,因为业务级别的复用非常依赖产品和设计,可以看作是锦上添花)。

另外,模块化带来的副作用就是通信的问题,模块化带来的边界割裂了一体化,需要额外的通信手段,这里我们增加一个Mediator来实现了模块之间的通信。实际上,一个良好的业务体系,子业务之间不应该有太多的通信,所以Mediator的复杂度和方法数并不会为因为模块数量的增加而指数级增长。

2. 数据边界

数据边界的宗旨是隔离外部原始数据和内部业务数据,在数据结构的维度实现边界和职责划分,以此来缓冲外部污染数据对内部业务逻辑的冲击力。

我们应该有这样的认识:外部原始数据永远是不可靠的。对于内层业务逻辑来说,外部数据来源有很多种,网络,文件,数据库等等,这些数据来源都是不可靠的,网络不稳定,服务器故障,磁盘文件被误写都是不可避免的。如果任由这部分污染数据直接传递到业务层逻辑,业务层逻辑在没有足够防御的情况下就会运转失常甚至于崩溃。退一步讲,即使业务层逻辑做了足够的防御,可以抑制污染数据的破坏力,从职责划分上讲已经出现了问题: 业务逻辑承担了它不应该承担的数据防御职责。另一个设计上的缺陷是:业务逻辑直接套用了外部原始数据的数据载体,这样导致业务层数据处理逻辑和外部原始数据的格式等信息发生了强耦合,在数据表示层两者没有得到区分,这其实是一个在数据结构上职责没有划分的问题。

上述设计缺陷的解决方案最终形成了数据边界。解决方案有这么几个要点:

  1. 数据校验的职责从业务逻辑中剥离,形成单独的职能模块。
  2. 外部原始数据的数据载体要和业务逻辑的数据载体做到完全隔离。
  3. 所有的外部数据入口要得到有效的控制。

上述几点互相关联:只有控制了所有外部数据入口,才能对所有外部数据做校验,而数据载体隔离需要的数据转换也需要依托对所有外部数据入口的控制,数据校验可以作为数据转换的前置步骤。数据边界的示意图如下:

image

每一个外部数据入口都有一道关卡: Data Mapper, 这就是上面第一点提到的独立职能模块,Data Mapper扮演外部数据运输到内部的关卡,Data Mapper承载了两个职责: 数据校验和数据转换(因为这两个职责本身是前后关联的,没有必要再次进行模块级的拆分)

  1. 数据校验: 按照业务特性或者规范约定,对外部原始数据进行一次全面排查:如果数据在整体上已经被污染,那么该数据会被彻底放弃,但会被转换为空数据或者约定好的无效数据(比如Java中的null)传递给业务层(数据到达本身也是一个信息,需要业务层知晓。比如加载数据会显示加载中界面,即使返回了污染数据,也是需要停止显示加载中界面而显示空页面或者错误页面的,如果直接忽略此次数据的到达,那么就会一直显示加载中)如果数据只是部分污染,那么会尝试剔除被污染的部分,以实现力所能及的信息传递。总结来说,数据校验将数据洗白,保证数据的安全性,至于数据的功能性则由数据转换来保证。
  2. 数据转换: 相对数据校验要复杂一些,需要根据外部原始数据组装出可用的业务层数据,除了单纯的数据结构调整外,还有可能会有数据裁剪,数据拼接等复合操作。数据转换还有一个作用是保持整体业务数据的规范性,比如对于车辆价格这类数据,在业务层面会对有一个精度方面的规范约束(最多两位小数), 如果得到的数据没有遵循这个规范,我们称其为违规数据,违规数据和污染数据是有区别的,前者可以在数据转换中得到矫正,从而保证了传递给业务层的数据的规范性。

如上图中Data Mapper示意图所示: 通过数据校验和数据转换的协力,各种各样正常的,残缺的,污染的,违规的外部原始数据最终被转化为安全的,规范的,可用的业务层数据。数据边界犹如一个数据沙盒,在业务层来看,它感知到的是一个绝对可靠的业务数据环境。

数据边界的思想内核其实是分层化,职责下放到不同的层,每一层向上一层提供特定的服务,上一层不必关心下面的细节,每一层功能和职责专一化。

3. 引入RxJava

先谈一下笔者对第三方库的使用观点:

  1. 谨慎,克制的引入新库,加法容易减法难,库越多,冲突和妥协就越多,开发和发布成本也会增加。
  2. 随着业务本身的不断演化,基于通用性目的设计的库迟早会和特定业务需求之间产生冲突。
  3. 组件级的库要比框架级的库安全。因为前者只封装了单点功能,后者则封装了流程。
  4. 在引入或者替换库前要清楚你的需求和要付出的代价,不要追逐风潮,人云亦云。

经过对响应式编程的学习理解和RxJava库的源码解析,我们引入了RxJava以期以下收益:

  1. Observable实现了回调方式的归一化。
  2. Operator让回调的传递和处理灵活而富有组合性。
  3. 线程调度非常方便。

从实际应用上讲,RxJava基于信息流模型对一些常见操作和场景进行了模拟封装,使得开发快捷,实现优雅,如:

  1. Observable模拟了一条单向信道。
  2. Subject模拟了一个自发信息源。
  3. BehaviorSubject作为Subject的扩展模拟了一个会缓存上次发出信息的信息源。
  4. Map模拟了形态切换。
  5. FlatMap模拟了复杂结构信息的降维。
  6. Merge模拟了信息流的合并。
  7. CombineLatest和Zip模拟了信息流的同步。
  8. 等等不再赘述。

上述功能和特性在我们的整个开发和重构过程中发挥了很大的助力,让实现更加简洁优雅。本文在这里不再做更多展开,在后面的重构项目中会穿插介绍对Rxjava特性或功能的使用。

4. 数据源和数据隧道

数据源和数据隧道是我们对提取数据-刷新页面这个常规流程的重新建模,以适应人人车App的页面刷新场景: 人人车App是一个重呈现的App,比如“我要买车”页面,用户在提交筛选条件后,需要从服务器获取数据然后刷新页面,将新的数据呈现给用户,显然,这个页面会有非常频繁的提取数据-刷新页面行为。

image

上图上半部分描述了之前我们对 提取数据-刷新页面 这一流程的建模: Module(界面)向Data Fetcher发起fetch调用,在调用中要传递一个回调,在Data Fetcher获取新数据以后,执行回调来触发Module刷新界面。这个模型在初期可以正常工作,不过在使用上已经暴露了其笨重的地方,每次想要请求新数据时,都必须携带一个回调,每次的请求数据和刷新都是一次性行为,使用起来比较麻烦。到了中期,新的需求出现了:在本Module之外的其他操作(比如用户在其他Module的行为)也要能触发该页面的 提取数据-刷新页面 流程,很显然,当前的one-shot模型必不能很优雅的实现这个需求。

示意图下半部分描述了我们对 提取数据-刷新页面 的重新建模:

  1. 引入数据源(Data Source),数据源和Data Fetcher的区别在于,数据源是独立存在的主动数据提供者,Data Fetcher则只能算是一个功能性的包装。数据源独立存在的意义在于将其从页面Module的功能性附属(Data Fetcher其实就是一个功能性附属,只提供了静态级功能,没有自己的核)中剥离为单独的实体,从此数据是数据,页面是页面。数据源作为主动数据提供者体现在它可以提供数据隧道给使用者。
  2. 引入数据隧道(Data Tunnel), 数据隧道本质上讲很简单,就是一个固定的观察者罢了,但是从设计角度看,它有截然不同的意义。它将原来主动“拉”新数据转换为了数据源“推”新数据过来,这样就可以满足我们在上面遇到的新需求了,页面只需要建立一条到数据源的数据隧道,在外界触发了数据源的更新后,数据源会主动的将新数据通过数据隧道推到页面。上图展示了这个流程以及其扩展: 一个数据源可以同复数个使用者建立数据隧道,只要数据源更新了数据(注意,这个数据源本身不具有自主更新的能力,需要外界来触发,比如上图的Module或者Trigger)就会将新数据推送到所有的数据对端。使用者不再需要数据隧道时可以方便的进行拆卸销毁。

数据源-数据隧道模型建立得益于RxJava的BehaviorSubject,实现的非常优雅,并且还解决了一个数据界面同步的问题,比如会有这样的场景:

页面启动和数据到来之间没有固定的先后顺序,页面启动完毕后需要数据,如果数据之前没有到来,那么页面等待数据到来即可,但是如果之前数据已经到来,鉴于数据到来时页面还没有启动完毕,感知不到数据到来,所以需要数据源能够缓存上一次成功发出的数据,BehaviorSubject的特性完美的契合了这个场景: BehaviorSubject内部会缓存消息流的最近一个条息, 在后续有Subscriber订阅时,会直接将缓存的消息投递给Subscriber。另外RxJava的onTerminateDetach优雅的处理了销毁数据隧道时的内存泄漏风险。

数据源-数据隧道模型化被动为主动,适应需求的同时也在架构上反映了数据层独立的趋势。

5. 数据源的分层和组合

该重构是上一步数据源模型的延伸扩展,在数据层进行的一次增强。

考虑这样的应用场景:数据源A提供的数据被模块B使用,数据源C提供的数据被模块D使用,有了新的需求导致数据源C的部分数据替换为数据源A提供的数据,这个时候有两种需求实现思路:

  1. 模块D直接使用数据源A和数据源C,但这样必然导致模块D逻辑代码的改动,除了要引入数据源A之外,还需要对原先处理数据的逻辑进行修改来适应这个变化。和我们希望的最理想方案有差距:理想方案是是模块D不需要修改,因为从本质上讲,模块D的功能在新需求中没有任何变化,这次的变化是一个数据层的变化,不应该让其影响数据层之上的模块,模块不应该承担数据整合的职责。
  2. 数据源A和数据源C进行组合,数据源C作为和模块D对口的数据源不变,这样就可以保证模块D的数据提取逻辑不变,为了适应新的数据需求,数据源C化身为数据源A的一个使用者,和数据源A建立数据通道后,就能获取数据源A的数据以及对A数据变化的感知,有了A的数据,就可以在内部使用A的数据替换自己内部要被替换的部分,经过聚合的数据就是满足这次需求的数据。

显然第二种方式在结构和职责上更加合理,而这种对数据源A和C的组合应用就是是数据源功能扩展的一个例子。

image

刚才的例子展示了数据源之间组合的灵活性,出于内部规范的要求,我们对数据源进行了一次概括性分层,如上图所示分为两层,业务级数据源和单点数据源:

  1. 单点级数据源和服务器接口等外部数据提供者一一对应,功能是最纯粹的,就是外部数据提供者在架构中的化身封装,只提供单一类型的数据,如上图的Data Source A,B,C,D。
  2. 业务级数据源一般自己没有产生数据的能力,其作用主要体现在对单点级数据源的聚合和管理上,只对接业务级模块。该类数据源的引入是作为一个中间层来抹平实际数据和业务数据需求之间的沟壑,如上图的Data Source E,F, 两者的数据生成依赖于Data Source A, B, C,但是A, B, C的数据也需要经过E, F的适配才能满足Module的数据需求。

数据源组合和涉及到一个数据同步的问题: 以Data Source F为例,F依赖于Data Source A和B的数据,F只有在A和B的数据都就绪的情况下,才能构造完整的业务数据进行投递,具体的细节如上图下半部分所示:在A和B的数据没有全部到位时,F不会发送数据 ,后续A和B有任何数据更新,F也需要同步的刷新数据并发送(该图参考了ReactX的CombineLatest示意图),得益于RxJava的CombineLatest功能,数据同步的实现极为简便。业务层数据源对业务模块屏蔽了数据源聚合的细节,作为中间层出色的完成了任务。

分层和组合还有一些要点是,业务级数据源之间可以也可以组合以适应需求,前面所说的两层是一个概念上的分层,实际实现中可以嵌套多层,一个业务级数据源在更上一层来看也可以认为是一个单点级数据源,只要在数据的分布上合理即可。另外业务模块不强制使用业务级数据源,因为某些业务所需要的数据一个单点数据源足以覆盖,没有必要引入多余层,如上图的Module D就直接使用了单点数据源 D。

数据源的分层和组合解决了实际服务端接口提供的数据和业务场景需要的数据之间的矛盾,可能会觉得这和数据边界的Data Mapper功能重叠,但其实两者处于不同的数据层次,业务级数据源的数据整合生成可以被认为是数据源内部的数据整合,而Data Mapper则是数据源外部的下游数据加工者。

6. 通用功能的聚合

通用功能的聚合是重构之路的必经阶段,将一个通用功能原来零散分布在代码中的实现全部聚合提取为单独的功能性模块,是一次功能级边界的确立,下图是一个简单示意:

image

人人车App的通用功能聚合涉及的功能较多,比如车辆筛选,砍价,预约等特定业务功能。这里大致总结下聚合的思路和手法: 全局的归全局,局部的归局部,功能聚合为功能模块。

功能聚合之前的现状是: 一些功能在实现时局限于产品设计,被限制在特定的页面子功能中,比如车辆筛选被限制在“我要买车”页中,砍价被限制在“车辆详情页”中,究其原因,是因为这个功能在提出时只存在于某个页面中,这样在实现时也不由的被限制了思路。但是,以砍价功能为例,从领域划分上讲,砍价功能和“车辆详情页”不在一个领域内,两者之间只有简单的使用关系罢了,从变化上讲,砍价功能和“车辆详情页”不是紧耦合的,在其他可以提供足够信息的页面,砍价功能理论上都可以被加上(后面果然在其他的页面增加了此功能)。因此砍价功能本身聚合为一个功能性模块是由必要的,在代码上避免了代码重复,也有了自己的领域。

上述思路是进行功能聚合的一个指导思想,在被提升为全局功能模块后,使用者只需创建功能模块,然后使用即可,不过对于一些依赖于Android生命周期的功能,使用者还需要保证生命周期回调。

通用功能聚合和前面的业务视图模块类似,两者的思路一致,不同的是处于的领域和独立性。

第一阶段演进总结

  1. 业务级页面得益于业务视图模块,在内部细节层面已经变的边界分明,结构清晰,部分视图模块得到了高度复用。
  2. 数据边界使得外界非法数据对内部逻辑基本不能造成影响,数据鲁棒性得到了提高,并且进一步的业务数据体系和外部数据体系实现了完全隔离,尽管有所冗余,但是却为了两端数据结构的灵活变化留够了余地,数据预处理和规范化职责也被明确的抽取到专门的实体,数据层内部的边界明确,功能粒度细化。
  3. 数据源及其扩展初步构建了独立的数据层,新的 提取数据-刷新页面 模型很好的适应了数据页面之间新的联动需求,数据源之间的灵活组合也提供了客户端对服务端接口变化的良好适应力。
  4. 通用功能聚合,减少代码重复,加快了项目的开发,确立了通用功能的层次和边界。

总结: 第一阶段的重构偏向于模块化和层次化,多是对一些次级领域进行改进。

第二阶段演进

7. 锚点系统

锚点系统引入的初衷,是为了得到当前显示在前台的Activity,充其量是一个Activity全局信息维护系统,不过随着后续的持续强化扩展,潜力被慢慢发掘,最终演化为骨干级的系统框架。先阐述一下锚点的概念: Android的特性决定了大多数视图相关操作都需要Activity的介入,比如显示一个对话框,必须要提供一个Activity才能进行显示,Activity的角色就是锚点,锚定了上下文,通过锚点就可以获得需要的上下文信息,从而进行基于上下文的操作。

Android中有很常见的异步操作场景,该异步操作在执行过程中会需要一个Activity,常规的思路就是让异步操作持有Activity,不过限于异步操作会导致activity内存延迟释放甚至泄露,需要使用一定手段来进行规避,MVP,弱引用等都是解决方案。不过MVP在使用上不够灵活,弱引用则不够优雅,因此引入了锚点系统来提供一个更好的解决方案。

锚点系统的思路是在系统内部通过独一无二的轻量级标识(一个数值类标识, PageId)来对应和识别Activity/Fragment等系统界面组件。外界使用者对界面组件的引用使用轻量级标识来避免直接引用Activity/Fragment。好比每个Activity/Fragment都会在锚点系统内登记,向锚点系统提供其特殊的标识,外界通过此标识借助锚点系统即可获得对应的Activity/Fragment实体,另外,鉴于Activity/Fragment拥有自己的生命周期,因此Activity/Fragment在自己的生命周期回调中都需要通知锚点系统,锚点系统根据中这些回调动态增删添改内部维护的实体信息,外部如果使用一个已经销毁的界面组件的标识会被告知标识已经无效。

我们在规划锚点系统时,除了赋予其上面描述的页面组件标识功能外,还通过制定Activity/Fragment页面规范来支撑了锚点系统的当前展示页面信息:

image

先引入PageView接口,作为业务级页面的表征,如上图右下角的人人车App“车辆详情页”: Android的视图展现一般都是采用Activity或者Activity内部使用Fragment来实现,Activity/Fragment各代表了不同层的业务页面,在这个例子中,Fragment D代表的是“车辆详情页”PageView,但是车辆详情页本身有可以被细分为不同的领域,Fragment F代表的是“车辆详情页”的“车辆参数”PageView,Activity C本身只作为Fragment的载体,是一个“无形”的PageView。在上图中,当前“呈现”的PageView是“车辆参数”。

锚点系统的另一个功能就是可以自动维护当前呈现的PageView信息,会对外提供一个查询接口来返回当前呈现的是哪个PageView,以及其对应的业务页面的类型,这个功能可以简化一些依赖当前展示界面类型的功能的实现,比如,在按钮点击的统计参数上报中需要增加当前处于哪个页面,如果没有锚点系统提供独立的查询服务,就要在在每个按钮点击上报的逻辑写死按钮处于的页面类型,而借助于锚点系统,只需要在提交上报请求时统一补充上当前页面类型即可,因为按钮点击上报时,当前呈现的页面一定是按钮所在的页面。

以上图来系统性的说明锚点系统如何运作,其描述了这样的场景:

用户退出Activity A进入Activity B(Activity B内部则包含了Fragment A,E),在从Activity B进入了Activity C(包含了Fragment C,D,F,G),用户继续进行操作,马上会进入Activity D。

首先上面的Activity/Fragment都以PageView的形态(即实现了PageView接口)注册在锚点系统中,在Activity/Fragment创建和销毁时会自动登记和释放。除了创建和销毁,其他的界面组件生命周期回调也会被通告给锚点系统,锚点系统根据这些变化维护当前哪个PageView是处于前台的。如上图所示的那样,Activity B以及其内部包含的Fragment都被切到了后台,处于冻结状态。

此时在前台的是Activity C和其包含的Fragment,但仅仅知道这一步不足以细化到业务页面层面,还需要在Activity C和Fragment集合中寻找真正在前台的PageView,锚点系统内部将Activity/Fragment按照预设的层级进行了分层管理,Activity一般会承载Fragment, 那么如果同时存在前台Activity和Fragment的话,前台Fragment代表的才是真正呈现的PageView(比如上图,Activity C虽然处于运行状态,但是不算前台PageView), 再进一步,Fragment之间也有层级划分,如上图的Fragment F和Fragment D, Fragment D的层级最深,也是展示在最前的页面,那么Fragment D就胜过了Fragment F成为了真正的前台PageView,直到即将到来的Activity D切到前台为止。

锚点系统还提供了对全局页面状态变化的监听服务,任何实体都可以向其注册来感知所有页面的状态变化。

锚点系统的前提是所有页面级的Activity/Fragment都遵循PageView接口规范,这个统一性的要求其实不难,因为一般Android开发时,都会继承原生的Activity/Fragment生成项目使用的BaseActivity/BaseFragment,集中控制已经提前做好了,PageView只需在这里实现即可。

8. 网络概念层

网络通信是一个APP的基础需求,除去少数特例,实际项目开发都会使用成熟的第三方网络库来着构建自己的网络功能实现,我们项目中使用的网络库是Volley,人人车App对于网络性能指标没有非常的要求,功能够用即可。

在重构网络层之前,项目对Volley做的是相对简单的功能层封装,即提供一个函数可以根据给出的请求相关参数构造一个Volley Request,并投递给Volley库来发起请求,这个做法在项目初期并没有什么太大的问题。

但是随着功能需求的演进,除了单纯的网络通信功能外,项目还增加了很多作用于网络请求本身的需求,比如,对网络请求或者回复的附加处理(Request Processor),网络认证模块(Authenticate)会要求拦截认证失败的请求并自动重发等,随着这些需求的叠加,项目代码和Volley产生了紧耦合,因为我们需要基于Volley提供的各种类或函数来发起请求和实现其他附加网络业务需求。

image

上图上半部是重构前的网络层架构,可以看到,一些业务需求的实现逻辑已经部分或者全部的位于了Volley的领域。这个情况初看是不可避免的,因为你要使用一个库,必然要使用其提供的类或者函数。但是,对网络通信这种底层服务机制来说,现在的情形是底层具体实现(Volley)绑架了上层(项目代码),上层需要根据Volley的类和功能来实现网络请求处理逻辑,两者之间正确的关系应该是底层遵循依赖上层提供的需求接口来适配自己的功能。

从一个更抽象的维度看,项目逻辑需要一个“概念”上的网络层,这个”概念”网络层的接口和结构均由上层按照自己对网络通信的需求和理解制定,下层的具体实现反过来则需要遵循或者适配这套上层“协议”。

基于上述思路,引入网络概念层,新的网络层架构如上图下半部分所示: Custom Request是上层对网络请求这一概念的封装和落地,成为项目代码中网络请求的唯一表现形式和载体,第三方库提供的具体请求类型(有的库甚至连请求类都没有)被隔离到最底层,只在Sender(负责对接第三方库的适配者)的适配实现中可见。Custom Request成为整体架构中的一个对外网关协议,所有的网络请求在内部均以Custom Request的形式创建和维护,最后由当前的Sender实现来翻译/适配为对应网络库的网络请求对象。

Custom Request采取注入式的方式和第三方库的具体请求对象协作,本身只提供对请求相关信息的查询(比如请求的地址,参数等)和回调接口,不会维持对第三方具体实现的引用,也感知不到第三方库。第三方网络请求会维护一个到通用Request的单向引用,以扩展的方式来回调驱动Custom Request,然后Custom Request进一步的将回调消息通过Network Callback反馈给上层使用者,组成了一条单向底层网络回调反馈链条。Custom Request的主要职责是网络请求信息的载体,基本不包含逻辑,像Authenticate之类网络扩展功能的实现会放在Interceptor中。

Interceptor负责拦截Custom Request的各个回调点,并将回调广播给Interceptor内部的所有的Processor,每个附加功能对应一个Processor。Processor在合适的回调点对请求和回复进行拦截处理来实现自己负责的功能,同以前基本所有的处理逻辑直接混杂在Volley Request内部相比,实现了职责分离。更关键的是: 所有网络附加处理逻辑现在都作用于Custom Request上,而不是Volley Request上,处理逻辑彻底和Volley划清了界限。

网络概念层的另一个收益是加强了对网络的控制,因为Custom Request处于项目领域内,每个发出的Custom Request完全可以被项目代码进行维护和管理(如果还使用Volley Request的话,第三方库是黑盒实现,很难说外部维护一个Volley Request没有潜在风险,这还会导致之前所述的业务逻辑和Volley产生了耦合),对网络请求状态就有一个大局观,比如当前有哪些请求正在进行中以及处于何种状态等。

网络概念层对第三方网络库也有功能延展性,毕竟是作为中间层存在。比如网络库本身不支持取消请求的话,网络概念层完全可以扩展Custom Request来增加一个canceled标记,上层想取消请求时设置此标记,尽管底层网络库的请求不能被取消,最终还是会回调到Custom Request,但是Custom Request完全可以检查canceled标记来将此回调忽略不向上传递,那么在上层来看,这个请求就是被成功取消的,更多的扩展应用限于篇幅,不再叙述。

总结来说,我们自己制定了一套网络层规范和流程,内部以这套准则来操作和管理网络请求,外部(第三方网络库)需要通过适配来提供底层运作。

9. 功能模块进化为功能服务

在功能模块化推进到一定程度后,开发效率确实得到了提升,新页面需要添加以前的功能时直接使用功能模块即可。不过在使用中还存在一些痛点:

  1. 使用者需要自己创建和维护功能模块,并将所在的Activity/Fragment生命周期变化传递给模块。
  2. 某些模块在创建时还需要提供Activity/Fragment作为基点,这样就限制了模块使用的灵活性,在Activity本身承载的业务被分拆为不同的业务视图模块后,业务视图模块如果想要使用功能模块,必须也能提供基点,这为业务视图模块增加了额外的需求。

因此考虑将功能模块升级为功能服务来缓解上述痛点,这里对功能服务的定义是: 不需要使用者显式的去创建服务以及维护服务状态,只需要单纯的使用其提供的功能即可。就像一个静态函数一样。

上面描述的功能服务将使用功能模块的额外开销封装在自己内部,使用者的职责得到简化。借助于之前介绍的锚点系统,这个构想是在一定程度上可以实现:

  1. 基于这样一个前提: 绝大多数情况下,功能的发起都是由用户在界面上触发的,比如砍价,预约等。这意味着,在功能被触发时,其所在的Activity/Fragment就是锚点系统维护的前台PageView,那么,功能性模块所需要的基点通过查询锚点系统即可轻松获得(锚点系统是全局服务,可以在任何地方调用),功能模块创建需要的基点获取限制就不存在了。
  2. 功能模块的创建和维护封装在功能服务内部,功能模块的创建则基于这样的现状: 一个功能即使在一个页面模块(Activity/Fragment)上有多个触发点,功能模块实例也只需要一个,功能模块和页面是一对一的。不过因为功能服务会同时为复数个页面提供服务,那么,在功能服务内部就需要同样数量的功能模块,功能模块的索引正好通过页面的PageId实现。另外为了让功能模块可以感知生命周期变化,功能服务还通过锚点系统监听了所有页面的生命周期状态,在收到页面状态变化时使用页面PageId找到对应的功能模块实例,继而将变化传递给功能模块。

至此,使用的功能模块所有额外操作都被功能服务包办了。外界使用功能服务变得极其快捷,只需要获得功能服务的全局实例,调用服务接口即可。功能服务对功能模块进行了包装,功能实现的主体依然在功能模块中,生命周期等管理则放在了功能服务包装层中。

10. 全局网络响应处理机制

全局网络响应处理机制的其实是网络层Interceptor中的一个Processor,在这里专门列出来是因为它是框架之间良性协作的成果,两套单独的机制基于不同的目的被开发出来,两两之间产生规模化效应,衍生出新的机制或者演化方向。全局网络响应处理机制是网络概念层和锚点系统配合的一个案例。

全局网络响应处理机制现在只内置了一个功能: 全局的验证码弹层。验证码弹层是人人车App的基础功能,所有的线索提交均有可能收到特定的回复,要求输入验证码后再重新提交,并且支持验证码的刷新(也需要重新发送请求)和验证错误提示。弹层使用Android的Dialog实现。

验证码弹层的特殊之处在于: 用户提交请求后得到服务器回复要求输入验证码,是一个异步网络过程,但是Dialog需要Activity作基点。 在最初的实现里,需要在回调对象中保存Activity的弱引用,回调对象中还包含了对验证码各种分支逻辑的处理,这种方式有几个不足之处:

  1. 弱引用不够优雅以及限制了使用场景(你需要一个能获得Activity的场景)。
  2. 在每个可能触发验证码的请求发起点,都要显式的使用这个验证码处理回调类,遇到一些对响应有特殊处理的情况,还必须继承这个回调类来保证验证码和特殊处理逻辑的兼顾。

上述缺陷从职责层面上讲,是提交请求者承担了验证码的处理职责。但是验证码机制和具体的请求响应处理之间是不应该有什么关联的,验证码是请求响应的一个全局前置步骤,在下游具体的请求响应处理不应该感知到验证码。

理想的验证码处理应该为下游的请求回复处理屏蔽掉验证码的存在,下游可以认为这是一个不需要验证码的世界。要实现这个效果,必然要对所有网络回复进行前置拦截,网络层 Interceptor正好提供这样一个切面,验证码机制可以作为其中的一个Processor存在。这样验证码机制在架构中的位置和层级就确定了。

如前所述,验证码机制一旦发现验证码相关回复就予以拦截,根据回复内容展示或者刷新验证码弹层,这就回到上面描述的基点获取问题,验证码弹层展现需要的Activity怎么获得? 验证码机制在Interceptor这一层只能看到网络请求和网络回复,得不到发起请求时的Activity。 简单的想,就会试图把Activity保存在网络请求对像中,这是一个糟糕的做法,导致内存泄露,也破坏了网络请求对象的结构,在一个网络请求对象中维护一个Activity是比较违和的,两者在层次上不匹配。我们需要一种更优雅轻量的方式来让验证码机制可以获取Activity。使用锚点系统提供提供的PageId就是个不错的轻量级方案,PageId保存在请求对象中并不显得突兀,因为它和请求对象处于同一个抽象层次。

验证码机制要满足同时处理复数个网络请求的场景,而这复数个网络请求又可能来自不同的页面,所以验证码的处理要先以页面为维度进行区分,再以网络请求为维度进行细分(得益于网络概念层对网络请求的封装,区分网络请求可以使用Custom Request的RequestId),网络回复会被验证码机制分发到对应的发起页面的网络回复处理接口中(借助于网络请求携带的PageId和锚点系统,我们可以得到合适的页面实体,当然了,对于对应页面已经被销毁的网络请求,就没有进一步处理的必要,直接放行任其消逝即可)。

页面随后承担起验证码处理的后续逻辑,因为每个请求都需要自己的验证码弹层,因此需要为每个请求单独维护一个验证码弹层的Handler,Handler承接了验证码弹层的显示和验证重发(验证重发得益于对网络请求的建模,非常简单,并且重发不会改变RequestId,这样使得重发后的回复可以继续被相应的处理器处理)等逻辑, 可以使用请求的RequestId作为key组织Handler映射表来管理复数个请求的Handler。请求成功或者失败都代表着请求的终结,此时可以释放其Handler。

只有非验证码相关回复才能被验证码机制放行,使得验证码机制对下游的回复处理逻辑是透明的。这样,借助于复数个已有机制的特性和少许的扩展,我们将原来的分散零碎的验证码处理逻辑聚合上升成为一个独立透明的中间层。

第二阶段演进总结

  1. 锚点系统提供了轻量页面索引和页面全局信息查询功能,解放了一部分原先显式依赖Activity/Fragment的实现,也为某些依赖接页面状态的需求提供了更快捷的功能接口。
  2. 网络概念层的建立将项目代码和第三方网络库解耦,所有的上层机制都基于项目本身对网络的概括理解来实现,同时还获得了对网络请求全局信息的掌控。
  3. 功能服务进一步分化了职责,功能使用开销得到进一步压缩。
  4. 全局网络响应处理机制将散落在各处的验证码处理逻辑集中为一个对下游透明的中间层。

总结: 第二阶段的重构偏向于框架化和服务化,通过引入全局性的机制在新的维度实现需求和改良架构,机制之间的良性协作效应开始显现。

结语

人人车Android客户端开发周期已近两年,迭代30多个版本,历经数次规模不等的重构,筚路蓝缕,终有小成。笔者有幸参与并主导了App的从萌芽到成长。尽管受限于技术水平和经验视野,我们的架构演进并没有实现最优解。但于我,这是一次伟大的朝圣之旅。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注