项目介绍
因公司要求,接到了一个需求是实现AI对话形式的App,因为公司android开发只有一个,所以我就使用react-native来进行前端开发,要求根据返回流快速返回回答
前期实现
一开始为了保证开发速度,同时axios无法流式返回数据,所以我直接使用的是获取到所有的答案字符串,然后通过setInterval来实现打印效果
因为后端使用流进行返回,所以我第一时间想到了用axios来进行返回流,我第一时间想到了使用responseType为stream的形式来返回,但是在实际开发中遇到了问题,第一个问题就是他其实并没有生效,仍然是在等待一段时间才进行返回,开发代码为以下内容
ajax({
url,
type: 'post',
data: {
inputs: {},
query: question,
response_mode: 'streaming',
conversation_id: conversationIdRef.current,
user: 'abc-123',
files: [],
},
responseType: 'stream',
}).then(response => {
console.log(response);
});
图中可见,他是等待了一段时间之后才一次性返回所有结果,并没有按照流的形式来进行返回的,同时debug还返回了一个警告,这个警告很明确的告诉我
意思就是stream并不是一个有效的参数,我很纳闷,然后去搜索相关材料,才发现
在 React Native 中,
axios
不支持 responseType: 'stream'
,你可以使用 blob
或直接使用 fetch
API 来处理流数据。
好,有问题就去解决,然后我就改为blob去实现,但是仍然没有出现我意料中的效果,仍然是等待一会才返回,我心想blob不能解决,我就去使用fetch解决,信以为应该解决了吧,但是意料之外,他仍然没有解决,仍然是等一会才返回结果,我就去网上搜索如何在react-native中实现流式返回
终于在一个视频中找到了这个问题的答案:
React Native 里提供了一个 Fetch API,看起来跟网页里差不多,但实际上,其内在是不同的。所以我们想在 React Native 里实现一般的流式传输是行不通的。但是为了能够同时兼容网页端和移动端,我们又不希望后端太复杂,所以使用 SSE 可能是最好的选择。
这是上述我搜索后的答案,React-native底层居然不能使用fetch进行流式传输!!!
地址放上面了,好,有解决办法就去做,终于在我的不懈努力下,发现react-native-sse
这个仓库是可以实现流式传输的!!
import EventSource from 'react-native-sse';
const es = new EventSource(url, {
headers: header,
body: data,
method: 'post',
});
es.addEventListener('open', event => {
console.log('Open SSE connection.');
});
es.addEventListener('message', event => {
console.log(JSON.parse(event.data));
});
es.addEventListener('error', event => {
if (event.type === 'error') {
console.error('Connection error:', event.message);
} else if (event.type === 'exception') {
console.error('Error:', event.message, event.error);
}
});
es.addEventListener('close', event => {
console.log('Close SSE connection.');
});
由此可见,上述返回的是一个个流形式的对象,拿到对象就简单了,首先确认event:message
才是我们需要的东西
实现方案
首先捋清思路。
- 首先使用sse来进行发起流式传输
- 我们需要动态的接收流,并将其内容组装起来
- 需要将已经组装起来的数据,通过打印机的形式动态展示,通过锁来判断当前是否展示完成
- 需要将后续组装的数据保留,并形成一个队列
- 前面输出动画结束之后,去队列中拿到新的数据进行渲染
- 查看队列中有没有未渲染的数据
- 处理后续结尾工作
思路清楚,开始实现!
使用sse发起流式传输
const es = new EventSource(url, {
headers: header,
body: data,
method: 'post',
});
es.addEventListener('open', event => {
console.log('Open SSE connection.');
});
es.addEventListener('message', event => {
handleStreamData(JSON.parse(event.data), question, pushAnswer);
if (JSON.parse(event.data).event == 'workflow_finished') {
pushAnswer('', true);
es.removeAllEventListeners();
es.close();
}
});
es.addEventListener('error', event => {
if (event.type === 'error') {
console.error('Connection error:', event.message);
} else if (event.type === 'exception') {
console.error('Error:', event.message, event.error);
}
});
es.addEventListener('close', event => {
console.log('Close SSE connection.');
});
处理到接收流的数据
const handleStreamData = (buffer, question, pushAnswer) => {
if (buffer.event == 'message_end') {
}
if (buffer.event === 'workflow_started') {
if (buffer.conversation_id && conversationIdRef.current == '') {
conversationIdRef.current = buffer.conversation_id;
// 拿到第一个问题作为对话答案
changeChatName(question, buffer.conversation_id);
}
if (!messageId.current || messageId.current !== buffer.message_id) {
messageId.current = buffer.message_id;
if (appAuth?.suggested_questions_after_answer?.enabled) {
//根据权限值 判断是否去拿到建议回答
getSuggestion().then(res => {
suggestion.current = res;
});
}
}
// 打开锁
setIsLock(false);
}
if (buffer.event == 'message') {
try {
// 推入回答
pushAnswer(buffer.answer);
} catch (e) {
console.log(e);
}
}
};
通过闭包的原理,形成一个储水池,保证打印机不会中断太长时间
const reserveAnswer = () => {
let str = '';
const _allList = [];
return function (answer, isEnd = false) {
str += `${answer ?? ''}`;
// 蓄洪 放水
if (str.length > 30 || isEnd) {
_allList.push(str);
str = '';
chatAllList.current = _allList;
setDisplayList([...chatAllList.current]);
}
};
};
const pushAnswer = reserveAnswer()
开始编写打印机效果,我是使用setInterval来保证正常运行
const startTotext = allStr => {
if (!allStr) return;
return new Promise((resolve, reject) => {
let startIndex = 0;
clearInterval2Ref.current = setInterval(() => {
scrollViewRef.current.scrollToEnd({animated: true});
}, 200);
clearIntervalRef.current = setInterval(() => {
if (startIndex <= allStr.length) {
let endIndex = startIndex + 1;
if (endIndex <= allStr.length) {
let anotherString = allStr.substring(startIndex, endIndex);
setDisplayText(
prevText => (prevText ?? '') + (anotherString ?? ''),
);
startIndex += 1;
} else {
resolve();
}
}
}, 20);
});
};
如果有锁或者队列中有数据,替换队列中的数据
async function fetchData(arr) {
setIsLock(true);
if (arr.length > 0 && arr.length > 0) {
for (let i = currentIndex.current; i < arr.length; i++) {
const element = arr[i];
await startTotext(element);
clearInterval(clearIntervalRef.current);
clearInterval(clearInterval2Ref.current);
// 使用currentIndex来保证上次遍历的位置
currentIndex.current = currentIndex.current + 1;
}
}
setIsLock(false);
}
useEffect(() => {
if (!chatAllList.current.length) return;
if (currentIndex.current === -1) {
chatListRef.current = [
...chatListRef.current,
{
type: 'ai',
text: '',
},
];
setChatList(chatListRef.current);
currentIndex.current = 0;
}
// 有锁或者仍有未遍历的数据,仍将新数据放到未遍历的数据
if (!isLock && !lastChatList.length) {
fetchData(displayList);
} else {
setLastChatList(displayList);
}
}, [displayList]);
锁一旦打开去队列中进行查询,是否有数据,如果有则渲染新的队列数据,如果没有则进行后续的清除工作
useUpdateEffect(() => {
if (!isLock) {
if (lastChatList.length) {
fetchData(lastChatList);
setLastChatList([]);
} else {
if (
displayText &&
chatAllList.current.length === displayUseText.length
) {
stopRender(displayText);
}
}
}
}, [isLock]);
因为害怕前司找茬,自己取消掉了效果图的演示,只能说确实加快了很多速度,同比没优化前,大概在前方案的一半时间,该方案就开始输出内容
因React-native的特殊性,导致在开发中遇到了很多岔路,查询了很多资料,中间也踩了很多的坑,虽然可能使用React-native开发App的并不多,但是最起码能为大家提供一个思路。