Flutter文本按百分比截断:TextPainter原理与字符边界实践 做 Flutter 开发这些年我遇到过不少产品经理提出“这段文字最多显示 70%”之类的需求。一开始我也以为就是substring(0, length * 0.7)那么简单结果真正落地的时候才发现按百分比截取文本这件事水比想象中深得多——中英文混排、emoji、字体缩放、省略号位置每一个细节都能让显示效果崩得莫名其妙。这篇就把我踩过的坑和最终的完整方案整理出来。1. “按百分比截取”到底在截什么先厘清三种维度1.1 需求落地前的几种理解接到“按百分比截取文本”这个需求先别急着写代码。我建议你先想清楚一个问题这个“百分比”到底按什么算我整理下来市面上常见理解至少有三种理解方式含义适用场景按字符数百分比总字符数乘以百分比截取前 N 个字符对字数有严格要求的场景如短信、标题限长按文本宽度百分比文本在屏幕上渲染出的总宽度乘以百分比截断到目标宽度列表卡片、自适应布局要求视觉上刚好占满容器比例按行数百分比文本总行数乘以百分比保留前 N 行详情页展开预览、多行摘要如果你的项目只是“字符数算一个比例”那直接用 Dart 内置的substring就能搞定。但现实情况是产品说要“显示 70%”往往指的是“视觉上这段文字占容器宽度的 70%”或者是“总行数只保留 70%”。这时候substring完全失效因为你不知道一个字符串渲染出来到底有多宽、占几行。1.2 我为什么最终选择“宽度百分比”作为主方案做实际项目时我发现最常遇到的是“视觉宽度百分比”这个诉求。原因很好理解UI 设计稿是像素级的产品经理在 Figma 里看到的是“这段文字占卡片 70% 宽度”他并不关心字符数。而 Flutter 的文本布局天然是宽度驱动的你最终能控制的是Text组件渲染出来的尺寸。所以我把宽度百分比作为核心方案字符数百分比和行数百分比作为补充方案。另外要强调一点按百分比截取和按固定宽度截取底层思路完全不同。固定宽度截取是“文本超出就截”百分比截取是“无论文本多长先算出完整文本的总宽再算目标宽度”。这要求你必须先把完整文本完整地布局一遍拿到总宽度之后才能决定从哪里砍。2. 基于TextPainter的宽度百分比截断原理与最小实现2.1 TextPainterFlutter文本布局的底层控制单元要拿到文本渲染后的真实宽度绕不开TextPainter。这个类的作用是脱离Text组件直接在离屏对一段文本做 layout然后返回各种布局信息。很多人以为TextPainter是给自定义绘制用的其实它做文本测量才是真正的强项。你不需要把Text真的画到屏幕上只需要让它跑一遍布局就可以拿到width文本整体宽度height文本整体高度getPositionForOffset(Offset)根据具体坐标点返回光标位置的TextPositioncomputeLineMetrics()每一行的度量信息这正是按百分比截断需要的全部要素。2.2 直接命中目标getPositionForOffset 截断方案我的第一版方案是用二分查找逼近目标宽度后来发现有一个更优雅的 API——getPositionForOffset。原理很简单先对完整文本做 layout拿到总宽度totalWidth然后目标宽度就是totalWidth * percent。接下来构造一个Offset(targetWidth, 0)调用getPositionForOffset它会返回文本中距离该坐标最近的字符位置。这个位置就是我们需要的截断点。这个思路省掉了二分查找的循环一次 layout 就能直接定位截断位置代码也更简洁。import package:flutter/rendering.dart; class TextTruncator { static String? truncateByPercent({ required String text, required double percent, required TextStyle style, double maxWidth double.infinity, TextDirection textDirection TextDirection.ltr, }) { if (text.isEmpty) return null; if (percent 0) return ; if (percent 1) return text; final fullPainter TextPainter( text: TextSpan(text: text, style: style), textDirection: textDirection, )..layout(maxWidth: maxWidth); final totalWidth fullPainter.width; final targetWidth totalWidth * percent; if (targetWidth totalWidth) return text; final position fullPainter.getPositionForOffset( Offset(targetWidth, 0), ); fullPainter.dispose(); var endIndex position.offset; if (endIndex 0) return ; if (endIndex text.length) return text; return text.substring(0, endIndex); } }这里有个细节要注意getPositionForOffset返回的TextPosition.offset是 UTF-16 编码单元的位置。对 ASCII 字符没问题但遇到中文字符和 emoji 时这个offset可能落在代理对中间直接substring就会截出乱码。后面第 3 节我会详细讲怎么处理。2.3 一个可以直接拿来用的PercentTruncatedText组件方法有了但实际项目里最好封装成组件这样外部用起来就像用Text一样自然。import package:flutter/material.dart; class PercentTruncatedText extends StatelessWidget { const PercentTruncatedText({ Key? key, required this.text, required this.percent, this.style, this.textDirection, this.maxWidth double.infinity, this.overflowReplacement, }) : super(key: key); final String text; final double percent; final TextStyle? style; final TextDirection? textDirection; final double maxWidth; final Widget? overflowReplacement; override Widget build(BuildContext context) { final effectiveStyle style ?? DefaultTextStyle.of(context).style; final direction textDirection ?? Directionality.of(context); final result TextTruncator.truncateByPercent( text: text, percent: percent, style: effectiveStyle, maxWidth: maxWidth, textDirection: direction, ); if (result null) return const SizedBox.shrink(); if (result text) { return Text(text, style: effectiveStyle); } return Text(result, style: effectiveStyle); } }使用的时候直接PercentTruncatedText( text: 这是一段很长的文本内容, percent: 0.7, style: TextStyle(fontSize: 16, color: Colors.black), )注意这里我把overflowReplacement字段留出来了目的是当文本被截断后你可以选择用其他 Widget 替代显示比如“全文”按钮而不是强制用Text展示截断结果。2.4 加上省略号百分比需要回退多少才不被挤出边界如果你截断之后就干巴巴地放“前 70% 的文本”视觉效果会很突兀用户也不知道后面还有内容。常规做法是截断后追加“...”但这里有个隐藏问题追加省略号之前得先把“...”本身的宽度预留出来。如果你直接substring到正好 70%再拼上“...”最终渲染宽度就会超过 70%。所以正确的做法是对百分比做回退——先把“...”的宽度算出来用目标宽度减去省略号宽度再去定位截断点。const suffix ...; final suffixPainter TextPainter( text: TextSpan(text: suffix, style: style), textDirection: textDirection, )..layout(); final targetWidth totalWidth * percent - suffixPainter.width; suffixPainter.dispose(); if (targetWidth 0) return suffix; final position fullPainter.getPositionForOffset( Offset(targetWidth, 0), ); return text.substring(0, position.offset) suffix;注意样式不同省略号的宽度就不同。比如字号 14 和字号 20 的“...”宽度差很远。所以后缀的TextPainter必须复用同一个style不能单独写死。3. 项目实战中真正会踩的坑中英文混排与字符边界3.1 emoji、组合字符与characters包我第一版上线后测试小姐姐反馈了一个很经典的 bug某条含 emoji 的评论“???”直接被截成了半个。原因就是我前面提到的 UTF-16 代理对问题。Dart 的String是 UTF-16 编码的。emoji 这类超出基本多语言平面的字符在字符串里占两个 UTF-16 code unit。但用户感知上它是一个“字”。你substring落在这个代理对中间渲染出来就是乱码。处理方式有两种。第一种写个工具方法把截断位置往回调到安全边界bool _isEmojiBoundary(String text, int index) { if (index 0 || index text.length) return true; final rune text.codeUnitAt(index); // 如果当前字符是低代理项说明index落在代理对中间 return !(rune 0xDC00 rune 0xDFFF); } int _safeSubstringIndex(String text, int rawIndex) { if (rawIndex text.length) return text.length; // 如果rawIndex落在代理对中间往前回退一个code unit if (!_isEmojiBoundary(text, rawIndex)) { return rawIndex - 1; } return rawIndex; }第二种更推荐——直接用characters包。Flutter 项目默认引入了这个包它把字符串按“字素簇”切分而不是按 UTF-16 code unit。import package:characters/characters.dart; final truncated text.characters.take(endIndex).toString();这样无论 emoji、组合表情符号比如肤色修饰符、ZWJ 序列都能完整保留不会切碎。3.2 TextDirection和文本对齐对结果的影响这个是新手最容易忽略的坑。TextPainter构造时如果不指定textDirection它不会默认帮你按“从左到右”处理而是会抛异常或者出脏数据。实际开发中你应该从Directionality.of(context)拿当前的文字方向不要写死。final direction Directionality.of(context);另外阿拉伯语、希伯来语这些 RTL 语言视觉方向是从右往左的。你原本写死的Offset(targetWidth, 0)坐标语义就会出问题——对 RTL 文本来说横向坐标越靠右对应的文本位置反而越靠后。如果你的产品有国际化需求一定要按textDirection TextDirection.rtl做镜像处理把 targetWidth 的坐标换算成从右往左的偏移。3.3 字体缩放、文字高度约束——被忽略的另一半还有一个隐蔽的坑系统字体缩放。用户在系统设置里把字体调大到 1.5 倍甚至更大这时候同样一个字符串渲染出的物理宽度会膨胀。如果你的TextPainter没有考虑textScaler而是直接用默认缩放那么截断结果在大字体模式下会偏长或者偏短。解决办法是显式传入textScalerfinal painter TextPainter( text: TextSpan(text: text, style: style), textDirection: direction, textScaler: MediaQuery.of(context).textScaler, )..layout(maxWidth: maxWidth);同时在定位截断点时坐标计算也要基于同一个缩放系数。否则就会出现“普通字体下截断正常大字体下漏出容器”的诡异问题。4. 进阶方案按行数百分比截断与超长文本降级4.1 maxLines背后的逻辑和局限性Flutter 自带的Text支持maxLines和overflow: TextOverflow.ellipsis。但它是“超出 N 行就截断”做不到“只显示总行数的 70%”。为什么因为maxLines是一个绝对值你必须在拿到总行数之后才能换算出 70% 对应多少行。如果你在 build 之前不知道总行数就得提前 layout 一次测量。这和宽度百分比的核心思路是一致的先全量测量再决定截断点。4.2 按行数百分比截断的自定义实现思路是先用一个无maxLines限制的TextPainter布局完整文本然后通过computeLineMetrics()拿到总行数再乘以百分比得到保留行数。然后逐行累加找到目标行结束位置再从那段位置截取文本。String? truncateByLines({ required String text, required double percent, required TextStyle style, double maxWidth 400, TextDirection textDirection TextDirection.ltr, }) { final painter TextPainter( text: TextSpan(text: text, style: style), textDirection: textDirection, )..layout(maxWidth: maxWidth); final lineCount painter.computeLineMetrics().length; if (lineCount 0) return text; final targetLineCount (lineCount * percent).floor(); if (targetLineCount lineCount) return text; if (targetLineCount 0) return ; // getLineBoundary 通过 TextPosition 拿到指定行的文本范围 // 这里用 getPositionForOffset 定位到目标行最后一行的高度中心 final metrics painter.computeLineMetrics(); var offsetY 0.0; for (var i 0; i targetLineCount; i) { offsetY metrics[i].height; } // 取目标行底部的坐标得到对应的字符位置 final boundaryPos painter.getPositionForOffset( Offset(0, offsetY), ); final endIndex boundaryPos.offset; painter.dispose(); if (endIndex 0) return ; if (endIndex text.length) return text; return text.characters.take(endIndex).toString(); }这段代码里我是通过坐标 y 值间接求行边界。getPositionForOffset传Offset(0, offsetY)offsetY累加到目标行底部返回的TextPosition就是这一行最后一个字符的位置。实测下来在固定行高的场景下非常准。4.3 超长文本降级为更多行数展示有时候业务需求不是“截断”而是“降级”——即文本太长了才触发百分比截断文本不够长就完整展示。这个逻辑其实很简单就是先算完整 layout 的宽度是不是超过容器宽度没有超就不截断。final containerWidth ...; // 从 LayoutBuilder 获取 final totalWidth painter.width; if (totalWidth containerWidth) { return text; } // 超过才按百分比截断注意千万不要在 build 方法里直接用MediaQuery.of(context).size.width当容器宽度因为实际容器往往不是全屏宽度而是卡片内边距减掉之后的结果。正确做法是用LayoutBuilder拿到实际约束宽度再传给截断方法。5. 性能实测与使用建议5.1 一次layout vs 多次二分性能对比我的第一版是用二分查找逼近目标宽度每次迭代都创建一个新的TextPainter做 layout然后比对宽度。一个 500 字的文本大约需要 log2(500) ≈ 9 次 layout。后来改成getPositionForOffset一次定位性能提升非常明显。这里给一组我本地实测的数据模拟器 Pixel 6方案文本长度截断耗时备注二分法500 字~3.2ms每次 layout 都有分配开销getPositionForOffset500 字~0.8ms一次 layout直接定位二分法2000 字~9.5ms文本越长二分次数越多getPositionForOffset2000 字~1.2ms仍然是常数次 layout可以看到getPositionForOffset几乎是常数级的耗时非常适合在列表滚动的build里调用。但即便如此我还是建议做一层缓存如果同一个文本同一个样式的截断结果已经算过就不要再重复 layout。比如用一个 LRU 缓存key 大概是“文本 hash fontSize 百分比”value 是截断后的字符串。5.2 什么时候不该用这个方案不是所有场景都适合“按百分比截断”。我梳理了几种别头铁硬上的场景文本很短但百分比很小比如“你好”加 30% 概率直接截成空串。这种情况还不如不截断或者用完整文本兜底。行高不固定的富文本如果你的文本里有不同字体大小、图片、自定义组件混排行高的计算会变得非常复杂。我的方案基于统一style富文本场景建议用WidgetSpan方案单独做。需要精确到字符级语义比如代码展示、电话号码截断会破坏语义。这时候应该尽量缩字号而不是截文本。5.3 我在实际项目中的使用体会这套“百分比截断”方案在我维护的一个社区类 App 里已经跑了大半年覆盖了标题、简介、评论摘要几个核心场景稳定性还不错。印象比较深的一次是某天测试反馈某个私信消息在部分手机上显示成“半个 emoji 乱码”。我排查了半天发现是另一条代码路径没用characters包直接用 UTF-16 索引截断导致的。从那时候起我给自己定了个规矩——凡是文本截断相关逻辑一律以字素簇为单位处理绝不在原始索引上直接动手。还有一次是线上用户反馈卡片上的文本在系统大字体模式下被截得只剩一半。追查后发现是TextPainter没有传textScaler在字体缩放 1.3 倍时实际渲染宽度和测量宽度偏差太大。这也解释了为什么同一个版本有人正常有人缩小。如果你也在做类似的功能我最后给你一个最实用的建议别把百分比截断做成一个黑盒方法扔在工具类里而是要封装成组件并且把“测量基础数据样式、行宽、缩放”通过参数暴露出来。因为产品大概率今天说要 70%明天就会说“70% 后面的省略号太丑给我换个样式”后天又会说“截断之后超长文本要能点击展开”。组件的灵活性会帮你省掉后面一大波返工。