LoginSignup
22
9

More than 3 years have passed since last update.

【Flutter】dio + freezed でAPIレスポンスをResult<T>で受け取る

Last updated at Posted at 2020-09-28

想定読者

  • FlutterでのAPIレスポンスのハンドリングにResult<T> を使いたい
  • swiftの Union や kotlinの sealed classのようなものをFlutterで扱いたい

はじめに

普段Androidを書いているときに retrofit から受け取ったAPIレスポンスを sealed class を利用し、以下のように変換しています。

sealed class Response<T> {
  data class Success<T>(val body: T?) : Response<T>()
  data class Error<T>(val type ErrorType) : Response<T>()

  companion object {
    fun create<T>(retrofitResponse: retrofit2.Response<T>): Response<T> {
      return when {
        response.isSuccessful -> Success(response.body())
        else -> Error(ErrorType.create(response))
      }
    }
  }
}

Response に変換することでViewModel層などでは、retrofit に依存せず、when文 を利用し、レスポンスをさばくことが可能になります。

fun request(keyword: String) {
  viewModelScope.launch {
    when (val res = repository.fetchSomething(keyword)) {
      is Success -> res.body?.let { _elements.value = it }
      is Error -> _errorType.value = res.type
    }
  }
}

最近Flutterを書いていて、上記と同じようなことをしたいと考え、以下のように実装してみました。

至らない点もあると思うので、ぜひコメント等でご指摘いただけたら嬉しいです。

dio

HTTP Clientライブラリです。

Flutterの公式では http というライブラリを推していますが
個人的にはAndroidで OkHttp を利用していたこともあり、dio の書き方がそれに似ている感触を得たので、dioを利用しています。

また、Githubのstar数が異常に多く、日本語の記事は少ないものの、英語での記事は大量にあったため安心して選定しました。

公式のリファレンスにもありますが、Dioクラスはabstract classなので、以下のようにアプリ独自のクラスを作ります。

class MyDio with DioMixin implements Dio{
  // ...
}

(公式 Extends より引用)

簡単なリクエストは以下のようにかけます

Response response;
Dio dio = new Dio();
response = await dio.get("/test?id=12&name=wendu");
print(response.data.toString());
// Optionally the request above could also be done as
response = await dio.get("/test", queryParameters: {"id": 12, "name": "wendu"});
print(response.data.toString());

(公式 Examples より引用)

freezed

Dartではsealed classのようなものがありません。
ただ、同様のものを生成してくれるライブラリとして freezedがあります。

詳細は割愛します。(公式を読んでいただけたらわかると思うので。)

@freezed
abstract class Union<T> with _$Union<T> {
  const factory Union(T value) = Data<T>;
  const factory Union.loading() = Loading<T>;
  const factory Union.error([String message]) = ErrorDetails<T>;
}

(公式 The syntax より引用)

実装

Resultクラスは以下のように設定しております。
(簡略化のため、importなどは省略しております。)

result.dart

@freezed
abstract class Result<T> with _$Result<T> {
  const factory Result.success(T value) = Success<T>;
  const factory Result.failure(Error error) = Failure<T>;
}

以下の記事を参考に作成しました。
Handling Network Calls and Exceptions in Flutter

余談ですが、Errorクラスは以下のようにつくっています。

@freezed
abstract class Error with _$Error {
  const factory Error.requestCancelled() = _RequestCancelled;

  const factory Error.unauthorisedRequest() = _UnauthorisedRequest;

  const factory Error.requestError({ ApiError apiError }) = _RequestError;

  const factory Error.serviceUnavailable() = _ServiceUnavailable;

  const factory Error.sendTimeout() = _SendTimeout;

  const factory Error.noInternetConnection() = _NoInternetConnection;

  const factory Error.unexpectedError() = _UnexpectedError;

  const Error._();

  static Error getApiError(error) {
    if (error is Exception) {
      try {
        Error _error;
        if (error is DioError) {
          switch (error.type) {
            case DioErrorType.CONNECT_TIMEOUT:
            case DioErrorType.SEND_TIMEOUT:
            case DioErrorType.RECEIVE_TIMEOUT:
              _error = Error.sendTimeout();
              break;
            case DioErrorType.CANCEL:
              _error = Error.requestCancelled();
              break;
            case DioErrorType.RESPONSE:
              final statusCode = error.response.statusCode;
              if (400 <= statusCode && statusCode < 500) {
                _error = Error.requestError(apiError: ApiError.fromJson(error.response.data));
              } else if (500 <= statusCode) {
                _error = Error.serviceUnavailable();
              }
              break;
            default:
              _error = Error.unexpectedError();
          }
        } else if (error is SocketException) {
          _error = Error.noInternetConnection();
        } else {
          _error = Error.unexpectedError();
        }
        return _error;
      } catch (_) {
        return Error.unexpectedError();
      }
    } else {
      return Error.unexpectedError();
    }
  }

  String get errorMessage => this.when(
      requestCancelled: () => "キャンセルされました",
      unauthorisedRequest: () => "認証エラーです",
      requestError: (apiError error) => error.message,
      serviceUnavailable: () => "しばらく時間をおいてから再度お試しください",
      sendTimeout: () => "通信環境の良いところで再度お試しください",
      noInternetConnection: () => "通信環境の良いところで再度お試しください",
      unexpectedError: () => "不明なエラーが発生しました"
  );
}

次に、AppDioというクラスを作成し、ここでDioの設定を色々と行っています。

app_dio.dart

class AppDio with DioMixin implements Dio {
  factory AppDio() {
    if (_instance == null) {
      final dio = AppDio._();
      dio.httpClientAdapter = DefaultHttpClientAdapter();
      dio.options = BaseOptions(...);
      dio.interceptors
        ..add(LogInterceptor(responseBody: true));
      _instance = dio;
    }
    return _instance;
  }

  static AppDio _instance;
  AppDio._();
}

APiClient, Dioなどを利用し、DataSourceImplなどから以下のようにリクエストを送ります。

class SomethingDataSourceImpl with SomethingDataSourceMixin {
  SomethingDataSourceImpl({ @required Dio dio }) : _dio = dio;

  final Dio _dio;

  @override
  Future<Result<Something>> fetchSomething() async {
    try {
      return await _dio.get<Map<String, dynamic>>("something").then((response) => 
        Result.success(Something.fromJson(json)));
    } catch(error) {
      return Result.failure(error);
    }
  }
}

上記のようにクラスやメソッドを用意することにより、SomethingDataSourceImpl からデータを受け取ったクラスでは、
レスポンスを以下のように処理することができます。

final Result<Something> result = await _somethingRepository.fetchSomething();
result.when(
  success: (Something something) {
    ...
  },
  failure: (Error error) {
    ...
  }
);

result. とうったら when() が予測に出てくると思います。

これによって、kotlinsealed class のように満たしたかった HTTP Client のレスポンスnの型に依存せず、when文を利用し、success, failure を処理することができました。

おまけ

dioのレスポンスをハンドルするクラスとして ApiClient というクラスを作成しました。
処理の内容はAppDioに書いても問題ないかもしれません。

api_client.dart

class ApiClient {
  factory ApiClient() {
    if (_instance == null) {
      _instance = ApiClient._();
    }
    return _instance;
  }
  static ApiClient _instance;
  ApiClient._();

  Future<Result<T>> sendRequest<T>({
    @required Future<Response<Map<String, dynamic>>> request,
    @required T Function(Map<String, dynamic>) jsonDecodeCallback
  }) async {
    try {
      return await request.then((value) => Result.success(jsonDecodeCallback(value.data)));
    } catch (error) {
      return Result.failure(Error.getApiError(error));
    }
  }
}

ここで鍵になるのは、引数でrequestjsonDecodeCallbackを受け取っていることです。

request は、dioを利用したリクエストそのもので、dioにはget(), post(), put() 等、様々なリクエストがありますが、それらのレスポンスの型が Future<Response<T>> となっていることを利用し、ここで集約します。Future<Response<T>> における <T> は then で流れてくる Response クラスのインスタンスが保持している data プロパティの型です。

これを Map<String> 十することで、Response クラスのインスタンスから json を受け取っています。

そして、受け取った json を本来ほしい型へ変換するために jsonDecodeCallback を用意しています。

T Function(Map<String, dynamic>) jsonDecodeCallback とすることで、Map<String, dynamic> を引数として T を返す Function を引数とすることができます。

これをやろうと思った背景は、上記の SomethingDataSourceImplのように、DataSource層に毎回 try-catch、エラーハンドリングを書くことが煩わしかったため、作成しました。

try {
  return await _dio.get().then((response) => Result.success(Something.fromJson(response)));
} catch(error) {
  return Result.failure(Error.getApiError(error));
}

SomethingDataSourceImpl は以下のように書き換わります。

class SomethingDataSourceImpl with SomethingDataSourceMixin {
  SomethingDataSourceImpl({ @required Dio dio }) : _dio = dio;

  final Dio _dio;

  @override
  Future<Result<Something>> fetchSomething() async => ApiClient().sendRequest(
    request: _dio.get<Map<String, dynamic>>("something"),
    jsonDecodeCallback: (json) => Something.fromJson(json)
  );
}

スッキリしたように感じないでしょうか?
感じなかったら別にやる必要はないと思います。

終わりに

拙い文章でしたので、もし何かdiofreezedの使い方、Result<T>型のつくり方、ミスの指摘やフィードバック、質問等がありましたら、ぜひ @muttsu_623 までご連絡ください。

22
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
9