目前探探 IM 聊天消息列表由于长年累月的代码堆积,对业务迭代产生了很多的困扰。所以趁着工作中的一些空隙,对聊天页消息卡片做了插件化,使得不同的消息类型,可以根据具体需求方便的增删迭代。下面分享一下自己重构过程中一些有趣的想法。虽然目前是在聊天消息列表中进行实践的,但对于各种复杂 Feed 流业务也有一定的借鉴意义。
此次插件化改造使用了 IGListKit,这是一个优秀的数据驱动 UICollectionView 展示的框架,有不少思路也是从这个框架里的一些设计中得到了灵感,有兴趣可以找一些文章看看。当然 IGListKit 也不是必要的,是否使用它也不影响我们的设计。
聊天消息列表可以泛化为一个有较多不同复杂业务逻辑卡片的 Feed 列表,不过聊天消息列表还是有不少独特的内容,比如涉及数据库存储读取,消息收发,实时更新等等,不过在本文中我们暂时忽略这些内容。这里先立个 flag,有时间另外写一篇文章,来单独介绍 IM 消息数据的管理以及如何驱动消息列表展示的。希望这篇文章能够对大家日常开发中有一定的帮助,不足之处,敬请指正。
不少项目中,或多或少都存在一些插件化的设计,但是有不少插件化设计并不是太好,大概的原因是对于插件本质特征理解有偏差。比如在探探原有的「插件(Plugin)」定义就容易造成理解上的混淆,比如有些插件被移除,导致整个聊天系统不工作,这种东西就不应该定义为插件。
那么什么应该是插件呢,我想插件一定要符合这些特征:
下面简要描述一下当前的应用场景,目前探探 IM 聊天消息列表中流通着约 120+ 种消息,很多消息除了展示以外还有特殊逻辑,下面简单描述一下
下面我们看一下如何设计架构,来优雅的支持这些需求
MessageCardViewModel 是负责携带消息卡渲染信息、以及逻辑处理工具的模型,消息 UI 数据源以及逻辑处理均通过 ViewModel 信息以及附属的工具。主要承载通过 message 解析完成后的静态内容;还会绑定一些具有逻辑处理能力的工具以及业务配置信息,各种不同消息可以通过 BaseMessageCardViewModel 来实现多态。
下面我们介绍一下 ViewModel 里都包含哪些内容,都涉及哪些类,这些类都是做什么的:
需要注意一下,为防止 ViewModel 在数据流传递的过程中被意外修改,对外所有属性都是 readonly 的,来保证数据流的数据生产到消费是单向的,下面是 ViewModel 所包含的具体内容
一、message 解析内容
message 解析内容是通过对消息原始数据,解析为消息卡片直接使用的数据,BaseMessageCardViewModel 包含 message 的原始数据,以及通过 message 解析而来可以直接用于兜底展示的属性,比如 displayedText 等。
其他消息可以通过继承 BaseMessageCardViewModel 来组装自己所需要的属性,比如 VideoMessageCardViewModel 可以通过继承,增加 videoInfo 属性,该属性也是通过 message 原始数据解析而来,对应的 UI 可以通过 videoInfo 等信息来渲染。
二、配置信息
通过列表的代理传入,通过 MessageCardViewModelFactory 绑定到具体的 ViewModel 中,配置信息包含主题配置、样式配置等。
主题配置,包括各 UI 元素的颜色、字体、背景等等
样式配置,包括是否展示昵称、备注、是否需要模糊头像、自定义的头像 url 等等
比如这种文本的样式,就需要代理根据具体的产品逻辑,对不同的消息返回不同的气泡背景颜色,然后绑定到 ViewModel 中,消息卡片 UI 直接使用 ViewModel 中主题信息对应的背景颜色字段来渲染
三、卡片 UI 组件信息
卡片 UI 组件是指可以独立通过 MessageCardViewModel 模型来渲染的 UI 组件,每个 UI 组件信息初始化方法以及内容更新方法一致,每一个独立的 UI 组件都可以灵活的复用。也就是说拿到类名以及对应的 ViewModel 就可以渲染,以及内部交互手势。通常一个消息卡片是一个容器,内部包含多个卡片 UI 组件,所以卡片 UI 组件信息主要包含一个 UI 组件的 Class 数组。
该部分内容是外部根据消息类型进行注册的,然后通过 MessageCardViewModelFactory 绑定到具体的 ViewModel。
下面举个例子说明一下:
每个暗黄色框起来的元素都是一个独立的卡片 UI 组件,其中每个红色框起来的元素是一个消息卡片。
上面这条消息引用了一条动态,会包含了一个动态 UI 组件以及一个普通文本消息 UI 组件;
下面这条消息就引用了一个话题,会包含一个话题 UI 组件以及一个普通文本消息组件。
业务接入方,就可以根据对应的消息类型,注册对应的卡片 UI 组件
MessageCardPlugin 后面会单独详细介绍,和卡片 UI 组件信息类似,也是是通过业务接入方,根据消息类型进行注册的,然后通过 MessageCardViewModelFactory 绑定到具体的 ViewModel 上。消息 Cell(卡片 UI 组件)可以直接从 ViewModel 拿到 MessageCardPlugin,直接进行业务数据之间的交互,从而避免通过业务 delegate 传给 cellDelegate 再传给对应 ViewDelegate 等等多层传递,每次更改接口就改好多层的情况。
MessageCardPlugin 负责为消息卡片提供业务相关数据源,同时可以通过初始化传入的上下文工具,来更新全部或者局部的 UI。消息卡片内部的每个卡片 UI 组件,都可以通过 ViewModel 获取到对应的 MessageCardPlugin。
同一聊天会话中,相同业务的消息,可以注册同一个 MessageCardPlugin,来使多个消息卡片共同持有同一个 MessageCardPlugin 实例,从而拥有共同的业务数据源。
由于同一条消息不太可能同时属于两个业务,为防止开发风险,每种消息类型只可绑定一个 Plugin,来隔离各个业务。如果确实存在横跨多个业务的情况,Plugin 内部可以采用伪单例或者其他共享内存的方式来实现,避免对消息列表架构造成破坏。
贴一张图看一下 ViewModel 是怎么生成的,其中,message 解析对应具体的 ViewModel 类、Plugin 以及 UI 组件信息都是业务接入方注册的。
我们回到 #应用场景 小节提到的几个问题,看如何通过 MessageCardPlugin 来优雅的解决:
点击跳转到特殊页面、存在特殊功能按钮 本质属于同一种问题,可以给消息对应的 Plugin 暴露业务跳转或者点赞等功能逻辑接口,消息 Cell 可以通过 ViewModel 拿到 Plugin,来调用对应的业务功能。
同步展示业务数据:这个也比较简单,消息 Cell 通过 ViewModel 拿到 Plugin,Plugin 内部可以和具体业务进行交互,根据消息体引用的内容 ID 字段直接获取缓存信息,或者异步获取再刷新均可;
多消息之间的联动:Plugin 内部监听数据变更,刷新对应的消息卡片。也可以暴露回调接口,对应的消息卡片监听回调接口,每次内容变更都通知对应的消息卡片更新 UI 就实现了多消息之间的联动等等。
下面来段伪代码示例如何处理上面的问题的
// 这个是 Cell
class MomentPreviewMessageCell: BaseMessageCell {
private var plugin: MomentMessageCardPlugin? {
return viewModel.messageCardPlugin as? MomentMessageCardPlugin
}
... 省略代码
// 针对问题 1,处理动态点赞事件(处理特殊事件)
private func handleTappedLikeButton() {
plugin.performLikeAction(viewModel.momentID)
}
override func render(_ viewModel: BaseMessageCardViewModel) {
super.render(viewModel)
// 从 plugin 获取对应的业务数据, 刷新 UI
renderMoment(plugin.moment(viewModel.momentID))
}
... 省略代码
}
class MomentMessageCardPlugin: BaseMessageCardPlugin {
// 针对问题 1,
func performLikeAction(_ momentID: String) {
// 请求 like 接口
requestLike(momentID) { success in
// updateCaches
// 完成回调, 刷新消息卡片
context.reloadMessageCard()
}
}
// 为消息卡片提供,业务方面的数据源
func moment(momentID: String) -> Moment? {
// 优先获取缓存
if let moment = likedMomentCaches[momentID] {
return moment
}
requestMoment(momentID) { moment in
// updateCaches
}
return nil
}
// 针对问题 2、3,同步业务数据,联动刷新多个 Cell 等
private func handleNotification(_ data: Any) {
// 收到了业务变更的通知,保存数据源,刷新列表
updateCaches(data)
context.reloadMessageCards()
}
}
通过上面的代码可以看出来,消息 Cell 和 MessageCardPlugin 是业务耦合比较紧密的,这时候要注意规范明确的接口,保证 UI 与逻辑是分开的,如果 MessageCardPlugin 业务逻辑比较复杂的时候也需要一定的设计模式保证代码质量。
这里大概介绍了 IM 消息卡片的插件化,以及如何处理前面提出的问题,不过并没有介绍进行插件化之前的情况,缺少了点对比说明,考虑到每个项目都有自己的实现方式,还有有兴趣的朋友结合自己项目中的实际情况来对比吧。