【Flutter】precacheImageで画面描画前に画像を読み込む

対象者

  • FlutterのprecacheImageを知りたい人
  • Flutterで画面起動と同時にすべての画像を表示させたい人
  • Flutterで特定の処理が完了するまで、画面を表示させたくない人(上記では、特定の処理が「画像の読み込み」にあたる)
  • Flutterで画像読み込み時に画像のサイズを知りたい人

はじめに

Flutterのアプリを起動させます。画面に画像を使ってますが、すこし残念なことに、起動直後は画像は出ず、しばらくしてから画像が表示されます。prechecheImageを使っても、起動後は良いのですが、起動から一瞬遅れて画像が表示されていました。
「画像読み込みまで最初の画面描画を遅らせる」という記事がありましたのが、そのままでは使えなかったので、使えるようにしたソースを解説します。

prechacheImageについて

StatefulWidgetのライフサイクルを使って、事前に画像を読み込んでおきます。

Flutterで、なるはやで画像をキャッシュする

  late Image image1;

  @override
  void initState() {
    super.initState();
    image1 = Image.asset("assets/logo.jpg");
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    precacheImage(image1.image, context);
  }

ただこの方法だと、「ボタンを押したら画像を表示する」というケースでは事前に読み込めます。しかし画面起動時には事前に読み込む時間がなく、最初の画面が表示されます。そのため、画面の描画があり、少し間を置いてから、画像の描画が始まります。(上記の動画の最初の方)。

ちなみに、precacheImageもimageCacheもなにかをimportする必要はなく、Flutterのソースにそのまま書けます。

キャッシュのクリア方法(未検証)

以下を使えばprecacheImageで読み込んだ画像を消せそう。(どうすれば検証できるかな。めっちゃ画像読み込むしかないかな)

imageCache.clear();
imageCache.clearLiveImages();

実施するソース

上記のサンプルコードの難点を解決するため、画像を読み込んでから画面の描画を開始するようにします。そうすることで、画面の描画と同時に画像を表示できるようになります。

void main() async {
    final binding = WidgetsFlutterBinding.ensureInitialized();

    binding.deferFirstFrame();
    binding.addPostFrameCallback((_) {
      final Element? context = binding.renderViewElement;
      if (context != null) {
        final image = const NetworkImage(imageUrl)
          ..resolve(const ImageConfiguration())
              .addListener(ImageStreamListener((_, __) {
            binding.allowFirstFrame();
          }));
        precacheImage(image, context);
      }
    });
  }
  runApp(const MyApp());
}

以下、徒然なるままに階説。

  • binding.deferFirstFrame()
    フレームの描画をストップさせる

  • binding.addPostFrameCallback
    通常最初のフレームの描画が完了したら実行する関数を定義する。
    描画が止められているから、そのまま実行しているのかな。

  • final Element? context = binding.renderViewElement;
    いつもお世話になっているBuildContext、こんな風にも取得できるのか、、、

  • final image = const NetworkImage(imageUrl)
    読み込む画像。AssetImageとかでも大丈夫です。

  • ..resolve(const ImageConfiguration()).addListener(ImageStreamListener
    画像を読み込み後に実行する関数を定義できる。
    (余談) ImageStreamListenerの第一引数から、読み込んだ画像のサイズを確認することができる。

  • binding.allowFirstFrame();
    フレームの描画を許可する。つまり、画像の読み込みが完了したら、フレームの描画を開始する。その結果、画像を読込終わった後で画面が描画される。画面と画像の表示のズレはなくなる、はず!

  • precacheImage(image, context);
    読み込んだ画像をキャッシュする。

まとめ

改めてリリースしたアプリでやってみると、画像と画面の描画のズレはアセットだと気になんないかなー、とか思ったりします。(おい!)
ただ、初回画面描画の画像表示のみならず、確実に実施したい処理が合ったときなど使えるかと思います。

参考

全ソース

「const precacheImageInMain = true;」 であれば、mainの中で画像を読み込んで、画面と画像を同時に描画します。false に変更すると、StatelessWidget内で画像読み込みするので、表示にすこし間ができます。

import 'package:flutter/material.dart';

const imageUrl = 'https://docs.flutter.dev/assets/images/dash/Dashatars.png';
const precacheImageInMain = true;

void main() async {
  if (precacheImageInMain) {
    final binding = WidgetsFlutterBinding.ensureInitialized();

    binding.deferFirstFrame();
    binding.addPostFrameCallback((_) {
      final Element? context = binding.renderViewElement;
      if (context != null) {
        final image = const NetworkImage(imageUrl)
          ..resolve(const ImageConfiguration())
              .addListener(ImageStreamListener((_, __) {
            binding.allowFirstFrame();
          }));
        precacheImage(image, context);
      }
    });
  }
  runApp(const MyApp('precacheImageInMain: $precacheImageInMain'));
}

class MyApp extends StatelessWidget {
  const MyApp(this.title, {Key? key}) : super(key: key);

  final String title;

  @override
  Widget build(BuildContext context) {
    if (!precacheImageInMain) {
      precacheImage(const NetworkImage(imageUrl), context);
    }
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: title),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          children: [
            const Text('Hello Dash!'),
            Image.network(imageUrl),
          ],
        ),
      ),
    );
  }
}