千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)

本文篇幅较长,共计19352字,预计阅读时长1-2h。

这是《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成》的第三篇:《APP 内部流程与逻辑》
系列文章可参考:
《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(一)》:Demo演示与IM设计
《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(二)》:UI设计与搭建
《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成》4:服务端搭建与总结(即将开放)

三、APP 内部流程与逻辑

1. 数据库操作

作为即时通讯的App,将会有很多的数据需要本地存储。比如联系人的信息,聊天的历史消息。假设如果每次都是从网上实时拉取对话的历史聊天记录,当一个延绵不绝的会话超过1个月以上时,比如,你和你好友的会话,那海量的聊天记录,不经时间漫长,而且还会让用户的带宽痛苦不已~~

所以,我们将使用数据库保存联系人信息和聊天记录。
对于数据库的选择,一向是够用就行,第三方依赖越少越好。刚好iOS自带SQLite,作为数据库的老手,我们决定直接使用SQLite,至于SQLite.swift这个知名的第三方库,目前感觉至少在这个Demo中,没有使用的必要。

1.1 添加SQLite库
打开项目属性页面,选择“TARGETS” -> “IMDemo” -> “General”:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第1张图片

点击页面上 “Frameworks, Libraries, and Embendded Content” 栏下方的“+”号,打开框架与库添加窗口:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第2张图片

在搜索框内输入“SQLite”,选择“libsqlite3.tbd”,点击“Add”按钮进行添加。
添加完毕后,“Frameworks, Libraries, and Embendded Content” 一栏显示为:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第3张图片

1.2. 初始化数据库
添加 swift 代码文件 DBOperator.swift,编辑代码如下:

class DBOperator {
    
    var db: OpaquePointer?
    
    init() {
        db = nil
    }
    
    deinit {
        if db != nil {
            sqlite3_close(db)
        }
    }
    
    func openDatabase(userId:Int64) {
        //-- TODO
    }
}

因为每个用户的数据需要隔离,所以最简单的方式便是每个用户使用一个独立的数据库。因此我们无法在App一起动的时候便打开数据库,而需要等到登录成功后,获取到了用户唯一的 userId,才可知道,该为谁打开数据库,应该打开那个数据库。

因此,我们完成 openDatabase() 的代码如下:

func openDatabase(userId:Int64) {

        let DocumentsPath =  NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.documentDirectory, FileManager.SearchPathDomainMask.userDomainMask, true).first!
        
        var databasePath = DocumentsPath + "/user_\(userId)"
        try! FileManager.default.createDirectory(at: URL(string: "file://" + databasePath)!, withIntermediateDirectories: true, attributes: nil)
        
        databasePath += "/database.sql3"
        
        let requireCreateTables = !(FileManager.default.fileExists(atPath: databasePath))
        
        if sqlite3_open(databasePath, &db) == SQLITE_OK {
            if requireCreateTables {
                createContactTable()
            }
        } else {
            print("Open database at " + databasePath + " failed.")
        }
    }
    
    private func createContactTable() {
        //-- TODO   
    }

每次打开数据库时,将先检查目标数据库是否存在,不存在则进行创建。

我们设计联系人信息表如下:

CREATE TABLE IF NOT EXISTS Contact(
  kind int not null,
  xid bigint not null,
  xname varchar(255) not null,
  nickname varchar(255) not null,
  imgUrl varchar(255) not null,
  imgPath varchar(255) not null,
  info varchar(255) not null,
  unique(kind, xid)
)

历史消息表如下:

CREATE TABLE IF NOT EXISTS message(
    kind int not null,
    xid bigint not null,
    senderUid bigint not null,
    isCmd tinyint not null default 0,
    mid bigint not null,
    message varchar(255) not null,
    mtime bigint not null,
    unique(kind, xid, senderUid, mid)
)

此外,如果历史消息非常多,一次拉取不完,则需要分段拉取。而如果在分段拉取的过程中,用户退出,便会造成拉取的中断。而此时如果一段时间后,用户再登录,则必然需要先拉取最新的历史消息,而不是从上次中断处继续拉取。而如果在拉取到上次中断处之前,用户再次退出,则拉取将再次停止。而此时,历史消息连续性上的空洞便产生了。
为了避免历史消息连续性上的空洞造成的消息遗留,我们还需要第三个表,来储存历史消息的中断位置,和中断的拉取方向。于是我们增加历史消息检查点数据表如下:

CREATE TABLE IF NOT EXISTS checkpoint(
    kind int not null,
    xid bigint not null,
    ts bigint not null,
    desc int not null,
    unique(kind, xid, ts, desc)
)

因为很多时候,我们知道需要执行的SQL必然会成功,而且我们不关心其状态和返回值。
因此,我们增加 executeSQL() 函数如下:

    private func executeSQL(sql: String, printError: Bool = true) -> Bool {
        
        if (sqlite3_exec(db, sql.cString(using: String.Encoding.utf8)!, nil, nil, nil) == SQLITE_OK) {
            return true
        } else {
            if printError, let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
            return false
        }
    }

最后,完成 createContactTable() 函数如下:

    private func createContactTable() {
        
        //-- Contact Kind: 0: 非联系人用户;1: 联系人用户;2: 群组;3: 房间。
        let contactSQL = """
    CREATE TABLE IF NOT EXISTS Contact(
        kind int not null,
        xid bigint not null,
        xname varchar(255) not null,
        nickname varchar(255) not null,
        imgUrl varchar(255) not null,
        imgPath varchar(255) not null,
        info varchar(255) not null,
        unique(kind, xid)
    )
"""
        
        let messageSQL = """
        CREATE TABLE IF NOT EXISTS message(
            kind int not null,
            xid bigint not null,
            senderUid bigint not null,
            isCmd tinyint not null default 0,
            mid bigint not null,
            message varchar(255) not null,
            mtime bigint not null,
            unique(kind, xid, senderUid, mid)
        )
"""
        let historyCheckpointSQL = """
        CREATE TABLE IF NOT EXISTS checkpoint(
            kind int not null,
            xid bigint not null,
            ts bigint not null,
            desc int not null,
            unique(kind, xid, ts, desc)
        )
"""
        
        _ = executeSQL(sql: contactSQL)
        _ = executeSQL(sql: messageSQL)
        _ = executeSQL(sql: historyCheckpointSQL)
    }

然后编辑 IMCenter.swift,加入对 DBOperator的引用:

class IMCenter {
    ... ...
    static var db = DBOperator()
    ... ...
}

1.3. 数据库的其他操作
我们添加IMDemoApp所需的数据库其余操作如下,具体功能请参见函数注释:

    //-- 保存新收到的聊天消息
    func insertChatMessage(type:Int, xid:Int64, sender:Int64, mid:Int64, message:String, mtime:Int64, printError: Bool = true) -> Bool {
        let sql = """
        insert into message (kind, xid, senderUid, mid, message, mtime) values
            (\(type), \(xid), \(sender), \(mid), '\(message)', \(mtime))
"""
        
        objc_sync_enter(self)
        let status = executeSQL(sql:sql, printError: printError)
        objc_sync_exit(self)
        
        return status
    }
    
    //-- 保存新收到的系统通知
    func insertChatCmd(type:Int, xid:Int64, sender:Int64, mid:Int64, message:String, mtime:Int64, printError: Bool = true) -> Bool {
        let sql = """
        insert into message (kind, xid, senderUid, mid, message, mtime, isCmd) values
            (\(type), \(xid), \(sender), \(mid), '\(message)', \(mtime), 1)
"""
        objc_sync_enter(self)
        let status = executeSQL(sql:sql, printError: printError)
        objc_sync_exit(self)
        
        return status
    }
    
    //-- 插入历史消息检查点
    func insertCheckPoint(type:Int, xid:Int64, ts:Int64, desc:Bool) {
        let sql = """
        insert into checkpoint (kind, xid, ts, desc) values
            (\(type), \(xid), \(ts), \(desc ? 1 : 0))
"""
        objc_sync_enter(self)
        _ = executeSQL(sql: sql)
        objc_sync_exit(self)
    }
    
    //-- 更新头像本地存储信息
    func updateImageStoreInfo(type: Int, xid: Int64, filePath: String) {
        let sql = "update Contact set imgPath='\(filePath)' where kind=\(type) and xid=\(xid)"
        
        objc_sync_enter(self)
        _ = executeSQL(sql: sql)
        objc_sync_exit(self)
    }
    
    //-- 保存新的联系人信息
    private func insertNewContact(contact: ContactInfo, printError: Bool = true) -> Bool {
        let sql = """
        insert into Contact (kind, xid, xname, nickname, imgUrl, imgPath, info) values (
            \(contact.kind), \(contact.xid), '\(contact.xname)', '\(contact.nickname)',
            '\(contact.imageUrl)', '\(contact.imagePath)', '\(contact.showInfo)')
"""

        return executeSQL(sql: sql, printError: printError)
    }
    
    //-- 保存新的联系人信息
    func storeNewContact(contact: ContactInfo) {
        let updateSQL = """
        update Contact set nickname='\(contact.nickname)', imgUrl='\(contact.imageUrl)',
        imgPath='\(contact.imagePath)', info='\(contact.showInfo)'
        where kind=\(contact.kind) and xid=\(contact.xid)
"""
        
        objc_sync_enter(self)
        if (insertNewContact(contact: contact, printError: false)) {
        } else if (sqlite3_exec(db, updateSQL.cString(using: String.Encoding.utf8)!, nil, nil, nil) == SQLITE_OK) {
        } else {
            if let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
        }
        objc_sync_exit(self)
    }
    
    //-- 当添加好友时,如果对方已经作为陌生人被保存在联系人数据表中,则修改陌生人状态为好友状态
    func changeStrangerToFriend(xid:Int64) {
        let sql = """
        update Contact set kind=\(ContactKind.Friend.rawValue) where kind=\(ContactKind.Stranger.rawValue) and xid=\(xid)
"""
        objc_sync_enter(self)
        _ = executeSQL(sql:sql)
        objc_sync_exit(self)
    }
    
    //-- 更新全剧唯一的联系人注册名称
    func updateXname(contact: ContactInfo) {
        let sql = """
        update Contact set xname='\(contact.xname)' where kind=\(contact.kind) and xid=\(contact.xid)
"""
        objc_sync_enter(self)
        if (sqlite3_exec(db, sql.cString(using: String.Encoding.utf8)!, nil, nil, nil) == SQLITE_OK) {
        } else if (insertNewContact(contact: contact, printError: false)) {
        } else {
            if let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
        }
        objc_sync_exit(self)
    }
    
    //-- 更新联系人公开信息:包含 昵称/展示名、头像地址、头像本地存储路径、用户签名/群组公告
    func updatePublicInfo(contact: ContactInfo) {
        let sql = """
        update Contact set nickname='\(contact.nickname)', imgUrl='\(contact.imageUrl)', imgPath='\(contact.imagePath)', info='\(contact.showInfo)'
        where kind=\(contact.kind) and xid=\(contact.xid)
"""
        
        objc_sync_enter(self)
        if (sqlite3_exec(db, sql.cString(using: String.Encoding.utf8)!, nil, nil, nil) == SQLITE_OK) {
        } else if (insertNewContact(contact: contact, printError: false)) {
        } else {
            if let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
        }
        objc_sync_exit(self)
    }
    
    //-- 从数据库获取所有联系人信息,陌生人除外
    func loadContentInfos() -> [ContactInfo] {
        
        let sql = """
    select kind, xid, xname, nickname, imgUrl, imgPath, info from Contact where kind <> 0
"""
        var result: [ContactInfo] = []
        var statement: OpaquePointer? = nil
        
        objc_sync_enter(self)
        
        if sqlite3_prepare_v2(db, sql.cString(using: String.Encoding.utf8)!, -1, &statement, nil) == SQLITE_OK {
            while sqlite3_step(statement) == SQLITE_ROW {
                
                let info = ContactInfo()
                
                info.kind = Int(sqlite3_column_int(statement, 0))
                info.xid = sqlite3_column_int64(statement, 1)
                
                var chars = UnsafePointer(sqlite3_column_text(statement, 2))
                info.xname = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 3))
                info.nickname = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 4))
                info.imageUrl = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 5))
                info.imagePath = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 6))
                info.showInfo = String.init(cString: chars!)
                
                result.append(info)
            }
        } else {
            if let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
        }
        
        sqlite3_finalize(statement)
        objc_sync_exit(self)
        return result
    }
    
    //-- 按类别从数据库获取所有陌联系人和好友信息
    func loadAllUserContactInfos() -> [Int64:ContactInfo] {
        
        let sql = """
    select kind, xid, xname, nickname, imgUrl, imgPath, info from Contact where kind in (0, 1)
"""
        var result: [Int64:ContactInfo] = [:]
        var statement: OpaquePointer? = nil
        
        objc_sync_enter(self)
        
        if sqlite3_prepare_v2(db, sql.cString(using: String.Encoding.utf8)!, -1, &statement, nil) == SQLITE_OK {
            while sqlite3_step(statement) == SQLITE_ROW {
                
                let info = ContactInfo()
                
                info.kind = Int(sqlite3_column_int(statement, 0))
                info.xid = sqlite3_column_int64(statement, 1)
                
                var chars = UnsafePointer(sqlite3_column_text(statement, 2))
                info.xname = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 3))
                info.nickname = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 4))
                info.imageUrl = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 5))
                info.imagePath = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 6))
                info.showInfo = String.init(cString: chars!)
                
                result[info.xid] = info
            }
        } else {
            if let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
        }
        
        sqlite3_finalize(statement)
        objc_sync_exit(self)
        return result
    }
    
    //-- 从数据库获取特定联系人的信息
    func loadContentInfo(type:Int, uid:Int64) -> ContactInfo? {
        
        let sql = """
    select xname, nickname, imgUrl, imgPath, info from Contact where kind=\(type) and xid=\(uid)
"""
        var contact: ContactInfo? = nil
        var statement: OpaquePointer? = nil
        
        objc_sync_enter(self)
        
        if sqlite3_prepare_v2(db, sql.cString(using: String.Encoding.utf8)!, -1, &statement, nil) == SQLITE_OK {
            while sqlite3_step(statement) == SQLITE_ROW {
                
                let info = ContactInfo()
                
                info.kind = type
                info.xid = uid
                
                var chars = UnsafePointer(sqlite3_column_text(statement, 0))
                info.xname = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 1))
                info.nickname = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 2))
                info.imageUrl = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 3))
                info.imagePath = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 4))
                info.showInfo = String.init(cString: chars!)
                
                contact = info
            }
        } else {
            if let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
        }
        
        sqlite3_finalize(statement)
        objc_sync_exit(self)
        return contact
    }
    
    //-- 从数据库获取特定联系人的信息
    func loadContentInfo(type:Int, xname:String) -> ContactInfo? {
        
        let sql = """
    select xid, nickname, imgUrl, imgPath, info from Contact where kind=\(type) and xname='\(xname)'
"""
        var contact: ContactInfo? = nil
        var statement: OpaquePointer? = nil
        
        objc_sync_enter(self)
        
        if sqlite3_prepare_v2(db, sql.cString(using: String.Encoding.utf8)!, -1, &statement, nil) == SQLITE_OK {
            while sqlite3_step(statement) == SQLITE_ROW {
                
                let info = ContactInfo()
                
                info.kind = type
                info.xname = xname
                
                info.xid = sqlite3_column_int64(statement, 0)
                
                var chars = UnsafePointer(sqlite3_column_text(statement, 1))
                info.nickname = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 2))
                info.imageUrl = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 3))
                info.imagePath = String.init(cString: chars!)
                
                chars = UnsafePointer(sqlite3_column_text(statement, 4))
                info.showInfo = String.init(cString: chars!)
                
                contact = info
            }
        } else {
            if let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
        }
        
        sqlite3_finalize(statement)
        objc_sync_exit(self)
        return contact
    }
    
    //-- 从数据库获取特定联系人在本地存储的最新一条历史消息
    private func loadLastMessage(type: Int, xid: Int64) -> LastMessage {

        let sql = "select mid, message, mtime from message where kind=\(type) and xid=\(xid) and isCmd=0"
        var lastMessage = LastMessage()
        var statement: OpaquePointer? = nil
        
        objc_sync_enter(self)
        
        if sqlite3_prepare_v2(db, sql.cString(using: String.Encoding.utf8)!, -1, &statement, nil) == SQLITE_OK {
            while sqlite3_step(statement) == SQLITE_ROW {
                
                lastMessage.mid = sqlite3_column_int64(statement, 0)
                
                let chars = UnsafePointer(sqlite3_column_text(statement, 1))
                lastMessage.message = String.init(cString: chars!)

                lastMessage.timestamp = sqlite3_column_int64(statement, 2)
            }
        } else {
            if let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
        }
        
        sqlite3_finalize(statement)
        objc_sync_exit(self)
        
        return lastMessage
    }
    
    //-- 从数据库获取指定的联系人在本地存储的最新一条历史消息,并将结果以会话列表的形式返回
    func loadLastMessage(contactList: [ContactInfo]) -> [SessionItem] {
        
        var sessions = [SessionItem]()
        
        for contact in contactList {
            let sessionItem = SessionItem(contact: contact)
            sessionItem.lastMessage = loadLastMessage(type: contact.kind, xid: contact.xid)
            sessions.append(sessionItem)
        }

        return sessions
    }
    
    //-- 从数据库获取指定联系人在本地保存的所有历史聊天记录
    func loadAllMessages(contact:ContactInfo) -> [ChatMessage] {
        let sql = """
        select senderUid, mid, message, mtime, isCmd from message where kind=\(contact.kind) and xid=\(contact.xid) order by mtime asc
"""
        
        var messages = [ChatMessage]()
        var statement: OpaquePointer? = nil
        
        objc_sync_enter(self)
        
        if sqlite3_prepare_v2(db, sql.cString(using: String.Encoding.utf8)!, -1, &statement, nil) == SQLITE_OK {
            while sqlite3_step(statement) == SQLITE_ROW {
                
                let senderUid = sqlite3_column_int64(statement, 0)
                let mid = sqlite3_column_int64(statement, 1)
                
                let chars = UnsafePointer(sqlite3_column_text(statement, 2))
                let messageContent = String.init(cString: chars!)

                let mtime = sqlite3_column_int64(statement, 3)
                
                let message = ChatMessage(sender: senderUid, mid: mid, mtime: mtime, message: messageContent)
                if sqlite3_column_int(statement, 4) != 0 {
                    message.isChat = false
                }
                
                messages.append(message)
            }
        } else {
            if let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
        }
        
        sqlite3_finalize(statement)
        objc_sync_exit(self)

        return messages
    }
    
    //-- 获取指定联系人所有的历史消息检查点信息
    func loadAllHistoryMessageCheckpoints(contact:ContactInfo) -> [HistoryCheckpoint] {
        let sql = """
        select ts, desc from checkpoint where kind=\(contact.kind) and xid=\(contact.xid)
"""
        
        var checkpoints = [HistoryCheckpoint]()
        var statement: OpaquePointer? = nil
        
        objc_sync_enter(self)
        
        if sqlite3_prepare_v2(db, sql.cString(using: String.Encoding.utf8)!, -1, &statement, nil) == SQLITE_OK {
            while sqlite3_step(statement) == SQLITE_ROW {
                
                let checkpoint = HistoryCheckpoint()
                
                checkpoint.ts = sqlite3_column_int64(statement, 0)
                checkpoint.desc = (sqlite3_column_int(statement, 1) > 0) ? true : false
                
                checkpoints.append(checkpoint)
            }
        } else {
            if let error = String(validatingUTF8:sqlite3_errmsg(db)) {
                print("SQL execute failed. Error: \(error)")
            }
        }
        
        sqlite3_finalize(statement)
        objc_sync_exit(self)

        return checkpoints
    }
    
    //-- 清除指定联系人所有的历史消息检查点信息
    func clearAllHistoryMessageCheckpoints(contact:ContactInfo) {
        let sql = """
        delete from checkpoint where kind=\(contact.kind) and xid=\(contact.xid)
"""
        
        objc_sync_enter(self)
        _ = executeSQL(sql: sql)
        objc_sync_exit(self)
    }

并于IMCenter.swift中加入对历史消息检查点类型的定义:

class HistoryCheckpoint {
    var ts:Int64 = 0
    var desc = false
}

以上便是数据库相关的全部操作。

2. 网络交互流程

为了确保App的用户对平台厂商的透明度,让云平台厂商无法白嫖,确保云平台的使用者安心,所以云上曲率IM即时通讯服务需要App的开发者确认App用户的合法性。因此,我们需要搭建一个自己的服务器,进行用户的确认,以及用户的注册管理。
同时,因为我们采用的是用户名注册,而非云上曲率IM即时通讯服务所使用的数字ID,因此,我们也需要一个简单的服务器,做一下用户名到数字ID的转换。
鉴于以上分析,我们的App需要连接两个服务,一个是云上曲率IM即时通讯服务,另外一个是我们自己的小型服务。与云上曲率IM即时通讯服务交互的部分,由云上曲率对应的SDK完成,我们无需关心。剩下的就是与我们自己的服务进行交互。

根据之前UI的设计和交互,初步梳理了一下,我们需要我们自己的服务器提供以下五个接口:

  1. 登陆接口
    确认App自身用户的合法性。如果通过验证,返回用户的数字ID,和即时通讯服务的登陆token,以便遇上曲率的SDK进行即时通讯服务的登录。
  2. 注册接口
    注册用户,如果注册正常,则返回用户的数字ID,和即时通讯服务的登陆token,以便遇上曲率的SDK进行即时通讯服务的登录。
  3. 创建群组接口
    确认群组名称唯一,创建群组,并将创建者加入群组。
  4. 创建房间接口
    确认房间名称唯一,创建房间,并将创建者加入房间。
  5. 查询信息接口
    通过用户/群组/房间的唯一名称查询对应的数字ID;通过用户/群组/房间唯一的数字ID查询对应的唯一名称。

3. 集成云上曲率即时通讯 iOS SDK

云上曲率即时通讯分成RTM(Real-Time Message,实时消息)、IMLib、IMKit 三类。其中RTM主要面对实时信令,同时也是IMLib和IMKit是的基础。
本篇我们将采用RTM SDK,而面向游戏的IMlib和面向社交软件的IMKit将另篇再说。

3.1 RTM SDK的获取
首先来到云上曲率官网,首页下拉至底部,可以看到GitHub的图标,来到云上曲率的GitHub仓库HighRAS。

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第4张图片

在搜索框搜索objc,会出现两个rtm-client-sdk。

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第5张图片

其中一个是包含RTC模块,另外一个不包含RTC模块。
本次我们选择使用不包含RTC模块的SDK,后续文章演示RTC功能时,再选用包含RTC的SDK。
点击链接,我们来到rtm-client-sdk-objc的项目页,点击右侧 Release 标签

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第6张图片

进入发布页面

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第7张图片

直接选择最新发布版本,点击下载。

3.2. RTM SDK 的编译
下载解压后,我们进入SDK源码目录,点击 RtmWorkSpace.xcworkspace,打开SDK项目工程。
选择编译目标为:Rtm -> Any iOS Device (arm64, armv7, armv7s)

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第8张图片

点击 Shift + Command + K,清理当前工程。清理完毕后,点击 Command + B,进行构建。
构建完成后,在XCode左侧项目浏览器中,打开Rtm项目,找到Products,展开。在Rtm上打开右键菜单:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第9张图片

选择“Show in Finder”,打开 Release-iphoneos 文件夹:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第10张图片

这就是我们需要集成的 RTM SDK。

3.3. RTM SDK 的集成
将上节生成的Rtm.framework文件夹拖入IMDemoApp工程,弹出对话,如图选择:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第11张图片

点击“Finish”。然后我们找到下载下来的RTM SDK源码目录,打开 Test 目录。将 RTMAudioManager 文件夹拖入拖入IMDemoApp工程。此时依旧弹出对话框,按上图选择。
采用之前添加 SQLite3 的方法,添加 libresolv.9.tbd。添加完如图所示:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第12张图片

在本视图,选择 TARGETS -> Build Settings,在搜索框中搜索“Other Linker Flags”:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第13张图片

为“Other Linker Flags”添加值“-ObjC”。添加完毕如下图所示:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第14张图片

因为RTM ObjC SDK 采用 Objective-C++实现,所以我们还需添加一个包装文件和一个.mm文件。
我们直接添加一个 Objective-C 文件:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第15张图片

取名 RTMWrapper,文件类型选择空文件:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第16张图片

当保存的时候,XCode会问你,是否要创建桥接头文件,选择创建:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第17张图片

此时我们的工程文件如图所示:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第18张图片

编辑 IMDemo-Bridging-Header.h,加入对 的引用:

#import 
#import 

将 RTMWrapper.m 改名为 RTMWrapper.mm,改后代码如下:

#import 
#import "IMDemo-Bridging-Header.h"

此时便集成完毕。
注意:因为我们刚才只做了iphone的SDK,没有制作模拟器的SDK,因此后续调试仅能在真机上调试。如需模拟器支持,可自行查阅相关文档。

3.4 使用 RTM SDK 的框架代码
编辑 IMCenter.swift,增加对 RTMClient 的引用:

class IMCenter {
    ... ...
    static var client: RTMClient? = nil
    ... ...
}

因为RTMClient会有很多的事件,我们如果需要处理对应的事件,比如收到新的消息,那我们需要一个事件通知的机制。RTMClient在创建时,便会要求我们提供一个实现了RTM协议的类。除了个别必须实现的接口外,其余仅用实现我们关心的事件接口即可。
增加 IMEventProcessor.swift,编辑内容如下:

import Foundation

@objcMembers public class IMEventProcessor: NSObject, RTMProtocol {
    public func rtmReloginWillStart(_ client: RTMClient, reloginCount: Int32) -> Bool {
        return true
    }
    
    public func rtmReloginCompleted(_ client: RTMClient, reloginCount: Int32, reloginResult: Bool, error: FPNError) {
        //-- Do nothings
    }
    
    public func rtmConnectClose(_ client: RTMClient) {
        DispatchQueue.main.async {
            IMCenter.viewSharedInfo.brokenInfo = "RTM 链接已关闭!"
            IMCenter.viewSharedInfo.currentPage = .LoginView
        }
    }
    
    public func rtmKickout(_ client: RTMClient) {
        DispatchQueue.main.async {
            IMCenter.viewSharedInfo.brokenInfo = "账号已在其他地方登陆!"
            IMCenter.viewSharedInfo.currentPage = .LoginView
        }
    }
    
    
    public func rtmPushP2PChatMessage(_ client: RTMClient, message: RTMMessage?) {
        //-- TODO
    }
    
    public func rtmPushGroupChatMessage(_ client: RTMClient, message: RTMMessage?) {
        //-- TODO
    }
    
    public func rtmPushRoomChatMessage(_ client: RTMClient, message: RTMMessage?) {
        //-- TODO
    }
    
    public func rtmPushGroupChatCmd(_ client: RTMClient, message: RTMMessage?) {
        //-- TODO
    }
    
    public func rtmPushRoomChatCmd(_ client: RTMClient, message: RTMMessage?) {
        //-- TODO
    }
}

其中 rtmReloginWillStart() 和 rtmReloginCompleted() 是网络中断时,自动重连机制开始于完成的通知。我们简单忽略即可。

rtmConnectClose() 是链接完全断开,且自动重连被禁止/关闭状态下的事件回调,比如说,用户退出登录。
rtmKickout() 是,当RTM多端登录未开启时,同一账号在另外的设备上登录,将当前设备的用户踢下线的通知。
rtmPushP2PChatMessage()、rtmPushGroupChatMessage()、rtmPushRoomChatMessage()则是P2P、群组、房间聊天的消息通知,而 rtmPushGroupChatCmd()、rtmPushRoomChatCmd() 则是群组和房间的指令/信令的通知。

最后,修改 IMCenter.swift,增加对 IMEventProcessor 的持有:

class IMCenter {
    ... ...
    static var imEventProcessor: IMEventProcessor = IMEventProcessor()
    ... ...
}

到此为止,除了创建 RTMClient 实例外,RTM iOS SDK的接入和使用相关的框架代码已经全部搭建完毕。

3.5 注册云上曲率即时通信服务账号
登录云上曲率官网,点击右上角“免费试用”。
注册完毕后,我们来到控制台页面。如果依旧在首页,可以通过点击右上角“控制台”,进入控制台页面。
在控制台左侧,“控制台概览”中,选择“实时信令”条目:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第19张图片

点击“创建项目”按钮,填写项目信息,完成项目创建。之后进入项目控制台,在左侧列表中,选择“服务配置”:

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第20张图片

然后在右侧便可看到项目的基本信息。

千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)_第21张图片

其中“项目编号”、“服务端SDK接入点”是下篇“服务器搭建与总结”需要记录和配置的内容。在这里,我们记下客户端SDK接入点(2.7.0版本及之后):rtm-nx-front.ilivedata.com:13321。

注意:国际版和国内版,接入点是不同的。

4. 访问我们自己的用户服务器

因为要向RTM登陆,首先需要我们自己的业务服务器确认用户有效,并通过 RTM 服务端 SDK链接 RTM服务端,获取对应用户的token,返回给客户端。然后客户端用这个token,向RTM服务集群登陆。因此我们先回到与我们自己的服务器交互上面来。
首先,假设我们自己的服务器IP地址为 43.138.12.11,监听端口为13601,编辑Config.swift文件,加入以下代码:

class IMDemoConfig {
    static let RTMEndpoint = "rtm-nx-front.ilivedata.com:13321"
    static let IMDemoServerEndpoint = "43.138.12.11:13601"
}

然后添加 BizClient.swift,编辑框架代码如下:

import Foundation
import UIKit

class BizClient {
    
    class func login(username: String, password: String, errorAction: @escaping (_ message: String) -> Void) {
        //-- TODO
    }
    
    class func register(username: String, password: String, errorAction: @escaping (_ message: String) -> Void) {
        //-- TODO
    }
    
    class func createGroup(uniqueName: String, completedAction: @escaping (_ gid: Int64) -> Void, errorAction: @escaping (_ message: String) -> Void) {
        //-- TODO
    }
    
    class func joinGroup(uniqueGroupName: String, completedAction: @escaping (_ gid: Int64) -> Void, errorAction: @escaping (_ message: String) -> Void) {
        //-- TODO
    }

    class func dropGroup(uniqueName: String, gid:String, completedAction: @escaping () -> Void, errorAction: @escaping (_ message: String) -> Void) {
        //-- TODO
    }
    
    class func createRoom(uniqueName: String, completedAction: @escaping (_ rid: Int64) -> Void, errorAction: @escaping (_ message: String) -> Void) {
        //-- TODO
    }
    
    class func dropRoom(uniqueName: String, rid:Int64, completedAction: @escaping () -> Void, errorAction: @escaping (_ message: String) -> Void) {
        //-- TODO
    }
}

为了简单起见,在没有SSL证书的情况下,我们选用HTTP协议和我们的服务器进行连接。

5. 登录流程

5.1 发送登陆请求

首先是登陆。App LoginView将获取到用户的注册名和密码,然后调用BizClient的login()函数,访问我们的服务器。我们自己的服务器如果确认是我们自己的合法用户,则将通过RTM服务端SDK向RTM服务器获取用户的登录token,并将登陆token和用户ID,以及我们作为RTM客户的项目ID一并返回,供App内部RTMClient进行登录。为了简单起见,我们采用GET方式。编辑BizClient.swift,添加如下代码:

struct UserLoginResponse: Codable {
    var pid: Int64
    var uid: Int64
    var token: String
}

struct FpnnErrorResponse: Codable {
    var code: Int
    var ex: String
}

class BizClient {
    private class func checkUserChanged(loginName: String) {
        
        let oldName = IMCenter.fetchUserProfile(key: "username")
        var changed = false
        
        if oldName.isEmpty {
            IMCenter.storeUserProfile(key: "username", value: loginName)
            changed = false
        }
        
        if oldName != loginName {
            IMCenter.storeUserProfile(key: "username", value: loginName)
            changed = true
        }
        
        if changed {
            IMCenter.storeUserProfile(key: "nickname", value: "")
            IMCenter.storeUserProfile(key: "showInfo", value: "")
        }
    }
    
    class func createIMClient(userLoginInfo: UserLoginResponse, errorAction: @escaping (_ message: String) -> Void) {
 
        let client = RTMClient(endpoint: IMDemoConfig.RTMEndpoint, projectId: userLoginInfo.pid, userId: userLoginInfo.uid, delegate: IMCenter.imEventProcessor, config: nil, autoRelogin: true)

        IMCenter.client = client

        client?.login(withToken: userLoginInfo.token, language: nil, attribute: nil, timeout: 20, success: {
            IMCenter.RTMLoginSuccess()
        }, connectFail: { error in
            if (error != nil) {
                errorAction(error!.ex)
            } else {
                errorAction("未知错误!")
            }
        })
    }
    
    class func urlEncode(string: String) -> String {
        let encodeUrlString = string.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
        return encodeUrlString ?? ""
    }
    
    class func login(username: String, password: String, errorAction: @escaping (_ message: String) -> Void) {
        
        let url = URL(string:"http://" + IMDemoConfig.IMDemoServerEndpoint + "/service/userLogin?username=" + urlEncode(string: username) + "&pwd=" + urlEncode(string: password))!
        
        let task = URLSession.shared.dataTask(with:url, completionHandler:{
            (data, response, error) in
            
            if error != nil {
                errorAction("连接错误: \(error!.localizedDescription)")
            }
            if response != nil {
                do {
                    let json = try JSONDecoder().decode(UserLoginResponse.self, from: data!)
                    
                    checkUserChanged(loginName: username)
                    createIMClient(userLoginInfo: json, errorAction: errorAction)
                       
                } catch {
                    do {
                        let json = try JSONDecoder().decode(FpnnErrorResponse.self, from: data!)
                        errorAction(json.ex)
                    } catch {
                        errorAction("Error during JSON serialization: \(error.localizedDescription)")
                    }
                }
            }
        })
        
        task.resume()
    }
}

login() 是App向我们自己服务器发送用户登录请求,并获取回应的接口,因为下篇我们将决定使用FPNN框架快速搭建我们的HTTP/HTTPS服务,因此使用FPNN HTTP/HTTPS访问的规范:http(s)://[:port]/service/[?...]
我们本次要访问的接口为userLogin,所以我们需要向 http://43.138.12.11:13601/ser... 的Url发出请求。
此外,因为在异常情况下,FPNN框架会返回FPNN异常,所以我们也需要添加FPNN异常响应结构:FpnnErrorResponse。
urlEncode() 则将中文等uri不安全字符进行转义。
当登录成功后,checkUserChanged() 检查当前登录用户是否是上次登录用户。如果不是,则意味着本地存储的用户相关信息需要更新。以前的需要清理,新的需要重新获取(放到后续处理,不在checkUserChanged()函数里)。
然后创建 RTMClient 实例,调用RTMClient.login()接口,进行登录。
为了代码的顺利编译,我们在 IMCenter.swift 中添加框架代码:

    class func RTMLoginSuccess() {
        //-- TODO: ....
    }

5.2 为登录成功后流程的准备

当 RTMClient.login()接口传入的回调函数 IMCenter.RTMLoginSuccess() 被触发的那一刻起,第二步就开始了。

第二步包含以下并发内容:

  1. 打开用户对应的数据库,加载联系人和最近一条聊天记录,初始化联系人页面和会话页面。
  2. 监察室否有新的回话,有则拉取会话信息,以及最新10条聊天记录。然后添加到联系人列表页和会话页面。
  3. 获取未读信息,拉取对应会话最新10条聊天记录,并更新会话列表页,将未读会话置顶,并添加未读标记。

因为期间我们需要反复多次查询用户信息,虽然都是通过数字ID查询注册名称(RTM新消息通知、新会话、群组/房间中的陌生人等),但考虑到后续在加入群组,加入房间时,也需要根据群组和房间的唯一名反查对应的数字ID。因此,我们设计 lookup 接口如下:
输入:用户ID 列表(可以为空)、群组ID列表(可以为空)、房间ID列表(可以为空)、用户注册名列表(可以为空)、群组唯一名称列表(可以为空)、房间唯一名称列表(可以为空)
输出:以 用户注册名为key,用户ID为值的字典;以 群组唯一名为key,群组ID为值的字典、以 房间唯一名为key,房间ID为值的字典。
因为我们采用HTTP协议,因此采用Json会比较方便。此外,因为Json的特性,我们无法加入以整型数字ID为key的字典类型。如果期望增加,可以通过将整型的数字ID变为字符串的变通方式,或者直接采用 FPNN 协议进行通讯(本Demo对应演示服务器即用FPNN框架进行开发,且RTMClient SDK是基于FPNN SDK进行的上层开发)。
此外,因为批量查询时,查询数据量可能较大,因此我们采用POST方式,而不是GET方式。

修改 BizClient.swift,增加代码如下:

struct LookupResponse: Codable {
    var users: [String:Int64]
    var groups: [String:Int64]
    var rooms: [String:Int64]
}

class BizClient {
    ... ...
    
    private class func appendStringArray(str: inout String, array: [String], withKey: String) -> Void {
        
        str.append("\"")
        str.append(withKey)
        str.append("\":[")
        
        var requireComma = false
        
        for item in array {
            if requireComma {
                str.append(",\"")
            } else {
                requireComma = true
                str.append("\"")
            }
            
            str.append(item)
            str.append("\"")
        }
        
        str.append("]")
    }
    
    private class func appendInt64Array(str: inout String, array: [Int64], withKey: String) -> Void {
        
        str.append("\"")
        str.append(withKey)
        str.append("\":[")
        
        var requireComma = false
        
        for item in array {
            if requireComma {
                str.append(",")
            } else {
                requireComma = true
            }
            
            str.append(String(item))
        }
        
        str.append("]")
    }
    
    class func lookup(users:[String]?, groups:[String]?, rooms:[String]?, uids:[Int64]?, gids: [Int64]?, rids: [Int64]?, completedAction: @escaping (_ response: LookupResponse) -> Void, errorAction: @escaping (_ info: String) -> Void) {
        
        var requireComma = false
        var postJson = "{"
        
        if users != nil {
            appendStringArray(str: &postJson, array: users!, withKey: "users")
            requireComma = true
        }
        
        if groups != nil {
            if requireComma {
                postJson.append(",")
            } else {
                requireComma = true
            }
            appendStringArray(str: &postJson, array: groups!, withKey: "groups")
        }
        
        if rooms != nil {
            if requireComma {
                postJson.append(",")
            } else {
                requireComma = true
            }
            appendStringArray(str: &postJson, array: rooms!, withKey: "rooms")
        }
        
        if uids != nil {
            if requireComma {
                postJson.append(",")
            } else {
                requireComma = true
            }
            appendInt64Array(str: &postJson, array: uids!, withKey: "uids")
        }
        
        if gids != nil {
            if requireComma {
                postJson.append(",")
            } else {
                requireComma = true
            }
            appendInt64Array(str: &postJson, array: gids!, withKey: "gids")
        }
        
        if rids != nil {
            if requireComma {
                postJson.append(",")
            } else {
                requireComma = true
            }
            appendInt64Array(str: &postJson, array: rids!, withKey: "rids")
        }
        
        postJson.append("}")
        
        let url = URL(string:"http://" + IMDemoConfig.IMDemoServerEndpoint + "/service/lookup")!
        var request = URLRequest(url: url)
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        request.httpMethod = "POST"
        
        request.httpBody = postJson.data(using: .utf8)    // postJson.percentEncoded()
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            guard let data = data, error == nil else {
                    errorAction("连接错误: \(error!.localizedDescription)")
                return
            }

            if let httpStatus = response as? HTTPURLResponse, httpStatus.statusCode != 200 {
                if response != nil {
                    errorAction("Error! Response: \(response!)")
                } else {
                    errorAction("Error! Response code: \(httpStatus.statusCode)")
                }
                return
            }

            // let responseString = String(data: data, encoding: .utf8)
            
            do {
                let json = try JSONDecoder().decode(LookupResponse.self, from: data)
                completedAction(json)
            } catch {
                do {
                    let json = try JSONDecoder().decode(FpnnErrorResponse.self, from: data)
                    errorAction(json.ex)
                } catch {
                    errorAction("Error during JSON serialization: \(error.localizedDescription)")
                }
            }
        }
        task.resume()
    }
}

代码基本上无需多说,整体流程就是:准备json数据,然后像我们自己的服务器发送查询请求,然后解析服务器返回的数据。

5.3. 登录成功后执行的流程

登录成功之后,RTM便会触发 IMCenter.RTMLoginSuccess() 的回调。该回调主要处理5件事:

  1. 打开登录用户对应的数据库;
  2. 检查并更新当前登录用户的用户信息
  3. 根据本地数据库的记录,准备好会话列表和联系人列表的基本数据,并检查更新联系人信息
  4. 检查是否有本地未记录的新的会话

编辑 IMCenter.swift,增加 RTMLoginSuccess() 基本代码如下:

    class func sortSessions(sessions: [SessionItem]) -> [SessionItem] {
        if sessions.count <= 1 {
            return sessions
        }
        
        return sessions.sorted(by: { (s1, s2) -> Bool in
            
            if s1.lastMessage.unread && !s2.lastMessage.unread {
                return true
            }
            
            if !s1.lastMessage.unread && s2.lastMessage.unread {
                return false
            }
            
            if s1.lastMessage.timestamp > s2.lastMessage.timestamp {
                return true
            }
            
            if s1.lastMessage.timestamp == s2.lastMessage.timestamp {
                return s1.lastMessage.mid > s2.lastMessage.mid
            }
            return false
        })
    }
    
    class func RTMLoginSuccess() {
        db.openDatabase(userId: IMCenter.client!.userId)
        
        querySelfInfo()
        
        let contacts = db.loadContentInfos()
        var sessions = db.loadLastMessage(contactList: contacts)
        sessions = IMCenter.sortSessions(sessions: sessions)
        
        let contactList = prepareContactList(contacts:contacts)
        
        DispatchQueue.main.async {
            IMCenter.viewSharedInfo.sessions = sessions
            IMCenter.viewSharedInfo.contactList = contactList
            IMCenter.viewSharedInfo.currentPage = .SessionView
            
            DispatchQueue.global(qos: .default).async {
                checkContactsUpdate(contactList: contactList)
            }
        }
        
        checkNewSessions(contacts: contacts)
        
        for contact in contacts {
            if contact.imagePath.isEmpty && contact.imageUrl.isEmpty == false {
                downloadImage(contactInfo: contact)
            }
        }
    }

其中 querySelfInfo() 检查并更新登录用户自身的信息,以确保和服务端同步。因为检查自身信息是一个网络操作,为了优化登录登录成功后,到会话页面展现前的等待时间,querySelfInfo() 被设计成采用异步并发执行。相关代码如下:

struct OpenInfoData: Codable {
    var nickname: String
    var imageUrl: String
    var showInfo: String
}

... ...

class IMCenter {
    ... ...
    
    private class func decodeOpenInfo(contact: inout ContactInfo, json: String) -> Void {
        do {
            let info = try JSONDecoder().decode(OpenInfoData.self, from: json.data(using: .utf8)!)
            contact.nickname = info.nickname
            contact.imageUrl = info.imageUrl
            contact.showInfo = info.showInfo
        } catch {
            print("Error during JSON serialization: " + json)
        }
    }
    
    private class func storeImage(type: Int, xid: Int64, image: Data) -> String? {
        
        let uid: Int64 = IMCenter.client!.userId
        var path = NSHomeDirectory() + "/Documents/user_\(uid)/"
        var relativePath = "user_\(uid)/"
        
        switch type {
        case 1:
            path.append("user/")
            relativePath.append("user/")
        case 2:
            path.append("group/")
            relativePath.append("group/")
        case 3:
            path.append("room/")
            relativePath.append("room/")
        default:
            //-- 陌生人,或者自己
            path.append("user/")
            relativePath.append("user/")
        }
        
        try! FileManager.default.createDirectory(at: URL(string: "file://" + path)!, withIntermediateDirectories: true, attributes: nil)

        let filePath = path + String(xid) + ".img"
        relativePath += String(xid) + ".img"
        do {
            try image.write(to: URL(fileURLWithPath: filePath))
            return relativePath
            
        } catch {
            return nil
        }
    }
    
    private class func updateViewsImageUrl(contactInfo: ContactInfo, newPath: String) {
        
        if let contacts = IMCenter.viewSharedInfo.contactList[contactInfo.kind] {
            for idx in 0..Void, failedAction: @escaping()->Void) {
        
        if contactInfo.imageUrl.isEmpty {
            failedAction()
            return
        }
        
        URLSession.shared.dataTask(with: URL(string:contactInfo.imageUrl)!) { data, response, error in
            guard
                let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
                let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
                let data = data, error == nil
                else {
                    print("Download image error:\(String(describing: error))")
                    failedAction()
                    return
                }
            
            if let path = IMCenter.storeImage(type: contactInfo.kind, xid: contactInfo.xid, image: data) {
                completedAction(path, contactInfo)
            } else {
                failedAction()
            }
            
            }.resume()
    }
    
    private class func downloadImage(contactInfo: ContactInfo) {
        downloadImage(contactInfo: contactInfo, completedAction: {
            (path, contactInfo) in
            
            IMCenter.db.updateImageStoreInfo(type: contactInfo.kind, xid: contactInfo.xid, filePath: path)
            
            DispatchQueue.main.async { updateViewsImageUrl(contactInfo: contactInfo, newPath: path) }
        }, failedAction: {})
    }
    
    class func querySelfInfo() {
        
        IMCenter.client!.getUserInfo(withTimeout: 0, success: {
            infoAnswer in
            
            if let openInfo = infoAnswer?.openInfo {
                var contact = ContactInfo(type: 0, xid: IMCenter.client!.userId)
                decodeOpenInfo(contact: &contact, json: openInfo)
            
                IMCenter.storeUserProfile(key: "nickname", value: contact.nickname)
                IMCenter.storeUserProfile(key: "showInfo", value: contact.showInfo)
                
                let username = IMCenter.fetchUserProfile(key: "username")
                IMCenter.storeUserProfile(key: "\(username)-image-url", value: contact.imageUrl)

                downloadImage(contactInfo: contact, completedAction: {
                    (path, contactInfo) in
                    
                    let username = IMCenter.fetchUserProfile(key: "username")
                    IMCenter.storeUserProfile(key: "\(username)-image", value: path)
                
                }, failedAction:{})
            }
            
        }, fail: {
            errorAnswer in
            
            if errorAnswer?.code == 200010 {
                sleep(2)
                querySelfInfo()
            }
        })
    }
    
    ... ...
}

其中,在 querySelfInfo() 函数中,错误码 200010 是达到了 RTM 项目的频率限制,所以我们要做一个简单等待,然后重试。对于付费客户,频率限制等是可以提交工单进行修改的。一般免费使用,除非像本教程异步和并发操作极多,否则通常情况下,是很难触发RTM的频率限制的。

然后,登录成功的回调中,prepareContactList() 负责准备好联系人列表的数据。相关代码如下:

    private class func sortContacts(contacts: [ContactInfo]) -> [ContactInfo] {
        if contacts.count <= 1 {
            return contacts
        }
        
        return contacts.sorted(by: { (c1, c2) -> Bool in
            
            if c1.nickname < c2.nickname {
                return true
            }
            
            if c1.nickname == c2.nickname {
                if c1.xname < c2.xname {
                    return true
                }
                
                if c1.xname == c2.xname {
                    return c1.xid < c2.xid
                }
            }
            return false
        })
    }

    private class func prepareContactList(contacts:[ContactInfo]) -> [Int: [ContactInfo]] {
        var contactList: [Int: [ContactInfo]] = [:]
        contactList[ContactKind.Friend.rawValue] = [ContactInfo]()
        contactList[ContactKind.Group.rawValue] = [ContactInfo]()
        contactList[ContactKind.Room.rawValue] = [ContactInfo]()
        
        for contact in contacts {
            if contact.kind == ContactKind.Friend.rawValue {
                contactList[ContactKind.Friend.rawValue]?.append(contact)
            } else if contact.kind == ContactKind.Group.rawValue {
                contactList[ContactKind.Group.rawValue]?.append(contact)
            } else if contact.kind == ContactKind.Room.rawValue {
                contactList[ContactKind.Room.rawValue]?.append(contact)
            }
        }
        
        for idx in ContactKind.Friend.rawValue...ContactKind.Room.rawValue {
            if contactList[idx]!.count > 1 {
                let tmp = contactList[idx]!
                contactList[idx] = sortContacts(contacts: tmp)
            }
        }

        return contactList
    }

然后 RTMLoginSuccess() 中,在准备好基础的会话列表信息和联系人列表信息后,触发一个主线程的异步并发任务(RTMLoginSuccess()当前是在异步线程中被调用的),跟新会话列表信息和联系人列表所用数据,并引导加载会话列表视图后,在主线程中再次出发一个异步的非主线程并发网络任务checkContactsUpdate(),更新联系人列表的信息,和服务器保持同步。checkContactsUpdate() 相关代码如下:

    private class func decodeAttributeAnswer(type: Int, attriAnswer: RTMAttriAnswer) -> [ContactInfo] {
        var contacts = [ContactInfo]()
        
        for (key, value) in  attriAnswer.atttriDictionary {
            if let keyStr = key as? String, let jsonStr = value as? String {
                if jsonStr.isEmpty {
                    continue
                }
                
                var contact = ContactInfo(type: type, xid: Int64(keyStr)!)
                decodeOpenInfo(contact: &contact, json: jsonStr)
                contacts.append(contact)
            }
        }

        return contacts
    }

    private class func syncCheckSessionsInfoUpdate(type: Int, contacts:[ContactInfo]) {
        
        var queryIds = [NSNumber]()
        for contact in contacts {
            queryIds.append(NSNumber(value: contact.xid))
        }
        
        var attriAnswer: RTMAttriAnswer? = nil
        if type == ContactKind.Friend.rawValue {
            attriAnswer = IMCenter.client!.getUserOpenInfo(queryIds, timeout: 0)
        } else if type == ContactKind.Group.rawValue {
            attriAnswer = IMCenter.client!.getGroupsOpenInfo(withId: queryIds, timeout: 0)
        } else if type == ContactKind.Room.rawValue {
            attriAnswer = IMCenter.client!.getRoomsOpenInfo(withId: queryIds, timeout: 0)
        } else { return }
        
        if attriAnswer != nil {
            let contacts = decodeAttributeAnswer(type: type, attriAnswer: attriAnswer!)
            
            DispatchQueue.main.async {
                for contact in contacts {
                    updateContactCustomInfos(contact: contact)
                }
            }
        }
    }    

    private class func updateContactXname(contact: ContactInfo) {
        if let contacts = IMCenter.viewSharedInfo.contactList[contact.kind] {
            for user in contacts {
                if contact.xid == user.xid {
                    if user.xname != contact.xname {
                        user.xname = contact.xname
                        db.updateXname(contact: user)
                    }
                }
            }
        }
    }
    
    private class func updateContactCustomInfos(contact: ContactInfo) {
        if let contacts = IMCenter.viewSharedInfo.contactList[contact.kind] {
            for user in contacts {
                if contact.xid == user.xid {
                    
                    if user.imageUrl != contact.imageUrl {
                        user.imagePath = ""
                    }
                        
                    user.nickname = contact.nickname
                    user.imageUrl = contact.imageUrl
                    user.showInfo = contact.showInfo

                    db.updatePublicInfo(contact: user)
                    downloadImage(contactInfo: user)
                }
            }
        }
    }

    private class func checkContactsUpdate(type:Int, contacts:[ContactInfo]) {
        var contactList = [Int:[ContactInfo]]()
        contactList[type] = contacts
        checkContactsUpdate(contactList: contactList)
    }

    private class func checkContactsUpdate(contactList:[Int:[ContactInfo]]) {
        
        //-- 查询 xname
        var uids = [Int64]()
        var gids = [Int64]()
        var rids = [Int64]()
        
        if let contacts = contactList[ContactKind.Friend.rawValue] {
            for contact in contacts {
                uids.append(contact.xid)
            }
        }
        
        if let contacts = contactList[ContactKind.Group.rawValue] {
            for contact in contacts {
                gids.append(contact.xid)
            }
        }
        
        if let contacts = contactList[ContactKind.Room.rawValue] {
            for contact in contacts {
                rids.append(contact.xid)
            }
        }
        
        BizClient.lookup(users: nil, groups: nil, rooms: nil, uids: uids, gids: gids, rids: rids, completedAction: {
            lookupData in
            
            var friends = [ContactInfo]()
            for (key, value) in lookupData.users {
                let contact = ContactInfo(type: ContactKind.Friend.rawValue, xid: value)
                contact.xname = key
                
                friends.append(contact)
            }
            
            var groups = [ContactInfo]()
            for (key, value) in lookupData.groups {
                let contact = ContactInfo(type: ContactKind.Group.rawValue, xid: value)
                contact.xname = key
                
                groups.append(contact)
            }
            
            var rooms = [ContactInfo]()
            for (key, value) in lookupData.rooms {
                let contact = ContactInfo(type: ContactKind.Room.rawValue, xid: value)
                contact.xname = key
                
                rooms.append(contact)
            }
            
            DispatchQueue.main.async {
                for user in friends {
                    updateContactXname(contact: user)
                }
                for group in groups {
                    updateContactXname(contact: group)
                }
                for room in rooms {
                    updateContactXname(contact: room)
                }
            }
        }, errorAction: { _ in })
        
        //-- 查询展示信息
        if let contacts = contactList[ContactKind.Friend.rawValue] {
            if contacts.count < 100 {
                syncCheckSessionsInfoUpdate(type: ContactKind.Friend.rawValue, contacts: contacts)
            } else {
                var queryContacts = [ContactInfo]()
                
                for contact in contacts {
                    queryContacts.append(contact)
                    if queryContacts.count == 99 {
                        syncCheckSessionsInfoUpdate(type: ContactKind.Friend.rawValue, contacts: queryContacts)
                        queryContacts.removeAll()
                    }
                }
                
                if queryContacts.count > 0 {
                    syncCheckSessionsInfoUpdate(type: ContactKind.Friend.rawValue, contacts: queryContacts)
                }
            }
        }
        
        if let contacts = contactList[ContactKind.Group.rawValue] {
            if contacts.count < 100 {
                syncCheckSessionsInfoUpdate(type: ContactKind.Group.rawValue, contacts: contacts)
            } else {
                var queryContacts = [ContactInfo]()
                
                for contact in contacts {
                    queryContacts.append(contact)
                    if queryContacts.count == 99 {
                        syncCheckSessionsInfoUpdate(type: ContactKind.Group.rawValue, contacts: queryContacts)
                        queryContacts.removeAll()
                    }
                }
                
                if queryContacts.count > 0 {
                    syncCheckSessionsInfoUpdate(type: ContactKind.Group.rawValue, contacts: queryContacts)
                }
            }
        }
        
        if let contacts = contactList[ContactKind.Room.rawValue] {
            if contacts.count < 100 {
                syncCheckSessionsInfoUpdate(type: ContactKind.Room.rawValue, contacts: contacts)
            } else {
                var queryContacts = [ContactInfo]()
                
                for contact in contacts {
                    queryContacts.append(contact)
                    if queryContacts.count == 99 {
                        syncCheckSessionsInfoUpdate(type: ContactKind.Room.rawValue, contacts: queryContacts)
                        queryContacts.removeAll()
                    }
                }
                
                if queryContacts.count > 0 {
                    syncCheckSessionsInfoUpdate(type: ContactKind.Room.rawValue, contacts: queryContacts)
                }
            }
        }
    }

因为 RTM 为了避免滥用,限制了 getUserOpenInfo、getGroupsOpenInfo、getRoomsOpenInfo 几个接口每次最多仅查询100个联系人的公开信息,所以在 checkContactsUpdate() 中,对超过100个的联系人列表,做了分段分批查询的处理。

最后,是检查登录用户是否有新会话。比如在离线期间,其他用户向当前登陆用户发起的会话。而checkNewSessions() 又分别做了以下几件事情:

  1. 从RTM获取当前登录用户的所有会话
  2. 获取并更新每个会话的信息
  3. 在更新每个绘画的信息后,检查是否存在未读消息

因为更新会话信息是网络操作,而且在 checkNewSessions() 中,P2P和群组会话是并发异步更新的。如果在会话信息更新完之前就异步开始检查未读消息,会导致后面的流程异常复杂。
本着教学演示的目的,简化流程起见,我们决定等P2P和群组会话两个异步流程都完成后,再启动未读消息的检查。于是,我们利用class的生命流程可以等价为一个三段状态机的原理,引入新的辅助类型 AsyncTask:

class AsyncTask {
 
    var action: () -> Void
    
    init(action: @escaping ()->Void) {
        self.action = action
    }
    deinit {
        action()
    }
    
    func npAction() {}
}

当 AsyncTask 实例析构时,我们需要的动作便会开始执行。于是 checkNewSessions() 相关代码如下:

    private class func appendContactsForSessionView(contacts: [ContactInfo]) {
        if contacts.isEmpty { return }
        
        var oldSessions = IMCenter.viewSharedInfo.sessions
        
        for contact in contacts {
            let sessionItem = SessionItem(contact: contact)
            oldSessions.append(sessionItem)
        }
        
        IMCenter.viewSharedInfo.sessions = sortSessions(sessions: oldSessions)
    }
    
    private class func appendContactsForContactView(contacts: [ContactInfo]) {
        if contacts.isEmpty { return }
        
        var oldContactList = IMCenter.viewSharedInfo.contactList
        
        for contact in contacts {
            oldContactList[contact.kind]?.append(contact)
        }
        
        for idx in ContactKind.Friend.rawValue...ContactKind.Room.rawValue {
            if oldContactList[idx]!.count > 1 {
                let tmp = oldContactList[idx]!
                oldContactList[idx] = sortContacts(contacts: tmp)
            }
        }
        
        IMCenter.viewSharedInfo.contactList = oldContactList
    }

    private class func getAllSessions(success: @escaping (_ answer: RTMP2pGroupMemberAnswer?)->Void) {
        
        client!.getAllSessions(withTimeout: 0, success: success, fail: {
            
            errorAnswer in
            
            if errorAnswer?.code == 200010 {
                sleep(2)
                getAllSessions(success: success)
            }
            
        })
    }
    
    private class func checkNewSessions(contacts: [ContactInfo]) -> Void {
    
        let asyncTask = AsyncTask(action: { IMCenter.checkUnreadMessage() })
        
        getAllSessions(success: { sessionInfos in
            guard sessionInfos != nil else { return }
            
            var localUids = Set()
            var localGids = Set()
            
            for index in 0.. Void {
        IMCenter.client?.getUserOpenInfo(uids, timeout: 0, success: {
            attriAnswer in
            
            if attriAnswer != nil {
                decodeNewSession(type: ContactKind.Friend.rawValue, xids:uids, attriAnswer: attriAnswer!, asyncTask: asyncTask)
            }
            
        }, fail: {
            errorAnswer in
            
            if errorAnswer?.code == 200010 {
                sleep(2)
                fetchNewP2PSessions(uids: uids, asyncTask: asyncTask)
            }
        })
    }
    
    private class func fetchNewGroupSessions(gids: [NSNumber], asyncTask: AsyncTask) -> Void {
        IMCenter.client?.getGroupsOpenInfo(withId: gids, timeout: 0, success: {
            attriAnswer in
            
            if attriAnswer != nil {
                decodeNewSession(type: ContactKind.Group.rawValue, xids:gids, attriAnswer: attriAnswer!, asyncTask: asyncTask)
            }
    
        }, fail: {
            errorAnswer in
            
            if errorAnswer?.code == 200010 {
                sleep(2)
                fetchNewGroupSessions(gids: gids, asyncTask: asyncTask)
            }
        })
    }

最后,获取未读消息的主要流程就是根据 RTMClient.getUnreadMessages() 接口返回的数据,找到会话列表中对应的条目,加上未读标记,然后对于有未读消息的会话,拉取最新的历史消息,并将最新的一条,显示在会话列表页对应条目联系人显示名称的下方。
checkUnreadMessage() 流程相关代码如下:

private class func updateUnreadStatus(p2pUids: [Int64], groupIds: [Int64]) {
        
        DispatchQueue.main.async {
            var sessions = IMCenter.viewSharedInfo.sessions
            
            for uid in p2pUids {
                for session in sessions {
                    if session.contact.kind == ContactKind.Friend.rawValue
                        && session.contact.xid == uid {
                        session.lastMessage.unread = true
                    }
                }
            }
            
            for gid in groupIds {
                for session in sessions {
                    if session.contact.kind == ContactKind.Group.rawValue
                        && session.contact.xid == gid {
                        session.lastMessage.unread = true
                    }
                }
            }
            
            sessions = sortSessions(sessions: sessions)
            IMCenter.viewSharedInfo.sessions = sessions
        }
    }
    
    
    class func checkUnreadMessage() {
        IMCenter.client!.getUnreadMessages(withClear: true, timeout: 0, success: {
            unreadArrays in
            
            guard unreadArrays != nil else { return }
            
            var p2pIds = [Int64]()
            var groupIds = [Int64]()
            
            for v in unreadArrays!.p2pArray {
                if let uid = v as? NSNumber {
                    p2pIds.append(uid.int64Value)
                }
            }
            
            for v in unreadArrays!.groupArray {
                if let gid = v as? NSNumber {
                    groupIds.append(gid.int64Value)
                }
            }
            
            updateUnreadStatus(p2pUids: p2pIds, groupIds: groupIds)
            fetchUnreadMessage(p2pUids: p2pIds, groupIds: groupIds)
            
        }, fail: {
            error in
            if error?.code == 200010 {
                sleep(2)
                checkUnreadMessage()
            } else {
                print("RTM: Get unread chat message faield. Error info: \(String(describing: error?.ex))")
            }
        })
    }
    
    private class func syncFetchUnreadP2PChat(uid:Int64) {
        let answer = IMCenter.client?.getP2PHistoryMessageChat(withUserId: NSNumber(value:uid), desc: true, num: NSNumber(value: 10), begin: nil, end: nil, lastid: nil, timeout: 0)
         
        var insertCheckoutPoint = true
        if let unreads = answer?.history.messageArray {
            for rtmMessage in unreads {
                //-- 暂不考虑二进制消息,和开启自动翻译后的翻译消息
                if IMCenter.db.insertChatMessage(type: ContactKind.Friend.rawValue, xid: uid, sender: rtmMessage.fromUid, mid: rtmMessage.messageId, message: rtmMessage.stringMessage, mtime: rtmMessage.modifiedTime) == false {
                    insertCheckoutPoint = false
                    break
                }
            }
            
            if unreads.count > 0 {
                
                //-- Insert check point
                if insertCheckoutPoint {
                    let rtmMessage = unreads.last!
                    IMCenter.db.insertCheckPoint(type: ContactKind.Friend.rawValue, xid: uid, ts:rtmMessage.modifiedTime, desc:true)
                }
                
                //-- Update unread info for SessionsView
                DispatchQueue.main.async {
                    var sessions = IMCenter.viewSharedInfo.sessions
                    
                    for session in sessions {
                        if session.contact.kind == ContactKind.Friend.rawValue
                            && session.contact.xid == uid {
                            session.lastMessage.unread = true
                            session.lastMessage.message = unreads.first!.stringMessage
                            break
                        }
                    }
                    
                    sessions = sortSessions(sessions: sessions)
                    IMCenter.viewSharedInfo.sessions = sessions
                }
            }
        }
    }
    
    private class func syncFetchUnreadGroupChat(gid:Int64) {
        let answer = IMCenter.client?.getGroupHistoryMessageChat(withGroupId: NSNumber(value:gid), desc: true, num: NSNumber(value: 10), begin: nil, end: nil, lastid: nil, timeout: 0)
        
        var insertCheckoutPoint = true
        var lastMessage = LastMessage()
        if let unreads = answer?.history.messageArray {
            for rtmMessage in unreads {
                //-- 暂不考虑二进制消息,和开启自动翻译后的翻译消息
                if rtmMessage.messageType == 30 {
                    if IMCenter.db.insertChatMessage(type: ContactKind.Group.rawValue, xid: gid, sender: rtmMessage.fromUid, mid: rtmMessage.messageId, message: rtmMessage.stringMessage, mtime: rtmMessage.modifiedTime) == false {
                        insertCheckoutPoint = false
                        break
                    } else {
                        lastMessage.message = rtmMessage.stringMessage
                        lastMessage.mid = rtmMessage.messageId
                        lastMessage.timestamp = rtmMessage.modifiedTime
                        lastMessage.unread = true
                    }
                } else {
                    if IMCenter.db.insertChatCmd(type: ContactKind.Group.rawValue, xid: gid, sender: rtmMessage.fromUid, mid: rtmMessage.messageId, message: rtmMessage.stringMessage, mtime: rtmMessage.modifiedTime) == false {
                        insertCheckoutPoint = false
                        break
                    }
                }
            }
            if unreads.count > 0 {
                
                //-- Insert check point
                if insertCheckoutPoint {
                    let rtmMessage = unreads.last!
                    IMCenter.db.insertCheckPoint(type: ContactKind.Group.rawValue, xid: gid, ts:rtmMessage.modifiedTime, desc:true)
                }
                
                if lastMessage.unread {
                    DispatchQueue.main.async {
                        var sessions = IMCenter.viewSharedInfo.sessions
                        
                        for session in sessions {
                            if session.contact.kind == ContactKind.Group.rawValue
                                && session.contact.xid == gid {
                                session.lastMessage = lastMessage
                                break
                            }
                        }
                        
                        sessions = sortSessions(sessions: sessions)
                        IMCenter.viewSharedInfo.sessions = sessions
                    }
                }
            }
        }
    }
        
    private class func fetchUnreadMessage(p2pUids: [Int64], groupIds: [Int64]) {
        
        for uid in p2pUids {
            syncFetchUnreadP2PChat(uid: uid)
        }
        
        for gid in groupIds {
            syncFetchUnreadGroupChat(gid: gid)
        }
    }

因为房间用户离线即视为退出,所以不存在未读一说。只有P2P会话,和群组,存在未读消息。
在 syncFetchUnreadP2PChat() 和 syncFetchUnreadGroupChat() 中,本着拉取一条是一次网络调用,拉取10条也是一次网络调用,白拉白不拉的精神,我们对每个有未读消息的会话,一次性拉取10条最新历史消息,然后依次插入数据库。如果对应的消息在数据库中已经存在,则意味着我们已经从时间上接上了数据库中上次保存的最新的消息。因此,后续的插入将被跳过。但如果没有,则意味着我们本次拉取到的数据,和上次数据库保存的最新数据之间未在时间上产生链接,其间可能存在有历史消息未被获取。即历史消息空洞。为了后续在对话页面显示时能填补这些空洞,我们网数据库中写入响应的历史消息检查点 checkPoint。

此时,登陆流程就已经完全开发完毕。下面将会对对接SwiftUI的显示界面。

5.4. UI显示对接

修改 LoginView.swift,修改 userLogin() 函数如下:

    func userLogin(){
        
        if username.isEmpty {
            
            self.alertTitle = "无效输入"
            self.errorMessage = "用户名不能为空!"
            self.showAlert = true
            
            return
        }
        
        if password.isEmpty {
            
            self.alertTitle = "无效输入"
            self.errorMessage = "用户密码不能为空!"
            self.showAlert = true
            
            return
        }
        
        self.showLoginingHint = true
        
        BizClient.login(username: username, password: password, errorAction: {
            (message) in
            
            self.showLoginingHint = false
            self.errorMessage = message
            self.loginFailed = true
        })
    }

6. 注册流程

有了登录流程的经验,注册流程其实非常类似。
编辑 BizClient.swift,修改 register() 函数代码如下:

class func register(username: String, password: String, errorAction: @escaping (_ message: String) -> Void) {
        
        let url = URL(string:"http://" + IMDemoConfig.IMDemoServerEndpoint + "/service/userRegister?username=" + urlEncode(string: username) + "&pwd=" + urlEncode(string: password))!
        
        let task = URLSession.shared.dataTask(with:url, completionHandler:{
            (data, response, error) in
            
            if error != nil {
                errorAction("连接错误: \(error!.localizedDescription)")
            }
            if response != nil {
                do {
                    let json = try JSONDecoder().decode(UserLoginResponse.self, from: data!)
                    
                    checkUserChanged(loginName: username)
                    createIMClient(userLoginInfo: json, errorAction: errorAction)
                       
                } catch {
                    do {
                        let json = try JSONDecoder().decode(FpnnErrorResponse.self, from: data!)
                        errorAction(json.ex)
                    } catch {
                        errorAction("Error during JSON serialization: \(error.localizedDescription)")
                    }
                }
            }
        })
        
        task.resume()
    }

修改 RegisterView.swift,修改 userRegister() 函数如下:

func userRegister(){
        
        if username.isEmpty {
            self.alertTitle = "无效输入"
            self.errorMessage = "用户名不能为空!"
            self.showAlert = true
            
            return
        }
        
        if password.isEmpty {
            self.alertTitle = "无效输入"
            self.errorMessage = "用户密码不能为空!"
            self.showAlert = true
            
            return
        }
        
        if passwordAgain.isEmpty {
            self.alertTitle = "无效输入"
            self.errorMessage = "确认密码不能为空!"
            self.showAlert = true
            
            return
        }
        
        if password != passwordAgain {
            self.alertTitle = "无效输入"
            self.errorMessage = "确认密码不匹配!"
            self.showAlert = true
            
            return
        }

        self.showLoginingHint = true
        
        BizClient.register(username: username, password: password, errorAction: {
                    (message) in
                    
                    self.showLoginingHint = false
                    self.errorMessage = message
                    self.loginFailed = true
                })
    }

注册 + 登陆流程,完成!

7. 会话窗口消息展示

进入会话窗口前,需要先准备好本地已保存的历史消息。然后显示窗口界面的同时,异步线程开始拉取最新的历史消息,并且检查之前的历史消息是否有缺失,有缺失就会持续填补,直到填补完成,或者用户退出等引发的填补中断。

编辑 IMCenter.swift,加入 showDialogueView() 函数:

    class func showDialogueView(contact: ContactInfo) {
        
        //-- 仅能在主线程中调用
        IMCenter.viewSharedInfo.targetContact = contact
        
        IMCenter.viewSharedInfo.strangerContacts.removeAll()
        
        IMCenter.prepareDialogueMesssageInfos(contact: contact)
        
        IMCenter.viewSharedInfo.lastPage = IMCenter.viewSharedInfo.currentPage
        
        IMCenter.viewSharedInfo.currentPage = .DialogueView
    }

并修改 ContactItemView.swift 中 onTapGesture 行为为:

        ... ...

        .onTapGesture {
            IMCenter.showDialogueView(contact: contactInfo)
        }

        ... ...

showDialogueView() 主要清理上一个(如果存在)会话的相关数据,并调用 prepareDialogueMesssageInfos() 函数,准备新的会话数据,然后显示会话窗口界面。

7.1. 准备会话窗口数据

prepareDialogueMesssageInfos() 函数先加载本地保存的历史数据,并按时间排序;然后启动两个异步线程,一个清理未同步的未知联系人信息,一个检查并不断填充历史消息。

prepareDialogueMesssageInfos() 代码如下:

    private class func soreChatMessages(messages: [ChatMessage]) -> [ChatMessage] {
        if messages.count <= 1 {
            return messages
        }
        
        return messages.sorted(by: { (m1, m2) -> Bool in
            if m1.mtime < m2.mtime {
                return true
            }
            
            if m1.mtime == m2.mtime {
                if m1.mid < m2.mid {
                    return true
                }
            }
            return false
        })
    }


    class func prepareDialogueMesssageInfos(contact:ContactInfo) {
        
        if contact.kind == ContactKind.Room.rawValue {
            DispatchQueue.global(qos: .default).async {
                IMCenter.client!.enterRoom(withId: NSNumber(value: contact.xid), timeout: 0, success: {
                    
                    DispatchQueue.main.sync {
                        
                        sendCmd(contact: contact, message: "\(getSelfDispalyName()) 进入房间")
                    }

                }, fail: { _ in })
            }
        }
        
        let chatMessages = IMCenter.db.loadAllMessages(contact:contact)
        IMCenter.viewSharedInfo.dialogueMesssages = soreChatMessages(messages: chatMessages)
        
        DispatchQueue.global(qos: .default).async {
            let unknownContacts = pickupUnknownContacts(messages:chatMessages)
            cleanUnknownContacts(unknownContacts: unknownContacts)
        }
        
        DispatchQueue.global(qos: .default).async {
            refillHistoryMessage(contact:contact)
        }
    }

7.2. 发送系统通知

当点击的联系人为房间类型时,为了简单起见,直接通过客户端进入房间。这时,我们需要告诉房间中的其他用户,谁进入房间了。因此,我们在这里用 RTM 预定义的 Cmd 类型消息发送系统通知:

    private class func sendGroupCmd(contact:ContactInfo, message:String) {
        IMCenter.client!.sendGroupCmdMessageChat(withId: NSNumber(value: contact.xid), message: message, attrs: "", timeout:0, success: { _ in
        }, fail: {
            _ in
        })
    }
    
    private class func sendRoomCmd(contact:ContactInfo, message:String) {
        IMCenter.client!.sendRoomCmdMessageChat(withId: NSNumber(value: contact.xid), message: message, attrs: "", timeout:0, success: { _ in
        }, fail: {
            _ in
        })
    }

    class func sendCmd(contact:ContactInfo, message:String) {
        if contact.kind == ContactKind.Group.rawValue {
            sendGroupCmd(contact:contact, message:message)
        }else if contact.kind == ContactKind.Room.rawValue {
            sendRoomCmd(contact:contact, message:message)
        } else { return }
        
        objc_sync_enter(locker)
        let mid = fakeMid
        fakeMid += 1
        objc_sync_exit(locker)
        
        let curr = Date().timeIntervalSince1970 * 1000
        
        let chatMessage = ChatMessage(sender: IMCenter.client!.userId, mid: mid, mtime: Int64(curr), message: message)
        chatMessage.isChat = false
        
        var chats = IMCenter.viewSharedInfo.dialogueMesssages
        chats.append(chatMessage)
        
        IMCenter.viewSharedInfo.dialogueMesssages = chats
    }

因为用户发送消息是网络操作,而网络操作可能会失败,比如断网时。因此我们无法等服务器确认后,再将用户发送的内容显示到界面上。但 RTM 只有在返回后,才能获取到消息的 messageId。但显示消息需要使用 MessageId 初始化 ChatMessage 对象。于是我们采用了当年QQ的方法:直接本地显示(这方法现在微信也还在用)。为此,我们引入了一个虚假的MessageId:fakeMid:Int64 进行代替。

但是在其他情况下,比如修改信息或者其他情况,也需要发送系统通知。而且这些系统通知往往是在网络操作完成后,在其他线程内异步触发的。那就存在着 fakeMid 被并发读写的情况。于是我们需要添加一个锁对象 class Locker,并用其进行同步。
于是在 IMCenter.swift 中,增加以下代码:

... ...

class Locker {}

... ...

class IMCenter {
    
    ... ...
    
    static var locker = Locker()
    
    ... ...
    
    static var fakeMid:Int64 = 1
    
    ... ...
}

7.3. 同步未知联系人
在一个群组,或者房间中,RTM系统推送过来的消息,只有发送人唯一数字ID,而其它的展示信息,则须要我们自己获取。无论是从我们自己的服务器上获取,还是从RTM服务器上获取。
此外,在获取的过程中,可能因为用户退出等原因,获取过程被中断,此时,App本地的数据中也将存在未知联系人。所以 pickupUnknownContacts() 便是从本地消息中筛查出未知联系人,然后交给 cleanUnknownContacts() 进行信息更新。
相关代码如下:

    private class func syncQueryUsersInfos(type: Int, contacts:[ContactInfo]) {
        
        var queryIds = [NSNumber]()
        for contact in contacts {
            queryIds.append(NSNumber(value: contact.xid))
        }
        
        let attriAnswer = IMCenter.client!.getUserOpenInfo(queryIds, timeout: 0)
        let strangers = decodeAttributeAnswer(type: type, attriAnswer: attriAnswer)
        
        DispatchQueue.main.async {
            for stranger in strangers {
                if let contact = IMCenter.viewSharedInfo.strangerContacts[stranger.xid] {
                    contact.nickname = stranger.nickname
                    contact.imageUrl = stranger.imageUrl
                    contact.showInfo = stranger.showInfo
                } else {
                    IMCenter.viewSharedInfo.strangerContacts[stranger.xid] = stranger
                }
            }
        }
    }

    private class func pickupUnknownContacts(messages:[ChatMessage]) -> [ContactInfo] {
        
        var uids: Set = []
        for msg in messages {
            if msg.sender != IMCenter.client!.userId {
                uids.insert(msg.sender)
            }
        }
        
        var contacts = [ContactInfo]()
        if uids.isEmpty == false {
            let allUsers = IMCenter.db.loadAllUserContactInfos()
            
            for uid in uids {
                if let contact = allUsers[uid] {
                    if contact.nickname.isEmpty || contact.imageUrl.isEmpty {
                        contacts.append(contact)
                    }
                } else {
                    contacts.append(ContactInfo(xid: uid))
                }
            }
        }
        
        return contacts
    }

    private class func cleanUnknownContacts(unknownContacts:[ContactInfo]) {
        
        //-- 查询 xname
        var uids = [Int64]()
        for contact in unknownContacts {
            uids.append(contact.xid)
        }
        BizClient.lookup(users: nil, groups: nil, rooms: nil, uids: uids, gids: nil, rids: nil, completedAction: {
            lookupData in
            
            var strangers = [ContactInfo]()
            for (key, value) in lookupData.users {
                let contact = ContactInfo(type: ContactKind.Stranger.rawValue, xid: value)
                contact.xname = key
                
                strangers.append(contact)
            }
            
            DispatchQueue.main.async {
                for stranger in strangers {
                    if let contact = IMCenter.viewSharedInfo.strangerContacts[stranger.xid] {
                        contact.xname = stranger.xname
                    } else {
                        IMCenter.viewSharedInfo.strangerContacts[stranger.xid] = stranger
                    }
                }
            }
        }, errorAction: { _ in })
        
        //-- 查询展示信息
        if unknownContacts.count < 100 {
            syncQueryUsersInfos(type: ContactKind.Stranger.rawValue, contacts: unknownContacts)
        } else {
            var queryContacts = [ContactInfo]()
            
            for contact in unknownContacts {
                queryContacts.append(contact)
                if queryContacts.count == 99 {
                    syncQueryUsersInfos(type: ContactKind.Stranger.rawValue, contacts: queryContacts)
                    queryContacts.removeAll()
                }
            }
            
            if queryContacts.count > 0 {
                syncQueryUsersInfos(type: ContactKind.Stranger.rawValue, contacts: queryContacts)
            }
        }
    }

7.4 填补空缺的历史消息

最后一步,是填补历史消息空缺。填补历史消息空缺其实也很简单。先从数据库中获取已经保存的历史消息检查点,按从新到旧进行排序。然后从新到旧,以10条为单位(RTM的默认限制),降序拉取当前会话的历史消息,然后逐条保存。一旦需要保存的历史消息已经在数据库中存在,则意味着已经填补了历史消息的最新一段空缺。则检查对应范围内是否存在历史消息检查点,如果存在则删除,不存在则以最新的历史消息检查点开始,继续从新到旧,以降序拉取历史消息。
如果这之间发现有未知联系人,则启动新的异步线程进行同步。
填补空缺历史消息的 refillHistoryMessage() 函数及相关代码如下:

    private class func sortHistoryCheckpoint(checkpoints:[HistoryCheckpoint]) -> [HistoryCheckpoint] {
        if checkpoints.count < 2 {
            return checkpoints
        }
        
        return checkpoints.sorted(by: { (c1, c2) -> Bool in
            if c1.ts > c2.ts {
                return true
            }
            if c1.ts == c2.ts {
                return c1.desc
            }
            return false
        })
    }

    private class func refillHistoryMessage(contact:ContactInfo) {
        var historyAnswer: RTMHistoryMessageAnswer? = nil
        var begin: Int64 = 0
        var end: Int64 = 0
        var lastId: Int64 = 0
        
        let fetchCount = 10
        let nsXid = NSNumber(value: contact.xid)
        let nsCount = NSNumber(value: fetchCount)
        
        var checkpoints = db.loadAllHistoryMessageCheckpoints(contact:contact)
        checkpoints = sortHistoryCheckpoint(checkpoints: checkpoints)
        
        while (true)
        {
            if contact.kind == ContactKind.Friend.rawValue {
                historyAnswer = IMCenter.client!.getP2PHistoryMessageChat(withUserId: nsXid, desc: true, num: nsCount, begin: NSNumber(value: begin), end: NSNumber(value: end), lastid: NSNumber(value: lastId), timeout: 0)
            } else if contact.kind == ContactKind.Group.rawValue {
                historyAnswer = IMCenter.client!.getGroupHistoryMessageChat(withGroupId: nsXid, desc: true, num: nsCount, begin: NSNumber(value: begin), end: NSNumber(value: end), lastid: NSNumber(value: lastId), timeout: 0)
            } else if contact.kind == ContactKind.Room.rawValue {
                historyAnswer = IMCenter.client!.getRoomHistoryMessageChat(withRoomId: nsXid, desc: true, num: nsCount, begin: NSNumber(value: begin), end: NSNumber(value: end), lastid: NSNumber(value: lastId), timeout: 0)
            } else { return }
            
            if historyAnswer != nil && historyAnswer!.error.code == 0 {
                
                var chatMessages = [ChatMessage]()
                for message in historyAnswer!.history.messageArray {
                    
                    if message.messageType == 30 {
                        if IMCenter.db.insertChatMessage(type: contact.kind, xid: contact.xid, sender: message.fromUid, mid: message.messageId, message: message.stringMessage, mtime: message.modifiedTime, printError: false) == false {
                            break
                        }
                    } else {
                        if IMCenter.db.insertChatCmd(type: contact.kind, xid: contact.xid, sender: message.fromUid, mid: message.messageId, message: message.stringMessage, mtime: message.modifiedTime, printError: false) == false {
                            break
                        }
                    }
                    
                    let chatMsg = ChatMessage(sender: message.fromUid, mid: message.messageId, mtime: message.modifiedTime, message: message.stringMessage)
                    
                    if message.messageType != 30 {
                        chatMsg.isChat = false
                    }
                    
                    chatMessages.append(chatMsg)
                }
                
                if chatMessages.count > 0 {
                    
                    DispatchQueue.global(qos: .default).async {
                        
                        let unknownContacts = pickupUnknownContacts(messages:chatMessages)
                        cleanUnknownContacts(unknownContacts: unknownContacts)
                    }
                    
                    var continueLoading = true

                    DispatchQueue.main.sync {
                        if IMCenter.viewSharedInfo.targetContact == nil
                            || IMCenter.viewSharedInfo.targetContact!.xid != contact.xid
                            || IMCenter.viewSharedInfo.targetContact!.kind != contact.kind {
                            continueLoading = false
                        } else {
                            var oldDialogues = IMCenter.viewSharedInfo.dialogueMesssages
                            
                            for chatMsg in chatMessages {
                                oldDialogues.append(chatMsg)
                            }
                            
                            oldDialogues = soreChatMessages(messages: oldDialogues)
                            IMCenter.viewSharedInfo.dialogueMesssages = oldDialogues
                        }
                    }

                    if continueLoading == false {
                        IMCenter.db.insertCheckPoint(type: contact.kind, xid: contact.xid, ts:historyAnswer!.history.end, desc:true)
                        return
                    }
                }
                
                if historyAnswer!.history.messageArray.count < fetchCount {
                    IMCenter.db.clearAllHistoryMessageCheckpoints(contact: contact)
                    return
                }
                
                if chatMessages.count == fetchCount {
                    
                    begin = historyAnswer!.history.begin
                    end = historyAnswer!.history.end
                    lastId = historyAnswer!.history.lastid
                    
                } else {
                    while (true) {
                        if checkpoints.count == 0 { return }
                        
                        if checkpoints.first!.ts >= end {
                            checkpoints.removeFirst()
                        } else {
                            begin = 0
                            end = Int64(checkpoints.first!.ts)
                            lastId = 0
                            
                            break
                        }
                    }
                }
            } else if historyAnswer != nil && historyAnswer!.error.code == 200010 {
                var continueLoading = true
                DispatchQueue.main.sync {
                    if IMCenter.viewSharedInfo.targetContact == nil
                        || IMCenter.viewSharedInfo.targetContact!.xid != contact.xid
                        || IMCenter.viewSharedInfo.targetContact!.kind != contact.kind {
                        continueLoading = false
                    }
                }
                
                if continueLoading == false {
                    IMCenter.db.insertCheckPoint(type: contact.kind, xid: contact.xid, ts:end, desc:true)
                    return
                }
                
                sleep(2)
            } else {
                return
            }
        }
    }

至此,会话窗口的历史信息处理完毕。

7.5 消息的发送

在我们开始修改会话窗口的视图界面前,我们还的处理消息的发送行为。毕竟会话窗口不仅需要展示会话内容,还需要提供发送消息的能力。
sendMessage() 其实与 sendCmd() 高度类似。毕竟 sendMessge() 本质上是使用的 RTM 预定义的 chat 类型消息。chat 类型消息与 cmd 消息类型的不同之处在于,如果 chat 的内容是单纯的文本聊天内容,而非json、xml等结构化的信息,则可以直接开启RTM的文本自动翻译和文本自动审核两个功能。除此之外,其余没有差别。

    private class func sendP2PMessage(contact:ContactInfo, message:String) {
        IMCenter.client!.sendP2PMessageChat(withId: NSNumber(value: contact.xid), message: message, attrs: "", timeout:0, success: {
            answer in
            _ = IMCenter.db.insertChatMessage(type: ContactKind.Friend.rawValue, xid: contact.xid, sender: IMCenter.client!.userId, mid: answer.messageId, message: message, mtime: answer.mtime)
        }, fail: {
            _ in
            //-- 这里应该在UI上显示红色圆形底叹号,但IMDemo为演示目的,这里从略
        })
    }
    
    private class func sendGroupMessage(contact:ContactInfo, message:String) {
        IMCenter.client!.sendGroupMessageChat(withId: NSNumber(value: contact.xid), message: message, attrs: "", timeout:0, success: {
            answer in
            _ = IMCenter.db.insertChatMessage(type: ContactKind.Group.rawValue, xid: contact.xid, sender: IMCenter.client!.userId, mid: answer.messageId, message: message, mtime: answer.mtime)
        }, fail: {
            _ in
            //-- 这里应该在UI上显示红色圆形底叹号,但IMDemo为演示目的,这里从略
        })
    }
    
    private class func sendRoomMessage(contact:ContactInfo, message:String) {
        IMCenter.client!.sendRoomMessageChat(withId: NSNumber(value: contact.xid), message: message, attrs: "", timeout:0, success: {
            answer in
            _ = IMCenter.db.insertChatMessage(type: ContactKind.Room.rawValue, xid: contact.xid, sender: IMCenter.client!.userId, mid: answer.messageId, message: message, mtime: answer.mtime)
        }, fail: {
            _ in
            //-- 这里应该在UI上显示红色圆形底叹号,但IMDemo为演示目的,这里从略
        })
    }

    class func sendMessage(contact:ContactInfo, message:String) {
        if contact.kind == ContactKind.Friend.rawValue {
            sendP2PMessage(contact:contact, message:message)
        } else if contact.kind == ContactKind.Group.rawValue {
            sendGroupMessage(contact:contact, message:message)
        }else if contact.kind == ContactKind.Room.rawValue {
            sendRoomMessage(contact:contact, message:message)
        } else { return }
        
        objc_sync_enter(locker)
        let mid = fakeMid
        fakeMid += 1
        objc_sync_exit(locker)
        
        let curr = Date().timeIntervalSince1970 * 1000
        
        let chatMessage = ChatMessage(sender: IMCenter.client!.userId, mid: mid, mtime: Int64(curr), message: message)
        
        var chats = IMCenter.viewSharedInfo.dialogueMesssages
        chats.append(chatMessage)
        
        IMCenter.viewSharedInfo.dialogueMesssages = chats
    }

7.6 实现之前占位的辅助功能

在最后和会话窗口页面关联起来之前,第二篇中,会话窗口需要的一个功能 findContact(),当时仅实现了一个空函数用于占位。此时,我们需要先将其完善。

函数的基本流程是,现在好友列表中查找,找到返回。如果不存在,则在记录的陌生人中查找。如果还找不到,检查是不是当前登录用户自身。如果不是,记录新的陌生人信息,并调用 cleanUnknownContacts() 同步联系人信息。

编辑 IMCenter.swift,修改 findContact() 函数代码如下:

class func findContact(chatMessage: ChatMessage) -> ContactInfo {
        //-- 好友列表中查找
        for contact in IMCenter.viewSharedInfo.contactList[ContactKind.Friend.rawValue]! {
            if contact.xid == chatMessage.sender {
                return contact
            }
        }
        
        //-- 陌生人中查找
        if let contact = IMCenter.viewSharedInfo.strangerContacts[chatMessage.sender] {
            return contact
        }
        
        let contact = ContactInfo(type: ContactKind.Stranger.rawValue, xid: chatMessage.sender)
        
        if chatMessage.sender == IMCenter.client!.userId {
            let username = IMCenter.fetchUserProfile(key: "username")
            contact.imageUrl = IMCenter.fetchUserProfile(key: "\(username)-image-url")
            contact.imagePath = IMCenter.fetchUserProfile(key: "\(username)-image")
        } else {
            IMCenter.viewSharedInfo.strangerContacts[chatMessage.sender] = contact
            
            DispatchQueue.global(qos: .default).async {
                
                var unknownContacts = [ContactInfo]()
                unknownContacts.append(contact)
                cleanUnknownContacts(unknownContacts: unknownContacts)
            }
        }
        
        return contact
    }

7.7 界面UI同步修改
最后,我们将功能和页面关联起来。
编辑 DialogueView.swift,首先是 DialogueHeaderView 视图,增加函数 updateSessionState(),并修改返回按钮的 onTapGesture 行为如下:

struct DialogueHeaderView: View {
    
    ... ...

    func updateSessionState() {
        for session in IMCenter.viewSharedInfo.sessions {
            if session.contact.kind == IMCenter.viewSharedInfo.targetContact!.kind && session.contact.xid == IMCenter.viewSharedInfo.targetContact!.xid {
                session.lastMessage.unread = false
                
                for idx in 0..

函数 updateSessionState() 用于在返回联系人列表或者会话列表界面前,去除会话页面中,当前会话可能的未读消息状态,以及更新联系人名称下显示的最新一条聊天消息。

之后是 DialogueFooterView 视图,添加对发送功能的调用:

struct DialogueFooterView: View {
 
    ... ...
    
    var body: some View {
        HStack {
            ... ...
            
            Image("button_send")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                .padding((IMDemoUIConfig.topNavigationHight - IMDemoUIConfig.navigationIconEdgeLen)/2)
                .onTapGesture {
                    
                    IMCenter.viewSharedInfo.newMessageReceived = false
                    
                    if self.message.isEmpty {
                        viewInfo.newestMessage = IMCenter.viewSharedInfo.dialogueMesssages.last!
                        hideKeyboard()
                        return
                    }
                    
                    IMCenter.sendMessage(contact: contact, message: self.message)
                    self.message = ""
                    
                    hideKeyboard()
                    viewInfo.newestMessage = IMCenter.viewSharedInfo.dialogueMesssages.last!
                }
        }
    }
}

最后是 DialogueView 视图,修正对当前用户ID的引用:

struct DialogueView: View {
    
    ... ...
    
    init() {
        self.contact = IMCenter.viewSharedInfo.targetContact!
        self.selfId = IMCenter.client!.userId
        self.viewInfo = IMCenter.viewSharedInfo
        self.contactForInfoPage = self.contact
    }
    
    ... ...
}

到此,会话视图便开发完毕。

8. 菜单事件

接下来,便是对菜单的点击产生响应。

8.1 添加好友

添加好友的基本思路是:

  1. 如果查询本地联系人信息,已经是好友了,则直接打开会话页面;
  2. 如果本地将对方作为陌生人记录,则将陌生人标记修改为好友标记,并添加会话条目到会话列表页,然后打开会话页面;
  3. 否则查询业务服务器,确认对方是合法注册用户,然后保存联系人信息,并添加会话条目到会话列表页,打开会话页面。

编辑 IMCenter.swift,添加 入口函数 addFriendInMainThread() 及相关功能函数:

    class func addNewSessionByMenuActionInMainThread(contact: ContactInfo) {
        
        var contacts = [ContactInfo]()
        contacts.append(contact)
        
        IMCenter.appendContactsForSessionView(contacts: contacts)
        IMCenter.appendContactsForContactView(contacts: contacts)
        
        DispatchQueue.global(qos: .default).async {
            downloadImage(contactInfo: contact)
            checkContactsUpdate(type: contact.kind, contacts: contacts)
        }
    }

    class func addFriendInMainThread(xname: String, existAction: @escaping (_ contact: ContactInfo)->Void, successAction: @escaping (_ contact: ContactInfo)->Void, errorAction: @escaping (_ errorInfo: ErrorInfo)->Void) {
        
        var contact = db.loadContentInfo(type: ContactKind.Friend.rawValue, xname: xname)
        if contact != nil {
            existAction(contact!)
            showDialogueView(contact: contact!)
            return
        } else {
            contact = db.loadContentInfo(type: ContactKind.Stranger.rawValue, xname: xname)
            if contact != nil {
                existAction(contact!)
                db.changeStrangerToFriend(xid: contact!.xid)
                addNewSessionByMenuActionInMainThread(contact: contact!)
                showDialogueView(contact: contact!)
                return
            }
        }
        
        var users = [String]()
        users.append(xname)
        
        BizClient.lookup(users: users, groups: nil, rooms: nil, uids: nil, gids: nil, rids: nil, completedAction: {
            respon in
            if let uid = respon.users[xname] {
                let contact = ContactInfo(type: ContactKind.Friend.rawValue, uniqueId: uid, uniqueName: xname, nickname: "")
                
                DispatchQueue.main.async {
                    successAction(contact)
                }
                return
            } else {
                var errInfo = ErrorInfo()
                errInfo.title = "用户不存在"
                errInfo.desc = "被添加的用户尚未注册!"
                
                DispatchQueue.main.async {
                    errorAction(errInfo)
                }
            }
        }, errorAction: { errorMessage in
            
            var errInfo = ErrorInfo()
            
            errInfo.title = "添加好友失败"
            errInfo.desc = errorMessage
            
            DispatchQueue.main.async {
                errorAction(errInfo)
            }
        })
    }

修改 MenuActionView.swift,编辑 AddFriendView 视图,添加 checkInput() 函数,并修改“添加”按钮响应如下:

struct AddFriendView: View {
    ... ...
    
    func checkInput() -> Bool {
        if username.isEmpty {
            let error = ErrorInfo(title: "无效的输入", desc: "用户名不能为空!")
            IMCenter.errorInfo = error
            
            return false
        }
        return true
    }
    
    var body: some View {
        ... ...
        
            Button("添加") {
                
                if !checkInput() {
                    showError = true
                    return
                }
                
                showProcessing = true
                IMCenter.addFriendInMainThread(xname: self.username, existAction: {
                    contact in
                    
                    showProcessing = false
                    IMCenter.viewSharedInfo.menuAction = .HideMode
                    
                }, successAction: {
                    contact in
                    
                    showProcessing = false
                    IMCenter.viewSharedInfo.menuAction = .HideMode
                    
                    IMCenter.db.storeNewContact(contact: contact)
                    IMCenter.addNewSessionByMenuActionInMainThread(contact: contact)
                    IMCenter.showDialogueView(contact: contact)
                    
                }, errorAction: {
                    errorInfo in
                    
                    IMCenter.errorInfo = errorInfo
                    
                    showProcessing = false
                    showError = true
                })
            }
        
        ... ...
    }
}

此时,“添加好友”菜单功能完成。

8.2 创建群组
创建群组的基本思路:

  1. 查询本地联系人信息,如果已经是群组成员,则直接打开会话页面;
  2. 否则查询业务服务器,确认目标是合法群组,然后保存群组联系人信息,并添加会话条目到会话列表页,打开会话页面。

编辑 BizClient.swift,完成 createGroup() 函数:

... ...

struct CreateGroupResponse: Codable {
    var gid: Int64
}

... ...

class BizClient {
    
    ... ...
    
    class func createGroup(uniqueName: String, completedAction: @escaping (_ gid: Int64) -> Void, errorAction: @escaping (_ message: String) -> Void) {
        
        let url = URL(string:"http://" + IMDemoConfig.IMDemoServerEndpoint + "/service/createGroup?uid=\(IMCenter.client!.userId)&group=" + urlEncode(string: uniqueName))!
        
        let task = URLSession.shared.dataTask(with:url, completionHandler:{
            (data, response, error) in
            
            if error != nil {
                errorAction("连接错误: \(error!.localizedDescription)")
            }
            if response != nil {
                do {
                    let json = try JSONDecoder().decode(CreateGroupResponse.self, from: data!)
                    completedAction(json.gid)
                } catch {
                    do {
                        let json = try JSONDecoder().decode(FpnnErrorResponse.self, from: data!)
                        errorAction(json.ex)
                    } catch {
                        errorAction("Error during JSON serialization: \(error.localizedDescription)")
                    }
                }
            }
        })
        
        task.resume()
    }
    
    ... ...
}

编辑 IMCenter.swift,添加入口函数 createGroupInMainThread():

    class func createGroupInMainThread(xname: String, existAction: @escaping (_ contact: ContactInfo)->Void, successAction: @escaping (_ contact: ContactInfo)->Void, errorAction: @escaping (_ errorInfo: ErrorInfo)->Void) {
        
        let contact = db.loadContentInfo(type: ContactKind.Group.rawValue, xname: xname)
        if contact != nil {
            existAction(contact!)
            showDialogueView(contact: contact!)
            return
        }
        
        BizClient.createGroup(uniqueName: xname, completedAction: {
            gid in
            
            let contact = ContactInfo(type: ContactKind.Group.rawValue, uniqueId: gid, uniqueName: xname, nickname: "")
            
            DispatchQueue.main.async {
                successAction(contact)
            }
            
        }, errorAction: {
            errorMessage in
            
            var errInfo = ErrorInfo()
            
            errInfo.title = "创建群组失败"
            errInfo.desc = errorMessage
            
            DispatchQueue.main.async {
                errorAction(errInfo)
            }
        })
    }

修改 MenuActionView.swift,编辑 JoinGroupView 视图,添加 checkInput() 函数,并修改“创建”按钮响应如下:

struct CreateGroupView: View {
    
    ... ...

    func checkInput() -> Bool {
        if groupname.isEmpty {
            let error = ErrorInfo(title: "无效的输入", desc: "群组唯一名称不能为空!")
            IMCenter.errorInfo = error
            
            return false
        }
        return true
    }
    
    var body: some View {
        
        ... ...
        
            Button("创建") {
                if !checkInput() {
                    showError = true
                    return
                }
                
                showProcessing = true
                IMCenter.createGroupInMainThread(xname: self.groupname, existAction: {
                    contact in
                    
                    showProcessing = false
                    IMCenter.viewSharedInfo.menuAction = .HideMode
                    
                }, successAction: {
                    contact in
                    
                    showProcessing = false
                    IMCenter.viewSharedInfo.menuAction = .HideMode
                    
                    IMCenter.db.storeNewContact(contact: contact)
                    IMCenter.addNewSessionByMenuActionInMainThread(contact: contact)
                    IMCenter.showDialogueView(contact: contact)
                    
                }, errorAction: {
                    errorInfo in
                    
                    IMCenter.errorInfo = errorInfo
                    
                    showProcessing = false
                    showError = true
                })
            }
        
        ... ...
    }   
}

此时,“创建群组”菜单功能完成。

8.3 加入群组

加入群组的基本思路是:

  1. 如果查询本地联系人信息,如果已经加入群组了,则直接打开会话页面;
  2. 否则查询业务服务器,确认目标是合法群组,然后保存群组联系人信息,并添加会话条目到会话列表页,打开会话页面。

编辑 BizClient.swift,完成 joinGroup() 函数:

    class func joinGroup(uniqueGroupName: String, completedAction: @escaping (_ gid: Int64) -> Void, errorAction: @escaping (_ message: String) -> Void) {
        
        let url = URL(string:"http://" + IMDemoConfig.IMDemoServerEndpoint + "/service/joinGroup?uid=\(IMCenter.client!.userId)&group=" + urlEncode(string: uniqueGroupName))!
        
        let task = URLSession.shared.dataTask(with:url, completionHandler:{
            (data, response, error) in
            
            if error != nil {
                errorAction("连接错误: \(error!.localizedDescription)")
            }
            if response != nil {
                do {
                    let json = try JSONDecoder().decode(CreateGroupResponse.self, from: data!)
                    completedAction(json.gid)
                } catch {
                    do {
                        let json = try JSONDecoder().decode(FpnnErrorResponse.self, from: data!)
                        errorAction(json.ex)
                    } catch {
                        errorAction("Error during JSON serialization: \(error.localizedDescription)")
                    }
                }
            }
        })
        
        task.resume()
    }

编辑 IMCenter.swift,添加 入口函数 joinGroupInMainThread():

    class func joinGroupInMainThread(xname: String, existAction: @escaping (_ contact: ContactInfo)->Void, successAction: @escaping (_ contact: ContactInfo)->Void, errorAction: @escaping (_ errorInfo: ErrorInfo)->Void) {
        
        let contact = db.loadContentInfo(type: ContactKind.Group.rawValue, xname: xname)
        if contact != nil {
            existAction(contact!)
            showDialogueView(contact: contact!)
            return
        }
        
        BizClient.joinGroup(uniqueGroupName: xname, completedAction: {
            gid in
            
            let contact = ContactInfo(type: ContactKind.Group.rawValue, uniqueId: gid, uniqueName: xname, nickname: "")
            
            DispatchQueue.main.async {
                successAction(contact)
            }
            
        }, errorAction: {
            errorMessage in
            
            var errInfo = ErrorInfo()
            
            errInfo.title = "加入群组失败"
            errInfo.desc = errorMessage
            
            DispatchQueue.main.async {
                errorAction(errInfo)
            }
        })
    }

修改 MenuActionView.swift,编辑 JoinGroupView 视图,添加 checkInput() 函数,并修改“加入”按钮响应如下:

struct JoinGroupView: View {
    
    ... ...
    
    func checkInput() -> Bool {
        if groupname.isEmpty {
            let error = ErrorInfo(title: "无效的输入", desc: "群组唯一名称不能为空!")
            IMCenter.errorInfo = error
            
            return false
        }
        return true
    }
    
    var body: some View {
        
        ... ...
        
            Button("加入") {
                if !checkInput() {
                    showError = true
                    return
                }
                
                showProcessing = true
                IMCenter.joinGroupInMainThread(xname: self.groupname, existAction: {
                    contact in
                    
                    showProcessing = false
                    IMCenter.viewSharedInfo.menuAction = .HideMode
                    
                }, successAction: {
                    contact in
                    
                    showProcessing = false
                    IMCenter.viewSharedInfo.menuAction = .HideMode
                    
                    IMCenter.db.storeNewContact(contact: contact)
                    IMCenter.addNewSessionByMenuActionInMainThread(contact: contact)
                    IMCenter.showDialogueView(contact: contact)
                    
                }, errorAction: {
                    errorInfo in
                    
                    IMCenter.errorInfo = errorInfo
                    
                    showProcessing = false
                    showError = true
                })
            }
        
        ... ...
    }   
}

此时,“加入群组”菜单功能完成。

8.4 创建房间

创建房间的基本思路是:

  1. 查询本地联系人信息,如果本地保存有对应的房间联系人信息,则直接打开会话页面;
  2. 否则查询业务服务器,确认对方是合法房间,然后保存房间联系人信息,并添加会话条目到会话列表页,打开会话页面。

编辑 BizClient.swift,完成 createRoom() 函数:

... ...

struct CreateRoomResponse: Codable {
    var rid: Int64
}

... ...

class BizClient {
    
    ... ...

    class func createRoom(uniqueName: String, completedAction: @escaping (_ rid: Int64) -> Void, errorAction: @escaping (_ message: String) -> Void) {
        
        let url = URL(string:"http://" + IMDemoConfig.IMDemoServerEndpoint + "/service/createRoom?room=" + urlEncode(string: uniqueName))!
        
        let task = URLSession.shared.dataTask(with:url, completionHandler:{
            (data, response, error) in
            
            if error != nil {
                errorAction("连接错误: \(error!.localizedDescription)")
            }
            if response != nil {
                do {
                    let json = try JSONDecoder().decode(CreateRoomResponse.self, from: data!)
                    completedAction(json.rid)
                } catch {
                    do {
                        let json = try JSONDecoder().decode(FpnnErrorResponse.self, from: data!)
                        errorAction(json.ex)
                    } catch {
                        errorAction("Error during JSON serialization: \(error.localizedDescription)")
                    }
                }
            }
        })
        
        task.resume()
    }
    
    ... ...
}

编辑 IMCenter.swift,添加 入口函数 createRoomInMainThread():

    class func createRoomInMainThread(xname: String, existAction: @escaping (_ contact: ContactInfo)->Void, successAction: @escaping (_ contact: ContactInfo)->Void, errorAction: @escaping (_ errorInfo: ErrorInfo)->Void) {
        
        let contact = db.loadContentInfo(type: ContactKind.Room.rawValue, xname: xname)
        if contact != nil {
            existAction(contact!)
            showDialogueView(contact: contact!)
            return
        }
        
        BizClient.createRoom(uniqueName: xname, completedAction: {
            rid in
            
            let contact = ContactInfo(type: ContactKind.Room.rawValue, uniqueId: rid, uniqueName: xname, nickname: "")
            
            DispatchQueue.main.async {
                successAction(contact)
            }
            
        }, errorAction: {
            errorMessage in
            
            var errInfo = ErrorInfo()
            
            errInfo.title = "创建房间失败"
            errInfo.desc = errorMessage
            
            DispatchQueue.main.async {
                errorAction(errInfo)
            }
        })
    }

修改 MenuActionView.swift,编辑 CreateRoomView 视图,添加 checkInput() 函数,并修改“创建”按钮响应如下:

struct CreateRoomView: View {
    
    ... ...
    
    func checkInput() -> Bool {
        if roomname.isEmpty {
            let error = ErrorInfo(title: "无效的输入", desc: "房间唯一名称不能为空!")
            IMCenter.errorInfo = error
            
            return false
        }
        return true
    }
    
    var body: some View {
        
        ... ...
        
            Button("创建") {
                if !checkInput() {
                    showError = true
                    return
                }
                
                showProcessing = true
                IMCenter.createRoomInMainThread(xname: self.roomname, existAction: {
                    contact in
                    
                    showProcessing = false
                    IMCenter.viewSharedInfo.menuAction = .HideMode
                    
                }, successAction: {
                    contact in
                    
                    showProcessing = false
                    IMCenter.viewSharedInfo.menuAction = .HideMode
                    
                    IMCenter.db.storeNewContact(contact: contact)
                    IMCenter.addNewSessionByMenuActionInMainThread(contact: contact)
                    IMCenter.showDialogueView(contact: contact)
                    
                }, errorAction: {
                    errorInfo in
                    
                    IMCenter.errorInfo = errorInfo
                    
                    showProcessing = false
                    showError = true
                })
            }
            
        ... ...
    }
}

此时,“创建房间”菜单功能完成。

8.5 加入房间

加入房间的基本思路是:

  1. 查询本地联系人信息,如果本地保存有对应的房间联系人信息,则直接打开会话页面;
  2. 否则查询业务服务器,确认对方是合法房间,然后保存房间联系人信息,并添加会话条目到会话列表页,打开会话页面。

编辑 IMCenter.swift,添加 入口函数 joinRoomInMainThread() 及相关功能函数:

    class func joinRoomInMainThread(xname: String, existAction: @escaping (_ contact: ContactInfo)->Void, successAction: @escaping (_ contact: ContactInfo)->Void, errorAction: @escaping (_ errorInfo: ErrorInfo)->Void) {
        
        let contact = db.loadContentInfo(type: ContactKind.Room.rawValue, xname: xname)
        if contact != nil {
            existAction(contact!)
            showDialogueView(contact: contact!)
            return
        }
        
        var rooms = [String]()
        rooms.append(xname)
        
        BizClient.lookup(users: nil, groups: nil, rooms: rooms, uids: nil, gids: nil, rids: nil, completedAction: {
            respon in
            if let rid = respon.rooms[xname] {
                let contact = ContactInfo(type: ContactKind.Room.rawValue, uniqueId: rid, uniqueName: xname, nickname: "")
                
                DispatchQueue.main.async {
                    successAction(contact)
                }
                return
            } else {
                var errInfo = ErrorInfo()
                errInfo.title = "房间不存在"
                errInfo.desc = "房间尚未被创建!"
                
                DispatchQueue.main.async {
                    errorAction(errInfo)
                }
            }
        }, errorAction: { errorMessage in
            
            var errInfo = ErrorInfo()
            
            errInfo.title = "进入房间失败"
            errInfo.desc = errorMessage
            
            DispatchQueue.main.async {
                errorAction(errInfo)
            }
        })
    }

修改 MenuActionView.swift,编辑 EnterRoomView 视图,添加 checkInput() 函数,并修改“加入”按钮响应如下:

struct EnterRoomView: View {
    
    ... ...
    
    func checkInput() -> Bool {
        if roomname.isEmpty {
            let error = ErrorInfo(title: "无效的输入", desc: "房间唯一名称不能为空!")
            IMCenter.errorInfo = error
            
            return false
        }
        return true
    }
    
    var body: some View {
        
        ... ...
        
            Button("加入") {
                if !checkInput() {
                    showError = true
                    return
                }
                
                showProcessing = true
                IMCenter.joinRoomInMainThread(xname: self.roomname, existAction: {
                    contact in
                    
                    showProcessing = false
                    IMCenter.viewSharedInfo.menuAction = .HideMode
                    
                }, successAction: {
                    contact in
                    
                    showProcessing = false
                    IMCenter.viewSharedInfo.menuAction = .HideMode
                    
                    IMCenter.db.storeNewContact(contact: contact)
                    IMCenter.addNewSessionByMenuActionInMainThread(contact: contact)
                    IMCenter.showDialogueView(contact: contact)
                    
                }, errorAction: {
                    errorInfo in
                    
                    IMCenter.errorInfo = errorInfo
                    
                    showProcessing = false
                    showError = true
                })
            }
            
        ... ...
    }
}

至此,菜单所有功能响应,添加完毕。

9. 接收消息

RTM消息种类其实非常丰富,目前我们可能收到的消息有五类:P2P聊天消息、群组聊天消息、房间聊天消息、群组系统通知、房间系统通知。

为了简化起见,聊天消息我们统一处理。
处理函数 receiveNewNessage() 先将收到的消息保存入数据库,然后发起一个主线程异步操作:

  1. 检查会话列表中是否已有对应会话
  2. 若当前不存在对应会话,则添加新会话和新联系人,然后更新联系人信息
  3. 若当前存在对应会话,则更新会话列表页中,对应条目的未读状态,以及最新聊天信息
  4. 若当前存在对应会话,且当前为对应的会话页面,则更新聊天信息列表,然后显示新消息标记

编辑 IMCenter.swift,添加代码如下:

    private class func addNewSession(contact: ContactInfo, message: RTMMessage?) {
        db.storeNewContact(contact: contact)
        downloadImage(contactInfo: contact)
        
        var contacts = [ContactInfo]()
        contacts.append(contact)
        
        DispatchQueue.main.async {
            IMCenter.appendContactsForContactView(contacts: contacts)
            
            var oldSessions = IMCenter.viewSharedInfo.sessions
            
            let sessionItem = SessionItem(contact: contact)
            if message != nil {
                sessionItem.lastMessage.message = extraChatMessage(rtmMessage: message!)
                sessionItem.lastMessage.mid = message!.messageId
                sessionItem.lastMessage.timestamp = message!.modifiedTime
                sessionItem.lastMessage.unread = true
            }
            oldSessions.append(sessionItem)
            
            IMCenter.viewSharedInfo.sessions = sortSessions(sessions: oldSessions)
            
            DispatchQueue.global(qos: .default).async {
                checkContactsUpdate(type: contact.kind, contacts: contacts)
            }
        }
    }

    private class func extraChatMessage(rtmMessage: RTMMessage) -> String {
        
        if rtmMessage.translatedInfo.targetText.isEmpty == false {
            return rtmMessage.translatedInfo.targetText
        }
        
        if rtmMessage.translatedInfo.sourceText.isEmpty == false {
            return rtmMessage.translatedInfo.sourceText
        }
        
        if rtmMessage.stringMessage.isEmpty == false {
            return rtmMessage.stringMessage
        }
        
        return ""
    }

    class func receiveNewNessage(type:Int, rtmMessage: RTMMessage) {
        
        _ = db.insertChatMessage(type: type, xid: rtmMessage.toId, sender: rtmMessage.fromUid, mid: rtmMessage.messageId, message: extraChatMessage(rtmMessage: rtmMessage), mtime: rtmMessage.modifiedTime)
        
        DispatchQueue.main.async {
            let sessions = IMCenter.viewSharedInfo.sessions
            
            DispatchQueue.global(qos: .default).async {
                for session in sessions {
                    let matchXid = (session.contact.kind == ContactKind.Friend.rawValue) ? rtmMessage.fromUid : rtmMessage.toId
                    if session.contact.kind == type && session.contact.xid == matchXid {
                        //-- 已有的 session
                        session.lastMessage.mid = rtmMessage.messageId
                        session.lastMessage.message = extraChatMessage(rtmMessage: rtmMessage)
                        session.lastMessage.timestamp = rtmMessage.modifiedTime
                        session.lastMessage.unread = true
                        
                        DispatchQueue.main.async {
                            let sessions2 = sortSessions(sessions: sessions)
                            IMCenter.viewSharedInfo.sessions = sessions2
                            
                            if let currContact = IMCenter.viewSharedInfo.targetContact {
                                if currContact.kind == type && currContact.xid == matchXid {
                                    
                                    var dialogueMesssages = IMCenter.viewSharedInfo.dialogueMesssages
                                    let chatMsg = ChatMessage(sender: rtmMessage.fromUid, mid: rtmMessage.messageId, mtime: rtmMessage.modifiedTime, message: extraChatMessage(rtmMessage: rtmMessage))
                                    dialogueMesssages.append(chatMsg)
                                    
                                    dialogueMesssages = soreChatMessages(messages: dialogueMesssages)
                                    
                                    IMCenter.viewSharedInfo.dialogueMesssages = dialogueMesssages
                                    IMCenter.viewSharedInfo.newMessageReceived = true
                                }
                            }
                        }
                        return
                    }
                }
                
                //-- new Session
                var newContact: ContactInfo? = nil
                if type == ContactKind.Group.rawValue || type == ContactKind.Room.rawValue {
                    newContact = ContactInfo(type: type, uniqueId: rtmMessage.toId, uniqueName: "", nickname: "")
                } else {
                    newContact = ContactInfo(type: type, uniqueId: rtmMessage.fromUid, uniqueName: "", nickname: "")
                }
                
                addNewSession(contact: newContact!, message: rtmMessage)
            }
        }
    }

同样为了简化起见,系统通知我们也统一处理。不过相对而言,系统通知要简单许多。

处理函数 receiveNewChatCmd() 先将收到的消息保存入数据库,然后发起一个主线程异步操作:

  1. 检查会话列表中是否已有对应会话
  2. 若当前不存在对应会话,则不做任何处理
  3. 若当前存在对应会话,且当前为对应的会话页面,则更新聊天信息列表,加入系统通知,但不显示新消息标记

编辑 IMCenter.swift,添加代码如下:

    class func receiveNewChatCmd(type:Int, rtmMessage: RTMMessage) {

        _ = db.insertChatCmd(type: type, xid: rtmMessage.toId, sender: rtmMessage.fromUid, mid: rtmMessage.messageId, message: rtmMessage.stringMessage, mtime: rtmMessage.modifiedTime)
        
        DispatchQueue.main.async {
            
            //-- 更新当前对话列表信息
            if let currContact = IMCenter.viewSharedInfo.targetContact {
                if currContact.kind == type && currContact.xid == rtmMessage.toId {
                    
                    var dialogueMesssages = IMCenter.viewSharedInfo.dialogueMesssages
                    let chatMsg = ChatMessage(sender: rtmMessage.fromUid, mid: rtmMessage.messageId, mtime: rtmMessage.modifiedTime, message: rtmMessage.stringMessage)
                    chatMsg.isChat = false
                    dialogueMesssages.append(chatMsg)
                    
                    dialogueMesssages = soreChatMessages(messages: dialogueMesssages)
                    
                    IMCenter.viewSharedInfo.dialogueMesssages = dialogueMesssages
                    return
                }
            }
        }
    }

最后,我们要和 RTMClient 的事件通知进行关联。打开 IMEventProcessor.swift,编辑代码如下:

@objcMembers public class IMEventProcessor: NSObject, RTMProtocol {
    
    ... ...

    public func rtmPushP2PChatMessage(_ client: RTMClient, message: RTMMessage?) {
        IMCenter.receiveNewNessage(type: ContactKind.Friend.rawValue, rtmMessage: message!)
    }
    
    public func rtmPushGroupChatMessage(_ client: RTMClient, message: RTMMessage?) {
        IMCenter.receiveNewNessage(type: ContactKind.Group.rawValue, rtmMessage: message!)
    }
    
    public func rtmPushRoomChatMessage(_ client: RTMClient, message: RTMMessage?) {
        IMCenter.receiveNewNessage(type: ContactKind.Room.rawValue, rtmMessage: message!)
    }
    
    public func rtmPushGroupChatCmd(_ client: RTMClient, message: RTMMessage?) {
        if let msg = message {
            IMCenter.receiveNewChatCmd(type: ContactKind.Group.rawValue, rtmMessage: msg)
        }
    }
    
    public func rtmPushRoomChatCmd(_ client: RTMClient, message: RTMMessage?) {
        if let msg = message {
            IMCenter.receiveNewChatCmd(type: ContactKind.Room.rawValue, rtmMessage: msg)
        }
    }
}

至此,RTMClient 相关事件关联完成,接收消息也同时处理完成。

10. 用户和群组、房间信息的查询和修改

用户信息页面在表现和功能上与联系人信息页面高度相同:不仅展现内容几乎相同,而且操作方式完全相同。但苦于基础数据来源形式不同,以及相似的数据性质不同,所以简单起见,我们分成两个独立的视图进行制作。

10.1 用户信息页面

用户信息页需要显示用户的头像、唯一数字ID、注册名称、昵称、签名,以及“退出登录”的按钮,而在编辑模式下,需要显示用户的头像、唯一数字ID、注册名称、昵称、头像的网络路径、签名。其中,在编辑模式时,“退出登录”的按钮将被隐藏,增加头像网络路径的显示,且仅有昵称、头像的网络路径、签名可以被编辑修改。
当用户在编辑模式下提交修改时,我们需要核对修改是否有效(不为空),然后向服务器提交。如果头像网络路径被修改,则还需要下载新的头像,并存储到本地,然后更新用户信息页面上的头像。
于是编辑 IMCenter.swift,增加处理函数 updateUserProfile():

    class func updateUserProfile(nickname: String, imgUrl: String, showInfo: String, completedAction: @escaping (_ path:String)->Void) {
        
        let info = OpenInfoData(nickname: nickname, imageUrl: imgUrl, showInfo: showInfo)
        let jsonEncoder = JSONEncoder()
        let jsonData = try? jsonEncoder.encode(info)
        let jsonStr = String(data: jsonData!, encoding: .utf8)
        
        DispatchQueue.global(qos: .default).async {
            
            //-- 暂时忽略错误处理
            IMCenter.client!.setUserInfoWithOpenInfo(jsonStr, privteinfo: nil, timeout: 0, success:{}, fail: { _ in })
            
            let contact = ContactInfo()
            contact.kind = ContactKind.Friend.rawValue
            contact.xid = IMCenter.client!.userId
            contact.xname = IMCenter.fetchUserProfile(key: "username")
            contact.imageUrl = imgUrl
            
            downloadImage(contactInfo: contact, completedAction: {
                (path, contactInfo) in
                
                let username = IMCenter.fetchUserProfile(key: "username")
                
                IMCenter.storeUserProfile(key: "\(username)-image-url", value: imgUrl)
                IMCenter.storeUserProfile(key: "\(username)-image", value: path)
                IMCenter.storeUserProfile(key: "nickname", value: nickname)
                IMCenter.storeUserProfile(key: "showInfo", value: showInfo)
                
                DispatchQueue.main.async {
                    completedAction(path)
                    IMCenter.viewSharedInfo.inProcessing = false
                }
            }, failedAction:{
                
                IMCenter.storeUserProfile(key: "nickname", value: nickname)
                IMCenter.storeUserProfile(key: "showInfo", value: showInfo)

                DispatchQueue.main.async {
                    completedAction("")
                    IMCenter.viewSharedInfo.inProcessing = false
                }
            })
        }
    }

然后编辑修改 ProfileView 的 body 部分为:

    var body: some View {
        ZStack {
        VStack {
            if self.editMode == false {
                TopNavigationView(title: "我的信息", icon: "button_edit", buttonAction: {
                    
                    self.editMode = true
                    
                }).frame(width: UIScreen.main.bounds.width, height: CGFloat(IMDemoUIConfig.topNavigationHight), alignment: .center)
            } else {
                TopNavigationView(title: "修改我的信息", icon: "button_ok", buttonAction: {
                    
                    self.editMode = false
                    self.viewInfo.inProcessing = true
                    
                    if self.newNickname.isEmpty {
                        self.newNickname = self.nickname
                    }
                    
                    if self.newImageUrl.isEmpty {
                        self.newImageUrl = self.userImageUrl
                    }
                    
                    if self.newShowInfo.isEmpty {
                        self.newShowInfo = self.showInfo
                    }
                    
                    IMCenter.updateUserProfile(nickname: self.newNickname, imgUrl: self.newImageUrl, showInfo: self.newShowInfo, completedAction: updateCallback)
                    
                }).frame(width: UIScreen.main.bounds.width, height: CGFloat(IMDemoUIConfig.topNavigationHight), alignment: .center)
            }
            
            
            Divider()
            
            Spacer()
            
            VStack {
                
                Spacer()
                
                if self.userImagePath.isEmpty {
                    Image(IMDemoUIConfig.defaultIcon)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                        .padding()
                } else {
                    Image(uiImage: IMCenter.loadUIIMage(path: self.userImagePath))
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                        .padding()
                }
                
                LazyVGrid(columns:[GridItem(.fixed(UIScreen.main.bounds.width * 0.4)), GridItem()]) {
                    HStack {
                        Spacer()
                        Text("用户ID:")
                            .padding()
                    }
                    HStack {
                        Text(String(IMCenter.client!.userId))
                            .padding()
                        Spacer()
                    }
                    
                    
                    HStack {
                        Spacer()
                        Text("用户名:")
                            .padding()
                    }
                    HStack {
                        Text(self.username)
                            .padding()
                        Spacer()
                    }
                    
                    HStack {
                        Spacer()
                        Text("用户昵称:")
                            .padding()
                    }
                    HStack {
                        if self.editMode == false {
                            if self.nickname.isEmpty {
                                Text(self.username)
                                    .padding()
                            } else {
                                Text(self.nickname)
                                    .padding()
                            }
                            
                        } else {
                            TextField(self.nickname.isEmpty ? "给自己取个昵称" : self.nickname, text: $newNickname)
                                .autocapitalization(.none)
                                .frame(width: UIScreen.main.bounds.width/3,
                                height: nil)
                                .padding()
                        }
                        
                        Spacer()
                    }
                    
                    if self.editMode {
                        HStack {
                            
                            Spacer()
                            Text("头像地址:")
                                .padding()
                        }
                        HStack {
                            TextField(self.userImageUrl.isEmpty ? "更改头像地址" : self.userImageUrl, text: $newImageUrl)
                                .autocapitalization(.none)
                                .frame(width: UIScreen.main.bounds.width/3,
                                height: nil)
                                .padding()
                            
                            Spacer()
                        }
                    }
                    
                    HStack {
                        Spacer()
                        Text("用户签名:")
                            .padding()
                    }
                    HStack {
                        if self.editMode == false {
                            TextEditor(text: $showInfo).disabled(true)
                                    .padding()
                        } else {
                            TextEditor(text: $showInfo)
                                .frame(width: UIScreen.main.bounds.width/3,
                                height: 80)
                                .ignoresSafeArea(.keyboard)
                                .padding()
                                .overlay(RoundedRectangle(cornerRadius: 8)
                                        .stroke(Color.secondary).opacity(0.5))
                        }
                        
                        Spacer()
                    }
                }
                
                if self.editMode == false {
                    Button("退出登录") {
                        
                        DispatchQueue.global(qos: .default).async {
                            IMCenter.client!.closeConnect()
                        }
                    }
                    .frame(width: UIScreen.main.bounds.width/4,
                    height: nil)
                    .padding(10)
                    .foregroundColor(.white)
                    .background(.blue)
                    .cornerRadius(10)
                }
                
                Spacer()
            }
            
            Spacer()
            
            Divider()
            
            BottomNavigationView().frame(width: UIScreen.main.bounds.width, height: CGFloat(IMDemoUIConfig.bottomNavigationHight), alignment: .center)
        }
        .onTapGesture {
            hideKeyboard()
        }
         
            if self.viewInfo.inProcessing {
                ProcessingView(info: "更新中,请等待……")
            }
        }
    }

到此,用户信息页面修改完成。

10.2. 联系人信息页面
最后,是联系人信息页面。
本页面和用户信息页面比较大的区别有两点:

  1. 用户信息页默认就能编辑,而联系人信息页,只有群组和房间的信息才能编辑,其他用户的信息不可被编辑
  2. 联系人信息页不含“退出登录”按钮

与用户信息页面类似,第二篇其实已经将UI相关的功能准备的差不多了,我们在此只需补上UI之外的相关处理。X相关处理也与 updateUserProfile() 类似。编辑IMCenter.swift,增加功能函数 updateGroupOrRoomProfile() 及相关辅助函数:

    class func genGroupOrRoomProfileChangedNotifyMessage(type: Int) -> String {
        
        if type == ContactKind.Group.rawValue {
            return "\(getSelfDispalyName()) 修改了本群信息"
        } else if type == ContactKind.Room.rawValue {
            return "\(getSelfDispalyName()) 修改了本房间信息"
        } else {
            return "\(getSelfDispalyName()) 修改了信息"
        }
    }
    
    class func updateGroupOrRoomProfile(contact: ContactInfo, orgImageUrl: String, completedAction: @escaping (_ path:String)->Void) {
        
        let info = OpenInfoData(nickname: contact.nickname, imageUrl: contact.imageUrl, showInfo: contact.showInfo)
        let jsonEncoder = JSONEncoder()
        let jsonData = try? jsonEncoder.encode(info)
        let jsonStr = String(data: jsonData!, encoding: .utf8)
        
        DispatchQueue.global(qos: .default).async {
            
            //-- 暂时忽略错误处理
            if contact.kind == ContactKind.Group.rawValue {
                IMCenter.client!.setGroupInfoWithId(NSNumber(value: contact.xid), openInfo: jsonStr, privateInfo: nil, timeout: 0, success: {}, fail: { _ in })
            } else if contact.kind == ContactKind.Room.rawValue {
                IMCenter.client!.setRoomInfoWithId(NSNumber(value: contact.xid), openInfo: jsonStr, privateInfo: nil, timeout: 0, success: {}, fail: { _ in })
            } else { return }
        
            if contact.imageUrl == orgImageUrl {
                
                DispatchQueue.main.async {
                    completedAction("")
                    IMCenter.viewSharedInfo.inProcessing = false
                }
                return
            }
            
            downloadImage(contactInfo: contact, completedAction: {
                (path, contactInfo) in
                
                contact.imagePath = path
                IMCenter.db.updatePublicInfo(contact: contact)
                sendCmd(contact: contact, message: genGroupOrRoomProfileChangedNotifyMessage(type: contact.kind))
                
                DispatchQueue.main.async {
                    completedAction(path)
                    IMCenter.viewSharedInfo.inProcessing = false
                }
            }, failedAction:{
                IMCenter.db.updatePublicInfo(contact: contact)
                sendCmd(contact: contact, message: genGroupOrRoomProfileChangedNotifyMessage(type: contact.kind))
                
                DispatchQueue.main.async {
                    completedAction("")
                    IMCenter.viewSharedInfo.inProcessing = false
                }
            })
        }
    }

然后编辑修改 ContactInfoView.swift,修改 ContactInfoView 视图 ContactInfoHeaderView 组件的 editAction 为:

                editAction: {
                    inEditing in
                    
                    self.editMode = inEditing
                    
                    
                    if inEditing == false {
                        self.viewInfo.inProcessing = true
                        
                        let newContact = ContactInfo()
                        newContact.kind = contact.kind
                        newContact.xid = contact.xid
                        newContact.xname = contact.xname
                        newContact.nickname = self.newNickname.isEmpty ? contact.nickname : self.newNickname
                        newContact.showInfo = self.newShowInfo.isEmpty ? contact.showInfo : self.newShowInfo
                        
                        if self.newImageUrl.isEmpty {
                            newContact.imageUrl = self.contact.imageUrl
                        } else {
                            newContact.imageUrl = self.newImageUrl
                        }
    
                        IMCenter.updateGroupOrRoomProfile(contact: newContact, orgImageUrl:self.contact.imageUrl, completedAction: updateCallback)
                    }
                }

至此,整个Demo的iOS 端部分,便全部开发完成。完整代码可参见:https://github.com/highras/rt...

下篇,我们将进入服务端部分的开发。

若有收获,可以留下你的赞和收藏。

你可能感兴趣的:(im聊天系统即时通信ios)