Flutter: ネットワークとREST API

ネットワークのないアプリは孤島です — APIリクエストは本土への架け橋です。

📋 前提条件: 以下を先にマスターしている必要があります

1. このレッスンで学ぶこと


2. ネットワークリクエスト失敗のリアルなストーリー

(1) 悩み:ネットワークエラーでアプリがクラッシュ

ShopAppのリリース当日、不安定な接続のユーザーはホワイトスクリーンクラッシュに遭遇 — try-catchなし、タイムアウトなし、リトライなし。さらに、Tokenの期限切れ時にすべてのリクエストが401を返し、アプリには自動リフレッシュ機構がなく、ユーザーに再ログインを強制。1日で2000件の否定的なレビューが寄せられました。

(2) Dioインターセプターのソリューション

Dioのインターセプターメカニズムは、リクエスト送信前にトークンを統一的に付与し、401レスポンスを自動的に処理してトークンをリフレッシュし、ネットワークタイムアウト時にリトライできます。

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

// ⚙️ Install dependency: flutter pub add dio

// Assume token and refreshToken are defined elsewhere
// String token = '...';
// Future<String> refreshToken() async { ... }

// Auth interceptor: auto-attach token + auto-refresh on 401
dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) {
    options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  },
  onError: (error, handler) async {
    if (error.response?.statusCode == 401) {
      token = await refreshToken();
      return handler.resolve(await dio.fetch(error.requestOptions));
    }
    handler.next(error);
  },
));
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

(3) メリット:ネットワーク関連クラッシュゼロ

Dioインターセプターを使用後、Bobは401自動トークンリフレッシュ、3回タイムアウトリトライ、統一エラー表示を獲得。ネットワーク関連のクラッシュ率が5%から0.01%に低下しました。


3. 基本的なHTTPリクエスト

(1) httpパッケージのCRUD操作

メソッド HTTP動詞 目的
http.get GET リソースの取得
http.post POST リソースの作成
http.put PUT リソースの更新
http.delete DELETE リソースの削除

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: httpパッケージの基本リクエスト

DART
import 'package:http/http.dart' as http;
import 'dart:convert';

// ⚙️ Install dependency: flutter pub add http

// Custom class definition sources:
// - Product: see Lesson 5 json_serializable model in this lesson
// - ApiException: see Lesson 6 unified error handling in this lesson

// Assume token is defined elsewhere
// String token = '...';

class ProductApi {
  static const String baseUrl = 'https://api.shopapp.com/v1';

  Future<List<Product>> getProducts({int page = 1}) async {
    final response = await http.get(
      Uri.parse('$baseUrl/products?page=$page'),
      headers: {'Authorization': 'Bearer $token'},
    );
    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      return (data['items'] as List).map((j) => Product.fromJson(j)).toList();
    }
    throw ApiException(response.statusCode, response.body);
  }

  Future<Product> createProduct(Product product) async {
    final response = await http.post(
      Uri.parse('$baseUrl/products'),
      headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $token'},
      body: jsonEncode(product.toJson()),
    );
    if (response.statusCode == 201) {
      return Product.fromJson(jsonDecode(response.body));
    }
    throw ApiException(response.statusCode, response.body);
  }
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

4. Dioの深掘り

(1) http vs Dio比較

機能 http Dio
基本CRUD
インターセプター
CancelToken
FormDataアップロード 手動
タイムアウトリトライ 手動
リクエストログ 手動 ✅(dio_logger)
グローバル設定 ✅ BaseOptions

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: Dio基本設定

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

// ⚙️ Install dependency: flutter pub add dio

// Custom class definition sources:
// - Product: see Lesson 5 json_serializable model in this lesson
// - Order/Cart: see Lesson 14 Phase 2 practice

final dio = Dio(BaseOptions(
  baseUrl: 'https://api.shopapp.com/v1',
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 30),
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
));

// GET request
Future<List<Product>> getProducts({int page = 1}) async {
  final response = await dio.get('/products', queryParameters: {'page': page});
  return (response.data['items'] as List).map((j) => Product.fromJson(j)).toList();
}

// POST request
Future<Order> createOrder(Cart cart) async {
  final response = await dio.post('/orders', data: cart.toJson());
  return Order.fromJson(response.data);
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: 認証インターセプター + トークンリフレッシュ

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

// Custom class definition sources:
// - SecureStorage: see Lesson 13 Flutter Secure Storage
// - AuthService: see Lesson 14 Auth Notifier

class AuthInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = SecureStorage.getToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      try {
        final newToken = await _refreshToken();
        SecureStorage.saveToken(newToken);
        // Retry original request with new token
        err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
        final response = await dio.fetch(err.requestOptions);
        return handler.resolve(response);
      } catch (_) {
        // Refresh failed, redirect to login
        AuthService.logout();
      }
    }
    handler.next(err);
  }

  Future<String> _refreshToken() async {
    final refreshToken = SecureStorage.getRefreshToken();
    final response = await Dio().post(
      'https://api.shopapp.com/v1/auth/refresh',
      data: {'refresh_token': refreshToken},
    );
    return response.data['access_token'];
  }
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: CancelTokenリクエストキャンセル

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

// ⚙️ Install dependency: flutter pub add dio

CancelToken? _cancelToken;

void searchProducts(String query) {
  _cancelToken?.cancel('New search request');
  _cancelToken = CancelToken();
  dio.get('/products/search', queryParameters: {'q': query},
    cancelToken: _cancelToken);
}

// Cancel on dispose
@override
void dispose() {
  _cancelToken?.cancel();
  super.dispose();
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

5. JSONシリアライズ

(1) シリアライズ方法の比較

方法 メリット デメリット 用途
手動fromJson/toJson 依存なし、完全制御 反復的、エラーが起きやすい 少数のシンプルなモデル
json_serializable 型安全、自動生成 build_runnerが必要 多数のモデル
freezed 不変 + copyWith + ユニオン 依存関係が多い 複雑なドメインモデル

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: json_serializableモデル

DART
import 'package:json_annotation/json_annotation.dart';

// ⚙️ Install dependency: flutter pub add json_annotation
// ⚙️ Dev dependency: flutter pub add --dev json_serializable build_runner

part 'product.g.dart';

@JsonSerializable()
class Product {
  final int id;
  final String name;
  @JsonKey(name: 'unit_price')
  final double price;
  @JsonKey(name: 'image_url')
  final String? imageUrl;
  final double rating;

  const Product({
    required this.id,
    required this.name,
    required this.price,
    this.imageUrl,
    this.rating = 0.0,
  });

  factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
  Map<String, dynamic> toJson() => _$ProductToJson(this);
}

// Run: dart run build_runner build --delete-conflicting-outputs
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

6. 統一エラーハンドリング

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: ApiException + エラーインターセプター

DART
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

// ⚙️ Install dependency: flutter pub add dio

class ApiException implements Exception {
  final int? statusCode;
  final String message;
  final String? errorCode;

  ApiException(this.statusCode, this.message, {this.errorCode});

  factory ApiException.fromDioError(DioException error) {
    switch (error.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.sendTimeout:
      case DioExceptionType.receiveTimeout:
        return ApiException(null, 'Connection timeout. Please try again.');
      case DioExceptionType.connectionError:
        return ApiException(null, 'No internet connection.');
      case DioExceptionType.badResponse:
        final status = error.response?.statusCode;
        final msg = error.response?.data['message'] ?? 'Server error';
        if (status == 401) return ApiException(401, 'Session expired. Please login again.');
        if (status == 403) return ApiException(403, 'Access denied.');
        if (status == 404) return ApiException(404, 'Resource not found.');
        if (status == 422) return ApiException(422, msg, errorCode: error.response?.data['code']);
        if (status != null && status >= 500) return ApiException(status, 'Server error. Please try later.');
        return ApiException(status, msg);
      default:
        return ApiException(null, 'Unexpected error.');
    }
  }
}

// Usage in UI
try {
  final products = await productRepo.getProducts();
} on ApiException catch (e) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text(e.message)),
  );
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

7. 完成例:ShopApp Dioリポジトリ

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

// ⚙️ Install dependency: flutter pub add dio

// Custom class definition sources:
// - Product: see Lesson 5 json_serializable model in this lesson
// - CartItem/Address/Order: see Lesson 14 Phase 2 practice
// - AuthInterceptor: see Lesson 4 Auth interceptor in this lesson

class ProductRepository {
  final Dio _dio;

  ProductRepository(this._dio);

  Future<List<Product>> getProducts({
    int page = 1,
    int limit = 20,
    String? category,
    String? search,
  }) async {
    final params = {'page': page, 'limit': limit};
    if (category != null) params['category'] = category;
    if (search != null) params['q'] = search;

    final response = await _dio.get('/products', queryParameters: params);
    return (response.data['items'] as List)
        .map((j) => Product.fromJson(j))
        .toList();
  }

  Future<Product> getProductById(int id) async {
    final response = await _dio.get('/products/$id');
    return Product.fromJson(response.data);
  }

  Future<Order> createOrder(List<CartItem> items, Address address) async {
    final response = await _dio.post('/orders', data: {
      'items': items.map((i) => {'product_id': i.product.id, 'quantity': i.quantity}).toList(),
      'shipping_address': address.toJson(),
    });
    return Order.fromJson(response.data);
  }

  Future<String> uploadProductImage(String filePath) async {
    final formData = FormData.fromMap({
      'image': await MultipartFile.fromFile(filePath),
    });
    final response = await _dio.post('/upload', data: formData);
    return response.data['url'];
  }
}

// Dio singleton setup
final dio = Dio(BaseOptions(
  baseUrl: 'https://api.shopapp.com/v1',
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 30),
))
  ..interceptors.add(AuthInterceptor())
  ..interceptors.add(LogInterceptor(requestBody: true, responseBody: true));

final productRepo = ProductRepository(dio);
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

❓ よくある質問

Q Dioとhttpパッケージのどちらを選ぶべきか?
A 小規模プロジェクトではhttpパッケージで十分。インターセプター、リクエストキャンセル、リトライ、FormDataアップロードなどが必要な場合はDioを選んでください。
Q build_runnerが毎回遅いのは?
A 開発中はdart run build_runner watch --delete-conflicting-outputsを使用して、ファイル変更を継続的に監視し自動生成してください。
Q トークンリフレッシュ中に複数のリクエストが同時に401になるのは?
A ロック機構を追加してください:最初の401がリフレッシュをトリガーし、他の401リクエストはリフレッシュ完了までキューに入れます。Completerを使って実装します。
Q FormDataで大きなファイルをアップロードするとOOMになる?
A MultipartFile.fromFileは遅延読み込みで、ファイル全体を一度にメモリに読み込みません。大きなファイルの場合はチャンクアップロードを検討してください。
Q 開発中にネットワークリクエストをデバッグするには?
A LogInterceptorを追加してリクエスト/レスポンスを印刷するか、Charles/Proxymanでパケットキャプチャを使用してください。Dioはプロキシ設定もサポートしています。
Q SSL証明書検証に失敗するのは?
A 開発中はHttpClientAdapterで検証をスキップできます。本番では証明書を検証する必要があります。

📖 まとめ


📝 練習問題

  1. 基本(⭐): httpパッケージでGETリクエストを行い、商品リストを取得してJSONを印刷してください。
  2. 中級(⭐⭐): DioでRepositoryをラップし、Authインターセプターを追加してBearerトークンを自動付与し、401時に自動リフレッシュしてください。
  3. チャレンジ(⭐⭐⭐): 完全なネットワーク層を実装してください:Dio + Authインターセプター + Logインターセプター + CancelToken検索デバウンス + ApiException統一エラーハンドリング + json_serializableモデル。

← 前へ | 次へ →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%