You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter:调整哪些参数可使TextPainter/Paragraph在Android与单元测试尺寸一致

问题:Flutter单元测试与Android设备中TextPainter/Paragraph尺寸不一致

我开发的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.21 19:27:01