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

14 min読了の目安(約8600字TECH技術記事
Likes11

想定読者

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

はじめに

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

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

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

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

fun request(keyword: String) {
  viewModelScope.launch {
    when (val result = repository.fetchSomething(keyword)) {
      is Success -> result.body?.let { _elements.value = it }
      is Failure -> _errorType.value = result.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があります。

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


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


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クラスは以下のようにつくっています。


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({  Dio dio }) : _dio = dio;

  final Dio _dio;

  
  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>({
     Future<Response<Map<String, dynamic>>> request,
     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({  Dio dio }) : _dio = dio;

  final Dio _dio;

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

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

終わりに

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