每个做过ChatGPT式Flutter应用的开发者都见过同一种崩溃体验:代码块在"无样式→有样式→无样式"之间疯狂闪烁,表格随着新单元格涌入不断抖动,滚动位置突然断裂,光标像在和UI打架一样乱跳。

问题根源只有一行代码,却在单次流式响应中被重复执行数千次:

打开网易新闻 查看精彩图片

StreamBuilder(
stream: openai.responseStream,
builder: (_, snap) => Markdown(data: snap.data ?? ''),
)

打开网易新闻 查看精彩图片

flutter_markdown确实履行了API承诺——接收完整字符串并渲染。但问题是每个新数据块都会产生新的data值,整个字符串被重新分词、重新解析、重新渲染。这种O(n²)的计算量在200字符的短响应中不可见;但在5KB的代码密集型回答中,它就是所有可见卡顿的元凶。

验证只需五分钟:将OpenAI补全流以chunk_size=1喂给flutter_markdown,看着语法高亮的代码块像癫痫发作一样频闪。

彻底修复需要三个改动同时落地——只改一两个毫无效果。

第一招:增量式分词器(仅追加)

不再对每个数据块重新分词整个缓冲区,而是让分词器的状态机跨块存活。新字符扩展尾部token;已输出的字符永不再访问。

块级分词器基于行且带状态——围栏代码块、列表、引用块和表格都需要知道"我们是否还在前一个结构内部?"行内分词器(强调、链接、代码跨度)是纯函数,在段落文本变化时从头重新运行即可。

打开网易新闻 查看精彩图片

第二招:仅追加的AST构建

解析器将token转为AST节点——但只修改尾部路径。已关闭的段落变为不可变。新段落节点被追加。列表持续增长条目,直到空行将其关闭。

关键设计:每个节点拥有单调递增的id,作为Flutter的Key。当新token到达时,只有尾部节点可能变化;其余节点的id和结构完全稳定。Flutter的差异化算法因此只需重建少量widget,而非整个树。

第三招:流式感知的widget层

标准Markdown widget假设数据完整。流式场景需要:未完成代码块显示灰色占位背景而非崩溃;表格在首行到达时即渲染表头,随数据流入逐步填充;滚动位置锚定到底部(或用户手动滚动后的位置)。

三招合一的效果:chunked输入场景下188倍提速,零可见闪烁。已打包为streamdown,pub.dev可直接引入,flutter_markdown的即插即用替代方案。