Flutter绘制4K图片内存问题与优化

Flutter是谷歌的高性能、跨端UI框架,可以通过一套代码,支持iOS、Android、Windows/MAC/Linux等多个平台,且能达到原生性能。Flutter也可以与平台原生代码进行混合开发。在全世界,Flutter正在被越来越多的开发者和组织使用,并且Flutter是完全免费、开源的。

在我们使用flutter框架开发windows桌面程序时,有这样一个简单需求:设置4k的背景图片

我们通过查询flutter相关文档,可以知道使用以下方式设置背景图片:

decoration: BoxDecoration(
  color: Colors.blue,
  image: DecorationImage(
    fit: BoxFit.cover,
    image: AssetImage('assets/images/wallpaper.png'),
  )
),

问题

当我们使用这个方式去添加3840×2160的4k图片作为背景时,虽然成功达到我们的需求,但是却发现,相比未加载背景图片之前,程序的内存居然多了接近70M

读入这张4k图片,正常会占用的内存是3840x2160x3约为24M和70M差距甚大,我们需要分析一下原因。

分析

分析flutter加载Image资源的原理,Image组件通过实现ImageProvider抽象类的loadBuffer方法(新的flutter版本load被loadBuffer替换)来加载图片资源数据,而AssetImage的loadBuffer方法实现如下:

@override
ImageStreamCompter loadBuffer(AssetBundleImageKey key, DecoderBufferCallback decode) {
  InformationCollector? collector;
  assert(() {
    collector = () => <DiagnosticsNode>[
      DiagnosticsProperty<ImageProvider>('Image provider', this),
      DiagnosticsProperty<AssetBundleImageKey>('Image key', key),
    ];
    return true;
  }());
  return MultiFrameImageStreamCompleter(
    codec: _loadAsync(key, decode, null),
    scale: key.scale,
    debugLabel: key.name,
    informationCollector: collector,
  );
}

loadBuffer会返回一个ImageStreamCompleter对象,这里返回的是MultiFrameImageStreamCompleter多帧图片管理器,是ImageStreamCompleter的一个子类。

MultiFrameImageStreamCompleter 需要一个Future<ui.Codec>类型的参数codec。Codec 是处理图片编解码的类的一个handler,是一个flutter engine API 的包装类。

  MultiFrameImageStreamCompleter({
    required Future<ui.Codec> codec,
    required double scale,
    String? debugLabel,
    Stream<ImageChunkEvent>? chunkEvents,
    InformationCollector? informationCollector,
  }) : assert(codec != null),
       _informationCollector = informationCollector,
       _scale = scale {
    this.debugLabel = debugLabel;
    codec.then<void>(_handleCodecReady, onError: (Object error, StackTrace stack) {
      reportError(
        context: ErrorDescription('resolving an image codec'),
        exception: error,
        stack: stack,
        informationCollector: informationCollector,
        silent: true,
      );
    });
    if (chunkEvents != null) {
      _chunkSubscription = chunkEvents.listen(reportImageChunkEvent,
        onError: (Object error, StackTrace stack) {
          reportError(
            context: ErrorDescription('loading an image'),
            exception: error,
            stack: stack,
            informationCollector: informationCollector,
            silent: true,
          );
        },
      );
    }
  }

codec最终的结果是一个或多个(动图)帧,而这些帧最终会绘制到屏幕上。codec的异步方法执行完成后会调用_handleCodecReady函数,调用_decodeNextFrameAndSchedule函数解码下一帧。

定位

当我们调试程序时,会发现到目前为止,程序的内存只是涨幅少许,但是当调试到图中获取下一帧数据时,内存突然涨幅非常大,因此定位到是这个getNextFrame导致了内存大量涨幅。

Future<void> _decodeNextFrameAndSchedule() async {
    _nextFrame?.image.dispose();
    _nextFrame = null;
    try {
      _nextFrame = await _codec!.getNextFrame();
    } catch (exception, stack) {
      reportError(
        context: ErrorDescription('resolving an image frame'),
        exception: exception,
        stack: stack,
        informationCollector: _informationCollector,
        silent: true,
      );
      return;
    }
    if (_codec!.frameCount == 1) {
      if (!hasListeners) {
        return;
      }
      _emitFrame(ImageInfo(
        image: _nextFrame!.image.clone(),
        scale: _scale,
        debugLabel: debugLabel,
      ));
      _nextFrame!.image.dispose();
      _nextFrame = null;
      return;
    }
    _scheduleAppFrame();
  }

继续跟踪,发现最终是调用“Codec::getNextFrame”映射方法,这个方法是在flutter engine中的c++实现的,接下来跟踪flutter engine代码。

我们可以看到getNextFrame方法是属于MultiFrameCodec的。其实方法里面看起来是很简单的,里面做了几个事情:

  1. 先获取UI Task线程。
  2. 获取当前Skia处理的Queue
  3. 获取上下文
  4. 切换到IO线程中运行GetNextFrameAndInvokeCallback

而在GetNextFrameAndInvokeCallback里做了两件事:

  • GetNextFrameImage:做的事情大致就是获取下一帧的SkImage数据,并且保存了上一帧的关键数据。这里我们可以看到有多次Copy的操作,所以这些操作都是在IO线程中的。
  • InvokeCallback:这里做的事情大概就是获取刚刚从GetNextFrameImage中拿到的SkImage,并且将它塞到了FrameInfo的结构体里面,并且改变下一帧的index。然后在UI线程中Callback回去。
sk_sp<DlImage> MultiFrameCodec::State::GetNextFrameImage(
    fml::WeakPtr<GrDirectContext> resourceContext,
    const std::shared_ptr<const fml::SyncSwitch>& gpu_disable_sync_switch,
    std::shared_ptr<impeller::Context> impeller_context_,
    fml::RefPtr<flutter::SkiaUnrefQueue> unref_queue) {
  SkBitmap bitmap = SkBitmap();
  SkImageInfo info = generator_->GetInfo().makeColorType(kN32_SkColorType);
  if (info.alphaType() == kUnpremul_SkAlphaType) {
    SkImageInfo updated = info.makeAlphaType(kPremul_SkAlphaType);
    info = updated;
  }
  if (!bitmap.tryAllocPixels(info)) {
    FML_LOG(ERROR) << "Failed to allocate memory for bitmap of size "
                   << info.computeMinByteSize() << "B";
    return nullptr;
  }
 
  ImageGenerator::FrameInfo frameInfo =
      generator_->GetFrameInfo(nextFrameIndex_);
 
  const int requiredFrameIndex =
      frameInfo.required_frame.value_or(SkCodec::kNoFrame);
  std::optional<unsigned int> prior_frame_index = std::nullopt;
 
  if (requiredFrameIndex != SkCodec::kNoFrame) {
    if (lastRequiredFrame_ == nullptr) {
      FML_LOG(ERROR) << "Frame " << nextFrameIndex_ << " depends on frame "
                     << requiredFrameIndex
                     << " and no required frames are cached.";
      return nullptr;
    } else if (lastRequiredFrameIndex_ != requiredFrameIndex) {
      FML_DLOG(INFO) << "Required frame " << requiredFrameIndex
                     << " is not cached. Using " << lastRequiredFrameIndex_
                     << " instead";
    }
 
    if (lastRequiredFrame_->getPixels() &&
        CopyToBitmap(&bitmap, lastRequiredFrame_->colorType(),
                     *lastRequiredFrame_)) {
      prior_frame_index = requiredFrameIndex;
    }
  }
 
  if (!generator_->GetPixels(info, bitmap.getPixels(), bitmap.rowBytes(),
                             nextFrameIndex_, requiredFrameIndex)) {
    FML_LOG(ERROR) << "Could not getPixels for frame " << nextFrameIndex_;
    return nullptr;
  }
 
  // Hold onto this if we need it to decode future frames.
  if (frameInfo.disposal_method == SkCodecAnimation::DisposalMethod::kKeep) {
    lastRequiredFrame_ = std::make_unique<SkBitmap>(bitmap);
    lastRequiredFrameIndex_ = nextFrameIndex_;
  }
 
  sk_sp<SkImage> skImage;
  gpu_disable_sync_switch->Execute(
      fml::SyncSwitch::Handlers()
          .SetIfTrue([&skImage, &bitmap] {
            skImage = SkImage::MakeFromBitmap(bitmap);
          })
          .SetIfFalse([&skImage, &resourceContext, &bitmap] {
            if (resourceContext) {
              SkPixmap pixmap(bitmap.info(), bitmap.pixelRef()->pixels(),
                              bitmap.pixelRef()->rowBytes());
              skImage = SkImage::MakeCrossContextFromPixmap(
                  resourceContext.get(), pixmap, true);
            } else {
              skImage = SkImage::MakeFromBitmap(bitmap);
            }
          }));
 
  return DlImageGPU::Make({skImage, std::move(unref_queue)});
}

结论

通过分析GetNextFrameImage方法,我们发现该方法额外保存了上一帧的数据,导致了内存多了一张图片的大小。

又发现缓存的SkBitmap是32位深的,而不是24位深,因此加载一张4k图片的内存为:3840x2160x4约为32M,getNextFrame额外保留了一帧图片,因此需要占用64M内存。

因此我们可以知道,内存涨这么多的原因是,flutter在加载图片数据时,保存的是32位深数据,并且会额外缓存一帧图片数据。

优化方案

如果要正面解决这个问题,就需要更加熟悉和理解flutter的源码,然后再修改flutter源码,这个工作量和风险都是巨大的。

但是,如果仅仅只是为了解决加载4k背景图片,导致内存涨幅大的问题,其实可以选择另一种方式去优化。

我们知道,flutter的程序只创建出了一个窗口,所有的ui操作都在这个窗口中进行。如果说flutter自己绘制背景图片的方式会大量增加内存消耗,那我们可以尝试自己创建背景窗口去绘制背景图片,这是否就能解决这个问题。

外部背景窗口

1. 创建背景窗口

背景窗口需要能够绘制背景图片,可以简单使用gdi+实现,例如:

auto image = Image(_bgImagePath.c_str());
if (image.GetLastStatus() == Status::Ok)
{
    RECT rc;
    GetClientRect(_hWnd, &rc);
    Graphics graphics(hdc);
    graphics.DrawImage(&image, 0, 0, rc.right - rc.left, rc.bottom - rc.top);
}

窗口绘制背景图片的方式有很多种,这里只是给个示例。

2.设置父窗口

为了保证背景窗口和flutter主窗口之间的层级关系,必须要将背景窗口设置为flutter主窗口的父窗口。

  BackgroundWindow bgWnd;
  bgWnd.Create(instance);
  HWND bghwnd = bgWnd.GetHandle();

  FlutterWindow window(project);
  Win32Window::Point origin(10, 10);
  Win32Window::Size size(1280, 720);
  if (!window.Create(L"myapp", origin, size, bghwnd)) {
    return EXIT_FAILURE;
  }

3.主窗口设置背景透明

要显示背景窗口,就需要设置主窗口的背景透明。考虑到对主窗口设置属性,以及监听事件等功能,我们可以使用第三方插件window_manager来实现设置背景透明的功能。

import 'package:window_manager/window_manager.dart';
 
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await windowManager.ensureInitialized();
  WindowOptions windowOptions = WindowOptions(
    size: Size(800, 600),
  );
  windowManager.waitUntilReadyToShow(windowOptions, () async {
  // 设置背景透明
  await windowManager.setBackgroundColor(Colors.transparent);
  });
  runApp(const MyApp());
}

@override
Widget build(BuildContext context) {
  return Scaffold(
    // 设置背景透明
    backgroundColor: Colors.transparent,
    appBar: AppBar(
      title: Text(widget.title),
    ),
    body: Center(
    //...
    )
  );
}

4.主窗口和背景窗口通信

主窗口和背景窗口之间要进行通信,最简单的一种方式就是窗口消息通信。创建插件,查找背景窗口,使用SendMessage发送WM_COPYDATA指令进行通信。

HWND hwnd = FindWindow(kWindowClassName, kWindowTitle);
if (!hwnd)
    return false;
COPYDATASTRUCT cpd;
SendMessage(hwnd, WM_COPYDATA, 0, (LPARAM)&cpd);

总结

通过使用外部背景窗口的方式,也能实现flutter内部绘制背景图片的效果,而且内存消耗远小于使用flutter绘制的方式。可以使用该方案,优化flutter绘制图片消耗内存大的问题。但是该方法也具有较多的局限性,例如:

  • 只能替换绘制背景图片,如果flutter需要其他组件绘制图片,则不能使用该方案
  • 代码维护复杂,flutter是一个整体框架,而该方案破坏了flutter的整体性,对于后期的维护也存在一定的成本
  • 可能存在更多的异常,存在更多的窗口,就增加了异常的可能性
图片

参考资料:

  • https://www.jianshu.com/p/3a4d8ebf9932
  • https://juejin.cn/post/6844904016225239047

作者:星黎 | 来源:公众号——好奇de悟空

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐

发表回复

登录后才能评论