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

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

简介

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

前置要求

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

一、项目初始化与依赖

1
2
flutter create chat_ui_demo
cd chat_ui_demo

pubspec.yaml 中添加依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 消息模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 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 回复专用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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 消息气泡组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
// 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 打字机效果)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// 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 消息列表组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 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 输入工具栏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
// 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 聊天页面整合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
// 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 自动键盘适配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 在聊天页面中使用
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 安全区域处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 在 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 默认就是虚拟化的,但需要注意:

1
2
3
4
5
6
7
8
9
10
11
12
// ✅ 好的做法:使用 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 构造函数

1
2
3
4
5
6
// ✅ 在 MessageBubble 中使用 const
const MessageBubble(
message: message,
showAvatar: true,
showTime: true,
)

7.3 图片缓存与懒加载

1
2
3
4
5
6
7
8
// 使用 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 消息去重与分组

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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

1
2
3
4
5
6
7
8
9
10
11
12
// 在 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:大量消息时列表卡顿?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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:流式消息显示有延迟?

1
2
3
4
5
6
7
8
9
10
11
// 减少 setState 频率:每 50ms 刷新一次 UI
Timer? _throttleTimer;

void _handleStreamChunk(String chunk) {
_currentStreamContent += chunk;

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 在 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 集成、状态管理、键盘适配和性能优化。