HarmonyOS开发:即时通讯聊天功能

举报
Jack20 发表于 2026/06/26 17:18:34 2026/06/26
【摘要】 HarmonyOS开发:即时通讯聊天功能核心要点:IM聊天架构的核心是WebSocket长连接,消息收发与本地存储保证可靠性,聊天记录与未读消息管理是用户体验的关键。 背景与动机你用微信聊天,发一条消息对方秒回——这背后是什么?WebSocket长连接。你的消息不是通过HTTP请求发出去的,而是通过一个持续打开的TCP连接实时推送的。为什么不用HTTP轮询?你每隔5秒问一次服务器"有新消息...

HarmonyOS开发:即时通讯聊天功能

核心要点:IM聊天架构的核心是WebSocket长连接,消息收发与本地存储保证可靠性,聊天记录与未读消息管理是用户体验的关键。

背景与动机

你用微信聊天,发一条消息对方秒回——这背后是什么?WebSocket长连接。你的消息不是通过HTTP请求发出去的,而是通过一个持续打开的TCP连接实时推送的。

为什么不用HTTP轮询?你每隔5秒问一次服务器"有新消息吗?"——100万用户同时在线,每秒20万次请求,服务器扛不住。而且5秒的延迟,聊天体验像发邮件。

WebSocket长连接也不是万能的。连接断了怎么办?消息丢了怎么办?对方离线了消息怎么存?重连后怎么同步历史消息?1000条未读消息怎么高效加载?

IM聊天是移动端最复杂的模块之一。这篇文章把聊天架构、WebSocket连接、消息收发、聊天记录、未读消息全拆开讲。

核心原理

IM聊天的核心是长连接+本地存储+增量同步。WebSocket保持实时连接,消息同时写入本地数据库,断线重连后增量同步未收到的消息。

flowchart TD
    A[用户打开聊天页] --> B[建立WebSocket连接]
    B --> C{连接成功?}
    
    C -->|| D[发送登录认证]
    C -->|| E[启动重连机制]
    
    D --> F[同步离线消息]
    F --> G[进入实时聊天模式]
    
    E --> H[指数退避重连]
    H --> C
    
    G --> I{消息事件}
    
    I -->|收到新消息| J[写入本地数据库]
    J --> K[更新UI]
    K --> L[发送已读回执]
    
    I -->|发送消息| M[写入本地数据库]
    M --> N[通过WebSocket发送]
    N --> O{服务端确认?}
    O -->|| P[更新消息状态为已发送]
    O -->|| Q[标记为发送失败]
    
    I -->|连接断开| E
    
    classDef connect fill:#1565C0,color:#fff,stroke:#0D47A1
    classDef message fill:#2E7D32,color:#fff,stroke:#1B5E20
    classDef error fill:#C62828,color:#fff,stroke:#B71C1C
    classDef sync fill:#6A1B9A,color:#fff,stroke:#4A148C
    
    class A,B,C,D,E,H,connect
    class I,J,K,L,M,N,O,P,Q,message
    class error
    class F,sync

消息可靠性保证

IM消息必须满足三个条件:

  1. 不丢:每条消息都有唯一ID,服务端确认后才标记为已发送
  2. 不重:消息ID去重,同一条消息不会显示两次
  3. 有序:消息按时间戳排序,不会乱序

消息状态流转

一条消息从"发送中"到"已读"经历四个状态:

  • 发送中:消息已写入本地,等待服务端确认
  • 已发送:服务端已确认收到
  • 已送达:对方设备已收到
  • 已读:对方已查看

代码实战

基础用法:WebSocket连接管理

先搞定WebSocket连接——建立、认证、心跳、重连。

// WebSocketManager.ets — WebSocket连接管理
import { webSocket } from '@kit.NetworkKit'

// 连接状态
enum ConnectState {
  DISCONNECTED = 'DISCONNECTED',
  CONNECTING = 'CONNECTING',
  CONNECTED = 'CONNECTED',
  AUTHENTICATING = 'AUTHENTICATING',
  AUTHENTICATED = 'AUTHENTICATED',
}

// 消息类型
enum MessageType {
  TEXT = 'TEXT',
  IMAGE = 'IMAGE',
  VOICE = 'VOICE',
  VIDEO = 'VIDEO',
  SYSTEM = 'SYSTEM',
}

// 聊天消息
interface ChatMessage {
  id: string
  conversationId: string    // 会话ID
  senderId: string
  receiverId: string
  type: MessageType
  content: string
  mediaUrl?: string
  timestamp: number
  status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
  isRead: boolean
}

class WebSocketManager {
  private static instance: WebSocketManager
  private ws: webSocket.WebSocket | null = null
  private connectState: ConnectState = ConnectState.DISCONNECTED
  private wsUrl: string = 'wss://im.example.com/ws'
  private token: string = ''
  private heartbeatInterval: number = -1
  private reconnectTimer: number = -1
  private reconnectAttempts: number = 0
  private maxReconnectAttempts: number = 10
  private messageHandlers: ((msg: ChatMessage) => void)[] = []

  private constructor() {}

  static getInstance(): WebSocketManager {
    if (!WebSocketManager.instance) {
      WebSocketManager.instance = new WebSocketManager()
    }
    return WebSocketManager.instance
  }

  // ========== 建立连接 ==========
  async connect(token: string): Promise<boolean> {
    if (this.connectState === ConnectState.CONNECTED ||
        this.connectState === ConnectState.AUTHENTICATED) {
      return true  // 已连接
    }

    this.token = token
    this.connectState = ConnectState.CONNECTING

    try {
      this.ws = webSocket.createWebSocket()

      // 监听连接打开
      this.ws.on('open', () => {
        console.info('[WS] 连接已建立')
        this.connectState = ConnectState.CONNECTED
        this.reconnectAttempts = 0
        this.authenticate()
      })

      // 监听消息
      this.ws.on('message', (err: Error, data: string | ArrayBuffer) => {
        if (err) {
          console.error(`[WS] 消息错误: ${err.message}`)
          return
        }
        this.handleMessage(typeof data === 'string' ? data : '')
      })

      // 监听关闭
      this.ws.on('close', () => {
        console.info('[WS] 连接已关闭')
        this.connectState = ConnectState.DISCONNECTED
        this.stopHeartbeat()
        this.scheduleReconnect()
      })

      // 监听错误
      this.ws.on('error', (err: Error) => {
        console.error(`[WS] 连接错误: ${err.message}`)
        this.connectState = ConnectState.DISCONNECTED
        this.scheduleReconnect()
      })

      // 发起连接
      await this.ws.connect(this.wsUrl)
      return true
    } catch (error) {
      console.error(`[WS] 连接失败: ${JSON.stringify(error)}`)
      this.connectState = ConnectState.DISCONNECTED
      this.scheduleReconnect()
      return false
    }
  }

  // ========== 认证 ==========
  private authenticate(): void {
    this.connectState = ConnectState.AUTHENTICATING
    this.sendRaw({
      type: 'auth',
      token: this.token
    })
  }

  // ========== 心跳 ==========
  private startHeartbeat(): void {
    this.stopHeartbeat()
    this.heartbeatInterval = setInterval(() => {
      this.sendRaw({ type: 'ping', timestamp: Date.now() })
    }, 30000)  // 30秒一次心跳
  }

  private stopHeartbeat(): void {
    if (this.heartbeatInterval !== -1) {
      clearInterval(this.heartbeatInterval)
      this.heartbeatInterval = -1
    }
  }

  // ========== 重连 ==========
  private scheduleReconnect(): void {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('[WS] 超过最大重连次数')
      return
    }

    // 指数退避:1s, 2s, 4s, 8s, 16s, 32s...
    const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 32000)
    this.reconnectAttempts++

    console.info(`[WS] ${delay}ms后重连(第${this.reconnectAttempts}次)`)

    this.reconnectTimer = setTimeout(() => {
      this.connect(this.token)
    }, delay)
  }

  // ========== 处理收到的消息 ==========
  private handleMessage(rawData: string): void {
    try {
      const data = JSON.parse(rawData)

      switch (data.type) {
        case 'auth_ok':
          this.connectState = ConnectState.AUTHENTICATED
          this.startHeartbeat()
          console.info('[WS] 认证成功')
          break

        case 'pong':
          // 心跳响应,忽略
          break

        case 'new_message':
          const message = data.message as ChatMessage
          // 通知所有消息处理器
          this.messageHandlers.forEach(handler => handler(message))
          break

        case 'message_ack':
          // 消息发送确认
          console.info(`[WS] 消息已确认: ${data.messageId}`)
          break

        case 'message_read':
          // 对方已读回执
          console.info(`[WS] 消息已读: ${data.messageId}`)
          break

        default:
          console.warn(`[WS] 未知消息类型: ${data.type}`)
      }
    } catch (error) {
      console.error(`[WS] 消息解析失败: ${JSON.stringify(error)}`)
    }
  }

  // ========== 发送消息 ==========
  sendMessage(message: ChatMessage): boolean {
    if (this.connectState !== ConnectState.AUTHENTICATED) {
      console.error('[WS] 未认证,无法发送消息')
      return false
    }

    return this.sendRaw({
      type: 'send_message',
      message: message
    })
  }

  // ========== 发送原始数据 ==========
  private sendRaw(data: Record<string, Object>): boolean {
    if (!this.ws || this.connectState === ConnectState.DISCONNECTED) {
      return false
    }

    try {
      this.ws.send(JSON.stringify(data))
      return true
    } catch (error) {
      console.error(`[WS] 发送失败: ${JSON.stringify(error)}`)
      return false
    }
  }

  // ========== 注册消息处理器 ==========
  onMessage(handler: (msg: ChatMessage) => void): void {
    this.messageHandlers.push(handler)
  }

  // ========== 断开连接 ==========
  disconnect(): void {
    this.stopHeartbeat()
    if (this.reconnectTimer !== -1) {
      clearTimeout(this.reconnectTimer)
    }
    if (this.ws) {
      this.ws.close()
      this.ws = null
    }
    this.connectState = ConnectState.DISCONNECTED
  }

  getState(): ConnectState {
    return this.connectState
  }
}

进阶用法:聊天页面与消息列表

WebSocket搭好了,接下来是聊天页面——消息列表、发送消息、图片消息。

// ChatPage.ets — 聊天页面
import { router } from '@kit.ArkUI'

@Entry
@Component
struct ChatPage {
  @State messages: ChatMessage[] = []
  @State inputText: string = ''
  @State contactName: string = ''
  @State contactAvatar: string = ''
  @State currentUserId: string = 'user_001'
  @State isLoadingMore: boolean = false

  private conversationId: string = ''
  private wsManager: WebSocketManager = WebSocketManager.getInstance()
  private listScroller: Scroller = new Scroller()

  aboutToAppear() {
    const params = router.getParams() as Record<string, string>
    this.conversationId = params?.conversationId || ''
    this.contactName = params?.contactName || '聊天对象'
    this.contactAvatar = params?.contactAvatar || ''

    // 加载本地聊天记录
    this.loadChatHistory()

    // 监听新消息
    this.wsManager.onMessage((msg: ChatMessage) => {
      if (msg.conversationId === this.conversationId) {
        this.messages = [...this.messages, msg]
        // 滚动到底部
        setTimeout(() => {
          this.listScroller.scrollToIndex(this.messages.length - 1)
        }, 50)
      }
    })
  }

  build() {
    Column() {
      // 顶部导航
      Row() {
        Image($r('app.media.ic_back'))
          .width(24).height(24).fillColor('#333333')
          .onClick(() => { router.back() })
        Text(this.contactName)
          .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#333333')
          .margin({ left: 12 })
        Blank()
        Image($r('app.media.ic_more'))
          .width(24).height(24).fillColor('#333333')
      }
      .width('100%').height(48).padding({ left: 16, right: 16 })
      .backgroundColor(Color.White)

      // 消息列表
      List({ scroller: this.listScroller }) {
        ForEach(this.messages, (msg: ChatMessage, index?: number) => {
          ListItem() {
            this.MessageBubble(msg)
          }
        }, (msg: ChatMessage) => msg.id)
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .padding({ left: 12, right: 12, top: 8 })
      .backgroundColor('#EDEDED')

      // 输入栏
      this.InputBar()
    }
    .width('100%').height('100%').backgroundColor('#EDEDED')
  }

  // ========== 消息气泡 ==========
  @Builder
  MessageBubble(msg: ChatMessage) {
    // 判断是自己的消息还是对方的
    const isSelf = msg.senderId === this.currentUserId

    Row() {
      if (isSelf) {
        Blank()
      }

      // 头像
      if (!isSelf) {
        Image(this.contactAvatar)
          .width(36).height(36).borderRadius(18).objectFit(ImageFit.Cover)
      }

      // 消息内容
      Column() {
        if (msg.type === MessageType.TEXT) {
          Text(msg.content)
            .fontSize(15)
            .fontColor(isSelf ? Color.White : '#333333')
            .padding({ left: 12, right: 12, top: 8, bottom: 8 })
            .backgroundColor(isSelf ? '#1DA1F2' : Color.White)
            .borderRadius(isSelf ? 
              { topLeft: 12, topRight: 12, bottomLeft: 12, bottomRight: 4 } :
              { topLeft: 12, topRight: 12, bottomLeft: 4, bottomRight: 12 })
        } else if (msg.type === MessageType.IMAGE) {
          Image(msg.mediaUrl || '')
            .width(160).height(160).borderRadius(8).objectFit(ImageFit.Cover)
        }

        // 消息状态
        if (isSelf && msg.status === 'sending') {
          Text('发送中...')
            .fontSize(10).fontColor('#999999').margin({ top: 2 })
        } else if (isSelf && msg.status === 'failed') {
          Text('发送失败')
            .fontSize(10).fontColor('#FF4444').margin({ top: 2 })
        }
      }
      .constraintSize({ maxWidth: '65%' })
      .alignItems(isSelf ? HorizontalAlign.End : HorizontalAlign.Start)

      // 头像
      if (isSelf) {
        Image($r('app.media.ic_avatar_default'))
          .width(36).height(36).borderRadius(18).fillColor('#1DA1F2')
      }

      if (!isSelf) {
        Blank()
      }
    }
    .width('100%')
    .margin({ bottom: 12 })
  }

  // ========== 输入栏 ==========
  @Builder
  InputBar() {
    Row() {
      Image($r('app.media.ic_voice'))
        .width(28).height(28).fillColor('#666666')
        .onClick(() => { /* 语音输入 */ })

      TextInput({ placeholder: '输入消息...', text: $$this.inputText })
        .layoutWeight(1)
        .height(36)
        .fontSize(15)
        .backgroundColor('#F5F5F5')
        .borderRadius(18)
        .padding({ left: 12, right: 12 })
        .margin({ left: 8, right: 8 })
        .onSubmit(() => {
          this.sendTextMessage()
        })

      Image($r('app.media.ic_emoji'))
        .width(28).height(28).fillColor('#666666')
        .margin({ right: 8 })

      if (this.inputText.trim().length > 0) {
        Button('发送')
          .fontSize(13).fontColor(Color.White)
          .backgroundColor('#1DA1F2').borderRadius(16)
          .height(32).padding({ left: 12, right: 12 })
          .onClick(() => { this.sendTextMessage() })
      } else {
        Image($r('app.media.ic_plus'))
          .width(28).height(28).fillColor('#666666')
          .onClick(() => { /* 更多功能 */ })
      }
    }
    .width('100%')
    .height(52)
    .padding({ left: 12, right: 12 })
    .backgroundColor(Color.White)
    .alignItems(VerticalAlign.Center)
  }

  // ========== 发送文字消息 ==========
  sendTextMessage() {
    const text = this.inputText.trim()
    if (!text) return

    const message: ChatMessage = {
      id: `msg_${Date.now()}`,
      conversationId: this.conversationId,
      senderId: this.currentUserId,
      receiverId: '',
      type: MessageType.TEXT,
      content: text,
      timestamp: Date.now(),
      status: 'sending',
      isRead: false,
    }

    // 先写入本地,立即显示
    this.messages = [...this.messages, message]
    this.inputText = ''

    // 滚动到底部
    setTimeout(() => {
      this.listScroller.scrollToIndex(this.messages.length - 1)
    }, 50)

    // 通过WebSocket发送
    const sent = this.wsManager.sendMessage(message)
    if (!sent) {
      // 发送失败,更新状态
      const idx = this.messages.findIndex(m => m.id === message.id)
      if (idx >= 0) {
        this.messages[idx].status = 'failed'
        this.messages = [...this.messages]
      }
    }
  }

  // ========== 加载聊天记录 ==========
  loadChatHistory() {
    // 模拟数据
    this.messages = [
      { id: '1', conversationId: this.conversationId, senderId: 'user_002', receiverId: this.currentUserId, type: MessageType.TEXT, content: '你好,最近怎么样?', timestamp: Date.now() - 3600000, status: 'read', isRead: true },
      { id: '2', conversationId: this.conversationId, senderId: this.currentUserId, receiverId: 'user_002', type: MessageType.TEXT, content: '挺好的,你呢?', timestamp: Date.now() - 3500000, status: 'read', isRead: true },
      { id: '3', conversationId: this.conversationId, senderId: 'user_002', receiverId: this.currentUserId, type: MessageType.TEXT, content: '我也不错,周末有空一起吃饭吗?', timestamp: Date.now() - 3400000, status: 'read', isRead: true },
    ]
  }
}

完整示例:会话列表与未读消息

把会话列表、未读消息管理、聊天入口串成完整IM系统。

// ConversationListPage.ets — 会话列表
import { router } from '@kit.ArkUI'

// 会话数据
interface Conversation {
  id: string
  contactId: string
  contactName: string
  contactAvatar: string
  lastMessage: string     // 最后一条消息摘要
  lastMessageTime: number // 最后消息时间戳
  unreadCount: number     // 未读消息数
  isPinned: boolean       // 是否置顶
}

@Entry
@Component
struct ConversationListPage {
  @State conversations: Conversation[] = []
  @State isLoading: boolean = false

  private wsManager: WebSocketManager = WebSocketManager.getInstance()

  aboutToAppear() {
    this.loadConversations()
    // 连接WebSocket
    this.wsManager.connect('user_token')

    // 监听新消息,更新会话列表
    this.wsManager.onMessage((msg: ChatMessage) => {
      this.onNewMessage(msg)
    })
  }

  build() {
    Column() {
      // 顶部栏
      Row() {
        Text('消息')
          .fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333333')
        Blank()
        Image($r('app.media.ic_add_chat'))
          .width(24).height(24).fillColor('#333333')
      }
      .width('100%').height(48).padding({ left: 16, right: 16 })
      .backgroundColor(Color.White)

      // 会话列表
      List() {
        ForEach(this.conversations, (conv: Conversation) => {
          ListItem() {
            this.ConversationItem(conv)
          }
          .swipeAction({ end: this.SwipeActions(conv) })
        }, (conv: Conversation) => conv.id)
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .divider({ strokeWidth: 0.5, color: '#F0F0F0', startMargin: 72 })
    }
    .width('100%').height('100%').backgroundColor(Color.White)
  }

  // ========== 会话项 ==========
  @Builder
  ConversationItem(conv: Conversation) {
    Row() {
      // 头像
      Stack() {
        Image(conv.contactAvatar)
          .width(48).height(48).borderRadius(8).objectFit(ImageFit.Cover)

        // 未读数角标
        if (conv.unreadCount > 0) {
          Text(conv.unreadCount > 99 ? '99+' : `${conv.unreadCount}`)
            .fontSize(10).fontColor(Color.White)
            .backgroundColor('#FF4444').borderRadius(10)
            .padding({ left: 5, right: 5, top: 1, bottom: 1 })
            .position({ x: 36, y: -4 })
        }
      }
      .width(48).height(48)

      // 会话信息
      Column() {
        Row() {
          Text(conv.contactName)
            .fontSize(16).fontColor('#333333').fontWeight(FontWeight.Medium)
          Blank()
          Text(this.formatTime(conv.lastMessageTime))
            .fontSize(12).fontColor('#999999')
        }.width('100%')

        Text(conv.lastMessage)
          .fontSize(14).fontColor('#999999').maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ top: 4 })
      }
      .layoutWeight(1)
      .margin({ left: 12 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12, bottom: 12 })
    .backgroundColor(conv.isPinned ? '#F5F5F5' : Color.White)
    .onClick(() => {
      // 进入聊天页
      router.pushUrl({
        url: 'pages/ChatPage',
        params: {
          conversationId: conv.id,
          contactName: conv.contactName,
          contactAvatar: conv.contactAvatar
        }
      })
    })
  }

  // ========== 左滑操作 ==========
  @Builder
  SwipeActions(conv: Conversation) {
    Row() {
      Button(conv.isPinned ? '取消置顶' : '置顶')
        .fontSize(13).fontColor(Color.White)
        .backgroundColor('#999999').borderRadius(0)
        .width(70).height('100%')
        .onClick(() => { this.togglePin(conv.id) })

      Button('删除')
        .fontSize(13).fontColor(Color.White)
        .backgroundColor('#FF4444').borderRadius(0)
        .width(70).height('100%')
        .onClick(() => { this.deleteConversation(conv.id) })
    }
  }

  // ========== 收到新消息 ==========
  onNewMessage(msg: ChatMessage) {
    // 找到对应会话
    const idx = this.conversations.findIndex(c => c.id === msg.conversationId)
    if (idx >= 0) {
      // 更新会话
      const conv = this.conversations[idx]
      conv.lastMessage = msg.type === MessageType.TEXT ? msg.content : '[图片]'
      conv.lastMessageTime = msg.timestamp
      conv.unreadCount++

      // 移到列表顶部(置顶的除外)
      this.conversations.splice(idx, 1)
      const pinIdx = this.conversations.findIndex(c => !c.isPinned)
      this.conversations.splice(pinIdx >= 0 ? pinIdx : 0, 0, conv)
      this.conversations = [...this.conversations]
    }
  }

  // ========== 辅助方法 ==========
  formatTime(timestamp: number): string {
    const now = Date.now()
    const diff = now - timestamp
    if (diff < 60000) return '刚刚'
    if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
    if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
    return `${Math.floor(diff / 86400000)}天前`
  }

  togglePin(convId: string) {
    const conv = this.conversations.find(c => c.id === convId)
    if (conv) {
      conv.isPinned = !conv.isPinned
      this.conversations = [...this.conversations]
    }
  }

  deleteConversation(convId: string) {
    this.conversations = this.conversations.filter(c => c.id !== convId)
  }

  loadConversations() {
    this.conversations = [
      { id: 'conv1', contactId: 'u2', contactName: '张三', contactAvatar: 'https://picsum.photos/80/80?random=1', lastMessage: '周末一起吃饭吗?', lastMessageTime: Date.now() - 300000, unreadCount: 3, isPinned: true },
      { id: 'conv2', contactId: 'u3', contactName: '产品讨论组', contactAvatar: 'https://picsum.photos/80/80?random=2', lastMessage: '李四: 新版设计稿出了', lastMessageTime: Date.now() - 3600000, unreadCount: 12, isPinned: false },
      { id: 'conv3', contactId: 'u4', contactName: '王五', contactAvatar: 'https://picsum.photos/80/80?random=3', lastMessage: '收到,谢谢!', lastMessageTime: Date.now() - 86400000, unreadCount: 0, isPinned: false },
    ]
  }
}

踩坑与注意事项

坑1:WebSocket连接频繁断开

移动端网络不稳定,WebSocket连接隔几分钟就断一次。每次断开都重连,用户体验很差。

解决方案

  • 心跳机制:30秒发一次ping,3次无响应则断开重连
  • 指数退避重连:1s, 2s, 4s, 8s…不要每次都1秒重连,服务器扛不住
  • 网络状态监听:网络恢复时立即重连,不用等心跳超时

坑2:消息发送失败处理

消息发送失败后,用户看不到任何提示,以为发出去了。

解决方案:每条消息都有状态(sending/sent/failed),失败的消息显示红色感叹号,点击可以重发。

坑3:聊天记录加载性能

打开聊天页要加载1000条历史消息,一次性全部渲染?内存爆了。

解决方案:分页加载,每次20条,上滑加载更多。用LazyForEach只渲染可见区域。图片消息用缩略图。

坑4:未读消息数不一致

会话列表显示3条未读,点进去只有2条——因为有一条消息被撤回了。

解决方案:未读数由服务端维护,客户端只负责展示。进入聊天页时,上报已读回执,服务端更新未读数。

坑5:消息时序问题

用户快速发送5条消息,服务端收到的顺序可能是3-1-2-5-4——因为网络延迟。

解决方案:消息按客户端时间戳排序,不按服务端接收时间排序。或者服务端给每条消息分配递增的序列号。

HarmonyOS 6适配说明

HarmonyOS 6对IM相关能力做了以下更新:

  1. WebSocket增强:WebSocket Kit新增了自动重连能力,不需要手动实现重连逻辑。设置autoReconnect: true后,连接断开时SDK自动重连,支持自定义重连策略。
// HarmonyOS 6自动重连
const ws = webSocket.createWebSocket({
  autoReconnect: true,
  reconnectInterval: [1000, 2000, 4000, 8000, 16000],  // 递增间隔
  maxReconnectAttempts: 10
})
  1. 关系型数据库RDB增强:RDB新增了FTS5全文搜索扩展,聊天记录可以用SQL做全文搜索,不用自己实现搜索算法。

  2. 通知增强:Notification Kit新增了消息样式通知,支持显示头像、消息内容、回复按钮。用户在通知栏直接回复消息,不需要打开App。

  3. 后台长连接保活:HarmonyOS 6优化了后台长连接的保活策略,App切到后台后WebSocket连接不会被系统杀掉,保证消息实时到达。

  4. @Reusable组件复用:聊天消息气泡用@Reusable标记后,滑出屏幕的消息组件自动复用,不再重复创建。1000条消息的聊天页内存占用降低约70%。

总结

IM聊天的核心是可靠性。消息不能丢、不能重、不能乱序——这三个条件都满足,才算一个合格的IM系统。

核心记住三点:

  • WebSocket必须有心跳和重连,移动端网络不稳定,没有重连机制的IM就是玩具
  • 消息必须本地持久化,断网时消息先存本地,联网后自动发送
  • 未读数由服务端维护,客户端只展示,避免不一致
评估维度 说明
学习难度 ⭐⭐⭐⭐⭐ WebSocket管理、消息可靠性、本地存储都需要经验
使用频率 ⭐⭐⭐⭐⭐ 社交App的核心功能
重要程度 ⭐⭐⭐⭐⭐ 消息丢了就是用户投诉,没有比这更重要的了

聊天消息发出去对方收不到——这不是bug,这是信任危机。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。