TEXTBOOK SECTION / AI LEARNING

API取得失敗時のエラー表示を作る

Flutterアプリケーション開発概論の「Flutter API連携入門|ポケモン図鑑アプリの作り方」より、API取得失敗時のエラー表示を作るを解説。生成AI、AI活用、DX、業務改善を実践しながら学べるオンライン教材です。

15Flutter API連携入門|ポケモン図鑑アプリの作り方Flutter / iOS / Android / MacOS / Windows / 基礎から学ぶ / 開発 / アプリ開発

OVERVIEW

この節で学べること

概要を表示する
項目内容
教材名Flutterアプリケーション開発概論
Flutter API連携入門|ポケモン図鑑アプリの作り方
API取得失敗時のエラー表示を作る
カテゴリFlutter / iOS / Android / MacOS / Windows / 基礎から学ぶ / 開発 / アプリ開発
学習内容生成AI、AI活用、DX、業務改善を実践しながら理解するための教材です。

TABLE OF CONTENTS

目次

CONTENT

ここから

前の節では、API通信中にローディング表示を出しました。

今回は、存在しない番号を入力したときや、通信に失敗したときに、アプリが止まらないようにエラー表示を作ります。


この節で作ること

作業内容
try-catch を使うAPI通信の失敗を受け止める
エラーメッセージを表示する画面に分かりやすく出す
ローディングを止める失敗しても _isLoadingfalse に戻す
結果カードを消す失敗時は古い結果を残さない

忙しい方はここだけ見て

API通信に失敗する可能性がある処理は、try-catch で囲みます。

try {
  final Map<String, dynamic> data = await fetchPokemonData(pokemonId);

  final String name = data['name'] as String;

  final Map<String, dynamic> sprites =
      data['sprites'] as Map<String, dynamic>;

  final String imageUrl = sprites['front_default'] as String;

  setState(() {
    _pokemonName = name;
    _pokemonImageUrl = imageUrl;
    _pokemonIdText = pokemonId.toString();
    _errorMessage = null;
    _isLoading = false;
  });
} catch (error) {
  setState(() {
    _pokemonName = '';
    _pokemonImageUrl = null;
    _pokemonIdText = null;
    _errorMessage = 'ポケモンを取得できませんでした';
    _isLoading = false;
  });
}

try-catchとは

try-catch は、エラーが起きるかもしれない処理を安全に実行するための書き方です。

try {
  // 失敗するかもしれない処理
} catch (error) {
  // 失敗したときの処理
}

API通信では、次のような理由で失敗することがあります。

原因
存在しない番号999999 を入力した
通信環境が悪いインターネットにつながっていない
API側の問題サーバーが一時的に不安定

失敗を想定しておくと、アプリが急に止まりにくくなります。


エラー用の変数を追加する

_PokemonHomePageState に、エラーメッセージ用の変数を追加します。

String? _errorMessage;

状態変数は、次のようになります。

String _pokemonName = 'ポケモン番号を入力してください';
String? _pokemonImageUrl;
String? _pokemonIdText;
String? _errorMessage;
bool _isLoading = false;

String? にしているのは、エラーがない状態もあるためです。


検索処理を修正する

_searchPokemon() を次のように変更します。

/**
 * 入力された番号を使ってPokéAPIを呼び出し、成功時と失敗時の表示を切り替える関数。
 *
 * 入力: なし
 * 出力: 検索結果、エラーメッセージ、ローディング状態を更新する
 */
Future<void> _searchPokemon() async {
  final int? pokemonId = int.tryParse(_pokemonIdController.text);

  if (pokemonId == null) {
    setState(() {
      _pokemonName = '';
      _pokemonImageUrl = null;
      _pokemonIdText = null;
      _errorMessage = '数字を入力してください';
      _isLoading = false;
    });
    return;
  }

  setState(() {
    _isLoading = true;
    _pokemonName = '';
    _pokemonImageUrl = null;
    _pokemonIdText = null;
    _errorMessage = null;
  });

  try {
    final Map<String, dynamic> data = await fetchPokemonData(pokemonId);

    final String name = data['name'] as String;

    final Map<String, dynamic> sprites =
        data['sprites'] as Map<String, dynamic>;

    final String imageUrl = sprites['front_default'] as String;

    setState(() {
      _pokemonName = name;
      _pokemonImageUrl = imageUrl;
      _pokemonIdText = pokemonId.toString();
      _errorMessage = null;
      _isLoading = false;
    });
  } catch (error) {
    setState(() {
      _pokemonName = '';
      _pokemonImageUrl = null;
      _pokemonIdText = null;
      _errorMessage = 'ポケモンを取得できませんでした';
      _isLoading = false;
    });
  }
}

ポイントは、失敗時にも必ず _isLoading = false に戻すことです。

これを忘れると、ローディング表示がずっと消えません。


エラー表示を画面に追加する

カード表示の前に、エラーメッセージを表示する条件を追加します。

if (_isLoading)
  const CircularProgressIndicator()
else if (_errorMessage != null)
  Text(
    _errorMessage!,
    style: const TextStyle(
      fontSize: 18,
      fontWeight: FontWeight.bold,
      color: Colors.red,
    ),
  )
else if (_pokemonImageUrl != null)
  Card(
    elevation: 4,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(20),
    ),
    child: Padding(
      padding: const EdgeInsets.all(24),
      child: Column(
        children: <Widget>[
          Text(
            'No. $_pokemonIdText',
            style: const TextStyle(
              fontSize: 16,
              color: Colors.grey,
            ),
          ),
          const SizedBox(height: 8),
          Image.network(
            _pokemonImageUrl!,
            width: 160,
            height: 160,
            fit: BoxFit.contain,
          ),
          const SizedBox(height: 16),
          Text(
            _pokemonName,
            style: const TextStyle(
              fontSize: 28,
              fontWeight: FontWeight.bold,
            ),
          ),
        ],
      ),
    ),
  )
else
  Text(
    _pokemonName,
    style: const TextStyle(
      fontSize: 20,
      fontWeight: FontWeight.bold,
    ),
  ),

表示の優先順位は、次の順番です。

1. 通信中ならローディング
2. エラーがあればエラー表示
3. 画像URLがあればカード表示
4. それ以外は通常メッセージ

完成コード

lib/main.dart を次のようにします。

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

/**
 * アプリの起点になる関数。
 *
 * 入力: なし
 * 出力: MyAppを起動する
 */
void main() {
  runApp(const MyApp());
}

/**
 * 指定されたポケモン番号でPokéAPIからデータを取得する関数。
 *
 * 入力: pokemonId ポケモン番号
 * 出力: ポケモン情報を持つMap<String, dynamic>
 */
Future<Map<String, dynamic>> fetchPokemonData(int pokemonId) async {
  final Uri url = Uri.https(
    'pokeapi.co',
    '/api/v2/pokemon/$pokemonId',
  );

  final http.Response response = await http.get(url);

  if (response.statusCode != 200) {
    throw Exception('ポケモンデータを取得できませんでした');
  }

  final Map<String, dynamic> data =
      jsonDecode(response.body) as Map<String, dynamic>;

  return data;
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  /**
   * アプリ全体のUIを構築する関数。
   *
   * 入力: BuildContext
   * 出力: MaterialApp
   */
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ポケモン図鑑',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.red),
        useMaterial3: true,
      ),
      home: const PokemonHomePage(),
    );
  }
}

class PokemonHomePage extends StatefulWidget {
  const PokemonHomePage({super.key});

  @override
  State<PokemonHomePage> createState() => _PokemonHomePageState();
}

class _PokemonHomePageState extends State<PokemonHomePage> {
  final TextEditingController _pokemonIdController = TextEditingController();

  String _pokemonName = 'ポケモン番号を入力してください';
  String? _pokemonImageUrl;
  String? _pokemonIdText;
  String? _errorMessage;
  bool _isLoading = false;

  /**
   * TextEditingControllerを破棄する関数。
   *
   * 入力: なし
   * 出力: Controllerのリソースを解放する
   */
  @override
  void dispose() {
    _pokemonIdController.dispose();
    super.dispose();
  }

  /**
   * 入力された番号を使ってPokéAPIを呼び出し、成功時と失敗時の表示を切り替える関数。
   *
   * 入力: なし
   * 出力: 検索結果、エラーメッセージ、ローディング状態を更新する
   */
  Future<void> _searchPokemon() async {
    final int? pokemonId = int.tryParse(_pokemonIdController.text);

    if (pokemonId == null) {
      setState(() {
        _pokemonName = '';
        _pokemonImageUrl = null;
        _pokemonIdText = null;
        _errorMessage = '数字を入力してください';
        _isLoading = false;
      });
      return;
    }

    setState(() {
      _isLoading = true;
      _pokemonName = '';
      _pokemonImageUrl = null;
      _pokemonIdText = null;
      _errorMessage = null;
    });

    try {
      final Map<String, dynamic> data = await fetchPokemonData(pokemonId);

      final String name = data['name'] as String;

      final Map<String, dynamic> sprites =
          data['sprites'] as Map<String, dynamic>;

      final String imageUrl = sprites['front_default'] as String;

      setState(() {
        _pokemonName = name;
        _pokemonImageUrl = imageUrl;
        _pokemonIdText = pokemonId.toString();
        _errorMessage = null;
        _isLoading = false;
      });
    } catch (error) {
      setState(() {
        _pokemonName = '';
        _pokemonImageUrl = null;
        _pokemonIdText = null;
        _errorMessage = 'ポケモンを取得できませんでした';
        _isLoading = false;
      });
    }
  }

  /**
   * ポケモン検索画面を構築する関数。
   *
   * 入力: BuildContext
   * 出力: Scaffold
   */
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ポケモン図鑑'),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: <Widget>[
            TextField(
              controller: _pokemonIdController,
              keyboardType: TextInputType.number,
              enabled: !_isLoading,
              decoration: const InputDecoration(
                labelText: 'ポケモン番号',
                hintText: '例: 25',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 16),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                onPressed: _isLoading ? null : _searchPokemon,
                child: Text(_isLoading ? '検索中...' : '検索する'),
              ),
            ),
            const SizedBox(height: 32),
            if (_isLoading)
              const CircularProgressIndicator()
            else if (_errorMessage != null)
              Text(
                _errorMessage!,
                style: const TextStyle(
                  fontSize: 18,
                  fontWeight: FontWeight.bold,
                  color: Colors.red,
                ),
              )
            else if (_pokemonImageUrl != null)
              Card(
                elevation: 4,
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(20),
                ),
                child: Padding(
                  padding: const EdgeInsets.all(24),
                  child: Column(
                    children: <Widget>[
                      Text(
                        'No. $_pokemonIdText',
                        style: const TextStyle(
                          fontSize: 16,
                          color: Colors.grey,
                        ),
                      ),
                      const SizedBox(height: 8),
                      Image.network(
                        _pokemonImageUrl!,
                        width: 160,
                        height: 160,
                        fit: BoxFit.contain,
                      ),
                      const SizedBox(height: 16),
                      Text(
                        _pokemonName,
                        style: const TextStyle(
                          fontSize: 28,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ],
                  ),
                ),
              )
            else
              Text(
                _pokemonName,
                style: const TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.bold,
                ),
              ),
          ],
        ),
      ),
    );
  }
}

動作確認

まず、正しい番号を入力します。

25

ピカチュウのカードが表示されれば成功です。

次に、存在しない番号を入力します。

999999

次のように表示されれば成功です。

ポケモンを取得できませんでした

空欄で検索した場合は、次のように表示されます。

数字を入力してください

よくあるエラー

エラー原因対応
アプリが止まるtry-catch がないAPI通信部分を囲む
ローディングが消えない失敗時に _isLoading = false がないcatch 内でも戻す
古いカードが残る失敗時に画像URLを消していない_pokemonImageUrl = null にする
エラーが出ない_errorMessage を表示していないelse if で表示する

まとめ

この節では、API取得失敗時のエラー表示を作りました。

大事なポイントは3つです。

ポイント内容
try-catch失敗する可能性のある処理を安全に扱う
_errorMessageエラー内容を画面に表示する
_isLoading = false失敗時もローディングを止める

これで、存在しない番号や通信失敗にも対応できるようになりました。

次は、アプリ全体の見た目を整えて、完成度を上げていきます。

FAQ

よくある質問

API取得失敗時のエラー表示を作るは医療関係者向けだけの内容ですか。
医療分野の例が含まれる場合もありますが、医療関係者だけに限定した内容ではありません。生成AI、AI活用、DX、業務改善、プロトタイプ開発など、一般的なAI学習の事例として読める内容です。
AI初心者でも読めますか。
はい。AIをこれから学ぶ方、数学が苦手な方、仕事でAIを使いたい方にも読み進めやすいように、教材の章と節の流れに沿って整理しています。
サムネイル画像は必ず表示されますか。
はい。教材にcoverUrlが設定されている場合はその画像を表示し、未設定の場合は代替サムネイル画像を表示します。
Flutterアプリケーション開発概論のほかの章も読めますか。
はい。教材トップから章立てを確認でき、前後の節へもページ下部のナビゲーションから移動できます。