Dio+Retrofit: 導入 使い方 - Ki-Kobayashi/flutter_wiki GitHub Wiki

🟩 Dio+Retrofit: 導入

🟡 pubspec.yaml

以下に加えて、「Freezed: 導入+使い方」の追加もしておく

fvm flutter pub add dio
fvm flutter pub add retrofit
fvm flutter pub add --dev retrofit_generator
dependencies:
  flutter:
    sdk: flutter
  ...
  // https://pub.dev/packages/dio
  dio: ^5.3.2 👈追加
 // https://pub.dev/packages/retrofit
  retrofit: ^4.0.1 👈追加
  ...

dev_dependencies:
  flutter_test:
    sdk: flutter
  ...
 // https://pub.dev/packages/retrofit_generator
  retrofit_generator: ^7.0.8 👈追加
 ...

.

🟩 設定

Dtoの作成は、「Freezed: 導入+使い方」を参照

.

🟡 Enviedを使用し、APIKeyなど記載しておく、Envファイルの作成

envied: 導入+使い方」を参照

.

🟡 DioProviderの作成

下記は、「Riverpod」導入済が前提

📚 Headerなどの設定参考:https://github.com/trevorwang/retrofit.dart/issues/190#issuecomment-1719090470
📚 リトライ処理:https://zenn.dev/minma/articles/5e611fc92b9e1f
📚 アクセストークンの有効期限が切れた時に更新: https://tech-blog.yayoi-kk.co.jp/entry/2023/05/23/110000#%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%AE%E5%8F%96%E5%BE%97
📚 上記のGithub:https://github.com/standfirm/misoca-sample-flutter/blob/main/lib/general/comm/authorization_interceptor.dart

.

@Riverpod(keepAlive: true)
Dio dio(DioRef ref) {
  var dio = Dio()
    ..options = BaseOptions(
      baseUrl: Env.baseUrl,
      contentType: Headers.jsonContentType,
      connectTimeout: const Duration(seconds: 15), 
      receiveTimeout: const Duration(seconds: 15), 
    );
  dio.options.headers[Env.apiKeyName] = Env.apiKey;

  dio.interceptors
    ..add(LogInterceptor())
    ..add(InterceptorsWrapper(
      onRequest: (options, handler) async {
        switch (options.path) {
          // ログイン確認が必要な API Request
          case '/setting/profile':
          case '/mypage':
            // 最新のログイン情報(sessionIDの有効性)をチェック & ログインステータス更新
            await ref.read(authenticationNotifierProvider.notifier).checkLoggedIn();
            // ログインしていなければリクエストしない
            if (!await ref.read(isLoggedInProvider)) {
              return;
            }
        }
        final sessionId =
            await ref.read(secureStorageRepositoryProvider).readSessionId();
        if (sessionId != null) {
          options.headers['Cookie'] = 'id=$sessionId';
        }

        return handler.next(options);
      },
      onResponse: (Response<dynamic> response, handler) async {
        List<String>? rowCookies = response.headers['set-cookie'];
        if (rowCookies != null) {
          String sessionId = _extractSessionId(rowCookies);
          if (sessionId != '') {
            await _saveSessionIds(ref, sessionId);
          }
          response.statusCode;
        }

        return handler.next(response);
      },
    ));

  return dio;
}



// Set-CookieからセッションID抽出
String _extractSessionId(List<String> rawCookies) {
  final sessionIdPart = rawCookies
      .map((rawCookie) {
        return rawCookie.split(';')[0];
      })
      .toList()
      .first;
  // 不要箇所の除去
  if (sessionIdPart.startsWith('id=')) {
    return sessionIdPart.replaceFirst('id=', '');
  }

  return '';
}

// セッションIDの保存
Future<void> _saveSessionIds(DioRef ref, String sessionId) async {
  final secureStorageRepository = ref.read(secureStorageRepositoryProvider);
  logger.d("@@@@@@@@ XXX   start save Session ID : $sessionId");
  if (await secureStorageRepository
      .containsKeyInSecureData(SharedPrefKeyConsts.sessionIdKey)) {
    await secureStorageRepository.deleteSessionId();
  }
  await secureStorageRepository.writeSessionId(id: sessionId);
}

.

🟩 Retrofitの抽象クラスを作成

ApiResponse: API側から、たとえば以下のように固定のパラメタのResponse形式で返される場合のみ、ラップするもの <※一般的にラップされない:Header情報に statuscode / message が含まれるため、重複するようだ。>

  • statusCode
  • message
  • payload

.

💎 Retrofit の抽象クラス作成(API取得用クラス)

part 'example_rest_client.g.dart';

// 🚨以下は、repositoryIFに定義しておくと良き
@riverpod
ExampleRestClient exampleRestClient(ExampleRestClientRef ref) {
  return ExampleRestClient(ref.read(dioProvider));
}

@RestApi()
abstract class ExampleRestClient implements ExampleDatasource {
  factory ExampleRestClientt(Dio dio) = _ExampleRestClient;

  @override
  @GET('/items')
  Future<ApiResponse<List<Item>>> getItemList();

  @override
  @POST('/example/register')
  Future<ApiResponse<ExampleResponse>> registerExample(
    @Body() RegisterExampleRequest requestBody,
  );

  @POST('/item/delete')
  Future<ApiResponse<String>> deleteItem(
    @Body() DeleteItemRequest requestBody,
  );
}

API接続処理を、

  • Mock用 の Jsonファイル から取得する DataSource
  • サーバーサイドの API 接続から取得する DataSource

で分けたい場合は、以下のように抽象クラス(Interface)を作成しておくと良い

.

💎 Interface の定義 

abstract class ExampleDatasource {
  Future<ApiResponse<List<Item>>> getItemList();

  Future<ApiResponse<ExampleResponse>> registerExample(
    RegisterExampleRequest requestBody,
  );

  Future<ApiResponse<String>> deleteItem(
    DeleteItemRequest requestBody,
  );
}

Mock用のJsonファイルは、「assets/mock/ココ」に配置し、
     pubspec.yaml に 該当ディレクトリを読み込めるよう、必要設定を追記しておく

.
下記は、MockJsonファイルを読み込むための、MockDataSourceの一例

.

💎 スタブデータの取得例

abstract class MockExampleDatasource {
  Future<List<Item>> fetchItemList() async {
    final content = await json.decode(await rootBundle
        .loadString('assets/stub/stub_items.json'));
    final res = ApiResponse.fromJson(
      content,
      (Object? json) => ItemList.fromJson(json as Map<String, dynamic>),
    );
    return res.payload.itemList ?? [];
  }
}

.

🛑重要🛑 repositoryクラスに「UnimplementedError」を返す、「datasource(interface)のProvider」を定義

.

💎Repository

part 'example_repository.g.dart';

// 👇以下追加
@riverpod
ExampleDatasource exampleDatasource(ExampleDatasourceRef ref) {
  throw UnimplementedError();
}

// 👇以下のRepository に Datasourceをセット
@riverpod
ExampleRepository exampleRepository(ExampleRepositoryRef ref) {
  final repository = ExampleRepository(client: ref.read(exampleDatasourceProvider));
  return repository ;
}

class ExampleRepository {
  ExampleRepository({
    required ExampleDatasource client,
  }) : _client = client;

  final ExampleDatasource _client;

  Future<ExampleResponse> registerExample({
    required RegisterExampleRequest requestBody,
  }) async {
    try {
      final res = await _client.registerExample(requestBody);
      if (res.statusCode != ApiStatusCode.success.statusCode) {
        // エラー情報を表示するログを入れてもOK
        throw NetworkError.getApiError(
          ApiStatusCodeError(apiStatusCode: res.statusCode),
        );
      }
      return res.payload;
    } on Exception catch (e) {
      throw NetworkError.getApiError(e);
    }
  }

.

💎 main.dart

Future<void> main() async {
  // WidgetsBindingはUIの構築やライフサイクルイベントの管理()
  // runApp()を呼び出す前に初期化する必要がある際に用いる(非同期処理や外部サービスの初期化が必要な場合は必須)
  // 非同期処理が完了する前にウィジェットがビルドされることを防ぐ
  WidgetsFlutterBinding.ensureInitialized();

  // アプリを縦方向に固定する
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
  ]);

  // 〇日前表示設定
  timeago.setLocaleMessages("ja", timeago.JaMessages());
  // google fontsの設定
  GoogleFonts.config.allowRuntimeFetching = kDebugMode;

  if (isRelease) {
    isDevExample = false;
  }

  runApp(
    ProviderScope(
      overrides: [
        flutterSecureStorageProvider.overrideWithValue(
          const FlutterSecureStorage(),
        ),
        sharedPreferencesProvider.overrideWithValue(
          await SharedPreferences.getInstance(),
        ),
        // 👇以下追加
        exampleDatasourceProvider.overrideWith((ref) => ref.watch(isDevExample
            ? stubExampleDatasourceProvider
            : exampleRestClientProvider)),
      ],
      child: const ExampleApp(),
    ),
  );
}

.

🟩 エラーハンドリング

下記は、見直しが必要

参考:https://gist.github.com/ashishrawat2911/1dcfde5d713f389dcebbd9a69b863145
参考:https://zenn.dev/dev_tatsuya/articles/cffaa7c50dfad7
参考:https://qiita.com/muttsu-623/items/2fa68fb6689c76f5415f
 (Resultの方法は、RivepodのAsyncValue使用してたら、いらなそう。。。)

.

sealed class NetworkError implements Exception {
  const NetworkError ();

  static NetworkError  getApiError(error) {
    if (error is ApiStatusCodeError) {
      return error;
    }
    if (error is Exception) {
      try {
        NetworkError error0 = const _UnexpectedException();
        if (error is DioException) {
          switch (error.type) {
            case DioExceptionType.sendTimeout:
            case DioExceptionType.receiveTimeout:
              logger.e('connection time out.', error: error, stackTrace: error.stackTrace);
              error0 = const _SendTimeoutException();
              break;
            case DioExceptionType.cancel:
              logger.e('connection canceled.', error: error, stackTrace: error.stackTrace);
              error0 = const _RequestCancelledException();
              break;
            case DioExceptionType.badResponse:
              final statusCode = error.response?.statusCode;
              if (statusCode == null) break;

              if (400 <= statusCode && statusCode < 500) {
                logger.e('$statusCode:',
                    error: error, stackTrace: error.stackTrace);
                error0 = const _RequestException();
              } else if (500 <= statusCode) {
                logger.e('$statusCode:',
                    error: error, stackTrace: error.stackTrace);
                error0 = const _ServiceUnavailableException();
              }
              break;
            case DioExceptionType.connectionError:
              logger.e('Communication environment error: ${error.message}');
              error0 = const _NoInternetConnectionException();
              break;
            default:
              if (error.error is SocketException) {
                logger.e('Communication environment error :${error.message}');
                error0 = const _NoInternetConnectionException();
                break;
              }
              error0 = const _UnexpectedException();
          }
        } else if (error is SocketException) {
          logger.e('Communication environment error:${error.message}');
          error0 = const _NoInternetConnectionException();
        } else if (error is _NoInternetConnectionException) {
          logger.e('Communication environment error:${error.errorMessage}');
          error0 = const _NoInternetConnectionException();
        } else {
          logger.e('Unknown error :$error', error: error);
          error0 = const _UnexpectedException();
        }
        return error0;
      } catch (_) {
        logger.e('Unknown error:$error');
        return const _UnexpectedException();
      }
    } else {
      logger.e('Unknown error :$error');
      return const _UnexpectedException();
    }
  }

  String get errorMessage => switch (this) {
        ApiStatusCodeError(apiStatusCode: int apiStatusCode) => 
          ApiStatusCode.getErrMessageFromStatusCode(apiStatusCode),  👈※次項の ApiStatusCode 参照
        _RequestCancelledException() => StringConstants.errNetworkCancel,
        _UnauthorisedRequestException() =>
          StringConstants.errNetworkAuthentication,
        _RequestException() => StringConstants.errNetworkServer, 
        _ServiceUnavailableException() => StringConstants.errNetworkServer,
        _SendTimeoutException() => StringConstants.errNetwork,
        _NoInternetConnectionException() => StringConstants.errNetwork,
        _UnexpectedException() => StringConstants.errNetworkUnexpected,
      };
}

final class ApiStatusCodeError extends NetworkError {
  const ApiStatusCodeError({required this.apiStatusCode}); 👈※次項の ApiStatusCode 参照
  final int apiStatusCode;
}

final class _RequestCancelledException extends NetworkError  {
  const _RequestCancelledException();
}

final class _UnauthorisedRequestException extends NetworkError {
  const _UnauthorisedRequestException();
}

final class _RequestException extends NetworkError {
  const _RequestException();
}

final class _ServiceUnavailableException extends NetworkError  {
  const _ServiceUnavailableException();
}

final class _SendTimeoutException extends NetworkError {
  const _SendTimeoutException();
}

final class _NoInternetConnectionException extends NetworkError {
  const _NoInternetConnectionException();
}

final class _UnexpectedException extends NetworkError  {
  const _UnexpectedException();
}

.

🟩 ApiStatusCodeの用意

API返却値のステータスコードを転記し、メッセージはアプリ用に丸める

// TODO : 
enum ApiStatusCode {
  // なし
  none(statusCode: 0),
  // 成功
  success(statusCode: 1),
  // その他DBの致命的なエラー(DBにアクセスできないなど)
  dbError(statusCode: 2),
  // セッション取得エラー
  userSessionCanNotGetError(
    statusCode: 3,
    message: StringConstants.errApiUserSessionCanNotGetError,
  ),
  // 2段階登録/更新(確認コード利用)の有効期限切れ
  userRegistrationExpires(
    statusCode: 4,
    message: StringConstants.errApiUserRegistrationExpires,
  ),
  // ID または パスワード不一致
  userLoginIncorrectIdOrPassword(
    statusCode: 5,
    message: StringConstants.errApiUserLoginIncorrectIdOrPassword,
  ),

 ・
 ・
 ・

  const ApiStatusCode({
    required this.statusCode,
    this.message,
  });

  final int statusCode;
  final String? message;

 /// Enumの値を順次確認し、ステータスコード に紐づく エラーメッセージ を取得
  static String getErrMessageFromStatusCode(int statusCode) {
    final apiStatus = ApiStatusCode.values.firstWhere(
      (apiStatusCode) => apiStatusCode._isMatchStatusCode(statusCode),
    );
    return apiStatus.message ?? "";
  }

  /// 引数のstatusCodeと同一か
  bool _isMatchStatusCode(int statusCode) {
    if (statusCode == this.statusCode) {
      return true;
    }
    return false;
  }
}

.

🟩

🟡

.

🟩

🟡

.

⚠️ **GitHub.com Fallback** ⚠️