Flutter 聊天界面开发实战指南:从气泡到流式消息

Flutter 聊天界面开发实战:从气泡到流式消息

简介

聊天界面是移动应用中最常见的交互模式之一,但在 Flutter 中实现一个高质量、可生产的聊天 UI 涉及大量细节:消息气泡样式、流式打字机效果、键盘适配、消息分组、图片/语音消息、性能优化等。本文从零开始构建一个完整的聊天界面,所有代码可直接用于生产项目。

前置要求

  • Flutter 3.10+(推荐 3.16+)
  • Dart 3.0+
  • 基础的 Flutter 项目结构知识
  • 已配置好 Flutter 开发环境

一、项目初始化与依赖

bash 复制代码
flutter create chat_ui_demo
cd chat_ui_demo

pubspec.yaml 中添加依赖:

yaml 复制代码
dependencies:
  flutter:
    sdk: flutter
  # 状态管理
  provider: ^6.1.1
  # 时间格式化
  intl: ^0.19.0
  # WebSocket 客户端
  web_socket_channel: ^2.4.0
  # 缓存图片
  cached_network_image: ^3.3.1
  # 加载更多(下拉加载历史)
  pull_to_refresh: ^2.0.0

二、数据模型设计

2.1 消息模型

dart 复制代码
// models/message.dart
enum MessageType {
  text,
  image,
  voice,
  system,
  typing,  // 对方正在输入
}

enum MessageStatus {
  sending,    // 发送中
  sent,       // 已发送
  delivered,  // 已送达
  read,       // 已读
  failed,     // 发送失败
}

class ChatMessage {
  final String id;
  final String content;
  final MessageType type;
  final bool isMe; // true: 我发送的, false: 对方发送的
  final DateTime timestamp;
  final MessageStatus status;
  final String? imageUrl;
  final String? voiceUrl;
  final int? voiceDuration; // 语音时长(秒)
  final List<ChatMessage>? replies; // 引用的消息

  const ChatMessage({
    required this.id,
    required this.content,
    required this.type,
    required this.isMe,
    required this.timestamp,
    this.status = MessageStatus.sent,
    this.imageUrl,
    this.voiceUrl,
    this.voiceDuration,
    this.replies,
  });

  /// 判断两条消息是否属于同一组(同一个人连续发送)
  bool isSameGroup(ChatMessage other) {
    return isMe == other.isMe && type == other.type;
  }

  /// 判断两条消息的时间是否接近(5分钟内算一组)
  bool isCloseTo(ChatMessage other) {
    return timestamp.difference(other.timestamp).inMinutes.abs() < 5;
  }
}

2.2 流式消息模型(AI 回复专用)

dart 复制代码
// models/stream_message.dart
class StreamMessage {
  final String conversationId;
  String content;
  bool isComplete;
  final DateTime createdAt;

  StreamMessage({
    required this.conversationId,
    this.content = '',
    this.isComplete = false,
    DateTime? createdAt,
  }) : createdAt = createdAt ?? DateTime.now();

  void appendChunk(String chunk) {
    content += chunk;
  }

  void markComplete() {
    isComplete = true;
  }
}

三、聊天列表核心组件

3.1 消息气泡组件

dart 复制代码
// widgets/message_bubble.dart
import 'package:flutter/material.dart';
import '../models/message.dart';

class MessageBubble extends StatelessWidget {
  final ChatMessage message;
  final bool showAvatar;
  final bool showTime;

  const MessageBubble({
    super.key,
    required this.message,
    this.showAvatar = true,
    this.showTime = true,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.only(
        left: message.isMe ? 60 : 12,
        right: message.isMe ? 12 : 60,
        top: 4,
        bottom: 4,
      ),
      child: Column(
        crossAxisAlignment:
            message.isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
        children: [
          if (showTime) _buildTimeLabel(),
          Row(
            mainAxisAlignment:
                message.isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              if (!message.isMe && showAvatar) _buildAvatar(),
              const SizedBox(width: 8),
              Flexible(child: _buildBubble(context)),
              const SizedBox(width: 8),
              if (message.isMe) _buildStatusIcon(),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildTimeLabel() {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Center(
        child: Text(
          _formatTime(message.timestamp),
          style: TextStyle(
            fontSize: 12,
            color: Colors.grey[400],
          ),
        ),
      ),
    );
  }

  Widget _buildAvatar() {
    return CircleAvatar(
      radius: 16,
      backgroundColor: message.isMe ? Colors.blue[100] : Colors.grey[200],
      child: Icon(
        message.isMe ? Icons.person : Icons.smart_toy,
        size: 18,
        color: message.isMe ? Colors.blue : Colors.grey[600],
      ),
    );
  }

  Widget _buildBubble(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
      decoration: BoxDecoration(
        color: message.isMe
            ? Theme.of(context).colorScheme.primary
            : Colors.grey[100],
        borderRadius: BorderRadius.only(
          topLeft: const Radius.circular(16),
          topRight: const Radius.circular(16),
          bottomLeft: Radius.circular(message.isMe ? 16 : 4),
          bottomRight: Radius.circular(message.isMe ? 4 : 16),
        ),
      ),
      child: _buildContent(context),
    );
  }

  Widget _buildContent(BuildContext context) {
    switch (message.type) {
      case MessageType.text:
        return Text(
          message.content,
          style: TextStyle(
            fontSize: 15,
            color: message.isMe ? Colors.white : Colors.black87,
            height: 1.4,
          ),
        );
      case MessageType.image:
        return _buildImageContent();
      case MessageType.voice:
        return _buildVoiceContent();
      case MessageType.system:
        return _buildSystemContent();
      case MessageType.typing:
        return _buildTypingIndicator();
    }
  }

  Widget _buildImageContent() {
    return ClipRRect(
      borderRadius: BorderRadius.circular(8),
      child: Image.network(
        message.imageUrl!,
        width: 200,
        height: 200,
        fit: BoxFit.cover,
        loadingBuilder: (context, child, progress) {
          if (progress == null) return child;
          return Container(
            width: 200,
            height: 200,
            color: Colors.grey[200],
            child: const Center(child: CircularProgressIndicator()),
          );
        },
        errorBuilder: (context, error, stackTrace) {
          return Container(
            width: 200,
            height: 200,
            color: Colors.grey[200],
            child: const Icon(Icons.broken_image, color: Colors.grey),
          );
        },
      ),
    );
  }

  Widget _buildVoiceContent() {
    return Container(
      width: 120,
      height: 36,
      child: Row(
        children: [
          Icon(
            Icons.mic,
            size: 18,
            color: message.isMe ? Colors.white : Colors.black87,
          ),
          const SizedBox(width: 8),
          Expanded(
            child: LinearProgressIndicator(
              value: 0.5,
              backgroundColor: message.isMe
                  ? Colors.white.withOpacity(0.3)
                  : Colors.grey[300],
              color: message.isMe ? Colors.white : Colors.blue,
            ),
          ),
          const SizedBox(width: 8),
          Text(
            "${message.voiceDuration ?? 0}s",
            style: TextStyle(
              fontSize: 12,
              color: message.isMe ? Colors.white : Colors.black87,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSystemContent() {
    return Center(
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
        decoration: BoxDecoration(
          color: Colors.grey[200],
          borderRadius: BorderRadius.circular(12),
        ),
        child: Text(
          message.content,
          style: TextStyle(fontSize: 12, color: Colors.grey[600]),
        ),
      ),
    );
  }

  Widget _buildTypingIndicator() {
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.grey[100],
        borderRadius: BorderRadius.circular(16),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          _buildDot(0),
          const SizedBox(width: 4),
          _buildDot(1),
          const SizedBox(width: 4),
          _buildDot(2),
        ],
      ),
    );
  }

  Widget _buildDot(int index) {
    return AnimatedOpacity(
      opacity: 1.0,
      duration: Duration(milliseconds: 400),
      child: Container(
        width: 8,
        height: 8,
        decoration: BoxDecoration(
          color: Colors.grey[400],
          shape: BoxShape.circle,
        ),
      ),
    );
  }

  Widget _buildStatusIcon() {
    switch (message.status) {
      case MessageStatus.sending:
        return const SizedBox(
          width: 16,
          height: 16,
          child: CircularProgressIndicator(strokeWidth: 2),
        );
      case MessageStatus.sent:
        return Icon(Icons.check, size: 16, color: Colors.grey[400]);
      case MessageStatus.delivered:
        return Icon(Icons.done_all, size: 16, color: Colors.grey[400]);
      case MessageStatus.read:
        return Icon(Icons.done_all, size: 16, color: Colors.blue[400]);
      case MessageStatus.failed:
        return Icon(Icons.error, size: 16, color: Colors.red[400]);
    }
  }

  String _formatTime(DateTime time) {
    final now = DateTime.now();
    final diff = now.difference(time);

    if (diff.inMinutes < 1) return '刚刚';
    if (diff.inHours < 1) return '${diff.inMinutes} 分钟前';
    if (diff.inDays < 1) {
      return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
    }
    return '${time.month}/${time.day} ${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
  }
}

3.2 流式消息气泡(AI 打字机效果)

dart 复制代码
// widgets/stream_bubble.dart
import 'dart:async';
import 'package:flutter/material.dart';

class StreamBubble extends StatefulWidget {
  final Stream<String> stream;
  final bool isComplete;
  final VoidCallback? onComplete;

  const StreamBubble({
    super.key,
    required this.stream,
    this.isComplete = false,
    this.onComplete,
  });

  @override
  State<StreamBubble> createState() => _StreamBubbleState();
}

class _StreamBubbleState extends State<StreamBubble> {
  String _displayedContent = '';
  StreamSubscription<String>? _subscription;
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _startListening();
  }

  void _startListening() {
    _subscription = widget.stream.listen(
      (chunk) {
        setState(() {
          _displayedContent += chunk;
        });
        // 自动滚动到底部
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (_scrollController.hasClients) {
            _scrollController.jumpTo(
              _scrollController.position.maxScrollExtent,
            );
          }
        });
      },
      onDone: () {
        widget.onComplete?.call();
      },
    );
  }

  @override
  void dispose() {
    _subscription?.cancel();
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.centerLeft,
      child: Padding(
        padding: const EdgeInsets.only(left: 12, right: 60, top: 4, bottom: 4),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            const CircleAvatar(
              radius: 16,
              backgroundColor: Colors.grey,
              child: Icon(Icons.smart_toy, size: 18, color: Colors.white),
            ),
            const SizedBox(width: 8),
            Flexible(
              child: Container(
                padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
                decoration: const BoxDecoration(
                  color: Color(0xFFF5F5F5),
                  borderRadius: BorderRadius.only(
                    topLeft: Radius.circular(4),
                    topRight: Radius.circular(16),
                    bottomLeft: Radius.circular(16),
                    bottomRight: Radius.circular(16),
                  ),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      _displayedContent,
                      style: const TextStyle(fontSize: 15, height: 1.4),
                    ),
                    if (!widget.isComplete)
                      const Padding(
                        padding: EdgeInsets.only(top: 2),
                        child: SizedBox(
                          width: 12,
                          height: 12,
                          child: CircularProgressIndicator(strokeWidth: 2),
                        ),
                      ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

四、聊天页面完整实现

4.1 消息列表组件

dart 复制代码
// widgets/message_list.dart
import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../models/message.dart';
import 'message_bubble.dart';

class MessageList extends StatelessWidget {
  final List<ChatMessage> messages;
  final bool isLoadingHistory;
  final VoidCallback onLoadHistory;
  final ScrollController scrollController;

  const MessageList({
    super.key,
    required this.messages,
    required this.isLoadingHistory,
    required this.onLoadHistory,
    required this.scrollController,
  });

  @override
  Widget build(BuildContext context) {
    return SmartRefresher(
      controller: RefreshController(initialRefresh: false),
      enablePullDown: true,
      enablePullUp: false,
      onRefresh: () async {
        onLoadHistory();
      },
      child: ListView.builder(
        controller: scrollController,
        padding: const EdgeInsets.only(top: 8, bottom: 8),
        reverse: true, // 最新消息在底部
        itemCount: messages.length,
        itemBuilder: (context, index) {
          final message = messages[index];
          final isFirstInGroup = index == messages.length - 1 ||
              !message.isSameGroup(messages[index + 1]);
          final showTime = index == messages.length - 1 ||
              !message.isCloseTo(messages[index + 1]);

          return MessageBubble(
            message: message,
            showAvatar: isFirstInGroup,
            showTime: showTime,
          );
        },
      ),
    );
  }
}

4.2 输入工具栏

dart 复制代码
// widgets/chat_input.dart
import 'package:flutter/material.dart';

class ChatInput extends StatefulWidget {
  final Function(String) onSend;
  final bool isSending;

  const ChatInput({
    super.key,
    required this.onSend,
    this.isSending = false,
  });

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

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

  void _handleSend() {
    final text = _controller.text.trim();
    if (text.isEmpty || widget.isSending) return;

    widget.onSend(text);
    _controller.clear();
  }

  @override
  void dispose() {
    _controller.dispose();
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: Theme.of(context).scaffoldBackgroundColor,
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.05),
            blurRadius: 8,
            offset: const Offset(0, -2),
          ),
        ],
      ),
      padding: EdgeInsets.only(
        left: 12,
        right: 8,
        top: 8,
        bottom: MediaQuery.of(context).padding.bottom + 8,
      ),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: [
          // 附件按钮
          IconButton(
            icon: const Icon(Icons.add_circle_outline),
            onPressed: () => _showAttachmentMenu(context),
            color: Colors.grey[600],
          ),
          const SizedBox(width: 4),
          // 输入框
          Expanded(
            child: Container(
              constraints: const BoxConstraints(maxHeight: 120),
              decoration: BoxDecoration(
                color: Colors.grey[100],
                borderRadius: BorderRadius.circular(20),
              ),
              child: TextField(
                controller: _controller,
                focusNode: _focusNode,
                maxLines: null,
                textInputAction: TextInputAction.newline,
                decoration: InputDecoration(
                  hintText: '输入消息...',
                  hintStyle: TextStyle(color: Colors.grey[400]),
                  border: InputBorder.none,
                  contentPadding: const EdgeInsets.symmetric(
                    horizontal: 16,
                    vertical: 10,
                  ),
                ),
                onSubmitted: (_) => _handleSend(),
              ),
            ),
          ),
          const SizedBox(width: 4),
          // 发送按钮
          AnimatedContainer(
            duration: const Duration(milliseconds: 200),
            decoration: BoxDecoration(
              color: _controller.text.isNotEmpty
                  ? Theme.of(context).colorScheme.primary
                  : Colors.grey[300],
              shape: BoxShape.circle,
            ),
            child: IconButton(
              icon: widget.isSending
                  ? const SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(
                        strokeWidth: 2,
                        color: Colors.white,
                      ),
                    )
                  : const Icon(Icons.send_rounded, color: Colors.white),
              onPressed: _handleSend,
            ),
          ),
        ],
      ),
    );
  }

  void _showAttachmentMenu(BuildContext context) {
    showModalBottomSheet(
      context: context,
      builder: (context) => SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _attachmentItem(Icons.photo_library, '相册', () {}),
              _attachmentItem(Icons.camera_alt, '拍照', () {}),
              _attachmentItem(Icons.mic, '语音', () {}),
              _attachmentItem(Icons.description, '文件', () {}),
            ],
          ),
        ),
      ),
    );
  }

  Widget _attachmentItem(IconData icon, String label, VoidCallback onTap) {
    return GestureDetector(
      onTap: onTap,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: Colors.grey[100],
              borderRadius: BorderRadius.circular(12),
            ),
            child: Icon(icon, size: 28, color: Colors.grey[700]),
          ),
          const SizedBox(height: 8),
          Text(label, style: const TextStyle(fontSize: 12)),
        ],
      ),
    );
  }
}

4.3 聊天页面整合

dart 复制代码
// pages/chat_page.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/message.dart';
import '../providers/chat_provider.dart';
import '../widgets/message_list.dart';
import '../widgets/chat_input.dart';
import '../widgets/stream_bubble.dart';

class ChatPage extends StatefulWidget {
  final String conversationId;

  const ChatPage({super.key, required this.conversationId});

  @override
  State<ChatPage> createState() => _ChatPageState();
}

class _ChatPageState extends State<ChatPage> {
  final ScrollController _scrollController = ScrollController();
  final TextEditingController _textController = TextEditingController();

  @override
  void initState() {
    super.initState();
    // 页面打开后自动滚动到底部
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _scrollToBottom();
    });
  }

  void _scrollToBottom() {
    if (_scrollController.hasClients) {
      _scrollController.animateTo(
        0,
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOut,
      );
    }
  }

  void _handleSend(String text) {
    final provider = context.read<ChatProvider>();
    provider.sendMessage(widget.conversationId, text);
    
    // 发送后延迟滚动到底部
    Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
  }

  @override
  void dispose() {
    _scrollController.dispose();
    _textController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('AI 外语伴聊'),
        centerTitle: true,
        elevation: 0,
        actions: [
          IconButton(
            icon: const Icon(Icons.more_vert),
            onPressed: () {},
          ),
        ],
      ),
      body: Column(
        children: [
          // 消息列表
          Expanded(
            child: Consumer<ChatProvider>(
              builder: (context, provider, child) {
                final messages = provider.getMessages(widget.conversationId);
                return MessageList(
                  messages: messages,
                  isLoadingHistory: provider.isLoadingHistory,
                  onLoadHistory: () => provider.loadHistory(widget.conversationId),
                  scrollController: _scrollController,
                );
              },
            ),
          ),
          // 输入工具栏
          Consumer<ChatProvider>(
            builder: (context, provider, child) {
              return ChatInput(
                onSend: _handleSend,
                isSending: provider.isSending,
              );
            },
          ),
        ],
      ),
    );
  }
}

五、状态管理与 WebSocket 集成

5.1 ChatProvider

dart 复制代码
// providers/chat_provider.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/message.dart';

class ChatProvider extends ChangeNotifier {
  final Map<String, List<ChatMessage>> _conversations = {};
  final Map<String, WebSocketChannel> _channels = {};
  bool _isSending = false;
  bool _isLoadingHistory = false;

  bool get isSending => _isSending;
  bool get isLoadingHistory => _isLoadingHistory;

  List<ChatMessage> getMessages(String conversationId) {
    return _conversations[conversationId] ?? [];
  }

  /// 连接 WebSocket
  void connect(String conversationId, String wsUrl) {
    if (_channels.containsKey(conversationId)) return;

    try {
      final channel = WebSocketChannel.connect(Uri.parse(wsUrl));
      _channels[conversationId] = channel;

      // 监听消息
      channel.stream.listen(
        (data) {
          final json = jsonDecode(data as String);
          _handleIncomingMessage(conversationId, json);
        },
        onError: (error) {
          debugPrint('WebSocket 错误: $error');
          _handleReconnect(conversationId, wsUrl);
        },
        onDone: () {
          debugPrint('WebSocket 连接关闭');
          _handleReconnect(conversationId, wsUrl);
        },
      );
    } catch (e) {
      debugPrint('WebSocket 连接失败: $e');
    }
  }

  /// 处理收到的消息
  void _handleIncomingMessage(String conversationId, Map<String, dynamic> json) {
    final type = json['type'] as String?;

    switch (type) {
      case 'ai_stream_chunk':
        _handleStreamChunk(conversationId, json['content'] as String);
        break;
      case 'ai_stream_end':
        _handleStreamEnd(conversationId);
        break;
      case 'typing':
        _handleTyping(conversationId);
        break;
      case 'history':
        _handleHistory(conversationId, json['messages'] as List);
        break;
      case 'error':
        _handleError(conversationId, json['content'] as String);
        break;
      default:
        _addMessage(conversationId, ChatMessage(
          id: DateTime.now().millisecondsSinceEpoch.toString(),
          content: json['content'] as String? ?? '',
          type: MessageType.text,
          isMe: false,
          timestamp: DateTime.now(),
        ));
    }
  }

  /// 流式 chunk 处理
  String _currentStreamContent = '';

  void _handleStreamChunk(String conversationId, String chunk) {
    _currentStreamContent += chunk;
    _updateLastMessage(conversationId, _currentStreamContent);
  }

  void _handleStreamEnd(String conversationId) {
    _currentStreamContent = '';
    _isSending = false;
    notifyListeners();
  }

  void _handleTyping(String conversationId) {
    _addMessage(conversationId, ChatMessage(
      id: 'typing_${DateTime.now().millisecondsSinceEpoch}',
      content: '',
      type: MessageType.typing,
      isMe: false,
      timestamp: DateTime.now(),
    ));
  }

  void _handleHistory(String conversationId, List messages) {
    _isLoadingHistory = false;
    final historyMessages = messages.map((m) => ChatMessage(
      id: m['id'] ?? DateTime.now().millisecondsSinceEpoch.toString(),
      content: m['content'] as String? ?? '',
      type: MessageType.text,
      isMe: m['role'] == 'user',
      timestamp: DateTime.parse(m['timestamp'] as String),
    )).toList();

    _conversations[conversationId] = [
      ...historyMessages,
      ...(_conversations[conversationId] ?? []),
    ];
    notifyListeners();
  }

  void _handleError(String conversationId, String error) {
    _isSending = false;
    _addMessage(conversationId, ChatMessage(
      id: 'error_${DateTime.now().millisecondsSinceEpoch}',
      content: '发送失败: $error',
      type: MessageType.system,
      isMe: false,
      timestamp: DateTime.now(),
    ));
  }

  /// 发送消息
  void sendMessage(String conversationId, String content) {
    if (content.trim().isEmpty) return;

    _isSending = true;

    // 添加用户消息
    _addMessage(conversationId, ChatMessage(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      content: content,
      type: MessageType.text,
      isMe: true,
      timestamp: DateTime.now(),
      status: MessageStatus.sending,
    ));

    // 通过 WebSocket 发送
    final channel = _channels[conversationId];
    if (channel != null) {
      channel.sink.add(jsonEncode({
        'type': 'user_message',
        'content': content,
      }));

      // 更新消息状态为已发送
      _updateLastSentStatus(conversationId, MessageStatus.sent);
    } else {
      _updateLastSentStatus(conversationId, MessageStatus.failed);
      _isSending = false;
      notifyListeners();
    }
  }

  /// 加载历史消息
  void loadHistory(String conversationId) {
    _isLoadingHistory = true;
    notifyListeners();

    final channel = _channels[conversationId];
    if (channel != null) {
      channel.sink.add(jsonEncode({
        'type': 'load_history',
        'conversation_id': conversationId,
      }));
    }
  }

  /// 断线重连
  void _handleReconnect(String conversationId, String wsUrl) {
    Future.delayed(const Duration(seconds: 3), () {
      if (_channels.containsKey(conversationId)) {
        _channels.remove(conversationId);
        connect(conversationId, wsUrl);
      }
    });
  }

  /// 辅助方法
  void _addMessage(String conversationId, ChatMessage message) {
    _conversations.putIfAbsent(conversationId, () => []);
    _conversations[conversationId]!.insert(0, message);
    notifyListeners();
  }

  void _updateLastMessage(String conversationId, String newContent) {
    final messages = _conversations[conversationId];
    if (messages != null && messages.isNotEmpty) {
      final lastMsg = messages.first;
      if (!lastMsg.isMe && lastMsg.type == MessageType.text) {
        messages[0] = ChatMessage(
          id: lastMsg.id,
          content: newContent,
          type: MessageType.text,
          isMe: false,
          timestamp: lastMsg.timestamp,
        );
        notifyListeners();
      }
    }
  }

  void _updateLastSentStatus(String conversationId, MessageStatus status) {
    final messages = _conversations[conversationId];
    if (messages != null && messages.isNotEmpty) {
      final lastMsg = messages.first;
      if (lastMsg.isMe) {
        messages[0] = ChatMessage(
          id: lastMsg.id,
          content: lastMsg.content,
          type: lastMsg.type,
          isMe: true,
          timestamp: lastMsg.timestamp,
          status: status,
        );
        notifyListeners();
      }
    }
  }

  @override
  void dispose() {
    for (final channel in _channels.values) {
      channel.sink.close();
    }
    super.dispose();
  }
}

六、键盘适配与安全区域

6.1 自动键盘适配

dart 复制代码
// 在聊天页面中使用
class ChatPage extends StatefulWidget {
  // ...
}

class _ChatPageState extends State<ChatPage> {
  // 监听键盘事件
  late FocusNode _focusNode;

  @override
  void initState() {
    super.initState();
    _focusNode = FocusNode();
    _focusNode.addListener(() {
      if (_focusNode.hasFocus) {
        // 键盘弹出后延迟滚动到底部
        Future.delayed(const Duration(milliseconds: 300), () {
          _scrollToBottom();
        });
      }
    });
  }

  // 在 Scaffold 中使用 resizeToAvoidBottomInset
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      resizeToAvoidBottomInset: true, // 默认就是 true
      // ...
    );
  }
}

6.2 MediaQuery 安全区域处理

dart 复制代码
// 在 ChatInput 中已经使用了 MediaQuery.of(context).padding.bottom
// 确保输入框不会被系统导航栏遮挡

// 全局设置(在 MaterialApp 中)
MaterialApp(
  builder: (context, child) {
    return MediaQuery(
      data: MediaQuery.of(context).copyWith(
        // 避免键盘覆盖输入框
        viewInsets: MediaQuery.of(context).viewInsets,
      ),
      child: child!,
    );
  },
)

七、性能优化

7.1 消息列表虚拟化

Flutter 的 ListView.builder 默认就是虚拟化的,但需要注意:

dart 复制代码
// ✅ 好的做法:使用 itemBuilder 按需构建
ListView.builder(
  itemCount: messages.length,
  itemBuilder: (context, index) => MessageBubble(
    message: messages[index],
  ),
)

// ❌ 坏的做法:一次性构建所有子组件
ListView(
  children: messages.map((m) => MessageBubble(message: m)).toList(),
)

7.2 使用 const 构造函数

dart 复制代码
// ✅ 在 MessageBubble 中使用 const
const MessageBubble(
  message: message,
  showAvatar: true,
  showTime: true,
)

7.3 图片缓存与懒加载

dart 复制代码
// 使用 cached_network_image 自动缓存
CachedNetworkImage(
  imageUrl: message.imageUrl!,
  placeholder: (context, url) => const CircularProgressIndicator(),
  errorWidget: (context, url, error) => const Icon(Icons.error),
  memCacheWidth: 400, // 限制内存缓存大小
  memCacheHeight: 400,
)

7.4 消息去重与分组

dart 复制代码
// 在 ChatProvider 中添加去重
void _addMessage(String conversationId, ChatMessage message) {
  final messages = _conversations.putIfAbsent(conversationId, () => []);
  
  // 去重:相同 id 的消息不重复添加
  if (messages.any((m) => m.id == message.id)) return;
  
  // 移除 typing 指示器(如果有)
  messages.removeWhere((m) => m.type == MessageType.typing);
  
  messages.insert(0, message);
  notifyListeners();
}

八、完整使用示例

8.1 main.dart

dart 复制代码
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/chat_provider.dart';
import 'pages/chat_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => ChatProvider(),
      child: MaterialApp(
        title: 'Chat UI Demo',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(
            seedColor: Colors.blue,
            brightness: Brightness.light,
          ),
          useMaterial3: true,
        ),
        home: const ChatPage(conversationId: 'demo-conversation'),
      ),
    );
  }
}

8.2 启动后连接 WebSocket

dart 复制代码
// 在 ChatPage 的 initState 中连接
@override
void initState() {
  super.initState();
  
  WidgetsBinding.instance.addPostFrameCallback((_) {
    context.read<ChatProvider>().connect(
      widget.conversationId,
      'ws://localhost:8000/ai-chat/${widget.conversationId}',
    );
  });
}

九、常见问题

Q:消息列表在键盘弹出时不能自动滚动到底部?

确保:

  1. ScaffoldresizeToAvoidBottomInsettrue(默认)
  2. TextField 获得焦点后延迟调用 scrollToBottom
  3. 使用 WidgetsBinding.instance.addPostFrameCallback 确保布局已完成

Q:大量消息时列表卡顿?

dart 复制代码
// 1. 限制消息列表长度(只保留最近 N 条)
const int MAX_MESSAGES = 200;

void _addMessage(String conversationId, ChatMessage message) {
  _conversations[conversationId]!.insert(0, message);
  if (_conversations[conversationId]!.length > MAX_MESSAGES) {
    _conversations[conversationId]!.removeLast();
  }
  notifyListeners();
}

// 2. 使用 RepaintBoundary 隔离重绘区域
RepaintBoundary(
  child: MessageBubble(message: message),
)

Q:流式消息显示有延迟?

dart 复制代码
// 减少 setState 频率:每 50ms 刷新一次 UI
Timer? _throttleTimer;

void _handleStreamChunk(String chunk) {
  _currentStreamContent += chunk;
  
  _throttleTimer?.cancel();
  _throttleTimer = Timer(const Duration(milliseconds: 50), () {
    _updateLastMessage(conversationId, _currentStreamContent);
  });
}

Q:如何实现消息引用/回复?

dart 复制代码
// 在 MessageBubble 中添加引用区域
Widget _buildReplyPreview() {
  if (message.replies == null || message.replies!.isEmpty) {
    return const SizedBox.shrink();
  }
  
  final replied = message.replies!.first;
  return Container(
    padding: const EdgeInsets.all(8),
    decoration: BoxDecoration(
      color: Colors.black.withOpacity(0.05),
      borderRadius: BorderRadius.circular(8),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          replied.isMe ? '你' : '对方',
          style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
        ),
        Text(
          replied.content,
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
          style: const TextStyle(fontSize: 13),
        ),
      ],
    ),
  );
}

本文覆盖了 Flutter 聊天界面从零到生产的完整实现。你可以直接将这些代码集成到你的 AI 外语伴聊 App 或其他需要聊天功能的应用中。关键点:消息气泡组件化、流式消息支持、WebSocket 集成、状态管理、键盘适配和性能优化。