美文网首页
iOS开发代码设计之: 一个接口做IM??办它!

iOS开发代码设计之: 一个接口做IM??办它!

作者: FKSky | 来源:发表于2020-08-05 13:26 被阅读0次

(一)前言

我写这篇内容主要是出于对最近公司的一个项目的开发思路总结还有自我的感想,觉得很典型,面对一个不合理的总体设计,如何在自己负责的一端将其转化合理。标题可能起的有点夸张,但是看完可能也就知道我想表达什么。

(二)需求和问题

简单来说这个项目的核心功能就是一个IM的聊天界面,比如微信的聊天界面那样,支持文本,图片,文件等的收发。文本图片都是直接显示,其他文件格式的信息都是显示一个文件名字和对应预设的图(在微信里发个文件试试就知道了),点击后才去展示对应文件。

虽说市面上很多第三方的这种平台能直接接入使用,不过公司决定自己做。自己做问题不大,但是迫于时间压力,一切求快,在项目总体设计上就出了问题,导致了很多显而易见的问题。

最大的问题之一是没有双向通信的长连接,只有http请求,这在如今做IM功能来说是不可能想象的。你只能定时轮询。。。询。。。询。。。
第二个大问题就是获取聊天内容只有一个接口,并且一次返回所有的聊天信息内容,而且还是不同的聊天群组的都一起回来。这对本地的数据过滤和加工效率还有内存的使用都是一个压力。

PS:这篇文章标题的来源就是于此,当然来我们还有发送内容的请求接口。不过我们今天主要就是考虑聊天内容的获取和展示,因为这里的只是简单的上传文件调用接口,的设计才是这里核心。

除了上述讲的两个公司本身技术设计的问题以外,对于聊天功能界面,最大的难点就是展示。这里涉及到使用的tableview/collectionview的动态行高,还有需要异步获取的内容,状态同步,还有最重要内存管理等等。如果我们一股脑的都把这些所有东西都丢进去Controller里,至少两千行的代码肯定跑不掉,尤其是你还要注意运行性能和用户体验。这样子的代码是完全不可维护,不可debug,自己写完了之后永远不敢说没问题,下次让你找bug或者修改需求时候,想死的心都会有。所以并不是出于对公司负责态度,为了以后自己不被自己恶心死,也要设计好了再敲。目前依照我的想法对于收和显示功能实现出来的Controller只有200行左右代码。而且业务流程很清晰,出现什么问题都可以快速定位。

(三)应对设计

我觉得我们要设计一个东西,首先应该要有一个目标。目标就是我希望的代码样子是怎么样的,或者说合理的样子应该是怎么样的,为此应该做怎样的设计来达到目标。先上图

结构图
从大结构上来看,我将功能分成这4大模块,LocalServerTableViewModelControllerView。最后那个RealServer是真实的公司服务器。先大致介绍一下功能,然后分章节细说一下
  1. 我们理想中的聊天的“收”消息功能一定要是服务器端能主动推送消息给客户端,并且消息数据是支持分页获取的。既然我们的接口只有一个获取所有聊天内容,那么我们可以在本地自己制作一个LocalServer的模块,把这个LocalServer当作是一个能主动推送新消息的服务器,把轮询那个唯一的接口,和对数据做解析拼接的工作都交给这个LocalServer来做。当它发现有多的新消息出现就会发event给他的listener。也提供了给调用者可以主动去获取分页的旧数据的方法。

  2. 先不说TableViewModel,先来弹我们最熟悉的Controller,也是通常代码堆积最多的地方。我们想一下理想状态下的Controller应该做什么?其实就是2个,设置view内容和响应view的事件。Controller本身并不用太知道view的显示内容和真实的数据内容,它就是把现成的内容丢给对应的view即可。另外在这个例子里,用户事件就2个,一个是点击某条消息(在此不讨论),二是上拉加载更多历史消息。所以总的来说Controller它需要一个显示数据提供者(DisplayDataProvider),这个显示数据提供者就是我们上图中在LocalServer和Controller中间的那个TableViewModel。

  3. 整个流程里最多工作的一环就在TableViewModel里。TableViewModel里实际上是保存了当前页面已经加载了的message内容。
    对于它的上层使用方Controller来说,它提供了主动推送事件的通道,这个事件可能是尾部新增了多少新数据、哪一行数据状态变了、头部新增多了老数据。。。,具体就看我们的业务需求来新增和设计。也就是说 Controller只要去监听TableViewModel的这个主动推送的事件,就能完成所有显示需求。
    TableViewModel除了提供主动推送通道,还提供了很多方法供Controller主动调用。比如:
    现在总共有多少消息?
    给我某一行的显示数据
    如果需要的话你去加载一下某一个消息的远程资源
    给我加载上一页

    。。。
    上面说的是TableViewModel针对它的调用方Controller的指责,它要能提供这么多功能给调用方,是因为它自己是LocalServer的事件监听者。当LocalServer将一个message的原始数据交给TableViewModel的时候,TableViewModel会用message创建一个CellViewModel,一个CellViewModel就是包含了界面上一行消息的所有所需内容。所以TableViewModel实际上的工作是维护所有已加载消息的CellViewModel

  1. CellViewModel里面做着真正计算布局,生成最终显示数据的工作

大体上的设计就是如此,除了View和Controller以外,其他模块内部都有各自的同步队列,它们会保证自身的任务是在后台线程执行,并且一些关键任务是按顺序同步执行。只有他们自身工作都在后台完成后才会在主线程上主动推送消息给Controller。

(四)LocalServer

LocalServer里对外接口最重要的就这几个,创建时候穿一个聊天群组ID还有坚挺事件的handler进来,然后执行run启动server,然后等着消息通知就行了。如果要拿老消息就主动请求上一页(这里我写死了一页长度,如果要灵活就让外面穿参进来)

    typealias ServerEventListener = (ChattingLocalServerEvent) -> Void

    init(chattingID: String, listener: @escaping ServerEventListener) { ... }

    func run() { ... }
    
    func fetchLastPageBefore(message: ChatMessage, completion: @escaping ([ChatMessage]?, Bool) -> Void) { ... }
    
    

LocalServer是创建对于以后公司服务器接口变化有着很好的隔离作用,外层永远依是按照目前设计的方式去接受新消息。
从另一个角度看LocalServer更像是一个不会网络中断的本地服务器。使用方不用去考虑网络中断,重连等问题。有新消息来LocalServer自然就会传递消息给上层。假如以后公司新增了比如WebSocket来做消息的主动推送长链接,那么连接维持Socket,处理Socket中断重连等等内容都是可以直接LocalServer里来做,完全不会影响上层使用方。

(五)TableViewModel

对于TableViewModel外层接口页很简单,初始化,传入事件的监听handler即可。在初始化里创建了一个LocalServer并且成为它的事件监听者

enum TableViewModelEvent {
    ///newMessageCount : Int
    case newMessagesAdded(Int)
    ///oldMessageCount : Int
    case oldMessageAdded(Int)
    ///messageIndex : Int
    case messageContentUpdated(Int)
}

typealias ChattingViewModelListener = (TableViewModelEvent) -> Void

init(groupID: String, listener: @escaping TableViewModelEvent) { ... }
private var server : ChattingLocalServer!
private var cellViewModels = [CellViewModel]()

下面是TableViewModel让使用方主动调用的接口,前两个方法是用来响应用户操作事件的。其实除了loadLastPage方法是去调用LocalServer的接口以外。其他方法都是先找到对应index的CellViewModel,然后从CellViewModel里获取需要的数据返回给上层。

func loadLastPage() { ... }
func fetchDistantDisplayDataIfNeed(at index: Int) { ... }
var messageCount : Int { ... }
func rowHeight(at index: Int) -> CGFloat? { ... }
func layout(at index: Int) -> ChattingCellLayout? { ...  }
func content(at index: Int) -> ChattingCellDisplayContent? { ... }

(六)V和C

View和Controller其实没有太多可以说的,因为工作都分给其他人了。Controller只需要创建持有一个TableViewModel,然后监听事件就没太多事了,比如:

//当Controller收到TableViewModel事件的时候就会调用
//根据时多了新校,还是多了旧消息,还是某一个消息内容更新了等等,去做执行的刷新方式
func viewModelEventComes(event: ChattingViewModelEvent) {
        switch event {
        case .newMessagesAdded(let count):
            .....
        case .oldMessageAdded(let count):
            .....
        case .messageContentUpdated(let index):
            .....
        }
}

//界面触发了上拉刷新,直接调用ViewModel的loadLastPage即可
func loadLastPage() {
      self.viewModel.loadLastPage()
}

//TableViewDatasource方法
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {  
  //简单地问ViewModel拿总行数即可
  return viewModel.messageCount
}
    
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = ChattingTableCell.reusedCell(in: tableView)

  //直接找ViewMode拿某一index位置的显示内容即可
  cell.layout = viewModel.layout(at: indexPath.row)
  cell.content = viewModel.content(at: indexPath.row)
  viewModel.fetchDistantDisplayDataIfNeed(at: indexPath.row)

  return cell
}

Controller可能最重大的工作就是根据收到的消息时间流来决定用哪种方式刷新界面,是只刷一行还是某几行、或是尾部拼接刷新、抑或是头部插入刷新,同时还要控制好是否自动滚动等效果。这些刷新方式的选择会最直接的影响到用户操作时候的体验

(七)CellViewModel

其实CellViewModel才是跟业务需求最强关联的地方,也就是说上面我们说的那几个大模块其实是有通用性的。业务需求改变,消息结构改变这些跟业务强关联的内容和工作都是在CellViewModel里完成。

enum DistantContentFetchingState  {
    case never
    case fetching(Int)
    case fetchedSuccess
    case fetchedFail
}

init(message: ChatMessage, lisenter: (DistantContentFetchingState) -> Void) { ... }
func startFetchDistantContentIfNeed() { ... }

通过一个message原始数据对象就能创建一个CellViewModel,同时设置CellViewModel的事件监听。
这个事件监听主要就是用来监听它的远程数据加载的状况,比如头像图片,还有消息内容图片下载情况。对于文字没有需要获取的远程资源,就可以初始化的时候就设为成功。还有文档这种,因为聊天界面显示的时候不需要文档的具体内容,只是一个占位图,也可以初始化时候直接设为成功。
因为聊天消息内容一页是显示不完的,所以用户没有滚动到的消息没有必要全部都去下载,所以CellViewModel会提供给使用方一个startFetchDistantContentIfNeed方法,在这个方法里,会先检查当前的state,如果成功或者正在获取中就没必要再次请求。(当然你也可以做提前下载,但是肯定不是傻乎乎的全部都提前下载,还是要根据用户的操作情况来设置触发条件)

还记得上面的TableViewModel有一个fetchDistantDisplayDataIfNeed(at index: Int)方法么,在这个方法里TableViewModel其实就是找到对应位置的CellViewModel然后调用他的startFetchDistantContentIfNeed方法。

CellViewModel的内部总体流程就是:
1.初始化的时候根据message内容检查远程数据是否已经有本地缓存,有就拿出来用,没有就设置一个占位资源(比如占位图)
2.根据已有的内容来计算布局和消息行高,还有根据业务需求转化出显示内容
3.当被调用startFetchDistantContentIfNeed方法时,如果远程资源需要下载就去下载,下载完成后再重新计算一个布局和显示内容。

(八)发送

本来不准备说发送方面的,因为我们这里发的本质就是把一个要发送的内容调用普通请求接口。而发送文件的难点并不在发送这个动作上面,而是依旧是在显示的上。比如发送中的数据怎么显示,什么时候从界面上移除,如何跟收到的真实消息做匹配等,这些所有其实只要上面收和显示的流程工作做好了,发送的显示也就很简单。
先在上面的结构图上加上发送的模块部分后得到:

加上发送功能

可以看到,要发消息,Controller依然是调用TableViewModel的接口(sendText\Image\File...),然后Controller不需要等待发送的异步回调结果,还是回去等Event事件即可(也就需要在前面的基础上增加事件的枚举类型),就可以正确的显示。

TableViewModel里面再调用MessageSender模块的发送接口,在MessageSender内执行真正的上传发送行为,MessageSender对于一个发送请求会返回给调用方一个SendingMessage的数据模型,SendingMessage 里包含SendingMessageID,用于MessageSender向它的调用者发送Event时候,定位到是具体哪个SendingMessage的状态变化。

TableViewModel拿到这个发送的数据模型就可以构建出一个上面说过的、用于显示的CellViewModel。等于说TableViewModel接了两个水管,一个是LocalServer,另一个是MessageSender,分别都会给TableViewModel传递服务器的新消息(ChatMessage)和本地发送的消息(SendingMessage),然后对这两种消息的保管、对比检查、转换显示数据都是TableViewModel里完成。最后综合各方面的因素得出一个相应的Event事件push给调用方(Controller),调用方根据Event内容结合具体业务涉及就知道应该怎么刷新界面,是在前面加几行、还是后面加几行、还是更新某一行、或是要不要滚动等等。。。。

(九)设计总结

在简单总结一下各个模块的主要作用,让理解更清晰
LocalServer:检查服务器消息,主动推送服务器的新消息给上层。消息的分页功能也是由它来控制的
MessageSender:执行真正发消息动作,推送新的发送消息和状态给上层
TableViewModel:监听接收LocalServerMessageSender推送的服务器和主动发送的消息,给他们排序、保存、对比检查、转换CellViewModel。综合消息的变化主动推送事件给它的调用方
CellViewModel: 根据消息内容,计算布局和行高,转换消息内容为真实显示内容。
Controller: 监听TableViewModel的事件,做出合适的更新界面的动作和滚动动画。根据View反馈的用户操作,去调用合适的请求。
View: 比较闲,等着接收显示内容即可。把用户的操作反馈给Controller

另外从前面看到,我所有底层向上层传递结果都用了事件信号流来代替一般的异步请求回调,这也可以说是一种响应式开发,基于监听状态和信号的变化来进行相应的动作。如果我们引入ReativeX这种库可以实现效果更统一。
从结构上看总体属于MVVM,最大的目的就是为了给Model和Contoller去减负,所以我的Controller里最后加上了发送的代码也只有不到300行,各个模块大约也都在200-300行左右。

我这里的设计也只是针对我们公司这个项目的需求来设定的,如果这个界面的功能更加复杂了,那就需要继续引入新的Event状态,甚至新模块来做新的心情,更要理清所有他们之间的关系,一定要让他们之间的依赖还有事件的传递达到一个相对线性的状态,千万不可纠缠在一起。

(十)其他细节

  1. 除Controller和View之外的其他所有任务都不要在主线程上运行,我的每个模块内有自己的串行队列,核心任务还有向上层传递Event一定要有严格的顺序。

  2. 数据缓存一定要做好,可以分磁盘和内存缓存,内存缓存设置阈值,超过自动清空。聊天界面的可能性太多,数据也可能很多,内存压力大的情况一定会有,所以这里既要要减少用户等待时间还要照顾好内存占用率,是有很多可以挖掘的地方。

  3. 还有一种情况就是当很多消息的远程资源都是同一个url地址,比如他们的头像可能在服务器上其实是同一张图片,这个时候当多个CellViewModel被触发startFetchDistantContentIfNeed时候会去同时请求这个资源,但是其实只要开启一个下载任务即可。所以我有另一个远程资源下载模块(DistantContentProvider)负责下载任务,如下图:

    DistantContentProvider
    一个远程资源下载模块收到一个下载url的请求,会先去上面的池子里查查有没有相同url地址的正在下载的任务,如果没有则创建然后丢进上面池子里。如果已经有了,就把这次请求的completion回掉丢进下面的池子里。当一个request完成了,会去下面的池子里检查有没有其他等待相同下载任务的completion回调,有就全部执行一次,执行完的completion移出下池子,最后将request从上面池子移出。(当然DistantContentProvider可以先去检查本地缓存,没有再去下,图中省略了)
  4. 在发送成功后,可以主动对于上传成功的文件,比如图片,视频等等甚至缩略图进行本地缓存,缓存的key为文件上传成功后获得的远程文件url地址,值就不需要再去下载了,直接用当时上传时候的data内容即可。这样对于收到服务器端的消息内容不用管是不是自己发的,消息的远程数据获取还是用先查本地、没有再请求的做法,只要没有主动清理本地缓存,那么我们主动发送的消息里的文件就永远不用再下载。

  5. 当然还有很多我有想法但是目前没有去做的优化,比如事件流传递的Event可以在短时间内做积累,批量一次性处理,这个对于Controller层来说会很有用,可以减少界面的刷新次数。等等等等

(十一)感想

对于国内很多小的软件公司,经常会提出一些项目让你快速的做出来,好拿去给客户看,而且还总希望可以不经过认真设计一天两天就弄出来一个小功能,最终很短周期做出一个完整的成品。其实根本没有经过仔细的设计的程序,是完全不可用的,因为bug是不可控的,一个程序的debug绝对不是依赖于一个活人拿着手机点点点。

当你业务代码敲多了以后就会发觉那个其实是一个熟练度的问题,因为我们都是站在巨人的肩膀上敲代码,也都是记各种api各种库的使用,这种类型的技能就是重复和时间所能堆叠出来的,也是最容易被替代的。但是如何让代码能稳健发展,整体逻辑清晰,易查bug是要花时间去仔细思考的。但是我觉得也不要盲目于设计模式,脱离了需求谈设计都是耍流氓。如果你的MVC用的好好的,没有应对业务变化的瓶颈,也就没有必要去换模式。

设计的目的其实就是为了清晰地看清工作流程,并不是为了少敲代码,你的具体业务一出来本质上就已经决定你一定要敲至少多少代代码。但是一个没有设计的代码是难debug,难扩展,难移植。

另外作为程序员多给自己设置一些底线,千万不要quick-dirty的代码。不要听信有的老板说的“不在乎性能或者设计”、”我就是要看效果“、”只要弄出来就行“等等催促你无脑写代码的瞎话,因为这里面是有逻辑漏洞的。具体我就不多说了,要谈这个我可以又写出一万字来。只记得一点就行了,出了bug是谁改? 可能在头几天里思考清楚整体设计会花点时间,不过一个程序的拿出手不是头两天赶一两个界面或者小功能就算结束了,所以即使开头在所谓的“开发进度”上有落后,但是后续的一切都会变得快速。

我始终还记得我曾经有一任老板对我说的话:首先一定要对技术持有敬畏之心,另外写代码的时间永远都是开发中最小的一部分,那只是比敲字速度。

相关文章

网友评论

      本文标题:iOS开发代码设计之: 一个接口做IM??办它!

      本文链接:https://www.haomeiwen.com/subject/bdgyrktx.html