本文用于记录一些聊天界面设计思路。不定时更新中......
本文主要从网易云信的界面设计分析。在开始梳理之前,我有以下疑问:
1.怎么实现自定义配置?
2.怎么往SDK注入数据,怎么实现数据(NIMSDK)和UI(NIMKit)的解耦?
- 聊天界面包含多种cell,是怎么设计的?
- cell的布局是怎么设计的?
- 聊天界面的输入键盘是怎么设计的?
1.SDK初始化
因为初始化参数会影响到后面的界面设计,所以要放在前面来看。
SDK我们指的是UI的SDK,所以他的总类是NIMKit。NIMSDK是聊天接口的SDK,主要聊天消息的相关请求、模型,以及设置。
关于NIMSDK,设置主要有NIMSDKConfig和NIMSDK这两个单例类,NIMSDKConfig主要用于一些个性化设置,NIMSDK主要是用于聊天系统的设置,比如SDK的注册,Debug系统的设置,以及各类接口的回调,这边将各自回调系统分成不同的协议,清晰化接口。具体看NIMSDKHeader和NIMSDKConfig这两个类,这边就不详细说明。
NIMKit是UI相关的SDK。NIMKit是UI类的设置类,也包含外界需要的头文件。从头文件的声明,我们可以看出NIMKit暴露给外界设置的一些权限。
/**
* 基础Model
*/
#import "NIMKitInfo.h" //基础信息(应该是用于聊天列表)
#import "NIMMediaItem.h" //多媒体面板对象(配合按钮)
#import "NIMMessageModel.h" //message Wrapper(消息的详细模型)
/**
* 协议
*/
#import "NIMKitMessageProvider.h" // 消息提供器(获取聊天消息)
#import "NIMCellConfig.h" // message cell配置协议(遵守该协议就是能处理返回cell布局相关的信息)实际是NIMCellLayoutConfig这个协议
#import "NIMInputProtocol.h" // 输入框回调(将输入框的动作回调给外界,比如聊天控制器)
#import "NIMKitDataProvider.h" // APP内容提供器(获取信息,比如个人或群的信息)
#import "NIMMessageCellProtocol.h" // message cell事件回调(将cell的动作回调给外界,比如聊天控制器)
#import "NIMSessionConfig.h" //会话页面配置(提供聊天界面的item和相关设置,比如是否自动下载文件,是否显示快捷回复等)
#import "NIMKitEvent.h" // 点击事件封装类
#import "NIMCellLayoutConfig.h" // 遵守NIMCellLayoutConfig协议,实现协议方法的类
/**
* 消息cell的视觉模板(带有背景图的泡泡视图)
*/
#import "NIMSessionMessageContentView.h"
/**
* UI 配置器(聊天界面的配置,字体颜色等)
*/
#import "NIMKitConfig.h"
/**
* 常用的界面
*/
// 会话页
#import "NIMSessionViewController.h"
// 会话列表页
#import "NIMSessionListViewController.h"
// 独立聊天室模式下需注入的信息
#import "NIMKitIndependentModeExtraInfo.h"
// 标记消息页
#import "NIMMessagePinListViewController.h"
// 收藏页
#import "NIMCollectMessageListViewController.h"
// 聊天常用UI方法(聊天的UI操作,转发)
#import "NIMChatUIManagerProtocol.h"
// 快捷评论
#import "NIMCollectionViewLeftAlignedLayout.h"
#import "NIMKitQuickCommentUtil.h"
常用的界面和模型类不用多说,协议类才是灵活实现自定义配置的关键。
NIMKit作为配置和提供功能的类,提供了如下的功能:
- 可以让使用者自定义cell布局(NIMCellLayoutConfig),聊天界面样式设置(NIMKitConfig)。
// 注册自定义的排版配置,通过注册自定义排版配置来实现自定义消息的定制化排版
- (void)registerLayoutConfig:(NIMCellLayoutConfig *)layoutConfig;
// 返回当前的排版配置
- (id<NIMCellLayoutConfig>)layoutConfig;
// UI 配置器(如果没设置,有默认值)
@property (nonatomic,strong) NIMKitConfig *config;
- 可以让使用者注入内容提供者
/**
* 内容提供者,由上层开发者注入。如果没有则使用默认 provider
*/
@property (nonatomic,strong) id<NIMKitDataProvider> provider;
// 返回用户信息
- (NIMKitInfo *)infoByUser:(NSString *)userId
option:(NIMKitInfoFetchOption *)option;
// 返回群信息
- (NIMKitInfo *)infoByTeam:(NSString *)teamId
option:(NIMKitInfoFetchOption *)option;
// 返回群信息
- (NIMKitInfo *)infoBySuperTeam:(NSString *)teamId
option:(NIMKitInfoFetchOption *)option;
// 返回被回复的消息,比如会话列表显示的简介【语音】
- (NSString *)replyedContentWithMessage:(NIMMessage *)message;
这个内容提供者主要是获取个人或群信息,NIMKit也开发了对应的接口让开发者获取对应信息。(这边把这个功能集成在NIMKit里面目的暂时不是很明白)
- 消息变更的相关通知
// 用户信息变更通知接口
- (void)notfiyUserInfoChanged:(NSArray *)userIds;
// 群信息变更通知接口
- (void)notifyTeamInfoChanged:(NSString *)teamId type:(NIMKitTeamType)type;
// 群成员变更通知接口
- (void)notifyTeamMemebersChanged:(NSString *)teamId type:(NIMKitTeamType)type;
让外界去通知NIMKit去发送变更通知。NIMKit发送后通知影响相关界面去reload。
- 提供转发的功能
NIMChatUIManager把转发的操作封装起来,这边实际上换个Helper方法也能实现。
那为什么把转发操作抽出来?应该是为了封装性,并且在NIMKit中提供给外界去使用。
- 修改图片资源的表情资源的路径,修改语言
2.消息列表
NIMSessionListViewController是消息列表的基类界面,提供了消息的展示。
作为消息列表的基类,主要是提供给外界实用,让外界去拓展其他功能。里面主要放了:
- tableView获取数据 和 实现。(从NIMSDK获取数据,不必暴露给外界)
- 暴露给子类自定义数据排序,选中会话和选择头像的回调,处理显示数据的实现。(意味着展示内容,点击回调等,开发者可以轻易重写)
- 数据更新通知回调。(与NIMSDK的交互,这部分封装在里面,不必暴露给外界)
当然开发者在定制消息会话页时,肯定会相对复杂。比如添加导航栏功能,添加数据为空的占位图,添加tableView的顶部视图。这些都可以在基类基础上拓展。
3.聊天界面
① cell的设计
简单Cell的设计是明朗的。比如 Model ➡️ Cell ➡️ Delegate 。
聊天界面的Cell一个是考虑定制性问题,一个是考虑多样性问题。
- 定制性
NIMCellLayoutConfig和NIMKitConfig都是外界能够自定义的,当外界没传入时都有默认值供使用。区别是NIMKitConfig是以类的形式存在的,定义了一些默认的设置(后续自定义需要定义子类重写)。
NIMKitConfig还包含各种不同类型CellContent的设置信息NIMKitSetting。NIMCellLayoutConfig以协议形式规范了Model怎么设置Cell。NIMCellLayoutConfig也有实现了基本逻辑的基类,后续开发者需要自定义逻辑,可以派生出子类重写。
简单说,NIMKitConfig是自定义了NIMKit通用的相关参数,NIMCellLayoutConfig是自定义Model转换成Cell所需参数的转换过程。
所谓的定制性,就是我们能控制界面和CellContent的样式,也能决定Model数据的转换。
- 多样性
聊天界面的Cell有多样性,主要在于左右位置,以及内容(文本,视频,语音等)。
在不考虑多种类型Cell的情况下,Model提供聊天原始数据,最多再辅助处理Cell展示相关的数据。
而多种Cell,变化点主要是在布局以及CellContent上。将不同点提取出来,分离CellContent,能做到最大程度的复用。
Cell需要获取展示数据,布局方式时,只需要直接从Model获取。有些需要开发者从中自定义怎么通过Model转换的,我们放在NIMCellLayoutConfig。
比如获取CellContent的内容类名。放在Model逻辑就定死了,放在NIMCellLayoutConfig,开发者能够在NIMCellLayoutConfig子类的基础上去重写这部分逻辑。
- (NSString *)cellContent:(NIMMessageModel *)model{
id<NIMSessionContentConfig>config = [[NIMSessionContentConfigFactory sharedFacotry] configBy:model.message];
NSString *cellContent = [config cellContent:model.message];
return cellContent.length ? cellContent : @"NIMSessionUnknowContentView";
}
在NIMCellLayoutConfig基类中,在不优化设计的情况下,会有一堆if...else...
如果是文本,返回文本视图,如果...
这边设计了NIMSessionContentConfig的概念,一个CellContent对应一个配置类,提供Cell内容的size、contentSrting、insets。
通过一个工厂类和NIMCellLayoutConfig解耦,需要文本的CellContent,我就让工厂提供对应的文本Config。后续出现新的CellContent时,只需要定制新的ContentView和Config,并在工厂中添加对应的Config。
在我的理解里,NIMCellLayoutConfig像是一个适配器,将工厂(被适配者)的接口适配给Cell,让Cell能够利用。
② 输入框的设计
输入框作为一个组件,对外有两个代理,一个是自身高度变化的代理(让外界即时变化frame),一个是按钮动作回调。
还有是配置设计,将输入框内容让config来决定。虽然config是通用的,相对有多余的部分。
//设置input bar 上的按钮
if ([_inputConfig respondsToSelector:@selector(inputBarItemTypes)]) {
NSArray *types = [_inputConfig inputBarItemTypes];
[_toolBar setInputBarItemTypes:types];
}
③ 界面总体的设计
按常用界面设计来讲,可能MVC就能适用。但是,对于聊天界面来说,即使Cell,inputView做了封装。怎么获取数据?怎么控制布局变化?怎么实现TableView的相关代理回调?这些繁琐的代码就可能造成控制器臃肿。
在NIMKit中,聊天控制器将职责细化,将交互和TableView适配器抽离出来。
在NIMSessionConfigurator这个装配中心中,生成对应的交互器和适配器,将职责分散开了,用面向对象的说法就是,让各自专业的对象去负责自己专业的事。
- (void)setup:(NIMSessionViewController *)vc
{
NIMSession *session = vc.session;
id<NIMSessionConfig> sessionConfig = vc.sessionConfig;
UITableView *tableView = vc.tableView;
NIMInputView *inputView = vc.sessionInputView;
// 数据源(抽离消息数据和回执相关的数据操作)
NIMSessionDataSourceImpl *datasource = [[NIMSessionDataSourceImpl alloc] initWithSession:session config:sessionConfig];
// 布局者(抽离tableView和inputView在数据变化时的布局逻辑)
NIMSessionLayoutImpl *layout = [[NIMSessionLayoutImpl alloc] initWithSession:session config:sessionConfig];
layout.tableView = tableView;
layout.inputView = inputView;
// 交互器(包含数据源和布局者,封装交互操作,通过协议提供功能给控制器)
_interactor = [[NIMSessionInteractorImpl alloc] initWithSession:session config:sessionConfig];
_interactor.delegate = vc;
_interactor.dataSource = datasource;
_interactor.layout = layout;
[layout setDelegate:_interactor];
// 适配器 (实现tableView代理回调,交互器提供数据)
_tableAdapter = [[NIMSessionTableAdapter alloc] init];
_tableAdapter.interactor = _interactor;
_tableAdapter.delegate = vc;
vc.tableView.delegate = _tableAdapter;
vc.tableView.dataSource = _tableAdapter;
[vc setInteractor:_interactor];
}
- NIMSessionInteractorImpl交互器
交互器,顾名思义是交互相关的操作,比如获取数据,比如改变布局。NIMSessionInteractor定制了交互的接口,NIMSessionInteractorDelegate定义了交互完成的时机供外界(控制器)去调用。
@protocol NIMSessionInteractor <NSObject>
//网络接口
- (void)sendMessage:(NIMMessage *)message;
- (void)sendMessage:(NIMMessage *)message toMessage:(NIMMessage *)toMessage;
- (void)sendMessage:(NIMMessage *)message completion:(void(^)(NSError * error))completion;
- (void)sendMessage:(NIMMessage *)message
toMessage:(NIMMessage *)toMessage
completion:(void(^)(NSError * error))completion;
- (void)sendMessageReceipt:(NSArray *)messages;
.
.
.
//界面操作接口
- (void)addMessages:(NSArray *)messages;
- (void)insertMessages:(NSArray *)messages;
- (NIMMessageModel *)updateMessage:(NIMMessage *)message;
- (NIMMessageModel *)deleteMessage:(NIMMessage *)message;
- (void)addPinForMessage:(NIMMessage *)message;
- (void)removePinForMessage:(NIMMessage *)message;
//数据接口
- (NSArray *)items;
- (void)markRead;
- (NIMMessageModel *)findMessageModel:(NIMMessage *)message;
- (BOOL)shouldHandleReceipt;
- (void)checkReceipts:(NSArray<NIMMessageReceipt *> *)receipts;
- (void)resetMessages:(void (^)(NSError *error))handler;
- (void)loadMessages:(void (^)(NSArray *messages, NSError *error))handler;
- (void)pullUpMessages:(void(^)(NSArray *messages, NSError *error))handler;
- (NSInteger)findMessageIndex:(NIMMessage *)message;
- (BOOL)messageCanBeSelected:(NIMMessage *)message;
- (void)loadMessagePins:(void (^)(NSError *error))handler;
- (void)willDisplayMessageModel:(NIMMessageModel *)model;
//排版接口
- (void)resetLayout;
- (void)changeLayout:(CGFloat)inputHeight;
- (void)cleanCache;
- (void)pullUp;
//按钮响应接口
- (void)mediaAudioPressed:(NIMMessageModel *)messageModel;
- (void)mediaPicturePressed;
- (void)mediaShootPressed;
- (void)mediaLocationPressed;
//页面状态同步接口
- (void)onViewWillAppear;
- (void)onViewDidDisappear;
//页面状态切换接口(正常/选择)
- (NIMKitSessionState)sessionState;
- (void)setSessionState:(NIMKitSessionState)sessionState;
- (void)setReferenceMessage:(NIMMessage *)message;
@end
简单说就是,控制器负责组装UI,以后最上层的一些监听(包括通知和动作,实际上所需要的交互是派发给交互器处理)。剩下的交给交互器,比如界面操作和布局,网络交互等。
交互器包含两个主要的部分,数据源(NIMSessionDataSourceImpl)和布局者(NIMSessionLayoutImpl),一个负责提供数据,一个负责处理InputView和TableView的布局变化。
当然,NIMSessionDataSourceImpl背后还有专门处理消息的数据源(NIMSessionMsgDatasource),数据数据源背后还有针对不同界面抽离的下拉请求方法和时间戳的数据提供者(<NIMKitMessageProvider>)。
交互器除了封装了交互方法,也提供时机回调的接口。数据交互,布局交互自己做,把完成的实际回调给外界(控制器),比如自身监听信息更新通知,回调刷新时机让控制器去刷新title和tableView。
// 时机的回调,主要是要让控制器去做某些事情
@protocol NIMSessionInteractorDelegate <NSObject>
// 让控制器决定加载完消息数据后的动作
- (void)didFetchMessageData;
// 让控制器决定刷新消息数据后的动作
- (void)didRefreshMessageData;
// 让控制器决定自己要下拉什么数据
- (void)didPullUpMessageData;
@end
- NIMSessionTableAdapter适配器
之所以叫适配器,是因为运用的适配器模式。消息数据数据是被适配者,控制器是适配者,通过适配器,将数据转化成控制器(适配者)可以使用的。
适配器的数据是由交互器提供的,Cell动作回调给控制器。
@interface NIMSessionTableAdapter : NSObject<UITableViewDelegate,UITableViewDataSource>
@property (nonatomic,weak) id<NIMSessionInteractor> interactor;
@property (nonatomic,weak) id<NIMMessageCellDelegate> delegate;
@end
- NIMSessionViewController基类控制器
作为一个聊天界面的基类,本身负责的比较简单(底层)。它封装了tableView和inputView,以及和这些控件的交互,比如发送消息,录音。提供了最基础的功能菜单,剩下的交给外界去拓展。
SessionVC设计比较好,将配置抽离(一个界面对应一个配置)。聊天界面基类可以在很多相似界面通用,通过抽离配置,可以做到最大程度的复用,也减少一些无所谓的判断。