Flutter 新图片库 PowerImage 架构

Power 系列新图片库 PowerImage 的核心能力

背景

去年,闲鱼图片库在大规模的应用下取得了不错的成绩,但也遇到了一些问题和诉求,需要进一步的演进,以适应更多的业务场景与最新的 flutter 特性。比如,因为完全抛弃了原生的 ImageCache,在与原生图片混用的场景下,会让一些低频的图片反而占用了缓存;比如,我们在模拟器上无法展示图片;比如我们在相册中,需要在图片库之外再搭建图片通道。

这次,我们巧妙地将外接纹理与 FFi 方案组合,以更贴近原生的设计,解决了一系列业务痛点。没错,Power 系列将新增一员,我们将新的图片库命名为 「PowerImage」!

我们将新增以下核心能力:

  1. 支持加载 ui.Image 能力。在去年基于外接纹理的方案中,使用方无法拿到真正的 ui.Image 去使用,这导致图片库在这种特殊的使用场景下无能为力。
  2. 支持图片预加载能力。正如原生precacheImage一样。这在某些对图片展示速度要求较高的场景下非常有用。
  3. 新增纹理缓存,与原生图片库缓存打通!统一图片缓存,避免原生图片混用带来的内存问题。
  4. 支持模拟器。在 flutter-1.23.0-18.1.pre之前的版本,模拟器无法展示 Texture Widget。
  5. 完善自定义图片类型通道。解决业务自定义图片获取诉求。
  6. 完善的异常捕获与收集。
  7. 支持动图。

去年图片方案可以参考[《闲鱼Flutter图片框架架构演进(超详细)》]。

Flutter原生方案

在我们新方案开始之前,先简单回忆一下 flutter 原生图片方案。

原生 Image Widget 先通过 ImageProvider 得到 ImageStream,通过监听它的状态,进行各种状态的展示。比如frameBuilderloadingBuilder,最终在图片加载成功后,会rebuildRawImageRawImage 会通过 RenderImage 来绘制,整个绘制的核心是 ImageInfo 中的ui.Image

  1. Image:负责图片加载的各个状态的展示,如加载中、失败、加载成功展示图片等。
  2. ImageProvider:负责 ImageStream 的获取,比如系统内置的 NetworkImage、AssetImage 等。
  3. ImageStream:图片资源加载的对象。

在梳理 flutter 原生图片方案之后,我们发现是不是有机会在某个环节将 flutter 图片和 native 以原生的方式打通?

新的方案

我们巧妙地将 FFi 方案与外接纹理方案组合,解决了一系列业务痛点。

FF1

正如开头说的那些问题,Texture 方案有些做不到的事情,这需要其他方案来互补,这其中核心需要的就是ui.Image。我们把 native 内存地址、长度等信息传递给 flutter 侧,用于生成 ui.Image。首先 native 侧先获取必要的参数(以 iOS 为例):

_rowBytes = CGImageGetBytesPerRow(cgImage);

    CGDataProviderRef dataProvider = CGImageGetDataProvider(cgImage);
    CFDataRef rawDataRef = CGDataProviderCopyData(dataProvider);
    _handle = (long)CFDataGetBytePtr(rawDataRef);

    NSData *data = CFBridgingRelease(rawDataRef);
    self.data = data;
    _length = data.length;

dart 侧拿到后


@override
  FutureOr<ImageInfo> createImageInfo(Map map) {
    Completer<ImageInfo> completer = Completer<ImageInfo>();
    int handle = map['handle'];
    int length = map['length'];
    int width = map['width'];
    int height = map['height'];
    int rowBytes = map['rowBytes'];
    ui.PixelFormat pixelFormat =
        ui.PixelFormat.values[map['flutterPixelFormat'] ?? 0];
    Pointer<Uint8> pointer = Pointer<Uint8>.fromAddress(handle);
    Uint8List pixels = pointer.asTypedList(length);
    ui.decodeImageFromPixels(pixels, width, height, pixelFormat,
        (ui.Image image) {
      ImageInfo imageInfo = ImageInfo(image: image);
      completer.complete(imageInfo);
      //释放 native 内存
      PowerImageLoader.instance.releaseImageRequest(options);
    }, rowBytes: rowBytes);
    return completer.future;
  }

我们可以通过 ffi 拿到 native 内存,从而生成 ui.Image。这里有个问题,虽然通过 ffi 能直接获取 native 内存,但是由于 decodeImageFromPixels 会有内存拷贝,在拷贝解码后的图片数据时,内存峰值会更加严重。这里有两个优化方向:

  1. 解码前的图片数据给 flutter,由 flutter 提供的解码器解码,从而削减内存拷贝峰值。
  2. 与 flutter 官方讨论,尝试从内部减少这次内存拷贝。

FFI 这种方式适合轻度使用、特殊场景使用,支持这种方式可以解决无法获取 ui.Image 的问题,也可以在模拟器上展示图片(flutter <= 1.23.0-18.1.pre),并且图片缓存将完全交给 ImageCache 管理。

Texture

Texture 方案与原生结合有一些难度,这里涉及到没有ui.Image 只有 textureId。这里有几个问题需要解决:问题一:Image Widget 需要 ui.Image 去 buildRawImage 从而绘制,这在本文前面的Flutter 原生方案介绍中也提到了。问题二:ImageCache 依赖 ImageInfo 中 ui.Image 的宽高进行 cache 大小计算以及缓存前的校验。问题三:native 侧 texture 生命周期管理都有解决方案:问题一:通过自定义 Image 解决,透出 imageBuilder 来让外部自定义图片 widget问题二:为 Texture 自定义ui.image,如下:

import 'dart:typed_data';
import 'dart:ui' as ui show Image;
import 'dart:ui';

class TextureImage implements ui.Image {
  int _width;
  int _height;
  int textureId;
  TextureImage(this.textureId, int width, int height)
      : _width = width,
        _height = height;

  @override
  void dispose() {
    // TODO: implement dispose
  }

  @override
  int get height => _height;

  @override
  Future<ByteData> toByteData(
      {ImageByteFormat format = ImageByteFormat.rawRgba}) {
    // TODO: implement toByteData
    throw UnimplementedError();
  }

  @override
  int get width => _width;
}

这样的话,TextureImage 实际上就是个壳,仅仅用来计算 cache 大小。实际上,ImageCache 计算大小,完全没必要直接接触到 ui.Image,可以直接找 ImageInfo 取,这样的话就没有这个问题了。这个问题可以具体看 @皓黯 的 ISSUE[1] 与 PR[2]。

问题三:关于 native 侧感知 flutter image 释放时机的问题

  1. flutter 在 2.2.0 之后,ImageCache 提供了释放时机,可以直接复用,无需修改。
  2. < 2.2.0 版本,需要修改 ImageCache,获取 cache 被丢弃的时机,在 cache 被丢弃的时候,通知 native 进行释放。

修改的 ImageCache 释放如下(部分代码):


typedef void HasRemovedCallback(dynamic key, dynamic value);

class RemoveAwareMap<K, V> implements Map<K, V> {
  HasRemovedCallback hasRemovedCallback;
  ...
}
//------
  final RemoveAwareMap<Object, _PendingImage> _pendingImages = RemoveAwareMap<Object, _PendingImage>();
//------
void hasImageRemovedCallback(dynamic key, dynamic value) {
    if (key is ImageProviderExt) {
      waitingToBeCheckedKeys.add(key);
    }
    if (isScheduledImageStatusCheck) return;
    isScheduledImageStatusCheck = true;
    //We should do check in MicroTask to avoid if image is remove and add right away
    scheduleMicrotask(() {
      waitingToBeCheckedKeys.forEach((key) {
        if (!_pendingImages.containsKey(key) &&
            !_cache.containsKey(key) &&
            !_liveImages.containsKey(key)) {
          if (key is ImageProviderExt) {
            key.dispose();
          }
        }
      });
      waitingToBeCheckedKeys.clear();
      isScheduledImageStatusCheck = false;
    });
  }

整体架构

我们将两种解决方案非常优雅地结合在了一起:

我们抽象出了 PowerImageProvider ,对于 external(ffi)、texture,分别生产自己的 ImageInfo 即可。它将通过对 PowerImageLoader 的调用,提供统一的加载与释放能力。

蓝色实线的 ImageExt 即为自定义的 Image Widget,为 texture 方式透出了 imageBuilder。

蓝色虚线 ImageCacheExt 即为 ImageCache 的扩展,仅在 flutter < 2.2.0 版本才需要,它将提供 ImageCache 释放时机的回调。

这次,我们也设计了超强的扩展能力。除了支持网络图、本地图、flutter 资源、native 资源外,我们提供了自定义图片类型的通道,flutter 可以传递任何自定义的参数组合给 native,只要 native 注册对应类型 loader,比如「相册」这种场景,使用方可以自定义 imageType 为 album ,native 使用自己的逻辑进行加载图片。有了这个自定义通道,甚至图片滤镜都可以使用 PowerImage 进行展示刷新。

除了图片类型的扩展,渲染类型也可进行自定义。比如在上面 ffi 中说的,为了降低内存拷贝带来的峰值问题,使用方可以在 flutter 侧进行解码,当然这需要 native 图片库提供解码前的数据。

数据对比

Texture

机型:iPhone 11 Pro,图片:300 张网络图,行为:在listView中手动滚动到底部再滚动到顶部,native Cache:100MB,flutter Cache:100MB

这里有两个现象:

Texture:395MB波动,内存较平滑
FFI:480MB波动,内存有毛刺

Texture 方案在内存方面表现优于 FFI,在内存水位与毛刺两方面:

  1. 内存水位:由于 Texture 方案在 flutter 侧的 cache 为占位空壳,没有实际占用内存,因此只在 native 图片库的内存缓存中存在一份,所以 flutter 侧内存缓存实际上比 ffi 方案少了 100MB
  2. 毛刺:由于 ffi 方案不能避免 flutter 侧内存拷贝,会有先拷贝再释放的过程,所以会有毛刺。

结论:

  1. Texture 适用于日常场景,优先选择;
  2. FFI 更适用于

flutter <= 1.23.0-18.1.pre 版本中,在模拟器上显示图片 获取 ui.Image 图片数据 flutter 侧解码,解码前的数据拷贝影响较小。(比如集团 Hummer 的外接解码库)

滚动流畅性分析

设备: Android OnePlus 8t,CPU和GPU进行了锁频。
case: GridView每行4张图片,300张图片,从上往下,再从下往上,滑动幅度从500,1000,1500,2000,2500,5轮滑动。重复20次。
方式: for i in {1..20}; do flutter drive --target=test_driver/app.dart --profile; done 跑数据,获取TimeLine数据并分析。

结论:

  1. UI thread 耗时 texture 方式最好,PowerImage 略好于 IFImage,FFI方式波动比较大。
  2. Raster thread 耗时 PowerImage 好于 IFImage。Origin 原生方式好是因为对图片 resize了,其他方式加载的是原图。

更精简的代码

dart 侧代码有较大幅度的减少,这归功于技术方案贴合 flutter 原生设计,我们与原生图片共用较多代码。

FFI 方案补全了外接纹理的不足,遵循原生 Image 的设计规范,不仅让我们享受到 ImageCache 带来的统一管理,也带来了更精简的代码。

数据对比

相信很多人注意到了,上文中少了动图部分。当前动图部分正在开发中,内部的 Pre Release 版本中,在 load 的时候返回的实际上是 OneFrameImageStreamCompleter,对于动图,我们将替换为 MultiFrameImageStreamCompleter,后面如何做,只是一些策略问题,并不难。顺便抛个另一种方案:可以把动图解码前的数据给 flutter 侧解码与渲染,但支持的格式不如原生丰富。

我们希望能将 PowerImage 贡献给社区,为了实现这一目标,我们提供了详细的设计文档、接入文档、性能报告,另外我们也在完善单元测试,在代码提交后或者 CR 时,都会进行单元测试。

最后,也是大家最关心的:我们计划在今年十二月底将代码开源在「XianyuTech[3]」。

References

[1] ISSUE: https://github.com/flutter/flutter/issues/86402

[2] PR: https://github.com/flutter/flutter/pull/86555

[3] XianyuTech: https://github.com/XianyuTech


手机扫码阅读