做 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 TextPainter:Flutter文本布局的底层控制单元
要拿到文本渲染后的真实宽度,绕不开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,而是直接用默认缩放,那么截断结果在大字体模式下会偏长或者偏短。
解决办法是显式传入textScaler:
final 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 都有分配开销 |
| getPositionForOffset | 500 字 | ~0.8ms | 一次 layout,直接定位 |
| 二分法 | 2000 字 | ~9.5ms | 文本越长二分次数越多 |
| getPositionForOffset | 2000 字 | ~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% 后面的省略号太丑,给我换个样式”,后天又会说“截断之后超长文本要能点击展开”。组件的灵活性会帮你省掉后面一大波返工。