注册

Flutter 3.7 之快速理解 toImageSync 是什么?能做什么?

随着 Flutter 3.7 的更新, dart:ui 下多了 Picture.toImageSyncScene.toImageSync 这两个方法,和Picture.toImage 以及 Scene.toImage 不同的是 ,toImageSync 是一个同步执行方法,所以它不需要 await 等待,而调用 toImageSync 会直接返回一个 Image 的句柄,并在 Engine 后台会异步对这个 Image 进行光栅化处理。


前言


toImageSync 有什么用?不是有个 toImage 方法了,为什么要多一个 Sync 这样的同步方法?




  • 目前 toImageSync 最大的特点就是图像会在 GPU 中常驻 ,所以对比 toImage 生成的图像,它的绘制速度会更快,并且可以重复利用,提高效率。



    toImage 生成的图像也可以实现 GPU 常驻,但目前没有未实现而已。





  • toImageSync 是一个同步方法,在某些场景上弥补了 toImage 必须是异步的不足。





toImageSync 的使用场景上,官方也列举了一些用途,例如:



  • 快速捕捉一张昂贵的栅格化图片,用户支持跨多帧重复使用
  • 应用在图片的多路过滤器上
  • 应用在自定义着色器上

具体在 Flutter Framework 里,目前 toImageSync 最直观的实现,就是被使用在 Android 默认的页面切换动画 ZoomPageTransitionsBuilder 上,得意于 toImageSync 的特性,Android 上的页面切换动画的性能,几乎减少了帧光栅化一半的时间,从而减少了掉帧和提高了刷新率。



当然,这是通过牺牲了一些其他特性来实现,后面我们会讲到。



SnapshotWidget


前面说了 toImageSync 让 Android 的默认页面切换动画性能得到了大幅提升,那究竟是如何实现的呢?这就要聊到 Flutter 3.7 里新增加的 SnapshotWidget


其实一开始 SnapshotWidget 是被定义为 RasterWidget ,从初始定义上看它的 Target 更大,但是最终在落地的时候,被简化处理为了 SnapshotWidget ,而从使用上看确实 Snapshot 更符合它的设定。



概念


SnapshotWidget 的作用是可以将 Child 变成的快照(ui.Image)从而替换它们进行显示,简而言之就是把子控件都变成一个快照图片,而 SnapshotWidget 得到快照的办法就是 Scene.toImageSync



那么到这里,你应该知道为什么 toImageSync 可以提高 Android 上的页面切换动画的性能了吧?因为 SnapshotWidget 会在页面跳转时把 Child 变成的快照,而 toImageSync 栅格化的图片还可以跨多帧重复使用。



那么问题来了,SnapshotWidget 既然是通过 toImageSync 将 Child 变成的快照(ui.Image)来提高性能,那么带来的副作用是什么?


答案是动画效果,因为子控件都变成了快照,所以如果 Child 控件带有动画效果,会呈现“冻结”状态,更形象的对比如下图所示:















FadeUpwardsPageTransitionsBuilderZoomPageTransitionsBuilder

默认情况下 Flutter 在 Android 上的页面切换效果使用的是 ZoomPageTransitionsBuilder ,而 ZoomPageTransitionsBuilder 里在页面切换时会开启 SnapshotWidget 的截图能力,所以可以看到,它在页面跳转时,对比 FadeUpwardsPageTransitionsBuilder 动图, ZoomPageTransitionsBuilder 的红色方块和掘金动画会停止。



因为动画很短,所以可以在代码里设置 timeDilation = 40.0;SchedulerBinding.resetEpoch 来全局减慢动画执行的速度,另外可以配置 MaterialApp ThemeData 下对应的 pageTransitionsTheme 来切换页面跳转效果。



所以在官方的定义中,SnapshotWidget 是用来协助执行一些简短的动画效果,比如一些 scale 、 skew 或者 blurs 动画在一些复杂的 child 构建上开销会很大,而使用 toImageSync 实现的 SnapshotWidget 可以依赖光栅缓存:



对于一些简短的动画,例如 ZoomPageTransitionsBuilder 的页面跳转, SnapshotWidget 会将页面内的 children 都转化为快照(ui.Image),尽管页面切换时会导致 child 动画“冻结”,但是实际页面切换时长很短,所以看不出什么异常,而带来的切换动画流畅度是清晰可见的



再举个更直观的例子,如下代码所示,运行后我们可以看到一个旋转的 logo 在屏幕上随机滚动,这里分别使用了 AnimatedSlideAnimatedRotation 执行移动和旋转动画。


Timer.periodic(const Duration(seconds: 2), (timer) {
final random = Random();
x = random.nextInt(6) - 3;
y = random.nextInt(6) - 3;
r = random.nextDouble() * 2 * pi;
setState(() {});
});

AnimatedSlide(
offset: Offset(x.floorToDouble(), y.floorToDouble()),
duration: Duration(milliseconds: 1500),
curve: Curves.easeInOut,
child: AnimatedRotation(
turns: r,
duration: Duration(milliseconds: 1500),
child: Image.asset(
'static/test_logo.png',
width: 100,
height: 100,
),
),
)


如果这时候在 AnimatedRotation 上层加多一个 SnapshotWidget ,并且打开 allowSnapshotting ,可以看到此时 logo 不再转动,因为整个 child 已经被转化为快照(ui.Image)。











所以 SnapshotWidget 不适用于子控件还需要继续动画或有交互响应的地方,例如轮播图。



使用


如之前的代码所示,使用 SnapshotWidget 也相对简单,你只需要配置 SnapshotController ,然后通过 allowSnapshotting 控制子控件是否渲染为快照即可。


 controller.allowSnapshotting = true;

SnapshotWidget 在捕获快照时,会生成一个全新的 OffsetLayerPaintingContext,然后通过 super.paint 完成内容捕获(这也是为什么不支持 PlatformView 的原因之一),之后通过 toImageSync 得到完整的快照(ui.Image)数据,并交给 SnapshotPainter 进行绘制。










所以 SnapshotWidget 完成图片绘制会需要一个 SnapshotPainter ,默认它是通过内置的 _DefaultSnapshotPainter 实现,当然我们也可以自定义实现 SnapshotPainter 来完成自定义逻辑。



从实现上看,SnapshotPainter 用来绘制子控件快照的接口,正如上面代码所示,会根据 child 是否支持捕获(_childRaster == null),从而选择调用 paintpaintSnapshot 来实现绘制。



另外,目前受制于 toImageSync 的底层实现, SnapshotWidget 无法捕获 PlatformView 子控件,如果遇到 PlatformView,SnapshotWidget 会根据 SnapshotMode 来决定它的行为:



















normal默认行为,如果遇到无法捕获快照的子控件,直接 thrown
permissive宽松行为,遇到无法捕获快照的子控件,使用未快照的子对象渲染
forced强制行为,遇到无法捕获快照的子控件直接忽略

另外 SnapshotPainter 可以通过调用 notifyListeners 触发 SnapshotWidget 使用相同的光栅进行重绘,简单来说就是:



你可以在不需要重新生成新快照的情况下,对当然快照进行一些缩放、模糊、旋转等效果,这对性能会有很大提升



所以在 SnapshotPainter 里主要需要实现的是 paintpaintSnapshot 两个方法:




  • paintSnapshot 是绘制 child 快照时会被调用




  • paint 方法里主要是通过 painter (对应 super.paint)这个 Callback 绘制 child ,当快照被禁用或者 permissive 模式下遭遇 PlatformView 时会调用此方法





举个例子,如下代码所示,在 paintSnapshot 方法里,通过调整 Paint ..color ,可以在前面的小 Logo 快照上添加透明度效果:


class TestPainter extends SnapshotPainter {
final Animation<double> animation;

TestPainter({
required this.animation,
});

@override
void paint(PaintingContext context, ui.Offset offset, Size size,
PaintingContextCallback painter) {}

@override
void paintSnapshot(PaintingContext context, Offset offset, Size size,
ui.Image image, Size sourceSize, double pixelRatio) {
final Rect src = Rect.fromLTWH(0, 0, sourceSize.width, sourceSize.height);
final Rect dst =
Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height);
final Paint paint = Paint()
..color = Color.fromRGBO(0, 0, 0, animation.value)
..filterQuality = FilterQuality.low;
context.canvas.drawImageRect(image, src, dst, paint);
}

@override
void dispose() {
super.dispose();
}

@override
bool shouldRepaint(covariant TestPainter oldDelegate) {
return oldDelegate.animation.value != animation.value;
}
}


其实还可以把移动的动画部分挪到 paintSnapshot 里,然后通过对 animation 的状态进行管理,然后通过 notifyListeners 直接更新快照绘制,这样在性能上会更有优势,Android 上的 ZoomPageTransitionsBuilder 就是类似实现。


  animation.addListener(notifyListeners);
animation.addStatusListener(_onStatusChange);

void _onStatusChange(_) {
notifyListeners();
}
@override
void paintSnapshot(PaintingContext context, Offset offset, Size size, ui.Image image, Size sourceSize, double pixelRatio) {
_drawMove(context, offset, size);
}

@override
void paint(PaintingContext context, ui.Offset offset, Size size, PaintingContextCallback painter) {
switch (animation.status) {
case AnimationStatus.completed:
case AnimationStatus.dismissed:
return painter(context, offset);
case AnimationStatus.forward:
case AnimationStatus.reverse:
}
....
}


更多详细可以参考系统 ZoomPageTransitionsBuilder 里的代码实现。



拓展探索


其实除了 SnapshotWidget 之外,RepaintBoundary 也支持了 toImageSync , 因为 toImageSync 获取到的是 GPU 中的常驻数据,所以在实现类似控件截图和高亮指引等场景绘制上,理论上应该可以得到更好的性能预期。


final RenderRepaintBoundary boundary =
globalKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final ui.Image image = boundary.toImageSync();

除此之外,dart:ui 里的 Scene_Image 对象其实都是 NativeFieldWrapperClass1 ,以前我们解释过:NativeFieldWrapperClass1 就是它的逻辑是由不同平台的 Engine 区分实现











所以如果你直接在 flutter/bin/cache/pkg/sky_engine/lib/ui/compositing.dart 下去断点 toImageSync 是无法成功执行到断点位置的,因为它的真实实现在对应平台的 Engine 实现。




另外,前面我们一直说 toImageSync 对比 toImage 是 GPU 常驻,那它们的区别在哪里?从上图我们就可以看出:



  • toImageSync 执行了 Scene:RasterizeToImage 并返回 Dart_Null 句柄
  • toImage 执行了 Picture:RasterizeLayerTreeToImage 并直接返回

简单展开来说,就是:



  • toImageSync 最终是通过 SkImage::MakeFromTexture 通过纹理得到一个 GPU SkImage 图片
  • toImage 是通过 makeImageSnapshotmakeRasterImage 生成 SkImagemakeRasterImage 是一个复制图像到 CPU 内存的操作。











其实一开始 toImageSync 是被命令为 toGpuImage ,但是为了更形象通用,最后才修改为 toImageSync



toImageSync 等相关功能的落地可以说同样历经了漫长的讨论,关于是否提供这样一个 API 到最终落地,其执行难度丝毫不比 background isolate 简单,比如:是否定义异常场景,遇到错误是否需要在Framwork 层消化,是否真的需要这样的接口来提高性能等等。












toImageSync 等相关功能最终能落地,其中最重要的一点我认为是:



toGoulmage gives the framework the ability to take performance into their own hands, which is important given that our priorities don't always line up.



最后


toImageSync 只是一个简单的 API ,但是它的背后经历了很多故事,同时 toImageSync 和它对应的封装 SnapshotWidget ,最终的目的就是提高 Flutter 运行的性能。


也许目前对于你来说 toImageSync 并不是必须的,甚至 SnapshotWidget 看起来也很鸡肋,但是一旦你需要处理复杂的绘制场景时, toImageSync 就是你必不可少的菜刀。


作者:恋猫de小郭
链接:https://juejin.cn/post/7197326179933372476
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册