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

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

这是《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成》的第三篇-上篇:《APP 内部流程与逻辑》

系列文章可参考:

《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(一)》:Demo演示与IM设计

《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(二)》:UI设计与搭建

《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(三)》:App内部流程与逻辑(下)

《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成(四)》:服务端搭建与总结

三、APP 内部流程与逻辑

1. 数据库操作

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

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

1.1 添加SQLite库

打开项目属性页面,选择“TARGETS” -> “IMDemo” -> “General”:

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

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

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<CUnsignedChar>(sqlite3_column_text(statement, 2))
                info.xname = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 3))
                info.nickname = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 4))
                info.imageUrl = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 5))
                info.imagePath = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(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<CUnsignedChar>(sqlite3_column_text(statement, 2))
                info.xname = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 3))
                info.nickname = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 4))
                info.imageUrl = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 5))
                info.imagePath = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(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<CUnsignedChar>(sqlite3_column_text(statement, 0))
                info.xname = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 1))
                info.nickname = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 2))
                info.imageUrl = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 3))
                info.imagePath = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(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<CUnsignedChar>(sqlite3_column_text(statement, 1))
                info.nickname = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 2))
                info.imageUrl = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(sqlite3_column_text(statement, 3))
                info.imagePath = String.init(cString: chars!)
                
                chars = UnsafePointer<CUnsignedChar>(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<CUnsignedChar>(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<CUnsignedChar>(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

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

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

进入发布页面

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

3.2 RTM SDK 的编译

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

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

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

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

3.3. RTM SDK 的集成

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

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

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

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

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

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

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

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

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

#import <Foundation/Foundation.h>
#import <Rtm/Rtm.h>

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

#import <Foundation/Foundation.h>
#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 注册云上曲率即时通信服务账号

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

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

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

其中“项目编号”、“服务端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)://<domain(ip)>[:port]/service/<interface>[?...]

我们本次要访问的接口为userLogin,所以我们需要向 http://43.138.12.11:13601/service/userLogin 的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..<contacts.count {
                if contacts[idx].kind == contactInfo.kind && contacts[idx].xid == contactInfo.xid {
                    contacts[idx].imagePath = newPath
                }
            }
        }
    }
    
    private class func downloadImage(contactInfo: ContactInfo, completedAction: @escaping (_ path:String, _ contactInfo: ContactInfo)->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<Int64>()
            var localGids = Set<Int64>()
            
            for index in 0..<contacts.count {
                let info = contacts[index]
                if info.kind == ContactKind.Friend.rawValue {
                    localUids.insert(info.xid)
                } else if info.kind == ContactKind.Group.rawValue {
                    localGids.insert(info.xid)
                }
            }
            
            var newUids = [NSNumber]()
            var newGids = [NSNumber]()
            
            for v in sessionInfos!.p2pArray {
                if let uid = v as? NSNumber {
                    if localUids.contains(uid.int64Value) == false {
                        newUids.append(uid)
                    }
                }
            }
            
            for v in sessionInfos!.groupArray {
                if let gid = v as? NSNumber {
                    if localGids.contains(gid.int64Value) == false {
                        newGids.append(gid)
                    }
                }
            }
            
            if newUids.isEmpty == false {
                fetchNewP2PSessions(uids: newUids, asyncTask: asyncTask)
            }
            
            if newGids.isEmpty == false {
                fetchNewGroupSessions(gids: newGids, asyncTask: asyncTask)
            }
            
        })
    }

    private class func addNewSessions(contacts: [ContactInfo], asyncTask: AsyncTask) {
        if contacts.isEmpty { return }
        
        for contact in contacts {
            db.storeNewContact(contact: contact)
        }
        
        DispatchQueue.main.async {
            IMCenter.appendContactsForSessionView(contacts: contacts)
            IMCenter.appendContactsForContactView(contacts: contacts)
            
            DispatchQueue.global(qos: .default).async {
                
                asyncTask.npAction()
                
                for contact in contacts {
                    downloadImage(contactInfo: contact)
                }
                checkContactsUpdate(type: contacts.first!.kind, contacts: contacts)
            }
        }
    }

    private class func decodeNewSession(type: Int, xids:[NSNumber], attriAnswer: RTMAttriAnswer, asyncTask: AsyncTask) {
        
        let dic = attriAnswer.atttriDictionary
        var contacts = [ContactInfo]()
            
        for number in xids {
            let xid = number.int64Value
            if let info = dic[String(xid)] {
                if let openInfo = info as? String {
                    if openInfo.isEmpty {
                        contacts.append(ContactInfo(type: type, xid: xid))
                    } else {
                        var contact = ContactInfo(type:type, xid: xid)
                        decodeOpenInfo(contact: &contact, json: openInfo)
                        contacts.append(contact)
                    }
                } else {
                    contacts.append(ContactInfo(type: type, xid: xid))
                }
                
            } else {
                contacts.append(ContactInfo(type: type, xid: xid))
            }
        }
            
        addNewSessions(contacts: contacts, asyncTask: asyncTask)
    }

    private class func fetchNewP2PSessions(uids: [NSNumber], asyncTask: AsyncTask) -> 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
                })
    }

注册 + 登陆流程,完成!

posted @   云上曲率  阅读(357)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
点击右上角即可分享
微信分享提示