のんびり精進

調べた情報などをおすそ分けできれば。

Flutter で画像を一覧表示するときに注意すること

画像を多く表示する画面が複数あるアプリの開発に今携わっていて、注意点をチーム内でどう共有しようかと考えている間に一つの記事になりそうだと思ったので書くことにしました。

大量読み込みが起こりやすい

Flutter で画像を含む一覧を作るのは簡単ですが、あまり気にせずに作ると大量の画像が一気に読み込まれたりメモリを使いすぎたりすることがあり、大きな一覧では特に注意が必要です。

ネットワークからの読み込み数、転送量/通信量、メモリ使用量などを減らす方法はパジネーションや小さなサムネイル画像など様々ですが、この記事では一般的な方法は省略して Flutter での方法に絞ります。

Column や Row に注意

Column に 20 個の要素があって画像を含んでいる例です。1

SingleChildScrollView(
  child: Column(
    children: [
      for (var i = 0; i < 20; i++) ...[
        SizedBox(
          height: 100.0,
          child: Image.network('https://xxxxx.xxx/$i.jpg'),
        ),
        const Divider(),
      ],
    ],
  ),
)

画面内に見えている要素は三つだけですが、画像はすぐに 20 枚すべてが読み込まれます。
ColumnRow ではすぐにマウントされてビルドまで(おそらく描画も)が一気に行われるからです。

全部読み込んでしまう様子を DevTools の Network view で見ることができます。
ウェブならブラウザの開発ツールでも確認できます。

メモリ使用量にも影響

画像がすぐに全部用意されるのはメモリが不足する原因にもなります。
そのことは DevTools の Memory view で確認できます。

これは 20 個の要素一つずつに 0.5 ~ 1.5 MB 程の画像がある Column で試した様子です。
最初に使用量が増えた後、スクロールしても変化がなくて平らになっています。
この程度のサイズと数なら大丈夫ですが、酷いとアプリが落ちるくらいになります。

小さなサムネイル画像を使って抑えても大量になれば使用量が大きくなるので、この先の対策も併せて必要です。

補足

Flutter の画像のキャッシュは ImageCache で設定されています。
初期値は数(maximumSize)が 1,000、大きさ(maximumSizeBytes)が 100 MiB です。
それを超えると、最後に使ってから時間が経過している画像から自動的に消去されていきます。

メモリを使いすぎないようにその値を変えれば、メモリが少ない端末で OOM が起こるのを防げそうです。2

// 上限を30MiBに抑える例
PaintingBinding.instance.imageCache.maximumSizeBytes = 30 << 20;

ただし、サーバから一気に読み込んでしまう問題は解決しませんし、キャッシュが消えた画像が再び必要になったときに取得しなおさないといけません。
この後で説明する他の方法を使うほうがいいと思います。

対策 - ListView や GridView を使う

Column / Row は要素が少ない一覧に限って使い、多ければ ListViewGridView を使いましょう。

ListView を使うと、表示部分とその前後(cacheExtent で指定したサイズ)から外れた範囲の画像が読み込まれるのを防ぐことができます。3

失敗例

しかし ListView にするだけでは不十分です。

ListView(
  // 検証しやすくするために見える範囲のみ描画されるよう設定
  cacheExtent: 0.0,
  children: [
    for (var i = 0; i < 20; i++) ...[
      child: Image.network('https://xxxxx.xxx/$i.jpg'),
      const Divider(),
    ],
  ],
)

下のスクリーンショットは、一覧を表示した瞬間のものです。
たくさんの Divider が見えていますね。
読み込まれる前は画像の高さがゼロのまま各要素が表示され、多くの要素が表示範囲内にある状態になります。

この例では Divider の padding の分の高さがありますが、それでも 20 個の要素が見えてしまっています。
もし画像のみの要素なら未表示時には高さがゼロなので、要素数が大量であっても全部が表示範囲に入ります。

表示範囲に入ると画像は読み込みが開始されて先ほど DevTools で見たのと同じことが起こりますので、ListView を使うだけでは確実な対策にはならないわけです。

補足

  • 画像が表示されるとすぐに大半の要素は破棄されるため、これだけでも Column よりはマシです。
  • 画像未表示でも高さが確保される一覧であっても、メモリの観点で Column は避けたほうが良いです。
  • GridView は main axis 方向の要素サイズがアスペクト比と cross axis の指定個数で決まるので、未表示でもサイズが勝手に確保されると思います。

確実な対策

高さがないことが問題なので、それを設定すれば解決できます。

※実際には画像がなくてもテキストやらボタンやらで幾らか高さがあることが多いので、問題にはなりにくいです。画像ビューアのような画像メインの一覧では要注意です。

高さ指定① - 固定値

画像ぴったりの高さ(横スクロールなら幅)をあらかじめ設定して確保しておく方法です。
設定する対象は画像でも要素のアイテムでもいいですが、ここでは画像のほうに設定してみます。

ListView(
  cacheExtent: 0.0,
  children: [
    for (var i = 0; i < 20; i++) ...[
      SizedBox(
        // 画像の高さを指定
        height: 100.0,
        child: Image.network('https://xxxxx.xxx/$i.jpg'),
      ),
      const Divider(),
    ],
  ],
)

画像が未表示でも 100.0 の高さが確保されました。
見えている要素は三つで、読み込みも三枚に抑えられていることが DevTools でわかります。

また、メモリ使用量の変化も Column のときと全然違うのがわかります。
最初に一気に増えずに、読み込んだ画像とスクロールして見えなくなった画像によって増減しています。

このように ListView を使うのは転送量を抑えるだけでなくメモリ不足を防ぐことにもなります。

ListView と ListView.builder の違い

どちらも遅延の効果があります。
遅延というのは、表示部分+α の範囲に入るまで使われないことです。
その範囲に入ったときに初めて initState() が呼ばれ、範囲から外れれば dispose() が呼ばれます。4

そこは共通していて、違いはインスタンス生成のタイミングにあります。
デフォルトコンストラクタでは子 widget の List を children に直に渡すため、その時点かそれより前に生成することになります。

しかし生成しただけでは build() が呼ばれないどころか createState() も呼ばれないので、デフォルトコンストラクタでも画像読み込みを遅延させる効果があります。
全要素が先に生成される分 ListView.builder より少し多めにメモリが使用されますが、インスタンスのサイズだけなので小さいです。
パフォーマンスの面では、生成時のコストが高ければ ListView.builder のほうが好ましいです。

通常の利用ではどちらでも良いですが、ここから先は ListView.builder のほうを使っていきます。
要素のインデックスを受け取って生成に利用できるという使いやすさもあります。

高さ指定② - 最小値

①の指定方法は画像の高さが 100.0 だとわかっているので使えましたが、あらかじめわからなくて特定の固定サイズに拡大/縮小したくもない場合には SizedBox は適していません。

大量の読み込みを防ぐにはある程度の高さがあればいいので、画像より小さめの高さ(でもそれなりのサイズ)を最小値として設定しておけば OK です。

ListView.builder(
  cacheExtent: 0.0,
  itemCount: 20,
  itemBuilder: (context, index) {
    return ConstrainedBox(
      constraints: const BoxConstraints(minHeight: 50.0),
      child: Column(
        children: [
          Image.network('https://xxxxx.xxx/$index.jpg'),
          const Divider(),
        ],
      ),
    );
  },
)

画像の読み込みが終わっていない間のスクリーンショットです。
画像の高さ(100.0)よりは小さい(50.0)ので本来の表示数より多くなりますが、表示範囲に大量の要素が入ることは防げています。
また最小値しか指定していないので、その高さより画像が大きければ最小値に影響されずに最終的にちゃんと画像サイズで表示されます。

DevTools で効果が確認できました。

プレースホルダ画像

ListView.builder(
  itemCount: 20,
  itemBuilder: (context, index) {
    return Column(
      children: [
        FadeInImage.assetNetwork(
          placeholder: 'images/placeholder.jpg',
          image: 'https://xxxxx.xxx/$index.jpg',
        ),
        const Divider(),
      ],
    );
  },
)

読み込まれるまでの間に代わりのプレースホルダ画像を表示しておけば、高さがあるので大丈夫…

と思ってしまいそうですが、代わりを指定するだけではダメです。
プレースホルダ画像もローカルかどこかにあって非同期に読み込まれるので、その画像を読み込み終える前の一瞬は高さがない状態になってしまいます。

FadeInImage(や名前付きコンストラクタ)には height の引数があってそこで指定できますが、これはプレースホルダ画像と本来の画像の両方で使われる高さなので、どちらも指定の高さになってしまいます。
つまり①の方法と同じ意味になります。

FadeInImage.assetNetwork(
  height: 100.0,
  placeholder: 'images/placeholder.jpg',
  image: 'https://xxxxx.xxx/$index.jpg',
)

ネットワークから取得する画像のサイズはわからない場合は、②の方法と併用してプレースホルダの高さを最小値として確保しましょう。

高さ指定③ - itemExtent

高さ指定①では画像の高さを固定しましたが、ListViewitemExtent を使って一覧のアイテムの高さを固定する方法もあります。

ListView(
  cacheExtent: 0.0,
  // 各アイテムの高さを100.0に統一
  itemExtent: 100.0,
  children: [
    for (var i = 0; i < 20; i++) ...[
      child: Image.network('https://xxxxx.xxx/$i.jpg'),
      const Divider(),
    ],
  ],
)

こうすれば画像が表示されていなくてもアイテムが 100.0 の高さになり、読み込み前に全アイテムが画面内に入ってしまうことを防げます。

ただし画像がその高さよりも大きいと overflow するので、FittedBox などで防ぐ必要があります。
また、全アイテムが同じ高さに統一されることがデザイン等の都合で許容できなければ使えません。
そのような制限がない②の方法のほうが広く対応できます。

ListView + 他の widget

画面の上方に説明文などがあって、その下に一覧がある UI です。
一覧のみがスクロール対象です。

Column(
  children: [
    Padding(
      padding: const EdgeInsets.all(16.0),
      child: Text('テキスト' * 8),
    ),
    Flexible(
      child: ListView.builder(
        itemCount: 20,
        itemBuilder: (context, index) {
          return Column(
            children: [
              SizedBox(
                height: 100.0,
                child: Image.network('https://xxxxx.xxx/$index.jpg'),
              ),
              const Divider(),
            ],
          );
        },
      ),
    ),
  ],
)

これはこれでいいのですが、AppBar 以外の全体をスクロールしたいこともあると思います。
その実装として、次のようなコードに最近出くわしました。

SingleChildScrollView(
  child: Column(
    children: [
      Padding(
        padding: const EdgeInsets.all(16.0),
        child: Text('テキスト' * 8),
      ),
      ListView.builder(
        physics: const NeverScrollableScrollPhysics(),
        shrinkWrap: true,
        itemCount: 20,
        itemBuilder: (context, index) {
          return Column(
            children: [
              SizedBox(
                height: 100.0,
                child: Image.network('https://xxxxx.xxx/$index.jpg'),
              ),
              const Divider(),
            ],
          );
        },
      ),
    ],
  ),
)

SingleChildScrollView の中で ListView.builder を使い、scrollable なものが二重になるので、内側のほうを physics: NeverScrollableScrollPhysics() で non-scrollable にするという力技です。

「何だこれは!?」と思ってウェブ検索すると、その方法を紹介している記事がありました。
経験が浅い人ならそういう記事を読んでも悪手だと気づけずに信じてしまうので怖いですね。

なぜ悪手かというと、SingleChildScrollView + Column とほとんど変わらないことになるからです。
画像の読み込みが一気に起こってしまいます。

まとめてスクロール① - 地道な方法

代わりの方法の一つは、テキストの部分を ListView の先頭要素とする方法です。
おすすめはしないけれど一応使えます。

ListView.builder(
  // テキスト分として一つ増やしておく
  itemCount: 21,
  itemBuilder: (context, index) {
    return Column(
      children: [
        if (index == 0)
          // テキストの部分をListViewの先頭要素として表示
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Text('テキスト' * 8),
          )
        else ...[
          SizedBox(
            height: 100.0,
            // 一つ増やした分indexがずれるので差し引く
            child: Image.network('https://xxxxx.xxx/${index - 1}.jpg'),
          ),
          const Divider(),
        ],
      ],
    );
  },
)

index がずれるので、本来の一覧部分でその index を使うときに間違えないようにしましょう。

なお、この方法は GridView には使えません。
GridView は基本的に cross axis の要素数2 以上にして使うので、テキストをその先頭にするとグリッドの一つとして狭い表示になってしまいます。

まとめてスクロール② - sliver

もっと Flutter らしいのは sliver 系の widget を使う方法です。

ListViewGridView も内部で sliver が使われているのですが、自分で直接使うなら細かな実装が必要で、そのための widget が多数あるので、難しい印象を受ける人が多いようです。
小難しい名前のクラスやメソッドもあります。

SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context))

などは確かに「はぁ?」と思いますね。

でもまとめてスクロールする程度なら難しいことはありません。
先に公式の動画を見たほうが理解しやすくなると思います。

www.youtube.com

Sliver の意味

英語の発音は /slívə(r)/ です。
細切れや薄切りにした一片のことで、Flutter ではスクロール部分の中の断片的な各パーツのことです。
それらのパーツをまとめて一繋がりに scrollable にしたり、もっと高度なスクロール効果を実現できたりします。

意味がわかると抵抗感が少し減るんじゃないでしょうか。

Sliver を使ってみる

CustomScrollView(
  slivers: [
    SliverToBoxAdapter(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Text('テキスト' * 8),
      ),
    ),
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          return Column(
            children: [
              SizedBox(
                height: 100.0,
                child: Image.network('https://xxxxx.xxx/$index.jpg'),
              ),
              const Divider(),
            ],
          );
        },
        childCount: 20,
      ),
    ),
  ],
)

長い部分を省略して書くと下記のように意外とシンプルです。
数の子を持つ ListView と似た構造ですね。
テキスト部分とその下の一覧の計二つをスクロール内の断片(slivers)として持っています。

CustomScrollView(
  cacheExtent: 0.0,
  slivers: [
    // テキストの部分
    SliverToBoxAdapter(child: ...),
    // テキストの下の一覧
    SliverList(delegate: ...),
  ],
)
  • 一般的に CustomScrollView が使われる
    • ScrollView の一種で、sliver 系の widget を組み合わせて様々なスクロールの効果を作るのに使える
    • 他に BoxScrollView があり、ListViewGridView はそれを継承している
  • slivers には sliver 系の widget しか渡せない
    • Text 等の通常の widget を使うには SliverToBoxAdaptorかます
    • SliverPadding のように通常の widget の sliver 版があったりもする
  • cacheExtentSliverList ではなく CustomScrollView のほうで設定

ではコードの省略を少し減らして再度見てみましょう。

CustomScrollView(
  slivers: [
    // テキストの部分
    SliverToBoxAdapter(
      child: ...,
    ),
    // テキストの下の一覧
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => ...,
        childCount: 20,
      ),
    ),
  ],
)

SliverListdelegate に渡すものは、SliverChildListDelegateListView のデフォルトコンストラクタ、SliverChildBuilderDelegateListView.builder に近いです。
この例では後者を使いました。

テキストと一覧を一緒にスクロールすることを可能にしつつ、ListView と同様の遅延も実現できました。

余白を設ける

SliverSizedBox という widget は存在しないので SliverToBoxAdapterSizedBox を組み合わせないといけません。

CustomScrollView(
  slivers: [
    ...,
    const SliverToBoxAdapter(
      child: SizedBox(height: 16.0),
    ),
    ...,
  ],
)

SliverPadding も使えます。
子を指定する引数が child ではなくて sliver になっていて便利です。
これを使って SliverList の周りに padding を付けても画像の遅延読み込みに悪影響しません(確認済み)。

SliverPadding(
  padding: const EdgeInsets.all(16.0),
  sliver: SliverList(...),
)

sliver についてもっと詳しく知りたい場合は下記記事が参考になると思います。
公式ドキュメント からもリンクされています。

medium.com

ローカルにキャッシュする

遅延読み込みと併用しておきたいのがキャッシュです。
画像が頻繁に変わるならキャッシュの有効期間が短くて効果が薄いですが、高頻度で変わるアプリが多いとは思えないので使えるケースのほうが多いと思います。

メリットとデメリット

  • バックエンドからの転送量を減らせる
  • 費用に影響しない
  • ユーザの通信量も減る
  • 大量に読み込んでもサーバの負荷にならない
  • 表示速度が上がる

といったメリットがあります。

バイス上のストレージは使用量が増えますが、デメリットというほどではないと思います。
無制限にキャッシュしてしまうアプリを使って空き容量がゼロになった経験はありますが…。
そんな酷いアプリにならないように、パッケージでキャッシュの期限などを設定できます。

パッケージを使わなくてもアプリが起動している間はある程度キャッシュされるようですが、再起動してもキャッシュが残るようにするにはプラグインパッケージが必要です。

cached_network_image

記事を書くにあたって探し直したのですが、過去にあったパッケージが discontinued になっていたり、人気が高めのパッケージでもテストコードがなかったりしました。
費用に関わる重要なところなので、自動テストされていないようなパッケージは選定対象になりません。

安心して使えそうなのは cached_network_image くらいでした。
他に同等の機能と人気/安定性を持つパッケージがあればぜひ教えてください。

pub.dev

このパッケージは公式の Cookbook でも紹介されていますが、細かな制御の説明はありません。
でも先に読むと良いと思います。とても短いです。

flutter.dev

これまで何度か使っているのですが、細かい設定までしたことがなかったので調べてみました。
ドキュメントから読み取ったことくらいしか書かないので、使うときにご自分で読んだり試したりしてください。

対応プラットフォーム

パッケージのページでは AndroidiOSmacOS のみの表記になっていますが、下記 issue 内の情報によれば他のプラットフォームでも動くようです。

github.com

キャッシュ制御設定の例

内部で flutter_cache_manager というパッケージが利用されていて、そちらの機能で制御できるようになっているようです。
下記はそのパッケージのページに載っている例です。

class CustomCacheManager {
  static const key = 'customCacheKey';
  static CacheManager instance = CacheManager(
    Config(
      key,
      stalePeriod: const Duration(days: 7),
      maxNrOfCacheObjects: 20,
      repo: JsonCacheInfoRepository(databaseName: key),
      fileSystem: IOFileSystem(key),
      fileService: HttpFileService(),
    ),
  );
}

有効な期間(stalePeriod: どれだけ経っていればもう古いとみなすか)やキャッシュの数(maxNrOfCacheObject) が指定されています。
こうやって設定すれば無制限にキャッシュしてしまうことはなくなりますね。

Config の引数

IOFileSystemflutter_cache_manager パッケージ内の export されていないファイルに書かれています。
ユーザが直接利用できないのでどうすればいいか調べてみると、Config は抽象クラスになっていてプラットフォーム別に実装されていました。
例えばウェブ以外用の実装クラスでは下のスクリーンショットのようになっています。

fileSystem に何も渡されなければ IOFileSystem が使われるようになっていますね。
ウェブなら MemoryCacheSystem です。
repofileSystemfileService の三つは省略しておけば良さそうです。

デフォルトのキャッシュ設定

上のスクリーンショットConfig の実装は DefaultCacheManager でも使用されているので、自分で cacheManager を指定しないときのデフォルト設定になります。
有効期間 30 日、最大数 200 のままでいいなら省略で OK です。

CacheManager のインスタンス

The cache manager is customizable by creating a new CacheManager. It is very important to not create more than 1 CacheManager instance with the same key as these bite each other. In the example down here the manager is created as a Singleton, but you could also use for example Provider to Provide a CacheManager on the top level of your app. Below is an example with other settings for the maximum age of files, maximum number of objects and a custom FileService. The key parameter in the constructor is mandatory, all other variables are optional.

CacheManagerインスタンスは一つのキーにつき一つだけにしないといけないようです。
Singleton パターンを使うか、一つを用意して DI して使い回せば大丈夫です。
CachedNetworkImage で使うには、DI 等で受け取ったインスタンスcacheManager という引数に渡します。

特定の画面で表示する画像は長期間のキャッシュを主に使い、他の画面では短期間にしておきたいような場合、異なるキーで CacheManager を用意しておいて使い分ければいいんじゃないかと思います。

保存先

By default the cached files are stored in the temporary directory of the app. This means the OS can delete the files any time.

Information about the files is stored in a database using sqflite on Android, iOS and macOs, or in a plain JSON file on other platforms. The file name of the database is the key of the cacheManager, that's why that has to be unique.

デフォルトでは、ファイルはアプリの一時ディレクトリにキャッシュされ、その情報は AndroidiOSmacOS では sqflite、他プラットフォームではプレーンな JSON ファイルに保存されるそうです。
また、一時ディレクトリなのでそこのファイルは OS によって消されることがあるとのことです。

キャッシュがなくなることがあって再びダウンロードが起こりますよ、という意味だと私は理解しました。
消えているかどうかを確認しながら使わないといけないという意味ではないと思いますが、未確認です。

画像が更新された場合

A valid url response should contain a Cache-Control header.

flutter_cache_manager は HTTP の Cache-Control ヘッダに従うようです。
画像配信側で適切に設定していないと意図した動作になりません。

  • キャッシュさせない設定になっていると、キャッシュ用のパッケージを使っても意味がない
  • 再検証までの期間が長めに設定されていると、画像を更新してもクライアント側でキャッシュを使ってしまう

といったことが考えられます。
キャッシュ期間を長くして再検証までの期間を短くするのが良さそうです。

画像ファイル名

ファイル名も重要です。
例えば商品一覧に coffee.png という画像を表示しているとすると、キャッシュ期間内に更新されたときに名前が同じならキャッシュが使われますが、coffee2.png に変えて一覧データ内に含まれるファイル名も変えればキャッシュを無視できます。

大きな画像のキャッシュ

キャッシュしても画像が大きければメモリが多く使用されます。
あらかじめ縮小しておく(大きく表示したい箇所以外では縮小済みの画像を使う)のが良いですが、クラウドのストレージに縮小画像がない場合などにローカルでキャッシュ画像のサイズを小さくすれば、何もしない場合より使用量を抑えることができるはずです。

  • maxWidthDiskCache / maxHeightDiskCache
    • 画像を縮小してディスクキャッシュに保存する
  • memCacheWidth / memCacheHeight
    • ResizeImage を使ってメモリ上の画像を縮小する

試してはいないので効果を確認したわけではないのですが、Flutter 標準の Image.xxxImage.network など)にある cacheWidth / cacheHeight に近いものだと思われるので、おそらく使用メモリの削減になります。

まとめ

遅延読み込み、メモリ使用量抑制

  • ColumnRow を避ける
    • ListViewGridView を使う 5
  • 高さ(横スクロールなら幅)の確保が必要
  • SingleChildScrollView + ListView / GridView はダメ
    • sliver を使う

キャッシュ

  • 積極的に使う
  • パッケージは cached_network_image
  • 適切に設定しないと意図しない挙動になり得る

これらとサムネイル画像の使用(+ 場合によってはパジネーションなども)を組み合わせましょう。


  1. Lorem Picsum からダウンロードして使いました。そのサイトの画像に直接的に大量アクセスすると悪影響を与えかねないので、ローカルのウェブサーバを使っています。ご自分で試したい方はご注意ください。

  2. maximumSizemaximumSizeBytes0 をセットしたときにキャッシュがクリアされるので、どちらかに 0 を設定してから元の値に戻すという意図的な消去にも使えます。

  3. cacheExtent のデフォルト値は 250.0 です。

  4. 要素が破棄されても、その中で使われていた画像はキャッシュされていて次の表示時にダウンロードし直さずに使えるようです(ただしアプリを終了したら消えます)。無限にキャッシュされるかどうかは把握していません。

  5. ListView(おそらく GridView も)には難点があるので注意しましょう。FormField を使っている要素が見える部分 +α の範囲から外れるとバリデーションが効かないそうです(https://github.com/flutter/flutter/issues/56159)。外れたときに dispose される仕組みが影響しているとすれば、不具合というよりは仕様に思えます。