日期: 2026-02-26(更新) 影响范围: React Native 端角色切换功能 严重程度: 高 - 影响用户切换角色后的使用体验
用户在 React Native 端切换角色时遇到以下问题:
- 显示"WS连接错误":状态指示器短暂显示红色(断开状态)
- 切换后无法发送消息:虽然界面显示连接成功,但
start_session消息丢失 - 文本聊天无法接收音频信号:即使连接建立,也无法正常接收音频回复
根本原因:useAudio.ts 的 onError 回调直接设置 setConnectionStatus('连接错误'),没有检查 isSwitchingCharacterRef。
角色切换流程中的问题点:
handleSwitchCharacter设置isSwitchingCharacterRef.current = trueapplyQrRaw更新 config.characterName- useAudio useEffect 检测到变化,销毁旧 AudioService
- 旧 WebSocket 关闭时可能触发
error事件 - onError 回调被触发,直接显示"连接错误"
而 onConnectionChange 在 main.tsx 中检查了 isSwitchingCharacterRef,所以能正确忽略。
但 onError 没有检查,导致问题。
即使添加了 isSwitchingRef 检查,仍然会出现错误,原因:
// handleSwitchCharacter 的 finally 块
finally {
isSwitchingCharacterRef.current = false; // ← 立即重置
}问题流程:
- 切换完成,显示 Alert
- finally 立即执行,
isSwitchingRef = false - 用户看到 Alert
- 此时旧连接的延迟 error 事件到达
onError检查isSwitchingRef→ 已经是false- 显示"连接错误"
日志显示:
LOG ✅ start_session 已调用 ← 发送消息(此时还没连接!)
ERROR WebSocket未连接,无法发送消息 ← 失败
LOG WebSocket连接已建立 ← 之后才连接成功
根本原因:useAudio 清理函数中没有重置 isReadyRef.current = false:
// ❌ 错误的清理函数
return () => {
audioServiceRef.current?.destroy();
audioServiceRef.current = null;
// 缺少:isReadyRef.current = false;
};问题流程:
- 旧 AudioService 已就绪,
isReadyRef.current = true - 角色切换,清理函数执行,销毁旧 AudioService
- 但
isReadyRef.current还是true - 新 AudioService 开始初始化(异步)
waitForConnection检查isReadyRef.current→true(旧值)- 立即返回,发送
start_session - 此时新 WebSocket 还没连接,发送失败
修改文件 1: hooks/useAudio.ts
interface UseAudioConfig {
// ... 其他属性
// 🔥 新增:角色切换标志 ref,用于在切换期间忽略错误
isSwitchingRef?: React.RefObject<boolean>;
}
// 在 AudioService 配置中:
onError: (error) => {
// 🔥 修复:在角色切换期间忽略错误,避免显示"连接错误"
if (config.isSwitchingRef?.current) {
console.log('🔄 角色切换中,忽略 WebSocket 错误:', error);
return;
}
console.error('❌ 音频服务错误:', error);
setConnectionStatus('连接错误');
},修改文件 2: app/(tabs)/main.tsx
const audio = useAudio({
host: config.host,
port: config.port,
characterName: config.characterName,
// 🔥 传入角色切换标志,用于在切换期间忽略错误
isSwitchingRef: isSwitchingCharacterRef,
// ... 其他配置
});
// handleSwitchCharacter 的 finally 块
finally {
// 🔥 修复:延迟重置角色切换标志,给旧连接足够时间清理
// 旧 WebSocket 关闭时可能会延迟触发 error 事件,需要延迟重置标志
setTimeout(() => {
isSwitchingCharacterRef.current = false;
console.log('🔄 角色切换标志已重置');
}, 2000); // 延迟 2 秒
setCharacterLoading(false);
}修改文件 3: hooks/useAudio.ts - 清理函数重置 isReadyRef
// 清理函数
return () => {
console.log('🧹 useAudio 清理中...');
audioServiceRef.current?.destroy();
audioServiceRef.current = null;
setIsRecording(false);
setIsConnected(false);
// 🔥 修复:清理时重置 isReadyRef,避免 waitForConnection 误判
isReadyRef.current = false;
};经过代码分析,确认消息流转机制:
-
realtime client 的事件派发(
client.ts:169-186):message事件 → WSService.onMessage → main.tsxjson事件 → audioServiceNative.handleIncomingJsonbinary事件 → audioServiceNative.handleIncomingBinary- 三个事件是独立派发的,不会互相拦截
-
audioServiceNative 的 handleIncomingJson:
session_started的return只跳过内部处理- 不会阻止 WSService 的
onMessage收到消息
- 服务端问题:角色切换后,服务端可能没有正确处理
audio_format参数,不返回 PCM 数据 - binary 监听器未正确工作:新实例的 attach() 可能有问题
- manualInterruptActive 状态异常:理论上新实例应该是 false
在 audioServiceNative.ts 的 handleIncomingBinary 添加日志:
const handleIncomingBinary = (data: unknown) => {
// 🔍 调试:确认是否收到 binary 数据
console.log('🎵 audioServiceNative 收到 binary:', {
type: typeof data,
isArrayBuffer: data instanceof ArrayBuffer,
isUint8Array: data instanceof Uint8Array,
byteLength: (data as any)?.byteLength,
skipNextBinary: interrupt.getSkipNextBinary(),
manualInterruptActive,
});
if (interrupt.getSkipNextBinary()) {
console.log('⚠️ binary 被 skipNextBinary 拦截');
return;
}
if (manualInterruptActive) {
console.log('⚠️ binary 被 manualInterruptActive 拦截');
return;
}
// ...
};切换角色后发送文本消息,观察日志:
- 如果没有
🎵 audioServiceNative 收到 binary→ 服务端没有返回 PCM - 如果有日志但
⚠️ binary 被拦截→ 检查拦截原因 - 如果有日志且数据正常 → 检查 PCMStream.playPCMChunk 是否成功
问题代码:
const connectionStatus: ConnectionStatus = audio.isConnected ? 'open' : 'closed';原因:
audio.isConnected来自 React state,在onConnectionChange回调中更新- 角色切换时,
useAudiohook 的依赖项(characterName)改变,触发 useEffect 清理和重新初始化 - 清理阶段
audio.isConnected短暂变为false,导致状态指示器显示红色
问题代码:
// 在 waitForConnection 闭包中
const isReady = audio.audioService?.isReady() ?? false;原因:
waitForConnection是一个闭包,捕获的是旧的audio对象引用- 当
useAudiohook 重新初始化AudioService时,闭包中的audio.audioService仍然指向已销毁的旧实例 - 旧实例的
isReady()返回false(因为connectionStatus是disconnected) - 导致
waitForConnection超时,start_session消息从未发送
时间线:
T0: 用户点击切换角色
T1: applyQrRaw() 更新 config.characterName
T2: useAudio useEffect 清理 -> 销毁旧 AudioService
T3: useAudio useEffect 重新执行 -> 创建新 AudioService
T4: waitForConnection 开始轮询(闭包捕获的是 T0 时刻的 audio 对象)
T5: 新 AudioService 初始化完成
T6: waitForConnection 检查 audio.audioService?.isReady()
-> 访问的是 T0 时刻的旧实例(已销毁)
-> isReady() 返回 false
T7: 超时,start_session 未发送
修改文件: app/(tabs)/main.tsx
// 将 audio.connectionStatus 映射到 ConnectionStatus 类型
// 在角色切换期间,保持 'open' 状态,避免显示断开错误
const connectionStatus: ConnectionStatus = isSwitchingCharacterRef.current
? 'open'
: (audio.isConnected ? 'open' : 'closed');原理:
- 在角色切换期间(
isSwitchingCharacterRef.current = true),强制状态为'open' - 避免用户看到短暂的断开状态
修改文件: services/AudioService.ts
/**
* 是否已完全初始化
*/
isReady(): boolean {
return this.isInitialized && this.connectionStatus === ConnectionStatus.CONNECTED;
}原理:
- 同时检查
isInitialized和connectionStatus - 确保不仅 WebSocket 连接,而且 AudioService 内部初始化也完成
修改文件: hooks/useAudio.ts
export interface UseAudioReturn {
// ... 其他属性
// AudioService 是否完全就绪的 ref(避免闭包引用问题)
isReadyRef: React.RefObject<boolean>;
}
export const useAudio = (config: UseAudioConfig): UseAudioReturn => {
// 🔥 AudioService 是否完全就绪的 ref
const isReadyRef = useRef<boolean>(false);
useEffect(() => {
// 创建 AudioService
audioServiceRef.current = new AudioService({...});
// 初始化服务
audioServiceRef.current.init().catch(error => {
console.error('❌ AudioService 初始化失败:', error);
isReadyRef.current = false;
}).then(() => {
// 🔥 初始化完成后,更新 isReadyRef
if (audioServiceRef.current?.isReady()) {
console.log('✅ AudioService 已完全就绪,更新 isReadyRef');
isReadyRef.current = true;
}
});
return () => {
audioServiceRef.current?.destroy();
isReadyRef.current = false; // 清理时重置
};
}, [config.host, config.port, config.characterName]);
return {
// ... 其他属性
isReadyRef,
};
};修改文件: app/(tabs)/main.tsx
// 🔥 等待连接重建(分两阶段:等待断开 -> 等待重连 + AudioService 完全就绪)
const waitForConnection = (): Promise<boolean> => {
return new Promise((resolve) => {
const check = () => {
if (phase === 'waiting_connect') {
// 🔥 使用 isReadyRef 避免闭包引用问题
const isReady = audio.isReadyRef.current;
if (isReady) {
console.log('✅ 新连接已建立且 AudioService 已完全初始化');
resolve(true);
}
// ...
}
};
check();
});
};原理:
isReadyRef是 React ref,其.current属性始终指向最新值- 即使闭包捕获的是旧的
audio对象,audio.isReadyRef.current也会返回最新的就绪状态 - 这是因为 ref 本身是一个容器,引用不变,但
.current的值会更新
State 的问题:
// ❌ 错误示例:使用 state
const [isReady, setIsReady] = useState(false);
// 在闭包中
const check = () => {
if (isReady) { // <- 捕获的是闭包创建时的值
resolve(true);
}
};Ref 的优势:
// ✅ 正确:使用 ref
const isReadyRef = useRef(false);
// 在闭包中
const check = () => {
if (isReadyRef.current) { // <- 始终访问最新值
resolve(true);
}
};-
正常切换角色
- 点击角色管理
- 选择新角色
- 观察状态指示器(应保持绿色)
- 发送文本消息,验证能收到音频回复
-
快速连续切换
- 快速切换多个角色
- 验证每次切换后都能正常发送消息
-
网络不稳定情况
- 在弱网环境下切换角色
- 验证超时提示是否正常
成功切换时应该看到:
LOG 🧹 AudioService 销毁中...
LOG ✅ AudioService 已销毁
LOG 🎧 useAudio 初始化中... {"characterName": "sakura", ...}
LOG 🎧 AudioService 初始化中...
LOG WebSocket连接已建立: ws://...
LOG ✅ WebSocket 已连接
LOG ✅ 音频服务初始化完成
LOG ✅ AudioService 初始化完成
LOG ✅ AudioService 已完全就绪,更新 isReadyRef
LOG ✅ 新连接已建立且 AudioService 已完全初始化
LOG 📤 发送 start_session 以重新加载角色音色
LOG ✅ start_session 已调用
失败情况(超时):
LOG ❌ 等待连接超时 {"isConnected": true, "isReadyRef": false}
app/(tabs)/main.tsx- 主界面,角色切换逻辑hooks/useAudio.ts- Audio hook,管理 AudioService 生命周期services/AudioService.ts- Audio 服务,管理 WebSocket 和音频处理services/wsService.ts- WebSocket 服务
当前有多个状态源:
audio.isConnected(React state)isConnectedRef.current(ref)AudioService.isConnected()(方法)AudioService.isReady()(方法)
建议统一为一个状态管理方案,减少混淆。
- 添加加载动画
- 显示"正在切换角色..."提示
- 切换成功后显示确认消息
- 网络超时时提供重试按钮
- 记录切换失败次数,超过阈值后提示用户检查网络
- 提供手动重连机制
将 waitForConnection 提取为独立的 hook:
function useAudioServiceReady() {
const audio = useAudio();
const waitForReady = useCallback(() => {...}, []);
return { waitForReady };
}这个问题的核心是 React 闭包引用陷阱:
- 在异步回调/闭包中访问外部变量时,捕获的是创建时的值
- 使用 ref 可以避免这个问题,因为 ref 的
.current始终指向最新值
修复后,角色切换流程更加可靠:
- ✅ 不再显示错误的断开状态
- ✅ 确保 AudioService 完全初始化后才发送消息
- ✅ 使用 ref 避免闭包引用问题
handleSwitchCharacter 中,切换标志 isSwitchingCharacterRef.current 是在收到服务端广播 catgirl_switched 后才置 true(由 onMessage 处理)。这意味着:从 API 请求成功 → 收到广播之间的时间窗口内,断线错误仍然不会被屏蔽,用户会短暂看到误报的"连接错误"。
API 成功后立即置位,不等服务端广播:
if (res.success) {
setCharacterModalVisible(false);
isSwitchingCharacterRef.current = true; // 立即置位,屏蔽后续断线错误
// ...设置超时保护 timer
}超时保护 timer(15 秒)负责在未收到切换完成事件时自动重置标志,保证不会永久屏蔽。
完整修复记录见:code-review-2026-03-06.md