Flutterで画像の読み込みを最適化!「multi_network_image」パッケージ

ネットワーク画像の読み込みが遅い、エラーが発生するといった問題はありませんか? そんなときに役立つのが multi_network_image パッケージです。複数の画像URLを指定して、最適な画像を自動選択してくれる便利なパッケージです。


1. multi_network_imageとは?

multi_network_image は、アダプティブ画像を簡単に扱うことができるパッケージです。複数の画像URLを提供し、キャッシュされた画像があればそれを優先表示し、メイン画像の読み込みに失敗した場合は自動的にフォールバック画像に切り替えます。

主な特徴:

  • 複数のURL指定による自動フォールバック機能
  • キャッシュされた画像の優先表示
  • 低品質画像をプレースホルダーとして活用
  • 高品質・低品質画像の使い分けが可能

2. インストール方法

pubspec.yaml に以下を追加します:

dependencies:
  multi_network_image: ^0.1.3

インストール後に実行:

flutter pub get

3. 基本的な使い方

MultiNetworkImageをImageのsourceとして使用します:

import 'package:flutter/material.dart';
import 'package:multi_network_image/multi_network_image.dart';

class ImageDisplayWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Image(
      image: MultiNetworkImage([
        // メイン画像(高品質)
        'https://picsum.photos/2000',
        // フォールバック画像(中品質)
        'https://picsum.photos/200',
        // プレースホルダー画像(低品質)
        'https://picsum.photos/20',
      ]),
      fit: BoxFit.cover,
      width: 300,
      height: 200,
    );
  }
}

4. 動作の仕組み

MultiNetworkImageは、リストで提供され、キャッシュされている最初の画像を初期ソースとして使用します。その後、リストの最初の画像の取得を試み、失敗した場合は次の画像を試します。

graph TD
    A[MultiNetworkImage開始] --> B{キャッシュ画像あり?}
    B -->|Yes| C[キャッシュ画像を表示]
    B -->|No| D[1番目のURL取得試行]
    D --> E{取得成功?}
    E -->|Yes| F[高品質画像を表示]
    E -->|No| G[2番目のURL取得試行]
    G --> H{取得成功?}
    H -->|Yes| I[フォールバック画像を表示]
    H -->|No| J[3番目のURL取得試行]

5. 実用的な使用例

プロフィール画像での活用

class ProfileImageWidget extends StatelessWidget {
  final String userId;
  
  ProfileImageWidget({required this.userId});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 80,
      height: 80,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        border: Border.all(color: Colors.grey.shade300, width: 2),
      ),
      child: ClipOval(
        child: Image(
          image: MultiNetworkImage([
            // 高解像度のプロフィール画像
            'https://api.example.com/users/$userId/profile/high',
            // 標準解像度のプロフィール画像
            'https://api.example.com/users/$userId/profile/medium',
            // デフォルトのアバター画像
            'https://api.example.com/default-avatar.png',
          ]),
          fit: BoxFit.cover,
          errorBuilder: (context, error, stackTrace) {
            return Icon(Icons.person, size: 40, color: Colors.grey);
          },
        ),
      ),
    );
  }
}

ギャラリー表示での活用

class GalleryImageWidget extends StatelessWidget {
  final String imageId;
  
  GalleryImageWidget({required this.imageId});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Column(
        children: [
          Expanded(
            child: Image(
              image: MultiNetworkImage([
                // 4K高画質画像
                'https://cdn.example.com/images/$imageId/4k.jpg',
                // HD画質画像
                'https://cdn.example.com/images/$imageId/hd.jpg',
                // サムネイル画像
                'https://cdn.example.com/images/$imageId/thumb.jpg',
              ]),
              fit: BoxFit.cover,
              loadingBuilder: (context, child, loadingProgress) {
                if (loadingProgress == null) return child;
                return Center(
                  child: CircularProgressIndicator(
                    value: loadingProgress.expectedTotalBytes != null
                        ? loadingProgress.cumulativeBytesLoaded /
                          loadingProgress.expectedTotalBytes!
                        : null,
                  ),
                );
              },
            ),
          ),
          Padding(
            padding: EdgeInsets.all(8.0),
            child: Text('画像 $imageId'),
          ),
        ],
      ),
    );
  }
}

6. 異なる解像度での使い分け

class ResponsiveImageWidget extends StatelessWidget {
  final String baseUrl;
  
  ResponsiveImageWidget({required this.baseUrl});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        // 画面サイズに応じて適切な解像度を選択
        List<String> imageUrls = [];
        
        if (constraints.maxWidth > 800) {
          // デスクトップ: 高解像度
          imageUrls.addAll([
            '$baseUrl@3x.jpg',  // 高解像度
            '$baseUrl@2x.jpg',  // 中解像度
            '$baseUrl@1x.jpg',  // 標準解像度
          ]);
        } else if (constraints.maxWidth > 400) {
          // タブレット: 中解像度
          imageUrls.addAll([
            '$baseUrl@2x.jpg',  // 中解像度
            '$baseUrl@1x.jpg',  // 標準解像度
            '$baseUrl@0.5x.jpg', // 低解像度
          ]);
        } else {
          // モバイル: 標準解像度
          imageUrls.addAll([
            '$baseUrl@1x.jpg',  // 標準解像度
            '$baseUrl@0.5x.jpg', // 低解像度
            '$baseUrl@0.25x.jpg', // 超低解像度
          ]);
        }
        
        return Image(
          image: MultiNetworkImage(imageUrls),
          fit: BoxFit.cover,
        );
      },
    );
  }
}

7. エラーハンドリングとの組み合わせ

class RobustImageWidget extends StatelessWidget {
  final List<String> imageUrls;
  final Widget? placeholder;
  
  RobustImageWidget({
    required this.imageUrls, 
    this.placeholder,
  });

  @override
  Widget build(BuildContext context) {
    return Image(
      image: MultiNetworkImage(imageUrls),
      fit: BoxFit.cover,
      frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
        if (wasSynchronouslyLoaded || frame != null) {
          return child;
        }
        return placeholder ?? 
               Container(
                 color: Colors.grey.shade200,
                 child: Center(
                   child: CircularProgressIndicator(),
                 ),
               );
      },
      errorBuilder: (context, error, stackTrace) {
        return Container(
          color: Colors.grey.shade100,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.broken_image, size: 50, color: Colors.grey),
              SizedBox(height: 8),
              Text('画像を読み込めませんでした', 
                   style: TextStyle(color: Colors.grey)),
            ],
          ),
        );
      },
    );
  }
}

8. パフォーマンス最適化のコツ

項目推奨事項理由
画像サイズの順序高品質 → 中品質 → 低品質最高品質を優先しつつ、フォールバック確保
プレースホルダー最後に超低品質画像を配置即座に表示可能な軽量画像
CDN活用複数のCDNを使い分け冗長性とパフォーマンス向上
キャッシュ戦略適切なキャッシュヘッダー設定初期表示の高速化

9. 注意点とベストプラクティス

  • Flutter 3.27.0以上が必要
  • 画像URLは品質の高い順に配置することを推奨
  • ネットワーク状況に応じて適切な解像度を選択
  • エラーハンドリングを必ず実装する
  • キャッシュを活用してパフォーマンスを向上
// ❌ 悪い例: 順序が不適切
MultiNetworkImage([
  'https://example.com/low-quality.jpg',    // 低品質が最初
  'https://example.com/high-quality.jpg',   // 高品質が後
])

// ✅ 良い例: 適切な順序
MultiNetworkImage([
  'https://example.com/high-quality.jpg',   // 高品質を最初
  'https://example.com/medium-quality.jpg', // 中品質でフォールバック
  'https://example.com/low-quality.jpg',    // 低品質で確実な表示
])

まとめ

特徴内容
自動フォールバック複数URL指定で読み込み失敗時の自動切り替え
キャッシュ最適化キャッシュされた画像の優先表示
パフォーマンス向上ネットワーク状況に応じた最適な画像選択
簡単実装既存のImageウィジェットにそのまま適用可能

おわりに

multi_network_image パッケージを活用することで、ユーザー体験を大幅に向上させることができます。 ネットワーク環境が不安定な場合でも、適切なフォールバック機能により画像を確実に表示し、アプリの信頼性を高めます。


参考リンク

タイトルとURLをコピーしました