Flutter 里的像素对齐问题,深入理解为什么界面有时候会出现诡异的细线?

你是否有过在使用 Flutter 的 Row/Column 或者列表控件布局时,发现屏幕出现了一些诡异的细线,而且这些细线出现并不规律,甚至粗细和深浅也存在差别:

这个问题在 Flutter 的 #14288 issue 被讨论至今依然没有 closed ,不是因为它没办法被彻底解决,而是它需要一个全新的底层的策略来 fix, 而这个策略看起来就在于 Impeller 。

事实上这类问题也不是 Flutter 特有,比如在原生 Android 里也有类似问题,比如在某些时候对文本进行平滑的缩放动画时,会遇到随着动画的变化,字体出现了抖动的情况:

这是因为随着字号的连续变化,字符(字形)的边界和位置可能会落在小数像素坐标上,而为了渲染,默认 Android 系统可能会将它们强制对齐到最近的整数像素,导致在动画过程中字符出现肉眼可见的"跳动"或"抖动" 。

针对这个问题,Android 的 Paint 提供了 SUBPIXEL_TEXT_FLAGLINEAR_TEXT_FLAG 等标志位,开启这些标志可以允许字形以亚像素精度进行定位和测量,从而让文本缩放动画变得平滑,但这本质上是用"平滑的模糊"换取了"锐利的跳动" 。

Android 原生这个问题和 Flutter 的"细线"问题表现得南辕北辙,但是引起问题的本质其实都一样:逻辑像素转换为物理像素时出现了浮点(亚像素)的情况

简单说,就是你写的像素 1,对应到实际物理像素的时候成了 1.5,所以这时候要么系统强制对齐为 2 ,要么就是通过某种处理让 1 个像素来模拟这 0.5 个像素,这两种选择对应的就是前面 Android 字体动画的不同表现。

而在 Flutter 里,Skia 在渲染时为了处理你这 0.5 个像素,就需要对这 0.5 个像素点做了模糊处理,也就是进行抗锯齿( Anti-Aliasing)渲染:

由于物理像素是最小的显示单元,没办法"点亮"半个像素,因此渲染引擎必须通过抗锯齿技术来模拟这个宽度,通过混合边缘像素及其相邻像素的颜色,来模拟一条平滑的边界,从而柔化由离散像素网格造成的阶梯状锯齿,而这也成了前面 Flutter 出现"细线"的来源。

也就是,你看到那些"细线",基本来自于相邻浮点像素的抗锯齿呈现,而这种情况特别容易出现在用户自定义显示缩放比例的设备上,例如:

曾经 win 的放大 125% 或者 150% 带来的字体模糊问题其实就很有代表性。

而 Android 设备也类似,因为不同的屏幕分辨率和像素密度组合下,并且一些系统还支持设置设备像素比(Device Pixel Ratio, DPR),用户可以自定义显示缩放比例,导致 DPR 出现 1.25、1.5 或 1.75 这样的非整数值,甚至和物理屏幕的 RGB 色彩排列方式有关系:

比如 Pixel 9 Pro 是 devicePixelRatio 是 2.25,200 的方块的话,就是 200 * 2.25 = 450,而 210 * 2.25 = 472.5 ,这就是不同高度方块在「同色独立背景时」会导致这个问题的原因之一:

另外,比如华为上就有直接调整显示大小的设置,用户可以根据需要调整「屏幕像素密度」而不是单纯字体大小,通过微信 tabbar 的变化,也可以看到这种自定义显示缩放比例对 UI 带来的影响:

而对于绝大多数渲染引擎,包括 Flutter 在内,都是使用双精度浮点数(double)进行所有的空间位置和尺寸计算,但是浮点其实也无法精确地表示大多数十进制小数,例如经典的 0.1 + 0.2 的计算结果并非精确的 0.3,而是一个非常接近的近似值,如 0.30000000000000004

也就是浮点换算会存在细微误差和累计误差的可能,特别是累计误差,这种微小误差的累计也是导致换算不精确,进而出现"细线"的另一个原因,比如:

一个宽度为 100 逻辑像素的容器需要被三个子控件平均分配,在理想的数学世界里,每个子控件的宽度是 100/3=33.333....,而在浮点运算中,布局引擎在后续处理中需要对每个子控件的计算结果进行取整(round)或截断(truncate),例如都处理为 33 逻辑像素,那么三个子控件的总宽度将是 33+33+33=99 逻辑像素,这与父容器的 100 逻辑像素宽度相比,就产生了一个 1 逻辑像素的未被填充的空隙,即用户看到的"1像素缝隙"线的由来。

而对于浮点数加法不一定满足 (a+b)+c≠a+(b+c) 的情况,这也意味着布局结果可能因元素的处理顺序而导致出现的偏差不同。

这个问题在像素映射出现浮点的时候比较明显,比如下图可以看到控件在某个位置停下来的时候,白色头部出现了灰色细线闪过,这其实就是某些特定像素比下,在运动过程中产生的浮点累计影响:

过去在 Flutter 3.3 之前,在一些低 DPR 设备上,composited layers 会执行像素对齐(pixel snapping)操作,即始终将合成图层与精确的像素边界对齐,但是这又导致了在动画过程中出现抖动,因为对齐导致的浮点误差导致了像素"跳跃"的情况(类似前面 Android 数字放大),所以像素对齐后来被移除。

当然,这种情况不是一定出现,而是出现在映射浮点和累计误差的时候,Skia 进行抗锯齿处理时才会出现的边界情况,特别是两个同颜色的控件紧挨着的时候:

两个独立的抗锯齿计算都认为自己没有完全覆盖这个像素,它们的覆盖率之和可能是 40% + 50% = 90%,而不是 100%,那剩下的 10% 的背景色就从这个微小的"缝隙"中"泄露"了出来,结合抗锯齿的透明度,形成了一条肉眼可见的、颜色更浅或半透明的细线。

所以,应对这部分场景,最好就是让颜色统一在背景渲染,或者尽量保证避免映射中的 0.5 场景的出现,而 pixel_snap 这个库,就是为了解决这个问题而出现。

pixel_snap

pixel_snap 主要做的其实就是一种像素对齐操作,核心就是根据设备的 devicePixelRatio 来进行换算,将逻辑值直接换算到物理像素,实现 1:1 的映射,从而避免出现抗锯齿模糊:

例如在 125% 缩放(DPR=1.25)下要绘制一条 1 物理像素宽的边框,开发者不能直接设置宽度为 1.0 逻辑像素,而应设置为 0.8 逻辑像素,引擎最终会将这个 0.8 逻辑像素的宽度渲染为 1 物理像素。

并且 pixel_snap 提供了三种四舍五入模式:

  • PixelSnapMode.snap :四舍五入到最接近的整数
  • PixelSnapMode.ceil :始终向上舍入到下一个整数
  • PixelSnapMode.floor :始终向下舍入到前一个整数

那么怎么具体理解这个问题?假设你让 Flutter 在 1.5x 的设备上画一个宽度为 55.5 逻辑像控件,系统会这样计算它在屏幕上需要占据的物理像素宽度:

55.5 (逻辑像素) * 1.5 (像素比) = 83.25 (物理像素)

而因为屏幕无法画出 83.25 个物理像素,它只能画 83 个或者 84 个,为了模拟出 0.25 的效果,系统就会用抗锯齿导致可能出现"细线"。

而通过 pixel_snap 的公式,我们把系统换算这一步提前到 dart 进行处理,把逻辑单位转换成物理单位,这样我们就提前得到了"有问题"的 83.25

ini 复制代码
logical_value * devicePixelRatio => 55.5 * 1.5 = 83.25

接着进行取整(对齐到物理像素网格), round() 函数(或 ceil / floor) 强制把这个带小数的物理像素值,对齐到了最近的整数物理像素网格上,这是我们就有了一个没有小数点的物理像素 83 :

scss 复制代码
round(...)  => round(83.25) = 83

最后再次转换回逻辑像素,因为 Flutter 的布局系统不直接使用物理像素,所以还需要逻辑像素值才能使用:

ini 复制代码
/ devicePixelRatio  => 83 / 1.5 = 55.333...

那么这个过程发生了什么?在经过 pixel_snap 的计算后,最初想要的 55.5 被修正成了 55.333... ,然后你告诉 Flutter:"请给我一个 55.333... 逻辑像素宽的控件" ,系统收到这个新值后,就会计算出最终它需要渲染的物理像素 83:

而 83 物理像素,就不会有浮点像素,从而出现抗锯齿的问题。

当然,看到你这里你应该发现了一个问题,那就是使用 pixel_snap ,你就没办法完全和设计稿 1:1 复刻,当然,实际物理渲染时总归需要对齐。

另外,pixel_snap 还 fork了 一些列控件进行布局对齐,例如针对 Flex 控件,会采用「余数分配算法」进行重构,从概念上:

  • 获取总空间:计算父容器在主轴上的可用空间,并将其转换为整数个物理像素(例如 100 物理像素)
  • 计算理想尺寸:用浮点数计算每个子控件的理想平均尺寸(例如,100/3=33.333... 物理像素)
  • 分配基础尺寸 :为每个子控件分配向下取整后的基础尺寸(例如,每个 child 获得 33 物理像素)
  • 计算已分配空间和余数:计算已分配的总空间(33×3=99 物理像素),并得出余数(100−99=1 物理像素)
  • 分配余数 :从某个子控件开始,依次将一个剩余像素分配给子控件,直到所有余数分配完毕,例如最后一个子控件的最终尺寸为 33+1=34 物理像素,其余两个子控件为 33 物理像素,最终尺寸组合为 (33, 33, 34),总和恰好为 100,从而完美地填充了父容器,消除了任何缝隙

对应到实际算法里,比如在源码里的 _computeSizes 方法,实际行为可以简化为:

  • 对于非最后一个弹性 Child :它的空间是根据其 flex 因子和每个 flex 单位可分配的空间 spacePerFlex 来计算的,关键在于这个计算结果会立刻通过 .pixelSnap(pixelSnap) 方法进行像素对齐,这可能会产生微小的舍入(比如理论值是 33.33 像素,对齐后变成 33 像素)
  • 对于最后一个弹性 Child :代码不再进行比例计算,而是直接从 freeSpace (总可用弹性空间) 中减去 allocatedFlexSpace (已经被前面分配掉的空间) freeSpace - allocatedFlexSpace 的差值,就是包含了所有前面控件在像素对齐时"舍去"的微小余数的精确剩余空间,把这部分空间完全交给最后一个子控件,就从根本上保证了所有子控件的总尺寸能完美填充父容器(33+33+34),不会因累积的舍入误差而产生缝隙
dart 复制代码
// ... inside _computeSizes method
    if (totalFlex > 0) {
      int remainingFlex = totalFlex;
      child = firstChild;
      while (child != null) {
        // ... (spacePerFlex calculation) ...
        final int flex = _getFlex(child);
        remainingFlex -= flex;
        if (flex > 0) {
          // ↓↓↓ 余数分配算法 ↓↓↓
          final double maxChildExtent = canFlex
              ? (child == lastFlexChild // 判断是否为最后一个弹性子控件
                  ? (freeSpace - allocatedFlexSpace) // 是:直接获得所有剩余空间
                  : (spacePerFlex * flex).pixelSnap(pixelSnap)) // 否:按比例计算并像素对齐
              : double.infinity;
          // ↑↑↑ 余数分配算法 ↑↑↑
          
          // ... (rest of the layout logic) ...
          
          allocatedFlexSpace += maxChildExtent;
          // ...
        }
        // ...
        child = childParentData.nextSibling;
      }
    }
// ...

简单来说,这个算法的策略就是:"前面的兄弟们都按规矩(比例和对齐)拿,最后一个兄弟负责把所有剩下的零头全拿走,保证一滴不漏"。

最后,除了核心的吸附和布局逻辑,pixel_snap 还提供了 PixelSnapScrollController,一个自定义的 ScrollController,它能确保滚动视图的 scroll offset 始终吸附到物理像素边界,防止在滚动过程中出现整屏的模糊 。

通过 pixel_snap 可以在上层解决大部分对齐问题。

Impeller

接着我们要讲 Impeller ,为什么要讲 Impeller?因为前面我们所说的,在 Skia 上因为抗锯齿带来的细线问题,在 Impeller 上有部分在"呈现"上被成功出现掉了。

为什么说是在"呈现"被处理掉?核心在于 Impeller 使用了不一样的默认抗锯齿实现: 多重采样抗锯齿(Multisample Anti-Aliasing, MSAA) ,它会通过在每个物理像素内部采集多个样本点来计算最终颜色,从而产生比传统抗锯齿更平滑、质量更高的边缘:

而对于 Skia,在此之前 Flutter 上使用的是 Analytic Anti-Aliasing(AAA),由于 AAA 是通过分析像素并进行模糊处理来平滑边缘,它有时会错误地将画面中的纹理细节或其他高对比度区域当作锯齿进行处理,导致锯齿相对变糊的情况:

而 MSAA 不再计算覆盖率,而是进行采样,从而在每个像素内部设置多个采样点(例如 4x MSAA 就有 4 个点),而由于它在像素内部有多个采样点,因此能更精确地计算出边缘的覆盖率。

但是,MSAA 并没有改变 UI 元素被布局在非整数物理像素坐标上这一事实,它只是让这个"错误"的位置看起来"更顺眼"

所以,当出现需要渲染为 1.5 物理像素宽的线条时,在 MSAA 的加持下,仍然会跨越多个物理像素进行渲染,MSAA 只是让这个过程中的颜色混合看起来相对精细和准确。

当然,并不是说 Skia 就不支持 MSAA ,Skia 也可以 MSAA ,但是这个支持在 Flutter 中很难广泛应用,因为 Skia 的渲染方式更接近于一个立即执行的绘图 API,并且它是运行时着色器编译。

而 Flutter 的图层合成(Layer Compositing)机制,MSAA 会频繁地触发 Skia 创建离屏缓冲区(Offscreen Buffers),最典型的例子就是 saveLayer ,这个操作会导致性能急剧下降,而如果全局默认开启 MSAA,那么每一个由 saveLayer 创建的临时缓冲区都需要支持 MSAA ,这意味着:

  • 内存爆炸 :MSAA 缓冲区本身就需要 4x 或更多的内存,如果一个复杂的页面有十几个 saveLayer,内存占用会急剧飙升
  • 性能雪崩:每次创建、渲染到、再解析一个 MSAA 缓冲区都有相当大的性能开销,在一帧之内反复进行这个过程,会导致严重的性能下降。

而 Impeller 的工作方式就更像现代游戏引擎,它会提前将 Flutter 的 Scene Graph 转换为一个优化的渲染命令列表,整个 Impeller 的架构设计都在极力避免创建 saveLayer 那样的中间缓冲区,因此大部分绘制命令都在同一个主渲染通道(Render Pass)中执行,所以 Impeller 只需要为这一个通道启用 MSAA 即可。

另外 Impeller 在引擎构建时预编译一组更小、更简单的着色器,因此它们不会在运行时进行编译,并且针对 MSAA 进行渲染时:

  • CPU 端 Tessellation:Impeller 会在 CPU 上将所有需要绘制的图形------无论是圆形、圆角矩形、复杂的矢量路径,还是文本字形,全部预先转换为三角形网格(Triangle Mesh)
  • GPU :转换完成后,发送给 GPU 的就只是一大堆简单的三角形,从 GPU 的角度看,它不再需要知道自己画的是圆还是路径,此时它只需要高效地将这些三角形光栅化到 MSAA 缓冲区即可

详细对比可见:Compose Multiplatform Skia 对比 Flutter Impeller ,都是跨平台自绘有什么差异

另外,3.32 的 Impeller 也改进了的文本渲染,从而让 Impeller 字形图集中的字形分辨率更高,文本动画更流畅,抖动更少,修复了浮点计算中的舍入错误,比如这个对比:

当然,Impeller 也没有完全解决所有对齐场景的视觉问题,所以如果一些情况下,你还是会需要 pixel_snap 的支持,而你在 Impeller 上看起问题好像被解决,其实也只是问题被隐藏了而已。

最后

本篇主要是从概念上介绍像素为什么会不对齐,还有不对齐会带来的问题,进而介绍对应的解决问题的场景和思路,当然设计的东西可能很多,但是它能用的也不只是 Flutter ,其实对于 UI 引擎来说,都可能会出现类似的问题,当然解决的思路也大相径庭。

那么,你是否也遇到过这样的细节?当时又是怎么解决的呢?是否也尝试过 isAntiAlias = false 或者 Clip.hardEdge 来"逃课"改问题?

参考链接

相关推荐
怪可爱的地球人20 小时前
骨架屏
前端
用户6778471506220 小时前
前端将html导出为word文件
前端
TimeFine20 小时前
Android WebView暗夜模式适配
android
studyForMokey20 小时前
【Android Activity】生命周期深入理解
android·kotlin
前端付豪20 小时前
如何使用 Vuex 设计你的数据流
前端·javascript·vue.js
李雨泽20 小时前
通过 Prisma 将结构推送到数据库
前端
浅影歌年20 小时前
Android 嵌入h5顶部状态栏空白
android
前端小万20 小时前
使用 AI 开发一款聊天工具
前端·全栈
咖啡の猫20 小时前
Vue消息订阅与发布
前端·javascript·vue.js
GIS好难学21 小时前
Three.js 粒子特效实战③:粒子重组效果
开发语言·前端·javascript