返回

Flutter首帧图片加载优化:告别闪烁,实现平滑过渡

IOS

Flutter 首帧图片加载:告别闪烁,实现原生启动页平滑过渡

应用启动时,从原生启动页(Native Splash Screen)到 Flutter 界面的切换,常常伴随着图片加载的延迟,导致短暂的白屏或闪烁。这很影响用户体验,让人感觉 App 不够流畅。想让首帧直接显示图片,就像原生启动页一样,实现无缝过渡吗?这篇博客就是来帮你解决这个问题的!

一、问题出在哪?

直接在 build 方法里使用 Image.asset,Flutter 引擎需要时间去加载和渲染图片,这就是产生“白屏帧”的原因。就算图片放在 assets 目录,也是需要读取和解码的,毕竟要显示图片了才知道该长什么样子。

Image.asset(
  'assets/splashScreen.jpg',
  fit: BoxFit.cover,
  width: double.infinity,
  height: double.infinity,
),

Flutter 的异步机制也有一点点“贡献”。build 函数很可能在图片加载完成之前 就已经执行完毕,因此第一帧呈现时,图片还没准备好。

二、解决方案:图片预加载!

为了避免出现“白屏帧”,必须在 build 方法执行之前,就让图片乖乖躺在内存里。这里介绍几个方法:

1. precacheImage:简单直接

Flutter 提供了 precacheImage 函数,它专门干这事!顾名思义,就是“预先缓存图片”。

  • 原理: precacheImage 会强制提前加载并解码指定的图片,并将解码后的图片数据缓存在 Flutter 的图片缓存(image cache)中。之后,只要你使用相同的 ImageProvider (比如 AssetImage),Flutter 会直接从缓存里捞图片,快得很!

  • 用法:initState 方法里调用 precacheImage,给它传入你的 ImageProvider 和当前的 BuildContext 就行。

  • 代码示例:

class MySplashScreen extends StatefulWidget {
  @override
  _MySplashScreenState createState() => _MySplashScreenState();
}

class _MySplashScreenState extends State<MySplashScreen> {
  @override
  void initState() {
    super.initState();
    precacheImage(AssetImage('assets/splashScreen.jpg'), context).then((_) {
       //图片预加载完成,可选: 做一些事情。例如跳转到Home界面。
    });
  }

  @override
  Widget build(BuildContext context) {
    return Image.asset(
      'assets/splashScreen.jpg',
      fit: BoxFit.cover,
      width: double.infinity,
      height: double.infinity,
    );
  }
}
  • 注意点: precacheImage 返回一个 Future。为了确保图片在 build 之前加载完毕,你可以用 then 来处理图片加载完成后的逻辑。then回调保证在图片加载成功后执行,确保主界面加载前一切准备就绪. 强烈建议这样使用.

2. WidgetsBinding.instance.addPostFrameCallback:精准控制

如果想要更精细地控制图片加载过程,或者想在第一帧绘制之后 再执行某些操作,可以使用 WidgetsBinding.instance.addPostFrameCallback

  • 原理: 这个回调函数会在每一帧绘制完成之后 被调用。但需要注意的是,第一次调用时机是在第一帧已经 绘制完成之后。这里使用它是用来配合下面的进阶用法。

  • 基本用法: (配合下面FutureBuilder使用)

3. FutureBuilder + 自定义加载逻辑:灵活应对各种情况

如果你的图片加载过程比较复杂(比如需要从网络下载,或者进行一些额外的处理),或者你想显示一个自定义的加载指示器,FutureBuilder 是个不错的选择。

  • 原理: FutureBuilder 可以根据一个 Future 的状态(未完成、已完成、出错)来构建不同的 UI。可以利用它来显示一个加载指示器,直到图片加载完成。

  • 代码示例:

class MySplashScreen extends StatefulWidget {
  @override
  _MySplashScreenState createState() => _MySplashScreenState();
}

class _MySplashScreenState extends State<MySplashScreen> {
  late Future<void> _loadImageFuture;

  @override
  void initState() {
    super.initState();
    _loadImageFuture = _loadImage();
     // WidgetsBinding.instance.addPostFrameCallback((_) async {
     //    await _loadImage();
     //     if (mounted) {  // Check if the widget is still in the tree
     //        setState(() {}); // Refresh UI
     //     }
     //  });
  }
  
  Future<void> _loadImage() async {
        final ByteData data = await rootBundle.load('assets/splashScreen.jpg');
        final ui.Codec codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
        final ui.FrameInfo frameInfo = await codec.getNextFrame();
      //  _image=frameInfo.image;  不需要, 只需要加载就可以.
  }
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<void>(
      future: _loadImageFuture,
      builder: (BuildContext context, AsyncSnapshot<void> snapshot) {
        switch (snapshot.connectionState) {
          case ConnectionState.done:
            return Image.asset(
              'assets/splashScreen.jpg',
              fit: BoxFit.cover,
              width: double.infinity,
              height: double.infinity,
            );
          default: //waiting,none,active
            // 或者显示一个简单的加载占位符,如白屏或纯色 Container.
           // return CircularProgressIndicator(); 
          // return Container(color: Colors.white); 纯白可能还是不顺滑,这里采用颜色和启动页接近的办法
          // return SizedBox.shrink(); // 不绘制任何东西
           return Container(color: Colors.blueGrey); //假设启动页接近灰蓝色.
        }
      },
    );
  }
}
  • 说明:

    1. _loadImage() 是自定义的图片加载函数。在这里执行比较耗时的操作,这里模拟的是解码图片的操作, 更接近于真实情况下的耗时操作.
    2. FutureBuilder 根据 _loadImageFuture 的状态来决定显示什么:
      • ConnectionState.done: 图片加载完成,显示图片。
      • 其他状态:图片加载中,可以显示加载指示器或者占位符,具体用哪个取决于你的用户体验考量。 SizedBox.shrink() 将会完全不显示内容,而如果启动画面和实际第一帧背景色有区别,简单的Container也会有些微突兀的感觉。

进阶用法与技巧:使用ui.instantiateImageCodec获得更底层的控制.

直接操作底层API。

import 'dart:ui' as ui;
import 'package:flutter/services.dart';

 Future<ui.Image> loadImage(String assetPath) async {
    final ByteData data = await rootBundle.load(assetPath);
    final ui.Codec codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
    final ui.FrameInfo frameInfo = await codec.getNextFrame();
    return frameInfo.image;
  }
  • 原理:
    • rootBundle.load(): 读取asset内容.
    • ui.instantiateImageCodec(): 更加底层的 API,用来创建一个图像编解码器(codec)实例,负责将原始图像数据(如 PNG、JPEG 等)解码成 Flutter 可以渲染的格式。
    • codec.getNextFrame():从图像编解码器中获取下一帧图像。对于静态图片,通常只有一帧。
    • frameInfo.image:获取到的解码后的 ui.Image 对象。
    • 通过以上方式可以直接操作Image对象,进行更高级操作。即使你不需要这些高级操作,这种方式的解码也能确保图片加载进内存,消除首帧白屏.
  • 使用场景 : 这个技巧在以下情况更加实用:
    • 对图片像素进行操作。
    • 绘制到 Canvas 前对图片进行处理。
    • 更加精确地控制图片加载和内存占用。

把以上代码替换 _loadImage 方法,效果也是一样.

安全建议

  • 避免内存泄漏: 虽然 precacheImage 缓存的是解码后的图像数据,通常不会占用过多内存,但对于超大图片,还是要注意避免内存泄漏。如果你不再需要这张图片了,可以使用 evict 方法从缓存中移除它:
imageCache!.evict(AssetImage('assets/large_image.jpg'));
  • 避免在循环中无限制地调用precacheImage.

总结

通过预加载图片,可以有效地解决 Flutter 应用首帧图片加载延迟的问题,让你的应用启动更加流畅。根据你的实际需求,选择适合你的方法,打造更好的用户体验吧!