PixelSnap – 任何像素缩放因子下外观锐利的应用程序

如果您曾经在非整数像素缩放的系统上运行过 Flutter 应用程序,例如 Windows 上的 150% 缩放,您可能会注意到您的精美 Flutter 应用程序突然变得模糊。但这是为什么呢?您没有使用任何位图,Flutter 用矢量绘制一切。那么模糊是从哪里来的呢?

逻辑像素 vs 物理像素

Flutter 坐标系使用逻辑像素,这意味着所有填充、内边距、尺寸和边框宽度都以逻辑像素指定。然而,显示器由物理像素组成,逻辑像素与物理像素之间的比率称为像素设备比率。如果设备像素比率为 1,则一个逻辑像素正好代表 1 个物理像素。如果设备比率为 2,则一个逻辑像素将转换为两个物理像素。

当设备像素比率不是整数时,就会出现问题。在这种情况下,比率为 1.5 的逻辑像素将导致 1.5 个物理像素。一个逻辑宽度为 1 像素的线条将渲染为 1.5 个物理像素宽。由于无法点亮物理像素的分数部分,该线条将被抗锯齿处理,因此看起来会模糊。

blurry-border

1px 边框在 150% 像素缩放下模糊。

非整数像素比率在 Windows 上非常普遍。如果您在缩放为 2.0 的 Mac 上开发应用程序(这可能是最宽容的),您可能甚至没有意识到您有问题,直到您第一次在 Windows 机器上运行应用程序。

2px 笔触对齐像素边界 2px 笔触对齐像素边界

我们如何解决这个问题?

当然是非常努力地确保所有内容都落在物理像素边界上?

假设您的缩放比例为 125%,并且想要绘制一个宽度正好为 1 个物理像素的边框。您不能仅仅将边框宽度设置为 1 个逻辑像素,因为这会导致 1.25 个物理像素。相反,您需要将边框宽度设置为 0.8 个逻辑像素。这将导致正好 1 个物理像素。

您需要对填充、内边距和任何类型的显式尺寸执行相同的操作。

但这还不够。此外,您还需要确保

  • 任何尺寸自适应的控件都需要将尺寸捕捉到物理像素。
  • 任何定位子控件的控件(例如 Align、Flex)都需要确保子控件落在物理像素边界上。例如,即使在 100% 缩放下,Flutter 中的居中控件有时也可能导致子控件模糊,因为子控件可能定位在半个物理像素上。
  • 填充区域并包含多个子控件的布局控件需要确保子控件的尺寸正确捕捉到物理像素,同时确保覆盖整个区域。如果您有一个有 3 个子控件的行,可以填充 100 个物理像素,那么子控件的尺寸需要正好是 33、33 和 34 个物理像素。
  • 每当设备像素比率发生变化时,您都需要重新计算布局,以确保上述条件得到满足。

PixelSnap 来救援

所有这些事情都手动进行将是大量工作,并且可能非常容易出错。幸运的是,您不必这样做。PixelSnap 可以通过多种方式提供帮助

用于方便像素捕捉的扩展方法

您可以使用 pixelSnap() 扩展方法来捕捉数字值、SizeEdgeInsetsRectOffsetDecoration 和其他基本 Flutter 类。

例如

    final widget = Container(
        width: 10.pixelSnap(),
        padding: const EdgeInsets.all(10).pixelSnap(),
        decoration: BoxDecoration(
            border: Border.all(
                width: 1.pixelSnap(),
                color: Colors.black,
            ),
        ),
    );

    // You can use .ps shorthand for numeric values.
    final width = 10.ps; // same as 10.pixelSnap()

像素捕捉的小部件

这看起来已经有所改进,但仍然显得非常手动。布局呢?这如何帮助 AlignRowColumn?当然我们可以做得更好?

是的,我们可以。PixelSnap 提供了许多 Flutter 小部件的轻量级包装器,它们已经为您处理了像素捕捉。要使用此功能,只需导入

import 'package:pixel_snap/widgets.dart';

而不是标准的

import 'package:flutter/widgets.dart';

这会用像素捕捉的替代品替换一些标准的 Flutter 小部件,并重新导出所有其他 Flutter 小部件。

如果您正在使用 Material 或 Cupertino,请导入此文件而不是

import 'package:pixel_snap/material.dart';
import 'package:pixel_snap/cupertino.dart';

注意,除了标准小部件的像素捕捉替代品之外,这还会重新导出原始的(未修改的)Material 和 Cupertino 小部件。

有了这个,上面的例子可以重写为

    final widget = Container(
        width: 10,
        padding: const EdgeInsets.all(10),
        decoration: const BoxDecoration(
            border: Border.all(
                width: 1,
                color: Colors.black,
            ),
        ),
    );

此导入还将为您提供修改后的 FlexRowColumn 小部件,它们将确保所有子小部件都经过正确像素捕捉。

以下是已修改为自动进行像素捕捉的小部件列表

  • Column
  • 文本
  • RichText
  • Center
  • FractionallySizedBox
  • Align
  • Baseline
  • ConstrainedBox
  • DecoratedBox
  • Container
  • FittedBox
  • IntrinsicWidth
  • LimitedBox
  • OverflowBox
  • Padding
  • SizedBox
  • SizedOverflowBox
  • Positioned
  • PhysicalModel
  • CustomPaint
  • Icon
  • Image
  • ImageIcon
  • AnimatedAlign
  • AnimatedContainer
  • AnimatedCrossFade
  • AnimatedPositioned
  • AnimatedPhysicalModel
  • AnimatedSize

如果您坚持使用这些小部件,您的应用程序应该只需很少的额外工作就能实现像素完美的显示。

  • 如果您需要使用尺寸自适应的小部件,则可以用 PixelSnapSize 小部件将其包装起来。这将把小部件的尺寸扩展到最近的物理像素,从而确保该小部件在像素捕捉的小部件层次结构中时不会干扰整体布局。

  • 如果您正在使用不支持物理像素但又足够可定制以至于允许您指定填充、内边距或边框的外部小部件,则可以使用 .pixelSnap() 扩展方法来捕捉它们的像素。

模拟不同的设备像素比率

PixelSnap 带有 PixelSnapDebugBar 小部件。您可以将其放在应用程序小部件的上方(它应该是顶层小部件),它会提供一个栏,用于切换模拟设备像素比率以及打开和关闭像素捕捉。

有关更多详细信息,请参阅示例应用程序。

Screenshot 2022-12-05 at 23 38 25

PixelSnapDebugBar 正在运行的截图。图像可能显示模糊,但实际应用程序具有清晰的 2px 宽边框线,在模拟的 1.75 倍设备像素比率下显示。

Screenshot 2022-12-05 at 23 42 27

启用了像素捕捉的相同应用程序。如果您放大,您可以看到,除了清晰的 2px 黑色边框之外,大多数线条是 3px 宽,带有不同深浅的灰色(由于抗锯齿)。

像素捕捉功能

默认的像素捕捉函数选择以产生以下结果

逻辑像素 缩放因子 物理像素
1 1 1
1 1.25 1
1 1.5 1
1 1.75 2
1 2.0 2
1 2.25 2
1 2.5 2
1 2.75 3

其他注意事项

像素捕捉与任意变换

在 Flutter 中,渲染对象在布局期间通常不知道它们在屏幕上的位置。要使像素捕捉正常工作,渲染对象不能在其祖先中具有任意的缩放/旋转变换。并且所有平移变换都必须正确进行像素捕捉。

这在实践中不应该成为问题。桌面应用程序通常不使用任意的缩放/旋转变换,即使使用,它们通常也是局部化的,并且仅在过渡等临时事件期间使用。

使用具有任意变换的像素捕捉将产生“略有错误”的结果,但由于变换很可能将事物移出像素边界,因此失真应该很难注意到。

更改设备像素比率后重建

PixelSnap 将检测设备像素比率的变化并强制重新组装整个应用程序。因为可能有很多计算需要当前的窗口设备像素比率,所以通过 MediaInfo.of(context) 获取比率成本太高。这也不方便(我们需要渲染对象中的比率以及扩展方法中的比率)。绕过 MediaInfo 意味着 Flutter 不知道哪些小部件依赖于设备像素比率,因此我们最终会重建整个树。

这并不理想,但更改设备像素比率本身已经是一项昂贵的操作,因此这种权衡似乎是可以接受的。在窗口在显示器之间移动时可能在调整大小时丢失动画帧似乎不是大问题。

GitHub

查看 Github