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 元素,不只是数学公式。

相关推荐
pe7er2 小时前
window管理开发环境篇 - 持续更新
前端·后端
We་ct3 小时前
LeetCode 5. 最长回文子串:DP + 中心扩展
前端·javascript·算法·leetcode·typescript
陈随易7 小时前
有生之年系列,Nodejs进程管理pm2 v7.0发布
前端·后端·程序员
冰暮流星7 小时前
javascript之事件代理/事件委托
前端
陈随易8 小时前
AI时代,你还在坚持手搓文章吗
前端·后端·程序员
jiejiejiejie_9 小时前
Flutter for OpenHarmony 心情日记功能实战指南
flutter·华为
jiejiejiejie_10 小时前
Flutter for OpenHarmony 倒计时功能实战开发
flutter
Math_teacher_fan10 小时前
Flutter 跨平台开发实战:鸿蒙与音乐律动艺术(六)、Lissajous 利萨茹曲线:频率耦合的轨迹艺术
flutter·ui·数学建模·华为·harmonyos·鸿蒙系统
里欧跑得慢10 小时前
17. Flutter Hero动画实现:让界面过渡更加优雅
前端·css·flutter·web
IT_陈寒11 小时前
Vue的这个响应式陷阱,我debug了一整天才爬出来
前端·人工智能·后端