Рубрики
Flutter

WebSockets во Flutter: Real-time с нуля до продакшена

WebSockets — это технология для двусторонней real-time связи между клиентом и сервером. В отличие от традиционного HTTP, WebSocket поддерживает постоянное соединение, которое позволяет серверу отправлять данные клиенту в любой момент. В этой статье мы реализуем надёжный WebSocket клиент во Flutter с нуля.

Что такое WebSockets?

WebSocket — это протокол связи, который обеспечивает постоянное двунаправленное соединение через одиночное TCP-соединение. После установки handshake-соединения обе стороны могут отправлять данные в любое время без накладных расходов HTTP.

Ключевые преимущества WebSockets

  • Постоянное соединение — нет необходимости создавать новое соединение для каждого запроса
  • Двусторонняя связь — сервер может инициировать отправку данных клиенту
  • Низкие накладные расходы — минимизированный overhead по сравнению с HTTP polling
  • Real-time обновления — мгновенная доставка сообщений
  • Поддержка на всех платформах — работает в браузерах и мобильных приложениях

Когда использовать WebSockets

WebSockets идеально подходят для сценариев, где требуются real-time обновления:

  • Чат-приложения и мессенджеры
  • Онлайн-игры с многопользовательским режимом
  • Real-time дашборды и аналитика
  • Коллаборативные редакторы
  • Push-уведомления без сторонних сервисов
  • Live стриминг данных (котировки, графики)

Установка

Для работы с WebSockets во Flutter мы будем использовать пакет web_socket_channel — официальное решение от команды Flutter.

dependencies:
  web_socket_channel: ^2.4.0

Добавьте зависимость в ваш pubspec.yaml и выполните flutter pub get.

Базовая реализация

Начнём с простой реализации WebSocket клиента. Мы создадим сервис, который управляет соединением и предоставляет Stream для получения сообщений.

import 'dart:async';
import 'package:web_socket_channel/web_socket_channel.dart';

class WebSocketService {
  final WebSocketChannel _channel;
  final StreamController<String> _messages = StreamController.broadcast();

  WebSocketService(String url)
      : _channel = WebSocketChannel.connect(url) {
    // Подписываемся на входящие сообщения
    _channel.stream.listen(
      (data) {
        _messages.add(data);
      },
      onError: (error) {
        print('WebSocket error: $error');
      },
      onDone: () {
        print('WebSocket connection closed');
      },
    );
  }

  // Stream для подписки на сообщения
  Stream<String> get messages => _messages.stream;

  // Отправка сообщения на сервер
  void send(String message) {
    _channel.sink.add(message);
  }

  // Закрытие соединения
  void dispose() {
    _channel.sink.close();
    _messages.close();
  }
}

В этой реализации: — _channel управляет WebSocket соединением — _messages — это broadcast Stream, который позволяет нескольким подписчикам получать сообщения — Метод send отправляет данные на сервер — Метод dispose корректно закрывает соединение и освобождает ресурсы

Обработка сообщений в UI

Теперь посмотрим, как использовать этот сервис в Flutter виджете. StreamBuilder идеально подходит для работы с WebSocket — он автоматически обновляет UI при получении новых сообщений.

class WebSocketChatWidget extends StatefulWidget {
  final String wsUrl;

  const WebSocketChatWidget({required this.wsUrl, super.key});

  @override
  State<WebSocketChatWidget> createState() => _WebSocketChatWidgetState();
}

class _WebSocketChatWidgetState extends State<WebSocketChatWidget> {
  late WebSocketService _ws;
  final TextEditingController _controller = TextEditingController();
  final List<String> _messages = [];

  @override
  void initState() {
    super.initState();
    _ws = WebSocketService(widget.wsUrl);
  }

  @override
  void dispose() {
    _ws.dispose();
    _controller.dispose();
    super.dispose();
  }

  void _sendMessage() {
    if (_controller.text.isNotEmpty) {
      _ws.send(_controller.text);
      _controller.clear();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: StreamBuilder<String>(
            stream: _ws.messages,
            builder: (context, snapshot) {
              if (snapshot.hasData) {
                _messages.add(snapshot.data!);
              }

              if (_messages.isEmpty) {
                return Center(child: Text('Нет сообщений'));
              }

              return ListView.builder(
                itemCount: _messages.length,
                itemBuilder: (context, index) {
                  return ListTile(
                    title: Text(_messages[index]),
                  );
                },
              );
            },
          ),
        ),
        Padding(
          padding: EdgeInsets.all(8),
          child: Row(
            children: [
              Expanded(
                child: TextField(
                  controller: _controller,
                  decoration: InputDecoration(
                    hintText: 'Введите сообщение',
                  ),
                ),
              ),
              SizedBox(width: 8),
              IconButton(
                icon: Icon(Icons.send),
                onPressed: _sendMessage,
              ),
            ],
          ),
        ),
      ],
    );
  }
}

Автоматическое переподключение

В реальных приложениях WebSocket соединение может разорваться из-за нестабильной сети. Нам нужна стратегия автоматического переподключения с экспоненциальным backoff.

Что такое Exponential Backoff?

Exponential backoff — это алгоритм повторных попыток, при котором интервал между попытками увеличивается экспоненциально. Например: 1с, 2с, 4с, 8с, 16с. Это предотвращает перегрузку сервера и даёт время сети восстановиться.

import 'dart:async';
import 'dart:math';
import 'package:web_socket_channel/web_socket_channel.dart';

class ReconnectingWebSocket {
  final String url;
  WebSocketChannel? _channel;
  Timer? _reconnectTimer;
  int _retryCount = 0;

  // Максимальное количество попыток
  static const _maxRetries = 5;

  // Базовая задержка
  static const _baseDelay = Duration(seconds: 1);

  // Максимальная задержка
  static const _maxDelay = Duration(seconds: 30);

  void connect() {
    _channel = WebSocketChannel.connect(url);

    _channel!.stream.listen(
      (data) {
        // Соединение восстановлено, сбрасываем счётчик
        _retryCount = 0;
        print('Received: $data');
      },
      onError: (error) {
        print('WebSocket error: $error');
        _scheduleReconnect();
      },
      onDone: () {
        print('WebSocket closed');
        _scheduleReconnect();
      },
    );
  }

  void _scheduleReconnect() {
    // Отменяем предыдущий таймер, если есть
    _reconnectTimer?.cancel();

    // Вычисляем задержку с экспоненциальным backoff
    final delay = _calculateBackoffDelay();

    print('Reconnecting in ${delay.inSeconds} seconds...');

    _reconnectTimer = Timer(delay, () {
      if (_retryCount < _maxRetries) {
        _retryCount++;
        print('Reconnection attempt $_retryCount');
        connect();
      } else {
        print('Max reconnection attempts reached');
      }
    });
  }

  Duration _calculateBackoffDelay() {
    // Экспоненциальный рост: 1s, 2s, 4s, 8s, 16s...
    final seconds = min(
      pow(2, _retryCount).toInt() * _baseDelay.inSeconds,
      _maxDelay.inSeconds,
    );

    // Добавляем небольшую случайность (jitter)
    final jitter = (seconds * 0.1).toInt();
    final randomSeconds = seconds + (Random().nextInt(jitter * 2) - jitter);

    return Duration(seconds: randomSeconds);
  }

  void send(String message) {
    _channel?.sink.add(message);
  }

  void disconnect() {
    _reconnectTimer?.cancel();
    _channel?.sink.close();
  }
}

Ключевые моменты этой реализации:

  1. Экспоненциальный backoff — задержка растёт экспоненциально (1s → 2s → 4s → 8s)
  2. Максимальная задержка — ограничиваем задержку до 30 секунд
  3. Jitter — добавляем небольшую случайность, чтобы избежать «thundering herd» проблемы
  4. Счётчик попыток — отслеживаем количество попыток и прекращаем после максимума
  5. Сброс счётчика — при успешном соединении сбрасываем счётчик повторных попыток

Heartbeat (Ping/Pong)

Долгое простаивающее соединение может быть разорвано промежуточными прокси-серверами или NAT. Чтобы предотвратить это, мы отправляем периодические ping-сообщения (heartbeat).

import 'dart:async';
import 'package:web_socket_channel/web_socket_channel.dart';

class HeartbeatWebSocket {
  final String url;
  WebSocketChannel? _channel;
  Timer? _heartbeatTimer;
  Timer? _timeoutTimer;

  // Интервал heartbeat (30 секунд)
  static const _heartbeatInterval = Duration(seconds: 30);

  // Таймаут ожидания pong (10 секунд)
  static const _pongTimeout = Duration(seconds: 10);

  bool _waitingForPong = false;

  void connect() {
    _channel = WebSocketChannel.connect(url);

    // Подписываемся на входящие сообщения
    _channel!.stream.listen(
      (data) {
        if (data == 'pong') {
          // Получили ответ на heartbeat
          _waitingForPong = false;
          _timeoutTimer?.cancel();
        } else {
          // Обычное сообщение
          print('Received: $data');
        }
      },
      onError: (error) {
        print('WebSocket error: $error');
        _stopHeartbeat();
      },
      onDone: () {
        print('WebSocket closed');
        _stopHeartbeat();
      },
    );

    // Запускаем heartbeat
    _startHeartbeat();
  }

  void _startHeartbeat() {
    _heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
      _sendPing();
    });
  }

  void _sendPing() {
    if (_waitingForPong) {
      // Не получили pong на предыдущий ping
      print('Pong timeout, reconnecting...');
      _stopHeartbeat();
      _channel?.sink.close();
      connect();
      return;
    }

    print('Sending ping...');
    _channel?.sink.add('ping');
    _waitingForPong = true;

    // Устанавливаем таймаут для ожидания pong
    _timeoutTimer?.cancel();
    _timeoutTimer = Timer(_pongTimeout, () {
      if (_waitingForPong) {
        print('Pong timeout');
        _stopHeartbeat();
        _channel?.sink.close();
        connect();
      }
    });
  }

  void _stopHeartbeat() {
    _heartbeatTimer?.cancel();
    _timeoutTimer?.cancel();
  }

  void send(String message) {
    _channel?.sink.add(message);
  }

  void dispose() {
    _stopHeartbeat();
    _channel?.sink.close();
  }
}

Эта реализация heartbeat:

  1. Отправляет ping каждые 30 секунд для поддержания соединения
  2. Ожидает pong ответ в течение 10 секунд
  3. Переподключается если pong не получен
  4. Останавливает heartbeat при разрыве соединения
  5. Предотвращает дубликаты — не отправляет новый ping пока ожидается pong

Управление состоянием соединения

Для production кода важно отслеживать состояние соединения и уведомлять UI об изменениях.

enum ConnectionState {
  connecting,
  connected,
  disconnected,
  reconnecting,
  error,
}

class ManagedWebSocket {
  final String url;

  WebSocketChannel? _channel;
  final StreamController<ConnectionState> _stateController =
      StreamController.broadcast();
  final StreamController<String> _messageController = StreamController.broadcast();

  Timer? _reconnectTimer;
  int _retryCount = 0;
  static const _maxRetries = 5;

  // Stream для подписки на изменения состояния
  Stream<ConnectionState> get stateStream => _stateController.stream;

  // Stream для получения сообщений
  Stream<String> get messageStream => _messageController.stream;

  // Текущее состояние
  ConnectionState _currentState = ConnectionState.disconnected;

  ConnectionState get currentState => _currentState;

  void connect() {
    if (_currentState == ConnectionState.connecting ||
        _currentState == ConnectionState.connected) {
      return;
    }

    _updateState(ConnectionState.connecting);

    _channel = WebSocketChannel.connect(url);

    _channel!.stream.listen(
      (data) {
        _updateState(ConnectionState.connected);
        _retryCount = 0;
        _messageController.add(data);
      },
      onError: (error) {
        _updateState(ConnectionState.error);
        print('WebSocket error: $error');
        _scheduleReconnect();
      },
      onDone: () {
        _updateState(ConnectionState.disconnected);
        print('WebSocket closed');
        _scheduleReconnect();
      },
    );
  }

  void _updateState(ConnectionState state) {
    _currentState = state;
    _stateController.add(state);
  }

  void _scheduleReconnect() {
    _reconnectTimer?.cancel();

    if (_retryCount >= _maxRetries) {
      _updateState(ConnectionState.error);
      return;
    }

    _updateState(ConnectionState.reconnecting);

    final delay = Duration(seconds: pow(2, _retryCount).toInt());

    _reconnectTimer = Timer(delay, () {
      _retryCount++;
      connect();
    });
  }

  void send(String message) {
    if (_currentState == ConnectionState.connected) {
      _channel?.sink.add(message);
    } else {
      print('Cannot send message: not connected');
    }
  }

  void dispose() {
    _reconnectTimer?.cancel();
    _channel?.sink.close();
    _stateController.close();
    _messageController.close();
  }
}

Оптимизация трафика

Для мобильных приложений важно минимизировать использование трафика. Рассмотрим несколько техник оптимизации.

Сжатие данных

Отправляйте данные в сжатом формате. Вместо JSON используйте MessagePack или Protocol Buffers.

// Вместо JSON
final jsonData = jsonEncode({'type': 'message', 'text': 'Hello'});
// Размер: ~35 байт

// Используйте компактный формат
final compactData = 'MSG|Hello'; // Ваш формат
// Размер: ~8 байт

Батчинг сообщений

Объединяйте несколько сообщений в один пакет:

class MessageBatcher {
  final List<String> _batch = [];
  final WebSocketService _ws;
  Timer? _batchTimer;

  MessageBatcher(this._ws);

  void add(String message) {
    _batch.add(message);

    // Отправляем батч каждые 100мс или когда накоплено 10 сообщений
    if (_batch.length >= 10) {
      _flush();
    } else {
      _scheduleFlush();
    }
  }

  void _scheduleFlush() {
    _batchTimer?.cancel();
    _batchTimer = Timer(Duration(milliseconds: 100), _flush);
  }

  void _flush() {
    if (_batch.isEmpty) return;

    final batch = jsonEncode({'messages': _batch});
    _ws.send(batch);

    _batch.clear();
  }
}

Заключение

WebSockets — это мощный инструмент для создания real-time приложений на Flutter. При реализации WebSocket клиента в production обязательно учитывайте:

  • Автоматическое переподключение с экспоненциальным backoff
  • Heartbeat (ping/pong) для поддержания соединения
  • Управление состоянием соединения и уведомление UI
  • Оптимизацию трафика через сжатие и батчинг
  • Правильную очистку ресурсов в методе dispose

Используйте эти практики для создания надёжных real-time приложений: чатов, онлайн-игр, collaborative tools и других сценариев, где важна мгновенная доставка сообщений.

Рубрики
Flutter

Flutter для Desktop: Полное руководство 2025

Flutter поддерживает десктоп платформы: Windows, macOS и Linux. Разберём особенности разработки десктоп приложений.

Поддерживаемые платформы

Windows

flutter create --platforms windows .
flutter run -d windows

macOS

flutter create --platforms macos .
flutter run -d macos

Linux

flutter create --platforms linux .
flutter run -d linux

Требования

Windows

  • Windows 10 или выше
  • Visual Studio 2022 с C++ workload
  • Windows 10 SDK

macOS

  • macOS 10.14 или выше
  • Xcode 13 или выше
  • CocoaPods

Linux

  • Ubuntu 18.04 или выше (или эквивалент)
  • clang, cmake, ninja-build, pkg-config
  • GTK development headers

Desktop специфичные виджеты

MenuBar

import 'package:flutter/material.dart';
import 'package:submenu/submenu.dart';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return PlatformMenuBar(
      menus: [
        PlatformMenu(
          label: 'File',
          menus: [
            PlatformMenuItem(
              label: 'New',
              onSelected: () => _createNewFile(),
              shortcut: const SingleActivator(
                LogicalKeyboardKey.keyN,
                control: true,
              ),
            ),
            PlatformMenuItem(
              label: 'Open',
              onSelected: () => _openFile(),
              shortcut: const SingleActivator(
                LogicalKeyboardKey.keyO,
                control: true,
              ),
            ),
            PlatformMenuDivider(),
            PlatformMenuItemGroup(
              members: [
                PlatformMenuItem(
                  label: 'Recent',
                  isEnabled: false,
                ),
              ],
            ),
          ],
        ),
        PlatformMenu(
          label: 'Edit',
          menus: [
            PlatformMenuItem(
              label: 'Undo',
              onSelected: () => _undo(),
            ),
            PlatformMenuItem(
              label: 'Redo',
              onSelected: () => _redo(),
            ),
          ],
        ),
      ],
      child: MaterialApp(
        home: HomeScreen(),
      ),
    );
  }
}

Window Controls

import 'package:bitsdojo_window/bitsdojo_window.dart';

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

  // Настройка окна
  doWhenWindowReady(() {
    final win = appWindow;

    const initialSize = Size(800, 600);
    win.minSize = initialSize;
    win.size = initialSize;
    win.alignment = Alignment.center;
    win.title = 'My Flutter App';
    win.show();
  });
}

// Кастомный title bar
class CustomTitleBar extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return WindowTitleBarBox(
      child: Row(
        children: [
          Expanded(child: MoveWindow()),
          const WindowButtons(),
        ],
      ),
    );
  }
}

Tray Icon

dependencies:
  system_tray: ^2.0.0
import 'package:system_tray/system_tray.dart';

class TrayManager {
  final SystemTray _systemTray = SystemTray();

  Future<void> initTray() async {
    await _systemTray.initSystemTray(
      iconPath: 'assets/tray_icon.png',
    );

    final menu = [
      MenuItem(label: 'Show', onClicked: _showWindow),
      MenuItem(label: 'Hide', onClicked: _hideWindow),
      MenuItem.separator(),
      MenuItem(label: 'Exit', onClicked: _exitApp),
    ];

    await _systemTray.setContextMenu(menu);
  }

  void _showWindow() {}
  void _hideWindow() {}
  void _exitApp() {}
}

File System

File Picker

dependencies:
  file_picker: ^8.0.0
import 'package:file_picker/file_picker.dart';

Future<void> pickFile() async {
  final result = await FilePicker.platform.pickFiles(
    type: FileType.custom,
    allowedExtensions: ['jpg', 'png', 'pdf'],
    allowMultiple: false,
  );

  if (result != null) {
    final file = result.files.single;

    print('File path: ${file.path}');
    print('File name: ${file.name}');
    print('File size: ${file.size}');
  }
}

Future<void> pickDirectory() async {
  final directory = await FilePicker.platform.getDirectoryPath();

  if (directory != null) {
    print('Selected directory: $directory');
  }
}

Сохранение файлов

Future<void> saveFile(String content) async {
  final outputPath = await FilePicker.platform.saveFile(
    dialogTitle: 'Save file',
    fileName: 'document.txt',
    type: FileType.custom,
    allowedExtensions: ['txt'],
  );

  if (outputPath != null) {
    final file = File(outputPath);
    await file.writeAsString(content);
  }
}

Drag and Drop

dependencies:
  desktop_drop: ^0.5.0
import 'package:desktop_drop/desktop_drop.dart';

class DropZone extends StatefulWidget {
  @override
  State<DropZone> createState() => _DropZoneState();
}

class _DropZoneState extends State<DropZone> {
  final List<String> _droppedFiles = [];

  @override
  Widget build(BuildContext context) {
    return DropTarget(
      onDragEntered: (details) {
        setState(() {});
      },
      onDragExited: (details) {
        setState(() {});
      },
      onDragDone: (details) {
        setState(() {
          _droppedFiles.addAll(
            details.files.map((e) => e.path),
          );
        });
      },
      child: Container(
        color: _droppedFiles.isEmpty ? Colors.grey[200] : Colors.green[200],
        child: Center(
          child: Column(
            children: [
              Text('Drop files here'),
              ..._droppedFiles.map((path) => Text(path)),
            ],
          ),
        ),
      ),
    );
  }
}

Горячие клавиши

RawKeyboardListener

class KeyboardHandler extends StatefulWidget {
  @override
  State<KeyboardHandler> createState() => _KeyboardHandlerState();
}

class _KeyboardHandlerState extends State<KeyboardHandler> {
  final FocusNode _focusNode = FocusNode();

  @override
  Widget build(BuildContext context) {
    return RawKeyboardListener(
      focusNode: _focusNode,
      autofocus: true,
      onKey: (event) {
        if (event is RawKeyDownEvent) {
          // Ctrl+S для сохранения
          if (event.logicalKey == LogicalKeyboardKey.keyS &&
              event.data.isControlPressed) {
            _saveFile();
          }

          // Ctrl+N для нового файла
          if (event.logicalKey == LogicalKeyboardKey.keyN &&
              event.data.isControlPressed) {
            _newFile();
          }
        }
      },
      child: Scaffold(
        body: Center(child: Text('Press Ctrl+S to save')),
      ),
    );
  }
}

Shortcuts Actions

class ShortcutApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Shortcuts(
      shortcuts: <LogicalKeySet, Intent>{
        LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
            const SaveIntent(),
        LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyN):
            const NewIntent(),
      },
      child: Actions(
        actions: <Type, Action<Intent>>{
          SaveIntent: CallbackAction<SaveIntent>(
            onInvoke: (intent) => _saveFile(),
          ),
          NewIntent: CallbackAction<NewIntent>(
            onInvoke: (intent) => _newFile(),
          ),
        },
        child: Focus(
          autofocus: true,
          child: MaterialApp(
            home: HomeScreen(),
          ),
        ),
      ),
    );
  }
}

class SaveIntent extends Intent {
  const SaveIntent();
}

class NewIntent extends Intent {
  const NewIntent();
}

Window Manager

Размер окна

import 'dart:io';

class WindowManager {
  Future<void> setWindowSize(double width, double height) async {
    if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
      // Используйте bitsdojo_window или window_manager
    }
  }
}

Полноэкранный режим

dependencies:
  window_manager: ^0.3.0
import 'package:window_manager/window_manager.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await windowManager.ensureInitialized();

  WindowOptions windowOptions = const WindowOptions(
    size: Size(800, 600),
    center: true,
    backgroundColor: Colors.transparent,
    skipTaskbar: false,
    titleBarStyle: TitleBarStyle.normal,
  );

  await windowManager.waitUntilReadyToShow(windowOptions, () async {
    await windowManager.show();
    await windowManager.focus();
  });

  runApp(MyApp());
}

// Полноэкранный режим
Future<void> toggleFullScreen() async {
  final isFullScreen = await windowManager.isFullScreen();
  if (isFullScreen) {
    await windowManager.setFullScreen(false);
  } else {
    await windowManager.setFullScreen(true);
  }
}

Desktop особенности

Права доступа

# macos/Runner/DebugProfile.entitlements
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>

Auto-start

dependencies:
  launch_at_startup: ^0.3.0
import 'package:launch_at_startup/launch_at_startup.dart';

Future<void> setupAutoStart() async {
  await LaunchAtStartup.instance.enable();
}

Уведомления

dependencies:
  local_notifier: ^0.2.0
import 'package:local_notifier/local_notifier.dart';

Future<void> showNotification() async {
  final localNotification = LocalNotification(
    title: 'My App',
    body: 'Notification body',
  );

  await localNotification.show();
}

Заключение

Flutter Desktop — отличный выбор для кроссплатформенных десктоп приложений. Учитывайте особенности каждой платформы при разработке.

Рубрики
Flutter

Отладка Flutter приложений: Полное руководство 2025

Отладка — важная часть разработки. Разберём инструменты и техники для эффективной отладки Flutter приложений.

Dart DevTools

Запуск DevTools

# Запуск через CLI
flutter pub global run devtools

# Или при запуске приложения
flutter run --profile

Основные функции DevTools

  • Flutter Inspector — исследование виджетов
  • Performance — анализ производительности
  • Memory — профилирование памяти
  • Network — анализ сетевых запросов
  • Logging — просмотр логов

Flutter Inspector

Tree view

<MyApp>
  <MaterialApp>
    <Scaffold>
      <CustomScrollView>
        <SliverAppBar>
        <ListView>

Widget select

// Используйте ключи для идентификации виджетов
Scaffold(
  key: Key('home-scaffold'),
  appBar: AppBar(title: Text('Home')),
)

Property Inspector

Просмотр свойств виджета в реальном времени.

Breakpoints

Установка breakpoints

В VS Code / Android Studio:

void main() {
  runApp(MyApp()); // Кликните слева для breakpoint
}

Conditional breakpoints

int counter = 0;

for (int i = 0; i < 100; i++) {
  counter += i; // Breakpoint когда i == 50
}

Logpoint

Вместо breakpoint добавьте лог:

counter += i; // Logpoint: print('Counter: $counter')

Debugging print statements

Print

print('Simple debug message');

// С переменными
final user = User(name: 'John');
print('User: ${user.name}');

debugPrint

// Автоматически разбивает длинные строки
debugPrint('Very long string that will be split...');

Flutter DevTools logging

import 'package:flutter/foundation.dart';

void main() {
  // Различные уровни логирования
  debugPrint('Info message');
  // Или использовать foundation
}

structured logging

Logger пакет

dependencies:
  logger: ^2.0.0
import 'package:logger/logger.dart';

final logger = Logger();

void main() {
  logger.d('Debug message');
  logger.i('Info message');
  logger.w('Warning message');
  logger.e('Error message');

  // С данными
  logger.i({'user': 'John', 'action': 'login'});

  // Исключения
  try {
    throw Exception('Something went wrong');
  } catch (e) {
    logger.e('Error occurred', e);
  }
}

Custom logger

class AppLogger {
  static const _isDebugMode = kDebugMode;

  static void debug(String message) {
    if (_isDebugMode) {
      print('[DEBUG] $message');
    }
  }

  static void info(String message) {
    if (_isDebugMode) {
      print('[INFO] $message');
    }
  }

  static void warning(String message) {
    print('[WARNING] $message');
  }

  static void error(String message, {Object? error, StackTrace? stackTrace}) {
    print('[ERROR] $message');
    if (error != null) {
      print(error);
    }
    if (stackTrace != null) {
      print(stackTrace);
    }
  }
}

Error handling

Try-catch

Future<void> fetchData() async {
  try {
    final response = await http.get(Uri.parse('https://api.example.com'));
    // Обработка ответа
  } on SocketException {
    print('No internet connection');
  } on HttpException {
    print('HTTP error');
  } catch (e, stackTrace) {
    print('Unexpected error: $e');
    print(stackTrace);
  }
}

Custom exceptions

class AppException implements Exception {
  final String message;
  final int? code;

  AppException(this.message, {this.code});

  @override
  String toString() => 'AppException: $message (code: $code)';
}

// Использование
try {
  // dangerous operation
} on AppException catch (e) {
  print('Handled app exception: $e');
} catch (e) {
  print('Unexpected error: $e');
}

ErrorWidget

class MyErrorWidget extends StatelessWidget {
  final FlutterErrorDetails details;

  const MyErrorWidget(this.details, {super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error, size: 48, color: Colors.red),
            SizedBox(height: 16),
            Text('Error occurred'),
            SizedBox(height: 8),
            Text(
              details.exception.toString(),
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ],
        ),
      ),
    );
  }
}

void main() {
  ErrorWidget.builder = (details) {
    return MyErrorWidget(details);
  };

  runApp(MyApp());
}

Crash reporting

Sentry

dependencies:
  sentry_flutter: ^8.0.0
import 'package:sentry_flutter/sentry_flutter.dart';

Future<void> main() async {
  await SentryFlutter.init(
    (options) {
      options.dsn = 'YOUR_SENTRY_DSN';
      options.tracesSampleRate = 1.0;
    },
  );

  runApp(MyApp());
}

// Отправка исключений
try {
  // dangerous operation
} catch (exception, stackTrace) {
  await Sentry.captureException(
    exception,
    stackTrace: stackTrace,
  );
}

// Отправка сообщений
await Sentry.captureMessage('Something happened');

// Breadcrumbs
Sentry.addBreadcrumb(
  Breadcrumb(
    message: 'User clicked button',
    category: 'user',
  ),
);

Firebase Crashlytics

dependencies:
  firebase_crashlytics: ^4.0.0
import 'package:firebase_crashlytics/firebase_crashlytics.dart';

Future<void> main() async {
  await Firebase.initializeApp();

  // Установка Crashlytics
  FlutterError.onError = (errorDetails) {
    FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
  };

  runApp(MyApp());
}

// Запись исключения
try {
  // dangerous operation
} catch (e, stack) {
  await FirebaseCrashlytics.instance.recordError(e, stack);
}

// Пользовательские ключи
await FirebaseCrashlytics.instance.setCustomKey('user_id', '123');

// Пользовательские логи
await FirebaseCrashlytics.instance.log('App started');

Performance profiling

Performance overlay

MaterialApp(
  showPerformanceOverlay: true,
  home: MyApp(),
)

Timeline

import 'package:flutter/foundation.dart';

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

  if (kDebugMode) {
    // Профилирование включено
  }
}

Dart DevTools Performance

  1. Запустите приложение в profile режиме
  2. Откройте DevTools
  3. Перейдите в Performance tab
  4. Запишите профиль
  5. Проанализируйте результаты

Network debugging

Dio logging interceptor

dependencies:
  pretty_dio_logger: ^1.3.0
final dio = Dio();

dio.interceptors.add(
  PrettyDioLogger(
    requestHeader: true,
    requestBody: true,
    responseBody: true,
    error: true,
  ),
);

Charles Proxy

Для локального перехвата HTTPS запросов настройте Charles Proxy.

Remote debugging

Flutter Remote Debugging

flutter attach --debug-url=http://localhost:8080

Debugging в production

Remote logging

class RemoteLogger {
  Future<void> log(String message) async {
    await http.post(
      Uri.parse('https://your-api.com/logs'),
      body: {
        'message': message,
        'timestamp': DateTime.now().toIso8601String(),
        'platform': Platform.operatingSystem,
      },
    );
  }
}

Flag для отключения

class Config {
  static const bool enableDebug = kDebugMode;
  static const bool enableRemoteLogging = !kDebugMode;
}

Best Practices

1. Используйте structured logging

logger.i('User logged in', error: null, stackTrace: null);

2. Добавляйте контекст

try {
  // operation
} catch (e, stackTrace) {
  logger.e('Failed to load user data',
    error: e,
    stackTrace: stackTrace,
  );
}

3. Используйте breadcrumbs

Sentry.addBreadcrumb(
  Breadcrumb(message: 'Navigated to home screen'),
);

4. Логируйте важные события

await FirebaseCrashlytics.instance.log('Purchase completed: $orderId');

Заключение

Отладка Flutter приложений в 2025 — это мощный набор инструментов. Используйте DevTools для профилирования и Sentry/Crashlytics для мониторинга production.

Рубрики
Flutter

Flutter для Web: Полное руководство 2025

Flutter Web позволяет создавать веб-приложения на Dart. Разберём особенности разработки для Web.

CanvasKit vs HTML

CanvasKit

Рендер через WebGL:

  • Лучшая производительность
  • Одинаковое отображение на всех платформах
  • Больший размер бандла (~2MB)

HTML (HTML renderer)

Рендер через HTML/CSS:

  • Меньший размер бандла
  • Лучше SEO
  • Может отличаться от мобильной версии

Выбор рендерера

// HTML renderer для лучшего SEO
flutter build web --web-renderer html

// CanvasKit для лучшей производительности
flutter build web --web-renderer canvaskit

// Auto (по умолчанию)
flutter build web --web-renderer auto

SEO оптимизация

Meta теги

// web/index.html
<head>
  <meta name="description" content="Описание вашего приложения">

  <!-- Open Graph -->
  <meta property="og:title" content="Заголовок">
  <meta property="og:description" content="Описание">
  <meta property="og:image" content="https://example.com/image.jpg">

  <!-- Twitter Card -->
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="Заголовок">
  <meta name="twitter:description" content="Описание">
</head>

Динамические meta теги

dependencies:
  flutter_seo: ^1.0.0
import 'package:flutter_seo/flutter_seo.dart';

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Seo.widget(
      tree: WidgetTree(text: 'Содержимое страницы'),
      // Или вручную
      title: 'Заголовок страницы',
      description: 'Описание страницы',
      keywords: ['flutter', 'web', 'seo'],
      child: Scaffold(...),
    );
  }
}

SSR (Server-Side Rendering)

dependencies:
  flutter_web_plugins: ^0.0.0

Для SSR используйте Dart Frog или другие серверные решения.

PWA (Progressive Web App)

Manifest

// web/manifest.json
{
  "name": "My Flutter App",
  "short_name": "FlutterApp",
  "description": "My Flutter PWA",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#0175C2",
  "icons": [
    {
      "src": "icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

Подключение manifest

<!-- web/index.html -->
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#0175C2">

Service Worker

// web/service_worker.js
const CACHE_NAME = 'flutter-app-cache-v1';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll([
        '/',
        '/main.dart.js',
        '/assets/',
      ]);
    }),
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    }),
  );
});

Регистрация Service Worker

<script>
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('service_worker.js');
  });
}
</script>

Оптимизация бандла

Deferred loading

// Отложенная загрузка тяжелых компонентов
import 'package:flutter/material.dart' deferred as ui;

Future<void> loadHeavyComponent() async {
  await ui.loadLibrary();
  // Теперь можно использовать ui
}

Анализ размера бандла

flutter build web --web-renderer canvaskit --analyze-size

Tree shaking

Убедитесь, что unused код удаляется:

// Используйте только нужные импорты
import 'package:flutter/material.dart'; // Хорошо
import 'package:flutter/material.dart' hide Router; // Если нужно исключить

URL Routing

go_router для Web

dependencies:
  go_router: ^14.0.0
final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => HomeScreen(),
    ),
    GoRoute(
      path: '/user/:id',
      builder: (context, state) {
        final id = state.pathParameters['id'];
        return UserScreen(id: id);
      },
    ),
  ],
);

void main() {
  runApp(MaterialApp.router(routerConfig: router));
}

Query параметры

GoRoute(
  path: '/search',
  builder: (context, state) {
    final query = state.uri.queryParameters['q'];
    return SearchScreen(query: query);
  },
)

Firebase Hosting

Установка Flutter CLI

dart pub global activate flutter_cli

Сборка для production

flutter build web --release

Firebase CLI

npm install -g firebase-tools
firebase login

Инициализация

firebase init hosting

firebase.json

{
  "hosting": {
    "public": "build/web",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ],
    "headers": [
      {
        "source": "**",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "max-age=1800"
          }
        ]
      }
    ]
  }
}

Deploy

firebase deploy

Vercel

vercel.json

{
  "rewrites": [
    {
      "source": "/(.*)",
      "destination": "/index.html"
    }
  ],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ]
}

Deploy

vercel --prod

Web специфичные API

Обработка URL

import 'dart:html' as html;

class UrlHandler {
  void openUrl(String url) {
    html.window.open(url, '_blank');
  }

  String get currentUrl => html.window.location.href;

  void updateUrl(String path) {
    html.window.history.pushState(null, '', path);
  }
}

LocalStorage

import 'dart:html' as html;

class WebStorage {
  void setString(String key, String value) {
    html.window.localStorage[key] = value;
  }

  String? getString(String key) {
    return html.window.localStorage[key];
  }

  void remove(String key) {
    html.window.localStorage.remove(key);
  }
}

Clipboard

import 'dart:html' as html;

class ClipboardService {
  Future<void> copyText(String text) async {
    await html.window.navigator.clipboard?.writeText(text);
  }
}

Hot Reload

При разработке

flutter run -d chrome

Auto refresh

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

  // Hot reload при изменениях файлов
  if (kDebugMode) {
    // Debug only code
  }
}

Заключение

Flutter Web в 2025 — это production-ready решение для создания веб-приложений. Используйте HTML renderer для SEO и CanvasKit для производительности.

Рубрики
Flutter

REST API во Flutter: Полное руководство 2025

Большинство Flutter приложений работают с сервером через REST API. Разберём лучшие практики работы с API.

HTTP пакет

Базовый запрос

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

Future<void> fetchData() async {
  final response = await http.get(
    Uri.parse('https://api.example.com/users'),
  );

  if (response.statusCode == 200) {
    final data = json.decode(response.body);
    print(data);
  }
}

POST запрос

Future<void> createUser(String name, String email) async {
  final response = await http.post(
    Uri.parse('https://api.example.com/users'),
    headers: {
      'Content-Type': 'application/json',
    },
    body: json.encode({
      'name': name,
      'email': email,
    }),
  );

  if (response.statusCode == 201) {
    print('User created');
  }
}

PUT и DELETE

Future<void> updateUser(String id, String name) async {
  final response = await http.put(
    Uri.parse('https://api.example.com/users/$id'),
    headers: {'Content-Type': 'application/json'},
    body: json.encode({'name': name}),
  );
}

Future<void> deleteUser(String id) async {
  final response = await http.delete(
    Uri.parse('https://api.example.com/users/$id'),
  );
}

Dio пакет

Установка

dependencies:
  dio: ^5.4.0

Базовая настройка

import 'package:dio/dio.dart';

class ApiService {
  late final Dio _dio;

  ApiService() {
    _dio = Dio(
      BaseOptions(
        baseUrl: 'https://api.example.com',
        connectTimeout: Duration(seconds: 5),
        receiveTimeout: Duration(seconds: 3),
      ),
    );

    _setupInterceptors();
  }

  void _setupInterceptors() {
    _dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) {
          // Добавить токен
          final token = storage.getToken();
          if (token != null) {
            options.headers['Authorization'] = 'Bearer $token';
          }
          return handler.next(options);
        },
        onResponse: (response, handler) {
          return handler.next(response);
        },
        onError: (error, handler) {
          // Обработка ошибок
          if (error.response?.statusCode == 401) {
            // Обновить токен
          }
          return handler.next(error);
        },
      ),
    );
  }
}

GET запросы

Future<List<User>> getUsers() async {
  try {
    final response = await _dio.get('/users');

    final List<dynamic> data = response.data;

    return data.map((json) => User.fromJson(json)).toList();
  } catch (e) {
    throw Exception('Failed to load users: $e');
  }
}

Future<User?> getUser(String id) async {
  try {
    final response = await _dio.get('/users/$id');

    return User.fromJson(response.data);
  } catch (e) {
    return null;
  }
}

POST запрос

Future<User> createUser(CreateUserRequest request) async {
  try {
    final response = await _dio.post(
      '/users',
      data: request.toJson(),
    );

    return User.fromJson(response.data);
  } catch (e) {
    throw Exception('Failed to create user: $e');
  }
}

Загрузка файлов

Future<String> uploadFile(File file) async {
  try {
    final formData = FormData.fromMap({
      'file': await MultipartFile.fromFile(file.path),
    });

    final response = await _dio.post(
      '/upload',
      data: formData,
    );

    return response.data['url'];
  } catch (e) {
    throw Exception('Failed to upload file: $e');
  }
}

Download файлов

Future<void> downloadFile(String url, String savePath) async {
  try {
    await _dio.download(
      url,
      savePath,
      onReceiveProgress: (received, total) {
        final progress = (received / total) * 100;
        print('Downloading: $progress%');
      },
    );
  } catch (e) {
    throw Exception('Failed to download: $e');
  }
}

Retrofit

Установка

dependencies:
  retrofit: ^4.0.0
  json_annotation: ^4.9.0
dev_dependencies:
  retrofit_generator: ^8.0.0
  build_runner: ^2.4.0
  json_serializable: ^6.8.0

API интерфейс

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

part 'api_service.g.dart';

@RestApi(baseUrl: 'https://api.example.com')
abstract class ApiService {
  factory ApiService(Dio dio) = _ApiService;

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

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

  @POST('/users')
  Future<User> createUser(@Body() CreateUserRequest request);

  @PUT('/users/{id}')
  Future<User> updateUser(
    @Path('id') String id,
    @Body() UpdateUserRequest request,
  );

  @DELETE('/users/{id}')
  Future<void> deleteUser(@Path('id') String id);

  @POST('/upload')
  Future<String> uploadFile(@Part() File file);

  @GET('/search')
  Future<List<Item>> searchItems(
    @Query('q') String query,
    @Query('page') int page,
    @Query('limit') int limit,
  );
}

Использование

final dio = Dio();
final apiService = ApiService(dio);

final users = await apiService.getUsers();
final user = await apiService.getUser('123');
final newUser = await apiService.createUser(
  CreateUserRequest(name: 'John', email: 'john@example.com'),
);

Error Handling

Custom exceptions

abstract class AppException implements Exception {
  final String message;
  final int? statusCode;

  AppException(this.message, {this.statusCode});

  @override
  String toString() => message;
}

class NetworkException extends AppException {
  NetworkException(String message) : super(message);
}

class ServerException extends AppException {
  ServerException(String message, {int? statusCode})
      : super(message, statusCode: statusCode);
}

class AuthException extends AppException {
  AuthException(String message) : super(message);
}

class NotFoundException extends AppException {
  NotFoundException(String message) : super(message, statusCode: 404);
}

Error handler

class ErrorHandler {
  static AppException handleError(dynamic error) {
    if (error is DioException) {
      switch (error.type) {
        case DioExceptionType.connectionTimeout:
        case DioExceptionType.sendTimeout:
        case DioExceptionType.receiveTimeout:
          return NetworkException('Connection timeout');

        case DioExceptionType.badResponse:
          final statusCode = error.response?.statusCode;

          switch (statusCode) {
            case 401:
              return AuthException('Unauthorized');
            case 404:
              return NotFoundException('Resource not found');
            case 500:
              return ServerException('Internal server error', statusCode: 500);
            default:
              return ServerException(
                'Request failed with status $statusCode',
                statusCode: statusCode,
              );
          }

        case DioExceptionType.cancel:
          return NetworkException('Request cancelled');

        case DioExceptionType.connectionError:
          return NetworkException('No internet connection');

        default:
          return AppException('Unexpected error occurred');
      }
    }

    return AppException(error.toString());
  }
}

Caching

HTTP Cache

dependencies:
  dio_cache_interceptor: ^3.4.0
final cacheOptions = CacheOptions(
  store: MemCacheStore(),
  policy: CachePolicy.request,
  hitCacheOnErrorExcept: [401, 403],
  maxStale: Duration(days: 7),
);

final dio = Dio();
dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));

Локальное кэширование

class CachedApiService {
  final ApiService _api;
  final Map<String, CachedData> _cache = {};

  CachedApiService(this._api);

  Future<T> get<T>(
    String key,
    Future<T> Function() fetch, {
    Duration duration = const Duration(minutes: 5),
  }) async {
    // Проверка кэша
    if (_cache.containsKey(key)) {
      final cached = _cache[key]!;

      if (!cached.isExpired) {
        return cached.data as T;
      }

      _cache.remove(key);
    }

    // Загрузка данных
    final data = await fetch();

    // Сохранение в кэш
    _cache[key] = CachedData(
      data: data,
      expiry: DateTime.now().add(duration),
    );

    return data;
  }

  void invalidate(String key) {
    _cache.remove(key);
  }

  void clear() {
    _cache.clear();
  }
}

class CachedData<T> {
  final T data;
  final DateTime expiry;

  CachedData({required this.data, required this.expiry});

  bool get isExpired => DateTime.now().isAfter(expiry);
}

Repository Pattern

abstract class UserRepository {
  Future<List<User>> getUsers();
  Future<User?> getUser(String id);
  Future<User> createUser(CreateUserRequest request);
}

class UserRepositoryImpl implements UserRepository {
  final ApiService _api;
  final CacheService _cache;

  UserRepositoryImpl(this._api, this._cache);

  @override
  Future<List<User>> getUsers() async {
    try {
      return await _api.getUsers();
    } catch (e) {
      throw ErrorHandler.handleError(e);
    }
  }

  @override
  Future<User?> getUser(String id) async {
    try {
      return await _cache.get(
        'user_$id',
        () => _api.getUser(id),
        duration: Duration(minutes: 10),
      );
    } catch (e) {
      throw ErrorHandler.handleError(e);
    }
  }

  @override
  Future<User> createUser(CreateUserRequest request) async {
    try {
      final user = await _api.createUser(request);

      // Инвалидация кэша
      _cache.clear();

      return user;
    } catch (e) {
      throw ErrorHandler.handleError(e);
    }
  }
}

Заключение

Работа с REST API во Flutter — это важная часть разработки. Используйте Dio для сложных сценариев и Retrofit для типобезопасности.