一、背景
随着58同城业务的不断发展,58同城IM业务也在不断地扩充。基于58同城多业务线的特性,58同城的IM业务也呈现着多样化的趋势。各业务线不在满足IM的基本需求,而是期望扩展与IM深度结合的个性化功能。因此,58同城的IM业务主要面临着以下3点问题:
- IM业务需求的差异化日益增强。
58同城的IM业务经历了几年的发展,基础功能已经基本趋于完善。充满业务线特征的个性化功能成了各个业务线的最迫切的需求。各个业务线都期望能在IM中体现各自的特性。 - IM业务由平台维护,大量的IM需求处于串行开发的状态。
IM业务由平台部门进行维护,所有的需求都需要汇总到平台需求池中,由相应的开发人员进行开发。因此各个业务线的IM需求都处于串行开发状态。虽然平台部门投入了大量的人力和时间,但是依旧无法满足业务线的需求要求。 - 沟通成本过高。
大量的IM需求涉及到跨部门沟通与协助,这就导致IM需求的沟通成本过高,降低了需求的开发效率。
为了解决上述三个问题,58同城客户端设计和实施了IM的拆分方案。期望通过设计拆分,使业务线的开发人员能够参与到IM需求的开发中,实现并行开发,从而提升开发效率。
二、问题挑战
从可扩展性、安全性、可行性三个方面考虑,IM拆分方案的设计应满足以下3点要求: - IM拆分后,支持业务线开发维护各自的IM页面。
基于IM的特性考虑,方案应具有一定的平台约束。各个业务线的IM页面应能保证消息的无障碍展示,且不能破坏IM页面的整体结构和布局。为此,在方案具体实施前,平台部门首先对IM的现有业务进行梳理,梳理出常用功能支持业务线通过继承的方式重写方法来实现个性化需求。 -
跳转方无需做改动。
现阶段,58同城客户端的跳转时依赖跳转协议的。当开发者想跳转到某个页面时需要向跳转中心传入跳转协议。由于每个页面的跳转协议都是不同的,因此当开发者想跳转到不同业务线时,跳转协议也不同于现有的IM跳转协议。但是,由于IM的跳转场景非常多,让诸多跳转来源做跳转协议的更改显然是不合适的。因此,需要实现跳转方无需做修改的条件下进行分别跳转到不同业务线的IM页面。
3.具备降级回滚的能力。
由于IM页面庞大且复杂,承载了诸多业务,大规模的重构拆分难免会造成功能的异常。因此在一段时间内,客户端必须具备一定的降级能力。当业务线的IM聊天页面出现异常时,开发人员能够快速响应做出降级回滚处理。
三、方案设计
业务线IM页面的实现方式有多种,可以将IM页面的所有组件暴露给业务线,由业务线自由组合,自由替换。也可以将代码开放给业务线,由业务线开发者拷贝一份,在拷贝代码基础上进行业务开发。但是这两种方案都存在一个共同的问题:平台无法把控IM的整体需求。比如拍照、语音、视频等平台属性的功能可能在不同的业务线页面位置、顺序等存在差异,这显然是不能接受的。因此,采用继承的方式比较合理。平台创建通用基类,对现有的常用功能封装具有一定约束力和扩展性的接口,各个业务线IM页面需要继承自通用基类。通用基类与现有的IM页面间的关系是并列的,彼此之间并无继承关系。
58同城IM在方案设计之初即有一套成熟的业务线区分方案。任何页面在跳转到IM聊天页面时,都需要在跳转协议中携带所处的业务线场景数据。场景数据主要包括rootCateID和cateID,rootCateID标明了当前所处的业务线大类,如房产、招聘,cateID标明了当前所处业务线的细分类,如二手房、兼职。正是有了rootCateID和cateID,IM聊天才能辨别业务线进而实现个性化。经过IM业务拆分后,现有的IM页面仅作为降级备案页面。跳转中心通过跳转参数rootCateID和cateID来判断应该跳转到哪个业务线的IM页面。
IM页面的降级发生在线上业务线IM子类页面或通用基类出现紧急状况时。因此IM页面的降级需要能够通过后台服务下发配置。方案中,转换器起到了拉取配置表和转换跳转协议的作用。在跳转中心获取到跳转协议之前,转换器会根据跳转参数rootCateID和cateID与配置表匹配,识别该跳转协议是否应该跳转到特定的业务线IM页面,如果需要跳转到特定的业务线IM页面,转换器会将当前的跳转协议转换成业务线IM页面的跳转协议并传递给跳转中心。
四、IM页面组件化
- IM页面的现状
目前IM页面承担了大量的业务,逻辑复杂,各个功能模块之间耦合比较严重。如果再此基础上封装接口,会引入更复杂的逻辑,且不利于日后的功能扩展。因此在封装接口之前,需要对相关功能进行组件化。由于IM现阶段只开放某几项功能的扩展能力,现阶段只对这几项功能做组件化拆分,其他功能暂时不做组件化。全面彻底的组件化会随着业务扩展能力的增强而不断推进。 -
组件设计
结合IM页面的现状和现阶段IM拆分功能的特性,我们将组件设计成视图组件,即每个组件都包含一个与之对应的视图。设计成视图组件主要从以下3点考虑:
1)视图组件可以更灵活地支持功能整体替换。虽然目前IM的拆分是通过继承的方式实现,组件并不可见。但是从长远角度考虑,视图组件可以更好地帮助业务线开发者介入开发,而视图是业务线开发者切入业务的重要一点。如果开发者想改变组件的逻辑功能,那么将该组件整体替换即可。
2)IM功能与视图是强关联的。无论是顶部帖子、底部功能区、快捷功能还是下拉菜单,都存在相应视图。
3)现阶段的视图除了承担页面展示和交互的职责外,还承担着业务逻辑处理的功能。设计视图组件可以更方便的将视图的业务逻辑移植到组件中。
设计成视图组件后,通用基类的IM页面的结构简图如图2所示:
视图组件将原本在视图控制器VC中和视图view中的业务逻辑集中到视图组件component中。并且视图组件解除了视图view与视图控制器VC间的耦合。视图控制器无需关注组件的视图类型,VC与view间的交互通信借助于视图组件component。
视图组件与视图组件之间是平行关系,彼此之间不会相互引用和持有。这样的设计保证了组件间的独立性,避免了组件间的耦合。Context是当前IM聊天页面的上下文。Context保存了当前聊天页的会话session、状态集、组件信息等信息。Context被视图控制器VC所强持有,在组件初始化时,context会被当做参数传入组件,组件从context中获取所需要的数据。通过context的唯一性保证了处于同一页面的所有组件及视图控制器VC具有相同的会话环境。
3.组件管理
视图组件不但有修改组件功能的能力,还应具备控制组件开关的能力。当业务线不想使用某个视图组件时,可以屏蔽特定组件的展示。为了方便使用和管理,我们对组件以枚举type进行区分,每个枚举值代表一个视图组件。通用基类在初始化后会根据type值来确定是否需要创建相关组件。根据现有的业务作为判断依据,暂且可以认为大多数业务线实际上是需要所有组件展示的。因此所有的组件是默认加载的,当业务线想屏蔽其中一个或几个组件时,需要通过重写基类方法的方式,将组件的type以按位或运算的方式返回给基类。基类中会将目前的组件全集列表与返回结果做相应运算后,形成组件支持列表。当某个组件的type不在组件支持列表中时,不初始化该组件,从而实现组件加载的控制。
视图组件需要实现type方法来定义该组件的枚举值,在组件初始化时会将组件自身存入控制器的context中。在基类中,只要想获取组件,可以根据枚举来获取组件,而不需要在控制器中引入额外的属性来引用组件。 - 组件间通信
如上文所述,组件间是平行关系,这就意味着组件间不能直接进行通信。在iOS系统中,通信的方式有多种,无论是何种方式通过直接或间接的使用总能达成目的。考虑到组件间的通信可能涉及到多个组件间的相互通信,因此组件间通信的采用NSNotification的方式比较合适。但是直接使用NSNotification会存在两个问题:
1)封闭性无法保证,当两个不同会话的IM页面同时存在于进程时,消息通知无法做出通知隔离。当A聊天页面发出通知时,B页面的组件可能也会做出响应。这显然是不可接受的。
2)NSNotification的使用方式不够简洁,处理一个通知时,开发者需要注册监听、处理通知、移除监听,步骤繁琐。
因此组件间通信需要以会话为纬度,不同会话的通信不能相互影响。通信应本着简便的原则,注册即可使用,无需主动移除监听。由于每个组件都具备通信的需求,因此组件基类包含注册事件监听、移除监听、抛出监听的方法。在组件基类中,注册事件监听需要key和block,为了防止不同页面的相互干扰,在向NSNotification注册监听时,基类会将key做一层封装,提取当前页面context中session的特征数据UID作为key的前缀,从而拼接成新的key。同理,在组件抛出通知时,也需要对key进行同样封装。通过注册和抛出时对key做的处理,实现了消息通知的封闭性。通过将传入的block保存到组件中,在收到通知时根据通知的key获取对应的block,从而实现将通知代理的模式改变为block的形式,使用起来简单直观。在组件dealloc时,对组件所管理的消息key做统一的移除监听,使用者无需关注监听移除,从而简化了通知的使用步骤。
五、消息注册
IM业务需求除了实现IM页面相关功能开发外,还需要能够进行消息注册,即除了展示目前支持的消息类型(如文本消息、图片消息、语音消息等)外,还应支持业务线自定义消息,如房源卡片消息、面试邀请消息等。 - 消息注册现状
58同城的IM业务开发依赖于58集团推出IM SDK。58同城IM经过数年的迭代和发展,存在十几种消息类型。消息的完整注册流程需要以下5步:
1)申请消息key。消息key是底层IM SDK消息注册的基础,唯一的消息key能映射出唯一的消息类型。
2)向IM SDK注册消息content。创建继承自GmacsMessageContent的content类,在SDK初始化时注册该类。Content中描述了该消息的key及消息相关的数据。
3)实现content与model之间的转换。Content作为IM的消息数据不应被UI层直接使用,model的存在正是为了替代content驱动渲染UI。因此content和model之间需要做一次数据清洗,将底层IM SDK的数据转换成UI层渲染数据。
4)在消息发送时,需要针对消息类型创建IM SDK所识别的content。
5) 绘制该类型消息处于接受和发送状态时的消息Cell。
通过以上5个步骤可以得知,现有的消息注册存在以下3个问题:
1)类与类间的关系较为复杂,现有的消息注册需要关注的类有:content类、model类、接受态Cell类、发送态Cell类。这4个类和1个key通过复杂的映射关系,共同描述了消息的接收、发送、展示功能。类和类之间的映射关于过于复杂,不利于理解与开发。
2)在绝大多数情况下,消息接收和展示的气泡部分展示是相同的。但是Cell类没有对相同视图做提取,导致接收和发送Cell都需要绘制气泡,绘制相近的视图。
3)消息注册流程繁琐,开发者除了需要关注UI的绘制外,还需要关注消息的注册时机和content和model之间的数据转换。 - 消息构建者
为了解决自定义消息的类关系复杂的问题,我们引入了消息构建者。通过消息构建者,将原本消息注册时存在的N对N的关系转化为1对N的关系。类关系从N对N收敛为1对N除了使类关系变得更加清晰外,还能通过继承的方式将这种逻辑关系沉降到基类中,从而实现子类层面逻辑关系无感知。业务线继承基类后,只需类自身的数据和逻辑即可。举例来说,在没有引入消息构建者时,model类在tableView渲染时需要向tableView提供其所对应的Cell类名,在model初始化时,需要将消息体content中的数据转换为model所需要的数据。在引入消息构建者后,子类只需要计算Cell高度即可,无需关注其他逻辑。
消息构建者的实际上是一个继承自NSObject的对象,该对象中的5个属性分别是:展示接收Cell的类——recvCellClass,展示发送Cell的类——sendCellClass,model类——modelClass,消息体类——msgClass,消息展示的复用View——view。这些类暂且统称为消息物料类。构建者对象的创建时机是在该构建者的+load方法调用时创建。在+load方法中,类会生成一个自身的对象,并将该对象存入到消息管理器中。通过消息key能够从消息管理器中映射出唯一的消息构建者。在所有消息物料基类中,都需要引用消息构建者,那么依旧以上文model类作为示例,此时model返回其所对应的Cell类名则变成了返回其构建者的recvCellClass和sendCellClass。
通过对过去一年的需求总结得知,58同城中的大多数消息在接收和发送的展示上基本一致。不同之处在于消息气泡的方向和头像的方向。因此消息构建者中只存在一个view,消息接收和发送的展示上都展示该view。那么view是如何与Cell相关联的呢?recvCellClass和sendCellClass在initWithStyle:reuseIdentifier:时会根据reuseIdentifier的规则获取view的类名。因此reuseIdentifier需要包含View类名、Cell类名以及防重标识。通过制定reuseIdentifier的规则,实现了view与Cell的绑定,在初始化Cell时即可获取到view。并且通过Cell——View字符串拼接方式,规避了Cell重用带来的错乱问题。 - 消息管理
消息构建者解决了消息构建时类和类之间的关系复杂的问题,简化了消息构建的逻辑,并且实现了相近视图的复用,但是消息的注册流程和消息发送并没有简化。如果实现业务线的消息注册并行开发,那么多个团队需要共同修改一处代码,这将大大代码冲突的概率,且不符合团队代码权限管理规范。消息管理器正是为了解决以上这些问题而生。
消息管理器管理了所有的消息构建者,在IMSDK初始化时消息管理器会获取所有的消息构建者。将所有的消息content注册到IMSDK中。因此业务线开发者无需关注消息注册流程,消息在+load方法中构建后,会立即放入消息管理器中,由消息管理器自动完成注册。
在消息发送时,没有消息管理器的情况下,开发人员需要根据消息类型手动创建相应的content,这就需要大量的if-else代码支持。有了消息管理器之后,在消息发送时,可以根据消息类型key获取到消息构建者,从而获取到该消息的 content类。因此消息发送完全可以通过消息管理器去除if-else,业务线开发人员不需要再关心消息发送这个步骤,只需要传入消息的key和消息内容即可完成消息发送。 - 消息降级
转换器的设计和实现能够保证当IM拆分方案上线后,如果出现严重问题能够及时回滚到原有的IM页面,无论是组件还是业务流程出了问题,原有的IM页面都能进行兜底。但是在IM业务中,消息流是以用户为纬度的,与页面本身无关。不论是在原有的IM页面还是拆分后的业务线IM页面,消息的总数和内容都是一致的。因此如果降级后,原有的IM页面需要展示业务线拆分后注册的消息,那么可能会存在以下两个风险:
1)如果是由于消息本身导致的降级,那么显然是降级失败。如果业务线注册的消息存在视图渲染崩溃,那么降级后,在原有的IM页面中依然会崩溃。
2)消息一旦展示后,消息与IM页面间的交互可能会造成新的崩溃。这是由于业务线注册的消息是基于通用落地页开发的,通用落地页与原IM页面存在一定的差异性。因此,载体页一旦发生变化,那么新注册的消息可能会无法进行正常交互。
问题的解决方式很简单,在原有的IM页面的tableView:cellForRowAtIndexPath:方法中判断出特定的model类型后,直接展示“暂不支持该消息类型”即可。虽然解决方式简单,但是从方案的完备性和安全性考虑,支持消息降级是必要的。 - 消息与载体页
经过设计拆分后,形成了多IM子类页面(我们暂且称之为IM载体页)共同展示IM消息的现象。
IM消息包括UI展示,因此存在消息交互与IM载体页通信的问题。例如,房产需要自定义一套消息,并且消息与IM载体页面存在交互,如果在IM消息的view中,动态调用了IM载体页的方法,那么就可能会造成相应的崩溃。因为IM消息可以漫游到所有的IM载体页中,在IM消息中通过响应链获取到的IM载体页可能并不是房产的IM载体页,该载体页可能并不存在被调用的方法,因此不能通过动态的方式获取IM载体页。经验告诉我们,IM 消息的与IM载体页的通信主要在于IM消息的点击事件上,也就是用户当点击IM消息时,需要调用载体页的相应方法。因此,为了满足IM消息与IM载体页通信需求,我们在IM载体页基类中实现了消息点击方法,当点击IM消息时,当前的载体页会回调此方法,开发者可以通过回调参数的model来确定其具体函数实现。
六、总结
经过以设计拆分后,本方案达到了以下3点目的: - IM页面可以在一定程度下扩展,满足了业务线日益增强的个性化需求。在保证个性化的前提同时,也保证了平台的统一性和可控性。用户进到不同业务线的IM页面即能感受到该业务线的特征,又不会感受到混乱无序。
- 平台不在对接具体的业务需求,降低了跨部门沟通的频次,提高了沟通效率。
- IM需求支持多业务线并行开发。将各个业务线需求从串行开发变为各业务线并行开发,提高了开发效率。