💬 23 - 聊天App

阶段五:实战项目 · 第23课
✅ 验证通过

📖 核心概念

聊天App是实时通信的经典场景。与天气App的"请求-响应"模式不同,聊天App需要WebSocket实现双向实时通信,消息列表需要高效滚动和差异化渲染,还要处理在线状态、消息已读、输入中等即时反馈。本课将构建一个功能完整的聊天App。

💬 消息数据模型

1. 消息、会话与用户模型

// lib/models/chat_message.dart
class ChatMessage {
  final String id;
  final String content;
  final String senderId;
  final String senderName;
  final String? senderAvatar;
  final MessageType type;
  final MessageStatus status;
  final DateTime timestamp;
  final String? replyTo;       // 回复的消息ID
  final Map<String, dynamic>? extra; // 扩展数据

  const ChatMessage({
    required this.id,
    required this.content,
    required this.senderId,
    required this.senderName,
    this.senderAvatar,
    this.type = MessageType.text,
    this.status = MessageStatus.sent,
    required this.timestamp,
    this.replyTo,
    this.extra,
  });

  bool get isMe => senderId == currentUserId;
  bool get isImage => type == MessageType.image;
  bool get isSystem => type == MessageType.system;

  factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
    id: json['id'] as String,
    content: json['content'] as String,
    senderId: json['sender_id'] as String,
    senderName: json['sender_name'] as String,
    senderAvatar: json['sender_avatar'] as String?,
    type: MessageType.values.firstWhere(
      (t) => t.name == json['type'], orElse: () => MessageType.text),
    status: MessageStatus.values.firstWhere(
      (s) => s.name == json['status'], orElse: () => MessageStatus.sent),
    timestamp: DateTime.parse(json['timestamp'] as String),
    replyTo: json['reply_to'] as String?,
    extra: json['extra'] as Map<String, dynamic>?,
  );

  Map<String, dynamic> toJson() => {
    'id': id, 'content': content, 'sender_id': senderId,
    'sender_name': senderName, 'type': type.name,
    'status': status.name, 'timestamp': timestamp.toIso8601String(),
    'reply_to': replyTo, 'extra': extra,
  };
}

enum MessageType { text, image, file, system, voice }
enum MessageStatus { sending, sent, delivered, read, failed }

extension MessageStatusUI on MessageStatus {
  IconData get icon => switch (this) {
    MessageStatus.sending => Icons.access_time,
    MessageStatus.sent => Icons.check,
    MessageStatus.delivered => Icons.done_all,
    MessageStatus.read => Icons.done_all,
    MessageStatus.failed => Icons.error_outline,
  };

  Color get color => switch (this) {
    MessageStatus.sending => Colors.grey,
    MessageStatus.sent => Colors.grey,
    MessageStatus.delivered => Colors.grey,
    MessageStatus.read => Colors.blue,
    MessageStatus.failed => Colors.red,
  };
}

// 会话模型
class Conversation {
  final String id;
  final String name;
  final String? avatar;
  final bool isGroup;
  final List<String> participants;
  final ChatMessage? lastMessage;
  final int unreadCount;
  final bool isOnline;
  final bool isTyping;

  const Conversation({
    required this.id, required this.name,
    this.avatar, this.isGroup = false,
    this.participants = const [],
    this.lastMessage, this.unreadCount = 0,
    this.isOnline = false, this.isTyping = false,
  });
}

🔌 WebSocket实时通信

2. WebSocket服务封装

// lib/services/chat_service.dart
class ChatService {
  WebSocketChannel? _channel;
  final String _serverUrl;
  final String _userId;
  final _messageController = StreamController<ChatMessage>.broadcast();
  final _statusController = StreamController<UserStatus>.broadcast();
  final _typingController = StreamController<TypingEvent>.broadcast();

  Stream<ChatMessage> get onMessage => _messageController.stream;
  Stream<UserStatus> get onStatusChange => _statusController.stream;
  Stream<TypingEvent> get onTyping => _typingController.stream;

  ChatService({required String serverUrl, required String userId})
    : _serverUrl = serverUrl, _userId = userId;

  // 连接WebSocket
  Future<void> connect() async {
    try {
      _channel = WebSocketChannel.connect(Uri.parse('$_serverUrl/ws?user=$_userId'));
      _channel!.stream.listen(
        (data) {
          final json = jsonDecode(data as String) as Map<String, dynamic>;
          _handleMessage(json);
        },
        onError: (error) => _reconnect(),
        onDone: () => _reconnect(),
      );
    } catch (e) {
      await _reconnect();
    }
  }

  // 处理收到的消息
  void _handleMessage(Map<String, dynamic> json) {
    final type = json['type'] as String;
    switch (type) {
      case 'message':
        _messageController.add(ChatMessage.fromJson(json['data']));
        break;
      case 'status':
        _statusController.add(UserStatus.fromJson(json['data']));
        break;
      case 'typing':
        _typingController.add(TypingEvent.fromJson(json['data']));
        break;
    }
  }

  // 发送文本消息
  void sendMessage(String conversationId, String content) {
    final msg = ChatMessage(
      id: const Uuid().v4(),
      content: content,
      senderId: _userId,
      senderName: 'Me',
      timestamp: DateTime.now(),
    );
    _channel?.sink.add(jsonEncode({
      'type': 'message',
      'conversation_id': conversationId,
      'data': msg.toJson(),
    }));
  }

  // 发送"正在输入"状态
  void sendTyping(String conversationId) {
    _channel?.sink.add(jsonEncode({
      'type': 'typing',
      'conversation_id': conversationId,
      'user_id': _userId,
    }));
  }

  // 标记已读
  void markAsRead(String conversationId, List<String> messageIds) {
    _channel?.sink.add(jsonEncode({
      'type': 'read',
      'conversation_id': conversationId,
      'message_ids': messageIds,
    }));
  }

  // 自动重连
  Future<void> _reconnect() async {
    await Future.delayed(const Duration(seconds: 3));
    await connect();
  }

  void disconnect() {
    _channel?.sink.close();
    _messageController.close();
    _statusController.close();
    _typingController.close();
  }
}

📱 聊天UI实现

3. 消息气泡组件

// lib/widgets/message_bubble.dart
class MessageBubble extends StatelessWidget {
  final ChatMessage message;
  final ChatMessage? replyTo;
  final VoidCallback onLongPress;
  final VoidCallback onAvatarTap;

  const MessageBubble({
    required this.message,
    this.replyTo,
    required this.onLongPress,
    required this.onAvatarTap,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.only(
        left: message.isMe ? 48 : 8,
        right: message.isMe ? 8 : 48,
        top: 4, bottom: 4,
      ),
      child: Row(
        mainAxisAlignment:
          message.isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
        crossAxisAlignment: CrossAxisAlignment.end,
        children: [
          if (!message.isMe)
            GestureDetector(
              onTap: onAvatarTap,
              child: CircleAvatar(
                radius: 16,
                backgroundImage: message.senderAvatar != null
                  ? NetworkImage(message.senderAvatar!) : null,
                child: message.senderAvatar == null
                  ? Text(message.senderName[0]) : null,
              ),
            ),
          const SizedBox(width: 8),
          Flexible(
            child: GestureDetector(
              onLongPress: onLongPress,
              child: Container(
                padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
                decoration: BoxDecoration(
                  color: message.isMe
                    ? Theme.of(context).colorScheme.primary
                    : Theme.of(context).colorScheme.surfaceVariant,
                  borderRadius: BorderRadius.only(
                    topLeft: const Radius.circular(18),
                    topRight: const Radius.circular(18),
                    bottomLeft: Radius.circular(message.isMe ? 18 : 4),
                    bottomRight: Radius.circular(message.isMe ? 4 : 18),
                  ),
                ),
                child: Column(
                  crossAxisAlignment: message.isMe
                    ? CrossAxisAlignment.end : CrossAxisAlignment.start,
                  children: [
                    // 回复引用
                    if (replyTo != null)
                      Container(
                        padding: const EdgeInsets.all(8),
                        margin: const EdgeInsets.only(bottom: 6),
                        decoration: BoxDecoration(
                          color: Colors.black12,
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: Text(replyTo!.content,
                          maxLines: 2, overflow: TextOverflow.ellipsis,
                          style: const TextStyle(fontSize: 12)),
                      ),
                    // 消息内容
                    Text(message.content,
                      style: TextStyle(
                        color: message.isMe ? Colors.white : null,
                      )),
                    const SizedBox(height: 4),
                    // 时间+状态
                    Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Text(
                          _formatTime(message.timestamp),
                          style: TextStyle(fontSize: 11,
                            color: message.isMe ? Colors.white70 : Colors.grey),
                        ),
                        if (message.isMe) ...[
                          const SizedBox(width: 4),
                          Icon(message.status.icon,
                            size: 14, color: message.status.color),
                        ],
                      ],
                    ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

4. 聊天输入框

// lib/widgets/chat_input.dart
class ChatInput extends StatefulWidget {
  final Function(String) onSend;
  final VoidCallback onTyping;

  const ChatInput({required this.onSend, required this.onTyping});

  @override
  State<ChatInput> createState() => _ChatInputState();
}

class _ChatInputState extends State<ChatInput> {
  final _controller = TextEditingController();
  final _focusNode = FocusNode();
  Timer? _typingTimer;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.surface,
        boxShadow: [BoxShadow(
          color: Colors.black12, blurRadius: 4, offset: const Offset(0, -2))],
      ),
      child: SafeArea(
        child: Row(
          children: [
            IconButton(icon: const Icon(Icons.add_circle_outline),
              onPressed: _showAttachmentSheet),
            Expanded(
              child: TextField(
                controller: _controller,
                focusNode: _focusNode,
                onChanged: _onChanged,
                decoration: InputDecoration(
                  hintText: '输入消息...',
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(24),
                    borderSide: BorderSide.none,
                  ),
                  filled: true,
                  fillColor: Theme.of(context).colorScheme.surfaceVariant,
                  contentPadding: const EdgeInsets.symmetric(
                    horizontal: 16, vertical: 10),
                ),
              ),
            ),
            const SizedBox(width: 4),
            IconButton(
              icon: const Icon(Icons.send),
              color: Theme.of(context).colorScheme.primary,
              onPressed: _send,
            ),
          ],
        ),
      ),
    );
  }

  void _onChanged(String value) {
    // 触发"正在输入"事件
    widget.onTyping();
    _typingTimer?.cancel();
    _typingTimer = Timer(const Duration(seconds: 2), () {
      // 停止输入
    });
  }

  void _send() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;
    widget.onSend(text);
    _controller.clear();
  }
}
✓ WebSocket连接:自动连接/断线重连 ✓ 消息收发:实时发送接收文本消息 ✓ 消息气泡:左右分布、头像、时间戳、状态图标 ✓ 输入指示:显示"对方正在输入..." ✓ 已读回执:单勾/双勾/蓝色双勾 ✓ 回复引用:长按消息可回复 ✓ 附件功能:图片/文件发送

📝 练习

  1. 实现语音消息——长按录音,松开发送,播放时显示波形动画
  2. 添加消息搜索功能——在全量消息中搜索关键词并定位
  3. 实现消息撤回——2分钟内可撤回已发送的消息
  4. 添加端到端加密——使用pointycastle对消息内容加密

🏆 成就解锁:连线达人

你已构建了一个聊天App!掌握了:

下一课预告:电商App——商品列表、购物车、支付流程、搜索,构建一个完整的购物体验!