基于推送的实时聊天功能实现

前言

最近做项目,遇到要做一个实时推送的聊天功能的需求,开发完成,把实现的细节和步骤以及遇到的问题记录一下。

效果

不废话,先看效果:







原理

利用极光实时推送,客户端端发消息发送给服务端,服务端再把消息发送给极光服务器,极光服务器把接收到的消息发送给苹果服务器,苹果服务器最后把消息推送到另外一个客户端手中。由于iOS和andriod双平台,所以需要和服务端定义好协议以及设置好标签(tags),别名(alias)。这样才能实现张三发送给李四的消息,只有李四能收到,李四回给张三的消息也只有上三能收到。就是一个客户端发送指定的内容到指定的客户端(可能是一个,也可能是一多个),这些指定的客户端能收到指定的内容。

步骤

界面搭建

一个基本的聊天界面。包括输入框,消息的展示,消息时间的处理,键盘的弹出隐藏,输入框高度的动态增长等这些基本的功能点:

监听键盘弹出和隐藏

在控制器viewDidLoad视图加载完毕的方法中接收键盘弹起和隐藏的通知:UIKeyboardWillChangeFrameNotification,键盘frame改变的这个通知,已经包含了键盘的弹出和隐藏,避免了写两个通知方法,通知响应事件,只需要到键盘frame变化以及动画时间,就可以通过该变输入框的约束从而达到输入框随着键盘的弹出而弹出,隐藏和降落,动画时间也是一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (void)keyboardWillChangeFrame:(NSNotification *)note
{
CGRect beginFrame = [[note.userInfo objectForKey:UIKeyboardFrameBeginUserInfoKey] CGRectValue];
CGRect endFrame = [[note.userInfo objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue];
CGFloat duration = [note.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
CGFloat delta = (endFrame.origin.y - beginFrame.origin.y);

// 动画
[UIView animateWithDuration:duration animations:^{
[self.chatInputToolBarView mas_updateConstraints:^(MASConstraintMaker *make) {
make.bottom.mas_equalTo(-(kAllHeight - endFrame.origin.y));
}];

CGFloat contentOffsetY = self.mainTableView.contentOffset.y - delta;
self.mainTableView.contentOffset = CGPointMake(0, contentOffsetY);
[self.view layoutIfNeeded];
}];
}

消息的展示

每次接受到新消息或者发送一条消息,都需要把最新的消息顶到表格的最底部,先更新数据源,再更新表格视图:

1
2
3
4
5
6
[self.mainTableView beginUpdates];
[self.messageDataArray addObject:message];
[self.mainTableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:self.messageDataArray.count inSection:0]] withRowAnimation:UITableViewRowAnimationBottom];
[self.mainTableView endUpdates];

[self scrollToBottom];

将消息滚动到最底部:

1
2
3
4
5
6
7
8
9
10
11
- (void)scrollToBottom
{
//1.获取最后一行
if (self.messageDataArray.count == 0)
{
return;
}

NSIndexPath *lastIndex = [NSIndexPath indexPathForRow:self.messageDataArray.count inSection:0];
[self.mainTableView scrollToRowAtIndexPath:lastIndex atScrollPosition:UITableViewScrollPositionBottom animated:NO];
}

消息时间的处理

规则:今天的消息只显示小时和分钟,昨天的消息显示昨天:时:分,昨天以前的消息显示具体的年月时时分,同一个小时发送的消息时间只显示一次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
+ (NSString *)getVSTChatFormatTime:(long long)time
{
//1.获取当前的时间
NSDate *currentDate = [SYTimeManager getCurrentDate];

// 1.1获取年,月,日
NSInteger currentYear = [SYTimeManager getYearWithDate:currentDate];
NSInteger currentMonth = [SYTimeManager getMonthWithDate:currentDate];
NSInteger currentDay = [SYTimeManager getDayWithDate:currentDate];

//2.获取消息发送时间
NSDate *msgDate = [NSDate dateWithTimeIntervalSince1970:time];

// 2.1获取年,月,日
NSInteger msgYear = [SYTimeManager getYearWithDate:msgDate];
NSInteger msgMonth = [SYTimeManager getMonthWithDate:msgDate];
NSInteger msgDay = [SYTimeManager getDayWithDate:msgDate];

//3.判断:
/*
今天:(HH:mm)
昨天: (昨天 HH:mm)
昨天以前:(2016-07-27 16:07)
*/
NSDateFormatter *dateFormat = [[NSDateFormatter alloc] init];
if (currentYear == msgYear && currentMonth == msgMonth && currentDay == msgDay)
{
// 今天
dateFormat.dateFormat= DateFormat_Hm;
}
else if(currentYear == msgYear && currentMonth == msgMonth && currentDay - 1 == msgDay)
{
// 昨天
dateFormat.dateFormat= [NSString stringWithFormat:@"昨天 %@",DateFormat_Hm];
}
else
{
// 昨天以前
dateFormat.dateFormat= DateFormat_yyyyMdHm;
}

return [dateFormat stringFromDate:msgDate];
}

// 比较两个时间是否在同一个小时内,若是,返回YES,否返回NO
+ (BOOL)isSameHourTime:(long long)timeA time:(long long)timeB;
{
NSDate *dateA = [NSDate dateWithTimeIntervalSince1970:timeA];
NSDate *dateB = [NSDate dateWithTimeIntervalSince1970:timeB];

// 获取年月日
NSString *tempStringA = [SYTimeManager getTimeWithDate:dateA format:DateFormat_yMd];
NSString *tempStringB = [SYTimeManager getTimeWithDate:dateB format:DateFormat_yMd];

// 获取小时
NSInteger hourA = [SYTimeManager getHourWithDate:dateA];
NSInteger hourB = [SYTimeManager getHourWithDate:dateB];

if (hourA == hourB)
{
// 日期也需要一样
if ([tempStringA isEqualToString:tempStringB])
{
return YES;
}

return NO;
}
else
{
return NO;
}
}

消息背景图片的拉伸

一般UI设计师会切一张有四个角的小图片,我们应该根据消息文本的长度和高度进行拉伸,但是要保留四个角,只拉伸证件部分,有两种做法,一种是用代码实现,另一种是通过Assets.xcassesShow Slicing实现,推荐使用第二种,直观和方便:

中间四条线框住的区域才是可以被拉伸的,其他四个角都是不会被拉伸,另外还可以在左边的属性栏中设置left,right,top,bottom四个方向上的值。拉伸的模式有平铺和拉伸两种。

动态增长输入框高度

当输入多行文本的时候,输入框的高度要随着输入文字的换行而自动增长,首先需要监听textView的- (void)textViewDidChange:(UITextView *)textView代理方法,当输入的文本换行时,动态增加输入框的高度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
 // 1.计算TextView的高度,
CGFloat textViewH = 0;
CGFloat minHeight = textViewDefaultHeight; //textView最小的高度
CGFloat maxHeight = textViewMaxHeight; //textView最大的高度

// 2。获取contentSize的高度
CGFloat contentHeight = textView.contentSize.height;
if (contentHeight < minHeight)
{
textViewH = minHeight;
}
else if (contentHeight > maxHeight)
{
textViewH = maxHeight;
}
else
{
textViewH = contentHeight;
}

// 3.加个动画
[UIView animateWithDuration:0.25 animations:^{
CGRect textViewF = textView.frame;
textViewF.size.height = textViewH;
textView.frame = textViewF;

[self.chatInputToolBarView mas_updateConstraints:^(MASConstraintMaker *make) {
make.height.mas_equalTo(textViewH + 2 * margins_V);
}];

[self.view layoutIfNeeded];
}];

// 4.光标回到原位
[textView setContentOffset:CGPointZero animated:YES];
[textView scrollRangeToVisible:textView.selectedRange];

推送

极光推送

下载好极光最新的SDK,在极光官网创建应用,创建开发、生产环境的服务端证书,APP描述文件,配置好AppKeyJPushChannel,在应用程序didFinishLaunchingWithOptions启动极光推送,分8.0系统前后两种不同的方法,通知形式有Badge角标,Sound声音,Alert横幅:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
if ([[UIDevice currentDevice].systemVersion floatValue] >= 8.0)
{
// categories
[JPUSHService registerForRemoteNotificationTypes:(UIUserNotificationTypeBadge | UIUserNotificationTypeSound | UIUserNotificationTypeAlert)
categories:nil];
}
else
{
// categories nil
[JPUSHService registerForRemoteNotificationTypes:(UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound | UIRemoteNotificationTypeAlert)
categories:nil];
}

// Required
// [JPUSHService setupWithOption:launchOptions]
// pushConfig.plist appKey
// 有广告符标识IDFA(尽量不用,避免上架审核被拒)
// NSString *JPushAdvertisingId = [[[ASIdentifierManager sharedManager] advertisingIdentifier] UUIDString];
// [JPUSHService setupWithOption:JPushOptions
// appKey:JPushAppKey
// channel:JPushChannel
// apsForProduction:JPushIsProduction
// advertisingIdentifier:JPushAdvertisingId];
// 或无广告符标识IDFA(尽量不用,避免上架审核被拒)
[JPUSHService setupWithOption:options
appKey:JPushAppKey
channel:JPushChannel
apsForProduction:JPushIsProduction];
注册极光推送,传deviceToken过去:

- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
[JPushManager JPushRegister:deviceToken];
}

前台配置

要想推送给指定的客户端,就需要绑定别名:

1
2
3
4
5
6
7
8
9
10
11
12
/// 绑定别名(注意:1 登录成功或者自动登录后;2 去除绑定-退出登录后)
+ (void)JPushTagsAndAliasInbackgroundTags:(NSSet *)set alias:(NSString *)name
{
// 标签分组(表示没有值)
NSSet *tags = set;
// 用户别名(自定义值,nil是表示没有值)
NSString *alias = name;
NSLog(@"tags = %@, alias = %@(registrationID = %@)", tags, alias, [self registrationID]);

// tags、alias均无值时表示去除绑定
[JPUSHService setTags:tags aliasInbackground:alias];
}

后台配置

需要和客户端这边协商,联调,设置同样的标签和别名,以JSON字符串的形式将消息内容推送过来

接收推送消息

有新消息过来,需要去接收,然后显示在表格的最底部:

1
2
3
4
5
6
7
8
9
- (void)receiveJPushMessage:(NSNotification *)notification
{
NSDictionary *dict = notification.object;
NSString *dictString = dict[@"objectId"];
NSDictionary *resultDict = [NSString jsonDictWithString:dictString];
ECSJPushContentModel *contentModel = [ECSJPushContentModel yy_modelWithJSON:resultDict];
VSTChatMessageModel *message = getChatMessageModel(contentModel.content, contentModel.sourceAccount, contentModel.account, contentModel.sendTime);

}

跳转到指定页面

当有消息进来,如果应用在后台或者被杀死,则需要点击推送消息横幅跳转到指定页面,总共有应用在前台,后台,应用被杀死三种情况需要考虑:

应用在前台,应用在后台

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
DLog(@"2-1 didReceiveRemoteNotification remoteNotification = %@", userInfo);

// apn 内容获取:
[JPushManager JPushHandleRemote:userInfo fetchCompletionHandler:completionHandler];

DLog(@"2-2 didReceiveRemoteNotification remoteNotification = %@", userInfo);
if ([userInfo isKindOfClass:[NSDictionary class]])
{
NSDictionary *dict = userInfo[@"aps"];
NSString *content = dict[@"alert"];
DLog(@"content = %@", content);

// [SYNotificationManager postNotificationJPush:dict];
[SYNotificationManager postNotificationJPush:userInfo];
}

if (application.applicationState == UIApplicationStateActive)
{
// 程序当前正处于前台
}
else if (application.applicationState == UIApplicationStateInactive)
{
// 程序处于后台
// do something
}
}

应用被杀死

1
2
3
4
5
6
7
8
9
10
11
12
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.

// 保存推送信息启动APP
NSDictionary *notification = [launchOptions objectForKey: UIApplicationLaunchOptionsRemoteNotificationKey];
if ([NSDictionary isValidNSDictionary:notification])
{
[SYUserDefaultManager SaveRemoteNotification:notification];
}

return YES;
}

然后再视加载完毕后进行跳转

遇到的问题

注意1:标签和别名的组成规则,之前用用户名设置,但是用户名出现了.这个非法字符,导致设置标签和别名失败,从而导致张三发给李四的消息,李四却收不到的问题。

1
2
3
4
5
6
7
8
9
10
错误码定义
Code 描述 详细解释
6001 无效的设置,tag/alias 不应参数都为 null
6002 设置超时 建议重试
6003 alias 字符串不合法 有效的别名、标签组成:字母(区分大小写)、数字、下划线、汉字。
6004 alias超长。最多 40个字节 中文 UTF-83 个字节
6005 某一个 tag 字符串不合法 有效的别名、标签组成:字母(区分大小写)、数字、下划线、汉字。
6006 某一个 tag 超长。一个 tag 最多 40个字符 中文 UTF-83 个字节
6007 tags 数量超出限制。最多 100个 这是一台设备的限制。一个应用全局的标签数量无限制。
6008 tag/alias 超出总长度限制。总长度最多 1K 字节

注意2:跳转要等视图控制器加载完了才能去做跳转,特别是应用被杀死后收到推送消息进行跳转,在APP完毕的方法里面可以先把推送消息保存起来,等到视图控制器加载完毕后,再执行跳转操作。

注意3:算消息cell高度时,如果是用自动布局算的:

1
2
3
// 使用自动布局后,直接让系统自动算出cell高度
self.mainTableView.estimatedRowHeight = 120.0;
self.mainTableView.rowHeight = UITableViewAutomaticDimension;

则将表格拉倒最底部或者最顶部的方法就会失效,所以,这个使用自动布局算cell高度只适用于cell只是静态展示,没有动态编辑的情况,如果cell需要动态的编辑,比如增删cell,cell上的控件有交互效果,就不能用系统自动去算高度了。

参考链接

总结

0%