iOS

@消息实现方案

更新时间: 2026/05/19 16:26:28

若您需要实现简单的 @ 功能,可以参考网易云信在 IM UIKit 上使用的艾特(@)逻辑,以实现简单的类微信的 @ 功能。

效果展示

@消息是一种即时通讯功能,用户可以在群聊或频道中通过“@”符号提及特定用户或角色,被提及的用户会收到提醒通知,从而快速注意到相关消息。效果如下图所示:

image.png

方案介绍

@ 消息通过扩展参数,保留 @ 消息的相关内容。在消息体 NIMMessage 中可以通过 remoteExt 来获取和设置消息体中的远程传输的扩展参数,@ 消息的内容就以 Object 形式保存在该消息体中,数据格式如下:

JSON//@消息的 key 值
"yxAitMsg": {
    //被@的账号,如果是 @All 则为 ait_all
    "332917623668992": {
        "text": "@昵称 01 ",//在消息中@的展示内容
        "segments": [{//在消息中@的展示位置
            "start": 0,//在消息中@的展示起始位置
            "end": 5,//在消息中@的展示终止位置
        }]
    }
}

在发送一条 @ 消息时,会将上述 @ 消息的内容,设置到该消息的 remoteExt 中。

实现流程

  1. 创建一条文本消息。

    Swift
    Swift// text 代表要发送的内容
    let message = MessageUtils.textMessage(text: text)
    
    Objective-C
    Objective-C// text 代表要发送的内容
    V2NIMMessage *message = [MessageUtils textMessageWithText:text];    
    
  2. 设置 @ 信息和推送配置。

    Swift
    Swiftopen 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-C
    Objective-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 方法。

  3. 发送消息。

    Swift
    Swiftlet params = ChatRepo.shared.getSendMessageParams()
    ChatRepo.shared.sendMessage(message: message,
                         conversationId: conversationId,
                                 params: params,
                                completion)
    
    Objective-C
    Objective-CV2NIMSendMessageParams *params = [[ChatRepo shared] getSendMessageParams];
    [[ChatRepo shared] sendMessage:message
                    conversationId:conversationId
                            params:params
                        completion:completion];
    
  4. 接受方对接收到的消息进行解析。

    Swift
    Swift/// 解析消息中的 @
    /// - 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-C
    Objective-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。
此文档是否对你有帮助?
有帮助
去反馈
  • 效果展示
  • 方案介绍
  • 实现流程
  • 参考信息