Flutter: ネットワークとREST API
ネットワークのないアプリは孤島です — APIリクエストは本土への架け橋です。
📋 前提条件: 以下を先にマスターしている必要があります
- レッスン10:リストとスクロール
1. このレッスンで学ぶこと
httpパッケージの基本的なGET/POST/PUT/DELETEリクエストDioの深掘り:インターセプター、CancelToken、FormDataアップロード、タイムアウトリトライ- JSONシリアライズ:
json_serializable+build_runnerコード生成 vs 手動パース - エラーハンドリング戦略:ネットワーク例外 / 401認証 / 500サーバーエラーの統一インターセプト
- ShopApp Dioリポジトリ層、
/api/v1/products商品エンドポイントとの統合
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で検証をスキップできます。本番では証明書を検証する必要があります。📖 まとめ
- httpパッケージはシンプルなシナリオに適し、Dioはインターセプター、キャンセル、リトライが必要な本番プロジェクト向け
- インターセプターでトークン付与と401自動リフレッシュを統一処理
- CancelTokenで古いリクエストをキャンセル(検索ボックスのデバウンスなど)
- json_serializableコード生成は型安全で手動エラーを削減
- ApiExceptionがエラーを統一ラップし、UI層は1つの例外型をキャッチするだけで済む
📝 練習問題
- 基本(⭐): httpパッケージでGETリクエストを行い、商品リストを取得してJSONを印刷してください。
- 中級(⭐⭐): DioでRepositoryをラップし、Authインターセプターを追加してBearerトークンを自動付与し、401時に自動リフレッシュしてください。
- チャレンジ(⭐⭐⭐): 完全なネットワーク層を実装してください:Dio + Authインターセプター + Logインターセプター + CancelToken検索デバウンス + ApiException統一エラーハンドリング + json_serializableモデル。