iOS聊天业务程序设计文档

 

前言

胜者先胜而求战,败者先战而求胜

大部分业务开发人员在开发的时候,经常没有通盘的思考结构设计和方案,或者因为经验不足而思考不全,一句话:先搞,遇到问题再说。这样对于业务简单的需求来说还好,但是对于复杂的业务模块就是灾难了,因为它从一出生就是修修补补出来的,扩展性、可维护性、稳定性都是很大的问题。随着业务的发展和业务场景的多变,会变成一颗随时爆炸的雷。

意义

  1. 奠定业务开发人员在模块设计方面的积累
  2. 应用到各个业务开发中,促进整个项目的设计规范统一,进一步提高互备的效率和质量

目标

  1. 大家对聊天业务模块的熟悉
  2. 大家对业务模块的设计有自己的思考和积累
  3. 未来要沉淀出公司特色的业务设计结构

发现问题

随着聊天业务的不断扩展,旧的chat相关业务程序设计已经难以支撑,问题主要有以下几点:

  1. 扩展性不足:旧聊天业务是传统的MVC结构,每次添加新业务都得将所需的所有代码写到主控制器中。随着业务的增加,私聊、群聊的控制器代码都已经超过了2000行甚至3000行。添加新功能新业务步履
  2. 代码复用率太低:旧的聊天业务并没有做复用优化,因此造成每添加一个业务场景就得copy一份控制器相关代码,并且由于不同的业务场景,视图都会有一些微妙的变化,导致view的复用率也很低
  3. 可调试性差:由于业务数据处理和业务视图处理完全耦合在一起,不同业务之间也没做隔离,很容易出现干扰。导致牵一发而动全身,想要调试小游戏对战的视图变化,改动却对2v2游戏造成了影响之类的问题屡见不鲜
  4. 缺少规范、难以维护:由于历史原因,聊天业务接手过的同事很多,每个人都有自己的代码风格和习惯,再加上添加的由其他业务影响过来的改动也很多,导致不同地方的代码风格迥异,代码阅读起来也比较困难,严重阻碍新同事上手速度

就以上几点问题,我们决定将聊天相关业务重构,重新设计程序架构以符合目前和未来一段时间的业务需求,增强扩展性和可维护性的同事、提高代码复用率和可调式能力。

这本文档不仅仅是描述代码方面改动,同时也作为其他类似业务的一种开发规范和设计模式的模版。建议大家充分了解,并将其中包含的思路合理运用到所负责的现有业务代码中。

架构设计概述

业务架构设计的方法论有很多,但在设计业务架构之前,首先得充分分析该业务的实际情况来选择合适的方式。

根据我对聊天相关业务的了解,最大的痛点还是在于包含的业务多且杂,部分场景还需要高性能优化导致代码可阅读性会变得很差。因此在做设计之前,需要先要分析不同子业务之间的关系和数据的流向。

整个聊天业务可以从场景和子业务两个维度去做划分,形成表格的话大概如下所示:

  私聊 群聊 临时聊天 游戏内气泡
文本 Y Y Y Y
图片 Y Y Y N
语音 Y Y Y N
弱提示 Y Y Y N

上表格所列业务可以大概分为两类:聊天消息类型和其他附加功能类型。

其中聊天消息类型主要跟当前业务场景所属的service强关联,不同场景下的消息结构基本无变化,并且交互的扩展很少,基本只有点击跳转等单向功能;而附加功能则是根据产品需求的推进而添加的,不同版本的附加功能可能会适配不同的业务场景,交互处理也相对复杂。

因此聊天消息的数据处理应当做到可以对数据进行通用预处理,在最终渲染时直接将预处理过的数据展示出来即可;附加功能则由于不同的需求差异巨大,从功能的角度上不适合进行抽象,因此对这类功能仅做简单的加载包装,并提供一个session下的模块功能对象获取方法即可。

这样我们就将一个聊天业务分离成了两大部分,就变成了如下图所示的结构:

alt

其中消息模块基于视图展示的性能优化需求,需要在获取到数据后将其转为可以提供的UI层展示的数据结构、并同时需要将数据缓存到本地,消息模块也有大量的点击事件需要进行处理。

自定义模块则需要提供展示的初始位置,可以随时被同session内的其他模块获取到的调用方式以及通用的调用回调用于做通用处理。

业务架构设计

上一节我们已经分析了业务构成,理清了不同子业务之间的数据和调用走向,接下来就是对整体的开发架构做设计了。

对于iOS的架构设计,我们同样可以从多个方面去做考虑。一般来说分层设计可以解决绝大部分问题,对于聊天模块也不例外,因此我们将聊天模块划分为三层,分别是:UI表现层、业务聚合层(业务逻辑+领域模型)、数据层(DAO+网络)这三层。分层设计图如下:

alt

理论上所有的分层设计都要遵循以下几点:

  1. 每层负责的职责边界需要明确,避免出现超级层导致分层职责不清
  2. 上层总是依赖下层、依赖关系不跨层
  3. 除UI交互层之外,其他层级之间不允许互相调用
  4. 业务架构设计需以领域模型为核心,根据领域模型构建DAO层

从UI的角度来说,消息模块所需内容基本是固定的,可以分为导航视图、表单视图、输入框视图三部分组成。因此我们选择将这三部分在聊天视图中固化,并添加参数配置使其可以在不同的业务场景选择是否展示或者展示不同样式。自定义模块可以通过指定默认布局的方式自动添加到聊天的主视图中,因此业务架构大致如下:

alt

这里先我们抛开Model不谈,只考虑与视图相关的内容。如图所见,实线代表持有实例、虚线代表持有通用对象(接口)。这里的控制器持有多个ViewModel,每个ViewModel内部绑定一个指定的View,View接受ViewModel的配置注入,渲染展示具体UI。如果View产生了事件,则通知给viewModel,viewModel之间如果有交叉调用,则向上传递给控制器,控制器再将消息分发给可以响应的viewModel做处理。这样我们就将UI相关的业务架构划分清楚了。

如果业务结构相对简单,Controller这里由ViewController来管理完全没有问题,但是如果业务处理一旦复杂,就不适用了。iOS中的ViewController不光要做数据处理,还要做视图管理,为了将业务数据处理从ViewController中独立出来,使ViewController只单纯的负责视图,我们需要修改一下代码结构:

alt

这里我使用了一个叫做DataCenter的实例来分担ViewController的具体业务功能,并利用DataCenter将UI与具体业务做隔离,ViewController内不涉及除UI处理(主要是生命周期管理)外的所有功能,具体业务功能完全由DataCenter承载,而这样的DataCenter就取代了ViewController,成为业务聚合实例。

目前来看Chat相关的架构设计已经基本完成了,但是目前这种结构,ChatVC与DataCenter是强绑定关系,这样的结构在单业务多场景下要实现不同功能的组合,只能使用子类化或者传配置做策略的方式来做。

这两种方式各有利弊,子类化的调用参数被子类固定,调用时很简单,但是实现起来相对麻烦,尤其是ViewController,因为系统方法太多,可能会无意中调用不需要的父类方法;传配置的话需要做配置解析,最终变成大量的switch-case或者if-else的条件判断,好处则是功能接口清晰,按需创建。

在Chat这个业务场景中,由于DataCenter与ViewController已经在功能上做了隔离,并且DataCenter是自定义实例,容易控制影响范围,因此我选择了DataCenter使用子类化的方式做策略实现,而ViewController则使用传配置参数的方式来创建,配置参数由DataCenter来提供。也就是说,DataCenter不仅负责交叉调用的事件处理和消息转发、还要负责ViewController的各种配置参数的存储。这样,每一个DataCenter的类型就可以代表一种业务场景。不同的业务场景只需要配置不同的DataCenter即可。

与此同时,为了解除DataCenter子类类型与ViewController之间的强绑定关系,我们需要有通过一个中间件将他们的直接耦合拆开,并且对于同时有多个聊天会话场景的状态下,需要有办法管理到所有正在运行的DataCenter,因此在上面的业务架构基础上,我们额外再加一层Manager层。这样我们的Chat业务架构就变成了如下所示的样子:

alt

我们将上面的View-ViewModel-Controller三层全部囊括进来,抽象成一次聊天会话(session)。每个session代表了一个业务场景的实例,然后使用manager,中心化的管理所有的session。并且sessionManager也作为session的工厂,提供ViewController和DataCenter的创建及绑定工作。这样就解决了之前的问题。

根据我们之前定义的三层设计,View组成了UI表现层,ViewModel和Controller组成了业务聚合层,那数据层该如何设计呢?

这里就体现出了聊天业务相对特殊的地方:聊天业务有多个数据源提供数据。对于iOS来说,数据的来源主要就是本地持久化数据和远程服务提供的数据两类,而在聊天业务里这俩都用上了。这种多个数据来源的处理方式,最方便的实施方案之一就是服务化(service)。

所谓的服务化大体上就是不同的功能模块提供标准的接口、使用通用的数据模型、粗颗粒度的进行功能划分、并且随时可用。放在我们这里,就是将mqtt的消息体和本地数据库提供的数据进行数据建模上的统一,合并接口屏蔽实现细节,以service的方式进行生命周期的管理,并抛出来做到随时可用。对于API请求,由于本身离散型设计的特点,使其已经有了一个标准化的调用方式并随时可用,这里也不需要再进行更细颗粒度的拆分或者合并,ViewModel根据需要直接调用即可。

同时数据层的服务化意味着数据层的内容并不会强关联到某一个具体业务上,因此我们在数据层的设计上使用了Pub/Sub的模式去做,生命周期也不会跟随当前session创建销毁而变化。

alt

不过为了做到ViewModel对具体的service的隔离,我们将ViewModel获取chat service的方式做了接口限制。因为不同的业务场景会使用不同的chat service,而在ViewModel上我们并不希望它去感知具体的场景,而是做更通用的处理,因此我们做了如图所示的处理:

alt

值得注意的是,这里的DataService就是传统意义上Model层的概念,而用于承载数据的Model,则在不同模块上有着不同的表现方式。我们在数据模型的设计上尽可能的使用充血模型去做,并让DataService直接抛出充血模型来提高编码效率,这里我们到下面的实现细节再讨论。

实现细节

上面我们从架构的角度考虑了整个程序应该如何设计,接下来要解决的就是具体的实施方案了。

实施方案上我建议尽量使用iOS和OC自身的语言特性去解决问题,除非万不得已的情况,减少hook方法的使用。对于性能不敏感的场景,可以使用反射来做一定程度上的代码复用;对于性能敏感的场景,可以进行针对性的优化,使用更低层的api和数据结构。接下来我们将从不同的模块去描述Chat的实施方案。

UI层的复用

UI层是和业务关联最紧密的地方,同时也是代码最难复用的地方之一。主要原因在于UI上的变化很多,且产品需求不可预测。那如何做到UI的复用呢?关键点在于两个:1. 只做结构复用,不做展示细节复用,避免在view内部做分支预测、2. 状态更新使用尽量使用系统接口,或者使用协议约束接口。

UI的结构复用

结构复用指的是根据实际需求,对view进行适当的颗粒度拆分。以聊天的cell为例,群聊、私聊、临时聊天的cell样式各不相同,但是cell里面的具体消息的展现是一样的,因此我们就可以在这个地方细化颗粒度,将不同cell的消息view取出来作为不同类型cell共享的资源。

这里需要注意的是,做视图的颗粒度细化工作时一定要避免在view内使用条件去进行渲染布局,除非你能保证这里的代码很长一段时间内不会被修改,否则将会造成维护上的灾难。视图内最好不要保存任何状态,只做单纯的渲染工作即可,它的状态由viewModel去接管控制。

在cell中进行内部消息view的布局
消息view使用不同类型对自身进行渲染和布局

UI的状态更新

那如何抛出状态呢?在RAC中有个办法,就是添加视图状态的观察者。与此类似,我们也可以将视图的状态变化以系统方法或者自定义协议的方式抛给viewModel,再由viewModel做相应处理。在tableViewModel中,我就使用了代理方法的方式抛出视图状态和对应的事件,并使用宏来简化代理方法的构建和调用方式。

view事件的代理和快速构造宏
点击事件的上抛和处理方法

UI的自约束

对于自定义模块,除了以上两点之外,还有一个地方需要注意。自定义视图根据所属业务情况的不同,有时候是需要赋予初始位置的。而我们的自定义模块的加载却是使用遍历类型的方式,通过通用接口自动创建的,这就意味着我们无法为某个自定义模块指定独特的加载方式和赋予默认坐标。

因此对于自定义模块的自约束就成了最容易实现的解决方案。通常情况下,我们的约束设置都是当前视图对各个子视图的布局。在自定义模块中我们使用了自身约束相对父视图的方式进行自我布局。也就是说当这个自定义模块被添加到chatMainView的时候,就自动根据自身的约束设置找到坐标并自行layout。

自定义模块的默认布局block

需要注意的是,由于masonry在进行约束设置时,要求两方item不可以为nil,否则会进断言。因此要注意视图的初始化顺序和布局的调用顺序

遍历初始化的时候需要先addSubview再调用layoutBlock,如果layoutBlock不存在,则说明该模块不需要进行初始化布局

ChatTableViewModel的核心–自定义消息队列

对于tableViewModel来说,作为数据源、代理、事件处理的集中点,已经承载了很大一部分业务功能。但即使如此,对于table相关业务来说,还是欠缺了很大一部分功能:聊天的业务场景中,消息并不是孤立的存在,而是一个有序集合。table的业务本质上则是对这个消息集合的操作。而集合操作则是有着特定共性的,无非就是CURD和排序处理。为了避免集合操作代码散落在各个地方,并提高集合操作的性能,我们选择了对消息集合的针对性优化————自定义消息队列。

在自定义集合类型的时候,需要先确定具体需求。在这里我们的消息队列需要满足以下几点:

  1. 消息队列显然是需要有顺序的,并且排序依据是根据消息体内的时间来的;
  2. 消息队列需要有根据sid进行去重的能力;
  3. 消息队列需要可以设置最大上限,并且以队列的形式FIFO;
  4. 消息队列需要方便的通过顺序下标、sid、头尾对象的方式获取到指定消息
  5. 消息队列需要方便的遍历方法
  6. 为了后面的mqtt消息子线程转发的需求,需要保证线程安全
  7. 消息队列的元素变化,需要有主动回调进行状态更新的通知

因此根据以上几点条件,我设计出了基于multikeyed-map结构的WBChatMessageQueue,其中multikeyed的部分可以称之为索引、value部分则是消息体。这个集合类型的实现方式如下:

  1. 主体结构使用了HashMap,为了提高存取性能,使用了CFMutableDictionaryRef作为承载实现。其中sid作为key,消息体作为value。
  2. 由于HashMap是无序的,因此对索引使用了有序的CFMutableArrayRef作为承载实现,索引的元素为包含sid和time的结构体。每次容器的元素内容发生变化时,对索引Array的元素time进行排序,通过排序后的sid找到关联的HashMap中的value使消息体有序。
  3. 通过限制添加方法的方式对容器进行主动trim,由于索引一直有序,因此可以很方便的做FIFO。
  4. 由于使用了array+dict关联的方式组成的multikeyed-map,因此通过下标、sid、time都可以查询到指定的消息体,如果以后需要添加更多的直接查询的条件,直接扩展array的元素结构体的属性即可。
  5. 在遍历时,如果使用了OC容器常用的枚举遍历器或者快速遍历器,则将CoreFoundation类型通过toll-bridge转为OC类型。如果是内部的遍历,为了性能考虑,使用自定义的函数指针进行遍历。
  6. 使用pthread_mutex_t对容器的增删方法做线程保护,对于批量修改的方法,使用pthread_mutex_trylock进行非阻塞互斥保护,由于互斥锁本质上属于sleep-waiting类型的锁保护,因此添加了10ms的超时保护,超过10ms就强行usleep,避免锁死。
  7. 由于所有的CURD方法都是自行实现的接口,因此容器的状态变化可以很轻松的进行回调转发。因此完全可以将tableView的reloadData方法等依赖容器状态变化的操作统一放在回调用进行处理。

这里就不贴代码了,具体内容看WBChatMessageQueue即可。