Chapter 09

Step8: APIを呼び出してデータを取得してみよう

sugit
sugit
2021.12.07に更新

Step8 の概要

Step8 では、PokeAPI (REST api) を呼び出してアプリに反映する実装を行います。

本章で学べること

  • HTTP をつかった API 呼び出し
  • JSONの取り扱い

APIの仕様を確認しよう

では、ダミーで用意していたデータを実際にAPIで取得したデータに置き換えていこうと思います。

https://pokeapi.co/

細かいデータはとりあえず後で考えるとして、

  • ID
  • 名前
  • タイプ
  • 画像

の4つをまずは確保したいと思います。

APIを呼び出して結果を保持するには http パッケージを使います。
Dioというのもありますが、以下の通り酷評です。

https://www.reddit.com/r/FlutterDev/comments/mzhmnb/http_vs_dio/?utm_source=ifttt

Dart:IO の HttpClient もありますが、これはあくまで Dart:IOのラッパーです。Webは一手間必要です。

https://api.dart.dev/stable/2.14.4/dart-io/HttpClient-class.html

ネイティブもWebも、ということならhttp が無難でしょう。

https://pub.dev/packages/http

ということで、httpを flutter pub get しておきます。
では早速APIを叩いていきたいのですが、APIをコールして受け取った結果を保持しておきたいですね。同じ結果が返ってくるAPIを何度も何度も呼び出すのは得策ではありません。
Poke API にも以下の記述があります。

Rules:

  • Locally cache resources whenever you request them.

Modelを定義しよう

では、まずは受け取ったデータを保持するための model クラスを定義します。
APIで受け取り可能な全てのデータを保持する必要はないので、以下の通り設計しました。

class Pokemon {
  final int id;
  final String name;
  final List<String> types;
  final String imageUrl;

  Pokemon({
    required this.id,
    required this.name,
    required this.types,
    required this.imageUrl,
  });
}

次に、このクラスに factoryコンストラクタを追加します。
既に記述されているデフォルトコンストラクタに対して、factoryコンストラクタを追加するのはその方がJSONの解釈部分のロジックを集中管理できるというメリットがあるためです。
factoryコンストラクタは PokeAPI の JSONの形式をみながら書くとこのようになります。

class Pokemon {
  final int id;
  final String name;
  final List<String> types;
  final String imageUrl;

  Pokemon({
    required this.id,
    required this.name,
    required this.types,
    required this.imageUrl,
  });

  factory Pokemon.fromJson(Map<String, dynamic> json) {
    List<String> typesToList(dynamic types) {
      List<String> ret = [];
      for (int i = 0; i < types.length; i++) {
        ret.add(types[i]['type']['name']);
      }
      return ret;
    }

    return Pokemon(
      id: json['id'],
      name: json['name'],
      types: typesToList(json['types']),
      imageUrl: json['sprites']['other']['official-artwork']['front_default'],
    );
  }
}

APIを呼び出してデータを受け取ろう

ではAPIをコールしてここの形式でデータを保持していきたいです。
設計に際してはAPIの仕様を十分に確認しておく必要があります。

まずポケモンの情報を取得する方法として、基本のAPIが
https://pokeapi.co/api/v2/pokemon/{id or name}/
であるということです。
idを範囲指定できないことが一番の考え所です。ポケモンのIDはこの執筆時点で898番まであります。つまり、IDを範囲指定できないということは、APIは最大で898回呼び出す必要があります。APIを898回呼び出すと言われたときに考えるべきはAPIサーバーの気持ちです。
例えば898種のポケモンの情報を一度にまとめて取得するような実装をしたとします。APIは短時間の間に同じクライアントから898回の呼び出しを受けることになります。

「え、攻撃されてる?」

そう感じても無理はない数字です。APIサーバーは同一のクライアントからの大量のリクエストが短時間であった時にリクエストを受け付けなくなることが多いです。
したがって、アプリケーションを組む側としては、一度に大量のリクエストを送らなくても済むような設計にすべきです。もしまとめてデータをとって動かすようなアプリを作りたいのなら、ストリームなり一括取得なりのAPIをサーバー側に用意すべきです。しかしこれはAPIサーバーの設計も自ら行なっている場合の選択肢であり、こういった善意の下に使用させていただくAPIにそれを求めるべきではありません。そこはAPIでできることの範疇で考えるのが礼儀です。
以下のようにAPIの呼び出しメソッドを作成します。これは ID 指定でポケモン情報を得るものですね。

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/pokemon.dart';
import '../const/pokeapi.dart';

Future<Pokemon> fetchPokemon(int id) async {
  final res = await http.get(Uri.parse('$pokeApiRoute/pokemon/$id'));
  if (res.statusCode == 200) {
    return Pokemon.fromJson(jsonDecode(res.body));
  } else {
    throw Exception('Failed to Load Pokemon');
  }
}

呼び出しに使うURLは定数を集めたファイルを作っておくと便利です。

// ~/lib/consts/pokeapi.dart
const int pokeMaxId = 898;
const String pokeApiRoute = "https://pokeapi.co/api/v2/";

受け取ったデータを Provider でアプリに反映しよう

では状態管理を含めて先程の ChangeNotifierProvider を使って実装してみます。
まずは ChangeNotifier の定義をします。

class PokemonsNotifier extends ChangeNotifier {
  // ID, Pokemon
  final Map<int, Pokemon> _pokeMap = {};

  Map<int, Pokemon> get pokes => _pokeMap;

  void addPoke(Pokemon poke) {
    _pokeMap[poke.id] = poke;
    notifyListeners();
  }
}

とりあえず、ポケモンはIDで管理したいので(API取得済みかどうかがIDで検索するとすぐに分かるのが良い)Mapで定義しています。
ポケモンの追加用メソッドだけ定義し、idをKeyにMapに登録します。
つぎにChangeNotifierProviderです。すでに ThemeMode で使用していたので、同じところに追加してみましょう。

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final SharedPreferences pref = await SharedPreferences.getInstance();
  final themeModeNotifier = ThemeModeNotifier(pref);
  final pokemonsNotifier = PokemonsNotifier();
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider<ThemeModeNotifier>(
          create: (context) => themeModeNotifier,
        ),
        ChangeNotifierProvider<PokemonsNotifier>(
          create: (context) => pokemonsNotifier,
        ),
      ],
      child: const MyApp(),
    ),
  );
}

MultiProviderというのが新しく出てきましたね。
Providerパッケージには複数のProviderをまとめて扱いたい時に使用する MultiProvider という仕組みが提供されています。以下の例のようにProviderを二重、三重に書くのはやめましょう。

ChangeNotifierProvider<NotifierOne>(
  create: () => {},
  child: ChangeNotifierProvider<NotifierTwo>(
    create: () => {},
    child: ChangeNotifierProvider<NotifierThree>(
      create: () => {},
      child: ...
    ),
  ),
)

では、これでNotifierにアクセスする準備ができましたね。
mainのPokeListのListView(ポケモン一覧)に対して Consumerで提供します。


Widget build(BuildContext context) {
    return Consumer<PokemonsNotifier>(
      builder: (context, pokes, child) => ListView.builder(
        padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
        itemCount: 10,
        itemBuilder: (context, index) {
            return PokeListItem(
              pokes: pokes.byId( index + 1 ),
            );
          }
        },
      ),
    );
}

APIリクエストが多発しないように、一旦 itemCount を 10 くらいに抑えておきます。この段階で 898を設定していると、実装ミスによってAPIを大量に呼び出すことになってしまいます。また、PokeListItemには ID で指定したポケモン情報を提供します。そのために byIdメソッドを追加します。
byIdメソッドは、id で指定された番号のポケモン情報を取得するという意味を持たせます。しかし、内部的には "キャッシュされた状態にあればそれを返し、なければAPIで取得してキャッシュする" という実装をします。

class PokemonsNotifier extends ChangeNotifier {
  final Map<int, Pokemon?> _pokeMap = {};

  Map<int, Pokemon?> get pokes => _pokeMap;

  void addPoke(Pokemon poke) {
    _pokeMap[poke.id] = poke;
    notifyListeners();
  }

  void fetchPoke(int id) async {
    _pokeMap[id] = null;
    addPoke(await fetchPokemon(id));
  }

  Pokemon? byId(int id) {
    if (!_pokeMap.containsKey(id)) {
      fetchPoke(id);
    }
    return _pokeMap[id];
  }
}

_pokeMapに入っているかどうかでAPI呼び出し済みかどうかを管理します。一旦nullで登録しているのはAPI呼び出しの非同期処理の時間差があるからです。例えば ID=1とID=2をほぼ同時にリクエストした時、ID=1が返ってきた時点で notifyListeners() が呼ばれるので、ListViewが再ビルドされます。その結果、fetchPoke(2) がもう一度呼び出されることになります。この時、ID=2のAPI呼び出し(最初の1回目)が終わっていなければAPI呼び出しの重複になります。これを避けるために、一旦nullでIDをMapに登録しています。

この実装がなんか変だなぁ、と感じましたか?しばらく我慢してこの続きを読み進めてみてください。

次は PokeListItem の中で実際のデータを表示するようにしましょう。いまはダミーのピカチュウだけでしたね。

import 'package:flutter/material.dart';
import './poke_detail.dart';
import './models/pokemon.dart';
import './const/pokeapi.dart';

class PokeListItem extends StatelessWidget {
  const PokeListItem({Key? key, required this.poke}) : super(key: key);
  final Pokemon? poke;
  
  Widget build(BuildContext context) {
    if (poke != null) {
      return ListTile(
        leading: Container(
          width: 80,
          decoration: BoxDecoration(
            color: (pokeTypeColors[poke!.types.first] ?? Colors.grey[100])
                ?.withOpacity(.3),
            borderRadius: BorderRadius.circular(10),
            image: DecorationImage(
              fit: BoxFit.fitWidth,
              image: NetworkImage(
                poke!.imageUrl,
              ),
            ),
          ),
        ),
        title: Text(
          poke!.name,
          style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
        ),
        subtitle: Text(poke!.types.first),
        trailing: const Icon(Icons.navigate_next),
        onTap: () => {
          Navigator.of(context).push(
            MaterialPageRoute(
              builder: (BuildContext context) => const PokeDetail(),
            ),
          ),
        },
      );
    } else {
      return const ListTile(title: Text('...'));
    }
  }
}

ListViewから受け取ったpokeがnullの可能性もあるので、nullの場合はエラーメッセージ付きのListTileを返すように分岐しています。
加えて、ポケモンの名前や画像をListViewから受け取ったpokeから表示するようにしています。
ちょっと背景色については凝ってみました。

color: (pokeTypeColors[poke!.types.first] ?? Colors.grey[100])?.withOpacity(.3),

pokeTypeColorsという定数を定義して、ポケモンのタイプにマッチした色を返すことができるようにしました。

const Map<String, Color> pokeTypeColors = {
  "normal": Color(0xFFA8A77A),
  "fire": Color(0xFFEE8130),
  "water": Color(0xFF6390F0),
  "electric": Color(0xFFF7D02C),
  "grass": Color(0xFF7AC74C),
  "ice": Color(0xFF96D9D6),
  "fighting": Color(0xFFC22E28),
  "poison": Color(0xFFA33EA1),
  "ground": Color(0xFFE2BF65),
  "flying": Color(0xFFA98FF3),
  "psychic": Color(0xFFF95587),
  "bug": Color(0xFFA6B91A),
  "rock": Color(0xFFB6A136),
  "ghost": Color(0xFF735797),
  "dragon": Color(0xFF6F35FC),
  "dark": Color(0xFF705746),
  "steel": Color(0xFFB7B7CE),
  "fairy": Color(0xFFD685AD),
};

これにより、ポケモンのタイプの色が背景に入るのでなんだかいい感じになります。

さて、あとは表示数の制限として設定していた "10" を解消する必要がありますね。
この実装のまま "898" に変えてしまうと、API呼び出しすぎだといってエラーになります。
これを回避するには「一度に取らないようなUI設計」に変える必要があります。API仕様にUIが引っ張られているのがなんだか気になりますが、最初に申し上げた通り、APIは善意でお借りしているものなので、UIがAPIに合わせるのは仕方がないです。

では、「一度にとらないようなUI設計」をするにはどうすれば良いでしょうか?簡単な解決策として "ページネーション" があります。
といっても、単にリストの下部に "もっと読み込む" 的なものを追加し、ユーザーの操作によって追加のAPI呼び出しを実行するようにします。
ListViewの一番下に要素を追加するのはちょっとトリッキーな方法で実現できます。単に、ListViewの表示数を必要個数より1つ多くし、最後の番号の時だけ異なるWidgetを返すようにするだけです。最後の1つをボタンにし、そのボタンを押すと表示数が一定数だけ増えるようにしてみました。表示されている個数を管理するために、StatefulWidgetに切り替えています。

pokeMaxIdは定数用のファイル内で898を定義しています。


class PokeList extends StatefulWidget {
  const PokeList({Key? key}) : super(key: key);
  
  _PokeListState createState() => _PokeListState();
}

class _PokeListState extends State<PokeList> {
  static const int more = 30;
  int pokeCount = more;
  
  Widget build(BuildContext context) {
    return Consumer<PokemonsNotifier>(
      builder: (context, pokes, child) => ListView.builder(
        padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
        itemCount: pokeCount + 1, // pokeMaxId,
        itemBuilder: (context, index) {
          if (index == pokeCount) {
            return OutlinedButton(
              child: const Text('more'),
              onPressed: () => {
                setState(
                  () {
                    pokeCount = pokeCount + more;
                    if (pokeCount > pokeMaxId) {
                      pokeCount = pokeMaxId;
                    }
                  },
                )
              },
            );
          } else {
            return PokeListItem(
              poke: pokes.byId(index + 1),
            );
          }
        },
      ),
    );
  }
}

こうすることによって、APIの過度な呼び出しを制限しつつ、ListViewを実現できます。

では、最後に詳細ページへの遷移にもデータを渡して表示します。

class PokeDetail extends StatelessWidget {
  const PokeDetail({Key? key, required this.poke}) : super(key: key);
  final Pokemon poke;
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Stack(
              children: [
                Container(
                  padding: const EdgeInsets.all(32),
                  child: Image.network(
                    poke.imageUrl,
                    height: 100,
                    width: 100,
                  ),
                ),
                Container(
                  padding: const EdgeInsets.all(8),
                  child: Text(
                    'No.${poke.id}',
                    style: const TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ],
            ),
            Text(
              poke.name,
              style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: poke.types
                  .map(
                    (type) => Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 8),
                      child: Chip(
                        backgroundColor: pokeTypeColors[type] ?? Colors.grey,
                        label: Text(
                          type,
                          style: TextStyle(
                            fontWeight: FontWeight.bold,
                            color: (pokeTypeColors[type] ?? Colors.grey)
                                        .computeLuminance() >
                                    0.5
                                ? Colors.black
                                : Colors.white,
                          ),
                        ),
                      ),
                    ),
                  )
                  .toList(),
            ),
          ],
        ),
      ),
    );
  }
}

ポケモンの名前、画像、IDは単純に受け取ったPokemonデータに置き換えただけです。
タイプのChipはちょっとだけ工夫しました。複数のタイプを持つポケモンがいることを考慮し、RowでタイプのChipを並べてみました。
以下の部分ですね。

poke.types.map((type) ⇒ widget).toList を Row や Column と一緒に使うのは結構よくある手法なので、ぜひ覚えておいてください。

Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: poke.types
      .map(
        (type) => Padding(
          padding: const EdgeInsets.symmetric(horizontal: 8),
          child: Chip(
            backgroundColor: pokeTypeColors[type] ?? Colors.grey,
            label: Text(
              type,
              style: TextStyle(
                fontWeight: FontWeight.bold,
                color: (pokeTypeColors[type] ?? Colors.grey)
                            .computeLuminance() >
                        0.5
                    ? Colors.black
                    : Colors.white,
              ),
            ),
          ),
        ),
      )
      .toList(),
),

いい感じですね!
そういえば "戻るボタン" をつけ忘れていましたね。
ついでにつけておきます。

import 'package:flutter/material.dart';
import './models/pokemon.dart';
import './const/pokeapi.dart';

class PokeDetail extends StatelessWidget {
  const PokeDetail({Key? key, required this.poke}) : super(key: key);
  final Pokemon poke;
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ListTile(
              leading: IconButton(
                icon: const Icon(Icons.arrow_back),
                onPressed: () => Navigator.pop(context),
              ),
            ),
            const Spacer(),
            Stack(
              children: [
                Container(
                  padding: const EdgeInsets.all(32),
                  child: Image.network(
                    poke.imageUrl,
                    height: 100,
                    width: 100,
                  ),
                ),
                Container(
                  padding: const EdgeInsets.all(8),
                  child: Text(
                    'No.${poke.id}',
                    style: const TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ],
            ),
            Text(
              poke.name,
              style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: poke.types
                  .map(
                    (type) => Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 8),
                      child: Chip(
                        backgroundColor: pokeTypeColors[type] ?? Colors.grey,
                        label: Text(
                          type,
                          style: TextStyle(
                            fontWeight: FontWeight.bold,
                            color: (pokeTypeColors[type] ?? Colors.grey)
                                        .computeLuminance() >
                                    0.5
                                ? Colors.black
                                : Colors.white,
                          ),
                        ),
                      ),
                    ),
                  )
                  .toList(),
            ),
            const Spacer(),
          ],
        ),
      ),
    );
  }
}
  • SafeAreaで包みました
  • Columnの最初にListTileを引いて、そこに戻るボタンを配置しました
  • 残りのスペースの中央にポケモン情報が来て欲しいので、ポケモン情報のWidget群の前後を Spacer() ではさみました。Spacer() は一通り要素の大きさを決めた後に空いているスペースを Spacer() 同士で半分こしてくれるものです。(Spacerに引数を渡すと、分けっこの比率を変えることもできます)

これでいい感じのUIができましたね。

ListViewを1つ増やしてボタンを加えるよりも、CustomScrollViewの方がいいんじゃない?という話もあります。機会があれば追記します。