在行動裝置環境裡,4G/5G 切換、電梯或地下室收訊不好,是我們日常生活常見的情況。如果 WebSocket 一斷線就讓錄音跟著中斷、資料跟著不見,會使使用者的體驗會差很多。
今天要處理的是語音串流的高可用性,WebSocket 斷線後自動重連,斷線期間先把音訊暫存在本地佇列,等連線恢復後再把積壓的資料補發回伺服器。
名詞補充:
建立 lib/services/resilient_websocket_service.dart
import 'dart:async';
import 'dart:collection';
import 'dart:math';
import 'dart:typed_data';
import 'package:web_socket_channel/web_socket_channel.dart';
class ResilientWebSocketService {
WebSocketChannel? _channel;
StreamSubscription? _subscription;
// 1. 本地邊緣端備援佇列(Ring Buffer 機制)
final Queue<Uint8List> _audioBuffer = Queue<Uint8List>();
final int _maxBufferSize = 100; // 最多暫存約 10 秒的語音 Chunk,防止記憶體溢出
bool _isConnected = false;
bool _isReconnecting = false;
int _retryCount = 0;
String? _currentUrl;
Function(String)? _onTextReceived;
bool get isConnected => _isConnected;
/// 開始連線
Future<void> connect(String url, Function(String textChunk) onTextReceived) async {
_currentUrl = url;
_onTextReceived = onTextReceived;
await _initWebSocket();
}
/// 初始化 WebSocket 與監聽器
Future<void> _initWebSocket() async {
if (_currentUrl == null) return;
try {
_channel = WebSocketChannel.connect(Uri.parse(_currentUrl!));
_isConnected = true;
_isReconnecting = false;
_retryCount = 0;
print('WebSocket 連線成功!');
// 重連成功後,優先清空並補發本地 Buffer 中的語音 Chunk
_flushBuffer();
_subscription = _channel!.stream.listen(
(data) {
if (_onTextReceived != null) _onTextReceived!(data.toString());
},
onError: (error) {
print('WebSocket 發生錯誤: $error');
_handleDisconnect();
},
onDone: () {
print('WebSocket 連線關閉');
_handleDisconnect();
},
);
} catch (e) {
print('連線建立失敗: $error');
_handleDisconnect();
}
}
/// 處理斷線與指數退避重連 (Exponential Backoff)
void _handleDisconnect() {
_isConnected = false;
_subscription?.cancel();
_channel?.sink.close();
if (_isReconnecting) return;
_isReconnecting = true;
// 計算重連延遲時間
_retryCount++;
final backoffSeconds = min(pow(2, _retryCount).toInt(), 30); // 上限 30 秒
print('網路斷線!將於 $backoffSeconds 秒後進行第 $_retryCount 次自動重連...');
Timer(Duration(seconds: backoffSeconds), () async {
if (_isReconnecting) {
await _initWebSocket();
}
});
}
/// 2. 發送 Audio Chunk(含邊緣端備援邏輯)
void sendAudioChunk(Uint8List chunk) {
if (_isConnected && _channel != null) {
try {
_channel!.sink.add(chunk);
} catch (e) {
print('傳送失敗,存入本地 Buffer');
_enqueueBuffer(chunk);
_handleDisconnect();
}
} else {
// 斷線狀態下,把 Chunk 暫存在邊緣端 Buffer
_enqueueBuffer(chunk);
}
}
/// 將 Chunk 加入 Buffer
void _enqueueBuffer(Uint8List chunk) {
if (_audioBuffer.length >= _maxBufferSize) {
_audioBuffer.removeFirst(); // 若 Buffer 滿了,丟棄最舊的 Chunk(確保記憶體安全)
}
_audioBuffer.add(chunk);
print('邊緣端暫存 Chunk,當前 Buffer 積壓數: ${_audioBuffer.length}');
}
/// 3. 重連成功後補發暫存封包
void _flushBuffer() {
if (_audioBuffer.isEmpty) return;
print('重連成功!開始補發 Buffer 中 ${_audioBuffer.length} 個音訊 Chunk...');
while (_audioBuffer.isNotEmpty && _isConnected) {
final chunk = _audioBuffer.removeFirst();
_channel!.sink.add(chunk);
}
}
/// 主動關閉連線
Future<void> disconnect() async {
_isReconnecting = false;
_isConnected = false;
_audioBuffer.clear();
await _subscription?.cancel();
await _channel?.sink.close();
print('手動斷開連線,清空 Buffer');
}
}
建立 lib/pages/resilient_stream_page.dart
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../services/audio_recorder_service.dart';
import '../services/resilient_websocket_service.dart';
class ResilientStreamPage extends StatefulWidget {
const ResilientStreamPage({super.key});
@override
State<ResilientStreamPage> createState() => _ResilientStreamPageState();
}
class _ResilientStreamPageState extends State<ResilientStreamPage> {
final AudioRecorderService _recorder = AudioRecorderService();
final ResilientWebSocketService _wsService = ResilientWebSocketService();
bool _isRecording = false;
String _transcript = '';
final String _testWsUrl = 'wss://echo.websocket.org';
void _toggleStreaming() async {
if (_isRecording) {
await _recorder.stopRecording();
await _wsService.disconnect();
setState(() => _isRecording = false);
} else {
setState(() {
_isRecording = true;
_transcript = '';
});
// 1. 啟動防斷線 WebSocket 連線
await _wsService.connect(_testWsUrl, (text) {
setState(() {
_transcript += '$text\n';
});
});
// 2. 開始錄音並將切片丟入 Resilient Service 處理
await _recorder.startChunkedRecording((Uint8List chunk) {
_wsService.sendAudioChunk(chunk);
});
}
}
@override
void dispose() {
_recorder.dispose();
_wsService.disconnect();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Day 13 - 語音串流斷線重連與備援')),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
// 連線狀態指示燈
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _wsService.isConnected ? Colors.green[100] : Colors.orange[100],
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_wsService.isConnected ? Icons.wifi : Icons.wifi_off,
color: _wsService.isConnected ? Colors.green : Colors.orange,
),
const SizedBox(width: 10),
Text(
_wsService.isConnected ? '網路狀態:正常連線中' : '網路狀態:斷線/重連中 (啟用邊緣端 Buffer)',
style: TextStyle(
color: _wsService.isConnected ? Colors.green[900] : Colors.orange[900],
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 30),
ElevatedButton.icon(
onPressed: _toggleStreaming,
icon: Icon(_isRecording ? Icons.stop : Icons.mic),
label: Text(_isRecording ? '停止錄音串流' : '開始高可用錄音串流'),
style: ElevatedButton.styleFrom(
backgroundColor: _isRecording ? Colors.red : Colors.blue,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
),
),
const SizedBox(height: 30),
const Align(
alignment: Alignment.centerLeft,
child: Text('串流日誌 / 逐字稿:', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(height: 10),
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: Colors.black87,
child: SingleChildScrollView(
child: Text(
_transcript.isEmpty ? '等待連線與語音輸入...' : _transcript,
style: const TextStyle(color: Colors.greenAccent, fontFamily: 'monospace'),
),
),
),
),
],
),
),
);
}
}
檢查清單