見出し画像

Flutterで快適にAPIクライアント実装をする

NTTレゾナントテクノロジーでiOS/Androidのアプリ開発エンジニアを担当している長谷川です。

私はここ2年ほど、プライベートを含めFlutterを使ったiOS/Android/macOS向けアプリ開発をおこなってきました。プライベートについては特にアプリを公開しているわけではありませんが、自身の業務を少しでも自動化したいと思い作ったものがいくつかあります。それらアプリを作っている中で、サーバーと通信を行うAPIクライアント実装のパターンが自分の中でかたまってきましたので、その紹介をしようと思います。


今回話すこと

  • Flutterを使ったiOS/Androidアプリ開発

  • retrofitパッケージを使ったHTTP通信APIクライアントの実装

  • JSON文字列からclass定義を自動生成

開発準備

まずは、今回のAPIクライアント実装に使用するパッケージを pubspec.yaml に追加します。

関係する依存関係部分のみ抜粋して掲載します。

dependencies:
  retrofit: ^4.1.0
  json_annotation: ^4.8.1
  freezed_annotation: ^2.4.1
  dio: ^5.4.2+1

dev_dependencies:
  retrofit_generator: ^8.1.0
  json_serializable: ^6.7.1
  build_runner: ^2.4.8
  freezed: ^2.4.7

retrofit / retrofit_generator

今回の主役となるパッケージです。
APIのパスやメソッド(GETやPOSTなど)、クエリ文字列などをメソッドとして定義するだけで、実際の通信部分の実装・変換処理を自動生成してくれるパッケージです。

もしAndroidのアプリ開発をおこなったことがある場合、聞いたことがある名前ではないでしょうか?それもそのはず、このFlutterのretrofitパッケージは、Android向けライブラリ「Retrofit」からインスピレーションを受けて作られたようです。

もしかしたら、AndroidでRetrofitを使ったことがある人はとっつきやすいかもしれません。

dio

HTTPで通信処理を行うパッケージです。retrofitはインターフェースで定義されたメソッドの内部実装(主にAPIパスやメソッド、クエリ文字列の組み立てなど)の自動生成を担いますが、dioは実際にHTTPサーバーと通信する処理を担当します。

freezed / freezed_annotation

Dart言語にはデータ型を表す struct や data class のような機能がないため、その役目を果たすパッケージです。

json_serializable / json_annotation

JSON文字列とDart言語上のオブジェクトを相互変換するためのパッケージです。retrofitパッケージと合わせて使用するため、依存関係に追加しておきます。

build_runner

Dart/Flutterでコードを自動生成する際に必要となるパッケージです。

retrofitやfreezed、json_serializableなどのパッケージは、このbuild_runnerを利用してコードの自動生成をおこなっています。

retrofitでAPIクライアントを実装

では、実装してみましょう。今回は「OpenWeathreMap」という世界の天気予報データが取得できるAPIを使って解説します。

無料プランもあり、現在の天気や3時間ごとの天気予報(5日間)は無料で取得できます。今回のサンプルでは、すでにOpenWeatherMapのアカウントを登録済みで、APIキーを取得できていることとします。

今回は以下のAPIを使って現在の気象データを取得してみます。

OpenWeatherMapと通信するためのAPIクライアントを実装していきます。ファイル名は open_weather_map_client.dart とします。retrofitパッケージのサンプルを元に実装します。

import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';

part 'open_weather_map_client.g.dart';

@RestApi(baseUrl: "https://api.openweathermap.org/")
abstract class OpenWeatherMapClient {
  factory OpenWeatherMapClient(Dio dio, {String? baseUrl}) = _OpenWeatherMapClient;

  @GET("/data/2.5/weather")
  Future<xxxxxx> getCurrentWeather({
    @Query("lat") required double lat,
    @Query("lon") required double lon,
    @Query("appid") required String appId,
    @Query("mode") String? mode,
    @Query("units") String? units,
    @Query("lang") String? lang,
  });
}

抽象クラスを作成し、1つのアクセス先=1つのメソッドとして定義していきます。作成した抽象クラスには @RestApi アノテーションを付与し、引数としてそのクラス内で通信を実行する際のベースとなるURL文字列を指定します。

アクセス先を表すメソッドには @GET のように各HTTPメソッドに対応したアノテーションを付与します。他にも @POST@PUT@PATCH などAPI通信を行なう際に利用するものが全てretrofitに用意されていますので、APIの定義に合わせて選択してください。

また、このHTTPメソッドを表すアノテーションの引数には、そのアクセス先のパスを指定します。

メソッドの戻り値は非同期通信となるため必ず Future とします。そのジェネリクスには、レスポンスとなるJSONオブジェクトに対応したclassを指定します。(上記サンプルコードでは xxxxxx となっていますが、Modelクラス生成後に埋めていきますので一旦このままとします)

メソッドの引数には、URLの末尾につけるクエリ文字列の定義をします。 各引数には @Query アノテーションを付与し、その中にはキー文字列を指定します。
最後にfactoryメソッドを用意します。ここは定義が決まっており、第一引数にDioクラス、第二引数にはString?クラスのオブジェクトをもらうようにします。また、その実装部分は _<クラス名> を代入します。

factory OpenWeatherMapClient(Dio dio, {String? baseUrl}) = _OpenWeatherMapClient;

この時点で _OpenWeatherMapClient が存在しないエラーが出ますが、後ほど build_runner を使ってコードが自動生成されますので、無視してください。

クライアントの実装が完了したら、 build_runner の build を実行し必要なコードを自動生成します。

$ dart run build_runner build

open_weather_map_client.g.dart が生成され、自動生成されたコードはそちらに入っています。先ほどエラーになっていた _OpenWeatherMapClient もそちらに定義されているはずです。

これでクライアント側の実装は終わりです。

Modelクラスを作成

次にAPIと通信した際のレスポンスとなるJSONオブジェクトに対応したclassを作成します。1つずつプロパティを書いていくのは大変なので、以下のサイトでサンプルのJSON文字列からclassを自動生成します。

quicktypeでコードを自動生成する

左側にJSON形式のレスポンスデータのサンプルコードを入れることで、それに対応したclassの定義が右側に自動で生成されます。入れ子となっているオブジェクトも別classがその下に定義され、入れ子構造をそのまま再現してくれます。

今回はDartのソースコードを生成しましたが、他にもJava、Kotlin、Swift、Go、PHP、Rubyなど主要な言語のコード生成にも対応しています。

あとは右側のメニューにある「Copy Code」ボタンをクリックしてクリップボードにコピーし、自身のファイルにペーストします。その後、一部データがnullとなっているものがありましたので、そのプロパティから required を外し、型名の右には ? を付与しnullableの型に変更しました。

最終的に以下のようなコードとなりました。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'current_weather.freezed.dart';
part 'current_weather.g.dart';

@freezed
class CurrentWeather with _$CurrentWeather {
  const factory CurrentWeather({
    @JsonKey(name: "coord") required Coord coord,
    @JsonKey(name: "weather") required List<Weather> weather,
    @JsonKey(name: "base") required String base,
    @JsonKey(name: "main") required Main main,
    @JsonKey(name: "visibility") required int visibility,
    @JsonKey(name: "wind") required Wind wind,
    @JsonKey(name: "rain") required Rain rain,
    @JsonKey(name: "clouds") required Clouds clouds,
    @JsonKey(name: "dt") required int dt,
    @JsonKey(name: "sys") required Sys sys,
    @JsonKey(name: "timezone") required int timezone,
    @JsonKey(name: "id") required int id,
    @JsonKey(name: "name") required String name,
    @JsonKey(name: "cod") required int cod,
  }) = _CurrentWeather;

  factory CurrentWeather.fromJson(Map<String, dynamic> json) => _$CurrentWeatherFromJson(json);
}

@freezed
class Clouds with _$Clouds {
  const factory Clouds({
    @JsonKey(name: "all") required int all,
  }) = _Clouds;

  factory Clouds.fromJson(Map<String, dynamic> json) => _$CloudsFromJson(json);
}

@freezed
class Coord with _$Coord {
  const factory Coord({
    @JsonKey(name: "lon") required double lon,
    @JsonKey(name: "lat") required double lat,
  }) = _Coord;

  factory Coord.fromJson(Map<String, dynamic> json) => _$CoordFromJson(json);
}

@freezed
class Main with _$Main {
  const factory Main({
    @JsonKey(name: "temp") required double temp,
    @JsonKey(name: "feels_like") required double feelsLike,
    @JsonKey(name: "temp_min") required double tempMin,
    @JsonKey(name: "temp_max") required double tempMax,
    @JsonKey(name: "pressure") required int pressure,
    @JsonKey(name: "humidity") required int humidity,
    @JsonKey(name: "sea_level") int? seaLevel,
    @JsonKey(name: "grnd_level") int? grndLevel,
  }) = _Main;

  factory Main.fromJson(Map<String, dynamic> json) => _$MainFromJson(json);
}

@freezed
class Rain with _$Rain {
  const factory Rain({
    @JsonKey(name: "1h") double? the1H,
  }) = _Rain;

  factory Rain.fromJson(Map<String, dynamic> json) => _$RainFromJson(json);
}

@freezed
class Sys with _$Sys {
  const factory Sys({
    @JsonKey(name: "type") required int type,
    @JsonKey(name: "id") required int id,
    @JsonKey(name: "country") required String country,
    @JsonKey(name: "sunrise") required int sunrise,
    @JsonKey(name: "sunset") required int sunset,
  }) = _Sys;

  factory Sys.fromJson(Map<String, dynamic> json) => _$SysFromJson(json);
}

@freezed
class Weather with _$Weather {
  const factory Weather({
    @JsonKey(name: "id") required int id,
    @JsonKey(name: "main") required String main,
    @JsonKey(name: "description") required String description,
    @JsonKey(name: "icon") required String icon,
  }) = _Weather;

  factory Weather.fromJson(Map<String, dynamic> json) => _$WeatherFromJson(json);
}

@freezed
class Wind with _$Wind {
  const factory Wind({
    @JsonKey(name: "speed") required double speed,
    @JsonKey(name: "deg") required int deg,
    @JsonKey(name: "gust") required double gust,
  }) = _Wind;

  factory Wind.fromJson(Map<String, dynamic> json) => _$WindFromJson(json);
}

これでModelクラスの実装が終わりましたので、先ほどの OpenWeatherMapClient クラス内にある getCurrentWeather() の戻り値のジェネリクス部分をここで作成した CurrentWeather に置き換えます。

最後に build_runner を実行し、 @freezed アノテーションが付いたクラスに必要なコードを自動生成をします。

$ dart run build_runner build

@freezed が付いたクラスの場合、 current_weather.freezed.dartcurrent_weather.g.dart の2つのファイルが生成されます。

Modelクラスも完成しました。コードのほとんどを自動生成に任せたので、とても楽に実装を進められたと思います。

完成!

あとはWidget側で OpenWeatherMapClient のインスタンスを生成し、  getCurrentWeather() を実行して取得した情報を元にデータを表示してみましょう!

東京タワー近辺の記事作成時の気象情報

最後に

NTTレゾナントテクノロジーではスマホアプリ開発エンジニアを募集しております。少しでも興味がありましたら、以下の採用ページから応募をお待ちしております。


この記事が気に入ったらサポートをしてみませんか?