Flutter 实现 AI 聊天页面 —— 记一次 Markdown 数学公式显示的踩坑之旅

Flutter 实现 AI 聊天页面 ------ 记一次 Markdown 数学公式显示的踩坑之旅

最近在做一个 Flutter 智能体聊天组件库,AI 返回的内容里如果经常夹着数学公式,结果页面上全是 $E=mc^2$ 这种原始字符串,如果是比较复杂的数学公式完全看不懂写的是啥, 完全渲染不出来正确的公式格式。折腾了一天,终于找到了一套比较完整的解决方案,记录一下。


先说说背景

项目是一个 Flutter 插件库,对外暴露两个组件:

  • AiChatPage:独立聊天页面,直接 push 进去就能用
  • AiChatWidget:可嵌入任意页面的聊天 Widget

AI 的回复走流式输出(SSE),内容是 Markdown 格式。用 flutter_markdown 渲染普通内容没问题,但一碰到公式就原样显示了,因为标准 Markdown 压根不认识 $...$ 这个语法。


问题在哪

flutter_markdown 底层依赖 markdown 包做解析。整个渲染流程分两步:

  1. 解析:把 Markdown 文本解析成 AST 节点树
  2. 渲染:遍历 AST,把每个节点转成 Flutter Widget

公式渲染挂的点在第一步------markdown 包不认识 $...$,直接把它当普通文本处理了,后面的渲染器根本没有机会介入。

所以光加一个渲染器是不够的,解析层和渲染层都要动


解法:在解析层注入自定义语法

markdown 包提供了 InlineSyntax 接口,可以用正则表达式匹配任意行内语法,命中后生成自定义 AST 节点。

我写了两个解析器:

dart 复制代码
/// 行内公式:$...$
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'] 把"行内/块级"信息带到后续渲染阶段,不然渲染器不知道该怎么处理。

然后把这两个解析器注册进去:

dart 复制代码
extensionSet: md.ExtensionSet(
  md.ExtensionSet.gitHubFlavored.blockSyntaxes,
  <md.InlineSyntax>[
    ...md.ExtensionSet.gitHubFlavored.inlineSyntaxes,
    InlineMathSyntax(),
    BlockMathSyntax(),
  ],
),

解法:在渲染层用 flutter_math_fork 渲染

解析层把公式内容提取成 math 标签节点了,接下来写一个 MarkdownElementBuilder 来消费它:

dart 复制代码
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'),
      );
    }
  }
}

最后把渲染器挂上:

dart 复制代码
builders: {
  'code': OptimizedCodeElementBuilder(theme: theme),
  'math': MathElementBuilder(theme: theme),
},

又踩了一个坑:表格里的行内公式会溢出

以为公式能显示就搞定了,结果发现公式出现在表格单元格里时,因为父容器宽度受限,长公式直接溢出报 overflow 错误。

最开始想直接给所有行内公式套一个 SingleChildScrollView,但这样又会影响在普通段落里的布局。

后来用 LayoutBuilder 判断了一下:

dart 复制代码
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 缓存起来:

dart 复制代码
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,流式更新时只重绘变化部分,不会连带整个消息列表重绘。


最终用法

依赖这几个包:

yaml 复制代码
dependencies:
  flutter_markdown: ^0.7.x
  markdown: ^7.x
  flutter_math_fork: ^0.7.x
  flutter_highlight: ^0.7.x

使用的时候配置好 ChatConfig 和主题,传入控制器就行:

dart 复制代码
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 助手',
  ),
));

总结

整个问题其实不复杂,捋清楚之后就是两步:

  1. 解析层 :继承 InlineSyntax,用正则把 $...$$$...$$ 识别出来,转成自定义 AST 节点
  2. 渲染层 :继承 MarkdownElementBuilder,用 flutter_math_fork 把节点渲染成 Widget

额外需要注意的是:

  • 块级公式要加横向滚动,防止溢出
  • 行内公式在有宽度限制的容器里也要加滚动(用 LayoutBuilder 判断)
  • 流式场景下务必加缓存,不然会卡
  • 公式解析失败要有降级处理,不能白屏

flutter_markdown 的扩展机制其实相当灵活,这套解析器 + 渲染器的模式可以推广到任何自定义 Markdown 元素,不只是数学公式。

相关推荐
ywf12151 小时前
前端的dist包放到后端springboot项目下一起打包
前端·spring boot·后端
恋猫de小郭1 小时前
2026,Android Compose 终于支持 Hot Reload 了,但是收费
android·前端·flutter
hpoenixf7 小时前
2026 年前端面试问什么
前端·面试
还是大剑师兰特7 小时前
Vue3 中的 defineExpose 完全指南
前端·javascript·vue.js
泯泷7 小时前
阶段一:从 0 看懂 JSVMP 架构,先在脑子里搭出一台最小 JSVM
前端·javascript·架构
mengchanmian8 小时前
前端node常用配置
前端
华洛8 小时前
利好打工人,openclaw不是企业提效工具,而是个人助理
前端·javascript·产品经理
xkxnq8 小时前
第六阶段:Vue生态高级整合与优化(第93天)Element Plus进阶:自定义主题(变量覆盖)+ 全局配置与组件按需加载优化
前端·javascript·vue.js
A黄俊辉A9 小时前
vue css中 :global的使用
前端·javascript·vue.js
小码哥_常10 小时前
被EdgeToEdge适配折磨疯了,谁懂!
前端