作者:蒋文豪(四点)
在客户端的设计中,一般的分层会至少包含下层的数据服务层和上层的UI层,下层的数据模型主要由所在领域决定,相对独立、稳定,而UI则更多变,且会对多种数据进行组合。由于UI的相对多变性与模型的相对稳定性,在数据层和UI之间,就需要对数据进行若干的处理才能交给UI展示。 比较简单的情况比如将原始数据的时间戳转换为 PD 要求的字符串,如果涉及到对不同数据进行关联、分页加载、变更计算,这部分数据处理逻辑就会比较复杂。
消息作为富客户端,这部分逻辑非常复杂,加上状态的存在,可以说是消息客户端中最复杂的逻辑之一,这种复杂主要体现在这些维度:
这块逻辑在消息客户端涉及会话、消息、profile、群、群成员、关系等所有核心服务数据模型,总计大约25000行代码,占消息总代码的8%左右,是核心的数据处理。由于这些逻辑很容易耦合在一起,形成一些高维的逻辑,表现为大量的条件分支和递归嵌套,这种高维的逻辑很难写,也很难维护,并且占据了不少包大小,因此有必要对这些逻辑抽象和简化。
通常会将客户端划分为数据服务层、逻辑层、UI层三层,这部分数据获取和计算会被归到逻辑层。这里的问题在于,数据服务层对应于领域定义,UI层对应于渲染、动画和交互事件处理,这样逻辑层很容易变成一个缝合怪,数据请求、数据转换、上下文维护、异步处理、递归逻辑、状态管理、变更同步,所有不属于另外两层的部分都会被扔到逻辑层,导致逻辑层的臃肿。
下图左侧是这个处理过程的工作内容和上下游,右侧为数据拉取和变更处理的数据流向和计算过程:
可以看到,将这部分数据处理仅仅定义为逻辑是过于宽泛的,不利于针对性的优化,因此有必要进行深入的分析和研究。
在对会话、消息、profile、群、群成员、关系6大核心数据处理链路进行归纳、分解、分析和综合之后,我们可以将数据处理过程简化为如下的过程:
我们可以将这些逻辑分为两类:
对应于上面的过程2、3、4、5、6。
如果我们将这块逻辑看做黑盒,关心它的输入输出和功能,可以得出这块逻辑的核心工作是将各种各样的输入数据转换为特定的输出数据,这完美的对应着计算的概念的结论,即:
基于计算的概念,可以将这段计算过程形式化地抽象为一个函数f,从而实现对状态计算的抽象,上图很直观的体现了入参为输入和当前状态,输出为新的状态和结果:
f :: (Input, State1) -> (State2, Result)
来分析一下函数f的入出参和形式:
第一,这里的Input可以能是拉取回来的数据,也可以是增删改等数据变更,或者是消息已读等明确的事件。这里我们可以通过定义插入、更新、删除三个来统一所有的事件,因为所有的事件逻辑上都必定可以唯一的映射到这三个事件上(尽管实际上,由于部分服务不具备计算变更细节的能力,我们还支持了RemoveAll和Reload两个事件)。
第二,结合高阶函数,Input实际上已经决定了这个函数的形式,即对于一个数据插入事件,其对应的f必然为 \state -> insert someData into state 的形式,即 Input 已经包含在 f的实现中了,因此可以将函数 f 进一步简化为:
f :: (State1) -> (State2, Result)
其中 f 的形式由输出的事件决定,这样就得到了一个非常简化的函数抽象。
第三,上面的分析还能得出一个推论,即事件和函数是等价的(可以互相转换),这使得我们可以通过处理事件来实现对函数的处理,从而可以通过数据的处理来优化计算的性能,可以看到,数据和过程边界的打破赋予我们更强的能力。
第四,对于 State 参数,需要包含聚合后的数据,因此需要处理数据的关联,一般的,我们可以将数据的关联场景抽象为 一个主数据对应多个附属数据 的形式,通过定义一个 pair 函数来进行关联关系的判断:
pair :: (mainData, subData) -> Bool
这样就可以通过注入 pair 函数来实现主数据和附属数据的关联,然后将有关联关系的数据进行聚合。
第五,State还涉及数据\树形结构计算,这里在不同的场景是不一样的,可以抽象为一个 DataStructure ,定义增删改查接口,然后在不同的场景使用不同的 DataStructure。
对应于上面的过程1、6。
这部分逻辑的作用是会话消息Profile等数据的获取和变更事件监听,由于6大服务的接口各不相同,之前的实现是一一对接。通过抽象之后,我们可以通过定义具备拉取接口和变更接口的Inject来实现这部分逻辑的抽象,这属于标准操作,不再赘述。
这部分数据获取的第二个特点是请求的平行分发和垂直组合,举例来说,有多个通道决定了数据请求时需要平行的请求每个通道,每个通道的请求则根据不同的请求策略和每一步的回调数据决定下一次请求(这里与标准的 Future/Promise 的区别在于,Future/Promise前后步骤的任务是不同的,后面的逻辑需要前面的数据,这里前后步骤的逻辑相同,可能上一步请求本地,下一次请求远端,因此可以比 Future/Promise 更简化)。
如果不进行抽象,这里是一个至少三维的逻辑,即对于多个通道的多个步骤进行主数据的获取,然后对获取的主数据再获取附属数据,逻辑会写的非常复杂。这里的关键在于每个通道的请求,每个步骤的请求都是非常相似的,主要是多次请求的结构不同,并且数据请求的结构由参数和数据决定,因此可以把它称为结构化数据获取,即这里可以通过对请求结构的抽象进行简化。
可以定义出结构化数据获取任务平行和垂直组合的核心函数:
dispatch :: [param] -> [task]
compose :: strategy -> task
其中 dispatch 函数对应于Rx中的 flatMap,不过由于手淘iOS没有集成 RxSwift 和 OpenCombine,官方的 Combine 框架要iOS13之上才能使用,因此只能自己实现一个轻量的。
这样通过 dispatch 和 compose 将任务进行结构化组合实现任务获取的抽象和简化。
核心模块:
此外,Inject为计算提供请求接口和变更事件,为所有数据的注入点,上层通过 ModelService 获取计算后有聚合数据构成的数据结构,以及变更事件。
ModelService会使用初始化DataStructure、CalCulator、主数据、附属数据的Inject,并用来初始化MergeDispatcher
最终,我们实现了计算和数据获取的分离,计算过程全部在Calculator,数据获取主要在MergeDispatcher,两部分独立实现,不再耦合,将逻辑层次从原来的模型数量 * 接口数量 * 数据结构 降为 事件数量 * 数据结构,处理模型非常清晰,且适用于任意模型。
针对计算过程,逻辑上抽象出一个高阶计算函数 f :: (State1) -> (State2, Result),这个函数形式上非常简单,却紧紧抓住这种复杂状态计算的本质,让我们得以统一计算过程,整个计算过程的正确性有完备的理论基础,后续新增模型不会增加计算逻辑。
针对数据获取,我们将数据类型化为主数据和附属数据,并针对由请求的结构进行抽象,实现了所有的数据获取的统一和简化。
关注【阿里巴巴移动技术】微信公众号,每周 3 篇移动技术实践&干货给你思考!
|