GSYFlutterBook/Flutter-N18.md

264 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Flutter 3.7 之快速理解 toImageSync 是什么?能做什么?
随着 Flutter 3.7 的更新, `dart:ui` 下多了 `Picture.toImageSync``Scene.toImageSync` 这两个方法,和`Picture.toImage` 以及 `Scene.toImage` 不同的是 `toImageSync` 是一个同步执行方法,所以它不需要 `await` 等待,而调用 `toImageSync` 会直接返回一个 Image 的句柄,并在 Engine 后台会异步对这个 Image 进行光栅化处理。
# 前言
`toImageSync ` 有什么用?不是有个 `toImage` 方法了,为什么要多一个 Sync 这样的同步方法?
- **目前 `toImageSync ` 最大的特点就是图像会在 GPU 中常驻** ,所以对比 `toImage` 生成的图像,它的绘制速度会更快,并且可以重复利用,提高效率。
> `toImage` 生成的图像也可以实现 GPU 常驻,但目前没有未实现而已。
- `toImageSync ` 是一个同步方法,在某些场景上弥补了 `toImage` 必须是异步的不足。
![](http://img.cdn.guoshuyu.cn/20230207_sync/image1.png)
`toImageSync ` 的使用场景上,官方也列举了一些用途,例如:
- 快速捕捉一张昂贵的栅格化图片,用户支持跨多帧重复使用
- 应用在图片的多路过滤器上
- 应用在自定义着色器上
具体在 Flutter Framework 里,目前 `toImageSync ` 最直观的实现,就是被使用在 Android 默认的页面切换动画 `ZoomPageTransitionsBuilder ` 上,得意于 `toImageSync ` 的特性Android 上的页面切换动画的性能,**几乎减少了帧光栅化一半的时间**,从而减少了掉帧和提高了刷新率。
> 当然,这是通过牺牲了一些其他特性来实现,后面我们会讲到。
# SnapshotWidget
前面说了 `toImageSync ` 让 Android 的默认页面切换动画性能得到了大幅提升,那究竟是如何实现的呢?这就要聊到 Flutter 3.7 里新增加的 `SnapshotWidget`
其实一开始 `SnapshotWidget` 是被定义为 `RasterWidget` ,从初始定义上看它的 Target 更大,但是最终在落地的时候,被简化处理为了 `SnapshotWidget` ,而从使用上看确实 Snapshot 更符合它的设定。
![](http://img.cdn.guoshuyu.cn/20230207_sync/image2.png)
## 概念
**`SnapshotWidget` 的作用是可以将 Child 变成的快照(`ui.Image`)从而替换它们进行显示,简而言之就是把子控件都变成一个快照图片**,而 `SnapshotWidget` 得到快照的办法就是 `Scene.toImageSync`
> 那么到这里,你应该知道为什么 `toImageSync ` 可以提高 Android 上的页面切换动画的性能了吧?因为 `SnapshotWidget` 会在页面跳转时把 Child 变成的快照,而 `toImageSync ` 栅格化的图片还可以跨多帧重复使用。
那么问题来了,`SnapshotWidget` 既然是通过 `toImageSync ` 将 Child 变成的快照(`ui.Image`)来提高性能,那么带来的副作用是什么?
答案是动画效果,**因为子控件都变成了快照,所以如果 Child 控件带有动画效果,会呈现“冻结”状态**,更形象的对比如下图所示:
| FadeUpwardsPageTransitionsBuilder | ZoomPageTransitionsBuilder |
| -------------------------------------------------------- | -------------------------------------------------------- |
| ![](http://img.cdn.guoshuyu.cn/20230207_sync/image3.gif) | ![](http://img.cdn.guoshuyu.cn/20230207_sync/image4.gif) |
默认情况下 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 在屏幕上随机滚动,这里分别使用了 `AnimatedSlide``AnimatedRotation` 执行移动和旋转动画。
```dart
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,
),
),
)
```
![](http://img.cdn.guoshuyu.cn/20230207_sync/image5.gif)
如果这时候在 `AnimatedRotation` 上层加多一个 `SnapshotWidget` ,并且打开 `allowSnapshotting` ,可以看到此时 logo 不再转动,因为整个 child 已经被转化为快照(`ui.Image`)。
| ![](http://img.cdn.guoshuyu.cn/20230207_sync/image6.png) | ![](http://img.cdn.guoshuyu.cn/20230207_sync/image7.gif) |
| -------------------------------------------------------- | -------------------------------------------------------- |
>所以 `SnapshotWidget` 不适用于子控件还需要继续动画或有交互响应的地方,例如轮播图。
## 使用
如之前的代码所示,使用 `SnapshotWidget` 也相对简单,你只需要配置 `SnapshotController` ,然后通过 `allowSnapshotting `控制子控件是否渲染为快照即可。
```dart
controller.allowSnapshotting = true;
```
`SnapshotWidget` 在捕获快照时,会生成一个全新的 `OffsetLayer``PaintingContext`,然后通过 `super.paint` 完成内容捕获(这也是为什么不支持 PlatformView 的原因之一),之后通过 `toImageSync` 得到完整的快照(`ui.Image`)数据,并交给 `SnapshotPainter` 进行绘制。
| ![](http://img.cdn.guoshuyu.cn/20230207_sync/image8.png) | ![](http://img.cdn.guoshuyu.cn/20230207_sync/image9.png) |
| -------------------------------------------------------- | -------------------------------------------------------- |
所以 `SnapshotWidget` 完成图片绘制会需要一个 `SnapshotPainter` ,默认它是通过内置的 `_DefaultSnapshotPainter` 实现,当然我们也可以自定义实现 `SnapshotPainter` 来完成自定义逻辑。
> 从实现上看,`SnapshotPainter` 用来绘制子控件快照的接口,正如上面代码所示,会根据 child 是否支持捕获(`_childRaster == null`),从而选择调用 `paint` 或 `paintSnapshot` 来实现绘制。
另外,目前受制于 `toImageSync ` 的底层实现, `SnapshotWidget` 无法捕获 PlatformView 子控件,如果遇到 PlatformView`SnapshotWidget` 会根据 `SnapshotMode` 来决定它的行为:
| normal | 默认行为,如果遇到无法捕获快照的子控件,直接 thrown |
| ---------- | ---------------------------------------------------------- |
| permissive | 宽松行为,遇到无法捕获快照的子控件,使用未快照的子对象渲染 |
| forced | 强制行为,遇到无法捕获快照的子控件直接忽略 |
另外 `SnapshotPainter` 可以通过调用 `notifyListeners` 触发 `SnapshotWidget` 使用相同的光栅进行重绘,简单来说就是:
> **你可以在不需要重新生成新快照的情况下,对当然快照进行一些缩放、模糊、旋转等效果,这对性能会有很大提升**。
所以在 `SnapshotPainter` 里主要需要实现的是 `paint``paintSnapshot` 两个方法:
- paintSnapshot 是绘制 child 快照时会被调用
- paint 方法里主要是通过 `painter` (对应 `super.paint`)这个 Callback 绘制 child ,当快照被禁用或者 `permissive` 模式下遭遇 PlatformView 时会调用此方法
![](http://img.cdn.guoshuyu.cn/20230207_sync/image10.png)
举个例子,如下代码所示,在 `paintSnapshot` 方法里,通过调整 `Paint ..color` ,可以在前面的小 Logo 快照上添加透明度效果:
```dart
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;
}
}
```
![](http://img.cdn.guoshuyu.cn/20230207_sync/image11.gif)
其实还可以把移动的动画部分挪到 `paintSnapshot` 里,然后通过对 animation 的状态进行管理,然后通过 `notifyListeners` 直接更新快照绘制这样在性能上会更有优势Android 上的 `ZoomPageTransitionsBuilder` 就是类似实现。
```dart
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 中的常驻数据,所以在**实现类似控件截图和高亮指引等场景绘制上**,理论上应该可以得到更好的性能预期。
```dart
final RenderRepaintBoundary boundary =
globalKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final ui.Image image = boundary.toImageSync();
```
除此之外,`dart:ui `里的 `Scene` 和 `_Image` 对象其实都是 `NativeFieldWrapperClass1` ,以前我们解释过:**`NativeFieldWrapperClass1` 就是它的逻辑是由不同平台的 Engine 区分实现** 。
| ![](http://img.cdn.guoshuyu.cn/20230207_sync/image12.png) | ![](http://img.cdn.guoshuyu.cn/20230207_sync/image13.png) |
| --------------------------------------------------------- | --------------------------------------------------------- |
> 所以如果你直接在 `flutter/bin/cache/pkg/sky_engine/lib/ui/compositing.dart `下去断点 `toImageSync` 是无法成功执行到断点位置的,因为它的真实实现在对应平台的 Engine 实现。
![](http://img.cdn.guoshuyu.cn/20230207_sync/image14.png)
另外,前面我们一直说 `toImageSync` 对比 `toImage` 是 GPU 常驻,那它们的区别在哪里?从上图我们就可以看出:
- `toImageSync` 执行了 `Scene:RasterizeToImage` 并返回 `Dart_Null` 句柄
- `toImage` 执行了 `Picture:RasterizeLayerTreeToImage` 并直接返回
简单展开来说,就是:
- `toImageSync` 最终是通过 `SkImage::MakeFromTexture` 通过纹理得到一个 GPU `SkImage` 图片
- `toImage` 是通过 `makeImageSnapshot``makeRasterImage` 生成 `SkImage` `makeRasterImage` 是一个复制图像到 CPU 内存的操作。
| ![](http://img.cdn.guoshuyu.cn/20230207_sync/image15.png) | ![](http://img.cdn.guoshuyu.cn/20230207_sync/image16.png) | ![](http://img.cdn.guoshuyu.cn/20230207_sync/image17.png) | ![](http://img.cdn.guoshuyu.cn/20230207_sync/image18.png) |
| --------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- |
其实一开始 `toImageSync` 是被命令为 `toGpuImage` ,但是为了更形象通用,最后才修改为 `toImageSync`
![](http://img.cdn.guoshuyu.cn/20230207_sync/image19.png)
`toImageSync` 等相关功能的落地可以说同样历经了漫长的讨论,关于是否提供这样一个 API 到最终落地,其执行难度丝毫不比 [background isolate ](https://juejin.cn/post/7195825738472620087) 简单比如是否定义异常场景遇到错误是否需要在Framwork 层消化,是否真的需要这样的接口来提高性能等等。
| ![](http://img.cdn.guoshuyu.cn/20230207_sync/image20.png) | ![](http://img.cdn.guoshuyu.cn/20230207_sync/image21.png) | ![](http://img.cdn.guoshuyu.cn/20230207_sync/image22.png) | ![](http://img.cdn.guoshuyu.cn/20230207_sync/image23.png) |
| --------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- |
`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` 就是你必不可少的菜刀。