Flutter 实现 AI 聊天页面 —— 记一次 Markdown 数学公式显示的踩坑之旅
最近在做一个 Flutter 智能体聊天组件库,AI 返回的内容里如果经常夹着数学公式,结果页面上全是
$E=mc^2$这种原始字符串,如果是比较复杂的数学公式完全看不懂写的是啥, 完全渲染不出来正确的公式格式。折腾了一天,终于找到了一套比较完整的解决方案,记录一下。
先说说背景
项目是一个 Flutter 插件库,对外暴露两个组件:
AiChatPage:独立聊天页面,直接push进去就能用AiChatWidget:可嵌入任意页面的聊天 Widget
AI 的回复走流式输出(SSE),内容是 Markdown 格式。用 flutter_markdown 渲染普通内容没问题,但一碰到公式就原样显示了,因为标准 Markdown 压根不认识 $...$ 这个语法。
问题在哪
flutter_markdown 底层依赖 markdown 包做解析。整个渲染流程分两步:
- 解析:把 Markdown 文本解析成 AST 节点树
- 渲染:遍历 AST,把每个节点转成 Flutter Widget
公式渲染挂的点在第一步——markdown 包不认识 $...$,直接把它当普通文本处理了,后面的渲染器根本没有机会介入。
所以光加一个渲染器是不够的,解析层和渲染层都要动。
解法:在解析层注入自定义语法
markdown 包提供了 InlineSyntax 接口,可以用正则表达式匹配任意行内语法,命中后生成自定义 AST 节点。
我写了两个解析器:
/// 行内公式:$...$
class InlineMathSyntax extends md.InlineSyntax {
InlineMathSyntax() : super(r'\$([^\$]+)\$');
@override
bool onMatch(md.InlineParser parser, Match match) {
final element = md.Element.text('math', match[1]!);
element.attributes['inline'] = 'true';
parser.addNode(element);
return true;
}
}
/// 块级公式:$$...$$
class BlockMathSyntax extends md.InlineSyntax {
BlockMathSyntax() : super(r'\$\$([^\$]+)\$\$');
@override
bool onMatch(md.InlineParser parser, Match match) {
final element = md.Element.text('math', match[1]!);
element.attributes['inline'] = 'false';
parser.addNode(element);
return true;
}
}
这里有个小细节:用 element.attributes['inline'] 把"行内/块级"信息带到后续渲染阶段,不然渲染器不知道该怎么处理。
然后把这两个解析器注册进去:
extensionSet: md.ExtensionSet(
md.ExtensionSet.gitHubFlavored.blockSyntaxes,
<md.InlineSyntax>[
...md.ExtensionSet.gitHubFlavored.inlineSyntaxes,
InlineMathSyntax(),
BlockMathSyntax(),
],
),
解法:在渲染层用 flutter_math_fork 渲染
解析层把公式内容提取成 math 标签节点了,接下来写一个 MarkdownElementBuilder 来消费它:
class MathElementBuilder extends MarkdownElementBuilder {
final ChatTheme theme;
@override
Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) {
final mathContent = element.textContent;
final isInline = element.attributes['inline'] == 'true';
return _buildMathWidget(mathContent, isInline);
}
Widget _buildMathWidget(String mathContent, bool isInline) {
try {
if (isInline) {
return Math.tex(
mathContent,
mathStyle: MathStyle.text,
options: MathOptions(fontSize: theme.fontSize, color: theme.aiTextColor),
);
} else {
// 块级公式加水平滚动,防止长公式撑出屏幕
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Math.tex(
mathContent,
mathStyle: MathStyle.display,
options: MathOptions(fontSize: theme.fontSize + 2, color: theme.aiTextColor),
),
);
}
} catch (e) {
// 渲染失败就降级,把原始文本显示出来,总比白屏强
return Text(
isInline ? '\$$mathContent\$' : '\$\$$mathContent\$\$',
style: TextStyle(color: Colors.red, fontFamily: 'monospace'),
);
}
}
}
最后把渲染器挂上:
builders: {
'code': OptimizedCodeElementBuilder(theme: theme),
'math': MathElementBuilder(theme: theme),
},
又踩了一个坑:表格里的行内公式会溢出
以为公式能显示就搞定了,结果发现公式出现在表格单元格里时,因为父容器宽度受限,长公式直接溢出报 overflow 错误。
最开始想直接给所有行内公式套一个 SingleChildScrollView,但这样又会影响在普通段落里的布局。
后来用 LayoutBuilder 判断了一下:
class _InlineMathWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final mathWidget = Math.tex(mathContent, mathStyle: MathStyle.text, ...);
return LayoutBuilder(
builder: (context, constraints) {
// 父容器宽度有限(比如表格单元格)才加滚动
if (constraints.maxWidth.isFinite && constraints.maxWidth < double.infinity) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
child: mathWidget,
);
}
// 普通段落里直接渲染就好
return mathWidget;
},
);
}
}
这样既解决了溢出,又不影响正常场景的性能。
流式输出导致的性能问题
AI 是流式回复的,每来一个 chunk 就更新一次消息内容,MarkdownRenderer 会跟着重建。如果每次都重新渲染一遍所有公式,当内容稍微长一点,肉眼就能看到卡顿。
我加了一个基于 LinkedHashMap 实现的 LRU 缓存,把渲染好的公式 Widget 缓存起来:
class LRUCache<K, V> {
final int capacity;
final LinkedHashMap<K, V> _cache = LinkedHashMap();
V? operator [](K key) {
if (!_cache.containsKey(key)) return null;
final value = _cache.remove(key)!;
_cache[key] = value; // 移到末尾(最近使用)
return value;
}
void operator []=(K key, V value) {
if (_cache.containsKey(key)) _cache.remove(key);
else if (_cache.length >= capacity) _cache.remove(_cache.keys.first);
_cache[key] = value;
}
}
缓存 key 用 公式内容 hashCode + 是否行内 + 主题 hashCode 组合,数学公式缓存上限设 100 个,代码块 50 个,基本够用了。
另外每个公式都包了一层 RepaintBoundary,流式更新时只重绘变化部分,不会连带整个消息列表重绘。
最终用法
依赖这几个包:
dependencies:
flutter_markdown: ^0.7.x
markdown: ^7.x
flutter_math_fork: ^0.7.x
flutter_highlight: ^0.7.x
使用的时候配置好 ChatConfig 和主题,传入控制器就行:
final controller = ChatStreamController(
config: ChatConfig(
apiProviders: {'default': myApiService},
enableMarkdown: true,
),
);
// 嵌入页面
AiChatWidget(
controller: controller,
theme: ChatTheme.light(),
)
// 或者独立页面
Navigator.push(context, MaterialPageRoute(
builder: (_) => AiChatPage(
controller: controller,
theme: ChatTheme.light(),
title: 'AI 助手',
),
));
总结
整个问题其实不复杂,捋清楚之后就是两步:
- 解析层:继承
InlineSyntax,用正则把$...$和$$...$$识别出来,转成自定义 AST 节点 - 渲染层:继承
MarkdownElementBuilder,用flutter_math_fork把节点渲染成 Widget
额外需要注意的是:
- 块级公式要加横向滚动,防止溢出
- 行内公式在有宽度限制的容器里也要加滚动(用
LayoutBuilder判断) - 流式场景下务必加缓存,不然会卡
- 公式解析失败要有降级处理,不能白屏
flutter_markdown 的扩展机制其实相当灵活,这套解析器 + 渲染器的模式可以推广到任何自定义 Markdown 元素,不只是数学公式。