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

相关推荐
用户14536981458781 小时前
VersionCheck.js - 让前端版本更新变得简单优雅
前端·javascript
米饭同学i1 小时前
微信小程序实现随机撒花效果
前端
Arthur14726122865471 小时前
模块化和组件化的区别
前端
codingWhat1 小时前
整理「祖传」代码,就是在开发脚手架?
前端·javascript·node.js
臣妾没空2 小时前
里程碑5:完成框架npm包抽象封装并发布
前端·npm
Wect2 小时前
LeetCode 210. 课程表 II 题解:Kahn算法+DFS 双解法精讲
前端·算法·typescript
cxxcode2 小时前
搞懂 JS 异步的底层真相:从 V8 源码看微任务与宏任务
前端
欧阳的棉花糖2 小时前
React 小误区:派生值 vs useEffect
前端
马可菠萝2 小时前
从零开始,用 Tauri + Vue 3 打造轻量级桌面应用
前端