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:消息列表在键盘弹出时不能自动滚动到底部?
确保:
Scaffold的resizeToAvoidBottomInset为true(默认)- 在
TextField获得焦点后延迟调用scrollToBottom - 使用
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 集成、状态管理、键盘适配和性能优化。