一、 从简单的文本说起:为什么需要富文本?

想象一下,我们要在App里展示一段文字,其中某些词要加粗、变色,甚至能点击跳转,或者在里面插入一个小图标或图片。如果你只用Flutter里最基本的Text组件,可能会发现它有点“力不从心”。它擅长处理样式统一的文字,但一旦遇到这种“混搭”风格,就麻烦了。

这就是富文本(Rich Text)登场的时候。在Flutter中,核心的解决方案是Text.rich构造函数配合TextSpan。你可以把TextSpan想象成一串珍珠项链,每一颗珍珠(一个TextSpan)都可以有自己的颜色、大小、字体,甚至可以是另一串更小的珍珠(嵌套的TextSpan)。这样,你就能自由组合出复杂的文字效果了。

下面我们来看一个简单的例子,感受一下它的基础用法。

// 技术栈:Flutter/Dart
import 'package:flutter/material.dart';

class SimpleRichTextDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('基础富文本示例')),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Text.rich(
          TextSpan(
            // 第一个文本片段:普通样式
            text: '欢迎来到',
            style: TextStyle(fontSize: 18.0, color: Colors.grey[700]),
            children: <InlineSpan>[
              // 第二个文本片段:加粗、变色
              TextSpan(
                text: ' Flutter ',
                style: TextStyle(
                  fontSize: 24.0,
                  color: Colors.blue,
                  fontWeight: FontWeight.bold,
                ),
              ),
              // 第三个文本片段:带下划线,可点击
              TextSpan(
                text: '世界!',
                style: TextStyle(
                  fontSize: 18.0,
                  color: Colors.green,
                  decoration: TextDecoration.underline,
                ),
                // 点击事件
                recognizer: TapGestureRecognizer()
                  ..onTap = () {
                    print('你点击了“世界!”');
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text('欢迎来到Flutter世界!')),
                    );
                  },
              ),
              // 第四个文本片段:斜体
              TextSpan(
                text: ' 让我们一起探索。',
                style: TextStyle(
                  fontSize: 16.0,
                  fontStyle: FontStyle.italic,
                  color: Colors.orange,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

通过这个例子,你应该能直观地看到,我们轻松地在一行文字里混合了四种不同的样式,并且其中一个还能响应点击。这就是富文本处理简单图文混排的基石。

二、 当富文本遇到图片:WidgetSpan的魔法

但是,如果我们想在文字流中插入一个更复杂的“东西”,比如一个图标、一张网络图片,甚至是一个自定义的按钮组件,单纯的TextSpan就办不到了。这时,我们需要请出WidgetSpan

WidgetSpanInlineSpan家族的另一员大将,它允许你将任何Flutter Widget嵌入到文本流中。这就像在珍珠项链里,嵌入了一颗钻石或者一块精美的吊坠。文本的排版布局会自动为这个Widget留出空间,让它与周围的文字完美对齐。

让我们看一个更贴近实际需求的例子:在商品描述中嵌入表情图标和自定义标签。

// 技术栈:Flutter/Dart
import 'package:flutter/material.dart';

class WidgetSpanDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('嵌入Widget的富文本')),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Text.rich(
          TextSpan(
            text: '这款商品真是太',
            style: TextStyle(fontSize: 18),
            children: <InlineSpan>[
              // 嵌入一个图标Widget
              WidgetSpan(
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 4.0),
                  child: Icon(Icons.favorite, color: Colors.red, size: 20),
                ),
                // alignment属性控制Widget与文本基线的对齐方式
                alignment: PlaceholderAlignment.middle,
              ),
              TextSpan(text: '了!'),
              TextSpan(text: '\n\n限时特价:'),
              // 嵌入一个自定义的“折扣标签”Widget
              WidgetSpan(
                child: Container(
                  margin: EdgeInsets.symmetric(horizontal: 6.0),
                  padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
                  decoration: BoxDecoration(
                    color: Colors.redAccent,
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: Text(
                    '5折',
                    style: TextStyle(color: Colors.white, fontSize: 12),
                  ),
                ),
                alignment: PlaceholderAlignment.middle,
              ),
              TextSpan(
                text: ' 仅售99元',
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.bold,
                  color: Colors.red,
                ),
              ),
              TextSpan(text: '\n\n'),
              // 嵌入一张网络图片
              WidgetSpan(
                child: Padding(
                  padding: const EdgeInsets.only(right: 8.0),
                  child: Image.network(
                    'https://via.placeholder.com/30x30/4CAF50/FFFFFF?text=✓',
                    width: 30,
                    height: 30,
                  ),
                ),
                alignment: PlaceholderAlignment.middle,
              ),
              TextSpan(text: '支持快速配送'),
            ],
          ),
        ),
      ),
    );
  }
}

现在,图文混排的能力大大增强了。不过,Text.rich配合WidgetSpan虽然强大,但在处理超大量、动态变化极快的复杂混排内容时(比如一个高度可交互的文档编辑器),其布局计算和渲染性能可能会成为瓶颈。而且,它对于每一帧的绘制控制力是有限的。这时,我们就需要更底层的武器。

三、 深入底层:CustomPainter与自定义渲染

当你需要极致的性能,或者要实现一些用常规Widget组合无法实现的效果时(比如特殊文字效果、复杂的图表、游戏元素等),Flutter提供了CustomPainter这个终极法宝。它允许你直接向画布(Canvas)上绘制任何你想要的内容。

你可以把它理解为自己拿起画笔(Paint对象),在指定的画布区域(由Canvas对象提供)进行自由创作。我们通过继承CustomPainter类并实现paintshouldRepaint方法来完成自定义绘制。

为了将自定义绘制与文本布局结合起来,我们需要用到TextPainterTextPainter是一个强大的文本布局引擎,它可以先对富文本(TextSpan树)进行“离线”布局计算,告诉你每个字符、每个组件的位置,然后你再根据这些位置信息,用CustomPainter进行精确绘制。

下面,我们实现一个更高级的案例:绘制一个带有自定义背景高亮和连接线的文字标注效果。

// 技术栈:Flutter/Dart
import 'package:flutter/material.dart';

class CustomTextPainterDemo extends StatefulWidget {
  @override
  _CustomTextPainterDemoState createState() => _CustomTextPainterDemoState();
}

class _CustomTextPainterDemoState extends State<CustomTextPainterDemo> {
  // 用于获取渲染后文本尺寸和位置的全局Key
  final GlobalKey _textKey = GlobalKey();
  // 存储注释文本的位置信息
  List<Rect> _annotationRects = [];

  @override
  void initState() {
    super.initState();
    // 在下一帧布局完成后,计算位置
    WidgetsBinding.instance.addPostFrameCallback((_) => _calculatePositions());
  }

  // 这个方法计算每个“注释”文本在屏幕上的具体矩形区域
  void _calculatePositions() {
    final RenderBox textBox = _textKey.currentContext?.findRenderObject() as RenderBox;
    if (textBox == null) return;

    final TextPainter textPainter = TextPainter(
      textDirection: TextDirection.ltr,
    );

    // 构建与UI中完全相同的TextSpan树
    final textSpan = _buildTextSpan();
    textPainter.text = textSpan;
    // 进行布局,指定最大宽度
    textPainter.layout(maxWidth: textBox.size.width);

    _annotationRects.clear();
    // 遍历所有TextSpan,找出我们标记为“注释”的部分
    textSpan.visitChildren((span) {
      if (span is TextSpan && span.style?.backgroundColor != null) {
        // 获取这个span在文本中的起始和结束偏移量(这里需要根据实际情况计算,示例简化处理)
        // 在实际项目中,你需要更精确地管理每个span的偏移量,这里仅为演示逻辑。
        // 假设我们手动标记了位置,这里模拟获取两个矩形区域。
        final offset1 = textPainter.getOffsetForCaret(TextPosition(offset: 10), Rect.zero);
        final offset2 = textPainter.getOffsetForCaret(TextPosition(offset: 30), Rect.zero);
        // 将相对于textPainter的坐标转换为相对于屏幕的坐标
        final localRect1 = Rect.fromLTWH(offset1.dx, offset1.dy, 60, textPainter.preferredLineHeight);
        final globalRect1 = textBox.localToGlobal(localRect1.topLeft) & localRect1.size;
        final localRect2 = Rect.fromLTWH(offset2.dx, offset2.dy, 80, textPainter.preferredLineHeight);
        final globalRect2 = textBox.localToGlobal(localRect2.topLeft) & localRect2.size;

        _annotationRects.addAll([globalRect1, globalRect2]);
      }
      return true; // 继续遍历
    });

    setState(() {}); // 触发重绘,让CustomPainter使用新的位置信息
  }

  // 构建富文本内容
  TextSpan _buildTextSpan() {
    return TextSpan(
      style: TextStyle(fontSize: 20, color: Colors.black87),
      children: [
        TextSpan(text: '在软件开发中,'),
        TextSpan(
          text: '状态管理',
          style: TextStyle(backgroundColor: Colors.yellow.withOpacity(0.5)),
        ),
        TextSpan(text: '是一个核心概念。而'),
        TextSpan(
          text: '响应式编程',
          style: TextStyle(backgroundColor: Colors.yellow.withOpacity(0.5)),
        ),
        TextSpan(text: '范式让状态管理变得更加清晰和可预测。'),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('自定义绘制与文本标注')),
      body: Stack(
        children: [
          // 底层:用于布局和显示文本的Widget
          Padding(
            key: _textKey, // 赋予Key以便获取位置
            padding: const EdgeInsets.all(30.0),
            child: Text.rich(_buildTextSpan()),
          ),
          // 上层:自定义绘制层,绘制连接线和注释框
          if (_annotationRects.isNotEmpty)
            CustomPaint(
              painter: _AnnotationPainter(_annotationRects),
              size: Size.infinite,
            ),
        ],
      ),
    );
  }
}

// 自定义绘制器
class _AnnotationPainter extends CustomPainter {
  final List<Rect> annotationRects;

  _AnnotationPainter(this.annotationRects);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.blue.withOpacity(0.7)
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;

    final textPaint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.fill;
    final textStyle = TextStyle(color: Colors.white, fontSize: 12);

    for (int i = 0; i < annotationRects.length; i++) {
      final rect = annotationRects[i];
      // 1. 绘制从文本到侧边注释的连接线
      final startPoint = Offset(rect.right, rect.center.dy);
      final endPoint = Offset(size.width - 50, 100 + i * 60); // 注释框位置
      canvas.drawLine(startPoint, endPoint, paint);

      // 2. 绘制注释框
      final annotationRect = Rect.fromLTWH(endPoint.dx - 40, endPoint.dy - 15, 80, 30);
      canvas.drawRRect(
        RRect.fromRectAndRadius(annotationRect, Radius.circular(6)),
        textPaint,
      );

      // 3. 在注释框内绘制文字(简化版,实际应用需用TextPainter精确绘制文字)
      // 这里仅示意,绘制一个圆点代替
      canvas.drawCircle(endPoint, 4, Paint()..color = Colors.red);
    }
  }

  @override
  bool shouldRepaint(covariant _AnnotationPainter oldDelegate) {
    return oldDelegate.annotationRects != annotationRects;
  }
}

这个例子虽然复杂,但它展示了将文本布局计算(TextPainter)与自由绘制(CustomPainter)结合的强大能力。你可以先让Flutter帮你算好文字怎么排、排在哪,然后在这些精确坐标的基础上,绘制任何天马行空的图形和效果。

四、 实战场景与选择指南

应用场景:

  1. 社交应用: 评论、帖子、聊天消息中的@用户、话题标签、表情包混排。
  2. 内容阅读: 电子书、新闻App,需要处理标题、加粗、斜体、脚注、插图混排。
  3. 商品展示: 商品标题和描述中嵌入价格标签、促销图标、物流标志等。
  4. 文档编辑器: 实现类似Word的复杂排版,包括表格、图片、公式嵌入。
  5. 个性化UI: 实现文字渐变、描边、背景图案、动态特效等纯Widget难以实现的效果。

技术优缺点:

  • 富文本 (Text.rich + TextSpan/WidgetSpan):
    • 优点: 使用简单、开发效率高,完全遵循Flutter的Widget响应式范式,自动处理布局、交互、无障碍功能。
    • 缺点: 性能在极端复杂场景下可能不足,绘制灵活性受限于Widget体系。
  • 自定义渲染 (CustomPainter + TextPainter):
    • 优点: 性能极高,绘制控制力极强,可以实现任何视觉特效,不依赖Widget树重建。
    • 缺点: 开发复杂度陡增,需要手动处理布局、点击检测、文本选择、无障碍支持等,维护成本高。

注意事项:

  1. 性能优先: 绝大多数情况下,优先使用Text.richWidgetSpan。只有在其性能无法满足需求,或无法实现特定效果时,才考虑CustomPainter
  2. 点击处理: 使用CustomPainter时,如需交互,必须结合GestureDetector并自行通过坐标计算来判断点击区域,这非常繁琐。
  3. 文本选择: CustomPainter绘制的文本默认无法被用户选中复制。如果需要此功能,需要模拟实现一整套文本选择逻辑,工作量巨大。
  4. 无障碍: CustomPainter绘制的内容对屏幕阅读器是不可见的,必须通过Semantics组件额外描述其内容。
  5. 状态管理:CustomPainter中,shouldRepaint方法的实现至关重要,它决定了何时重绘,优化不当会导致不必要的性能开销。

总结: Flutter为我们提供了从高层到低层、从易用到极致灵活的一整套图文混排与渲染解决方案。Text.rich是我们的“瑞士军刀”,能解决80%以上的日常需求,便捷而强大。WidgetSpan则打开了在文本流中嵌入复杂UI的大门。当你遇到那20%的、对性能和定制性有极端要求的场景时,CustomPainter配合TextPainter就是你的“精密机床”,让你能深入到渲染引擎层面,亲手打造独一无二的效果。

理解这些工具的不同特性和适用边界,就能在面对复杂的图文渲染需求时,做出最合适的技术选型,在开发效率、维护成本和性能表现之间找到最佳平衡点。