🔖

Flutter retrofit_generatorを使ったAPI組み込み×ログ出力

2024/07/10に公開

はじめに

retrofit_generatorを使用したAPI組み込み方法とそれに伴うログ出力の方法を紹介する
flutterで学んでいる人・興味がある人、flutterを仕事で扱っている人にぜひ読んでほしい

記事を書こうと思った経緯

以前、参画したプロジェクトで私が再利用性の低い不完全なコードを生み出してしまった
今回は、その改善としてこのような記事を書こうと思った
私はまだ経験の浅い新人ゆえ、もっと良いコードの書き方があるかもしれない
もし、疑問点や改善点があればぜひコメントして指摘して頂きたい

RetrofitとRetrofit Generatorについて

Retrofitとは?

Retrofitは、FlutterアプリケーションにおけるHTTPクライアントで、ネットワーク通信を簡潔かつ効果的に行うためのライブラリの一つで、主にRESTful APIとのやり取りを行う際に役立つ
retrofit公式

JSON形式のデータをDartオブジェクトに変換する機能を持ち、json_serializableなどのパッケージと組み合わせて使用することで、シリアル化とデシリアル化を簡単に行える

Retrofit Generatorとは?

Retrofit Generatorは、RetrofitのAPIクライアントを自動生成するためのコード生成ツールである
APIエンドポイントのインターフェースを定義するだけで、実際のHTTPリクエストを行うコードを自動的に生成することが可能
retrofit_generator公式

RetrofitとRetrofit Generatorを使用することにより、Flutterアプリケーションでのネットワーク通信が簡単かつ効率的に行える

実装例

これから実装方法について説明していく

まずは依存関係を追加から

pubspec
environment:
  sdk: '>=3.3.4 <4.0.0'

dependencies:
  freezed_annotation: ^2.4.3
  json_annotation: ^4.9.0
  retrofit: ^4.1.0
  dio: ^5.5.0
  logger: ^2.3.0

dev_dependencies:
  freezed: ^2.5.2
  build_runner: ^2.4.11
  json_serializable: ^6.8.0
  retrofit_generator: ^8.1.1

1. データモデル(Model)定義

今回はWebで見つけたChuck Norris Jokes APIサービスを適当に利用する
カテゴリを指定したらランダムにジョークを返してくれるAPIらしい

取得するJSONデータにあわせてModelを定義をする
Modelはfreezedを使い、こちらも自動生成する

chuck_norris_joke_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'chuck_norris_joke_model.freezed.dart';
part 'chuck_norris_joke_model.g.dart';


class Joke with _$Joke {
  // ignore: invalid_annotation_target
  (fieldRename: FieldRename.snake)
  factory Joke({
    required String iconUrl,
    required String id,
    required String url,
    required String value,
  }) = _ChuckNorrisJoke;

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

Modelを定義したら以下のコマンドを実行

flutter pub run build_runner build

2. ドメイン・エンドポイントを指定

ここではAPIをリクエストするメソッドを定義する
Retrofit Generatorで@RestApiでドメインを指定し、各エンドポイントごとにリクエストするメソッドを定義する
HTTPヘッダーやパラメータなどの指定方法は今回は省略する
今回は通常のリクエストメソッドとエラーケース用のリクエストメソッドを2つ用意した

api_client.dart
import 'package:dio/dio.dart' hide Headers; // ヘッダーを使用しない場合はhide以降を削除
import 'package:retrofit/http.dart';
import 'package:retrofit_sample/chuck_norris_joke_model.dart';

part 'api_client.g.dart';

(baseUrl: "https://api.chucknorris.io/jokes/")
abstract class ApiClient {
  factory ApiClient(Dio dio, {String baseUrl}) = _ApiClient;

  static const _headers = <String, dynamic> {
    "Content-Type": "application/json",
    // "Custom-Header": "Sample header"
  };

  ("/random")
  (_headers)
  Future<Joke> getJoke(("category") String category);

  ("/404")
  (_headers)
  Future<Joke> error404API();
}

リクエストメソッドを定義したら以下のコマンドを実行
下記のコマンドを実行することでパラメータの設定、レスポンスの処理といったHTTPリクエストに必要なコードを生成してくれる

flutter pub run build_runner build

3. ログ出力及びRepository実装

今回の記事で伝えたいメインの実装になる
まずはDioManagerというクラスを作成しAPI通信をする際にログが出力されるようにする

dio_manager.dart
import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
import 'package:retrofit_sample/chuck_norris_joke_model.dart';

import 'api_client.dart';

class DioManager {

  final ApiClient _client;
  final Logger _logger;

  DioManager({
    ApiClient? client,
    Logger? logger,
  })  : _client = client ?? ApiClient(Dio()),
        _logger = logger ?? Logger();
  Future<T?> _executeApiCall<T>(
      Future<T> Function() apiCall, {
        void Function()? onSuccess,
        void Function()? onError,
      }) async {
    try {
      final result = await apiCall();
      _logger.i(result);
      if (onSuccess != null) {
        onSuccess();
      }
      return result;
    } catch (error) {
      if (onError != null) {
        onError();
      }
      _logger.e(error);
    }
    return null;
  }

  Future<Joke> getJoke(
      String category, {
        void Function()? onSuccess,
        void Function()? onError,
      }) async {
    final result = await _executeApiCall(
          () => _client.getJoke(category),
      onSuccess: onSuccess,
      onError: onError,
    );
    return result!;
  }

  Future<Joke> error404API({
    void Function()? onSuccess,
    void Function()? onError,
  }) async {
    final result = await _executeApiCall(
          () => _client.error404API(),
      onSuccess: onSuccess,
      onError: onError,
    );
    return result!;
  }
}

DioManagerはあくまでログの出力のみを行う
Retrofit Generatorで作成したクラスをそのままRepositoryに参照させるのではなくDioManagerを通して参照させるのが目的

joke_repository.dart
import 'package:flutter/foundation.dart';
import 'package:retrofit_sample/chuck_norris_joke_model.dart';
import 'package:retrofit_sample/dio_manager.dart';

class JokeRepository {

  final DioManager _client = DioManager();

  /// ジョークを取得
  Future<Joke> getJoke(String category) async {
    return await _client.getJoke(category,
        onSuccess: () {
          // 成功時の処理
          if (kDebugMode) {
            print('取得に成功しました');
          }
        },
        onError: () {
          // エラー時の処理
          if (kDebugMode) {
            print('取得に失敗しました');
          }
        }
    );
  }

  /// ジョークを取得(エラーケース)
  Future<Joke> error404API() async {
    return await _client.error404API(
        onSuccess: () {
          // 成功時の処理
          if (kDebugMode) {
            print('取得に成功しました');
          }
        },
        onError: () {
          // エラー時の処理
          if (kDebugMode) {
            print('取得に失敗しました');
          }
        }
    );
  }
}


このようにDioManagerをRepositoryから参照させることで、各Repositoryの展開先でLoggerをインスタンスさせる必要がなくなる
また、パラメータにonErrorを追加することでログ出力以外のエラーハンドリングは各Repositoryの参照先に任せることで柔軟性を保つ

4. 画面構築・ログ結果(おまけ)

※この辺はざっくりで適当にやってます。
先ほど作成したメソッドを呼び出し、ログを出力する
サンプルとしてAPIから取得したデータは画面に表示する

main.dart
import 'package:flutter/material.dart';
import 'package:retrofit_sample/joke_repository.dart';
import 'package:retrofit_sample/chuck_norris_joke_model.dart';

void main() {
  runApp(const MyApp());
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

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

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  bool loading = true;
  late Joke joke;
  
  void initState() {
    load();
    super.initState();
  }

  Future<void> load() async {
    joke = await JokeRepository().getJoke('dev');
    // joke = await UserRepository().error404API();
    setState(() {
      loading = false;
    });
  }

  
  Widget build(BuildContext context) {
    if (loading) {
      return const Center(child: CircularProgressIndicator());
    }

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Flutter Demo Home Page'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Image.network(joke.iconUrl),
            const SizedBox(),
            Text('Name: ${joke.value} '),
          ],
        ),
      ),
    );
  }
}


正常系(getJokeメソッドを呼び出した場合
正常系のログが表示される

APIから取得したデータを画面に表示


正常系(404メソッドを呼び出した場合
異常系のログが表示される

まとめ

Retrofit Generatorを使ってログ出力する方法を紹介した
ログ出力方法を共通化することでメイン実装にあまり副作用を持たせないようなロジックを心がけた

最後に今回紹介した実装のデメリットも伝えたい
・コードの冗長性
1つのリクエストメソッドに対してAPIリクエストするクラス(ApiClientクラス)とDiomanagerクラスで2つメソッドを定義しなければならない
そのため、コードのメンテナンスが不便になるというデメリットがある

・ログ出力の不透明性
今回の実装例のログの出し方だとJSONデータをパース(解析)中のエラーがわかりにくいという問題がある
個人的に、実装する上で一番多いエラーケースはModelの型とJSONデータの不一致によるエラーだが、どのフィールドでエラーになっているかという細部まではログからわからない
この場合だとデバッグしたり、JSONデータを直接見る必要がある
そうではなくて、ログで直接わかるようにしたいというのが本音である
私はまだ新人が上に優良な方法がまだ思い浮かばなかった
もし、この記事を最後まで見た方で良い方法があればぜひ教えて欲しい

Discussion