Flutter 基础体系 · 第 42/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 文本与排版:TextSpan、字体、缩放、溢出和国际文本

Flutter 中的文本不是“把字符串画到屏幕上”这么简单。一个文本组件至少要经历以下阶段:

  1. 将字符串或 TextSpan 组织成文本树;
  2. 根据 TextStyle、字体文件和字体回退规则进行字形选择;
  3. 处理 Unicode 字符、组合字符、脚本塑形和双向文字;
  4. 在父组件给出的宽高约束内进行换行和布局;
  5. 根据缩放、最大行数和溢出策略决定最终绘制结果;
  6. 将可读内容暴露给语义树,并处理点击等交互。

因此,TextSpan、字体、缩放、溢出和国际化文本之间并不是彼此独立的属性。一个字体缺少某个字符,可能触发回退;回退字体的度量不同,可能改变换行;换行变化又可能导致 maxLines 截断;用户字体缩放还会进一步放大这些差异。


一、Flutter 文本的基本模型

1. Text 是高层组件,TextSpan 是文本结构

最简单的文本使用 Text

Text(
  'Hello Flutter',
  style: const TextStyle(
    fontSize: 16,
    color: Colors.black87,
  ),
)

Text 主要负责:

  • 创建文本布局;
  • 从环境中获取 DefaultTextStyleDirectionality 等信息;
  • 将文本绘制到界面;
  • 提供 maxLinesoverflowtextScaler 等常用参数。

当一段文本中包含多个样式或交互区域时,应使用 TextSpan

RichText(
  text: TextSpan(
    style: const TextStyle(
      fontSize: 16,
      color: Colors.black87,
    ),
    children: [
      const TextSpan(text: '请阅读 '),
      TextSpan(
        text: '服务条款',
        style: const TextStyle(
          color: Colors.blue,
          decoration: TextDecoration.underline,
        ),
      ),
      const TextSpan(text: '。'),
    ],
  ),
)

TextSpan 不是 Widget,而是 InlineSpan 的一种实现。它描述一棵文本树:

根 TextSpan
├── 普通文本
├── 加粗文本
└── 可点击文本

根节点的样式会被子节点继承。子节点只需要覆盖变化的属性:

const TextSpan(
  style: TextStyle(
    fontSize: 16,
    color: Colors.black87,
  ),
  children: [
    TextSpan(text: '普通文字'),
    TextSpan(
      text: '重点',
      style: TextStyle(fontWeight: FontWeight.bold),
    ),
  ],
)

这里第二个 TextSpan 继承 fontSize: 16color: Colors.black87,只将 fontWeight 覆盖为粗体。

2. TextSpanRichTextWidgetSpan 的关系

RichText 接收一个 InlineSpan

RichText(
  text: TextSpan(
    text: '文字',
  ),
)

TextSpan 的内容是文本;WidgetSpan 则可以把 Widget 嵌入文本布局:

RichText(
  text: TextSpan(
    style: const TextStyle(fontSize: 16),
    children: [
      const TextSpan(text: '状态:'),
      WidgetSpan(
        alignment: PlaceholderAlignment.middle,
        child: Icon(
          Icons.check_circle,
          size: 16,
          color: Colors.green,
        ),
      ),
      const TextSpan(text: ' 已完成'),
    ],
  ),
)

WidgetSpan 的占位尺寸来自子 Widget 的布局结果。它不是把 Widget 转换成字形,因此存在几个边界:

  • Widget 不能像普通字形一样参与所有文本塑形;
  • 基线和垂直对齐需要通过 alignmentbaseline 等参数调整;
  • 它的语义和命中测试行为取决于嵌入的 Widget;
  • 在复杂列表中大量使用可能比纯文本更昂贵。

如果只是显示图标或符号,优先确认是否可以使用字体中的字符;如果需要真正的交互控件、动态内容或独立语义,再使用 WidgetSpan

3. TextSpan 的点击处理需要管理生命周期

TextSpan 可以使用 recognizer 处理点击:

class TermsText extends StatefulWidget {
  const TermsText({super.key});

  @override
  State<TermsText> createState() => _TermsTextState();
}

class _TermsTextState extends State<TermsText> {
  late final TapGestureRecognizer _termsRecognizer;

  @override
  void initState() {
    super.initState();
    _termsRecognizer = TapGestureRecognizer()
      ..onTap = () {
        debugPrint('打开服务条款');
      };
  }

  @override
  void dispose() {
    _termsRecognizer.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return RichText(
      text: TextSpan(
        style: DefaultTextStyle.of(context).style,
        children: [
          const TextSpan(text: '请阅读 '),
          TextSpan(
            text: '服务条款',
            style: const TextStyle(color: Colors.blue),
            recognizer: _termsRecognizer,
          ),
        ],
      ),
    );
  }
}

TapGestureRecognizer 是状态对象,不能在每次 build 中无条件创建而不释放。否则会产生对象泄漏或不必要的手势状态。若点击区域应被辅助技术识别为链接,还应进一步设置语义信息,或者使用更适合语义表达的独立 TextButtonLink 类组件。


二、文本布局发生了什么

1. 约束决定可用宽度

Flutter 使用约束驱动布局。父组件向文本传递一个约束:

minWidth ≤ 实际宽度 ≤ maxWidth
minHeight ≤ 实际高度 ≤ maxHeight

文本布局最关键的是可用宽度 W。对于一行文本,若其测量宽度为 L

  • L ≤ W 时,可以完整绘制;
  • L > W 且允许换行时,文本会尝试拆分为多行;
  • 当达到 maxLines 后仍有剩余内容,就发生截断或溢出;
  • softWrap: false 时,不会在普通断点换行,文本更容易产生水平溢出。

例如:

SizedBox(
  width: 180,
  child: Text(
    '这是一段可能需要换行的较长文本',
    maxLines: 2,
    overflow: TextOverflow.ellipsis,
  ),
)

SizedBox 提供约 180 像素的最大宽度,Text 才能计算换行。若文本位于 Row 中而没有得到有限宽度,常见结果是布局异常或内容溢出:

Row(
  children: [
    const Icon(Icons.info),
    Text(longText), // 可能没有得到足够的有限宽度
  ],
)

通常应使用 ExpandedFlexible

Row(
  children: [
    const Icon(Icons.info),
    const SizedBox(width: 8),
    Expanded(
      child: Text(
        longText,
        maxLines: 2,
        overflow: TextOverflow.ellipsis,
      ),
    ),
  ],
)

这里 Expanded 将剩余宽度转化为文本的最大宽度,文本布局才有明确输入。

2. 换行不是简单按字符切割

文本布局引擎不会简单地把字符串按 String 的索引逐个切开。它会综合考虑:

  • Unicode 字符边界;
  • 脚本规则;
  • 空格和标点;
  • 连字和组合字符;
  • 双向文本;
  • 字体的字形宽度。

因此,字符串长度不等于显示宽度:

'iiii'.length == 4
'WWWW'.length == 4

WWWW 的绘制宽度通常明显大于 iiii。同样,一个表情符号可能由多个 UTF-16 code unit 组成,但用户感知上仍是一个字符。

如果需要按用户感知的字符处理字符串,不应直接使用 substring 按 UTF-16 索引切割。Dart 3 环境下可以使用 characters 包:

import 'package:characters/characters.dart';

final value = '👨‍👩‍👧‍👦abc';
final firstTwo = value.characters.take(2).toString();

print(firstTwo); // 👨‍👩‍👧‍👦a

String.length 统计的是 UTF-16 code unit 数量,而 characters 处理的是扩展字素簇(extended grapheme cluster)。例如肤色修饰符、组合重音、连接序列表情都可能跨越多个 code unit。


三、字体:从字体家族到字形回退

1. fontFamily 不是字体文件本身

TextStyle(fontFamily: 'Roboto') 使用的是一个字体家族名称。实际可用字体来自:

  • Flutter 或平台提供的系统字体;
  • 应用通过 pubspec.yaml 打包的字体;
  • 运行时加载的字体;
  • 字体回退链。

应用自带字体的典型配置如下:

flutter:
  fonts:
    - family: AppSans
      fonts:
        - asset: assets/fonts/AppSans-Regular.ttf
          weight: 400
        - asset: assets/fonts/AppSans-Bold.ttf
          weight: 700

使用时:

const TextStyle(
  fontFamily: 'AppSans',
  fontWeight: FontWeight.w700,
)

配置中的 weight 是 Flutter 选择字体文件时使用的样式权重。它不应随意写成字体文件的实际名称。若只注册了 400,却请求 700,Flutter 可能选择最接近的已注册字体,或者使用合成效果,结果不一定等同于真正的粗体字体。

2. 字体粗细不一定有对应文件

FontWeight.w600 表示请求一个字体权重,但具体结果取决于字体家族:

  • 字体可能提供真正的 600 文件;
  • 可能只有 400 和 700,框架选择较接近的文件;
  • 某些平台或字体引擎可能进行合成;
  • 不同字体的“视觉粗细”不能仅靠数字比较。

因此,品牌界面需要验证实际字体文件、字体许可和各平台渲染结果,而不能只依赖 fontWeight 数字。

3. 字符缺失时会发生字体回退

一个字体文件不一定包含所有 Unicode 字符。例如拉丁字体可能没有中文、阿拉伯文或某些 Emoji。文本引擎会为缺失字形寻找回退字体。

回退过程会改变:

  • 字形外观;
  • 字符宽度;
  • 上升部和下降部;
  • 行高;
  • 粗细和基线;
  • 某些脚本的连接与塑形效果。

这解释了一个常见现象:同一个 fontSize: 16,中文、阿拉伯文和 Emoji 显示出来的视觉大小或行高并不一致。

如果 UI 必须使用稳定的品牌字体,应为目标脚本提供完整字体覆盖,或明确设计字体回退策略。若字体覆盖不完整,至少要在 Android、iOS、桌面和 Web 上用真实文本测试,而不是只测试 ASCII。

4. 字体加载是异步资源问题

应用内字体会在构建资源时被打包,但字体可用时机仍与字体加载流程有关。首屏、热重载或动态字体加载时,可能先使用回退字体,再切换到目标字体。切换后文本宽度改变,可能导致:

  • 文本重新换行;
  • 按钮宽度变化;
  • 页面高度变化;
  • 截断结果变化。

生产环境中应避免依赖“某个字体加载前后的临时尺寸”。如果需要动态加载字体,应在布局依赖字体尺寸的页面上考虑加载状态,并验证字体加载后的最终布局。

5. 字体度量决定行高

文本行高不是简单的 fontSize。字体通常包含:

  • ascent:基线以上的高度;
  • descent:基线以下的高度;
  • leading:额外行间空间;
  • glyph bounds:具体字形的实际边界。

TextStyle(height: h) 是一个相对字号的行高因子,近似目标行高为:

Hline=h×FH_{\text{line}} = h \times F

其中:

  • FFfontSize
  • hhTextStyle.height
  • HlineH_{\text{line}} 是目标行高。

例如:

const TextStyle(
  fontSize: 16,
  height: 1.5,
)

目标行高约为 16×1.5=2416 \times 1.5 = 24 像素,但实际视觉结果还受字体度量、文本高度行为和平台实现影响。height 不是给字形本身加缩放,而是控制文本行盒子的高度。

需要固定多种文本的基线或行高时,可以使用 StrutStyle

RichText(
  strutStyle: const StrutStyle(
    fontSize: 16,
    height: 1.5,
    forceStrutHeight: false,
  ),
  text: const TextSpan(
    text: '第一行\n第二行',
    style: TextStyle(fontSize: 16),
  ),
)

StrutStyle 提供行的最低结构度量,适合富文本中不同字体混排时保持较稳定的行高。forceStrutHeight: true 会强制使用 strut 高度,但可能裁剪字体实际需要的上下空间,不能在没有验证的情况下全局开启。


四、文本缩放:用户可读性优先于固定像素

1. 文本缩放与字体大小不同

文本缩放是对用户可见文本尺寸的整体调整。它与直接修改 fontSize 的区别在于:

  • fontSize 是样式本身的设计值;
  • 文本缩放来自系统可访问性设置或应用上下文;
  • 缩放会影响测量、换行、行数和溢出;
  • 缩放不应只改变绘制结果而跳过布局重新计算。

当前 Flutter API 使用 TextScaler 表示缩放。新代码应优先使用:

Text(
  '可访问文本',
  textScaler: MediaQuery.textScalerOf(context),
)

在应用默认情况下,Text 通常会从环境中继承文本缩放配置。显式传递 MediaQuery.textScalerOf(context) 可以让组件行为更清晰。

旧代码经常使用:

Text(
  '旧 API 示例',
  textScaleFactor: 1.2,
)

textScaleFactor 已逐步被 TextScaler 取代,尤其是支持非线性文本缩放时。版本迁移时应以当前稳定 Flutter API 文档为准,不要在新代码中继续假定所有缩放都能用单一浮点乘法表示。

2. 缩放会改变换行条件

假设一行文字在原始字号下的测量宽度为 L0L_0,可用宽度为 WW,线性缩放因子为 ss,则近似有:

LssL0L_s \approx sL_0

完整显示的条件从:

L0WL_0 \leq W

变为:

sL0WsL_0 \leq W

例如,原始文本宽度为 150,容器宽度为 180:

  • s=1.0s = 1.0150180150 \leq 180,可以一行显示;
  • s=1.3s = 1.3195>180195 > 180,必须换行或截断。

这就是为什么“设计稿上刚好一行”的标题,在用户开启大字体后可能变成两行。正确的处理不是强行禁用系统缩放,而是让布局适应更大的文本。

3. TextScaler.noScaling 只适用于明确的非正文内容

如果某个数字必须与图标保持固定几何尺寸,可以局部使用:

Text(
  '12',
  textScaler: TextScaler.noScaling,
)

但正文、按钮标题、错误信息和表单标签通常不应禁用缩放。禁用缩放会损害低视力用户的可读性,也可能与平台无障碍设置冲突。

更稳妥的设计是:

  • 允许标题换行;
  • 给按钮预留足够宽度;
  • 避免固定高度包裹多行文本;
  • 对极端缩放进行 UI 测试;
  • 只有在几何协议明确要求固定尺寸时才局部禁用缩放。

五、溢出、换行与截断

1. maxLinesoverflowsoftWrap 分别控制什么

Text(
  '这是一段较长的文本',
  maxLines: 2,
  overflow: TextOverflow.ellipsis,
  softWrap: true,
)

三个参数职责不同:

  • maxLines:允许的最大行数;
  • overflow:内容无法完整布局时的视觉处理方式;
  • softWrap:是否允许在普通换行位置自动换行。

常用 TextOverflow 包括:

TextOverflow.clip      // 直接裁剪
TextOverflow.fade      // 淡出
TextOverflow.ellipsis  // 显示省略号
TextOverflow.visible   // 允许绘制到布局边界之外

多行省略通常需要同时指定有限的 maxLinesTextOverflow.ellipsis

SizedBox(
  width: 240,
  child: Text(
    '这是一段会在第二行末尾显示省略号的较长文本。',
    maxLines: 2,
    overflow: TextOverflow.ellipsis,
  ),
)

省略号并不代表字符串已经被修改。它主要是布局和绘制阶段的视觉结果。若业务需要复制、分享、搜索或无障碍读取完整文本,原始字符串仍应保留。

2. 单行省略需要有限宽度

SizedBox(
  width: 160,
  child: Text(
    '非常长的文件名:report-2025-final-version.pdf',
    maxLines: 1,
    overflow: TextOverflow.ellipsis,
  ),
)

如果父布局没有给出有限宽度,文本不知道应在哪个位置截断。常见故障路径是:

Row
└── Text
    └── 没有有限 maxWidth
        └── 无法按预期省略

修复方式通常是:

Row(
  children: [
    const Icon(Icons.insert_drive_file),
    const SizedBox(width: 8),
    Expanded(
      child: Text(
        fileName,
        maxLines: 1,
        overflow: TextOverflow.ellipsis,
      ),
    ),
  ],
)

3. 固定高度可能裁剪放大后的文本

以下写法在默认字号下可能正常,但在大字体或字体回退后会裁剪:

SizedBox(
  height: 40,
  child: Text(
    '可能变成两行的标题',
    maxLines: 2,
  ),
)

原因是文本实际高度由行数、行高、字体度量和缩放共同决定。若高度 40 小于两行文本需要的高度,RenderParagraph 会被父约束限制,最终表现为裁剪或布局异常。

应尽量让高度由内容决定,或者显式计算并验证高度:

Padding(
  padding: const EdgeInsets.symmetric(vertical: 12),
  child: Text(
    title,
    style: const TextStyle(fontSize: 18, height: 1.3),
    maxLines: 2,
    overflow: TextOverflow.ellipsis,
  ),
)

4. TextOverflow.visible 不是解决布局问题的办法

visible 允许文字绘制到自身布局边界之外,但这不会扩大父组件的布局尺寸,也不会自动处理相邻组件遮挡。它适合明确知道绘制边界的场景,不适合用来掩盖缺少约束或错误布局。


六、富文本样式继承和不可继承属性

1. TextStyle 的合并逻辑

可以使用 TextStyle.merge 合并样式:

const base = TextStyle(
  fontSize: 16,
  color: Colors.black87,
);

final emphasis = base.merge(
  const TextStyle(fontWeight: FontWeight.bold),
);

merge 的含义是:非空属性覆盖原样式,未指定的属性继续继承。对于 TextSpan,还存在父子节点继承:

TextSpan(
  style: const TextStyle(
    fontSize: 16,
    color: Colors.black87,
  ),
  children: [
    const TextSpan(text: '继承字号和颜色'),
    TextSpan(
      text: '只覆盖颜色',
      style: const TextStyle(color: Colors.red),
    ),
  ],
)

这比为每个片段重复填写完整样式更容易维护,也减少了样式不一致的概率。

2. TextSpan 不能承载所有 Widget 能力

TextSpan 可以改变字体、颜色、装饰和手势识别器,但它不是 Widget,因此不能直接设置:

  • padding
  • margin
  • 独立背景布局;
  • 任意边框;
  • 独立的约束和尺寸。

需要这些能力时,可以使用 WidgetSpan 或拆成多个 Widget。不要把所有界面都塞入一个巨大的 RichText,否则布局、语义和交互都更难诊断。


七、国际文本:Unicode、脚本、方向和本地化

1. Unicode 字符、字形和用户感知字符不是同一概念

需要区分三个层次:

  • Unicode code point:抽象字符编号;
  • glyph:字体用于绘制的具体字形;
  • extended grapheme cluster:用户感知的字符单元。

一个 code point 不一定对应一个 glyph;多个 code point 也可能组合成一个用户感知字符。例如:

e + 组合重音符号
👩 + 肤色修饰符
👨 + ZWJ + 👩 + ZWJ + 👧

因此,下列代码可能破坏用户看到的字符:

final broken = value.substring(0, 2);

如果用于截断、光标移动或删除,应使用 characters 包或平台文本编辑组件提供的边界逻辑。

2. locale 影响字体选择和脚本行为

TextStyle 可以指定 locale

const Text(
  '中文文本',
  style: TextStyle(
    locale: Locale('zh', 'CN'),
  ),
)

locale 不是把中文翻译成另一种语言,它主要为字体选择、字形变体和脚本处理提供语言环境。例如同一个汉字在简体中文、繁体中文、日文和韩文环境下可能存在不同字形偏好。

实际本地化文本仍应通过 LocalizationsAppLocalizationsintl 等机制获得,而不是把翻译逻辑写入 TextStyle.locale

3. 双向文本由 textDirection 和 Unicode 双向算法共同决定

阿拉伯文、希伯来文属于主要从右到左(RTL)的脚本;数字和拉丁字母通常是从左到右(LTR)。一段文本可能同时包含多种方向:

رقم الطلب: 12345

显示方向不是简单地把整个字符串反转。Unicode 双向算法会根据字符类别和嵌入方向决定视觉顺序。

Flutter 中,普通 Text 通常从 Directionality 继承方向:

Directionality(
  textDirection: TextDirection.rtl,
  child: const Text('نص عربي'),
)

应用一般通过本地化配置和 MaterialApp / WidgetsApp 提供正确的方向。对低层 API,特别是 TextPainter,应显式提供方向:

final painter = TextPainter(
  text: const TextSpan(
    text: 'שלום 123',
    style: TextStyle(fontSize: 18),
  ),
  textDirection: TextDirection.rtl,
)..layout(maxWidth: 200);

painter.paint(canvas, Offset.zero);

如果 TextPainter 缺少必要的 textDirection,在包含方向相关内容时可能无法正确布局;低层绘制代码不应假定总能自动获得 Widget 树中的方向环境。

4. textAlign 不是 textDirection

这两个概念经常被混淆:

Text(
  '内容',
  textAlign: TextAlign.start,
)
  • textDirection 决定 startend 的方向,以及双向布局的基础;
  • textAlign 决定文本行在可用宽度中的对齐方式。

TextAlign.start 在 LTR 中通常相当于左对齐,在 RTL 中通常相当于右对齐;TextAlign.left 则是物理左侧,不会随方向改变。

国际化界面通常优先使用:

textAlign: TextAlign.start

除非设计明确要求物理左对齐。

5. TextOverflow.ellipsis 在不同脚本中应实际验证

省略号涉及截断位置、方向和字体字形。对 RTL 文本、混合方向文本、CJK 文本和表情序列,不能只凭 LTR 英文测试结果推断行为。应覆盖:

  • 长中文;
  • 长阿拉伯文;
  • 希伯来文和数字混排;
  • URL、文件名和邮箱;
  • Emoji 与组合字符;
  • 大字体缩放;
  • 从右到左布局下的多行截断。

八、国际化文本不只是翻译

1. 文本长度会因语言显著变化

英文按钮:

Delete

德文可能更长:

Löschen

阿拉伯文、俄文、法文等语言还可能改变方向、大小写和词形。固定宽度按钮和固定高度标题在本地化后容易失败。

错误示例:

SizedBox(
  width: 80,
  height: 40,
  child: Text(
    AppLocalizations.of(context)!.delete,
    textAlign: TextAlign.center,
  ),
)

更有弹性的写法是让按钮根据内容和内边距确定尺寸:

FilledButton(
  onPressed: onDelete,
  child: Text(AppLocalizations.of(context)!.delete),
)

如果产品要求所有按钮等宽,应在最长本地化文本、最大字体缩放和 RTL 下验证,而不是只以英文默认字号测量。

2. 数字、日期和复数应交给本地化库

不要手工拼接:

Text('$count items')

因为不同语言的复数规则不统一,有的语言有两种以上复数形式。应使用 intl 或生成的本地化资源处理:

Text(
  AppLocalizations.of(context)!.itemsCount(count),
)

日期、时间、货币和数字也应由本地化格式化器处理。文本排版层只负责显示结果,不应承担翻译、复数判断或货币格式化逻辑。


九、TextPainter:需要测量或自定义绘制时的底层工具

普通界面优先使用 TextRichText。当需要在 CustomPainter 中绘制、计算文本宽度或进行命中测试时,可以使用 TextPainter

class LabelPainter extends CustomPainter {
  final String label;

  LabelPainter(this.label);

  @override
  void paint(Canvas canvas, Size size) {
    final painter = TextPainter(
      text: TextSpan(
        text: label,
        style: const TextStyle(
          color: Colors.black,
          fontSize: 18,
        ),
      ),
      textDirection: TextDirection.ltr,
      maxLines: 1,
      ellipsis: '…',
    )..layout(maxWidth: size.width);

    painter.paint(canvas, Offset.zero);
  }

  @override
  bool shouldRepaint(covariant LabelPainter oldDelegate) {
    return oldDelegate.label != label;
  }
}

执行过程是:

  1. 创建 TextPainter
  2. 提供 InlineSpan
  3. 提供 textDirection
  4. 调用 layout(maxWidth: ...) 完成测量;
  5. 调用 paint 绘制。

如果忘记 layout 就读取尺寸或绘制,TextPainter 尚未完成布局,结果会错误或触发断言。

可以读取:

painter.width
painter.height
painter.size
painter.didExceedMaxLines

didExceedMaxLines 可以帮助判断是否发生了最大行数溢出,但它只表示布局结果超过限制,不等同于“字符串已被业务截断”。

TextPainter 的生命周期由调用方负责。如果文本、样式、方向或缩放发生变化,应重新布局。不能缓存一个旧的 TextPainter 并忽略字体缩放、Locale 或可用宽度变化。


十、文本高度行为和基线控制

1. textHeightBehavior 控制首尾行的额外空间

某些字体行高包含额外 leading。TextHeightBehavior 可以控制首行和末行是否应用这些空间:

Text(
  '标题',
  style: const TextStyle(
    fontSize: 24,
    height: 1.2,
  ),
  textHeightBehavior: const TextHeightBehavior(
    applyHeightToFirstAscent: false,
    applyHeightToLastDescent: false,
  ),
)

这适合标题需要紧贴上下边界的场景,但会改变视觉留白。不要仅通过负 margin 修正文本位置;先确认字体度量、heighttextHeightBehavior 是否符合预期。

2. 基线对齐对图标和文本混排很重要

Row 默认按顶部、中心或其他几何方式对齐,并不一定等同于文本基线对齐。文本与文本并排时可以使用:

Row(
  crossAxisAlignment: CrossAxisAlignment.baseline,
  textBaseline: TextBaseline.alphabetic,
  children: const [
    Text(
      '价格',
      style: TextStyle(fontSize: 16),
    ),
    SizedBox(width: 4),
    Text(
      '99',
      style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
    ),
  ],
)

CrossAxisAlignment.baseline 必须同时指定 textBaseline。否则 Flutter 无法确定基线参照。

图标通常不是字形,直接放入 Row 可能与文字基线不一致。需要精确对齐时,使用 BaselineIcon 尺寸和垂直偏移进行验证,而不是假设 Icon(size: 16) 与 16 像素文字天然对齐。


十一、平台差异

1. Android 和 iOS

Android 和 iOS 都会受到系统字体、字体回退、无障碍字体缩放和系统渲染环境影响,但默认字体与字形度量并不相同。相同的字号不保证相同的:

  • 文本宽度;
  • 行高;
  • 标点位置;
  • 中日韩字形;
  • Emoji 外观;
  • 粗体视觉效果。

如果应用依赖自定义字体,应同时测试字体文件在 Android 和 iOS 上的完整脚本覆盖。不能只在一个平台确认布局后推断另一个平台一致。

2. 桌面平台

Windows、macOS 和 Linux 的系统字体、字体安装情况、字体回退链和文本栅格化可能不同。桌面应用尤其容易遇到:

  • 用户环境缺少目标字体;
  • 字体文件许可或安装策略不同;
  • 高 DPI 下尺寸和像素对齐不同;
  • 窗口可调整导致换行频繁变化。

桌面布局应适应可变窗口宽度,不要把移动端固定宽度假设直接复用。

3. Web

Flutter Web 的文本渲染取决于所使用的 Web 渲染器、浏览器和字体加载环境。浏览器的字体回退、字体加载时机、抗锯齿和 Emoji 字体通常由宿主系统控制。Web 上还要注意:

  • 字体资源路径和部署服务器配置;
  • 跨域响应头;
  • 自定义字体加载失败后的回退;
  • 不同浏览器对字体格式和渲染细节的差异;
  • 首次加载时的字体切换。

因此 Web 应测试真实部署环境,而不是只测试本地开发服务器。

平台差异属于常见实现和运行环境差异,不应把某个平台当前的视觉结果当作 Flutter API 的规范保证。


十二、一个完整的可运行示例

下面示例展示:

  • 普通文本;
  • 富文本;
  • 点击 TextSpan
  • 最大行数和省略号;
  • 当前环境的文本缩放;
  • RTL 文本;
  • characters 安全截取。

pubspec.yaml 添加:

dependencies:
  flutter:
    sdk: flutter
  characters: ^1.3.0

Dart 代码:

import 'package:characters/characters.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const TextDemoApp());
}

class TextDemoApp extends StatelessWidget {
  const TextDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Text Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const TextDemoPage(),
    );
  }
}

class TextDemoPage extends StatefulWidget {
  const TextDemoPage({super.key});

  @override
  State<TextDemoPage> createState() => _TextDemoPageState();
}

class _TextDemoPageState extends State<TextDemoPage> {
  late final TapGestureRecognizer _linkRecognizer;

  @override
  void initState() {
    super.initState();
    _linkRecognizer = TapGestureRecognizer()
      ..onTap = () {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('链接被点击')),
        );
      };
  }

  @override
  void dispose() {
    _linkRecognizer.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final scaler = MediaQuery.textScalerOf(context);
    const longText =
        '这是一段较长的文本,用于观察有限宽度、最大行数、字体缩放和省略号之间的关系。';

    final family = '👨‍👩‍👧‍👦abc'.characters.take(2).toString();

    return Scaffold(
      appBar: AppBar(title: const Text('文本与排版')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          const Text(
            '普通文本',
            style: TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 8),
          Text(
            '当前缩放:${scaler.runtimeType}',
            textScaler: scaler,
          ),
          const SizedBox(height: 16),

          const Text(
            '富文本',
            style: TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 8),
          RichText(
            textScaler: scaler,
            text: TextSpan(
              style: DefaultTextStyle.of(context).style,
              children: [
                const TextSpan(text: '请阅读 '),
                TextSpan(
                  text: '服务条款',
                  style: const TextStyle(
                    color: Colors.blue,
                    decoration: TextDecoration.underline,
                  ),
                  recognizer: _linkRecognizer,
                ),
                const TextSpan(text: ' 后继续。'),
              ],
            ),
          ),
          const SizedBox(height: 16),

          const Text(
            '多行省略',
            style: TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 8),
          Container(
            width: double.infinity,
            padding: const EdgeInsets.all(12),
            color: Colors.blue.withValues(alpha: 0.08),
            child: Text(
              longText,
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
              textScaler: scaler,
            ),
          ),
          const SizedBox(height: 16),

          const Text(
            'RTL 文本',
            style: TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 8),
          const Directionality(
            textDirection: TextDirection.rtl,
            child: Text(
              'رقم الطلب: 12345',
              textAlign: TextAlign.start,
            ),
          ),
          const SizedBox(height: 16),

          const Text(
            '按用户感知字符截取',
            style: TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 8),
          Text('前两个字符:$family'),
        ],
      ),
    );
  }
}

运行条件:

flutter pub get
flutter run

预期行为:

  • RichText 中只有“服务条款”区域可点击;
  • 长文本在有限宽度和两行限制下显示省略号;
  • RTL 文本的 start 从右侧开始;
  • 家庭 Emoji 不会被 substring 从中间拆坏;
  • 系统或应用文本缩放变化后,文本会重新测量和换行。

示例中的 withValues(alpha: ...) 使用当前 Flutter 中较新的颜色 API。如果项目版本较旧,可能需要使用旧的 withOpacity 写法;这属于 API 迁移差异,不影响文本布局原理。


十三、常见失败表现与诊断路径

1. 文本被截断但没有省略号

依次检查:

  1. 是否存在有限宽度;
  2. 是否设置了有限的 maxLines
  3. overflow 是否为 TextOverflow.ellipsis
  4. 父组件是否通过固定高度裁剪了文本;
  5. 是否在 Row 中缺少 ExpandedFlexible
  6. 是否因字体缩放或字体回退导致实际宽度增加。

最小诊断代码:

LayoutBuilder(
  builder: (context, constraints) {
    debugPrint('maxWidth=${constraints.maxWidth}');
    return Text(
      value,
      maxLines: 1,
      overflow: TextOverflow.ellipsis,
    );
  },
)

如果 constraints.maxWidth 是无穷大,问题通常在父布局,而不是 TextOverflow

2. 中文或 Emoji 显示成方框

这通常说明当前字体或回退字体缺少对应字形。诊断步骤:

  1. 确认文本本身不是错误编码;
  2. 在目标平台检查字体文件是否包含目标脚本;
  3. 暂时移除自定义 fontFamily,观察系统回退是否正常;
  4. 检查自定义字体是否真的被 pubspec.yaml 注册;
  5. 在真实设备或浏览器中验证,而不是只看模拟器。

方框不是通过增大 fontSize 或改变 fontWeight 修复的。

3. 文本在大字体下重叠

检查:

  • 外层是否使用固定高度;
  • TextStyle.height 是否过小;
  • StrutStyle 是否强制了不够大的行高;
  • WidgetSpan 是否与文字基线不匹配;
  • 是否错误地使用了 TextScaler.noScaling
  • 是否有 Clip 或固定尺寸父组件。

放大字体后的重叠通常是约束与字体度量冲突,而不是绘制颜色问题。

4. RTL 页面中图标或箭头方向错误

Directionality 会影响使用 startend 的布局,但并不会自动保证所有图标都语义正确。需要分别检查:

  • Row 中的排列方向;
  • EdgeInsetsDirectional 是否替代了物理 left/right
  • 返回、前进等方向性图标是否根据 RTL 翻转;
  • 文本对齐是否使用 start/end
  • 混合方向字符串是否包含预期的 Unicode 双向控制字符。

不要通过反转整个字符串修复 RTL;那会破坏数字、标点和嵌入文本的顺序。

5. 点击富文本区域没有反应

检查:

  • TextSpan.recognizer 是否仍然存活;
  • 是否设置了 onTap
  • RichText 是否被其他 Widget 覆盖;
  • 文本是否实际存在于命中区域;
  • recognizer 是否在 dispose 中释放;
  • 语义测试是否能识别该区域。

点击识别器处理的是手势,不等同于普通按钮的完整可访问性语义。对关键操作,独立按钮通常更容易满足无障碍和焦点导航要求。


十四、测试文本布局,而不是只测试字符串

文本组件至少应测试以下变量组合:

语言:中文、英文、阿拉伯文、希伯来文、日文
方向:LTR、RTL、混合方向
字体:默认字体、自定义字体、缺字形回退
缩放:默认、大字体、极端可访问性设置
容器:窄宽度、宽窗口、Row、Column、滚动区域
内容:Emoji、组合字符、长 URL、长文件名、数字

可以在 Widget 测试中设置文本缩放和方向环境:

await tester.pumpWidget(
  MediaQuery(
    data: const MediaQueryData(
      textScaler: TextScaler.linear(1.5),
    ),
    child: Directionality(
      textDirection: TextDirection.rtl,
      child: MaterialApp(
        home: Text(
          'نص طويل للاختبار',
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
        ),
      ),
    ),
  ),
);

然后使用 find.text、语义测试和尺寸断言验证结果。对视觉差异明显的字体、Emoji 和跨平台排版,可以使用黄金测试,但黄金图应按平台或渲染环境管理,不能假设 Android、iOS 和 Web 的像素结果完全一致。


十五、核心取舍

TextSpan 适合在同一段文本布局中表达多种样式和局部交互;它不应替代所有 Widget。字体决定的不只是外观,还包括字形覆盖、度量、基线和换行结果。文本缩放是布局输入的一部分,不是绘制完成后的简单放大。溢出策略只有在父组件提供有效约束时才有意义。国际文本则要求同时考虑 Unicode 边界、字体回退、脚本塑形、双向算法、本地化长度和无障碍缩放。

当文本出现异常时,最有效的排查顺序通常是:

父约束
→ 文本缩放
→ maxLines / softWrap / overflow
→ 字体注册与字形覆盖
→ 方向和 Locale
→ 行高、基线和 WidgetSpan
→ 平台渲染差异

这个顺序对应文本从布局输入到最终绘制的因果链。先确认约束和文本环境,再检查字体与脚本,最后处理视觉细节,通常比直接修改字号、加固定高度或裁剪组件更容易得到可维护的结果。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。