实现 1 对 1 消息
更新时间: 2023/08/24 02:15:59
本文介绍通过消息聊天组件 chatKit 实现 1 对 1 消息。消息聊天组件 chatKit 底层基于IM UIKit。
功能介绍
1 对 1 消息的主要功能包括会话列表、聊天消息、通知消息和语音输入。
前提条件
请确保您已完成以下操作:
准备工作
注册云信 IM 测试账号,获取 accid 和 token
为了方便您调试,您可以在云信控制台注册云信 IM 测试账号,获取 accid 和 token。accid 和 token 将用于登录云信服务端。
-
在云信控制台的首页单击指定应用名称,进入该应用的详情页面。
-
在左侧导航栏选择产品功能 > IM即时通讯,单击基础功能页签。
-
在测试账号管理区域单击子功能配置。
-
在测试账号管理页面,单击新建账号,并填写账号(即accid)、昵称(即 name)、密码(即 Token)后,单击确定。
配置高德地图参数(地理位置消息功能需要)
地理位置消息功能基于高德地图,因此需要您配置高德地图相关信息。在初始化路由的同时,调用 setupMapClient
方法设置高德地图的 API Key,对地图 Map 进行初始化。
- Swift
func loadService() {
//初始化路由
ContactRouter.register()
ChatRouter.register()
TeamRouter.register()
ConversationRouter.register()
//注册个人设置页面,用于实现单击头像后跳转至个人设置页面功能
Router.shared.register(MeSettingRouter) { param in
if let nav = param["nav"] as? UINavigationController {
let me = PersonInfoViewController()
nav.pushViewController(me, animated: true)
}
}
//地图map初始化
NEMapClient.shared().setupMapClient(withAppkey: AppKey.gaodeMapAppkey)
}
- Objective-C
- (void)registerRouter { [ContactRouter register]; [ChatRouter register]; [TeamRouter register]; [ConversationRouter register]; //注册个人设置页面,用于实现单击头像后跳转至个人设置页面功能 [[Router shared] register:MeSettingRouter closure:^(NSDictionary<NSString *, id> *_Nonnull param) { NSObject *param1 = [param objectForKey:@"nav"]; if ([param1 isKindOfClass:[UINavigationController class]]) { UINavigationController *nav = (UINavigationController *)param1; PersonInfoViewController *me = [[PersonInfoViewController alloc] init]; [nav pushViewController:me animated:YES]; } }]; //地图map初始化 [[NEMapClient shared] setupMapClientWithAppkey:@"gaodeMap Appkey"]; }
开发环境
开发环境要求如下:
环境要求 | 说明 |
---|---|
iOS 版本 | 11.0 及以上的 iPhone 或者 iPad 真机 |
CPU 架构 | ARM64、ARMV7 |
IDE | XCode 10 及以上版本 |
其他 | 安装 CocoaPods。 |
示例项目源码
1 对 1 娱乐社交示例项目源码,跑通示例项目的方法请参见跑通示例项目。
步骤1:集成 chatKit 组件
您可通过添加远端仓库依赖或者添加本地代码依赖,导入组件。
添加远端仓库依赖
本节介绍如何通过 Cocoapods 添加远程依赖,将您业务所需的的 UI 组件导入到您的项目,进行项目构建。
-
创建 Podfile 文件,并在 Podfile 文件中引入 Ui 组件。
引入 UI 组件时,需要指定相同版本的基础 Kit 库引入,否则可能导致后续出现报错。具体可参见下文的常见问题排查。
swift
# Uncomment the next line to define a global platform for your project platform :ios, '9.0' target 'your project name' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! # UI组件 pod 'NEConversationUIKit', '9.4.0' //会话列表组件 pod 'NEChatUIKit', '9.4.0' //会话(聊天)组件 pod 'NERtcCallUIKit', '1.8.2'//呼叫组件 # Kit组件(和UI组件对应) pod 'NEConversationKit', '9.4.0' pod 'NEChatKit', '9.4.0' pod 'NERtcCallKit', '1.8.2' # 基础Kit库 pod 'NECommonUIKit', '9.4.0' pod 'NECommonKit', '9.4.0' pod 'NECoreIMKit', '9.4.0' pod 'NECoreKit', '9.4.0' # 扩展库 pod 'NEMapKit', '9.4.0' //地理位置组件 pod 'NERtcSDK', '4.6.29'//RTC音视频组件 end
-
执行以下命令导入组件。
pod install
- 上述示例代码中的 9.4.0 为版本号,仅用于示例。建议使用最新版本。
如果出现类似“版本不存在”的报错,可执行
pod update
命令,然后双击.xcworkspace
文件,启动项目即可。 - 如果需要在 Objective-C 项目中导入组件,头文件引用请参考以下引用方式。
#import <NEConversationUIKit/NEConversationUIKit-Swift.h> #import <NEContactUIKit/NEContactUIKit-Swift.h> #import <NEChatUIKit/NEChatUIKit-Swift.h> #import <NETeamUIKit/NETeamUIKit-Swift.h> #import <NERtcCallUIKit/NERtcCallUIKit.h> ...
添加本地代码依赖
本节介绍如何通过 Cocoapods 添加本地依赖,将所需的 IM UIKit 源码导入到您的项目。
- 前往云信开源代码仓库,下载开源的 IM UIKit 到本地,然后将源码文件夹拷贝到项目目录。
- 在 Podfile 文件中写入以下内容。
pod 'NEQChatUIKit', :path => 'NEQChatUIKit/NEQChatUIKit.podspec'
pod 'NEContactUIKit', :path => 'NEContactUIKit/NEContactUIKit.podspec'
pod 'NEConversationUIKit', :path => 'NEConversationUIKit/NEConversationUIKit.podspec'
pod 'NETeamUIKit', :path => 'NETeamUIKit/NETeamUIKit.podspec'
pod 'NEChatUIKit', :path => 'NEChatUIKit/NEChatUIKit.podspec'
pod 'NEMapKit', :path => 'NEMapKit/NEMapKit.podspec'
pod 'NERtcCallUIKit', :path => 'NERtcCallUIKit/NERtcCallUIKit.podspec'
上述内容中的 path
为相对路径,即当对应组件源码文件与 Podfile 文件处于同级目录时,才能通过上述相对路径正确引入组件。
- 执行以下命令导入 IM UIKit 源码。
pod install
步骤2:初始化
在应用启动后,调用 setupCoreKitIM
方法进行初始化。
option 参数 |
是否必传 | 说明 |
---|---|---|
appKey |
是 | 云信控制台获取到的 App Key |
apnsCername |
否 | APNs 推送证书名,如不需要实现离线推送可不配置 |
pkCername |
否 | PushKit 推送证书名,如不需要实现离线推送可不配置 |
示例代码:
let option = NIMSDKOption()
option.appKey = "your app key"
option.apnsCername = "云信控制台配置的 APNS 推送证书名称"
option.pkCername = "云信控制台配置的 PushKit 推送证书名称"
IMKitClient.instance.setupCoreKitIM(option)
let _ = NEAtMessageManager.instance
NIMSDKOption *option = [NIMSDKOption optionWithAppKey:AppKey];
option.apnsCername = @"";
option.pkCername = @"";
[[IMKitClient instance] setupCoreKitIM:option];
NEAtMessageManager * _ = [NEAtMessageManager instance];
更多初始化说明,请参见初始化。
步骤3:登录
在完成初始化后,调用 loginIM
方法登录 IM。
示例代码:
IMKitClient.instance.loginIM(accid, token) { error in
if let err = error {
print("NEKitCore login error : ", err)
}else {
//在登录成功回调中初始化路由以及配置各个模块首页
/*
weakSelf?.setupTabbar()
*/
}
}
[[IMKitClient instance] loginIM:@"accid" :@"token" :^(NSError * _Nullable error) {
if (error != nil) {
NSLog(@"NEKitCore login error : %@", [error description]);
} else {
//在登录成功回调中初始化路由以及配置各个模块首页
/*
[weakSelf setupTabbar];
*/
}
}];
调用登录的方法时,将示例代码中的 accid
和 token
分别替换为您的云信账号 ID (即 accid)和 Token。
步骤4:初始化路由
如果未在登录成功回调中初始化路由,需要单独初始化路由,才能进行后续的界面搭建。在初始化路由时可同时初始化地图 Map,初始化后,您的应用即可实现地理位置消息功能。具体请参见实现地理位置消息功能。
示例代码:
func loadService() {
//初始化路由
ContactRouter.register()
ChatRouter.register()
TeamRouter.register()
ConversationRouter.register()
//注册个人设置页面,用于实现单击头像后跳转至个人设置页面功能
Router.shared.register(MeSettingRouter) { param in
if let nav = param["nav"] as? UINavigationController {
let me = PersonInfoViewController()
nav.pushViewController(me, animated: true)
}
}
//地图map初始化
NEMapClient.shared().setupMapClient(withAppkey: AppKey.gaodeMapAppkey)
}
- (void)registerRouter {
[ContactRouter register];
[ChatRouter register];
[TeamRouter register];
[ConversationRouter register];
//注册个人设置页面,用于实现单击头像后跳转至个人设置页面功能
[[Router shared] register:MeSettingRouter
closure:^(NSDictionary<NSString *, id> *_Nonnull param) {
NSObject *param1 = [param objectForKey:@"nav"];
if ([param1 isKindOfClass:[UINavigationController class]]) {
UINavigationController *nav = (UINavigationController *)param1;
PersonInfoViewController *me =
[[PersonInfoViewController alloc] init];
[nav pushViewController:me animated:YES];
}
}];
//地图map初始化
[[NEMapClient shared] setupMapClientWithAppkey:@"gaodeMap Appkey"];
}
若需要注册个人设置页面,实现单击头像后跳转至个人设置页面的功能,首先需要在 XCode 中拖入相关的源码文件至您的工程。相关的源码文件包括:
步骤5:界面搭建(Swift)
1 对 1 娱乐社交消息系统常用的功能包括会话列表和聊天界面,本文介绍 chatKit 如何基于 IM UIKit 搭建相应界面。
搭建会话列表
基于 Fragment 方式集成 IM UIKit 的会话列表,具体步骤请参见IM UIKit 的集成会话列表。
搭建聊天界面
通过会话消息模块(chatkit-ui)搭建聊天界面,实现接收和发送基本的消息类型,包括文本消息、图片消息、语音消息、视频消息、表情和地理位置消息。
IM UIKit 提供基于 UITableview 实现的会话消息界面,其类名为 ChatViewController
下的 P2PChatViewController
子类。
方法原型
let p2pChatVC = P2PChatViewController(session: session)
参数说明
参数 | 类型 | 说明 |
---|---|---|
session | NIMSession | 会话对象 |
anchor | NIMMessage | 锚点(用于历史消息搜索) |
代码示例
if conversationModel?.recentSession?.session?.sessionType == .P2P {
let session = NIMSession(commonId, type: .P2P)
Router.shared.use(PushP2pChatVCRouter, parameters: ["nav": self.navigationController as Any, "session" : session as Any], closure: nil)
}else if conversationModel?.recentSession?.session?.sessionType == .team {
let session = NIMSession(commonId, type: .team)
Router.shared.use(PushTeamChatVCRouter, parameters: ["nav": self.navigationController as Any, "session" : session as Any], closure: nil)
}
使用时,根据不同的会话类型,跳转到对应的界面,开源代码中内部跳转通过 Router 路由实现。路由器的具体使用说明,请参见界面跳转。
实现通知消息
调用以下方法实现发送通知消息,通知消息只有本端可见,对方不可见。
示例代码:
sendSalutionMsg(type: 1, antiSpamMessage: nil)
// 发送打招呼消息:本地消息
func sendSalutionMsg(type: Int, antiSpamMessage: NIMMessage?) {
let message = NIMMessage()
if let antiSpamMessage = antiSpamMessage {
message.timestamp = antiSpamMessage.timestamp + 1
}
let object = NIMCustomObject()
let attachment = CustomAttachment()
// 本地消息不回走到 CustomAttachment的解析,所以需要手动 cellHeight 以及 customType ,否则不会刷新。发送消息不需要
attachment.cellHeight = 50 + 20
switch type {
case 1:
// audio
attachment.type = OneOnOneChatCustomMessageType.TRY_AUDIO_CALL_MESSAGE_TYPE
attachment.customType = OneOnOneChatCustomMessageType.TRY_AUDIO_CALL_MESSAGE_TYPE
case 2:
// video
attachment.type = OneOnOneChatCustomMessageType.TRY_VIDEO_CALL_MESSAGE_TYPE
attachment.customType = OneOnOneChatCustomMessageType.TRY_VIDEO_CALL_MESSAGE_TYPE
case 3:
// 三方消息违规
attachment.type = OneOnOneChatCustomMessageType.PRIVACY_RISK_MESSAGE_TYPE
attachment.customType = OneOnOneChatCustomMessageType.PRIVACY_RISK_MESSAGE_TYPE
case 4:
// 通用违规消息
attachment.type = OneOnOneChatCustomMessageType.COMMON_RISK_MESSAGE_TYPE
attachment.customType = OneOnOneChatCustomMessageType.COMMON_RISK_MESSAGE_TYPE
default:
attachment.type = OneOnOneChatCustomMessageType.ACCOST_MESSAGE_TIPS_TYPE
attachment.customType = OneOnOneChatCustomMessageType.ACCOST_MESSAGE_TIPS_TYPE
attachment.cellHeight = 30 + 20
}
object.attachment = attachment
message.messageObject = object
let setting = NIMMessageSetting()
setting.shouldBeCounted = false
message.setting = setting
viewmodel.repo.saveMessageToDB(message, viewmodel.session) { error in
print("send custom message error : ", error?.localizedDescription as Any)
}
}
//注:customType 需要有对应的注册:如下所示
registerCellDic[String(OneOnOneChatCustomMessageType.ACCOST_MESSAGE_TIPS_TYPE)] = NEOneOnOneTextSalutuionCell.self
registerCellDic[String(OneOnOneChatCustomMessageType.PRIVACY_RISK_MESSAGE_TYPE)] = NEOneOnOneTextThirdPrivacyCell.self
registerCellDic[String(OneOnOneChatCustomMessageType.TRY_AUDIO_CALL_MESSAGE_TYPE)] = NEOneOnOneAudioSalutuionCell.self
registerCellDic[String(OneOnOneChatCustomMessageType.TRY_VIDEO_CALL_MESSAGE_TYPE)] = NEOneOnOneVideoSalutuionCell.self
registerCellDic[String(OneOnOneChatCustomMessageType.COMMON_RISK_MESSAGE_TYPE)] = NEOneOnOneTextNonComplianceCell.self
registerCellDic[String(SEND_GIFT_TYPE_SEND)] = NEOneOnOneRewardRightCell.self
registerCellDic[String(SEND_GIFT_TYPE_RECV)] = NEOneOnOneRewardLeftCell.self
registerCellDic[String(OneOnOneChatCustomMessageType.OFFICIAL_GIFT_TYPE)] = NEOneOnOneOfficialCell.self
注:上述的cell 都是基于NEChatBaseCell 的自定义UI
搭建语音输入页面
示例代码:
// 语音输入条
lazy var audioInputButton: NEOneOnOneSpeakButton = {
let audioInputButton = NEOneOnOneSpeakButton()
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressGesture(_:)))
audioInputButton.addGestureRecognizer(longPressGesture)
return audioInputButton
}()
//将语音输入条添加到页面
menuView.addSubview(audioInputButton)
audioInputButton.snp.makeConstraints { make in
make.left.right.top.bottom.equalTo(menuView.textView)
}
view.bringSubviewToFront(audioInputButton)
// 长按手势处理函数
@objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
let semaphore = DispatchSemaphore(value: 0)
var hasPermissions = false
var firstRequest = false
let microphoneStatus = AVCaptureDevice.authorizationStatus(for: .audio)
switch microphoneStatus {
case .authorized:
hasPermissions = true
print("Microphone access granted")
case .denied:
print("Microphone access denied")
case .notDetermined:
firstRequest = true
print("Microphone access not determined")
case .restricted:
print("Microphone access restricted")
@unknown default:
fatalError("Unknown microphone status")
}
if firstRequest {
AVCaptureDevice.requestAccess(for: .audio) { granted in
if granted {
hasPermissions = true
semaphore.signal()
} else {
semaphore.signal()
print("Microphone access denied")
}
}
} else {
semaphore.signal()
}
semaphore.wait()
if !hasPermissions {
NEOneOnOneToast.show(ne_localized("麦克风权限已关闭,请开启后重试"))
return
}
if hasPermissions, firstRequest {
/// 第一次申请不进行录制操作
return
}
print("权限判断通过")
// 判断是否在小窗:弹出Toast
if NEOneOnOneUIKitEngine.sharedInstance().canCall() != nil {
let message = NEOneOnOneUIKitEngine.sharedInstance().canCall()
if let message = message, message.count > 0 {
/// 不能操作
NEOneOnOneToast.show(ne_localized("您当前处于语聊房中,请结束后再试"))
return
}
}
switch gesture.state {
case .began:
NEOneOnOneLog.infoLog(
tag,
desc: "开始录音"
)
// 添加全屏视图
_audioInputingView = nil
UIApplication.shared.keyWindow?.addSubview(audioInputingView)
// TODO: 录制开始
startRecord()
case .changed:
print("changed")
// 手指不离开进行滑动
// 判断是否滑动到全屏视图的某一个区域
let inreact = CGRectContainsPoint(audioInputingView.audioInputImageView.frame, gesture.location(in: UIApplication.shared.keyWindow))
if inreact {
audioInputingView.needCancel = true
} else {
audioInputingView.needCancel = false
}
case .ended:
// 手指离开
if !audioInputingView.needCancel {
// 发送
NEOneOnOneLog.infoLog(
tag,
desc: "结束录音并发送"
)
endRecord(insideView: true)
} else {
// 不发送
// 发送
NEOneOnOneLog.infoLog(
tag,
desc: "结束录音不发送"
)
endRecord(insideView: false)
}
// audioInputingView.endAudioiInputing()
audioInputingView.removeFromSuperview()
default:
break
}
}