需求:實(shí)現(xiàn)聊天文字收發(fā)功能,不需要圖片,視頻,音頻等數(shù)據(jù)的傳輸。
內(nèi)容:界面布局(UIButton包裹內(nèi)容),通信(基于socket和http),本地存儲(chǔ)(FMDB),酒一點(diǎn)一點(diǎn)喝,內(nèi)容一個(gè)一個(gè)介紹。
界面布局:

cell高度調(diào)整思路:
有自動(dòng)布局和手動(dòng)計(jì)算布局兩種,孰優(yōu)孰劣,各取所需吧,本人更傾向于手動(dòng)布局.這兩種我都嘗試了一下,感覺上使用自動(dòng)布局在,進(jìn)入界面的時(shí)候會(huì)慢一點(diǎn),滑動(dòng)的時(shí)候沒有手動(dòng)計(jì)算的流暢.
cell的布局計(jì)算,網(wǎng)上也有比較多的Demo,在這就不貼代碼.介紹一下主要思路和關(guān)鍵點(diǎn):
- 氣泡圖片的拉伸處理,是在images.xcassets中的Show Slicing中進(jìn)行圖片處理,如果不了解相關(guān)操作,可以戳這里.首先將此圖片設(shè)置為UIButton的背景圖片,然后內(nèi)容自然就是設(shè)置title了,邊距的設(shè)置就設(shè)置UIButton的屬性 contentEdgeInsets來(lái)調(diào)整.
- 數(shù)據(jù)模型,做法是兩個(gè)Model類,一個(gè)是聊天內(nèi)容的Model類(time時(shí)間戳,message內(nèi)容,userID用戶ID等),一個(gè)是計(jì)算出cell布局的FrameModel類,主要用來(lái)根據(jù)傳入聊天內(nèi)容的Model類去計(jì)算出Rect.
//FrameModel類可能是這個(gè)樣子的
/** 頭像的Frame */
@property (nonatomic,assign)CGRect avatarViewFrame;
/** 時(shí)間的Frame */
@property (nonatomic,assign)CGRect timeFrame;
/** 按鈕(內(nèi)容)的Frame */
@property (nonatomic,assign)CGRect btnFrame;
/** cell的高度 */
@property (nonatomic,assign)CGFloat cellHeight;
/**
* 數(shù)據(jù)模型
*/
@property (nonatomic,strong)DTVLetterModel *letterModel;
//根據(jù)letterModel初始化
+(instancetype)modelFrameWithLetterModel:(DTVLetterModel *)letterModel timeIsHidden:(BOOL)timeHidden;
//計(jì)算出Rect的方法,NSString的方法
//size:最大的范圍 options:NSStringDrawingUsesLineFragmentOrigin attributes:NSFontAttributeName:[UIFont systemFontOfSize:字號(hào)] context:nil
- (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options attributes:(nullable NSDictionary<NSString *, id> *)attributes
context:(nullable NSStringDrawingContext *)context NS_AVAILABLE(10_11, 7_0);
//最后返回cell的高度
//比較輸入的內(nèi)容和頭像的Y值哪個(gè)大
CGFloat cellH = MAX(CGRectGetMaxY(frameModel.btnFrame),CGRectGetMaxY(frameModel.avatarViewFrame));
frameModel.cellHeight = cellH + distance;
- 關(guān)于彈出鍵盤高度處理和tableView增加數(shù)據(jù)后滑動(dòng)處理
//對(duì)鍵盤添加通知,這里可以只用UIKeyboardWillChangeFrameNotification,也可以兩個(gè)都用,區(qū)別就是在一個(gè)方法中還是兩個(gè)方法中處理而已
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillChangeFrameNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillHide:) name:UIKeyboardWillHideNotification object:nil];
//在這里會(huì)有一個(gè)處理系統(tǒng)鍵盤和第三方鍵盤彈出高度不同的問(wèn)題,第三方鍵盤彈出時(shí)keyboardWillShow會(huì)被調(diào)用三次,處理判斷后只收到最后一次
- (void)keyboardWillShow:(NSNotification *)notification {
NSDictionary *userInfo = notification.userInfo;
// 動(dòng)畫的持續(xù)時(shí)間
NSNumber *duration = userInfo[UIKeyboardAnimationDurationUserInfoKey];
CGFloat curkeyBoardHeight = [[[notification userInfo] objectForKey:@"UIKeyboardBoundsUserInfoKey"] CGRectValue].size.height;
CGRect begin = [[[notification userInfo] objectForKey:@"UIKeyboardFrameBeginUserInfoKey"] CGRectValue];
CGRect end = [[[notification userInfo] objectForKey:@"UIKeyboardFrameEndUserInfoKey"] CGRectValue];
// 第三方鍵盤回調(diào)三次問(wèn)題,監(jiān)聽僅執(zhí)行最后一次
if(begin.size.height > 0 && (begin.origin.y - end.origin.y > 0)){
if ([_delegate respondsToSelector:@selector(inputView:willShowKeyboardHeight:time:)]) {
//代理
[_delegate inputView:self willShowKeyboardHeight:curkeyBoardHeight time:duration];
}
}
}
- (void)keyboardWillHide:(NSNotification *)notification {
if ([_delegate respondsToSelector:@selector(willHideKeyboardWithInputView:time:)]) {
[_delegate willHideKeyboardWithInputView:self time:[notification.userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey]];
}
}
//控制器中執(zhí)行的鍵盤代理方法,我在這里tableView和下面的工具條是用約束來(lái)布局的,所以兩者也會(huì)跟著一起變化
- (void)inputView:(DTVInPutView *)inputView willShowKeyboardHeight:(CGFloat)height time:(NSNumber *)time {
if (height == 0) return;
//動(dòng)畫調(diào)整視圖的大小,
[UIView animateWithDuration:time.floatValue animations:^{
self.view.frame = CGRectMake(Zero,NavigationBarHeight + StatusBarHeight, self.view.width,Screen_Height - NavigationBarHeight - StatusBarHeight - height);
}completion:^(BOOL finished) {
//讓tableView顯示最后一條數(shù)據(jù)在底部
[self upDataTableViewFrame];
}];
}
//恢復(fù)Frame
- (void)willHideKeyboardWithInputView:(DTVInPutView *)inputView time:(NSNumber *)time {
[UIView animateWithDuration:time.floatValue animations:^{
self.view.frame = CGRectMake(Zero,NavigationBarHeight + StatusBarHeight, self.view.width,Screen_Height - NavigationBarHeight - StatusBarHeight);
}];
}
//作用:讓tableView顯示最后一條數(shù)據(jù)在底部,每次顯示一條數(shù)據(jù)都執(zhí)行一次這個(gè)方法,就可以在進(jìn)入控制器,聊天,彈出鍵盤時(shí)做到信息一直顯示出來(lái)的效果了
- (void)upDataTableViewFrame {
NSUInteger count = self.letterChatArray.count;
if (count > 0) {
NSIndexPath *lastIndex = [NSIndexPath indexPathForRow:count - 1 inSection:0];
[self.tableView scrollToRowAtIndexPath:lastIndex atScrollPosition:UITableViewScrollPositionBottom animated:NO];
[self.view layoutIfNeeded];
}
}
- 時(shí)間的顯示和隱藏,我們用的是上一條信息和最新一條信息相隔5分鐘就顯示時(shí)間,在聊天Model和FrameModel轉(zhuǎn)換時(shí),記錄上一條時(shí)間戳,進(jìn)行時(shí)間戳比較小于300秒就設(shè)置timeFrame高度為0.
界面方向大概就這么些了,如有遺漏,忘各位看官不吝賜教
通信
用戶自己發(fā)送消息,我們用的是HTTP的請(qǐng)求來(lái)傳遞的(當(dāng)然這樣不是很嚴(yán)謹(jǐn),但是后臺(tái)老大這樣做的,而且本來(lái)就是非專業(yè)的嘛).既然這樣各位肯定也就知道了發(fā)消息如何實(shí)現(xiàn)的了.
UITextField的代理方法(textFieldShouldReturn:)和UIButton[發(fā)送]點(diǎn)擊觸發(fā)發(fā)送消息事件,在控制器中處理事件
1:如果輸入字符串長(zhǎng)度0或者空格,提醒用戶;
2:根據(jù)內(nèi)容message,自己的ID,當(dāng)前時(shí)間戳生成一個(gè)Model對(duì)象,存入本地?cái)?shù)據(jù)庫(kù),將Model轉(zhuǎn)為FrameModel添加進(jìn)tableView的數(shù)據(jù)源數(shù)組中,刷新tableView,再讓顯示最后一條數(shù)據(jù)在底部.(需求讓用戶先看到自己發(fā)的東西,再網(wǎng)絡(luò)發(fā)送請(qǐng)求,跟著需求走)
3,發(fā)送請(qǐng)求,把聊天目標(biāo)ID,和內(nèi)容message發(fā)給服務(wù)器.
----------------------------------------------------------------------------------------------------------------------
用戶收消息我們用的是Socket,關(guān)于Socket使用在此不多做贅述,定義好收到消息后Socket傳遞的類型(比如用10086),收到10086類型的數(shù)據(jù),就發(fā)出一個(gè)通知,這個(gè)通知有兩個(gè)觀察者Observer,一個(gè)是本地存儲(chǔ)數(shù)據(jù)用(因?yàn)榉菍I(yè),所以服務(wù)器不存那么多數(shù)據(jù),只能存本地了),把這條信息存入數(shù)據(jù)庫(kù)中,另外一個(gè)是聊天界面,如果收到的消息數(shù)據(jù)里的ID和當(dāng)前聊天界面的目標(biāo)ID一樣,那就得顯示出來(lái),顯示流程和發(fā)送消息一樣.
在這里會(huì)有一個(gè)表情文字混編的問(wèn)題,后臺(tái)老大把HTTP收到的消息進(jìn)行Base64和UTF8編碼,客戶端收到消息再對(duì)內(nèi)容進(jìn)行解碼.
//對(duì)聊天內(nèi)容進(jìn)行解碼
NSString *msgStr = dictionary[@"msg"];
NSData *chatConentData = [[NSData alloc]initWithBase64EncodedString:msgStr options:0];
NSString *chatConent = [[NSString alloc] initWithData:chatConentData encoding:NSUTF8StringEncoding];
本地存儲(chǔ)
本地儲(chǔ)存用的FMDB寫的,github上封裝好的很多,可以根據(jù)需要搜索,各位根據(jù)自己喜好自己選擇存儲(chǔ)方式了.我的方法也是貪圖快,很多細(xì)節(jié)沒有處理特別好,大概流程就是根據(jù)用戶自己的ID創(chuàng)建一個(gè)文件夾(有可能一臺(tái)機(jī)子登錄兩個(gè)賬號(hào),所以用ID標(biāo)識(shí)),每個(gè)目標(biāo)ID創(chuàng)建一個(gè)表,表里存的就是聊天的Model類.這樣插入信息就直接到表的最下方了.之后就是對(duì)表的操作了,增刪改查~每次進(jìn)入聊天界面,就去表里查詢出數(shù)據(jù),轉(zhuǎn)換為聊天的Model,再轉(zhuǎn)為FrameModel.
還有一點(diǎn)是收到新消息的提醒,和提醒用戶未讀有幾條信息,我是用的plist做得.記錄了是否已讀狀態(tài),和未讀的條數(shù),每個(gè)聊天列表的cell顯示時(shí),查詢Plist中記錄,有未讀消息就和QQ一樣顯示出標(biāo)記和未讀條數(shù).(不知道別人怎么做的,我是感覺自己做的挺笨的,可能別人是服務(wù)器端做的,而我們做本地了).每次向數(shù)據(jù)庫(kù)插入信息時(shí),以及當(dāng)點(diǎn)開聊天列表查看詳情時(shí),相應(yīng)修改plist中數(shù)據(jù).
//otherUid:修改閱讀狀態(tài)的ID status:是否已閱讀
- (void)changeChatMarkWithOtherUid:(NSString *)otherUid status:(BOOL)status {
//如果用戶正在聊天界面,目標(biāo)ID和收到的ID一樣,直接返回,不處理
if (self.targetID && [self.targetID.stringValue isEqualToString:otherUid]) {
return;
}
//如果不一致就根據(jù)uidKey找到plist文件中的對(duì)應(yīng)的字典
NSString *uidKey = [NSString stringWithFormat:@"%zd%@",[PTVLoginUserInfo getLoginUserInfo].uid,otherUid];
NSMutableDictionary *markDict = [self readLocalPlist];
//如果此字典存在
if (markDict[uidKey]) {
NSDictionary *dict = markDict[uidKey];
//修改閱讀狀態(tài)
[dict setValue:@(status) forKey:markStatusKey];
//取出未讀的條數(shù)
NSNumber *lastNumber = dict[markNumberKey];
//如果是標(biāo)記未閱讀,未讀條數(shù)+1,否則,修改為0
if (status) {
NSInteger newNumber = lastNumber.integerValue + 1;
[dict setValue:@(newNumber) forKey:markNumberKey];
}else{
[dict setValue:@(0) forKey:markNumberKey];
}
}else{
//如果此字典不存在,就記錄狀態(tài),未讀條數(shù)為1
NSDictionary *dict = @{markStatusKey : @(status),
markNumberKey : @(1)
};
[markDict setObject:dict forKey:uidKey];
}
//寫入本地中
[markDict writeToFile:self.dataPath atomically:YES];
}
發(fā)送圖片我想也可以實(shí)現(xiàn),無(wú)外乎服務(wù)器再傳一個(gè)是文字還是圖片的類型,將圖片data數(shù)據(jù)轉(zhuǎn)為image顯示出來(lái).聊天功能大概就寫了這些,如果有疑問(wèn)或者還有什么遺漏,希望大家提醒一下,我找時(shí)間補(bǔ)充上.
2017年8月11日補(bǔ)充,關(guān)于GCDAsyncSocket適配Ipv6的問(wèn)題
解決方案:如果當(dāng)前是處于IPv6的網(wǎng)絡(luò)環(huán)境中,那就對(duì)該IPv4的IP進(jìn)行轉(zhuǎn)換,拿到一個(gè)IPv6格式的IP進(jìn)行連接。
CocoaAsyncSocket里面有一個(gè)類方法 :
+ (NSMutableArray *)lookupHost:(NSString *)host port:(uint16_t)port error:(NSError **)errPtr
它的作用是:通過(guò)這個(gè)傳入一個(gè)IPv4的IP或者傳入一個(gè)URL可以查到對(duì)應(yīng)的IPv6的IP,前提是在IPv6的網(wǎng)絡(luò)環(huán)境
下,如果是在IPv4的網(wǎng)絡(luò)環(huán)境下,只會(huì)拿到IPv4的IP,當(dāng)判斷查到的array里面有IPv6的IP的時(shí)候就直接用這個(gè)
進(jìn)行連接,否則用IPv4的IP進(jìn)行連接
//針對(duì)ipv6網(wǎng)絡(luò)環(huán)境下適配,ipv4環(huán)境直接使用原來(lái)的地址
- (NSString *)getProperIPWithAddress:(NSString *)ipAddr port:(UInt32)port
{
NSError *addresseError = nil;
NSArray *addresseArray = [GCDAsyncSocket lookupHost:ipAddr port:port error:&addresseError];
if (addresseError) {
LQ_Log(@"轉(zhuǎn)換IPV6出錯(cuò)---%@",addresseError.localizedDescription);
}
NSString *ipv6Addr = @"";
for (NSData *addrData in addresseArray) {
if ([GCDAsyncSocket isIPv6Address:addrData]) {
ipv6Addr = [GCDAsyncSocket hostFromAddress:addrData];
}
}
if (ipv6Addr.length == 0) {
ipv6Addr = ipAddr;
}
return ipv6Addr;
}
每次連接socket的時(shí)候都將連接的ip和port傳進(jìn)來(lái),最后會(huì)輸出一個(gè)合適的ip,用這個(gè)ip和port進(jìn)行連接就好了。