Dart, Flutter и Riverpod для desktop-приложения управления xray-core

Курс последовательно проводит от уверенного владения Dart и Flutter к проектированию и разработке профессионального desktop-приложения с Riverpod, интеграцией xray-core и современным UI. Программа охватывает язык, архитектуру, асинхронность, навигацию, desktop-возможности, сетевое взаимодействие, сериализацию, управление процессами, безопасность, производительность и автоматическую сборку релизов.

1. Введение в Dart: типы данных, переменные и null safety

Введение в Dart: типы данных, переменные и null safety

Почему один и тот же код на вид «почти правильный» может спокойно работать на JavaScript, но ломаться в Dart ещё до запуска? Именно за это Dart любят в больших приложениях: язык старается поймать ошибки раньше, чем они превратятся в сломанный интерфейс, потерянные настройки или внезапно неработающий запуск xray-core у пользователя.

Когда вы пишете desktop-приложение для управления сетевым процессом, ошибки в данных стоят дороже, чем в учебных примерах. Если порт пришёл строкой вместо числа, если путь к конфигу неожиданно оказался null, если вы думали, что токен есть всегда, а он не сохранился в настройках, приложение может не просто показать неверный текст, а не запустить основной процесс. Именно поэтому знакомство с Dart нужно начинать не с «синтаксиса вообще», а с того, как язык относится к данным и их отсутствию.

Вы наверняка уже замечали в других языках, что переменная как будто «может быть чем угодно», пока это не приводит к хаосу. Dart устроен строже: он хочет, чтобы вы заранее понимали, какие данные где живут и могут ли они отсутствовать. Это снижает количество случайных ошибок ещё на этапе написания кода.

Как Dart думает о данных

Тип данных — это договор между вами и компилятором о том, что именно лежит в переменной и какие операции с этим значением допустимы. Простыми словами: если в переменной число, вы можете складывать его с другими числами; если строка, вы работаете с текстом; если логическое значение, там всего два состояния — истина или ложь. Это важно, потому что компилятор помогает не смешивать несмешиваемое. В реальном приложении это спасает, когда, например, порт сервера должен быть числом 1080, а не строкой "1080".

В Dart есть несколько базовых типов, с которых начинается почти любой код:

  • int — целое число
  • double — число с дробной частью
  • String — строка
  • bool — логическое значение true или false
  • dynamic — значение без жёсткой проверки типа на этапе компиляции
  • Object и Object? — базовые верхнеуровневые типы
  • Микропример: если вы храните количество попыток переподключения, это обычно int. Если храните задержку в секундах 1.5, нужен double. Если храните адрес сервера 127.0.0.1, это String.

    Dart различает целые и дробные числа специально. В учебном коде это может казаться мелочью, но в сетевых и desktop-приложениях точный выбор типа упрощает чтение кода. Значение timeoutSeconds = 5 ясно воспринимается как целое количество секунд, а animationScale = 0.85 — как дробный коэффициент.

    Здесь полезно увидеть не только синтаксис, но и смысл. Переменная port объявлена как int, поэтому туда нельзя присвоить строку. Переменная isRunning объявлена как bool, и это делает состояние процесса явным: он либо запущен, либо нет. Такой код проще поддерживать, чем набор переменных с расплывчатым назначением.

    Переменные: почему var удобно, но не магия

    Переменная — это именованная область памяти, где хранится значение. На практике это просто «контейнер с подписью». Но важен не только сам контейнер, а то, можно ли потом заменить его содержимое и как компилятор поймёт его тип.

    В Dart вы можете объявлять переменные явно или с выводом типа:

    Во втором случае вывод типа означает, что Dart сам определяет тип по начальному значению. Если вы написали var retries = 3;, компилятор понимает, что это int. Это не значит, что тип «плавающий». Наоборот: после инициализации retries остаётся целым числом.

    Микропример: если вы написали var port = 1080;, позже нельзя сделать port = '1080';. var экономит время на записи, но не отключает типизацию.

    Именно здесь новички часто путают Dart с динамическими языками. var — это не «любой тип», а «выведи тип один раз и дальше соблюдай его». Это хороший выбор, когда тип и так очевиден из правой части выражения.

    Но есть три похожих слова, которые нужно чётко различать: var, final и const.

  • var — переменную можно переназначать
  • final — значение задаётся один раз во время выполнения
  • const — значение известно на этапе компиляции и является константой компиляции
  • Микропример: адрес, введённый пользователем в поле настроек, может храниться в var или чаще в состоянии объекта. Путь к домашней директории, вычисленный один раз при старте, удобно положить в final. Цвет интерфейса Color(0xFF6750A4) в фиксированной теме может быть const.

    !Сравнение var, final, const и nullable типов

    Разница между final и const сначала кажется искусственной, но в Flutter она очень практична. final подходит для значений, которые вы узнаёте только в момент запуска: например, путь к файлу конфигурации из системной папки пользователя. const работает для того, что никогда не меняется и известно заранее: текстовые метки, числа, некоторые виджеты и значения конфигурации сборки.

    Почему строку с путём здесь лучше не делать const? Потому что в реальном приложении путь часто зависит от машины, пользователя, окружения или платформы. Компилятор не может знать его заранее.

    Что такое dynamic и почему он опаснее, чем кажется

    dynamic — это специальный тип, который отключает большую часть проверок на этапе компиляции. Простыми словами: вы говорите Dart «поверь мне, я сам разберусь». Это удобно для быстрых экспериментов, но опасно в большом проекте.

    На первый взгляд гибко. На практике код с dynamic хуже читается и чаще ломается в неожиданных местах. Когда приложение получает JSON с сервера или читает внешние данные, соблазн использовать dynamic особенно велик. Но затем ошибка проявляется глубоко в UI или логике запуска процесса.

    Микропример: если API вернул "enabled": "true" строкой, а вы ожидали bool, dynamic позволит донести эту проблему далеко по коду. Строгий тип помог бы поймать несовпадение раньше.

    Профессиональная привычка в Dart — использовать dynamic только там, где это действительно оправдано, и как можно быстрее преобразовывать данные в точные типы.

    Null safety: главная защита от «значения нет»

    Самая важная идея раннего Dart для практической разработки — null safety. Это механизм, который заставляет явно обозначать, может ли переменная не иметь значения. Простыми словами: Dart больше не считает, что null можно незаметно подложить куда угодно.

    Это критично для desktop-приложения. У вас могут отсутствовать:

  • путь к конфигу при первом запуске
  • токен авторизации
  • выбранный сервер
  • сохранённые настройки в shared_preferences
  • активный процесс xray-core
  • Если код делает вид, что эти значения существуют всегда, вы получаете падения в неожиданных местах.

    В Dart типы по умолчанию non-nullable, то есть не допускают null.

    Если значение может отсутствовать, это нужно указать знаком ?:

    Nullable-тип — это тип, который может хранить либо обычное значение, либо null. Зачем это знать: так вы прямо в сигнатуре показываете, что отсутствие значения — нормальный сценарий, а не авария. В жизни это выглядит так: пока пользователь не выбрал конфиг, selectedConfigPath равен null, и UI показывает кнопку «Выбрать файл».

    Здесь важно не просто знать синтаксис, а почувствовать дисциплину мышления. Если написано String, значение обязано быть. Если String?, отсутствие допустимо, и вы обязаны это обработать.

    Как работать с nullable-значениями безопасно

    Как только появляется String?, вы уже не можете обращаться к нему так же свободно, как к обычной строке. Dart требует сначала доказать, что значение не null.

    Это ограничение не мешает, а защищает. Оно заставляет вас продумать ветки поведения.

    Есть несколько основных способов работы с nullable-типами.

    Проверка через if

    Самый прямой и часто самый читаемый путь:

    После такой проверки Dart понимает, что внутри блока configPath уже не null.

    Микропример: если путь к конфигу ещё не выбран, вы не пытаетесь запускать процесс, а вместо этого показываете пользователю уведомление.

    Оператор ?.

    Null-aware access позволяет безопасно обратиться к полю или методу, только если объект не null.

    Если configPath равен null, всё выражение тоже даст null, а не ошибку.

    Микропример: для необязательного описания профиля profileDescription?.trim() безопаснее, чем прямой вызов trim().

    Оператор ??

    Null-coalescing operator позволяет задать значение по умолчанию.

    Если profileName равен null, используется строка 'Без имени'.

    Микропример: в списке профилей пустое имя пользователя не ломает интерфейс — вместо этого показывается резервный текст.

    Оператор !

    Null assertion operator говорит компилятору: «Я точно знаю, что здесь не null». Это самый опасный из частых операторов работы с null.

    Если на самом деле там null, приложение упадёт во время выполнения. Поэтому ! — не способ «исправить ошибку компиляции», а обещание, за которое вы отвечаете.

    Микропример: если вы нажимаете «Запустить xray-core» и уверены, что путь уже выбран после валидации формы, ! может быть допустим. Но если пользователь может обойти эту ветку, вы получите аварийное завершение.

    late: отложенная инициализация без ?

    Иногда значение точно появится позже, но в момент объявления его ещё нет. Для этого существует late — ключевое слово для отложенной инициализации non-nullable переменной.

    Смысл такой: переменная не nullable, но вы обещаете инициализировать её до первого чтения. Это полезно, когда значение вычисляется на старте приложения, в методе инициализации, в конструкторе или после обращения к платформенному API.

    Микропример: путь к рабочей директории можно получить после запуска приложения и сохранить в late String, если он обязан существовать до показа основного окна.

    Но late — ещё одно место, где легко обмануть себя. Если вы попытаетесь прочитать переменную до присваивания, получите runtime-ошибку. Поэтому late полезен, когда жизненный цикл объекта действительно гарантирует инициализацию.

    Сравнение здесь такое:

  • String? configPath; — путь может отсутствовать, и это нормальная бизнес-ситуация
  • late String appDir; — путь обязан быть, просто станет известен чуть позже
  • String appTitle = 'Xray Desktop'; — значение есть сразу
  • Это не просто синтаксические варианты, а три разные модели мышления о данных.

    Worked example: настройки подключения для будущего desktop-приложения

    Представьте экран базовых настроек клиента. Пользователь может:

  • указать локальный HTTP-порт
  • выбрать имя профиля
  • при желании задать комментарий
  • включить автозапуск подключения
  • Попробуем описать эти данные корректно с точки зрения типов и null safety.

    Шаг 1. localPort объявлен как final int, потому что в рамках текущего объекта настроек он зафиксирован после создания. Почему так: порт — число, и если вы не собираетесь менять его прямо в этом экземпляре, неизменяемость упрощает логику.

    Шаг 2. profileName — обычный String, потому что имя профиля должно существовать всегда. Почему так: если экран не допускает пустое сохранение без имени, лучше выражать это в типе, а не надеяться на комментарий.

    Шаг 3. noteString?, потому что комментарий необязателен. Почему так: отсутствие комментария — не ошибка, а нормальный пользовательский сценарий.

    Шаг 4. autoConnectbool, потому что здесь только два состояния. Почему так: логическое поле читается лучше, чем строковые значения вроде "yes" и "no".

    Теперь представьте отображение этих данных в интерфейсе. Имя можно вывести напрямую, а комментарий — только с резервным значением:

    Здесь ?? делает код устойчивым к отсутствию комментария. Без него вы бы либо получали null в тексте, либо были бы вынуждены писать более громоздкую проверку.

    Но вот более интересный случай: пользователь ничего не выбрал для пути к конфигу.

    Если вы попробуете сразу запускать процесс:

    компилятор остановит вас. Это хорошо, потому что запуск без конфига — не техническая, а бизнес-ошибка. Значит, надо явно обработать сценарий:

    Такое поведение особенно ценно в приложении для xray-core: система не должна «угадывать», что делать с отсутствующими критическими данными.

    Частые ошибки новичков и что за ними стоит

    Одна из самых частых ошибок — использовать dynamic, когда не хочется разбираться с типами. На короткой дистанции это кажется быстрее. На длинной дистанции вы теряете подсказки IDE, автодополнение, проверки и уверенность в коде.

    Другая ошибка — злоупотреблять !. Если вы регулярно ставите !, чтобы компилятор «отстал», значит проблема не в компиляторе, а в модели данных. Возможно, значение должно быть non-nullable и инициализироваться раньше. Или наоборот — nullable, но с честной обработкой отсутствия.

    Третья ошибка — путать final и const. В Flutter это быстро всплывёт, когда вы начнёте строить UI. const не означает просто «не меняется после создания», а означает «значение известно на этапе компиляции». Если это различие не почувствовать сейчас, дальше будут странные ошибки и лишняя путаница.

    Есть и менее очевидный edge case: пустая строка '' — это не null. Для Dart это полноценное значение. Поэтому если путь, имя профиля или токен не должны быть пустыми, одной лишь null safety недостаточно. Нужна ещё валидация содержимого.

    Микропример: String? token может быть не null, но содержать ''. Для логики авторизации это два разных случая: «токена нет» и «токен испорчен или пуст».

    Где эти знания сразу пригодятся в вашем курсе

    Как только вы перейдёте к Flutter, типы начнут влиять на UI. Nullable-поле будет означать условный рендеринг. final и const будут влиять на читаемость и производительность интерфейса. Когда начнётся Riverpod, правильно описанные типы состояния сразу уменьшат хаос в провайдерах.

    А в desktop-части эти основы станут ещё важнее. Работа с конфигами, путями к файлам, JSON, HTTP-ответами, состоянием процесса и пользовательскими настройками почти целиком строится на вопросах: «Какой здесь тип?» и «Может ли здесь быть null

    Если из этой главы запомнить только три вещи — это:

  • var в Dart не делает переменную “бестиповой”: тип просто выводится автоматически и дальше остаётся фиксированным.
  • String и String? — это разные договоры с программой: в первом случае значение обязано быть, во втором отсутствие допустимо и требует обработки.
  • Оператор ! не исправляет null safety, а откладывает риск до runtime: использовать его стоит только там, где вы действительно гарантируете наличие значения.
  • 10. Архитектура приложения и state management best practices

    Архитектура приложения и state management best practices

    Почему один desktop-проект можно спокойно расширять месяцами — добавлять системный трей, новые настройки, импорт конфигов, диагностику сети — а другой начинает ломаться уже на этапе «давайте просто добавим кнопку перезапуска процесса»? Обычно проблема не в количестве функций, а в архитектуре. Пока приложение маленькое, плохая структура маскируется скоростью разработки. Но как только вы соединяете Flutter UI, Riverpod, dio, файловую систему, shared_preferences, управление окном и запуск xray-core, беспорядок становится дорогим.

    Для клиента управления xray-core архитектура особенно важна, потому что у вас есть сразу несколько миров: пользовательский интерфейс, асинхронные операции, системные API desktop, внешний процесс, сохранённые настройки и сетевые запросы. Если все эти вещи начинают разговаривать друг с другом напрямую, проект быстро превращается в лабиринт из «тут мы прямо из виджета вызываем Process.start, а тут же обновляем глобальный флаг, а потом пишем в SharedPreferences». Работать так можно очень недолго.

    Вы уже познакомились с Flutter-виджетами и Riverpod. Теперь задача глубже: понять, как разложить ответственность по слоям, чтобы состояние было предсказуемым, код — тестируемым, а расширение функциональности не превращалось в переписывание половины приложения.

    Почему «всё в одном виджете» перестаёт работать

    Вы наверняка уже пробовали такой путь: экран строит UI, по нажатию кнопки вызывает HTTP-запрос, тут же пишет что-то в настройки, затем меняет локальный setState, а при ошибке показывает SnackBar. Для учебного примера это терпимо. Для реального desktop-клиента — почти гарантированный источник хрупкости.

    Микропример: кнопка «Запустить» на главном экране одновременно валидирует путь к бинарнику, читает конфиг, запускает процесс, ждёт ответ локального API, сохраняет время последнего запуска и обновляет статус трея. Если всё это живёт в обработчике onPressed, вы практически не сможете изолированно протестировать логику и переиспользовать её в другом месте.

    Архитектура нужна не ради «красивых папок», а чтобы каждая часть системы отвечала на свой вопрос. UI отвечает за отображение и ввод, состояние — за текущую картину мира, доменная логика — за правила, инфраструктура — за работу с внешними системами.

    Базовая идея слоёв: кто о ком должен знать

    Хорошая прикладная архитектура для Flutter/Riverpod-проекта обычно строится вокруг нескольких смысловых слоёв. Названия могут немного различаться, но логика остаётся похожей.

    Представление

    Presentation layer — это виджеты, страницы, компоненты UI и их локальное визуальное состояние. Простыми словами, этот слой отвечает за то, что пользователь видит и какие действия инициирует.

    Он должен знать, как показать список профилей, индикатор загрузки, кнопку запуска, диалог ошибки. Но он не должен знать детали HTTP, формат JSON или нюансы запуска процесса на Windows.

    Микропример: экран логов должен уметь отобразить строки и кнопку очистки, но не обязан понимать, как именно stdout процесса преобразуется в поток данных.

    Слой состояния приложения

    Это провайдеры Riverpod, Notifier, AsyncNotifier и связанные модели состояния. Здесь живут правила переходов между состояниями, координация use case-сценариев и адаптация данных для UI.

    Микропример: XrayProcessNotifier может знать, что при запуске нужно перейти в состояние starting, затем либо running, либо error, а UI уже просто подписывается на эти состояния.

    Доменный слой

    Domain layer — это сценарии и правила предметной области. Простыми словами, это ответы на вопросы «что значит запустить ядро правильно?», «когда конфиг считается валидным?», «какие шаги нужны для импорта профиля?».

    Именно здесь особенно удобно размещать use case-логику:

  • запустить процесс
  • остановить процесс
  • загрузить настройки
  • проверить доступность локального API
  • импортировать JSON-конфиг
  • Микропример: use case запуска может сначала проверить наличие файла, затем сформировать аргументы, затем вызвать сервис процесса, а потом сообщить результат в состояние.

    Инфраструктура или data layer

    Этот слой работает с конкретными технологиями:

  • dio
  • shared_preferences
  • файловая система
  • Process.start
  • system_tray
  • window_manager
  • Простыми словами, это место, где приложение касается внешнего мира.

    Микропример: XrayProcessRepository может оборачивать Process.start, а SettingsLocalDataSource — чтение и запись настроек в shared_preferences.

    !Слои desktop-приложения и поток данных между ними

    Почему UI не должен напрямую знать об инфраструктуре

    Это одна из главных профессиональных привычек. Если UI напрямую создаёт Dio, читает файлы и запускает процессы, вы теряете:

  • тестируемость
  • переиспользуемость
  • заменяемость реализаций
  • контроль над ошибками
  • Микропример: если экран настроек сам пишет в SharedPreferences, вам труднее изменить формат хранения или добавить кэширование. Если он вызывает абстракцию saveSettings(), реализацию можно менять без переписывания UI.

    Кроме того, смешение UI и инфраструктуры почти всегда ведёт к дублированию. Один экран запускает процесс так, другой — чуть иначе, третий — с ещё одной проверкой. Через месяц вы уже не уверены, какой путь считается «правильным».

    Где должен жить источник истины

    Один из важнейших принципов state management — single source of truth, единый источник истины. Простыми словами, для каждого значимого вида состояния должно быть понятно, где находится его каноническая версия.

    Примеры для вашего проекта:

  • текущий статус процесса xray-core — в одном notifier/provider, а не в двух экранах и трее отдельно
  • сохранённые настройки приложения — в одном слое репозитория/настроек, а не частично в UI, частично в памяти
  • активный профиль — в одном состоянии выбора, а не в локальных флагах разных страниц
  • Микропример: если системный трей считает, что соединение активно, а главный экран — что нет, это почти всегда следствие того, что источников истины стало два.

    Важно различать источник истины и кэшированные представления. UI-компонент может отображать вычисленный текст статуса, но не должен становиться «хозяином» базового состояния процесса.

    Модели, DTO и UI-state: не смешивать без необходимости

    Новички часто складывают в одну модель всё сразу: сетевой JSON, бизнес-данные и флаги UI. Это создаёт тяжёлые объекты, которые трудно эволюционируют.

    Полезно различать хотя бы три роли:

  • DTO — объект для обмена с внешним миром, например JSON
  • domain model — осмысленная модель приложения
  • UI state — форма данных, удобная для конкретного экрана
  • Микропример: JSON-конфиг профиля, доменная модель ProfileConfig и экранное состояние формы редактирования — это не всегда один и тот же объект. Если смешать всё в одну сущность, каждый слой начинает тянуть за собой чужие детали.

    Для desktop-клиента это особенно заметно в настройках. Сохранённый профиль может иметь десятки полей, но экрану нужна ещё информация вроде isDirty, isSaving, validationError, которые к самой конфигурации не относятся.

    Репозитории: зачем нужен ещё один слой между логикой и API

    Слово repository иногда используют слишком механически, но в прикладном проекте оно очень полезно, если понимать смысл. Репозиторий — это объект, который скрывает детали доступа к данным и предлагает приложению осмысленные операции.

    Примеры:

  • SettingsRepository
  • ProfilesRepository
  • XrayProcessRepository
  • HealthCheckRepository
  • Микропример: ProfilesRepository.loadAll() может читать данные из JSON-файла сегодня, а завтра — из локальной базы. UI и доменная логика не обязаны знать, что поменялась технология хранения.

    Здесь важен нюанс: не нужно делать репозиторий просто прокладкой без логики. Если класс только переименовывает методы другого класса один в один, он не даёт ценности. Репозиторий оправдан, когда действительно изолирует источник данных, формат, ошибки или стратегию кэширования.

    Use case или application service: сценарий как отдельный объект

    Когда операция включает несколько шагов и несколько зависимостей, её удобно выделять в отдельный use case или application service. Это не строгий догмат, а практический приём.

    Например, сценарий запуска xray-core может включать:

  • Проверку пути к бинарнику
  • Проверку существования конфига
  • Построение аргументов запуска
  • Старт процесса
  • Ожидание короткого таймаута
  • Проверку, отвечает ли локальный API
  • Обновление состояния
  • Запись в лог
  • Микропример: если все эти шаги держать внутри notifier, он быстро станет перегруженным. А если вынести в StartXrayUseCase, notifier останется координатором состояния, а use case — исполнителем сценария.

    Такой подход особенно полезен там, где бизнес-операция должна вызываться не только из одного экрана, но и, например, из действий трея или автозапуска.

    State management best practices: что реально работает в Riverpod-проектах

    Держите провайдеры маленькими и осмысленными

    Провайдер должен иметь понятную причину существования. Если его нельзя коротко назвать и объяснить в одном предложении, возможно, он пытается делать слишком много.

    Микропример: selectedProfileIdProvider и profilesListProvider — хорошие отдельные сущности. everythingAboutProfilesAndThemeAndWindowProvider — почти наверняка плохая.

    Не складывайте побочные эффекты в build() без необходимости

    build() у провайдера или AsyncNotifier должен быть предсказуемой точкой получения состояния. Если туда начинают попадать побочные действия с непонятным временем жизни, поведение становится трудно объяснить.

    Микропример: читать сохранённые настройки в build() разумно. Показывать системное уведомление оттуда — уже плохая идея.

    Разделяйте длительное состояние и одноразовые события

    Одна из частых архитектурных ловушек — пытаться хранить одноразовые события, например «показать SnackBar», в том же состоянии, где живут долгоживущие данные. Это приводит к повторным показам после rebuild или восстановления.

    Микропример: сообщение «Профиль сохранён» лучше обрабатывать как реакцию на изменение состояния или отдельный event-механизм, а не хранить вечно в SettingsState.message.

    Не делайте UI слепым к структуре ошибок

    Ошибка «файл не найден», «порт занят» и «не удалось распарсить JSON» — это разные ситуации. Если все они превращаются в строку Something went wrong, UX и отладка сильно страдают.

    Микропример: при старте xray-core ошибка отсутствующего бинарника должна приводить пользователя в настройки пути, а ошибка занятого порта — к смене локального порта. Это разные ветки поведения, и архитектура должна позволять их различать.

    Worked example: архитектура главного сценария запуска xray-core

    Представьте, что пользователь нажимает кнопку «Запустить» на главном экране.

    Шаг 1. UI инициирует действие, но не исполняет сценарий сам

    ConsumerWidget вызывает метод notifier, например ref.read(xrayControllerProvider.notifier).start().

    Почему так: UI выражает намерение пользователя, но не знает технических деталей запуска.

    Шаг 2. Notifier меняет состояние на starting

    Почему так: интерфейс должен сразу показать обратную связь — индикатор запуска и блокировку повторного клика.

    Шаг 3. Notifier вызывает use case StartXrayUseCase

    Почему так: сам сценарий состоит из нескольких шагов и не должен распухать внутри слоя состояния.

    Шаг 4. Use case обращается к зависимостям

    Он может использовать:

  • SettingsRepository для получения путей и параметров
  • XrayProcessRepository для старта процесса
  • HealthCheckRepository для проверки HTTP API
  • LoggerService для диагностики
  • Почему так: каждая зависимость отвечает за свой участок инфраструктуры.

    Шаг 5. Результат возвращается как осмысленный outcome

    Например: success, binaryNotFound, configInvalid, portBusy, processExitedEarly.

    Почему так: notifier и UI могут по-разному реагировать на разные исходы, не гадая по строкам ошибок.

    Шаг 6. Notifier обновляет состояние и, при необходимости, инициирует одноразовый эффект

    Почему так: долгоживущее состояние и реакция интерфейса остаются разделены.

    Этот сценарий кажется чуть более многослойным, чем «вызвать всё из кнопки», но именно такая структура позволяет позже добавить автозапуск, перезапуск из трея и тестирование без переписывания всей цепочки.

    Частые архитектурные ошибки

    Бог-объект состояния

    Один гигантский notifier, который знает про всё: тему, окно, сеть, процесс, настройки, профили. Сначала удобно, потом любой change request становится опасным.

    Инфраструктура в виджетах

    onPressed: () async { final prefs = await SharedPreferences.getInstance(); ... } — красный флаг для растущего проекта. Иногда допустимо в прототипе, но не как устойчивая архитектура.

    Дублирование состояния

    Когда активный профиль хранится и в списке, и в настройках, и в локальном StatefulWidget. Потом изменения расходятся.

    Слишком ранняя «чистая архитектура ради архитектуры»

    Есть и обратная крайность: создавать по пять интерфейсов и по три абстракции на каждый простой сценарий. Архитектура должна уменьшать сложность, а не создавать ритуал.

    Микропример: для простого чтения одной настройки отдельный use case может быть избыточен. Но для многослойного запуска процесса — уже вполне оправдан.

    Практический ориентир для вашего проекта

    Когда будете строить desktop-клиент, полезно думать о каждой новой функции через три вопроса:

  • Это состояние долгоживущее или локальное UI-состояние?
  • Это бизнес-сценарий или просто отображение?
  • Это знание о предметной области или о конкретной технологии?
  • Если ответ на третий вопрос звучит как dio, Process, SharedPreferences, window_manager, это обычно не должно жить в UI-слое. Если ответ на второй — «сценарий из нескольких шагов», стоит подумать о use case или сервисе. Если состояние нужно нескольким частям приложения, у него должен быть явный источник истины.

    Если из этой главы запомнить только три вещи — это:

  • Хорошая архитектура разделяет UI, состояние, предметную логику и инфраструктуру, чтобы каждая часть приложения решала свой класс задач.
  • Для каждого важного состояния нужен единый источник истины, иначе разные части desktop-приложения начинают жить в разных версиях реальности.
  • Riverpod силён не сам по себе, а в сочетании с ясными границами ответственности: провайдеры должны координировать и выражать зависимости, а не заменять архитектуру целиком.
  • 11. Работа с dio: HTTP-запросы, обработка ошибок и интерсепторы

    Работа с dio: HTTP-запросы, обработка ошибок и интерсепторы

    Почему один и тот же HTTP-запрос в приложении иногда выглядит как безобидная строка get('/health'), а в реальном проекте внезапно требует таймаутов, заголовков, логирования, повторов, обработки сетевых сбоев и разборчивых ошибок для пользователя? Потому что сеть почти никогда не ведёт себя идеально. Для desktop-клиента управления xray-core это особенно важно: приложение может обращаться к локальному API ядра, скачивать конфиги, проверять удалённые endpoints и отправлять диагностические запросы. Если сетевой слой собран наивно, пользователь получает либо зависшие экраны, либо нечитаемые сообщения вроде SocketException.

    Вы уже знаете, что асинхронные операции требуют дисциплины состояния. HTTP — один из главных источников такой асинхронности. И в экосистеме Flutter пакет dio ценят именно за то, что он даёт не просто «способ сделать запрос», а полноценный управляемый клиент: конфигурацию, интерсепторы, типы ошибок, отмену запросов и удобную расширяемость.

    Почему dio, а не просто любой HTTP-вызов

    Вы наверняка уже сталкивались с идеей: «зачем отдельная библиотека, если можно просто сделать GET-запрос?» Для учебного примера действительно можно обойтись более простыми средствами. Но в прикладном приложении быстро появляются требования:

  • базовый URL
  • общие заголовки
  • таймауты
  • логирование запросов и ответов
  • централизованная обработка ошибок
  • повтор авторизации или обновление токена
  • разные настройки для разных окружений
  • Dio — это HTTP-клиент для Dart, который удобно конфигурируется и даёт расширяемый pipeline обработки. Простыми словами, он нужен не ради моды, а потому что превращает сетевой код из россыпи вызовов в управляемую систему.

    Микропример: запрос к локальному API http://127.0.0.1:8080/stats можно сделать и вручную, но как только вам нужно для каждого запроса ставить таймаут 2 секунды, логировать задержку и одинаково обрабатывать ошибки соединения, dio резко начинает окупаться.

    Базовая конфигурация клиента

    Обычно работа начинается с создания одного настроенного экземпляра Dio, а не нового объекта на каждый запрос.

    Здесь BaseOptions задаёт общие параметры клиента. Это важно, потому что центр тяжести переносится из отдельных вызовов в конфигурацию.

    Микропример: если локальный API xray-core должен отвечать быстро, таймаут соединения в 3 секунды — разумная защита от зависания UI. Пользователь лучше увидит контролируемую ошибку, чем бесконечное ожидание.

    Почему один экземпляр клиента лучше множества случайных

    Один настроенный экземпляр даёт:

  • единые таймауты
  • единые интерсепторы
  • общие заголовки
  • предсказуемое логирование
  • централизованную замену в тестах
  • Микропример: если один экран использует Dio с таймаутом 3 секунды, а другой создаёт новый клиент без таймаута, вы получите непоследовательное поведение и сложную диагностику.

    В Riverpod-проекте такой клиент часто создают через провайдер зависимости, а не как глобальную переменную.

    Основные запросы: GET, POST и передача параметров

    Базовые методы dio читаются довольно прямо.

    GET

    POST

    Query parameters

    Микропример: если вы делаете локальный запрос на диагностику логов с фильтром по уровню, queryParameters лучше ручной сборки строки URL. Это уменьшает ошибки кодирования и улучшает читаемость.

    Важно помнить, что response.data может быть разного типа в зависимости от ответа сервера и настроек клиента. На границе сети почти всегда нужно думать о преобразовании данных, а не считать их автоматически корректными.

    Что такое Response и почему не стоит сразу тащить data в UI

    Response в dio — это объект ответа, содержащий не только тело данных, но и статус-код, заголовки, исходный запрос и другие метаданные.

    Микропример: если эндпоинт /health вернул 200 и JSON {"status":"ok"}, для бизнес-логики вам может быть достаточно status, но при отладке полезны и заголовки, и код ответа.

    Профессиональный нюанс здесь такой: UI редко должен напрямую знать про Response. Обычно сетевой слой преобразует ответ в доменную модель или хотя бы в более осмысленный результат. Иначе экраны начинают зависеть от деталей HTTP, а это плохая связность.

    !Структура dio-клиента и точки обработки ошибок

    Ошибки сети: нормальная часть работы, а не исключение из правил

    Новички часто пишут сетевой код так, будто сервер обязательно ответит быстро и правильно. В реальном приложении ошибка — это обычный сценарий. Для desktop-клиента, который может обращаться и к локальному API, и к внешним ресурсам, особенно важны как минимум такие классы проблем:

  • сервер недоступен
  • порт не слушается
  • превышен таймаут
  • ответ пришёл с кодом 400/401/500
  • пришёл неожиданный формат данных
  • запрос отменён пользователем
  • В dio сетевые ошибки обычно представлены как DioException.

    Микропример: если xray-core ещё не успел поднять локальный API после старта процесса, запрос /health может завершиться ошибкой соединения. Это не «катастрофа», а нормальное состояние раннего запуска, которое надо обрабатывать осмысленно.

    Типы ошибок DioException

    Среди полезных категорий вы часто встретите:

  • connection timeout
  • send timeout
  • receive timeout
  • bad response
  • cancel
  • connection error
  • unknown
  • Это позволяет не просто вывести e.toString(), а различать сценарии.

    Микропример: таймаут локального health-check после 2 секунд может означать «ядро ещё поднимается», а 401 Unauthorized при работе с внешним API — уже другую ветку обработки.

    Почему пользователю нельзя показывать сырые сетевые ошибки

    Сырые тексты исключений почти всегда неудобны: они технические, нестабильные и плохо переводятся на язык действий пользователя.

    Лучше превращать сетевые ошибки в понятные категории:

  • «Локальный сервис не отвечает»
  • «Проверьте, запущен ли xray-core»
  • «Сервер вернул ошибку авторизации»
  • «Превышено время ожидания»
  • Микропример: сообщение Connection refused полезно разработчику, но пользователю полезнее текст «Не удалось подключиться к локальному API. Возможно, процесс ещё не запущен или порт указан неверно».

    Это не значит, что технические детали надо уничтожить. Их можно логировать отдельно для диагностики, но UI должен говорить на языке сценария.

    Интерсепторы: централизованная обработка запросов и ответов

    Interceptor в dio — это механизм, который позволяет перехватывать запрос до отправки, ответ после получения и ошибку при сбое. Простыми словами, это точка подключения общей логики, которую иначе пришлось бы дублировать в каждом вызове.

    Пример:

    Микропример: вам нужно добавлять версию приложения в каждый запрос к диагностическому серверу. Интерсептор делает это один раз централизованно, а не в десяти местах.

    onRequest

    Используется для подготовки запроса:

  • добавить токен
  • добавить trace-id
  • записать время старта
  • модифицировать заголовки
  • onResponse

    Используется для общей обработки ответа:

  • логирование
  • метрики
  • извлечение общих заголовков
  • валидация общего формата
  • onError

    Используется для:

  • преобразования ошибок
  • повторных попыток
  • логирования
  • специальных сценариев вроде обновления токена
  • !Путь HTTP-запроса через dio и интерсепторы

    Интерсепторы — не место для любой логики подряд

    Как и любой мощный инструмент, интерсепторы легко перегрузить. Они хороши для сквозной логики, то есть такой, которая относится ко многим или всем запросам. Но если поведение нужно только одному эндпоинту, лучше держать его рядом с самим запросом.

    Микропример: общий лог времени ответа — хороший кандидат для интерсептора. Специфическая обработка поля servers из ответа /subscription/import — уже нет, это логика конкретного API-метода.

    Частая ошибка — складывать в один интерсептор и логирование, и токены, и ретраи, и UI-уведомления. Такой код становится тяжело отлаживать. Лучше несколько небольших интерсепторов или хотя бы чётко разделённые обязанности.

    Логирование запросов: польза без утечки секретов

    В desktop-разработке логирование HTTP особенно полезно: пользователь может прислать лог-файл, и вы увидите, какие запросы шли, какие коды ответов были, где упали таймауты. Но здесь есть вопрос безопасности.

    Нельзя бездумно логировать:

  • токены авторизации
  • приватные URL подписок
  • чувствительные заголовки
  • содержимое конфигов с секретами
  • Микропример: если пользователь импортирует подписку с секретной ссылкой, полный URL в обычном debug-логе может стать утечкой.

    Хорошая практика — маскировать чувствительные данные или логировать только безопасные части запроса: метод, путь, код ответа, время выполнения.

    Таймауты, отмена и повторные попытки

    Таймауты

    Без таймаутов HTTP-клиент может ждать слишком долго. Для локального API таймауты обычно короче, чем для внешнего сервера.

    Микропример: для 127.0.0.1 разумен таймаут соединения 1–3 секунды. Для скачивания удалённого конфига может быть уместно 10–15 секунд.

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

    В dio можно отменять запросы через CancelToken.

    Микропример: пользователь открыл диагностику, затем сразу ушёл на другой экран. Нет смысла продолжать запрос, если результат уже никому не нужен.

    Повторные попытки

    Retry-логика полезна, но опасна, если применять её без разбора. Для безопасных idempotent-запросов вроде GET повтор может быть оправдан. Для некоторых POST — уже нет.

    Микропример: health-check локального API можно попробовать повторить 2–3 раза после запуска ядра с короткой задержкой. А вот импорт профиля через POST с побочным эффектом не всегда стоит автоматически повторять бездумно.

    Worked example: клиент для проверки локального API xray-core

    Представьте практический сценарий. После старта xray-core приложение должно проверить, отвечает ли локальный API /health.

    Шаг 1. Создаём настроенный Dio

    Базовый URL: http://127.0.0.1:8080, таймаут соединения 2 секунды, заголовок Accept: application/json.

    Почему так: локальный API либо поднимется быстро, либо мы хотим достаточно рано сообщить об ошибке.

    Шаг 2. Добавляем интерсептор логирования без секретов

    Логируем метод, путь, статус-код и время. Не логируем токены и полный ответ целиком.

    Почему так: для диагностики важен маршрут и задержка, а не потенциально чувствительное содержимое.

    Шаг 3. Пишем метод репозитория

    Почему так: отсутствие соединения здесь можно интерпретировать как «локальный сервис пока не готов», а не как фатальную ошибку всей программы.

    Шаг 4. Интегрируем в AsyncNotifier

    UI получает loading, затем либо data(true/false), либо ошибку другого типа.

    Почему так: экран может показать «Проверяем доступность API», затем «Ядро отвечает» или «Не удалось подключиться».

    Шаг 5. Обрабатываем edge case

    Если процесс только что стартовал, API может быть недоступен первые 300–800 мс. Значит, иногда полезно добавить короткий retry с контролируемым числом попыток, например 3 запроса с паузой 400 мс.

    Почему так: это улучшает UX без превращения сети в «бесконечное ожидание чуда».

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

    Первая ошибка — создавать новый Dio() прямо в каждом методе или виджете. Это ломает единообразие конфигурации и усложняет интерсепторы.

    Вторая ошибка — ловить любую ошибку и превращать её в null. Так теряется информация о причинах сбоя, и приложение не может принимать правильные решения.

    Третья ошибка — доверять response.data без валидации формы данных. Если сервер внезапно вернул не тот JSON, проблема вылезет уже глубже в коде.

    Четвёртая ошибка — делать интерсепторы слишком умными и побочными. Сетевой слой должен быть мощным, но не магическим.

    Если из этой главы запомнить только три вещи — это:

  • dio полезен не самим фактом запроса, а тем, что даёт централизованно настроенный HTTP-клиент с таймаутами, заголовками, интерсепторами и единым поведением.
  • Сетевые ошибки — нормальная часть прикладного сценария, поэтому их нужно классифицировать и переводить в понятные реакции UI, а не просто печатать toString().
  • Интерсепторы хороши для общей сквозной логики, но не должны превращаться в свалку всей сетевой бизнес-логики приложения.
  • 12. JSON-сериализация и работа с данными

    JSON-сериализация и работа с данными

    Почему приложение может успешно получить ответ сервера, прочитать файл конфигурации и всё равно упасть уже на следующей строке? Потому что между «какие-то байты пришли» и «в приложении появилась надёжная модель данных» лежит самый недооценённый участок разработки — преобразование данных. Для desktop-клиента управления xray-core это критично: конфиги, профили, ответы локального API, пользовательские настройки и импортированные подписки почти наверняка будут приходить в формате JSON. И если обращаться с ним небрежно, приложение станет хрупким именно в тех местах, где пользователь ждёт надёжности.

    Вы уже знаете, что Dart строг к типам и что на границе сети или файла часто появляется Map<String, dynamic>. Теперь нужно сделать следующий шаг: понять, почему работать с «сырым JSON» долго нельзя, как строить типобезопасные модели и как автоматизировать сериализацию без ручного копипаста.

    Почему Map<String, dynamic> — это только промежуточная остановка

    Вы наверняка уже видели такой код:

    Он выглядит коротко и даже работает на первых шагах. Но проблема в том, что такой Map почти ничего не гарантирует:

  • ключ может отсутствовать
  • значение может быть другого типа
  • вложенная структура может отличаться от ожиданий
  • ошибка проявится далеко от места чтения
  • Микропример: если локальный JSON-конфиг хранит "port": "1080" строкой вместо числа, Map пропустит это дальше, и ошибка может проявиться уже в момент построения URL или запуска процесса.

    Поэтому хороший стиль такой: сырой JSON разрешён на границе ввода-вывода, но как можно быстрее должен превращаться в типизированную модель Dart.

    JSON: что это и зачем он так популярен

    JSON — это текстовый формат обмена данными, основанный на объектах, массивах, строках, числах, логических значениях и null. Простыми словами, это стандартный способ описывать структурированные данные в виде текста, который легко передавать по сети и сохранять в файлах.

    Пример:

    Микропример: профиль подключения, сохранённый пользователем, удобно хранить в JSON-файле, потому что его легко читать программно и при необходимости даже посмотреть вручную.

    Важно понимать и ограничение: JSON знает только базовые типы. Он не знает про классы Dart, DateTime, enum, сложные доменные правила и валидацию. Именно поэтому сериализация — это не просто «распарсить строку», а построить мост между текстовым форматом и вашей моделью приложения.

    Сериализация и десериализация: два направления одного моста

    Десериализация — это преобразование JSON в объект Dart. Сериализация — обратное преобразование объекта в JSON. Простыми словами, первое нужно для чтения данных, второе — для сохранения или отправки.

    Десериализация

    Сериализация

    Микропример: вы прочитали файл с профилем при запуске приложения — это десериализация. Затем пользователь отредактировал настройки и нажал «Сохранить» — это сериализация.

    Почему это важно формулировать именно так? Потому что ошибка в одном направлении не всегда симметрична другой. Приложение может уметь читать старый формат данных, но писать уже новый. Или корректно сохранять объект, но плохо переживать отсутствие обязательного поля при чтении.

    Ручная модель fromJson / toJson: полезно понять до автоматизации

    Прежде чем переходить к генерации кода, полезно руками почувствовать, что именно происходит.

    Микропример: объект ProfileConfig(name: 'Home', host: '127.0.0.1', port: 1080, enabled: true) можно сохранить в файл как JSON и потом восстановить обратно.

    Ручной подход полезен тем, что даёт полный контроль и понимание. Но он быстро становится утомительным и опасным для ошибок, когда моделей много.

    Где ручной подход начинает ломаться

    Проблемы ручной сериализации появляются быстро:

  • много шаблонного кода
  • легко опечататься в ключах
  • трудно поддерживать вложенные структуры
  • сложно эволюционировать формат
  • nullable и default-значения начинают путаться
  • Микропример: сегодня у профиля 4 поля, завтра 12, потом появляется вложенный объект transport settings и список outbound-узлов. Ручной fromJson превращается в минное поле.

    Именно поэтому в Dart-экосистеме часто используют генерацию через json_serializable или родственные подходы.

    !Преобразование JSON в модель Dart и обратно

    Типобезопасные модели: главное лекарство от хаоса сырых данных

    Типобезопасная модель — это класс Dart, который явно описывает ожидаемую структуру данных. Простыми словами, вместо бесформенной карты у вас появляется объект с контрактом: какие поля обязательны, каких типов они должны быть, какие значения допустимы.

    Это важно по трём причинам:

  • Компилятор помогает ловить ошибки раньше
  • IDE даёт автодополнение и навигацию
  • Бизнес-логика начинает работать с предметными сущностями, а не с ключами строк
  • Микропример: метод запуска процесса должен принимать ProfileConfig, а не Map<String, dynamic>. Тогда ему не нужно гадать, как называется ключ порта и есть ли он вообще.

    Профессиональная привычка: границу «сырые данные → модель» проходить как можно ближе к источнику данных — в репозитории, data source или mapper, но не растягивать Map по всему проекту.

    json_serializable: автоматизация без потери ясности

    Пакет json_serializable генерирует код сериализации на основе модели и аннотаций. Это особенно удобно в проектах, где моделей много и они должны оставаться типобезопасными.

    Идея выглядит так:

    Далее код генерируется через build_runner.

    Микропример: вы меняете модель, добавляете поле remarks, запускаете генерацию — и большая часть шаблонной сериализации обновляется автоматически.

    Почему это не просто «сокращение кода»

    Генерация полезна не только тем, что убирает шаблонность. Она даёт единообразие. Когда в проекте 20–30 моделей, ручная сериализация почти неизбежно начинает отличаться по стилю, допускать пропуски и ломаться при рефакторинге.

    Микропример: если один разработчик забывает обновить toJson, а другой — fromJson, данные начинают вести себя асимметрично. Генерация уменьшает такой риск.

    Nullable-поля, значения по умолчанию и отсутствие ключей

    На границе JSON одна из самых частых проблем — разница между:

  • ключ отсутствует
  • ключ есть, но значение null
  • ключ есть, но значение пустое
  • ключ есть, но тип неожиданен
  • Это четыре разных сценария, и хороший код должен различать их там, где это важно.

    Микропример: поле remarks у профиля может отсутствовать у старых конфигов — это нормально. Поле port отсутствовать не должно — без него профиль некорректен. Поле tag может быть пустой строкой, но это уже отдельная бизнес-валидация, а не проблема парсинга.

    С json_serializable можно использовать значения по умолчанию и тоньше описывать поведение через аннотации. Но даже без тонкостей важно мыслить так: null safety сама по себе не решает всех проблем внешних данных. Нужна ещё валидация семантики.

    Вложенные объекты и списки

    Реальные конфиги редко плоские. Даже если не брать полный JSON xray-core, у вас быстро появятся вложенные структуры: сервер, transport settings, routing rules, список inbound/outbound-элементов.

    Пример вложенной модели:

    Затем в ProfileConfig:

    Микропример: профиль «Tokyo» может иметь имя как строку, а endpoint как отдельный объект. Это лучше, чем хранить все поля на одном уровне только ради простоты JSON.

    Со списками логика та же: JSON-массив лучше превращать в List<ProfileConfig> или List<Rule>, а не в List<dynamic>.

    Валидация после парсинга: парсинг и корректность — не одно и то же

    Очень важный профессиональный нюанс: объект может быть успешно десериализован и при этом оставаться некорректным для предметной области.

    Микропример: port = 0 — это число, значит десериализация пройдёт. Но с точки зрения сетевой конфигурации такой порт, скорее всего, недопустим. Значит, после парсинга может понадобиться дополнительная бизнес-валидация.

    Полезно думать о двух этапах:

  • Технический парсинг: JSON соответствует ожидаемым типам
  • Смысловая валидация: данные допустимы для сценария приложения
  • Для клиента xray-core это особенно важно в импортируемых конфигурациях. Файл может быть «валидным JSON», но невалидным конфигом для запуска.

    Worked example: чтение и сохранение локального профиля

    Представьте практический сценарий. У пользователя есть локальный JSON-файл профиля:

    Шаг 1. Читаем строку из файла

    Почему так: файловая система отдаёт текст, а не готовый объект.

    Шаг 2. Преобразуем строку в Map<String, dynamic> через jsonDecode

    Почему так: это промежуточный формат между сырым JSON и моделью.

    Шаг 3. Создаём ProfileConfig.fromJson(map)

    Почему так: начиная с этого момента приложение работает уже с типизированной моделью, а не с картой строковых ключей.

    Шаг 4. Валидируем бизнес-смысл

    Например, проверяем, что имя не пустое, порт в диапазоне, host не пустой.

    Почему так: успешный парсинг ещё не гарантирует пригодность данных для запуска.

    Шаг 5. Показываем данные в UI и даём отредактировать

    Почему так: UI удобнее работать с моделью, а не с Map.

    Шаг 6. Сохраняем обратно через toJson() и jsonEncode

    Почему так: обратное преобразование должно быть централизованным и симметричным.

    Если позже к профилю добавится поле remark, изменение будет локализовано в модели и сериализации, а не расползётся по виджетам.

    !Пошаговое преобразование raw JSON в модель и обратно

    Эволюция формата и обратная совместимость

    В реальном приложении форматы данных меняются. Это особенно вероятно, если вы храните настройки между версиями приложения. Значит, нужно думать о старых файлах.

    Примеры изменений:

  • добавили новое поле
  • переименовали поле
  • перенесли поле во вложенный объект
  • начали хранить enum строкой
  • Микропример: в версии 1.0 вы хранили server, а в версии 1.1 разделили его на host и port. Старые конфиги всё ещё могут существовать у пользователей.

    Поэтому полезно заранее проектировать парсер более терпимым там, где это уместно: иметь default-значения для необязательных полей, уметь мигрировать формат и хранить версию схемы, если структура серьёзно растёт.

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

    Первая ошибка — таскать Map<String, dynamic> через весь проект. Так вы теряете почти все преимущества Dart-типизации.

    Вторая ошибка — считать, что успешный jsonDecode означает успешную обработку данных. На самом деле это только начало пути.

    Третья ошибка — смешивать сериализацию и бизнес-логику в одном месте. fromJson должен заниматься преобразованием формы данных, а не решать, например, можно ли уже запускать процесс.

    Четвёртая ошибка — не думать о старых форматах данных при обновлениях приложения. Именно такие случаи дают «необъяснимые» баги только у части пользователей.

    Если из этой главы запомнить только три вещи — это:

  • Map<String, dynamic> — это временный формат на границе ввода-вывода, а не полноценная внутренняя модель приложения.
  • Сериализация и десериализация должны как можно раньше переводить JSON в типобезопасные объекты Dart, с которыми уже работает бизнес-логика и UI.
  • Успешный парсинг не равен корректности данных для предметной области: после десериализации часто нужна отдельная валидация смысла.
  • 13. Desktop-функциональность: system_tray, window_manager, shared_preferences

    Desktop-функциональность: system_tray, window_manager, shared_preferences

    Почему desktop-приложение может быть технически рабочим, но при этом ощущаться «не как настоящее Windows-приложение»? Часто дело не в главном экране и не в красивых кнопках, а в поведении окна, трея и сохранённых настроек. Пользователь ожидает, что VPN-клиент или прокси-инструмент умеет сворачиваться в системный трей, корректно восстанавливать окно, помнить размер и позицию, сохранять выбранный профиль и режим запуска. Если этого нет, даже хороший UI воспринимается как незавершённый.

    Для клиента управления xray-core desktop-функциональность — не украшение, а часть основного UX. Приложение может работать фоном, показывать статус через трей, не закрываться полностью при нажатии на крестик, восстанавливать последний экран и помнить настройки между перезапусками. Всё это требует понимания не только Flutter-виджетов, но и взаимодействия с платформенными API через специализированные пакеты, такие как system_tray, window_manager и shared_preferences.

    Чем desktop-мышление отличается от «просто Flutter на большом экране»

    Вы наверняка уже заметили, что Flutter позволяет запускать те же виджеты на мобильном и desktop. Но поведение пользователя на desktop другое. Окно можно растягивать, сворачивать, закрывать, переносить между мониторами. Приложение может жить в фоне часами и почти не показываться на экране. У пользователя есть системный трей, контекстные меню, ожидание автозапуска и сохранения состояния.

    Микропример: на мобильном нажатие «назад» обычно уводит назад по навигации. На desktop крестик окна у VPN-клиента часто не завершает процесс полностью, а лишь прячет приложение в трей.

    Именно поэтому desktop-функции нельзя рассматривать как «добавим потом». Их архитектура влияет на запуск, состояние, UX и даже безопасность сценариев.

    window_manager: контроль над окном как частью UX

    Пакет window_manager позволяет управлять desktop-окном Flutter-приложения: размером, положением, видимостью, фокусом, заголовком, минимальными размерами и реакцией на события закрытия. Простыми словами, он даёт вашему приложению поведение, ожидаемое от нормальной настольной программы.

    Обычно инициализация выглядит так:

    Затем можно настроить окно:

    Микропример: для клиента управления xray-core разумно задать минимальную ширину окна, чтобы панель профилей и логов не ломала layout при сильном сжатии.

    Почему настройки окна — это не просто косметика

    Размер и поведение окна влияют на читаемость, доступность функций и восприятие стабильности приложения.

    Микропример: если пользователь растянул лог-панель на второй монитор, закрыл приложение и при следующем запуске всё вернулось к дефолтному маленькому окну в случайном месте, это раздражает сильнее, чем кажется.

    Поэтому хорошая практика — сохранять и восстанавливать:

  • ширину и высоту
  • позицию окна
  • состояние максимизации
  • возможно, факт «открывать ли сразу скрытым»
  • Перехват закрытия окна: «крестик» не всегда значит выход

    Для VPN-клиентов и фоновый инструментов особенно часто используется сценарий: при нажатии на кнопку закрытия окно не уничтожается, а скрывается в трей. Это поведение надо реализовывать осознанно.

    В window_manager можно подписываться на события окна и решать, что делать при попытке закрытия.

    Микропример: пользователь нажал крестик, ожидая «убрать с глаз, но оставить подключение активным». Если приложение завершится полностью и оборвёт соединение, это будет уже не мелкий UX-дефект, а функциональная проблема.

    Но здесь есть edge case: пользователю всё равно нужен способ действительно выйти из приложения. Обычно его дают через контекстное меню в трее: «Показать», «Перезапустить ядро», «Выход».

    system_tray: приложение, которое живёт не только окном

    system_tray позволяет создать иконку в системном трее и контекстное меню для фоновых действий. Простыми словами, это мост к привычному desktop-поведению: приложение может оставаться активным, даже когда основное окно скрыто.

    Типичные сценарии для вашего проекта:

  • показать/скрыть окно
  • запустить/остановить xray-core
  • переключить активный профиль
  • открыть настройки
  • выйти из приложения
  • Микропример: пользователь запускает клиент один раз утром, затем в течение дня почти не открывает окно, а контролирует состояние через иконку трея и пару пунктов меню.

    Инициализация трея

    Примерный принцип:

    Затем создаётся меню:

    Микропример: даже если окно скрыто, пользователь может правой кнопкой открыть меню и вернуть приложение на экран без повторного запуска.

    !Связь окна, системного трея и сохранённых настроек

    Важный нюанс трея: состояние должно быть синхронизировано с приложением

    Одна из самых частых ошибок — делать трей отдельным «мини-приложением», которое не синхронизировано с основным состоянием. В результате в окне кнопка показывает «Запущено», а в меню трея всё ещё написано «Запустить».

    Правильный подход — считать состояние процесса единым источником истины, а трей лишь ещё одним представлением этого состояния.

    Микропример: если processStateProvider перешёл в running, пункт меню трея может поменяться с «Запустить» на «Остановить», а иконка — на активную. Но вычисляться это должно из того же состояния, что и UI.

    shared_preferences: простое хранение пользовательских настроек

    SharedPreferences — это простой key-value storage для небольших пользовательских настроек. Простыми словами, это способ сохранить между запусками приложения строки, числа, флаги и небольшие списки.

    Примеры того, что здесь хорошо хранить:

  • выбранную тему
  • локальный порт
  • путь к последнему конфигу
  • флаг «сворачивать в трей при закрытии»
  • факт первого запуска
  • размер и позицию окна в простом виде
  • Микропример: пользователь однажды включил «автозапуск соединения» и «запускать свернутым». Эти флаги должны сохраниться без отдельной базы данных и сложной инфраструктуры.

    Пример чтения:

    Пример записи:

    Когда shared_preferences подходит, а когда уже нет

    Он хорош для небольших настроек и простых пользовательских предпочтений. Но если вы собираетесь хранить:

  • большие JSON-конфиги
  • историю логов
  • десятки профилей со сложной структурой
  • кэш больших данных
  • лучше подумать о файлах, базе или специализированном хранилище.

    Микропример: сам список полноценных профилей xray-core лучше хранить в JSON-файле или другой структуре, а вот идентификатор последнего выбранного профиля — отлично подходит для shared_preferences.

    Асинхронность настроек: почему нельзя считать их «мгновенными»

    Хотя shared_preferences кажется очень простым, он работает асинхронно. Это значит, что на старте приложения у вас есть короткий период, пока настройки ещё не загружены.

    Микропример: если тема приложения зависит от сохранённого theme_mode, вы не должны сначала жёстко строить весь UI в light, а затем резко переключать его без контроля. Лучше загрузить настройки в раннем состоянии приложения и уже потом использовать их как источник истины.

    Для Riverpod-проекта это часто означает отдельный AsyncNotifier или инициализационный провайдер настроек.

    Жизненный цикл desktop-приложения: запуск, скрытие, восстановление, выход

    Desktop-клиент живёт дольше и сложнее, чем обычный экран мобильного приложения. Полезно мысленно представить основные состояния приложения:

  • стартует
  • показывает окно
  • переходит в фон через скрытие
  • живёт в трее
  • восстанавливается
  • реально завершает работу
  • !Сценарий скрытия в трей, восстановления окна и чтения настроек

    Микропример: пользователь запускает клиент, окно появляется в размере 1200×800, затем он нажимает крестик — окно скрывается, но процесс остаётся активным. Через час он кликает по иконке трея — окно возвращается в прежнем размере и позиции.

    Эта последовательность выглядит естественно для пользователя, но для разработчика требует координации нескольких систем: состояния процесса, окна, трея и настроек.

    Worked example: поведение «скрыть в трей вместо закрытия»

    Представьте реальный UX-сценарий для вашего приложения.

    Шаг 1. В настройках хранится флаг minimizeToTray = true

    Почему так: это пользовательское предпочтение, и его логично сохранять между запусками.

    Шаг 2. При инициализации приложения настройки читаются из shared_preferences

    Почему так: поведение окна при закрытии должно быть известно до первого взаимодействия пользователя.

    Шаг 3. window_manager перехватывает событие закрытия окна

    Если minimizeToTray == true, окно не уничтожается, а скрывается.

    Почему так: это соответствует ожиданию от фонового сетевого клиента.

    Шаг 4. system_tray показывает контекстное меню с пунктами «Показать окно», «Остановить xray-core», «Выход»

    Почему так: пользователь должен иметь полный контроль и без основного окна.

    Шаг 5. По клику на «Показать окно» приложение вызывает show(), focus() и, при необходимости, restore()

    Почему так: окно должно не просто существовать, а возвращаться в активный рабочий вид.

    Шаг 6. По клику на «Выход» приложение сначала корректно завершает процесс ядра, сохраняет состояние и только потом закрывается

    Почему так: для сетевого клиента «выход» — это не просто закрыть окно, а завершить связанную инфраструктуру предсказуемо.

    Этот кейс очень показателен: desktop-функции здесь не живут отдельно от архитектуры состояния. Они участвуют в основном сценарии продукта.

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

    Первая ошибка — управлять окном и треем напрямую из UI-кнопок без отдельного слоя координации. Это быстро становится трудно сопровождать.

    Вторая ошибка — не сохранять настройки окна и пользовательские предпочтения. Тогда приложение каждый раз ощущается «как впервые запущенное».

    Третья ошибка — использовать shared_preferences как универсальную базу данных. Для сложных конфигов и больших структур это плохой выбор.

    Четвёртая ошибка — не различать «скрыть» и «завершить». В контексте фонового клиента это две разные операции с разными последствиями.

    Если из этой главы запомнить только три вещи — это:

  • Для desktop-приложения окно, трей и сохранённые настройки — часть основного UX, а не второстепенная отделка.
  • window_manager и system_tray должны работать от общего источника состояния, иначе окно и трей начинают расходиться в поведении и статусах.
  • shared_preferences хорош для небольших пользовательских настроек и флагов, но не должен превращаться в хранилище сложных конфигов и больших структур.
  • 14. Интеграция и управление xray-core процессом

    Интеграция и управление xray-core процессом

    Почему кнопка «Запустить ядро» в учебном прототипе кажется простой, а в реальном desktop-клиенте превращается в один из самых сложных участков системы? Потому что вы управляете не просто флагом в UI, а внешним процессом со своим жизненным циклом, stdout, stderr, кодами завершения, конфигами, таймаутами запуска и возможными сбоями. Для приложения управления xray-core это центральная задача: если интеграция с процессом сделана плохо, все остальные красивые экраны теряют смысл.

    Вы уже прошли основы Dart, Flutter, Riverpod, сетевой слой и desktop-функции. Теперь приходит момент, когда приложение должно сделать действительно системную работу: найти исполняемый файл, сформировать аргументы, запустить процесс, понять, что он реально поднялся, читать логи, уметь остановить и не оставлять «зомби»-процессы. Здесь особенно важны и архитектура, и асинхронность, и внимательность к edge cases.

    Почему запуск процесса — это не просто Process.start(...)

    Вы наверняка уже знаете, что Dart умеет запускать внешние процессы через Process.start. На уровне одной строки это правда:

    Но реальная интеграция на этом только начинается. После запуска нужно ответить на целую серию вопросов:

  • процесс действительно стартовал или мгновенно завершился?
  • конфиг корректен?
  • stdout содержит полезные логи?
  • stderr сообщает об ошибках?
  • локальный API уже готов принимать запросы?
  • что делать, если порт занят?
  • как корректно завершать процесс при выходе из приложения?
  • Микропример: кнопка нажата, Process.start вернулся успешно, но через 150 мс xray-core завершился из-за битого конфига. Если UI просто поставил isRunning = true, приложение уже живёт в неверной картине мира.

    Поэтому интеграция с процессом — это сценарий, а не одно действие.

    Базовая модель: что приложение должно знать о процессе

    Хорошо начать с модели состояния процесса. Не просто bool isRunning, а более точные фазы.

    Например:

  • stopped
  • starting
  • running
  • stopping
  • error
  • Это может быть enum или отдельная модель состояния.

    Микропример: когда пользователь нажал «Запустить», UI должен на короткое время перейти в starting, заблокировать повторный запуск и показать индикатор. Только после подтверждения готовности ядра состояние должно стать running.

    Такой подход особенно важен для desktop-приложения, потому что помимо кнопки на главном экране на это состояние могут смотреть ещё трей, строка статуса, лог-панель и диагностика API.

    Process.start и что он реально возвращает

    В Dart метод Process.start запускает внешний процесс и возвращает объект Process. Через него вы получаете доступ к:

  • pid
  • stdin
  • stdout
  • stderr
  • exitCode
  • Пример:

    Микропример: pid можно сохранить в состоянии для диагностики, а stdout и stderr — подписать на поток логов в UI.

    Но важно понимать нюанс: успешный возврат Process.start не означает, что приложение уже готово к работе. Это лишь означает, что ОС смогла создать процесс.

    Пути к исполняемому файлу и конфигу: первый источник ошибок

    Одна из самых частых практических проблем — не логика запуска как таковая, а банальные пути.

    Что нужно проверять до старта:

  • файл xray.exe существует
  • конфиг существует
  • пути корректны для текущей платформы
  • путь не пустой
  • у процесса есть доступ к файлам
  • Микропример: пользователь переместил папку с ядром или удалил конфиг вручную. Приложение должно поймать это до запуска и дать осмысленную ошибку, а не просто показать «не удалось стартовать».

    Для Windows стоит особенно внимательно относиться к пробелам в путях и рабочей директории процесса. Иногда полезно явно задавать workingDirectory, если ядро ожидает относительные пути.

    Аргументы запуска: формировать явно и предсказуемо

    Лучше собирать аргументы запуска отдельной функцией или use case, а не строковой конкатенацией в UI.

    Микропример: сегодня нужен только run -c config.json, а завтра вы добавите режим логирования, другой уровень verbosity или временный флаг диагностики. Явная функция сборки аргументов облегчит развитие.

    Профессиональный нюанс: не стоит собирать команду в одну строку ради «красоты». Надёжнее передавать исполняемый путь и список аргументов отдельно.

    stdout и stderr: два потока, которые нельзя игнорировать

    У процесса есть два основных текстовых потока вывода:

  • stdout — обычный вывод
  • stderr — ошибки и предупреждения
  • В Dart это Stream<List<int>>, который обычно нужно декодировать через UTF-8 и разбивать на строки.

    Пример идеи:

    Микропример: xray-core может писать в stderr, что конфиг невалиден или порт уже занят. Если вы не слушаете stderr, пользователь увидит только «не сработало» без объяснения.

    !Архитектура интеграции Flutter-приложения с процессом xray-core

    Почему логи полезны не только для разработчика

    В desktop-клиенте лог-панель — это не роскошь. Она помогает:

  • понимать, что происходит при запуске
  • диагностировать ошибки конфигов
  • видеть ранние сбои процесса
  • упрощать поддержку пользователя
  • Микропример: пользователь присылает скриншот строки «failed to start inbound: address already in use». Это сразу указывает на занятый порт, а не заставляет гадать, в чём проблема.

    Но здесь есть и вопрос безопасности: если логи содержат чувствительные данные, нужно продумать фильтрацию или ограничение того, что показывается и сохраняется.

    Как понять, что xray-core реально готов

    Это один из самых важных edge cases. Успешный старт процесса не равен готовности сервиса. Для приложений вроде xray-core разумно отделять два этапа:

  • процесс создан ОС
  • ядро реально готово обслуживать запросы или трафик
  • Обычно это проверяют одним из способов:

  • анализируют логи на строку успешного запуска
  • делают health-check по локальному API
  • ждут короткий таймаут и затем проверяют доступность порта/API
  • Микропример: процесс стартовал, но локальный API на 127.0.0.1:8080 начнёт отвечать только через 400–700 мс. Если UI сразу покажет «Готово», можно получить ложноположительный статус.

    На практике часто надёжнее комбинация: запустить процесс, начать читать логи, затем сделать ограниченное число health-check-запросов с маленькой задержкой.

    exitCode: раннее завершение как диагностический сигнал

    У объекта Process есть exitCode, который можно await-ить. Это Future, завершающийся, когда процесс закончится.

    Микропример: если xray-core завершился с кодом ошибки через 200 мс после запуска, это сильный сигнал, что конфиг некорректен или среда не позволяет стартовать.

    Профессионально важно слушать этот момент не только при ручной остановке, но и в фоне. Иначе приложение может продолжать считать процесс активным после аварийного завершения.

    Остановка процесса: мягко, жёстко и предсказуемо

    Запуск — это только половина задачи. Хороший клиент должен корректно останавливать xray-core при:

  • нажатии «Остановить»
  • перезапуске с новым конфигом
  • реальном выходе из приложения
  • В Dart у процесса можно вызвать kill().

    Но здесь важны нюансы:

  • был ли процесс вообще запущен?
  • идёт ли уже остановка?
  • что делать, если процесс не завершился вовремя?
  • нужно ли ждать exitCode после kill()
  • Микропример: пользователь дважды быстро нажал «Остановить». Если нет промежуточного состояния stopping, можно получить гонку состояний и странный UI.

    Хорошая практика — после сигнала остановки ждать завершения ограниченное время и только затем считать процесс реально остановленным.

    !Жизненный цикл процесса xray-core: старт, readiness check, running и stop

    Перезапуск: отдельный сценарий, а не «stop + start без мыслей»

    Перезапуск часто нужен при смене профиля или редактировании конфига. Но это не просто механическое «остановить и сразу запустить». Нужны ответы на вопросы:

  • что делать, если остановка зависла?
  • нужно ли блокировать UI на время перезапуска?
  • как не потерять логический контекст и логи?
  • что если новый запуск не удался после успешной остановки старого?
  • Микропример: пользователь изменил локальный порт и нажал «Применить». Клиент должен остановить старый процесс, убедиться, что он завершён, затем стартовать новый и только потом обновить состояние на running.

    В профессиональном коде перезапуск обычно оформляют как отдельный use case со своей диагностикой.

    Работа с логами как со Stream

    Потоки stdout и stderr естественно ложатся на модель Stream. Это особенно удобно в Riverpod и Flutter: можно направить строки логов в notifier и показывать в UI постепенно, а не ждать накопления.

    Микропример: во время запуска пользователь видит последовательность сообщений: loading config, starting api, ready. Это гораздо лучше, чем молчание на 2 секунды и внезапный итоговый статус.

    Но здесь есть edge case: если вы без ограничений складываете все строки в память, долгоживущее приложение может разрастить потребление RAM. Поэтому полезно:

  • ограничивать размер буфера логов
  • сохранять только последние N строк
  • архивировать логи в файл отдельно, если нужно
  • Безопасность и устойчивость интеграции

    При работе с внешним процессом важно помнить не только о функциональности, но и о безопасности и устойчивости.

    Не доверяйте внешним путям без проверки

    Путь к бинарнику и конфигу приходит из пользовательских настроек. Он может быть пустым, некорректным или указывать не на тот файл.

    Не запускайте процесс из UI напрямую

    Лучше держать запуск в отдельном слое сервиса или репозитория, а UI лишь инициирует сценарий.

    Не храните секреты в логах без маскировки

    Если конфиги содержат чувствительные URI или ключи, их вывод в лог-панель должен быть осознанным.

    Думайте о гонках состояний

    start, stop, restart, ранний exitCode, delayed health-check — всё это события во времени, и они могут пересекаться.

    Микропример: пользователь нажал «Запустить», потом почти сразу «Остановить», а health-check ещё не завершился. Если архитектура не учитывает такие гонки, состояние может случайно стать running уже после команды остановки.

    Worked example: сценарий запуска с проверкой готовности

    Представьте полный сценарий запуска.

    Шаг 1. Проверяем настройки

    Путь к xray.exe и путь к конфигу должны существовать.

    Почему так: это дешёвые и ранние проверки, которые лучше сделать до запуска процесса.

    Шаг 2. Переводим состояние в starting

    Почему так: UI должен сразу показать, что команда принята и повторный клик временно недоступен.

    Шаг 3. Вызываем Process.start и сохраняем объект процесса

    Почему так: нам нужны pid, потоки вывода и exitCode.

    Шаг 4. Подписываемся на stdout и stderr

    Почему так: логи запуска и ошибки нужны сразу, а не постфактум.

    Шаг 5. Запускаем health-check с 3 попытками по 400 мс

    Почему так: ядру может потребоваться короткое время на инициализацию API.

    Шаг 6. Если health-check успешен — переводим состояние в running

    Почему так: только теперь можно честно считать ядро готовым.

    Шаг 7. Если процесс завершился раньше или health-check провалился — читаем логи и переходим в error

    Почему так: пользователь должен видеть не ложный running, а реальное состояние с диагностикой.

    Этот сценарий даёт вам уже почти профессиональную основу для управления ядром.

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

    Первая ошибка — считать, что Process.start равен успешному запуску сервиса. Это неверно для большинства реальных процессов.

    Вторая ошибка — не слушать stderr и exitCode. Так теряется большая часть полезной диагностики.

    Третья ошибка — не различать состояния starting, running, stopping и error. Один bool isRunning слишком беден для реального UX и устойчивой логики.

    Четвёртая ошибка — не обрабатывать гонки быстрых пользовательских действий и асинхронных проверок готовности.

    Если из этой главы запомнить только три вещи — это:

  • Управление xray-core — это сценарий жизненного цикла процесса, а не один вызов Process.start.
  • Готовность ядра нужно подтверждать отдельно от факта создания процесса, например через health-check или анализ логов.
  • stdout, stderr и exitCode — такие же важные источники состояния, как кнопки UI, и игнорировать их в desktop-клиенте нельзя.
  • 15. Сборка приложения: GitHub Actions, CI/CD и релиз .exe

    Сборка приложения: GitHub Actions, CI/CD и релиз .exe

    Почему приложение может отлично работать на вашей машине, но каждый релиз превращаться в ритуал из ручных команд, копирования файлов, забытых зависимостей и нервного вопроса «а точно ли я собрал ту версию»? Потому что локальная сборка и воспроизводимая поставка — это две разные зрелости проекта. Для desktop-клиента управления xray-core этот переход особенно важен: пользователю нужен понятный .exe, вам — повторяемый процесс сборки, а команде или будущему вам — уверенность, что релиз создаётся одинаково каждый раз.

    До этого момента вы в основном думали о коде приложения: UI, Riverpod, сеть, JSON, системный трей, внешний процесс. Но профессиональная разработка заканчивается не на flutter run, а на цепочке CI/CD: проверка, сборка, упаковка, публикация артефактов и релизов. Если эту часть игнорировать, проект остаётся прототипом, даже если внутри уже хорошая архитектура.

    Почему ручная сборка плохо масштабируется

    Вы наверняка уже делали это хотя бы мысленно: открыть терминал, запустить flutter build windows, найти итоговую папку, вручную положить рядом xray.exe, переименовать архив, загрузить его на GitHub Release или отправить в мессенджер. На первых этапах это кажется терпимым. Но затем начинают копиться проблемы:

  • забыли обновить версию
  • собрали не ту ветку
  • пропустили генерацию кода
  • локальная машина содержит зависимость, которой нет у других
  • непонятно, какой коммит соответствует какому .exe
  • Микропример: вы исправили баг в обработке трея, собрали локально, отправили архив пользователю, а через три дня уже не уверены, был ли в этой сборке новый бинарник xray-core или старая версия.

    CI/CD нужен именно затем, чтобы сделать процесс воспроизводимым и наблюдаемым.

    Что такое CI и CD простыми словами

    CIcontinuous integration, непрерывная интеграция. Простыми словами, это автоматический запуск проверок и сборок при изменениях в коде, чтобы быстро находить проблемы совместимости и не держать проект в состоянии «на моей машине работает».

    CDcontinuous delivery или continuous deployment. В контексте desktop-приложения чаще полезнее думать как о непрерывной доставке: после успешной проверки и сборки вы автоматически получаете готовый артефакт или релиз, который можно выдать пользователю.

    Микропример: вы пушите тег версии v1.2.0, GitHub Actions автоматически собирает Windows-версию, архивирует результат и прикладывает его к GitHub Release. Вам не нужно повторять ручные команды на локальной машине.

    Важно понимать, что CI/CD — это не «магия GitHub», а описание шагов, которые и так нужно делать, но теперь они формализованы и исполняются одинаково.

    Почему GitHub Actions подходит для Flutter desktop-проекта

    GitHub Actions — это встроенная в GitHub система автоматизации workflow. Для проекта на Flutter это удобно по нескольким причинам:

  • workflow хранится рядом с кодом
  • триггеры легко привязать к push, pull request и тегам
  • Windows runner доступен из коробки
  • можно публиковать артефакты и релизы
  • удобно хранить секреты репозитория
  • Микропример: для сборки .exe вам нужен Windows runner, потому что Flutter desktop-сборка под Windows делается именно на Windows. GitHub Actions позволяет запускать такой pipeline без вашей локальной машины.

    Это особенно полезно, если проект растёт, у вас появляется несколько веток, релизные теги и желание не зависеть от одного компьютера.

    Базовый жизненный цикл pipeline

    Хорошо начать с простой ментальной модели. Типичный pipeline для вашего приложения выглядит так:

  • Сработал триггер: push, pull request или тег
  • Репозиторий клонируется на runner
  • Устанавливается Flutter SDK
  • Загружаются зависимости
  • Генерируется код (build_runner, если нужно)
  • Запускаются проверки или тесты
  • Выполняется flutter build windows
  • Готовые файлы архивируются
  • Артефакт публикуется
  • При релизном триггере создаётся GitHub Release и прикрепляется архив
  • Микропример: вы пушите тег v0.9.0-beta, и через несколько минут получаете ZIP с desktop-клиентом, который соответствует конкретному коммиту и может быть скачан напрямую.

    !Структура CI/CD пайплайна для Flutter desktop-приложения

    Что именно собирается при flutter build windows

    Команда:

    собирает Windows desktop-приложение. Результат обычно находится в каталоге вроде:

    build\windows\x64\runner\Release

    Там будет не один только .exe, а набор файлов, необходимых для запуска приложения:

  • основной .exe
  • DLL и runtime-зависимости
  • папка данных и ресурсов
  • Микропример: если просто взять один .exe и отправить пользователю, он часто не запустится, потому что приложение зависит от соседних файлов. Для релиза обычно архивируют всю папку Release или заранее готовят инсталлятор.

    Для курса сейчас важнее уверенно освоить воспроизводимую сборку и упаковку артефакта, а не сразу уходить в сложные инсталляторы.

    Workflow-файл: сердце GitHub Actions

    Workflow хранится в репозитории по пути .github/workflows/...yml. Это YAML-описание шагов.

    Простейший каркас может выглядеть так:

    Даже на этом этапе уже есть две важные идеи:

  • trigger — когда workflow запускается
  • job — на какой среде и какие шаги выполняются
  • Микропример: workflow_dispatch позволяет вручную запустить сборку из интерфейса GitHub, даже без нового коммита. Это удобно для проверочных сборок.

    Установка Flutter в pipeline

    Чтобы runner мог собрать приложение, ему нужен Flutter SDK. Обычно используют готовое action, например через subosito/flutter-action.

    После этого полезно проверить окружение и установить зависимости:

    Микропример: если локально у вас всё уже скачано, легко забыть, что CI-машина начинает с чистого состояния. Именно поэтому pub get всегда должен быть частью pipeline.

    Code generation и анализ перед сборкой

    Если проект использует Riverpod code generation или json_serializable, генерацию нужно запускать и в CI.

    Также полезно запускать анализ:

    И при наличии тестов:

    Микропример: вы добавили новый @riverpod-класс и сгенерировали код локально, но забыли закоммитить часть артефактов или проверить чистую сборку. CI поймает это раньше, чем вы отдадите broken build пользователю.

    Профессиональный нюанс: даже если тестов пока мало, flutter analyze уже даёт огромную пользу. Он ловит предупреждения, типовые ошибки и часть проблем архитектурной гигиены.

    Сборка Windows-версии и упаковка

    Далее сам build:

    После сборки обычно архивируют папку Release.

    На Windows runner для этого можно использовать PowerShell:

    Микропример: пользователь скачивает один ZIP, распаковывает его и получает всю структуру файлов, нужную для запуска. Это уже значительно лучше, чем ручное пересылание отдельных бинарников.

    Артефакты: где взять результат сборки

    Artifacts в GitHub Actions — это файлы, прикреплённые к конкретному запуску workflow. Они полезны для внутренних тестовых сборок и проверки результата, даже если вы ещё не делаете официальный релиз.

    Микропример: вы сделали push в ветку develop, workflow собрал приложение, и QA может скачать ZIP из вкладки Actions, не дожидаясь формального GitHub Release.

    Это хороший промежуточный уровень зрелости между «только локальная сборка» и полноценными релизами по тегу.

    Релизы по тегу: от артефакта к поставке пользователю

    Для пользовательских релизов часто удобно запускать workflow по тегам вроде v1.0.0.

    Тогда после успешной сборки можно создать GitHub Release и прикрепить ZIP. Для этого часто используют готовые actions.

    Микропример: вы пушите git tag v1.0.0 и git push --tags, а GitHub Actions автоматически создаёт страницу релиза с готовым архивом Windows-клиента.

    Это даёт очень важную вещь: релиз жёстко связан с тегом и коммитом, а не с «какой-то сборкой, которую кто-то сделал вечером на ноутбуке».

    Что делать с xray-core в релизной сборке

    Так как ваше приложение управляет внешним ядром, нужно заранее решить стратегию поставки.

    Варианты:

    | Подход | Плюсы | Минусы | |---|---|---| | Пользователь сам указывает путь к xray-core | Меньше юридических и упаковочных вопросов | Хуже первый запуск, больше ручной настройки | | Вы кладёте xray-core рядом с приложением | Лучший UX, приложение сразу готово | Нужно продумать обновление и структуру поставки | | Отдельная загрузка ядра при первом старте | Гибкость версий | Усложнение логики и зависимость от сети |

    Микропример: для внутреннего инструмента команды удобно сразу включить проверенный xray-core в архив релиза. Для более общего дистрибутива вы можете сначала оставить путь настраиваемым вручную.

    Здесь нет универсально правильного ответа, но pipeline должен отражать выбранную стратегию. Если бинарник включается в релиз, шаг упаковки должен собирать и его тоже.

    Секреты и безопасность CI/CD

    Хотя сборка desktop-клиента на первый взгляд может не требовать секретов, со временем они появляются:

  • токены публикации
  • приватные URL загрузки зависимостей
  • ключи подписи релиза
  • API-ключи для уведомлений
  • В GitHub Actions секреты хранятся в настройках репозитория и подставляются через secrets.

    Микропример: если вы публикуете релиз через внешний сервис или отправляете уведомление в чат, токен нельзя хранить в YAML-файле репозитория.

    Даже если пока секретов мало, важно формировать правильную привычку: чувствительные данные не коммитятся в код.

    Кэширование зависимостей и скорость pipeline

    Когда CI начинает работать регулярно, время сборки становится заметным. Один из способов ускорить workflow — кэшировать зависимости. Но здесь важен баланс: сначала лучше сделать pipeline надёжным и понятным, а уже потом оптимизировать.

    Микропример: если обычная сборка длится 7–10 минут, это терпимо на старте проекта. Гораздо хуже быстрый, но нестабильный pipeline, который иногда пропускает ошибки или ломается на генерации.

    Worked example: минимальный production-путь к релизу .exe

    Представьте, что вы хотите организовать первый нормальный релиз вашего desktop-клиента.

    Шаг 1. Настраиваете workflow на push тега v*

    Почему так: именно тег служит намерением «это релизная версия», а не любой push в main.

    Шаг 2. В workflow выполняете:

  • checkout
  • setup Flutter
  • flutter pub get
  • build_runner build
  • flutter analyze
  • flutter test
  • flutter build windows --release
  • Почему так: релиз не должен собираться из состояния «без анализа и без тестов», иначе CI превращается просто в удалённый терминал.

    Шаг 3. Архивируете папку Release

    Почему так: один .exe недостаточен, пользователю нужен весь комплект runtime-файлов.

    Шаг 4. Загружаете архив как artifact и прикладываете к GitHub Release

    Почему так: artifact полезен для диагностики workflow, а Release — для дистрибуции.

    Шаг 5. Проверяете, что версия в приложении, тег и описание релиза согласованы

    Почему так: релиз — это не только технический архив, но и коммуникация с пользователем и самим собой в будущем.

    Этот сценарий уже даёт профессионально приемлемый baseline для поставки Windows-клиента.

    !Пошаговый pipeline GitHub Actions от push до релиза

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

    Первая ошибка — собирать релизы только локально и не фиксировать процесс. Тогда воспроизводимость теряется почти полностью.

    Вторая ошибка — забывать про code generation в CI. Локально всё может работать благодаря уже существующим файлам, а на чистой машине сборка ломается.

    Третья ошибка — архивировать только .exe, а не всю папку релиза. Для Flutter desktop это почти всегда недостаточно.

    Четвёртая ошибка — смешивать тестовые артефакты и официальные релизы без чётких правил именования и триггеров.

    Пятая ошибка — хранить секреты прямо в YAML или коде. Это технический долг, который рано или поздно превращается в инцидент.

    Мостик к реальной практике проекта

    Когда вы дойдёте до первого рабочего клиента управления xray-core, полезно начать не с «идеального enterprise-CD», а с честного минимального пайплайна:

  • сборка по push в main для внутренних артефактов
  • релиз по тегу для пользовательских ZIP
  • обязательные analyze и генерация кода
  • одна понятная схема именования версий
  • Это уже даст вам резкий скачок зрелости проекта. Дальше можно постепенно добавлять подпись бинарников, автообновление, несколько окружений и более сложную упаковку.

    Если из этой главы запомнить только три вещи — это:

  • CI/CD нужен не ради автоматизации как таковой, а ради воспроизводимой, проверяемой и привязанной к коммиту сборки desktop-приложения.
  • Для Flutter Windows-релиза обычно нужен не один .exe, а весь комплект файлов из папки Release, поэтому релизный артефакт чаще всего должен быть ZIP-архивом этой папки.
  • GitHub Actions позволяет формализовать путь от тега версии до готового релиза, чтобы поставка приложения перестала зависеть от ручных действий на одной машине.
  • 2. Функции, стрелочный синтаксис и продвинутые возможности Dart

    Функции, стрелочный синтаксис и продвинутые возможности Dart

    Почему одна и та же кнопка в приложении может запускать процесс, сохранять настройки, валидировать форму и при этом оставаться читаемой? Ответ почти всегда упирается в то, как вы проектируете функции. В Dart хорошие функции — это не просто куски кода, а главный способ сделать приложение предсказуемым, тестируемым и пригодным для роста.

    В desktop-приложении для управления xray-core у вас будет много действий: нажать «Запустить», проверить путь к бинарнику, отправить HTTP-запрос за конфигом, обновить состояние интерфейса, записать параметры в настройки. Если всё это складывать прямо в обработчики кнопок, проект быстро превращается в лабиринт. Именно поэтому функции нужно освоить глубже, чем на уровне «принимают аргументы и что-то возвращают».

    Вы наверняка уже пользовались функциями в других языках или хотя бы вызывали print(). В Dart идея та же, но язык даёт особенно удобные средства для аккуратного API: именованные параметры, параметры по умолчанию, короткий стрелочный синтаксис и функции как полноценные значения.

    Зачем функции нужны помимо «не повторять код»

    Функция — это именованный блок логики, который можно вызвать позже, передав ему данные и получив результат. Простыми словами, это инструмент упаковки действий в понятный контракт. Это важно не только для переиспользования, но и для изоляции ответственности: одна функция проверяет данные, другая формирует команду запуска, третья парсит ответ сервера.

    Микропример: вместо большого обработчика кнопки «Подключиться» лучше иметь функции validateConfigPath(), buildStartArgs() и startXrayProcess(). Тогда сбой легче локализовать.

    Базовый вид функции в Dart такой:

    Здесь String перед именем функции — это возвращаемый тип. В скобках — параметры. В теле — логика. Даже на таком простом примере видно главное: функция делает одну маленькую вещь и делает её явно.

    Если функция ничего не возвращает, используется void.

    Это типичный вариант для логирования, обновления UI-события или вызова побочного действия.

    Параметры: позиционные, именованные и почему это влияет на качество API

    В Dart есть несколько способов передавать данные в функцию, и это одна из его сильных сторон.

    Позиционные параметры

    Это обычные параметры, порядок которых важен.

    Вызов:

    Такой стиль хорош, когда параметры короткие, очевидные и их мало. Но как только появляются три-четыре однотипных значения, код начинает путаться.

    Микропример: connect('127.0.0.1', 1080, true) хуже читается, чем вариант с именами. По вызову не сразу понятно, что означает true.

    Именованные параметры

    Именованные параметры заключаются в фигурные скобки и передаются по имени. Это делает вызов самодокументируемым.

    Почему именно так: обязательные параметры названы явно, поэтому вызов не допускает двусмысленности. Опциональные имеют безопасные значения по умолчанию.

    Шаг 2. Проектируем читаемый вызов.

    Теперь код читается почти как конфигурация. Через месяц вы всё ещё поймёте, что именно происходит.

    Шаг 3. Добавляем функцию-валидатор и передаём её как зависимость.

    Почему это полезно: вы отделяете логику запуска от логики проверки. В тестах можно передать одну функцию, в боевом коде — другую.

    Шаг 4. Используем замыкание для счётчика повторных попыток.

    Почему это уместно: состояние попыток хранится рядом с логикой, не расползаясь по глобальным переменным.

    Этот пример показывает не просто синтаксис функций, а профессиональный стиль проектирования API: явные контракты, читаемые вызовы, композиция и локализация состояния.

    Частые ошибки и профессиональные приёмы

    Первая типичная ошибка — перегружать одну функцию слишком многими обязанностями. Если функция и валидирует вход, и пишет лог, и меняет состояние UI, и запускает процесс, её трудно тестировать и сопровождать.

    Вторая ошибка — злоупотреблять анонимными функциями в длинных цепочках. map, where, fold и вложенные колбэки выглядят элегантно до тех пор, пока не превращаются в «стену из стрелок». Если чтение требует усилия, вынесение в именованную функцию почти всегда оправдано.

    Третья ошибка — использовать позиционные параметры там, где важна семантика вызова. Для прикладных API desktop-приложения именованные параметры обычно выигрывают.

    Есть и тонкий момент с замыканиями: если вы захватили изменяемую коллекцию и передали функцию куда-то далеко, поведение зависит от того, кто и когда меняет эту коллекцию. Поэтому при передаче функций стоит думать не только о синтаксисе, но и о времени жизни захваченного состояния.

    Если из этой главы запомнить только три вещи — это:

  • Именованные параметры в Dart — не украшение, а способ проектировать читаемый и безопасный API, особенно когда аргументов больше двух или часть из них логические.
  • Стрелочный синтаксис хорош только для одной простой идеи: как только в функции появляется несколько шагов или побочные действия, обычный блок почти всегда лучше.
  • Функции в Dart — полноценные значения: их можно передавать, возвращать и хранить, а значит через них строятся колбэки, обработчики событий, стратегии и замыкания.
  • 3. Объектно-ориентированное программирование в Dart

    Объектно-ориентированное программирование в Dart

    Почему в одном проекте добавление новой функции занимает десять минут, а в другом — ломает три несвязанных экрана, конфиги и запуск процесса? Обычно дело не в размере проекта, а в том, как организованы объекты. Для приложения, которое управляет xray-core, хранит настройки, работает с HTTP и desktop-возможностями, беспорядочный код особенно опасен: логика быстро разрастается в разные стороны.

    Когда приложение маленькое, можно жить набором функций и парой переменных. Но как только появляются профиль подключения, конфигурация запуска, клиент API, менеджер окна, состояние трея и сервис сохранения настроек, вам нужен способ описывать сущности мира программы целостно. Для этого и используется объектно-ориентированное программирование.

    Вы наверняка уже сталкивались с идеей «шаблон плюс экземпляр» в жизни. Чертёж стула — это ещё не стул, а конкретный предмет в комнате — уже экземпляр. В Dart ровно так же: класс задаёт устройство и поведение, а объект представляет конкретную сущность с реальными данными.

    Класс и объект: чертёж и экземпляр

    Класс — это описание структуры и поведения объектов. Простыми словами, он говорит, какие данные объект хранит и что умеет делать. Объект — это конкретный экземпляр класса с реальными значениями.

    Теперь можно создать объект:

    Зачем это нужно знать? Потому что класс позволяет сгруппировать связанные данные и поведение вокруг одной сущности. Вместо трёх-четырёх разрозненных переменных вы получаете один осмысленный объект.

    Микропример: профиль подключения — это не просто строка имени и путь отдельно где-то ещё. Это единый объект, который удобно передавать между слоями приложения.

    Если бы вы хранили те же данные отдельными списками или набором несвязанных переменных, риск путаницы быстро вырос бы. ООП снижает этот хаос за счёт смысловых границ.

    Поля и методы: что объект хранит и что умеет

    Поле — это переменная внутри объекта. Метод — функция внутри класса, которая работает с его данными.

    Здесь name, configPath и autoConnect — поля, а isValid() — метод.

    Микропример: если у вас есть профиль Office, проверка его корректности логично живёт рядом с самими данными профиля, а не в случайной утилите где-то далеко.

    Это кажется удобством, но на практике это ещё и защита от рассинхронизации. Когда логика разбросана, изменения в модели данных легко забыть отразить в связанных проверках.

    Конструкторы: как объект появляется в корректном состоянии

    Конструктор — это специальный способ создать объект и сразу задать ему начальное состояние. В Dart конструкторы гибкие и при этом довольно лаконичные.

    Самый короткий вариант вы уже видели:

    Эта запись сразу связывает параметры конструктора с полями класса.

    Но профессиональный код часто требует большего контроля. Например, стоит сделать часть полей неизменяемыми через final.

    Теперь после создания объекта нельзя случайно поменять host или port. Это особенно полезно для моделей данных, которые должны быть предсказуемыми.

    Микропример: конфигурация активного подключения, переданная в процесс запуска, не должна самопроизвольно меняться посреди работы.

    Dart также поддерживает именованные конструкторы:

    Именованный конструктор полезен, когда есть несколько осмысленных сценариев создания объекта. Например, загрузка из файла, создание пустого профиля, генерация настроек по умолчанию.

    Инициализирующие списки и валидация на входе

    Иногда поле нужно вычислить до входа в тело конструктора или проверить аргументы сразу при создании. Для этого в Dart есть initializer list и assert.

    Здесь объект нельзя создать с некорректным портом. Это не просто удобство, а способ сохранить модель в валидном состоянии с самого начала.

    Микропример: если приложение допускает порт 70000, ошибка вылезет позже и в странном месте. Лучше остановить некорректные данные при создании объекта.

    Важно понимать edge case: assert активно помогает в разработке, но не должен быть единственной линией бизнес-валидации там, где данные приходят от пользователя или внешнего API. Для пользовательских сценариев лучше дополнять его обычной проверкой и сообщением об ошибке.

    Инкапсуляция: скрывать детали, а не информацию

    Инкапсуляция — это организация класса так, чтобы внешний код видел только нужный интерфейс, а внутренние детали оставались скрытыми. Простыми словами: объект должен давать понятные способы взаимодействия, а не позволять всем подряд менять всё что угодно.

    В Dart приватность работает на уровне файла с помощью _ в начале имени.

    Здесь внешний код может читать isRunning, но не может напрямую присвоить что-то в _isRunning.

    !Связи между классами, наследованием, интерфейсами и миксинами в Dart

    Зачем это нужно? Чтобы защищать объект от неконтролируемого изменения состояния. Если любой участок программы сможет поставить _isRunning = true, ваш контроллер перестанет быть надёжным источником истины.

    Микропример: процесс может считаться запущенным только после успешного старта реального Process. Прямое изменение флага без запуска процесса создало бы ложное состояние UI.

    Геттеры и сеттеры

    Геттер — способ читать значение как свойство. Сеттер — контролируемый способ записывать значение.

    Такой подход полезен, когда нужно фильтровать или нормализовать входные данные.

    Но здесь есть профессиональный нюанс: если сеттер начинает содержать сложную бизнес-логику, иногда лучше заменить его явным методом вроде changeTheme(). Метод лучше выражает действие и позволяет позже расширить контракт без сюрпризов.

    Наследование: когда «является видом» действительно уместно

    Наследование позволяет одному классу расширять другой, получая его поля и методы. Это мощный инструмент, но использовать его стоит аккуратно.

    RemoteConfig наследует поле name из BaseConfig и добавляет url.

    Микропример: если у вас есть общий набор данных для конфигураций и частный случай сетевого конфига, наследование может быть уместно.

    Но типичная ошибка новичков — применять наследование ради повторного использования кода там, где между сущностями нет отношения «является видом». В прикладной архитектуре часто лучше композиция, когда объект содержит другой объект, а не наследуется от него.

    Микропример: XrayProcessManager не является видом Logger. Он просто использует логгер. Значит, лучше передать логгер как зависимость, а не наследоваться.

    Абстрактные классы и интерфейсы: контракт важнее реализации

    Абстрактный класс — это класс, который нельзя создать напрямую; он задаёт контракт и, при желании, часть общей логики.

    Теперь конкретные реализации обязаны определить load().

    В Dart любой класс может выступать как интерфейс через implements. Это означает: «я обязуюсь реализовать весь публичный контракт».

    Зачем это нужно? Интерфейсы и абстракции позволяют писать код против контракта, а не против конкретной реализации. Это особенно важно для тестирования и архитектуры.

    Микропример: сервис загрузки конфигурации может работать с ConfigSource, не зная, приходит ли конфиг из файла, сети или встроенного шаблона.

    Профессиональный плюс здесь огромен: вам легче подменять реализации в тестах и проще расширять проект без переписывания старой логики.

    Миксины: добавить поведение без жёсткой иерархии

    Миксин — это способ переиспользовать поведение в нескольких классах без классического наследования по цепочке.

    Почему так: конкретная реализация знает детали работы с файлом, но остальная система видит только ConfigSource.

    Шаг 4. Делаем контроллер с инкапсуляцией.

    Почему так: внешнему коду не дают напрямую менять _status. Это защищает согласованность состояния.

    Шаг 5. Подумать об edge case.

    Если запуск процесса реально не удался, нельзя просто выставлять isRunning: true. Значит, в боевом приложении метод start() должен обновлять статус только после успешного запуска Process.start и корректно обрабатывать ошибки. ООП здесь помогает тем, что ответственность за такую логику сосредоточена в одном объекте.

    Когда ООП помогает, а когда мешает

    ООП полезно, когда вы моделируете долгоживущие сущности с состоянием и поведением: профиль, конфиг, контроллер процесса, клиент API, хранилище настроек. Но оно мешает, когда вы создаёте классы ради классов.

    Типичный антипример — «утилитарный бог-объект», который знает всё: и про UI, и про сеть, и про файлы, и про окно, и про трэй. Формально это тоже класс, но архитектурно это центр хаоса.

    Ещё один антипример — глубокие иерархии наследования. В Dart и Flutter чаще выигрывают простые модели, композиция и явные зависимости. Наследование полезно, но не должно становиться привычкой «по умолчанию».

    Если из этой главы запомнить только три вещи — это:

  • Класс в Dart нужен не ради теории, а ради смысловой упаковки данных и поведения, чтобы сущности вроде профиля, статуса процесса или источника конфигурации имели чёткие границы.
  • Инкапсуляция защищает согласованность состояния: если значение нельзя честно менять снаружи, его не стоит делать публичным полем.
  • extends, implements и with выражают разные отношения: наследование, контракт и подмешивание поведения нельзя выбирать наугад как взаимозаменяемые инструменты.
  • 4. Коллекции, async/await, Future и Stream

    Коллекции, async/await, Future и Stream

    Почему приложение может «подвиснуть» от одного невинного запроса к файлу или сети? Потому что современная программа почти никогда не живёт в мире мгновенных ответов. Она ждёт конфиг с диска, ответ от API, завершение процесса, событие из системного трея, изменение настроек и при этом должна оставаться отзывчивой. Именно здесь встречаются две большие темы Dart: коллекции и асинхронность.

    В desktop-приложении для управления xray-core коллекции будут хранить профили, параметры, заголовки HTTP, список серверов, набор активных маршрутов. Асинхронность будет сопровождать почти каждый реальный шаг: чтение файла, запуск процесса, запросы через dio, сохранение настроек, ожидание событий процесса. Если не понять эти механизмы глубоко, код быстро начинает вести себя «иногда работает, иногда нет».

    Вы уже наверняка пользовались списками в других языках и сталкивались с идеей «подождать ответ». В Dart это всё оформлено довольно элегантно, но важно не запомнить отдельные слова, а увидеть модель: коллекции организуют данные, а Future и Stream описывают, как значения приходят во времени.

    List, Set, Map: три способа хранить группу данных

    Коллекция — это структура, которая хранит несколько значений по определённым правилам. В Dart чаще всего вы будете работать с тремя типами: List, Set и Map.

    List: порядок важен, элементы могут повторяться

    List — это упорядоченная последовательность элементов, к которым можно обращаться по индексу.

    Здесь profiles[0]'Home'.

    List подходит, когда порядок имеет значение или когда вы собираетесь показывать элементы в UI как список.

    Микропример: список профилей подключения в боковой панели — естественный кандидат для List, потому что пользователь видит их в определённой последовательности.

    Но важно помнить edge case: обращение к несуществующему индексу вызывает ошибку. Если список может быть пустым, нельзя вслепую брать profiles[0].

    Set: уникальность важнее порядка

    Set — это набор уникальных значений. Если попытаться добавить дубликат, второй экземпляр не появится.

    Set полезен, когда сама идея данных требует уникальности.

    Микропример: если вы храните набор активных транспортных протоколов или список уникальных стран серверов, Set избавляет от повторов автоматически.

    Новички часто используют List там, где на самом деле нужен Set, а затем отдельно пишут логику удаления дублей. Это работает, но усложняет код без причины.

    Map: ключ и значение

    Map хранит пары «ключ — значение». Это удобно, когда данные естественно адресуются по имени или идентификатору.

    Вы можете получить значение по ключу:

    Микропример: параметры HTTP-заголовков, словарь настроек, соответствие profileId -> profileName — типичные случаи для Map.

    Здесь есть тонкий момент: если ключа нет, Map вернёт null. Это значит, что чтение из карты почти всегда связано с вопросом nullable-типов.

    !Сравнение List, Set, Map и выбор между Future и Stream

    Типизированные коллекции: не просто удобство, а защита

    Dart позволяет делать коллекции строго типизированными:

    Это важно, потому что коллекция без точного типа быстро становится источником ошибок. Если в List<dynamic> случайно попадут и строки, и числа, и карты, любая последующая обработка усложняется.

    Микропример: List<ServerProfile> говорит вам и компилятору, что здесь лежат именно профили серверов, а не случайный набор объектов.

    Когда вы начнёте работать с JSON, temptation использовать Map<String, dynamic> будет очень сильной. Это нормально на границе ввода-вывода, но внутри бизнес-логики лучше как можно раньше преобразовывать сырые структуры в типизированные модели.

    Основные операции с коллекциями

    Списки, множества и карты — это не только хранение, но и преобразование данных. Особенно часто в Dart используются map, where, any, every, fold.

    Здесь where фильтрует элементы, а toList() собирает результат обратно в список.

    Микропример: если вы показываете только активные профили, фильтрация списка по флагу isEnabled — обычная задача для where.

    Важно не путать тип Map и метод .map(). Метод map() у коллекций означает преобразование элементов, а Map как тип — это словарь.

    Есть и частая ловушка с ленивостью некоторых операций. Например, where возвращает не готовый список, а итерируемую последовательность. Это обычно удобно, но если исходная коллекция потом изменится, результат может оказаться не тем, что вы ожидали. Когда нужен «снимок сейчас», полезно делать .toList().

    Что значит «асинхронный код» в Dart

    Пока коллекции организуют данные в пространстве, асинхронность организует их во времени.

    Асинхронная операция — это задача, результат которой появится позже: чтение файла, запрос к API, запуск процесса, ожидание события. Простыми словами: вы запускаете действие сейчас, а ответ получаете потом.

    В синхронном коде всё идёт строго по порядку и блокирует поток выполнения. В UI-приложении это опасно: если вы на несколько секунд заблокируете главный поток, интерфейс перестанет реагировать.

    Микропример: если приложение ждёт сетевой ответ и в это время нельзя нажать кнопку, переместить окно или открыть меню трея, пользователь воспринимает программу как зависшую.

    Dart решает это через Future и Stream.

    Future: одно значение в будущем

    Future — это объект, который обещает дать один результат позже: либо успешное значение, либо ошибку. Это лучший способ думать о запросе к сети, чтении файла или запуске процесса.

    Если функция помечена async, она автоматически возвращает Future, даже если внутри вы пишете почти «обычный» код.

    Микропример: загрузка текстового конфига с диска — это один будущий результат. Либо файл прочитан, либо произошла ошибка.

    await: подождать без блокировки UI

    Ключевое слово await позволяет писать асинхронный код в почти синхронном стиле.

    Здесь выполнение функции приостанавливается до готовности loadConfig(), но главный поток UI не замораживается.

    Это и есть главная практическая ценность async/await: код остаётся читаемым без громоздких цепочек колбэков.

    !Пошаговая работа async/await, Future и Stream во времени

    Обработка ошибок в Future

    Асинхронный код может завершаться ошибкой так же, как и синхронный. Но ошибки нужно ловить осознанно.

    Почему так: UI должен либо получить список и показать его, либо отреагировать на ошибку контролируемо.

    Сценарий 2: поток логов процесса

    Теперь представьте, что xray-core запущен и постоянно пишет строки в stdout. Это уже не один ответ, а поток.

    Шаг 3. Подписываемся на события через await for.

    Почему так: каждую строку хочется показывать сразу, а не ждать завершения всего процесса.

    Что здесь особенно важно

    Если вы попытаетесь сделать логи как Future<List<String>>, интерфейс получит всё только в конце — а для живого окна логов это бесполезно. Если же список профилей оформить как Stream, вы усложните обработку без выигрыша. Именно различие во временной природе данных определяет выбор между Future и Stream.

    Частые ошибки и edge cases

    Первая ошибка — забывать await. Тогда вместо результата вы работаете с объектом Future, а не с готовыми данными. В простом случае это сразу видно по типам, но в цепочках логики ошибка может быть неочевидной.

    Вторая ошибка — не ловить асинхронные ошибки. Если запуск процесса, чтение файла или запрос к API падают без try/catch, приложение может перейти в странное состояние: например, индикатор загрузки останется висеть вечно.

    Третья ошибка — смешивать «данные ещё загружаются» и «данных нет». Для UI это разные состояния. Пустой список профилей и ещё не завершённая загрузка — не одно и то же.

    Четвёртая ошибка — бесконечный Stream без отписки. В desktop-приложении это может приводить к утечкам ресурсов: окно уже закрыто, а подписка на логи или события процесса всё ещё жива.

    Если из этой главы запомнить только три вещи — это:

  • List, Set и Map выбираются по смыслу данных: порядок, уникальность или адресация по ключу, а не «что первое пришло в голову».
  • Future — это один результат позже, Stream — серия значений во времени: выбор между ними определяется природой данных, а не личным стилем.
  • async/await делает код читаемым, но не отменяет необходимость думать об ошибках, времени жизни подписок и состоянии UI во время ожидания.
  • 5. Основы Flutter: Widgets, StatelessWidget, StatefulWidget

    Основы Flutter: Widgets, StatelessWidget, StatefulWidget

    Почему в Flutter почти всё называется Widget, даже текст, кнопка, отступ и целый экран? Потому что Flutter предлагает не набор разрозненных UI-элементов, а единую модель интерфейса, где всё строится из маленьких описаний. Если не понять эту идею в самом начале, дальше Column, темы, навигация и Riverpod будут казаться набором приёмов без внутренней логики.

    Для desktop-приложения управления xray-core это особенно важно. Вам нужен интерфейс, который показывает статусы процесса, список профилей, кнопки запуска и остановки, окно настроек, лог-панель, состояние соединения. Всё это придётся собирать из виджетов. И качество приложения во многом зависит не от «красоты кнопок», а от того, насколько правильно вы понимаете, что именно Flutter просит от разработчика: не управлять пикселями вручную, а описывать текущее состояние интерфейса.

    Вы наверняка уже видели в других GUI-фреймворках идею компонентов. Во Flutter она доведена до предела: UI — это дерево виджетов, а обновление интерфейса — это перестроение описания, а не ручное изменение каждой детали на экране.

    Widget: не «элемент на экране», а декларация интерфейса

    Widget — это неизменяемое описание части интерфейса. Простыми словами, виджет говорит Flutter: «вот как этот кусок UI должен выглядеть сейчас». Он не является самим пиксельным объектом на экране в грубом смысле; это скорее конфигурация.

    Микропример: Text('Running') — это не «живой текстовый блок, который вы потом редактируете вручную», а описание: сейчас здесь должен быть текст Running.

    Это важная смена мышления. Во многих старых UI-системах вы создаёте элемент и потом мутируете его свойства. Во Flutter чаще вы описываете новый UI для нового состояния, а фреймворк сам эффективно обновляет отображение.

    Дерево виджетов: интерфейс как вложенная структура

    Widget tree — это иерархия виджетов, где одни содержат другие. Например, экран может содержать Scaffold, внутри него Column, внутри колонки — Text, ElevatedButton, SizedBox и так далее.

    Точка входа runApp() получает корневой виджет. Обычно это приложение верхнего уровня.

    Даже такой крошечный пример уже показывает идею: один виджет возвращает другой, тот содержит следующий, и так строится всё приложение.

    Микропример: будущее окно клиента может выглядеть как MaterialApp -> Scaffold -> Column -> Card -> Row -> Text + Button. Это не хаос вложенности, а точная карта интерфейса.

    !Дерево виджетов и различие StatelessWidget/StatefulWidget

    Почему виджеты неизменяемы

    Это одна из самых важных особенностей Flutter. Виджеты в Flutter immutable, то есть их поля не меняются после создания. Сначала это может раздражать: «Почему нельзя просто обновить текст кнопки?» Но именно благодаря этому обновление UI становится предсказуемым.

    Когда состояние изменилось, Flutter не просит вас вручную менять существующий виджет. Вместо этого вы создаёте новое описание, а фреймворк сравнивает и перестраивает нужные части дерева.

    Микропример: вместо «измени надпись старой кнопки» вы делаете так, чтобы при новом состоянии isRunning = true метод build() вернул Text('Остановить') вместо Text('Запустить').

    Этот подход особенно хорошо сочетается с управлением состоянием через Riverpod, к которому вы скоро перейдёте.

    StatelessWidget: UI без внутреннего изменяемого состояния

    StatelessWidget — это виджет, чьё отображение полностью определяется входными данными и не меняется изнутри самого виджета. Простыми словами: он просто рисует то, что ему дали.

    Здесь виджет сам не хранит изменяемое состояние. Он получает isRunning снаружи и строит UI на его основе.

    Микропример: бейдж статуса процесса, строка версии приложения, заголовок окна, иконка протокола — всё это часто идеально укладывается в StatelessWidget.

    Новички иногда думают, что StatelessWidget нужен только для «совсем простых» компонентов. На практике в хорошем Flutter-коде их очень много. Чем больше UI можно выразить через чистые входные данные, тем легче его тестировать и сопровождать.

    StatefulWidget: когда UI должен реагировать на локальные изменения

    StatefulWidget нужен, когда часть состояния живёт рядом с виджетом и влияет на его внешний вид во времени. Сам объект StatefulWidget тоже неизменяем, но он создаёт отдельный объект State, в котором и хранится изменяемое состояние.

    Это первый важный шаблон Flutter: состояние хранится не в виджете, а в связанном объекте State.

    Микропример: переключатель вкладки логов, локальное раскрытие панели, временное состояние поля ввода, выбранный элемент в маленьком локальном компоненте — разумные случаи для StatefulWidget.

    Что делает setState

    setState сообщает Flutter, что внутреннее состояние этого объекта изменилось и нужно заново вызвать build() для соответствующего участка дерева.

    Простыми словами: setState не рисует интерфейс сам и не «меняет текст напрямую». Он лишь запускает цикл обновления, после которого ваш build() снова опишет UI уже для новых данных.

    Микропример: пользователь нажал кнопку «Показать расширенные настройки». Вы меняете isExpanded внутри setState, а затем build() возвращает дополнительный блок настроек.

    Здесь важно понимать профессиональный нюанс: в setState стоит помещать только само изменение состояния, а не тяжёлую логику, сетевые запросы и длинные вычисления. Иначе обновление UI становится трудно прослеживать.

    BuildContext: где вы находитесь в дереве

    BuildContext — это объект, который связывает виджет с его положением в дереве. Простыми словами, через context Flutter понимает, где именно сейчас строится этот кусок UI и к каким родительским данным он может обратиться.

    Через context вы позже будете получать тему, навигатор, размеры, локализацию и многое другое.

    Микропример: когда кнопка открывает новый экран или получает цвета из текущей темы, это происходит не «из воздуха», а через положение виджета в дереве.

    Новички часто воспринимают context как что-то мистическое. На деле это адрес виджета внутри структуры интерфейса. Поэтому его нельзя бездумно хранить надолго и использовать после того, как виджет уже удалён из дерева.

    Жизненный цикл StatefulWidget

    У StatefulWidget есть жизненный цикл, и это критично для реальных приложений.

    Основные точки, которые важно знать:

  • createState() — создаётся объект состояния
  • initState() — одноразовая инициализация
  • build() — построение UI, вызывается многократно
  • dispose() — освобождение ресурсов перед уничтожением
  • Микропример: если вы подписались на поток логов процесса или создали TextEditingController, освобождать ресурсы надо в dispose(). Иначе в desktop-приложении легко получить утечку памяти или висящие подписки.

    !Жизненный цикл StatefulWidget и вызов setState

    Когда использовать StatefulWidget, а когда нет

    Это один из самых важных практических вопросов.

    Выбирайте StatelessWidget, если:

  • UI зависит только от входных параметров
  • состояние приходит из родителя или из Riverpod
  • внутри не нужно локально менять данные во времени
  • Выбирайте StatefulWidget, если:

  • есть локальное, короткоживущее UI-состояние
  • нужны контроллеры, анимации, подписки
  • нужен жизненный цикл initState и dispose
  • Микропример: экран списка профилей, данные для которого приходят из провайдера, может быть ConsumerWidget или StatelessWidget. Но поле ввода с TextEditingController часто требует StatefulWidget.

    Частая ошибка новичков — делать StatefulWidget «на всякий случай». Это увеличивает количество состояния и затрудняет понимание того, где находится источник истины.

    Worked example: маленькая панель управления процессом

    Представьте компактный блок в desktop-приложении: статус процесса, кнопка запуска и переключатель показа логов.

    Сначала разберём, что здесь локальное, а что нет. Реальный факт запуска xray-core — это важное бизнес-состояние, которое позже лучше хранить во внешнем state management, а не локально. Но временное раскрытие лог-панели — типичное локальное UI-состояние.

    Шаг 1. Отдельный статический статус делаем через StatelessWidget.

    Почему так: этот компонент ничего не знает о том, как меняется состояние. Он только отображает входные данные.

    Шаг 2. Локальное раскрытие логов делаем через StatefulWidget.

    Почему так: состояние showLogs относится только к этому визуальному блоку и не обязательно выносить в глобальное состояние.

    Шаг 3. Подумать об edge case.

    Если вы позже решите загружать реальные логи по подписке на Stream, внутри этого StatefulWidget уже может понадобиться StreamSubscription и dispose(). Именно поэтому понимание жизненного цикла нужно не «для галочки», а для реальной устойчивости интерфейса.

    Типичные ошибки новичков

    Первая ошибка — менять переменную в State без setState. Тогда данные меняются, но build() не вызывается, и UI остаётся старым. Возникает ощущение, что «Flutter глючит», хотя проблема в том, что фреймворку не сообщили об обновлении.

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

    Третья ошибка — помещать сетевые запросы прямо в build(). Поскольку build() может вызываться много раз, вы рискуете многократно запускать одну и ту же тяжёлую операцию.

    Четвёртая ошибка — использовать BuildContext после удаления виджета. Например, после долгого await виджет мог уже исчезнуть из дерева, и попытка открыть диалог через старый context приведёт к проблемам. Именно поэтому дальше вы встретите проверки вроде if (!mounted) return;.

    Если из этой главы запомнить только три вещи — это:

  • Widget во Flutter — это неизменяемое описание UI, а не объект, который обычно редактируют вручную по месту.
  • StatelessWidget отображает данные, StatefulWidget хранит локальное состояние через объект State: различие не формальное, а архитектурное.
  • setState не меняет интерфейс напрямую, а просит Flutter перестроить UI для нового состояния, поэтому важны и правильное место хранения состояния, и жизненный цикл виджета.
  • 6. Компоновка интерфейса: Column, Row, Stack, Expanded и Material 3

    Компоновка интерфейса: Column, Row, Stack, Expanded и Material 3

    Почему интерфейс, который отлично выглядит в маленьком примере, внезапно разваливается на реальном desktop-окне при изменении размера? Потому что хороший UI — это не просто набор кнопок и текстов, а правильная модель раскладки. В Flutter особенно важно рано понять, как элементы получают ограничения по размеру и как контейнеры договариваются о пространстве.

    Для приложения управления xray-core это не абстракция. У вас будет окно, которое пользователь может растягивать, сворачивать, прятать в трэй. Внутри — панель профилей, лог-окно, кнопки управления процессом, блок настроек, строки статуса, карточки соединений. Если layout построен случайно, уже на первом десктопном прототипе появятся переполнения, странные отступы, кнопки «улетающие» за край и лог-панель, съедающая весь экран.

    Вы наверняка видели в вёрстке идеи вертикального и горизонтального расположения. В Flutter это выражается через несколько базовых виджетов, но за ними стоит более общая логика: родитель задаёт ограничения, ребёнок выбирает размер, родитель размещает ребёнка. Как только вы это почувствуете, Column, Row, Stack и Expanded перестанут быть набором отдельных трюков.

    Как Flutter раскладывает интерфейс

    Flutter использует модель constraints go down, sizes go up, parent sets position. Простыми словами:

  • родитель говорит ребёнку, сколько места ему можно занять
  • ребёнок выбирает размер в этих рамках
  • родитель решает, где разместить ребёнка
  • Это фундамент layout-системы. Большинство «странных» визуальных ошибок происходят не потому, что Flutter капризен, а потому что разработчик не учёл, какие ограничения пришли от родителя.

    Микропример: если Row даёт своим детям неограниченную ширину на уровне их естественного размера, длинный текст может не уместиться и вызвать overflow. Проблема не в тексте, а в сочетании ограничений и выбора размера.

    Column: вертикальная ось

    Column располагает детей по вертикали сверху вниз. Это один из самых частых контейнеров в Flutter.

    Column хорош, когда элементы образуют вертикальный поток: заголовок, подзаголовок, кнопки, поля формы.

    Микропример: левая панель настроек с секциями «Профиль», «Сетевые параметры», «Автозапуск» почти естественно строится через Column.

    Но у Column есть важный нюанс: если детей много и они не помещаются по высоте, Column сама по себе не прокручивается. Для длинного содержимого нужен SingleChildScrollView, ListView или другая скроллируемая структура.

    Row: горизонтальная ось

    Row раскладывает детей слева направо.

    Row подходит для небольших горизонтальных групп: иконка плюс текст, кнопки в одной строке, бейдж статуса рядом с таймером.

    Микропример: строка статуса внизу окна может содержать индикатор процесса, название активного профиля и кнопку «Показать логи».

    Здесь и проявляется одна из самых частых ошибок: Row не умеет сам magically «ужимать» длинный текст. Если несколько детей хотят больше ширины, чем есть, вы увидите жёлто-чёрные полосы overflow. Обычно это лечится через Expanded, Flexible или изменение структуры layout.

    Управление выравниванием в Column и Row

    У обоих виджетов есть две важные оси:

  • main axis — основная ось раскладки
  • cross axis — поперечная ось
  • Для Column основная ось вертикальная, для Row горизонтальная.

    Соответственно:

  • mainAxisAlignment управляет распределением по основной оси
  • crossAxisAlignment — по поперечной
  • Микропример: если в карточке подключения нужно прижать тексты к левому краю, crossAxisAlignment: CrossAxisAlignment.start часто обязателен, иначе элементы могут визуально выглядеть «по центру» и ломать читаемость.

    Профессиональный нюанс: начинающие разработчики нередко добавляют лишние контейнеры только ради выравнивания, хотя достаточно правильно настроить оси в уже существующем Row или Column.

    Expanded и Flexible: как делить доступное пространство

    Expanded — это виджет, который говорит родителю Row, Column или Flex: «заставь меня занять доступное свободное место». Это один из ключей к адаптивному layout.

    Здесь текст получает гибкое пространство, а иконки сохраняют свой размер.

    Микропример: в строке списка профилей название может быть длинным, но кнопка запуска и иконка удаления должны оставаться видимыми. Expanded решает именно такую задачу.

    Flexible похож на Expanded, но даёт ребёнку больше свободы занимать меньше полного доступного пространства, если он может. На первых этапах чаще используется Expanded, но полезно знать различие.

    !Схема Column, Row, Stack, Expanded и Material 3-компонентов

    flex: доли пространства

    У Expanded и Flexible есть параметр flex, который определяет, как делится свободное место между несколькими гибкими детьми.

    Свободная ширина поделится в отношении 2:1.

    Микропример: в основном окне лог-панель может занимать примерно две трети ширины, а панель действий — одну треть. Вместо жёстких пикселей это удобно выразить через flex.

    !Изменение распределения пространства через Expanded и flex

    Stack: элементы поверх друг друга

    Stack позволяет размещать детей слоями, а не только в одну линию. Это полезно, когда нужен фон, наложение бейджа, плавающая кнопка или декоративная подложка.

    Микропример: карточка соединения может иметь цветной фон статуса, а в правом верхнем углу — маленький индикатор ошибки или предупреждения.

    Stack не должен становиться универсальным способом «поставить всё куда хочется». Если обычная структура решается через Row и Column, лучше использовать их. Иначе layout становится трудно поддерживать и адаптировать под разные размеры окна.

    Отступы и дыхание интерфейса

    Технически тема статьи про layout-контейнеры, но нельзя построить хороший UI без понимания отступов. Во Flutter чаще всего используются:

  • Padding
  • SizedBox
  • иногда Spacer
  • Микропример: если между заголовком и полем ввода нет воздуха, desktop-интерфейс сразу выглядит «склеенным», даже если все элементы технически на месте.

    Профессиональная привычка — использовать консистентную сетку отступов: например 4, 8, 12, 16, 24 пикселя, а не случайные числа.

    Material 3: не только внешний вид, но и система компонентов

    Material 3 — это современная дизайн-система Flutter, которая даёт готовые компоненты, токены темы, цветовые роли и более предсказуемое поведение UI. Для desktop-приложения это важно не меньше, чем для мобильного: интерфейс становится визуально цельным и быстрее собирается из стандартных деталей.

    Чтобы включить Material 3, обычно используется:

    Микропример: вместо ручной стилизации каждой кнопки вы получаете системно согласованные FilledButton, OutlinedButton, Card, NavigationRail, TextField.

    Какие компоненты особенно полезны для desktop-инструмента

    Для клиента управления xray-core особенно полезны:

  • Scaffold — каркас страницы
  • AppBar или кастомный верхний бар
  • Card — группировка блоков
  • FilledButton, OutlinedButton, IconButton
  • TextField для настроек
  • Switch и Checkbox
  • NavigationRail или боковая навигация
  • Dialog для подтверждений
  • Микропример: блок «Активный профиль» в Card читается лучше, чем набор текстов без контейнера. OutlinedButton для вторичных действий визуально не спорит с главной кнопкой «Запустить».

    Неочевидная ловушка Material 3

    Новички иногда воспринимают Material 3 как «просто включить флаг». Но без продуманной темы, правильных отступов и выбора компонентов интерфейс всё равно может выглядеть сырым. Material 3 даёт систему, а не заменяет дизайн-мышление.

    Worked example: каркас главного окна desktop-клиента

    Представьте главное окно будущего приложения. Слева — список профилей, справа — основная зона с карточкой статуса и панелью логов.

    Шаг 1. Верхний каркас через Scaffold и внешний Row.

    Почему так: desktop-окно естественно делится на две вертикальные области. flex лучше жёсткой ширины, потому что окно можно растягивать.

    Шаг 2. Левая панель профилей — Column внутри Padding.

    Сверху заголовок, ниже список, внизу кнопка добавления. Почему именно Column: структура вертикальная, логичная и легко читается.

    Шаг 3. Правая часть — тоже Column, где сверху карточка статуса, ниже блок управления, а затем Expanded лог-панель.

    Почему так: логи должны занимать всё оставшееся место, а верхние карточки — только свой естественный размер.

    Шаг 4. Внутри строки статуса используем Row, где длинное имя профиля оборачиваем в Expanded.

    Почему так: именно текст должен быть гибким, а иконки и кнопки должны сохранять фиксированный размер.

    Шаг 5. Для бейджа ошибки добавляем Stack поверх карточки, а не ломаем основную вертикальную структуру.

    Почему так: наложение — это отдельная задача, и Stack здесь работает адресно, а не заменяет весь layout.

    Этот кейс показывает главное: хороший интерфейс строится не за счёт магических компонентов, а за счёт осмысленного распределения пространства и ответственности между контейнерами.

    Частые ошибки и edge cases

    Первая ошибка — использовать фиксированные размеры там, где окно должно адаптироваться. На desktop это особенно болезненно: пользователь меняет размер окна, и layout ломается.

    Вторая ошибка — вкладывать Column в Column без понимания ограничений по высоте. Если внутренний блок хочет бесконечную высоту, а родитель не может её дать, появляются ошибки layout.

    Третья ошибка — пытаться решить переполнение через Stack. На деле проблема обычно в неверном распределении пространства, а не в недостатке слоёв.

    Четвёртая ошибка — визуально смешивать первичные и вторичные действия. Material 3 помогает различать иерархию кнопок, и этим стоит пользоваться: «Запустить» может быть FilledButton, а «Открыть папку логов» — OutlinedButton.

    Если из этой главы запомнить только три вещи — это:

  • Flutter-layout начинается с ограничений, а не с пикселей: родитель задаёт рамки, ребёнок выбирает размер, родитель размещает.
  • Column, Row, Stack и Expanded решают разные задачи: поток по оси, слоистость и гибкое деление пространства не стоит смешивать без причины.
  • Material 3 даёт систему компонентов и визуальной иерархии, но хороший интерфейс всё равно опирается на правильную раскладку, отступы и продуманные сценарии desktop-окна.
  • 7. Навигация с go_router и управление темами

    Навигация с go_router и управление темами

    Почему приложение может быть технически рабочим, но ощущаться как хаотичный набор окон? Чаще всего проблема не в кнопках, а в двух системных вещах: маршрутизации и визуальной целостности. Если пользователь не понимает, где он находится и почему один экран выглядит иначе, чем другой, интерфейс начинает сопротивляться использованию.

    Для desktop-приложения управления xray-core это особенно заметно. У вас, скорее всего, будут экран главной панели, окно настроек, страница профилей, экран диагностики, возможно, журнал логов или мастер первого запуска. Если навигация сделана на скорую руку, код начинает зависеть от случайных push и pop, а UI теряет структуру. Если тема не централизована, одно окно будет синим, другое серым, третье с неожиданными отступами и случайными стилями кнопок.

    Вы уже знаете, что Flutter строит интерфейс как дерево виджетов. Навигация и темы продолжают ту же идею: это не отдельные хаки, а системные механизмы, которые задают структуру приложения целиком.

    Что такое навигация во Flutter на практике

    Навигация — это способ перемещения пользователя между экранами и состояниями приложения. Простыми словами, она отвечает на вопрос: какой экран показывать сейчас и как попасть на другой.

    В маленьких примерах часто используют Navigator.push(), и это нормально для знакомства. Но как только приложение растёт, появляются проблемы:

  • сложнее видеть всю карту экранов
  • параметры маршрутов передаются разрозненно
  • редиректы и guards становятся неудобными
  • deep links и desktop-сценарии требуют большей явности
  • Именно поэтому в современных Flutter-проектах часто выбирают go_router.

    Микропример: если при старте приложения нужно решить, показать главный экран или страницу первичной настройки пути к xray-core, простые ручные переходы быстро становятся неудобными. Нужен централизованный слой маршрутов.

    Почему go_router удобнее для реального приложения

    go_router — это пакет маршрутизации, который позволяет описывать структуру экранов декларативно: через дерево маршрутов, пути, редиректы и параметры. Простыми словами, вы не просто «толкаете новый экран поверх», а проектируете карту приложения.

    Базовая идея выглядит так:

    В MaterialApp.router вы передаёте именно этот роутер.

    Микропример: экран / может быть главной панелью клиента, а /settings — настройками путей, прокси-параметров и поведения окна.

    Главное достоинство такого подхода — структура приложения становится видимой в одном месте. Это улучшает и читаемость, и поддержку, и масштабирование.

    go, push и разница между заменой и стеком

    В go_router важно различать два частых сценария перехода:

  • go — перейти на маршрут, заменив текущее навигационное состояние соответствующим путём
  • push — добавить новый экран поверх текущего стека
  • На практике go часто используется для основных разделов приложения, а push — для дополнительных страниц или деталей.

    Микропример: переход между /profiles и /settings логично делать через go, потому что это основные разделы. Открытие страницы детального редактирования конкретного профиля поверх списка может быть push.

    Новички нередко используют один стиль везде и затем удивляются странному поведению кнопки «назад». В desktop-приложении это особенно заметно, если вы хотите предсказуемый UX.

    Параметры маршрута: путь и extra

    Реальное приложение почти всегда требует передавать данные в маршрут. У go_router для этого есть несколько путей.

    Path parameters

    Если часть данных логично встроена в URL-путь, можно использовать параметры маршрута.

    Микропример: /profiles/home может открывать профиль home, а /profiles/office — профиль office.

    Query parameters

    Подходят для опциональных настроек состояния экрана.

    Микропример: /logs?level=debug может открывать лог-экран уже с выбранным фильтром уровня.

    extra

    Иногда удобнее передать готовый объект через extra, но с этим стоит быть осторожнее: такой способ менее удобен для deep links и восстановления состояния.

    Микропример: если вы уже выбрали объект профиля в памяти и хотите быстро передать его на экран предпросмотра, extra может быть допустим. Но если экран должен уметь открываться и по прямому пути, надёжнее передавать идентификатор и догружать данные.

    Redirect: навигационная логика доступа

    Redirect — это механизм, который позволяет автоматически перенаправить пользователя на другой маршрут в зависимости от состояния приложения.

    Это очень полезно в desktop-клиенте. Например:

  • если путь к xray-core ещё не настроен, отправить пользователя на /setup
  • если приложение заблокировано незавершённой миграцией настроек, не пускать на главный экран
  • если пользователь уже завершил первичную настройку, не показывать мастер снова
  • Пример идеи:

    Микропример: при первом запуске пользователь ещё не указал путь к бинарнику. Даже если код пытается открыть главную страницу, redirect отправит его на обязательную настройку.

    !Структура маршрутов и темы приложения во Flutter

    Важный нюанс: redirect не должен быть хаотичной бизнес-логикой

    Redirect мощный, но опасный инструмент. Если в него складывать случайные проверки, навигация превращается в трудноотлаживаемую систему. Хорошая практика — держать редиректы короткими, детерминированными и завязанными на хорошо определённое состояние приложения.

    Микропример: «есть ли базовая настройка пути к xray-core» — хороший критерий для redirect. «какая кнопка была нажата три экрана назад» — уже плохой.

    ShellRoute и структура desktop-приложения

    Когда приложение имеет общий каркас и несколько внутренних разделов, полезен ShellRoute. Он позволяет сделать общий контейнер — например, окно с боковой навигацией — и внутри него переключать содержимое.

    Микропример: слева у вас постоянная навигационная панель с разделами «Главная», «Профили», «Настройки», «Логи», а справа меняется активный экран. ShellRoute особенно хорошо подходит именно для таких desktop-макетов.

    Это архитектурно важнее, чем кажется. Вместо того чтобы пересоздавать весь интерфейс целиком для каждого раздела, вы выделяете устойчивый каркас приложения.

    Тема: почему это не только про цвета

    Тема во Flutter — это централизованное описание визуальных правил приложения: цветов, типографики, поведения компонентов, форм, размеров и других аспектов. Простыми словами, тема делает так, чтобы интерфейс выглядел как одно приложение, а не как набор случайных экранов.

    Базовый вход — ThemeData.

    Микропример: если у вас основная акцентная роль — фиолетово-синяя, ColorScheme.fromSeed помогает получить согласованную палитру для кнопок, карточек, выделений и состояний.

    ColorScheme: основа современной темы

    В Material 3 важнее думать не отдельными цветами кнопок, а ColorScheme — набором цветовых ролей. Это делает кастомизацию системной.

    Ключевая идея такая: вместо «эта кнопка синяя просто потому что» вы задаёте смысловые роли, и компоненты используют их предсказуемо.

    Микропример: статус «подключено» можно визуально опирать на primary или специальные пользовательские токены, а предупреждение — на error-роль, чтобы интерфейс оставался консистентным.

    Светлая и тёмная тема

    Для desktop-приложения тёмная тема часто особенно востребована, потому что инструмент может работать долгое время в фоне и открываться поверх других рабочих приложений.

    Во Flutter можно определить обе темы:

    Микропример: пользователь запускает клиент вечером на тёмной системной теме Windows, и ваше приложение автоматически подстраивается без отдельного переключения.

    Но для профессионального результата почти всегда стоит задать свои темы явно, а не полагаться на сырые ThemeData.light() и ThemeData.dark().

    Кастомизация компонентов через тему

    Одна из лучших практик — не стилизовать каждый ElevatedButton вручную по месту, а централизованно настраивать тему компонентов.

    Микропример: если все главные кнопки в приложении должны иметь одинаковую высоту и внутренние отступы, тематическая настройка избавляет от повторений и расхождений между экранами.

    Worked example: маршруты и тема для desktop-клиента

    Представьте структуру приложения:

  • /setup — первичная настройка
  • / — главная панель
  • /profiles — список профилей
  • /profiles/:id — детальная страница профиля
  • /settings — настройки приложения
  • /logs — просмотр логов
  • Шаг 1. Выделяем обязательный сценарий первого запуска. Если путь к бинарнику не настроен, любой вход должен вести на /setup. Почему так: пользователь не может полноценно работать с приложением без базовой конфигурации.

    Шаг 2. Основные разделы вроде /, /profiles, /settings, /logs логично держать в общем shell-каркасе с боковой навигацией. Почему так: на desktop важна стабильность окружения, а не постоянная смена целого окна.

    Шаг 3. Детальный экран /profiles/:id получает идентификатор профиля из path parameters. Почему так: такой маршрут проще дебажить, логировать и при необходимости открывать напрямую.

    Шаг 4. Темы определяем централизованно через ThemeData и ColorScheme. Главные действия вроде «Запустить» и «Сохранить» получают согласованный стиль через тему кнопок, а карточки статуса — через тему CardTheme и цветовые роли.

    Шаг 5. Добавляем themeMode, чтобы пользователь мог выбрать светлую, тёмную или системную тему. Почему так: desktop-приложение должно уважать окружение пользователя, но иногда давать ручной контроль.

    !Переходы go_router и redirect во времени

    Этот кейс показывает, что навигация и тема — это не два разрозненных раздела. Оба механизма задают скелет приложения: один — поведенческий, другой — визуальный.

    Частые ошибки и edge cases

    Первая ошибка — хранить маршруты строками по всему проекту. Лучше централизовать их, иначе опечатки и расхождения становятся неизбежными.

    Вторая ошибка — злоупотреблять extra для передачи крупных объектов. Это удобно, но ухудшает воспроизводимость состояния и deep linking.

    Третья ошибка — делать redirect зависимым от тяжёлой асинхронной логики прямо на лету. Навигационный слой должен опираться на уже известное состояние, а не сам инициировать сложные операции при каждом движении.

    Четвёртая ошибка — стилизовать UI по месту. Когда у кнопок на разных экранах случайно разные радиусы, отступы и оттенки, пользователь чувствует это даже без формального знания дизайна.

    Если из этой главы запомнить только три вещи — это:

  • go_router нужен не ради моды, а ради декларативной карты приложения, где маршруты, параметры и редиректы описаны централизованно.
  • Параметры маршрута стоит выбирать по смыслу: идентификаторы и URL-подобные данные — в path/query, крупные объекты в extra только осознанно.
  • Тема Flutter — это система визуальных правил, а не просто один цвет: чем больше стили централизованы через ThemeData и ColorScheme, тем устойчивее и профессиональнее выглядит приложение.
  • 8. Введение в Riverpod: Provider, ref и ConsumerWidget

    Введение в Riverpod: Provider, ref и ConsumerWidget

    Почему в одном Flutter-приложении кнопка «Подключиться» мгновенно обновляет статус, лог-панель и системный трей, а в другом те же данные приходится протаскивать через пять конструкторов и всё равно что-то рассинхронизируется? Почти всегда дело в том, где живёт состояние и кто считается его законным владельцем. Для desktop-клиента управления xray-core это не теоретический вопрос: у вас будут статус процесса, активный профиль, путь к бинарнику, настройки окна, тема, последние ошибки, состояние HTTP-запросов и, возможно, поток логов. Если не договориться с приложением, где всё это хранить и как читать, код быстро станет хрупким.

    Вы уже видели, как Flutter строит UI из виджетов и как StatefulWidget хранит локальное состояние. Но вы наверняка замечали и другую реальность: некоторые данные нужны не одному маленькому виджету, а сразу многим частям приложения. Статус запущен ли процесс нужен и на главном экране, и в трее, и в кнопке в тулбаре. Именно здесь появляется Riverpod — система управления зависимостями и состоянием, которая позволяет описывать данные отдельно от UI и читать их предсказуемо.

    Почему локального setState быстро становится мало

    Когда приложение маленькое, всё кажется простым: есть кнопка, есть флаг isRunning, есть setState(). Но как только тот же флаг нужен нескольким несвязанным виджетам, вы начинаете поднимать состояние вверх по дереву. Затем передаёте его вниз через параметры. Потом добавляете колбэки, чтобы дети могли менять это состояние. Через некоторое время появляется то, что в Flutter-разработке часто называют prop drilling — протаскивание данных и функций через промежуточные слои, которым эти данные вообще не нужны.

    Микропример: экран HomeScreen передаёт isRunning в MainLayout, тот в Toolbar, затем в StatusButton. При этом MainLayout и Toolbar не используют значение сами, а лишь служат трубой.

    Проблема не только в длине цепочки. Чем больше посредников, тем выше шанс забыть обновить один из параметров, перепутать типы или создать две почти одинаковые копии состояния. Для desktop-приложения это особенно опасно: один экран может считать, что xray-core уже запущен, а другой — что ещё нет.

    Что Riverpod решает на самом деле

    Riverpod — это библиотека для управления состоянием и зависимостями в Dart/Flutter. Простыми словами, она позволяет описать источник данных отдельно от интерфейса, а затем читать его там, где он реально нужен, без протаскивания через всё дерево виджетов. Это важно не только для удобства, но и для архитектуры: состояние начинает жить в осмысленных узлах, а UI лишь подписывается на нужные части.

    Микропример: вместо передачи пути к конфигу через несколько уровней вы создаёте провайдер selectedConfigPathProvider, а экран настроек, кнопка запуска и панель статуса читают его напрямую.

    Важно почувствовать, что Riverpod — это не «магический глобальный стейт». Хорошо спроектированные провайдеры создают сеть зависимостей, где понятно, откуда берутся данные, кто их вычисляет и когда они пересчитываются.

    Provider: источник значения, а не контейнер «на всё подряд»

    Базовый строительный блок Riverpod — Provider. Это описание того, как получить некоторое значение. Простыми словами, провайдер — это рецепт: «если кто-то спросит это значение, вот как его вычислить или откуда взять».

    Простейший пример:

    Здесь провайдер возвращает строку. На первый взгляд это похоже на константу, но идея шире: значение может зависеть от других провайдеров, конфигурации, окружения и логики.

    Микропример: провайдер может возвращать не только заголовок, но и экземпляр Dio, путь к директории логов, текущую тему или сервис запуска процесса.

    Почему это полезно знать именно в таком виде? Потому что провайдер в Riverpod — это не всегда «изменяемое состояние». Иногда это просто способ централизованно создать зависимость. Например, клиент API лучше получить из провайдера, чем конструировать вручную в каждом экране.

    Провайдер как декларация зависимости

    Если функция провайдера принимает ref, значит она может читать другие провайдеры и строить значение на их основе. Это уже не просто хранилище, а маленький узел графа зависимостей.

    Здесь ref.watch говорит: «я завишу от этого провайдера, и если он изменится, пересчитай меня».

    Микропример: если строка статуса зависит от выбранного профиля и статуса процесса, ей не нужно вручную подписываться на оба источника. Достаточно прочитать их через ref.watch.

    watch, read, listen: почему это три разных действия

    Это одно из ключевых мест, где новички путаются.

  • ref.watch — подписывает на изменения и перестраивает зависимое вычисление или виджет
  • ref.read — читает текущее значение один раз без подписки
  • ref.listen — реагирует на изменение побочным действием
  • Разница не декоративная, а архитектурная.

    #### watch

    Используйте, когда UI или провайдер должны обновляться при изменении данных.

    Микропример: если статус процесса сменился на «ошибка запуска», вы хотите показать уведомление. Это не часть чистого UI-описания, а реакция на событие.

    Профессиональный нюанс здесь очень важен: не стоит использовать read там, где нужен автоматический апдейт, и не стоит пытаться делать побочные эффекты через watch в build(). Эти действия выражают разные намерения.

    ConsumerWidget: виджет, который умеет читать провайдеры

    Чтобы читать провайдеры в UI, Riverpod предлагает несколько подходов. Самый базовый и удобный для начала — ConsumerWidget. Это аналог StatelessWidget, но с доступом к WidgetRef.

    Здесь WidgetRef играет ту же роль, что и ref в провайдерах, но уже в UI-слое. Виджет подписывается на statusTextProvider и автоматически перестраивается при изменении значения.

    Микропример: на главном экране бейдж статуса процесса может быть ConsumerWidget, который читает processStateProvider и показывает «Запущено», «Остановлено» или «Ошибка».

    Почему это лучше, чем читать данные где-то выше и прокидывать через параметры? Потому что виджет получает только ту зависимость, которая ему реально нужна. Это уменьшает связность и облегчает переиспользование.

    ProviderScope: корень контейнера зависимостей

    Чтобы Riverpod работал, приложение должно быть обёрнуто в ProviderScope. Это корневой контейнер, внутри которого живут провайдеры.

    Простыми словами, ProviderScope — это среда выполнения для ваших провайдеров. Без него Riverpod не знает, где хранить состояния, кэш и зависимости.

    Микропример: если вы забыли добавить ProviderScope, приложение не сможет читать провайдеры так же, как Flutter не сможет применить тему без MaterialApp.

    Профессионально важно знать ещё одно: ProviderScope можно создавать не только один раз в main, но и локально для изоляции части дерева, переопределения зависимостей или тестов. Например, в тестовом окружении вы можете подменить реальный API-клиент фейковой реализацией.

    Как провайдеры образуют граф зависимостей

    Когда один провайдер читает другой, получается provider graph — граф зависимостей. Это одна из причин, почему Riverpod удобен для растущих приложений. Вместо случайных вызовов сервисов вы получаете явную структуру: настройки → клиент API → репозиторий → состояние экрана.

    Пример цепочки для будущего desktop-клиента:

  • провайдер пути к настройкам
  • провайдер загруженной конфигурации
  • провайдер клиента Dio
  • провайдер сервиса проверки доступности ядра
  • провайдер текста статуса UI
  • Микропример: если пользователь меняет локальный порт в настройках, провайдер настроек пересчитывается, затем клиент Dio, затем сервис проверки здоровья, затем текст статуса на экране. При этом вам не нужно вручную дёргать пять обновлений подряд.

    Но здесь возникает и ловушка: если делать провайдеры слишком крупными и хаотичными, граф превращается в неочевидный клубок. Поэтому одна из лучших практик — делать провайдеры небольшими и осмысленными.

    Жизненный цикл провайдеров: они не «живут вечно» автоматически

    Провайдеры в Riverpod имеют жизненный цикл: они создаются, могут кэшироваться, пересчитываться и уничтожаться. Для новичка это иногда неожиданно, потому что провайдер воспринимается как глобальная переменная. На самом деле это ближе к управляемому вычислению.

    Если никто не использует провайдер, некоторые его типы могут быть освобождены. Если зависимость изменилась, значение может пересчитаться. Если вы явно инвалидируете провайдер, он будет построен заново при следующем чтении.

    !Жизненный цикл провайдера: создание, чтение, invalidation, dispose

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

    Это особенно важно для desktop-приложений, где некоторые экраны могут открываться и закрываться много раз. Если неправильно понимать время жизни состояний, можно случайно терять данные формы или, наоборот, держать ненужные ресурсы слишком долго.

    Worked example: строим первый слой состояния для клиента xray-core

    Представьте, что вам нужен минимальный, но профессионально организованный набор провайдеров для главного экрана.

    Шаг 1. Провайдер настроек порта

    Почему так: это пока простой источник конфигурации. Даже если позже порт будет читаться из shared_preferences, внешний код уже работает не с «голым числом», а с абстракцией.

    Шаг 2. Провайдер базового URL

    Почему так: UI не должен сам собирать строки из кусочков конфигурации. Это лучше держать в отдельном вычисляемом провайдере.

    Шаг 4. Виджет-потребитель

    Почему так: виджет читает ровно то, что ему нужно. Он не знает о порте и URL отдельно, а зависит от готового значения.

    Шаг 5. Побочный эффект через listen

    Представьте, что при изменении адреса вы хотите записать диагностический лог.

    Почему так: логирование — это не часть визуального описания, а реакция на изменение.

    Этот пример пока нарочно простой, но он показывает правильный ритм Riverpod-мышления: данные описываются отдельно, зависимости выражаются явно, UI подписывается точечно.

    Частые ошибки новичков

    Первая ошибка — использовать провайдер как свалку. Например, хранить в одном объекте и тему, и настройки сети, и статус процесса, и текущее окно. Так код становится труднее тестировать и изменять. Лучше несколько маленьких провайдеров, чем один «бог-провайдер».

    Вторая ошибка — путать read и watch. Если вы прочитали состояние через read в build(), UI не будет обновляться автоматически. Потом кажется, что Riverpod «не работает», хотя проблема в типе доступа.

    Третья ошибка — делать побочные эффекты прямо в функции провайдера без понимания жизненного цикла. Провайдер должен по возможности оставаться декларативным вычислением. Если там запускается сетевой запрос, открывается файл или подписка, нужно очень хорошо понимать, когда это создаётся и уничтожается.

    Четвёртая ошибка — создавать провайдеры слишком низко по смыслу, но слишком высоко по области жизни. Например, временное состояние конкретной формы иногда лучше держать локально, а не поднимать в глобальный слой только потому, что «у нас же Riverpod».

    Есть и обратный edge case: пытаться всё оставить в StatefulWidget, даже когда данные нужны нескольким экранам и сервисам. Профессиональная архитектура обычно сочетает локальное UI-состояние и внешние провайдеры, а не выбирает один инструмент на все случаи.

    Где это пригодится уже в следующих главах

    Как только вы перейдёте к StateNotifier, AsyncNotifier и генерации кода, Riverpod станет не просто способом читать константы, а полноценным слоем управления состоянием. Через него вы будете запускать запросы, хранить асинхронные статусы, управлять списком профилей, запуском xray-core и обработкой ошибок.

    Для desktop-клиента особенно важны три будущих направления:

  • хранение состояния процесса и логов
  • управление настройками и их сохранением
  • реакция UI на долгие асинхронные операции без хаоса колбэков
  • Если из этой главы запомнить только три вещи — это:

  • Provider в Riverpod — это декларация того, как получить значение или зависимость, а не просто «глобальная переменная с удобным доступом».
  • ref.watch, ref.read и ref.listen выражают три разных намерения: подписка на изменения, разовое чтение и побочный эффект.
  • ConsumerWidget позволяет читать состояние там, где оно реально нужно UI, не протаскивая данные через всё дерево виджетов.
  • 9. StateNotifier, AsyncNotifier и code generation (@riverpod)

    StateNotifier, AsyncNotifier и code generation (@riverpod)

    Почему один и тот же экран загрузки в одном приложении выглядит аккуратно — с состояниями «идёт запрос», «есть данные», «ошибка» — а в другом превращается в россыпь булевых флагов isLoading, hasError, isInitialized, которые легко входят в противоречие друг с другом? Потому что чем сложнее становится состояние, тем сильнее нужен не просто доступ к данным, а дисциплина их переходов. Для desktop-клиента управления xray-core это критично: запуск процесса, чтение конфигов, сетевые проверки, сохранение настроек и загрузка списка профилей — всё это не укладывается в пару строковых провайдеров.

    В предыдущей главе вы уже увидели, что провайдер может описывать источник значения и зависимость между значениями. Но как только состояние начинает меняться во времени и особенно когда оно асинхронно, простого Provider становится мало. Вам нужен инструмент, который умеет не только «вычислить», но и управлять состоянием. Именно здесь на сцену выходят StateNotifier, AsyncNotifier и генерация кода через @riverpod.

    Почему изменяемое состояние нельзя оставлять бесформенным

    Вы наверняка уже замечали это в бытовой разработке: сначала состояние экрана кажется простым — например, есть список профилей. Потом оказывается, что список надо загружать с диска, уметь обновлять, обрабатывать ошибку чтения, показывать индикатор, сохранять выбранный профиль, а после удаления ещё и обновлять UI. Если управлять этим набором через разрозненные переменные, логика расползается.

    Микропример: флаги isLoading = false, profiles = [], errorMessage = null по отдельности выглядят безобидно. Но набор isLoading = false, profiles = [], errorMessage = null может означать и «ещё не загрузили», и «успешно загрузили пустой список». UI начинает гадать.

    Хорошее state management стремится сделать состояние не просто «набором полей», а осмысленной моделью переходов. Riverpod предлагает для этого несколько уровней инструментария.

    StateNotifier: явное управление синхронным состоянием

    StateNotifier — это объект, который хранит состояние в поле state и предоставляет методы для его изменения. Простыми словами, это контроллер, внутри которого вы описываете разрешённые способы менять состояние.

    Он особенно полезен, когда у вас есть синхронное или преимущественно синхронное состояние: настройки формы, выбранный профиль, фильтры логов, локальная конфигурация экрана.

    Пример:

    Здесь CounterNotifier хранит текущее число в state, а внешний код получает и значение, и контроллер через провайдер.

    Микропример: вместо хаотического изменения выбранного режима логирования в трёх местах вы создаёте LogFilterNotifier с методами setLevel(), toggleErrorsOnly(), clear().

    Почему методы важнее прямой мутации

    Главная ценность StateNotifier не в том, что он «умеет хранить состояние», а в том, что он вводит контракт изменения. Внешний код не должен произвольно менять внутренние поля. Он вызывает понятные методы: selectProfile, setThemeMode, updatePort, markAsRunning.

    Микропример: если профиль можно выбирать только из существующего списка, метод selectProfile(id) может проверить корректность, а не позволять записать любой случайный идентификатор.

    Это делает поведение приложения предсказуемее и проще для тестов. Тестировать «метод выбора профиля» легче, чем распутывать, кто и где вручную переприсвоил поле.

    Когда StateNotifier особенно уместен

    Используйте StateNotifier, когда:

  • состояние изменяется по явным командам
  • логика переходов важнее, чем асинхронная загрузка
  • вы хотите отделить чтение состояния от набора допустимых действий
  • состояние можно представить как один снимок данных
  • Типичные примеры в вашем будущем проекте:

  • текущие настройки формы редактирования профиля
  • состояние выбранной вкладки или фильтра
  • конфигурация окна и переключатели интерфейса
  • список локально отредактированных, но ещё не сохранённых параметров
  • Но здесь есть тонкий edge case: если почти каждый метод внутри StateNotifier начинает делать await, ловить ошибки и управлять загрузкой, это сигнал, что задача уже асинхронная по природе и лучше посмотреть в сторону AsyncNotifier.

    Состояние как модель, а не как россыпь примитивов

    StateNotifier особенно хорош, когда вы храните не примитивы, а отдельную модель состояния.

    Далее контроллер:

    Микропример: пользователь переключил тему на dark, изменил порт на 2080, включил автозапуск. Каждое действие проходит через явный метод, а состояние остаётся единым объектом, а не тремя разрозненными глобальными переменными.

    Такой подход особенно полезен в desktop-приложении с формами настроек: один объект проще валидировать, сохранять и сравнивать.

    !Сравнение StateNotifier, AsyncNotifier и AsyncValue

    AsyncNotifier: когда состояние по природе асинхронно

    AsyncNotifier — это более современный инструмент Riverpod для состояния, которое загружается или обновляется асинхронно. Простыми словами, он создан для ситуаций, где данные надо получить позже: из сети, файла, базы, процесса или платформенного API.

    Ключевая идея в том, что состояние такого провайдера обычно представлено через AsyncValue — обёртку, которая может находиться в одном из трёх главных состояний:

  • загрузка
  • данные
  • ошибка
  • Это очень важный сдвиг мышления. Вместо самостоятельного набора флагов вы получаете структурированную модель асинхронного состояния.

    Пример концепции:

    Микропример: при открытии экрана список профилей ещё не загружен — UI показывает индикатор. После успешного чтения файла появляются данные. Если файл повреждён, экран показывает контролируемую ошибку вместо падения.

    Почему build() важен

    В AsyncNotifier метод build() — это место начальной загрузки состояния. Он вызывается системой Riverpod, когда провайдер создаётся или пересоздаётся.

    Простыми словами, build() отвечает на вопрос: «какое стартовое асинхронное значение должен дать этот провайдер?»

    Микропример: при первом открытии окна настроек build() может прочитать текущие настройки из shared_preferences, а затем UI отобразит уже реальные данные, а не временные заглушки.

    Профессионально это важно ещё и потому, что build() можно использовать вместе с зависимостями через ref, строя асинхронный граф довольно чисто.

    AsyncValue: три состояния без хаоса булевых флагов

    AsyncValue — это тип, который представляет асинхронный результат безопасно и явно. Обычно вы будете работать с ним через when, maybeWhen, value, hasError, isLoading, но главное — не набор API, а сама модель.

    Пример в UI:

    Микропример: если чтение профилей с диска занимает 300 мс, пользователь видит спиннер. Если файл удалён или JSON повреждён, он видит осмысленное сообщение. Если всё прошло успешно, UI получает данные.

    Именно поэтому AsyncValue так полезен: он принуждает код честно отрабатывать разные исходы, а не прятать их за null и логические флаги.

    AsyncValue.guard

    Это один из самых удобных приёмов для обновляющих методов:

    AsyncValue.guard автоматически превращает успешный результат в AsyncData, а исключение — в AsyncError.

    Микропример: вам не нужно вручную писать try/catch вокруг каждого асинхронного обновления списка конфигов. Guard делает шаблон короче и чище.

    Когда выбирать StateNotifier, а когда AsyncNotifier

    Это один из самых важных практических выборов.

    Выбирайте StateNotifier, если:

  • состояние в основном синхронное
  • изменения происходят через команды пользователя
  • вам важно моделировать бизнес-переходы вручную
  • загрузка не является центральной природой состояния
  • Выбирайте AsyncNotifier, если:

  • данные появляются асинхронно
  • экран естественно имеет состояния loading/data/error
  • источник данных — сеть, файл, процесс, хранилище
  • вы хотите встроенную дисциплину для асинхронных обновлений
  • | Ситуация | Лучше взять | Почему | |---|---|---| | Выбранный профиль и фильтры логов | StateNotifier | Это в основном синхронные команды и локальные переходы | | Список профилей из файла | AsyncNotifier | Данные загружаются асинхронно и могут завершиться ошибкой | | Текущая тема UI | StateNotifier | Простое состояние с явными изменениями | | Проверка доступности локального HTTP API xray-core | AsyncNotifier | Есть запрос, ожидание, ошибка и повторная загрузка |

    Микропример: флаг «автозапуск включён» не требует AsyncValue, а вот состояние «проверяем, отвечает ли локальный API после старта процесса» — почти идеальный случай для AsyncNotifier.

    Code generation и @riverpod: меньше шаблонов, чище API

    Когда проект растёт, ручное объявление провайдеров начинает занимать заметную долю кода. Riverpod предлагает современный подход через code generation и аннотацию @riverpod. Простыми словами, вы описываете логику декларативнее, а часть шаблонного кода генерируется автоматически.

    Идея выглядит так:

    После генерации вы получаете готовый провайдер.

    Для асинхронного состояния:

    Почему так: build() становится единственной точкой начального получения данных.

    Шаг 3. Добавляем ручное обновление

    Почему так: пользователь может нажать «Обновить», и UI снова корректно пройдёт через загрузку.

    Шаг 4. Рисуем UI через when

    Почему так: три состояния UI выражены явно, а не распылены по условиям в разных местах.

    Шаг 5. Обрабатываем edge case

    Если список пуст, это не ошибка. Значит, ветка data должна уметь показать осмысленный пустой экран, а не только «колонку из элементов».

    Микропример: пользователь впервые запустил приложение, профилей ещё нет. Это корректное состояние «данные есть, но список пустой», а не error и не loading.

    Этот кейс очень близок к реальной разработке вашего приложения: ровно так же будут выглядеть списки серверов, конфигов, логов или результаты проверки состояния ядра.

    Частые ошибки и профессиональные приёмы

    Первая ошибка — использовать StateNotifier для всего подряд, включая тяжёлую асинхронщину. Формально можно, но код быстро обрастает ручной логикой loading/error/data, которую AsyncNotifier уже выражает лучше.

    Вторая ошибка — хранить в AsyncNotifier слишком сложную составную модель, где отдельно живут данные, флаги, выбранные элементы и UI-состояние формы. Асинхронное состояние загрузки данных и локальное редактирование формы иногда лучше разделить на два слоя.

    Третья ошибка — в каждом обновлении терять предыдущие данные без необходимости. Иногда вместо полного AsyncLoading() лучше сохранить старое значение и показать более мягкий индикатор обновления. Это уже более продвинутый UX-нюанс, но его полезно помнить.

    Четвёртая ошибка — бояться генерации кода и оставаться на ручном API из-за привычки. Для маленьких экспериментов ручной стиль нормален, но в крупном desktop-проекте @riverpod обычно даёт выигрыш в читаемости.

    Если из этой главы запомнить только три вещи — это:

  • StateNotifier хорош там, где важны явные команды изменения синхронного состояния, а не природная асинхронность загрузки.
  • AsyncNotifier и AsyncValue дают дисциплину для loading/data/error без хаоса булевых флагов, что особенно ценно для файлов, сети и процессов.
  • @riverpod и генерация кода уменьшают шаблонность и делают провайдеры типобезопаснее, особенно когда проект начинает быстро расти.