前言
代码案例基于Api13。
目前哪个行业最火,非AI莫属,deepseek发布之后,可以说,又把AI推上了一个新高度,在和AI进行询问会话的时候,我们可以发现,AI的回答都是以流式的效果进行展示的,也就是类似于打字机的效果,那么针对这种效果在实际的开发中是如何实现的呢?
具体的效果,根据业务情况而定,有两种模式,一种主动的流式输出,也就是数据以流式的形式进行返回,前端直接用组件加载即可,第二种就是刻意的流式展示,也就是在拿到数据之后,前端实现流式输出,进行打字机展示。
打字机的效果,一般都是在会话聊天之中,也就是列表之中,在实际的开发中,还要兼顾到,流式输出的数据加载是否会影响性能,页面闪烁,最新的聊天信息可展示等问题。
主动的流式输出
在一般的AI会话中,实现一个流式输出,一般会采用SSE或者WebSocket协议,像OpenAI官网,DeepSeek官网是采用SSE协议,当然,在实际的开发中,大家可以选择自己适用的技术即可。客户端发送问题之后,服务端检索到内容,就会时时的返回内容,具体是返回连接式的内容,还是逐字返回,需要和服务端进行定义。连接式返回,客户端只管加载,逐字返回,需要客户端拼接。
@Entry @Component struct Index { @State message?: string = "" intervalID?: number /** *AUTHOR:AbnerMing *INTRODUCE:模拟请求网络接口 */ doHttp(success: (message: string) => void) { let data = "具体的实现效果,根据业务情况而定,有两种模式,一种主动的流式输出,也就是数据以流式的形式进行返回,前端直接用组件加载即可,第二种就是刻意的流式展示,也就是在拿到数据之后,前端实现流式输出,进行打字机展示。" let position: number = 0 //模拟请求流式输出 this.intervalID = setInterval(() => { position = position + 2 let message = data.substring(0, position) if (success != undefined) { success(message) } if (message.length >= data.length) { clearInterval(this.intervalID) } }, 100) } build() { Column() { Button("加载") .margin({ top: 10 }) .onClick(() => { this.doHttp((message: string) => { this.message = message }) }) Text(this.message) .margin({ top: 20 }) .width("100%") }.width("100%") .height("100%") .padding(10) } }
我们可以看下演示的效果,实际的开发中,前端无须关注每次的返回字的长度。
被动的流式展示
所谓的被动,就是在已有数据的情况下,如何实现打字机的效果,这个比较的简单,无非是开启一个定时,以每隔多少时间,输出多少字为主,时间和输出字的长度都可以自己调节,简单案例如下,当然了,这种方式一般很少应用于实际的开发,不过在客户端有类似打字机效果的情况下可以使用。
@Entry @Component struct Index { @State message?: string = "" intervalID?: number build() { Column() { Button("加载") .margin({ top: 10 }) .onClick(() => { this.start() }) Text(this.message) .margin({ top: 20 }) .width("100%") }.width("100%") .height("100%") .padding(10) } private start() { let data = "具体的实现效果,根据业务情况而定,有两种模式,一种主动的流式输出,也就是数据以流式的形式进行返回,前端直接用组件加载即可,第二种就是刻意的流式展示,也就是在拿到数据之后,前端实现流式输出,进行打字机展示。" let position: number = 0 this.intervalID = setInterval(() => { position = position + 2 this.message = data.substring(0, position) if (this.message.length >= data.length) { clearInterval(this.intervalID) } console.log("======定时") }, 100) } }
我们看下输出效果,是不是有那种打字机的效果了,需要注意的是,定时关闭。
列表打印机效果
以上的效果都是以一个Text组件展示的情况,在实际的开发中,更多的是以左右会话形式,这时需要考虑的就多一点,比如会话定位在底部,流式展示时,不让列表闪烁等等问题,那么都是需要考虑的。
下面简单的实现一下聊天会话模式,所有的数据都是模拟的,UI简单的绘制了一下,在实际的开发中,肯定比较精细。
import { KeyboardAvoidMode } from '@kit.ArkUI' @Observed export class MessageBean { leftMessage?: string rightMessage?: string } @Component struct ChatView { @ObjectLink messageBean: MessageBean; build() { Column() { if (this.messageBean.rightMessage != undefined) { Row() { Text(this.messageBean.rightMessage) .margin({ right: 10 }) Image($r("app.media.startIcon")) .id("user_logo") .border({ color: Color.Red, width: 1, radius: 20 }) .width(20) .height(20) }.width("100%") .justifyContent(FlexAlign.End) .padding({ right: 10 }) } else { Row() { Image($r("app.media.startIcon")) .border({ color: Color.Red, width: 1, radius: 20 }) .width(20) .height(20) Text(this.messageBean.leftMessage) .margin({ left: 10 }) }.alignItems(VerticalAlign.Top) .padding({ right: 20 }) } } } } @Entry @Component struct Index { private scroller: Scroller = new Scroller() @State sendMessage: string = "" @State messageList: MessageBean[] = [] intervalID?: number private isEnd: boolean = true aboutToAppear(): void { this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE) } /** *AUTHOR:AbnerMing *INTRODUCE:模拟请求网络接口 */ doHttp(success: (message: string) => void) { let data = "具体的实现效果,根据业务情况而定,有两种模式,一种主动的流式输出,也就是数据以流式的形式进行返回,前端直接用组件加载即可,第二种就是刻意的流式展示,也就是在拿到数据之后,前端实现流式输出,进行打字机展示。" let position: number = 0 //模拟请求流式输出 this.intervalID = setInterval(() => { position = position + 2 let message = data.substring(0, position) if (success != undefined) { success(message) } if (message.length >= data.length) { this.isEnd = true //模拟结束 clearInterval(this.intervalID) } }, 100) } build() { RelativeContainer() { List({ space: 10, scroller: this.scroller }) { ForEach(this.messageList, (item: MessageBean) => { ListItem() { ChatView({ messageBean: item }) } }) }.margin({ top: 10 }) .alignRules({ top: { anchor: "__container__", align: VerticalAlign.Top }, bottom: { anchor: "layout_send", align: VerticalAlign.Top } }) RelativeContainer() { TextInput({ placeholder: "请输入问题" }) .onChange((text) => { this.sendMessage = text }) .alignRules({ left: { anchor: "__container__", align: HorizontalAlign.Start }, right: { anchor: "btn_send", align: HorizontalAlign.Start } }) Button("发送") .id("btn_send") .margin({ left: 10 }) .alignRules({ right: { anchor: "__container__", align: HorizontalAlign.End } }).onClick(() => { //发送 if (this.isEnd) { this.isEnd = false let bean = new MessageBean() bean.rightMessage = this.sendMessage this.messageList.push(bean) this.scroller.scrollEdge(Edge.Bottom) //模拟接口返回数据 let leftBean = new MessageBean() this.messageList.push(leftBean) this.doHttp((content) => { leftBean.leftMessage = content }) } }) } .height(40) .id("layout_send") .padding({ left: 10, right: 10 }) .backgroundColor(Color.White) .alignRules({ bottom: { anchor: "__container__", align: VerticalAlign.Bottom } }) }.width("100%") .height("100%") } }
运行之后,我们简单测试一下:
一般会话列表的形式,有一点需要注意,那就是历史记录。
相关总结
需要注意的是,内容一般都是以markdown的形式输出,也就是真实的数据中,内容都是有样式的,比如加粗,图片,表格等等,所以,不能以单一的Text组件进行展示,需要针对markdown文本适配。
打字机的效果,更多的是在服务端的数据输出,客户端,最主要的是针对数据的渲染。