@消息实现方案
更新时间: 2026/05/19 16:26:28
若您需要实现简单的 @ 功能,可以参考网易云信在 IM UIKit 上使用的艾特(@)逻辑,以实现简单的类微信的 @ 功能。
效果展示
@消息是一种即时通讯功能,用户可以在群聊或频道中通过“@”符号提及特定用户或角色,被提及的用户会收到提醒通知,从而快速注意到相关消息。效果如下图所示:
方案介绍
@ 消息通过扩展参数,保留 @ 消息的相关内容。在消息体 NIMMessage 中可以通过 remoteExt 来获取和设置消息体中的远程传输的扩展参数,@ 消息的内容就以 Object 形式保存在该消息体中,数据格式如下:
JSON//@消息的 key 值
"yxAitMsg": {
//被@的账号,如果是 @All 则为 ait_all
"332917623668992": {
"text": "@昵称 01 ",//在消息中@的展示内容
"segments": [{//在消息中@的展示位置
"start": 0,//在消息中@的展示起始位置
"end": 5,//在消息中@的展示终止位置
}]
}
}
在发送一条 @ 消息时,会将上述 @ 消息的内容,设置到该消息的 remoteExt 中。
实现流程
-
创建一条文本消息。
SwiftSwift// text 代表要发送的内容 let message = MessageUtils.textMessage(text: text)Objective-CObjective-C// text 代表要发送的内容 V2NIMMessage *message = [MessageUtils textMessageWithText:text]; -
设置 @ 信息和推送配置。
SwiftSwiftopen func getAtRemoteExtension(_ attri: NSAttributedString?) -> [String: Any]? { guard let attribute = attri else { return nil } var atDic = [String: [String: Any]]() let string = attribute.string attribute.enumerateAttribute( NSAttributedString.Key.foregroundColor, in: NSMakeRange(0, attribute.length) ) { value, findRange, stop in guard let findColor = value as? UIColor else { return } if isEqualToColor(findColor, UIColor.ne_normalTheme) == false { return } if let range = Range(findRange, in: string) { let text = string[range] let model = MessageAtInfoModel() print("range text : ", String(text)) // 计算 at 前有表情导致索引新增的数量 let expandIndex = getConvertedExtraIndex(attribute.attributedSubstring(from: NSRange(location: 0, length: findRange.location))) print("expand index value ", expandIndex) model.start = findRange.location + expandIndex let nameExpandCount = getConvertedExtraIndex(attribute.attributedSubstring(from: findRange)) print("name expand index value ", nameExpandCount) model.end = model.start + findRange.length + nameExpandCount print("model start : ", model.start, " model end : ", model.end) var dic: [String: Any]? var array: [Any]? if let accid = nickAccidDic[String(text)] { if let atCacheDic = atDic[accid] { dic = atCacheDic } else { dic = [String: Any]() } if let atCacheArray = dic?[atSegmentsKey] as? [Any] { array = atCacheArray } else { array = [Any]() } if let object = model.yx_modelToJSONObject() { array?.append(object) } dic?[atSegmentsKey] = array dic?[atTextKey] = String(text) + " " dic?[#keyPath(MessageAtCacheModel.accid)] = accid atDic[accid] = dic } } } if atDic.count > 0 { return [yxAtMsg: atDic] } return nil }Objective-CObjective-C- (nullable NSDictionary *)getAtRemoteExtension:(nullable NSAttributedString *)attri { if (!attri) return nil; NSMutableDictionary *atDic = [NSMutableDictionary dictionary]; NSString *string = attri.string; [attri enumerateAttribute:NSForegroundColorAttributeName inRange:NSMakeRange(0, attri.length) options:0 usingBlock:^(id value, NSRange findRange, BOOL *stop) { if (![value isKindOfClass:[UIColor class]]) return; if (![self isEqualToColor:(UIColor *)value color2:UIColor.ne_normalTheme]) return; if (findRange.location + findRange.length > string.length) return; NSString *text = [string substringWithRange:findRange]; NSString *accid = self.nickAccidDic[text]; if (accid) { id oldDic = atDic[accid]; NSMutableDictionary *dic = [oldDic isKindOfClass:[NSDictionary class]] ? [oldDic mutableCopy] : [NSMutableDictionary dictionary]; id oldArray = dic[atSegmentsKey]; NSMutableArray *array = [oldArray isKindOfClass:[NSArray class]] ? [oldArray mutableCopy] : [NSMutableArray array]; MessageAtInfoModel *model = [[MessageAtInfoModel alloc] init]; // 计算at前有表情导致索引新增的数量 NSRange frontRange = NSMakeRange(0, MIN(findRange.location, attri.length)); NSInteger expandIndex = [self getConvertedExtraIndex:[attri attributedSubstringFromRange:frontRange]]; model.start = findRange.location + expandIndex; NSInteger nameExpandCount = [self getConvertedExtraIndex:[attri attributedSubstringFromRange:findRange]]; model.end = model.start + findRange.length + nameExpandCount; id object = [model yx_modelToJSONObject]; if (object) [array addObject:object]; dic[atSegmentsKey] = array; dic[atTextKey] = [text stringByAppendingString:@" "]; dic[@"accid"] = accid; atDic[accid] = dic; } }]; return atDic.count > 0 ? @{yxAtMsg: atDic} : nil; }@ 信息需要按照上述的 JSON 格式配置,如果使用 UIKit,则可以通过 NEBaseChatInputView 提供的方法。示例代码可参考 ChatViewController 中的 sendContentText 方法。
-
发送消息。
SwiftSwiftlet params = ChatRepo.shared.getSendMessageParams() ChatRepo.shared.sendMessage(message: message, conversationId: conversationId, params: params, completion)Objective-CObjective-CV2NIMSendMessageParams *params = [[ChatRepo shared] getSendMessageParams]; [[ChatRepo shared] sendMessage:message conversationId:conversationId params:params completion:completion]; -
接受方对接收到的消息进行解析。
SwiftSwift/// 解析消息中的 @ /// - Parameters: /// - message: 消息 /// - attributeStr: 消息富文本 /// - Returns: 高亮 @ 后的消息富文本 public static func loadAtInMessage(_ message: V2NIMMessage?, _ attributeStr: NSMutableAttributedString?) -> NSMutableAttributedString? { // 数字人回复的消息不展示高亮(serverExtension 会被带回) if message?.aiConfig != nil, message?.aiConfig?.aiStatus == .MESSAGE_AI_STATUS_RESPONSE { return nil } let text = message?.text ?? "" let messageTextFont = UIFont.systemFont(ofSize: ChatUIConfig.shared.messageProperties.messageTextSize) // 兼容老的表情消息,如果前面有表情而位置计算异常则回退回老的解析 var notFound = false // 计算表情(根据转码后的 index) if let remoteExt = getDictionaryFromJSONString(message?.serverExtension ?? ""), let dic = remoteExt[yxAtMsg] as? [String: AnyObject] { for (_, value) in dic { if let contentDic = value as? [String: AnyObject] { if let array = contentDic[atSegmentsKey] as? [AnyObject] { if let models = NSArray.yx_modelArray(with: MessageAtInfoModel.self, json: array) as? [MessageAtInfoModel] { for model in models { // 前面因为表情增加的索引数量 var count = 0 if text.count > model.start { let frontAttributeStr = NEEmotionTool.getAttWithStr( str: String(text.prefix(model.start)), font: messageTextFont ) count = getReduceIndexCount(frontAttributeStr) } let start = model.start - count if start < 0 { notFound = true break } var end = model.end - count if model.end + atRangeOffset > text.count { notFound = true break } // 获取起始索引 let startIndex = text.index(text.startIndex, offsetBy: model.start) // 获取结束索引 let endIndex = text.index(text.startIndex, offsetBy: model.end + atRangeOffset) let frontAttributeStr = NEEmotionTool.getAttWithStr( str: String(text[startIndex ..< endIndex]), font: messageTextFont ) let innerCount = getReduceIndexCount(frontAttributeStr) end = end - innerCount if end <= start { notFound = true break } if attributeStr?.length ?? 0 > end { attributeStr?.addAttribute(NSAttributedString.Key.foregroundColor, value: UIColor.ne_normalTheme, range: NSMakeRange(start, end - start + atRangeOffset)) } } } } } } } if notFound == true, let remoteExt = getDictionaryFromJSONString(message?.serverExtension ?? ""), let dic = remoteExt[yxAtMsg] as? [String: AnyObject] { for (_, value) in dic { if let contentDic = value as? [String: AnyObject] { if let array = contentDic[atSegmentsKey] as? [AnyObject] { if let models = NSArray.yx_modelArray(with: MessageAtInfoModel.self, json: array) as? [MessageAtInfoModel] { for model in models { if attributeStr?.length ?? 0 > model.end { attributeStr?.addAttribute(NSAttributedString.Key.foregroundColor, value: UIColor.ne_normalTheme, range: NSMakeRange(model.start, model.end - model.start + atRangeOffset)) } } } } } } } return attributeStr }Objective-CObjective-C/// 解析消息中的 @ /// - Parameters: /// - message: 消息 /// - attributeStr: 消息富文本 /// - Returns: 高亮 @ 后的消息富文本 + (nullable NSMutableAttributedString *)loadAtInMessage:(nullable V2NIMMessage *)message attributeStr:(nullable NSMutableAttributedString *)attributeStr { // 数字人回复的消息不展示高亮 if (message.aiConfig != nil && message.aiConfig.aiStatus == V2NIM_MESSAGE_AI_STATUS_RESPONSE) { return nil; } NSString *text = message.text ?: @""; BOOL isSelf = (message.isSelf == YES); CGFloat messageTextSize = isSelf ? [ChatUIConfig shared].messageProperties.selfMessageTextSize : [ChatUIConfig shared].messageProperties.receiveMessageTextSize; UIFont *messageTextFont = [UIFont systemFontOfSize:messageTextSize]; UIColor *messageTextColor = isSelf ? [ChatUIConfig shared].messageProperties.selfMessageTextColor : [ChatUIConfig shared].messageProperties.receiveMessageTextColor; // 兼容老的表情消息,如果前面有表情而位置计算异常则回退回老的解析 BOOL notFound = NO; NSDictionary *remoteExtDict = getDictionaryFromJSONString(message.serverExtension ?: @""); if (![remoteExtDict isKindOfClass:[NSDictionary class]]) { return attributeStr; } NSDictionary *atDic = remoteExtDict[kYxAtMsg]; if (![atDic isKindOfClass:[NSDictionary class]]) { return attributeStr; } // 第一轮:根据转码后的 index 处理 @ for (id key in atDic) { id value = atDic[key]; if (![value isKindOfClass:[NSDictionary class]]) continue; NSDictionary *contentDic = (NSDictionary *)value; NSArray *array = contentDic[kAtSegmentsKey]; if (![array isKindOfClass:[NSArray class]]) continue; NSArray<MessageAtInfoModel *> *models = [NSArray yx_modelArrayWithClass:[MessageAtInfoModel class] json:array]; if (![models isKindOfClass:[NSArray class]]) continue; for (MessageAtInfoModel *model in models) { if (model.start < 0 || model.end < model.start || model.start >= (NSInteger)text.length || model.end + kAtRangeOffset > (NSInteger)text.length) { notFound = YES; break; } // 前面因为表情增加的索引数量 NSInteger countOffset = 0; if (model.start > 0 && (NSInteger)text.length > model.start) { NSString *frontStr = [text substringToIndex:model.start]; NSMutableAttributedString *frontAttrStr = [NEEmotionTool getAttWithStr:frontStr font:messageTextFont color:messageTextColor offset:CGPointZero]; countOffset = [ChatMessageHelper getReduceIndexCount:frontAttrStr]; } NSInteger start = model.start - countOffset; if (start < 0) { notFound = YES; break; } NSInteger rawLength = model.end - model.start + kAtRangeOffset; if (rawLength <= 0 || model.start + rawLength > (NSInteger)text.length) { notFound = YES; break; } NSString *rangeStr = [text substringWithRange:NSMakeRange(model.start, rawLength)]; NSMutableAttributedString *rangeAttrStr = [NEEmotionTool getAttWithStr:rangeStr font:messageTextFont color:messageTextColor offset:CGPointZero]; NSInteger innerCount = [ChatMessageHelper getReduceIndexCount:rangeAttrStr]; NSInteger end = model.end - countOffset - innerCount; if (end <= start) { notFound = YES; break; } NSInteger attrLength = end - start + kAtRangeOffset; if (attrLength > 0 && start >= 0 && start + attrLength <= (NSInteger)attributeStr.length) { [attributeStr addAttribute:NSForegroundColorAttributeName value:[UIColor ne_normalTheme] range:NSMakeRange(start, attrLength)]; } } if (notFound) break; } // 第二轮回退:直接用原始 index 处理 if (notFound) { for (id key in atDic) { id value = atDic[key]; if (![value isKindOfClass:[NSDictionary class]]) continue; NSDictionary *contentDic = (NSDictionary *)value; NSArray *array = contentDic[kAtSegmentsKey]; if (![array isKindOfClass:[NSArray class]]) continue; NSArray<MessageAtInfoModel *> *models = [NSArray yx_modelArrayWithClass:[MessageAtInfoModel class] json:array]; if (![models isKindOfClass:[NSArray class]]) continue; for (MessageAtInfoModel *model in models) { NSInteger attrLength = model.end - model.start + kAtRangeOffset; if (model.start >= 0 && attrLength > 0 && model.start + attrLength <= (NSInteger)attributeStr.length) { [attributeStr addAttribute:NSForegroundColorAttributeName value:[UIColor ne_normalTheme] range:NSMakeRange(model.start, attrLength)]; } } } } return attributeStr; }
参考信息
消息构建和发送消息的示例代码可以参考:
- ChatViewController 中的 sendContentText 方法。
- @相关的数据组装和解析参考 NEBaseChatInputView。
- @消息接受和解析参考 NEBaseChatInputView 和 NEAtMessageManager。
此文档是否对你有帮助?




