Пакет dio во Flutter: запросы, ошибки, перехватчики, скачивание и отправка файлов

Егор Долгов · 12.11.2026
Приложение
Сервер
→ GET /posts/1
адрес · токен · таймаут
→ GET /posts/2
адрес · токен · таймаут
→ GET /posts/3
адрес · токен · таймаут
→ GET /posts/4
адрес · токен · таймаут
Показать по шагам
Субтитры
Сначала выберите систему — команды подставятся под неё.

dio — пакет для HTTP-запросов в Dart и Flutter, как и http из Урока 7 . Он умеет больше: общие настройки для всех запросов, перехватчики, скачивание и отправку файлов с прогрессом. Отменять запросы умеют оба пакета. В этой статье разберём, что это даёт и как этим пользоваться.

Содержание:

  1. Когда брать dio, а когда хватит http
  2. Подключение и запуск примеров
  3. Первый запрос
  4. Ошибки: код 404 — уже исключение
  5. Общие настройки: baseUrl и таймауты
  6. POST: отправляем JSON
  7. Перехватчики: код перед каждым запросом
  8. Отмена запроса
  9. Скачивание файла с прогрессом
  10. Отправка файлов на сервер
  11. dio во Flutter-приложении
  12. http и dio: отличия по пунктам
  13. Частые ошибки
  14. Итог

1. Когда брать dio, а когда хватит http

Задача: приложение ходит к одному серверу десятками запросов. Каждому нужен один и тот же адрес сервера, токен авторизации и ограничение по времени, а ошибки хочется обрабатывать в одном месте. В пакете http нет общих настроек и перехватчиков — их пришлось бы писать самому. В dio это встроено:

  • общие настройки — адрес сервера, таймауты и заголовки задаются один раз для всех запросов;
  • перехватчики — код, который выполняется перед каждым запросом и после каждого ответа;
  • отмена запроса — например, когда пользователь ушёл с экрана (в http для этого есть AbortableRequest );
  • скачивание и отправка файлов с отчётом о прогрессе.

Если запросов пара и всё это не нужно, достаточно http из Урока 7. Официальные руководства Flutter тоже используют http : например, Fetch data from the internet . Отдельного руководства по dio в документации Flutter нет.

dio выпускает команда CFUG — издатель flutter.cn на pub.dev, исходники — на GitHub .

2. Подключение и запуск примеров

Во Flutter-проекте в папке проекта выполните:

flutter pub add dio

В pubspec.yaml появится строка dio: ^5.11.1 . В коде пакет подключают так:

import 'package:dio/dio.dart';

Примеры в разделах 3–10 — короткие программы для консоли: так виден результат каждого запроса. Отдельный Flutter-проект для них не нужен, а команда dart у вас уже есть — Flutter SDK включает полный Dart SDK . Создайте консольный проект и подключите dio:

dart create dio_demo
cd dio_demo
dart pub add dio

Каждый пример — отдельная программа: вставьте его в bin/dio_demo.dart вместо того, что там есть, и запустите командой dart run . Пример во Flutter-приложении — в разделе 11.

3. Первый запрос

Задача: получить с сервера запись номер 1. Запросим запись с тестового сервера JSONPlaceholder , как в Уроке 7:

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio();
  final response = await dio.get(
    'https://jsonplaceholder.typicode.com/posts/1',
  );
  print(response.statusCode);
  print(response.data.runtimeType);
  print(response.data['title']);
}

Результат:

200
_Map<String, dynamic>
sunt aut facere repellat provident occaecati excepturi optio reprehenderit
  • Dio() — клиент, через который идут запросы.
  • dio.get(адрес) — GET-запрос. Адрес — обычная строка, в отличие от http , где нужен Uri .
  • response.data — ответ, уже разобранный из JSON : это Map , поэтому response.data['title'] работает сразу, без jsonDecode() . dio разбирает ответ, если сервер прислал его с типом application/json .

4. Ошибки: код 404 — уже исключение

Это первое отличие, которое вы заметите после Урока 7. Там ответ с кодом 404 приходил как обычный ответ, и код проверяли сами. dio по умолчанию считает успешными только коды от 200 до 299 (это меняет параметр validateStatus ), а на любой другой бросает исключение DioException :

import 'package:dio/dio.dart';

Future<void> load(Dio dio, String url) async {
  try {
    final response = await dio.get(url);
    print('OK ${response.statusCode}');
  } on DioException catch (e) {
    print('${e.type}: ${e.response?.statusCode}');
  }
}

Future<void> main() async {
  final dio = Dio();
  await load(dio, 'https://jsonplaceholder.typicode.com/posts/0');
  await load(dio, 'https://no-such-host.invalid/posts/1');
}

Результат:

DioExceptionType.badResponse: 404
DioExceptionType.connectionError: null
  • on DioException catch (e) ловит любую ошибку запроса.
  • e.type — вид ошибки. badResponse — сервер ответил кодом вне 200–299, connectionError — до сервера не достучались (здесь — такого сервера нет).
  • e.response?.statusCode — код ответа. Когда ответа нет вовсе, e.response равен null .

Ещё виды ошибок: connectionTimeout , sendTimeout , receiveTimeout — вышло время, cancel — запрос отменили, unknown — другая ошибка, исходное исключение лежит в e.error . Так приходит, например, ошибка сертификата: у просроченного или самоподписанного сертификата e.type — unknown , а в e.error — HandshakeException . Вид badCertificate бывает, только если вы задали свою проверку сертификата. Полный список — в справке DioExceptionType .

5. Общие настройки: baseUrl и таймауты

Задача: не повторять в каждом запросе адрес сервера и ограничения по времени. Их задают один раз в BaseOptions :

import 'package:dio/dio.dart';

final dio = Dio(
  BaseOptions(
    baseUrl: 'https://jsonplaceholder.typicode.com',
    connectTimeout: const Duration(seconds: 5),
    receiveTimeout: const Duration(seconds: 10),
  ),
);

Future<void> main() async {
  final post = await dio.get('/posts/1');
  final user = await dio.get('/users/1');
  print(post.data['title']);
  print(user.data['name']);
}

Результат:

sunt aut facere repellat provident occaecati excepturi optio reprehenderit
Leanne Graham
  • baseUrl — начало адреса. Запрос dio.get('/posts/1') уйдёт на https://jsonplaceholder.typicode.com/posts/1 .
  • connectTimeout — сколько ждать соединения с сервером.
  • receiveTimeout — сколько ждать данных: первых байтов ответа и каждой следующей порции. Это не предел на весь ответ: большой файл может качаться дольше, если данные идут без пауз.
  • По умолчанию dio не ограничивает время ожидания — запрос может висеть очень долго. Поэтому таймауты стоит задать.
  • Здесь же задают заголовки для всех запросов — параметр headers .

Когда время вышло, dio бросает DioException с соответствующим видом. Проверим с нарочно маленьким receiveTimeout :

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio(
    BaseOptions(receiveTimeout: const Duration(milliseconds: 1)),
  );
  try {
    await dio.get('https://jsonplaceholder.typicode.com/posts');
  } on DioException catch (e) {
    print(e.type);
  }
}

Результат: DioExceptionType.receiveTimeout .

Авторы dio советуют держать в приложении один экземпляр `Dio` : тогда настройки едины для всех запросов. В примере выше он объявлен один раз на верхнем уровне файла.

6. POST: отправляем JSON

Задача: отправить на сервер новую запись.

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio(BaseOptions(baseUrl: 'https://jsonplaceholder.typicode.com'));
  final response = await dio.post(
    '/posts',
    data: {'title': 'Привет', 'body': 'Мой первый пост', 'userId': 1},
  );
  print(response.statusCode);
  print(response.requestOptions.contentType);
  print(response.data);
}

Результат:

201
application/json
{title: Привет, body: Мой первый пост, userId: 1, id: 101}
  • data: — тело запроса. Map dio сам превращает в JSON — jsonEncode() не нужен.
  • Заголовок Content-Type: application/json dio тоже ставит сам — это видно во второй строке результата.
  • В ответ — код 201 «создано» и запись с номером 101 (JSONPlaceholder ничего не сохраняет, а только отвечает так, будто сохранил).

7. Перехватчики: код перед каждым запросом

Задача: видеть в консоли каждый запрос и ответ, а к каждому запросу добавлять токен авторизации — например, полученный после входа в приложение. Постоянный заголовок проще задать в BaseOptions , а здесь нужен код, который выполняется при каждом запросе. Это перехватчик (interceptor): код, который dio вызывает перед каждым запросом и после каждого ответа.

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio(BaseOptions(baseUrl: 'https://jsonplaceholder.typicode.com'));
  dio.interceptors.add(
    InterceptorsWrapper(
      onRequest: (options, handler) {
        options.headers['Authorization'] = 'Bearer my-token';
        print('→ ${options.method} ${options.uri}');
        handler.next(options);
      },
      onResponse: (response, handler) {
        print('← ${response.statusCode}');
        handler.next(response);
      },
    ),
  );
  await dio.get('/posts/1');
  await dio.get('/users/1');
}

Результат:

→ GET https://jsonplaceholder.typicode.com/posts/1
← 200
→ GET https://jsonplaceholder.typicode.com/users/1
← 200
  • onRequest вызывается перед каждым запросом: здесь мы добавляем заголовок Authorization и печатаем адрес.
  • onResponse — после каждого ответа, onError — при каждой ошибке.
  • handler.next(...) передаёт запрос или ответ дальше — следующему перехватчику. Вызывайте его в конце каждого обработчика, как в примере.

Для журнала запросов в dio есть готовый перехватчик `LogInterceptor` . Тела запросов и ответов он по умолчанию не печатает, а во Flutter авторы dio советуют выводить его журнал через debugPrint : LogInterceptor(logPrint: (o) => debugPrint(o.toString())) .

8. Отмена запроса

Задача: пользователь ушёл с экрана, а запрос ещё идёт — его результат уже не нужен. Запрос можно отменить через CancelToken :

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio();
  final cancelToken = CancelToken();
  final request = dio.get(
    'https://jsonplaceholder.typicode.com/posts',
    cancelToken: cancelToken,
  );
  cancelToken.cancel('Пользователь ушёл с экрана');
  try {
    await request;
  } on DioException catch (e) {
    print('${e.type}: ${e.error}');
  }
}

Результат: DioExceptionType.cancel: Пользователь ушёл с экрана .

  • cancelToken: передаётся в запрос.
  • cancelToken.cancel(причина) отменяет его: запрос завершается исключением DioException с видом cancel , а в e.error — причина.
  • Одним CancelToken можно отменить сразу несколько запросов.

9. Скачивание файла с прогрессом

Задача: скачать большой файл и показывать, сколько уже скачано. Для этого есть dio.download — он сохраняет ответ в файл и по ходу сообщает о прогрессе:

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio();
  await dio.download(
    'https://jsonplaceholder.typicode.com/photos',
    'photos.json',
    onReceiveProgress: (received, total) {
      if (total > 0) {
        print('${(received / total * 100).toStringAsFixed(0)}%');
      } else {
        print('$received байт');
      }
    },
  );
  print('Готово');
}

Результат (начало и конец):

602 байт
9309 байт
…
1039898 байт
Готово
  • Второй аргумент — путь к файлу, куда сохранить. В консоли это просто имя файла в текущей папке, а в приложении путь берут в папке приложения — например, через getTemporaryDirectory() из пакета path_provider, как в справке `download` .
  • onReceiveProgress вызывается по мере скачивания: received — сколько байт получено, total — полный размер.
  • Если сервер не сообщил размер (заголовок content-length ) или прислал данные сжатыми (заголовок content-encoding: gzip , как здесь), total равен −1. Тогда показать проценты нельзя, только объём скачанного.
  • В вебе браузер не даёт писать файлы куда угодно: там download скачивает файл в память и предлагает сохранить его, а путь становится предлагаемым именем файла .

Большой файл и память. На телефоне и компьютере download не держит файл в памяти целиком: он записывает на диск каждую пришедшую порцию и только потом принимает следующую. В нашей проверке память программы не росла вместе с размером файла: при скачивании файла в 3 ГБ она была такой же, как при скачивании средствами самого Dart ( dart:io ). Поэтому большие файлы — видео, архивы, обновления — скачивайте через download .

10. Отправка файлов на сервер

Задача: отправить на сервер фотографию — например, в профиль или в альбом. Чтобы отправить файл вместе с полями формы, используют формат multipart/form-data , в dio для этого есть FormData .

Для этого примера нужны две вещи:

  • сервер, который принимает файлы. В коде вместо его адреса стоит заглушка https://example.com/upload — подставьте адрес своего сервера. Какой адрес и какие имена полей он ждёт, написано в его документации;
  • картинка photo.jpg в папке проекта dio_demo , рядом с pubspec.yaml : путь к файлу отсчитывается от папки, где вы запускаете dart run . Если файла нет, программа остановится с ошибкой PathNotFoundException .
import 'package:dio/dio.dart';

const uploadUrl = 'https://example.com/upload';

Future<void> main() async {
  final dio = Dio();
  final formData = FormData.fromMap({
    'title': 'Отпуск',
    'photo': await MultipartFile.fromFile('photo.jpg'),
  });
  final response = await dio.post(uploadUrl, data: formData);
  print(response.statusCode);
  print(response.data);
}

Что придёт в ответ, решает сервер. Наш тестовый сервер ответил кодом 200 и списком того, что получил:

200
{fields: {title: Отпуск}, files: [{field: photo, filename: photo.jpg, size: 150000}]}
  • FormData.fromMap собирает форму: обычные поля ( 'title' ) и файлы ( 'photo' ) — вместе.
  • MultipartFile.fromFile(путь) — файл с диска. Читается он по частям , а не целиком: в нашей проверке память программы при отправке файла в 1 ГБ почти не выросла.
  • data: formData — форма уходит телом POST-запроса, заголовок Content-Type dio ставит сам.
  • В вебе путей к файлам нет, и fromFile там бросает UnsupportedError . Файл передают байтами — через MultipartFile.fromBytes .

Несколько файлов кладут списком в одно поле, а прогресс отправки показывает onSendProgress :

import 'package:dio/dio.dart';

const uploadUrl = 'https://example.com/upload';

Future<void> main() async {
  final dio = Dio();
  final formData = FormData.fromMap({
    'photos': [
      await MultipartFile.fromFile('photo1.jpg'),
      await MultipartFile.fromFile('photo2.jpg'),
    ],
  });
  final response = await dio.post(
    uploadUrl,
    data: formData,
    onSendProgress: (sent, total) {
      print('${(sent / total * 100).toStringAsFixed(0)}%');
    },
  );
  print(response.data);
}

Результат (начало и конец, ответ нашего тестового сервера):

0%
13%
…
100%
{fields: {}, files: [{field: photos, filename: photo1.jpg, size: 200000}, {field: photos, filename: photo2.jpg, size: 300000}]}
  • В поле со списком каждый файл уходит под одним и тем же именем — photos , это видно в ответе сервера. В README dio написано, что имя станет files[] , но dio 5.11.1 отправляет его без скобок.
  • Если сервер ждёт имя со скобками — photos[] , так принято, например, в PHP, — передайте вторым аргументом ListFormat.multiCompatible : FormData.fromMap({...}, ListFormat.multiCompatible) .
  • onSendProgress устроен как onReceiveProgress из раздела 9: sent — сколько байт отправлено, total — сколько всего. У FormData размер известен заранее, поэтому проверка на −1 здесь не нужна.
  • FormData и MultipartFile годятся для одного запроса. Чтобы отправить ещё раз, создайте их заново, как советует README dio , или сделайте копию через formData.clone() .

11. dio во Flutter-приложении

Вот функция fetchPost из Урока 7, переписанная на dio, вместе с экраном, который загружает запись при открытии. В проекте из Урока 7 выполните flutter pub add dio и замените lib/main.dart целиком. Разрешения на сеть из Урока 7 (раздел 2) нужны и здесь.

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

final dio = Dio(
  BaseOptions(
    baseUrl: 'https://jsonplaceholder.typicode.com',
    connectTimeout: const Duration(seconds: 5),
    receiveTimeout: const Duration(seconds: 10),
  ),
);

class Post {
  const Post({required this.id, required this.title, required this.body});

  final int id;
  final String title;
  final String body;

  factory Post.fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {'id': int id, 'title': String title, 'body': String body} => Post(
        id: id,
        title: title,
        body: body,
      ),
      _ => throw const FormatException('Не удалось прочитать пост'),
    };
  }
}

Future<Post> fetchPost(int id) async {
  final response = await dio.get('/posts/$id');
  return Post.fromJson(response.data as Map<String, dynamic>);
}

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('dio')),
        body: const PostView(),
      ),
    );
  }
}

class PostView extends StatefulWidget {
  const PostView({super.key});

  @override
  State<PostView> createState() => _PostViewState();
}

class _PostViewState extends State<PostView> {
  late Future<Post> _post;

  @override
  void initState() {
    super.initState();
    _post = fetchPost(1);
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: FutureBuilder<Post>(
        future: _post,
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Text('Ошибка: ${snapshot.error}');
          }
          if (!snapshot.hasData) {
            return const Center(child: CircularProgressIndicator());
          }
          final post = snapshot.data!;
          return Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(post.title, style: Theme.of(context).textTheme.titleLarge),
              const SizedBox(height: 8),
              Text(post.body),
            ],
          );
        },
      ),
    );
  }
}

Что изменилось по сравнению с Уроком 7:

  • адрес сервера и таймауты заданы один раз в BaseOptions ;
  • нет jsonDecode() — response.data уже Map ;
  • нет проверки statusCode : при коде не из 200–299 dio сам бросит DioException , и FutureBuilder покажет «Ошибка: …».

12. http и dio: отличия по пунктам

  • Ответ с кодом 404 или 500. http — обычный ответ, код проверяете сами. dio — исключение DioException с видом badResponse .
  • JSON в ответе. http — jsonDecode(response.body) . dio — response.data уже разобран.
  • JSON в запросе. http — jsonEncode() и заголовок Content-Type вручную. dio — data: {...} , остальное сам.
  • Таймаут. http — .timeout() у Future . dio — connectTimeout , receiveTimeout , sendTimeout в настройках.
  • Общие заголовки и журнал. http — свой класс-обёртка над `Client` . dio — перехватчики.
  • Отмена. http — AbortableRequest (с версии 1.5.0). dio — CancelToken .
  • Скачивание с прогрессом. Готовый метод есть только в dio — download с onReceiveProgress .
  • Отправка файлов. http — MultipartRequest . dio — FormData в data: , прогресс — onSendProgress .
  • Кто выпускает. http — команда Dart ( dart.dev ). dio — CFUG ( flutter.cn ).

13. Частые ошибки

  • DioError устарел. Класс ошибки с версии dio 5.2.0 называется DioException , DioError оставлен как устаревший. В старых примерах из интернета замените DioError на DioException .
  • Ждут код 404 в ответе, а получают исключение. Если вы привыкли к http , ответ с кодом ошибки нужно ловить в on DioException catch (e) и смотреть e.response?.statusCode .
  • Новый Dio() на каждый запрос. Тогда настройки и перехватчики приходится задавать каждый раз заново. Создайте один экземпляр на приложение.
  • Запрос висит очень долго. Таймауты по умолчанию не заданы — укажите их в BaseOptions .

Ошибка сертификата ( HandshakeException внутри DioException ) и как доверять своему сертификату — в отдельной статье о HTTPS и сертификатах (готовится).

14. Итог

  • dio — HTTP-клиент с общими настройками ( BaseOptions ), перехватчиками, отменой ( CancelToken ), скачиванием ( download ) и отправкой файлов ( FormData ).
  • На телефоне и компьютере большие файлы download пишет на диск порциями, а MultipartFile.fromFile читает по частям — файл целиком в память не попадает.
  • response.data уже разобран из JSON, а Map в data: отправляется как JSON.
  • По умолчанию ответ с кодом вне 200–299 — это исключение DioException , вид ошибки — в e.type .
  • Таймауты по умолчанию не заданы — задайте их сами.
  • Для пары простых запросов достаточно пакета http — Урок 7 .

Вопросы и предложения по статье присылайте на contact@flutter.su.