Chương 10 · Network — HTTP, Dio, Retrofit

Network — HTTP, Dio, Retrofit

Mọi app real-world gọi API. Flutter có 2 lựa chọn chính: http (Flutter team, đủ cho 80% case) hoặc dio (community, mạnh hơn). Pattern Repository + JSON model là chuẩn industry — không hiểu thì code rối.

Độ dài: ~1170 dòng Bài tập: 5 Quiz: 8 Prerequisites: Dart Ch 7 (async) + Ch 5+ state mgmt
🎯 Mục tiêu chương
  • Sử dụng http package cho REST cơ bản.
  • Sử dụng Dio với interceptor, retry, timeout.
  • Parse JSON: manual + json_serializable codegen.
  • Pattern Repository: tách HTTP khỏi widget.
  • Error handling: network, parse, business.
  • Timeout & retry strategy.
  • retrofit package: type-safe API client.
  • Upload file (multipart).
  • WebSocket cơ bản với web_socket_channel.

1. http package — basic

flutter pub add http
import 'package:http/http.dart' as http;
import 'dart:convert';

Future<List<User>> fetchUsers() async {
  final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users'));

  if (response.statusCode == 200) {
    final json = jsonDecode(response.body) as List;
    return json.map((j) => User.fromJson(j)).toList();
  } else {
    throw Exception('Failed: ${response.statusCode}');
  }
}

2. Response handling

final response = await http.get(uri);

response.statusCode;       // int: 200, 404, 500...
response.body;             // String
response.bodyBytes;        // Uint8List — cho binary
response.headers;          // Map<String, String>
response.contentLength;

3. JSON decode

import 'dart:convert';

final raw = response.body;     // '{"id": 1, "name": "Việt"}'

// jsonDecode trả dynamic — phải cast
final obj = jsonDecode(raw) as Map<String, dynamic>;
print(obj['name']);            // 'Việt'

// List response
final list = jsonDecode(raw) as List;
final users = list.map((j) => User.fromJson(j as Map<String, dynamic>)).toList();

4. Model class manual

class User {
  final int id;
  final String name;
  final String email;

  User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => User(
    id: json['id'] as int,
    name: json['name'] as String,
    email: json['email'] as String,
  );

  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
    'email': email,
  };
}

5. JSON codegen — json_serializable

flutter pub add json_annotation
flutter pub add --dev build_runner json_serializable
// user.dart
import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';       // codegen output

@JsonSerializable()
class User {
  final int id;
  final String name;
  final String email;

  User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}
# Generate
dart run build_runner build
# Hoặc watch mode (auto regenerate khi save)
dart run build_runner watch

6. freezed + json_serializable — pattern modern

freezed sinh thêm copyWith, ==, hashCode, toString — pattern chuẩn cho immutable model.

flutter pub add freezed_annotation json_annotation
flutter pub add --dev build_runner freezed json_serializable
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    required String email,
    @Default(false) bool premium,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

// Auto có:
// - copyWith
// - == + hashCode
// - toString
// - fromJson / toJson

7. POST / PUT / DELETE

// POST với body JSON
final response = await http.post(
  Uri.parse('$baseUrl/users'),
  headers: {'Content-Type': 'application/json'},
  body: jsonEncode({'name': 'Việt', 'email': 'v@x.com'}),
);

// PUT — update
await http.put(Uri.parse('$baseUrl/users/1'), headers: ..., body: ...);

// DELETE
await http.delete(Uri.parse('$baseUrl/users/1'));

8. Dio — mạnh hơn http

flutter pub add dio
import 'package:dio/dio.dart';

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

// GET
final r = await dio.get('/users');
print(r.data);   // auto parse JSON, không cần jsonDecode

// POST
await dio.post('/users', data: {'name': 'Việt'});

// Query param
await dio.get('/users', queryParameters: {'page': 1, 'limit': 10});

9. Dio interceptor

// Auth interceptor — auto add Authorization header
class AuthInterceptor extends Interceptor {
  final String Function() getToken;
  AuthInterceptor(this.getToken);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = getToken();
    options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  }
}

// Log interceptor — built-in
dio.interceptors.add(LogInterceptor(
  requestBody: true,
  responseBody: true,
));

dio.interceptors.add(AuthInterceptor(() => tokenService.token));

Retry interceptor — exponential backoff

class RetryInterceptor extends Interceptor {
  final Dio dio;
  RetryInterceptor(this.dio);

  @override
  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
    final options = err.requestOptions;
    final retryCount = (options.extra['retryCount'] as int?) ?? 0;

    if (retryCount < 3 && (err.response?.statusCode ?? 0) >= 500) {
      options.extra['retryCount'] = retryCount + 1;
      await Future.delayed(Duration(seconds: 1 << retryCount));   // 1s, 2s, 4s
      try {
        final r = await dio.fetch(options);
        return handler.resolve(r);
      } catch (e) {
        return handler.next(err);
      }
    }
    handler.next(err);
  }
}

Hoặc dùng package dio_smart_retry sẵn.

10. CancelToken — cancel khi widget unmount

class _UserPageState extends State<UserPage> {
  final _cancelToken = CancelToken();

  Future<void> _load() async {
    try {
      final r = await dio.get('/users', cancelToken: _cancelToken);
      if (!mounted) return;
      setState(() => _users = r.data);
    } on DioException catch (e) {
      if (CancelToken.isCancel(e)) return;
      // handle other errors
    }
  }

  @override
  void dispose() {
    _cancelToken.cancel('Widget disposed');
    super.dispose();
  }
}

11. Repository pattern

// Interface — Bloc/Cubit depend trên này
abstract class UserRepository {
  Future<User> fetchUser(int id);
  Future<List<User>> fetchAll();
}

// Impl HTTP
class UserRepositoryImpl implements UserRepository {
  final Dio _dio;
  UserRepositoryImpl(this._dio);

  @override
  Future<User> fetchUser(int id) async {
    try {
      final r = await _dio.get('/users/$id');
      return User.fromJson(r.data);
    } on DioException catch (e) {
      throw ApiException(e.response?.statusCode ?? 0, e.message ?? '');
    }
  }

  @override
  Future<List<User>> fetchAll() async {
    final r = await _dio.get('/users');
    return (r.data as List).map((j) => User.fromJson(j)).toList();
  }
}

// Inject vào Bloc (Ch 7)
class UserBloc extends Cubit<UserState> {
  final UserRepository _repo;
  UserBloc(this._repo) : super(UserInitial());

  Future<void> load(int id) async {
    emit(UserLoading());
    try {
      final u = await _repo.fetchUser(id);
      emit(UserLoaded(u));
    } on ApiException catch (e) {
      emit(UserError(e.message));
    }
  }
}
🧠 Vì sao Repository pattern?
  • Test: mock UserRepository interface, test Bloc với mock data — không cần HTTP thật.
  • Swap source: đổi HTTP → GraphQL → local DB không cần đổi Bloc/UI.
  • Cache layer: wrap repo với caching impl, transparent với caller.
  • Tách concern: Bloc không biết JSON shape, không biết Dio.

12. Custom ApiException

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

  const ApiException(this.statusCode, this.message, [this.endpoint]);

  @override
  String toString() => 'ApiException($statusCode) $message [$endpoint]';
}

// Trong catch DioException — convert thành ApiException
try {
  final r = await dio.get('/users/$id');
  return User.fromJson(r.data);
} on DioException catch (e) {
  throw ApiException(
    e.response?.statusCode ?? 0,
    e.response?.data?['message'] ?? e.message,
    '/users/$id',
  );
}

13. Timeout pattern

// Dio — option
Dio(BaseOptions(
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 10),
));

// http package — qua .timeout
try {
  final r = await http.get(uri).timeout(const Duration(seconds: 10));
} on TimeoutException {
  // handle
}

14. retrofit — annotation-driven client

flutter pub add retrofit dio
flutter pub add --dev retrofit_generator build_runner
import 'package:retrofit/retrofit.dart';
import 'package:dio/dio.dart';

part 'api_client.g.dart';

@RestApi(baseUrl: 'https://api.example.com')
abstract class ApiClient {
  factory ApiClient(Dio dio, {String baseUrl}) = _ApiClient;

  @GET('/users')
  Future<List<User>> getUsers();

  @GET('/users/{id}')
  Future<User> getUser(@Path() int id);

  @POST('/users')
  Future<User> createUser(@Body() User user);
}

// Codegen sinh impl ApiClient với Dio bên dưới
final api = ApiClient(Dio());
final users = await api.getUsers();

15. Upload file — Multipart

final formData = FormData.fromMap({
  'avatar': await MultipartFile.fromFile(localPath, filename: 'avatar.jpg'),
  'name': 'Việt',
});

final r = await dio.post(
  '/upload',
  data: formData,
  onSendProgress: (sent, total) {
    print('${(sent / total * 100).toStringAsFixed(1)}%');
  },
);

16. WebSocket — web_socket_channel

flutter pub add web_socket_channel
import 'package:web_socket_channel/web_socket_channel.dart';

final channel = WebSocketChannel.connect(
  Uri.parse('wss://echo.websocket.events'),
);

// Listen incoming
channel.stream.listen(
  (message) => print('Received: $message'),
  onError: (e) => print('Error: $e'),
  onDone: () => print('Closed'),
);

// Send message
channel.sink.add('Hello!');

// Close
channel.sink.close();

17. Pitfall: jsonDecode lớn block UI

JSON 10MB parse mất hàng trăm ms — main isolate block, UI jank.

// ❌ Sai — block main
final data = jsonDecode(largeJson);

// ✅ Đúng — parse trong isolate khác
final data = await compute(jsonDecode, largeJson);

// Hoặc convert sang model trong isolate
List<User> parseUsers(String raw) {
  final list = jsonDecode(raw) as List;
  return list.map((j) => User.fromJson(j)).toList();
}

final users = await compute(parseUsers, response.body);

18. Bài tập

List user từ JSONPlaceholder

App list user từ https://jsonplaceholder.typicode.com/users. Pull-to-refresh. Error state với retry button.

💡 Gợi ý đáp án

Dùng RefreshIndicator wrap ListView. State: loading, success, error.

Detail page

Tap user → detail page fetch /users/:id. Loading + error state đầy đủ.

💡 Gợi ý đáp án

Navigate qua go_router với path param. Hoặc Navigator 1.0 với arguments.

Migrate http → Dio + interceptor

Migrate bài 1 từ http sang dio. Add LogInterceptor để log request/response.

💡 Gợi ý đáp án

Tham khảo section 8-9. dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true)).

Repository pattern + test mock

Implement repository: UserRepository interface, UserRepositoryImpl(Dio) concrete. UserBloc inject. Test UserBloc với mock repo.

💡 Gợi ý đáp án

Mock với mocktail: class MockRepo extends Mock implements UserRepository {}. Chương 14 đi sâu.

JWT auth flow

Add JWT auth flow: login → save token (SharedPreferences, Ch 11) → interceptor add header. Handle 401 → refresh token retry.

💡 Gợi ý đáp án

AuthInterceptor onRequest add Bearer. RetryInterceptor on 401 → call refresh → retry original request.

19. Quiz

Q1

http.get return type?

Xem đáp án

Đáp án: Future<http.Response>. Response có statusCode, body, headers.

Q2

jsonDecode string JSON 10MB — chuyện gì?

Xem đáp án

Đáp án: Block main isolate, UI jank. Fix: compute(jsonDecode, body) để parse ở isolate khác.

Q3

Dio interceptor onErrorhandler.next vs handler.reject?

Xem đáp án

Đáp án: .next tiếp tục chain (lan tỏa error). .reject stop chain, throw error sớm. .resolve swap thành response thành công.

Q4

freezed model auto sinh method nào?

Xem đáp án

Đáp án: copyWith, ==, hashCode, toString, optional toJson/fromJson (qua json_serializable). Pattern matching khi dùng sealed/union.

Q5

CancelToken use case?

Xem đáp án

Đáp án: Cancel request khi widget unmount, tránh setState after dispose. Hoặc khi user search nhanh, cancel request cũ.

Q6

Retry với 401 (unauthorized) — strategy?

Xem đáp án

Đáp án: Refresh access token bằng refresh token. Retry original request với token mới. Refresh fail → logout user.

Q7

Multipart upload Dio — class chính?

Xem đáp án

Đáp án: FormData + MultipartFile.fromFile. Pass vào data: param.

Q8

http follow redirect (3xx) tự không?

Xem đáp án

Đáp án: Có, mặc định. Disable: tạo Request object trực tiếp với followRedirects = false.

20. Tổng kết

  • http đủ cho project nhỏ. dio cho project lớn cần interceptor.
  • ✅ JSON: manual fromJson/toJson hoặc json_serializable codegen.
  • freezed + json_serializable = pattern modern cho immutable model.
  • ✅ Dio interceptor: auth, log, retry.
  • ✅ CancelToken cho cancel + cleanup.
  • ✅ Repository pattern: tách HTTP/JSON khỏi Bloc.
  • ✅ Custom ApiException chuẩn cho error handling.
  • ✅ Timeout + retry exponential backoff.
  • retrofit codegen API client từ annotation.
  • ✅ Multipart upload qua FormData + MultipartFile.
  • ✅ WebSocket với web_socket_channel.
  • ✅ JSON lớn → compute() ra isolate khác.

21. Kết nối

  • Ch 7-8: Bloc/Provider gọi repository.
  • Ch 11: cache response local (offline-first).
  • Ch 14: test repo + Bloc với mock.