基于XMPP实现即时通讯(三、增删好友、好友列表、发送各种消息)

在实现整套流程时,引入了UI框架MessageDisplayKit和XHPopMenu,可以通过pod直接集成到工程中:pod "MessageDisplayKit", pod "XHPopMenu"
同时引入了腾讯云来存储多媒体消息,具体为腾讯云的对象存储服务: https://www.qcloud.com/product/cos.html 。整个完整的Demo会在最后一节放到个人github上。

一、增删好友

首先了解一下XMPP的通信有三种方式:message、presence和iq,主要内容和区别可以看下:http://my.oschina.net/greki/blog/272867,简单了解下。
然后需要知道好友相关请求使用的是presence, 它有以下几种type:

available: 表示处于在线状态(通知好友在线)
unavailable: 表示处于离线状态(通知好友下线)
subscribe: 表示发出添加好友的申请(添加好友请求)
unsubscribe: 表示发出删除好友的申请(删除好友请求)
unsubscribed: 表示拒绝添加对方为好友(拒绝添加对方为好友)
error: 表示presence信息报中包含了一个错误消息。(出错)

1. 添加好友

先申请花名册并进行激活

//花名册相关
var _xmppRoster: XMPPRoster!
var _xmppRosterStorage = XMPPRosterCoreDataStorage.sharedInstance()     //花名单存储

在activeXMPPModules方法中激活模块:

//花名册
 _xmppRoster = XMPPRoster(rosterStorage: _xmppRosterStorage)
 //自动获取用户列表
 _xmppRoster.autoFetchRoster = true
 _xmppRoster.autoAcceptKnownPresenceSubscriptionRequests = true
 //激活&添加代理
 _xmppRoster.activate(_xmppStream)
 _xmppRoster.addDelegate(self, delegateQueue: dispatch_get_main_queue())

 //重连
 _xmppReconnect = XMPPReconnect()
 _xmppReconnect.activate(_xmppStream)

功能实现:

/**
     添加好友

 - parameter userId:         好友id
 - parameter addFriendBlock: 添加好友请求的结果回调(并非好友回复的结果,只是请求是否发送成功的回调)
 */
func addFriend(friendId: String, addFriendBlock: LGXMPP_AddOrDeleteFriendRequestBlock?) {

    let friendJidString = self.getChatJidString(friendId)
    //先判断是不是添加了自己
    if friendJidString == "\(_userId!)@\(vHostChat)"{
        if addFriendBlock != nil{
            addFriendBlock!(isSuccess: false, faildMsg: "你不能添加你自己哦")
            return
        }
    }

    // 先判断是否已经是我的好友,如果是,就不再添加
    let userJID = XMPPJID.jidWithString(friendJidString)
    if let theFirendData = _xmppRosterStorage.userForJID(userJID, xmppStream: _xmppStream, managedObjectContext: _xmppRosterStorage.mainThreadManagedObjectContext){
        if theFirendData.subscription == "to" || theFirendData.subscription == "both"{
            if addFriendBlock != nil{
                addFriendBlock!(isSuccess: false, faildMsg: "\(friendJidString)已经是你的好友了或者已发送过请求了哦")
                return
            }
        }

    }
    _addFriendBlock = addFriendBlock
    _xmppRoster.subscribePresenceToUser(userJID)
}

2. 删除好友

func deleteFriend(friendJid: XMPPJID, deleteFriendBlock: LGXMPP_AddOrDeleteFriendRequestBlock?) {
    _deleteFriendBlock = deleteFriendBlock
    _xmppRoster.removeUser(friendJid)
}

3. 增删好友的相关回调

// MARK: ——————————– 好友列表相关回调 ——————————-

//获取到一个好友节点- 已经互为好友以后,会回调此方法
func xmppRoster(sender: XMPPRoster!, didReceiveRosterItem item: DDXMLElement!) {
    DPrintln("11 item = \(item)")
}

func xmppRosterDidEndPopulating(sender: XMPPRoster!) {
    DPrintln("好友列表加载完毕")
}

在下面两个回调中响应好友申请请求及被删除的回调,可能会有人疑惑为何只有提示框和选择交互部分内容,没有数据库更新,UI更新通知等内容。这个是因为在下一节好友列表获取中,可以把好友列表搜索器设置成UI界面的代理,当好友列表有任何信息更新时,让UI界面的tableview.reloadData()即可,所以这两个方法只要让用户进行是否同意的选择及相关提示即可

    func xmppRoster(sender: XMPPRoster!, didReceivePresenceSubscriptionRequest presence: XMPPPresence!) {
        DPrintln("\(presence)")

        // 好友在线状态
        let type = presence.type()
        let fromUser = presence.from().user
        let user = _xmppStream.myJID.user
        DPrintln("接收到状态为:\(type),来自发送者\(fromUser),接收者\(user)")

        // 防止自己添加自己为好友
        if fromUser != user{
            switch type {
            case "available":
                DPrintln("好友上线")
            case "away":
                DPrintln("好友离开")
            case "do not disturb":
                DPrintln("好友忙碌")
            case "unavailable":
                DPrintln("好友下线")
            case "subscribe":
                DPrintln("请求添加好友")
                _addMeJid = presence.from()
                let alert = UIAlertView(title: "好友申请", message: "\(fromUser)请求添加你为好友,是否同意?", delegate: self, cancelButtonTitle: "取消", otherButtonTitles: "确定")
                alert.tag = vAddFriendAlertTag
                alert.show()
            case "unsubscribe":
                DPrintln("请求并删除了我这个好友")
                case "unsubscribed":
                DPrintln("对方拒绝了我的好友请求")
                case "error":
                DPrintln("错误信息")
            default:
                DPrintln("其它信息 type = \(type)")
            }
        }
    }

    func xmppRoster(sender: XMPPRoster!, didReceiveRosterPush iq: XMPPIQ!) {

        let query = iq.elementForName("query").childAtIndex(0) as! DDXMLElement

        let jidString = query.attributeForName("jid").stringValue()
        let subscription = query.attributeForName("subscription").stringValue()
        if let ask = query.attributeForName("ask")
        {
            DPrintln("请求类型 \(ask.stringValue())")
            if _addFriendBlock != nil{
                _addFriendBlock!(isSuccess: true, faildMsg: nil)
                _addFriendBlock = nil
            }
            return
        }

        switch subscription {
        case "from":
            DPrintln("我已同意对方添加我为好友,关系确认成功")
            //此处不用提示,因为同意对方后,就会双方都变成彼此的好友,会进入both,同时 from会回调两次,ask也会被调用两次(从from变成both)
//            Tools.shared.showAlertViewAndDismissDefault("你已经成为\(jidString)的好友", message: nil)

        case "to":
            DPrintln("添加对方为好友成功,或者被对方删除")
            //此处不用提示,因为添加好友:默认对方同意后,就会双方都变成彼此的好友,会进入both,同时 to会回调两次
            //还有可能是被对方删除我这个好友的回调
//            Tools.shared.showAlertViewAndDismissDefault("\(jidString)已经成为你的好友", message: nil)
        case "both":
            DPrintln("添加好友成功,彼此成为好友")
            //此处不用提示,因为默认对方同意后,就会双方都变成彼此的好友,会进入both,同时 to会回调两次
            Tools.shared.showAlertViewAndDismissDefault("\(jidString)和你已经互为好友", message: nil)

        case "remove":
            DPrintln("删除好友成功")
            if _deleteFriendBlock != nil{
                //删除会调用两次,subscription会从 from(带ask) 变成 none(带ask),最后是remove
                _deleteFriendBlock!(isSuccess: true, faildMsg: nil)
                _deleteFriendBlock = nil
            }

        default:
                DPrintln("subscription = \(subscription))")
            }
}

  // MARK:  ---------------- UIAlertView delegate -----------------
    func alertView(alertView: UIAlertView, clickedButtonAtIndex buttonIndex: Int) {

        DPrintln("check index = \(buttonIndex)")
        if alertView.tag == vAddFriendAlertTag{
        if buttonIndex == 1{
            DPrintln("同意添加好友")
            _xmppRoster.acceptPresenceSubscriptionRequestFrom(_addMeJid!, andAddToRoster: true)
            _addMeJid = nil
        }else{
            Println("拒接好友申请")
            _xmppRoster.rejectPresenceSubscriptionRequestFrom(_addMeJid!)
            _addMeJid = nil
        }
    }

二、好友列表

好友列表可以从服务器获取,也可以在本地XMPP默认数据库中获取(XMPPRosterCoreDataStorage.sharedInstance() 中),通常用后者实现,设置代理后可自动监听好友状态的更新

//好友列表-//好友搜索结果控制器
       func getFriendList(sucessResult: (NSFetchedResultsController -> Void)?, faildMsg: (String? -> Void)?){

           if !_xmppStream.isAuthenticated()
           {
               if faildMsg != nil{
                   return faildMsg!("请先登录哦")
               }
           }

           let context = _xmppRosterStorage.mainThreadManagedObjectContext
           //从CoreData中获取数据
           //通过实体获取FetchRequest实体
           let request = NSFetchRequest(entityName: NSStringFromClass(XMPPUserCoreDataStorageObject))

   //        //添加排序规则
   //        let sortFriend = NSSortDescriptor(key: "jidStr", ascending: true)
   //        request.sortDescriptors = [sortFriend]

           // 在线状态排序
           let sortOnLine = NSSortDescriptor(key: "sectionNum", ascending: true)
           // 显示的名称排序
           let sortByName = NSSortDescriptor(key: "displayName", ascending: true)

           // 添加排序
           request.sortDescriptors = [sortOnLine, sortByName]

           // 添加谓词过滤器 状态为None的排除(加好友对方还没确认,或者好友关系,被对方删除)
           request.predicate = NSPredicate(format: "!(subscription CONTAINS 'none')")

           dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {

               //获取FRC
               let friendResultsController = NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
               do {
                   try friendResultsController.performFetch()
               } catch let error as NSError {

                   dispatch_async(dispatch_get_main_queue(), {
                       if faildMsg != nil{
                           return faildMsg!(error.description)
                       }
                   })
               }

               dispatch_async(dispatch_get_main_queue(), {
                   if sucessResult != nil{
                       return sucessResult!(friendResultsController)
                   }
               })
           }
       }

使用时,将得到的friendResultsConrtoller.sections.count作为UI显示好友列表tableview的numberOfSectionsInTableView,sections对应位置的numberOfObjects为numberOfRowsInSection,friendResultsConrtoller?.objectAtIndexPath(indexPath) 可以得到一个XMPPUserCoreDataStorageObject对象,作为每个cell的UI数据源。
同时将friendResultsConrtoller的代理设置成这个好友列表的UIViewController,在其代理 方法controllerDidChangeContent实现self.tableView.reloadData(),即可针对数据库更新来更新UI。

三、发送各种消息

1. 发送

在具体项目中实现发送文字以外的消息时(图片,视频,语音等),通常还是采用文字消息的xml方式来通过XMPP传递,只是增加自已约定的属性来区分真正的消息类型,多媒体消息通常会先上传到服务器,发送时传递给对方具体URL和相关属性即可;
表情的发送也可以走文字xml格式,只是标记真正类型为表情,同时传递的内容是表情名称或URL地址,接受方解析完成后直接将工程或网络中的表情显示出来即可。
下面看具体Demo方法

/**
     发送消息(文字,图片,视频,语音等所有格式),包括单独聊天和群聊

     - parameter messageType:      消息类型,来自MessageDisplayKit,也可以自己实现枚举
     - parameter messageURL:       多媒体消息时,传递的是URL
     - parameter messageText:      文字消息时的文字内容
     - parameter otherMessage:     其它附带属性
     - parameter receiveId:        发送对象的id,可能是某个人也可以是某个房间id
     - parameter isRoomChat:       是否是聊天室(房间)聊天
     - parameter sendMessageBlock: 发送成功后的回调(来确保消息已成功到达服务器)
     */
    func sendMessage(messageType: XHBubbleMessageMediaType, messageURL: String?, messageText: String?, otherMessage: String?, receiveId: String, isRoomChat: Bool,sendMessageBlock: LGXMPP_SendMessageBlock) {

        _sendMessageBlock = nil
        _sendMessageBlock = sendMessageBlock

        let bodyElement = DDXMLElement(name: "body")
        if messageType == .Text{
            bodyElement.setStringValue(messageText)
        }else {
            bodyElement.setStringValue(messageURL)
        }

        let messageElement = DDXMLElement(name: "message")
        messageElement.addAttributeWithName("type", stringValue: isRoomChat ? "groupchat" : "chat")
        messageElement.addAttributeWithName("releayType", stringValue: self.getMessageTypeString(messageType))
        if otherMessage != nil{
            messageElement.addAttributeWithName("otherMessage", stringValue: otherMessage!)
        }

        let recevieJidString = self.getChatJidString(receiveId)
        messageElement.addAttributeWithName("to", stringValue: recevieJidString)
        messageElement.addChild(bodyElement)
        _xmppStream.sendElement(messageElement)
    }

对应的getMessageTypeString方法是将枚举转换成可传递的string,接受者收到这个string后通过getMessageTypeFromString来还原成对应的type

2. 发送回调

// MARK:  --------------------------------  发送消息-回调  -------------------------------
   //message是一种基本推送消息方法,它不要求响应。主要用于IM、groupChat、alert和notification之类的应用中。
   func xmppStream(sender: XMPPStream!, didSendMessage message: XMPPMessage!) {
       DPrintln("发送成功")
       if _sendMessageBlock != nil{
           _sendMessageBlock!(isSucess: true, faildMsg: nil)
       }
   }

   func xmppStream(sender: XMPPStream!, didFailToSendMessage message: XMPPMessage!, error: NSError!) {
       Tools.shared.showAlertViewAndDismissDefault(nil, message: "消息发送失败")
       if _sendMessageBlock != nil{
           _sendMessageBlock!(isSucess: false, faildMsg: error.description)
       }
   }

3. 接收多种消息类型

结合发送消息复用文字(”chat”)类型的实现方式,接收消息也要对应进行解析,其中getXHMessageFromXMPPMessage方法可以解析当前收到的消息,也可以解析从数据库中得到的历史记录消息。
关于已读和未读,本地设备可以再建一个记录消息数据库,标记已读未读红点等,同时在后台实现本地推送;服务器端需要建立离线消息推送机制;如果涉及到多设备的同步就更复杂了,本Demo不讨论已读状态的实现

// MARK:  --------------------------------  接收消息-来自XMPP回调  -------------------------------
   func xmppStream(sender: XMPPStream!, didReceiveMessage message: XMPPMessage!) {
       DPrintln("收到消息 \(message)")

       if message.isChatMessageWithBody(){

           let newMessage = self.getXHMessageFromXMPPMessage(message, messageSender: message.from().full(), isUserSendMessage: false, isHistory: false)
           //用通知的方式来触发聊天界面刷新相关UI
           NSNotificationCenter.defaultCenter().postNotificationName(kXMPPNewMessage, object: newMessage, userInfo: nil)
       }
       else{
           DPrintln("收到其它类型消息/非正常消息")
           //在这里可以判断是否是消息回执,对消息回执作处理,可参考:http://blog.csdn.net/huwenfeng_2011/article/details/43459039
           //消息回执说明文档 http://xmpp.org/extensions/xep-0184.html
       }
   }

   /**
    将XMPPMessage转成和UI可对应的XHMessage,(XHMessage来自MessageDisplayKit,也可以实现自己的UI模型)

    - parameter message:           收到的消息
    - parameter messageSender:     消息发送者
    - parameter isUserSendMessage: 是否是我自己发送的消息
    - parameter isHistory:         是否是历史记录

    - returns: 返回XHMessage
    */
   func getXHMessageFromXMPPMessage(message: XMPPMessage, messageSender: String?, isUserSendMessage: Bool, isHistory: Bool) -> XHMessage {

       var sender = messageSender
       if sender == nil{
           //发送者为空,判定为自己发出去的信息
          sender = "\(_userId!)@\(vHostChat)"
       }
       let nowTime = NSDate()
       let mediaText = message.body()

       var newMessage: XHMessage
       var messageType = XHBubbleMessageMediaType.Text
       if let releayType = message.attributeForName("releayType"){
           messageType = self.getMessageTypeFromString(releayType.stringValue())
       }

       switch messageType {
       case .Emotion:
           newMessage = XHMessage(emotionPath: NSBundle.mainBundle().pathForResource(mediaText, ofType: nil), sender: sender, timestamp: nowTime)
       case .LocalPosition:
           newMessage = XHMessage(text: "发送了位置信息", sender: sender, timestamp: nowTime)
       case .Photo:
           newMessage = XHMessage(photo: nil, thumbnailUrl: mediaText, originPhotoUrl: nil, sender: sender, timestamp: nowTime)
       case .Text:
           newMessage = XHMessage(text: mediaText, sender: sender, timestamp: nowTime)
       case .Video:
           newMessage = XHMessage(videoConverPhoto: nil, videoPath: nil, videoUrl: mediaText, sender: sender, timestamp: nowTime)
       case .Voice:
           var durantion = "60"
           if let durantionMessage = message.attributeForName("otherMessage"){
               durantion = durantionMessage.stringValue()
           }

           newMessage = XHMessage(voicePath: nil, voiceUrl: mediaText, voiceDuration: durantion, sender: sender, timestamp: nowTime)
           //当前收到的消息,通过腾讯云来下载语音(可以放到UI界面来实现)
           //历史记录复用此方法时,通过查询Url来得到可能已存在本地的voicepath
           let resultPath = LGTXCloudManager.shared.getFilePathFromURLString(mediaText, typeString: "file")
           if NSFileManager.defaultManager().fileExistsAtPath(resultPath){
               newMessage.voicePath = resultPath
           }

       }
       DPrintln("sender = \(sender)")
       if isUserSendMessage {
           newMessage.bubbleMessageType = .Sending         //发送消息
       }else{
           newMessage.bubbleMessageType = .Receiving       //接收消息
       }
       //未读已读推送等需定制化实现,这里简单将历史信息全部标记成已读
       newMessage.isRead = isHistory
       return newMessage
   }

作者 @代码书生
2016 年 07月 29日

尘满面,鬓如霜,Bug多多岂不白忙?重敏捷,保质量,Case重重亦可远航。^.^