Flutter:调整哪些参数可使TextPainter/Paragraph在Android与单元测试尺寸一致
我开发的Flutter应用涉及布局计算,单元测试时发现TextPainter和Paragraph在Android设备与单元测试环境返回的Size不匹配。即使统一设置fontFamily为Roboto-Black(单元测试默认字体不是Roboto),差异仍然存在。
使用TextPainter的尺寸对比
fontFamily: 留空(默认应为Roboto) Unit test: Size(400, 100) Android: Size(171, 117) ----------<< +1 width Chrome: Size(170, 117) fontFamily: Roboto-Black Unit test: Size(175, 120) ----------<< +3 height Android: Size(175, 117) Chrome: Size(175, 117)
使用Paragraph的尺寸对比
fontFamily: 留空(默认应为Roboto) Unit test: (200,200) Android: (170.1, 117) -------<< + 0.2 width Chrome: (169.9, 117) fontFamily: Roboto-Black Unit test: (174.31640625, 120) -------<< +3 height, +0.01640625 width Android: (174.3, 117) Chrome: (174.3, 117)
注:单元测试必须匹配174.31640625,否则像174.3会导致测试失败,说明结果未按预期取整。
设备端Paragraph实现代码
import 'dart:ui'; import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: SafeArea( child: Scaffold( body: Builder( builder: ((context) { // ------------------------------------------------------ const styleCustom = TextStyle(fontFamily: 'myDefaultFont', fontSize: 100); const textCustom = Text('test', style: styleCustom); const style = TextStyle(fontSize: 100); const text = Text('test', style: style); // ------------------------------------------------------ const constraints = ParagraphConstraints(width: 200); final builderCustom = ParagraphBuilder(ParagraphStyle( fontFamily: styleCustom.fontFamily, fontSize: styleCustom.fontSize)); builderCustom.addText(textCustom.data!); final paragraphCustom = builderCustom.build(); paragraphCustom.layout(constraints); final sizeCustom = Size(paragraphCustom.longestLine, paragraphCustom.height); // ------------------------------------------------------ final builder = ParagraphBuilder(ParagraphStyle(fontSize: style.fontSize)); builder.addText(text.data!); final paragraph = builder.build(); paragraph.layout(constraints); final size = Size(paragraph.longestLine, paragraph.height); // ------------------------------------------------------ // fontFamily: '', // Unit test: (200,200) // Android: (170.1, 117) -------<< + 0.2 width // Chrome: (169.9, 117) print(size); // fontFamily: 'myDefaultFont', // Unit test: (174.31640625, 120) -------<< +3 height, +0.01640625 width // Android: (174.3, 117) // Chrome: (174.3, 117) print(sizeCustom); return Column( children: [ text, Text(size.toString()), textCustom, Text(sizeCustom.toString()), ], ); }), ), ), ), ); } }
设备端TextPainter实现代码
import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: SafeArea( child: Scaffold( body: Builder(builder: ((context) { // myDefaultFont == Roboto-Black const styleCustom = TextStyle(fontFamily: 'myDefaultFont', fontSize: 100); const textCustom = Text('test', style: styleCustom); const style = TextStyle(fontSize: 100); const text = Text('test', style: style); // fontFamily: '', // Unit test: (400, 100) -------<< // Android: (171, 117) -------<< // Chrome: (170, 117) var painter = _getPainter(text, style); print(painter.size); // fontFamily: 'myDefaultFont', // Unit test: (175, 120) -------<< // Android: (175, 117) // Chrome: (175, 117) final painterCustom = _getPainter(textCustom, styleCustom); print(painterCustom.size); return Column( children: [ text, Text(painter.size.toString()), textCustom, Text(painterCustom.size.toString()), ], ); })), ), ), ); } _getPainter(Text text, TextStyle style) { final TextPainter painter = TextPainter( text: TextSpan(children: [TextSpan(text: text.data!, style: style)]), textDirection: text.textDirection ?? TextDirection.ltr, maxLines: text.maxLines, textScaleFactor: text.textScaleFactor ?? 1.0, locale: text.locale, textAlign: text.textAlign ?? TextAlign.start, textHeightBehavior: text.textHeightBehavior, textWidthBasis: text.textWidthBasis ?? TextWidthBasis.parent, ); painter.layout(); return painter; } }
单元测试代码
注:单元测试必须匹配174.31640625,否则像174.3会导致测试失败,说明结果未按预期取整。
import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('paragraph', () async { final Future<ByteData> data = rootBundle.load('assets/Roboto-Black.ttf'); final FontLoader fontLoader = FontLoader('myDefaultFont')..addFont(data); await fontLoader.load(); const styleCustom = TextStyle(fontFamily: 'myDefaultFont', fontSize: 100); const textCustom = Text('test', style: styleCustom); const style = TextStyle(fontSize: 100); const text = Text('test', style: style); // ------------------------------------------------------ const constraints = ParagraphConstraints(width: 200); final builderCustom = ParagraphBuilder(ParagraphStyle( fontFamily: styleCustom.fontFamily, fontSize: styleCustom.fontSize)); builderCustom.addText(textCustom.data!); final paragraphCustom = builderCustom.build(); paragraphCustom.layout(constraints); final sizeCustom = Size(paragraphCustom.longestLine, paragraphCustom.height); // ------------------------------------------------------ final builder = ParagraphBuilder(ParagraphStyle(fontSize: style.fontSize)); builder.addText(text.data!); final paragraph = builder.build(); paragraph.layout(constraints); final size = Size(paragraph.longestLine, paragraph.height); print(size); print(sizeCustom); expect(size, const Size(200, 200)); expect(sizeCustom, const Size(174.31640625, 120)); }); test('textpainter', () async { final Future<ByteData> data = rootBundle.load('assets/Roboto-Black.ttf'); final FontLoader fontLoader = FontLoader('myDefaultFont')..addFont(data); await fontLoader.load(); const style = TextStyle(fontSize: 100); const text = Text('test', style: style); const styleCustom = TextStyle(fontFamily: 'myDefaultFont', fontSize: 100); const textCustom = Text('test', style: styleCustom); var painter = _getPainter(text, style); final painterCustom = _getPainter(textCustom, styleCustom); print(painter.size); print(painterCustom.size); expect(painter.size, const Size(400, 100)); expect(painterCustom.size, const Size(175, 120)); }); } _getPainter(Text text, TextStyle style) { final TextPainter painter = TextPainter( text: TextSpan(children: [TextSpan(text: text.data!, style: style)]), textDirection: text.textDirection ?? TextDirection.ltr, maxLines: text.maxLines, textScaleFactor: text.textScaleFactor ?? 1.0, locale: text.locale, textAlign: text.textAlign ?? TextAlign.start, textHeightBehavior: text.textHeightBehavior, textWidthBasis: text.textWidthBasis ?? TextWidthBasis.parent, ); painter.layout(); return painter; }
解决方案:统一文本渲染参数与环境配置
要让Android设备和单元测试的尺寸结果一致,需要从以下几个方面调整:
1. 统一textHeightBehavior参数
单元测试中默认的textHeightBehavior和Android设备存在差异,需要显式设置为与设备一致的配置。在创建TextPainter和ParagraphBuilder时,指定:
textHeightBehavior: const TextHeightBehavior( applyHeightToFirstAscent: true, applyHeightToLastDescent: true, ),
Android设备默认启用这两个参数,而单元测试环境可能未默认开启,导致高度计算差3像素。
2. 确保字体加载完全一致
- 单元测试中加载Roboto-Black字体时,要确保和Android设备使用的是完全相同的字体文件,避免字体文件版本差异导致的度量差异。
- 对于默认字体(未指定fontFamily),单元测试环境默认使用的不是Roboto,需要显式加载Roboto作为默认字体,或者在测试中统一指定fontFamily为Roboto,避免 fallback 到不同字体。
3. 处理浮点精度差异
对于Paragraph的宽度差异(如174.31640625 vs 174.3),可以在测试中使用expect的近似匹配,而不是严格相等:
expect(sizeCustom.width, closeTo(174.3, 0.02)); expect(sizeCustom.height, 117);
或者在设备端和测试端统一设置相同的文本渲染精度配置,不过通常近似匹配更实用。
4. 统一textWidthBasis参数
确保TextPainter的textWidthBasis在设备和测试中一致,默认是TextWidthBasis.parent,但如果有修改需要同步。
5. 设置测试环境的设备像素比
单元测试默认的设备像素比可能和Android设备不同,可以通过以下代码设置:
TestWidgetsFlutterBinding.instance.window.devicePixelRatio = 3.0; // 匹配目标Android设备的DPR
不过这对文本尺寸的影响较小,但如果是涉及像素对齐的场景需要考虑。
内容的提问来源于Stack Exchange,提问作者Dabbel

