Автор: Чернышев С. Петров Ю. Ильин С. Гершевич П.
Теги: взаимодействие сетей межсетевой обмен оргсвязь программирование программное обеспечение кодирование информационные технологии
ISBN: 978-5-4461-4469-3
Год: 2026
Станислав Чернышев, Юрий Петров
Станислав Ильин, Павел Гершевич
Основы
Flutter
2026
ББК 32.988.02-018
УДК 004.738.5
О-75
Чернышев Станислав, Петров Юрий, Ильин Станислав,
Гершевич Павел
О-75 Основы Flutter. — СПб.: Питер, 2026. — 688 с.: ил. — (Серия «Библиотека программиста»).
ISBN 978-5-4461-4469-3
Книга предназначена для всех, кто хочет погрузиться в эффективную кроссплатформенную разработку
с Flutter. Вы не только изучите синтаксис языка Dart и основы фреймворка, но и сразу примените знания на
практике, разрабатывая проект, который развивается на протяжении всей книги. После каждой новой темы
вас ждут задания по доработке его кодовой базы, что позволит уверенно прокачать свои hard-скиллы.
Материал структурирован так, чтобы вы могли постепенно перейти от основ к созданию полноценных
приложений для всех популярных платформ. Описаны не просто базовые принципы, но и лучшие практики,
проверенные авторами в проектах для создания отзывчивых и современных интерфейсов. Особый подход
с минимальным использованием внешних пакетов и упором на встроенные возможности Dart и Flutter обес
печивает долгую актуальность материала. Дополнительно прилагаются лабораторные практикумы, которые
помогут закрепить теорию, а весь исходный код доступен в GitHub-репозитории.
16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.)
ББК 32.988.02-018
УДК 004.738.5
Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме
без письменного разрешения владельцев авторских прав.
Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может
гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные
ошибки, связанные с использованием книги. В книге возможны упоминания организаций, деятельность которых
запрещена на территории Российской Федерации, таких как Meta Platforms Inc., Facebook, Instagram и др. Издательство не несет ответственности за доступность материалов, ссылки на которые вы можете найти в этой книге.
На момент подготовки книги к изданию все ссылки на интернет-ресурсы были действующими.
ISBN 978-5-4461-4469-3
© ООО Издательство «Питер», 2025
© Серия «Библиотека программиста», 2025
© Станислав Чернышев, Юрий Петров, Станислав Ильин,
Павел Гершевич, 2025
КРАТКОЕ СОДЕРЖАНИЕ
Предисловие............................................................................................................................................... 14
От издательства.......................................................................................................................................... 18
Глава 0. Установка и настройка рабочего окружения. Основы Dart....................................................... 19
Глава 1. Краткая история и принципы работы Flutter...........................................................................180
Глава 2. Основные виджеты, их компоновка и работа с assets............................................................258
Глава 3. Управление состоянием.............................................................................................................399
Глава 4. Навигация...................................................................................................................................433
Глава 5. Работа с сетью. ..........................................................................................................................493
Глава 6. Локальное хранение данных. ...................................................................................................555
Глава 7. Тестирование приложений........................................................................................................600
Глава 8. Локализация приложения.........................................................................................................634
Глава 9. Сборка приложения...................................................................................................................662
Заключение. .............................................................................................................................................683
ОГЛАВЛЕНИЕ
Предисловие............................................................................................................................................... 14
Присоединяйся!.............................................................................................................................. 16
Как работать с книгой................................................................................................................... 17
От издательства.......................................................................................................................................... 18
Глава 0. Установка и настройка рабочего окружения. Основы Dart....................................................... 19
0.1. Установка и настройка рабочего окружения............................................................... 19
0.1.1. ОС Windows................................................................................................................. 19
0.1.2. ОС macOS...................................................................................................................... 22
0.1.3. ОС Ubuntu 20.04 LTS+ и Debian 11+.................................................................. 28
0.1.4. Установка и настройка Android Studio............................................................... 32
0.2. Создание первого проекта на Dart................................................................................... 37
0.3. Базовые типы данных, модификаторы доступа и null-safety................................. 41
0.3.1. Числа (int, double)...................................................................................................... 43
0.3.2. Строки (String)............................................................................................................ 44
0.3.3. Логические значения (bool).................................................................................... 45
0.3.4. Списки (List)................................................................................................................ 45
0.3.5. Записи (Record)........................................................................................................... 47
0.3.6. Множества (Set).......................................................................................................... 48
0.3.7. Таблицы/карты (Map).............................................................................................. 49
0.3.8. Runes и Symbols........................................................................................................... 52
0.3.9. Модификаторы final, const и late........................................................................... 52
0.3.10. Null-безопасность (null-safety)............................................................................ 53
0.3.11. Тип данных dynamic vs Object............................................................................. 56
0.4. Основные операторы и pattern matching....................................................................... 57
0.4.1. Основные операторы Dart....................................................................................... 57
0.4.2. Что такое Pattern Matching и Destructuring...................................................... 61
0.4.3. Деструктурирование списка................................................................................... 62
0.4.4. Деструктурирование записи................................................................................... 63
0.4.5. Деструктурирование таблицы/карты.................................................................. 63
0.4.6. Деструктурирование экземпляра класса............................................................ 65
0.5. Управление потоком выполнения кода......................................................................... 66
0.5.1. Условный оператор if................................................................................................ 66
0.5.2. Оператор if-case........................................................................................................... 68
Оглавление 5
0.5.3. Тернарный оператор ?:.............................................................................................. 70
0.5.4. Оператор ??................................................................................................................... 70
0.5.5. Операторы циклов for, for-in, while и do-while................................................. 71
0.5.6. Операторы потока выполнения break, continue, return................................. 73
0.5.7. Оператор выбора потока выполнения switch-case.......................................... 74
0.5.8. Null-aware elements..................................................................................................... 76
0.6. Функции................................................................................................................................... 78
0.6.1. Объявление входных аргументов функции...................................................... 79
0.6.2. Необязательные аргументы функции по умолчанию.................................... 81
0.6.3. Обращение к функции с помощью переменной.............................................. 82
0.6.4. Функция как входной аргумент другой функции........................................... 82
0.6.5. Type Alias....................................................................................................................... 83
0.6.6. Анонимные и стрелочные функции..................................................................... 85
0.7. Библиотеки.............................................................................................................................. 86
0.7.1. Импортирование кода из файла с расширением .dart................................... 86
0.7.2. Импортирование части функциональности...................................................... 89
0.7.3. Создание и использование библиотеки.............................................................. 90
Проект: игра «Тетрис» v. 0.......................................................................................................... 92
Разработка библиотеки ansi_cli_helper.dart................................................................. 93
Разработка библиотеки blocks.dart.................................................................................. 94
Разработка библиотеки board.dart................................................................................... 98
Компоновка библиотек и запуск игры.........................................................................104
Задания на модификацию проекта................................................................................106
0.8. Объектно-ориентированное программирование......................................................106
0.8.1. Конструктор класса..................................................................................................110
0.8.2. Статические переменные и методы класса......................................................114
0.8.3. Методы расширения (extension methods)........................................................115
0.8.4. Наследование и переопределение методов......................................................116
0.8.5. Абстрактный класс и интерфейс.........................................................................123
0.8.6. Модификаторы класса............................................................................................127
0.8.7. Запечатанные (sealed) классы..............................................................................128
0.8.8. Миксины и модификатор класса mixin.............................................................132
0.8.9. Enum (перечисления)..............................................................................................136
0.9. Exceptions (исключения)..................................................................................................138
0.9.1. Конструкция try…catch…finally............................................................................138
0.9.2. Генерация исключений и ошибок.......................................................................140
0.9.3. Пользовательские исключения и ошибки........................................................141
0.9.4. Assert (утверждение)...............................................................................................142
0.10. Асинхронное программирование и изоляты............................................................143
0.10.1. Базовая концепция Event Loop-архитектуры в Dart.................................143
0.10.2. Очереди и цикл событий в Dart........................................................................144
0.10.3. Что такое асинхронное программирование...................................................146
6 Оглавление
0.10.4. Future API, async и await......................................................................................147
0.10.5. Stream (поток).........................................................................................................154
0.10.6. Isolate (изоляты).....................................................................................................160
0.10.7. Async или Isolate?...................................................................................................165
Проект: игра «Тетрис» v. 1........................................................................................................166
Рефакторинг библиотеки ansi_cli_helper.dart...........................................................166
Рефакторинг библиотеки blocks.dart............................................................................169
Рефакторинг библиотеки board.dart.............................................................................172
Разработка библиотеки game.dart..................................................................................176
Компоновка библиотек и запуск игры.........................................................................177
Задания на модификацию проекта................................................................................178
Резюме..............................................................................................................................................178
Вопросы для самопроверки......................................................................................................179
Глава 1. Краткая история и принципы работы Flutter...........................................................................180
1.1.
1.2.
1.3.
1.4.
1.5.
1.6.
Краткая история и основные нюансы...........................................................................180
Как обстоят дела с разработкой под Web....................................................................185
Создание первого проекта и его запуск........................................................................185
Структура проекта на Flutter...........................................................................................191
Структура файла pubspec.yaml........................................................................................193
Типы виджетов во Flutter.................................................................................................195
1.6.1. StatelessWidget..........................................................................................................195
1.6.2. StatefulWidget............................................................................................................202
1.7. В недрах BuildContext........................................................................................................212
1.8. Передача информации по дереву элементов..............................................................228
1.8.1. InheritedWidget.........................................................................................................230
1.8.2. InheritedModel...........................................................................................................233
1.8.3. InheritedNotifier.........................................................................................................236
1.8.4. ChangeNotifier и ListenableBuilder......................................................................242
1.9. Зачем нужны ключи............................................................................................................246
1.10. Жизненный цикл приложения......................................................................................252
Резюме..............................................................................................................................................256
Вопросы для самопроверки......................................................................................................257
Глава 2. Основные виджеты, их компоновка и работа с assets............................................................258
2.1. Стили виджетов — Material vs Cupertino....................................................................258
2.2. Виджеты-«коробки»...........................................................................................................264
2.2.1. «Коробки» для задания размеров........................................................................264
2.2.2. «Коробки» для отрисовки......................................................................................268
2.2.3. Виджет для положения в пространстве............................................................275
2.2.4. Виджет Container......................................................................................................277
2.3. Виджеты компоновки.........................................................................................................278
2.3.1. Виджеты Row и Column.........................................................................................278
2.3.2. Виджеты Flexible, Expanded и Spacer................................................................281
Оглавление 7
2.3.3. Виджет Wrap..............................................................................................................285
2.3.4. Виджеты Stack и Positioned..................................................................................286
2.3.5. Виджеты Align и Center..........................................................................................290
2.4. Виджеты выбора и ввода данных...................................................................................293
2.4.1. Виджет TextField......................................................................................................293
2.4.2. Виджет Radio и его вариации...............................................................................297
2.4.3. Виджет Checkbox и его вариации........................................................................302
2.4.4. Виджеты Switch и SwitchListTile........................................................................303
2.4.5. Виджет DropdownMenu..........................................................................................305
2.4.6. Виджет Slider..............................................................................................................309
2.4.7. Виджет ввода времени............................................................................................311
2.4.8. Виджет ввода даты....................................................................................................314
2.5. Виджеты кнопок...................................................................................................................316
2.5.1. Виджет ElevatedButton...........................................................................................316
2.5.2. Виджеты FilledButton и OutlinedButton..........................................................319
2.5.3. Виджет TextButton...................................................................................................322
2.5.4. Виджет IconButton...................................................................................................323
2.5.5. Виджет SegmentedButton.......................................................................................325
2.6. Виджеты отображения данных и работа с assets.......................................................329
2.6.1. Виджеты Text и RichText.......................................................................................329
2.6.2. Работа со шрифтами и их импорт посредством assets.................................333
2.6.3. Виджет Image и его связь с assets........................................................................337
2.6.4. Виджет Icon и добавление своих значков........................................................340
2.6.5. Assets и JSON-файлы...............................................................................................344
2.7. Скроллируемые виджеты..................................................................................................347
2.7.1. Виджет SingleScrollChildView..............................................................................348
2.7.2. Виджеты ListView и GridView.............................................................................350
2.7.3. Виджеты PageView и Carousel..............................................................................353
2.7.4. Кастомная полоса прокрутки и Slivers..............................................................357
2.8. Scaffold и его составные виджеты...................................................................................362
2.8.1. Виджет AppBar..........................................................................................................362
2.8.2. Виджеты NavigationBar и BottomAppBar........................................................369
2.8.3. Виджет NavigationDrawer......................................................................................375
2.8.4. Виджет FloatingActionButton...............................................................................379
2.8.5. Виджет BottomSheet................................................................................................383
2.8.6. Виджет SnackBar и способы показа сообщения .
пользователю.........................................................................................................................388
Проект: игра «Тетрис» v.2. Портирование на Flutter......................................................391
Перенос и рефакторинг файлов......................................................................................391
Реализация на Flutter.........................................................................................................394
Задания на модификацию проекта................................................................................398
Резюме..............................................................................................................................................398
Вопросы для самопроверки......................................................................................................398
8 Оглавление
Глава 3. Управление состоянием.............................................................................................................399
3.1. Типы состояния приложения..........................................................................................399
3.1.1. Состояние приложения (App state)....................................................................400
3.1.2. Эфемерное состояние (Ephemeral state)...........................................................400
3.2. Инструменты Flutter для работы с состоянием приложения..............................402
3.2.1. setState..........................................................................................................................402
3.2.2. ChangeNotifier............................................................................................................403
3.2.3. ValueNotifier................................................................................................................406
3.2.4. InheritedWidget.........................................................................................................407
3.3. Паттерны для управления состоянием........................................................................412
3.3.1. BLoC (Business Logic Component)......................................................................412
3.3.2. MVP (Model-View-Presenter)...............................................................................416
3.3.3. MVC (Model-View-Controller).............................................................................419
3.3.4. MVVM (Model-View-ViewModel)......................................................................422
3.3.5. Сравнение MVP, MVC и MVVM........................................................................426
Проект: игра «Тетрис» v. 3. Переход на ChangeNotifier..................................................426
Задания на модификацию проекта................................................................................431
Резюме..............................................................................................................................................432
Вопросы для самопроверки......................................................................................................432
Глава 4. Навигация...................................................................................................................................433
4.1. Основные концепции навигации во Flutter...............................................................433
4.1.1. Разница между императивной и декларативной навигацией...................433
4.1.2. Императивная навигация (Navigator 1.0)........................................................435
4.1.3. Декларативная навигация (Navigator 2.0).......................................................437
4.1.4. Направление навигации.........................................................................................438
4.2. Основные элементы навигации во Flutter..................................................................440
4.2.1. Класс Navigator..........................................................................................................440
4.2.2. Класс Route.................................................................................................................443
4.2.3. Класс NavigatorObserver.........................................................................................447
4.3. Именованные маршруты...................................................................................................451
4.3.1. onUnknownRoute......................................................................................................456
4.3.2. onGenerateRoute........................................................................................................456
4.4. Навигация без контекста — GlobalKey и NavigatorState.......................................458
4.5. Инструменты декларативной навигации....................................................................460
4.5.1. RouteInformationProvider......................................................................................461
4.5.2. RouteInformationParser...........................................................................................461
4.5.3. RouterDelegate...........................................................................................................463
4.5.4. Настройка MyRouterDelegate..............................................................................464
4.5.5. PopNavigatorRouterDelegateMixin.....................................................................468
4.5.6. RouterConfig...............................................................................................................469
4.6. Передача информации между экранами......................................................................470
4.6.1. Императивная передача с помощью RouteSettings......................................471
4.6.2. Декларативный подход к передаче информации...........................................473
Оглавление 9
4.7. Вложенная навигация (nested navigation)..................................................................476
4.7.1. Императивная реализация....................................................................................476
4.7.2. Декларативная реализация...................................................................................480
Проект: игра «Тетрис» v. 4. Добавление навигации.........................................................485
Задания на модификацию проекта................................................................................491
Резюме..............................................................................................................................................491
Вопросы для самопроверки......................................................................................................492
Глава 5. Работа с сетью. ..........................................................................................................................493
5.1. Клиент-серверная архитектура.......................................................................................493
5.1.1. API..................................................................................................................................494
5.1.2. HTTP-запросы...........................................................................................................494
5.1.3. HTTP-статусы ответов сервера...........................................................................496
5.2. Встроенный инструмент Flutter для работы с HTTP.............................................497
5.2.1. Стандартная библиотека dart:io...........................................................................497
5.2.2. Ограничения и проблемы dart:io.........................................................................498
5.3. Пакет (библиотека) HTTP...............................................................................................498
5.3.1. Первый GET-запрос в сеть....................................................................................499
5.3.2. Обработка ответа сервера......................................................................................500
5.3.3. Парсинг JSON-моделей..........................................................................................502
5.3.4. Метод POST...............................................................................................................506
5.3.5. Методы PUT и PATH.............................................................................................507
5.3.6. Метод DELETE.........................................................................................................508
5.3.7. Обработка HTTP-статусов ответов от сервера..............................................509
5.3.8. Заголовки (headers)..................................................................................................510
5.3.9. Перехватчик запросов Interceptor.......................................................................512
5.4. Веб-сокеты..............................................................................................................................515
5.4.1. Начало работы с веб-сокетами.............................................................................516
5.4.2. Чтение данных из веб-сокета................................................................................516
5.4.3. Отправка данных через сокет...............................................................................517
5.4.4. Отображение данных из сокета на экране........................................................518
Проект: игра «Тетрис» v. 5. Работа с сетью.........................................................................523
Запуск серверной части.....................................................................................................523
Базовая концепция приложения и структура папок проекта..............................523
Разработка DI-контейнера (Dependency Injection).................................................525
Разработка HTTP-клиента...............................................................................................527
Отображение списка лучших результатов..................................................................529
Разработка экрана с главным меню...............................................................................537
Создание нового пользователя.......................................................................................539
Модификация экрана с игрой.........................................................................................550
Задания на модификацию проекта................................................................................553
Резюме..............................................................................................................................................554
Вопросы для самопроверки......................................................................................................554
10 Оглавление
Глава 6. Локальное хранение данных. ...................................................................................................555
6.1. SharedPrefrences...................................................................................................................555
6.1.1. Начало работы............................................................................................................556
6.1.2. Запись данных............................................................................................................557
6.1.3. Запись пользовательских данных.......................................................................558
6.1.4. Чтение данных...........................................................................................................559
6.1.5. Чтение кастомных данных.....................................................................................560
6.1.6. Удаление данных и очистка..................................................................................562
6.1.7. Пример интеграции с интерфейсом...................................................................563
6.1.8. Как давать имена ключам.......................................................................................566
6.1.9. Что принято и что не принято хранить в SharedPrefrences.......................566
6.1.10. Новые API — SharedPreferencesAsync и WithCache..................................568
6.2. Secure Storage........................................................................................................................569
6.2.1. Начало работы............................................................................................................569
6.2.2. Запись и чтение данных..........................................................................................570
6.2.3. Чтение и запись типизированных данных.......................................................571
6.2.4. Параметры кастомизации — AndroidOptions.................................................572
6.2.5. Параметры кастомизации — IOSOptions.........................................................573
6.2.6. Параметры кастомизации — другие платформы...........................................575
6.3. SQLite.......................................................................................................................................575
6.3.1. Реляционные базы данных и язык SQL............................................................576
6.3.2. Начало работы............................................................................................................577
6.3.3. Создание первой таблицы......................................................................................578
6.3.4. Запись данных............................................................................................................580
6.3.5. Чтение данных...........................................................................................................582
6.3.6. Обновление данных.................................................................................................585
6.3.7. Удаление данных......................................................................................................585
6.3.8. Миграции.....................................................................................................................586
Проект: игра «Тетрис» v. 6. Сохранение, кэширование данных и игра
без Интернета................................................................................................................................587
Разработка сервиса локального хранилища...............................................................587
Рефакторинг контейнера зависимостей......................................................................589
Рефакторинг запуска приложения................................................................................591
Рефакторинг HTTP-клиента...........................................................................................593
Рефакторинг функционала UserRepository................................................................594
Задания на модификацию проекта................................................................................598
Резюме..............................................................................................................................................599
Вопросы для самопроверки......................................................................................................599
Глава 7. Тестирование приложений........................................................................................................600
7.1. Теория тестирования..........................................................................................................600
7.1.1. Какими бывают тесты.............................................................................................601
7.1.2. Подходы к тестированию.......................................................................................601
Оглавление 11
7.1.3. Паттерн AAA...............................................................................................................602
7.1.4. Тестирование во Flutter..........................................................................................603
7.2. Unit-тесты...............................................................................................................................604
7.2.1. Основные функции для написания unit-тестов.............................................605
7.2.2. Тестирование калькулятора..................................................................................606
7.3. Виджет-тесты........................................................................................................................614
7.3.1. WidgetTester...............................................................................................................615
7.3.2. testWidgets()...............................................................................................................616
7.3.3. Finder-классы.............................................................................................................617
7.3.4. Matcher-классы..........................................................................................................619
7.3.5. Тестируем калькулятор..........................................................................................620
7.4. Асинхронные виджет-тесты.............................................................................................621
7.5. Интеграционные тесты......................................................................................................624
Проект: игра «Тетрис» v. 7. Тестирование...........................................................................626
Задания на модификацию проекта................................................................................632
Резюме..............................................................................................................................................633
Вопросы для самопроверки......................................................................................................633
Глава 8. Локализация приложения.........................................................................................................634
8.1. Интернационализация vs локализация.......................................................................635
8.2. Подключение и настройка библиотеки flutter_localizations.................................635
8.3. Интерполяция строк (placeholder)................................................................................640
8.4. Плюрализация строк (plural)..........................................................................................643
8.5. Еще один механизм плюрализации (select)...............................................................645
8.6. Форматирование даты и времени..................................................................................648
8.7. Форматирование чисел и данных о валюте................................................................651
8.8. Локализация для iOS (продуктов Apple)....................................................................654
8.9. Экранирование фигурных скобок..................................................................................654
Проект: игра «Тетрис» v. 8. Локализация игры.................................................................655
Задания на модификацию проекта................................................................................660
Резюме..............................................................................................................................................661
Вопросы для самопроверки......................................................................................................661
Глава 9. Сборка приложения...................................................................................................................662
9.1. Режимы сборок.....................................................................................................................662
9.1.1. Debug — отладочный режим.................................................................................662
9.1.2. Profile — режим для анализа производительности.......................................666
9.1.3. Release — режим для публикации.......................................................................667
9.1.4. Основные различия release, profile и debug......................................................667
9.2. Подпись сборки под Android...........................................................................................668
9.2.1. Android APK...............................................................................................................668
9.2.2. Android ABB...............................................................................................................668
9.2.3. APK vs AAB.................................................................................................................669
9.2.4. Процесс подписи release-сборки приложения................................................669
12 Оглавление
9.3. Подпись сборки под iOS/MacOS...................................................................................675
9.3.1. Регистрация уникального идентификатора Bundle ID...............................675
9.3.2. Создание приложения в App Store Connect....................................................675
9.3.3. Настройка проекта приложения в XCode перед релиз-сборкой..............676
9.3.4. Создание релизного архива...................................................................................677
9.4. Подпись сборки под OC «Аврора»................................................................................678
9.4.1. Генерация файла-запроса на сертификат.........................................................679
9.4.2. Запрос сертификата.................................................................................................680
9.4.3. Сборка и подпись приложения............................................................................680
Резюме..............................................................................................................................................682
Вопросы для самопроверки......................................................................................................682
Заключение. .............................................................................................................................................683
Лабораторные практикумы к книге........................................................................................................685
Лабораторный практикум по Dart.........................................................................................685
Лабораторный практикум по Flutter.....................................................................................686
Огромная благодарность нашим родным,
близким и всем тем, кто поддерживал авторский
коллектив в процессе написания книги!
ПРЕДИСЛОВИЕ
Привет! После того как была написана книга «Основы Dart», от многих людей
был запрос на ее логическое продолжение, а именно книгу по фреймворку Flutter.
Не являясь гением многозадачности и понимая, сколько времени потребуется на ее
написание, принял решение собрать авторский коллектив и воплотить в жизнь одну
из безумных идей — договориться с одной или двумя компаниями на разработку
ими лабораторного практикума к книге. Ведь это позволит не просто увеличить
объем практических задач и примеров по разработке приложений, а использовать
книгу вкупе с лабораторным практикумом как полноценный учебник не только
для самообучения, но также в вузах и системе среднего профессионального образования. Если вы читаете эти строки, будучи студентом, и у вас до сих пор не преподают кросс-платформенную разработку — знайте, все для этого есть! Донесите,
пожалуйста, до своей кафедры или преподавателей то, что львиная часть работы
за них была сделана, а их задача заключается только в том, чтобы взять готовый
материал, изучить его и использовать при проведении занятий.
Будучи идейным вдохновителем «Основы Flutter», лабораторного практикума
и техническим редактором книги, могу с гордостью сказать, что за время написания
всеми причастными к ней людьми (авторский коллектив, сотрудники Surf и Mad
Brains) была проделана большая комплексная работа. Огромное спасибо вам за
то, что поверили в блеск в моих глазах, когда я озвучивал свои мысли и задумки,
а также решили принять участие в этом деле! Особая благодарность жене, дочери
и родным! И куда же без тебя, читатель, и всех тех, кто поддерживал процесс работы
над книгой как денежно (покупая курс на Stepik, куда первым делом выкладывался
утвержденный материал книги), так и морально!
Всех приобнял ^_^,
Станислав Чернышев
Эта книга особенная, потому что ее написал не один человек, а целая команда, которая любит Flutter и тратит свое время на то, чтобы развивать и популяризировать
наш любимый фреймворк.
Мои коллеги — настоящие профессионалы, и их знания и опыт помогают
нам создавать качественный учебный материал. Я уверен, что у нас получился
Предисловие 15
полноценный учебный комплекс. Он включает в себя интерактивные задания — это
позволит читателям не только изучать теорию, но и сразу начать применять знания
на практике. А преподаватели смогут использовать наш материал в своих учебных
программах в вузах и колледжах.
Благодарю свою супругу Светлану, которая поддерживала меня и придавала
мне сил и энергии писать. Благодарю сообщество Flutter, которое поверило в нашу
экспертность и поддержало нашу команду. Благодарю руководство компании Friflex
за поддержку. Благодарю тех ребят, которые купят книгу или курс. Знайте, именно
благодаря таким, как вы, появилась эта книга и фреймворк Flutter так стремительно
развивается в российском сегменте.
Эта книга — результат сотни часов работы, обсуждений, идей и упорства. Надеюсь,
она станет для вас надежным помощником в изучении Flutter.
С уважением, Юрий Петров
Еще год назад и представить не мог, что буду писать обращение к читателю книги.
С первого приложения я искренне влюблен во Flutter! Его округлые кнопочки,
изящные формы и мудрая архитектура. Как тут не влюбиться?!
Этот фреймворк на протяжении уже многих лет дает возможность мне и моим
коллегам создавать отзывчивые и красивые интерфейсы для миллионов пользователей на любой платформе.
В книге мы c авторами постарались передать всю красоту Flutter и по максимуму поделиться своим опытом в создании кросс-платформенных приложений. Вас
ждет тонна базированной информации, подкрепленная практикой и углублением
в такие детали, о которых вы, возможно, даже не ожидали услышать.
Спасибо авторскому коллективу за то, что пригласили принять участие в работе. Как говорят у нас в хип-хопе, «мерси, что позвали на этот cypher». Целую свою
родню (банду) и друзей за поддержку! Это действительно было непросто!
А тебе, читатель, — краба за поддержку этой книги и приятного чтения!
С уважением, Станислав Ильин
Эта книга — огромный пласт опыта, который мы, авторы, получили за долгие годы
работы с фреймворком Flutter. В ней собраны не просто основы, которые можно
почерпнуть из документации, но и различные лучшие практики, применяемые
нами в проектах.
Вам предстоит пройти путь от изучения того, как это работает, и первого применения виджетов до создания невероятных приложений, которые могут работать на
нескольких платформах! Поэтому книга может стать для вас не просто источником
новых знаний, но и попутчиком в изучении всех прелестей мобильной разработки.
Благодарю моих друзей и коллег за поддержку при написании этой книги,
а также сообщество Flutter-разработчиков, которое и стало отправной точкой
для старта.
С уважением, Павел Гершевич
16 Предисловие
Присоединяйся!
Далее приведены ссылки на полезные ресурсы, материалы к книге (курс на платформе Stepik с интерактивными задачами) и каналы, которые ведут авторы.
Мы в Telegram
https://t.me/FlutterBasics
ТГ-канал авторского коллектива книги «Основы Flutter», в котором публикуются новости
о книге и курсе на Stepik
Чат сообщества
https://t.me/+Q_otDObSvYMyMTgy
Если при чтении книги у вас появляются вопросы, их можно задать в нашем чате
в Telegram
Электронный курс на Stepik
https://stepik.org/a/197817
Курс «Основы Flutter» — электронная версия книги на платформе Stepik с тестами и интерактивными задачами на программирование (только Dart)
ТГ-канал Станислава Чернышева, где он делится своими мыслями о творящемся
в образовании, мире IT и Dart/Flutter.
https://t.me/madteacher_channel
ТГ-канал Юрия Петрова, где вы найдете все, что касается мобильной разработки
и Flutter.
https://t.me/mobile_developing
ТГ-канал Станислава Ильина: новости IT и Dart/Flutter, в частности, о разработке проектов, работе, обучении и немного о жизни. Лютый замес пользы и кринжа.
https://t.me/frezycode
ТГ-канал Павла Гершевича посвящен не только разработке мобильных приложений
на Flutter — без нее никак, но и различным около-IT-темам и лайфстайлу.
https://t.me/ftl_notes
Как работать с книгой 17
Как работать с книгой
Для упрощения навигации по используемым в книге примерам был выбран подход, когда базовую часть URL-пути до исходного файла в репозитории выделяют
в отдельную переменную, в нашем случае:
base_url = https://github.com/MADTeacher/flutter_basics/tree/main/
В случае с нулевой главой далее идут номер главы, раздела и название самого
файла:
base_url/0/0.4/ex0_10.dart
В последующих главах за номером раздела может идти название примера и путь
до файла:
base_url/2/2.6/flutter_image/lib/main.dart
Глава 0 идет особняком и предназначена для тех, кто не знает Dart.
Комментарии // используются для отображения вывода в терминал либо
описывают то, что происходит в коде. Некоторые части кода, на которые стоит
обратить внимание (добавлены или изменены), выделены полужирным шрифтом.
Аналогичным образом в тексте выделяются определения или места, на которые
стоит обратить внимание в процессе изучения материала книги. А зачеркивается
код, который нужно удалить.
ОТ ИЗДАТЕЛЬСТВА
Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com
(издательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На веб-сайте издательства www.piter.com вы найдете подробную информацию
о наших книгах.
Глава 0
УСТАНОВКА И НАСТРОЙКА РАБОЧЕГО
ОКРУЖЕНИЯ. ОСНОВЫ DART
В этой главе мы рассмотрим, как выполнить установку и настройку рабочего
окружения Flutter в различных операционных системах, а также познакомим вас
с базовым синтаксисом Dart. Для более глубокого погружения в этот замечательный
язык программирования обратитесь к книге Станислава Чернышева «Основы Dart».
0.1. Установка и настройка рабочего окружения
Написание кода, представленного в книге, будет вестись в Visual Studio Code, который можно скачать по ссылке https://code.visualstudio.com/download.
0.1.1. ОС Windows
Существует два варианта установки Flutter в Windows: автоматическая и ручная.
Оба они не слишком сложны, но во втором случае у вас есть возможность явно выбрать
каталог, куда будет выполнена распаковка Flutter SDK. Поэтому с него мы и начнем,
воздав дань тем, кто любит, когда власть полностью сосредоточена в их руках.
Перейдите по следующей ссылке: https://docs.flutter.dev/get-started/install.
Либо зайдите на сайт фреймворка, в раздел Get started. Перед вами откроется страница выбора операционной системы, в которой вы будет вести разработку (рис. 0.1).
Рис. 0.1. Выбор ОС, в которой будет вестись разработка
20 Глава 0 Установка и настройка рабочего окружения. Основы Dart
На следующем шаге выберите предлагаемую по умолчанию конфигурацию запуска новых проектов на Flutter (рис. 0.2).
Рис. 0.2. Выбор конфигурации запуска новых проектов на Flutter
Далее найдите раздел Install the Flutter SDK, выберите Download and install и скачайте
архив с актуальной версией фреймворка (рис. 0.3).
Рис. 0.3. Скачивание архива с актуальной версией Flutter
После того как архив с Flutter SDK загрузится, распакуйте его в удобную для
вас папку. Обычно ею выступает корневой каталог диска (C, D, F и т. д.). Теперь
необходимо прописать путь до распакованного Flutter SDK в переменных средах
в переменной Path (рис. 0.4).
В зависимости от целевой платформы, под которую вы планируете разрабатывать приложения, предстоит скачать необходимый инструментарий. Для Windows
это будет Visual Studio Community со следующим выбранным пакетом (рис. 0.5).
0.1. Установка и настройка рабочего окружения 21
Рис. 0.4. Указываем путь до Flutter SDK в переменной Path
Рис. 0.5. Установка Visual Studio Community
Если планируете погружаться в мобильную разработку, то для Android необходимо скачать Android Studio и смириться с тем, что под iOS на Windows или Linux
написать какое-либо приложение с наличием платформозависимого кода, а особенно
22 Глава 0 Установка и настройка рабочего окружения. Основы Dart
произвести сборку, не получится. Это связано с закрытостью экосистемы Apple.
Поэтому для этих целей часто используется ноутбук с операционной системой
macOS — его наличие позволяет одновременно вести разработку как под iOS, так
и под Android. Но, так как не у всех есть возможность купить такой ноутбук, будем
довольствоваться тем, что есть.
Далее нам необходимо установить и настроить Android SDK и Android Studio.
Этот процесс вынесен в подраздел 0.1.4, так как он общий для всех операционных
систем.
Чтобы проверить наличие всех компонентов для полноценной разработки
приложений на Flutter в рамках используемой операционной системы, введите
в терминале flutter doctor без дополнительных флагов и параметров:
C:\Users\MADTeacher>flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[√] Flutter (Channel stable, 3.16.8, on Microsoft Windows [Version 10.0.19045.4046],
locale ru-RU)
[√] Windows Version (Installed version of Windows is version 10 or higher)
[√] Android toolchain - develop for Android devices (Android SDK version 33.0.2)
[√] Chrome - develop for the web
[√] Visual Studio - develop Windows apps (Visual Studio Community 2022 17.8.3)
[√] Android Studio (version 2023.1)
[√] IntelliJ IDEA Community Edition (version 2023.1)
[√] VS Code (version 1.87.0)
[√] Connected device (3 available)
[√] Network resources
• No issues found!
Если такие компоненты, как Chrome или IntelliJ, помечены восклицательным
знаком — ничего страшного, а вот отсутствие других (Android toolchain, Android
Studio) — тревожный звоночек.
0.1.2. ОС macOS
В отличие от установки Flutter в Windows, для macOS нужна небольшая дополнительная подготовка. Если у вас компьютер на Apple Silicon, сначала необходимо
настроить Rosetta 2. Для этого требуется открыть приложение Terminal и выполнить
следующую команду:
sudo softwareupdate --install-rosetta --agree-to-license
После этого понадобится установить дополнительное программное обеспечение,
а именно IDE Xcode и менеджер зависимостей для приложений под iOS, macOS
и другие операционные системы от Apple — CocoaPods.
Xcode — IDE от Apple, которая нужна для разработки приложений для iOS,
iPadOS, macOS, watchOS и других ОС. С ней также поставляются:
y система контроля версий git;
y SDK для операционных систем от Apple;
y языки программирования Swift и Objective-C;
y симуляторы iOS, iPadOS, watchOS и tvOS.
0.1. Установка и настройка рабочего окружения 23
В установке Xcode нет ничего сложного, так как его можно скачать с помощью
Apple App Store. Для этого воспользуйтесь поиском или перейдите в раздел Разработка в меню справа (рис. 0.6).
Рис. 0.6. Раздел Разработка в App Store на macOS
Для скачивания и установки Xcode (рис. 0.7) понадобится некоторое время, так
как он занимает довольно много места на устройстве. После завершения установки
перейдите в терминал и запустите команду для настройки командной строки:
sudo sh -c 'xcode-select -s /Applications/Xcode.app/Contents/Developer && xcodebuild
-runFirstLaunch'
Следующим действием необходимо принять лицензию Xcode. Сделать это
можно, не выходя из терминала:
sudo xcodebuild -license
Если ваш компьютер на Apple Silicon, то перед установкой CocoaPods нужно
выполнить еще одну команду:
sudo gem install ffi
После чего выполните уже саму установку CocoaPods:
sudo gem install cocoapods
24 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Рис. 0.7. Страница IDE Xcode в App Store на macOS
Теперь необходимо прописать путь до установленного нами CocoaPods в переменной PATH. macOS использует несколько различных терминалов в зависимости
от версии. Мы рассмотрим тот, что присутствует в последних, — zsh. Для начала
проверьте, существует ли файл ~/.zshenv, и если он отсутствует, то создайте. После
чего откройте его любым текстовым редактором, будь то TextEdit или встроенный
в терминал nano, и введите туда новую строку:
export PATH=$HOME/.gem/bin/:$PATH
где переменная $HOME — путь к нашему пользователю, переменная $PATH — та, к которой мы добавляем CocoaPods.
Сохраните файл и перезагрузите терминал. Для этого есть два способа: просто
полностью выключить все текущие сессии терминала или выполнить команду:
source ~/.zshenv
CocoaPods можно установить и по-другому — с помощью пакетного менеджера
Homebrew, который способен помочь и с установкой Flutter на компьютер. Для
его установки введите в терминале следующие команды (их можно найти на сайте
Homebrew https://brew.sh/):
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/
install.sh)"
0.1. Установка и настройка рабочего окружения 25
Как только на компьютере появляется Homebrew, для установки CocoaPods
потребуется всего одна команда:
brew install cocoapods
Настала пора приступить к установке Flutter. Как и в случае с ОС Windows,
перейдите на сайт фреймворка в раздел Get started или по следующей ссылке: https://
docs.flutter.dev/get-started/install.
В нашем случае сайт сам подсветит выбранную macOS (рис. 0.8).
Рис. 0.8. Выбор ОС, в которой будет вестись разработка
На следующем шаге выберите предлагаемую по умолчанию конфигурацию запуска новых проектов на Flutter (рис. 0.9).
Рис. 0.9. Выбор конфигурации запуска новых проектов на Flutter
Далее найдите раздел Install the Flutter SDK, выберите Download and install и скачайте
архив с актуальной версией фреймворка под ваш процессор (рис. 0.10).
26 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Рис. 0.10. Скачивание архива с актуальной версией Flutter
Документация Flutter рекомендует создавать каталог и распаковывать архив
с помощью терминала, но никто не запрещает сделать это вручную. Обычно каталогом для распаковки выступает папка development в корневом каталоге вашего
пользователя. Для его создания и распаковки архива воспользуемся командами:
mkdir ~/development
unzip ~/Downloads/flutter_macos_arm64_3.22.2-stable.zip \ -d ~/development
На следующем шаге необходимо прописать в уже знакомом нам по предыдущим
шагам файле ~/.zshenv путь до распакованного Flutter SDK в переменной PATH.
Откройте его любым удобным способом и добавьте в конец строку:
export PATH=$HOME/development/flutter/bin:$PATH
После сохранения файла не забудьте про перезагрузку терминала, которую
можно выполнить, введя следующую команду:
source ~/.zshenv
Второй способ установки Flutter — с помощью Homebrew. Если вы уже установили Homebrew для CocoaPods (см. ранее), то для установки Flutter SDK достаточно
вызвать одну команду:
brew install --cask flutter
и дождаться завершения процесса.
Если хотите запускать Flutter-приложения на Android, то вам необходимы
Android SDK и Android Studio, установка и настройка которого описаны в подразделе 0.1.4.
0.1. Установка и настройка рабочего окружения 27
ВАЖНОЕ ЗАМЕЧАНИЕ!
Для работы с Android требуется JDK, официальные версии которого работают только на процессорах Intel. Поэтому, если у вас компьютер на Apple Silicon, то необходимо установить Azul Zulu OpenJDK 17 под эту архитектуру. Скачать его (рис. 0.11)
можно на сайте Azul по ссылке https://www.azul.com/downloads/?version=java-17lts&os=macos&architecture=arm-64-bit&package=jdk#zulu.
Рис. 0.11. Скачивание Azul Zulu OpenJDK 17 под ARM
Для разработки под iOS у нас практически все готово, осталось только докачать
необходимые для работы симуляторов файлы. Это делается такой командой:
xcodebuild -downloadPlatform iOS
Если есть необходимость запуска на реальном iPhone или iPad, то сначала нужно
купить подписку Apple Development Program, иначе ничего не получится. Правила
у Apple довольно строгие, и это необходимо, чтобы никто не мог навредить своему
устройству.
Чтобы проверить наличие всех компонентов для полноценной разработки
приложений на Flutter в рамках используемой операционной системы, введите
в терминал flutter doctor без дополнительных флагов и параметров:
fognature@MBP-Pavel-2 ~ % flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[√] Flutter (Channel stable, 3.22.2, on macOS 14.0 23A344 darwin-arm64, locale ru-RU)
[√] Android toolchain - develop for Android devices (Android SDK version 33.0.1)
[√] Xcode - develop for iOS and macOS (Xcode 15.4)
[√] Chrome - develop for the web
[√] Android Studio (version 2023.1)
[√] VS Code (version 1.89.0)
[√] Connected device (4 available)
[√] Network resources
• No issues found!
Если такие компоненты, как Chrome или IntelliJ, помечены восклицательным
знаком — ничего страшного, а вот отсутствие других (Android toolchain, Android
Studio, Xcode) — тревожный звоночек.
28 Глава 0 Установка и настройка рабочего окружения. Основы Dart
0.1.3. ОС Ubuntu 20.04 LTS+ и Debian 11+
Как и в предыдущих случаях, перейдите на сайт фреймворка в раздел Get started
или по следующей ссылке: https://docs.flutter.dev/get-started/install и выберите вариант
установки Flutter в Linux. Так как мы рассмотрим установку Flutter с конфигурацией, позволяющей разрабатывать под Desktop и Android (для Web нужно будет
только установить браузер Chrome), то нет разницы, что вы выберете на этом шаге
(рис. 0.12).
Рис. 0.12. Выбор конфигурации запуска новых проектов на Flutter
Далее найдите раздел Install the Flutter SDK, выберите Download and install и скачайте
архив с актуальной версией фреймворка под ваш процессор (рис. 0.13).
Рис. 0.13. Скачивание архива с актуальной версией Flutter
0.1. Установка и настройка рабочего окружения 29
Первым делом после скачивания Flutter откройте терминал и убедитесь, что
у вас установлены следующие инструменты:
which bash file mkdir rm which
/usr/bin/bash
/usr/bin/file
/usr/bin/mkdir
/usr/bin/rm
/usr/bin/which
Далее установим необходимые пакеты:
sudo apt-get update -y && sudo apt-get upgrade -y;
sudo apt-get install -y curl git unzip xz-utils zip libglu1-mesa
Для одновременной разработки приложений под Desktop и Android нам потребуется установить довольно большой набор пакетов:
sudo apt-get install \
clang cmake git \
ninja-build pkg-config \
libgtk-3-dev liblzma-dev \
libstdc++-12-dev \
libc6:i386 libncurses5:i386 \
libstdc++6:i386 lib32z1 \
libbz2-1.0:i386
Если вы уже скачали Visual Studio Code, то переходите к следующему шагу, в противном случае сделайте это, перейдя по ссылке https://code.visualstudio.com/download.
В нашем случае был скачан пакет code_1.90.1-1718141439_amd64.deb. Для его
установки необходимо перейти в терминале в каталог с загрузками, после чего
ввести команду:
x@x-pc:~/Загрузки$ sudo apt install ./code_1.90.1-1718141439_amd64.deb
Для распаковки Flutter в каталог /usr/bin/ введите в терминале (находясь в каталоге с загрузками) следующую команду:
x@x-pc:~/Загрузки$ sudo tar -xf ./flutter_linux_3.22.2-stable.tar.xz -C /usr/bin/
Чтобы добавить Flutter в переменную среды PATH, первым делом проверьте,
какая оболочка консоли используется в вашей операционной системе:
x@x-pc:~/Загрузки$ echo $0
bash
Если bash, то для указания путей до Flutter используйте команду:
echo 'export PATH="/usr/bin/flutter/bin:$PATH"' >> ~/.bash_profile
Для zsh:
echo 'export PATH="/usr/bin/flutter/bin:$PATH"' >> ~/.zshenv
После чего закройте терминал. При наличии другой оболочки обратитесь
к официальной документации Flutter: https://docs.flutter.dev/get-started/install/linux/
android?tab=download#add-flutter-to-your-path.
30 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Теперь перейдем к небольшим пляскам с бубном, а именно к установке Android
Studio, скачать который можно по следующей ссылке: https://developer.android.com/studio.
По завершении скачивания распакуйте архив и откройте в терминале каталог
с загрузками, переместив Android Studio в /usr/local/:
х@х-pc:~/Загрузки$ sudo mv ./android-studio /usr/local/
Сам процесс установки и последующего запуска IDE состоит из нескольких
команд:
х@х-pc:~/Загрузки$ cd /usr/local/android-studio/bin/
х@х-pc:/usr/local/android-studio/bin$ ./studio.sh
После завершения установки Android Studio ее необходимо перезапустить,
а также инсталлировать дополнительные компоненты. Для этого на стартовом
экране IDE нажмите на More Actions и выберите запуск SDK Manager (рис. 0.14).
Рис. 0.14. Запуск SDK Manager
Далее перейдите на вкладку SDK Tools и убедитесь, что установлены все компоненты, выделенные галочками на рис. 0.15.
В противном случае поставьте галочки напротив:
y Android SDK Command-line Tools;
y Android SDK Build-Tools;
y Android SDK Platform-Tools;
y Android Emulator.
0.1. Установка и настройка рабочего окружения 31
Рис. 0.15. Установка компонентов
После этого нажмите кнопку Apply, дождавшись их установки. Порядок создания нового виртуального устройства и последующей настройки VS Code ничем
не отличается от приведенного в подразделе 0.1.4. Поэтому для начала перейдите
в него и создайте свой первый эмулятор, попутно установив нужный плагин для
IDE и приняв все лицензионные соглашения от Google.
Чтобы проверить наличие всех компонентов для полноценной разработки
приложений на Flutter в рамках используемой операционной системы, введите
в терминал flutter doctor без дополнительных флагов и параметров:
х@х-pc:~$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.22.2, on Ubuntu 22.04.4 LTS 6.5.0-35-generic, locale
ru_RU.UTF-8)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✗] Chrome - develop for the web (Cannot find Chrome executable at google-chrome) !
Cannot find Chrome. Try setting CHROME_EXECUTABLE to a Chrome executable.
[✓] Linux toolchain - develop for Linux desktop
[✓] Android Studio (version 2024.1)
[✓] VS Code (version 1.90.1)
[✓] Connected device (1 available)
[✓] Network resources
! Doctor found issues in 1 category.
Если компонент Chrome помечен восклицательным знаком — ничего страшного,
а вот отсутствие Android, Linux toolchain, Android Studio и т. д. — тревожный звоночек.
Возможно, у вас есть непреодолимое желание разрабатывать на Flutter в Android
Studio, но каждый раз запускать ее командами — то еще удовольствие:
х@х-pc:~/Загрузки$ cd /usr/local/android-studio/bin/
х@х-pc:/usr/local/android-studio/bin$ ./studio.sh
32 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Разместить ее ярлык (рис. 0.16) в меню быстрого доступа можно средствами самой IDE. Для
этого создайте любой проект и выполните последовательность шагов Android StudioToolsCreate
Desktop Entry.
Щелкнув на ярлыке правой кнопкой мыши,
можно выбрать его фиксацию на панели инструментов на рабочем столе.
Рис. 0.16. Ярлык для запуска Android Studio
0.1.4. Установка и настройка Android Studio
Скачать Android Studio можно по следующей ссылке: https://developer.android.com/studio.
Прежде чем его устанавливать, убедитесь, что для процессора в BIOS включена
аппаратная поддержка виртуализации. Это повысит скорость работы Androidэмулятора, дав ему прямой доступ к аппаратным ресурсам компьютера.
В самой установке Android Studio нет ничего сложного. Достаточно нажимать на Далее и соглашаться на инсталляцию дополнительных компонентов,
а именно:
y Android SDK Platform, API 34.0.0 (версия может отличаться);
y Android SDK Build-Tools;
y Android SDK Platform-Tools;
y Android Emulator.
Если у вас много оперативной памяти и вы дружите с тремя буквами, которые
нынче нельзя произносить на просторах РФ, то на Flutter можно разрабатывать
и в Android Studio. Для этого после запуска IDE перейдите в раздел Plugins и установите следующий плагин (рис. 0.17).
Рис. 0.17. Установка плагина Flutter в Android Studio
0.1. Установка и настройка рабочего окружения 33
После установки плагина и перезагрузки IDE у вас появится возможность создать новый проект на Flutter (рис. 0.18).
Рис. 0.18. Стартовый экран Android Studio
После нажатия на More Actions открывается
меню с дополнительными элементами (рис. 0.19),
дающее нам доступ к менеджеру виртуальных
устройств и SDK. Первый нужен для создания
и удаления конфигурации эмулятора, а также
управления ею, а второй позволяет скачать целевой Android SDK, под который будет вестись
разработка, и управлять дополнительными инструментами (драйверы, сборщик и т. д.).
Откройте менеджер виртуальных устройств,
который должен встретить вас следующим пользовательским интерфейсом (рис. 0.20).
Рис. 0.19. Структура меню More Actions
Рис. 0.20. Менеджер виртуальных устройств
34 Глава 0 Установка и настройка рабочего окружения. Основы Dart
По умолчанию при установке IDE были созданы несколько эмуляторов под 33-ю
и 34-ю версии API. Если вам нужна другая версия Android на эмуляторе, то его
придется добавить вручную (рис. 0.21). Для этого щелкните на крестике + в левом
верхнем углу (см. рис. 0.20).
Рис. 0.21. Конфигурация создаваемого виртуального устройства
Выберите в категории Phone один из свежих смартфонов Pixel и нажмите кнопку
Next (рис. 0.22).
Рис. 0.22. Выбор версии Android на создаваемом виртуальном устройстве
0.1. Установка и настройка рабочего окружения 35
Если нужная вам версия не установлена ни на одной из вкладок (Recommended,
, после чего перейти
к следующему шагу (рис. 0.23).
x86 Images, Other Images), ее можно скачать, щелкнув на
Рис. 0.23. Завершение конфигурации создаваемого виртуального устройства
На данном шаге можно изменить выбор устройства или версии Android, задать
имя, например MADPixel 7 Pro API 33, и завершить конфигурацию создаваемого
виртуального устройства, нажав Finish. Новое устройство будет добавлено в список
доступных эмуляторов (закройте Device Manager) (рис. 0.24).
Рис. 0.24. Добавление нового виртуального устройства в менеджер
36 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Представим, что вам жалко выделяемой для эмулятора оперативной памяти и вы
приняли уверенное решение вести разработку с реального устройства. Активируйте
на телефоне режим разработчика, затем, если у вас модель от Google, перейдите
в раздел SDK Tools менеджера SDK (SDK Manager) и установите Google USB Driver
(рис. 0.25). В ином случае перейдите на сайт производителя и скачайте их там.
Рис. 0.25. Установка Google USB Driver
Независимо от того, будете вы разрабатывать только в эмуляторе или на реальном устройстве, на этой же вкладке установите флажок Android SDK Command-line
Tools (рис. 0.26).
Рис. 0.26. Установка Android SDK Command-line Tools
0.2. Создание первого проекта на Dart 37
Теперь закройте Android Studio и запустите Visual Studio Code, установив расширение Flutter (рис. 0.27) и перезапустив приложение.
Рис. 0.27. Установка расширения Flutter для VS Code
После перезапуска VS Code откройте терминал с помощью меню или сочетания
клавиш Ctrl+Shift+`. Перед использованием Flutter нам необходимо принять лицензии Android SDK platform. Для этого введите в терминале следующую команду:
C:\Users\MADTeacher>flutter doctor --android-licenses
[=======================================] 100% Computing updates...
5 of 7 SDK package licenses not accepted.
Review licenses that have not been accepted (y/N)? y
Если на данном этапе появляется сообщение об ошибке:
Android sdkmanager not found. Update to the latest Android SDK and ensure that the
cmdline-tools are installed to resolve this.
значит, вы не установили Android SDK Command-line Tools.
0.2. Создание первого проекта на Dart
Поскольку Dart — неотъемлемая часть Flutter, у нас уже все готово для создания
и запуска первого приложения. Для этого откроем VS Code и создадим новый Dartпроект, используя сочетание клавиш Ctrl+Shift+P (на латинице) и введя в появившейся командной строке Dart: New Project (рис. 0.28). Команда может появиться
в списке до того, как будет введена полностью. В этом случае просто выбираем ее
из списка.
Рис. 0.28. Создание нового проекта
На этом шаге обращаем внимание на правый нижний угол приложения. Возможно, оно предложит установить какие-то новые зависимости расширения Dart,
38 Глава 0 Установка и настройка рабочего окружения. Основы Dart
что не даст с первой попытки создать новый проект. Если это произошло, то повторяем предыдущий шаг и в появившемся новом списке выбираем Console Application
(рис. 0.29).
Рис. 0.29. Создание консольного приложения
На следующих шагах необходимо выбрать папку, в которой будет располагаться
консольный проект, и его имя. После успешно проделанных шагов VS Code может
спросить вас: «Доверяете ли вы сами себе?» (рис. 0.30).
Рис. 0.30. Добавление создаваемого проекта в Workspace Trust
Если у вас нет доверия даже к себе, то лучше отложите книгу и забросьте программирование — вам прямой путь в кибербезопасность. 😉
В итоге VS Code должен выглядеть примерно следующим образом (рис. 0.31).
Нажав клавишу F5, можно запустить код в режиме отладки (debug), а если
воспользоваться сочетанием клавиш Ctrl+F5, код запустится без данного режима.
Попробуйте один из этих вариантов. В результате должна появиться консоль
с текстом Hello world: 42!.
0.2. Создание первого проекта на Dart 39
Рис. 0.31. Созданный проект
На данный момент структура папок проекта должна выглядеть следующим
образом (рис. 0.32).
Рис. 0.32. Структура папок проекта
В каталоге bin должен находиться файл с точкой входа в приложение, в котором
объявлена функция верхнего уровня main. В папке lib принято хранить основной код
проекта в виде подключаемого пакета. А в папке test находятся файлы с тестовым
окружением проекта.
Сейчас не будем заострять внимание на папках lib и test. Откройте файл в каталоге bin и измените в нем код с такого:
import 'package:hello_world/hello_world.dart' as hello_world;
void main(List<String> arguments) {
print('Hello world: ${hello_world.calculate()}!');
}
на такой:
void main(List<String> arguments) {
print('Hello world!');
}
40 Глава 0 Установка и настройка рабочего окружения. Основы Dart
При повторном запуске проекта у вас в терминал должно быть выведено Hello
world!.
В завершение главы нам остается разобраться с вводом данных с клавиатуры,
их приведением к необходимому типу данных и дальнейшим использованием. Это
необходимо для успешного выполнения заданий лабораторной работы, приводимой
после вопросов для самопроверки.
Того, как мы ранее настроили рабочее окружение, недостаточно, так как по
умолчанию в VS Code для Dart используется консоль, из которой невозможно переопределить поток ввода. Чтобы исправить эту ситуацию, пройдите по следующим
вкладкам меню: FilePreferencesSettings.
Далее в графе поиска введите dart cli и в параметре Dart: Cli Console вместо
debugConsole выберите terminal (рис. 0.33).
Рис. 0.33. Выбор Dart: Cli Console
Несмотря на то что и при инициализации строковой переменной, и в функции
мы можем использовать кириллицу, ее ввод в терминале при работающем
приложении не поддерживается. Поэтому вооружаемся словариком и постигаем
дзен английского языка. 😉
Для ввода данных с клавиатуры через терминал нам потребуется импортировать библиотеку dart:io, которая позволяет работать с операциями ввода-вывода,
файлами, сокетами, HTTP и т. д. Мы немного упростим код и будем считать, что
пользователь всегда вводит корректное значение:
print
import 'dart:io';
void main() {
print('Введите целочисленное значение');
String? input = stdin.readLineSync(); // синхронный ввод данных
0.3. Базовые типы данных, модификаторы доступа и null-safety 41
print(input?.runtimeType); // String
var inputInt = int.tryParse(input!); // перевод строки в число
print(inputInt.runtimeType); // int
}
print('Введенное значение: $inputInt');
Теперь нажмите F5 и в открывшемся терминале введите значение 5, после чего
завершите ввод данных, нажав Enter. В итоге ваша программа должна отработать
следующим образом (рис. 0.34).
Рис. 0.34. Пример работы приложения
0.3. Базовые типы данных, модификаторы доступа
и null-safety
Согласно документации Dart все помещаемые в переменную значения являются
объектами, которые, в свою очередь, представляют собой экземпляр класса. Такая
концепция очень похожа на ту, что применяется в языке программирования Python,
в котором все является объектом. Таким образом, даже числа, строки, функции
и null — это объекты.
Существуют следующие встроенные типы данных:
y числа (int, double);
y строки (String);
y логические значения (bool);
y списки (List);
y записи (Record);
y множества (Set);
y таблицы (Map);
y руны (Rune);
y символы (Symbol);
y значение null (Null).
42 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Но прежде, чем начать ближе знакомиться с встроенными типами данных и их
объявлением, необходимо поговорить про комментарии и правила именования.
Комментарии в Dart делятся на два типа: однострочные и многострочные. В первом
случае используется //, после чего идет комментарий, который не переносится на
следующую строку:
// комментарий
var a = 10; // еще один комментарий
Если комментарий будет занимать более двух строк, то необходимо либо каждую его строку начинать с //, либо использовать многострочный формат записи:
/*
Сверхдлинный комментарий
*/
var a = 10;
Комментарии можно использовать не только для того, чтобы комментировать
происходящее в коде. Например, с их помощью можно исключить выполнение
определенной строки или блока кода, то есть закомментировать их. Но сильно этим
увлекаться не стоит, так как такой подход засоряет чистоту вашей кодовой базы,
из-за чего впоследствии обязательно возникнут трудности у новых людей в команде.
Строки же документации, которые позволяют использовать инструмент dartdoc
для автоматической генерации документации вашего проекта, начинаются с ///.
Считается плохим тоном в тех частях, где пояснения должны попасть в документацию, использовать простые комментарии, так как они будут пропущены. Более
подробно с тем, как принято документировать код в проектах, разрабатываемых
с применением Dart, можно ознакомиться в руководстве по документированию
проектов, размещенном на официальном сайте.
В нашей книге комментарии используются для демонстрации того, какой
результат будет на выходе программы, что вернет та или иная строка кода или дополнительного пояснения.
Что же касается именования при объявлении на Dart переменных, функций,
классов и их методов, придерживайтесь следующих рекомендаций.
1. При объявлении переменных, функций и методов классов используется верблюжий стиль, а само название начинается с маленькой буквы (lowerCamelCase).
Для логического разделения слов в объявляемой переменной необходимо
использовать символ в верхнем регистре — myCatName. Имя же объявляемого
класса начинается с большой буквы (UpperCamelCase) — DailySchedule.
2. Нельзя использовать в начале объявляемого имени числовые значения.
3. Регистр символов имеет значение. Так, например, var CHECK = 10; и var check
= 10; — две совершенно разные переменные.
4. Не используйте в качестве имен переменных ключевые слова Dart.
5. Если имя переменной, функции и так далее начинается с символа _, то она
является приватной (для импортирующего код модуля).
0.3. Базовые типы данных, модификаторы доступа и null-safety 43
0.3.1. Числа (int, double)
В Dart всего два числовых типа данных: целочисленные (int) и вещественные,
то есть с плавающей точкой (double).
Целочисленные значения типа int в зависимости от платформы могут занимать
в памяти не более 64 бит. В виртуальной машине Dart числа типа int могут принимать значения в диапазоне от –263 до 263 – 1, а при переводе кода в JavaScript
используется диапазон значений, характерный для этого языка программирования, — от –253 до 253 – 1.
Числа с плавающей точкой типа double занимают в памяти 64 бита и реализованы
в соответствии со стандартом IEEE 754.
Теперь рассмотрим, как можно объявлять переменные числовых типов
данных:
int a = 5;
int hex = 0xDEAFF; // 912127
var b = 10; // int
double c = 30.5;
var d = 1.1;
var exponents = 1.42e5; // 142000.0
Ключевое слово var перед именем переменной означает, что компилятор Dart
сам выведет тип объявляемой переменной в зависимости от того, что разработчик
напишет в правой части объявления после символа =.
Как и в других языках программирования со статической типизацией, если мы
объявили целочисленную переменную, то компилятор не даст нам записать в эту
переменную значение вещественного типа:
int a = 5;
a = 3.5; // error: A value of type 'double' can't be
//assigned to a variable of type 'int'.
var b = 2;
b = 3.5; // error
При этом вещественным переменным можно присваивать целочисленные значения:
double a = 3.5;
a = 5;
var b = 2.2;
b = 3;
В версии Dart 3.6 был введен цифровой разделитель — символ нижнего подчеркивания между числами. Он существенно упрощает чтение и ввод длинных
числовых значений:
// корректное использование цифрового разделителя
100__000_000__000 // поддерживается несколько последовательных символов _
0x8000_0000
0.000_000_020
0x00_10_26_01_27_55 // MAC-адрес
44 Глава 0 Установка и настройка рабочего окружения. Основы Dart
0.3.2. Строки (String)
Строки в Dart представляют собой последовательность символов в кодировке
UTF-16. Для их объявления (создания) могут использоваться как одинарные, так
и двойные кавычки:
String s1 = 'Мама мыла раму';
var s2 = "Мама мыла две рамы";
var s3 = '''Многострочная
строка''';
Для обращения к конкретному элементу строки по его индексу можно использовать квадратные скобки:
print(s2[0]); // М, так как индексация начинается с нуля
Так как строки — неизменяемый тип данных (Immutable ), то запись вида
s2[0] = 'П' приведет к ошибке. Ввиду этого на основе одного объекта должен быть
создан другой, где в процессе создания производятся необходимые изменения:
var s4 = 'П' + s2.substring(1); // Пама мыла две рамы
В этом случае использовалась операция конкатенации (операция сложения
между двумя строками). Из строки s2 были взяты все символы, кроме первого,
посредством метода substring. Он применяется, когда необходимо вырезать подстроку определенной длины. Для этого в метод substring необходимо передать
индекс первого и последнего элементов, на основе которых сформируется новая
строка, например:
var s3 = 'П' + s2.substring(1, 9); // Пама мыла
Узнать длину строки можно, обратившись к атрибуту переменной length:
print(s2.length); // 18
Для перевода всех символов в верхний или нижний регистр используются
следующие методы:
print(s2.toUpperCase()); // МАМА МЫЛА ДВЕ РАМЫ
print(s2.toLowerCase()); // мама мыла две рамы
Когда вызываете такие методы у строк, необходимо помнить, что они не влия
ют на оригинальный объект, а возвращают преобразованное значение, которое
необходимо присвоить другой переменной для последующей работы с ним. Более
наглядно это объяснит следующий пример:
var s2 = "Мама мыла две рамы";
s2.toUpperCase();
print(s2); // Мама мыла две рамы
var s3 = s2.toUpperCase();
print(s3); // МАМА МЫЛА ДВЕ РАМЫ
Теперь рассмотрим ситуацию, которая довольно часто будет встречаться при
написании кода, — перевод числа в строку и наоборот:
// String -> int
var myInt = int.parse('34'); // строка в число
0.3. Базовые типы данных, модификаторы доступа и null-safety 45
// String -> double
var myDouble = double.parse('11.45');
// int -> String
String s1 = 14.toString();
String s2 = myInt.toString();
// double -> String
String s3 = 3.14159.toStringAsFixed(2); // Два числа после точки
String s4 = myDouble.toString();
Для сравнения строк используйте следующий подход:
// посимвольное сравнение
var s1 = 'Oo', s2 = 'Oo';
print(s2 = = s1); // true — строки равны
print(s2 = = 'oO'); // false
// лексикографическое (в алфавитном порядке) сравнение
var s1 = 'Мама', s2 = 'Папа';
print(s1.compareTo(s2)); // -1
print(s2.compareTo(s1)); // 1
print(s1.compareTo('Мама')); // 0
Чтобы разбить строку на несколько частей, воспользуйтесь методом split:
var s1 = "Мама мыла рамы";
print(s1.split(' ')); // [Мама, мыла, рамы]
print(s1.split('л')); // [Мама мы, а рамы]
print(s1.split('мыла')); // [Мама , рамы]
В результате работы метода split вернется список подстрок, количество которых
будет зависеть от выбранного разделителя.
0.3.3. Логические значения (bool)
Переменные типа bool могут принимать только два значения: true и false. Их объявление производится следующим образом:
bool a = false;
var b = true;
0.3.4. Списки (List)
Списки — позиционно упорядоченные коллекции объектов. В отличие от строк их
можно модифицировать на месте присваиванием по индексу или вызовом некоторых списковых методов. Это позволяет использовать списки как довольно гибкий
инструмент для представления коллекций объектов, таких как перечень продуктов
в чеке, текущие дела на день и т. д.
На самом деле списки в Dart — это массивы, которые делятся на два типа:
y с фиксированным количеством элементов;
y с произвольным количеством элементов.
По умолчанию создается список с произвольным количеством элементов. Аналогично числам и строкам списки можно объявлять несколькими
46 Глава 0 Установка и настройка рабочего окружения. Основы Dart
способами — отдав вывод типа объектов, с которыми работает список, на откуп
Dart либо задав явно:
var myList1 = [ 1, 2, 3];
List<int> myList2 = [1, 2, 3];
var myList1 = <int>[]; // пустой список
Значение элемента списка изменяется следующим образом:
myList1[0] = 20;
print(myList1); // [20, 2, 3]
Поскольку списки по умолчанию могут хранить только объекты одного типа
данных, то, если добавить объект другого типа данных или изменить значение
элемента списка на другое, это приведет к ошибке:
myList1[0] = 20.4;
myList1.add('Oo');
//
//
//
//
error: A value
be assigned to
error: A value
be assigned to
of type 'double' can't
a variable of type 'int'.
of type 'String' can't
a variable of type 'int'.
Для создания списка из неизменяемых (константных) элементов, в который невозможно добавить новые элементы или из которого удалить уже существующие,
необходимо использовать ключевое слово const либо именованный конструктор —
unmodifiable:
var constList = const [4, 2, 1];
// или var constList = List.unmodifiable([4, 2, 1]);
constList[0] = 5;//exception: Cannot modify an unmodifiable list
constList.remove(2); // Unsupported operation: Cannot remove
// from an unmodifiable list
Далее приведем некоторые операции добавления и удаления элементов:
var myList = <int>[]; // пустой список для элементов типа int
// добавление в список
myList.add(4); // в конец списка
myList.addAll([1, 3, 5]); // расширяем список элементами другого
print(myList); // [4, 1, 3, 5]
print(myList.length); // 4 — размер списка
// создаем новый список, добавляя в него элементы существующего
var myList2 = <int>[1, ...myList];
print(myList2); // [1, 4, 1, 3, 5]
// Расширение списка с помощью оператора +=
myList2 += [4, 5, 6];
print(myList2); // [1, 4, 1, 3, 5, 4, 5, 6]
// Вставка элемента на указанную позицию с помощью метода insert
myList2.insert(0, 100);
print(myList2); // [100, 1, 4, 1, 3, 5, 4, 5, 6]
// удаление из списка
myList = <int>[1, 2, 2, 5, 2, 10];
// удаляем из списка элемент, хранящийся по индексу 0
myList.removeAt(0);
print(myList); // [2, 2, 5, 2, 10]
// удаляем первый элемент с заданным значением
myList.remove(2);
print(myList); // [2, 5, 2, 10]
0.3. Базовые типы данных, модификаторы доступа и null-safety 47
// удаляем диапазон элементов с k-го по n-1-й
myList = <int>[1, 2, 2, 5, 2, 10];
myList.removeRange(1, 4);
print(myList); // [1, 2, 10]
// удаляем все элементы
myList.clear();
print(myList); // []
Когда вам нужно на основе текущего списка создать новый с внесением некоторых изменений в хранящиеся значения, используйте метод map (поддерживаются
List, Set и Map). Для примера увеличим значения целочисленного списка на 1,
а также в 2 раза и преобразуем числа в строки:
var myList = <int>[1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
var newList1 = myList.map((element) = > element + 1).toList();
print(newList1); // [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
var newList2 = myList.map((element) = > element * 2).toList();
print(newList2); // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
Поскольку метод map возвращает набор элементов (значений) Iterable<T>,
где T — текущий тип элементов списка, то его необходимо явным образом привести
к списку, что мы и делаем, вызывая метод toList().
0.3.5. Записи (Record)
Записи анонимны, неизменяемы и позволяют группировать разные типы данных.
Если вы знакомы с другими языками программирования, то в голове, наверное,
мелькнуло слово «кортеж» (tuple/product).
Поскольку записи анонимны, то при их объявлении не упоминается тип Record:
var myRecord = (10, '-_-');
// или (int, String) myRecord = (10, '-_-');
print(myRecord); // (10, -_-)
// вывод типа в рантайме
print(myRecord.runtimeType); // (int, String)
// Обращение к элементам записи
print(myRecord.$1); // 10
print(myRecord.$2); // -_-
В данном случае мы объявили запись с неименованными позиционными полями, поэтому обращение к ним производится посредством геттеров $1 и $2. Такой
подход не всегда удобен, так как приходится держать в голове, с помощью какого
геттера обращаться к необходимым данным, что выражается в использовании
именованных полей:
var myRecord = (cost: 10, smile: '-_-');
// или ({int cost, String smile}) myRecord = (cost: 10,
//
smile: '-_-');
print(myRecord); // (cost: 10, smile: -_-)
// Вывод типа в рантайме
print(myRecord.runtimeType); // ({int cost, String smile})
48 Глава 0 Установка и настройка рабочего окружения. Основы Dart
// Обращение к элементам записи
print(myRecord.cost); // 10
print(myRecord.smile); // -_-
Еще одним свойством записей является то, что их можно проверять на равенство.
Единственное, что нужно учитывать, — тип проверяемых записей должен совпадать,
иначе можно допустить ошибку:
var myRecord1 = (10, '-_-');
var myRecord2 = (10, '-_-');
print(myRecord1 = = myRecord2); // true
var myRecord3 = (cost: 10, smile: '-_-');
var myRecord4 = (cost: 10, smile: '-_-');
print(myRecord3 = = myRecord4); // true
print(myRecord1 = = myRecord3); // false
Распаковывается запись с позиционными и/или именованными полями следующим образом:
var myRecord1 = (10, '-_-');
var (first, second) = myRecord1;
print('$first $second'); // 10 -_var myRecord2 = (3.14, cost: 10, smile: '-_-', 22);
var (firstPos, secondPos,
cost: costPos, smile: SmilePos) = myRecord2;
// Сначала распаковываем позиционные,
// именованные можно распаковывать в любом порядке
print(firstPos); // 3.14
print(costPos); // 10
print(SmilePos); // -_print(secondPos); // 22
В целом записи в Dart — довольно мощный инструмент, позволяющий возвращать из функции сразу несколько значений (объединять несколько объектов
в один), создавать неизменяемые наборы данных и довольно изящно объявлять
и реализовывать Data Transfer Object (DTO — объект передачи данных).
0.3.6. Множества (Set)
Множество — неупорядоченная совокупность объектов одного типа данных, в которой не может быть дубликатов. Их часто используют для двух целей: удаления
дубликатов и проверки принадлежности. Как и другие типы, множество можно
объявить явным и неявным образом:
var mySet = <int>{1, 2,
// Set<int> mySet = {1,
// Set<int> mySet = {};
// var mySet = <int>{};
print(mySet); // {1, 2,
5,
2,
//
//
5,
5, 5, 6, 7, 8};
5, 5, 5, 6, 7, 8};
пустое множество
пустое множество
6, 7, 8}
Для примера рассмотрим ситуацию, когда имеется список различных чисел
и необходимо отбросить все существующие в нем дубликаты для формирования
нового списка:
0.3. Базовые типы данных, модификаторы доступа и null-safety 49
var myList = <int>[1, 1, 1, 2, 2, 5, 5, 5, 6, 7, 8];
print(myList); // [1, 1, 1, 2, 2, 5, 5, 5, 6, 7, 8]
var newList = Set<int>.from(myList).toList();
print(newList); // [1, 2, 5, 6, 7, 8]
Как и в случае со строками, значение элементов множества не может быть изменено:
var mySet = <int>{1, 2, 5, 5, 5, 6, 7, 8};
mySet[0] = 3; // The operator '[]=' isn't defined for the type 'Set<int>'.
Добавляются элементы в множество и удаляются из него следующим способом:
var mySet = <int>{1, 2, 5, 5, 5, 6, 7, 8};
// Добавление
mySet.add(10);
print(mySet); // {1, 2, 5, 6, 7, 8, 10}
mySet.addAll([11, 12, 13]);
print(mySet); // {1, 2, 5, 6, 7, 8, 10, 11, 12, 13}
// Удаление
mySet.remove(1); // удаляем один элемент
print(mySet); // {2, 5, 6, 7, 8, 10, 11, 12, 13}
mySet.removeWhere((element) = > element > 10);
// удаление по условию
print(mySet); // {5, 7, 8, 10}
mySet.clear();
print(mySet); // {}
Dart поддерживает всего три математические операции над множествами:
y объединение (union);
y разница (difference);
y пересечение (intersection).
Рассмотрим, как эти операции реализуются в коде:
var mySetA = <int>{1, 2, 5, 6, 7, 8};
var mySetB = <int>{20, 22, 5, 6, 73, 88, 25};
print(mySetA.union(mySetB));
// {1, 2, 5, 6, 7, 8, 20, 22, 73, 88, 25} объединение
print(mySetA.difference(mySetB)); // {1, 2, 7, 8} А-В
print(mySetB.difference(mySetA)); // {20, 22, 73, 88, 25} В-А
print(mySetA.intersection(mySetB)); //{5, 6} пересечение A и В
0.3.7. Таблицы/карты (Map)
представляет собой объект, который связывает ключи и значения. Как ключи, так и значения могут быть объектами любого типа данных. Ключ по своей
сути уникален и не может встречаться несколько раз, в то время как значение
может использоваться сколько угодно раз и связываться с различными ключами.
Говоря простыми словами, Map представляет собой серию пар «ключ:значение»
(MapEntry <K, V>).
Map
50 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Далее приведен пример объявления этого типа данных:
var myMap = <String, String>{
//ключ
//значение
'first': 'Мама',
'second': 'мыла',
'fifth': 'раму'
};
print(myMap); // {first: Мама, second: мыла, fifth: раму}
var myMap2 = <int, String>{
1: 'Мама',
2: 'мыла',
3: 'раму'
};
print(myMap2); // {1: Мама, 2: мыла, 3: раму}
var myMap3 = Map<String, int>(); // пустой объект
var myMap4 = <String, int>{};
// Создание таблицы (map) из двух списков
List<int> keys = [1, 2, 3, 4, 5];
List<String> values = ['one', 'two', 'three', 'four', 'five'];
var myMap = Map<int, String>.fromIterables(keys, values);
print(myMap); // {1: one, 2: two, 3: three, 4: four, 5: five}
Изменение значения, которое хранится по ключу, или добавление новой пары
«ключ:значение» производится практически идентично:
var myMap = <int, String>{
1: 'Мама',
2: 'мыла',
3: 'раму'
};
myMap[1] = 'Бабушка';
// добавляем новую пару "ключ:значение"
myMap [10] = 'по утрам!';
print(myMap);
// {1: Бабушка, 2: мыла, 3: раму, 10: по утрам!}
Попытка извлечь данные по несуществующему ключу не приведет ни к чему
хорошему, так как вернется null:
var myMap = <int, String>{
1: 'Мама',
};
var a = myMap[2];
print(a); // null
Более подробно с null разберемся позднее. Сейчас же нас интересует, как избежать подобной ситуации, а именно — как добавить значение по умолчанию,
если при обращении по ключу такого нет в Map , либо получить значение по
уже имеющемуся. Для этих целей существует метод putIfAbsent. Он принимает на вход значение ключа и анонимную функцию, возвращающую значение,
которое надо записать, если такого ключа еще нет, и возвращает хранящееся по
ключу значение:
0.3. Базовые типы данных, модификаторы доступа и null-safety 51
var myMap = <int, String>{
1: 'Оо',
};
print(myMap.putIfAbsent(1, () = > '!!!')); // Оо
print(myMap); // {1: Оо}
print(myMap.putIfAbsent(3, () = > '!!!')); // !!!
print(myMap); // {1: Оо, 3: !!!}
Далее приведены некоторые примеры работы со свойствами экземпляра Map:
var myMap = <int, String>{
1: 'a', 2: 'b', 3: 'c',
4: 'd', 5: 'e', 6: 'f',
};
// Количество элементов
print(myMap.length); // 6
// Список ключей
print(myMap.keys.toList()); // [1, 2, 3, 4, 5, 6]
// Список значений
print(myMap.values.toList()); // [a, b, c, d, e, f]
// Хранит элементы или пустой
print(myMap.isEmpty); // false
print(myMap.isNotEmpty); // true
Для удаления элементов существуют следующие методы:
var myMap = <int, String>{
1: 'a', 2: 'b', 3: 'c',
4: 'd', 5: 'e', 6: 'f',
};
// Удалить пару "ключ:значение"
myMap.remove(1); // указываем ключ
print(myMap); // {2: b, 3: c, 4: d, 5: e, 6: f}
// удалить все связки пар, которые подходят
// под условие, гласящее, что значение ключа
// не делится на 2 без остатка
myMap.removeWhere((key, value) = > (key % 2 ! = 0));
print(myMap); // {2: b, 4: d, 6: f}
// очистка
myMap.clear();
print(myMap); // {}
Проверить наличие ключа или значения можно так:
var myMap = <int, String>{
1: 'a', 2: 'b', 3: 'c',
4: 'd', 5: 'e', 6: 'f',
};
print(myMap.containsKey(2)); // true
print(myMap.containsKey(34)); // false
print(myMap.containsValue('b')); // true
print(myMap.containsValue('g')); // false
52 Глава 0 Установка и настройка рабочего окружения. Основы Dart
И под конец знакомства с типом Map рассмотрим некоторые методы обновления
хранимого по ключу значения:
var myMap = <int, String>{ 1: 'a', 3: 'c', 2: 'f', };
myMap.update(
3, // ключ
(value) = > 'k', // новое значение
);
print(myMap); // {1: a, 3: k, 2: f}
myMap.update(
2, // ключ
(value) = > '$value!', // новое значение
);
print(myMap); // {1: a, 3: k, 2: f!}
myMap.update(
7, // ключ
(value) = > '$value!', // новое значение
ifAbsent: () = > 'l', // если ключа нет, то добавить
);
print(myMap); // {1: a, 3: k, 2: f!, 7: l}
myMap.updateAll( // Применяется ко всем значениям
(key, value) = > value.toUpperCase(),
);
print(myMap); // {1: A, 3: K, 2: F!, 7: L}
0.3.8. Runes и Symbols
практически аналогичны строкам, с тем отличием, что они представляют
собой последовательность символов в кодировке UTF-32, а не UTF-16. В этом случае каждый символ представляет собой запись вида '\uXXX', где ХХХХ — значение,
состоящее из четырех чисел, в шестнадцатеричной системе счисления. Например,
букве «П» соответствует представление '\u041F'.
Объекты типа Symbol представляют собой некоторые идентификаторы для
ссылки на различные элементы API, такие как библиотеки или классы. Они применяются не так уж часто, и, возможно, вам никогда не придется их лицезреть.
Чтобы объявить Symbol, необходимо использовать #:
Runes
var mySymbol = #myAPI;
print(mySymbol); // Symbol("myAPI")
0.3.9. Модификаторы final, const и late
Модификаторы final и const по своей сути очень похожи. Переменные, перед типом
которых ставятся эти модификаторы, не могут изменяться в процессе выполнения
программы. Основное их различие в том, что константные переменные должны
быть инициализированы в момент объявления, а переменные с модификатором
final можно инициализировать позже, но только один раз:
final int
const int
a = 4; //
b = 5; //
a = 3; //
a;
b = 10;
ok
error: Constant variables can't be assigned a value.
error: The final variable 'a' can only be set once.
0.3. Базовые типы данных, модификаторы доступа и null-safety 53
Модификатор late был добавлен в версии Dart 2.12 и имеет два варианта использования:
y для объявления переменной, не хранящей значение null, инициализация
которой происходит уже после ее объявления;
y для ленивой инициализации переменной.
Отличие final от late заключается в том, что переменные с модификатором
final не могут быть объявлены на верхнем уровне кода. При этом переменные,
объявленные с одним из этих модификаторов, должны быть проинициализированы до их использования, иначе в процессе выполнения приложение выбросит
исключение.
0.3.10. Null-безопасность (null-safety)
Чтобы рассмотреть тему null-безопасности, нам придется забежать немного вперед,
но это позволит более подробно объяснить, где она используется и почему была
введена в версии Dart 2.12.
Основная проблема, возникающая, когда объект может хранить значение null,
связана с тем, что это может вызвать падение программы и увеличение кодовой
базы проекта за счет введения дополнительных проверок на null. Переменная
экземпляра класса имеет некоторое состояние и реализует поведение. В то же
самое время, если переменная хранит ссылку на null, мы не можем реализовать
необходимое поведение в рамках приложения. Null ничего не знает о поведении
объекта, из-за чего, когда мы пытаемся вызвать какой-либо метод у переменной,
происходит падение приложения.
Начиная с версии Dart 2.12 все объявляемые переменные создаются как nullsafety, то есть переменной объявляемого типа данных нельзя присвоить значение null. Также, если мы не проинициализировали переменную до ее использования,
компилятор выведет ошибку:
class Cat {
void helloMaster(){
print("Мяу-у-у-у!!!");
}
}
void main(List<String> arguments) {
Cat myCat;
myCat.helloMaster(); // The non-nullable local variable 'myCat'
// must be assigned before it can be used.
}
Рассмотрим следующую ситуацию. Вы живете в квартире с кошкой. Каждый
раз, когда открываете холодильник, она начинает неистово мяукать, пытаясь надавить на жалость, чтобы ее покормили колбасой. Закодировать эту ситуацию
можно следующим образом:
class Cat {
void helloMaster(){
print("Мяу-у-у-у!!!");
}
}
54 Глава 0 Установка и настройка рабочего окружения. Основы Dart
void openFridge(Cat cat){
cat.helloMaster(); // Мяу-у-у-у!!!
}
void main(List<String> arguments) {
var myCat = Cat();
openFridge(myCat);
}
В данном случае в коде подразумевается, что кошка всегда находится в квартире.
А вдруг кто-то из родственников повез ее к ветеринару или она настолько любит
гулять, что раз в день ваша вторая половинка выходит с ней на улицу… и как раз
в этот момент вы решили открыть холодильник? Так вот, открывая холодильник, вы
все равно услышите протяжное «Мяу-у-у-у!!!». Как же так? Кошки ведь не должно
быть в квартире, а значит, переменная не должна хранить ссылку на экземпляр
класса — она должна указывать на null.
С учетом null-safety нельзя экземпляру класса кошки, который объявлен в примере, присвоить значение null:
void main(List<String> arguments) {
var myCat = Cat();
myCat = null; // error: A value of type 'Null' can't be assigned
// to a variable of type 'Cat'.
}
В этом случае необходимо явно указать компилятору, что объявляемая переменная не является null-safety, то есть может ссылаться на null. Для этого используется
символ ? сразу после объявления типа переменной:
void main(List<String> arguments) {
Cat firstCat = null; // error: A value of type 'Null' can't
// be assigned to a variable of type 'Cat'.
Cat? myCat = null; // ок
}
Так как теперь у нас кошка может то присутствовать, то отсутствовать в квартире, в метод openFridge необходимо передавать аргумент типа Cat? и учитывать,
ссылается передаваемый аргумент на null или на экземпляр класса. Для этого
Dart предоставляет несколько возможностей в виде операторов ?., ?? и !.. Оператор ?. вызовет метод экземпляра класса, если переменная не ссылается на null,
иначе никакой метод вызываться не будет:
void openFridge(Cat? cat){
cat?.helloMaster();
}
void main(List<String> arguments) {
Cat? myCat;
Cat? newCat = Cat();
openFridge(myCat); // ничего не выведется
openFridge(newCat); // Мяу-у-у-у!!!
}
Оператор ?? позволит нам организовать заглушку. Если переменная ссылается на
null, то работа будет производиться с экземпляром класса, переданным в функцию
(более подробно этот оператор рассмотрим в главе 2):
0.3. Базовые типы данных, модификаторы доступа и null-safety 55
void openFridge(Cat? cat){
final someCat = cat ?? Cat();
someCat.helloMaster();
}
void main(List<String> arguments) {
Cat? myCat;
Cat? newCat = Cat();
openFridge(myCat); // Мяу-у-у-у!!!
openFridge(newCat); // Мяу-у-у-у!!!
}
То есть при использовании этого оператора, даже если кошки нет в квартире,
по комнате все равно пронесется протяжное «Мяу-у-у-у!!!».
С помощью последнего оператора !. вы как бы говорите компилятору, что, хоть
переменная и может ссылаться на null, вы более чем уверены, что она ссылается
на экземпляр класса:
void openFridge(Cat? cat){
cat!.helloMaster();
}
void main(List<String> arguments) {
Cat? newCat = Cat();
openFridge(newCat); // Мяу-у-у-у!!!
}
В то же самое время при передаче в функцию null приложение выбросит исключение:
void openFridge(Cat? cat){
cat!.helloMaster(); // _CastError (Null check operator
// used on a null value)
}
void main(List<String> arguments) {
openFridge(null);
}
Таким образом, использование не-null-safety-переменных оправданно только
в том случае, если по-другому логику работы программы не реализовать.
Дополнительно обращайте внимание на то, что не во всех случаях значения неnull-safety-переменных могут присваиваться null-safety-переменным:
void main(List<String> arguments) {
Cat? cat;
Cat newCat = cat; // error: A value of type 'Cat?' can't
// be assigned to a variable of type 'Cat'.
}
void main(List<String> arguments) {
Cat? cat = Cat();
Cat newCat = cat; // ok
}
Аналогичным образом можно объявлять и не-null-safety-переменные встроенных типов данных:
int? a;
String? name = null;
// и т. д.
56 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Все не-null-safety-типы данных наследуются от Object?, а null-safety — от Object,
который является базовым классом для всех объектов Dart, кроме null.
Если хотите лучше узнать о null-safety и о том, как выстроен подход к организации типов данных в Dart, советуем ознакомиться со следующей статьей: https://
dart.dev/null-safety/understanding-null-safety.
0.3.11. Тип данных dynamic vs Object
Поскольку Dart позиционировался в качестве замены JavaScript, то без такого типа
данных, как dynamic, было бы даже нереально попытаться сказать об этом вслух.
dynamic позволяет разработчику в случае необходимости присваивать одной переменной значения различных типов данных:
dynamic myValue = 3;
myValue = 4.10;
print(myValue); // 4.10
myValue = 'oO';
print(myValue); // oO
myValue = [3, 4, 'w'];
print(myValue); // [3, 4, w]
myValue = null;
print(myValue); // null
Но и Object позволяет делать то же самое:
Object myValue = 3;
myValue = 4.10;
print(myValue); // 4.10
myValue = 'oO';
print(myValue); // oO
myValue = [3, 4, 'w'];
print(myValue); // [3, 4, w]
// значение null возможно только при использовании Object?
Так в чем между ними разница? Согласно спецификации Dart 3 (https://dart.dev/
guides/language/spec) dynamic — статический тип, который является базовым для всех
других типов, как и Object, но отличается от них тем, что разрешает все операции.
Это значит, что следующий код запустится без предупреждений, но в процессе
выполнения приложение завершится сбоем:
void main(List<String> arguments) {
dynamic myValue = 3;
myValue.run(); // NoSuchMethodError: Class 'int'
// has no instance method 'run'
}
Иными словами, при сборке приложения не проверяется, есть ли вообще этот
метод у объекта, и, когда делается безуспешная попытка вызвать его в ходе работы приложения, оно экстренно завершает работу. Но что будет, если такой метод
у объекта есть? Вот что:
class Cat {
void helloMaster(){
}
print("Мяу-у-у-у!!!"); }
void main(List<String> arguments) {
dynamic myValue = Cat();
myValue.helloMaster(); // Мяу-у-у-у!!!
}
0.4. Основные операторы и pattern matching 57
Как видите, программа завершилась корректно. А вот при использовании Object
(или Object?) Dart выдаст ошибку еще на этапе компиляции:
void main(List<String> arguments) {
Object myValue = 3;
myValue.run();
}
/* Error: The method 'run' isn't defined for the class 'Object'.
- 'Object' is from 'dart:core'.
Try correcting the name to the name of an existing method, or defining a method
named 'run'. */
Если у переменной типа dynamic можно просто вызвать существующий метод
хранящегося в ней объекта, то тип Object такое запрещает. При его использовании
нужно явно проверить, объект какого типа там сейчас хранится, чтобы иметь возможность вызвать необходимый метод:
class Cat {
void helloMaster(){ print("Мяу-у-у-у!!!"); }
}
void main(List<String> arguments) {
Object myValue = Cat();
if (myValue is Cat){
myValue.helloMaster(); // Мяу-у-у-у!!!
}
}
Несмотря на то что механизм dynamic очень гибок, его не рекомендуется использовать повсеместно, так как это может повлечь за собой трудно отлавливаемые
ошибки не только в самом коде, но и в логике разрабатываемого приложения. Поэто
му применению dynamic стоит предпочесть работу с Object. Основное исключение
из этого правила — работа с существующими API, которые задействуют dynamic.
Например, Map<String, dynamic> используется для представления JSON-объекта.
0.4. Основные операторы и pattern matching
0.4.1. Основные операторы Dart
Операторы в Dart классифицируются следующим образом:
y арифметические операторы;
y операторы сравнения;
y операторы проверки;
y операторы присваивания;
y логические операторы;
y побитовые операторы;
y условные выражения;
y каскадная запись;
y другие операторы.
Арифметические операторы представлены в табл. 0.1. К ним относятся также
как префиксные, так и постфиксные операторы инкремента и декремента значения
переменной.
58 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Таблица 0.1. Арифметические операторы
Оператор
+
Описание
Сложение
Примеры
10 + 5 = 15
10 + -3 = 7
Вычитание
–
15 – 5 = 10
25 – –3 =28
11.98 – 7 = 4.98
Умножение
*
2*2=4
7 * 3.2 = 22.4
–2 * 4 = -8
/
Деление
%
Деление по модулю
12 / 4 = 3
7 / 3 = 2.334
4%2=0
9%2=1
13.2 % 4 = 1.199
~/
Целочисленное деление
++var
Префиксный инкремент
var = var + 1;
var++
Постфиксный инкремент
var = var + 1;
--var
Префиксный декремент
var = var – 1;
var--
Постфиксный декремент
var = var – 1;
17 ~/ 5 = 3
10 ~/ 3 = 3
Далее рассмотрим операторы сравнения (табл. 0.2).
Таблица 0.2. Операторы сравнения
Оператор
Примеры
==
Описание
Проверка на равенство
!=
Проверка на неравенство
1 ! = 2 (true)
false ! = false (false)
"test" ! = "Test" (true)
>
Проверка на то, что значение левого операнда больше значения правого
Проверка на то, что значение левого операнда меньше значения правого
Проверка на то, что значение левого операнда больше или равно значению правого
Проверка на то, что значение левого операнда меньше или равно значению правого
3 > 2 (true)
2 > 3 (false)
<
>=
<=
1 = = 1 (true)
true = = false (false)
"test" = = "test" (true)
3 < 2 (false)
2 < 3 (true)
3 > = 1 (true)
3 > = 3 (true)
5 < = 5 (true)
–4 < = -21 (false)
0.4. Основные операторы и pattern matching 59
В табл. 0.3 представлены операторы проверки, которые удобно использовать
для проверки типов во время выполнения кода.
Таблица 0.3. Операторы проверки
Оператор
Описание
Примеры
as
Используется для приведения одного типа
данных к другому
—
is
true, если объект имеет указанный тип
double a = 3.4;
print(a is double); // true
print(a is String); // false
is!
true, если объект не имеет указанного типа
double a = 3.4;
print(a is! double); // false
print(a is! String); // true
Перечень существующих операторов присваивания в Dart представлен в табл. 0.4.
Таблица 0.4. Операторы присваивания
Оператор
Примеры
=
Описание
Оператор присваивания
+=
a += b, что равносильно a = a + b
var a = 3;
a += 2; // 5
–=
a –= b, что равносильно a = a – b
var a = 3;
a -= –2; // 5
*=
a *= b, что равносильно a = a * b
var a = 3;
a *= 2; // 6
/=
a /= b, что равносильно a = a / b
var a = 9;
a / = 3; // 3
%=
a %= b, что равносильно a = a % b
var a = 9;
a % =3; // 0
~/ =
a ~/= b, что равносильно a = a ~/ b
var a = 9;
a ~/ = 3; // 3
>> =
a >>= b, что равносильно a = a >> b
var a = 8;
a >> = 2; // 2
<< =
a <<= b, что равносильно a = a<< b
var a = 8;
a << = 2; // 32
^=
a ^= b, что равносильно a = a ^ b
var a = 7;
a ^ = 2; // 5
&=
a &= b, что равносильно a = a & b
var a = 7;
a & = 2; // 2
|=
a |= b, что равносильно a = a | b
var a = 7;
a | = 2; // 7
var a = 3;
var a = 2.3;
60 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Для рассмотрения работы побитовых операторов необходимо ввести два значения, a и b. Примем a = 33 в десятичной системе счисления, что эквивалентно
0010 0001 в двоичной системе счисления, и b = 87 (0101 0111) (табл. 0.5).
Таблица 0.5. Побитовые операторы
Оператор
&
|
^
~
<<
>>
>>>
Описание
Примеры
Побитовое логическое «И» между операндами
a & b = 0000 0001 (1)
Побитовое логическое «ИЛИ» между операндами
a | b = 0111 0111 (119)
Побитовое логическое «исключающее ИЛИ» между операндами a ^ b = 0111 0110 (118)
Логическое отрицание. Инвертирует значения бита операнда,
~a = 1101 1110 (–34)
к которому применяется
Побитовый сдвиг влево. Применяется к одному операнду. Экви- a << 2 = 1000 0100
валентно умножению на 2n, где n — число, на которое произво- (33 * 22 = 132)
дится сдвиг
Побитовый сдвиг вправо. Применяется к одному операнду. Экви- a >> 2 = 0000 0100 (33 ~/ 22 = 8)
валентно целочисленному делению на 2n
Беззнаковый побитовый сдвиг вправо. Отличается от опера–a >> 2 = – 1001 (–9)
тора >> тем, что выполняется логический (сдвиг без знака),
–a >>> 2 = 4611686018427387895
а не арифметический сдвиг. Младшие биты отбрасываются,
а остальные сдвигаются, и старшие биты заменяются нулем.
Таким образом затирается знак минус, если осуществляется
сдвиг на отрицательное число
К логическим операторам относят обычные логические операции, такие как
«И», «ИЛИ» и «НЕ» (табл. 0.6).
Таблица 0.6. Логические операторы
Оператор
&&
||
!
Описание
Логическое «И» между операндами
Логическое «ИЛИ» между операндами
Логическое отрицание
Примеры
true and true = true,
во всех остальных случаях false
false and false = false, во всех остальных случаях true
!false = true
!true = false
В табл. 0.7 приведены существующие в Dart условные выражения.
Таблица 0.7. Условные выражения
Выражение
condition ? expr1 : expr2
expr1 ?? expr2
Описание
Тернарный оператор. Если условие истинно, то вычисляется и возвращается expr1,
в противном случае вычисляется и возвращается expr2
Если expr1 не равно null, возвращается его значение, в противном случае вычисляется и возвращается expr2
0.4. Основные операторы и pattern matching 61
Операторы, которые не вошли в приведенную классификацию операторов Dart,
относятся к категории «другие операторы» (табл. 0.8).
Таблица 0.8. Другие операторы
Оператор
()
[]
Описание
Используется для вызова функций
Ссылается на значение в списке в соответствии с задаваемым индексом
Позволяет обратиться к свойствам объекта
Как и оператор ., только дополнительно выполняет проверку на null. Если объект хранит значение
null, обращение к его свойству не производится и не выбрасываются никакие исключения
?
Последний вид операторов, который предоставляет Dart, — это каскадные
операторы (.., ?..). Они позволяют выполнять последовательность операций над
одним и тем же объектом:
// base_url/0/0.4/ex1.dart
class Cat {
late final int _old;
late final String _name;
set old(int old) {
this._old = old;
}
set name(String name) {
this._name = name;
}
}
void helloMaster(){
print("Мяу-у-у-у!!!");
}
void main(List<String> arguments) {
var cat = Cat()
..name = 'Муся'
..old = 4
..helloMaster();
// аналогично записи ниже
var newCat = Cat();
newCat.name = 'Муся';
newCat.old = 4;
newCat.helloMaster();
}
0.4.2. Что такое Pattern Matching и Destructuring
Прежде чем приступить к рассмотрению того, какими способами можно управлять
потоком выполнения программы, затронем такие механизмы, как Pattern Matching
(сопоставление с шаблоном) и Destructuring (деструктурирование). Практическое
применение Pattern Matching будет рассмотрено в следующих разделах главы,
а деструктурирование — в этом.
62 Глава 0 Установка и настройка рабочего окружения. Основы Dart
При переходе со второй версии на третью в Dart внесли довольно существенные
изменения, среди которых был и Pattern Matching. Сопоставление с шаблоном
используется для проверки того, соответствует ли значение определенным характеристикам. Так, например, можно проверить, равно ли оно константе, имеет ли
определенные форму, тип (форму и тип) и соответствует ли заданным критериям.
Pattern Matching поддерживает рекурсивное сопоставление с подшаблонами, благодаря чему может сопоставлять свойства объекта или элементы коллекции (List,
Map). До Dart 3, чтобы выполнить такие проверки, приходилось писать больше кода,
и он не всегда был удобен для восприятия.
Данный механизм десятилетиями использовался в функциональных языках
программирования. Со временем он начал появляться в языках программирования
общего назначения (Java, Python и т. д.) и вот теперь добрался до Dart. Что же такого
дает Pattern Matching, что его решили реализовать? А все просто как дважды два.
Более быстрое написание читаемого кода! Если вы думаете, что это не стоит приложенных разработчиками Dart усилий, то вернитесь к любому своему проекту спустя
полгода. Хорошо, если к нему будет документация, но чаще всего на нее забивают
и обновляют в последнюю очередь, если вообще ведут. И вот в этой ситуации сам
код и является документацией! Если он читаем, то вы быстро вспомните, как тут
все работает, а если нет… себя точно молодцом не назовете. 😉
Деструктурирование шаблона (Pattern Destructuring) позволяет более удобным
способом извлекать данные из объекта. То есть если объект соответствует шаблону,
его свойства или элементы можно преобразовать в переменные. Далее приведен
пример того, как осуществлялось деструктурирование списка во второй версии
Dart и насколько лаконичнее эта операция смотрится в третьей:
// base_url/0/0.4/ex2.dart
void main() {
// Dart 2
final myList = [1, 2];
var a = myList[0];
var b = myList[1];
print('a: $a, b: $b'); // a: 1, b: 2
}
// Dart 3
var [a1, b1] = myList; // или final [a1, b1] = myList;
print('a1: $a1, b1: $b1'); // a1: 1, b2: 2
Далее рассмотрим коллекции и объекты, к которым можно применять данную
операцию.
0.4.3. Деструктурирование списка
С деструктурированием списка из двух элементов вроде бы разобрались. А что
делать, если их три? Какой-то элемент нам вообще не нужен или из большого списка посредством деструктурирования необходимо извлечь конкретные элементы
и записать их значения в переменные? Давайте с этим разбираться.
Количество переменных, на которое распаковывается список, должно соответствовать количеству элементов в нем:
final myList = [1, 2];
final [a, ] = myList; // Bad state: Pattern matching error
0.4. Основные операторы и pattern matching 63
Когда какой-то элемент для нас неважен, при деструктурировании следует использовать символ нижнего подчеркивания с левой стороны выражения:
// base_url/0/0.4/ex3.dart
var myList = [1, 2];
final [a, _] = myList;
print('a: $a'); // a: 1
myList = [1, 2, 3, 4];
final [b, _, c, _] = myList;
print('b: $b, c: $c'); // b: 1, c: 3
Если список содержит большое количество элементов, то для пропуска какой-то
части используется многоточие:
// base_url/0/0.4/ex4.dart
var myList = [1, 2, 3, 4, 5, 6, 7];
final [a, ..., b] = myList;
print('a: $a, b: $b'); // a: 1, b: 7
final [c, d, ...] = myList;
print('c: $c, d: $d'); // c: 1, d: 2
final [..., e, f] = myList;
print('e: $e, f: $f'); // e: 6, f: 7
0.4.4. Деструктурирование записи
Фактически мы уже встречались с деструктурированием записи при знакомстве
с этим типом данных. Тогда это было названо распаковкой, чтобы не напугать более
точными определениями раньше времени:
// base_url/0/0.4/ex5.dart
var myRecord1 = (10, '-_-');
var (first, second) = myRecord1;
print('$first $second'); // 10 -_var (a, _) = myRecord1;
print('$a'); // 10
var myRecord2 = (3.14, cost: 10, smile: '-_-', 22);
var (firstPos, secondPos,
cost: costPos, smile: SmilePos) = myRecord2;
print('$firstPos, $secondPos, $costPos, $SmilePos');
// 3.14 22 10 -_var (b, _, cost: _, smile: c) = myRecord2;
print('$b, $c'); // 3.14 -_-
0.4.5. Деструктурирование таблицы/карты
Деструктурировать таблицу можно различными способами. Самый простой случай — отсутствие вложений. Здесь можно даже не прибегать к символу нижнего
подчеркивания:
// base_url/0/0.4/ex6.dart
final myMap = {"first": 1, "second": 2};
print(myMap); // {first: 1, second: 2}
64 Глава 0 Установка и настройка рабочего окружения. Основы Dart
final {"first": first, "second": second} = myMap;
print("$first, $second"); // 1, 2
final {"first": a} = myMap;
print("$a"); // 1
final {"second": b} = myMap;
print("$b"); // 2
При наличии вложений, когда в качестве значения по ключу хранится список
или еще одна таблица, к ним также можно применять операцию деструктурирования:
// base_url/0/0.4/ex7.dart
Map<String, List<int>> myMap = {
'first': [1, 2, 3],
'second': [4, 5, 6],
};
var {'first': [a, _, b]} = myMap;
print('a: $a, b: $b'); // a: 1, b: 3
Map<int, Map<String, int>> myMap2 = {
1: {'a': 1, 'b': 2},
2: {'c': 3, 'd': 4},
};
var {1: {'a': c, 'b': d}} = myMap2;
print('c: $c, d: $d'); // c: 1, d: 2
Чаще всего вам придется иметь дело с деструктурированием таблицы в ходе
работы с JSON, когда данные хранятся в формате Map<String, dynamic>:
// base_url/0/0.4/ex8.dart
Map<String, dynamic> myMap = {
'person1': ['Alex', 22],
'person2': ['Max', 52],
'employee': {
'name': 'John',
'age': 25,
'salary': 1000,
'boss': {
'name': 'Alex',
'idEmployees': [1, 2, 3],
}
},
};
var {'person1': [name, age]} = myMap;
print('person1: $name, age: $age'); // person1: Alex, age: 22
var {
'employee': {
'name': empName,
'age': empAge,
'salary': empsalary,
'boss': {
'name': bossName,
},
}
} = myMap;
0.4. Основные операторы и pattern matching 65
print('employee: $empName, age: $empAge, salary: $empsalary, boss: $bossName');
// employee: John, age: 25, salary: 1000, boss: Alex
var {'employee': {'boss': {'idEmployees': [...ids]}}} = myMap;
print('ids: $ids'); // ids: [1, 2, 3]
0.4.6. Деструктурирование экземпляра класса
В данном случае нам снова придется забежать немного вперед и рассмотреть, как
деструктурируется экземпляр класса. Обязательное условие такой операции —
указание имени класса, экземпляр которого будет деструктурирован, за которым
в круглых скобках указываются имена его полей, которые будут распакованы
в переменные:
// base_url/0/0.4/ex9.dart
class Employee {
final String name;
final int age;
final int salary;
}
Employee(this.name, this.age, this.salary);
void main() {
var employee = Employee("John", 25, 50000);
var Employee(name: empName, age: empAge,
salary: empSalary) = employee;
print("Name: $empName, Age: $empAge, Salary: $empSalary");
// Name: John, Age: 25, Salary: 50000
}
employee = Employee("Alex", 19, 3000);
var Employee(name: empName1, salary: empSalary1) = employee;
print("Name: $empName1, Salary: $empSalary1");
// Name: Alex, Salary: 3000
Имеется возможность сократить запись операции деструктурирования. Для
этого переменная, в которую будет распаковано значение, должна носить имя,
идентичное полю класса:
// base_url/0/0.4/ex10.dart
var employee = Employee("John", 25, 50000);
var Employee(:name, :age, :salary) = employee;
print("Name: $name, Age: $age, Salary: $salary");
// Name: John, Age: 25, Salary: 50000
employee = Employee("Alex", 19, 3000);
Employee(:name, :salary) = employee;
print("Name: $name, Salary: $salary");
// Name: Alex, Salary: 3000
Employee(:age,) = employee;
print("Age: $age"); // Age: 19
Обратите внимание: так как переменные при первом деструктурировании экземпляра класса были объявлены с помощью var, то их можно использовать и в последующих аналогичных операциях, не прибегая к созданию новых.
66 Глава 0 Установка и настройка рабочего окружения. Основы Dart
0.5. Управление потоком выполнения кода
Для управления потоком выполнения кода в Dart применяются следующие виды
операторов:
y условный оператор if, if-case;
y тернарный оператор;
y оператор ??;
y операторы циклов for, for-in, while и do-while;
y операторы потока выполнения break, continue, return;
y оператор выбора потока выполнения (switch-case) и switch-выражения.
0.5.1. Условный оператор if
Оператор if с необязательным оператором else выбирает действия, которые будут
выполняться в процессе работы программы в зависимости от условий (результата
выполнения выражений), которые проверяются в скобках после объявления оператора if. Общая форма конструкции «если — то — иначе» представлена далее:
if (условие1){
блок1
}
else if (условие2){
блок 2
}
…
else if (условие(n – 1)){
блок (n – 1)
}
else{
блок (n)
}
Условие представляет собой проверку на истинность, и если в результате вычисления при проверке условия возвращается true , то будет выполнен код из
блока 1, в противном случае будет выполняться проверка в блоках else if. Если
не выполнилось ни одно условие, то выполнится код из блока else:
// base_url/0/0.5/0.5.1/ex1.dart
void main(List<String> arguments) {
var a = 10;
var b = 30;
var c = 7;
if (a > b) {
print('a > b');
} else if (a > c){
print('a > c'); // a > c
}
else{
print('Ни то ни другое');
}
}
0.5. Управление потоком выполнения кода 67
Выражение, возвращающее булев результат в операторе if, может быть как простым (рассмотренный ранее вариант), так и сложносоставным. Для второго варианта
между выражениями используются логические операторы &&, ||, ! со следующими
таблицами истинности (табл. 0.9–0.11).
Таблица 0.9. Таблица истинности логической операции «И»
a
b
a && b
false
false
false
true
false
false
false
true
false
true
true
true
Таблица 0.10. Таблица истинности логической операции «ИЛИ»
a
b
a || b
false
false
false
true
false
true
false
true
true
true
true
true
Таблица 0.11. Таблица истинности логической операции «Отрицание»
a
!a
false
true
true
false
Представим ситуацию, когда нам необходимо проверить, входит ли число в необходимый диапазон значений:
// base_url/0/0.5/0.5.1/ex2.dart
void main() {
var a = 10;
if (a > 5 && a < 20) {
print('Значение входит в промежуток');
// Значение входит в промежуток
} else {
print('Значение не входит в промежуток');
}
}
Чем более сложное выражение проверяется, тем больше вероятность допустить
какую-нибудь ошибку. Поэтому рекомендуют отдельные части таких выражений
заключать в скобки, чтобы можно было проследить последовательность логических
операций.
68 Глава 0 Установка и настройка рабочего окружения. Основы Dart
0.5.2. Оператор if-case
Начиная с третьей версии в Dart появилась поддержка оператора if-case, позволяющего более удобным способом проверять объекты на соответствие необходимым
типу, форме и т. д. Общий принцип работы этого оператора можно представить
следующим образом:
if (значение case шаблон){
блок1
}
else if (значение case шаблон){
блок 2
}
…
else if (значение case шаблон){
блок (n-1)
}
else{
блок (n)
}
Представим, что у нас на входе список и необходимо проверить, действительно ли
в нем два значения, после чего деструктурировать его по переменным:
// base_url/0/0.5/0.5.2/ex1.dart
void main() {
List<int> myList = [1, 2, 3];
if (myList case [int x, int y]){
print('2 значения $x и $y');
} else if (myList case [int x, ..., int y]){
print('3 и более значения'); // 3 и более значения
}
}
myList = [1, 2];
if (myList case [int x, int y]){
print('2 значения, $x и $y'); // 2 значения, 1 и 2
}
В ходе работы с классами оператор if-case можно использовать как для проверки того, относится ли объект к нужному типу данных, так и сразу для его деструктурирования, чтобы перенести необходимые значения полей в переменные,
с которыми в блоке if и продолжится работа:
// base_url/0/0.5/0.5.2/ex2.dart
class Employee {
final String name;
final int age;
final int salary;
}
Employee(this.name, this.age, this.salary);
class Cat {
final String name;
final int age;
0.5. Управление потоком выполнения кода 69
}
Cat(this.name, this.age);
void main() {
dynamic obj = Employee('John', 30, 1000);
if (obj case Cat(:String name, : int age)) {
print('Cat name is $name, age is $age');
}
if (obj case Employee(:String name, : int age, : int salary)) {
print(
'Employee name is $name, age is $age, salary is $salary'
);
} // Employee name is John, age is 30, salary is 1000
if (obj case Employee(:String name)) {
print('Employee name is $name'); // Employee name is John
}
obj = Cat('Tom', 20);
if (obj case Employee(:String name, : int age)) {
print('Employee name is $name, age is $age');
}
}
if (obj case Cat(:String name, : int age)) {
print('Cat name is $name, age is $age');
// Cat name is Tom, age is 20
}
На проверяемые объекты, а точнее, на их значения, которые в процессе сопоставления записываются в переменные, можно накладывать guard clause (защитное
условие). Для этого после case с указанием шаблона должно следовать ключевое
слово when, за которым идет условное выражение:
// base_url/0/0.5/0.5.2/ex3.dart
class Cat {
final String name;
final int age;
}
Cat(this.name, this.age);
void main() {
Cat obj = Cat('Tom', 30);
if (obj case Cat(: int age) when age > 20) {
print('Cat name is ${obj.name}, age is $age');
}
obj = Cat('Tommy', 3);
if (obj case Cat(: int age) when age > 20) {
print('Cat name is ${obj.name}, age is $age');
}
var list = [8, 3];
if (list case [int a, int b] when a + b > 10) {
70 Глава 0 Установка и настройка рабочего окружения. Основы Dart
}
}
print('list sum is ${a + b}');
// Cat name is Tom, age is 30
// list sum is 11
0.5.3. Тернарный оператор ?:
Общий вид записи тернарного оператора можно представить следующим способом:
condition ? expr1 : expr2
Если условие истинно, то вычисляется и возвращается expr1, в противном случае
вычисляется и возвращается expr2. Для примера посредством данного оператора
реализуем поиск максимального из двух значений:
// base_url/0/0.5/0.5.3/ex1.dart
var a = 5, b =10;
var c = a > b ? a : b;
print('Max is $c'); // 10
0.5.4. Оператор ??
Общий вид записи данного оператора можно представить следующим способом:
expr1 ?? expr2
Если expr1 не равно null, возвращается его значение, в противном случае вычисляется и возвращается значение expr2.
Ранее мы рассматривали использование оператора ?? как заглушки, но у него
куда больше вариантов применения. Например, если объекты реализуют один
интерфейс и expr1 в данный момент времени хранит null, то объект expr2 будет
создан, приведен к общему интерфейсу и присвоен переменной интерфейсного
типа, с которой потом идет взаимодействие в клиентском коде.
Либо у нас имеется две функции, и если первая возвращает null, то в работу
включается вторая. Для этого примера нам придется немного забежать вперед
и реализовать функцию с параметром по умолчанию, равным null:
// base_url/0/0.5/0.5.4/ex1.dart
int? calculate([int? a]) {
if (a = = null) {
return a;
}
return a * 7;
}
void main() {
var c = calculate() ?? calculate(10);
// попробуйте переписать для оператора ?:
print(c); // 70
}
var d = calculate(3) ?? calculate(10);
print(d); // 21
0.5. Управление потоком выполнения кода 71
0.5.5. Операторы циклов for, for-in, while и do-while
Цикл for позволяет выполнить блок кода определенное количество раз. В общем
виде структуру этого цикла можно представить следующим образом:
for (действие до начала цикла; условие выхода из цикла;
действие по завершении текущего шага цикла) {
// блок кода
}
Любой из элементов — действие или условие выхода — при объявлении цикла
может быть не задан. Так, например, следующий цикл бесконечный, поскольку при
его объявлении не указано условие завершения цикла:
for(;;){
}
В следующем коде цикл for будет выполнен пять раз, после чего будет напечатана строка:
// base_url/0/0.5/0.5.5/ex1.dart
var str = '';
for(var i = 0; i < = 4; i++){
str += i.toString();
}
print(str); // 01234
Этот код может быть переписан следующим образом:
// base_url/0/0.5/0.5.5/ex2.dart
var str = '';
var i = 0;
for(; i < = 4;){
str += i.toString();
i++;
}
print(str); // 01234
По сути, оба цикла выполняют одинаковые действия. Различие заключается
лишь в чистоте и читаемости кода. В следующем примере используем цикл for
для заполнения списка:
// base_url/0/0.5/0.5.5/ex3.dart
var myList = <int>[];
for(var i = 0; i < = 4; i++){
myList.add(i);
}
print(myList); // [0, 1, 2, 3, 4]
for(var i = 4; i > = 0; i--){
myList.add(i);
}
print(myList); // [0, 1, 2, 3, 4, 4, 3, 2, 1, 0]
Если использовать цикл for для обхода элементов списка, то код будет выглядеть следующим образом:
// base_url/0/0.5/0.5.5/ex4.dart
var myList = <int>[0, 1, 2, 3, 4, 4, 3, 2, 1, 0];
var sum = 0;
for(var i = 0; i < myList.length; i++){
72 Глава 0 Установка и настройка рабочего окружения. Основы Dart
}
sum += myList[i];
print('sum: $sum'); // 20
Но лучше для итерации по коллекциям использовать цикл for-in, который позволяет перебирать значения, возвращаемые любым объектом, поддерживающим
итерацию: списки, множества и т. д., то есть объект должен представлять собой
коллекцию элементов:
// base_url/0/0.5/0.5.5/ex5.dart
var myList = <int>[for (var i = 0; i < = 3; i++) i];
for (var it in myList){
print(it); // 0 1 2 3
}
var mySet = <int>{1, 2, 5, 6, 7, 8};
for (var it in mySet){
print(it); // 1 2 5 6 7 8
}
До Dart 3 итерация по объектам типа Map<K,V> могла осуществляться несколькими способами:
// base_url/0/0.5/0.5.5/ex6.dart
var myMap = <int, String>{
1: 'Мама',
2: 'мыла',
3: 'раму'
};
myMap.forEach((key, value) {
print('$key = > $value');
});
for (var it in myMap.entries) {
// it - MapEntry<int, String>, хранит ключ и значение
// текущего элемента итерации
print('${it.key} = > ${it.value}');
}
1 = > Мама
2 = > мыла
3 = > раму
Начиная с третьей версии, цикл for-in поддерживает деструктурирование
элементов итерируемой коллекции, что позволяет переписать предыдущий пример так:
for (var MapEntry(:key, :value) in myMap.entries) {
print('$key = > $value');
}
То же самое можно делать и с элементами списка:
// base_url/0/0.5/0.5.5/ex7.dart
class Cat {
final String name;
final int age;
Cat(this.name, this.age);
}
void main() {
var catList = <Cat>[for (var i = 0; i < = 3; i++)
Cat('Tommy$i', i+1)];
0.5. Управление потоком выполнения кода 73
for (var Cat(:name, :age) in catList) {
print('$name is $age years old');
}
}
//
//
//
//
Tommy0
Tommy1
Tommy2
Tommy3
is
is
is
is
1
2
3
4
years
years
years
years
old
old
old
old
Принципы работы циклов while и do-while похожи. Ключевое различие заключается в том, что цикл while может ни разу не выполниться. Это связано с тем, что
сначала проверяется условие его выполнения. Если оно возвращает значение false,
поток выполнения кода переходит к командам и операторам, расположенным за
циклом. Цикл do-while выполнится хотя бы один раз, после чего будет проверяться,
нужно ли выполнить цикл заново или выйти из него.
Структуру этих циклов можно представить следующим образом:
while (условие выхода из цикла) {
// блок кода
}
do{
// блок кода
}
while (условие выхода из цикла);
Далее приведен пример того, как можно использовать эти циклы вместо
цикла for:
// base_url/0/0.5/0.5.5/ex8.dart
var myStr = 'Hi!';
var i = 0;
while(i < myStr.length){
print(myStr[i]); // H i !
i++;
}
i = 0;
do{
print(i); // 0
i++;
}while(i < 3);
1
2
0.5.6. Операторы потока выполнения break, continue, return
Оператор continue используется для немедленного перехода в начало цикла, в котором он был вызван:
// base_url/0/0.5/0.5.6/ex1.dart
var i = 13;
while(i > 0){
i--;
if (i % 2 = = 0){
continue;
}
print(i); // 11 9 7 5 3 1
}
Оператор break применяется для немедленного выхода из цикла, в котором он
был вызван. Представим ситуацию: у нас имеется вложенный цикл (цикл в цикле).
74 Глава 0 Установка и настройка рабочего окружения. Основы Dart
При использовании оператора break внутри вложенного цикла поток управления
перейдет к циклу верхнего уровня, который продолжит выполняться. Далее приведен пример выхода из бесконечного цикла с помощью оператора break:
// base_url/0/0.5/0.5.6/ex2.dart
var i = 33;
while(true){
if (i < = 3){
break;
}
i--;
}
print(i); // 3
Оператор return возвращает результат функции,
класса и завершает их выполнение:
switch-выражения,
метода
// base_url/0/0.5/0.5.6/ex3.dart
void main(List<String> arguments) {
var a = 10;
print(a); // 10
return;
// дальнейший код не имеет смысла, так как он не будет выполнен
var b = 20;
print(b);
}
0.5.7. Оператор выбора потока выполнения switch-case
Если блок кода в вашей программе состоит из большого числа цепочек if-else if-else,
стоит задуматься об использовании другой условной конструкции управления потоками выполнения программы — switch-case. В Dart 2 оператор switch-case позволял
сравнивать целочисленные, строковые переменные или константы времени компиляции с помощью оператора сравнения ==. Начиная с Dart 3, сравнение осуществляется
с помощью шаблонов (Pattern Matching), указываемых после ключевого слова case.
В общем виде конструкцию switch-case можно записать следующим образом:
switch(объект):
case шаблон1:
блок1
case шаблон2:
блок2
case шаблон3:
блок3
...
default:
блок (n) # действие по умолчанию
Рассмотрим принципы работы
строковый тип данных:
switch-case
на примере, где на вход подается
// base_url/0/0.5/0.5.7/ex1.dart
void main(List<String> arguments) {
var command = 'close'; // проверяемое значение
switch (command) {
case 'close': // если значение в command = = 'close'
print('closed'); // < - closed
case 'open': // если значение в command = = 'open'
print('open');
0.5. Управление потоком выполнения кода 75
}
}
default: // если не подошел ни один вариант
print('default');
Если мы хотим, чтобы между открытием и закрытием не было разницы, немного
модифицируем предыдущий пример:
// base_url/0/0.5/0.5.7/ex2.dart
var command = 'close';
switch (command) {
case 'close':
case 'open':
print('open/close'); // <- open/close
default:
print('default');
}
А теперь перейдем к «уличной магии» switch-case, что свалилась на разработчиков в Dart 3, и начнем со switch-выражения, результат которого можно присваивать
переменной, возвращать из функции и т. д.:
// base_url/0/0.5/0.5.7/ex3.dart
var a = 10;
var b = switch (a) {
2 = > 5 + a,
3 = > 4 + a,
_ = > 10 - a, // значение по умолчанию
};
print(b); // 0
Такой способ работает только при замене case на =>, где в левой части указывается шаблон для сравнения, а в правой — возвращаемый результат. Шаблоны могут
быть различного вида и состоять из логических операций «И», «ИЛИ» (логический
шаблон) и операций сравнения (реляционный шаблон):
// base_url/0/0.5/0.5.7/ex4.dart
void main() {
var myList = [1, 4, 5, 2, 33, 45, 90];
for (var element in myList) {
switch (element) {
case 2 || 3 || 5:
print('a ($element) is 2, 3, or 5');
case > = 30 && < = 40:
print('a ($element) is between 30 and 40');
default:
print('Default value: $element');
}
}
}
/* Default value:
Default value: 4
a (5) is 2, 3, or
a (2) is 2, 3, or
a (33) is between
Default value: 45
Default value: 90
// или
void main() {
1
5
5
30 and 40
*/
76 Глава 0 Установка и настройка рабочего окружения. Основы Dart
var myList = [1, 4, 5, 2, 33, 45, 90];
var newList = <int>[];
for (var element in myList) {
newList.add(
switch (element) {
2 || 3 || 5 = > element + 1,
> = 30 && < = 40 = > element * 2,
< 50 = > element - 5,
= = 1 = > element + 3,
_ = > element,
});
}
}
print(newList); // [-4, -1, 6, 3, 66, 40, 90]
Поскольку оператор switch-case сопоставляет шаблоны, то его можно использовать со списками, таблицами, записями и классами (объектами). В качестве примера
реализуем работу со списком:
// base_url/0/0.5/0.5.7/ex5.dart
void main() {
var myList = [
[],
[1],
[1, 2, 3],
[1, 2, 3, 4, 5],
];
var myStr = '';
for (var element in myList) {
switch (element) {
case [1]:
myStr += '1 ';
case [1, 2, 3]:
myStr += '3 ';
case []:
myStr += '0 ';
default:
myStr += '! ';
}
}
}
print(myStr); // 0 1 3 !
0.5.8. Null-aware elements
Начиная с Dart 3.8, в язык добавлена возможность более простой проверки на null
для формирования тела таких коллекций, как List, Map и Set. Другими словами, эта
функциональность работает только в теле коллекций на этапе их инициализации.
Если раньше, чтобы сформировать список из имеющихся данных, отбросив те из
них, которые ссылаются на null, нужно было прибегать к многословной проверке
с использованием конструкции if:
void main() {
int? a;
int? b = 1;
int c = 10;
0.5. Управление потоком выполнения кода 77
}
var tt = [if (a ! = null) a, if (b ! = null) b, c];
print(tt); // [1, 10]
то теперь для достижения аналогичного результата от разработчика требуется куда
меньше телодвижений — достаточно использования вопросительного знака перед
не-null-safety-переменной:
// base_url/0/0.5/0.5.8/ex1.dart
void main() {
int? a;
int? b = 1;
int c = 10;
}
var tt = [?a, ?b, c];
print(tt); // [1, 10]
Что же творится в представленном коде? Каждая запись вида ?имя проверяется
на то, ссылается переменная на null или нет. При положительном ответе она выбрасывается из коллекции. Благодаря этому список формируется только из тех
значений переменных, в которых хранятся реальные данные.
При написании приложений на чистом Dart вы редко будете прибегать к такому
формированию списков или множеств. А вот во Flutter — постоянно! Описывая
графический интерфейс того или иного экрана, всегда придется отталкиваться от
наличия имеющихся в коллекции данных для их отображения. Да и код с огромным количеством if смотрится куда менее читаемым и более раздутым, чем при
добавлении всего одного символа перед переменной.
Еще один часто встречающийся случай применения такой проверки на null —
работа с таблицами/картами. Этот тип данных часто используется как промежуточное звено между преобразованием объекта с его свойствами в последовательность
байтов для передачи по сети и восстановлением объектов из этих данных. Более
подробно этот процесс мы рассмотрим в главе 5, а пока сосредоточимся на использовании Null-aware elements при формировании экземпляра Map, представляющего
собой коллекцию пар «ключ:значение». Здесь важно отметить, что в зависимости
от необходимого финального результата вопросительный знак может ставиться как
перед значением, так и перед ключом. В первом случае при условии, что значение
равно null, пара «ключ:значение» не будет добавляться в таблицу. А во втором
случае вместо значения будет записано null:
// base_url/0/0.5/0.5.8/ex2.dart
void main(List<String> arguments) {
String name = 'Tommy';
int age = 10;
String? master;
Map<String, dynamic> cat = {
'name': name,
'age': age,
// эта пара "ключ:значение" не попадет в таблицу
'firstMaster': ?master,
// эта пара попадет, но значение будет null
?'secondMaster': master,
};
print(cat); // {name: Tommy, age: 10, secondMaster: null}
78 Глава 0 Установка и настройка рабочего окружения. Основы Dart
}
master = 'Alex'; // задаем имя хозяина кота
Map<String, dynamic> newCat = {
'name': name,
'age': age,
'master1': ?master,
?'master2': master,
};
print(newCat); // {name: Tommy, age: 10, master1: Alex, master2: Alex}
0.6. Функции
Для начала рассмотрим общий шаблон объявления функции:
[возвращаемый тип данных] имяФункции(
[ТипВходногоАргумента1 имяАргумента1, …, n]
){
// тело функции
[return возвращаемое значение]
}
В квадратных скобках указаны необязательные при объявлении функции
элементы. Если в таком языке, как C++, нам необходимо явно указать тип возвращаемого значения, то Dart может вывести его автоматически, поэтому его можно
не указывать при объявлении функции. Несмотря на то что это позволяет писать
меньше кода, при большой кодовой базе такой подход может сыграть с вами злую
шутку, так как код проекта станет менее читаемым и понятным, особенно для новых разработчиков в проекте. В связи с этим стоит указывать тип возвращаемого
значения, даже если функция ничего не возвращает (тогда пишем void).
Функции можно объявить практически в любой части кода. Обычно принято
делать это на верхнем уровне модуля:
void myFunction(){
print('Привет!!!');
}
void main(List<String> arguments) {
myFunction();
// Привет!!!
}
Когда при выполнении приложения в коде встречается вызов функции, то поток управления переходит в нее и выполняет ее код. После этого управление возвращается в точку вызова функции и продолжается последовательная отработка
кода основной программы.
Для передачи аргументов (параметров) в функцию необходимо после объявления ее имени в круглых скобках указать тип и имя ее входного аргумента, который
свяжется с переменной, подаваемой при вызове функции на ее вход:
void myFunction(String name){
print('Привет, $name!');
}
void main(List<String> arguments) {
myFunction('Александр');
// Привет, Александр!
}
0.6. Функции 79
Если функция должна возвращать значение, используйте оператор return:
String myFunction(String name){
var hello = 'Привет, $name!';
return hello;
}
void main(List<String> arguments) {
var myHelloString = myFunction('Александр');
print(myHelloString); // Привет, Александр!
}
Когда необходимо вернуть сразу несколько значений, на помощь приходят записи (records):
(String, String) myFunction(String name){
return ('Привет', '$name!');
}
void main(List<String> arguments) {
var myHelloRec = myFunction('Александр');
print('${myHelloRec.$1} ${myHelloRec.$2}');
// Привет, Александр!
}
0.6.1. Объявление входных аргументов функции
Входные аргументы функции Dart подразделяются:
y на позиционные;
y именованные;
y необязательные позиционные;
y необязательные именованные;
y комбинацию из перечисленных.
Самыми простыми для понимания являются позиционное аргументы, так как
последовательность передаваемых на вход функций переменных должна соответствовать последовательности и типу объявленных в ней аргументов:
// base_url/0/0.6/0.6.1/ex1.dart
void myFunction(String name, int date, String monthName){
print('$name родился $date $monthName!');
}
void main(List<String> arguments) {
myFunction('Александр', 10, 'сентября');
}
// Александр родился 10 сентября!
Для объявления того, что на вход функции аргументы передаются именованным
образом, необходимо обернуть их в фигурные скобки { }, после чего в момент вызова
функции явно указать, какому именованному аргументу какое значение передается.
Если тип объявляемого аргумента null-safety, то есть не может хранить значение
null, используйте перед объявляемым аргументом ключевое слово required:
// base_url/0/0.6/0.6.1/ex2.dart
void myFunction({
required String name,
80 Глава 0 Установка и настройка рабочего окружения. Основы Dart
required int date,
required String monthName,
}) {
print('$name родился $date $monthName!');
}
void main(List<String> arguments) {
myFunction(
date: 10,
name: 'Александр',
monthName: 'сентября',
);
}
// Александр родился 10 сентября!
Обратите внимание на порядок передаваемых в функцию значений. Они могут
передаваться в произвольной последовательности, так как мы явно указываем,
какому из аргументов функции будет соответствовать то или иное значение.
Если же значение передаваемого аргумента может хранить значение null, то
после указания его типа добавьте символ ? и предусмотрите проверку на null,
который может быть передан в двух случаях:
y аргументу функции передали переменную, хранящую значение null;
y именованному аргументу ничего не передавали, поэтому значение аргумента
по умолчанию становится равным null.
// base_url/0/0.6/0.6.1/ex3.dart
String myFunction({
String? name,
required int date,
required String monthName,
}) {
if (name ! = null) {
return '$name родился $date $monthName!';
}
return 'Не установлено имя новорожденного!';
}
void main(List<String> arguments) {
print(myFunction(
date: 10,
monthName: 'сентября',
));
print(myFunction(
date: 10,
monthName: 'сентября',
name: 'Иван',
));
}
// Не установлено имя новорожденного!
// Иван родился 10 сентября!
Когда мы указываем, что именованный аргумент может принимать значение
null, он становится необязательным при вызове функции. Но когда кровь из носу
запрещено давать такую свободу разработчику, который будет использовать ваш
код, — пометьте именованный аргумент как required. Тогда у него не останется
вариантов, кроме как самому передать такому именованному аргументу вызываемой функции null.
0.6. Функции 81
Чтобы указать необязательные аргументы при их позиционном размещении,
оберните их в квадратные скобки [ ]:
// base_url/0/0.6/0.6.1/ex4.dart
String myFunction(String name, int date, [String? monthName]){
if(monthName ! = null){
return '$name родился $date $monthName!';
}
return '$date числа, неустановленного месяца, родился $name!';
}
void main(List<String> arguments) {
print(myFunction('Александр', 20));
print(myFunction('Александр', 20, 'мая'));
}
// 20 числа неустановленного месяца родился Александр!
// Александр родился 20 мая!
0.6.2. Необязательные аргументы функции по умолчанию
Когда мы объявляем необязательные позиционные или именованные аргументы,
они по умолчанию инициализируются значением null (если при вызове функции
им не задается отличное от null значение). Чтобы присвоить им значение по умолчанию, отличное от null, после объявления имени аргумента добавьте оператор
присваивания и само значение, которое станет использоваться в коде функции
всякий раз, пока явно не будет передано другое значение. При этом неважно, nullsafety-тип объявляемого аргумента или нет:
// base_url/0/0.6/0.6.2/ex1.dart
String myFunction(
String name, [
int? date = 10,
String monthName = 'июля',
]) {
return '$name родился $date $monthName!';
}
void main(List<String> arguments) {
print(myFunction('Александр'));
print(myFunction('Александр', 24));
print(myFunction('Александр', 20, 'мая'));
}
// Александр родился 10 июля!
// Александр родился 24 июля!
// Александр родился 20 мая!
Чтобы ввести значение по умолчанию для именованных аргументов, достаточно
просто присвоить им значение и не использовать ключевое слово required. К расположению значений по умолчанию для такого типа аргументов требований нет,
но лучше придерживаться устоявшейся традиции и объявлять их в конце:
// base_url/0/0.6/0.6.2/ex2.dart
String myFunction({
required String name,
required String monthName,
int date = 10,
}) {
return '$name родился $date $monthName!';
}
82 Глава 0 Установка и настройка рабочего окружения. Основы Dart
void main(List<String> arguments) {
print(myFunction(name: 'Александр', monthName: 'мая'));
print(myFunction(
date: 14,
name: 'Александр',
monthName: 'мая',
));
}
// Александр родился 10 мая!
// Александр родился 14 мая!
0.6.3. Обращение к функции с помощью переменной
Как уже говорилось, функцию можно присваивать переменной (связывать с ней),
после чего вызывать ее при работе с этой самой переменной. Для примера напишем функцию сложения двух чисел и будем с ней работать с помощью переменной:
// base_url/0/0.6/0.6.3/ex1.dart
int add(int a, int b) {
return a + b;
}
String season(int month) {
return switch (month) {
= = 12 || > 0 && < 3 = > 'Winter',
> = 3 && < 6 = > 'Spring',
> = 6 && < 9 = > 'Summer',
> = 9 && < 12 = > 'Autumn',
_ = > "WTF? (╯'□')╯︵ ┻━┻",
};
}
void main(List<String> arguments) {
var myAdd = add;
print(myAdd(10, 5)); // 15
}
var mySeason = season;
print(mySeason(10)); // Autumn
print(mySeason(-1)); // WTF? (╯'□')╯︵ ┻━┻
var o_O = (int a, int b){
return a * b;
};
print(o_O(10, 5)); // 50
0.6.4. Функция как входной аргумент другой функции
Теперь используем написанные в предыдущем разделе функции в качестве входного аргумента другой функции. Для этого понадобится в качестве типа аргумента указать сигнатуру принимаемой на вход функции (возвращаемый тип
данных Function(тип1, …, типN)):
// base_url/0/0.6/0.6.4/ex1.dart
int add(int a, int b) {
return a + b;
}
0.6. Функции 83
String season(int month) {
return switch (month) {
= = 12 || > 0 && < 3 = > 'Winter',
> = 3 && < 6 = > 'Spring',
> = 6 && < 9 = > 'Summer',
> = 9 && < 12 = > 'Autumn',
_ = > "WTF? (╯'□')╯︵ ┻━┻",
};
}
String myStrFunc(
String prefix,
int month,
String Function(int) func,
) {
return prefix + ' ' + func(month);
}
int sub(
int a,
int b, {
int c = 10,
int Function(int, int) func = add,
}) {
return c - func(a, b);
}
void main(List<String> arguments) {
print(myStrFunc('ヽ༼ಥ_ಥ༽ノ', 12, season)); // ヽ༼ಥ_ಥ༽ノ Winter
print(myStrFunc("(҂ 'з´) ︻╦̵
╤──", 0, season));
̿
̵
// (҂ 'з´) ︻╦̵̵̿╤── WTF? (╯'□')╯︵ ┻━┻
print(sub(3, 7)); // 0
print(sub(2, 4, c: 2)); // -4
print(sub(2, 4, c: 2, func: (int a, int b) {
return a * b;
})); // -6
}
0.6.5. Type Alias
Иногда бывают ситуации, когда используемый разработчиком тип данных представляет собой довольно сложную комбинацию коллекций:
// base_url/0/0.6/0.6.5/ex1.dart
int myFunc(Map<String, Map<(int, List<int>), int>> data) {
var sum = 0.0;
for (var MapEntry(:value) in data.entries) {
for (var MapEntry(key: recKey, value: recValue)
in value.entries) {
var (int a, List<int> b) = recKey;
sum += (a * b.reduce(
(value, element) = > value + element)
)/recValue;
}
}
return sum.floor();
}
void main(List<String> arguments) {
Map<String, Map<(int, List<int>), int>> map = {
84 Глава 0 Установка и настройка рабочего окружения. Основы Dart
'a': {
(1, [1, 2, 3]): 100,
(2, [2, -4, 9]): -98,
(3, [3, 4, 5]): 3,
},
'b': {
(10, [1, 0, 3]): 100,
(20, [6, -4, 2]): -98,
(30, [-3, 4, -5]): 3,
}
}
};
print(myFunc(map)); // -29
Когда у функции один аргумент с таким типом данных, это еще терпимо. А теперь
представьте, что их три или более и все различные. Такая функция станет ночным
кошмаром! Просыпаться в холодном поту — то еще удовольствие, поэтому разработчики Dart предусмотрели Type Alias (псевдоним типа). Он позволяет в более
компактной форме написать объявление типов и функций. Изначально этот механизм был доступен только для функций (псевдоним типа функций), но, начиная
с Dart 2.13, его распространили и на типы данных. Согласно документации языка
программирования в отношении функций им рекомендуется пользоваться в случаях, когда тип (сигнатура) функции особенно длинный или часто применяется
в коде. Но важно понимать, что в большинстве случаев другие разработчики захотят
увидеть, какой у функции тип на самом деле. Это позволит им лучше понять, как
работать с самой функцией, особенно когда ее тип используется для задания типа
входного аргумента другой функции.
Для начала дадим типу Map<String, Map<(int, List<int>), int>> псевдоним
OMyMap. В этом нам поможет ключевое слово typedef:
// base_url/0/0.6/0.6.5/ex2.dart
typedef OMyMap = Map<String, Map<(int, List<int>), int>>;
int myFunc(OMyMap data) {
var sum = 0.0;
for (var MapEntry(:value) in data.entries) {
for (var MapEntry(key: recKey, value: recValue)
in value.entries) {
var (int a, List<int> b) = recKey;
sum += (a * b.reduce(
(value, element) = > value + element)
)/recValue;
}
}
return sum.floor();
}
void main(List<String> arguments) {
OMyMap map = {
'a': {
(1, [1, 2, 3]): 100,
(2, [2, -4, 9]): -98,
(3, [3, 4, 5]): 3,
},
'b': {
(10, [1, 0, 3]): 100,
(20, [6, -4, 2]): -98,
0.6. Функции 85
}
}
(30, [-3, 4, -5]): 3,
};
print(myFunc(map)); // -29
0.6.6. Анонимные и стрелочные функции
Помимо функций с явными названиями, вы можете создавать и анонимные функции
(порой их называют лямбда-функциями), то есть функции без имени. Их структуру
записи можно представить следующим образом:
([[Type] arg1[, …]]) {
// блок кода
};
Эти функции на вход могут принимать сколько угодно значений или не принимать их вовсе — все зависит от того, каким образом они были объявлены. При
этом тип входного аргумента в некоторых случаях можно опускать. В качестве
примера создадим анонимную функцию для вывода элементов списка и передадим
ее в метод списка forEach:
// base_url/0/0.6/0.6.6/ex1.dart
void main(List<String> arguments) {
var myList = ['Привет!', 'Я', '-', 'анонимная', 'функция!'];
myList.forEach((item) {
print(
'По индексу ${myList.indexOf(item)} хранится значение = > $item'
);
});
}
// По индексу 0 хранится значение = > Привет!
// По индексу 1 хранится значение = > Я
// По индексу 2 хранится значение = > // По индексу 3 хранится значение = > анонимная
// По индексу 4 хранится значение = > функция!
Стрелочная функция предоставляет форму для объявления однострочных
именованных или анонимных функций. Их поведение практически аналогично
поведению обычных функций, за исключением того, что они по умолчанию всегда
возвращают значение, то есть оператор return в этих функциях не используется,
но его наличие подразумевается:
// base_url/0/0.6/0.6.6/ex2.dart
int add(int a, int b) = > a + b;
int sub(
int c,
int a,
int b,
int Function(int, int) func,
) =>
c - func(a, b);
void main(List<String> arguments) {
print(sub(30, 21, 2, add)); // 7
}
86 Глава 0 Установка и настройка рабочего окружения. Основы Dart
0.7. Библиотеки
При написании многократно используемого кода его принято выделять в отдельные модули, представляющие собой единицу организации программ наивысшего
уровня, которая упаковывает программный код, данные и предоставляет изолированные пространства имен, цель которых — свести к минимуму конфликты имен
переменных внутри программ.
Для модульного представления кода вашего проекта в Dart используются библиотеки и пакеты. Библиотека может представлять собой как один файл с кодом,
подключаемый к основному приложению и расположенный в той же папке, так
и набор таких файлов, организованных в каталог, с единой или множественной
точкой доступа к ним, позволяющей скрыть детали реализации.
0.7.1. Импортирование кода из файла с расширением .dart
Для начала разберемся с тем, как подключать (импортировать) код из другого
файла с расширением .dart (библиотеки), расположенного в основной папке разрабатываемого приложения. В качестве примера реализуем файл, который содержит
функции операций над числами: умножения, деления, сложения и т. д.
На первом шаге создадим простой проект (Console Application) my_lib (рис. 0.35).
Рис. 0.35. Структура созданного приложения
Далее в каталоге bin, где расположен файл
куда добавим новый файл my_calculator.dart:
my_lib
├── bin/
│
└── src/
│
│
└── my_calculator.dart
│
└── my_lib.dart
├── lib/
├── test/
└── pubspec.yaml
my_lib.dart,
создадим каталог src,
0.7. Библиотеки 87
Теперь можно приступать к его заполнению кодом:
// src/my_calculator.dart
import 'dart:math';
double
double
double
double
double
double
add(double a, double b) = > a + b;
sub(double a, double b) = > a - b;
div(double a, double b) = > a / b;
mul(double a, double b) = > a * b;
pow2(double a) = > a * a;
powN(double a, double n) = > pow(a, n).toDouble();
Импортируем код из файла my_calculator.dart в файл my_lib.dart и выведем
в терминал результаты выполнения некоторых функций:
import 'src/my_calculator.dart';
void main(List<String> arguments) {
print(add(3.5, 10)); // 13.5
print(mul(2.5, 4)); // 10.0
print(pow2(3)); // 9.0
}
Как видно из результата выполнения кода, функции, объявленные в файле
доступны в основном файле приложения по их имени. Такое
поведение при импортировании не всегда удобно, особенно когда несколько импортируемых файлов будут содержать одинаковое объявление какой-либо функции
или другого объекта, — это приведет к ошибке. Чтобы в этом убедиться, в каталоге
src создадим файл short_calculator.dart, в который скопируем функции сложения
и вычитания:
my_calculator.dart,
double add(double a, double b) = > a + b;
double sub(double a, double b) = > a - b;
Импортируем его в файл my_lib.dart:
import 'src/my_calculator.dart';
import 'src/short_calculator.dart';
void main(List<String> arguments) {
print(add(3.5, 10));
}
// Error: 'add' is imported from both 'bin/src/my_calculator.dart'
// and 'bin/src/short_calculator.dart'
Чтобы избежать таких ошибок, после объявления импорта одной из библиотек
можно использовать ключевое слово as (префикс/prefix), за которым следует указать имя для обращения к функциям из библиотеки:
import 'src/my_calculator.dart' as calculator;
import 'src/short_calculator.dart';
void main(List<String> arguments) {
// вызов функции из short_calculator.dart
print(add(3.5, 10)); // 13.5
// вызов функции из my_calculator.dart
print(calculator.mul(2.5, 4)); // 10.0
print(calculator.pow2(3)); // 9.0
print(calculator.add(2.5, 4)); // 6.5
}
88 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Часть функций в библиотеке можно объявлять приватными, добавив символ
нижнего подчеркивания перед именем функции. Тогда они останутся доступными
для использования в самой библиотеке, но станут неимпортируемыми. Для примера
перепишем файл my_calculator.dart следующим образом:
import 'dart:math';
double
double
double
double
double
double
add(double a, double b) = > _add(a, b);
sub(double a, double b) = > a - b;
div(double a, double b) = > a / b;
mul(double a, double b) = > a * b;
pow2(double a) = > a * a;
powN(double a, double n) = > pow(a, n).toDouble();
double _add(double a, double b){
return (a + b) * 10;
}
Попробуем средствами IDE обратиться к приватной функции библиотеки
(рис. 0.36).
Рис. 0.36. Доступные для использования функции библиотеки
Как видно из рисунка, доступ к приватным функциям импортируемых библиотек из основного кода приложения закрыт. А попытка их вызова приведет
к ошибке:
import 'src/my_calculator.dart' as calculator;
import 'src/short_calculator.dart';
void main(List<String> arguments) {
// вызов функции из short_calculator.dart
print(add(3.5, 10));
calculator._add(2.5, 4);
}
// Error: Method not found: '_add'.
0.7. Библиотеки 89
0.7.2. Импортирование части функциональности
Порой бывают ситуации, когда из имеющейся в нашем распоряжении библиотеки
необходимо использовать одну или две функции либо же закрыть к одной из функций доступ, чтобы не было возможности к ней обратиться из кода, где подключили
библиотеку. На эти случаи Dart предоставляет следующие ключевые слова:
y show — позволяет указать используемую часть импортируемой библиотеки.
К остальным частям доступ будет закрыт;
y hide — скрывает указанную часть импортируемой библиотеки, к которой
будет закрыт доступ. Остальные части библиотеки будут доступны для использования.
Эти ключевые слова можно использовать как с ключевым словом as (после его
объявления), так и без него.
Для демонстрации принципа работы частичного импортирования с помощью
ключевого слова show возьмем предыдущий пример кода и укажем, что из импортируемой библиотеки нам нужна только функция умножения (рис. 0.37).
Рис. 0.37. Импортирование части библиотеки
Если необходимо импортировать несколько частей библиотеки, перечислите
их через запятую:
import 'src/my_calculator.dart' as calculator show mul, add;
void main(List<String> arguments) {
print(calculator.mul(2.5, 4)); // 10.0
print(calculator.add(2.5, 4)); // 65.0
}
Если результат вызова функции сложения вам кажется подозрительным, то
вспомните, что сейчас она вызывает выполнение приватной функции _add:
double _add(double a, double b){
return (a + b) * 10;
}
Для демонстрации принципа работы частичного импортирования с использованием ключевого слова hide укажем, что запрещаем импортировать функции
сложения и умножения (рис. 0.38).
90 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Рис. 0.38. Импортирование части библиотеки
Как видно на рисунке, после такого импортирования мы не можем использовать
в клиентском коде функции mul и add.
0.7.3. Создание и использование библиотеки
В Dart существует негласное соглашение относительно библиотек. Несмотря на
то что создаваемая библиотека может иметь любую иерархию каталогов с кодом,
рекомендуется весь код, содержащий реализацию, помещать в папку lib/src. Это
закрытый каталог, и не рекомендуется явным образом импортировать из него
файлы с кодом. Для того чтобы пользователь имел возможность взаимодействовать с реализацией библиотеки, необходимо в саму папку lib поместить файлы,
которые экспортируют файлы с кодом из lib/src и представляют собой API вашей
библиотеки.
Для начала создадим новый консольный проект my_new_lib, после чего добавим
в каталог lib папку src и следующие файлы:
my_new_lib
├── bin/
│
└── my_new_lib.dart
├── lib/
│
└── src/
│
│
├── my_add.dart
│
│
├── my_sub.dart
│
│
├── my_mul.dart
│
│
└── my_pow_n.dart
│
└── my_new_lib.dart
├── test/
└── pubspec.yaml
С кодом функций:
// my_add.dart
double add(double a, double b) = > a + b;
// my_sub.dart
double sub(double a, double b) = > a - b;
0.7. Библиотеки 91
// my_mul.dart
double mul(double a, double b) = > a * b;
// my_pow_n.dart
import 'dart:math';
double powN(double a, double n) = > pow(a, n).toDouble();
При импорте таких файлов из библиотеки необходимо в клиентском коде приложения (файлах, расположенных в каталоге bin) в начале указания пути использовать директиву package:. Если мы в одном файле библиотеки импортируем другой,
который также расположен в папке lib, то необходимо задействовать относительные
пути (скажем, как в предыдущих примерах).
Теперь перейдите в каталог bin и откройте my_new_lib.dart. Любой из объявленных ранее файлов импортируется из библиотеки следующим образом:
import 'package:my_new_lib/src/my_add.dart';
void main(List<String> arguments) {
print(add(10, 34.2)); // 44.2
}
Обратите внимание на то, что после директивы package: указывается название
текущего приложения, а остальная часть пути берется из каталога lib. Но приведенный способ импортирования файлов из библиотеки — нерекомендуемый, так как
у нас нет файла, описывающего API библиотеки, и мы получаем прямой доступ
к реализациям ее файлов.
Чтобы привести структуру библиотеки в соответствии с рекомендациями документации Dart, в каталоге lib переименуем my_new_lib.dart в calculator.dart (пока
не обращайте внимания на папку test, даже если она стала красной) и укажем в нем
экспортируемые из библиотеки файлы:
export
export
export
export
'src/my_add.dart';
'src/my_mul.dart';
'src/my_pow_n.dart';
'src/my_sub.dart';
Перепишем предыдущий код:
import 'package:my_new_lib/calculator.dart';
void main(List<String> arguments) {
print(add(10, 34.2)); // 44.2
print(mul(10, 4.2)); // 42.0
print(sub(10, 4.8)); // 5.2
print(powN(10, 3)); // 1000.0
}
Как видно из кода, мы одной строчкой импортировали все доступные функции,
которые объявляли в различных файлах библиотеки, и прописали как экспортируе
мые в файле calculator.dart каталога lib. При таком импортировании библиотеки
доступны все ранее рассмотренные механизмы, когда используются ключевые слова
show, hide, as и deferred as.
В каталоге lib можно создавать неограниченное количество файлов, которые
могут предоставлять доступ либо к части файлов библиотеки, либо к ним всем. Для
92 Глава 0 Установка и настройка рабочего окружения. Основы Dart
примера создадим еще один файл, который назовем short_calculator.dart, и с его
помощью экспортируем файлы библиотеки только для сложения и вычитания:
export 'src/my_add.dart';
export 'src/my_sub.dart';
В случае импортирования этого файла нам станут доступны всего две функции,
находящиеся в нашей библиотеке (рис. 0.39).
Рис. 0.39. Подключение библиотеки в клиентском коде приложения
Текущий проект my_new_lib можно рассматривать как пакет, уже готовый для
импортирования в другой проект, так как на его верхнем уровне имеется файл
pubspec.yaml с информацией о самом пакете и его зависимостях от других пакетов.
Проект: игра «Тетрис» v. 0
Для разработки «Тетриса» нам потребуется забежать немного вперед и использовать
некоторые возможности асинхронного программирования. В противном случае
игровой цикл будет привязан к нажатиям клавиш и фигуры не смогут самостоятельно перемещаться по игровой доске.
Кроме того, потребуется написать библиотеку ansi_cli_helper.dart, посредством
которой мы организуем управление потоком вывода в терминал: действия с курсором, изменение цвета текста, заливка области, очистка терминала и т. д. Поскольку
Dart не предоставляет разработчикам такой функциональности «из коробки», реа
лизуем ее сами. 😉 Для этого воспользуемся ANSI-символами — управляющими
последовательностями символов, встраиваемых в текст, которые терминал воспринимает как команды к действию.
Создайте новый консольный проект tetris_cli со следующей структурой папок
и файлов, не забыв удалить все файлы из каталога test:
tetris_cli
├── bin/
│
└── main.dart
├── lib/
│
└── src/
│
│
├── blocks.dart
│
│
└── board.dart
│
├── ansi_cli_helper.dart
│
└── tetris_cli.dart
├── test/
└── pubspec.yaml
Проект: игра «Тетрис» v. 0 93
Разработка библиотеки ansi_cli_helper.dart
Первым делом объявим импорт встроенной библиотеки
типов для установки цвета фона и текста:
dart:io
и псевдонимы
// ansi_cli_helper.dart
import 'dart:io';
// Константы заливки фона
typedef AnsiBackgroundColor = String;
const AnsiBackgroundColor blackBgColor = '\u001b[40m';
const AnsiBackgroundColor redBgColor = '\u001b[41m';
const AnsiBackgroundColor greenBgColor = '\u001b[42m';
const AnsiBackgroundColor yellowBgColor = '\u001b[43m';
const AnsiBackgroundColor blueBgColor = '\u001b[44m';
const AnsiBackgroundColor magentaBgColor = '\u001b[45m';
const AnsiBackgroundColor cyanBgColor = '\u001b[46m';
const AnsiBackgroundColor whiteBgColor = '\u001b[47m';
// Константы цвета текста
typedef AnsiTextColor = String;
const AnsiTextColor blackTColor = '\u001b[30m';
const AnsiTextColor redTColor = '\u001b[31m';
const AnsiTextColor greenTColor = '\u001b[32m';
const AnsiTextColor yellowTColor = '\u001b[33m';
const AnsiTextColor blueTColor = '\u001b[34m';
const AnsiTextColor magentaTColor = '\u001b[35m';
const AnsiTextColor cyanTColor = '\u001b[36m';
const AnsiTextColor whiteTColor = '\u001b[37m';
Далее реализуем функционал, отвечающий за отображение и сокрытие курсора
на его позиции в терминале:
bool _isHideCursor = false;
bool isHideCursor() = > _isHideCursor;
// Функция для показа курсора
void showCursor() {
if (_isHideCursor) {
stdout.write('\u001b[?25h'); // Включение курсора
_isHideCursor = false;
}
}
// Функция для сокрытия курсора
void hideCursor() {
if (!_isHideCursor) {
stdout.write('\u001b[?25l'); // Выключение курсора
_isHideCursor = true;
}
}
Следом пойдут функции установки цвета текста и фона:
// Функция для установки цвета текста
void setTextColor(AnsiTextColor color) {
stdout.write(color);
}
// Функция для установки цвета фона
void setBackgroundColor(AnsiBackgroundColor color) {
stdout.write(color);
}
94 Глава 0 Установка и настройка рабочего окружения. Основы Dart
И как вишенку на торт последними функциями в эту библиотеку добавим те,
которые будут отвечать за сброс настроек, очистку терминала и перемещение
по нему курсора:
// Функция для очистки экрана
void clear() {
stdout.write('\u001b[2J\u001b[0;0H'); // Очистка экрана
}
// Функция для очистки экрана и сброса цветов
void reset() {
setTextColor(whiteTColor);
setBackgroundColor(blackBgColor);
clear();
showCursor();
}
// Функция для перемещения курсора в заданную позицию
// считается от левого верхнего угла, чьи координаты — 0, 0
void gotoxy(int x, int y) {
if (x < 0 || y < 0) {
return;
}
stdout.write('\u001b[$y;${x}H');
}
Разработка библиотеки blocks.dart
Поскольку первую версию игры мы реализуем в процедурном, а не объектно-ориентированном стиле, описание блоков и их вариации при различных типах поворотов
будем хранить в четырехмерном списке List<List<List<List<int>>>> _defBlocks.
На первом уровне списка будут располагаться типы фигур (квадрат, линия и т. д.),
второй отвечает за поворот фигуры, а нижние два уровня хранят представление
фигуры в двумерном списке 4 × 4, где 0 — пустой блок, а 1 — блок с телом фигуры:
// blocks.dart
import 'dart:math';
// Размеры блоков
const int horizontalBlockSize = 4; // по горизонтали
const int verticalBlockSize = 4; // по вертикали
var _defBlocks = [
// Квадрат
[
[
[0, 0, 0, 0],
[0, 1, 1, 0],
[0, 1, 1, 0],
[0, 0, 0, 0]
],
[
[0, 0, 0, 0],
[0, 1, 1, 0],
[0, 1, 1, 0],
[0, 0, 0, 0]
],
Проект: игра «Тетрис» v. 0 95
[
[0,
[0,
[0,
[0,
0,
1,
1,
0,
0,
1,
1,
0,
0],
0],
0],
0]
[0,
[0,
[0,
[0,
0,
1,
1,
0,
0,
1,
1,
0,
0],
0],
0],
0]
[0,
[1,
[0,
[0,
0,
1,
0,
0,
0,
1,
0,
0,
0],
1],
0],
0]
[0,
[0,
[0,
[0,
1,
1,
1,
1,
0,
0,
0,
0,
0],
0],
0],
0]
[0,
[1,
[0,
[0,
0,
1,
0,
0,
0,
1,
0,
0,
0],
1],
0],
0]
[0,
[0,
[0,
[0,
1,
1,
1,
1,
0,
0,
0,
0,
0],
0],
0],
0],
[0,
[0,
[0,
[0,
1,
1,
1,
0,
0,
0,
1,
0,
0],
0],
0],
0]
[0,
[1,
[1,
[0,
0,
1,
0,
0,
0,
1,
0,
0,
0],
0],
0],
0]
[1,
[0,
[0,
[0,
1,
1,
1,
0,
0,
0,
0,
0,
0],
0],
0],
0]
],
[
]
],
// I
[
[
],
[
],
[
],
[
]
],
// L
[
[
],
[
],
[
],
96 Глава 0 Установка и настройка рабочего окружения. Основы Dart
[
[0,
[1,
[0,
[0,
0,
1,
0,
0,
1,
1,
0,
0,
]
],
// Зеркальное L
[
[
[0, 1, 0,
[0, 1, 0,
[1, 1, 0,
[0, 0, 0,
],
[
[1, 0, 0,
[1, 1, 1,
[0, 0, 0,
[0, 0, 0,
],
[
[0, 1, 1,
[0, 1, 0,
[0, 1, 0,
[0, 0, 0,
],
[
[0, 0, 0,
[1, 1, 1,
[0, 0, 1,
[0, 0, 0,
]
],
// N
[
[
[0, 0, 1,
[0, 1, 1,
[0, 1, 0,
[0, 0, 0,
],
[
[0, 0, 0,
[1, 1, 0,
[0, 1, 1,
[0, 0, 0,
],
[
[0, 1, 0,
[1, 1, 0,
[1, 0, 0,
[0, 0, 0,
],
[
[1, 1, 0,
[0, 1, 1,
[0, 0, 0,
[0, 0, 0,
]
],
// Зеркальное N
[
[
0],
0],
0],
0]
0],
0],
0],
0]
0],
0],
0],
0]
0],
0],
0],
0]
0],
0],
0],
0]
0],
0],
0],
0]
0],
0],
0],
0]
0],
0],
0],
0]
0],
0],
0],
0]
Проект: игра «Тетрис» v. 0 97
[0,
[0,
[0,
[0,
1,
1,
0,
0,
0,
1,
1,
0,
0],
0],
0],
0]
[0,
[0,
[1,
[0,
0,
1,
1,
0,
0,
1,
0,
0,
0],
0],
0],
0]
[1,
[1,
[0,
[0,
0,
1,
1,
0,
0,
0,
0,
0,
0],
0],
0],
0]
[0,
[1,
[0,
[0,
1,
1,
0,
0,
1,
0,
0,
0,
0],
0],
0],
0]
[0,
[0,
[0,
[0,
1,
1,
1,
0,
0,
1,
0,
0,
0],
0],
0],
0]
[0,
[1,
[0,
[0,
0,
1,
1,
0,
0,
1,
0,
0,
0],
0],
0],
0]
[0,
[1,
[0,
[0,
1,
1,
1,
0,
0,
0,
0,
0,
0],
0],
0],
0]
[0,
[1,
[0,
[0,
1,
1,
0,
0,
0,
1,
0,
0,
0],
0],
0],
0]
],
[
],
[
],
[
]
],
// T
[
[
],
[
],
[
],
[
]
]
];
Далее объявим псевдонимы типов и приватные функции. Их будем использовать
при генерации следующего случайного блока, который отобразится на игровом поле:
int _getRandom(int min, int max) {
return Random().nextInt(max - min + 1) + min;
}
/******Тип блока*********/
typedef BlockType = int;
// 0 - square
// 1 - stick
98 Глава 0 Установка и настройка рабочего окружения. Основы Dart
//
//
//
//
//
2
3
4
5
6
-
L
mirror L
N
mirror N
T
const List<BlockType> _blockTypes = [0, 1, 2, 3, 4, 5, 6];
BlockType _getRandomBlockType() = > _blockTypes[_getRandom(
0,
_blockTypes.length - 1,
)];
/******Тип поворота блока*******/
typedef BlockRotation = int;
// 0 - zero
// 1 - one
// 2 - two
// 3 - three
const List<BlockRotation> _blockRotations = [0, 1, 2, 3];
BlockRotation _getRandomRotation() = > _blockRotations[_getRandom(
0,
_blockRotations.length - 1,
)];
Последней добавим функцию getNewBlock. Она будет возвращать копию случайной фигуры:
List<List<int>> getNewBlock() {
var blockType = _getRandomBlockType();
var rotation = _getRandomRotation();
}
List<List<int>> tmp = List.generate(4, (_) = > List.filled(4, 0));
var defBlock = _defBlocks[blockType][rotation];
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
tmp[i][j] = defBlock[i][j];
}
}
return tmp;
Разработка библиотеки board.dart
Данная библиотека будет отвечать за игровой цикл, отображение игровой доски
и фигур на ней, а также обработку нажатий клавиш пользователем. Для этого нам
понадобится импортировать некоторые встроенные и реализованные ранее библио
теки и объявить следующий набор переменных:
import 'dart:async';
import 'dart:io';
import 'blocks.dart';
import '../ansi_cli_helper.dart' as ansi;
const int heightBoard = 20; // высота игровой доски
const int widthBoard = 10; // ширина игровой доски
Проект: игра «Тетрис» v. 0 99
// тип заполнения ячеек игровой доски
const int posFree = 0; // свободное место
const int posFilled = 1; // заполненное место
const int posBoarder = 2; // граница
late List<List<int>> mainBoard; // основная доска
late List<List<int>> mainCpy; // копия основной доски
late List<List<int>> mblock; // блок c фигурой
late int x; // координата x
late int y; // координата y
bool _isGameOver = false; // игра окончена
int scoreGame = 0; // набранные очки
// подписка на получение нажатия клавиш
StreamSubscription? _subscription;
bool get isGameOver = > _isGameOver;
На следующем шаге внесем несколько функций, а именно: добавления новой
случайной фигуры на доску, очистки заполненных строк со смещением игрового
поля (сверху вниз) и отрисовки самой доски:
// Функция отрисовки основной доски
void drawBoard() {
ansi.gotoxy(0, 0); // устанавливаем курсор в начало
for (int i = 0; i < heightBoard - 1; i++) {
for (int j = 0; j < widthBoard - 1; j++) {
switch (mainBoard[i][j]) {
case posFree:
stdout.write(' '); // пустое место
case posFilled:
stdout.write('O'); // заполненное место и фигура
case posBoarder:
// устанавливаем красный цвет текста
ansi.setTextColor(ansi.redTColor);
stdout.write('#'); // граница доски
// возвращаем белый цвет
ansi.setTextColor(ansi.whiteTColor);
}
}
stdout.write('\n');
}
}
// Функция очистки заполненных строк
void clearLine() {
for (int j = 0; j < = heightBoard - 3; j++) {
// проверка заполненности строки
int i = 1;
while (i < = widthBoard - 3) {
if (mainBoard[j][i] = = posFree) {
break;
}
i++;
}
if (i = = widthBoard - 2) { // если строка заполнена
// очистка строки и сдвиг строк игровой доски вниз
for (int k = j; k > 0; k--) {
100 Глава 0 Установка и настройка рабочего окружения. Основы Dart
for (int idx = 1; idx < = widthBoard - 3; idx++) {
mainBoard[k][idx] = mainBoard[k - 1][idx];
}
}
}
}
}
// увеличение очков
scoreGame += 10;
// Функция генерации нового блока и добавления его на основную доску
void newBlock() {
// начальные координаты новой фигуры
x = 4;
y = 0;
mblock = getNewBlock();
// добавляем новый блок на основную доску
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
mainBoard[i][x + j] = mainCpy[i][x + j] + mblock[i][j];
}
}
}
// проверка на пересечение
if (mainBoard[i][x + j] > 1) {
_isGameOver = true; // игра окончена
}
Далее приведена функция перемещения фигуры по основной доске. Происходит это за счет предварительного вычитания из доски текущей фигуры, что
делает это место пустым, и последующего добавления фигуры на обновленную
позицию:
// Функция перемещения фигуры по основной доске
void moveBlock(int x2, int y2) {
// убираем фигуру с текущей позиции
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
if (x + j > = 0) {
mainBoard[y + i][x + j] -= mblock[i][j];
}
}
}
// устанавливаем новую позицию
x = x2;
y = y2;
// добавляем фигуру
for (int i = 0; i <
for (int j = 0; j
if (x + j > = 0)
mainBoard[y +
}
}
}
}
drawBoard();
на новую позицию
4; i++) {
< 4; j++) {
{
i][x + j] += mblock[i][j];
Проект: игра «Тетрис» v. 0 101
Поворот фигуры происходит по аналогичному сценарию, за тем исключением,
что используется промежуточный двумерный массив и добавляется проверка на
то, можно ли повернуть фигуру:
// Функция проверки возможности сдвига блока в заданном направлении
bool isFilledBlock(int x2, int y2) {
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
if (mblock[i][j] ! = 0 && mainCpy[y2 + i][x2 + j] ! = 0) {
return true;
}
}
}
return false;
}
// Функция обработки поворота блока
void rotateBlock() {
// Временный блок с текущей фигурой
List<List<int>> tmp = List.generate(4, (_) = > List.filled(4, 0));
// Заполняем временный блок
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
tmp[i][j] = mblock[i][j];
}
}
// Поворачиваем фигуру
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
mblock[i][j] = tmp[3 - j][i];
}
}
// Проверка на то, что фигура не пересекается
// с границей или другими блоками ранее помещенных на доску фигур
if (isFilledBlock(x, y)) {
// если есть пересечения, то возвращаем старую фигуру
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
mblock[i][j] = tmp[i][j];
}
}
}
// Обновляем основную доску
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
// Убираем старую фигуру
mainBoard[y + i][x + j] -= tmp[i][j];
}
}
}
// Добавляем новую фигуру
mainBoard[y + i][x + j] += mblock[i][j];
drawBoard();
Поскольку копия игровой доски используется при добавлении новой случайной фигуры и в функции, отвечающей за проверку на возможность сдвига фигуры
102 Глава 0 Установка и настройка рабочего окружения. Основы Dart
в заданном направлении, ее необходимо обновлять после завершения каждого игрового цикла (кадра). Поэтому для большего удобства воспринимайте переменную
mainCpy как буферный кадр игры:
void savePresentBoardToCpy() {
for (int i = 0; i < heightBoard - 1; i++) {
for (int j = 0; j < widthBoard - 1; j++) {
mainCpy[i][j] = mainBoard[i][j];
}
}
}
Теперь реализуем функцию для обработки нажатия клавиш, отключив эхо-режим
отображения нажимаемых символов на клавиатуре в терминале и нажатия Enter,
обозначающего завершение строки. Поскольку этот функционал не должен мешать
основному игровому циклу, воспользуемся асинхронным программированием,
а именно потоками (Stream):
// Функция для обработки нажатия клавиш
void controlUserInput() {
stdin.echoMode = false;
stdin.lineMode = false;
_subscription = stdin.listen((data) {
int key = data.first;
switch (key) {
case 119: // W — поворот фигуры
rotateBlock();
case 97: // A — влево
if (!isFilledBlock(x - 1, y)) {
moveBlock(x - 1, y);
}
case 115: // S — вниз
if (!isFilledBlock(x, y + 1)) {
moveBlock(x, y + 1);
}
case 100: // D — вправо
if (!isFilledBlock(x + 1, y)) {
moveBlock(x + 1, y);
}
}
});
}
Из-за специфики написанного ранее кода повороты фигур в результате нажатия
на клавишу W могут приводить к генерации исключения и экстренному завершению приложения. Чтобы избежать этого, уменьшим в функции initDraw размер
игровой доски, оставив пустыми крайний правый столбец и нижнюю строку и заполнив строку (heightBoard – 2) и столбец (widthBoard – 2) перед ними значением 2.
Аналогично поступим и с первым столбцом доски. Это эквивалентно объявлению
границы игрового поля, по которому могут перемещаться фигуры:
// Функция для инициализации игры
initGame() {
scoreGame = 0; // обнуляем набранные очки
mainBoard = List.generate(
heightBoard,
(_) = > List.filled(widthBoard, posFree),
);
mainCpy = List.generate(
Проект: игра «Тетрис» v. 0 103
heightBoard,
(_) = > List.filled(widthBoard, posFree),
);
mblock = List.generate(
4,
(_) = > List.filled(4, posFree),
);
}
initDraw();
controlUserInput();
// Функция инициализации основной доски
void initDraw() {
// Заполняем границу игровой зоны на основной и вспомогательной доске
for (int i = 0; i < = heightBoard - 2; i++) {
for (int j = 0; j < = widthBoard - 2; j++) {
if (j = = 0 || j = = widthBoard - 2 || i = = heightBoard - 2) {
mainBoard[i][j] = posBoarder;
mainCpy[i][j] = posBoarder;
}
}
}
}
newBlock();
drawBoard();
Единственное, что еще удерживает нас в пределах файла board.dart, — отсутствие функций, отвечающих за запуск «Тетриса» и обработку каждого шага
игрового цикла:
// Функция обработки шага игрового цикла
void nextStep() {
// можно сдвинуть фигуру?
if (!isFilledBlock(x, y + 1)) { // да
moveBlock(x, y + 1);
} else { // нет
clearLine();
savePresentBoardToCpy();
newBlock();
drawBoard();
}
}
// Функция запуска игрового цикла
Future<void> start() async {
while (!isGameOver) { // пока игра не окончена
nextStep();
await Future.delayed(const Duration(milliseconds: 500));
}
}
// завершаем игру
_subscription?.cancel(); // завершаем прослушивание нажатий клавиш
ansi.setTextColor(ansi.yellowTColor);
stdout.write('===============\n'
'~~~Game Over~~~\n'
'===============\n');
ansi.setBackgroundColor(ansi.blueBgColor);
stdout.writeln('Score: $scoreGame ');
await Future.delayed(const Duration(seconds: 5));
ansi.reset();
104 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Компоновка библиотек и запуск игры
Чтобы более красиво и правильно импортировать функционал игры в файл main.dart,
откройте в папке lib библиотеку tetris_cli.dart и добавьте в нее следующий экспорт:
// lib/tetris_cli.dart
export 'src/board.dart';
Теперь откройте main.dart и внесите в него этот код:
// bin/main.dart
import 'package:tetris_cli/tetris_cli.dart';
import 'package:tetris_cli/ansi_cli_helper.dart' as ansi;
void main(List<String> arguments) {
ansi.reset();
ansi.hideCursor();
}
initGame();
start();
Если к этому моменту у вас закончился запас попкорна (чая, чипсов, терпения и т. д.), самое время его обновить! После чего убедиться, что в системе используется английская раскладка, и запустить игру. 😉 На вкладке TERMINAL вас
встретит картина, представленная на рис. 0.40.
Рис. 0.40. Запуск игры
Щелкните кнопкой мыши на основной области терминала и смело приступайте к игре, нажимая клавиши, отвечающие за то или иное действие фигуры. И как
только на игровом поле не останется свободного места для размещения очередной
фигуры, запустится завершение игры (рис. 0.41).
Проект: игра «Тетрис» v. 0 105
Наверное, в процессе игры вы обратили внимание на то, как неудобно прицеливаться фигурой в примеченную глазом позицию на игровом поле… Всему
виной количество пикселов, отводимое по ширине и высоте на текстовые символы
в терминале. Единственный способ исправить это допущение и придать нашему
«Тетрису» еще больше благородства и величия — заменить символы O # на эмодзи
цветных квадратов:
// lib/src/board.dart
// Функция отрисовки основной доски
void drawBoard() {
ansi.gotoxy(0, 0); // устанавливаем курсор в начало
for (int i = 0; i < heightBoard - 2; i++) {
for (int j = 0; j < widthBoard - 1; j++) {
switch (mainBoard[i][j]) {
case posFree:
stdout.write('⬛');
case posFilled:
stdout.write('⬜');
case posBoarder:
stdout.write('⬜');
}
}
stdout.write('\n');
}
// отрисовываем нижнюю границу
stdout.write('⬜');
stdout.write('${'⬜' * 8}\n');
}
Итог — на рис. 0.42.
Такое и друзьям не стыдно показать. 😉
Рис. 0.41. Завершение игры
Рис. 0.42. Замена символов разноцветными
квадратными эмодзи
106 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Задания на модификацию проекта
В следующий раз мы перепишем игру, используя объектно-ориентированный
подход, выделив имеющиеся функции в классы и сделав код более структурированным и удобным для восприятия. А пока можете выполнить следующие задания
по внесению изменений в существующую кодовую базу.
1. Обеспечьте возможность после завершения игры запустить новую игру без
выхода из приложения.
2. Добавьте команду завершения текущей игровой сессии.
3. Добавьте команду установки игры на паузу и снятия с нее.
4. Добавьте игроку возможность просматривать набранные очки в процессе
игры, а не только по ее завершении.
5. Введите понятие уровней и при каждом достижении порога (допустим,
с шагом 50 очков) переводите игрока на следующий уровень, увеличивая
скорость игры.
6. Доработайте функционал таким образом, чтобы рядом с игровым полем отображалась фигура, которая появится следующей.
0.8. Объектно-ориентированное программирование
Для объявления класса в Dart используется ключевое слово class. В качестве
примера посредством класса опишем один из объектов предметной области ветеринарной клиники — кота, а также его состояние и поведение. Для этого создадим
файл cat.dart:
// base_url/0/0.8/0.8.1/ex1/cat.dart
class Cat{
late final String name;
String address = 'Unknown';
int age = 0;
bool sleepState = true;
void sleep(){
if(!sleepState){
sleepState = true;
print('Кот засыпает: Хр-р-р-р-р...');
}
else{
print('Сон во сне... м-м-м-м...');
}
}
void wakeUp(){
if(sleepState){
sleepState = false;
print('Лениво потягиваясь, открывает глаза...');
}
}
void helloMaster(){
if(!sleepState){
0.8. Объектно-ориентированное программирование 107
}
}
print('Мя-я-я-я-у!!!');
void currentState(){
if(sleepState){
print('Кот спит');
}
else{
print('Кот бодрствует');
}
}
}
// base_url/0/0.8/0.8.1/ex1/main.dart
import 'cat.dart';
void main(List<String> arguments) {
// создаем экземпляр класса Cat
var cat = Cat()
..age=3
..name='Тимоха'
..sleepState=false;
}
cat.helloMaster(); // Мя-я-я-я-у!!!
cat.currentState(); // Кот бодрствует
cat.sleepState = true;
cat.currentState(); // Кот спит
cat.sleep(); // Сон во сне... м-м-м-м...
В приведенном коде мы объявили класс Cat, описывающий состояние и различное поведение объекта кот. По умолчанию в Dart все переменные и методы
класса являются public (публичными), и именно поэтому можно обращаться
к ним и менять их значения из клиентского кода (в нашем случае — функция main).
Для объявления приватной переменной либо метода их имя должно начинаться
с символа нижнего подчеркивания (_). Обычно приватные методы скрывают детали реализации, которые мы не хотим выставлять на всеобщее обозрение, доступ
к которым осуществляется посредством публичных методов, а чтобы получить
доступ к приватным переменным, получить или установить их значения, принято
использовать геттеры (get) и сеттеры (set).
Перепишем класс Cat с учетом того, что часть переменных состояния кота теперь
будут приватными и класс не предоставляет возможности установки или чтения
их значений:
// base_url/0/0.8/0.8.1/ex2/cat.dart
class Cat{
late final String name;
String _address = 'Unknown';
int age = 0;
// по умолчанию кот при создании экземпляра
// класса всегда бодрствует
bool _sleepState = false;
void sleep(){
if(!_sleepState){
108 Глава 0 Установка и настройка рабочего окружения. Основы Dart
_sleepState = true;
print('Кот засыпает: Хр-р-р-р-р...');
}
}
else{
print('Сон во сне... м-м-м-м...');
}
void wakeUp(){
if(_sleepState){
_sleepState = false;
print('Лениво потягиваясь, открывает глаза...');
}
}
void helloMaster(){
if(!_sleepState){
print('Мя-я-я-я-у!!!');
}
}
}
void currentState(){
if(_sleepState){
print('Кот спит');
}
else{
print('Кот бодрствует');
}
}
// base_url/0/0.8/0.8.1/ex2/main.dart
import 'cat.dart';
void main(List<String> arguments) {
// создаем экземпляр класса Cat
var cat = Cat()
..age=3
..name='Тимоха';
/*
// ошибка
var cat = Cat()
..age=3
..name='Тимоха'
.._sleepState=true;
*/
}
cat.helloMaster(); // Мя-я-я-я-у!!!
cat.currentState(); // Кот бодрствует
cat.sleep(); // Кот засыпает: Хр-р-р-р-р...
cat.sleep(); // Сон во сне... м-м-м-м...
cat.wakeUp(); // Лениво потягиваясь, открывает глаза...
Теперь из модуля, где объявлена функция main, нельзя обращаться к приватной переменной класса Cat — _sleepState или _address. Но это действительно
только в том случае, если описываемый класс импортируется. То есть символ _
скрывает переменные и методы класса от других элементов приложения при его
импортировании. В рамках того модуля (Dart-файла), где описывается класс со
своими приватными переменными, они остаются общедоступными. Для примера
0.8. Объектно-ориентированное программирование 109
перенесем класс Cat, в котором имеется приватная переменная из файла cat.dart,
в файл с функцией верхнего уровня main и изменим из него значение переменной
_sleepState экземпляра класса Cat:
// base_url/0/0.8/0.8.1/ex3.dart
class Cat{
// без изменений относительно предыдущего примера
}
void main(List<String> arguments) {
// создаем экземпляр класса Cat
var cat = Cat()
..age=3
..name='Тимоха'
.._sleepState=false;
}
cat.helloMaster(); // Мя-я-я-я-у!!!
cat.currentState(); // Кот бодрствует
cat._sleepState = true;
cat.currentState(); // Кот спит
cat.sleep(); // Сон во сне... м-м-м-м...
cat._sleepState = false;
cat.currentState(); // Кот бодрствует
Из-за такой особенности поведения приватных методов и переменных необходимо помнить о том, что относительно того модуля (файла), где они были объявлены,
и переменные класса или методы, и приватные переменные модуля или функции
остаются публичными.
Вернем класс в файл cat.dart и напишем геттер и сеттер для установки и чтения
значения переменной — _sleepState и _address:
// base_url/0/0.8/0.8.1/ex4/cat.dart
class Cat {
late final String name;
String _address = 'Unknown';
int age = 0;
bool _sleepState = false;
bool get isSleep = > _sleepState;
set setSleepState(bool val) = > _sleepState = val;
String get address = > _address;
set address(String val) = > _address = val;
}
// остальные методы не изменялись
// base_url/0/0.8/0.8.1/ex4/main.dart
import 'cat.dart';
void main(List<String> arguments) {
// создаем экземпляр класса Cat
var cat = Cat()
..age = 3
..name = 'Тимоха';
print(cat.address); // Unknown
cat.address = 'Москва';
print(cat.address); // Москва
110 Глава 0 Установка и настройка рабочего окружения. Основы Dart
cat.helloMaster(); // Мя-я-я-я-у!!!
cat.currentState(); // Кот бодрствует
print(cat.isSleep); // false
}
cat.setSleepState = true;
cat.currentState(); // Кот спит
cat.sleep(); // Сон во сне... м-м-м-м...
0.8.1. Конструктор класса
Посредством конструктора создается экземпляр класса, и когда он не объявляется
явно, компилятор создает конструктор по умолчанию, который не принимает на свой
вход аргументы для инициализации состояния создаваемого экземпляра класса.
Рассмотрим несколько способов объявления конструктора класса в Dart.
В первом случае мы можем использовать стандартное объявление из языков программирования семейства С:
// base_url/0/0.8/0.8.1/ex5/cat.dart
class Cat{
late final String name;
String _address = 'Unknown';
int age = 0;
bool _sleepState = false;
Cat(String name, int age, String address) {
this.name = name;
this.age = age;
this._address = address;
}
}
// остальные методы не изменялись
// base_url/0/0.8/0.8.1/ex5/main.dart
import 'cat.dart';
void main(List<String> arguments) {
// создаем экземпляр класса Cat
var cat = Cat('Тимоха', 5, 'Москва');
print(cat.name); // Тимоха
print(cat.age); // 5
print(cat.address); // Москва
}
cat.helloMaster(); // Мя-я-я-я-у!!!
cat.currentState(); // Кот бодрствует
Еще один способ объявления конструктора позволяет записать его более компактно. В этом случае имена аргументов конструктора явно связываются с переменными экземпляра создаваемого класса:
class Cat{
late final String name;
String _address = 'Unknown';
int age = 0;
// по умолчанию кот при создании экземпляра
// класса всегда бодрствует
bool _sleepState = false;
0.8. Объектно-ориентированное программирование 111
Cat(this.name, this.age);
}
// остальные методы не изменялись
Аналогичным образом мы можем с помощью конструктора инициализировать
значения приватных переменных экземпляра класса:
// base_url/0/0.8/0.8.1/ex6/cat.dart
class Cat{
late final String name;
String _address = 'Unknown';
int age = 0;
// по умолчанию кот при создании экземпляра
// класса всегда бодрствует
bool _sleepState = false;
}
Cat(
this.name,
this.age,
this._address,
this._sleepState,
);
// остальные методы не изменялись
// base_url/0/0.8/0.8.1/ex6/main.dart
import 'cat.dart';
void main(List<String> arguments) {
// создаем экземпляр класса Cat
var cat = Cat('Тимоха', 3, 'Москва', true);
print(cat.name); // Тимоха
print(cat.age); // 3
print(cat.address); // Москва
}
cat.helloMaster();
cat.currentState(); // Кот спит
К конструкторам и методам класса применимы те же способы передачи аргументов, что и к функциям. Для примера сделаем необязательной передачу в конструктор
флага бодрствования кота и его возраста:
// base_url/0/0.8/0.8.1/ex7/cat.dart
class Cat{
late final String name;
String _address = 'Unknown';
int age = 0;
// по умолчанию кот при создании экземпляра
// класса всегда бодрствует
bool _sleepState = false;
}
Cat(
this.name,
this._address, [
this.age = 4,
this._sleepState = true,
]);
// остальные методы не изменялись
112 Глава 0 Установка и настройка рабочего окружения. Основы Dart
// base_url/0/0.8/0.8.1/ex7/main.dart
import 'cat.dart';
void main(List<String> arguments) {
var cat = Cat('Тимоха', 'Москва');
print('Возраст кота "${cat.name}" = ${cat.age}');
// Возраст кота "Тимоха" = 4
print(
'Город проживания кота "${cat.name}" = ${cat.address}',
); // Город проживания кота "Тимоха" = Москва
cat.helloMaster();
cat.currentState(); // Кот спит
}
В отличие от С-образных языков программирования Dart не поддерживает
перегрузку конструктора, то есть мы не можем объявить несколько конструкторов
с одним именем, но принимающих на вход различное количество аргументов или
различающихся типом принимаемых аргументов. Вместо механизма перегрузки
конструкторов программисту предоставляется возможность объявлять именованный конструктор:
// base_url/0/0.8/0.8.1/ex8/cat.dart
class Cat{
late final String name;
String _address = 'Unknown';
int age = 0;
// по умолчанию кот при создании экземпляра
// класса всегда бодрствует
bool _sleepState = false;
Cat({
required this.name,
this.age = 2,
required bool sleepState,
String address = 'Unknown',
}) : _sleepState = sleepState,
_address = address;
Cat.onlyName(this.name);
Cat.fromNameAndAddress(
this.name,
String address,
) : _address = address;
}
// остальные методы не изменялись
// base_url/0/0.8/0.8.1/ex8/main.dart
import 'cat.dart';
void catProcessing(Cat cat) {
print('Возраст кота "${cat.name}" = ${cat.age}');
print(
'Город проживания кота "${cat.name}" = ${cat.address}',
);
cat.helloMaster();
cat.currentState();
}
void main(List<String> arguments) {
var cat = Cat(
0.8. Объектно-ориентированное программирование 113
name: 'Тимоха',
sleepState: true,
address: 'Питер',
);
catProcessing(cat);
print('*' * 20);
cat = Cat.onlyName('Тимоха');
catProcessing(cat);
print('*' * 20);
cat = Cat.fromNameAndAddress('Тимоха', 'Москва');
catProcessing(cat);
}
/* Возраст кота "Тимоха" = 2
Город проживания кота "Тимоха" = Питер
Кот спит
********************
Возраст кота "Тимоха" = 0
Город проживания кота "Тимоха" = Default City
Мя-я-я-я-у!!!
Кот бодрствует
********************
Возраст кота "Тимоха" = 0
Город проживания кота "Тимоха" = Москва
Мя-я-я-я-у!!!
Кот бодрствует */
Константный конструктор необходимо использовать в тех случаях, когда
значения объектов и переменных класса не меняются, то есть все они объявлены
с помощью final. Чтобы объявить константный конструктор, перед ним следует
добавить ключевое слово const:
// base_url/0/0.8/0.8.1/ex9/cat.dart
class ImmutableCat {
final String name;
final int age;
const ImmutableCat(this.name, this.age);
}
void helloMaster(){
print('Мя-я-я-я-у!!!');
}
// base_url/0/0.8/0.8.1/ex9/main.dart
import 'cat.dart';
void main(List<String> arguments) {
var cat = const ImmutableCat('Тимоха', 3);
var newcat = const ImmutableCat('Тимоха', 3);
var barsik = const ImmutableCat('Барсик', 2);
}
print(identical(cat, newcat)); // cat = = newcat
print(identical(cat, barsik)); // cat ! = barsik
barsik.helloMaster(); // Мя-я-я-я-у!!!
Обычные конструкторы отвечают за создание и инициализацию новых экземпляров класса. А что, если нам необходимо, чтобы конструктор в случае создания объекта
114 Глава 0 Установка и настройка рабочего окружения. Основы Dart
с параметрами, по которым ранее уже создавался объект, возвращал именно его?
Или в системе присутствовал только один объект (экземпляр) нужного нам класса?
Для этих случаев необходимо использовать фабричные конструкторы, добавив
перед конструктором класса ключевое слово factory. Такой конструктор от обычных
отличается еще и тем, что в нем явно должен осуществляться возврат экземпляра
класса посредством return.
В качестве примера представим ситуацию: у нас в системе огромное количество
книг и, чтобы не создавать дубликаты, мы в случае запроса на создание экземпляра
класса с параметрами, по которым ранее был создан экземпляр класса книги, будем возвращать его. А если экземпляр класса книги с необходимыми параметрами
не создавался, то мы его создадим, сделаем себе об этом заметку и вернем объект
в клиентский код:
// base_url/0/0.8/0.8.1/ex10.dart
class Book{
final String name;
final int pages;
static var _booksMap = <String, Book>{};
Book.fromSettings(this.name, this.pages);
}
factory Book(String name, int pages){
var cache = name.toLowerCase() + pages.toString();
return _booksMap.putIfAbsent(cache,
() = > Book.fromSettings(name, pages));
}
void main(List<String> arguments) {
var book1 = Book('Война и мир т.1', 1234);
var book2 = Book('Тихий Дон т.1', 400);
var book3 = Book('Евгений Онегин', 250);
var book4 = Book('Война и мир т.1', 1234);
print(identical(book2, book3)); // false
print(identical(book1, book4)); // true
}
0.8.2. Статические переменные и методы класса
Отличие статических переменных от обычных заключается в том, что они будут
хранить одно и то же значение вне зависимости от того, с каким экземпляром класса сейчас идет работа. Также к ним можно обращаться только с помощью имени
самого класса (без создания экземпляра класса) либо прописав сеттеры и геттеры.
Отдельно стоит обратить внимание на то, что статическая переменная должна быть
инициализирована до момента ее использования (обращения к ней):
// base_url/0/0.8/0.8.2/ex1.dart
class Book{
static var bookPages = 10;
}
int get pages = > bookPages;
void main(List<String> arguments) {
var book1 = Book();
0.8. Объектно-ориентированное программирование 115
}
print(book1.pages); // 10
Book.bookPages = 20; // меняем значение
var book2 = Book();
print(book2.pages); // 20
Можно вызывать статические методы, обращаясь к ним с помощью имени
класса, без создания самого экземпляра класса, или написать отдельный метод для
экземпляра класса, который будет переадресовывать вызов статическому методу:
// base_url/0/0.8/0.8.2/ex2.dart
class Calc{
static int add(int a, int b){
return a + b;
}
int sum(int a, int b) = > add(a, b);
}
void main(List<String> arguments) {
print(Calc.add(3, 5)); // 8
print(Calc.add(13, 5)); // 18
var calc = Calc();
print(calc.sum(13, 5)); // 18
}
0.8.3. Методы расширения (extension methods)
Иногда, задействуя тот или иной встроенный или пользовательский тип данных,
так и хочется спросить: «Где живет разрабатывавший это человек? Почему он
не добавил таких-то методов?» Не думайте, что только вы задаете такие вопросы,
но та щепотка магии, о которой далее пойдет речь, сгладит острые углы неприятия
сложившейся ситуации.
Как гласит народная мудрость: «Не можешь бороться — возглавь!» Если переложить ее на IT-сленг, то звучать она, скорее всего, будет следующим образом:
«Что-то не нравится — напиши свой велосипедокостыль!» Как раз такой функционал
и предоставляют разработчику методы расширения, обобщенный вид объявления
которых можно представить следующим образом:
extension <ИмяРасширения>? on <тип> {
(<добавляемые методы>)*
}
В качестве примера возьмем встроенный тип данных (он же класс) int и добавим
ему метод умножения на 2 (посредством битовых операций), а также проверку на
то, установлен ли заданный бит в 1:
// base_url/0/0.8/0.8.3/ex1.dart
extension MyInt on int {
int mul2() {
return this<<1;
}
}
bool isSetBit(int bit) {
return (this & (1 << bit)) ! = 0;
}
116 Глава 0 Установка и настройка рабочего окружения. Основы Dart
void main(List<String> arguments) {
var value = 11;
print(value.mul2()); // 22
print(value.isSetBit(2)); // false
print(value.isSetBit(0)); // true
}
0.8.4. Наследование и переопределение методов
В Dart нет множественного наследования, то есть наследовать можно только от
одного базового класса. В то же самое время класс может реализовывать множество
интерфейсов. Для начала рассмотрим классический пример обобщения сотрудников
в организации. Объявим классы, описывающие сантехника и строителя:
// base_url/0/0.8/0.8.4/ex1.dart
class Plumber {
final String name;
final int id;
int _age;
int _salary;
int _yearsExperience;
Plumber(
this.name,
this._age,
this.id,
this._salary,
this._yearsExperience,
);
Plumber.withMinSalary(
this.name,
this._age,
this.id,
this._yearsExperience,
) : _salary = 1000;
int get salary = > _salary;
int get age = > _age;
int get experience = > _yearsExperience;
void ageIncrease() {
_age++;
}
void yearsExperienceIncrease() {
_yearsExperience++;
}
void salaryDown(int percent) {
// штрафуем сотрудника
_salary -= ((_salary / 100) * percent).toInt();
}
void salaryUp(int percent) {
// премируем сотрудника
_salary += ((_salary / 100) * percent).toInt();
}
@override
String toString() {
0.8. Объектно-ориентированное программирование 117
}
}
return 'Plumber($name, $age, $id, $_salary)';
class Builder {
final String name;
final int id;
int _age;
int _salary;
int _yearsExperience;
int _category;
Builder(
this.name,
this._age,
this.id,
this._salary,
this._yearsExperience,
this._category,
);
Builder.withMinSalary(
this.name,
this._age,
this.id,
this._yearsExperience,
this._category,
) : _salary = 1000;
int
int
int
int
get
get
get
get
salary = > _salary;
age = > _age;
experience = > _yearsExperience;
category = > _category;
void ageIncrease() {
_age++;
}
void yearsExperienceIncrease() {
_yearsExperience++;
}
void salaryDown(int percent) {
// штрафуем сотрудника
_salary -= ((_salary / 100) * percent).toInt();
_category--;
}
void salaryUp(int percent) {
// премируем сотрудника
_salary += ((_salary / 100) * percent).toInt();
_category++;
}
}
@override
String toString() {
return 'Builder($name, $age, $id, $_salary, $_category)';
}
К сожалению, если придется увеличивать количество классов работников, такой подход приведет к появлению большого количества дублирующего кода. При
этом возникнет трудность, если мы захотим работать с различными экземплярами
118 Глава 0 Установка и настройка рабочего окружения. Основы Dart
классов сотрудников в рамках одного интерфейса. Поэтому абстрагируемся от
конкретных должностей и введем класс «Сотрудник» (Employee), от которого будет
производиться наследование:
// base_url/0/0.8/0.8.4/ex2.dart
class Employee {
final String name;
final int id;
int _age;
int _salary;
int _yearsExperience;
Employee(
this.name,
this._age,
this.id,
this._salary,
this._yearsExperience,
);
int get salary = > _salary;
int get age = > _age;
int get experience = > _yearsExperience;
void ageIncrease() {
_age++;
}
void yearsExperienceIncrease() {
_yearsExperience++;
}
void salaryDown(int percent) {
// увеличиваем оклад
_salary -= ((_salary / 100) * percent).toInt();
}
void salaryUp(int percent) {
// уменьшаем оклад
_salary += ((_salary / 100) * percent).toInt();
}
}
@override
String toString() {
return 'Employee($name, $age, $id, $_salary)';
}
class Plumber extends Employee { // наследование
Plumber(
String name,
int age,
int id,
int salary,
int yearsExperience,
) : super(name, age, id, salary, yearsExperience);
Plumber.withMinSalary(
String name,
int age,
int id,
int yearsExperience,
) : super(name, age, id, 1000, yearsExperience);
0.8. Объектно-ориентированное программирование 119
}
@override
String toString() {
return 'Plumber($name, $age, $id, $_salary)';
}
class Builder extends Employee { // наследование
int _category;
Builder(
this._category, {
required String name,
required int age,
required int id,
required int salary,
required int yearsExperience,
}) : super(name, age, id, salary, yearsExperience);
Builder.withMinSalary({
required String name,
required int age,
required int id,
required int yearsExperience,
required int category,
}) : _category = category,
super(name, age, id, 3000, yearsExperience);
int get category = > _category;
@override
void salaryDown(int percent) {
// штрафуем сотрудника
super.salaryDown(percent);
_category--;
}
@override
void salaryUp(int percent) {
// премируем сотрудника
super.salaryUp(percent);
_category++;
}
}
@override
String toString() {
return 'Builder($name, $age, $id, $_salary, $_category)';
}
Класс Employee базовый по отношению к Plumber и Builder. А они, в свою очередь,
считаются производными классами от Employee, что задается использованием ключевого слова extends. Поскольку Employee более абстрактно описывает сотрудника, он
задает основные его свойства и поведение (поля и методы). В случае с сантехником
(Plumber) производный класс наследуется от базового и не имеет никаких новых
полей и поведения, отличного от поведения базового класса, а имеет только переопределенный метод toString. В классе, описывающем строителя (Builder), добавилось поле, отвечающее за его категорию, которая уменьшается или увеличивается
в зависимости от того, что происходит с окладом. Поэтому для него обязательно
переопределить методы увеличения и уменьшения оклада, а так как они завязаны на
120 Глава 0 Установка и настройка рабочего окружения. Основы Dart
реализацию базового класса, то она вызывается с помощью ключевого слова super,
которое позволяет работать с поведением и состоянием базового класса.
При создании производного класса нужно вызвать конструктор базового и передать ему необходимые параметры. В нашем случае это делается после двоеточия, где
указывается ключевое слово super и передаются аргументы, ожидаемые на входе
конструктора базового класса.
Начиная с Dart 2.17, благодаря суперпараметрам (https://dart.dev/language/
constructors#super-parameters) можно упростить процесс передачи аргументов для
инициализации базового класса, от которого производилось наследование, и явно
не вызывать конструктор базового класса. Для этого достаточно объявить аргументы
в конструкторе производного класса с помощью ключевого слова super:
Plumber(
super.name,
super.age,
super.id,
super.salary,
super.yearsExperience,
);
Но большей гибкости можно добиться при использовании именованных аргументов в конструкторе производного и базового классов:
// base_url/0/0.8/0.8.4/ex3.dart
class Employee {
final String name;
final int id;
int _age;
int _salary;
int _yearsExperience;
Employee(
this.name,
this._age,
this.id,
this._salary,
this._yearsExperience,
);
Employee.named({
required String name,
required int age,
required int id,
required int salary,
required int yearsExperience,
}) : this(name, age, id, salary, yearsExperience);
}
// остальные методы не изменялись
class Plumber extends Employee {
Plumber(
super.name,
super.age,
super.id,
super.salary,
super.yearsExperience,
);
0.8. Объектно-ориентированное программирование 121
Plumber.withMinSalary({
required super.name,
required super.age,
required super.id,
required super.yearsExperience,
}) : super.named(salary: 1000);
}
// остальные методы не изменялись
class Builder extends Employee {
int _category;
Builder(
super.name,
super.age,
super.id,
super.salary,
super.yearsExperience,
this._category,
);
}
Builder.withMinSalary({
required super.name,
required super.age,
required super.id,
required super.yearsExperience,
required int category,
}) : _category = category,
super.named(salary: 3000);
// остальные методы не изменялись
При таком написании конструкторов важно следовать правилу: сначала с помощью super указываются аргументы базового класса, а потом через this — производного.
Перейдем к созданию экземпляра производного класса, а также разберем, как его
приводить к базовому, чтобы вся последующая работа с ним шла через интерфейс
базового класса:
// base_url/0/0.8/0.8.4/ex3.dart
void main() {
var builder = Builder.withMinSalary(
name: 'Ivan',
age: 30,
id: 1,
yearsExperience: 7,
category: 2,
);
var plumber = Plumber.withMinSalary(
name: 'Max',
age: 22,
id: 4,
yearsExperience: 1,
);
print(builder); // Builder(Ivan, 30, 1, 3000, 2)
print(plumber); // Plumber(Max, 22, 4, 1000)
Employee employee = builder; // неявное приведение к Employee
print(employee); // Builder(Ivan, 30, 1, 3000, 2)
122 Глава 0 Установка и настройка рабочего окружения. Основы Dart
employee.salaryDown(50);
print(builder); // Builder(Ivan, 30, 1, 1500, 1)
// проверка на тип объекта
if (employee is Plumber){
print(employee); // ничего не выведет
}
if (employee is Builder){
// появляется доступ к полям и методам Builder
print(employee.category); // 1
}
employee = plumber as Employee; // явное приведение к Employee
print(employee); // Plumber(Max, 22, 4, 1000)
}
var employee2 = plumber as Employee;
employee2.salaryUp(30);
print(employee2); // Plumber(Max, 22, 4, 1300)
Работа через интерфейс базового класса позволяет скрыть реализацию производ
ного, то есть мы работаем с объектом на более абстрактном уровне, что позволяет,
например, не заводить список типа dynamic или Object для хранения в нем производных классов, а использовать тип базового:
// base_url/0/0.8/0.8.4/ex4.dart
void main() {
var listEmployee = <Employee>[
Builder('Alex', 22, 1, 2000,
Plumber('John', 27, 4, 9000,
Builder('Max', 33, 2, 12000,
Plumber('Kate', 23, 4, 9000,
];
1, 1),
3),
10, 3),
3),
for (var it in listEmployee){
if (it is Plumber){
it.salaryDown(10);
}
if (it is Builder){
it.salaryUp(10);
}
print(it);
}
}
//
//
//
//
Builder(Alex, 22, 1, 2200, 2)
Plumber(John, 27, 4, 8100)
Builder(Max, 33, 2, 13200, 4)
Plumber(Kate, 23, 4, 8100)
Как можно заметить по работе кода со строителями, вне зависимости от того,
что мы привели производный класс к базовому, при повышении или понижении
оклада будет вызываться метод производного класса. Это связано с тем, что его
переопределили, используя аннотацию @override.
Что касается сокрытия свойств и методов производного класса при приведении
к базовому, это можно посмотреть следующим образом. Переместите код с объявлением классов в отдельный файл, импортируйте его, создайте экземпляр класса
Builder, приведите к Employee и попробуйте отыскать доступ к его свойству category
(дисклеймер — его не будет видно) (рис. 0.43).
0.8. Объектно-ориентированное программирование 123
Рис. 0.43. Сокрытие свойств (полей) и методов при приведении
0.8.5. Абстрактный класс и интерфейс
Не всегда базовый класс может содержать реализации всех объявленных в нем
методов. Такие классы представляют собой некоторое обобщение, поэтому часть
реализации их методов может быть отдана на откуп производным классам. То есть
базовый класс можно рассматривать как некоторый обобщенный интерфейс, через который мы в клиентском коде можем работать с экземплярами производных
классов, приводя их к базовому. Это дает нам еще один уровень инкапсуляции.
Так, например, приведя производный класс к базовому, мы можем вызывать только
методы базового класса, так как методы, специфичные для производного класса,
становятся недоступными. Если же нам нужно вызвать один из таких методов производного класса, то сперва необходимо проверить, можно ли текущий экземпляр
класса, с которым идет работа, привести к необходимому производному классу.
Методы, которые объявлены, но не имеют реализации, называются чисто
виртуальными методами, а класс, содержащий хоть один виртуальный метод, —
абстрактным классом. Отличие абстрактного класса от обычного заключается
в том, что экземпляр абстрактного класса не может быть создан (исключение —
фабричный конструктор). Но к таким классам мы можем приводить производные
от них классы. То есть и базовый, и абстрактный класс может выступать в роли
публичного интерфейса к экземплярам производных от них классов.
Перепишем класс Employee таким образом, чтобы он стал абстрактным базовым
классом, передав на откуп производным классам расчет премии для сотрудников:
// base_url/0/0.8/0.8.5/ex1.dart
abstract class Employee {
final String name;
final int id;
int _age;
int _salary;
int _yearsExperience;
// конструкторы класса не изменялись
124 Глава 0 Установка и настройка рабочего окружения. Основы Dart
}
int calculateBonus(); // чисто виртуальный метод
int calculateBonusWithParam(int percent); // аналогично
// остальные методы не изменялись
После объявления Employee абстрактным классом на его производные классы
Plumber и Builder ложится обязанность реализовать методы calculateBonus и calculateBonusWithParam. В противном случае они тоже будут считаться абстрактными:
// base_url/0/0.9/0.9.6/ex0_90.dart
class Plumber extends Employee {
// конструкторы и методы не изменялись
@override
int calculateBonus() {
return ((_salary / 100) * _yearsExperience).toInt();
}
}
@override
int calculateBonusWithParam(int percent) {
return ((_salary / 100) * percent).toInt();
}
class Builder extends Employee {
// конструкторы, поля и методы не изменялись
@override
int calculateBonus() {
return calculateBonusWithParam(50);
}
}
@override
int calculateBonusWithParam(int percent) {
return ((_salary / 100) * percent).toInt();
}
По поводу интерфейса принято говорить: «Класс реализует такой-то интерфейс». Его отличие от абстрактного или обычного класса заключается в том, что
определенные в нем состояние и поведение также должны быть реализованы в том
классе, который его реализует. В случае наследования нам не надо было заново
прописывать в производном все переменные и методы базового класса — только
те, которые мы хотели переопределить. Здесь же все наоборот. Все переменные
и методы, объявленные в интерфейсе, должны быть объявлены и реализованы в том
классе, который этот интерфейс реализует.
Таким образом, если в случае с наследованием мы наследуем состояние и поведение, то в случае с интерфейсом — объявляем контракт, которому должен следовать
класс, реализующий интерфейс.
Каждый класс в Dart неявно определяет интерфейс, и, когда нам необходимо,
чтобы разрабатываемый класс А поддерживал API другого класса, В, не наследуя
его реализацию, класс A должен реализовать интерфейс B.
В отличие от наследования класс может реализовывать сколько угодно интерфейсов. Для указания того, что текущий класс реализует интерфейс другого класса,
используется ключевое слово implements.
0.8. Объектно-ориентированное программирование 125
В качестве примера рассмотрим ситуацию: у нас есть коробка для хранения вещей и шкаф. Оба этих объекта представляют собой систему хранения. Мы можем
добавлять в них вещи, забирать последнюю добавленную вещь и подсчитывать
общий вес вещей, которые в них хранятся. Поскольку это объекты из различных
предметных областей, то они не будут наследоваться от базового абстрактного
класса StorageSystem, но будут реализовывать его интерфейс:
// base_url/0/0.8/0.8.5/ex2.dart
class Item{
final String name;
final double weight;
}
Item(this.name, this.weight) ;
abstract class StorageSystem {
void addItem(Item item);
Item popItem();
}
double systemWeight();
class Box implements StorageSystem {
var itemsList = <Item>[];
final double weightLimit;
Box(this.weightLimit);
@override
void addItem(Item item) {
var currentSystemWeight = systemWeight();
if((currentSystemWeight+item.weight) < weightLimit){
itemsList.add(item);
print('${item.name} добавлен(о/а) в коробку!');
}
else{
print('${item.name} не помещается в коробку!');
}
}
@override
Item popItem() {
return itemsList.removeLast();
}
}
@override
double systemWeight() {
var sum = 0.0;
for (var element in itemsList) {
sum += element.weight;
}
return sum;
}
// методы, характерные для коробки
class Cupboard implements StorageSystem {
var itemsList = <Item>[];
126 Глава 0 Установка и настройка рабочего окружения. Основы Dart
@override
void addItem(Item item) {
itemsList.add(item);
print('${item.name} добавлен(о/а) в шкаф!');
}
@override
Item popItem() {
return itemsList.removeLast();
}
}
@override
double systemWeight() {
var sum = 0.0;
for (var element in itemsList) {
sum += element.weight;
}
return sum;
}
// методы, характерные для шкафа
void main(List<String> arguments) {
var box = Box(18);
var cupboard = Cupboard();
StorageSystem? storageSystem = box;
storageSystem.addItem(Item('Книга', 2.6));
storageSystem.addItem(Item('Чайник', 3.9));
storageSystem.addItem(Item('Гантель', 10));
storageSystem.addItem(Item('Монитор', 4));
print(storageSystem.popItem().name);
print(storageSystem.systemWeight());
storageSystem = cupboard;
print(storageSystem.systemWeight());
storageSystem.addItem(Item('Монитор', 4));
storageSystem.addItem(Item('Чайник', 3.9));
print(storageSystem.systemWeight());
}
/* Книга добавлен(о/а) в коробку!
Чайник добавлен(о/а) в коробку!
Гантель добавлен(о/а) в коробку!
Монитор не помещается в коробку!
Гантель
6.5
0.0
Монитор добавлен(о/а) в шкаф!
Чайник добавлен(о/а) в шкаф!
7.9 */
Как видно из примера, посредством приведения к интерфейсу мы можем работать
с любой системой хранения, которая реализует этот интерфейс, и нам не надо прописывать в функции main код для добавления, изъятия или подсчета веса хранимых
вещей для каждого из классов, реализующих этот интерфейс. Достаточно привести
экземпляр класса к интерфейсу и работать через него. Таким образом мы можем
довольно легко переключаться между шкафом и коробкой, и если добавится еще
один объект, реализующий интерфейс системы хранения, не возникнет никаких
трудностей при взаимодействии с ним из клиентского кода.
0.8. Объектно-ориентированное программирование 127
0.8.6. Модификаторы класса
До Dart 3 у класса был всего один модификатор — abstract. Его использовали для
объявления как базового абстрактного класса, так и интерфейса. Но этого разработчикам языка программирования показалось мало, и они добавили еще пять.
Аргументировалось это подготовкой к будущему обновлению Dart и стабилизацией
API библиотек. А поскольку библиотекой в этом языке программирования считается
каждый импортируемый файл, повеселились они знатно…
Далее приведен список существующих на данный момент модификаторов
классов:
y abstract;
y base;
y interface;
y final;
y sealed;
y mixin (как ключевое слово было в Dart, но не как модификатор).
Что важно знать о них? Модификаторы накладывают ограничения только за
пределами библиотеки (файла с расширением .dart), тогда как в рамках самой
библиотеки (файла) программист может делать все что угодно.
И чтобы все не было настолько просто, разработчики Dart решили подкинуть
дровишек разрешили комбинировать модификаторы и представили сообществу
табл. 0.12.
Таблица 0.12. Комбинация модификаторов класса
Объявление
class
base class
interface class
final class
sealed class
abstract class
abstract base class
abstract interface class
abstract final class
mixin class
base mixin class
abstract mixin class
abstract base mixin class
mixin
base mixin
Construct?
Yes
Yes
Yes
Yes
No
No
No
No
No
Yes
Yes
No
No
No
No
Extend?
Yes
Yes
No
No
No
Yes
Yes
No
No
Yes
Yes
Yes
Yes
No
No
Implement?
Yes
No
Yes
No
No
Yes
No
Yes
No
Yes
No
Yes
No
Yes
No
Mix in?
No
No
No
No
No
No
No
No
No
Yes
Yes
Yes
Yes
Yes
Yes
Exhaustive?
No
No
No
No
Yes
No
No
No
No
No
No
No
No
No
No
128 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Графа Construct сигнализирует нам о том, можно ли создать экземпляр класса,
с помощью простого конструктора (не фабричного), Extend — можно ли от класса
с такой комбинацией модификаторов наследовать, Implement — поддерживает ли
класс его использование в качестве интерфейса, Mix in — можно ли создавать примеси (миксины, mixin) на основе класса, Exhaustive (исчерпываемость) — поддерживает ли класс создание перечисляемого набора подтипов.
В общей сложности 15 комбинаций стрельбы в ногу! Но радостная новость заключается в том, что большинство их ориентировано на разработку собственных
пакетов, чтобы ограничить раздолье для программистов, которые будут их использовать. К тому же для разработки приложения не обязательно вникать в подноготную
каждого из модификаторов, вполне хватит abstract, sealed и mixin.
0.8.7. Запечатанные (sealed) классы
Такой вид классов не является каким-то ноу-хау Dart, они поддерживаются различными языками программирования, например C# и Kotlin. Нельзя наследовать
от классов с таким модификатором за пределами его библиотеки (файла) или использовать их как интерфейсные. Еще одной чертой sealed-классов является запрет
на создание их экземпляров. То есть экземпляр такого класса может быть создан
только с помощью фабричного конструктора.
Поскольку класс, объявленный с модификатором sealed, считается исчерпыва
ющим, то экземпляры производных от него классов можно задействовать в конструкции switch-case. Поэтому производные классы должны быть определены в одной
библиотеке с базовым. Данное ограничение позволяет Dart знать обо всех производных
классах, наследующихся от sealed-класса, и требовать их присутствия в switch-case.
Сначала мы рассмотрим простые варианты реализации sealed-классов и только потом перейдем к примерам с щепоткой «уличной магии». Давайте вернемся
к концепции кошелька и первым делом объявим sealed-класс Money и его производные классы:
sealed class Money {}
class RUB extends Money {}
class USD extends Money {}
class EUR extends Money {}
Если попытаться привычным способом создать экземпляр класса Money, Dart
не даст этого сделать:
void main() {
var money = Money();
}
// Error: The class 'Money' is abstract
// and can't be instantiated.
Но это не распространяется на производные классы:
void main() {
var money = RUB();
print(money); // Instance of 'RUB'
}
Теперь попробуем добавить конструкцию
тип входной валюты:
switch-case
и вывести в терминал
0.8. Объектно-ориентированное программирование 129
void main() {
Money money = RUB();
switch (money) {
case RUB():
print('RUB');
case USD():
print('USD');
}
}
// Error: The type 'Money' is not exhaustively matched
// by the switch cases since it doesn't match 'EUR()'
Поскольку мы не указали все производные классы от Money, это привело к ошибке
времени компиляции. Для начала исправим этот недочет:
// base_url/0/0.8/0.8.7/ex1.dart
void main() {
Money money = RUB();
switch (money) {
case RUB():
print('RUB'); // RUB
case USD():
print('USD');
case EUR():
print('EUR');
}
}
Модификатор sealed накладывает на нас ограничение, заставляя писать код
в одном файле. Когда его немного, это не проблема, но при увеличении количества
производных классов и их функционала, особенно когда один из производных
классов тоже помечается как sealed и может иметь свою цепочку полноты (производных классов), такую библиотеку будет сложно поддерживать. А у новичка
в проекте при первом взгляде на нее волосы встанут дыбом до такой степени,
что неудобно будет сидеть на стуле… Здесь нам на помощь приходит еще одна
фишка Dart для работы с библиотеками, которая была придержана до текущего
момента, — разделять библиотеку на несколько файлов, используя ключевое
слово part.
Создайте новый консольный проект sealed_example со следующей структурой
папок и файлов:
sealed_example
├── bin/
│
└── sealed_example.dart
├── lib/
│
└── src/
│
│
├── eur.dart
│
│
├── money.dart
│
│
├── rub.dart
│
│
└── usd.dart
│
└── sealed_example.dart
├── test/
└── pubspec.yaml
В файл
money.dart:
sealed_example.dart
папки lib добавьте строчку экспорта только для
export 'src/money.dart';
130 Глава 0 Установка и настройка рабочего окружения. Основы Dart
В money.dart объявим класс Money и укажем в качестве приватных частей этой
библиотеки остальные файлы каталога src:
part 'eur.dart';
part 'usd.dart';
part 'rub.dart';
sealed class Money {
late final int _val;
Money(this._val);
factory Money.fromStr(String currency, String value) {
var money = (double.parse(value) * 100).toStringAsFixed(0);
switch (currency.toLowerCase()) {
case 'rub':
return RUB(int.parse(money));
case 'usd':
return USD(int.parse(money));
case 'eur':
return EUR(int.parse(money));
}
throw UnsupportedError('Неподдерживаемая валюта');
}
}
int get value = > _val;
Money operator +(Money other);
Откройте файл usd.dart, объявив в нем одноименный класс и указав, что этот
файл является частью money.dart:
part of 'money.dart';
class USD extends Money {
USD(super.val);
factory USD.fromStr(String value) {
var usd = (double.parse(value) * 100).toStringAsFixed(0);
return USD(int.parse(usd));
}
@override
USD operator +(Money other) {
if (other is RUB) {
return USD(value + other.value ~/ 100);
} else {
return USD(value + other.value);
}
}
}
@override
String toString() {
var usd = (value / 100).toStringAsFixed(2);
return 'USD($usd)';
}
Проделаем те же самые действия для файла rub.dart:
part of 'money.dart';
class RUB extends Money {
RUB(super.val);
0.8. Объектно-ориентированное программирование 131
factory RUB.fromStr(String value) {
var rub = (double.parse(value) * 100).toStringAsFixed(0);
return RUB(int.parse(rub));
}
@override
RUB operator +(Money other) {
return RUB(value + other.value);
}
}
@override
String toString() {
var rub = (value / 100).toStringAsFixed(2);
return 'RUB($rub)';
}
И для файла eur.dart из каталога src:
part of 'money.dart';
class EUR extends Money {
EUR(super.val);
factory EUR.fromStr(String value) {
var eur = (double.parse(value) * 100).toStringAsFixed(0);
return EUR(int.parse(eur));
}
@override
EUR operator +(Money other) {
return EUR(value + other.value);
}
}
@override
String toString() {
var eur = (value / 100).toStringAsFixed(2);
return 'EUR($eur)';
}
В money.dart мы объявили, что часть его функционала будет вынесена в другие
файлы, указанные после ключевого слова part. А эти файлы связали с основным
с помощью part of 'money.dart'.
Теперь перейдем в sealed_example.dart в папке bin и объявим в нем функцию
addMoney:
import 'package:sealed_example/sealed_example.dart';
void addMoney((Money, Money) money) {
switch (money) {
case (RUB(value: > 30000), RUB(value: < = 500)):
print(money.$1 + money.$2);
case (USD(), USD(value: < = 300)):
print(money.$1 + money.$2);
case (EUR(value: > 20000), EUR()):
print(money.$1 + money.$2);
case (RUB(value: > 50000), USD()):
print(money.$2 + money.$1);
case _:
print('(╯'□')╯︵ ┻━┻');
}
}
132 Глава 0 Установка и настройка рабочего окружения. Основы Dart
void main(List<String> arguments) {
addMoney(
(Money.fromStr('rub', '200'), Money.fromStr('rub', '100'))
);
addMoney(
(Money.fromStr('usd', '200'), Money.fromStr('usd', '3'))
);
addMoney(
(Money.fromStr('eur', '200'), Money.fromStr('eur', '100'))
);
addMoney(
(Money.fromStr('rub', '2000'), Money.fromStr('usd', '10'))
);
addMoney(
(Money.fromStr('eur', '200'), Money.fromStr('rub', '100'))
);
}
После запуска приложения в терминале должен получиться следующий результат:
(╯'□')╯︵ ┻━┻
USD(203.00)
(╯'□')╯︵ ┻━┻
USD(30.00)
(╯'□')╯︵ ┻━┻
Таким образом, модификатор sealed в основном влияет на то, как можно использовать базовый тип. Он не накладывает особых ограничений на производные
классы, за исключением того, что они должны быть определены в одной библиотеке.
0.8.8. Миксины и модификатор класса mixin
Поскольку в Dart отсутствует множественное наследование, он предоставляет
ряд механизмов расширения возможностей классов. Одним из них и являются
миксины (примеси). Они позволяют подмешать к классу новые методы или поля,
не переопределяя их.
Представим, что у нас есть два класса животных, каждый из которых обладает
собственным набором поведения. Самое первое, что придет на ум, — выделить
общее состояние и поведение в базовый класс. Пока в системе два класса животных,
все работает отлично, но вот нам понадобился класс птиц. Казалось бы, у базового
класса животных и птиц много общего, но вот летать животные не умеют, хотя
так же бегают, едят, купаются и т. д., у них имеется атрибут возраста и названия
вида, к которому принадлежат. Очевидно, что хочется найти наиболее эффективную
структуру для написания этих классов с максимальной возможностью повторного
использования кода. Здесь на помощь и приходят примеси, поскольку они позволяют вставлять блоки кода в класс, не создавая производного класса.
Для начала объявим миксин, который можно будет использовать и для класса
денежной валюты, и для банковской ячейки:
mixin Inflation{
int pecent = 14;
RUB inflation(RUB rub){
return switch(pecent){
0.8. Объектно-ориентированное программирование 133
! = 0 = > RUB.kopek(
rub.value - (rub.value * pecent / 100).ceil(),
),
_ = > RUB.kopek(rub.value),
}
}
};
Чтобы подмешать объявленный миксин Inflation к классу RUB, после его объявления используйте ключевое слово with:
// base_url/0/0.8/0.8.8/ex1.dart
class RUB with Inflation {
int _val;
RUB._(this._val);
factory RUB.fromStr(String value) {
var rub = (double.parse(value) * 100).toStringAsFixed(0);
return RUB._(int.parse(rub));
}
RUB.kopek(this._val);
int get value = > _val;
RUB operator +(RUB other) {
return RUB._(value + other.value);
}
}
@override
String toString() {
var rub = (value / 100).toStringAsFixed(2);
return 'RUB($rub)';
}
void main() {
RUB rub = RUB.fromStr('100');
print(rub.inflation(rub)); // RUB(86.00)
}
Пока что пользоваться миксином не слишком удобно, но у класса появилась
необходимая функциональность, позволяющая понять, насколько обесценились
100 рублей при инфляции 14 %. Если хотите регулировать данный процент при
создании экземпляра класса рубля, это можно сделать посредством его конструктора или метода:
class RUB with Inflation {
int _val;
RUB._(this._val, [inflationPecent = 14]){
pecent = inflationPecent; // обновляем значения у поля примеси
}
factory RUB.fromStr(String value, [inflationPecent = 14]) {
var rub = (double.parse(value) * 100).toStringAsFixed(0);
return RUB._(int.parse(rub), inflationPecent);
}
}
RUB.kopek(this._val, [inflationPecent = 14]) {
pecent = inflationPecent;
}
// остальной код не изменился
134 Глава 0 Установка и настройка рабочего окружения. Основы Dart
void main() {
RUB rub = RUB.fromStr('100', 55);
print(rub.inflation(rub)); // RUB(45.00)
}
Экземпляр миксина нельзя создать, но к нему можно привести созданный с его
помощью экземпляр класса рубля. То есть Dart позволяет использовать миксины
как интерфейс:
void main() {
// var infl = Inflation(); // error
Inflation infl = RUB.fromStr('100', 55);
print(infl.inflation(RUB.fromStr('100'))); // RUB(45.00)
}
А такое поведение дает нам возможность при объявлении миксина указать его
методы либо часть из них как чисто виртуальные, тем самым возложив обязанность
по их реализации на класс, использующий этот миксин:
// base_url/0/0.8/0.8.8/ex2.dart
mixin Inflation {
int pecent = 14;
}
RUB inflation([RUB? rub]);
class RUB with Inflation {
int _val;
RUB._(this._val, [inflationPecent = 14]) {
pecent = inflationPecent;
}
factory RUB.fromStr(String value, [inflationPecent = 14]) {
var rub = (double.parse(value) * 100).toStringAsFixed(0);
return RUB._(int.parse(rub), inflationPecent);
}
RUB.kopek(this._val, [inflationPecent = 14]) {
pecent = inflationPecent;
}
int get value = > _val;
RUB operator +(RUB other) {
return RUB._(value + other.value);
}
@override
String toString() {
var rub = (value / 100).toStringAsFixed(2);
return 'RUB($rub)';
}
@override
RUB inflation([RUB? rub]) {
if (rub = = null) {
return switch (pecent) {
! = 0 = > RUB.kopek(value - (value * pecent / 100).ceil()),
_ = > RUB.kopek(value),
};
0.8. Объектно-ориентированное программирование 135
}
}
}else{
return switch (pecent) {
! = 0 = > RUB.kopek(
rub.value - (rub.value * pecent / 100).ceil()
),
_ = > RUB.kopek(rub.value),
};
}
void main() {
var rub = RUB.fromStr('100', 55);
print(rub.inflation()); // RUB(45.00)
print(rub.inflation(RUB.fromStr('200'))); // RUB(90.00)
}
Таким образом, в классе мы можем переопределять объявленные в миксине
методы, что не только дает бо́льшую гибкость, но и создает проблемы, особенно
если при переопределении в код закрадется ошибка. Но будьте осторожны: наличие
в миксинах одноименных методов приведет к затиранию реализации одного из них!
Если точнее, метод последнего миксина затрет методы предыдущих.
Начиная с Dart 3, обычные классы запрещено задействовать в качестве миксинов. Теперь, если вы хотите использовать для этих целей объявляемый класс, его
надо пометить модификатором mixin. Экземпляры таких классов можно создавать
только конструктором по умолчанию (то есть поля класса должны быть явно проинициализированы при его объявлении), можно наследовать от них, а также использовать их в качестве интерфейсных. Но это, признаюсь, то еще удовольствие…
К тому же классы с данным модификатором не могут быть производными классами и их нельзя использовать с ключевым словом on:
// base_url/0/0.8/0.8.8/ex3.dart
mixin class Inflation{
int pecent = 14;
// Inflation(this.pecent); // error
// The class 'Inflation' can't be used as a mixin
// because it declares a constructor.
}
RUB inflation(RUB rub){
return switch(pecent){
! = 0 = > RUB.kopek(
rub.value - (rub.value * pecent / 100).ceil(),
),
_ = > RUB.kopek(rub.value),
};
}
class RUB with Inflation { // или extends Inflation
int _val;
RUB._(this._val, [inflationPecent = 14]){
pecent = inflationPecent;
}
factory RUB.fromStr(String value, [inflationPecent = 14]) {
var rub = (double.parse(value) * 100).toStringAsFixed(0);
136 Глава 0 Установка и настройка рабочего окружения. Основы Dart
}
return RUB._(int.parse(rub), inflationPecent);
RUB.kopek(this._val, [inflationPecent = 14]) {
pecent = inflationPecent;
}
int get value = > _val;
}
@override
String toString() {
var rub = (value / 100).toStringAsFixed(2);
return 'RUB($rub)';
}
void main() {
var infl = Inflation();
RUB rub = RUB.fromStr('100', 55);
print(rub.inflation(rub)); // RUB(45.00)
print(infl.inflation(rub)); // RUB(86.00)
}
0.8.9. Enum (перечисления)
Перечисления представляют собой особый вид классов, используемых для пред
ставления фиксированного числа постоянных значений. Чтобы его создать,
достаточно взять ключевое слово enum. Каждому значению в перечислении соответствует свой целочисленный индекс, например, первое значение имеет индекс 0,
второе — индекс 1 и т. д.:
// base_url/0/0.8/0.8.9/ex1.dart
enum State { none, open, close, lock}
void main(List<String> arguments) {
print(State.none.index = = 0); // true
print(State.open.index = = 1); // true
// формируем список значений перечисления
var listEnums = State.values;
for (var element in listEnums) {
print('${element.index} = > ${element.toString()}');
}
}
/*
0 = > State.none
1 = > State.open
2 = > State.close
3 = > State.lock */
Чаще всего перечисления используют для создания машины состояний и выбора потока управления выполнения кода в приложении посредством оператора
swith-case либо когда необходимо, чтобы одно из состояний объекта могло меняться
только в четко определенном диапазоне возможных состояний.
Перечисления в Dart имеют ряд ограничений:
y нельзя создавать подклассы, примеси, объявления и реализации методов
перечисления;
y нельзя создать экземпляр перечисления.
0.8. Объектно-ориентированное программирование 137
С версии Dart 2.17 появилась возможность задавать расширенные перечисления в стиле классов — с полями, методами и константными конструкторами. Для
примера реализуем перечисление по странам, добавив поля с данными по ВВП,
госдолгу и количеству населения:
// base_url/0/0.8/0.8.9/ex2.dart
enum Country {
Russia(GDP: 5327, nationalDebt: 200.5, population: 144713314),
China(GDP: 30327, nationalDebt: 14000.1,
population: 1425887337),
USA(GDP: 25463, nationalDebt: 33400.0, population: 338289857),
India(GDP: 11875, nationalDebt: 600.7, population: 1417173173);
const Country({
required this.GDP,
required this.nationalDebt,
required this.population,
});
final int GDP; // ВВП, млрд долларов
final double nationalDebt; // млрд долларов
final int population; // млн человек
double get GDP2debt = > (nationalDebt / GDP);
double get deptOneMan = > nationalDebt / population;
}
@override
String toString() {
return '${this.name}($GDP, $nationalDebt, $population)';
}
(String, int) whatCountry(Country country) {
return switch (country) {
Country.Russia = > (
'ε(´。•᎑•`)っ 💕 ${country.name}', country.index,
),
Country.China = > (
'😎 ${country.name}', country.index,
),
Country.USA = > (
'¯\\_(ツ)_/¯ ${country.name}', country.index,
),
Country.India = > (
'^_^ ${country.name}', country.index,
),
};
}
void main() {
var russia = Country.Russia;
print(russia); // Russia(5327, 200.5, 144713314)
print(russia.GDP); // 5327
print(russia.nationalDebt); // 200.5
print(russia.population); // 144713314
print(russia.GDP2debt); // 0.03763844565421438
print(russia.deptOneMan); // 0.0000013854979507967042
}
print(whatCountry(russia)); // (ε(´。•᎑•`)っ 💕 Russia, 0)
print(whatCountry(Country.India)); // (^_^ India, 3)
print(whatCountry(Country.China)); // (😎 China, 1)
print(whatCountry(Country.USA)); // (¯\\_(ツ)_/¯ USA, 2)
138 Глава 0 Установка и настройка рабочего окружения. Основы Dart
0.9. Exceptions (исключения)
В процессе работы приложения могут возникать непредвиденные ситуации: обращение по несуществующему индексу списка, деление на ноль и т. д., способные
привести к экстренному завершению программы. Исключения позволяют в случае
ошибки в вычислениях или логике работы программы сразу же перейти к ее обработке, отменяя при этом все вызовы функций (методов), которые начались до того,
как был выполнен вход в данный обработчик. Поэтому механизм обработки исключений можно рассматривать как некий структурированный безусловный переход.
Несмотря на то что исключения можно перехватывать и обрабатывать, тем самым
сохраняя программу в рабочем состоянии, в ряде случаев этого делать не стоит.
Лучше пусть программа «упадет» и укажет вам на наличие ошибок, чем продолжит
работать с некорректными данными. В первую очередь не стоит перехватывать
для обработки исключений класс Exception, так как он является базовым классом
для всех исключений, то есть какое бы ни сгенерировалось исключение, оно будет
обработано, и если в блоке обработки не предусмотрена повторная генерация исключения, чтобы предупредить более верхний уровень приложения, вы можете
и не узнать о наличии ошибки.
Для работы с исключениями в Dart используются классы Exception и Error,
а также множество их производных классов. Сказать честно… хватило бы и одного,
а наличие двух классов приводит к неприятным моментам. Но тут уж ничего не поделать и придется страдать привыкнуть.
0.9.1. Конструкция try…catch…finally
Общий вид данной конструкции можно записать следующим образом:
try{
// блок кода, который может генерировать исключение
}
catch(e){
// блок обработки исключения
}
finally{
// блок кода, который выполнится в любом случае,
// как при отсутствии исключения,
// так и после его обработки
}
Та часть кода, в которой может быть сгенерировано исключение, помещается
в блок try. Если в ходе выполнения кода, помещенного в блок try, было сгенерировано исключение, оно может быть перехвачено и обработано в блоке catch. При этом
мы можем перехватывать как высокоуровневое исключение (базовое Exception),
так и специализированное, явно задавая тип перехватываемого исключения. Блок
finally выполнится в любом случае, даже если будет сгенерировано исключение.
Обычно в него помещают код, где завершается работа с файлом, сокетом и прочими объектами. Такой подход гарантирует, что и при отсутствии, и при наличии
исключения мы завершим работу с объектом, источником информации и т. д.
Представим ситуацию: в разрабатываемом нами приложении осуществляется
целочисленное деление на значение, которое ввел пользователь, а мы на этапе ввода
0.9. Exceptions (исключения) 139
забыли проверить, чтобы оно не равнялось нулю. В этом случае в процессе работы приложения будет сгенерировано исключение IntegerDivisionByZeroException и приложение прекратит свою работу. Следует отметить, что использовать данное исключение
не рекомендуется, так как оно отмечено как deprecated (устаревшее) и скоро исчезнет
из Dart. Поэтому вместо него следует явно перехватывать ошибку UnsupportedError:
// base_url/0/0.9/ex1.dart
void main(List<String> arguments) {
var inputValue = 0;
var resultValue = 0;
var scalingValue = 100;
// 1
try{
resultValue = scalingValue ~/ inputValue;
}
catch(e){
// перехват всех исключений и ошибок
print('Произошло деление на ноль!!!');
print(e);
}
// Произошло деление на ноль!!!
// IntegerDivisionByZeroException
//2
try{
resultValue = scalingValue ~/ inputValue;
} on UnsupportedError {
// перехват специализированного исключения
print('Произошло деление на ноль!!!');
}
// Произошло деление на ноль!!!
//3
try{
resultValue = scalingValue ~/ inputValue;
}on Exception catch (e) {
// Перехватывает все исключения
print('Сгенерированное исключение: $e');
} catch (e) {
// Перехватывает вообще все:
// и исключения, и ошибки
print('Что-то из ряда вон выходящее: $e');
}
// Сгенерированное исключение: IntegerDivisionByZeroException
//4
try{
resultValue = scalingValue ~/ inputValue;
}on UnsupportedError {
// перехват специализированного исключения
print('Произошло деление на ноль!!!');
}
finally{
print('Что бы ни произошло, я — великолепен!!!');
}
// Произошло деление на ноль!!!
// Что бы ни произошло, я — великолепен!!!
}
Как видно из примера, мы можем перехватывать одно исключение (ошибку),
несколько или сразу все. А если сгенерированное исключение не соответствует
140 Глава 0 Установка и настройка рабочего окружения. Основы Dart
ни одному из перехватываемых, то оно распространится после завершения блока finally:
// base_url/0/0.9/ex2.dart
void main(List<String> arguments) {
var inputValue = 0;
var scalingValue = 100;
try{
var resultValue = scalingValue ~/ inputValue;
}
finally{
print('Что бы ни произошло, я — великолепен!!!');
}
}
/* Что бы ни произошло, я — великолепен!!!
Unhandled exception:
IntegerDivisionByZeroException */
0.9.2. Генерация исключений и ошибок
В тех случаях, когда нам самим необходимо генерировать сообщения об ошибках
или исключения, следует использовать ключевое слово throw:
// base_url/0/0.9/ex3.dart
void main(List<String> arguments) {
var inputValue = 0;
var resultValue = 0;
var scalingValue = 100;
try{
if (inputValue = = 0){
throw UnsupportedError('Oo');
}
resultValue = scalingValue ~/ inputValue;
}on UnsupportedError{
print('Произошло деление на ноль!!!');
}
catch(e){
print('Ошибка: $e');
}
// Произошло деление на ноль!!!
}
try{
if (inputValue = = 0){
throw ArgumentError();
}
resultValue = scalingValue ~/ inputValue;
}on UnsupportedError{
print('Произошло деление на ноль!!!');
}
catch(e){
print('Ошибка: $e');
}
//Ошибка: Invalid argument(s)
Если генерируемое исключение не может быть обработано на данном уровне,
может быть обработано частично или нам необходимо оповестить более верхний
уровень приложения о сгенерированном исключении, его можно распространить
за текущий блок обработки, используя ключевое слово rethrow:
0.9. Exceptions (исключения) 141
// base_url/0/0.9/ex4.dart
void main(List<String> arguments) {
var inputValue = 0;
var resultValue = 0;
var scalingValue = 100;
try{
resultValue = scalingValue ~/ inputValue;
}on UnsupportedError{
print('Произошло деление на ноль!!!');
rethrow;
} on ArgumentError catch(e){ print('Ошибка: $e');
}
/* Что бы ни произошло, я — великолепен!!!
Unhandled exception:
IntegerDivisionByZeroException */
}
У разработчиков также имеется возможность перехватывать и обрабатывать
ошибки по их типу, как и в случае с исключениями:
// base_url/0/0.9/ex5.dart
try{
if (inputValue = = 0){
throw ArgumentError();
}
}on UnsupportedError {
print('Произошло деление на ноль!!!');
rethrow;
} on ArgumentError catch(e){
print('Ошибка: $e');
// Ошибка: Invalid argument(s)
}
0.9.3. Пользовательские исключения и ошибки
Чтобы реализовать ваше собственное исключение и иметь возможность его генерировать в процессе работы приложения, пользовательский класс должен реализовывать интерфейс базового класса для всех исключений — Exception, а в случае
создания пользовательской ошибки необходимо наследовать от Error:
// base_url/0/0.9/ex6.dart
class MyException implements Exception {
final String? msg;
const MyException([this.msg]);
}
@override
String toString() = > msg ?? 'MyException';
class MyError extends Error {
final String? msg;
MyError([this.msg]);
}
@override
String toString() = > msg ?? 'MyException';
class MyNewError extends Error {}
void main(List<String> arguments) {
try{
throw MyException('Пользовательское исключение');
142 Глава 0 Установка и настройка рабочего окружения. Основы Dart
} on MyException catch(e){
print(e); // Пользовательское исключение
}
try{
throw MyError('Пользовательская ошибка');
} on MyError catch(e){
print(e); // Пользовательская ошибка
}
}
try{
throw MyNewError();
} on MyNewError catch(e){
print(e); // Instance of 'MyNewError'
}
0.9.4. Assert (утверждение)
Одно из средств отладки логики работы приложения в процессе его разработки —
assert (утверждения). Они используются для того, чтобы прервать выполнение программы, если утверждение ложно, и сообщить о наличии проблемы разработчику.
Общая структура утверждения может быть представлена следующим образом:
assert (условие, опциональноеСообщение);
Для начала посмотрим, как использовать утверждение без прикрепленного к нему
опционального сообщения и что при этом выводится, когда условие в утверждении
возвращает значение false:
// base_url/0/0.9/ex7.dart
int myFunc(int a, int b){
assert(b ! = 0);
return a ~/ b;
}
void main(List<String> arguments) {
print(myFunc(6, 0));
}
//Unhandled exception:
// … : Failed assertion: line 2 pos 10: 'b ! = 0': is not true.
Теперь добавим сообщение, которое внесет дополнительную ясность в то, с чем
связано «падение» программы:
// base_url/0/0.9/ex8.dart
int myFunc(int a, int b){
assert(b ! = 0, 'Деление на ноль');
return a ~/ b;
}
void main(List<String> arguments) {
print(myFunc(6, 0));
}
/*
Unhandled exception:
'…: Failed assertion: line 2 pos 10: 'b ! = 0': Деление на ноль */
Первым аргументом assert может быть любое выражение, которое возвращает
логическое значение true или false. Если результат вычисления выражения true,
0.10. Асинхронное программирование и изоляты 143
утверждение завершается успешно и управление переходит к коду, находящемуся
за ним, если false, то утверждение генерирует ошибку AssertionError.
Утверждения удобно использовать в процессе разработки программных продуктов. Но необходимо быть внимательными и не помещать в них в качестве выражений различные функции, возвращающие значение логического типа данных,
не использовать в них вызовы методов экземпляров классов и т. д. Это связано
с тем, что при release-сборке проекта все утверждения в коде игнорируются, то есть
не выполняются.
0.10. Асинхронное программирование и изоляты
Dart — «однопоточный» язык программирования, но это совсем не значит, что у вас
нет возможности писать код, который будет выполняться параллельно. Для этих
случаев используются Isolate. Если сравнивать концепцию Isolate с инструментами
для параллельного программирования в других языках, то ближе всего будет такое
понятие, как процесс. Это связано с тем, что каждый Isolate работает со своими
областью памяти, циклом и очередью событий и может обмениваться с другими
Isolatе данными посредством сообщений. Основной поток выполнения программы
на Dart также представляет собой Isolate.
Весь код на Dart выполняется последовательно, то есть за раз — одна операция.
Таким образом, если в основном коде приложения вызвать выполнение функции,
то управление перейдет к ней и придется дожидаться, пока она не вернет результат
(завершит возлагаемую на нее работу). А если в функции будет выполняться довольно большой объем вычислений, то, с точки зрения пользователя, ваша программа
зависнет. Такое поведение приложения указывает на неправильное использование
процессорного времени. Что уж говорить о негодовании пользователя, когда практически в каждом компьютере установлены многоядерные процессоры.
Асинхронное программирование позволяет отложить выполнение функции,
не останавливая выполнения основного кода, и задать функцию, которая обработает возвращаемый ей результат. То есть асинхронная функция будет выполнена
конкурентно немного позже, в освободившееся процессорное время. Но необходимо
понимать следующее: выполнится она все в том же основном потоке приложения. Поэтому не следует в функциях, объявляемых как асинхронные, производить
сложные вычисления, а тем более прописывать вечные циклы.
Перед тем как перейти к изучению инструментов асинхронного программирования в Dart, рассмотрим, что такое цикл событий (event loop). Это позволит писать
более качественный асинхронный (конкурентный) код, в котором вас будет поджидать куда меньшее количество сюрпризов, чем в том случае, когда вы не имеете
никакого представления о Event Loop.
0.10.1. Базовая концепция Event Loop-архитектуры в Dart
В каждом приложении с графическим пользовательским интерфейсом (GUI) реализована концепция цикла событий и очереди событий. Именно они гарантируют,
что любые графические операции и события (движение или щелчки кнопкой мыши,
нажатие клавиш и т. д.) обрабатываются по очереди. То есть каждый попадающий
144 Глава 0 Установка и настройка рабочего окружения. Основы Dart
в очередь событий элемент берется оттуда циклом событий, после чего обрабатывается, и так до тех пор, пока в очереди есть элементы, которые представляют собой
операции ввода/вывода, таймеры и т. д. Рассмотрим очередь событий, содержащую
события таймера и ввода данных пользователем (рис. 0.44).
Рис. 0.44. Концепция обработки события из очереди циклом событий
В рамках Dart концепция обработки очереди событий может быть представлена
следующим образом (рис. 0.45).
Рис. 0.45. Пример обработки очереди циклом событий в Dart
Здесь видно, что все элементы очереди событий обрабатываются в главном потоке приложения.
0.10.2. Очереди и цикл событий в Dart
Каждое Dart-приложение имеет один цикл событий, который работает с двумя
очередями.
y Очередь событий содержит как события Dart, так и все внешние события:
ввод/вывод, таймеры, сообщения между экземплярами Isolate и т. д.
0.10. Асинхронное программирование и изоляты 145
y Очередь микрозадач используется для очень коротких внутренних действий,
которые необходимо выполнять асинхронно сразу после завершения какого-
либо события и перед передачей управления обратно в очередь событий.
В качестве примера элемента для помещения в очередь микрозадач может выступать задача удаления ресурса (файла и т. д.) после того, как он был закрыт. Так
как этот процесс может занимать какое-то время, его разумнее всего выполнить
в асинхронном режиме, тем более что такие операции отдаются на откуп операционной системе и не выполняются средствами языка программирования.
Цикл обработки событий начинает работу при выходе потока управления из
функции верхнего уровня main. Первым делом он выполняет любые микрозадачи
в порядке их помещения в очередь микрозадач. Следом за этим начинается обработка
первого элемента очереди событий — он извлекается из очереди и обрабатывается. Затем идет повторение цикла: сначала выполняются все микрозадачи, а после
обрабатывается следующий элемент очереди событий. Когда обе очереди пусты
и событий больше не ожидается, приложение закрывается.
Далее представлена структурная схема алгоритма работы цикла событий
(рис. 0.46).
Рис. 0.46. Алгоритм работы цикла событий
146 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Посмотрите внимательно на структурную схему алгоритма работы цикла событий. Из нее следует, что, пока цикл событий выполняет задачи из очереди микрозадач, элементы очереди событий не обрабатываются, то есть приложение не может
рисовать графику, обрабатывать события ввода/вывода и т. д.
И хотя теперь, лучше разбираясь в алгоритме работы цикла событий, вы имеете
возможность предсказать порядок выполнения задач, нельзя точно сказать, когда
цикл событий будет доставать для обработки задачу из очереди. Это связано с тем,
что сама система обработки событий Dart основана на однопоточном цикле: при
создании очередной отложенной задачи событие ставится в очередь, но не может
быть обработано до тех пор, пока не будут обработаны все события, находящиеся
перед ним.
Для добавления в очередь событий очередного элемента, код которого должен
быть выполнен позже, в Dart используется класс Future. А для добавления нового элемента в конец очереди микрозадач применяется функция верхнего уровня
scheduleMicrotask либо именованный конструктор Future.microtask.
Обычно при планировании отложенных задач рекомендуется использовать класс
Future, чтобы поместить их в очередь событий. Это помогает сохранить короткую
очередь микрозадач, тем самым уменьшая вероятность того, что из-за нее будет простаивать очередь событий. В тех же случаях, когда задача должна завершиться до
того, как будут обработаны какие-либо элементы из очереди событий, немедленно
вызывайте выполнение функции для ее обработки. Если этого сделать не получается, помещайте задачу в очередь микрозадач.
В качестве примера работы алгоритма цикла событий реализуем программу, выводящую в терминал строки. Сначала поместим событие вывода строки в очередь
событий, а потом — в очередь задач:
// base_url/0/0.10/0.10.2/ex1.dart
import 'dart:async';
void main(List<String> arguments) {
Future(() = > print('1-й элемент очереди событий'));
Future(() = > print('2-й элемент очереди событий'));
Future.microtask((){
print('1-й элемент очереди микрозадач');
});
scheduleMicrotask((){
print('2-й элемент очереди микрозадач');
});
}
/* 1-й элемент очереди микрозадач
2-й элемент очереди микрозадач
1-й элемент очереди событий
2-й элемент очереди событий */
0.10.3. Что такое асинхронное программирование
Асинхронное программирование представляет собой подход, при котором программа может обрабатывать задачи, не блокируясь на операциях ввода-вывода или
на ожидании результата от других задач. Это позволяет ей конкурентно выполнять
другие задачи, пока ожидается завершение асинхронных операций, что улучшает
общую производительность и эффективность использования ресурсов.
0.10. Асинхронное программирование и изоляты 147
Асинхронное программирование особенно полезно в сценариях, где программа должна выполнять множество небольших задач, которые зависят от внешних
ресурсов, таких как файлы, сетевые запросы или базы данных. Оно позволяет избежать ненужных задержек, так как программа продолжает работать над другими
задачами во время ожидания ответа от внешних ресурсов. Дополнительно к этому
асинхронное программирование может снизить нагрузку на процессор, так как
асинхронные операции, такие как ожидание сетевых запросов, не требуют активного использования процессорного времени, что особенно критично в системах
с ограниченными ресурсами, например на встраиваемых устройствах или серверах
с высокой нагрузкой.
Чтобы ваш код имел возможность выполняться асинхронно, в заголовке модуля необходимо импортировать библиотеку dart:async. После этого вам станут
доступны классы Future и Stream. В Dart также имеются ключевые слова async
и await, которые позволяют писать асинхронный код, внешне незначительно отличающийся от синхронного.
К наиболее частым задачам, при решении которых код должен выполняться
асинхронно, можно отнести:
y получение данных по сети;
y запись в базу данных;
y чтение данных из файла.
0.10.4. Future API, async и await
позволяет добавлять задачи как в очередь событий, так и в очередь
микрозадач для их отложенного асинхронного выполнения. Каждая задача может
завершиться успешно, либо в процессе ее работы сгенерируется исключение, которое следует обработать или дать распространиться дальше, вплоть до «падения»
приложения.
В Dart существует понятие future (фьючерс/будущее). Под ним понимается
объект, представляющий собой результат вычисления, которое, возможно, еще
не произошло и результат которого может стать известен когда-нибудь в будущем
(о, как загнул! ^_^). Говоря простыми словами, future — экземпляр класса Future<T>,
позволяющий нам писать асинхронный код и предоставляющий доступ к результату
вычисления, где Т — тип возвращаемого результата.
Экземпляр класса Future может быть создан с использованием одного из следующих конструкторов.
y Future(FutureOr<T> computation()) создает future, содержащий результат
асинхронного вызова computation с помощью Timer.run.
y Future.delayed(Duration duration, [FutureOr<T> computation()]) создает
future , который вычисляется после задержки, указываемой в duration .
Необязательный аргумент конструктора computation представляет собой
ссылку на функцию, которая будет выполняться после задаваемой задержки.
y Future.error(Object error, [StackTrace? stackTrace]) создает future, который
будет завершен ошибкой error. Это дает достаточно времени для добавления
Future API
148 Глава 0 Установка и настройка рабочего окружения. Основы Dart
обработчика ошибки. Если обработчик не будет добавлен до завершения
future, ошибка будет считаться необработанной.
y Future.microtask(FutureOr<T> computation()) создает future, который посредством функции scheduleMicrotask помещает функцию computation в очередь
микрозадач (во всех остальных случаях идет работа с очередью событий),
запускает ее асинхронно и возвращает результат.
y Future.sync(FutureOr<T> computation()) создает future, содержащий результат
немедленного вызова computation.
y Future.value([FutureOr<T> value]) создает future, содержащий значение value.
Почти во всех приведенных конструкторах класса Future тип возвращаемого
значения, передаваемый функции в качестве аргумента computation, указывается
как FutureOr<T>. Это значит, что передаваемая функция должна возвращать либо
Future<T>, либо объект типа T:
import 'dart:async';
int add() = > 10 + 15;
void main(List<String> arguments) {
Future<int> future = Future(add);
}
Чтобы получить вычисляемое значение функции add, у future необходимо вызвать метод then, куда передать callback-функцию (функцию обратного вызова)
с типом входного аргумента, соответствующим типу возвращаемого значения.
Callback-функция будет вызвана сразу, как обработается элемент очереди событий,
соответствующий future:
// base_url/0/0.10/0.10.4/ex1.dart
import 'dart:async';
int add() = > 10 + 15;
void main(List<String> arguments) {
var firstFuture = Future<int>(add);
Future(()= > print('Oo'));
firstFuture.then((value) = > print(value));
print('завершение main');
}
/* завершение main
25
Oo */
Тот же самый результат можно получить следующим способом:
// base_url/0/0.10/0.10.4/ex2.dart
import 'dart:async';
int add() = > 10 + 15;
void myPrint(int a) = > print(a);
void main(List<String> arguments) {
var firstFuture = Future<int>(add);
var secondFuture = Future(()= > 'Oo');
firstFuture.then(myPrint);
0.10. Асинхронное программирование и изоляты 149
secondFuture.then(print);
print('завершение main');
}
/* завершение main
25
Oo */
Callback-функция способна возвращать объекты различного типа, тем самым
организуя цепочки из методов then, которые будут вызываться друг за другом:
// base_url/0/0.10/0.10.4/ex3.dart
import 'dart:async';
void main(List<String> arguments) {
var future = Future<String>(() = >
'Привет! Это событие в очереди под номером: ');
var newfuture = Future<String>(() = >
'Привет! Это еще одно событие в очереди под номером: ');
int a = 10;
future.then((value){
print('$value 1');
return 1;
}).then((value) = > print(value + a));
newfuture.then((value){
print('$value 2');
return 2.5;
}).then((value) = > print(value + a));
print('завершение main');
}
/* завершение main
Привет! Это событие в очереди под номером: 1
11
Привет! Это еще одно событие в очереди под номером:
12.5 */
2
Если в ходе асинхронного выполнения задачи сгенерируется исключение, то оно
вернется как результат выполнения отложенной операции. Future позволяет перехватывать все возникающие при отложенном выполнении исключения и ошибки
методом catchError. При отсутствии обработки исключений либо невозможности
обработать исключение сгенерированного типа оно будет распространено дальше,
что в итоге приведет к завершению программы. Так, например, в следующем коде
будут обрабатываться все возможные исключения:
// base_url/0/0.10/0.10.4/ex4.dart
import 'dart:async';
class MyException implements Exception {
final String? msg;
const MyException([this.msg]);
}
@override
String toString() = > msg ?? 'MyException';
int myFunction(){
var sum = 0;
150 Глава 0 Установка и настройка рабочего окружения. Основы Dart
}
for(var i=0; i<30; i++){
sum += i;
if(sum > 40){
throw MyException();
}
}
return sum;
void main(List<String> arguments) {
var future = Future<int>(myFunction);
future.then(print)
.catchError((onError) = > print(onError));
Future(()= > print('-_-'));
print('завершение main');
}
/* завершение main
MyException
-_- */
Теперь добавим в метод catchError дополнительную проверку, чтобы он обрабатывал исключения или ошибки только заданного типа. Для этого используется его
второй именованный (необязательный) аргумент test, которому придается функция,
принимающая на вход экземпляр исключения и возвращающая логическое значение. Если возвращается true, значит, мы перехватили нужное исключение и будем
обрабатывать его в методе catchError, иначе исключение распространится дальше:
// base_url/0/0.10/0.10.4/ex5.dart
void main(List<String> arguments) {
var future = Future<int>(myFunction);
future.then(print)
.catchError((onError) = > print(onError),
test: (error) = > error is MyException);
Future(()= > print('-_-'));
print('завершение main');
}
/* завершение main
MyException
-_- */
Когда есть острая необходимость при успешном выполнении Future или наличии
ошибки выполнить какое-либо действие, например закрыть доступ к ресурсу и т. д.,
на помощь приходит метод whenComplete:
// base_url/0/0.10/0.10.4/ex6.dart
void main(List<String> arguments) {
var future = Future<int>.delayed(
Duration(seconds: 3), // задержка перед выполнением
myFunction
);
future.then(print)
.catchError((onError) = > print(onError),
test: (error) = > error is MyException)
.whenComplete(() = > print('Я все равно лучший!!!'));
Future(()= > print('-_-'));
print('завершение main');
}
/* завершение main
-_MyException
Я все равно лучший!!! */
0.10. Асинхронное программирование и изоляты 151
Чтобы какой-то функционал приложения вызывался с определенной периодичностью, следует использовать класс Timer, а не Future:
// base_url/0/0.10/0.10.4/ex7.dart
import ''dart:async'';
import ''dart:io'';
void main(List<String> arguments) async {
var count = 0;
Timer.periodic(
Duration(milliseconds: 500),
(timer) {
// вызов функции, запрос на бэк и т. д.
stdout.write(''*'');
count++;
if (count > = 10) {
timer.cancel();
}
},
);
}
// **********
Для упрощения написания функций, которые должны выполняться асинхронно,
существуют ключевые слова async и await. Они довольно тесно связаны друг с другом, поскольку ключевое слово await можно использовать только в теле функций,
помеченных как async. К тому же тип возвращаемого результата функции должен
быть обернут в Future:
// base_url/0/0.10/0.10.4/ex1.dart
Future<String> getBigData() async {
return 'Гигатонны информации))';
}
Future<void> makeRequestData() async {
print('Запрос данных');
var data = await getBigData();
print(data);
print('Данные получены');
}
void main(List<String> arguments) {
print('Запуск main');
makeRequestData();
print('Завершение main');
}
/* Запуск main
Запрос данных
Завершение main
Гигатонны информации))
Данные получены */
Код в асинхронной функции makeRequestData выполняется синхронно (просто
примите это ^_^) вплоть до первого вызова await, который представляет собой
асинхронную операцию. То есть она не блокирует выполнение других операций,
позволяя им выполняться до завершения. Таким образом, когда поток управления
в функции makeRequestData встречается с ключевым словом await, он останавливается до тех пор, пока вызываемая функция getBigData не вернет свой результат.
После этого выполнение функции makeRequestData продолжается.
152 Глава 0 Установка и настройка рабочего окружения. Основы Dart
В результате выполнения await всегда возвращается объект Future. А когда
вызываемая функция возвращает значение отличного от Future типа данных,
Dart автоматически оборачивает его в Future. При этом возвращаемое значение
вызываемой с помощью await функции может быть получено обычным присваи
ванием.
Если в функции getBigData сгенерируется исключение, его следует обработать
с помощью конструкции try…catch…finally:
// base_url/0/0.10/0.10.4/ex9.dart
Future<String> getBigData() async{
throw Exception('Прервалось соединение!!!');
}
Future<void> makeRequestData() async {
print('Запрос данных');
try {
print(await getBigData());
print('Данные получены');
} catch (e) {
print('Что-то пошло не так: $e');
}
}
void main() {
print('Запуск main');
makeRequestData();
print('Завершение main');
}
/* Запуск main
Запрос данных
Завершение main
Что-то пошло не так: Exception: Прервалось соединение!!! */
Количество вызовов функций с использованием ключевого слова await в теле
функции, помеченной как async, не ограничено. Необходимо помнить только то,
что вызываться они будут последовательно.
Статический метод класса Future — wait позволяет подождать завершения нескольких фьючерсов и собрать их результаты в список. А результатом могут быть
как нормальные данные, так и ошибки. Для примера изменим у одной из книг
в JSON-представлении id на строковый тип данных и модифицируем функцию
decode и getBooks:
// base_url/0/0.10/0.10.4/ex10.dart
import 'dart:convert';
import 'dart:async';
Future<String> downloadData() async {
final jsonString = '''
[
{
"id": "1",
"title": "Изучаем Python",
"author": "Гаспарян Эрик",
"urlImage": "https://6008409614.jpg"
},
// далее без изменений
]
''';
return jsonString;
}
0.10. Асинхронное программирование и изоляты 153
class Book {
// без изменений
}
Future<Book> decode(Map<String, dynamic> json) async {
var completer = Completer<Book>();
try {
var book = Book.fromJson(json);
print('Future decoded book with id: ${book.id}');
completer.complete(book);
} catch (e) {
completer.completeError(e);
}
return completer.future;
}
Future<List<Book>> getBooks() async {
var completer = Completer<List<Book>>();
var books = <Book>[];
try {
final jsonString = await downloadData();
final List<dynamic> jsonList = jsonDecode(jsonString);
await Future.wait([
for (var json in jsonList) decode(json)
],
cleanUp: (Book value) {
books.add(value);
});
} catch (e) {
print(e);
// так себе решение, но ради примера — можно
} finally {
completer.complete(books);
}
return completer.future;
}
void main() {
print('Запуск main');
getBooks().then((books) {
for (var it in books) {
print(it);
}
}).catchError((onError) {
print(onError);
});
print('Завершение main');
}
/* Запуск main
Завершение main
Future decoded book with id: 2
Future decoded book with id: 3
type 'String' is not a subtype of type 'int'
Book(id: 2, title: Программирование на C++, author: Петров А.Н., urlImage:
https://6053518495.jpg)
Book(id: 3, title: Программирование на Java, author: Иванов А.Н., urlImage:
https://6053518383.jpg)*/
Еще один метод класса Future — timeout, принимающий на вход время и необязательную анонимную функцию onTimeout, которая должна возвращать тот же
тип данных, что и ожидаемый от Future. Метод timeout позволяет задать временной
лимит на выполнение задачи. То есть если задача выполнится за отведенное время,
то все хорошо, иначе будет выполнена функция, переданная аргументу onTimeout.
154 Глава 0 Установка и настройка рабочего окружения. Основы Dart
А когда ему ничего не передается, по истечении указанного времени сгенерируется
исключение TimeoutException:
// base_url/0/0.10/0.10.4/ex11.dart
import 'dart:async';
void main(List<String> arguments) {
var future1 = Future.delayed(
Duration(seconds: 2),
() = > 'Future 1',
);
var future2 = Future.delayed(
Duration(seconds: 4),
() = > 'Future 2',
);
future1
.timeout(
Duration(seconds: 3),
onTimeout: () = > 'Timeout for Future 1',
)
.then((value) = > print(value));
future2
.timeout(
Duration(seconds: 3),
onTimeout: () = > 'Timeout for Future 2',
)
.then((value) = > print(value));
future2
.timeout(
Duration(seconds: 3),
)
.then((value) = > print(value))
.catchError(
(e) = > print(e),
);
}
// Future 1
// Timeout for Future 2
// TimeoutException after 0:00:03.000000: Future not completed
0.10.5. Stream (поток)
Поток (Stream) в Dart — это последовательность асинхронных событий, которые
подразделяются на три типа:
y событие данных (элемент потока);
y событие ошибки (что-то пошло не так);
y событие "done", оповещающее всех слушателей (тех, кто подписался на поток) о его завершении.
Основное преимущество использования потоков заключается в том, что код
остается слабосвязанным, так как классу, в котором создается экземпляр потока,
отвечающий за выдачу готовых данных, не нужно ничего знать о том, кто подписался на получение событий (слушает их) и почему. Аналогичная ситуация и с потребителями данных: они должны только придерживаться интерфейса потока, в то
время как источник данных от них скрыт.
0.10. Асинхронное программирование и изоляты 155
Для управления потоками в Dart предназначены следующие классы:
y Stream. Представляет асинхронный поток данных. Слушатели могут подписаться на получение уведомлений о появлении новых событий данных;
y EventSink. Обратный поток, добавление в него событий данных направляет
эти данные в подключенный поток;
y StreamController. Упрощает управление потоками, автоматически создавая
поток и приемник, а также предоставляя методы для управления поведением
потока;
y StreamSubscription. Экземпляры этого класса могут сохранять ссылку на
подписку, что позволяет им приостанавливать, возобновлять или отменять
поток данных.
В большинстве случаев нет необходимости напрямую создавать экземпляры
классов Stream и EventSink. Это связано с тем, что при создании экземпляра класса
StreamController автоматически создаются поток и приемник.
Примерами потока могут служить изменение положения указателя мыши,
список простых чисел, получаемые по сети данные и т. д. Имеется возможность
подписаться на каждый поток (прослушать его), задав одну или несколько callbackфункций, которые будут вызываться при добавлении в него новых данных. Самый
простой пример использования потоков — написание асинхронной генераторной
функции:
// base_url/0/0.10/0.10.5/ex1.dart
Stream<int> myGenerator(int last) async* {
for (var i = 0; i < = last; i++) {
yield i;
}
}
void createGenerator(int lastValue) async {
var stream = myGenerator(lastValue);
// слушаем поток и выводим получаемые данные в терминал
stream.listen((s) = > print(s));
}
void main(List<String> arguments) {
print('Запуск main');
createGenerator(20);
print('Завершение main');
}
/* Запуск main
Завершение main
0
...
20 */
Потоки также предоставляют возможность асинхронно итерироваться по
существующим последовательностям. Для этого используется именованный конструктор Stream.fromIterable:
// base_url/0/0.10/0.10.5/ex2.dart
void iterableStream(List<int> list) {
var stream = Stream.fromIterable(list);
print('Начало работы потока');
156 Глава 0 Установка и настройка рабочего окружения. Основы Dart
}
stream.listen(
(s) = > print(s),
);
print('Завершение работы потока');
void main(List<String> arguments) {
iterableStream([1, 2, 3, 4, 5]);
}
/* Начало работы потока
Завершение работы потока
1
…
5 */
Обратите внимание на вывод в терминал в предыдущем примере. Итерация по
списку происходила в асинхронном режиме. Порой может возникнуть ситуация,
когда основной код функции, обрабатывающей события потока, должен работать
в синхронном режиме. Для этого следует использовать конструкцию await for:
// base_url/0/0.10/0.10.5/ex3.dart
void iterableStream(List<int> list) async {
var stream = Stream.fromIterable(list);
print('Начало работы потока');
await for (var num in stream) {
print(num);
}
print('Завершение работы потока');
}
void main(List<String> arguments) {
iterableStream([1, 5]);
}
/* Начало работы потока
1
5
Завершение работы потока */
При использовании await for для обработки исключения следует задействовать
конструкцию try…catch…finally. А когда функция обработки задается с помощью
метода listen экземпляра класса Stream, для этих целей применяется анонимная
функция, передаваемая именованному аргументу onError:
// base_url/0/0.10/0.10.5/ex4.dart
Stream<int> myGenerator(int last) async* {
for (var i = 0; i < = last; i++) {
if (i > = 2) {
throw Exception('Ошибка!!!');
}
yield i;
}
}
void createGenerator(int lastValue) async {
var stream = myGenerator(lastValue);
// слушаем поток и выводим получаемые данные в терминал
stream.listen((s) = > print(s),
onError: (e) = > print(e));
}
void main(List<String> arguments) {
print('Запуск main');
0.10. Асинхронное программирование и изоляты 157
createGenerator(20);
print('Завершение main');
}
/* Запуск main
Завершение main
0
1
Exception: Ошибка!!! */
Давайте разберемся, как идет работа с экземпляром класса StreamController:
// base_url/0/0.10/0.10.5/ex5.dart
import 'dart:async';
void main(List<String> arguments) {
final controller = StreamController<String>();
final subscription = controller.stream.listen((String data) {
print('Listening: $data');
});
controller.add('Привет!!!');
controller.add('И еще раз привет!!!');
}
// Listening: Привет!!!
// Listening: И еще раз привет!!!
Экземпляр класса StreamController предоставляет доступ к потоку для прослушивания событий и реагирования на них посредством метода listen экземпляра
класса Stream, для которого задается функция обратного вызова, обрабатывающая
поступающие в поток данные с помощью метода add. Сам же метод потока listen
возвращает экземпляр StreamSubscription, позволяющий управлять подпиской
на поток.
Представим, что у нас имеется кофемашина, состоящая из монетоприемника
и блока приготовления кофе. Блок приготовления подписывается на события
поступления денег в монетоприемник и после того, как накапливается пороговая
сумма, начинает приготовление капучино:
// base_url/0/0.10/0.10.5/ex6.dart
import 'dart:async';
class CoinAcceptor{
final _addCoin = StreamController<int>();
Stream<int> get dataStream = > _addCoin.stream;
}
void addCoin(int coin) = > _addCoin.add(coin);
class CoffeMachine{
int valueCoins = 0;
CoffeMachine(Stream<int> stream){
stream.listen(addCoin);
}
void addCoin(int coin){
valueCoins += coin;
if(valueCoins > =30){
print('Готовим капучино!');
}
158 Глава 0 Установка и настройка рабочего окружения. Основы Dart
}
}
print('Общее количество монет: $valueCoins');
void main(List<String> arguments) {
print('Запуск main');
var coinAcceptor = CoinAcceptor();
var coffeMachine = CoffeMachine(coinAcceptor.dataStream);
coinAcceptor.addCoin(25);
coinAcceptor.addCoin(4);
coinAcceptor.addCoin(3);
print('Завершение main');
}
/* Запуск main
Завершение main
Общее количество монет: 25
Общее количество монет: 29
Готовим капучино!
Общее количество монет: 32 */
Класс CoffeMachine может быть реализован и более компактно:
class CoffeMachine{
int valueCoins = 0;
}
CoffeMachine(Stream<int> stream){
stream.listen((coin){
valueCoins += coin;
if(valueCoins > =30){
print('Готовим капучино!');
}
print('Общее количество монет: $valueCoins');
});
}
В примере работы кофемашины у монетоприемника может быть только один
подписчик событий. Когда же имеется необходимость разрешить существование
нескольких слушателей потока, следует создать широковещательный поток, используя именованный конструктор Stream<T>.broadcast:
final _addCoin = StreamController<int>.broadcast ();
Потоки поддерживают возможность передавать значения не только встроенных,
но и пользовательских типов данных:
// base_url/0/0.10/0.10.5/ex7.dart
import 'dart:async';
class Coin{
final int value;
Coin(this.value);
}
class CoinAcceptor{
final _addCoin = StreamController<Coin>();
Stream<Coin> get dataStream = > _addCoin.stream;
}
void addCoin(Coin coin) = > _addCoin.add(coin);
class CoffeMachine{
int valueCoins = 0;
0.10. Асинхронное программирование и изоляты 159
}
CoffeMachine(Stream<Coin> stream){
stream.listen((coin){
valueCoins += coin.value;
if(valueCoins > = 30){
print('Готовим капучино!');
}
print('Общее количество монет: $valueCoins');
});
}
void main(List<String> arguments) {
print('Запуск main');
var coinAcceptor = CoinAcceptor();
var coffeMachine = CoffeMachine(coinAcceptor.dataStream);
coinAcceptor.addCoin(Coin(35));
print('Завершение main');
}
/* Запуск main
Завершение main
Готовим капучино!
Общее количество монет: 35 */
Чтобы продемонстрировать, как можно самостоятельно закрыть поток, немного
перепишем последний пример:
// base_url/0/0.10/0.10.5/ex8.dart
import 'dart:async';
class Coin{
final int value;
Coin(this.value);
}
class CoinAcceptor{
final _addCoin = StreamController<Coin>();
Stream<Coin> get dataStream = > _addCoin.stream;
}
Future<void> addCoin(Coin coin) async{
if (!_addCoin.isClosed){
_addCoin.add(coin);
}
}
Future<void> dispose() async = > await _addCoin.close();
class CoffeMachine{
int valueCoins = 0;
}
CoffeMachine(CoinAcceptor coinAcceptor){
coinAcceptor.dataStream.listen((coin) async{
valueCoins += coin.value;
if(valueCoins > = 30){
print('Готовим капучино!');
}
if (valueCoins > = 60){
await coinAcceptor.dispose();
}
print('Общее количество монет: $valueCoins');
}, onDone: (){
print('Завершение работы');
});
}
160 Глава 0 Установка и настройка рабочего окружения. Основы Dart
void main(List<String> arguments) async{
print('Запуск main');
var coinAcceptor = CoinAcceptor();
var coffeMachine = CoffeMachine(coinAcceptor);
await coinAcceptor.addCoin(Coin(35));
await coinAcceptor.addCoin(Coin(5));
await coinAcceptor.addCoin(Coin(20));
await coinAcceptor.addCoin(Coin(63));
print('Завершение main');
}
/* Запуск main
Готовим капучино!
Общее количество монет: 35
Готовим капучино!
Общее количество монет: 40
Готовим капучино!
Завершение работы
Завершение main
Общее количество монет: 60 */
Именованный аргумент onDone метода listen позволяет отследить завершение
потока, чтобы освободить занятые ресурсы. Это может быть открытый ранее файл,
сетевое соединение и т. д.
0.10.6. Isolate (изоляты)
Несмотря на то что Dart запускает приложение в одном изоляте, при необходимости есть возможность создавать пользовательские изоляты, имеющие собственную
память и единственный поток выполнения, который запускает цикл обработки событий. Иначе говоря, каждый новый изолят получает собственный цикл событий
и собственную память, к которой другие изоляты не имеют доступа. Единственный
способ, благодаря которому изоляты могут работать вместе, — обмен сообщениями.
Конечно, у такого подхода есть недостатки, но имеется у него и ряд преимуществ.
y Выделение памяти и сборка мусора в изолированном объекте не требуют
блокировки.
y Есть только один поток, и если он не занят, то память не изменяется.
В качестве первого примера, как обычно, реализуем эхо-изолят и разберем принцип его работы, а уже после пустимся во все тяжкие «уличной магии» 😉:
// base_url/0/0.10/0.10.6/ex1.dart
import 'dart:isolate';
class IsolatesMessage<T> {
final SendPort sender;
final T message;
}
IsolatesMessage({
required this.sender,
required this.message,
});
late SendPort isolateSendPort;
late Isolate isolate;
0.10. Асинхронное программирование и изоляты 161
Future<void> createIsolate() async {
var receivePort = ReceivePort();
isolate = await Isolate.spawn(
echoCallbackFunction,
receivePort.sendPort,
);
isolateSendPort = await receivePort.first;
}
Future<String> sendReceive(String send) async{
var port = ReceivePort();
isolateSendPort.send(
IsolatesMessage<String>(
sender: port.sendPort,
message: send,
)
);
return await port.first;
}
void echoCallbackFunction(SendPort sendPort){
var receivePort = ReceivePort();
// возвращаем ссылку на порт для отправки данных
// в главный изолят
sendPort.send(receivePort.sendPort);
receivePort.listen((message) {
// обработчик принимаемых изолятом сообщений
var isolateMessage = message as IsolatesMessage<String>;
print('Isolate: ${isolateMessage.message}');
isolateMessage.sender.send(isolateMessage.message);
});
}
void main()async{
await createIsolate();
print('Main: ${await sendReceive('Старт!')}');
print('Main: ${await sendReceive('1')}');
print('Main: ${await sendReceive('2')}');
print('Main: ${await sendReceive('3')}');
isolate.kill();
}
/* Isolate: Старт!
Main: Старт!
Isolate: 1
Main: 1
Isolate: 2
Main: 2
Isolate: 3
Main: 3 */
Для создания экземпляра изолята используется статический метод Isola
te.spawn<T>, где первым аргументом выступает ссылка на функцию, которая будет
выполняться в изоляте и принимает в качестве входного аргумента сообщение типа
данных T, а вторым — ссылка на экземпляр сообщения, которое поступает в изолят
сразу при его создании. В нашем случае это ссылка на порт, через который вернется
ссылка на порт из создаваемого изолята для последующего обмена сообщениями
с ним.
Класс IsolatesMessage используется для обмена сообщениями между главным
и созданным изолятами. Его поле sender хранит ссылку на SendPort, через который
162 Глава 0 Установка и настройка рабочего окружения. Основы Dart
будет отправляться сообщение из изолята главному приложению, а в поле message
передаются данные, работа с которыми будет производиться в изоляте.
ReceivePort создается каждый раз при обращении к изоляту, поэтому функция
createIsolate отвечает за создание изолята и его инициализацию, то есть получение
от него ссылки на порт, через который в последующем будут пересылаться сообщения в изолят. Посредством метода sendReceive производится вся остальная работа
с созданным изолятом: передается сообщение из основного приложения, ожидается
ответ, после чего он возвращается в основное приложение.
Такой пример работы с изолятом можно часто встретить на просторах Интернета, но его проблема в том, что в нем очень много лишних действий. Так, например,
await port.first закрывает ReceivePort, из-за чего при отправке сообщения изоляту приходится каждый раз создавать новый экземпляр класса ReceivePort и его
SendPort передавать в изолят, чтобы получить из него ответ.
Прежде чем использовать изолят, постарайтесь ответить на вопрос «Зачем он
мне нужен?». Если для единичного выполнения расчетов или разового получения
большого объема данных по сети с их десериализацией, то для его создания лучше
подойдет статический метод run класса Isolate. А для часто повторяющихся ресурсоемких задач приоритетнее поднять изолят один раз с помощью Isolate.spawn.
Представим ситуацию: нам необходимо получать с сервера большой JSON, который нежелательно десериализовать в главном изоляте, так как это скажется на
пользовательском интерфейсе (будет тормозить). В качестве сервера задействуем
сервис, предоставляющий фейковый Rest API — https://reqres.in/ и его end-point
https://reqres.in/api/users/{ID}, который возвращает JSON:
{
}
"data": {
"id": 2,
"email": "janet.weaver@reqres.in",
"first_name": "Janet",
"last_name": "Weaver",
"avatar": "https://reqres.in/img/faces/2-image.jpg"
},
"support": {
"url": "https://reqres.in/#support-heading",
"text": "To keep ReqRes free, contributions towards server costs are
appreciated!"
}
Будем считать, что данные пользователя необходимо получить один раз, поэтому при создании изолята применим статический метод run, аналогом которого
во Flutter выступает функция верхнего уровня compute. Начнем с импортирования
необходимых библиотек, а также с описания класса User (и его составляющих)
с конструктором fromJson:
// base_url/0/0.10/0.10.6/ex2.dart
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
class User {
final UserData data;
final Support support;
0.10. Асинхронное программирование и изоляты 163
User({
required this.data,
required this.support,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
data: UserData.fromJson(json['data']),
support: Support.fromJson(json['support']),
);
}
Map<String, dynamic> toJson() {
return {
'data': data.toJson(),
'support': support.toJson(),
};
}
}
@override
String toString() {
return JsonEncoder.withIndent('
}
').convert(this);
class UserData {
final int id;
final String email;
final String firstName;
final String lastName;
final String avatar;
UserData({
required
required
required
required
required
});
this.id,
this.email,
this.firstName,
this.lastName,
this.avatar,
factory UserData.fromJson(Map<String, dynamic> json) {
return UserData(
id: json['id'],
email: json['email'],
firstName: json['first_name'],
lastName: json['last_name'],
avatar: json['avatar'],
);
}
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'first_name': firstName,
'last_name': lastName,
'avatar': avatar,
};
}
class Support {
final String url;
final String text;
164 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Support({
required this.url,
required this.text,
});
factory Support.fromJson(Map<String, dynamic> json) {
return Support(
url: json['url'],
text: json['text'],
);
}
}
Map<String, dynamic> toJson() {
return {
'url': url,
'text': text,
};
}
На следующем шаге добавим функции fetchUser и fetchUserWithoutId для получения данных о конкретном пользователе и их десериализации. Они нам понадобятся, чтобы продемонстрировать, как при создании изолята использовать функции,
как требующие, так и не требующие передачи на их вход значений аргументов:
Future<User?> fetchUser(int id) async {
User? user;
var httpClient = HttpClient();
try {
var request = await httpClient.getUrl(
Uri.parse('https://reqres.in/api/users/$id'),
); // запрос по адресу, чтобы получить данные
// о пользователе с конкретным id
var response = await request.close();
if (response.statusCode = = HttpStatus.ok) {
var responseBody = await response
.transform(
utf8.decoder,
)
.join();
user = User.fromJson(jsonDecode(responseBody));
}
} catch (e) {
print('An error occurred during the API call: $e');
} finally {
httpClient.close();
}
return user;
}
Future<User?> fetchUserWithoutId() async{
return await fetchUser(1);
}
Теперь перейдем к функции main:
void main() async {
print('Запуск main');
// запуск изолята с передачей функции, которая требует
// указания аргумента и будет выполняться в новом изоляте
0.10. Асинхронное программирование и изоляты 165
Isolate.run<User?>(() = > fetchUser(2)).then(
(value) = > print(value),
);
// или
// var user = await Isolate.run<User?>(
//
() = > fetchUser(2),
// );
// запуск изолята с указанием функции без входных
// аргументов, которая будет выполняться в новом
// изоляте
Isolate.run<User?>(fetchUserWithoutId).then(
(value) = > print(value),
);
print('завершение main');
}
/* Запуск main
завершение main
{
"data": {
"id": 2,
"email": "janet.weaver@reqres.in",
"first_name": "Janet",
"last_name": "Weaver",
"avatar": "https://reqres.in/img/faces/2-image.jpg"
},
"support": {
"url": "https://reqres.in/#support-heading",
"text": "To keep ReqRes free, contributions towards server costs are
appreciated!"
}
}
{
"data": {
"id": 1,
"email": "george.bluth@reqres.in",
"first_name": "George",
"last_name": "Bluth",
"avatar": "https://reqres.in/img/faces/1-image.jpg"
},
"support": {
"url": "https://reqres.in/#support-heading",
"text": "To keep ReqRes free, contributions towards server costs are
appreciated!"
}
} */
0.10.7. Async или Isolate?
Вот несколько советов, которые помогут вам определиться, что использовать —
асинхронное программирование или изоляты.
y Если части кода не должны быть прерваны, используйте обычный синхрон-
ный процесс (один метод или несколько методов, которые вызывают друг
друга).
y Если фрагменты кода могут работать независимо, не влияя на плавность
работы приложения (отсутствие зависаний), используйте Future.
166 Глава 0 Установка и настройка рабочего окружения. Основы Dart
y Если работа может занять некоторое время и потенциально вызывать за-
держки в работе графического пользовательского интерфейса приложения,
задействуйте Isolate.
При выборе того, использовать Future или Isolate, можно также ориентироваться
на среднее время, необходимое для выполнения кода:
y Future, если выполнение метода занимает пару миллисекунд;
y Isolate, если время работы метода может занять несколько сотен и более
миллисекунд.
Проект: игра «Тетрис» v. 1
Перед тем как перейти к знакомству с Flutter, переведем игру «Тетрис» с процедурных рельс на объектно-ориентированные. Для этого нам понадобится
изменить структуру папок проекта, сделать рефакторинг библиотек, разнеся
их функционал по нескольким, и выделить отдельный класс, который будет отвечать за игровой цикл. Откройте проект tetris_cli и приведите его структуру
к следующему виду:
tetris_cli
├── bin/
│
└── main.dart
├── lib/
│
├── src/
│
│
├── ansi_cli_helper/
│
│
│
├── ansi_cli_helper.dart
│
│
│
├── ansi_background_colors.dart
│
│
│
└── ansi_text_colors.dart
│
│
├── blocks/
│
│
│
├── block.dart
│
│
│
└── blocks.dart
│
│
├── board.dart
│
│
└── game.dart
│
└── tetris_cli.dart
├── test/
└── pubspec.yaml
Рефакторинг библиотеки ansi_cli_helper.dart
В первой итерации константы с цветом фона и текста были нами спрятаны за
псевдонимами наподобие:
// Константы заливки фона
typedef AnsiBackgroundColor = String;
// Константы цвета текста
typedef AnsiTextColor = String;
Такой подход не всегда удобен! Например, в функцию, где в качестве типа аргумента использовался псевдоним типа AnsiBackgroundColor, можно передать любую
строку. А это, в свою очередь, способно привести к «падению» приложения. Для
исправления этого недочета воспользуемся перечислениями, разнеся их по двум
файлам — ansi_background_colors.dart и ansi_text_colors.dart:
Проект: игра «Тетрис» v. 1 167
// ansi_background_colors.dart
// Варианты цвета фона
enum AnsiBackgroundColor{
black('\u001b[40m'),
red('\u001b[41m'),
green('\u001b[42m'),
yellow('\u001b[43m'),
blue('\u001b[44m'),
magenta('\u001b[45m'),
cyan('\u001b[46m'),
white('\u001b[47m');
}
final String ansiText;
const AnsiBackgroundColor(this.ansiText);
// ansi_text_colors.dart
// Варианты цвета текста
enum AnsiTextColor{
black('\u001b[30m'),
red('\u001b[31m'),
green('\u001b[32m'),
yellow('\u001b[33m'),
blue('\u001b[34m'),
magenta('\u001b[35m'),
cyan('\u001b[36m'),
white('\u001b[37m');
}
final String ansiText;
const AnsiTextColor(this.ansiText);
Теперь откройте файл ansi_cli_helper.dart. Так как наша цель — привнести
в текущий код этого файла щепотку объектно-ориентированной магии, воспользуемся шаблоном Singleton («Одиночка»). Он позволит из любой точки программы
работать с одним-единственным экземпляром класса AnsiCliHelper. Что касается
других шагов — все просто! Сделаем из существующих функций методы класса,
добавив парочку новых. Они будут использоваться для вывода подаваемой на их
вход строки в терминал:
// ansi_cli_helper.dart
import 'dart:io';
// импортируем варианты цвета текста и фона
import 'ansi_background_colors.dart';
import 'ansi_text_colors.dart';
// экспортируем варианты цвета текста и фона
export 'ansi_background_colors.dart';
export 'ansi_text_colors.dart';
// Нельзя наследовать от класса или использовать его как интерфейсный
final class AnsiCliHelper {
static AnsiCliHelper? _instance;
bool _isHideCursor = false;
AnsiCliHelper._();
// Паттерн проектирования Singleton
factory AnsiCliHelper() {
return _instance ??= AnsiCliHelper._();
}
168 Глава 0 Установка и настройка рабочего окружения. Основы Dart
bool get isHideCursor = > _isHideCursor;
// Метод показа курсора
void showCursor() {
if (_isHideCursor) {
stdout.write('\u001b[?25h'); // Включение курсора
_isHideCursor = false;
}
}
// Метод сокрытия курсора
void hideCursor() {
if (!_isHideCursor) {
stdout.write('\u001b[?25l'); // Выключение курсора
_isHideCursor = true;
}
}
// Метод очистки экрана
void clear() {
stdout.write('\u001b[2J\u001b[0;0H'); // Очистка экрана
}
// Метод очистки экрана и сброса цветов
void reset() {
setTextColor(AnsiTextColor.white);
setBackgroundColor(AnsiBackgroundColor.black);
clear();
showCursor();
}
// Метод вывода текста в терминал без переноса
// на новую строку
void write(String text) {
stdout.write(text);
}
// Метод вывода текста в терминал с переносом
// на новую строку
void writeLine(String text) {
stdout.writeln(text);
}
// Метод установки цвета текста
void setTextColor(AnsiTextColor color) {
stdout.write(color.ansiText);
}
// Метод установки цвета фона
void setBackgroundColor(AnsiBackgroundColor color) {
stdout.write(color.ansiText);
}
}
// Метод для перемещения курсора в заданную
// позицию считается от левого верхнего угла,
// чьи координаты — 0, 0
void gotoxy(int x, int y) {
if (x < 0 || y < 0) {
return;
}
stdout.write('\u001b[$y;${x}H');
}
Проект: игра «Тетрис» v. 1 169
Рефакторинг библиотеки blocks.dart
Перед рефакторингом задумаемся о том, какие обязанности возложить на базовый
класс Block (файл block.dart) и как организовать объявление различных фигур
(файл blocks.dart). В этом случае очень многое будет зависеть от самого класса
Block и от того, какие методы работы над фигурой он будет предоставлять. Проще
всего перенести на уровень базового класса весь функционал работы с блоками
фигур, а объявление фигур — на уровень производных классов. Поэтому возложим
на Block следующие обязанности:
y запрет на использование класса в качестве интерфейсного;
y хранение и изменение координат блока с фигурой;
y хранение фигуры в блоке 4 × 4;
y доступ по индексу к данным блока с фигурой;
y поворот фигуры в блоке;
y параметризованное копирование блока.
Далее приведен код, удовлетворяющий этим требованиям:
// block.dart
// Базовый класс блока, хранящего в себе игровую фигуру
base class Block {
int _x; // координаты по x
int _y; // координаты по y
// Двумерный массив 4 × 4 для хранения игровой фигуры
List<List<int>> _block = List.generate(
4,
(_) = > List.filled(4, 0),
);
// Конструктор
Block(this._block, [this._x = 4, this._y = 0]);
int get x = > _x;
int get y = > _y;
// Метод перемещения фигуры
void move(int x, int y) {
_x = x;
_y = y;
}
// Метод параметризованного копирования фигуры
Block copyWith({int? xParam, int? yParam}) {
List<List<int>> tmp = List.generate(4, (_) = > List.filled(4, 0));
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
tmp[i][j] = _block[i][j];
}
}
return Block(tmp, xParam ?? _x, yParam ?? _y);
}
// Метод поворота
void rotate() {
List<List<int>> tmp = List.generate(4, (_) = > List.filled(4, 0));
for (int i = 0; i < 4; i++) {
170 Глава 0 Установка и настройка рабочего окружения. Основы Dart
for (int j = 0; j < 4; j++) {
tmp[i][j] = _block[j][3 - i];
}
}
}
}
_block = tmp;
// Оператор индексирования
List<int> operator [](int index) {
return _block[index];
}
Перейдем к файлу blocks.dart и первым делом объявим импорт библиотек, экспорт базового класса и производные классы, описывающие всевозможные фигуры,
которые будем помещать на игровое поле:
// blocks.dart
import 'dart:math';
import 'block.dart';
export 'block.dart';
final class IBlock extends Block {
IBlock()
: super([
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0],
[0, 0, 0, 0],
]);
}
final class OBlock extends Block {
OBlock()
: super([
[0, 0, 0, 0],
[0, 1, 1, 0],
[0, 1, 1, 0],
[0, 0, 0, 0],
]);
}
final class TBlock extends Block {
TBlock()
: super([
[0, 0, 0, 0],
[1, 1, 1, 0],
[0, 1, 0, 0],
[0, 0, 0, 0],
]);
}
final class LBlock extends Block {
LBlock()
: super([
[0, 0, 0, 0],
[1, 1, 1, 0],
[1, 0, 0, 0],
[0, 0, 0, 0],
]);
}
Проект: игра «Тетрис» v. 1 171
final class JBlock extends Block {
JBlock()
: super([
[0, 0, 0, 0],
[1, 1, 1, 0],
[0, 0, 1, 0],
[0, 0, 0, 0],
]);
}
final class SBlock extends Block {
SBlock()
: super([
[0, 0, 0, 0],
[0, 1, 1, 0],
[1, 1, 0, 0],
[0, 0, 0, 0],
]);
}
final class ZBlock extends Block {
ZBlock()
: super([
[0, 0, 0, 0],
[1, 1, 0, 0],
[0, 1, 1, 0],
[0, 0, 0, 0],
]);
}
Благодаря такому объявлению фигур функция, возвращающая случайную
новую фигуру, и список, из которого эта фигура выбирается, будут переписаны
следующим образом:
// blocks.dart
// объявленный ранее код
Block getNewRandomBlock() {
return _defBlocks[Random().nextInt(_defBlocks.length)].copyWith();
}
final _defBlocks = [
OBlock(),
IBlock(),
IBlock()..rotate(),
LBlock(),
LBlock()..rotate(),
JBlock(),
JBlock()..rotate(),
TBlock(),
TBlock()..rotate(),
TBlock()..rotate()..rotate(),
TBlock()..rotate()..rotate()..rotate(),
SBlock(),
SBlock()..rotate(),
SBlock()..rotate()..rotate(),
SBlock()..rotate()..rotate()..rotate(),
ZBlock()..rotate(),
ZBlock()..rotate()..rotate(),
ZBlock()..rotate()..rotate()..rotate(),
];
ZBlock(),
172 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Рефакторинг библиотеки board.dart
Предыдущий код, который находился в этом файле, разумнее всего разбить на два
класса:
y
Board — будет отвечать за обработку ASCII-кодов клавиш, отрисовку фигур
и игровой доски и останется в библиотеке board.dart;
— сюда переместим весь функционал, отвечающий за запуск игры,
y
главный игровой цикл и работу с подпиской на прослушивание нажатий на
клавиши. Этот класс объявим в новой библиотеке — game.dart.
Game
Чтобы связать эти два класса между собой, воспользуемся callback-функциями,
которые будут вызываться при изменении состояния ситуации на игровой доске,
сигнализируя об этом экземпляру класса Game:
// board.dart
import 'blocks/blocks.dart';
import 'ansi_cli_helper/ansi_cli_helper.dart';
class Board {
static const
static const
static const
static const
static const
int
int
int
int
int
heightBoard = 20;
widthBoard = 10;
posFree = 0;
posFilled = 1;
posBoarder = 2;
late List<List<int>> mainBoard;
late List<List<int>> mainCpy;
// callback-функция для создания нового блока
Block Function() newBlockFunc;
// callback-функция для обновления счета
void Function() updateScore;
// callback-функция для обновления блока
void Function(Block block) updateBlock;
// callback-функция завершения игры
void Function() gameOver;
Block currentBlock; // текущий блок с игровой фигурой
AnsiCliHelper ansiCliHelper;
Board({
required this.newBlockFunc,
required this.currentBlock,
required this.updateScore,
required this.updateBlock,
required this.ansiCliHelper,
required this.gameOver,
}) {
mainBoard = List.generate(
heightBoard,
(_) = > List.filled(widthBoard, 0),
);
mainCpy = List.generate(
heightBoard,
(_) = > List.filled(widthBoard, 0),
);
Проект: игра «Тетрис» v. 1 173
}
initDrawMain();
// обработка нажатия клавиш по их ASCII-коду
void keyboardEventHandler(int key) {
var x = currentBlock.x;
var y = currentBlock.y;
}
switch (key) {
case 119: // W — поворот фигуры
rotateBlock();
case 97: // A — влево
if (!isFilledBlock(x - 1, y)) {
moveBlock(x - 1, y);
}
case 115: // S — вниз
if (!isFilledBlock(x, y + 1)) {
moveBlock(x, y + 1);
}
case 100: // D — вправо
if (!isFilledBlock(x + 1, y)) {
moveBlock(x + 1, y);
}
}
// сохранение текущего состояния игрового поля
void savePresentBoardToCpy() {
for (int i = 0; i < heightBoard - 1; i++) {
for (int j = 0; j < widthBoard - 1; j++) {
mainCpy[i][j] = mainBoard[i][j];
}
}
}
// Метод инициализации игровой доски
void initDrawMain() {
for (int i = 0; i < = heightBoard - 2; i++) {
for (int j = 0; j < = widthBoard - 2; j++) {
if (j = = 0 || j = = widthBoard - 2 || i = = heightBoard - 2) {
mainBoard[i][j] = posBoarder;
mainCpy[i][j] = posBoarder;
}
}
}
}
newBlock();
drawBoard();
// Метод отрисовки основной доски
void drawBoard() {
ansiCliHelper.gotoxy(0, 0);
for (int i = 0; i < heightBoard - 2; i++) {
for (int j = 0; j < widthBoard - 1; j++) {
switch (mainBoard[i][j]) {
case posFree:
ansiCliHelper.write('⬛');
case posFilled:
ansiCliHelper.write('⬜');
case posBoarder:
ansiCliHelper.write('⬜');
}
}
174 Глава 0 Установка и настройка рабочего окружения. Основы Dart
}
ansiCliHelper.write('\n');
}
ansiCliHelper.write('🟥');
ansiCliHelper.write('${'🟥' * 8}\n');
// Метод генерации нового блока и его добавления на основную доску
void newBlock() {
currentBlock = newBlockFunc();
var x = currentBlock.x;
// добавляем новый блок на основную доску
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
mainBoard[i][x + j] = mainCpy[i][x + j] + currentBlock[i][j];
}
}
}
// проверка на пересечение
if (mainBoard[i][x + j] > 1) {
gameOver(); // игра окончена
}
// Метод перемещения фигуры по основной доске
void moveBlock(int x2, int y2) {
// убираем фигуру с текущей позиции
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
if (currentBlock.x + j > = 0) {
mainBoard[currentBlock.y + i][currentBlock.x + j] -=
currentBlock[i][j];
}
}
}
// устанавливаем новую позицию
currentBlock.move(x2, y2);
// добавляем фигуру на новую позицию
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
// проверка левого края
if (currentBlock.x + j > = 0) {
mainBoard[currentBlock.y + i][currentBlock.x + j] +=
currentBlock[i][j];
}
}
}
}
drawBoard();
// Метод обработки поворота блока
void rotateBlock() {
// Временный блок с текущей фигурой
var tmpBlock = currentBlock.copyWith();
currentBlock.rotate(); // Поворачиваем фигуру
// Проверка того, что фигура не пересекается
// с границей или другими блоками ранее
// помещенных на доску фигур
if (isFilledBlock(tmpBlock.x, tmpBlock.y)) {
currentBlock = tmpBlock;
Проект: игра «Тетрис» v. 1 175
}
// обновляем текущую фигуру в классе Game
updateBlock(currentBlock);
var x = currentBlock.x;
var y = currentBlock.y;
// Обновляем основную доску
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
// убираем старую фигуру
mainBoard[y + i][x + j] -= tmpBlock[i][j];
}
}
}
// добавляем новую фигуру
mainBoard[y + i][x + j] += currentBlock[i][j];
drawBoard();
// Метод очистки заполненных строк
void clearLine() {
for (int j = 0; j < = heightBoard - 3; j++) {
// проверка заполненности строки
int i = 1;
while (i < = widthBoard - 3) {
if (mainBoard[j][i] = = posFree) {
break;
}
i++;
}
}
}
}
if (i = = widthBoard - 2) {
// если строка заполнена, очистка строки
// и сдвиг строк игровой доски вниз
for (int k = j; k > 0; k--) {
for (int idx = 1; idx < = widthBoard - 3; idx++) {
mainBoard[k][idx] = mainBoard[k - 1][idx];
}
}
// вызываем callback-функцию для увеличения очков
updateScore();
}
// Метод проверки возможности сдвига блока в заданном направлении
bool isFilledBlock(int x2, int y2) {
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
if (currentBlock[i][j] ! = 0 && mainCpy[y2 + i][x2 + j] ! = 0) {
return true;
}
}
}
return false;
}
Обратите внимание, что большая часть кода при переходе с процедурного на
объектно-ориентированный стиль написания никак не изменилась. Но в то же
время класс Board инкапсулировал в себе всю логику работы с игровой доской,
выведя наш код на абстракцию более высокого уровня.
176 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Разработка библиотеки game.dart
Теперь откройте файл game.dart. Как говорилось ранее, в нем мы объявим класс
Game, переместив в него весь функционал, отвечающий за запуск игры, главный
игровой цикл, работу с подпиской на прослушивание нажатий на клавиши и хранящий в себе экземпляр класса Board:
// game.dart
import 'dart:async';
import 'dart:io';
import 'ansi_cli_helper/ansi_cli_helper.dart';
import 'blocks/blocks.dart';
import 'board.dart';
final class Game {
late Board _board;
StreamSubscription? _subscription;
late Block currentBlock; // текущий блок
late Block nextBlock; // следующий блок
AnsiCliHelper ansiCliHelper;
bool _isGameOver = false;
int score = 0;
Game(this.ansiCliHelper) {
currentBlock = getNewRandomBlock();
nextBlock = getNewRandomBlock();
}
_board = Board(
currentBlock: currentBlock,
newBlockFunc: newBlock,
updateScore: updateScore,
updateBlock: updateBlock,
gameOver: gameOver,
ansiCliHelper: ansiCliHelper,
);
keyboardEventHandler();
// Метод обновления блока фигуры
void updateBlock(Block block) {
currentBlock = block;
}
// Метод обновления счета
void updateScore() {
score += 10;
}
// Метод генерации новой фигуры
Block newBlock() {
currentBlock = nextBlock;
nextBlock = getNewRandomBlock();
return currentBlock;
}
// Метод установки прослушивания нажатий клавиш
// и передачи ASCII-кода нажатой клавиши на уровень ниже
void keyboardEventHandler() {
stdin.echoMode = false;
stdin.lineMode = false;
_subscription = stdin.listen((data) {
int key = data.first;
Проект: игра «Тетрис» v. 1 177
}
_board.keyboardEventHandler(key);
});
// Метод запуска игры
Future<void> start() async {
// Запускаем игровой цикл
while (!isGameOver) {
nextStep();
printScore();
await Future.delayed(const Duration(milliseconds: 500));
}
}
// завершаем прослушивание нажатий клавиш
_subscription?.cancel();
ansiCliHelper.setTextColor(AnsiTextColor.yellow);
ansiCliHelper.gotoxy(0, 22);
stdout.write('===============\n'
'~~~Game Over~~~\n'
'===============\n');
ansiCliHelper.setBackgroundColor(AnsiBackgroundColor.blue);
stdout.writeln('Score: $score ');
await Future.delayed(const Duration(seconds: 5));
ansiCliHelper.reset();
// Метод вывода текущего счета в игре
void printScore() {
ansiCliHelper.gotoxy(30, 10);
ansiCliHelper.setTextColor(AnsiTextColor.red);
ansiCliHelper.write('Score:
$score');
ansiCliHelper.setTextColor(AnsiTextColor.white);
}
bool get isGameOver = > _isGameOver;
void gameOver(){
_isGameOver = true;
}
// Метод обработки шага игрового цикла
void nextStep() {
var x = currentBlock.x;
var y = currentBlock.y;
}
}
if (!_board.isFilledBlock(x, y + 1)) {
_board.moveBlock(x, y + 1);
} else {
_board.clearLine();
_board.savePresentBoardToCpy();
_board.newBlock();
_board.drawBoard();
}
Компоновка библиотек и запуск игры
Откройте в папке lib библиотеку tetris_cli.dart и добавьте в нее следующий экспорт:
// lib/tetris_cli.dart
export 'src/game.dart';
export 'src/ansi_cli_helper/ansi_cli_helper.dart';
178 Глава 0 Установка и настройка рабочего окружения. Основы Dart
Последнее, что нам осталось, — скомпоновать весь функционал на уровне библиотеки main.dart, и можно будет запускать игру:
// bin/main.dart
import 'package:tetris_cli/tetris_cli.dart';
void main(List<String> arguments) {
Game(
AnsiCliHelper()
..reset()
..hideCursor(),
).start();
}
Поздравляю! Вы прошли путь написания игры «Тетрис» от процедурного стиля
до объектно-ориентированного. Запомните этот опыт и сравните, каким было ваше
восприятие кода в начале пути и как с этим обстоит дело сейчас. Насколько было
сложно переходить от одной парадигмы программирования к другой и какая, по
вашим ощущениям, позволяет писать лучше поддерживаемый, тестируемый, читаемый и расширяемый код? Почему? В каких случаях? А вы уверены?
Постарайтесь на всю жизнь запомнить момент, когда произошел слом мышления
и объектно-ориентированное программирование начало восприниматься как само
собой разумеющееся. Это пригодится вам в те моменты, когда придется стать ментором или обучать людей программированию. Вспоминая, как вы себя чувствовали
в их ситуации и каких объяснений вам не хватало, вы сможете быстрее помочь этим
людям расти как разработчикам.
Задания на модификацию проекта
В следующий раз мы портируем игру на Flutter. А пока можете выполнить следующие задания по внесению изменений в существующую кодовую базу.
1. Обеспечьте возможность после завершения игры, не выходя из приложения,
запустить новую игру.
2. Добавьте команду установки игры на паузу и снятия с нее.
3. Добавьте игроку возможность просматривать набранные очки в процессе
самой игры, а не только по ее завершении.
4. Введите понятие уровней и при каждом достижении порога (допустим,
с шагом 50 очков) переводите игрока на следующий уровень, увеличивая
скорость игры.
5. Добавьте меню с выбором уровня сложности (скорости падения блоков)
перед стартом игры.
6. Доработайте функционал таким образом, чтобы рядом с игровым полем отображалась фигура, которая появится следующей.
Резюме
В данной главе мы рассмотрели, как выполнить установку Flutter и настройку рабочего окружения в различных операционных системах, а также познакомились с базовым синтаксисом Dart, которого достаточно для освоения следующих глав книги.
Вопросы для самопроверки 179
В целом с момента своего появления этот язык программирования прошел довольно тернистый путь. Все дело в том, что изначально Dart позиционировался
Google как язык программирования для замены JavaScript, что сыграло с ним довольно злую шутку. Несмотря на интерес сообщества программистов, его не стали
повсеместно использовать, и чаще всего упоминание об этом языке программирования можно было встретить на форумах при описании pet-проекта. Единственное,
что вдохнуло новую жизнь в забытый всеми Dart, — появление Flutter SDK.
Вопросы для самопроверки
1. Какие встроенные типы данных предоставляет Dart?
2. Что такое список? Как использовать список в Dart в качестве массива? Какие
методы списка вы знаете? Расскажите, за что они отвечают.
3. Что такое запись? Чем запись отличается от кортежа и какие типы полей
у нее существуют? Как обращаться к полям записи?
4. Что такое множество? Приведите его ключевые особенности. Какие методы
множества вы знаете? Расскажите, за что они отвечают.
5. Что такое таблица, или карта, Map? Приведите ее ключевые особенности.
Какие методы Map вы знаете? Расскажите, за что они отвечают.
6. Какой тип данных следует использовать, если необходимо объявляемой
переменной присваивать значения различных типов данных?
7. В чем сходство и различие dynamic и Object? Когда и что из них лучше использовать?
8. В чем схожи, а чем различаются модификаторы final и const, final и late?
9. Перечислите ключевые моменты концепции null-безопасности (null-safety)?
10. Какие операторы существуют в Dart?
11. Какие способы деструктурирования объектов вы знаете? Приведите примеры.
12. Перечислите все способы объявления аргументов функции.
13. Какие типы конструкторов класса существуют в Dart?
14. Как объявляются и для чего используются статические переменные и методы
класса?
15. Можно ли в Dart использовать множественное наследование? Какое ключевое
слово применяется для наследования от базового класса?
16. Для чего и как переопределяются методы базового класса в производном?
17. Что такое абстрактный класс и интерфейс? Чем они схожи, а в чем их различие?
18. Что такое исключение? Для чего используются исключения?
19. В каких случаях следует задействовать асинхронное программирование
и изоляты?
Глава 1
КРАТКАЯ ИСТОРИЯ И ПРИНЦИПЫ РАБОТЫ
FLUTTER
1.1. Краткая история и основные нюансы
В 2015 году на Dart Developer Summit был анонсирован фреймворк, с которого
берет свое начало Flutter, а именно Project Sky, работа над которым стартовала
годом ранее. На нем должны были разрабатываться приложения под Android на
языке программирования Dart. Наверное, в этот момент в головах разработчиков
и возник вопрос: «А как всех джавистов пересадить на Dart, когда Kotlin набирает
обороты и он куда более привычен Android-разработчикам?» Ответ на этот вопрос
не заставил себя долго ждать, и уже в мае 2017 года на ежегодной конференции
Google I/O была представлена альфа-версия Flutter, которая позиционировалась
как UI-фреймворк для Android и iOS. Бета-версия вышла в середине марта 2018-го,
а 5 декабря того же года в рамках конференции Flutter Live фреймворк окончательно
был переведен в релиз и представлен как кросс-платформенный набор инструментов
для разработки приложений с графическим пользовательским интерфейсом различной сложности под iOS и Android, позволяющий напрямую взаимодействовать
с базовыми сервисами целевых платформ. Постепенно список целевых платформ
увеличивался, и сейчас в рамках одной кодовой базы можно вести разработку
для iOS, Android, macOS, Windows, Linux и Web. Конечно, это не значит, что код,
написанный под Android, спокойно будет работать в Web без дополнительного
размахивания напильником, плясок с бубном и матерных заклинаний в адрес
разработчика, но сама концепция и усилия Google, приложенные к ее реализации,
способствуют тому, чтобы пристально присмотреться к этому инструменту.
Фреймворк Flutter вдохнул вторую жизнь в Dart, который был выбран для него
в качестве основного языка программирования и при своем появлении позиционировался как убийца JavaScript, что сыграло с ним злую шутку. Из-за своей сложной
истории Dart прошел огромный путь, и фактически его первая версия — совершенно
другой язык программирования, имеющий мало общего с текущей реализацией.
Архитектура Flutter для всех платформ, за исключением Web, состоит из трех
слоев (рис. 1.1):
y Flutter framework;
y Flutter engine;
y Embedder.
1.1. Краткая история и основные нюансы 181
Рис. 1.1. Архитектура Flutter SDK (Creative Commons Attribution 4.0 International License, https://flutter.dev/)
Каждый из представленных слоев имеет несколько независимых библиотек,
которые расположены на различных уровнях и спроектированы таким образом,
что библиотека более верхнего уровня зависит только от библиотек или библиотеки следующего за ней нижнего уровня. Такой подход делает любую из библиотек заменяемой и дает возможность разработчикам различных операционных
систем (ОС) реализовывать свой порт фреймворка под данную ОС. Так, например, ООО «Открытая мобильная платформа» в 2023 году портировала (https://
habr.com/ru/articles/761176/) Flutter под отечественную мобильную операционную
систему «ОС Аврора».
Слой Embedder позволяет упаковывать разрабатываемые приложения на Flutter
под различные целевые платформы таким образом, как будто они были написаны
на поддерживаемом ими языке программирования. Таким образом, слой Embedder
обеспечивает для приложения точку входа, отвечает за взаимодействие с целевой
операционной системой для доступа к ее службам (визуализация, ввод/вывод и т. д.)
и управляет циклом обработки сообщений. Для каждой из поддерживаемых платформ этот слой написан на подходящем для нее языке программирования:
y Java/Kotlin и С++ для Android;
y Objective-C и Swift для iOS и macOS;
y C++ для Windows и Linux.
182 Глава 1 Краткая история и принципы работы Flutter
Именно благодаря слою Embedder разрабатываемое на Flutter приложение может
как быть самостоятельным, так и интегрироваться в качестве модуля в нативные
приложения.
Слой Flutter engine в основном написан на C++ и обеспечивает низкоуровневую
реализацию API Flutter: работу с графикой (графический движок Skia/Impeller),
операции ввода-вывода, межсетевое взаимодействие и т. д., а также отвечает за
поддержку ряда специальных возможностей, среду выполнения и компиляции
Dart. Именно благодаря собственному графическому движку, ответственному
за отрисовку пользовательского интерфейса на любой из платформ, Flutter SDK
не пошел по стопам React Native. То есть все виджеты отрисовываются средствами
Flutter, и нет никаких дополнительных прослоек, что сказывается на плавности их
отрисовки, не уступающей плавности нативного приложения. Изначально для всех
целевых платформ фреймворк задействовал движок Skia, но сейчас он применяется
только при запуске Web- и desktop-приложений. Для iOS и Android (с поддержкой Vulkan, иначе используется Skia) был разработан новый графический движок
с более предсказуемой производительностью — Impeller.
Чаще всего разработчики и не подозревают о наличии рассмотренных слоев и взаимодействуют только со слоем Flutter framework, написанным на Dart.
Он включает в себя довольно богатый набор базовых библиотек, расположенных
на различных уровнях текущего слоя.
y Уровень Foundational предоставляет базовые классы и функции, которые
используются для создания приложения, а также содержит API-интерфейсы
для связи со слоем Flutter engine.
y Уровень Rendering обеспечивает абстракцию для работы с компоновкой
виджетов, позволяет построить дерево визуализируемых объектов и ди
намически управлять ими, что автоматически отразится на структуре
дерева.
y Уровень Widgets представляет собой композицию абстракций и модель реактивного программирования. Так, например, у каждого объекта на уровне
рендеринга имеется соответствующий класс на уровне Widgets, что позволяет
определять повторно используемые комбинации классов.
y Библиотеки Material и Cupertino предоставляют разработчику наборы элементов управления, которые используют примитивы композиции уровня
Widgets для реализации виджетов в стиле Material или iOS.
Давайте разберем прочитанное вами словоблудие. Как в Dart существует концепция, что все является объектом, так и во Flutter имеется своя концепция: все
является виджетом. То есть графический пользовательский интерфейс разрабатываемых вами приложений полностью состоит из виджетов в различной компоновке.
Каждому виджету соответствует свой элемент на уровне Rendering, в соответствии
с чем на этапе сборки Flutter переводит виджеты, которые вы использовали в коде,
в соответствующее дерево элементов (рис. 1.2).
Дерево виджетов отвечает за конфигурирование, а именно декларативное описание пользовательского интерфейса и хранение свойств виджетов. Дерево элементов
отвечает за управление, то есть элементы управляют жизненным циклом виджетов,
1.1. Краткая история и основные нюансы 183
а также связывают их в древовидную иерархию и с объектами рендеринга. Дерево
рендеринга отвечает за отрисовку виджетов с учетом их положения и ограничений.
Рис. 1.2. Приведение дерева виджетов к дереву рендеринга
(Creative Commons Attribution 4.0 International License, https://flutter.dev/)
Наглядный пример — дерево виджетов стартового приложения, которое будет
встречать вас каждый раз при создании нового проекта на Flutter (рис. 1.3).
Рис. 1.3. Дерево виджетов стартового приложения
Пока сильно не задумывайтесь о названиях виджетов, а запомните, что самый
верхний виджет всегда обозначается [root], и присмотритесь к рисунку получше.
184 Глава 1 Краткая история и принципы работы Flutter
С первого взгляда не скажешь, что это приложение состоит из такого количества
виджетов, и, не привыкнув к такому положению дел, в дереве виджетов можно
легко запутаться.
Несмотря на то что Dart — «однопоточный» язык программирования, приложения на Flutter используют четыре исполнителя задач потоков.
y Platform Task Runner управляет основным потоком в приложениях, написанных на Flutter, и используется для взаимодействия Embedder с Flutter engine
(и наоборот), а также для обработки сообщений между целевой платформой
и приложением, работой с таймером и т. д. Все задачи, связанные с Flutter
engine, выполняются в основном потоке, так как их работа в других потоках
может привести к ошибкам.
y UI Task Runner предоставляет поток, где Flutter engine выполняет весь код
Dart для корневого (главного) изолята, который управляет UI (рендеринг
кадров, анимации, обработка событий ввода/вывода и т. д.) и обрабатывает
логику приложения. Длительные операции в главном изоляте могут вызвать
подвисания пользовательского интерфейса. Поэтому тяжелые вычисления
следует выполнять во второстепенных изолятах, которые работают в отдельных потоках в виртуальной машине Dart и не могут напрямую взаимодействовать с фреймворком Flutter.
y Raster Task Runner отвечает за выполнение задач, связанных с растеризацией. Он преобразует древовидную структуру слоев виджетов, описываемую
в Dart-коде, в команды отрисовки графического процессора (GPU). Растровый и UI потоки часто работают параллельно, поэтому чрезмерная нагрузка
на растровый поток может вызвать неровности в анимации.
y IO Task Runner выполняет операции ввода-вывода, слишком затратные
для выполнения в растровом потоке, а именно чтение изображений из хранилища ресурсов и их декомпрессию. Он подготавливает изображения для
рендеринга, преобразуя их в формат, подходящий для GPU, и загружает их
на графический процессор. Чтобы иметь безопасный доступ к GPU, IO Task
Runner использует специальный контекст, связанный с растровым потоком.
Работа с этими исполнителями потоков идет в слое Flutter engine, а вот создаются и управляются они слоем Embedder. В идеале под каждый исполнитель должен
выделяться свой поток, но в зависимости от конфигурации (целевой платформы)
в одном потоке могут работать несколько исполнителей задач. Следует отметить,
что слой Embedder должен обеспечивать несменяемость стартовой конфигурации
запуска исполнителей по потокам на всем протяжении работы Flutter engine.
Главной особенностью Flutter, из-за которой он так полюбился огромному
количеству разработчиков, является Hot Reload (горячая перезагрузка). Он дает
возможность не перекомпилировать приложение после каждого внесения изменения в его код, что значительно экономит время создания пользовательского
интерфейса, отладки и добавления новой функциональности. Механизм работает
за счет внедрения файлов с обновленным кодом в запущенную виртуальную машину Dart, после чего фреймворк Flutter запускает автоматическое перестроение
дерева виджетов.
1.3. Создание первого проекта и его запуск 185
1.2. Как обстоят дела с разработкой под Web
С момента своего появления Dart компилировался в JavaScript, из-за чего портирование Flutter для Web пошло по другому пути. Это связано с тем, что слой
Flutter engine написан на C++ и ориентирован на взаимодействие с операционной
системой, а не браузером (рис. 1.4).
Рис. 1.4. Архитектура Flutter Web (Creative Commons Attribution 4.0 International License, https://flutter.dev/)
Flutter Web обеспечивает повторную реализацию слоя Flutter engine поверх
стандартных API браузера. В связи с этим существуют два способа рендеринга содержимого Flutter для Web: HTML и WebGL. В первом случае Flutter использует
HTML, CSS, Canvas и SVG, а для рендеринга в WebGL — версию Skia (CanvasKit),
скомпилированную в WebAssembly.
1.3. Создание первого проекта и его запуск
Для запуска процесса создания нового проекта на Flutter откройте VS Code, используйте сочетание клавиш Ctrl+Shift+P и введите в появившейся командной строке
Flutter: New Project. Данная команда может появиться в списке команд до того, как
будет набрана полностью. В этом случае просто выберите ее из списка (рис. 1.5).
Рис. 1.5. Создание нового проекта
186 Глава 1 Краткая история и принципы работы Flutter
Далее вам будет предложено выбрать шаблон создаваемого проекта. Это обычное стартовое или пустое приложение, модуль, плагин и т. д. Выберите первый
вариант (рис. 1.6)
Рис. 1.6. Конфигурация создаваемого проекта
На следующих шагах необходимо выбрать папку, в которой будет располагаться проект, и его имя (оставьте по умолчанию). После того как все будет успешно
сделано, VS Code может спросить вас: «Доверяете ли вы сами себе?» (рис. 1.7).
Рис. 1.7. Добавление создаваемого проекта в Workspace Trust
1.3. Создание первого проекта и его запуск 187
Если у вас нет доверия даже к себе, то лучше отложите книгу и забросьте программирование — вам прямой путь в кибербезопасность. 😉
В итоге VS Code должен выглядеть примерно так, как на рис. 1.8.
Рис. 1.8. Созданный проект
Перед запуском проекта следует выбрать
эмулятор или целевую платформу, на которой он будет производиться. Для этого
нажмите на поле No Device в правом нижнем
углу (рис. 1.9).
В верхней части IDE должно появиться
меню с доступными устройствами и эмуляторами (рис. 1.10).
Рис. 1.9. Выбор устройства для запуска проекта
Рис. 1.10. Выбор устройства для запуска проекта
188 Глава 1 Краткая история и принципы работы Flutter
Выберите первый эмулятор, запустите его и дождитесь загрузки операционной
системы. Чтобы проект стартовал в режиме отладки (debug), достаточно нажать
клавишу F5, а воспользовавшись сочетанием Ctrl+F5, вы запустите код без данного режима. Если же вы любитель ручной
работы, подразумевающей шевеление мышью, то выберите необходимый формат запуска из указанных над функцией main, которая является точкой входа в приложение
(рис. 1.11).
Первый запуск на эмуляторе может занять
Рис. 1.11. Запуск приложения
некоторое время. Поэтому в зависимости от
конфигурации вашего железа можно успеть
заварить себе чашечку крепкого кофе или налить чая. Насладившись им, вы должны
увидеть следующий интерфейс (рис. 1.12), где после нажатия кнопки + значение
в середине экрана будет увеличиваться на единицу.
Для демонстрации Hot Reload увеличим размер текста, изменив конфигурацию
константного виджета Text с
const Text(
'You have pushed the button this many times:',
),
на
const Text(
'You have pushed the button this many times:',
style: TextStyle(
fontSize: 24,
),
textAlign: TextAlign.center,
),
После внесения изменений для их сохранения и применения к пользовательскому интерфейсу запущенного приложения нажмите Ctrl+S (рис. 1.13).
Использование эмулятора в зависимости от объема оперативной памяти, применяемого жесткого диска (HDD, SDD), мощности процессора и так далее может
довольно сильно нагружать систему. В таком случае при разработке мобильного
приложения можно прибегнуть к хитрости — вести основную разработку на деск
топной или веб-платформе. Естественно, в этом случае не получится отработать
жесты и придется следить за тем, чтобы пакеты, выбранные в качестве зависимостей,
поддерживали не только Android или iOS. Такой подход, если изменить размер окна
десктопного приложения, позволит заниматься прототипированием не только под
смартфоны, но и под планшеты.
Завершите приложение и выключите эмулятор, выбрав в качестве нового устройства веб-браузер или десктопную платформу. В моем случае это будет Windows
(рис. 1.14).
Благодаря Hot Reload мы можем запустить приложение один раз без его полной
перекомпиляции (в большинстве случаев), изменить размер под формат смартфона
и продолжить разработку (рис. 1.15).
1.3. Создание первого проекта и его запуск 189
Рис. 1.12. Запущенное приложение
Рис. 1.13. Пример работы Hot Reload
Рис. 1.14. Запуск проекта под Windows
190 Глава 1 Краткая история и принципы работы Flutter
Несмотря на кажущуюся громоздкость стартового приложения, оно не такое уж и большое.
Чтобы это понять, достаточно удалить все комментарии:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: const MyHomePage(
title: 'Flutter Demo Home Page',
),
);
}
Рис. 1.15. И это запуск проекта под Windows
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
}
@override
State<MyHomePage> createState() = > _MyHomePageState();
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(
context,
).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
1.4. Структура проекта на Flutter 191
Text(
'$_counter',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
],
),
}
}
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
В функцию runApp подается виджет, который будет корневым для приложения
и растянется на весь экран телефона. С другими виджетами и их параметрами вы
познакомитесь позже, а вначале сосредоточим внимание на структуре проекта,
существующих типах виджетов, жизненном цикле приложения на Flutter и т. д.
1.4. Структура проекта на Flutter
Структура папок созданного проекта должна
выглядеть следующим образом (рис. 1.16)
Папка .dart_tool содержит информацию о Dart
SDK, кэшированных пакетах, артефактах сборки приложения и т. д. Так, например, ее файл
package_config.json используется системой
разрешения пакетов Dart для определения доступных версий пакетов для вашего проекта.
Каталог .idea создается на тот случай, чтобы проект можно было открывать в Android
Studio или IntelliJ IDEA. Он содержит файлы
конфигурации (.xml) проекта, характерные для
данных IDE.
Каталоги android, ios, linux, macos, web, windows содержат код, зависящий от платформы, который
обеспечивает для приложения точку входа (для
конкретной платформы) и отвечает за взаимодействие с целевой операционной системой для
доступа к ее службам. Если вы планируете разрабатывать программу для всех перечисленных
платформ, то обеспокойтесь тем, чтобы пакеты,
выбранные в качестве внешних зависимостей,
поддерживали их. В некоторых папках также
содержатся манифесты разрешений, где необходимо прописывать ресурсы операционной
Рис. 1.16. Структура нового проекта
192 Глава 1 Краткая история и принципы работы Flutter
системы, к которым приложение может обращаться во время работы (Интернет,
камера, контакты и т. д.).
В каталоге build содержатся результаты сборки проекта под целевую платформу.
Например, скомпилированный ранее проект (в режиме отладки) для Windows будет
располагаться по следующему пути: C:\...\flutter_application_1\build\windows\
x64\runner\Debug>flutter_application_1.ex.
В папке lib будет храниться весь исходный код приложения. Если откроете ее, то
увидите, что в данный момент там имеется только файл main.dart, предоставляющий
точку входа во Flutter-приложение (функция main), и сам код демонстрационной
программы. Что же касается unit- и виджет-тестов, то они помещены в папку test.
Далее в порядке очередности разберем файлы проекта.
1. .gitignore содержит список файлов (их расширений) и папок проекта, которые должны игнорироваться системой контроля версий git. Это позволяет
не загружать в репозиторий лишний мусор, получившийся в ходе разработки
или сборки проекта.
2. .metadata содержит данные по параметрам Flutter-проекта (версия фреймворка, тип проекта, данные для миграции в другую версию фреймворка и т. д.),
которые использует IDE. Его не рекомендуется править вручную — это может
привести к неприятным последствиям.
3. analysis_options.yaml содержит набор правил для анализатора (линтера)
Dart-кода. По умолчанию используется стандартный набор правил, с полным
набором которых можно ознакомиться на сайте Dart (https://dart.dev/tools/linterrules). Внося изменения в данный файл, можно отключить какие-то правила,
добавить поддержку экспериментальных возможностей Dart (https://dart.dev/
tools/experiment-flags), чтобы «пощупать» их до релиза новой версии, либо
добавить собственные правила анализа кода (https://dart.dev/tools/analysis#theanalysis-options-file).
4. название_проекта.iml хранит данные о проекте и его настройках для Android
Studio или IntelliJ IDEA.
5. pubspec.yaml содержит настройки сборки, зависимости и конфигурацию
проекта и еще много чего. С содержимым этого файла мы познакомимся
поближе в следующем разделе.
6. pubspec.lock используется менеджером пакетов Pub и содержит точные версии
всех установленных зависимостей проекта с их прямыми и транзитивными
зависимостями, описываемых в pubspec.yaml. Обычно в случае разработки
пакета (библиотеки) его рекомендуют добавлять в .gitignore, что позволит
поддерживать на компьютере разработчика актуальные версии зависимостей.
7. README.md может содержать информацию о проекте, его зависимостях, лицензии, под которой он поставляется, способах установки, примеры использования и т. д.
Обычно в проект добавляют еще как минимум одну папку — assets, в которой
будут храниться значки, шрифты, изображения, мелодии и т. д. А при желании выделить часть кода приложения в отдельные пакеты для повторного использования
в дальнейшем можно создать каталог packages, где будут размещаться пакеты с вынесенным кодом, подключаемые в качестве зависимостей в pubspec.yaml.
1.5. Структура файла pubspec.yaml 193
1.5. Структура файла pubspec.yaml
Как уже говорилось, данный файл содержит настройки сборки, зависимости и конфигурацию проекта. Но это далеко не все. Так, при публикации разрабатываемого
пакета (плагина) в pub.dev файл pubspec.yaml должен содержать еще указание
поддерживаемых платформ, скриншоты с описанием и т. д. Далее приведены его
основные поля:
# Имя проекта
name: flutter_application_1
# Описание проекта
description: "A new Flutter project."
# Определяет репозиторий, в который будет публиковаться проект
# после его сборки. Если значение 'none', то проект не будет публиковаться
publish_to: 'none'
# Текущая версия проекта
version: 1.0.0+1
# Ссылка на домашнюю страницу проекта
homepage: https://www.example.com
# Ссылка на документацию проекта
documentation: https://www.example.com/docs
# Ссылка на репозиторий
repository: https://github.com/<user>/<repository>
# Ссылка на трекер ошибок
issue_tracker: https://github.com/<user>/<repository>/issues
# Поле используется, если пакет предоставляет один или несколько
# своих скриптов в качестве исполняемого файла,
# который можно запустить непосредственно из командной строки
executables:
# имя пакета: имя исполняемого файла
<name-of-executable>: <Dart-script-from-bin>
# Платформы, которые поддерживает пакет
platforms:
android:
ios:
linux:
macos:
web:
windows:
# Ссылки на пожертвование
funding:
- https://www.example.com/donate
- https://www.patreon.com/some-account
# Список файлов, которые не проверяются на наличие секретов
# Когда публикуется пакет, pub выполняет поиск потенциальных
# утечек секретных данных, ключей API или криптографических ключей.
# Поскольку этот процесс неидеален, мы можем явно указать список файлов,
# которые не проверяются на наличие секретов
false_secrets:
- /lib/src/hardcoded_server_api_key.dart
- /test/app_certificates/*.pem
194 Глава 1 Краткая история и принципы работы Flutter
# Если пакет публикуется в pub.dev, желательно использовать текущее поле
# для демонстрации скриншотов предоставляемого функционала/виджетов.
# Имеющиеся ограничения:
# 👉 Не более 10 скриншотов
# 👉 Описание на каждый скриншот не более 160 символов
# 👉 Размер файла: не более 4 МБ на изображение
# 👉 Типы файлов: png, jpg, gif или webp
# 👉 Можно использовать как статические, так
#
и анимированные изображения.
screenshots:
- description: 'bla-bla-bla' # Описание
path: path/to/image/in/package/200x250.webp # Путь к изображению
- description: 'App screenshot '
path: path/to/image/in/package.png
# Если пакет публикуется в pub.dev, автор может указать его категорию
# для облегчения поиска с помощью фильтров в pub.dev.
# Ссылка на существующие категории: https://pub.dev/topics
# Требования к оформлению.
# 👉 Пакету можно задать не более чем 5 категорий.
# 👉 Требования к названию категории пакетов.
#
👉 Используйте от 2 до 32 символов.
#
👉 Используйте только строчные буквенно-цифровые
#
символы или дефисы (a-z, 0-9, -).
#
👉 Не используйте два последовательных дефиса (--).
#
👉 Имя категории содержит только строчные буквы алфавита (a-z).
#
👉 Имя может заканчиваться буквой или цифрой (a-z или 0-9)
topics:
- ui
- http
# При публикации пакета pub может давать рекомендации
# по безопасности во время разрешения зависимостей.
# Поле ignored_advisories содержит список разрешенных
# запускаемых рекомендаций, которые не относятся к пакету.
# Подробнее: https://dart.dev/tools/pub/security-advisories
ignored_advisories:
- CVE-2020-12345
# Конфигурация окружения
environment:
# Версия Dart SDK, которую использует проект.
# Можно также задавать диапазоны версий: '> =1.2.3 <2.0.0'
sdk: '> =3.2.5 <4.0.0'
# Список зависимостей проекта
# В качестве зависимости может указываться:
# 👉 пакет с pub.dev;
# 👉 локальный пакет;
# 👉 пакет из репозитория
dependencies:
# dependency_name: <version>
# Зависимость от Flutter SDK. Данная зависимость
# необходима для каждого Flutter-проекта, так как Flutter
# использует свой собственный Dart SDK
flutter:
sdk: flutter
# Зависимость от библиотеки с иконками для iOS-стиля
cupertino_icons: ^1.0.2 # пакет с pub.dev
# локальный пакет
mad_package:
# относительный путь к папке с пакетом
path: ../mad_package
1.6. Типы виджетов во Flutter 195
# пакет из репозитория
mad_package1:
git: https://github.com/MADTeacher/mad_package.git
# Список зависимостей, используемых только в процессе разработки
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
# Настройки Flutter
flutter:
# Флаг, который указывает, что проект использует Material Design 3
uses-material-design: true
assets: # ассеты приложения/пакета
- assets/images/ # Путь к папке с изображениями
fonts:
- family: Poppins # Название семейства шрифтов
fonts: # Путь к шрифту в папке assets/fonts
- asset: assets/fonts/Poppins-Regular.ttf
1.6. Типы виджетов во Flutter
Во Flutter существует два типа виджетов:
y StatelessWidget;
y StatefulWidget.
1.6.1. StatelessWidget
— это виджет, который не имеет изменяемого состояния. В конструктор такого виджета передается состояние, которое не изменяется на протяжении времени его жизни. Значит ли это, что их не придется перерисовывать
в процессе работы вашего приложения? Нет. Применяемые для разработки
менеджеры состояния (BLoC, Redux, mobX и т. д.) зачастую используют именно
эти виджеты для отображения необходимых данных на пользовательском интерфейсе, беря на себя ответственность за отслеживание и изменение состояния
приложения либо его отдельного модуля, после чего запускается процесс создания нового экземпляра класса виджета и его замены на дереве. Говоря другими
словами, с помощью StatelessWidget вы не можете в коде самого виджета описать
бизнес-логику и изменить состояние приложения, так как для этого требуются
дополнительные инструменты.
Здесь возникает вполне закономерный вопрос: а что же за зверь такой это состояние? Представим следующую ситуацию. У нас есть виджет «Переключатель»,
который позволяет включать и выключать свет в комнате. При использовании
StatelessWidget мы будем в конструктор создаваемого экземпляра класса виджета
передавать явный параметр булева типа, то есть задавать состояние — отображать для пользователя, включен или выключен свет в комнате, без возможности
перевести этот переключатель в другое положение средствами самого виджета,
не прибегая к сторонней помощи. А StatefulWidget позволяет в самом виджете
не только хранить начальное состояние переключателя, которое передается ему
StatelessWidget
196 Глава 1 Краткая история и принципы работы Flutter
с помощью конструктора класса, либо использовать значение по умолчанию при
его инициализации, но и изменять его, вызывая свою перерисовку на графическом
пользовательском интерфейсе.
В процессе написания своего виджета с неизменяемым состоянием нам необходимо наследовать от класса StatelessWidget, переопределив метод build.
Чтобы ближе познакомиться с данным типом виджета, создайте новый проект
ex_stateless_widget1 или вносите изменения в тот, который создали до этого.
В качестве примера создадим виджет MyTextWidget, отвечающий за отображение
передаваемой в него текстовой информации, с рядом конфигурируемых параметров
при создании экземпляра класса виджета:
// baseURL/1/1.6/ex_stateful_widget1/lib/main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyTextWidget extends StatelessWidget {
final String text;
final Color textColor;
final double fontSize;
const MyTextWidget(
{super.key,
required this.text,
this.textColor = Colors.black,
this.fontSize = 14});
}
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(color: textColor, fontSize: fontSize),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyTextWidget(
text: 'Test1',
),
1.6. Типы виджетов во Flutter 197
MyTextWidget(
text: 'Test2',
textColor: Colors.indigo,
),
MyTextWidget(
text: 'Test3',
textColor: Colors.orange,
fontSize: 20,
)
],
),
),
),
}
}
);
В конструкторе класса MyTextWidget только один обязательный аргумент — text.
Все остальные могут принимать значения по умолчанию. Обратите внимание на то,
что конструктор объявлен как константный — это позволяет не создавать каждый
раз новый объект при вызове конструктора класса с одинаковыми параметрами, что
в целом положительно сказывается на производительности приложения. Говоря
другими словами, экземпляры классов, создаваемые посредством конструктора,
вызывают переопределенный метод build единожды, и так продолжается до тех пор,
пока не изменятся значения аргументов, передаваемых в константный конструктор
класса. Такой конструктор вызывается явным указанием ключевого слова const.
В нашем случае он используется перед виджетом Scaffold и распространяется на
его дочерние виджеты.
Переопределение метода Widget build(BuildContext context) возвращает компонуемый нами виджет, который добавляется в дерево виджетов, а также является
узлом дерева элементов, посредством которого мы можем обращаться к различным
свойствам виджетов, находящихся на различных уровнях его иерархии.
Далее приведен внешний вид запущенного в эмуляторе приложения (рис. 1.17).
Рис. 1.17. Пример создания пользовательского StatelessWidget
198 Глава 1 Краткая история и принципы работы Flutter
А теперь зададим себе вопрос: что значит — не может менять свое состояние?
Это проще всего пояснить на примере, немного изменив код, генерируемый Flutter
при создании нового проекта:
// baseURL/1/1.6/ex_stateful_widget2/lib/main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: MyHomePage(),
);
}
class MyHomePage extends StatelessWidget {
int counter;
String title;
MyHomePage({
super.key,
this.title = 'Flutter Demo Home Page',
this.counter = 0,
});
void _incrementCounter() {
counter++;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor:Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
1.6. Типы виджетов во Flutter 199
}
}
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
Сколько бы раз вы ни нажали кнопку, значение счетчика на графическом
пользовательском интерфейсе не изменится относительно того, что было передано
в конструктор виджета. Именно это и значит, что StatelessWidget не может менять
свое состояние.
Чтобы лучше разобраться с работой константного конструктора, модифицируем
код следующим образом:
// baseURL/1/1.6/ex_stateful_widget3/lib/main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
}
@override
State<MyHomePage> createState() = > _MyHomePageState();
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
debugPrint('Count value = $_counter');
}
@override
Widget build(BuildContext context) {
debugPrint('Run build MyHomePage widget');
200 Глава 1 Краткая история и принципы работы Flutter
}
}
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
MyTextInfo(
text: 'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
class MyTextInfo extends StatelessWidget {
final String text;
MyTextInfo({super.key, required this.text}) {
debugPrint('MyTextInfo widget init');
}
}
@override
Widget build(BuildContext context) {
debugPrint('Run build MyTextInfo widget');
return Text(
text,
);
}
В данном случае мы объявили пользовательский виджет, который каждый раз
при создании будет выводить сообщение в терминал (при вызове конструктора
и метода build). В результате нажатия кнопки у виджета MyHomePage изменится состояние значение счетчика (так как это StatefulWidget), после чего будет вызвана
перерисовка всего виджета. Таким образом, каждый раз при нажатии кнопки виджет
MyTextInfo станет пересоздаваться:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
Run build MyHomePage widget
MyTextInfo widget init
Run build MyTextInfo widget
Count value = 1
Run build MyHomePage widget
MyTextInfo widget init
Run build MyTextInfo widget
Count value = 2
Run build MyHomePage widget
MyTextInfo widget init
Run build MyTextInfo widget
1.6. Типы виджетов во Flutter 201
Но что, если у нас таких виджетов много и затрачивать ресурсы системы на
их пересоздание не хочется, ведь в них ничего не меняется? Здесь нас и выручит константный конструктор, благодаря которому экземпляр класса виджета
MyTextInfo создастся единожды и у него один раз за время существования родительского виджета вызывается метод build. Для этого изменим его конструктор
со следующего:
MyTextInfo({super.key, required this.text}) {
debugPrint('MyTextInfo widget init');
}
на такой:
const MyTextInfo({super.key, required this.text});
Тело конструктора с сообщением пришлось убрать, так как у константного
конструктора его не может быть.
На следующем шаге добавьте ключевое слово const перед создаваемым экземпляром класса MyTextInfo, после чего перезапустите программу:
const MyTextInfo(
text: 'You have pushed the button this many times:',
),
На выходе терминала можно заметить то, о чем говорилось ранее, — экземпляр
класса MyTextInfo создан единожды:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
Run build MyHomePage
Run build MyTextInfo
Count value = 1
Run build MyHomePage
Count value = 2
Run build MyHomePage
Count value = 3
Run build MyHomePage
widget
widget
widget
widget
widget
Согласно документации метод build у StatelessWidget вызывается в трех случаях:
y при первом добавлении виджета в дерево;
y при изменении конфигурации родительского виджета;
y при наличии зависимости от изменений посредством InheritedWidget.
Существование большого количества перестраиваемых элементов в родительском виджете может сказаться на отклике пользовательского интерфейса приложения, поэтому команда Flutter сформировала ряд рекомендаций, которые позволят
вам оптимизировать его работу.
1. Насколько возможно, минимизируйте вложенность виджетов. Например, рассмотрите возможность использования вместо сложной компоновки Rows, Columns, Paddings и SizedBox для размещения одного дочернего
элемента только Align или CustomSingleChildLayout. Либо подумайте об
использовании CustomPaint вместо сложного наслоения из нескольких виджетов для рисования графического пользовательского интерфейса. К этой
рекомендации вам придется обращаться очень редко, но лучше знать о ее
существовании.
202 Глава 1 Краткая история и принципы работы Flutter
2. Создавайте константные виджеты везде, где только возможно, и предоставляйте константный конструктор для ваших пользовательских виджетов.
3. Рассмотрите возможность рефакторинга StatelessWidget в StatefulWidget,
что позволит кэшировать общие части поддеревьев и даст возможность задействовать GlobalKeys при изменении структуры дерева.
4. Если виджет часто перестраивается из-за использования InheritedWidgets
или менеджеров состояния, подумайте о рефакторинге StatelessWidget на
несколько виджетов. При этом изменяющиеся части дерева перемещайте
ближе к его листьям, а не узлам.
1.6.2. StatefulWidget
При создании виджета, который может менять свое состояние, необходимо наследовать от StatefulWidget и переопределить метод State<Т> createState, который
должен вернуть экземпляр создаваемого виджета, наследуемого от State<Т>, где
T — класс, наследующийся от StatefulWidget. Звучит немного запутанно, но на самом
деле здесь нет ничего сложного. Давайте разберем код стартового проекта, который
встречает нас каждый раз при создании нового приложения, обратив внимание на
процесс создания виджета, который может менять свое состояние:
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
}
@override
State<MyHomePage> createState() = > _MyHomePageState();
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
…
}
Мы объявили класс MyHomePage, который наследуется от StatefulWidget, принимает на вход конструктора ряд параметров и переопределяет метод State<Т>
createState следующим образом — State<MyHomePage> createState. В этом классе
будут храниться неизменяемые состояния, в то время как изменяемые объявляются в следующем классе, наследуемом от State<MyHomePage>, экземпляр которого
и возвращается из переопределенного метода. Такой подход позволяет скрыть саму
реализацию виджета и дает возможность из класса _MyHomePageState получить доступ к полям MyHomePage с помощью конструкции вида widget.название_поля_класса.
Класс State имеет следующие свойства:
y context → BuildContext — отвечает за расположение виджета в дереве, в котором создается;
y mounted → bool — флаг проверки того, находится ли объект в дереве;
y widget → T — обеспечивает доступ к текущей конфигурации виджета.
1.6. Типы виджетов во Flutter 203
Помимо свойств, класс State предоставляет разработчику следующие методы
работы:
y activate() → void — вызывается в случае, когда виджет повторно вставляется
в дерево после его удаления с помощью метода deactivate;
y build(BuildContextcontext) → Widget — метод для описания части пользовательского интерфейса, за которую ответственен виджет. Вызывается каждый раз при изменении состояния виджета для его перерисовки. Изменить
состояние можно либо вызвав методы initState, didChangeDependencies,
didUpdateWidget, либо вручную методом setState;
y deactivate() → void — вызывается, если объект удален из дерева, но еще жив
и его можно вставить в другое место дерева до момента обновления кадра;
y debugFillProperties(DiagnosticPropertiesBuilderproperties) → void — позволяет добавлять дополнительные свойства, связанные с текущим узлом
в дереве. Используется при отладке;
y didChangeDependencies() → void — вызывается сразу после initState и при
изменении состояния объекта посредством InheritedWidget. В данном методе также осуществляется подписка на отслеживание изменения состояния
в InheritedWidget;
y didUpdateWidget(covariant T oldWidget) → void — вызывается каждый раз при
изменении конфигурации виджета, например, когда родительский виджет
в дереве передает некоторую переменную виджету с помощью конструктора;
y dispose() → void — вызывается при безвозвратном удалении виджета из
дерева и необходим в случае, если у нас имеются занятые ресурсы (активен
контроллер анимации и т. д.), которые требуется освободить;
y initState() → void — вызывается при создании экземпляра класса и используется для инициализации изменяемых состояний;
y reassemble() → void — вызывается при повторной сборке виджета во время
отладки, например, во время запуска Hot Reload;
y setState(VoidCallback fn) → void — метод для оповещения фреймворка об
изменении внутреннего состояния объекта.
С такими методами, как reassemble, activate, debugFillProperties и пр. (см. документацию), вам нет необходимости знакомиться подробно, но полезно знать об
их существовании, поскольку это дает представление о жизненном цикле виджетов
с изменяемым состоянием.
1. Flutter создает объект State, вызывая StatefulWidget.createState.
2. Созданный объект на постоянной основе связывается с BuildContext, и его
свойство mounted устанавливается равным true. Следует уточнить, что State
никогда не изменит свой BuildContext, но сам BuildContext можно перемещать
по дереву вместе с его поддеревом.
3. У создаваемого экземпляра класса вызывается переопределяемый метод
initState для инициализации начальных состояний.
204 Глава 1 Краткая история и принципы работы Flutter
4. Flutter вызывает переопределяемый метод didChangeDependencies, который
используется для инициализации с участием InheritedWidget. Этот метод
будет вызван снова, если унаследованные виджеты впоследствии изменятся
или виджет переместится в дереве.
5. На этом этапе объект State полностью инициализирован, и Flutter может
вызывать метод его сборки любое количество раз, чтобы получить описание
пользовательского интерфейса для текущего поддерева. При этом объекты
State могут спонтанно запрашивать перестройку своего поддерева, вызывая
метод setState, который указывает, что изменились некоторые внутренние
состояния.
6. На текущей стадии жизненного цикла родительский виджет может перестроиться и запросить обновление дерева для отображения нового виджета
с теми же runtimeType и Widget.key. Когда это происходит, Flutter обновляет
свойство виджета, чтобы ссылаться на новый виджет, после чего вызывает
метод didUpdateWidget с предыдущим виджетом в качестве аргумента. Пользовательские StatefulWidget должны переопределять метод didUpdateWidget,
чтобы ответить на изменения в связанном с ними виджете (например, для
запуска неявной анимации). После вызова didUpdateWidget всегда вызывается
метод build, вследствие чего вызовы setState в didUpdateWidget являются
избыточными.
7. Если во время разработки приложения происходит Hot Reload, это приводит
к вызову метода повторной сборки reassemble, что дает возможность повторно
инициализировать любые данные в initState.
8. Если происходит удаление поддерева, которое содержит объект State, то
Flutter вызывает метод deactivate. Пользовательский класс должен переопределять этот метод, когда необходимо очистить любые связи между экземпляром класса (объектом) и другими элементами в дереве (например,
если базовому классу был предоставлен указатель на производный).
9. Пока поддерево окончательно не удалено, его можно повторно вставить
в другую часть дерева. Когда это происходит, Flutter вызывает метод build
для адаптации объекта State к его новому положению в дереве. Flutter должен
вставить это поддерево в рамках текущего кадра, то есть до начала отрисовки
нового. Именно по этой причине объекты State могут отложить высвобождение большинства ресурсов до тех пор, пока Flutter не вызовет их метод dispose.
10. Если Flutter повторно не вставит это поддерево к концу текущего кадра, то
у объекта State будет вызван метод dispose и он никогда не будет создан снова.
Ваши пользовательские классы должны переопределить этот метод для освобождения любых ресурсов, которые могут удерживаться удаляемым объектом.
11. После вызова метода dispose объект State считается отключенным, а его
свойство mounted устанавливается в false. Это последняя стадия жизненного
цикла, на которой происходит удаление объекта, из-за чего нет возможности
смонтировать его повторно.
В графическом виде жизненный цикл State может быть представлен следующим
образом (рис. 1.18).
1.6. Типы виджетов во Flutter 205
Рис. 1.18. Жизненный цикл State
В качестве примера демонстрации возможностей StatefulWidget реализуем
следующее приложение. На главном экране пользователю будет доступна однаединственная кнопка с текстом Go MainPage. Нажав на нее, он будет перенесен на
следующий экран (с возможностью возврата), где его ожидает StatefulWidget —
виджет с привычной реализацией увеличения значения счетчика после нажатия
кнопки. Отличие в том, что она находится посередине, а в правом нижнем углу
расположим кнопку, в результате нажатия на которую центральный виджет будет
обернут в цветной контейнер и запущена его перерисовка. Повторное нажатие
должно убрать контейнер и снова запустить процесс обновления графического
206 Глава 1 Краткая история и принципы работы Flutter
пользовательского интерфейса. А для отслеживания изменений жизненного цикла
StatefulWidget переопределим некоторые из рассмотренных ранее методов.
Создайте новый проект ex_stateful_widget1 со следующим начальным наполнением:
// baseURL/1/1.6/ex_stateful_widget1/lib/main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: const StartPage(),
);
}
class StartPage extends StatelessWidget {
const StartPage({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('StatefulWidget LifeCircle'),
),
body: Center(
child: FloatingActionButton.extended(
onPressed: () { // при нажатии кнопки
Navigator.push( // переход на новую страницу
context,
MaterialPageRoute(builder: (context) {
return Scaffold(
appBar: AppBar(
title: const Text('MainPage'),
),
body: const Center(
child: Text(
'Hello World!',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 24.0),
),
),
);
}),
);
},
label: const Text('Go MainPage')),
),
);
}
1.6. Типы виджетов во Flutter 207
Если выполнить сборку проекта, то в центре главного окна пользователя будет
ждать кнопка, нажатие на которую переведет его на следующий экран, с которого
можно вернуться обратно (рис. 1.19).
Рис. 1.19. Переход между экранами приложения
Далее в main.dart добавим StatefulWidget, который будет содержать Scaffold,
располагаться на следующем экране и менять значение переменной, отвечающей
за то, надо ли оборачивать виджет со счетчиком в контейнер или нет:
class MainPage extends StatefulWidget {
final String title;
MainPage({super.key, required this.title}) {
debugPrint('MainPage constructor');
}
}
@override
State<MainPage> createState() = > _MainPageState();
class _MainPageState extends State<MainPage> {
bool _isWrapNewPage = false;
// Метод для переключения значения _isWarpNewPage и обновления UI
void wrapNewPage() {
// _isWrapNewPage ! = _isWrapNewPage;
_isWrapNewPage = _isWrapNewPage ? false : true;
setState(() {});
debugPrint('isWrapNewPage: $_isWrapNewPage');
}
@override
Widget build(BuildContext context) {
debugPrint('MainPage build');
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: const Center(),
floatingActionButton: FloatingActionButton(
onPressed: wrapNewPage, // задаем обработчик нажатия кнопки
208 Глава 1 Краткая история и принципы работы Flutter
}
}
),
);
child: const Icon(Icons.toc),
Наличие функций debugPrint позволит отследить каждую перерисовку виджета и его изменение состояний, выводя оповещения в терминал. А чтобы добавить
MainPage в приложение, замените код в Navigator.push со следующего:
Navigator.push( // переход на новую страницу
context,
MaterialPageRoute(builder: (context) {
return Scaffold(
appBar: AppBar(
title: const Text('MainPage'),
),
body: const Center(
child: Text(
'Hello World!',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 24.0),
),
),
);
на такой:
Navigator.push( // переход на новую страницу
context,
MaterialPageRoute(builder: (context) {
return MainPage(title: 'Main StatefulWidget');
}),
);
Теперь после нажатия кнопки Go MainPage изменится экран с виджетом, на который будет осуществлен переход (рис. 1.20).
Рис. 1.20. Переход между экранами приложения
Следующим действием реализуем класс MyStatefulWidget со счетчиком и переопределим ряд методов, чтобы можно было отследить передвижение по жизненному
циклу StatefulWidget:
1.6. Типы виджетов во Flutter 209
class MyStatefulWidget extends StatefulWidget {
final bool isWrapped;
const MyStatefulWidget({super.key, required this.isWrapped});
}
@override
State<MyStatefulWidget> createState() = > _MyStatefulWidgetState();
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
int _counter = 0;
late bool _isWrapped;
// Вызывается при создании экземпляра класса
// и используется для инициализации изменяемых состояний
@override
void initState() {
super.initState();
_isWrapped = widget.isWrapped;
debugPrint('--MyStatefulWidget initState');
}
// Вызывается сразу после initState и при изменении
// состояния объекта посредством InheritedWidget
@override
void didChangeDependencies() {
super.didChangeDependencies();
debugPrint('--MyStatefulWidget didChangeDependencies');
}
// Вызывается каждый раз при изменении конфигурации виджета
@override
void didUpdateWidget(covariant MyStatefulWidget oldWidget) {
super.didUpdateWidget(oldWidget);
debugPrint('--MyStatefulWidget didUpdateWidget');
if (oldWidget.isWrapped = = widget.isWrapped) return;
_isWrapped = widget.isWrapped; // обновляем состояние
}
// Вызывается, когда объект удален из дерева, но еще жив и его
// можно вставить в другое место дерева до момента обновления кадра
@override
void deactivate() {
super.deactivate();
debugPrint('--MyStatefulWidget deactivate');
}
// Метод освобождения ресурсов, используемых MyStatefulWidget.
// Вызывается при безвозвратном удалении виджета из дерева
@override
void dispose() {
debugPrint('--MyStatefulWidget dispose');
super.dispose();
}
// Пересобирает состояние виджета при Hot Reload.
// Вызывает метод пересборки суперкласса, устанавливает состояние
// с новым значением переменной _counter и выводит отладочное
// сообщение, указывающее на горячую перезагрузку MyStatefulWidget.
@override
void reassemble() {
super.reassemble();
setState(() {
_counter = 32;
});
210 Глава 1 Краткая история и принципы работы Flutter
}
debugPrint('--MyStatefulWidget hot reload');
// Увеличивает и выводит в терминал значение
// счетчика, а также обновляет состояние виджета.
void _incrementCounter() {
setState(() {
_counter++;
});
debugPrint('--Counter: $_counter');
}
// Создает виджет на основе значения _isWrapped.
// Если _isWrapped равно true, возвращает контейнер полупрозрачного оранжевого
// цвета с результатом _buildWidget в качестве дочернего элемента.
// В противном случае возвращает результат выполнения _buildWidget.
@override
Widget build(BuildContext context) {
debugPrint('--MyStatefulWidget build with wrap is: $_isWrapped');
return _isWrapped
? Container(
color: Colors.deepOrange.withOpacity(0.5),
child: _buildWidget(),
)
: _buildWidget();
}
}
// Создает и возвращает виджет, содержащий колонку с виджетом
// текста, отображающую текущее значение счетчика, и ElevatedButton,
// при нажатии на которую вызывается функция, отвечающая
// за увеличиение значения счетчика.
Widget _buildWidget() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
ElevatedButton(
onPressed: () {
_incrementCounter();
},
child: const Text('Increment'))
],
);
}
Чтобы добавить данный виджет в приложение, замените аргумент body виджета
Scaffold у класса _MainPageState с
body: const Center(),
на
Center(
child: MyStatefulWidget(
isWrapped: _isWarpNewPage,
)),
После нажатия кнопки Increment значение счетчика увеличится на единицу, а на
правую нижнюю кнопку — центральный виджет будет оборачиваться в цветной
контейнер. Повторное нажатие вернет все на место (рис. 1.21).
1.6. Типы виджетов во Flutter 211
Рис. 1.21. Оборачивание центрального виджета в контейнер
Любое действие, перехваченное в процессе нажатий кнопки и перерисовок,
отобразится в терминале:
flutter: MainPage constructor
flutter: MainPage build
flutter: --MyStatefulWidget initState
flutter: --MyStatefulWidget didChangeDependencies
flutter: --MyStatefulWidget build with wrap is: false
flutter: --Counter: 1
flutter: --MyStatefulWidget build with wrap is: false
flutter: --Counter: 2
flutter: --MyStatefulWidget build with wrap is: false
flutter: isWrapNewPage: true
flutter: MainPage build
flutter: --MyStatefulWidget didUpdateWidget
flutter: --MyStatefulWidget build with wrap is: true
flutter: isWrapNewPage: false
flutter: MainPage build
flutter: --MyStatefulWidget didUpdateWidget
flutter: --MyStatefulWidget build with wrap
is: false
flutter: --Counter: 3
flutter: --MyStatefulWidget build with wrap
is: false
flutter: isWrapNewPage: true
flutter: MainPage build
flutter: --MyStatefulWidget didUpdateWidget
flutter: --MyStatefulWidget build with wrap
is: true
А если при запущенном приложении изменить в коде название кнопки с Increment
на Increment1 и сохранить эти изменения,
запустив Hot Reload, то в соответствии с переопределенным методом reassemble значение
счетчика установится равным 32 (рис. 1.22).
Чтобы лучше понимать процессы, протекающие в приложении при нажатии кнопки
Рис. 1.22. Результат работы метода reassemble
в процессе Hot Reload
212 Глава 1 Краткая история и принципы работы Flutter
и смене экранов (главный на следующий и обратно), обращайте внимание на вывод
состояний в терминал. К тому же такое исследование позволит глубже проникнуться
философией жизненного цикла StatefulWidget.
1.7. В недрах BuildContext
Как уже говорилось, во Flutter все является виджетом. Из кода приложения
формируется дерево виджетов, которое в runtime преобразуется в дерево элементов. А непосредственно на экране пользователь видит отрисованное дерево из
RenderObject, сформированное из дерева элементов. При этом не каждый элемент
имеет свой RenderObject, задействуя в таком случае его представление из родительского элемента. Поэтому дерево рендеринга получается значительно миниатюрнее
предыдущих двух деревьев. Но на самом деле во Flutter нет дерева виджетов!!! Это
понятие используется для удобства восприятия, так как программист в основном
оперирует виджетами и не задумывается, что у них «под капотом». Вся основная
работа фреймворка завязана на элементы, дерево элементов и рендеринг.
У каждого виджета имеется метод createElement(), возвращающий Flutter его
элемент, который использует данные самого виджета для своей конфигурации
и отвечает за его управление расположением в дереве. Жизненный цикл таких
элементов можно представить следующим образом.
1. Flutter создает элемент на основе конфигурации виджета, вызывая его метод
Widget.createElement.
2. Для добавления созданного элемента в дерево относительно его родительского элемента Flutter использует метод Element.mount, который отвечает за
создание любых дочерних элементов виджетов и вызов Element.attachRenderObject, когда необходимо прикрепить к дереву рендеринга все связанные
с ним объекты.
3. Элемент становится активным и может появиться на экране.
4. Если в какой-то момент родитель решит изменить виджет, используемый для
настройки этого элемента, у нового виджета Flutter вызовет Element.update.
Этот виджет всегда будет иметь то же значение runtimeType и ключа, что
и старый. Чтобы родитель мог изменить runtimeType или ключ виджета
в данном месте дерева, он должен размонтировать этот элемент и создать
здесь новый виджет.
5. Если в какой-то момент родительский элемент решит удалить этот дочерний элемент (или промежуточного потомка) из дерева, он вызовет метод
Element.deactivateChild. В результате объект рендеринга этого элемента
будет удален из дерева рендеринга, а сам элемент — добавлен в список неактивных элементов, тем самым заставив Flutter вызвать у этого элемента
метод Element.deactivate.
6. Элемент становится неактивным и перестает отображаться на экране.
В таком состоянии он может находиться до конца текущего кадра анимации, по завершении которого все неактивные элементы будут размонтированы.
1.7. В недрах BuildContext 213
7. Если элемент будет повторно включен в дерево (например, элемент или один
из его предков имеет глобальный ключ, который используется повторно),
Flutter удалит его из списка неактивных элементов, сделав снова активным
с помощью метода Element.activate, после чего повторно прикрепит объект
рендеринга элемента к дереву рендеринга.
8. Если элемент до конца текущего кадра анимации не включается повторно
в дерево, Flutter вызовет у него метод Element.unmount.
9. Элемент считается устаревшим и в дальнейшем не включается в дерево.
Каждый элемент реализует интерфейс абстрактного класса BuildContext и, приводясь к нему, передается в метод StatelessWidget.build или State.build в качестве
переменной context:
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: const HomePage(),
);
}
Передаваемый в метод build контекст относится только к конкретному виджету и считается родительским по отношению к виджету, возвращаемому методом
StatelessWidget.build или State.build. В случае StatelessWidget доступ к контексту имеется только в методе build либо когда он явно передается из него в другие
методы виджета или функции модуля. У класса State доступ к контексту имеется
как в методе build (с помощью входного аргумента context), так и из любого места,
поскольку связывается с его одноименным полем.
Зачем еще нужен BuildContext, помимо участия в формировании дерева элементов и хранения информации о местоположении в нем текущего виджета? Все дело
в том, что, используя контекст, мы можем обращаться к родительским виджетам,
находящимся на более высоких уровнях дерева, получая доступ к их свойствам
и методам. BuildContext можно также задействовать для передачи сообщения по
дереву виджетов от родительского к дочернему и обратно (с помощью InheritedWidget, который рассмотрим в следующем разделе).
Класс BuildContext имеет следующие уникальные свойства:
y debugDoingBuild → bool — возвращает true или false в зависимости от того,
обновляется ли в данный момент виджет или дерево рендеринга;
y mounted → bool — возвращает true или false в зависимости от того, установлен ли виджет, с которым связан данный контекст, в дерево элементов;
y owner → BuildOwner? — возвращает BuildOwner текущего контекста, который
отвечает за управление конвейером рендеринга данного контекста;
214 Глава 1 Краткая история и принципы работы Flutter
→ Size? — возвращает реальный размер виджета, отображаемого на
экране. К данному свойству следует обращаться только после того, как виджет
был отрисован! Да и в принципе не стоит им злоупотреблять;
y widget → Widget — возвращает текущую конфигурацию элемента, связанного
с конкретным BuildContext.
Помимо свойств, этот класс предоставляет разработчику следующие методы
работы:
y dependOnInheritedElement(InheritedElement ancestor, {Object? aspect}) →
InheritedWidget — используется для связывания текущего контекста сборки
с предком, что позволяет перестраивать его при изменении виджета предка;
y
size
y
dependOnInheritedWidgetOfExactType<T extends InheritedWidget>( {Object?
aspect}) → T? — возвращает ближайший виджет заданного типа T и создает
зависимость от него. Если виджет не найден, вернется null;
y
describeElement(String name, {DiagnosticsTreeStyle style = DiagnosticsTreeStyle.errorProperty}) → DiagnosticsNode — возвращает описание элемента,
связанного с текущим BuildContext;
y
describeMissingAncestor({required Type expectedAncestorType}) →
List<DiagnosticsNode> — используется для добавления описания конкретного типа виджета, отсутствующего в дереве предков текущего BuildContext;
y
describeOwnershipChain(String name)
y
describeWidget(String name, {DiagnosticsTreeStyle style = DiagnosticsTreeStyle.errorProperty}) → DiagnosticsNode — возвращает описание виджета,
связанного с текущим BuildContext;
y
dispatchNotification(Notification notification)
y
y
y
y
y
→ DiagnosticsNode — используется
для добавления описания цепочки владения конкретного элемента в отчет
об ошибке;
→ void — запускает рассылку передаваемого уведомления в текущем контексте сборки;
findAncestorRenderObjectOfType<T extends RenderObject>() → T? — возвращает
RenderObject ближайшего предка виджета RenderObjectWidget, являющегося
экземпляром заданного типа T. Если объект не найден, вернется null;
findAncestorStateOfType<T extends State<StatefulWidget>>() → T? — возвращает State ближайшего предка виджета StatefulWidget, являющегося
экземпляром заданного типа T. Если объект не найден, вернется null;
findAncestorWidgetOfExactType<T extends Widget>() → T? — возвращает
ближайший родительский виджет заданного типа T, являющийся типом
конкретного подкласса Widget. Если объект не найден, вернется null;
findRenderObject() → RenderObject? — возвращает RenderObject для текущего виджета. Если элемент виджета не имеет RenderObjectWidget, то есть
не отображается на экране, вернется объект рендеринга первого потомка
RenderObjectWidget;
findRootAncestorStateOfType<T extends State<StatefulWidget>>() → T? — возвращает State самого дальнего предка виджета StatefulWidget, являющегося
экземпляром заданного типа T. Если объект не найден, вернется null;
1.7. В недрах BuildContext 215
y
getElementForInheritedWidgetOfExactType<T extends InheritedWidget >() →
InheritedElement? — возвращает элемент ближайшего виджета заданного
типа T, который является производным классом от InheritedWidget;
y
getInheritedWidgetOfExactType<T extends InheritedWidget>() → T? — возвращает ближайший виджет заданного типа T, который является производным
классом от InheritedWidget. Если подходящий виджет не найден, вернется null;
y
visitAncestorElements(ConditionalElementVisitor visitor) → void — исполь-
зуется для организации обхода по цепочке родительских элементов, начиная
с элемента текущего BuildContext;
y visitChildElements(ElementVisitor visitor) → void — используется для орга
низации обхода по цепочке дочерних элементов текущего виджета.
Для поиска в дереве элементов ряда виджетов (Theme, Scaffold и т. д.) предоставляется статический метод of (это делают сами виджеты), на вход которого
подается экземпляр BuildContext. Контекст также используется для организации
навигации между экранами:
// baseURL/1/1.7/ex_build_context1/lib/main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: const HomePage(),
);
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Home Page'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'Current Theme Primary Color:',
216 Глава 1 Краткая история и принципы работы Flutter
style: TextStyle(fontSize: 20),
),
const SizedBox(height: 10),
// Использование BuildContext для доступа к теме
Container(
width: 100,
height: 100,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 20),
ElevatedButton(
child: const Text('Go to Details'),
onPressed: () {
// Использование BuildContext для навигации
Navigator.push(
context,
MaterialPageRoute(
builder: (context) = > const DetailsPage(),
),
);
},
),
],
),
),
}
}
);
class DetailsPage extends StatelessWidget {
const DetailsPage({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Details Page'),
),
body: Center(
child: ElevatedButton(
child: const Text('Go Back'),
onPressed: () {
// Использование BuildContext для навигации назад
Navigator.pop(context);
},
),
),
);
}
Поскольку экземпляр BuildContext — это элемент, мы можем кинуться в «уличную магию», вернувшись, к примеру, из StatelessWidget , где нажатие кнопки
не приводило ни к каким изменениям счетчика на экране, и, внеся изменение всего
лишь в пару строк кода, сделать перерисовку неизменяемого виджета. Пример
далее носит демонстративный характер, и за его перенос в реальный проект вас
могут жестоко покарать более старшие коллеги, так как для таких задач лучше
использовать StatefulWidget, а не BuildContext:
// baseURL/1/1.7/ex_build_context2/lib/main.dart
import 'package:flutter/material.dart';
1.7. В недрах BuildContext 217
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
// без изменений
}
class MyHomePage extends StatelessWidget {
int counter;
String title;
MyHomePage({
// без изменений
});
void _incrementCounter(BuildContext context) {
counter++;
// вызываем перерисовку элемента, запросив его обновление
(context as Element).markNeedsBuild();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: // без изменений
body: // без изменений
floatingActionButton: FloatingActionButton(
onPressed: () = > _incrementCounter(context),
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
Конечно, на более низком уровне метод setState у State использует аналогичный способ для запуска перерисовки при изменении состояния, но это не значит,
что такой велосипедокостыльный способ
надо применять для StatelessWidget. Лучшей политикой будет придерживаться
тех концепций, за которые эти виджеты
отвечают, а не превращать проект в минное поле.
Чтобы лучше понять зацепление контекста в методе build и поиск с его помощью нужных родительских виджетов,
реализуем несколько примеров как с State
less
Widget, так и с StatefulWidget. В этом
нам поможет простое приложение со следующим графическим пользовательским
Рис. 1.23. Пример GUI реализуемого приложения
интерфейсом (GUI) (рис. 1.23).
218 Глава 1 Краткая история и принципы работы Flutter
После нажатия на центральную кнопку в приложении будет начат поиск первого
родительского виджета (цветного контейнера) MyColorBox относительно зацепленного контекста. Начнем с StatelessWidget и в первом варианте выполним верстку
и объявим кнопку в методе build корневого виджета:
// baseURL/1/1.7/ex_build_context3/lib/main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: Scaffold(
body: Center(
child: MyColorBox(
color: Colors.blue,
colorName: 'Blue',
child: MyColorBox(
color: Colors.red,
colorName: 'Red',
child: MyColorBox(
color: Colors.green,
colorName: 'Green',
child: ElevatedButton(
onPressed: () {
debugPrint(
MyColorBox.ofColor(context)?.toString(),
);
debugPrint(
MyColorBox.of(context)?.toString(),
);
},
child: const Text('Click Me'),
),
),
),
),
),
),
);
}
class MyColorBox extends StatelessWidget {
static const padding = 30.0; // внутренний отступ
final String colorName; // название цвета
final Color color; // цвет
final Widget? child; // дочерний виджет
1.7. В недрах BuildContext 219
const MyColorBox({
super.key,
required this.colorName,
required this.color,
this.child,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(
MyColorBox.padding,
),
color: color,
child: child,
);
}
static Color? ofColor(BuildContext context) {
// возвращает цвет первого найденного
// родительского виджета или null
return
context.findAncestorWidgetOfExactType<MyColorBox>()?.color;
}
}
static MyColorBox? of(BuildContext context) {
// возвращает первый найденный родительский виджет или null
return context.findAncestorWidgetOfExactType<MyColorBox>();
}
В текущем примере результатом нажатия кнопки будет вывод в терминал
значения null. Это связано с тем, что мы захватили контекст элемента, который
ничего не знает о своих дочерних виджетах, а поиск всегда осуществляется по родительским элементам текущего контекста. Чтобы исправить эту ситуацию, можно
вынести кнопку в отдельный пользовательский виджет MyButton, а его поместить
в зеленый контейнер:
// baseURL/1/1.7/ex_build_context1/lib/main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: // без изменений,
home: const Scaffold(
body: Center(
child: MyColorBox(
color: Colors.blue,
colorName: 'Blue',
child: MyColorBox(
color: Colors.red,
colorName: 'Red',
220 Глава 1 Краткая история и принципы работы Flutter
child: MyColorBox(
color: Colors.green,
colorName: 'Green',
child: MyButton(),
),
),
),
),
),
}
}
);
class MyButton extends StatelessWidget {
const MyButton({super.key});
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
debugPrint(
MyColorBox.ofColor(context)?.toString(),
);
debugPrint(
MyColorBox.of(context)?.colorName,
);
},
child: const Text('Click Me'),
);
}
class MyColorBox extends StatelessWidget {
// без изменений
}
В данном случае при нажатии кнопки мы ищем ближайший родительский виджет
MyColorBox в рамках контекста метода build виджета MyButton, который был указан
в качестве дочернего для контейнера зеленого цвета. В результате в терминал будут
выведены следующие строки:
flutter: MaterialColor(primary value: Color(0xff4caf50))
flutter: Green
Если поменяем верстку и расположим кнопку на одном уровне с зеленым
контейнером, как показано на рис. 1.24, то с учетом изменений в дереве элемента
и захватываемого контекста виджетом MyButton в терминал будет выводиться:
flutter: MaterialColor(primary value: Color(0xfff44336))
flutter: Red
// baseURL/1/1.7/ex_build_context5/lib/main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
1.7. В недрах BuildContext 221
}
}
return MaterialApp(
title: 'Flutter Demo',
theme: // без изменений
home: const Scaffold(
body: Center(
child: MyColorBox(
color: Colors.blue,
colorName: 'Blue',
child: MyColorBox(
color: Colors.red,
colorName: 'Red',
child: Column(
children: [
MyColorBox(
color: Colors.green,
colorName: 'Green',
),
SizedBox(height: 30),
MyButton(),
],
),
),
),
),
),
);
class MyButton extends StatelessWidget {
// без изменений
}
class MyColorBox extends StatelessWidget {
// без изменений
}
Рис. 1.24. Пример GUI реализуемого приложения
Теперь перейдем к примерам со StatefulWidget и нажатием кнопки будем менять
цвет контейнера. В первом случае, так как BuildContext предоставляет подходящие
222 Глава 1 Краткая история и принципы работы Flutter
методы, это будут первый и последний контейнеры. При вызове findAncestor
StateOfType<T> вернется первый родительский виджет ( State ) относительно
текущего контекста, который будет найден в дереве. А findRootAncestorStateOf
Type<T> вернет последний виджет заданного типа в дереве. Поскольку эти методы
запускают обход дерева и имеют линейную временную сложность О(n), при большом количестве элементов их использование для обращения к конкретному виджету для вызова его методов не самая лучшая идея. Метод же findRootAncestor
StateOfType вообще осуществляет проход до корня дерева, после чего возвращает
последний найденный State заданного типа.
Для начала перепишем существующий StatelessWidget — MyColorBox на StatefulWidget, добавив в него метод, меняющий цвет:
// baseURL/1/1.7/ex_build_context6/lib/main.dart
class MyColorBox extends StatefulWidget {
static const padding = 30.0;
final Color color;
final Widget? child;
const MyColorBox({
super.key,
required this.color,
this.child,
});
}
@override
State<MyColorBox> createState() = > _MyColorBoxState();
class _MyColorBoxState extends State<MyColorBox> {
Color _color = Colors.transparent;
Widget? _child;
@override
void initState() {
super.initState();
setState(() {
_color = widget.color;
_child = widget.child;
});
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(
MyColorBox.padding,
),
color: _color,
child: _child,
);
}
}
void changeColor(Color color) {
setState(() {
_color = color;
});
}
1.7. В недрах BuildContext 223
Класс виджета MyButton тоже претерпит изменения. Добавим ему несколько
аргументов, чтобы можно было передавать анонимную функцию, которая будет
вызываться нажатием кнопки, и текст, отображаемый на кнопке:
// baseURL/1/1.7/ex_build_context6/lib/main.dart
class MyButton extends StatelessWidget {
final void Function(BuildContext context) onPressed;
final String text;
const MyButton({
super.key,
required this.onPressed,
required this.text,
});
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
onPressed(context);
},
child: Text(text),
);
}
Функции, передаваемые в конструктор MyButton, можно объявить несколькими способами: при указании аргумента конструктора передать ему анонимную
функцию, объявленную в самом конструкторе, либо объявить ее в другом месте
в качестве функции верхнего уровня (или метода класса), после чего передать
имя функции аргументу onPressed класса MyButton. Для большей наглядности
воспользуемся вторым подходом и на верхнем уровне модуля объявим несколько
функций:
// baseURL/1/1.7/ex_build_context6/lib/main.dart
// Функция поиска первого родительского
// MyColorBoxState в дереве виджетов (относительно
// контекста) для изменения цвета.
void firstParentChangeColor(BuildContext context) {
final myColorBox =
context.findAncestorStateOfType<_MyColorBoxState>();
if (myColorBox?._color = = Colors.green) {
myColorBox?.changeColor(Colors.grey);
} else {
myColorBox?.changeColor(Colors.green);
}
}
// Функция поиска верхнего родительского
// MyColorBoxState в дереве виджетов для изменения цвета.
void lastParentChangeColor(BuildContext context) {
final myColorBox =
context.findRootAncestorStateOfType<_MyColorBoxState>();
if (myColorBox?._color = = Colors.blue) {
myColorBox?.changeColor(Colors.brown);
} else {
myColorBox?.changeColor(Colors.blue);
}
}
224 Глава 1 Краткая история и принципы работы Flutter
Далее изменим код виджета MyApp, добавив в последний контейнер две кнопки
вместо одной. Первая будет отвечать за изменение цвета ближайшего родителя,
а вторая — самого дальнего:
// baseURL/1/1.7/ex_build_context6/lib/main.dart
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: const Scaffold(
body: Center(
child: MyColorBox(
color: Colors.blue,
child: MyColorBox(
color: Colors.red,
child: MyColorBox(
color: Colors.green,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MyButton(
text: 'First parent',
onPressed: firstParentChangeColor,
),
SizedBox(height: 20),
MyButton(
text: 'Last parent',
onPressed: lastParentChangeColor,
)
],
),
),
),
),
),
),
),
);
}
Теперь можно запустить приложение и поиграть с изменением цвета самого верхнего и последнего контейнеров, нажимая на соответствующие кнопки (рис. 1.25).
А как же быть со средним цветным контейнером? Для изменения его цвета
можно применить несколько подходов. Один из них мы рассмотрим в следующем разделе, а два — здесь. Поскольку известно, что средний контейнер является родителем третьего и они реализованы посредством StatefulWidget, то
при нажатии кнопки сначала получим State первого родителя заданного типа,
а уже используя его контекст, можно запросить State центрального цветного
1.7. В недрах BuildContext 225
контейнера, то есть его родителя. Для воплощения в жизнь такого финта ушами
объявим следующую функцию:
// baseURL/1/1.7/ex_build_context7/lib/main.dart
// Функция поиска второго родительского MyColorBoxState
// в дереве виджетов для изменения цвета.
void secondParentChangeColor(BuildContext context) {
final myColorBox =
context.findAncestorStateOfType<_MyColorBoxState>();
final secondColorBox =
myColorBox?.context.findAncestorStateOfType<_MyColorBoxState>();
if (secondColorBox?._color = = Colors.red) {
secondColorBox?.changeColor(Colors.grey);
} else {
secondColorBox?.changeColor(Colors.red);
}
}
Рис. 1.25. Пример работы приложения
После чего внесем изменения в конфигурацию виджета
кнопку:
// baseURL/1/1.7/ex_build_context7/lib/main.dart
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: const Scaffold(
body: Center(
child: MyColorBox(
color: Colors.blue,
child: MyColorBox(
color: Colors.red,
MyApp,
оставив одну
226 Глава 1 Краткая история и принципы работы Flutter
child: MyColorBox(
color: Colors.green,
child: Center(
child: MyButton(
text: Second parent',
onPressed: secondParentChangeColor,
),
),
),
),
),
),
),
}
}
);
Теперь нажатие кнопки будет изменять цвет центрального контейнера (рис. 1.26).
Рис. 1.26. Пример работы приложения
Следующий способ заключается в использовании глобального ключа. Можно
объявить его на верхнем уровне, передать в конструктор среднего цветного контейнера и в функции, вызываемой нажатием кнопки, получить по данному ключу
State необходимого виджета, после чего вызвать метод изменения его цвета:
// baseURL/1/1.7/ex_build_context8/lib/main.dart
final myKey = GlobalKey<_MyColorBoxState>();
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: Scaffold(
body: Center(
1.7. В недрах BuildContext 227
child: MyColorBox(
color: Colors.blue,
child: MyColorBox(
key: myKey,
color: Colors.red,
child: const MyColorBox(
color: Colors.green,
child: Center(
child: MyButton(
text: 'Second parent',
onPressed: secondParentChangeColor,
),
),
),
),
),
),
),
}
}
);
// Функция поиска второго родительского MyColorBoxState
// в дереве виджетов для изменения цвета.
void secondParentChangeColor(BuildContext context) {
final myColorBox = myKey.currentState;
if (myColorBox?._color = = Colors.red) {
myColorBox?.changeColor(Colors.grey);
} else {
myColorBox?.changeColor(Colors.red);
}
}
Более подробно о том, зачем нужны ключи и как их использовать, мы поговорим
в одном из следующих разделов. А пока подведем итоги.
1. BuildContext предоставляет разработчику довольно удобный интерфейс
для поиска необходимых виджетов в дереве, абстрагируя собой экземпляр
элемента виджета, который создается Flutter. Он позволяет организовать
передачу сообщений как от дочернего виджета в родительский, так и наоборот, но нужно быть внимательными при использовании ряда методов, так как
они имеют линейную сложность. Поэтому при наличии в пользовательском
интерфейсе огромного количества виджетов лучше задействовать другие
механизмы для вызова метода родительского виджета из дочернего.
2. Передаваемый в метод build контекст относится только к конкретному виджету и считается родительским по отношению к виджету, возвращаемому
методом StatelessWidget.build или State.build. Это следует учитывать при
обращении через контекст к родительскому виджету, так как может возникнуть ситуация, когда вы считаете один виджет родительским, а другой —
дочерним, но они объявлены на одном уровне и метод поиска захватывает
контекст более высокого уровня. Один из наиболее предпочтительных способов избежать такого поведения — объявить отдельный виджет и работать
на уровне его контекста. Другой — использовать виджет Builder, который
позволяет создать для текущего элемента виджета в дереве элемент более
низкого уровня. И если для ряда виджетов, таких как ListView с его именованным конструктором builder, это нормальная практика, то виджет Builder
228 Глава 1 Краткая история и принципы работы Flutter
способен внести сумятицу в восприятие кода приложения и трактовку дерева
элементов текущей страницы GUI.
3. К свойству BuildContext.size необходимо обращаться с особой осторожностью, так как оно будет доступно только после отрисовки виджета.
4. Если вам дорого свое психическое и физическое здоровье, то не следует
пользоваться «уличной магией» элементов и превращать StatelessWidget
в StatefulWidget.
1.8. Передача информации по дереву элементов
Представим, что в приложении имеется довольно большое количество элементов
в дереве и нажатием кнопки нам нужно передать данные из родительского виджета
дальнему потомку. Прокидывать их через кучу конструкторов виджетов — то еще
удовольствие, которое вы в полной мере прочувствуете, если в какой-то момент
понадобится убрать данный функционал. Ранее мы рассмотрели один из способов,
позволяющий передать данные из родительского виджета в дочерний с помощью
контекста, а именно с использованием его метода findAncestorStateOfType.
Для следующего примера вынесем из стартового проекта Flutter виджет Text,
в котором отображается текущее значение счетчика после нажатия кнопки, в отдельный пользовательский StatelessWidget — MyTextWidget, из которого будем
обращаться с помощью контекста к переменной _counter класса _MyHomePageState:
// baseURL/1/1.8/ex_inherited_widget_1/lib/main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
// без изменений
}
class MyHomePage extends StatefulWidget {
// без изменений
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
// без изменений
}
@override
Widget build(BuildContext context) {
debugPrint('MyHomePage: $_counter');
return Scaffold(
appBar: // без изменений
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
1.8. Передача информации по дереву элементов 229
'You have pushed the button this many times:',
),
MyTextWidget(),
],
),
),
floatingActionButton: // без изменений
}
}
);
class MyTextWidget extends StatelessWidget {
const MyTextWidget({super.key});
}
@override
Widget build(BuildContext context) {
final countValue =
context.findAncestorStateOfType<_MyHomePageState>()?._counter;
debugPrint('MyTextWidget: $countValue');
return Text(
'$countValue',
style: Theme.of(
context,
).textTheme.headlineMedium,
);
}
При каждом нажатии кнопки в терминал будут выводиться две строки с текущим состоянием счетчика и названием класса, из чьего метода build был вызван
этот вывод:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
MyHomePage: 0
MyTextWidget:
MyHomePage: 1
MyTextWidget:
MyHomePage: 2
MyTextWidget:
MyHomePage: 3
MyTextWidget:
0
1
2
3
Когда виджеты расположены рядом, в этом нет ничего страшного. Но чем
дальше они друг от друга, тем у большего количества потомков будет вызван их
метод build, что довольно затратно. Это касается всех неконстантных виджетов.
Поэтому команда Flutter и рекомендует как можно чаще, когда мы знаем, что
виджет не меняется, объявлять его посредством константного конструктора.
В таком случае метод build вызовется у виджета только в момент его создания,
освобождая тем самым ресурсы приложения для другой полезной работы. Да и для
приведенного кода IDE самостоятельно предложит объявить составляющие тела
виджета Scaffold как константы времени компиляции:
return Scaffold(
appBar: // без изменений,
body: const Center( // const сделает константными всех потомков
child: Column(
// родительского виджета
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
230 Глава 1 Краткая история и принципы работы Flutter
MyTextWidget(), // будет использован константный конструктор
],
),
),
floatingActionButton: // без изменений
);
Но в таком случае приложение перестанет работать, так как после нажатия
кнопки не будет никакого обновления данных на экране, что можно проследить
по выводу в терминал:
flutter:
flutter:
flutter:
flutter:
flutter:
MyHomePage: 0
MyTextWidget: 0 // будет выведен один раз
MyHomePage: 1
MyHomePage: 2
MyHomePage: 3
Если вы подумали что-то наподобие: «Так какому же дьяволу нужно продать
душу, чтобы повторно вызвать метод build у нужного константного виджета на
любой глубине дерева, сохранив при этом все преимущества от использования
ключевого слова const?» — спешу вас успокоить. Никому и ничего продавать
не нужно, так как Flutter «из коробки» предоставляет нужный механизм, а именно
InheritedWidget с его производными классами InheritedModel и InheritedNotifier. Они не отображаются в пользовательском интерфейсе (то есть не имеют
своего RenderObject) и применяются для распространения информации по дереву
элементов. Дополнительным преимуществом использования данных типов виджетов является то, что связывание с ними с помощью контекста происходит за
константное время О(1), а не линейное, как при обращении к ближайшему или
дальнему экземпляру State в дереве.
1.8.1. InheritedWidget
При использовании данного подхода разработчик обязан объявить пользовательский производный класс от InheritedWidget, обозначить, в передаче каких данных
он будет принимать участие, а также реализовать в нем несколько методов:
// baseURL/1/1.8/ex_inherited_widget_2/lib/main.dart
class CounterInherited extends InheritedWidget {
final int count;
const CounterInherited({
super.key,
required super.child, // оборачиваемый инхеритом виджет
required this.count, // данные для передачи
});
@override
bool updateShouldNotify(CounterInherited oldWidget) {
return count ! = oldWidget.count;
}
static CounterInherited? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<CounterInherited>();
}
static CounterInherited of(BuildContext context) {
final CounterInherited? result = maybeOf(context);
1.8. Передача информации по дереву элементов 231
}
}
assert(result ! = null, 'No CounterInherited found in context');
return result!;
Поскольку во Flutter InheritedWidget объявлен как immutable (неизменяемый
объект), то все его пользовательские поля должны быть final. А это значит, что
данные могут передаваться только в одном направлении — от родительского элемента к дочернему и не могут быть изменены из дочернего элемента, а только при
пересоздании InheritedWidget на уровне его объявления. В этот момент он займет
то же место в дереве элементов и будет запущен метод updateShouldNotify, на вход
которого подается представление старого InheritedWidget с его данными. Если в результате проверки текущих и предыдущих данных InheritedWidget вернется true,
то будет запущен метод build дочернего виджета, из которого произошла подписка
на InheritedWidget, в противном случае ничего не произойдет.
Методы of и maybeOf, объявляемые на уровне пользовательского InheritedWidget, позволяют упростить подписку на изменения, так как помещенный в них
код не придется прописывать каждый раз. Одно дело, если на InheritedWidget
подписывается один дочерний виджет, но когда он используется для обновления
состояния большого количества дочерних виджетов, такой подход сократит объем
кода, который требуется написать, и упростит его читаемость, а соответственно,
и дальнейшую поддержку.
Теперь добавим в метод build класса _MyHomePageState объявление CounterInherited, обернув в него виджет MyTextWidget:
// baseURL/1/1.8/ex_inherited_widget_2/lib/main.dart
return Scaffold(
appBar: // без изменений,
body: CounterInherited(
count: _counter,
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
MyTextWidget(),
],
),
),
),
floatingActionButton: // без изменений,
);
При каждом вызове метода build у класса _MyHomePageState в пользовательском
инхерите будет осуществляться проверка того, поменялись ли хранимые в нем данные, то есть следует ли оповестить подписанные на изменения дочерние виджеты,
тем самым запустив их перерисовку, или оставить все как есть.
Заменим одну строчку кода в классе в MyTextWidget, осуществив подписку на
изменения данных в пользовательском инхерите CounterInherited:
// baseURL/1/1.8/ex_inherited_widget_2/lib/main.dart
class MyTextWidget extends StatelessWidget {
const MyTextWidget({super.key});
232 Глава 1 Краткая история и принципы работы Flutter
}
@override
Widget build(BuildContext context) {
// подписываемся на изменения count
final countValue = CounterInherited.of(context).count;
debugPrint('MyTextWidget: $countValue');
return Text(
'$countValue',
style: Theme.of(
context,
).textTheme.headlineMedium,
);
}
В результате внесенных изменений приложение начнет работать так, как изначально задумывалось, и Flutter не будет вызывать метод build у всех виджетов
тела Scaffold, а только у тех, кто подписался на инхерит:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
MyHomePage: 0
MyTextWidget:
MyHomePage: 1
MyTextWidget:
MyHomePage: 2
MyTextWidget:
MyHomePage: 3
MyTextWidget:
0
1
2
3
Если же вы хотите получить доступ к данным InheritedWidget из StatefulWidget,
то это можно сделать в его методах build, didChangeDependencies или didUpdateWidget. В связи с тем что initState вызывается только один раз, подписка в нем на
оповещение об изменении состояния инхерита не будет приводить к перестроению
виджета:
class _MyStatefullTextState extends State<MyStatefullText> {
int _counter = 0;
@override
void didChangeDependencies() {
super.didChangeDependencies();
debugPrint('MyStatefullText didChangeDependencies');
_counter = CounterInherited.of(context).count;
}
@override
void didUpdateWidget(MyStatefullText oldWidget) {
super.didUpdateWidget(oldWidget);
debugPrint('MyStatefullText didUpdateWidget');
_counter = CounterInherited.of(context).count;
}
}
@override
Widget build(BuildContext context) {
_counter = CounterInherited.of(context).count;
debugPrint('MyStatefullText build: $_counter');
return Text(
'$_counter',
style: Theme.of(
context,
).textTheme.headlineMedium,
);
}
1.8. Передача информации по дереву элементов 233
И все же InheritedWidget неидеален! Он не позволяет подписаться на изменение
значения какого-то одного поля. Иначе говоря, если в инхерите будут храниться
несколько значений и дочерние виджеты подписываются каждый на свое, изменение любого значения все равно будет приводить к перестройке всех виджетов,
подписанных на инхерит. Чтобы избежать лишних перестроений виджетов и вызывать их только в момент изменения конкретного значения инхерита, на которое
осуществлялась подписка, следует использовать InheritedModel.
1.8.2. InheritedModel
В качестве примера использования данного класса модифицируем предыдущий пример со счетчиком таким образом, чтобы при нажатии кнопки изменялось значение
нескольких текстовых виджетов, введя вместо одного несколько счетчиков. Каждое
нажатие будет увеличивать значение первого счетчика на единицу и запускать отрисовку одного виджета Text, а когда его значение будет кратно трем, увеличится
второй счетчик, что приведет к перерисовке другого виджета Text.
Начнем с описания пользовательской модели CounterModel, наследующейся от
InheritedModel<Т>, где Т — тип аспекта, который будет применяться при подписке
на изменения конкретного значения:
// baseURL/1/1.8/ex_inherited_model_1/lib/main.dart
class CounterModel extends InheritedModel<String> {
final int value1; // значение первого счетчика
final int value2; // значение второго счетчика
const CounterModel({
super.key,
required super.child,
required this.value1,
required this.value2,
});
@override
bool updateShouldNotify(CounterModel oldWidget) {
return value1 ! = oldWidget.value1 || value2 ! = oldWidget.value2;
}
// Метод проверки того, есть ли какие-то изменения в наборе
// зависимостей аспектов. Возвращает true, если изменения
// между текущей и предыдущей моделями (oldWidget)
// совпадают с аспектом из множества dependencies.
@override
bool updateShouldNotifyDependent(
CounterModel oldWidget,
Set<String> dependencies,
) {
final value1Changed = value1 ! = oldWidget.value1 &&
dependencies.contains(
'value1', // имя аспекта
);
final value2Changed = value2 ! = oldWidget.value2 &&
dependencies.contains(
'value2', // имя аспекта
);
return value1Changed || value2Changed;
}
234 Глава 1 Краткая история и принципы работы Flutter
// Методы для подписки на изменения по задаваемому аспекту
static CounterModel? maybeOf(
BuildContext context, [
String? aspect,
]) {
return context.dependOnInheritedWidgetOfExactType<CounterModel>(
aspect: aspect,
);
}
}
static CounterModel of(
BuildContext context, [
String? aspect,
]) {
final CounterModel? result = maybeOf(context, aspect);
assert(
result ! = null,
'Unable to find an instance of CounterModel',
);
return result!;
}
Далее объявим пользовательские текстовые виджеты, из которых осуществим
подписку на изменения конкретного значения с помощью его аспекта. При такой
подписке необходимо быть особенно внимательным, так как вся ответственность за
то, подписался ли виджет на изменение значения с помощью необходимого аспекта, лежит на разработчике и может привести к некорректной работе приложения.
В нашем случае в CounterModel были объявлены два значения, на чьи изменения
можно подписаться, с одноименными текстовыми аспектами value1 и value2 (аспект
может быть любого типа, экземпляры класса которого поддерживают добавление
в множество):
// baseURL/1/1.8/ex_inherited_model_1/lib/main.dart
class MyTextWidget1 extends StatelessWidget {
const MyTextWidget1({super.key});
}
@override
Widget build(BuildContext context) {
// подписываемся на изменения value1
final countValue = CounterModel.of(context, 'value1').value1;
debugPrint('MyTextWidget1: $countValue');
return Text(
'$countValue',
style: Theme.of(
context,
).textTheme.headlineMedium,
);
}
class MyTextWidget2 extends StatelessWidget {
const MyTextWidget2({super.key});
@override
Widget build(BuildContext context) {
// подписываемся на изменения value2
final countValue = CounterModel.of(context, 'value2').value2;
debugPrint('MyTextWidget2: $countValue');
return Text(
1.8. Передача информации по дереву элементов 235
'$countValue',
style: Theme.of(
context,
).textTheme.headlineMedium,
}
}
);
Теперь внесем изменения в StatefulWidget — MyHomePage, добавив еще одну
переменную-счетчик и логику ее обновления, а также слегка изменим его верстку.
Что же касается виджета MyApp и функции main, то их оставляем без изменений:
// baseURL/1/1.8/ex_inherited_model_1/lib/main.dart
class MyHomePage extends StatefulWidget {
// без изменений
}
class _MyHomePageState extends State<MyHomePage> {
int _counter1 = 0;
int _counter2 = 0;
void _incrementCounter() {
_counter1++;
if (_counter1 % 3 = = 0) {
_counter2++;
}
setState(() {});
}
}
@override
Widget build(BuildContext context) {
debugPrint('MyHomePage: $_counter1, $_counter2');
return Scaffold(
appBar: // без изменений,
body: CounterModel(
value1: _counter1,
value2: _counter2,
child: const Center( // константный виджет
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyTextWidget1(),
SizedBox(width: 20),
MyTextWidget2(),
],
),
),
),
floatingActionButton:
// без изменений,
);
}
После запуска приложения понажимайте какое-то количество раз кнопку,
запустив перестроение обоих текстовых
виджетов (рис. 1.27).
По результатам вывода в терминал
можно заметить, что метод build у виджета
Рис. 1.27. Пример работы приложения
с использованием InheritedModel
236 Глава 1 Краткая история и принципы работы Flutter
MyTextWidget2 вызывается только на старте приложения и в случаях, когда значение
основного счетчика кратно трем:
flutter: MyHomePage: 0, 0
flutter: MyTextWidget1: 0
flutter: MyTextWidget2: 0
flutter: MyHomePage: 1, 0
flutter: MyTextWidget1: 1
flutter: MyHomePage: 2, 0
flutter: MyTextWidget1: 2
flutter: MyHomePage: 3, 1
flutter: MyTextWidget2: 1
flutter: MyTextWidget1: 3
flutter: MyHomePage: 4, 1
flutter: MyTextWidget1: 4
flutter: MyHomePage: 5, 1
flutter: MyTextWidget1: 5
flutter: MyHomePage: 6, 2
flutter: MyTextWidget2: 2
flutter: MyTextWidget1: 6
flutter: MyHomePage: 7, 2
flutter: MyTextWidget1: 7
В качестве эксперимента попробуйте поменять у одной из подписок значение
аспекта (например, у MyTextWidget1 с value1 на value2 или установить несуществующий) и проследить, как изменится поведение приложения, не забыв его перед
этим перезапустить щелчком на
.
1.8.3. InheritedNotifier
Если необходимо выделить данные в отдельный класс (модель) и перестраивать
виджет, который подписывается на изменения, только при наличии необходимых
событий (например, изменении значения переменной при вызове методов модели)
и без указания аспектов, следует задействовать InheritedNotifier. Такой подход
позволяет отделить бизнес-логику приложения от его графического пользовательского интерфейса, что скажется на удобстве восприятия кода и его последующей
поддержке. Основной же особенностью объявления таких моделей является то,
что они должны наследовать от класса ChangeNotifier, который реализует интерфейс Listenable и предоставляет API уведомлений об изменениях, используя для
них функцию VoidCallback, то есть ничего не возвращающую и не принимающую
данных на свой вход.
В качестве примера использования InheritedNotifier реализуем следующий
миниатюрный калькулятор, выполняющий несколько базовых операций: сложение,
вычитание, умножение и деление по модулю (рис. 1.28).
Создайте новый проект (счетчик), дав ему любое название (в нашем случае —
ex_notifier_model_1), и добавьте в его каталог lib два новых файла: math_model.dart
и math_inherited.dart. В первом из них реализуем класс MathModel, наследующийся
1.8. Передача информации по дереву элементов 237
от ChangeNotifier и предоставляющий пользователю несколько сеттеров для установки
значений, над которыми будут совершаться
операции, а также методы для сложения, вычитания и т. д., которые завершаются оповещением подписчиков о том, что в модели
произошло изменение. За запуск оповещения
у класса ChangeNotifier отвечает метод notifyListeners:
// baseURL/1/1.8/ex_notifier_model_1/lib/
// math_model.dart
import 'package:flutter/widgets.dart';
class MathModel extends ChangeNotifier {
int? _firstValue;
int? _secondValue;
int? result;
Рис. 1.28. Пример работы приложения
с использованием InheritedNotifier
bool get isReady = > _firstValue ! = null && _secondValue ! = null;
set firstValue(String value) {
_firstValue = int.tryParse(value);
}
set secondValue(String value) {
_secondValue = int.tryParse(value);
}
void add() {
if (isReady) {
result = _firstValue! + _secondValue!;
notifyListeners();
}
}
void subtract() {
if (isReady) {
result = _firstValue! - _secondValue!;
notifyListeners();
}
}
void multiply() {
if (isReady) {
result = _firstValue! * _secondValue!;
notifyListeners();
}
}
}
void modDivide() {
if (isReady) {
result = _firstValue! % _secondValue!;
notifyListeners();
}
}
В файле math_inherited.dart объявим пользовательский инхерит MathInherited,
наследующийся от InheritedNotifier<T>, где Т — класс, реализующий интерфейс Listenable, экземпляр которого должен быть передан аргументу notifier в конструктор
238 Глава 1 Краткая история и принципы работы Flutter
класса. В нашем случае это будет MathModel. Что же касается доступа к модели из
дочерних виджетов, то следует выделить два способа сделать это:
y с подпиской на изменения значений модели с использованием метода
dependOnInheritedWidgetOfExactType класса BuildContext;
без
подписки на изменения значений модели с использованием метода
y
getElementForInheritedWidgetOfExactType класса BuildContext.
Такой подход оправдан в тех случаях, когда виджеты должны получать доступ
к модели для передачи ей данных и запуска операций калькулятора без подписи
на ее обновления, а у других должна быть возможность подписаться на изменения
необходимых значений модели:
// baseURL/1/1.8/ex_notifier_model_1/lib/math_inherited.dart
import 'package:ex_notifier_model_1/math_model.dart';
import 'package:flutter/widgets.dart';
class MathInherited extends InheritedNotifier<MathModel> {
const MathInherited({
super.key,
required super.notifier,
required super.child,
});
@override
bool updateShouldNotify(MathInherited oldWidget) {
return notifier ! = oldWidget.notifier;
}
// Методы подписки на изменения
static MathModel? maybeSubscribeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<MathInherited>()
?.notifier;
}
static MathModel subscribeOf(BuildContext context) {
final MathModel? result = maybeSubscribeOf(context);
assert(
result ! = null,
'Unable to find an instance of MathModel',
);
return result!;
}
// Методы получения модели без подписки
static MathModel? maybeModelOf(BuildContext context) {
var widget = context
.getElementForInheritedWidgetOfExactType<MathInherited>()
?.widget;
return widget is MathInherited ? widget.notifier : null;
}
}
static MathModel modelOf(BuildContext context) {
final MathModel? result = maybeModelOf(context);
assert(
result ! = null,
'Unable to find an instance of MathModel',
);
return result!;
}
1.8. Передача информации по дереву элементов 239
Весь последующий код будем добавлять в файл main.dart, а чтобы уменьшить
количество объявляемых пользовательских виджетов и не создавать отдельные
для каждой кнопки и поля ввода, воспользуемся подходом с конфигурацией, когда
в конструктор пользовательского виджета будет передаваться функция (это действие позволяет использовать механизм замыканий), отвечающая за то, как он себя
должен повести при наступлении в виджете определенного события. Для виджета
кнопки это нажатие, а для виджета пользовательского ввода — обновление хранящегося в нем значения:
// baseURL/1/1.8/ex_notifier_model_1/lib/main.dart
import 'package:ex_notifier_model_1/math_inherited.dart';
import 'package:ex_notifier_model_1/math_model.dart';
import 'package:flutter/material.dart';
class InputValue extends StatelessWidget {
final void Function(String value) onChanged;
const InputValue({super.key, required this.onChanged});
}
@override
Widget build(BuildContext context) {
return TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
onChanged: onChanged, // передача управления в функцию
);
}
class MyButton extends StatelessWidget {
final void Function(MathModel mathModel) onPressed;
final String text;
const MyButton({
super.key,
required this.onPressed,
required this.text,
});
}
@override
Widget build(BuildContext context) {
// получаем модель без подписки на изменения
final mathModel = MathInherited.modelOf(context);
return ElevatedButton( // передача управления в функцию
onPressed: () = > onPressed(mathModel),
child: Text(text),
);
}
Далее реализуем виджет вывода результата операции, который в методе build
подписывается на событие изменения поля result у MathModel, используя для этого
метод subscribeOf класса MathInherited:
// baseURL/1/1.8/ex_notifier_model_1/lib/main.dart
class ResultText extends StatelessWidget {
const ResultText({super.key});
@override
Widget build(BuildContext context) {
240 Глава 1 Краткая история и принципы работы Flutter
}
}
// подписываемся на изменения result
final result = MathInherited.subscribeOf(context).result;
return Text(
result ! = null ? result.toString() : 'No result',
style: Theme.of(
context,
).textTheme.headlineMedium,
);
Теперь объявим пользовательский виджет MyContainer, экземпляр которого
будет отвечать за расположение основных виджетов пользовательского интерфейса
и оборачиваться MathInherited:
// baseURL/1/1.8/ex_notifier_model_1/lib/main.dart
class MyContainer extends StatelessWidget {
const MyContainer({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// Виджеты для ввода первого числа
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'A = ',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
width: 10,
),
SizedBox(
width: 150,
child: InputValue(
onChanged: (String value) {
MathInherited.modelOf(context).firstValue = value;
},
),
),
],
),
const SizedBox(height: 10),
// Виджеты для ввода второго числа
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'B = ',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
width: 10,
),
SizedBox(
width: 150,
child: InputValue(
onChanged: (String value) {
MathInherited.modelOf(context).secondValue = value;
},
1.8. Передача информации по дереву элементов 241
),
),
],
),
const SizedBox(height: 10),
// Виджеты кнопок для запуска вычислений
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyButton(
onPressed: (mathModel) = > mathModel.add(),
text: '+',
),
const SizedBox(width: 5),
MyButton(
onPressed: (mathModel) = > mathModel.subtract(),
text: '-',
),
const SizedBox(width: 5),
MyButton(
onPressed: (mathModel) = > mathModel.multiply(),
text: '*',
),
const SizedBox(width: 5),
MyButton(
onPressed: (mathModel) = > mathModel.modDivide(),
text: '%',
),
],
),
const SizedBox(height: 10),
const ResultText(), // Виджет для вывода результата
],
),
}
}
);
В качестве последнего шага перед запуском приложения внесем изменения
в StatefulWidget — MyHomePage, добавив создание экземпляра класса MathModel и его
передачу в MathInherited, слегка изменив базовую верстку. Что же касается виджета
MyApp и функции main, то их оставляем без изменений:
// baseURL/1/1.8/ex_notifier_model_1/lib/main.dart
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
}
@override
State<MyHomePage> createState() = > _MyHomePageState();
class _MyHomePageState extends State<MyHomePage> {
final mathModel = MathModel();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: MathInherited(
notifier: mathModel,
242 Глава 1 Краткая история и принципы работы Flutter
}
}
),
);
child: const MyContainer(),
Запустив приложение, введите пару числовых значений и выполните над ними
любую из доступных операций. Использование InheritedNotifier позволило нам
организовать передачу данных в двух направлениях: от родительского виджета
к любому из его потомков и наоборот, а выделение бизнес-логики в отдельный
класс (MathModel) способствовало большему структурированию кода приложения,
упростив его дальнейшую поддержку и сделав более быстрым добавление новой
функциональности.
1.8.4. ChangeNotifier и ListenableBuilder
На самом деле, когда StatefulWidget, хранящий модель, и StatelessWidget или
StatefulWidget, подписывающиеся на оповещения для своего перестроения, а также
использующие методы модели, располагаются довольно близко, можно обойтись
без InheritedNotifier, передавая модель с помощью конструктора класса. Для этого
достаточно объявить модель и подписываться на ее изменения, используя виджет
ListenableBuilder в случае StatelessWidget и метод addListener, предоставляемый
классом ChangeNotifier, позволяющий организовать перестроение StatefulWidget.
Для демонстрации данной возможности создайте новое приложение (в нашем случае ex_change_notifier_1), скопировав в его папку lib файл main.dart
и math_model.dart из предыдущего примера. Код модели меняться не будет, а вот
в виджеты, объявленные в main.dart, придется внести ряд изменений. Начнем
с виджета кнопки, поля ввода и отображения результата операции над числами:
// baseURL/1/1.8/ex_change_notifier_1/lib/main.dart
// перенастроили импорт
import 'package:ex_change_notifier_1/math_model.dart';
class InputValue extends StatelessWidget {
// без изменений
}
class MyButton extends StatelessWidget {
final void Function() onPressed;
final String text;
const MyButton({
super.key,
required this.onPressed,
required this.text,
});
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () = > onPressed(),
child: Text(text),
);
}
1.8. Передача информации по дереву элементов 243
class ResultText extends StatelessWidget {
final MathModel mathModel;
const ResultText({super.key, required this.mathModel});
}
@override
Widget build(BuildContext context) {
// подписываемся на изменения result, используя ListenableBuilder
return ListenableBuilder(
listenable: mathModel,
builder: (BuildContext context, Widget? child) {
return Text(
'${mathModel.result ?? 'No result'}',
style: Theme.of(
context,
).textTheme.headlineMedium,
);
},
);
}
Далее внесем изменения в виджет MyContainer. Поскольку в этом примере мы
отказываемся от использования InheritedNotifier, экземпляр MathModel, к которому будет производиться обращение из кнопок и поля ввода, будет передаваться
в конструктор класса:
// baseURL/1/1.8/ex_change_notifier_1/lib/main.dart
class MyContainer extends StatelessWidget {
final MathModel mathModel;
const MyContainer({super.key, required this.mathModel});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// Виджеты для ввода первого числа
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
// без изменений
),
const SizedBox(
width: 10,
),
SizedBox(
width: 150,
child: InputValue(
onChanged: (String value) {
mathModel.firstValue = value;
},
),
),
],
),
const SizedBox(height: 10),
// Виджеты для ввода второго числа
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
244 Глава 1 Краткая история и принципы работы Flutter
// без изменений
),
const SizedBox(
width: 10,
),
SizedBox(
width: 150,
child: InputValue(
onChanged: (String value) {
mathModel.secondValue = value;
},
),
),
],
),
const SizedBox(height: 10),
// Виджеты кнопок для запуска вычислений
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyButton(
onPressed: () = > mathModel.add(),
text: '+',
),
const SizedBox(width: 5),
MyButton(
onPressed: () = > mathModel.subtract(),
text: '-',
),
const SizedBox(width: 5),
MyButton(
onPressed: () = > mathModel.multiply(),
text: '*',
),
const SizedBox(width: 5),
MyButton(
onPressed: () = > mathModel.modDivide(),
text: '%',
),
],
),
const SizedBox(height: 10),
// Виджет для вывода результата
ResultText(mathModel: mathModel),
],
),
}
}
);
В качестве последнего шага перед запуском приложения внесем изменения
в StatefulWidget — MyHomePage, удалив объявление MathInherited и передавая в конструктор виджета MyContainer экземпляр MathModel. Что же касается виджета MyApp
и функции main, то их оставляем без изменений:
// baseURL/1/1.8/ex_change_notifier_1/lib/main.dart
class MyHomePage extends StatefulWidget {
// без изменений
}
class _MyHomePageState extends State<MyHomePage> {
final mathModel = MathModel();
1.8. Передача информации по дереву элементов 245
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: MyContainer(
mathModel: mathModel,
),
);
}
Приложение готово к запуску! Если же подписка на изменение состояния
модели должна производиться из StatefulWidget, то необходимо в одном из его
методов: initState, didUpdateWidget или didChangeDependencies — вызвать у модели
метод addListener, передав ему на вход функцию, которая будет вызываться для
перестроения виджета при ее обновлении. Следует также предусмотреть отказ от
обновления при удалении виджета. Сделать это можно в его методе dispose. В качестве примера перепишем виджет ResultText на StatefulWidget:
// baseURL/1/1.8/ex_change_notifier_2/lib/main.dart
class ResultText extends StatefulWidget {
final MathModel mathModel;
const ResultText({super.key, required this.mathModel});
}
@override
State<ResultText> createState()
= > _ResultTextState();
class _ResultTextState extends State<ResultText> {
late MathModel _mathModel;
@override
void initState() {
super.initState();
_mathModel = widget.mathModel;
widget.mathModel.addListener(_update);
}
@override
void dispose() {
_mathModel.removeListener(_update);
super.dispose();
}
void _update() {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return Text(
'${_mathModel.result ?? 'No result'}',
style: Theme.of(
context,
).textTheme.headlineMedium,
);
}
246 Глава 1 Краткая история и принципы работы Flutter
1.9. Зачем нужны ключи
В ходе работы со StatelessWidget, если в дереве необходимо поменять местами
виджеты одного и того же класса, не возникнет никаких проблем. Flutter заметит
изменение в дереве элементов и вызовет его перестроение. Но вот StatefulWidget
в такие моменты может доставить ряд неприятностей, вызванных внутренним
механизмом проверки виджетов, меняющихся местами. Точнее, эти неприятности
будут связаны с его экземпляром класса State, который не изменит своего положения в дереве и будет связан с другим виджетом того же класса, который займет
место исходного. Другими словами, из-за того что State не переместится в дереве
элементов вместе со своим StatefulWidget, на графическом пользовательском интерфейсе не произойдет никаких изменений! Для демонстрации такой ситуации
реализуем приложение, в котором после нажатия кнопки попробуем поменять
местами два StatefulWidget с текстом (эмодзи) (рис. 1.29).
Рис. 1.29. Пример реализуемого приложения
Создайте новое приложение (в нашем случае flutter_keys_1) и добавьте объявление EmojiWidget:
// baseURL/1/1.9/flutter_keys_1/lib/main.dart
class EmojiWidget extends StatefulWidget {
final String emoji;
const EmojiWidget({super.key, required this.emoji});
@override
State<EmojiWidget> createState() = > _EmojiWidgetState();
}
@override
String toString({ // для вывода в консоль
DiagnosticLevel minLevel = DiagnosticLevel.info,
}) {
return emoji;
}
class _EmojiWidgetState extends State<EmojiWidget> {
late String emoji;
1.9. Зачем нужны ключи 247
@override
void initState() {
super.initState();
emoji = widget.emoji;
debugPrint('Init $emoji');
}
}
@override
Widget build(BuildContext context) {
debugPrint("Building $emoji");
return Text( emoji,
style: const TextStyle(
fontSize: 80,
),
);
}
Если хотите, чтобы цвет элементов пользовательского интерфейса был таким же,
как на предыдущем рисунке, поменяйте цвет темы в виджете MyApp на Colors.cyan.
А в виджет MyHomePage добавьте список из нескольких экземпляров EmojiWidget,
разместив их на одном уровне дерева с помощью виджета Row. При нажатии на
ElevatedButton они должны меняться местами (вызов метода _swapWidgets):
// baseURL/1/1.9/flutter_keys_1/lib/main.dart
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
}
@override
State<MyHomePage> createState() = > _MyHomePageState();
class _MyHomePageState extends State<MyHomePage> {
// Список для перемены виджетов местами
var emojiListWidgets = <EmojiWidget>[
EmojiWidget(emoji: "¯\\_(ツ)_/¯"),
EmojiWidget(emoji: "(⊙ _ ⊙ )"),
];
void _swapWidgets() {
setState(() {
emojiListWidgets.insert(
1,
emojiListWidgets.removeAt(0),
);
});
debugPrint(emojiListWidgets.toString());
}
@override
Widget build(BuildContext context) {
debugPrint("Building MyHomePage");
return Scaffold(
appBar: AppBar(
backgroundColor:Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
248 Глава 1 Краткая история и принципы работы Flutter
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: emojiListWidgets,
),
ElevatedButton(
onPressed: _swapWidgets,
child: const Text(
"Swap Widgets",
style: TextStyle(
fontSize: 24,
),
),
)
],
),
),
}
}
);
Запустите приложение и пару раз нажмите кнопку, запуская процесс изменения
расположения виджетов в дереве. О выводе отладочной информации в терминал
можно сказать, что замена выполняется, но на пользовательском интерфейсе это
никак не отражается:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
Building MyHomePage
Init ¯\_(ツ)_/¯
Building ¯\_(ツ)_/¯
Init (⊙ _ ⊙ )
Building (⊙ _ ⊙ )
[(⊙ _ ⊙ ), ¯\_(ツ)_/¯]
Building MyHomePage
Building ¯\_(ツ)_/¯
Building (⊙ _ ⊙ )
[¯\_(ツ)_/¯, (⊙ _ ⊙ )]
Building MyHomePage
Building ¯\_(ツ)_/¯
Building (⊙ _ ⊙ )
Чтобы при перемещении виджетов по дереву принять решение о начале его перестроения, Flutter выполняет проверку типов виджетов и их уникальных ключей
(аргумент key). Поскольку тип виджетов одинаков и их key равен null, фреймворк
заменяет их в дереве, не запуская перестроения, поскольку считает, что это одни
и те же виджеты. В момент их перемещения State открепляется от виджета и остается на том же месте, связываясь с заменяемым StatefulWidget, в результате чего
у него заново запускается метод build (см. вывод в терминал Building ¯\_(ツ)_/¯
и Building (⊙ _ ⊙)), чего в идеале быть не должно.
Не допустить таких ситуаций помогают ключи, с помощью которых объявляемым
виджетам (и создаваемым на их основе элементам) задаются уникальные идентификаторы, участвующие в сравнении элементов дерева при принятии Flutter решения
о его перестроении. Наличие уникального ключа у StatefulWidget явным образом
связывает его с экземпляром State, перемещая оба эти элемента по дереву как один.
Ключи можно классифицировать несколькими способами.
y По области видимости — глобальные и локальные. К глобальным относятся
GlobalKey, LabeledGlobalKey и GlobalObjectKey, а к локальным — ObjectKey,
UniqueKey, ValueKey и PageStorageKey.
1.9. Зачем нужны ключи 249
y По типу хранимых значений — уникальные и неуникальные. Уникальные
ключи можно объявить с помощью GlobalKey, LabeledGlobalKey и UniqueKey,
а для объявления неуникальных используются GlobalObjectKey, ObjectKey,
ValueKey и PageStorageKey. Если с первыми все понятно, то неуникальные
ключи формируются на основе значений (int, String и т. д.) или ссылок на
объекты, допускающих сравнение на равенство.
Лучше понять отношения между перечисленными ключами поможет дерево
наследования, приведенное на рис. 1.30.
Рис. 1.30. Дерево наследования ключей
Абстрактный класс Key является базовым для всех ключей и задает интерфейс
проверки на равенство. Имеет фабричный конструктор Key(String value), в результате вызова которого вернется экземпляр ValueKey<String>.
Абстрактный класс LocalKey накладывает на свои производные классы ограничение — ключи должны быть уникальными среди элементов с одним и тем же родителем. Поскольку у LocalKey отсутствует фабричный конструктор, то нельзя создать
его экземпляр класса, но к нему можно привести любой из производных классов.
В отличие от предыдущего, GlobalKey должен быть уникальным для всего
приложения и использоваться только со StatefulWidget. Точнее, при объявлении
глобального ключа разработчик должен явно указать, с каким экземпляром класса
State тот должен быть связан. Его использование позволяет из любой точки приложения получить доступ к виджету, контексту, экземпляру State, а также переместить
StatefulWidget и его State из одной части дерева в другую (поменять родительский
узел у виджета). Данный класс имеет фабричный конструктор GlobalKey({String?
debugLabel}), возвращающий экземпляр LabeledGlobalKey, который в случае указания отладочной метки можно использовать для отладки.
250 Глава 1 Краткая история и принципы работы Flutter
У локального ключа ValueKey<T> T — это тип значения, поддерживающий сравнение на равенство, что и должно обеспечивать его уникальность. То есть Flutter
не допустит наличия двух ValueKey с одним и тем же значением, а если передаваемым
в конструктор значением служит пользовательский тип, то в нем должны быть переопределены hashCode и оператор сравнения ==. Как видно из дерева наследования,
этот класс — базовый для PageStorageKey<T>, который, в свою очередь, используется
для сохранения состояния виджета в хранилище после его уничтожения и восстановления состояния при повторном создании. Например, при переключении между
страницами (или вкладками) пользователь сможет продолжить прокручивать их
с того же места, на котором остановился до этого. Следует также отметить, что на
основе ValueKey<T> можно объявить производный класс, который будет создавать
уникальные ключи даже в тех случаях, когда в несколькие ValueKey передаются
одинаковые значения.
Уникальный локальный ключ UniqueKey считается равным только самому себе,
поэтому при создании его экземпляра класса не надо передавать в конструктор
какие-либо аргументы. Что же касается ObjectKey, то его конструктору передается
объект, который будет сравниваться по ссылке на объект в куче изоляционной
группы, а не по значению.
Глобальный ключ LabeledGlobalKey<T extends State<StatefulWidget>> можно
рассматривать как аналог UniqueKey, основным отличием которого является входной
аргумент _debugLabel (тип String?) у конструктора класса. Этот аргумент не влияет на уникальность ключа и используется в режиме отладки. А GlobalObjectKey<T
extends State<StatefulWidget>> — аналог ObjectKey, но в отличие от последнего он
связывается со State определенного StatefulWidget.
Для примера того, как наличие ключей изменит работу нашего приложения, добавьте в конструктор объявляемых EmojiWidget уникальный ключ, перезапустите
приложение и пару раз нажмите кнопку Swap Widgets:
var emojiListWidgets = <EmojiWidget>[
EmojiWidget(key: UniqueKey(), emoji: "¯\\_(ツ)_/¯"),
EmojiWidget(key: UniqueKey(), emoji: "(⊙ _ ⊙ )"),
];
Теперь виджеты меняются местами, а в выводе в терминал можно увидеть, что
при перемещении у связанных с ними экземпляров State не вызывается метод build:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
Building MyHomePage
Init ¯\_(ツ)_/¯
Building ¯\_(ツ)_/¯
Init (⊙ _ ⊙ )
Building (⊙ _ ⊙ )
[(⊙ _ ⊙ ), ¯\_(ツ)_/¯]
Building MyHomePage
[¯\_(ツ)_/¯, (⊙ _ ⊙ )]
Building MyHomePage
Если же заменить ключи на следующие:
var emojiListWidgets = <EmojiWidget>[
EmojiWidget(key: ValueKey(0), emoji: "¯\\_(ツ)_/¯"),
EmojiWidget(key: ValueKey(0), emoji: "(⊙ _ ⊙ )"),
];
то приложение запустится с ошибкой Duplicate keys found.
1.9. Зачем нужны ключи 251
Следующий момент, на который стоит обратить внимание, — на каком уровне
объявлять локальные ключи. Flutter сопоставляет такие ключи на одном уровне,
и если мы обернем виджеты EmojiWidget в любой другой виджет, сделав их дочерними, то каждый вызов процедуры, в ходе которой виджеты станут меняться местами,
приведет к созданию и инициализации нового EmojiWidget. Это связано с тем, что
Flutter начинает сравнение с верхних узлов меняющихся местами поддеревьев:
var emojiListWidgets = <Widget>[
Padding(
padding: const EdgeInsets.all(2.0),
child: EmojiWidget(
key: UniqueKey(),
emoji: "¯\\_(ツ)_/¯",
),
),
Padding(
padding: const EdgeInsets.all(2.0),
child: EmojiWidget(
key: UniqueKey(),
emoji: "(⊙ _ ⊙ )",
),
),
];
/*
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
Building MyHomePage
Init ¯\_(ツ)_/¯
Building ¯\_(ツ)_/¯
Init (⊙ _ ⊙ )
Building (⊙ _ ⊙ )
[Padding…, Padding…]
Building MyHomePage
Init (⊙ _ ⊙ )
Building (⊙ _ ⊙ )
Init ¯\_(ツ)_/¯
Building ¯\_(ツ)_/¯
[Padding…, Padding…]
Building MyHomePage
Init ¯\_(ツ)_/¯
Building ¯\_(ツ)_/¯
Init (⊙ _ ⊙ )
Building (⊙ _ ⊙ ) */
Стоит только перенести локальные ключи на один уровень поддеревьев, меняющихся между собой, и все сразу встанет на свои места:
var emojiListWidgets = <Widget>[
Padding(
key: UniqueKey(),
padding: const EdgeInsets.all(2.0),
child: EmojiWidget(
key: UniqueKey(),
emoji: "¯\\_(ツ)_/¯",
),
),
Padding(
key: UniqueKey(),
padding: const EdgeInsets.all(2.0),
child: EmojiWidget(
key: UniqueKey(),
emoji: "(⊙ _ ⊙ )",
),
),
];
252 Глава 1 Краткая история и принципы работы Flutter
/*
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
Building MyHomePage
Init ¯\_(ツ)_/¯
Building ¯\_(ツ)_/¯
Init (⊙ _ ⊙ )
Building (⊙ _ ⊙ )
[Padding-[#4ac9b]…, Padding-[#8248d] …]
Building MyHomePage
[Padding-[#8248d] …, Padding-[#4ac9b] …]
Building MyHomePage */
Подведем некоторый итог. Использование ключей позволяет:
y организовать доступ к виджету из любой точки приложения (GlobalKey);
y корректно переместить виджет по дереву (когда речь идет о StatefulWidget);
y сохранить состояние виджета в моменты его удаления из дерева (Page
StorageKey).
Понимаем, что возможности этого механизма куда шире рассмотренных, но во
всех остальных случаях, когда у вас начинают чесаться руки, подумайте раз двадцать:
«Стоит ли вызывать осуждающие взгляды старших коллег и городить неоптимальное
решение, влияющее на производительность и удобство поддержки приложения?»
Если хотите глубже погрузиться в тему ключей и проникнуться всем их величием, рекомендуем прочитать на «Хабре» статью «Flutter. Keys! Для чего они?»
(https://habr.com/ru/articles/446050/).
1.10. Жизненный цикл приложения
Иногда бывает необходимо перехватить то или иное событие в приложении, связанное с его жизненным циклом (сворачивание, закрытие и т. д.). Так, например, при
закрытии приложения у State (StatefulWidget) не вызовется метод dispose, из-за
чего может произойти некорректное закрытие доступа к ресурсу или повреждение
(пропажа) каких-нибудь критических данных. Либо приложение должно работать
в любом случае, отклоняя любые стандартные команды, призванные его закрыть.
Для всех этих целей Flutter предоставляет класс AppLifecycleListener, позволя
ющий отслеживать все переходы приложения в разные состояния его жизненного
цикла (рис. 1.31).
Рис. 1.31. Жизненный цикл приложения на Flutter
1.10. Жизненный цикл приложения 253
На рисунке изображены основные состояния жизненного цикла приложения
(в прямоугольниках) и направления перехода из одного состояния в другое с именами callback-функций (подписи на стрелках), которые можно установить для
перехвата нужного перехода.
Начальное состояние запускаемого приложения — detached, и как только от
платформы поступает первое обновление жизненного цикла, оно меняется на
resumed. Следует отметить, что не все платформы поддерживают перечисленные
состояния жизненного цикла, но это решается на уровне фреймворка путем синтеза
«синтетического» промежуточного состояния при переходе из одного состояния
в другое. Такой подход гарантирует, что на любой из платформ, на которой запускается приложение, будет использоваться один и тот же конечный автомат
жизненного цикла приложения. Еще один неприятный момент заключается в том,
что не все имена состояний жизненного цикла Flutter-приложений соответствуют
названиям состояний на различных платформах. Яркий пример — операционная
система Android. По историческим причинам при вызове данной платформой
Activity.onPause Flutter будет переходить в состояние inactive, а при вызове
Activity.onStop — в состояние paused.
Прежде чем перейти к примерам с кодом, разберемся, за что отвечает то или
иное состояние жизненного цикла Flutter-приложения.
y detached — приложение отключено от любого хоста, но по-прежнему размещено на Flutter engine. Данное состояние считается стартовым, и приложение
в нем находится перед переходом к инициализации, а в случае Android и iOS
может в нем находиться и после того, как все его активные представления
были отключены от хоста (переход от состояния paused к detached).
y resumed — приложение видимо и на него смещен фокус ввода операционной
системы. Другими словами, оно находится в режиме работы и отображается
на переднем плане.
y inactive — приложение продолжает работать, но на него больше не направлен
фокус ввода операционной системы. Для Windows или Linux это означает
что приложение все еще видно пользователю, но больше не находится на
первом плане. В браузере это состояние означает, что приложение запущено
на вкладке, но не имеет фокуса ввода (например, открыто диалоговое окно).
В случае iOS и macOS состояние inactive оповещает, что приложение все еще
запущено, но находится в неактивном состоянии переднего плана, в которое
переходит при телефонном звонке, ответе на запрос TouchID и т. д. На Android
Flutter-приложение может пребывать в состоянии inactive в нескольких
случаях: оно запущено, но находится в приостановленном состоянии (Acti
vity.onPause) или в момент перед переходом к состоянию resumed, когда еще
не в фокусе (Activity.onResume). Примером перехода к состоянию inactive
могут выступать частично скрытое приложение, входящий телефонный
звонок, вызов приложением диалогового окна и т. д. Следует также отметить, что для Android и iOS при переходе приложения в состояние inactive
разработчик должен предусмотреть то, что оно в любое время может быть
скрыто или приостановлено.
254 Глава 1 Краткая история и принципы работы Flutter
y hidden — приложение свернуто. Для iOS и Android это означает, что оно
вот-вот будет приостановлено, для Windows или Linux — что приложение
больше не отображается в окне на рабочем столе, а в браузере это состояние говорит о том, что приложение запущено на вкладке, но пользователь
сделал ее неактивной, перейдя на соседнюю вкладку. Для iOS и Android это
«синтетическое» состояние, то есть оно синтезируется Flutter и является
промежуточным перед переходом в состояние paused.
y paused — приложение не видно пользователю и не реагирует на вводимые
им данные. Это состояние имеется только на iOS и Android, а для остальных
платформ — синтезируется.
Если вам надо подписаться на изменение состояния приложения или события
перехода из одного состояния в другое, при создании экземпляра класса AppLifecycleListener передайте в его конструктор одну из callback-функций, которые
должны вызываться в случае наступления необходимого события:
AppLifecycleListener({
WidgetsBinding? binding, /* Так как AppLifecycleListener получает binding посредством
обращения к WidgetsBinding.instance, данный аргумент при создании экземпляра класса
обычно не используется. Другое дело — тестирование или организация специализированных
привязок. */
VoidCallback? onResume, /* Вызов callback-функций указывает на то, что приложение
переходит в состояние, когда оно видно, активно и доступно для пользовательского
ввода. */
VoidCallback? onInactive, /* Вызов callback-функций указывает на то, что приложение
теряет фокус ввода. */
VoidCallback? onHide, /* Вызов callback-функций указывает на то, что приложение
сворачивается. */
VoidCallback? onShow, /* Вызов callback-функций указывает на то, что приложение
переходит на передний план для его непосредственного отображения. */
VoidCallback? onPause, /* Вызов callback-функций указывает на то, что приложение
приостанавливается. На iOS и Android (мобильных платформах) функция вызывается перед
заменой приложения другим приложением. На других платформах onPause не вызывается. */
VoidCallback? onRestart, /* Вызов callback-функций указывает на то, что приложение
после приостановки возобновляет свою работу. Работает только на мобильных платформах.
*/
VoidCallback? onDetach, /* Вызов callback-функций указывает на то, что приложение
было закрыто и отсоединилось от Flutter engine. Работает только на мобильных
платформах. */
AppExitRequestCallback? onExitRequested, /* Вызов callback-функций указывает на то,
что приложение закрывается и используется для того, чтобы, когда это возможно, принять
решение: разрешить выход или отменить его. Если функция вернет AppExitResponse.exit
или null, то приложение продолжит завершаться, а если AppExitResponse.cancel — отменит
текущую операцию и приложение продолжит работу. Может использоваться, когда мы хотим
точно удостовериться, что пользователю необходимо выйти из приложения. */
Function(AppLifecycleState)? onStateChange, /* Вызов callback-функций указывает
на то, что произошло изменение состояния. */
})
1.10. Жизненный цикл приложения 255
События жизненного цикла принято обрабатывать на верхнем уровне приложения, поэтому добавлять AppLifecycleListener следует в StatefulWidget, максимально
близкий к корню дерева:
// baseURL/1/1.10/app_lifecycle_1/lib/main.dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
void main() {
runApp(const AppLifecycle());
}
class AppLifecycle extends StatelessWidget {
const AppLifecycle({super.key});
}
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(body: AppLifecycleWidget()),
);
}
class AppLifecycleWidget extends StatefulWidget {
const AppLifecycleWidget({super.key});
}
@override
State<AppLifecycleWidget> createState() = > _AppLifecycleWidgetState();
class _AppLifecycleWidgetState extends State<AppLifecycleWidget> {
late final AppLifecycleListener _listener;
@override
void initState() {
super.initState();
// получение текущего состояния приложения
var state = SchedulerBinding.instance.lifecycleState;
debugPrint('AppLifecycleDisplay init: ${state?.name}');
_listener = AppLifecycleListener(
onResume: () = > _handleEvent('resume'),
onInactive: () = > _handleEvent('inactive'),
onHide: () = > _handleEvent('hide'),
onShow: () = > _handleEvent('show'),
onPause: () = > _handleEvent('pause'),
onRestart: () = > _handleEvent('restart'),
onDetach: () = > _handleEvent('detach'),
onExitRequested: () = > _handleExit(),
onStateChange: _handleStateChange,
);
}
@override
void dispose() {
_listener.dispose();
debugPrint('AppLifecycleDisplay disposed');
super.dispose();
}
256 Глава 1 Краткая история и принципы работы Flutter
Future<AppExitResponse> _handleExit() async {
debugPrint('AppLifecycleDisplay exiting');
return AppExitResponse.exit;
}
void _handleEvent(String name) {
debugPrint('Callback: $name');
setState(() {});
}
void _handleStateChange(AppLifecycleState state) {
debugPrint('State change: ${state.name}');
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return const Center();
}
Запустите приложение на десктопе или эмуляторе Android и поэкспериментируйте с режимами перехода из одного состояния в другое, наблюдая за выводимой
в терминал информацией:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
AppLifecycleDisplay init: null
Callback: inactive
State change: inactive
Callback: hide
State change: hidden
Callback: show
State change: inactive
Callback: resume
State change: resumed
AppLifecycleDisplay exiting
Callback: inactive
State change: inactive
Несмотря на наличие такого удобного инструмента для отслеживания состояния
жизненного цикла приложения, не следует на все 146 % полагаться на него. Так, например, при выключении приложения с помощью диспетчера задач, выдергивании
батареи из телефона либо отключении питания состояния не будут обработаны.
Чтобы лучше понимать, какие из состояний поддерживаются платформой,
а какие синтезируются самим Flutter, рекомендую ознакомиться в официальной
документации с классом AppLifecycleState, а также обратиться к документации
самих платформ, где описываются принятые на них жизненные циклы приложений.
Резюме
В этой главе мы рассмотрели архитектуру Flutter и некоторые нюансы его работы,
а также поняли, как огрести от коллег, прибегая к «уличной магии». На что еще
хочется обратить внимание — не воспринимайте этот фреймворк как некую серебряную пулю. Да, на нем можно реализовать большой спектр кросс-платформенных
приложений, но одно лишь знание Flutter не избавит вас от погружения в особенности работы целевых платформ. Несмотря на обилие пакетов и плагинов,
Вопросы для самопроверки 257
поддерживающих различные платформы, всегда может наступить момент, когда
нет необходимого, или нужная платформа не поддерживается, или разработчик
отказался от его дальнейшего развития, или, того веселее, заказчик требует свести
к минимуму наличие внешних зависимостей в проекте. К тому же работу напильником под каждую операционную систему никто не отменял!
Были рассмотрены также жизненный цикл виджета, приложения и способы
организации передачи информации по дереву элементов. Обращение к ближайшему или дальнему StatefulWidget с помощью BuildContext имеет линейную сложность О(n), из-за чего при большом количестве используемых виджетов к такому
способу получения данных дочерним узлом от родительского стоит прибегать,
только когда они располагаются довольно близко. Предпочтительный способ
организации взаимодействия между элементами дерева — использование InheritedWidget и его производных классов, так как их поиск посредством BuildContext
занимает константное время О(1). Чаще всего вам не придется напрямую работать
с InheritedWidget — за вас это сделает используемый менеджер состояний (Provider,
Riverpod и т. д.), но знание того, что происходит «под капотом», значительно ускорит
их изучение и начало применения в реальных проектах.
Вопросы для самопроверки
1. Сколько слоев содержит архитектура Flutter? Каково их предназначение?
2. Как Flutter отрисовывает виджеты на экране?
3. Сколько исполнителей задач потоков используется в приложениях на Flutter?
Каково предназначение каждого из исполнителей задач потоков?
4. Какие папки и файлы Flutter помещает в новый проект? В чем их предназначение?
5. Зачем в проекте нужен файл pubspec.yaml? Перечислите его основные поля
и зоны их ответственности.
6. Сколько во Flutter существует типов виджетов? В чем их различия?
7. Зачем использовать константные конструкторы при объявлении виджета?
8. Каков жизненный цикл виджетов с изменяемым состоянием?
9. Для чего нужен BuildContext и как он связан с деревом элементов?
10. Какие способы передачи данных от родительского виджета к дочернему и обратно вы знаете? В чем их сходство и различия? В каких ситуациях какой из
способов использовать лучше всего?
11. За что отвечают ключи и в каких случаях их следует использовать?
12. Каков жизненный цикл у приложений на Flutter? Расскажите, за что отвечает
каждое состояние жизненного цикла и какие существуют нюансы работы
с его событиями.
Глава 2
ОСНОВНЫЕ ВИДЖЕТЫ, ИХ КОМПОНОВКА
И РАБОТА С ASSETS
В этой главе мы рассмотрим ряд основных существующих виджетов и принципы
их компоновки и поговорим о том, как добавлять ресурсы в assets. Почему только
ряд основных виджетов? Все дело в том, что базовых виджетов во Flutter огромное
количество! А если говорить о том, сколько готовых решений виджетов представлено на https://pub.dev, то рассмотреть их все не представляется возможным. Поэтому,
если не нашли интересующий вас виджет, обратитесь к официальной документации
либо к репозиторию пакетов (pub).
Начиная с раздела 2.2, большая часть кода главы будет располагаться в теле
виджета Scaffold — стартового приложения, где из класса _MyHomePageState вырезан
функционал счетчика и удален виджет кнопки:
// base_url/2/flutter_app_template/lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Placeholder(), // Виджет-заглушка, на его место будут
// добавляться рассматриваемые в главе виджеты
),
);
}
Если нет желания создавать новый проект, воспользуйтесь шаблоном из репозитория.
2.1. Стили виджетов — Material vs Cupertino
Flutter предоставляет разработчикам два варианта стиля виджетов с разными
наборами компонентов пользовательского интерфейса и различным поведением:
Material и Cupertino. Иногда компании разрабатывают собственную дизайн-систему
и UI ToolKit, предоставляющий готовую реализацию ее виджетов. Это актуально
2.1. Стили виджетов — Material vs Cupertino 259
для тех случаев, когда у элементов пользовательского интерфейса должны быть
однотипные дизайн и цветовое оформление или определенное поведение, не свойственное стандартным виджетам Flutter и улучшающее пользовательский опыт
конечного потребителя.
Material Design (https://m3.material.io/) — это дизайн-система с открытым исходным кодом, созданная и поддерживаемая дизайнерами и разработчиками Google.
Начиная с Flutter 3.16, по умолчанию используется ее третья версия, лучше адаптированная для экранов различного размера. При ее проектировании была принята
концепция, согласно которой виджеты в стиле Material могут использоваться на
различных платформах: Android, iOS, Web и даже Desktop. Чтобы подключить
данный стиль, укажите в начале файла с кодом следующий импорт:
import 'package:flutter/material.dart';
Cupertino Design — дизайн-система от Apple, содержащая набор рекомендаций
по пользовательскому интерфейсу приложений для операционной системы iOS.
Наличие виджетов в стиле Cupertino дает Flutter-разработчикам, преклоняющимся
перед величием яблочной компании, возможность позволить прикоснуться к этому
великолепию и обычным пользователям других платформ. Но Apple не была бы
Apple, если бы не подложила небольшую лицензионную свинью. Так, например,
при использовании виджетов Cupertino на платформе, отличной от iOS, Flutter
будет использовать шрифт, не предназначенный для этого стиля. Конечно, эту проблему можно решить подключением подходящего шрифта через assets, но осадочек
все равно имеется. Чтобы использовать виджеты данного стиля, укажите в начале
файла с кодом следующий импорт:
import 'package:flutter/cupertino.dart';
В зависимости от того, какой стиль был выбран для приложения в качестве основного, в корне дерева должен располагаться виджет MaterialApp или CupertinoApp.
Это делается для того, чтобы задать функциональность, характерную для конкретной дизайн-системы, а именно: стиль текста, навигацию по экранам приложения,
анимацию и т. д.
Аргументы конструктора классов MaterialApp и CupertinoApp мало чем различаются, а так как стиль Material более универсальный, то рассмотрим именно его,
выделив основные, а чтобы ознакомиться со всеми аргументами и их предназначением, обратитесь к документации Flutter. У всех аргументов конструктора есть
значение по умолчанию, поэтому при объявлении экземпляра класса используйте
только необходимые для конкретного случая:
const MaterialApp({
super.key, // Ключ, используемый для идентификации виджета
this.navigatorKey,// Ключ для доступа к навигатору
this.scaffoldMessengerKey,// Ключ для доступа к ScaffoldMessenger
this.home, // Виджет, используемый в качестве начального
this.initialRoute, // Начальный маршрут в приложение
this.title = '', // Текст заголовка приложения
this.theme, // Основная тема приложения
this.locale, // Язык приложения
this.shortcuts, // Коллекция глобальных горячих клавиш
260 Глава 2 Основные виджеты, их компоновка и работа с assets
// Поддерживаемые языки
this.supportedLocales = const <Locale>[Locale('en', 'US')],
// Карта маршрутов для навигации по страницам (экранам) приложения
Map<String, WidgetBuilder> this.routes =
const <String, WidgetBuilder>{},
// Список наблюдателей навигатора
List<NavigatorObserver> this.navigatorObservers =
const <NavigatorObserver>[],
// Опции отладки и отображения различных инструментов
this.debugShowMaterialGrid = false,
this.showPerformanceOverlay = false,
this.checkerboardRasterCacheImages = false,
this.checkerboardOffscreenLayers = false,
this.showSemanticsDebugger = false,
this.debugShowCheckedModeBanner = true,
})
Более подробно с некоторыми аргументами мы разберемся в следующих разделах и главах книги, а пока реализуем простенькое приложение, в котором после
нажатия кнопки она будет менять свой стиль с Material на Cupertino и обратно
(рис. 2.1). Да, так как во Flutter все является виджетом, то на одном экране можно
использовать виджеты различных стилей вне зависимости от того, какой из них был
выбран основным. Другой вопрос, зачем так издеваться над глазами пользователя,
но иногда это позволяет добавить некоторого шарма вашему приложению:
// base_url/2/flutter_app_style/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.grey,
),
useMaterial3: true,
),
debugShowCheckedModeBanner: false,
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
}
@override
State<MyHomePage> createState() = > _MyHomePageState();
2.1. Стили виджетов — Material vs Cupertino 261
class _MyHomePageState extends State<MyHomePage> {
var _isMaterialStyle = true;
void _changedStyle() {
setState(() {
_isMaterialStyle = !_isMaterialStyle;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
_isMaterialStyle ? 'Material style' : 'Cupertino style',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
_isMaterialStyle
? ElevatedButton(
onPressed: _changedStyle,
child: const Text('Change style'),
)
: CupertinoButton(
color: Theme.of(context).colorScheme.primary,
onPressed: _changedStyle,
child: const Text('Change style'),
),
],
),
),
);
}
Рис. 2.1. Пример отображения кнопки при использовании различных стилей
262 Глава 2 Основные виджеты, их компоновка и работа с assets
Когда верстка пользовательского интерфейса приложения и его основной
стиль должны зависеть от целевой платформы, можно задействовать специализированные пакеты с https://pub.dev, например, flutter_platform_widgets, либо, закатав
рукава, обратиться к старому дедовскому способу — импортировать стандартную
библиотеку foundation.
Перепишем стартовое приложение-счетчик таким образом, чтобы для Android
использовался стиль Material, а для любой другой платформы — Cupertino. Создайте
новый проект flutter_app_style_2 и переименуйте класс MyHomePage в MyMaterialPage. Поскольку ряд виджетов Material и Cupertino различаются довольно сильно
(например, Scaffold), проще объявить отдельный класс для работы с каждым стилем:
// base_url/2/flutter_app_style_2/lib/main.dart
import 'package:flutter/cupertino.dart';
// ...
class MyCupertinoPage extends StatefulWidget {
const MyCupertinoPage({super.key, required this.title});
final String title;
}
@override
State<MyCupertinoPage> createState() = > _MyCupertinoPageState();
class _MyCupertinoPageState extends State<MyCupertinoPage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
}
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(widget.title),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 20),
CupertinoButton.filled(
onPressed: _incrementCounter,
child: const Icon(CupertinoIcons.add),
),
],
),
),
);
}
2.1. Стили виджетов — Material vs Cupertino 263
На следующем шаге импортируем библиотеку foundation и объявим функции
для создания платформозависимых виджетов:
// base_url/2/flutter_app_style_2/lib/main.dart
import 'package:flutter/foundation.dart';
// ...
Widget createScaffold(String title) {
if (defaultTargetPlatform = = TargetPlatform.android) {
return MyMaterialPage(title: title);
} else {
return CupertinoPageScaffold(
child: MyCupertinoPage(title: title),
);
}
}
Widget createStyleApp(String title){
var scaffoldWidget = createScaffold(title);
if (defaultTargetPlatform = = TargetPlatform.android) {
return MaterialApp(
title: title,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.grey,
),
useMaterial3: true,
),
home: scaffoldWidget,
);
} else {
return CupertinoApp(
title: title,
theme: const CupertinoThemeData(
brightness: Brightness.light,
),
home: scaffoldWidget,
);
}
}
В качестве последнего штриха перепишите код класса MyApp следующим образом:
// base_url/2/flutter_app_style_2/lib/main.dart
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return createStyleApp('Flutter Style');
}
после чего запустите приложение на эмуляторе и десктопе (или в браузере)
(рис. 2.2).
Если приложение для iOS должно иметь аутентичные вид, анимацию, навигацию
и управление жестами, то задействуйте виджеты Cupertino. Это обеспечит отсутствие
припадков праведного гнева у пользователей из-за того, что дизайн виджетов и их
поведение отличаются от нативных (характерных для операционной системы).
Для всех остальных платформ (и для iOS при уникальном дизайне приложения)
идеально подходят виджеты Material.
264 Глава 2 Основные виджеты, их компоновка и работа с assets
Рис. 2.2. Пример запуска приложения на разных платформах
2.2. Виджеты-«коробки»
Виджеты-«коробки» позволяют максимально кастомизировать приложение — от
формы виджетов, цвета и теней до разных вариантов вращения. Такие виджеты
часто содержат префикс Box в названии и выполняют одну определенную функцию.
Но даже среди них существует универсальный швейцарский нож, объединяющий
в себе практически все такие виджеты, — Container. Его мы рассмотрим в последнюю очередь, а начнем с более специфичных.
2.2.1. «Коробки» для задания размеров
Начинаем с виджетов, которые могут ограничить свои дочерние виджеты по размеру. Первый из них — SizedBox. Он нужен для того, чтобы все дочерние виджеты
имели заданную нами ширину и/или высоту (рис. 2.3). За это отвечают параметры
width и height соответственно:
2.2. Виджеты-«коробки» 265
// base_url/2/2.2/flutter_sizedbox/lib/example_1.dart
SizedBox(
width: 100,
height: 100,
child: ColoredBox(color: Colors.red),
),
Рис. 2.3. SizedBox со сторонами 100
Кроме стандартного конструктора, у SizedBox есть еще несколько именованных:
y SizedBox.expand — создаст виджет максимального размера, который позволяет
его родительский виджет;
y SizedBox.shrink — создаст виджет минимального размера, который только
позволяет его родительский виджет;
y SizedBox.fromSize — использует объект типа Size для того, чтобы задать
ширину и высоту;
y SizedBox.square — создает квадратный виджет, поэтому вместо ширины
и высоты мы передаем ему параметр dimension — размер одной стороны
квадрата:
// base_url/2/2.2/flutter_sizedbox/lib/example_2.dart
SizedBox.fromSize(
size: Size(100, 100)
child: ColoredBox(color: Colors.red),
),
// base_url/2/2.2/flutter_sizedbox/lib/example_3.dart
SizedBox.square(
dimension: 100,
child: ColoredBox(color: Colors.red),
),
Но как быть, если нужно занять определенную часть родительского виджета?
В таком случае воспользуйтесь FractionallySizedBox. Этот виджет позволяет задать
266 Глава 2 Основные виджеты, их компоновка и работа с assets
не ширину и высоту, а отношение в процентах к максимально занимаемой площади
с помощью параметров heightFactor и widthFactor (рис. 2.4). Например, если нужно, чтобы какой-то виджет занимал 70 % высоты экрана, установите widthFactor
равным 0.7:
// base_url/2/2.2/flutter_fractionallysizedbox/lib/main.dart
FractionallySizedBox(
widthFactor: 0.5,
heightFactor: 0.5,
child: ColoredBox(color: Colors.red),
),
Рис. 2.4. FractionallySizedBox в половину допустимого размера
Виджет ConstrainedBox более комплексный, хотя он принимает меньше параметров, чем SizedBox (рис. 2.5) (в него, кроме дочернего виджета, мы должны передать
объект BoxConstrains), по которому будет ограничиваться размер:
2.2. Виджеты-«коробки» 267
// base_url/2/2.2/flutter_constrainedbox/lib/main.dart
ConstrainedBox(
constraints: BoxConstraints(minWidth: 100, minHeight: 100),
child: ColoredBox(color: Colors.red),
),
Рис. 2.5. ConstrainedBox с минимальными размерами 100
Следующий виджет — BoxConstraints. В отличие от SizedBox в этом случае дочерний виджет может изменять свой размер, ограничиваясь минимальным и максимальным размерами родительского виджета, что регулируется следующими
именованными аргументами конструктора:
y minWidth — минимальная ширина дочернего виджета, по умолчанию 0;
y maxWidth — максимальная ширина дочернего виджета, по умолчанию равна
double.infinity, то есть не ограничена;
y minHeight — минимальная высота дочернего виджета, по умолчанию 0;
y maxHeight — максимальная ширина дочернего виджета, по умолчанию равна
double.infinity, то есть не ограничена.
Такие параметры позволяют задавать ограничения только по ширине или только
по высоте. За другие виды ограничений отвечают следующие именованные конструкторы виджета BoxConstraints:
y BoxConstraints.expand — позволяет создать ограничения, которые растягивают
виджет, пока он не станет нужного размера или не сравняется с родительским
виджетом. Принимает width и height для задания настраиваемого размера.
Но если мы не передадим их, то поведение виджета будет аналогично поведению SizedBox.expand;
y BoxConstraints.loose — принимает на вход объект типа Size и запрещает
дочернему виджету выходить за рамки области заданного размера. Похож
на вариант expand;
y BoxConstraints.tight — принимает на вход объект типа Size и создает ограничения, которые соблюдаются для области заданного размера;
268 Глава 2 Основные виджеты, их компоновка и работа с assets
BoxConstraints.tightFor — повторяет BoxConstraints.tight, только вместо
объекта типа Size на вход конструктора подаются width и height;
y BoxConstraints.tightForFinite — повторяет BoxConstraints.tightFor, ширина и высота должны быть заданы для всех случаев, кроме тех, когда они
бесконечны.
Еще один виджет, который помогает нам с размерами, — AspectRatio. Он позволяет задать соотношение сторон, чтобы ширина дочернего виджета зависела
от высоты и наоборот. Для его работы потребуется еще один виджет (допустим,
SizedBox), чтобы ограничить высоту или ширину, так как именно от них и будет
отсчитываться соотношение. Например, здесь нам нужно встроить проигрыватель
видео со стандартным соотношением 16:9 (рис. 2.6):
y
// base_url/2/2.2/flutter_aspectratio/lib/main.dart
SizedBox(
height: 100,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ColoredBox(color: Colors.red),
),
),
Рис. 2.6. AspectRatio с соотношением сторон 16:9
2.2.2. «Коробки» для отрисовки
К «коробкам» относятся и виджеты, которые помогают с отрисовкой, например
ColoredBox (рис. 2.7), который принимает на вход определенный цвет и окрашивает
им максимально доступное ему пространство:
// base_url/2/2.2/flutter_coloredbox/lib/main.dart
SizedBox(
height: 100,
width: 100,
child: ColoredBox(
2.2. Виджеты-«коробки» 269
),
),
color: Colors.red,
child: ColoredBox(color: Colors.blue),
Рис. 2.7. Виджет ColoredBox
Если необходимо реализовать что-то более сложное, на помощь приходит
DecoratedBox. В конструкторе на вход именованного аргумента decoration он принимает объект типа BoxDecoration, который позволяет менять цвет, форму, добавлять
тень, рамки и скругления углов:
// base_url/2/2.2/flutter_decoratedbox/lib/example_1.dart
SizedBox(
height: 100,
width: 100,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.red,
),
),
),
Давайте разберемся с различными возможностями этого виджета, не считая
цвета, и начнем с фона. Наряду с цветом для фона в этом виджете можно применить
изображение (рис. 2.8). Делается это с помощью аргумента image и объекта типа
DecorationImage, аналогичного классу Image:
// base_url/2/2.2/flutter_decoratedbox/lib/example_2.dart
SizedBox(
height: 100,
width: 100,
child: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage('https://http.cat/images/200.jpg'),
fit: BoxFit.cover,
),
),
),
),
270 Глава 2 Основные виджеты, их компоновка и работа с assets
Рис. 2.8. Виджет DecoratedBox с фоном в виде изображения
Существует также заливка градиентом (рис. 2.9). Она позволяет создавать
плавные переходы от одного цвета к другому:
// base_url/2/2.2/flutter_decoratedbox/lib/example_3.dart
SizedBox(
height: 100,
width: 100,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.yellow, Colors.green],
),
),
),
),
Рис. 2.9. DecoratedBox с фоном из градиента
Как видно из примера, мы создали линейный градиент от желтого цвета к зеленому. Существуют и другие типы градиентов (рис. 2.10):
y радиальный, где стартовый цвет находится в центре виджета, а конечный —
по краям;
y развертка, когда цвета идут по кругу.
2.2. Виджеты-«коробки» 271
Рис. 2.10. Градиенты: слева направо — линейный, радиальный и развертка
Рассмотрим каждый из трех типов градиента подробнее, начав с линейного:
// base_url/2/2.2/flutter_decoratedbox/lib/example_4.dart
LinearGradient(
colors: [Colors.yellow, Colors.green, Colors.green, Colors.blue],
stops: [0.0, 0.33, 0.67, 1],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
За цвет в любом градиенте отвечает аргумент colors, куда мы и передаем цвета
от первого к последнему. Их может быть любое количество. Далее идет аргумент
stops, который позволяет указать, где будет находиться тот цвет, который был задан под таким же индексом. Это означает, что список цветов и список остановок
должны быть одной длины. Значения могут быть от 0 (начало градиента) до 1
(конец градиента).
А вот другие свойства будут различаться. Для линейного градиента, который
задается с помощью LinearGradient, это будут его начало (аргумент begin) и конец
(аргумент end) применительно к нашему виджету. По умолчанию это центры слева
и справа. Но ничто не мешает настроить направление градиента вручную. Например,
для радиального градиента, который задается с помощью класса RadialGradient,
доступны следующие аргументы конструктора:
y center — местоположение центра градиента в виджете, размер которого принимается от [–1, –1] до [1, 1];
y radius — радиус градиента. Все за его пределами будет залито конечным
(последним в списке) цветом:
// base_url/2/2.2/flutter_decoratedbox/lib/example_5.dart
RadialGradient(
colors: [Colors.yellow, Colors.green],
center: Alignment(0.7, -0.6),
radius: 0.2,
),
Виджет SweepGradient очень похож на радиальный, но в его конструктор вместо радиуса передаются два аргумента — startAngle и endAngle. Они указываются
в значениях от 0 до числа π, умноженного на 2:
// base_url/2/2.2/flutter_decoratedbox/lib/example_6.dart
SweepGradient(
colors: [Colors.yellow, Colors.green],
center: Alignment(0.7, -0.6),
startAngle: 0.1,
endAngle: Math.pi,
),
272 Глава 2 Основные виджеты, их компоновка и работа с assets
BoxDecoration можно использовать также для обрамления виджета-потомка
в рамку (рис. 2.11). За это отвечает аргумент конструктора border:
// base_url/2/2.2/flutter_decoratedbox/lib/example_7.dart
SizedBox(
height: 100,
width: 100,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Colors.red),
),
),
),
Рис. 2.11. DecoratedBox с рамкой
Этот аргумент конструктора принимает объекты класса Border, посредством
которых мы можем сконфигурировать, как будет выглядеть рамка вокруг дочернего
виджета. У класса Border есть четыре конструктора — один по умолчанию и три
именованных:
y Border({top, right, bottom, left}) — ожидает на вход данные для каждой
стороны по отдельности. Они передаются аргументам left, right, top и bottom,
принимающим на свой вход объекты типа BorderSide;
y Border.all({color, width, style, strokeAlign}) — применяет стили для всех
сторон сразу;
y Border.symmetric({vertical, horizontal}) — позволяет отдельно задать вертикальные и горизонтальные рамки;
y Border.fromBorderSide(BorderSide side) — аналогичен конструктору all, но
принимает BorderSide как входной аргумент конструктора вместо каждого
параметра по отдельности.
Ширина добавленной на экран рамки по умолчанию равна 1, она черного цвета,
и на ней нет разрывов. Мы можем поменять это с помощью параметров width, color
и style соответственно.
Следующий аргумент конструктора виджета BoxDecoration — borderRadius
(рис. 2.12). Он отвечает за скругление углов и принимает объекты типа BorderRadius:
2.2. Виджеты-«коробки» 273
// base_url/2/2.2/flutter_decoratedbox/lib/example_8.dart
SizedBox(
height: 100,
width: 100,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(16),
),
),
),
Рис. 2.12. DecoratedBox со скругленными углами
Из примера видно, что мы задали скругление с помощью именованного конструктора circular, в который передали простое числовое значение. Но у BorderRadius
он не единственный:
y BorderRadius.all(Radius radius) — принимает радиус и применяет соответствующее скругление ко всем углам;
y BorderRadius.only({topLeft, topRight, bottomLeft, bottomRight}) — позволяет
задать скругление одному или нескольким углам по отдельности;
y BorderRadius.horizontal({left, right}) — позволяет задать скругление углам
либо справа, либо слева;
y BorderRadius.vertical({top, bottom}) — позволяет задать скругление углам
либо сверху, либо снизу.
Все именованные конструкторы, кроме circular, можно сделать константными,
поэтому в качестве его замены лучше всего объявить BorderRadius с конструктором
all и передать ему Radius.circular, что будет иметь тот же эффект:
// base_url/2/2.2/flutter_decoratedbox/lib/example_9.dart
SizedBox(
height: 100,
width: 100,
child: DecoratedBox(
decoration: BoxDecoration(
274 Глава 2 Основные виджеты, их компоновка и работа с assets
),
),
color: Colors.red,
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
Используя аргумент shape виджета BoxDecoration, мы можем не только скруглить
углы, но и изменить форму дочернего виджета. Так, например, при передаче ему BoxShape.circle на экране появится эллипс или его частный случай — круг (рис. 2.13):
// base_url/2/2.2/flutter_decoratedbox/lib/example_10.dart
SizedBox(
height: 100,
width: 100,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
Рис. 2.13. DecoratedBox в форме круга
Последний аргумент конструктора виджета BoxDecoration, который мы разберем, — boxShadow. Он позволяет добавить тень к текущему виджету (рис. 2.14).
Делается это с помощью списка объектов BoxShadow (может содержать и один элемент). Далее приведены аргументы конструктора класса BoxShadow и их назначение:
y offset — сдвиг тени относительно текущего виджета, по умолчанию отсутствует;
y color — цвет тени;
y blurRadius — радиус для размытия тени, чтобы она не выглядела как градиент.
Размытие происходит по Гауссу;
y spreadRadius — расстояние от виджета до конца тени:
// base_url/2/2.2/flutter_decoratedbox/lib/example_11.dart
SizedBox(
height: 100,
width: 100,
2.2. Виджеты-«коробки» 275
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
offset: Offset(2, 2),
color: Colors.black12,
blurRadius: 4,
spreadRadius: 4,
),
],
),
),
),
Рис. 2.14. DecoratedBox с тенью
2.2.3. Виджет для положения в пространстве
Теперь немного поговорим о положении виджетов в пространстве. За это тоже
могут отвечать некоторые виджеты-«коробки». Начнем, пожалуй, с виджета
Padding (рис. 2.15), который позволяет задать отступы как внутри виджета, так
и снаружи:
// base_url/2/2.2/flutter_padding/lib/main.dart
SizedBox(
height: 100,
width: 100,
child: ColoredBox(
color: Colors.blue,
child: Padding(
padding: const EdgeInsets.all(16),
child: ColoredBox(color: Colors.red),
),
),
),
Как видно на рисунке, дочерний виджет
находится на расстоянии от краев родительского. Это было сделано с помощью аргумента
padding одноименного виджета. Он принимает
Рис. 2.15. Виджет Padding
276 Глава 2 Основные виджеты, их компоновка и работа с assets
на вход объект типа EdgeInsetsGeometry, который можно создать с помощью одного
из именованных конструкторов класса EdgeInsets:
y EdgeInsets.all(double value) — применяет одинаковый отступ ко всем сторонам;
y EdgeInsets.symmetric({horizontal, vertical}) — позволяет задать отдельные
отступы для боковых сторон (слева и справа) и сторон сверху и снизу;
y EdgeInsets.only({left, right, top, bottom}) — позволяет задать отдельный
отступ для каждой стороны. Можно добавить его только для одной стороны
или сразу для нескольких;
y EdgeInsets.fromLTRB(left, right, top, bottom) — полностью повторяет only,
только задавать нужно все значения и в определенном порядке: слева —
сверху — справа — снизу.
Еще один виджет, который позволяет изменять положение дочернего виджета
в пространстве, — Transform. Он может использоваться для масштабирования, разворота или отзеркаливания виджета (рис. 2.16):
// base_url/2/2.2/flutter_transform/lib/example_1.dart
Transform.scale(
scale: 1.5,
child: SizedBox(
height: 100,
width: 100,
child: ColoredBox(color: Colors.blue),
),
),
Рис. 2.16. Пример работы виджета Transform
Здесь мы использовали один из конструкторов Transform — Transform.scale.
Он позволяет изменять размер виджета вне зависимости от того, какие размеры
ему заданы, — просто увеличивает или уменьшает картинку. Это делается с помощью параметра scale, но можно применить разные коэффициенты масштаби
рования по горизонтали и вертикали, за что отвечают scaleX и scaleY соответственно.
Еще один из конструкторов Transform — Transform.rotate. Он позволяет вращать
дочерний виджет. Для этого ему на вход аргумента angle передается значение угла.
Основной нюанс заключается в том, что это угол в радианах, а не в градусах! Есть
два способа вычислить угол — привязаться к числу π из dart:math или вычислить
радианы из заданного угла:
2.2. Виджеты-«коробки» 277
// base_url/2/2.2/flutter_transform/lib/example_2.dart
Transform.rotate(
angle: Math.pi / 4,
child: SizedBox(
height: 100,
width: 100,
child: ColoredBox(color: Colors.blue),
),
),
Мы также можем отзеркалить дочерний виджет с помощью конструктора Transаргументу
form.flip. Для отражения по вертикали нужно передать значение true
flipY, а для отражения по горизонтали — аргументу flipX:
// base_url/2/2.2/flutter_transform/lib/example_3.dart
Transform.flip(
flipX: true,
child: SizedBox(
height: 100,
width: 100,
child: Center(child: Text(''Transform.flip'')),
),
),
2.2.4. Виджет Container
Мы посмотрели на основные виджеты-«коробки», но остался главный из них —
Container (рис. 2.17). Он объединяет в себе все перечисленные виджеты-«коробки»:
// base_url/2/2.2/flutter_container/lib/main.dart
Container(
width: 100,
height: 100,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Center(child: Text(''Container'')),
),
Обратите внимание: виджету Container
можно задать размер, как у SizedBox, внутренний отступ (вместо использования Padding)
и внешний вид с помощью BoxDecoration, как
у DecoratedBox. Но это еще не все!
Кроме размера, имеется возможность задать ограничения, как у ConstrainedBox. Вместо внутреннего отступа посредством padding добавить внешний с помощью margin,
что отменяет потребность в оборачивании
Container внутрь виджета Padding. Для украшения можно задать цвет, как в ColoredBox,
либо поменять внешний вид переднего плана
Рис. 2.17. Виджет Container
с помощью аргумента foregroundDecoration.
Имеется также возможность с помощью аргумента transform конструктора Container
использовать трансформирование, передав в него объект типа Matrix4.
278 Глава 2 Основные виджеты, их компоновка и работа с assets
Виджет Container нужен, чтобы уменьшать вложенность. Так, например, чтобы
реализовать виджет из предыдущего примера (см. рис. 2.17) без помощи Container,
только средствами более примитивных виджетов-«коробок», потребовалось бы
минимум три виджета.
2.3. Виджеты компоновки
Очень часто в процессе разработки приложений перед вами будет стоять задача
правильно расположить множество виджетов на экране. Это не так просто, как
может показаться на первый взгляд! Поэтому, чтобы уменьшить вероятность получения вами нервного срыва при попытке корректно расположить тот или иной
виджет, в данном разделе мы рассмотрим не только набор виджетов, но и правила
и ограничения компоновки, применяемые во Flutter.
Когда вы добавляете дочерний виджет на экран, на него действуют ограничения
родительского виджета (элемента) как по минимальной, так и по максимальной
высоте и ширине. Получая эти ограничения, дочерний виджет начинает обход
вложенных виджетов, по отношению к которым он является родительским, и сообщает им уже их ограничения, попутно собирая данные о размере, который они
хотят занимать. И лишь последовательно разместив их, виджет передает данные
о собственном размере своему родителю. А так как размер должен отталкиваться
от родительских ограничений, выход за них способен привести к довольно интересным и неприятным последствиям. Чтобы их избежать, необходимо помнить
о следующих ограничениях компоновки виджетов.
y Виджет получает собственный размер с учетом ограничений родителя, то есть
не может иметь произвольный размер.
y Положение виджета на экране устанавливается только родительским виджетом, а не им самим.
y Не имея представления обо всем дереве виджетов экрана, нельзя определить
точное положение и размер произвольного (любого) виджета.
2.3.1. Виджеты Row и Column
Для вывода нескольких виджетов в ряд по горизонтали используется виджет Row,
а по вертикали — Column, и так как они идентичны во всем, кроме своей оси, будем
рассматривать их вместе. А если еще точнее, то все свойства этих виджетов будут
рассмотрены только на примере виджета Row.
Чтобы поместить виджеты в Row или Column (рис. 2.18), воспользуйтесь аргументом конструктора children, который принимает на свой вход List<Widgets>:
// base_url/2/2.3/flutter_row_column/lib/example_1.dart
Row(
children: [
Container(width: 100, height: 100, color: Colors.red),
Container(width: 100, height: 100, color: Colors.blue),
],
spacing: 10, // размер отступов между виджетами Container
),
2.3. Виджеты компоновки 279
// base_url/2/2.3/flutter_row_column/lib/example_2.dart
Column(
children: [
Container(width: 100, height: 100, color: Colors.red),
Container(width: 100, height: 100, color: Colors.blue),
],
spacing: 16, // размер отступов между виджетами Container
),
Рис. 2.18. Виджеты: слева — Row; справа — Column
По умолчанию ряд или колонка будут занимать все доступное им пространство
на своей оси. За это отвечает аргумент mainAxisSize, который принимает на вход
перечисление MainAxisSize — max или min (рис. 2.19):
// base_url/2/2.3/flutter_row_column/lib/example_3.dart
Row(
mainAxisSize: MainAxisSize.max, // Максимальный размер
// MainAxisSize.min — минимальный размер
children: [
Container(width: 100, height: 100, color: Colors.red),
Container(width: 100, height: 100, color: Colors.blue),
],
spacing: 16,
),
Кроме того, в ходе работы с основной
осью имеется возможность изменять положение объектов, находящихся внутри
виджета. Для этого на вход аргументу
mainAxisAlignment необходимо передать
один из следующих вариантов перечисления MainAxisAlignment (рис. 2.20):
y MainAxisAlignment.start — все виджеты располагаются в начале;
y MainAxisAlignment.end — все виджеты располагаются в конце;
y MainAxisAlignment.center — все виджеты располагаются в центре;
Рис. 2.19. Свойство MainAxisSize виджета Row
280 Глава 2 Основные виджеты, их компоновка и работа с assets
y
MainAxisAlignment.spaceBetween — расстояние между дочерними виджетами
становится одинаковым;
— расстояние между дочерними виджетами
становится одинаковым, а с краев появляются отступы в половину этого
расстояния;
y MainAxisAlignment.spaceEvenly — расстояние от краев и между дочерними
виджетами становится одинаковым:
y
MainAxisAlignment.spaceAround
// base_url/2/2.3/flutter_row_column/lib/example_4.dart
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Container(width: 100, height: 100, color: Colors.red),
Container(width: 100, height: 100, color: Colors.blue),
],
spacing: 16,
),
Рис. 2.20. Свойство MainAxisAlignment виджета Row
Если же вам необходимо перемещать дочерние виджеты по второй оси — вертикальной для Row и горизонтальной для Column, — воспользуйтесь аргументом crossAxisAlignment, принимающим на вход один из следующих вариантов перечисления
CrossAxisAlignment (рис. 2.21):
y CrossAxisAlignment.start — дочерние виджеты будут располагаться в начале
оси — либо сверху (в Row), либо слева/справа (в Column) в зависимости от
направления текста на устройстве;
y CrossAxisAlignment.end — дочерние виджеты будут располагаться в конце
оси — либо сверху (в Row), либо справа/слева (в Column) в зависимости от
направления текста на устройстве;
2.3. Виджеты компоновки 281
— дочерние виджеты будут располагаться по
y
CrossAxisAlignment.center
y
CrossAxisAlignment.stretch — дочерние виджеты растянутся на весь доступ-
y
середине оси;
ный размер родительского виджета;
CrossAxisAlignment.baseline — подходит для Row, если все дочерние виджеты
содержат текст разных размеров и нужно, чтобы их нижняя часть была выровнена по одной линии:
// base_url/2/2.3/flutter_row_column/lib/example_5.dart
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(width: 100, height: 100, color: Colors.red),
const SizedBox(width: 16),
Container(width: 200, height: 200, color: Colors.blue),
],
spacing: 16,
),
Рис. 2.21. Свойство CrossAxisAlignment виджета Row
2.3.2. Виджеты Flexible, Expanded и Spacer
А что, если нам нужно растянуть какой-то виджет на максимально возможную ширину или высоту? Или сделать так, чтобы размеры дочерних виджетов составляли
2:1 или 3:5? Или добавить максимальное расстояние между виджетами? В этом нам
помогут виджеты Flexible, Expanded и Spacer соответственно.
Виджет Expanded позволяет своему потомку растягиваться на максимально доступную ширину (в Row) или высоту (в Column) родительского виджета. Когда рядом
282 Глава 2 Основные виджеты, их компоновка и работа с assets
друг с другом располагаются несколько Expanded, на экране они будут одинакового
размера (рис. 2.22):
// base_url/2/2.3/flutter_expanded/lib/example_1.dart
Row(
children: [
Expanded(
child: Container(height: 100, color: Colors.red),
),
Container(width: 100, height: 100, color: Colors.blue),
],
spacing: 16,
),
// base_url/2/2.3/flutter_expanded/lib/example_2.dart
Row(
children: [
Expanded(
child: Container(height: 100, color: Colors.red),
),
Expanded(
child: Container(height: 100, color: Colors.blue),
),
],
),
Рис. 2.22. Виджет Expanded: слева — один; справа — два
Для настройки размера дочернего виджета путем установки различных соотношений воспользуйтесь аргументом flex (рис. 2.23). Например, если только для
одного Expanded мы зададим flex: 2 (второму значение этого аргумента не устанавливаем), то соотношение размеров будет 2:1:
// base_url/2/2.3/flutter_expanded/lib/example_2.dart
Row(
children: [
Expanded(
flex: 3,
child: Container(height: 100, color: Colors.red),
),
Expanded(
flex: 5,
child: Container(height: 100, color: Colors.blue),
),
],
),
2.3. Виджеты компоновки 283
Рис. 2.23. Свойство flex виджета Expanded
Виджет Flexible максимально похож на Expanded. Это связано с тем, что до тех
пор, пока в его конструкторе не задан аргумент fit, они будут работать совершенно идентично (рис. 2.24). Данный аргумент отвечает за то, сколько места внутри
Flexible займет дочерний виджет, что регулируется передачей на его вход одного
из следующих вариантов перечисления FlexFit:
y FlexFit.tight — дочерний виджет обязан занять максимально доступную ему
площадь. Используется по умолчанию и делает FlexFit полным аналогом
Expanded;
y FlexFit.loose — максимальный размер дочернего виджета ограничен, но он
может быть и меньше:
// base_url/2/2.3/flutter_flexible/lib/example_1.dart
Row(
children: [
Flexible(
flex: 3,
child: Container(height: 100, color: Colors.red),
),
Flexible(
flex: 5,
child: Container(height: 100, color: Colors.blue),
),
],
),
// base_url/2/2.3/flutter_flexible/lib/example_2.dart
Row(
children: [
Flexible(
flex: 2,
fit: FlexFit.loose,
child: Container(width: 100, height: 100, color: Colors.red),
),
Flexible(
flex: 1,
child: Container(height: 100, color: Colors.blue),
),
],
),
284 Глава 2 Основные виджеты, их компоновка и работа с assets
Рис. 2.24. Виджет Flexible: слева — внешний вид; справа — его свойство fit
Виджет Spacer используется в качестве разделителя и растягивается до максимально возможного размера, создавая пустое пространство между остальными
виджетами в ряду или в колонке (рис. 2.25). У его конструктора также есть аргумент
flex, позволяющий задавать соотношение:
// base_url/2/2.3/flutter_spacer/lib/main.dart
Column(
children: [
Row(
children: [
Container(width: 100, height: 100, color:
const Spacer(),
Container(width: 100, height: 100, color:
],
),
Row(
children: [
Container(width: 100, height: 100, color:
const Spacer(),
Container(width: 100, height: 100, color:
const Spacer(flex: 2),
Container(width: 100, height: 100, color:
],
),
],
spacing: 16,
),
Рис. 2.25. Виджет Spacer
Colors.red),
Colors.blue),
Colors.red),
Colors.blue),
Colors.green),
2.3. Виджеты компоновки 285
Если в Row и Column отступы между вложенными виджетами одинаковы, воспользуйтесь аргументом spacing.
2.3.3. Виджет Wrap
Данный виджет, в отличие от Row, позволяет переносить потомков на другую строку
(рис. 2.26). Помимо уже привычного нам аргумента конструктора children, для
начала работы с ним необходимо добавить еще два:
y spacing — отступ между дочерними виджетами;
y runSpacing — отступ между строками:
// base_url/2/2.3/flutter_wrap/lib/example_1.dart
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: [
Container(width: 100, height: 100, color: Colors.red),
Container(width: 100, height: 100, color: Colors.blue),
Container(width: 100, height: 100, color: Colors.green),
Container(width: 100, height: 100, color: Colors.yellow),
Container(width: 100, height: 100, color: Colors.purple),
],
),
Рис. 2.26. Виджет Wrap
Если перед вами стоит задача указать, как будут размещены дочерние виджеты
(по аналогии с Row), при объявлении Wrap явно укажите аргумент alignment, задающий размещение в одной строке и runAlignment между строками (рис. 2.27). Они
оба принимают перечисление WrapAlignment, которое идентично MainAxisAlignment
в виджете Flex (см. подраздел 2.3.1). Для работы с дополнительной осью используйте аргумент crossAxisAlignment, принимающий перечисление WrapCrossAlignment, благодаря которому можно задать расположение дочерних виджетов в начале,
в конце или в середине оси:
// base_url/2/2.3/flutter_wrap/lib/example_2.dart
Wrap(
spacing: 8.0,
286 Глава 2 Основные виджеты, их компоновка и работа с assets
alignment: WrapAlignment.end,
runSpacing: 8.0,
runAlignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Container(width: 100, height: 150, color: Colors.red),
Container(width: 100, height: 100, color: Colors.blue),
Container(width: 100, height: 150, color: Colors.green),
Container(width: 100, height: 100, color: Colors.yellow),
Container(width: 100, height: 150, color: Colors.purple),
],
),
Рис. 2.27. Свойства alignment, runAlignment и crossAxisAlignment виджета Wrap
2.3.4. Виджеты Stack и Positioned
С вертикальным и горизонтальным расположением виджетов на экране мы
разобрались. А что, если надо показать дочерние элементы друг над другом?
Например, текст на картинке. В этом случае на помощь приходит виджет Stack
(рис. 2.28). Он очень похож на Row и Column, но отображает дочерние виджеты без
привязки к оси:
// base_url/2/2.3/flutter_stack/lib/example_1.dart
Stack(
children: [
Container(width: 200, height: 200, color: Colors.blue),
Container(width: 150, height: 150, color: Colors.red),
Container(width: 100, height: 100, color: Colors.green),
],
),
2.3. Виджеты компоновки 287
Рис. 2.28. Виджет Stack
Как видно из рисунка, по умолчанию все дочерние виджеты Stack размещаются в левом верхнем углу. Чтобы явно задать их позицию относительно родителя,
воспользуйтесь виджетом Positioned, который позволяет задать как размеры дочернего виджета, так и его отступы от определенной стороны или от всех сторон
сразу (рис. 2.29):
// base_url/2/2.3/flutter_stack/lib/example_2.dart
Stack(
children: [
Positioned(
left: 8.0,
right: 8.0,
top: 8.0,
bottom: 8.0,
child: Container(
color: Colors.green,
),
),
Positioned(
left: 16.0,
right: 16.0,
top: 16.0,
child: Container(
height: 100,
color: Colors.red,
),
),
Positioned(
left: 16.0,
right: 16.0,
bottom: 16.0,
child: Container(
height: 100,
color: Colors.blue,
),
),
],
),
288 Глава 2 Основные виджеты, их компоновка и работа с assets
Рис. 2.29. Виджет Positioned внутри Stack
Если вам необходимо из определенной точки внутри Stack выстраивать все
дочерние непозиционированные или частично позиционированные виджеты, воспользуйтесь аргументом alignment конструктора Stack (рис. 2.30), принимающим
на вход перечисление Alignment:
y Alignment.topLeft — в левом верхнем углу;
y Alignment.topCenter — сверху посередине;
y Alignment.topRight — в правом верхнем углу;
y Alignment.centerLeft — слева посередине;
y Alignment.center — в центре Stack;
y Alignment.centerRight — справа посередине;
y Alignment.bottomLeft — в левом нижнем углу;
y Alignment.bottomCenter — снизу посередине;
2.3. Виджеты компоновки 289
y
Alignment.bottomRight — в правом нижнем углу:
// base_url/2/2.3/flutter_stack/lib/example_3.dart
Stack(
alignment: Alignment.bottomRight,
children: [
Container(width: 200, height: 200, color: Colors.blue),
Container(width: 150, height: 150, color: Colors.red),
Container(width: 100, height: 100, color: Colors.green),
],
),
Рис. 2.30. Свойство alignment виджета Stack
Когда вам необходимо, чтобы ограничения размера передавались от родительского виджета и напрямую влияли на отображение дочерних элементов Stack,
используйте аргумент fit, принимающий на свой вход перечисление StackFit
(рис. 2.31), в составе которого:
y StackFit.loose — дочерние виджеты ограничены только размером самого
Stack (используется по умолчанию);
y StackFit.expand — дочерние виджеты без позиционирования занимают максимально возможную область;
y StackFit.passthrough — ограничения размера передаются от родительского
виджета к потомкам без изменения:
// base_url/2/2.3/flutter_stack/lib/example_4.dart
Stack(
fit: StackFit.expand,
children: [
Container(color: Colors.red),
Positioned(
top: 24.0,
left: 24.0,
child: Container(
height: 100,
width: 100,
color: Colors.blue,
),
),
],
),
290 Глава 2 Основные виджеты, их компоновка и работа с assets
Рис. 2.31. Значение StackFit.expand свойства fit виджета Stack
2.3.5. Виджеты Align и Center
Все рассмотренные до этого момента виджеты чаще всего используются для
позиционирования нескольких дочерних элементов. А что, если нам требуется
отобразить всего один виджет в центре или где-то в углу? Для этого необходимо
взять один из двух виджетов позиционирования — Align или Center.
Align используется для размещения дочернего элемента в определенной точке.
Для этого передайте его аргументу конструктора alignment один из вариантов перечисления Alignment (рис. 2.32) (см. ранее в разделе):
// base_url/2/2.3/flutter_align_center/lib/align_example_1.dart
Align(
alignment: Alignment.topRight,
child: Container(height: 100, width: 100, color: Colors.red),
),
2.3. Виджеты компоновки 291
Рис. 2.32. Виджет Align со значением Alignment.topRight свойства alignment
Если из-за ваших внутренних убеждений нет возможности использовать виджет
Alignment, его можно заменить классом FractionalOffset (рис. 2.33). Он позволяет
динамически задавать положение дочернего виджета в зависимости от размера
родительского и предоставляет три конструктора. В конструктор по умолчанию
передаются dx (доля расстояния от края по горизонтали) и dy (доля расстояния от
края по вертикали):
// base_url/2/2.3/flutter_align_center/lib/align_example_2.dart
Align(
alignment: FractionalOffset(0.2, 0.8),
child: Container(height: 100, width: 100, color: Colors.red),
),
Остальные два конструктора вычисляют dx и dy автоматически, основываясь
на переданной им информации. Например, FractionalOffset.fromOffsetAndRect
сделает это из заданного смещения (Offset) и координат, а FractionalOffset.from
OffsetAndSize заменит координаты размером родительского виджета:
292 Глава 2 Основные виджеты, их компоновка и работа с assets
FractionalOffset.fromOffsetAndRect(
offset: Offset(10, 10),
rect: Rect.fromLTRB(0, 0, 15, 15),
);
FractionalOffset.fromOffsetAndSize(
offset: Offset(10, 10),
size: Size(100, 100),
);
А чтобы не усложнять код различными нагромождениями, для размещения
дочернего элемента в центре родительского используйте виджет Center (рис. 2.34):
// base_url/2/2.3/flutter_align_center/lib/center_example_1.dart
Center(
child: Container(height: 100, width: 100, color: Colors.red),
),
Рис. 2.33. Виджет Align с использованием
FractionalOffset
Рис. 2.34. Виджет Center
2.4. Виджеты выбора и ввода данных 293
Следует отметить, что рассмотренные в текущем разделе виджеты могут работать
и внутри других виджетов, например в Stack.
2.4. Виджеты выбора и ввода данных
Во Flutter за ввод данных с клавиатуры отвечает виджет TextField, все остальные
виджеты, в состав которых он не входит, а именно Radio, Checkbox, Chip и т. д., можно
отнести к виджетам выбора.
2.4.1. Виджет TextField
Виджет TextField позволяет вводить текст как с аппаратной клавиатуры, так
и с помощью экранной. Чтобы отслеживать добавление нового символа в текстовое
поле, ему можно задать callback-функцию onChanged, а для отслеживания момента,
когда пользователь на экранной клавиатуре нажмет кнопку, означающую конец
ввода (скрыть клавиатуру), или Enter на аппаратной, существует callback-функция
onSubmitted. Непосредственным управлением текстом и организацией подписки на
его изменение для других виджетов занимается TextEditingController.
Для начала объявим простой TextField с перехватом ввода данных:
// base_url/2/2.4/flutter_textfield/lib/example_1.dart
Center(
child: Padding(
padding: const EdgeInsets.only(left: 20, right: 20),
child: TextField(
onChanged: (value) {
debugPrint('Changed to:$value');
},
onSubmitted: (value) {
debugPrint('Submitted to:$value');
},
style: const TextStyle(
fontSize: 24, // размер шрифта
),
),
),
),
Запустите приложение на Android, наведите фокус на TextField и введите пару
символов с появившейся экранной клавиатуры (рис. 2.35).
Поскольку по умолчанию виджет плохо различим в пользовательском интерфейсе и нужно еще понять, что полоска по центру — нижняя часть виджета, упростим
жизнь себе и пользователям, выделив его явно в пользовательском интерфейсе
(рис. 2.36):
// base_url/2/2.4/flutter_textfield/lib/example_2.dart
Center(
child: Padding(
padding: const EdgeInsets.only(left: 20, right: 20),
child: TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
294 Глава 2 Основные виджеты, их компоновка и работа с assets
labelText: 'Enter your text',
hintText: 'Enter some text',
),
// без изменений
),
),
),
Рис. 2.35. Пример ввода текста
с экранной клавиатуры
Рис. 2.36. Пример ввода декорирования
виджета TextField
Хотите задействовать виджет для ввода пароля так, чтобы он не отсвечивал
в процессе этого таинства (рис. 2.37)? Используйте аргумент obscureText:
// base_url/2/2.4/flutter_textfield/lib/example_3.dart
class _MyHomePageState extends State<MyHomePage> {
var isNotVisible = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Padding(
padding: const EdgeInsets.only(left: 20, right: 20),
child: TextField(
obscureText: isNotVisible,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'Enter your password',
hintText: 'Password',
suffixIcon: IconButton(
onPressed: () {
setState(() {
isNotVisible = !isNotVisible;
});
},
icon: const Icon(Icons.visibility),
),
),
2.4. Виджеты выбора и ввода данных 295
onChanged: (value) {
debugPrint('Changed to:$value');
},
style: const TextStyle(
fontSize: 24, // размер шрифта
),
),
),
),
}
}
);
Нужно иметь возможность вводить несколько строк текста вместо одной либо
регулировать их количество? Тогда вам помогут аргументы maxLines и minLines
(рис. 2.38):
// base_url/2/2.4/flutter_textfield/lib/example_4.dart
TextField(
keyboardType: TextInputType.multiline,
maxLines: null,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Enter your text',
hintText: 'Text',
),
onChanged: (value) {
debugPrint('Changed to:$value');
},
style: const TextStyle(
fontSize: 24, // размер шрифта
),
),
Рис. 2.37. Пример реализации поля
для ввода пароля
Рис. 2.38. Режим ввода произвольного
количества строк
А чтобы ограничить возможность ввода ряда данных в рамках клавиатуры,
можно указать необходимый ее тип, например сделать так, чтобы пользователь
вводил только числовые данные (рис. 2.39):
// base_url/2/2.4/flutter_textfield/lib/example_5.dart
TextField(
keyboardType: TextInputType.number,
decoration: const InputDecoration(
border: OutlineInputBorder(),
296 Глава 2 Основные виджеты, их компоновка и работа с assets
labelText: 'Enter your number',
hintText: '001',
),
onChanged: (value) {
debugPrint('Changed to:$value');
},
style: const TextStyle(
fontSize: 24, // размер шрифта
)),
Добавление контроллера позволяет проделывать более интересные манипуляции с вводимым текстом, отслеживать его выделение и т. д. Для примера внесем
изменения в класс _MyHomePageState, чтобы каждый введенный символ a сразу же
менялся на t (рис. 2.40):
// base_url/2/2.4/flutter_textfield/lib/example_6.dart
class _MyHomePageState extends State<MyHomePage> {
final controller = TextEditingController();
@override
void initState() {
super.initState();
// подписываемся с помощью контроллера на изменения вводимого текста
controller.addListener(() {
var text = controller.text;
text = text.replaceAll('a', 't');
debugPrint(text);
// обновляем состояние контроллера
controller.value = controller.value.copyWith(
text: text,
);
});
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
),
child: TextField(
controller: controller,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
style: const TextStyle(
fontSize: 24, // размер шрифта
),
),
),
),
);
}
2.4. Виджеты выбора и ввода данных 297
Рис. 2.39. Выбор типа клавиатуры
для ввода данных
Рис. 2.40. Изменение текста в процессе
пользовательского ввода
2.4.2. Виджет Radio и его вариации
Виджет Radio<T> позволяет организовать выбор пользователем только одного из
предложенных вариантов, где Т — тип перечисления, в рамках которого делается
выбор. Так как Radio не хранит значения, он должен быть вынесен на более высокий уровень и в процессе изменения вызывать перерисовку виджетов на странице.
Чаще всего этот виджет используют вместе с ListTile, что позволяет добавлять
текстовое описание выбираемого значения (рис. 2.41).
Рис. 2.41. Пример работы виджета Radio
Для начала объявите на верхнем уровне перечисление MyColors и функцию
которая будет возвращать цвет в зависимости от переданного на ее
вход экземпляра MyColors:
enumToColor,
// base_url/2/2.4/flutter_radio/lib/example_1.dart
enum MyColors { red, blue, green }
Color enumToColor(MyColors color) {
return switch (color) {
MyColors.red = > Colors.red,
298 Глава 2 Основные виджеты, их компоновка и работа с assets
MyColors.blue = > Colors.blue,
MyColors.green = > Colors.green,
}
};
class _MyHomePageState extends State<MyHomePage> {
var defaultColor = MyColors.red;
void changeColor(MyColors? color) {
setState(() {
defaultColor = color!;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
),
child: Column(
children: [
ListTile(
title: const Text('Red'),
leading: Radio<MyColors>(
// значение, устанавливаемое текущим виджетом
value: MyColors.red,
// с каким перечислением работает группа виджетов Radio
groupValue: defaultColor,
// функция, вызываемая при изменении значения
onChanged: changeColor,
),
),
ListTile(
title: const Text('Blue'),
leading: Radio<MyColors>(
value: MyColors.blue,
groupValue: defaultColor,
onChanged: changeColor,
)),
ListTile(
title: const Text('Green'),
leading: Radio<MyColors>(
value: MyColors.green,
groupValue: defaultColor,
onChanged: changeColor,
)),
const SizedBox(
height: 20,
),
Container(
height: 100,
width: 100,
color: enumToColor(defaultColor),
),
],
),
),
);
}
2.4. Виджеты выбора и ввода данных 299
Такой формат дает возможность выполнять более гибкую верстку, однако для
переключения цвета контейнера необходимо явно нажимать на виджет Radio, что
не всегда удобно. Перепишем приведенный ранее код, используя виджет RadioListTile (рис. 2.42):
// base_url/2/2.4/flutter_radio/lib/example_2.dart
RadioListTile<MyColors>(
title: const Text('1'), // заголовок
subtitle: const Text('Red color'), // подзаголовок
// значение, устанавливаемое текущим виджетом
value: MyColors.red,
// с каким перечислением работает
// группа виджетов Radio
groupValue: defaultColor,
// функция, вызываемая при изменении значения
onChanged: changeColor,
),
RadioListTile<MyColors>(
title: const Text('2'),
subtitle: const Text('Blue color'),
value: MyColors.blue,
groupValue: defaultColor,
onChanged: changeColor,
),
RadioListTile<MyColors>(
title: const Text('3'),
subtitle: const Text('Green color'),
value: MyColors.green,
groupValue: defaultColor,
onChanged: changeColor,
),
Рис. 2.42. Пример работы виджета RadioListTile
Еще одна разновидность рассматриваемого виджета Radio — RadioMenuButton.
С его помощью можно организовать выбор единственного варианта среди име
ющихся в раскрывающемся меню и назначить горячие клавиши (рис. 2.43).
300 Глава 2 Основные виджеты, их компоновка и работа с assets
Рис. 2.43. Пример работы виджета RadioMenuButton
Для реализации этого приложения нам понадобится удалить из перечисления
синий цвет (сократить объем кода), импортировать библиотеку services, входящую
во Flutter, и использовать виджет MenuAnchor, элементами которого и будут выступать варианты переключения цвета контейнера:
// base_url/2/2.4/flutter_radio/lib/example_3.dart
import 'package:flutter/services.dart';
// код приложения
enum MyColors { red, green }
Color enumToColor(MyColors color) {
return switch (color) {
MyColors.red = > Colors.red,
MyColors.green = > Colors.green,
};
}
class _MyHomePageState extends State<MyHomePage> {
// FocusNode отслеживания нажатия кнопки вызова меню
final FocusNode buttonFN = FocusNode(
debugLabel: 'MyMenuButton',
);
var defaultColor = MyColors.red;
// Контроллер для ввода с клавиатуры
late ShortcutRegistryEntry myShortcut;
// Объявление горячих клавиш
static const SingleActivator redShortcut = SingleActivator(
LogicalKeyboardKey.digit0,
control: true,
);
static const SingleActivator greenShortcut = SingleActivator(
LogicalKeyboardKey.digit1,
control: true,
);
@override
void didChangeDependencies() {
super.didChangeDependencies();
2.4. Виджеты выбора и ввода данных 301
}
// Регистрация горячих клавиш
myShortcut = ShortcutRegistry.of(context)
.addAll(<ShortcutActivator, VoidCallbackIntent>{
redShortcut: VoidCallbackIntent(
() = > changeColor(MyColors.red),
),
greenShortcut: VoidCallbackIntent(
() = > changeColor(MyColors.green),
),
});
@override
void dispose() {
buttonFN.dispose();
myShortcut.dispose();
super.dispose();
}
void changeColor(MyColors? color) {
setState(() {
defaultColor = color!;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.only(left: 20, right: 20),
child: Column(
children: [
MenuAnchor(
childFocusNode: buttonFN,
menuChildren: [
RadioMenuButton<MyColors>(
value: MyColors.red,
// Горячая клавиша для переключения цвета
shortcut: redShortcut,
groupValue: defaultColor,
onChanged: changeColor,
child: const Text('Red Background'),
),
RadioMenuButton<MyColors>(
value: MyColors.green,
shortcut: greenShortcut,
groupValue: defaultColor,
onChanged: changeColor,
child: const Text('Green Background'),
),
],
builder: (
// Builder виджета MenuAnchor
BuildContext context,
MenuController controller,
Widget? child,
) {
return TextButton(
focusNode: buttonFN,
// обработчик нажатия кнопки меню
onPressed: () {
302 Глава 2 Основные виджеты, их компоновка и работа с assets
// открытие/закрытие меню
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: const Text('Change Color'),
);
},
),
const SizedBox(
height: 20,
),
Container(
height: 100,
width: 100,
color: enumToColor(defaultColor),
),
],
),
),
}
}
);
2.4.3. Виджет Checkbox и его вариации
Данный виджет позволяет организовать множественный выбор и не сохраняет
свое состояние. Таким образом, за его перерисовку на экране отвечает разработчик, который должен организовать хранение текущего состояния и его изменение
с вызовом callback-функции, передаваемой в аргумент onChanged. По умолчанию
Checkbox имеет два состояния: true или
false , но при передаче аргументу конструктора tristate значения true он способен отображать три состояния: true
(флажок установлен), false (флажок отсутствует) и null (отображается тире).
Как и у виджета Radio, у Checkbox имеются вариации CheckboxListTile и CheckboxMenuButton (не будем рассматривать).
В качестве примера реализуем следу
ющую верстку экрана (рис. 2.44).
Для этого нам придется на верхнем
уровне _MyHomePageState объявить три переменные типа bool и вызывать перерисовРис. 2.44. Пример работы виджета Checkbox
ку виджета посредством метода setState
и CheckboxListTile
при нажатии на любой виджет Checkbox:
// base_url/2/2.4/flutter_checkbox/lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
bool firstValue = false;
2.4. Виджеты выбора и ввода данных 303
bool secondValue = false;
bool thirdValue = false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Checkbox(
// Цвет в активном состоянии
activeColor: Colors.green,
// Цвет области активации виджета при наведении на него указателя
hoverColor: Colors.amber,
// Цвет флажка
checkColor: Colors.red,
value: firstValue, // Начальное состояние
// Функция, вызываемая при изменении состояния
onChanged: (bool? value) {
setState(() {
firstValue = value!;
});
},
),
CheckboxListTile(
title: const Text('CheckboxListTile'),
activeColor: Colors.greenAccent,
value: secondValue,
onChanged: (bool? value) {
setState(() {
secondValue = value!;
});
}),
CheckboxListTile(
title: const Text('Second CheckboxListTile'),
subtitle: const Text('With subtitle'),
value: thirdValue,
onChanged: (bool? value) {
setState(() {
thirdValue = value!;
});
},
),
],
),
),
);
}
2.4.4. Виджеты Switch и SwitchListTile
Виджет Switch, как и Checkbox, позволяет организовать множественный выбор и не
сохраняет свое состояние. Так как у них одинаковая концепция использования,
давайте дополнительно рассмотрим, как посредством виджета выбора можно сделать неактивным другой виджет выбора или ввода данных. Для этого реализуем
следующее приложение (рис. 2.45).
304 Глава 2 Основные виджеты, их компоновка и работа с assets
Рис. 2.45. Пример работы виджета Switch и SwitchListTile
// base_url/2/2.4/flutter_switch/lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
bool firstValue = false;
bool secondValue = false;
bool isEnabled = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
const SizedBox(height: 20),
Switch(
// Цвет кружка в неактивном состоянии
inactiveThumbColor: Colors.brown,
// Цвет заполнения виджета в неактивном состоянии
inactiveTrackColor: Colors.blueGrey,
// Цвет кружка в активном состоянии
activeColor: Colors.green,
// Цвет области активации виджета при наведении на него
hoverColor: Colors.amber,
// Цвет заполнения виджета в активном состоянии
activeTrackColor: Colors.red,
value: firstValue, // Начальное состояние
// Функция, вызываемая при изменении состояния
onChanged: (bool? value) {
setState(() {
firstValue = value!;
});
},
),
SwitchListTile(
title: const Text('Enable'),
subtitle: const Text('Enable some widgets'),
// Добавление значка в левую часть ListTile
secondary: const Icon(Icons.settings_display),
value: isEnabled,
onChanged: (bool? value) {
setState(() {
isEnabled = value!;
});
}),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Switch(
2.4. Виджеты выбора и ввода данных 305
// Цвет кружка в неактивном состоянии
inactiveThumbColor: Colors.brown,
// Цвет заполнения виджета в неактивном состоянии
inactiveTrackColor: Colors.blueGrey,
value: secondValue,
// Если onChanged не задан, то виджет будет неактивным
onChanged: !isEnabled
? null
: (bool? value) {
setState(() {
secondValue = value!;
});
}),
Checkbox(
value: secondValue,
onChanged: !isEnabled
? null
: (bool? value) {
setState(() {
secondValue = value!;
});
})
)
],
],
),
}
}
);
2.4.5. Виджет DropdownMenu
Виджет состоит из списка виджетов DropdownMenuEntrys и представляет собой
обертку над TextField, где при вводе текста будет предлагаться выбрать один из
имеющихся вариантов. Его удобно применять для сортировки по классам продуктов,
отображаемых на экране пользователя, или когда необходимо обеспечить выбор
единственного из нескольких вариантов.
В качестве первого примера разработаем следующее приложение с использованием виджета DropdownMenu (рис. 2.46), не прибегая к магии оптимизации,
а вручную добавляя DropdownMenuEntrys для ряда животных, объявленных в перечислении:
Рис. 2.46. Пример работы виджета DropdownMenu
306 Глава 2 Основные виджеты, их компоновка и работа с assets
// base_url/2/2.4/flutter_dropdownmenu/lib/example_1.dart
enum Animals {
dog('Dog', Colors.deepOrange, Icon(Icons.warning)),
pig('Pig', Colors.pink, Icon(Icons.wash)),
snake('Snake', Colors.green, Icon(Icons.sos)),
lion('Lion', Colors.orange, Icon(Icons.pets)),
elephant('Elephant', Colors.grey, Icon(Icons.abc));
}
const
final
final
final
Animals(this.label, this.color, this.icon);
String label;
Color color;
Icon icon;
class _MyHomePageState extends State<MyHomePage> {
final textController = TextEditingController();
Animals? selectedAnimal;
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: <Widget>[
const SizedBox(height: 20),
Center(
child: DropdownMenu(
// Устанавливаем контроллер
controller: textController,
// Подпись области виджета
label: const Text('Animals'),
// Обработчик выбора
onSelected: (Animals? animal) {
setState(() {
selectedAnimal = animal;
});
},
// Список элементов меню
dropdownMenuEntries: [
DropdownMenuEntry<Animals>(
value: Animals.dog, // Значение
label: Animals.dog.label, // Текст
leadingIcon: Animals.dog.icon, // Значок
// назначаем стилю текста цвет из перечисления
style: MenuItemButton.styleFrom(
foregroundColor: Animals.dog.color,
),
),
DropdownMenuEntry<Animals>(
value: Animals.pig,
label: Animals.pig.label,
leadingIcon: Animals.pig.icon,
),
DropdownMenuEntry<Animals>(
value: Animals.lion,
label: Animals.lion.label,
enabled: false, // Запрещаем выбор элемента
2.4. Виджеты выбора и ввода данных 307
leadingIcon: Animals.lion.icon,
),
],
),
),
const SizedBox(height: 20),
// Выводим выбранный элемент
if (selectedAnimal ! = null)
Text('You selected ${selectedAnimal!.label}')
else
const Text('No Animal Selected')
],
),
}
}
);
Поскольку данные, которые нужно отображать в DropdownMenu, будут чаще всего
приходить по сети или представлять собой перечисление, лучший вариант организации списка dropdownMenuEntries — это использование метода map у принимаемого
списка данных (или перечисления):
// base_url/2/2.4/flutter_dropdownmenu/lib/example_2.dart
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: <Widget>[
const SizedBox(height: 20),
Center(
child: DropdownMenu(
// код без изменений
dropdownMenuEntries:
Animals.values.map<DropdownMenuEntry<Animals>>(
(Animals animal) {
return DropdownMenuEntry<Animals>(
label: animal.label,
leadingIcon: animal.icon,
value: animal,
enabled: animal ! = Animals.lion,
style: MenuItemButton.styleFrom(
foregroundColor: animal.color,
),
);
},
).toList(),
),
),
const SizedBox(height: 20),
// Выводим выбранный элемент
// код без изменений
],
),
);
}
Несмотря на то что сейчас можно вызвать меню, установив курсор в Dropdownи передвигаться по его элементам с помощью кнопок «вверх» или «вниз»,
а также вводить текст, что запустит подсветку подходящих элементов, пока что этот
виджет больше напоминает поле выбора, а не ввода данных. Чтобы это исправить,
Menu,
308 Глава 2 Основные виджеты, их компоновка и работа с assets
воспользуемся дополнительными аргументами класса и преобразим пользовательский интерфейс следующим образом (рис. 2.47):
// base_url/2/2.4/flutter_dropdownmenu/lib/example_2.dart
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: <Widget>[
const SizedBox(height: 20),
Center(
child: DropdownMenu(
// Устанавливаем контроллер
controller: textController,
// Подпись области виджета
label: const Text('Animal'),
// Устанавливаем значок в левую часть виджета
leadingIcon: const Icon(Icons.search),
// включаем фильтрацию списка меню по вводимому тексту
enableFilter: true,
// включаем фокусировку при нажатии
// и отображении виртуальной клавиатуры
requestFocusOnTap: true,
// убираем обрамление виджета
inputDecorationTheme: const InputDecorationTheme(
filled: true,
contentPadding: EdgeInsets.symmetric(
vertical: 10.0,
),
),
// Обработчик выбора
onSelected: (Animals? animal) {
setState(() {
selectedAnimal = animal;
});
},
// Список элементов меню
dropdownMenuEntries:
// код без изменений
),
),
const SizedBox(height: 20),
// Выводим выбранный элемент
// код без изменений
],
),
);
}
Рис. 2.47. Пример работы виджета DropdownMenu
2.4. Виджеты выбора и ввода данных 309
2.4.6. Виджет Slider
Виджет позволяет организовать выбор числового значения из определенного диапазона с задаваемым шагом перемещения слайдера. Поскольку выбираемое значение
имеет тип double, то при отображении в пользовательском интерфейсе его следует
округлять до ближайшего целого (рис. 2.48).
Рис. 2.48. Пример работы виджета Slider
Для начала рассмотрим как раз такой пример, а потом реализуем слайдер для
выбора пользователем вещественного значения из предлагаемого диапазона:
// base_url/2/2.4/flutter_slider/lib/example_1.dart
class _MyHomePageState extends State<MyHomePage> {
double firstValue = 0; // текущее значение слайдера
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: <Widget>[
const SizedBox(height: 50),
Slider(
value: firstValue, // текущее значение
divisions: 20, // количество дискретных делений
min: -30, // минимальное значение
max: 30, // максимальное значение
// функция при изменении значения
onChanged: (double value) {
setState(() {
firstValue = value;
});
},
label: firstValue.round().toString(),
// Цвет активной части слайдера
activeColor: Colors.red,
// Цвет неактивной части слайдера
inactiveColor: Colors.grey,
// Цвет слайдера
thumbColor: Colors.green,
// Возвращает начальное значение
// при старте перемещения слайдера
onChangeStart: (double value) {
debugPrint('Started change on ${value.round()}');
},
310 Глава 2 Основные виджеты, их компоновка и работа с assets
// Возвращает конечное значение, устанавливаемое
// при завершении перемещения слайдера
onChangeEnd: (double value) {
debugPrint('Ended change on ${value.round()}');
},
),
const SizedBox(height: 50),
Text(
firstValue.round().toString(),
style: const TextStyle(fontSize: 20),
),
],
),
}
}
);
С помощью аргумента divisions можно установить количество шагов перемещения от минимального до максимального значения слайдера. В текущий момент
он равен трем, а если убрать данный аргумент, то выбираемое значение будет изменяться на единицу. При этом, если аргумент установлен и нужно выбрать значение,
лежащее между метками дискретного деления, этого сделать не получится. Поэтому
заранее рассчитывайте конфигурацию объявляемого виджета, чтобы не допустить
таких эксцессов.
Теперь организуем выбор вещественного значения, лежащего в диапазоне от 0 до 2,
с шагом 0,2 (рис. 2.49):
// base_url/2/2.4/flutter_slider/lib/example_2.dart
class _MyHomePageState extends State<MyHomePage> {
double firstValue = 0; // текущее значение слайдера
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: <Widget>[
const SizedBox(height: 50),
Slider(
value: firstValue, // текущее значение
divisions: 10, // количество дискретных делений
min: 0, // минимальное значение
max: 2, // максимальное значение
// функция при изменении значения
onChanged: (double value) {
setState(() {
firstValue = value;
});
},
// округляем значение до первого знака после запятой
label: firstValue.toStringAsFixed(1),
),
const SizedBox(height: 50),
Text(
firstValue.toStringAsFixed(1),
style: const TextStyle(fontSize: 20),
),
],
),
);
}
2.4. Виджеты выбора и ввода данных 311
Рис. 2.49. Пример работы виджета Slider с шагом 0,2
Если нужен шаг 0,1, увеличьте значение аргумента divisions с 10 до 20. А если
нет необходимости выводить над слайдером текущее значение, то при объявлении
виджета не указывайте аргумент label.
2.4.7. Виджет ввода времени
За создание диалогового окна с виджетом ввода времени во Flutter отвечает функция верхнего уровня showTimePicker. Ей на вход передаются такие параметры, как
текущий контекст, начальное время, относительно которого будет делаться выбор,
и если не удовлетворяют параметры по умолчанию, то можно задать текст кнопки
подтверждения или отмены выбранного времени, ориентацию диалогового окна,
режим ввода и т. д.
Чтобы явно указать режим ввода, необходимо передать аргументу initialEntry
Mode одно из следующих значений перечисления TimePickerEntryMode:
y dial — выбор времени начинается с циферблата с возможностью переключения на обычный ввод с клавиатуры (используется по умолчанию);
y input — выбор времени начинается с пользовательского ввода с клавиатуры,
есть возможность переключиться на циферблат;
y dialOnly — время выбирается только с помощью циферблата;
y inputOnly — время вводится только с клавиатуры.
В качестве примера реализуем следующую верстку приложения (рис. 2.50).
Нажатием кнопки Select time будет вызываться функция showTimePicker, которая
возвращает не-null-safety-тип TimeOfDay?. Таким образом, при успешном закрытии
диалогового окна вернется значение с выбранным временем, а в противном случае — null. Виджет SwitchListTile позволяет пользователю переключиться между
12- или 24-часовым форматом выбора времени (рис. 2.51):
// base_url/2/2.4/flutter_timepicker/lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
bool is24HourTime = true;
TimeOfDay? selectedTime; // выбранное пользователем время
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
312 Глава 2 Основные виджеты, их компоновка и работа с assets
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SizedBox(height: 100),
SwitchListTile(
title: Text(
is24HourTime ? '24-hour time' : '12-hour time',
),
value: is24HourTime,
onChanged: (value) {
setState(() {
is24HourTime = value;
});
}),
ElevatedButton(
child: const Text('Select time'),
onPressed: () async {
selectedTime = await showTimePicker(
// Подпись вверху диалогового виджета
barrierLabel: 'Select time',
// передаем контекст в showTimePicker
context: context,
// устанавливаем начальное время
initialTime: selectedTime ?? TimeOfDay.now(),
// устанавливаем начальный режим
initialEntryMode: TimePickerEntryMode.dial,
// устанавливаем ориентацию
orientation: Orientation.portrait,
// устанавливаем формат времени
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
alwaysUse24HourFormat: is24HourTime,
),
child: child!,
);
},
);
setState(() {
// обновляем состояние
});
}),
const SizedBox(height: 100),
if (selectedTime ! = null)
Text(
'Selected time: ${selectedTime!.format(context)}',
style: const TextStyle(fontSize: 20),
)
else
const Text(
'No time selected',
style: TextStyle(fontSize: 20),
),
],
),
}
}
);
Если вам нужна более тонкая настройка ввода времени, обратитесь к официальной документации рассматриваемой функции.
2.4. Виджеты выбора и ввода данных 313
Рис. 2.50. Пример разрабатываемого приложения
Рис. 2.51. Пример переключения между режимами ввода времени
314 Глава 2 Основные виджеты, их компоновка и работа с assets
2.4.8. Виджет ввода даты
Чтобы предоставить пользователю возможность ввести дату, воспользуйтесь функцией верхнего уровня showDatePicker, которая создаст диалоговое окно с виджетом
ввода даты (рис. 2.52). Ей на вход обязательно нужно передать такие параметры,
как текущий контекст, даты начала и окончания промежутка для выбора, что сделает невозможным выбрать дату вне заданного отрезка времени. Все остальные
параметры опциональные.
Рис. 2.52. Пример переключения между режимами ввода даты
Если нужно явно указать режим ввода, передайте аргументу initialEntryMode
одно из следующих значений перечисления DatePickerEntryMode:
— выбор времени начинается с календаря, есть возможность переключиться на обычный ввод с клавиатуры (используется по умолчанию);
y input — выбор времени начинается с пользовательского ввода с клавиатуры,
есть возможность переключиться на календарь;
y calendarOnly — время выбирается только с помощью календаря;
y inputOnly — время вводится только с клавиатуры.
y
calendar
В качестве примера реализуем приложение, где при нажатии кнопки Select date
будет вызываться функция showDatePicker, которая возвращает не-null-safety-тип
DateTime?. Таким образом, при успешном закрытии диалогового окна вернется
значение с выбранной датой, а в противном случае — null:
// base_url/2/2.4/flutter_datepicker/lib/example_1.dart
class _MyHomePageState extends State<MyHomePage> {
DateTime? selectedDate; // выбранная пользователем дата
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
2.4. Виджеты выбора и ввода данных 315
}
}
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const SizedBox(height: 100),
ElevatedButton(
child: const Text('Select time'),
onPressed: () async {
selectedDate = await showDatePicker(
// передаем контекст в showDatePicker
context: context,
// устанавливаем начальную дату
firstDate: DateTime(2020),
// устанавливаем конечную дату (год, месяц, день)
lastDate: DateTime(2025, 9, 1),
// устанавливаем текущую дату
currentDate: DateTime.now(),
// устанавливаем начальный режим
initialEntryMode: DatePickerEntryMode.calendar,
);
setState(() { // обновляем состояние });
}),
const SizedBox(height: 100),
if (selectedDate ! = null)
Text(
'Selected date: $selectedDate',
style: const TextStyle(fontSize: 20),
)
else
const Text(
'Date not selected',
style: TextStyle(fontSize: 20),
),
],
)));
Чтобы предоставить пользователю возможность выбирать диапазон дат
(рис. 2.53), задействуйте функцию showDateRangePicker, возвращающую значение
типа DateTimeRange?:
Рис. 2.53. Пример переключения между режимами ввода диапазона дат
316 Глава 2 Основные виджеты, их компоновка и работа с assets
// base_url/2/2.4/flutter_datepicker/lib/example_2.dart
class _MyHomePageState extends State<MyHomePage> {
DateTimeRange? selectedDateRange; // выбранная пользователем дата
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const SizedBox(height: 100),
ElevatedButton(
child: const Text('Select date'),
onPressed: () async {
selectedDateRange = await showDateRangePicker(
// код без изменений
);
setState(() {
// обновляем состояние
});
}),
// далее измените selectedDate на selectedDateRange
],
)),
);
}
Если вам нужна более тонкая настройка ввода даты или диапазона дат, обратитесь к официальной документации рассматриваемых функций.
2.5. Виджеты кнопок
В IT-индустрии ходят легенды о том, что разработчики, которые умеют хорошо работать с кнопками и владеют сакральным знанием о различных способах их окраски,
могут смело рассчитывать на двойной рост зарплаты. Но это касается только тех, кто,
помимо окрашивания кнопок, погружается в детали фреймворков, изучает новые
технологии и активно занимается саморазвитием. Те же программисты, которые
останавливаются в своем развитии и до конца карьеры играют роль «кнопочного
маляра», на такое увеличение заработной платы могут не надеяться.
2.5.1. Виджет ElevatedButton
Этот виджет кнопки рекомендуется использовать на основных экранах, в списках и т. д. — там, где надо придать объемность пользовательскому интерфейсу.
У него имеется два конструктора, ElevatedButton и ElevatedButton.icon, где, помимо
callback-функций для обычного и длительного нажатия посредством необязательного аргумента style, принимающего на свой вход значение типа ButtonStyle?, можно
задать цвет кнопки, шрифт текста и т. д. Первый конструктор обычно используется
для объявления виджета кнопки с текстовым содержимым, а второй — текста со
значком (рис. 2.54).
2.5. Виджеты кнопок 317
Рис. 2.54. Примеры работы виджета ElevatedButton
В качестве примера реализуем следующее приложение, продемонстрировав
некоторые способы вызова функций обработки нажатия клавиш и задания стиля
виджета ElevatedButton. При простом нажатии счетчик будет увеличиваться на 1,
а при длительном — на 2:
// base_url/2/2.5/flutter_elevatedbutton/lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
int _count = 0;
void _fastIncrementCounter() {
setState(() {
_count++;
});
}
void _longIncrementCounter() {
setState(() {
_count += 2;
});
}
@override
Widget build(BuildContext context) {
// стиль без изменения формы кнопки
final ButtonStyle style = ElevatedButton.styleFrom(
// размер шрифта текста внутри кнопки
textStyle: const TextStyle(fontSize: 20),
// цвет фона кнопки
backgroundColor: Colors.black54,
// цвет текста
foregroundColor: Colors.white,
);
// стиль с изменением формы кнопки
final ButtonStyle styleWitnBorder = ElevatedButton.styleFrom(
textStyle: const TextStyle(fontSize: 20),
318 Глава 2 Основные виджеты, их компоновка и работа с assets
foregroundColor: Colors.amber,
backgroundColor: Colors.blue,
// форма кнопки
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(0)),
));
}
}
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton( // без установленного стиля
// обработка обычного нажатия
onPressed: _fastIncrementCounter,
// обработка долгого нажатия
onLongPress: _longIncrementCounter,
child: const Text('First'),
),
const SizedBox(width: 20),
ElevatedButton(
style: styleWitnBorder,
// обработка обычного нажатия
onPressed: () {
_fastIncrementCounter();
},
// обработка долгого нажатия
onLongPress: () {
setState(() {
_count += 2;
});
},
child: const Text('Second'),
)
],
),
const SizedBox(height: 40),
ElevatedButton.icon(
// установка стиля
style: style,
// обработка обычного нажатия
onPressed: _fastIncrementCounter,
// обработка долгого нажатия
onLongPress: _longIncrementCounter,
// Текст внутри кнопки
label: const Text('First'),
// Значок внутри кнопки
icon: const Icon(Icons.add_alert),
),
const SizedBox(height: 40),
Text('Count: $_count'),
],
),
),
);
Если необходимо, чтобы кнопка была неактивной, то на вход аргументов
onPressed и onLongPress должен быть подан null.
2.5. Виджеты кнопок 319
2.5.2. Виджеты FilledButton и OutlinedButton
Виджет FilledButton, в отличие от ElevatedButton, без настройки стилей заполняет
фон кнопки цветом, выделяя ее в ряду прочих. Его рекомендуется использовать для
выделения таких важных действий, как сохранение, подтверждение, присоединение и т. д. У этого виджета имеется четыре конструктора: FilledButton, FilledButton.icon, FilledButton.tonal и FilledButton.tonalIcon, позволяющих организовать
отображение кнопки различными способами. Отличие обычных конструкторов от
тех, что начинаются со слова tonal, заключается в том, что у них более насыщенное
цветовое заполнение кнопки.
Что же касается виджета OutlinedButton, то это полная противоположность
FilledButton. То есть здесь сама кнопка не закрашивается, но в отличие от Elevate
d
Button ее границы лучше различимы для пользователя. Этот виджет рекомендуется
задействовать для выделения действий важных, но не основных в приложении.
У него два конструктора: основной OutlinedButton и именованный Outlined
Button.icon.
Для демонстрации этих виджетов (рис. 2.55) реализуем следующую верстку
пользовательского интерфейса приложения:
// base_url/2/2.5/flutter_filled_and_outlinedbutton/lib/example_1.dart
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Column(
children: [
const SizedBox(height: 30),
const Text('FilledButton'),
const SizedBox(height: 15),
FilledButton(
onPressed: () {},
child: const Text('Click me'),
),
const SizedBox(height: 30),
FilledButton.icon(
onPressed: () {},
label: const Text('Click me'),
icon: const Icon(Icons.add),
),
],
),
const SizedBox(width: 50),
Column(
children: [
const SizedBox(height: 30),
const Text('FilledButton.tonal'),
const SizedBox(height: 15),
FilledButton.tonal(
onPressed: () {},
child: const Text('Click me'),
),
const SizedBox(height: 30),
FilledButton.tonalIcon(
320 Глава 2 Основные виджеты, их компоновка и работа с assets
onPressed: () {},
label: const Text('Click me'),
icon: const Icon(Icons.add),
),
],
),
const SizedBox(width: 50),
Column(
children: [
const SizedBox(height: 30),
const Text('OutlinedButton'),
const SizedBox(height: 15),
OutlinedButton(
onPressed: () {},
child: const Text('Click me'),
),
const SizedBox(height: 30),
OutlinedButton.icon(
onPressed: () {},
label: const Text('Click me'),
icon: const Icon(Icons.add),
),
],
),
],
}
}
),
));
Рис. 2.55. Пример отображения FilledButton и OutlinedButton
Если необходимо конфигурировать кнопку — изменить цвет, форму и т. д.,
используйте необязательный аргумент конструктора — style, передав на его вход
значение типа ButtonStyle?, содержащее нужный набор данных. В качестве примера
изменим форму кнопок со стандартной на ту, что показана на рис. 2.56.
Для этого в начале метода build объявите следующие форматы стилей, после
чего присвойте их соответствующим виджетам кнопок:
// base_url/2/2.5/flutter_filled_and_outlinedbutton/lib/example_2.dart
final filledTonalStyle = FilledButton.styleFrom(
// форма кнопки
shape: const ContinuousRectangleBorder(
2.5. Виджеты кнопок 321
side: BorderSide.none,
),
// отступы
padding: const EdgeInsets.all(15),
// минимальный размер
minimumSize: const Size(100, 100),
// граница и ее цвет
side: const BorderSide(
color: Colors.green,
width: 10,
));
final filledStyle = FilledButton.styleFrom(
shape: const StadiumBorder(
side: BorderSide.none,
),
padding: const EdgeInsets.all(15),
minimumSize: const Size(100, 100),
side: const BorderSide(
color: Colors.black,
width: 0,
));
final outlinedStyle = FilledButton.styleFrom(
shape: const StarBorder(),
padding: const EdgeInsets.all(15),
minimumSize: const Size(100, 100),
side: const BorderSide(
color: Colors.red,
width: 5,
),
);
Рис. 2.56. Пример отображения FilledButton и OutlinedButton
322 Глава 2 Основные виджеты, их компоновка и работа с assets
А для того, чтобы кнопка была неактивной, на вход аргументов
и onLongPress должен быть подан null.
onPressed
2.5.3. Виджет TextButton
Данный виджет принято использовать на панели инструментов, в диалоговых окнах либо строках. В последнем случае для того, чтобы он хоть как-то выделялся
в пользовательском интерфейсе, виджет TextButton смещают относительно основного текста, что делает его присутствие более очевидным. Такое положение дел
связано с тем, что TextButton, в отличие от рассмотренных ранее виджетов кнопок,
по умолчанию не имеет видимых границ и становится визуально различимым
только при наведении на него фокуса. Поэтому, если текстовую кнопку необходимо явно выделить на реализуемом UI, используйте необязательный аргумент
конструктора — style.
В качестве примера работы с рассматриваемым виджетом (рис. 2.57) реализуем
следующую верстку пользовательского интерфейса приложения:
// base_url/2/2.5/flutter_textbutton/lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Column(
children: [
const SizedBox(height: 30),
const Text('Enable Button'),
const SizedBox(height: 15),
TextButton(
onPressed: () {},
child: const Text('Click me'),
),
],
),
const SizedBox(width: 50),
const Column(
children: [
SizedBox(height: 30),
Text('Disable Button'),
SizedBox(height: 15),
TextButton(
onPressed: null,
child: Text('Click me'),
),
],
),
const SizedBox(width: 50),
Column(
children: [
const SizedBox(height: 30),
const Text('Styled Button'),
const SizedBox(height: 15),
2.5. Виджеты кнопок 323
TextButton(
onPressed: () {},
style: TextButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
textStyle: const TextStyle(
fontSize: 20,
)
),
child: const Text('Click me'),
),
],
)],
}
}
)));
Рис. 2.57. Пример отображения TextButton
2.5.4. Виджет IconButton
В тех случаях, когда при добавлении кнопки можно обойтись без текста и достаточно значка, воспользуйтесь виджетом IconButton. У него имеется четыре
конструктора: IconButton, IconButton.filled, IconButton.filledTonal и IconButton.
outlined, позволяющих организовать отображение кнопки различными способами, без явной передачи стиля необязательному аргументу конструктора — style.
В качестве более наглядного примера на рис. 2.58 показано визуальное различие
экземпляров IconButton, объявленных с помощью разных вариантов конструктора.
Рис. 2.58. Пример отображения IconButton
324 Глава 2 Основные виджеты, их компоновка и работа с assets
При таком объявлении IconButton необходимо явно указать отображаемый значок (icon) и обработчик нажатия (onPressed). Если аргументу onPressed передается
значение null, кнопка станет неактивной:
// base_url/2/2.5/flutter_iconbutton/lib/example_1.dart
IconButton(
onPressed: () {},
icon: const Icon(Icons.add),
),
Чтобы IconButton лучше выделялась на UI, аргументом iconSize можно регулировать ее размер:
IconButton(
onPressed: () {},
icon: const Icon(Icons.add),
iconSize: 105, // размер значка
),
В отличие от рассматриваемых ранее виджетов кнопок, IconButton благодаря
наличию необязательного аргумента isSelected поддерживает режим выделения,
что позволяет задействовать его не только в качестве кнопки, нажатие на которую
запустит действие, но и как виджет выбора. В качестве примера реализуем следующий пользовательский UI, дополнительно настроив подсказки при наведении на
виджет и значок, который будет отображаться в выделенном состоянии (рис. 2.59):
// base_url/2/2.5/flutter_iconbutton/lib/example_2.dart
class _MyHomePageState extends State<MyHomePage> {
bool isPressed = false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
// обработка нажатия
onPressed: () {
setState(() {
isPressed = !isPressed;
});
},
// установка флага выделения
isSelected: isPressed,
// значок по умолчанию
icon: const Icon(Icons.add),
// значок в выделенном состоянии
selectedIcon: const Icon(Icons.remove_red_eye),
// размер значка
iconSize: 50,
// подсказка при наведении на значок
tooltip: 'Click me',
),
const SizedBox(height: 30),
Text(isPressed ? 'Pressed' : 'Not pressed')
],
),
));
}
2.5. Виджеты кнопок 325
Рис. 2.59. Пример работы IconButton в режиме выделения
2.5.5. Виджет SegmentedButton
SegmentedButton<T> используется для организации выбора из нескольких вариантов,
переключения виджетов на экране или сортировки отображаемых данных. На вход
его конструктора подается список из ButtonSegment<T>, где T — тип хранимого
виджетом значения, поддерживающего операцию сравнения на равенство (чаще
всего перечисление). По умолчанию пользователь может выбирать только один
ButtonSegment, что можно изменить, передав в аргумент multiSelectionEnabled
значение true. Несмотря на то что SegmentedButton можно задействовать с одним
ButtonSegment, обычно они применяются для выбора пользователем от двух до
пяти вариантов.
Как и у предыдущих виджетов кнопок, визуальное оформление SegmentedButton можно настроить с помощью необязательного аргумента конструктора — style,
принимающего на свой вход значение типа ButtonStyle?. При этом одни параметры
стиля будут применяться ко всему виджету SegmentedButton, а другие — к каждому
из ButtonSegment.
При объявлении текущего виджета в аргумент selected передается множество
значений Set<T>, чьи SegmentedButton будут выделены в пользовательском интерфейсе, а в аргумент onSelectionChanged передается callback-функция, принимающая
на вход Set<T> и срабатывающая в момент изменения состояния виджета.
В качестве примера реализуем приложение, предоставляющее пользователю
выбор текущего кредитного рейтинга (рис. 2.60):
// base_url/2/2.5/flutter_segmentedbutton/lib/example_1.dart
enum CreditRating {
aaa('AAA'),
aaPlus('AA+'),
cMinus('C-');
}
final String text;
const CreditRating(this.text);
326 Глава 2 Основные виджеты, их компоновка и работа с assets
class _MyHomePageState extends State<MyHomePage> {
CreditRating currentRating = CreditRating.aaa;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
children: [
const SizedBox(height: 60),
SegmentedButton<CreditRating>(
// список сегментов
segments: <ButtonSegment<CreditRating>>[
ButtonSegment<CreditRating>(
// значение ButtonSegment
value: CreditRating.aaa,
// текст внутри ButtonSegment
label: Text(CreditRating.aaa.text),
),
ButtonSegment<CreditRating>(
value: CreditRating.aaPlus,
label: Text(CreditRating.aaPlus.text),
),
ButtonSegment<CreditRating>(
value: CreditRating.cMinus,
label: Text(CreditRating.cMinus.text),
)
],
selected: <CreditRating>{
currentRating
},
onSelectionChanged: (Set<CreditRating> newSelection) {
setState(() {
// обновляем текущее значение рейтинга
currentRating = newSelection.first;
});
}),
const SizedBox(height: 20),
Text(
currentRating.text,
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
],
)));
}
Рис. 2.60. Пример работы SegmentedButton
2.5. Виджеты кнопок 327
Для выделения выбранного сегмента виджета по умолчанию используется
значок галочки. Чтобы изменить такое поведение, воспользуйтесь аргументами
showSelectedIcon и selectedIcon класса SegmentedButton. Так, например, при передаче showSelectedIcon значения fasle значки не будут отображаться в выделенных
сегментах. А аргумент selectedIcon позволяет установить в выделяемый сегмент
виджета любой доступный значок (рис. 2.61):
Рис. 2.61. Пример работы SegmentedButton
// base_url/2/2.5/flutter_segmentedbutton/lib/example_2.dart
class _MyHomePageState extends State<MyHomePage> {
CreditRating currentRating = CreditRating.aaa;
bool isShowIcon = true;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
children: [
const SizedBox(height: 30),
SizedBox(
width: 250,
child: CheckboxListTile(
value: isShowIcon,
title: const Text('Show selected icon'),
onChanged: (bool? value) {
setState(() {
isShowIcon = value!;
});
},
)),
const SizedBox(height: 20),
SegmentedButton<CreditRating>(
segments: <ButtonSegment<CreditRating>>[
// без изменений
],
selected: <CreditRating>{currentRating},
onSelectionChanged: // без изменений
// устанавливаем флаг работы с отображением значка
showSelectedIcon: isShowIcon,
// устанавливаем значок для выбранного сегмента виджета
selectedIcon: const Icon(Icons.star_rounded),
),
const SizedBox(height: 20),
328 Глава 2 Основные виджеты, их компоновка и работа с assets
}
}
Text(
// без изменений
),
],
)));
При добавлении возможности выделять сразу несколько сегментов виджета
(рис. 2.62) для хранения выделенных элементов используйте не переменную,
а коллекцию Set:
// base_url/2/2.5/flutter_segmentedbutton/lib/example_3.dart
class _MyHomePageState extends State<MyHomePage> {
Set<CreditRating> currentRating = {CreditRating.aaa};
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
children: [
const SizedBox(height: 60),
SegmentedButton<CreditRating>(
multiSelectionEnabled: true,
// список сегментов
segments: <ButtonSegment<CreditRating>>[
// без изменений
],
selected: currentRating,
onSelectionChanged: (Set<CreditRating> newSelection) {
setState(() {
// обновляем текущее значение рейтинга
currentRating = newSelection;
});
}),
const SizedBox(height: 20),
Text(
'Selected: ${currentRating.length} segments',
// далее без изменений
),
],
)));
}
Рис. 2.62. Пример работы режима множественного выбора SegmentedButton
2.6. Виджеты отображения данных и работа с assets 329
Если хотите, чтобы в ButtonSegment рядом с текстом отображался значок, используйте его необязательный аргумент icon:
ButtonSegment<CreditRating>(
// значение ButtonSegment
value: CreditRating.aaa,
// значок слева от текста
icon: const Icon(Icons.star),
// текст внутри ButtonSegment
label: Text(CreditRating.aaa.text),
),
Отдельные ButtonSegment могут быть установлены в активное или неактивное
состояние с помощью флага ButtonSegment.enabled. А если полю onSelectionChanged
класса SegmentedButton передать значение null, то весь виджет вместе с его элементами станет неактивным.
2.6. Виджеты отображения данных и работа с assets
Ввод данных и кнопочки — это, конечно, хорошо… но какой нам от них прок, если на
экране пользователя не будет виджетов с текстовой или графической информацией?
Поэтому в данном разделе мы познакомимся с базовыми виджетами отображения
данных и с тем, как можно задавать различный шрифт тексту и работать с изображениями, которые должны входить в состав разрабатываемого приложения.
2.6.1. Виджеты Text и RichText
Настало время поближе познакомиться с виджетом Text, который мы уже не один
раз использовали, выполняя его стилизацию (задание цвета, размера шрифта и т. д.).
Как следует из названия, он применяется для отображения одной или нескольких
строк текста в пользовательском интерфейсе, и, так как аргумент конструктора style
необязательный, по умолчанию задействуется стиль DefaultTextStyle ближайшего
родителя, в дереве которого он был задан. Чаще всего таким родителем выступает
корневой виджет используемой дизайн-системы (MaterialApp, CupertinoApp).
У данного виджета два конструктора: по умолчанию и именованный Text.rich,
который позволяет разбивать отображаемую строку на составные части с различными стилями TextSpans. В случае с конструктором по умолчанию задаваемый
стиль распространяется на всю отображаемую строку.
Для начала разберемся с обычным конструктором и используем виджет Text
(рис. 2.63) для вывода трех строк, ограничив их таким образом, чтобы отображались только две.
// base_url/2/2.6/flutter_text/lib/example_1.dart
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text(
'Hello, World!\nHello, new day!\nHello, bad news!',
330 Глава 2 Основные виджеты, их компоновка и работа с assets
// задаем стиль текста
style: TextStyle(
// размер шрифта
fontSize: 30,
// толщина шрифта
fontWeight: FontWeight.bold,
// цвет шрифта
color: Colors.red,
// семейство шрифта
fontFamily: 'Roboto',
// стиль шрифта
// FontStyle.italic — курсив
// FontStyle.normal — обычный (по умолчанию)
fontStyle: FontStyle.italic
),
// textAlign управляет тем, как текст будет
// выровнен внутри контейнера (например,
// по центру, по левой стороне и т. д.)
// TextAlign.left — по левой стороне (по умолчанию)
// TextAlign.center — по центру
// TextAlign.right — по правой стороне
// TextAlign.justify — по ширине
// TextAlign.start — по верхнему краю контейнера
// TextAlign.end — по нижнему краю контейнера
textAlign: TextAlign.center,
// textDirection управляет направлением текста
// TextDirection.ltr — слева направо (по умолчанию)
// TextDirection.rtl — справа налево
textDirection: TextDirection.rtl,
// maxLines — максимальное количество строк в тексте
maxLines: 2,
),
),
}
);
}
Обратите внимание, что стиль шрифта
должен передаваться аргументу fontFamily
в виде строки с учетом его регистра. Что же
касается выравнивания и направления
текста, попробуйте поэкспериментировать
с аргументами textAlign и textDirection,
обращая внимание на то, как меняются
расположение текста и порядок слов в нем
при передаче различных вариантов перечислений.
В зависимости от контейнера, в котоРис. 2.63. Пример работы с виджетом Text
ром используется виджет Text, отображаемый текст может автоматически переноситься на следующую строку. А так как
размер контейнера может быть ограничен, он не отобразится в пользовательском
интерфейсе, из-за чего пользователь может и не подозревать о его продолжении.
Чтобы избежать таких моментов, воспользуемся аргументом overflow. Он позволяет
задать, как следует обрабатывать визуальное переполнение, что продемонстрировано на рис. 2.64.
2.6. Виджеты отображения данных и работа с assets 331
Рис. 2.64. Отображение виджета Text: слева — без конфигурации аргумента overflow;
справа — с переданным значением
Аргумент overflow имеет различные значения, позволяющие указать, как Flutter
должен обрабатывать ситуации с переполнением текста в виджете:
// base_url/2/2.6/flutter_text/lib/example_2.dart
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Container(
width: 150,
height: 50,
child: const Text(
'Hello, World!\nHello, new day!\nHello, bad news!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.red,
fontFamily: 'Roboto',
fontStyle: FontStyle.italic),
textAlign: TextAlign.center,
/* TextOverflow.ellipsis — для указания переполнения используется ...
TextOverflow.visible — отображает переполненный текст за пределами контейнера
TextOverflow.clip — обрезает переполняемый текст, закрепляя его в контейнере.
TextOverflow.fade — обесцвечивает переполняемый текст до прозрачного */
overflow: TextOverflow.ellipsis,
)),
));
}
}
В отличие от конструктора по умолчанию именованный конструктор Text.rich
или виджет RichText на свой вход в качестве первого аргумента принимает не строку,
а экземпляр производного класса от InlineSpan — TextSpan, которому может передаваться не только обычная строка, но и список TextSpan, позволяющий стилизовать
каждый его элемент:
// base_url/2/2.6/flutter_text/lib/example_3.dart
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
332 Глава 2 Основные виджеты, их компоновка и работа с assets
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//
// пример использования Text.rich
//
const Text.rich(
TextSpan(
text: 'Hello, ',
children: <TextSpan>[
TextSpan(
text: 'Text ',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: 'widget!',
style: TextStyle(
fontStyle: FontStyle.italic,
color: Colors.blue,
),
),
],
),
// распространяется на все TextSpan,
// которые не переопределяют стиль
style: TextStyle(
fontSize: 24,
color: Colors.red,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
//
// пример использования виджета RichText
//
RichText(
text: const TextSpan(
text: 'Hello, ',
// распространяется на все TextSpan,
// которые не переопределяют стиль
style: TextStyle(
fontSize: 24,
color: Colors.blue,
),
children: <TextSpan>[
TextSpan(
text: 'RichText ',
style: TextStyle(
fontSize: 20,
)),
TextSpan(
text: 'widget!',
style: TextStyle(
fontStyle: FontStyle.italic,
color: Colors.red,
))
]),
2.6. Виджеты отображения данных и работа с assets 333
),
],
textAlign: TextAlign.center,
),
),
}
}
);
На рис. 2.65 показан запуск предыдущего примера, демонстрирующего работу
с виджетом Text с помощью его именованного конструктора rich, а также виджетом
RichText.
Рис. 2.65. Пример использования Text.rich и RichText
2.6.2. Работа со шрифтами и их импорт посредством assets
Бывает, что выбранного дизайнером шрифта нет на целевой платформе и его приходится явно добавлять в проект, прописывая в pubspec.yaml пути до импортируемого
шрифта. В качестве примера реализуем приложение, которое будет переключать
семейство шрифтов, используемое для отображаемого текста, и имеет следующий
пользовательский интерфейс (рис. 2.66).
Рис. 2.66. Пример разрабатываемого приложения
Перейдите по ссылке https://fonts.google.com/ и скачайте два семейства шрифтов:
PTSerif и AmaticSC, после чего создайте в корневой папке нового или шаблонного
проекта каталог fonts и распакуйте в него файлы из скачанных архивов:
fonts
├── AmaticSC-Bold.ttf
├── AmaticSC-Regular.ttf
├── PTSerif-Bold.ttf
├── PTSerif-Italic.ttf
├── PTSerif-BoldItalic.ttf
└── PTSerif-Regular.ttf
Теперь откройте pubspec.yaml и в раздел с настройками Flutter добавьте следующую конфигурацию:
flutter:
uses-material-design: true
fonts: # подключение шрифтов
334 Глава 2 Основные виджеты, их компоновка и работа с assets
- family: PTSerif # семейство подключаемого шрифта
fonts: # список подключаемых шрифтов и настройка их свойств
# путь к шрифту в папке проекта
- asset: fonts/PTSerif-Regular.ttf
- asset: fonts/PTSerif-Italic.ttf
- asset: fonts/PTSerif-Bold.ttf
- asset: fonts/PTSerif-BoldItalic.ttf
- family: AmaticSC
fonts:
- asset: fonts/AmaticSC-Regular.ttf
- asset: fonts/AmaticSC-Bold.ttf
Сохранив нажатием Ctrl+S заданную конфигурацию, перейдите к основному
коду приложения и добавьте в него:
// base_url/2/2.6/flutter_flutter_font_assets/lib/example_1.dart
enum FontFamily {
ptserif('PTSerif'),
amaticsc('AmaticSC');
}
final String family;
const FontFamily(this.family);
class _MyHomePageState extends State<MyHomePage> {
FontFamily myFontFamily = FontFamily.ptserif;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SegmentedButton<FontFamily>(
// список сегментов
segments: <ButtonSegment<FontFamily>>[
ButtonSegment<FontFamily>(
// значение ButtonSegment
value: FontFamily.ptserif,
// текст внутри ButtonSegment
label: Text(FontFamily.ptserif.family),
),
ButtonSegment<FontFamily>(
value: FontFamily.amaticsc,
label: Text(FontFamily.amaticsc.family),
),
],
selected: <FontFamily>{
myFontFamily
},
onSelectionChanged: (Set<FontFamily> newSelection) {
setState(() {
// обновляем текущее значение рейтинга
myFontFamily = newSelection.first;
});
}),
const SizedBox(height: 60),
Text('Hello, ${myFontFamily.family} font style!',
style: TextStyle(
// указываем семейство шрифта
fontFamily: myFontFamily.family,
2.6. Виджеты отображения данных и работа с assets 335
],
fontSize: 24,
color: Colors.red,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic,
)),
),
),
}
}
);
Если необходимо, чтобы импортируемый или системный шрифт по умолчанию
использовался всеми виджетами, которые содержат текст, передайте его название
аргументу fontFamily класса ThemeData при объявлении виджета MaterialApp:
// base_url/2/2.6/flutter_flutter_font_assets/lib/example_2.dart
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
fontFamily: 'PTSerif',
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
Такой подход позволяет точечно изменять используемое семейство шрифтов
в текстовых виджетах:
// base_url/2/2.6/flutter_flutter_font_assets/lib/example_2.dart
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Flutter Assets Example'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Hello, PTSerif font style!',
style: TextStyle(
fontSize: 24,
color: Colors.red,
),
),
SizedBox(height: 20),
Text(
336 Глава 2 Основные виджеты, их компоновка и работа с assets
'New line with PTSerif font style!',
style: TextStyle(
fontSize: 24,
),
),
SizedBox(height: 20),
Text(
'Hello, AmaticSC font style!',
style: TextStyle(
fontFamily: 'AmaticSC',
fontSize: 22,
color: Colors.blue,
fontWeight: FontWeight.w900
),
),
],
),
),
}
}
);
Запущенное приложение должно иметь вид, как показано на рис. 2.67.
Когда же стоит задача распространить шрифт только на виджеты, находящиеся
в определенном поддереве, воспользуйтесь для задания родительского элемента классом DefaultTextStyle, а именно его статическим методом DefaultText
Style.merge:
// base_url/2/2.6/flutter_flutter_font_assets/lib/example_3.dart
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Flutter Assets Example'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Hello, PTSerif font style!',
style: TextStyle(
fontSize: 24,
color: Colors.red,
),
),
const SizedBox(height: 20),
DefaultTextStyle.merge(
// все текстовые виджеты поддерева будут
// использовать этот стиль
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
fontFamily: 'AmaticSC',
color: Colors.blue,
),
child: const Column(
children: [
Text('Hello, PTSerif font style!'),
2.6. Виджеты отображения данных и работа с assets 337
],
],
)),
SizedBox(height: 20),
Text('New line AmaticSC font style!'),
),
),
}
}
);
Запустите текущий вариант приложения и обратите внимание на то, как использование DefaultTextStyle позволило в его поддереве переопределить стиль
шрифта, задаваемый на уровне MaterialApp (рис. 2.68).
Рис. 2.67. Пример установки шрифта на уровне
приложения
Рис. 2.68. Пример переопределения стиля шрифта
в поддереве виджетов
2.6.3. Виджет Image и его связь с assets
Виджет Image задействуется для визуализации изображений из различных источников в пользовательском графическом интерфейсе приложения. В качестве
источника могут выступать файлы, картинки из Сети (URL), байтовое (Uint8List)
представление и изображения, путь до которых прописывается в подразделе assets
файла pubspec.yaml, то есть поставляемые с самим приложением. Для указания
используемого типа источника виджет Image предоставляет следующий набор
именованных конструкторов.
y Image.asset. Применяется для получения изображения по его ключу из AssetBundle. Если не установлено значение для флага scale, возвращает экземпляр
AssetImage, в противном случае — ExactAssetImage.
y Image.network. Позволяет отобразить изображение по его URL. Возвращает
экземпляр ResizeImage, собранный на основе NetworkImage.
y Image.file. Загружает изображение из указанного файла и выводит его в пользовательский интерфейс. Возвращает экземпляр ResizeImage, собранный на
основе FileImage. Не поддерживается Flutter Web.
338 Глава 2 Основные виджеты, их компоновка и работа с assets
y
Image.memory.
Применяется для получения изображения из Uint8List. Это
могут быть данные, полученные от серверной части или из открываемого
файла. Возвращает экземпляр ResizeImage, собранный на основе MemoryImage.
FileImage, MemoryImage, NetworkImage, ResizeImage и т. д. — производные классы
от ImageProvider. Их можно использовать в качестве входного аргумента image
базового конструктора виджета Image, тем самым указывая, изображение из какого
типа ресурса (сеть, файл, память, asset) будет отображаться в пользовательском
интерфейсе. Несмотря на наличие такой возможности, принято пользоваться именованными конструкторами, абстрагирующими вас от применения производных
классов ImageProvider. А создание виджета с помощью его базового конструктора
может понадобиться при необходимости подмены источника изображения в ходе
работы приложения.
К процессу добавления изображения в дерево элементов следует подходить
с осторожностью. В любой из конструкторов виджета Image необходимо передать
аргументы width и height или убедиться, что у родительского (или выше по дереву) виджета жестко установлены параметры компоновки. Если проигнорируете
данные ограничения, то размеры изображения будут меняться по мере его загрузки,
приводя к такому изменению первичной верстки макета, что Пабло Пикассо будет
нервно курить в сторонке.
В качестве примера реализуем подход с добавлением изображения с помощью assets. Для этого создайте новый проект, добавьте в корневую папку каталог
images с несколькими картинками, в нашем случае сгенерированными нейросетью
Kandinsky, и пропишите путь до них в pubspec.yaml:
assets:
- images/kandinsky-1.png
- images/kandinsky-2.png
Сохраните заданную конфигурацию нажатием Ctrl+S, откройте файл main.dart
и добавьте в него следующий код, заменив реализацию _MyHomePageState:
// base_url/2/2.6/flutter_image/lib/main.dart
// перечисление для доступа к изображениям
enum MyImages {
kandinsky1(url: 'images/kandinsky-1.png'),
kandinsky2(url: 'images/kandinsky-2.png');
}
final String url;
const MyImages({required this.url});
class _MyHomePageState extends State<MyHomePage> {
var image = MyImages.kandinsky1;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 20),
2.6. Виджеты отображения данных и работа с assets 339
ElevatedButton(
onPressed: () {
setState(() {
// меняем изображение
image = image = = MyImages.kandinsky1
? MyImages.kandinsky2
: MyImages.kandinsky1;
});
},
child: const Text('Change image'),
),
const SizedBox(height: 20),
Expanded(
child: SizedBox(
width: 500,
height: 500,
child: Padding(
padding: const EdgeInsets.all(16.0),
// добавляем изображение в виджет
child: Image.asset(
image.url,
// Указываем, как вписать изображение
// в отведенное пространство.
// BoxFit.cover — как можно меньше, но при этом
// охватывает всю целевую область
fit: BoxFit.cover,
),
),
),
),
],
}
}
),
));
После нажатия кнопки Change image будет запущена замена изображения, отображаемого в пользовательском интерфейсе. А при изменении размеров интерфейса
под него будет подстраиваться размер самого изображения (рис. 2.69).
Рис. 2.69. Пример загрузки изображения из assets
340 Глава 2 Основные виджеты, их компоновка и работа с assets
При использовании конструктора Image.network на его вход достаточно передать URL — Image.network('https:/путь до изображения'), а если это Image.file —
абсолютный путь до файла.
2.6.4. Виджет Icon и добавление своих значков
Иногда описание какого-нибудь действия или настройки может быть настолько
зубодробительным, что пользователь при каждой встрече с ними будет вспоминать
разработчика недобрым словом. Использование виджета Icon позволяет свести
количество таких ситуаций к минимуму. Все дело в том, что значок, добавленный
к текстовому описанию, несет вспомогательный смысл, а в некоторых случаях
полностью перекрывает собой необходимость добавления текста с пояснениями.
У виджета Icon не так уж много входных аргументов, но многие из них зависят
от базового шрифта значка. Поэтому рассмотрим только основные из них — icon,
size и color . Аргументу icon необходимо передать на вход экземпляр класса
IconData, хранящий в себе описание визуализируемого в интерфейсе значка с его
параметрами (семейство шрифтов, список семейства шрифтов и т. д.). Для стиля
Material Flutter «из коробки» предоставляет большой набор значков, с которым
можно ознакомиться, перейдя по ссылке https://api.flutter.dev/flutter/material/Icons-class.
html. А для стиля Cupertino в создаваемых проектах в качестве одной из зависимостей
(по умолчанию) используется пакет cupertino_icons.
Создайте новый проект или возьмите заготовку из начала главы. Для начала
добавим список из значков и цветов, которые сможем переключать в ходе работы
разрабатываемого приложения, а также функцию для создания пользовательского
виджета, объединяющего в себе виджеты Text и Slider. С его помощью будем переключать цвет и изменять размер значка:
// base_url/2/2.6/flutter_icon/lib/example_1.dart
final iconList = <IconData>[
Icons.abc_rounded,
Icons.zoom_out_map_outlined,
Icons.access_alarm_outlined,
];
final colorList = <Color>[
Colors.black,
Colors.red,
Colors.green,
Colors.blue,
];
/// Функция построения слайдера с заданными параметрами
Widget sliderBuilder({
required String text,
required double currentValue,
required double min,
required double max,
int? divisions,
required void Function(double) onChanged,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
2.6. Виджеты отображения данных и работа с assets 341
Text(text, style: const TextStyle(fontSize: 20)),
const SizedBox(
width: 20,
),
Slider(
value: currentValue,
min: min,
max: max,
divisions: divisions,
onChanged: onChanged,
),
],
}
);
Изменять значок в пользовательском интерфейсе будем нажатием кнопки
FloatingActionButton виджета Scaffold. Кроме того, объявим методы для изменения
параметров отображения и передадим их в пользовательский виджет, создаваемый
функцией sliderBuilder:
class _MyHomePageState extends State<MyHomePage> {
int _iconIndex = 0;
int _colorIndex = 0;
double _iconSize = 100;
/// Метод для переключения значка
void _switchIcon() {
setState(() {
_iconIndex = (_iconIndex + 1) % iconList.length;
});
}
/// Метод для изменения размера значка
void _changeIconSize(double newSize) {
setState(() {
if (newSize > 30) {
_iconSize = newSize;
}
});
}
void _changeIconColor(double index) {
setState(() {
_colorIndex = (_colorIndex + 1) % colorList.length;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Icon(
iconList[_iconIndex],
size: _iconSize,
color: colorList[_colorIndex],
),
const SizedBox(height: 20),
sliderBuilder(
text: 'Размер ',
currentValue: _iconSize,
342 Глава 2 Основные виджеты, их компоновка и работа с assets
min: 30,
max: 300,
divisions: 10,
onChanged: _changeIconSize,
),
const SizedBox(height: 10),
sliderBuilder(
text: 'Цвет ',
currentValue: _colorIndex.toDouble(),
min: 0,
max: 3,
divisions: 3,
onChanged: _changeIconColor,
),
],
),
}
}
),
floatingActionButton: FloatingActionButton(
onPressed: _switchIcon,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
Запустите приложение, изменяя положение бегунка виджета Slider для смены
цвета и размера значка (рис. 2.70).
Рис. 2.70. Пример работы приложения
Для добавления собственного значка необходимо использовать виджет ImageIcon,
на вход которому следует передать ImageProvider, size и color. Поскольку с рисунками мы уже имели дело ранее в текущем разделе, воспользуемся подходом
с добавлением изображения с помощью assets. Для этого добавьте в корневую
папку каталог assets с вложенной папкой icons с несколькими картинками значков
в формате png (убедитесь, что у них установлен прозрачный фон) и пропишите
путь до них в pubspec.yaml:
assets:
- assets/icons/plus.png
- assets/icons/minus.png
2.6. Виджеты отображения данных и работа с assets 343
Сохраните заданную конфигурацию нажатием клавиш Ctrl+S, откройте файл
main.dart и замените iconList на iconAssetList, а также виджет Icon — на ImageIcon:
// base_url/2/2.6/flutter_icon/lib/example_1.dart
final iconAssetList = <String>[
'assets/icons/plus.png',
'assets/icons/minus.png',
];
final colorList = <Color>[
// код без изменений
];
/// Функция построения слайдера с заданными параметрами
Widget sliderBuilder({
required String text,
required double currentValue,
required double min,
required double max,
int? divisions,
required void Function(double) onChanged,
}) {
// код без изменений
}
class _MyHomePageState extends State<MyHomePage> {
int _iconIndex = 0;
int _colorIndex = 0;
double _iconSize = 100;
/// Метод для переключения значка
void _switchIcon() {
setState(() {
_iconIndex = (_iconIndex + 1) % iconAssetList.length;
});
}
/// Метод для изменения размера значка
void _changeIconSize(double newSize) {
// код без изменений
}
void _changeIconColor(double index) {
// код без изменений
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
ImageIcon(
AssetImage(
iconAssetList[_iconIndex],
),
size: _iconSize,
color: colorList[_colorIndex],
),
// далее код без изменений
],
)),
344 Глава 2 Основные виджеты, их компоновка и работа с assets
floatingActionButton: FloatingActionButton(
// код без изменений
),
}
}
);
Запустите приложение и обратите внимание на его работу. Изменение размера
значка приведет к изменению занимаемой им области, но не к масштабированию
рисунка (рис. 2.71).
Рис. 2.71. Пример работы приложения
2.6.5. Assets и JSON-файлы
Иногда необходимо хранить предварительные настройки виджетов или несекретные конфигурационные данные в одном месте. Либо у вас могут зачесаться руки
написать свой велосипед для локализации приложения и т. д. Во всех этих случаях
можно задействовать JSON-файлы, добавляя их в качестве ассетов.
Для доступа к таким файлам есть несколько подходов.
1. Напрямую обращаться к основному пакету ресурсов посредством экземпляра
класса AssetBundle — rootBundle, который предоставляет встроенная библио
тека flutter/services.dart.
2. С помощью конструкции DefaultAssetBundle.of(BuildContext context) организовать косвенное обращение к ресурсу из среды выполнения rootBundle.
Рассмотрим каждый из них на примере предыдущего проекта с значком, вынеся
стартовые настройки приложения в JSON-файл. Первое, что нам понадобится, —
создать в корневой папке проекта каталог assets и добавить в него файл icon_settings.json с таким содержимым:
// base_url/2/2.6/flutter_json_asset/assets/icon_settings.json
{
"size": 200,
"color_index": 2,
"icon_index": 1
}
2.6. Виджеты отображения данных и работа с assets 345
На следующем шаге добавим в каталог lib файл icon_settings.dart, который
будет отвечать за десериализацию хранимых в формате JSON данных:
// base_url/2/2.6/flutter_json_asset/lib/icon_settings.dart
final class IconSettings {
final int size;
final int colorIndex;
final int iconIndex;
IconSettings(this.size, this.colorIndex, this.iconIndex);
}
factory IconSettings.fromJson(Map<String, dynamic> json) {
return IconSettings(
json["size"],
json["color_index"],
json["icon_index"],
);
}
В основном файле проекта (main или example_1) импортируйте icon_settings.dart
и добавьте функцию для загрузки конфигурационных данных из ресурсов приложения, возвращающую Future<IconSettings>:
// base_url/2/2.6/flutter_json_asset/lib/example_1.dart
import 'icon_settings.dart';
/// Функция для загрузки настроек
Future<IconSettings> _loadIconSettings() async {
final jsonString = await rootBundle.loadString(
'assets/icon_settings.json',
);
var json = jsonDecode(jsonString);
return IconSettings.fromJson(json);
}
На следующем шаге добавьте виджету MyHomePage поле iconSettings, инициализируемое с помощью конструктора:
class MyHomePage extends StatefulWidget {
final IconSettings iconSettings;
const MyHomePage({
super.key,
required this.iconSettings,
});
}
@override
State<MyHomePage> createState() = > _MyHomePageState();
Чтобы проинициализировать переменные, участвующие в изменении цвета,
размера и индекса отображаемого значка, переопределим в классе _MyHomePageState
метод initState:
class _MyHomePageState extends State<MyHomePage> {
late int _iconIndex;
late int _colorIndex;
late double _iconSize;
346 Глава 2 Основные виджеты, их компоновка и работа с assets
@override
void initState() {
super.initState();
_colorIndex = widget.iconSettings.colorIndex;
_iconIndex = widget.iconSettings.iconIndex;
_iconSize = widget.iconSettings.size.toDouble();
}
}
// далее код без изменений
На последнем шаге перепишем тело метода build пользовательского виджета
MyApp. Для загрузки конфигурационных данных и передачи их дочернему виджету
воспользуемся FutureBuilder. Он выполняет в асинхронном режиме функцию,
передаваемую на вход его аргумента future, возвращает результат ее работы — экземпляр AsyncSnapshot<T> и передает его вместе с контекстом на вход анонимной
функции аргумента builder:
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<IconSettings>(
future: _loadIconSettings(context),
builder: (context, snapshot) {
if (snapshot.hasData) { // Если данные загружены
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
),
useMaterial3: true,
),
home: MyHomePage(
iconSettings: snapshot.data!,
),
);
} else { // Если данные не загружены
return const CircularProgressIndicator();
}
});
}
В результате проделанных манипуляций приложение должно запуститься
с учетом настроек, прописанных в icon_settings.json (рис. 2.72).
Для демонстрации второго подхода нам потребуется изменить сигнатуру функции _loadIconSettings, передав в нее экземпляр BuildContext:
/// Функция для загрузки настроек
Future<IconSettings> _loadIconSettings(BuildContext context) async {
final jsonString = await DefaultAssetBundle.of(context).loadString(
'assets/icon_settings.json',
);
2.7. Скроллируемые виджеты 347
}
var json = jsonDecode(jsonString);
return IconSettings.fromJson(json);
а также код в месте ее вызова:
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<IconSettings>(
future: _loadIconSettings(context),
builder: (context, snapshot) {
// далее код без изменений
},
);
}
Рис. 2.72. Пример запущенного приложения
Несмотря на то что rootBundle предоставляет простой способ доступа к ресурсам, у него есть пара существенных недостатков — трудность тестирования
и невозможность подмены AssetBundle в дереве виджетов. В документации Flutter
рекомендуют использовать вместо него второй подход с DefaultAssetBundle.of. Он
возвращает первого встретившегося в дереве родителя DefaultAssetBundle (у MaterialApp по умолчанию в качестве DefaultAssetBundle устанавливается rootBundle),
что позволяет в ходе тестирования делать подмену ресурсов и реализовывать
собственный вариант локализации. Для этого необходимо объявить производный
класс от CachingAssetBundle, переопределить у него нужный метод (методы) (load,
loadString и т. д.) и передать его экземпляр в конструктор виджета DefaultAssetBundle, устанавливаемый в нужной части дерева виджетов приложения. При
такой реализации любое обращение к ресурсам в дочерних виджетах с помощью
DefaultAssetBundle.of будет возвращать пользовательскую реализацию CachingAssetBundle, приведенную к интерфейсу AssetBundle.
2.7. Скроллируемые виджеты
Когда на экране невозможно разместить все элементы, есть несколько вариантов
того, что можно сделать.
1. Уменьшать размер виджетов, пытаясь запихнуть на один экран приложения
всё и вся. Такое положение дел вряд ли придется по вкусу большинству
пользователей, что отразится на их комментариях и оценках приложения.
Конечно, если вам нравится причинять другим людям боль, их мнение для
вас неважно и вы не планировали монетизировать свой продукт, лучшего
варианта не придумать!
2. Воспользоваться прокручиваемыми виджетами. Им и посвящен этот
раздел.
348 Глава 2 Основные виджеты, их компоновка и работа с assets
2.7.1. Виджет SingleScrollChildView
Начнем знакомство с данным типом виджетов с SingleChildScrollView. Он помогает сделать Column или Row прокручиваемыми. Допустим, мы сверстали страницу,
на которой есть блок текста, не помещающийся на экран телефона. Наша задача —
сделать так, чтобы пользователь мог его прочитать. И это нужно сделать быстро.
Для этого достаточно обернуть текст или, может, даже весь экран в SingleChildScrollView — и у пользователя появится возможность долистать дочерний виджет
до самого конца:
// base_url/2/2.7/flutter_singlechildscrollview/lib/example_1.dart
SingleChildScrollView(
child: Column(...), // Виджет с нашим контентом
),
Один из возможных результатов оборачивания виджетов в SingleChildScrollView
представлен на рис. 2.73.
На основе текущего виджета рассмотрим ряд свойств, которые имеются и у некоторых из следующих прокручиваемых
виджетов. Начнем с аргумента конструктора scrollDirection. Он отвечает за направление прокрутки вложенных виджетов
(горизонтально или вертикально) и принимает на свой вход перечисление типа
Axis (vertical и horizontal):
// base_url/2/2.7/flutter_
singlechildscrollview/lib/example_2.dart
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(...), // Виджет с нашим
// контентом
),
Рис. 2.73. Виджет SingleChildScrollView
На рис. 2.74 представлен горизонтальный вариант прокрутки виджета SingleChildScrollView.
Рис. 2.74. Горизонтальный вид виджета SingleChildScrollView
2.7. Скроллируемые виджеты 349
Следующий аргумент конструктора — padding. Он принимает на свой вход объект типа EdgeInsets и управляет отступами от краев дочернего виджета так, что отступы, совпадающие с осью полосы прокрутки, видны только в начале или в конце:
// base_url/2/2.7/flutter_singlechildscrollview/lib/example_3.dart
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.all(8),
reverse: true,
physics: const BouncingScrollPhysics(),
child: Row(...), // Виджет с нашим контентом
),
Если вам необходимо, чтобы пользователь прокручивал контент (например,
сообщения в каком-либо чате) снизу вверх, воспользуйтесь аргументом reverse:
// base_url/2/2.7/flutter_singlechildscrollview/lib/example_3.dart
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.all(8),
physics: const BouncingScrollPhysics(),
reverse: true,
child: Row(...), // Виджет с нашим контентом
),
Еще один полезный и важный аргумент — physics. Он принимает на вход объект типа ScrollPhysics и отвечает за то, как будет вести себя полоса прокрутки.
Во Flutter уже вшито несколько интересных и полезных классов, которые наследуются от него (табл. 2.1).
Таблица 2.1. Производные классы от ScrollPhysics
Имя класса
Описание
AlwaysScrollableScrollPhysics
Позволяет пользователю всегда пролистывать дочерний виджет
NeverScrollableScrollPhysics
Не позволяет пользователю задействовать полосу прокрутки
BouncingScrollPhysics
Когда пользователь долистывает до начала или конца, полоса
прокрутки немного продолжается пустым местом, но потом возвращается к началу или концу. Является стандартом для iOS
ClampingScrollPhysics
Когда пользователь долистывает до начала или конца, будет
виден эффект свечения — полупрозрачный полукруг. Является
стандартом под Android
RangeMaintainingScrollPhysics
Позволяет удерживать положение прокрутки (процент от максимального размера прокрутки), если контент вдруг изменился
Существуют и другие классы для определения физических свойств прокрутки,
но их мы рассмотрим позже. Что же касается перечисленных, то их можно сочетать,
например использовать RangeMaintainingScrollPhysics вместе с BouncingScrollPhysics для достижения наилучшего эффекта:
// base_url/2/2.7/flutter_singlechildscrollview/lib/example_3.dart
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.all(8),
350 Глава 2 Основные виджеты, их компоновка и работа с assets
reverse: true,
physics: const BouncingScrollPhysics(),
child: Row(...), // Виджет с нашим контентом
),
Как в таком случае выглядит SingleChildScrollView, можно увидеть на рис. 2.75,
а для просмотра того, как он работает, перейдите по QR-коду на заранее подготовленный нами GIF в репозитории к книге.
Рис. 2.75. Развернутый SingleChildScrollView с примененными отступами и physics
Существует также аргумент restorationId. Он позволит вам восстанавливать
место, где пользователь остановился в прошлый раз.
Для более тонких настроек полосы прокрутки можно использовать аргумент
controller и передать ему какой-либо ScrollController — класс, который позволяет отслеживать работу полосы прокрутки в приложении и конфигурировать
его, например, если стоит задача сделать пагинацию с «бесконечной» прокруткой.
То есть, когда пользователь доходит до конца списка, должна загружаться следующая страница.
Стоит отметить, что SingleChildScrollView не слишком оптимизирован. Он увеличивает дерево виджетов и хранит всех потомков в оперативной памяти, а это
плохо сказывается на производительности! Поэтому стоит использовать его лишь
в крайнем случае.
2.7.2. Виджеты ListView и GridView
Виджет ListView, в отличие от SingleChildScrollView, позволяет передать на свой
вход сразу несколько дочерних виджетов (как в Column, Row или Stack):
// base_url/2/2.7/flutter_listview/lib/example_1.dart
ListView(
children: [...],
),
ListView пригодится вам для объявления как вертикальных, так и горизонтальных списков. К тому же вместе с ним часто применяют виджет ListTile из Material
2.7. Скроллируемые виджеты 351
Design,
которому можно передать какие-либо данные, например название (title)
или подзаголовок (subtitle):
// base_url/2/2.7/flutter_listview/lib/example_2.dart
ListView(
children: List.generator(
50,
(int index) = > ListTile(
title: Text(''ListTile index is $index''),
),
),
),
Но, так как все 50 элементов списка будут выведены на экран сразу же, а не
в момент их появления на экране, такой код сложно назвать оптимальным! А что,
если их будет не 50, а 100 или даже 1000? Тогда мы столкнемся с понижением FPS
и, как следствие, испорченным настроением пользователя.
Чтобы избежать такого поведения приложения, для создания ListView следует
использовать его именованный конструктор — ListView.builder. На вход этого
конструктора можно передать количество элементов и функцию, которая будет их
создавать перед появлением на экране:
// base_url/2/2.7/flutter_listview/lib/example_3.dart
ListView.builder(
itemCount: 50,
itemBuilder: (BuildContext context, int index) = > ListTile(
title: Text(''ListTile index is ${index}''),
),
),
Не кажется ли вам, что как будто не хватает каких-нибудь разделителей между
элементами списка? Для их задания тоже есть отдельный именованный конструктор — ListView.separated. На свой вход он принимает две функции: одну для
элементов списка, другую для разделителей, куда можно добавить отступы или
что-нибудь другое, например виджет Divider, представляющий собой полоску с настраиваемой толщиной (thickness), отдельными отступами с двух концов (indent,
endIndent) и цветом (color). В качестве примера добавим черную полоску толщиной
2 пункта с отступом слева:
// base_url/2/2.7/flutter_listview/lib/example_4.dart
ListView.separated(
itemCount: 50,
itemBuilder: (BuildContext context, int index) = > ListTile(
title: Text(''ListTile index is ${index}''),
),
separatorBuilder: (BuildContext context, int index) = > Divider(
thickness: 2,
color: Colors.black,
indent: 24,
),
),
Разницу между конструкторами builder и separated виджета ListView можно
увидеть на рис. 2.76.
352 Глава 2 Основные виджеты, их компоновка и работа с assets
Рис. 2.76. Слева — ListView.builder; справа — ListView.separated
А что, если нужно вывести сетку из элементов? Первое, что приходит на ум, — использовать виджет Row и в нем несколько Expanded, чтобы сделать дочерние виджеты
одинаковыми. Но нет, лучше всего задействовать специальный виджет GridView.
Он работает почти аналогично ListView, за тем исключением, что отрисовывает
несколько элементов списка в ряд, а потом переходит на новый. Для создания
этого виджета с фиксированным количеством элементов по оси Х используется
именованный конструктор GridView.count:
// base_url/2/2.7/flutter_gridview/lib/example_1.dart
GridView.count(
crossAxisCount: 2, // Количество элементов в одном ряду
crossAxisSpacing: 16,
mainAxisSpacing: 16,
padding: 16,
children: [...],
),
На рис. 2.77 показано, как будет выглядеть такой виджет.
Обратите внимание на два аргумента конструктора GridView — mainAxisSpacing
и crossAxisSpacing. Первый позволяет задать отступы между элементами столбца,
а второй — между элементами списка.
Если необходимо объявить GridView посредством стандартного конструктора,
то это необходимо сделать с помощью объявления делегата для него — класса
SliverGridDelegate. Для этого воспользуйтесь одной из двух его имплементаций,
которые уже есть во Flutter.
1. SliverGridDelegateWithFixedCrossAxisCount создает GridView с фиксированным количеством элементов в одном ряду.
2. SliverGridDelegateWithMaxCrossAxisExtent создает GridView с заданной
максимальной шириной одного элемента. Например, если мы задали
2.7. Скроллируемые виджеты 353
ширину 125, а ширина экрана 500, то
будет четыре колонки, а если ширина
экрана 450 — три.
Еще одна особенность GridView заключается в том, что задать высоту элементов можно
двумя способами — напрямую посредством
mainAxisExtent или через соотношение сторон
с помощью аргумента childAspectRatio:
// base_url/2/2.7/flutter_gridview/lib/
// example_2.dart
GridView(
gridDelegate: const
SliverGridDelegateWithFixedCrossAxisCount(
childAspectRatio: 1,
crossAxisCount: 2,
),
children: [...],
),
Виджет GridView имеет ту же ахиллесову
пяту, что и ListView , — элементы виджета
будут выведены на экран сразу же. И решение
этой проблемы у них одно и то же — именованный конструктор builder:
Рис. 2.77. Виджет GridView
// base_url/2/2.7/flutter_gridview/lib/example_2.dart
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
childAspectRatio: 1,
crossAxisCount: 2,
),
itemCount: 50,
itemBuilder: (BuildContext context, int index) = > ColoredBox(
color: Colors.blue.withAlpha(25),
child: Center(child: Text(''Item $index'')),
),
),
2.7.3. Виджеты PageView и Carousel
Помимо обычной полосы прокрутки, существует еще такой зверь, как постраничная полоса прокрутки. Для его объявления следует использовать виджет PageView:
// base_url/2/2.7/flutter_pageview/lib/example_1.dart
PageView(
children: [
Center(
child: Text(''Page One''),
),
Center(
child: Text(''Page Two''),
),
Center(
child: Text(''Page Three''),
),
],
),
354 Глава 2 Основные виджеты, их компоновка и работа с assets
На рис. 2.78 показано, как он будет выглядеть.
По умолчанию такой тип полосы прокрутки — горизонтальный. Если необходимо, чтобы страницы листались вертикально, то, как и в случае с ListView ,
воспользуйтесь аргументом конструктора
scrollDirection.
Для более гибкой работы с PageView
задайте ему PageController:
// base_url/2/2.7/flutter_pageview/lib/
example_2.dart
final _controller = PageController();
...
PageView(
controller: _controller,
children: [
Center(
child: Text(''Page One''),
),
Center(
child: Text(''Page Two''),
),
Center(
child: Text(''Page Three''),
),
],
),
Его контроллер позволяет управлять
состоянием PageView из других мест, например добавлять кнопки для перехода по
страницам. Для этого нужно всего лишь
вызвать метод animateToPage у контроллера, куда задать номер страницы. А еще
потребуется добавить длительность анимации и ее кривую (что это и зачем — разберем в главе, посвященной работе с анимацией):
Рис. 2.78. Виджет PageView
// base_url/2/2.7/flutter_pageview/lib/example_2.dart
IconButton(
icon: Icon(Icons.chevronRight),
onTap: () {
_controller.animateToPage(
1,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
),
},
),
Аналогичным способом можно перейти к следующей (метод nextPage) и предыдущей (previousPage) страницам. Если у вас нет необходимости анимировать
2.7. Скроллируемые виджеты 355
переход, то можно прибегнуть к использованию метода jumpToPage, куда просто
передать индекс страницы.
Для прослушивания того, какая страница в PageView стала активной, передайте аргументу onPageChanged анонимную callback-функцию, которая и будет
реагировать на изменения, например выводить для пользователя номер текущей
страницы:
// base_url/2/2.7/flutter_pageview/lib/example_3.dart
int _currentPage = 0;
...
PageView(
onPageChanged: (pageIndex) {
setState(() {
_currentPage = pageIndex;
});
},
children: [
Center(
child: Text(''Page One''),
),
Center(
child: Text(''Page Two''),
),
Center(
child: Text(''Page Three''),
),
],
),
Когда на страницах много элементов, для объявления виджета PageView используйте его именованный конструктор builder. Здесь все похоже на работу с другими
скроллируемыми виджетами:
// base_url/2/2.7/flutter_pageview/lib/example_4.dart
PageView.builder(
// itemBuilder будет вызываться только с индексами,
// больше или равными нулю и меньше itemCount
itemBuilder: (context, index) {
return Center(
child: Text(''Page $index''),
),
},
itemCount: 3,
),
Во Flutter 3.24 был добавлен новый скроллирующий виджет — CarouselView
(карусель), который позволяет реализовать четыре вида виджетов-каруселей с помощью всего двух конструкторов — обычного и CarouselView.weighted.
1. Multi-browse. Показывает одновременно три страницы — большую, среднюю
и маленькую.
2. Uncontained. Страница занимает все доступное пространство. Используется
по умолчанию.
3. Hero. Показывает минимум две страницы — одну большую и одну маленькую.
4. Полноэкранная карусель. Показывает страницу во весь экран.
356 Глава 2 Основные виджеты, их компоновка и работа с assets
Поскольку CarouselView был добавлен только в Material Design 3, для других
дизайн-систем его придется писать самим, используя PageView или библиотеки,
как это делалось ранее.
Для начала объявим обычный CarouselView:
// base_url/2/2.7/flutter_carouselview/lib/example_1.dart
CarouselView(
itemExtent: 200,
shrinkExtent: 150,
children: ...
),
На рис. 2.79 показано, как будет выглядеть такой виджет.
Рис. 2.79. Виджет CarouselView
Кроме списка виджетов, которые будут отображаться внутри, мы использовали
еще два аргумента — itemExtent для задания полной ширины элемента и shrink
Extent для задания минимальной ширины, которую может занять элемент. Еще
имеется возможность задать действие нажатием на элемент. Делается это с помощью аргумента onTap.
При использовании конструктора CarouselView.weighted его аргументу flexWeights передаются веса каждого видимого элемента. Это позволяет не задавать
ширину, так как она будет выставлена автоматически:
// base_url/2/2.7/flutter_carouselview/lib/example_2.dart
CarouselView.weighted(
flexWeights: <int>[1, 2, 3, 2, 1],
// видимый элемент сохраняет максимальный вес?
consumeMaxWeight: false,
children: ...
),
На рис. 2.80 показано, как будет выглядеть такой виджет.
В отличие от других скроллируемых виджетов у CarouselView есть собственный
контроллер — CarouselController. Он позволяет управлять положением элементов
и максимально похож на ScrollController.
2.7. Скроллируемые виджеты 357
Рис. 2.80. Виджет CarouselView.weighted
2.7.4. Кастомная полоса прокрутки и Slivers
Наши приложения состоят не только из однообразных, но и из различных комбинированных списков. Например, если мы откроем главный экран какого-нибудь
популярного приложения (банковское, маркетплейс и т. д.), то там обязательно
будут несколько горизонтальных списков, сетка и обычный вертикальный список.
Чтобы повторить такой подход, неопытные разработчики чаще всего прибегают
к использованию одного ListView и добавляют в него другие списки с параметром
shrinkWrap. Поскольку все элементы списков, а не только видимая часть сразу же
попадают на отрисовку, это плохо отражается на производительности приложения
и нервах пользователей. Поэтому разработчики Flutter сделали для таких случаев
отдельный виджет — CustomScrollView, который необходимо использовать только
со специальными виджетами, известными как Slivers (сливеры):
// base_url/2/2.7/flutter_customscrollview/lib/example_1.dart
CustomScrollView(
slivers: [
...
],
),
Пример того, как выглядят некоторые Slivers, приведен на рис. 2.81, а чтобы
увидеть, как он работает, перейдите по QR-коду на заранее подготовленный нами
GIF в репозитории книги.
CustomScrollView работает точно так же, как ListView, поэтому не будем останавливаться на его параметрах, а сразу перейдем к разбору основных Slivers. Для
простоты разделим их на три типа:
y статичные виджеты;
y списки;
y вспомогательные виджеты.
358 Глава 2 Основные виджеты, их компоновка и работа с assets
Рис. 2.81. Виджет CustomScrollView со складывающимся AppBar, статичным виджетом и списком
Главный среди статичных виджетов — SliverToBoxAdapter. С его помощью можно
добавить любой другой виджет внутрь CustomScrollView:
// base_url/2/2.7/flutter_customscrollview/lib/example_1.dart
CustomScrollView(
slivers: [
...,
SliverToBoxAdapter(
child: Container(
color: Colors.red,
height: 200,
width: 200,
),
),
],
),
Чтобы сделать AppBar, который реагирует на пролистывание, следует использовать виджет SliverAppBar. Он чем-то похож на обычный виджет AppBar, но может
иметь несколько форм. Например, когда мы только открыли страницу, он может
быть большого размера (с изображением внутри), а после того, как прокрутили
вниз, станет обычного размера и одноцветным.
Далее приведен список аргументов конструктора, которыми SliverAppBar отличается от AppBar:
y flexibleSpace — принимает на вход дочерний виджет, который будет виден,
если SliverAppBar развернут;
2.7. Скроллируемые виджеты 359
— указывает на то, что SliverAppBar будет появляться и/или
растягиваться при прокрутке наверх с любой позиции, а не только тогда,
когда до него дойдет положение ползунка;
y bool pinned — если true, то SliverAppBar будет виден постоянно. При значении
false он будет скрываться за экран;
y bool snap — указывает на то, что наполовину раскрытый SliverAppBar должен
полностью раскрыться по завершении прокрутки, если этого не произошло
ранее;
y double? expandedHeight — высота SliverAppBar в полностью открытом состоянии.
y
bool floating
// base_url/2/2.7/flutter_customscrollview/lib/example_1.dart
CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
floating: true,
snap: false,
expandedHeight: 200,
flexibleSpace: const FlexibleSpaceBar(
title: Text('SliverAppBar'),
background: FlutterLogo(),
),
),
...
],
),
Другой похожий на SliverAppBar виджет, SliverPersistentHeader, используется
только для того, чтобы оставлять сверху какой-то определенный виджет. Понять
его сложнее, так как для него нужно прописывать дополнительный класс-делегат.
Кроме этого, как и в SliverAppBar, конструктор SliverPersistentHeader принимает
на свой вход такие аргументы, как floating и pinned:
// base_url/2/2.7/flutter_customscrollview/lib/example_2.dart
CustomScrollView(
slivers: [
...,
SliverPersistentHeader(
delegate: AppPersistentHeaderDelegate(title: ''Header''),
),
...
],
),
Помимо верстки, требуется передать делегату минимальный (minExtent) и максимальный (maxExtent) размер, а также функцию определения того, нужно ли перестраивать виджет, — shouldRebuild. Она отвечает за отслеживание дополнительных
изменений в делегате:
// base_url/2/2.7/flutter_customscrollview/lib/example_2.dart
class AppPersistentHeaderDelegate extends
SliverPersistentHeaderDelegate {
final String title;
AppPersistentHeaderDelegate({required this.title});
@override
double minExtent = 100;
360 Глава 2 Основные виджеты, их компоновка и работа с assets
@override
double maxExtent = 250;
@override
bool shouldRebuild(AppPersistentHeaderDelegate oldDelegate) = > false;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
...
}
}
Как работает такой Sliver, показано на рис. 2.82, а чтобы увидеть его в действии,
перейдите по QR-коду на заранее подготовленный нами GIF в репозитории книги.
Рис. 2.82. Виджет SliverPersistentHeader
Со списками тоже не все так просто. Да, есть SliverList и SliverGrid, которые
могут работать так же (но с небольшими нюансами), как и их прототипы из обычных виджетов — ListView и GridView. Как минимум они нужны для встраивания
списков внутрь CustomScrollView.
Внимательно посмотрим на SliverList. У него четыре конструктора, три из
которых максимально повторяют те, что есть у ListView:
y
y
y
SliverList.list работает, как стандартный ListView;
SliverList.builder работает, как ListView.builder;
SliverList.separated работает, как ListView.separated;
2.7. Скроллируемые виджеты 361
y
SliverList получает на вход делегат класса SliverChildDelegate.
// base_url/2/2.7/flutter_customscrollview/lib/example_1.dart
CustomScrollView(
slivers: [
...,
SliverList.list(
children: [
...
],
),
],
),
Его отличительной чертой является также отсутствие аргументов конструктора
shrinkWrap и physics.
Что касается SliverGrid, у него есть несколько основных конструкторов:
y SliverGrid принимает два делегата — SliverChildDelegate в аргумент delegate
для формирования элементов и SliverGridDelegate в gridDelegate для расположения этих элементов;
y SliverGrid.count аналогичен GridView.count;
y SliverGrid.builder аналогичен GridView.builder.
// base_url/2/2.7/flutter_customscrollview/lib/example_1.dart
CustomScrollView(
slivers: [
...,
SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
childAspectRatio: 1,
crossAxisCount: 2,
),
itemCount: 50,
itemBuilder: (BuildContext context, int index) = > ColoredBox(
color: Colors.blue.withAlpha(25),
child: Center(child: Text(''Item $index'')),
),
),
],
),
С тем, как организовывать контент с помощью CustomScrollView, мы разобрались,
но, помимо рассмотренных Slivers, нужно знать еще несколько вспомогательных
виджетов. Они аналогичны некоторым обычным, только вместо свойства child
у них есть свойство sliver. Соответствие таких виджетов показано в табл. 2.2.
Таблица 2.2. Соответствие обычных виджетов и Sliver
Обычный Widget
Sliver
Padding
SliverPadding
SafeArea
SliverSafeArea
Visibility
SliverVisibility
Opacity
SliverOpacity
Column, Row (в зависимости от направления прокрутки)
SliverMainAxisGroup
362 Глава 2 Основные виджеты, их компоновка и работа с assets
2.8. Scaffold и его составные виджеты
Виджет Scaffold — швейцарский нож Flutter,
используемый для объявления базовой структуры приложения. Он может состоять из
ряда вложенных виджетов, таких как AppBar,
BottomNavigationBar, FloatingActionButton,
Drawer и т. д. С некоторыми из них мы уже
сталкивались в коде предыдущих примеров,
но не разбирали, для чего они используются
и какими свойствами обладают. Другие же
будут рассмотрены впервые. Обратите внимание на фразу «может состоять»: виджет
отобразится на экране только в том случае,
если вы явно передадите его необходимому
аргументу конструктора Scaffold.
На рис. 2.83 представлен базовый (джентльменский) набор вложенных виджетов.
2.8.1. Виджет AppBar
Виджеты AppBar, или, как его еще называют,
виджет панели приложений, может состоять из набора вложенных виджетов (если их
объявления переданы в конструктор класса) и отображается в шапке приложения.
На рис. 2.84 приведена его структура.
Рис. 2.83. Структура виджета Scaffold
Рис. 2.84. Структура виджета AppBar
(BSD-3-Clause license, https://api.flutter.dev/flutter/material/AppBar-class.html)
2.8. Scaffold и его составные виджеты 363
В аргумент title чаще всего передается виджет Text, содержимое которого будет
отображаться в шапке приложения. Но у него куда больше возможностей использования, чем может показаться на первый взгляд! Все дело в том, что на вход аргумента ожидается экземпляр или производный класс от Widget. В качестве примера
(рис. 2.85) добавим после текста IconButton, при нажатии на который увеличится
значение счетчика, отображаемое в поле body виджета Scaffold:
Рис. 2.85. Пример запущенного приложения
// base_url/2/2.8/flutter_appbar/lib/example_1.dart
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// цвет навигационной панели
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Тык!!!',
style: TextStyle(fontSize: 24),
),
IconButton(
onPressed: () {
setState(() {
_counter++;
});
},
icon: const Icon(
Icons.add,
color: Colors.red,
),
)
],
),
),
364 Глава 2 Основные виджеты, их компоновка и работа с assets
body: Center(
child: Text(
'$_counter',
style: const TextStyle(fontSize: 24),
),
),
}
}
);
Когда вы передаете аргументу title один виджет Text и хотите расположить его
текст посередине, установите аргумент centerTitle виджета AppBar равным true:
Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text(
'AppBar',
style: TextStyle(fontSize: 24),
),
centerTitle: true,
),
А если планируется добавить сложный виджет, составным частям которого
необходимо задать единый стиль текста, используйте аргумент titleTextStyle:
Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: // сложносоставной виджет
titleTextStyle: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic,
),
),
Аргумент actions (см. рис. 2.84) ожидает на свой вход список виджетов. Чаще
всего это IconButton или PopupMenuButton. Но никто не ограничивает вашу фантазию! Так что на этом месте AppBar можно встретить и какой-нибудь TextButton.
Для следующих примеров уберем плашку debug в правом верхнем углу приложения. Сделать это можно, передав аргументу debugShowCheckedModeBanner виджета
MaterialApp значение false:
// base_url/2/2.8/flutter_appbar/lib/example_2.dart
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// цвет навигационной панели
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text(
'AppBar',
style: TextStyle(fontSize: 24),
),
2.8. Scaffold и его составные виджеты 365
actions: [
TextButton(
onPressed: () {
// переход на новый экран
Navigator.push(
context,
MaterialPageRoute(
builder: (context) = > const NewPage(),
));
},
child: const Text(
'Тык!!!',
style: TextStyle(color: Colors.black87),
)),
IconButton(
onPressed: () {
// Увеличение счетчика
setState(() {
_counter++;
});
},
icon: const Icon(Icons.sailing),
)
],
),
body: Center(
child: Text(
'$_counter',
style: const TextStyle(fontSize: 24),
),
),
}
}
);
// Новый экран
class NewPage extends StatelessWidget {
const NewPage({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
);
}
Обратите внимание на то, что при переходе на новый экран нас встречает только стрелка назад, благодаря которой можно вернуться на предыдущий (рис. 2.86).
Она появляется автоматически при условии, что аргументу leading виджета AppBar
(см. код виджета NewPage) не передается никаких объектов (кнопка, значок и т. д.).
В противном случае для перехода к предыдущему экрану на мобильных телефонах необходимо использовать системные кнопки, а в десктопных операционных
системах и Web — молиться, чтобы разработчик не забыл о дополнительных возможностях навигации по приложению.
366 Глава 2 Основные виджеты, их компоновка и работа с assets
Рис. 2.86. Пример автоматической навигации по приложению
Аналогичная ситуация и с виджетом Drawer , отвечающим за боковое меню
(рис. 2.87). Если он установлен в Scaffold и в AppBar не передавали виджет аргументу leading, то значок для запуска Drawer появится в верхнем левом углу приложения. Если же leading был задействован для того, чтобы достучаться до Drawer
на мобильном телефоне, придется провести пальцем по экрану слева направо, ну
а для десктопных операционных систем и Web ситуация нисколько не меняется —
молитвы наше все!
// base_url/2/2.8/flutter_appbar/lib/example_3.dart
class _MyHomePageState extends State<MyHomePage> {
final int _counter = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// цвет навигационной панели
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text(
'AppBar',
style: TextStyle(fontSize: 24),
),
// Уберите комментарии и попробуйте достучаться до Drawer
// leading: IconButton(
//
onPressed: () {},
//
icon: const Icon(Icons.delete_sweep_rounded),
// ),
),
body: Center(
child: Text(
'$_counter',
style: const TextStyle(fontSize: 24),
),
),
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: const [
DrawerHeader(
decoration: BoxDecoration(
color: Colors.blue,
),
child: Text('Drawer Header'),
),
2.8. Scaffold и его составные виджеты 367
ListTile(
title: Text('Item 1'),
),
ListTile(
title: Text('Item 2'),
),
],
),
),
}
}
);
Рис. 2.87. Пример запущенного приложения
Аргумент bottom принимает на свой вход экземпляр PreferredSizeWidget или его
производные виджеты. В первом случае вам придется вручную настраивать все отображаемые элементы в пользовательском интерфейсе, а во втором — задействовать
готовый виджет, который берет на себя все предыдущие заботы, например TabBar
для переключения между вкладками (рис. 2.88). Если в вашем приложении пара
экранов, то благодаря этому виджету можно избежать добавления навигации между
ними с помощью Navigator.push() и его аналогов — достаточно связать вкладку
TabBar с отображаемыми на ней виджетами и данными.
Рис. 2.88. Пример работы TabBar
368 Глава 2 Основные виджеты, их компоновка и работа с assets
Есть несколько подходов к добавлению TabBar.
1. Обернуть Scaffold в DefaultTabController, который будет по умолчанию использоваться виджетами TabBar и TabBarView, отвечающими за переключение
между вкладками и отображение виджетов конкретной вкладки.
2. Подключить к StatefulWidget миксин TickerProviderStateMixin (позволяет
анимациям работать плавно и синхронно по времени), объявить и проинициализировать пользовательский TabController, экземпляр которого надо
будет передавать в TabBar и TabBarView.
Второй подход более гибкий и позволяет напрямую взаимодействовать с контроллером:
// base_url/2/2.8/flutter_appbar/lib/example_4.dart
class _MyHomePageState extends State<MyHomePage>
with TickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
// length - количество вкладок
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// цвет навигационной панели
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text(
'AppBar',
style: TextStyle(fontSize: 24),
),
bottom: TabBar(
controller: _tabController,
tabs: const [
// Варианты вкладок
Tab(icon: Icon(Icons.add)),
Tab(text: 'Tab 2'),
Tab(text: 'Tab 3', icon: Icon(Icons.add_a_photo)),
],
),
),
body: TabBarView(
// Контроллер будет автоматически при изменении состояния
// переключать вкладки виджета TabBarView
controller: _tabController,
// Виджеты, отображаемые на вкладках
children: const [
Center( // первая вкладка
child: Text(
'Tab 1',
style: TextStyle(fontSize: 24),
)),
Center( // вторая вкладка
child: Text(
'Tab 2',
style: TextStyle(fontSize: 22),
)),
2.8. Scaffold и его составные виджеты 369
Center( // третья вкладка
child: Text(
'Tab 3',
style: TextStyle(fontSize: 26),
)),
],
),
}
}
);
Когда же вам необходимо извратиться и задать виджету AppBar определенный
внешний вид, например закругленные углы (рис. 2.89), на помощь приходит аргумент shape:
// base_url/2/2.8/flutter_appbar/lib/example_5.dart
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// цвет навигационной панели
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text(
'AppBar',
style: TextStyle(fontSize: 24),
),
shape: ShapeBorder.lerp(
const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(50),
bottomRight: Radius.circular(30),
)),
null,
0,
),
),
body: const Center(),
);
}
}
Что касается аргумента (области) fle
xible
Space, то этот виджет по размеру равен AppBar и размещается позади панели
инструментов (actions ) и панели вкладок (bottom). Такое поведение позволяет
добавлять на AppBar различные фоновые
картинки.
Рис. 2.89. Пример изменения внешнего вида AppBar
2.8.2. Виджеты NavigationBar и BottomAppBar
по аналогии с TabBar используется для организации переключения
между разными наборами виджетов в пределах одного экрана приложения, не прибегая к классу Navigator. А в отличие от него на составные элементы виджета
BottomAppBar накладывают обязанности по запуску как новых экранов приложения, так
и различных вычислений (получение результата из БД, по сети, изменение состояния
NavigationBar
370 Глава 2 Основные виджеты, их компоновка и работа с assets
приложения и т. д.). Оба этих виджета обычно помещаются на нижнем уровне
Scaffold посредством передачи их экземпляров аргументу bottomNavigationBar.
До появления Material Design 3 вместо виджета NavigationBar использовался
BottomNavigationBar. Пока что применять его не запрещается, но в официальной
документации Flutter уже есть строки, в которых для новых приложений вместо
BottomNavigationBar рекомендуют брать NavigationBar.
В качестве примера работы с NavigationBar разработаем следующее приложение
(рис. 2.90).
Рис. 2.90. Пример работы NavigationBar
При нажатии кнопки My Home Page в центре экрана будет меняться политика
демонстрации подписи значков навигационной панели (показывать текст всегда,
текст только на выделенной вкладке, скрыть текст подписей). А переключение
между элементами навигационной панели запустит перерисовку экрана, изменив
виджеты, которые расположим в теле Scaffold:
// base_url/2/2.8/flutter_navigationbar/lib/example_1.dart
typedef LabelShowsPolicy = NavigationDestinationLabelBehavior;
class _MyHomePageState extends State<MyHomePage> {
int currentPageIndex = 0;
int labelShowsIndex = 0;
void onSelectedItem(int index) {
setState(() {
currentPageIndex = index;
});
}
LabelShowsPolicy getLabelPolicy() {
final length = LabelShowsPolicy.values.length;
return LabelShowsPolicy.values[labelShowsIndex % length];
}
Widget getCurrentPage() {
switch (currentPageIndex) {
case 0:
return Center(
child: Column(
2.8. Scaffold и его составные виджеты 371
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'My Home Page',
style: TextStyle(fontSize: 24),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () {
setState(() {
labelShowsIndex++;
});
},
child: const Text('Swith label shows policy'))
],
}
}
),
);
case 1:
return const Center(
child: Text(
'My Notifications Page',
style: TextStyle(fontSize: 24),
),
);
default:
return const Center(
child: Text(
'WTF Page???',
));
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text(
'AppBar',
style: TextStyle(fontSize: 24),
),
centerTitle: true,
),
body: getCurrentPage(),
bottomNavigationBar: NavigationBar(
// обработка щелчка на элементе навигационной панели
onDestinationSelected: onSelectedItem,
// Цвет фона выбранного элемента
indicatorColor: Colors.cyan,
// Индекс выбранной вкладки навигационной панели
selectedIndex: currentPageIndex,
// Перечисление, настраивающее поведение
// отображения текста вкладок навигационной панели
// onlyShowSelected — отображать только выбранный элемент
// alwaysShow — всегда отображать текст вкладок
// alwaysHide — скрыть текст вкладок
labelBehavior: getLabelPolicy(),
destinations: const <Widget>[
// Объявление элементов навигационной панели
NavigationDestination(
// Значок, когда выбрана эта вкладка
selectedIcon: Icon(Icons.home),
372 Глава 2 Основные виджеты, их компоновка и работа с assets
// Значок, когда выбрана другая вкладка панели
icon: Icon(Icons.home_outlined),
label: 'Home', // текст вкладки
tooltip: 'MyHome!!!', // текст всплывающей подсказки
),
NavigationDestination(
icon: Icon(Icons.notifications_none),
label: 'Notifications',
),
],
),
}
}
);
Если нужно сделать какой-то акцент на вкладке навигационной панели (пришло
сообщение, наступило событие и т. д.) (рис. 2.91), оберните значок необходимого
элемента в виджет Badge:
// base_url/2/2.8/flutter_navigationbar/lib/example_2.dart
class _MyHomePageState extends State<MyHomePage> {
// код без изменений
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(...)
),
body: getCurrentPage(),
bottomNavigationBar: NavigationBar(
onDestinationSelected: onSelectedItem,
// Цвет фона выбранного элемента
indicatorColor: Colors.cyan,
selectedIndex: currentPageIndex,
labelBehavior: getLabelPolicy(),
destinations: const <Widget>[
// Объявление элементов навигационной панели
NavigationDestination(
selectedIcon: Icon(Icons.home),
// Когда вкладка не выбрана, над значком
// дома будет отображаться метка без текста
icon: Badge(child: Icon(Icons.home_outlined)),
label: 'Home', // текст вкладки
tooltip: 'MyHome!!!', // текст всплывающей подсказки
),
NavigationDestination(
icon: Badge(
label: Text('5'),
child: Icon(Icons.notifications_none),
),
// аналогично предыдущему примеру, только
// надо поменять расстановку модификатора
// const (удалить перед списком <Widget>[])
// icon: Badge.count(
//
count: 5,
//
child: const Icon(Icons.notifications_none),
// ),
label: 'Notifications',
),
],
),
);
}
2.8. Scaffold и его составные виджеты 373
Рис. 2.91. Пример работы NavigationBar
В качестве примера использования BottomAppBar (рис. 2.92) реализуем простое
приложение, где нажатие на элементы виджета будет увеличивать счетчик на разные значения:
// base_url/2/2.8/flutter_navigationbar/lib/example_3.dart
class _MyHomePageState extends State<MyHomePage> {
int counter = 0;
int valueIncrement = 1;
void incrementCounter(int value) {
setState(() {
valueIncrement = value;
counter += value;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text(
'AppBar',
style: TextStyle(fontSize: 24),
),
centerTitle: true,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
valueIncrement < = 1
? ''
: 'Значение увеличилось на $valueIncrement',
style: const TextStyle(fontSize: 24),
),
const SizedBox(height: 10),
Text(
'$counter',
style: const TextStyle(fontSize: 24),
),
],
)),
374 Глава 2 Основные виджеты, их компоновка и работа с assets
bottomNavigationBar: BottomAppBar(
// Так как child ожидает на вход объект типа Widget,
// то наполнение BottomAppBar ограничено только вашей фантазией!!!
child: Row(
children: <Widget>[
// список из IconButton
IconButton(
// подсказка
tooltip: 'Increment on 1',
// значок
icon: const Icon(Icons.merge_type_outlined),
// обработчик нажатия
onPressed: () {
incrementCounter(1);
},
),
IconButton(
tooltip: 'Increment on 2',
icon: const Icon(Icons.search),
onPressed: () {
incrementCounter(2);
},
),
IconButton(
tooltip: 'Increment on 3',
icon: const Icon(Icons.settings_input_antenna),
onPressed: () {
incrementCounter(3);
},
),
],
),
),
}
}
);
Рис. 2.92. Пример работы BottomAppBar
Виджеты нижней панели обеспечивают быструю навигацию на одном уровне
вложенности между различными представлениями приложения (подмена дерева
виджетов тела Scaffold при переключении элементов меню) и идеальны для использования в мобильных приложениях. Для верстки под большие экраны лучше
подойдет боковая навигационная панель — виджет NavigationDrawer.
2.8. Scaffold и его составные виджеты 375
2.8.3. Виджет NavigationDrawer
Данный виджет позволяет добавить в приложение боковую навигационную панель (боковое меню). Как и в случае с NavigationBar, начиная с перехода Flutter
на Material Design 3, его рекомендуется использовать вместо виджета Drawer .
Их ключевое различие заключается в том, что виджет Drawer имеет только одного
потомка — чаще всего ListView, а NavigationDrawer не страдает такой проблемой
и принимает на свой вход список виджетов: NavigationDrawerDestination (виджеты,
связанные с NavigationDrawer), пользовательские виджеты, заголовки и разделители.
Scaffold позволяет добавить боковую навигационную панель слева (аргумент
drawer) и справа (endDrawer). Если в AppBar не передавали виджет аргументу leading
или actions, то значок для запуска Drawer появлялся в верхнем левом или правом
углу приложения. В противном случае, чтобы достучаться до NavigationDrawer, на
мобильном телефоне придется провести пальцем по экрану слева направо или наоборот (зависит от стороны, с которой расположено боковое меню). На остальных
платформах пользователю остается только молиться, чтобы разработчик добавил
кнопку вызова боковой навигационной панели. Но это не значит, что обладатели
смартфонов защищены от такого произвола. Для придания им реактивной тяги достаточно аргументам drawerEnableOpenDragGesture и endDrawerEnableOpenDragGesture
конструктора Scaffold передать значение false (по умолчанию true).
Для начала разберем стандартный случай, когда в приложении есть AppBar
с текстом и места для автоматического размещения кнопки вызова бокового меню
не заняты:
// base_url/2/2.8/flutter_drawer/lib/example_1.dart
class _MyHomePageState extends State<MyHomePage> {
int selectIndex = 0;
// Метод обработки нажатия на элемент меню
void onItemMenuChange(int index) {
setState(() {
selectIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text(
'AppBar',
style: TextStyle(fontSize: 24),
),
centerTitle: true,
),
drawer: NavigationDrawer(
// Передаем индекс выбранного элемента в метод onItemMenuChange
onDestinationSelected: onItemMenuChange,
// Устанавливаем индекс текущего выбранного элемента
selectedIndex: selectIndex,
children: const [
DrawerHeader(
decoration: BoxDecoration(
color: Colors.lightBlue,
),
376 Глава 2 Основные виджеты, их компоновка и работа с assets
child: Text('Drawer Header'),
),
// Первый элемент меню
NavigationDrawerDestination(
label: Text('Item 1'),
icon: Icon(Icons.abc),
selectedIcon: Icon(Icons.accessibility_sharp),
),
// Второй элемент меню
NavigationDrawerDestination(
label: Text('Item 2'),
icon: Icon(Icons.access_time),
selectedIcon: Icon(Icons.access_time_filled),
),
// Третий элемент меню
NavigationDrawerDestination(
label: Text('Item 3'),
icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.account_balance_wallet),
),
// Разделитель
Padding(
padding: EdgeInsets.fromLTRB(20, 12, 20, 10),
child: Divider(),
)
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Current Menu Index = $selectIndex',
style: const TextStyle(fontSize: 24),
),
],
),
),
}
}
);
На рис. 2.93 представлены примеры разрабатываемого приложения.
Рис. 2.93. Пример работы NavigationDrawer
2.8. Scaffold и его составные виджеты 377
Теперь разберем ситуацию с запуском боковой навигационной панели с помощью
нажатия на произвольную кнопку (в нашем случае FloatingActionButton) в графическом пользовательском интерфейсе. Для этого откажемся от AppBar и запретим
запуск NavigationDrawer с помощью движения пальца по экрану смартфона. Но для
начала объявим дополнительный класс DestinationData для хранения данных по
каждому из элементов бокового меню — NavigationDrawerDestination и заведем
константный список из экземпляров DestinationData. Он нам потребуется для
демонстрации еще одного варианта организации верстки бокового меню:
// base_url/2/2.8/flutter_drawer/lib/example_2.dart
class DestinationData {
const DestinationData(
this.label,
this.icon, [
this.selectedIcon,
]);
}
final String label;
final Widget icon;
final Widget? selectedIcon;
const destinations = <DestinationData>[
DestinationData(
'Item 1',
Icon(Icons.abc),
Icon(Icons.accessibility_sharp),
),
DestinationData(
'Item 2',
Icon(Icons.access_time),
Icon(Icons.access_time_filled),
),
DestinationData(
'Item 3',
Icon(Icons.account_balance_wallet_outlined),
Icon(Icons.account_balance_wallet),
),
];
Чтобы достучаться до механизма виджета Scaffold, который открывает боковую
навигационную панель, воспользуемся глобальным ключом GlobalKey<ScaffoldState>:
// base_url/2/2.8/flutter_drawer/lib/example_2.dart
class _MyHomePageState extends State<MyHomePage> {
final scaffoldKey = GlobalKey<ScaffoldState>();
int selectIndex = 0;
// Метод открытия бокового меню с помощью ключа
void openDrawer() {
scaffoldKey.currentState!.openDrawer();
}
// Метод обработки нажатия на элемент меню
void onItemMenuChange(int index) {
setState(() {
selectIndex = index;
});
}
378 Глава 2 Основные виджеты, их компоновка и работа с assets
}
@override
Widget build(BuildContext context) {
return Scaffold(
key: scaffoldKey,
// Отключение открытия бокового меню
// движением пальца по экрану
drawerEnableOpenDragGesture: false,
endDrawerEnableOpenDragGesture: false,
drawer: NavigationDrawer(
// Передаем индекс выбранного элемента
// в функцию onItemMenuChange
onDestinationSelected: onItemMenuChange,
// Устанавливаем индекс текущего выбранного элемента
selectedIndex: selectIndex,
children: [
// Еще один вариант заголовка бокового меню
const UserAccountsDrawerHeader(
decoration: BoxDecoration(
color: Colors.lightBlue,
),
accountName: Text('Account Name'),
accountEmail: Text('Account Email'),
currentAccountPicture: CircleAvatar(
backgroundColor: Colors.white,
child: Icon(Icons.abc),
),
),
// верстаем следующие элементы меню,
// используя список destinations
...destinations.map(
(destination) = > NavigationDrawerDestination(
label: Text(destination.label),
icon: destination.icon,
selectedIcon: destination.selectedIcon,
),
),
// Разделитель
const Padding(
padding: EdgeInsets.fromLTRB(20, 12, 20, 10),
child: Divider(),
)
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Current Menu Index = $selectIndex',
style: const TextStyle(fontSize: 24),
),
],
),
),
floatingActionButton: FloatingActionButton(
// Вызываем метод открытия бокового меню
onPressed: openDrawer,
tooltip: 'Show Drawer',
child: const Icon(Icons.menu),
),
);
}
2.8. Scaffold и его составные виджеты 379
Результат запуска модифицированного варианта приложения приведен на рис. 2.94.
Рис. 2.94. Пример работы NavigationDrawer
Виджет боковой панели может обеспечивать быструю навигацию как на одном
уровне вложенности между различными представлениями приложения (подмена
дерева виджетов тела Scaffold при переключении элементов меню), так и для организации более сложной навигации по экранам приложения с использованием класса
Navigator. К тому же он идеально подходит для применения на любых платформах.
2.8.4. Виджет FloatingActionButton
Данный виджет может располагаться в любом месте пользовательского интерфейса,
но чаще всего его применяют совместно с Scaffold.floatingActionButton. Мы уже
не раз встречались с этим вариантом кнопки, и вы, наверное, заметили, что она
как бы нависает над содержимым экрана приложения и применяется для запуска
одного из основных действий — создать, поделиться и т. д. Обычно рекомендуется
использовать не более одного FloatingActionButton на экран. Поэтому неразумное добавление нескольких таких кнопок в одну ветку поддерева виджетов или
организация составных, где один FloatingActionButton является предком другого,
вызовет исключение. Чтобы избежать такой ситуации, каждый виджет Floating
ActionButton должен иметь уникальный heroTag.
В качестве первого примера разберем базовые возможности FloatingActionButton
и его различные конструкторы, реализовав приложение, представленное на рис. 2.95.
Рис. 2.95. Варианты объявления FloatingActionButton
380 Глава 2 Основные виджеты, их компоновка и работа с assets
Далее представлен код приложения, а точнее, его объявление класса _MyHome
PageState:
// base_url/2/2.8/flutter_floatingactionbutton/lib/example_1.dart
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.only(left: 50, right: 50),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Настройки по умолчанию
FloatingActionButton(
// Обработчик нажатия кнопки
onPressed: () {},
),
const SizedBox(height: 60),
const Text(
'Default',
style: TextStyle(fontSize: 20),
),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Глубокая кастомизация большой кнопки
FloatingActionButton.large(
// Обработчик нажатия кнопки
onPressed: () {},
// Цвет кнопки
backgroundColor: Colors.deepOrange,
// Цвет текста и значок на кнопке
foregroundColor: Colors.limeAccent,
// Тиб обрезки краев кнопки
clipBehavior: Clip.hardEdge,
// Обнуляем heroTag, чтобы сделать
// вложенный FloatingActionButton
heroTag: null,
// Подсказка при наведении на кнопку
tooltip: 'Уииииииииии!!!',
// Цвет наполнения при нажатии кнопки
splashColor: Colors.black,
// Задаем форму кнопки
shape: BeveledRectangleBorder(
borderRadius: BorderRadius.circular(90),
),
// Добавляем вложенный FloatingActionButton
child: FloatingActionButton(
onPressed: () {},
mini: true, // Мини-кнопка
child: const Icon(Icons.monetization_on_rounded),
),
),
const SizedBox(height: 20),
2.8. Scaffold и его составные виджеты 381
const Text(
'Large',
style: TextStyle(fontSize: 20),
),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Мини-кнопка
FloatingActionButton.small(
onPressed: () {},
child: const Icon(Icons.disc_full_rounded),
),
const SizedBox(height: 80),
const Text(
'Small',
style: TextStyle(fontSize: 20),
),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Расширенная кнопка
FloatingActionButton.extended(
onPressed: () {},
// Текст внутри кнопки
label: const Text('Тык!!!'),
// Значок на кнопке
icon: const Icon(Icons.sd_card),
// Расстояние между значком и текстом
extendedIconLabelSpacing: 20,
// Отступы внутри кнопки
extendedPadding: const EdgeInsets.all(10),
// Стиль текста внутри кнопки
extendedTextStyle: const TextStyle(
fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 60),
const Text(
'Extended',
style: TextStyle(fontSize: 20),
),
],
),
],
),
),
),
}
}
);
На примере именованного конструктора FloatingActionButton.large мы рассмотрели, как можно покрасить этот виджет и устроить матрешку из кнопок. Но что
делать, если по задумке дизайнера FloatingActionButton должен располагаться совсем в другом месте, отличном от того, куда виджет устанавливается по умолчанию?
Для начала убедитесь, что желаемое в дизайне местоположение не отличается от
382 Глава 2 Основные виджеты, их компоновка и работа с assets
тех, где есть возможность разместить кнопки средствами Scaffold. В противном
случае придется постигать дзен использования виджетов компоновки.
Далее приведен код, в котором демонстрируются стандартные места расположения FloatingActionButton на Scaffold:
// base_url/2/2.8/flutter_floatingactionbutton/lib/example_2.dart
// Cписок мест расположения FloatingActionButton на Scaffold
const localions = <FloatingActionButtonLocation>[
FloatingActionButtonLocation.centerDocked,
FloatingActionButtonLocation.centerFloat,
FloatingActionButtonLocation.centerTop,
FloatingActionButtonLocation.endContained,
FloatingActionButtonLocation.endDocked,
FloatingActionButtonLocation.endFloat,
FloatingActionButtonLocation.endTop,
FloatingActionButtonLocation.startDocked,
FloatingActionButtonLocation.startFloat,
FloatingActionButtonLocation.startTop
];
//
//
//
//
//
//
//
//
//
//
//
Варианты размещения, оптимизированные
под маленькие FloatingActionButton
FloatingActionButtonLocation.miniCenterDocked,
FloatingActionButtonLocation.miniCenterFloat,
FloatingActionButtonLocation.miniCenterTop,
FloatingActionButtonLocation.miniEndDocked,
FloatingActionButtonLocation.miniEndFloat,
FloatingActionButtonLocation.miniEndTop,
FloatingActionButtonLocation.miniStartDocked,
FloatingActionButtonLocation.miniStartFloat,
FloatingActionButtonLocation.miniStartTop,
class _MyHomePageState extends State<MyHomePage> {
int indexLocation = 0;
FloatingActionButtonLocation getLocation()
{
return localions[indexLocation];
}
void changeLocation()
{
setState(() {
if (indexLocation < localions.length - 1) {
indexLocation++;
} else {
indexLocation = 0;
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text(
getLocation().toString().split('.').last,
style: const TextStyle(fontSize: 24),
),
),
2.8. Scaffold и его составные виджеты 383
floatingActionButton: FloatingActionButton(
// При нажатии кнопки меняется расположение FloatingActionButton
onPressed: changeLocation,
child: const Icon(Icons.add),
),
floatingActionButtonLocation: getLocation(),
bottomNavigationBar: NavigationBar(
onDestinationSelected: null,
indicatorColor: Colors.cyan,
selectedIndex: 0,
destinations: const <Widget>[
NavigationDestination(
selectedIcon: Icon(Icons.home),
icon: Icon(Icons.home_outlined),
label: 'Home',
tooltip: 'MyHome!!!',
),
NavigationDestination(
icon: Icon(Icons.notifications_none),
label: 'Notifications',
),
],
),
}
}
);
Запустите приложение и ознакомьтесь со стандартным набором мест расположения FloatingActionButton на Scaffold (рис. 2.96).
Рис. 2.96. Варианты размещения FloatingActionButton на Scaffold
2.8.5. Виджет BottomSheet
Данный виджет располагается в нижней части Scaffold, поверх других виджетов,
передаваемых в его аргумент body. Он может использоваться для показа дополнительной информации или настроек приложения, ввода данных, организации
дополнительных действий пользователей и т. д.
Существует два типа виджетов BottomSheet.
y Постоянный. Служит для показа информации, которая дополняет основное
содержимое приложения. Постоянный BottomSheet оставляет пользователю
384 Глава 2 Основные виджеты, их компоновка и работа с assets
возможность взаимодействовать с другими виджетами на экране, но только
с теми, которые не перекрывает после того, как будет добавлен на Scaffold
посредством передачи виджета в аргумент bottomSheet. Еще один способ
создать и отобразить такой виджет на экране — метод ScaffoldState.showBottomSheet.
y Модальный. Служит альтернативой боковому меню или диалоговому окну.
Модальный BottomSheet не позволяет пользователю взаимодействовать
с остальной частью приложения, то есть он может работать только с виджетами, отображенными на самом BottomSheet. Чтобы создать и отобразить
модальный BottomSheet, воспользуйтесь функцией showModalBottomSheet.
Для начала рассмотрим пример с добавлением на экран BottomSheet с помощью
аргумента конструктора Scaffold — bottomSheet. А чтобы продемонстрировать, как
он перекрывает доступ к части виджетов, но оставляет их активными, реализуем
приложение, представленное на рис. 2.97:
// base_url/2/2.8/flutter_bottomsheet/lib/example_1.dart
class _MyHomePageState extends State<MyHomePage> {
double bottomSheetHeight = 60;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Slider(
value: bottomSheetHeight,
divisions: 10,
min: 60,
max: 200,
onChanged: (double value) {
setState(() {
bottomSheetHeight = value;
});
},
label: bottomSheetHeight.toStringAsFixed(1),
),
const SizedBox(height: 100),
const Text(
'<(˶ᵔᵕᵔ˶)>',
style: TextStyle(fontSize: 50),
),
const SizedBox(height: 100),
],
),
),
bottomSheet: Container(
color: Colors.cyan,
height: bottomSheetHeight,
child: const Padding(
padding: EdgeInsets.all(5.0),
child: Column(
children: [
Row(
children: [
2.8. Scaffold и его составные виджеты 385
Icon(Icons.account_balance_wallet),
SizedBox(width: 5, height: 5),
Text("Stasko Bank")
],
),
Row(
children: [
Icon(Icons.phone),
SizedBox(width: 5, height: 5),
Text("+7(999) xxx-09-42)")
],
)
],
),
}
}
),
),
bottomNavigationBar: NavigationBar(
// относительно предыдущего примера код не изменился
),
);
Рис. 2.97. Пример установки BottomSheet посредством аргумента Scaffold
Перепишем код примера таким образом, чтобы BottomSheet добавлялся на экран
с помощью вызова метода ScaffoldState.showBottomSheet. Достучаться до него
можно через Scaffold.of(context) и GlobalKey<ScaffoldState>. В первом случае
виджет, из которого создается и открывается BottomSheet, должен располагаться
ниже по дереву элементов. Это может быть новый пользовательский виджет либо
виджет Builder. А во втором случае мы можем совершать эти действия в теле самого
виджета Scaffold, не перенося эту обязанность на одного из его потомков.
386 Глава 2 Основные виджеты, их компоновка и работа с assets
Пойдем по простому пути и сделаем так, чтобы BottomSheet добавлялся на экран
после нажатия на FloatingActionButton (рис. 2.98):
// base_url/2/2.8/flutter_bottomsheet/lib/example_2.dart
class _MyHomePageState extends State<MyHomePage> {
final scaffoldKey = GlobalKey<ScaffoldState>();
}
@override
Widget build(BuildContext context) {
return Scaffold(
key: scaffoldKey,
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
SizedBox(height: 100),
Text(
'<(˶ᵔᵕᵔ˶)>',
style: TextStyle(fontSize: 50),
),
SizedBox(height: 100),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
scaffoldKey.currentState!.showBottomSheet((
BuildContext context,
) {
return SizedBox(
height: 200,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Text('BottomSheet'),
ElevatedButton(
child: const Text('Close'),
onPressed: () {
Navigator.pop(context);
},
),
],
),
),
);
},
backgroundColor: Colors.cyan,
// Если нужно отключить закрытие движением
// пальца по экрану, то enableDrag: false,
);
},
child: const Icon(Icons.open_in_browser),
),
bottomNavigationBar: NavigationBar(
// код без изменений
),
);
}
2.8. Scaffold и его составные виджеты 387
Рис. 2.98. Пример создания постоянного BottomSheet
В представленном примере закрыть виджет BottomSheet можно, нажав кнопку
Close или проведя пальцем по экрану сверху вниз. Когда требуется запретить за-
крытие таким способом, передайте аргументу enableDrag значение false.
Теперь реализуем модальный BottomSheet, добавление которого на экран сделает неактивными другие виджеты до тех пор, пока он не будет закрыт (рис. 2.99):
// base_url/2/2.8/flutter_bottomsheet/lib/example_3.dart
class _MyHomePageState extends State<MyHomePage> {
void openBottomSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
return SizedBox(
height: 200,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Text('BottomSheet'),
ElevatedButton(
child: const Text('Close'),
onPressed: () {
Navigator.pop(context);
},
),
],
),
),
);
},
);
}
388 Глава 2 Основные виджеты, их компоновка и работа с assets
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
const SizedBox(height: 100),
const Text(
'<(˶ᵔᵕᵔ˶)>',
style: TextStyle(fontSize: 50),
),
const SizedBox(height: 50),
ElevatedButton(
child: const Text('Open BottomSheet'),
onPressed: () {
openBottomSheet(context);
},
),
const SizedBox(height: 50),
],
),
),
);
}
Рис. 2.99. Пример создания модального BottomSheet
Следует отметить, что в отличие от постоянного BottomSheet, отображаемого
в результате вызова метода showBottomSheet, модальный тип виджета не является
LocalHistoryEntry, то есть не может быть закрыт кнопкой Назад панели приложений
Scaffold.
2.8.6. Виджет SnackBar и способы показа сообщения пользователю
Бывают случаи, когда в процессе работы пользователя с приложением или наступлением события необходимо вывести на экран небольшое информационное сообщение,
не обязывающее пользователя к каким-либо действиям. За это как раз и отвечает
2.8. Scaffold и его составные виджеты 389
виджет SnackBar, а точнее, метод ScaffoldMessenger.of(context).showSnackBar(), на
вход которого передается экземпляр SnackBar, отображаемый на Scaffold. Поэтому
вызов метода должен располагаться как минимум на один уровень ниже по дереву
элементов за объявлением виджета Scaffold либо использовать виджет Builder.
В качестве примера реализуем приложение, демонстрирующее несколько способов конфигурации выводимого на экран сообщения (рис. 2.100):
// base_url/2/2.8/flutter_snackbar/lib/main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
),
// Объявляем Scaffold на данном уровне
home: const Scaffold(body: MyHomePage()),
);
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
}
@override
State<MyHomePage> createState() = > _MyHomePageState();
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton(
child: const Text('First SnackBar'),
onPressed: () {
showFirstSnackBar(context);
},
),
const SizedBox(height: 50),
ElevatedButton(
child: const Text('Second SnackBar'),
onPressed: () {
showSecondSnackBar(context);
},
),
],
),
);
}
390 Глава 2 Основные виджеты, их компоновка и работа с assets
void showFirstSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Some message!'),
// если нужно добавить кнопку
action: SnackBarAction(
label: "Let's go",
onPressed: () {
// обработка нажатия
},
),
// Добавить значок для закрытия SnackBar
showCloseIcon: true,
),
);
}
}
void showSecondSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Some message!'),
// если нужно добавить кнопку
action: SnackBarAction(
label: "Let's go",
onPressed: () {
// обработка нажатия
},
),
// Задаем поведение SnackBar
behavior: SnackBarBehavior.floating,
// Задаем форму для SnackBar
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
),
);
}
Рис. 2.100. Пример использования SnackBar
В зависимости от наполнения Scaffold виджет SnackBar может отображаться
внизу экрана или поверх нижней навигационной панели и FloatingActionButton.
Проект: игра «Тетрис» v.2. Портирование на Flutter 391
Проект: игра «Тетрис» v.2. Портирование на Flutter
Пришла пора дать бой консольной реализации «Тетриса» и с минимальными затратами перенести ее на Flutter.
Перенос и рефакторинг файлов
Создайте новый проект на Flutter с именем tetris, удалите файлы из его папки test
и перенесите в папку lib только те файлы и папки, которые отвечают за саму игру,
отбросив функционал для работы с терминалом с помощью ANSI-команд:
tetris_cli_v1
└──lib/
└── src/
├──
│
│
├──
└──
blocks/
├── block.dart
└── blocks.dart
board.dart
game.dart
В итоге в проекте Flutter-приложения должна получиться следующая структура
каталогов и файлов (рис. 2.101).
Рис. 2.101. Перенос файлов из консольной версии игры
Либо перенесите всю папку src, удалив из нее каталог ansi_cli_helper.
Хорошая новость заключается в том, что файлы и классы, связанные с блоками
фигур, нам трогать не придется, но необходимо избавиться от наследия удаленного
функционала в файлах board.dart и game.dart. И начнем мы с доски. Откройте
board.dart, очистите тело метода drawBoard, попутно удалив все, что касается работы
с переменной класса ansiCliHelper:
// base_url/2/tetris/lib/src/board.dart
import 'blocks/blocks.dart';
class Board {
static const int heightBoard = 20;
static const int widthBoard = 10;
392 Глава 2 Основные виджеты, их компоновка и работа с assets
static const int posFree = 0;
static const int posFilled = 1;
static const int posBoarder = 2;
late List<List<int>> mainBoard;
late List<List<int>> mainCpy;
// callback-функция создания нового блока
Block Function() newBlockFunc;
// callback-функция обновления счета
void Function() updateScore;
// callback-функция обновления блока
void Function(Block block) updateBlock;
// callback-функция завершения игры
void Function() gameOver;
Block currentBlock; // текущий блок с игровой фигурой
Board({
required this.newBlockFunc,
required this.currentBlock,
required this.updateScore,
required this.updateBlock,
required this.gameOver,
}) {
mainBoard = List.generate(
heightBoard,
(_) = > List.filled(widthBoard, 0),
);
mainCpy = List.generate(
heightBoard,
(_) = > List.filled(widthBoard, 0),
);
initDrawMain();
}
// Метод отрисовки основной доски
void drawBoard() {
}
}
// код остальных методов остался без изменений
На следующем шаге откройте файл game.dart. Для начала удалим в нем все
методы, связанные с stdin, stdout и _subscription:
// base_url/2/tetris/lib/src/game.dart
import 'dart:async';
import 'blocks/blocks.dart';
import 'board.dart';
final class Game {
late Board _board;
late Block currentBlock; // текущий блок
late Block nextBlock; // следующий блок
bool _isGameOver = false;
int score = 0;
Game() {
currentBlock = getNewRandomBlock();
nextBlock = getNewRandomBlock();
_board = Board(
currentBlock: currentBlock,
Проект: игра «Тетрис» v.2. Портирование на Flutter 393
newBlockFunc: newBlock,
updateScore: updateScore,
updateBlock: updateBlock,
gameOver: gameOver,
}
);
keyboardEventHandler();
// Метод для установки прослушивания нажатий клавиш
// и передачи ASCII-кода нажатой клавиши на уровень ниже
void keyboardEventHandler() {}
// Метод запуска игры
Future<void> start() async {
// Запускаем игровой цикл
while (!isGameOver) {
nextStep();
printScore();
await Future.delayed(const Duration(milliseconds: 500));
}
}
// Метод вывода текущего счета в игре
void printScore() {}
// код остальных методов остался без изменений
}
После этого шага анализатор Dart не должен показывать ошибки. Но это не значит, что с самим классом Game можно попрощаться. Наведите указатель мыши на
переменную класса _board, нажмите F2 и уберите символ нижнего подчеркивания,
с которого начинается ее имя. Такой подход упростит нам жизнь, так как IDE сама
поменяет имя поля класса во всех других местах, где оно встречается. А сделали
мы ее публичной затем, чтобы использовать данные о состоянии игровой доски
для отрисовки на экране средствами Flutter. А чтобы отслеживать момент завершения игры, добавим в класс Game функцию обратного вызова onGameOver, которую
будем вызывать в методе, модифицированном start (в него тоже добавим callbackфункцию на вход):
// base_url/2/tetris/lib/src/game.dart
import 'dart:async';
import 'dart:ui';
import 'blocks/blocks.dart';
import 'board.dart';
final class Game {
late Board _board;
late Block currentBlock; // текущий блок
late Block nextBlock; // следующий блок
bool _isGameOver = false;
int score = 0;
// Обратный вызов при окончании игры
final Function(String scores) onGameOver;
Game({required this.onGameOver}) {
// код без изменений
}
394 Глава 2 Основные виджеты, их компоновка и работа с assets
// Метод запуска игры
Future<void> start({required VoidCallback onUpdate}) async {
// Запускаем игровой цикл
while (!_isGameOver) {
nextStep();
await Future.delayed(const Duration(milliseconds: 500));
onUpdate(); // Вызывается на каждый цикл игры
}
onGameOver(score.toString());// Вызывается при завершении игры
}
// код остальных методов остался без изменений
}
На этом процесс переноса ядра игры завершен, и можно приступать к реализации на Flutter.
Реализация на Flutter
Первое, что нам необходимо сделать, — создать в папке lib файл tetris_game.dart.
Он будет отвечать за рендеринг элементов на экране. Для этого объявим в нем
класс _GamePainter, наследуемый от CustomPainter, и переопределим метод void
paint(Canvas canvas, Size size), где и будем отрисовывать игровое поле:
// base_url/2/tetris/lib/tetris_game.dart
import 'dart:math';
import
import
import
import
'package:flutter/material.dart';
'package:flutter/services.dart';
'/src/board.dart';
'/src/game.dart';
// Класс отрисовки игрового поля
class _GamePainter extends CustomPainter {
// Игровое поле
final List<List<int>> board;
// Размер блока
final double blockSize;
_GamePainter(this.board, this.blockSize);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
for (int i = 0; i < board.length; i++) {
for (int j = 0; j < board[i].length; j++) {
Rect rect = Rect.fromLTWH(
j*blockSize,
i * blockSize,
blockSize,
blockSize,
);
switch (board[i][j]) {
// Отрисовка пустых клеток поля
case Board.posFree:
paint.color = Colors.black;
// Отрисовка блоков и заполненных клеток поля
case Board.posFilled:
paint.color = Colors.white;
Проект: игра «Тетрис» v.2. Портирование на Flutter 395
// Отрисовка границ поля
case Board.posBoarder:
paint.color = Colors.red;
}
}
}
}
canvas.drawRect(rect, paint);
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) = > true;
}
Далее в этом же файле объявим новый StatefulWidget — TetrisGame:
class TetrisGame extends StatefulWidget {
const TetrisGame({super.key});
@override
State<TetrisGame> createState() = > _TetrisGameState();
}
class _TetrisGameState extends State<TetrisGame> {
late Game game;
// Метод для отображения диалогового окна при завершении игры
// Принимает параметр scores в виде строки, содержащей набранные очки
void _showGameOverDialog(String scores) {
// Проверяем, что виджет все еще находится в дереве виджетов
// Если виджет удален, прерываем выполнение метода
if (!mounted) return;
}
// Планируем показ диалога на следующий кадр отрисовки.
// Это гарантирует, что диалог появится после
// полной инициализации виджета
WidgetsBinding.instance.addPostFrameCallback((_) {
// Показываем диалоговое окно
showDialog(
// Передаем контекст для правильного позиционирования диалога
context: context,
// Запрещаем закрытие диалога при щелчке вне его области
barrierDismissible: false,
// Функция построения содержимого диалога
builder: (BuildContext context) {
// Возвращаем виджет AlertDialog с информацией
// об окончании игры
return AlertDialog(
// Заголовок диалога
title: const Text('Game Over'),
// Текст с количеством набранных очков
content: Text('Your score: $scores'),
// Список кнопок действий (пока пустой)
actions: const [],
);
},
);
});
@override
void initState() {
super.initState();
game = Game(onGameOver: _showGameOverDialog);
396 Глава 2 Основные виджеты, их компоновка и работа с assets
game.start(
onUpdate: () {
setState(() {});
},
);
@override
Widget build(BuildContext context) {
// добавим код позже
}
}
Обратите внимание на код в методе initState(). В нем создается и инициализируется экземпляр класса Game, запускается игра, где при каждом обновлении таймера
внутри метода start будет вызываться обновление текущего виджета с помощью
стандартного механизма setState().
Последнее, что нас держит в этом классе, — реализация метода build:
@override
Widget build(BuildContext context) {
return Focus(
autofocus: true,
onKeyEvent: (FocusNode node, KeyEvent event) {
// Обработка нажатий клавиш
// Обрабатываем как нажатие, так и удержание клавиши
if (event is KeyDownEvent || event is KeyRepeatEvent) {
game.board.keyboardEventHandler(event.logicalKey.keyId);
setState(() {});
return KeyEventResult.handled;
}
// Если событие не обработано, возвращаем ignored
return KeyEventResult.ignored;
},
child: Align(
alignment: Alignment.center,
// Получаем размеры виджета
child: LayoutBuilder(
builder: (context, constraints) {
final board = game.board.mainBoard;
// Вычисляем размер клетки поля
double blockSize = min(
constraints.maxWidth / board[0].length,
constraints.maxHeight / board.length,
);
return CustomPaint(
painter: _GamePainter(board, blockSize),
size: Size(
board[0].length * blockSize,
board.length * blockSize,
),
);
},
),
),
}
);
Для получения событий клавиатуры мы воспользовались виджетом Focus.
Его аргумент autofocus: true автоматически устанавливает фокус на этот виджет
при его создании, а анонимная функция, передаваемая аргументу onKeyEvent,
Проект: игра «Тетрис» v.2. Портирование на Flutter 397
будет применяться для обработки событий клавиатуры. Поскольку на уровне
ниже у нас уже был реализован полноценный обработчик нажатия на клавиши,
в него и передается идентификатор нажимаемой пользователем клавиши. После
чего следует вызов метода setState, что обновляет состояние виджета и перерисовывает его:
if (event is KeyDownEvent) {
game.board.keyboardEventHandler(event.logicalKey.keyId);
setState(() {});
return KeyEventResult.handled;
}
В качестве дочернего виджета Focus для центрирования остальной части дерева
виджетов мы использовали виджет Align. Внутри него находится LayoutBuilder,
который предоставляет размеры доступного пространства для дочернего виджета. А уже внутри LayoutBuilder вычисляется размер клетки игрового поля blockSize на основе минимального значения
между шириной и высотой доступного
пространства, деленного на количество
клеток в соответствующем измерении.
На последнем шаге создается виджет CustomPaint, который использует _GamePainter для отрисовки игрового поля. Размер
самого CustomPaint устанавливается на
основе количества клеток на игровом поле
и размера каждой клетки.
Теперь откройте файл main.dart, добавьте в него следующий код и запустите
игру:
import 'package:flutter/material.dart';
import 'tetris_game.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: TetrisGame(),
),
);
}
Если все было сделано без ошибок, запущенная игра должна выглядеть примерно так, как на рис. 2.102.
Рис. 2.102. Запущенная игра
398 Глава 2 Основные виджеты, их компоновка и работа с assets
Задания на модификацию проекта
В следующий раз мы откажемся от механизма setState и перепишем приложение
для использования ChangeNotifier. А пока можете выполнить задания по внесению
изменений в существующую кодовую базу.
1. По завершении игры выведите заработанные пользователем игровые очки.
2. После завершения игры должна существовать возможность без выхода из
приложения запустить новую игру.
3. Добавьте команду завершения текущей игровой сессии.
4. Добавьте команду установки игры на паузу и снятия с нее.
5. Добавьте игроку возможность просматривать набранные очки в процессе
самой игры, а не только по ее завершении.
6. Введите понятие уровней и при каждом достижении порога (допустим,
с шагом 50 очков) переводите игрока на следующий уровень, увеличивая
скорость игры.
7. Добавьте меню с выбором уровня сложности (скорости падения блоков)
перед стартом игры.
8. Доработайте функционал таким образом, чтобы рядом с игровым полем отображалась фигура, которая появится следующей.
Резюме
В этой главе мы рассмотрели существующие виджеты и поддерживаемые «из коробки» дизайн-системы. Не спешите при первой же задаче нестись на pub.dev для
поиска увиденного в дизайне приложения виджета. Вполне вероятно, он уже есть
среди встроенных виджетов Flutter или реализуется компоновкой нескольких
из них. Написанный вами кастомный виджет всегда должен быть приоритетней
очередной внешней зависимости, иначе файл pubspec.yaml вашего проекта может
разрастись до таких размеров, что отказ от любого стороннего пакета будет приводить к неприятным последствиям, связанным с постоянным переписыванием кода.
Вопросы для самопроверки
1. Какие стили виджетов поддерживает Flutter?
2. Чем дизайн-система Material Design отличается от Cupertino Design? Какую
и в каких ситуациях лучше всего использовать?
3. Как можно классифицировать существующие виджеты во Flutter?
4. За что отвечают виджеты-«коробки» и компоновки? В чем их различие?
5. Каким образом к проекту можно подключить сторонний шрифт и ассеты?
Что может выступать в роли ассета?
6. Из каких составных частей состоит виджет Scaffold? За что ответственна
каждая из них?
7. Чем Sliver-виджеты отличаются от обычных скроллируемых виджетов?
Глава 3
УПРАВЛЕНИЕ СОСТОЯНИЕМ
Красить кнопочки и расставлять на экране виджеты — полдела. Другая половина — корректно менять состояние отображаемых данных на виджетах и самого
приложения, что в итоге сказывается на его работоспособности и удобстве использования. Поэтому в этой главе мы рассмотрим не только саму концепцию
управления состоянием приложения, но и некоторые принципы, подходы, паттерны
и практические примеры.
3.1. Типы состояния приложения
Во Flutter принят декларативный стиль описания графического пользовательского
интерфейса приложения. Это позволяет вместо указания шагов его построения
(императивный стиль) описывать, как интерфейс должен выглядеть и отражать
текущее состояние, что можно выразить следующей формулой (рис. 3.1).
Рис. 3.1. Формула декларативного стиля
С одной стороны, под состоянием приложения можно понимать все, что хранится в памяти приложения для работы с пользовательским интерфейсом в момент
его выполнения: шрифты, различные ресурсы, анимации и т. д. Но с другой —
и сам Flutter работает с различными состояниями для реализации внутренней
механики, сокрытой от глаз пользователей и не сверхдотошных разработчиков.
Сюда входят управление текстурами, то, как происходит смена кадра, и другие
взаимодействия внутри фреймворка. И все это тоже относится к понятию «состояние приложения». Чтобы лучше понять этот термин, приведем следующее
определение.
Состояние приложения — это любые данные, которые нужны для перестройки
пользовательского интерфейса в каждый момент времени.
400 Глава 3 Управление состоянием
Проще говоря, если пользователь нажал кнопку получения данных из Сети, то
состояние этой кнопки или графического пользовательского интерфейса должно
поменяться на «ожидание данных». В таком случае пользователь видит, что приложение не зависло, а работает (рис. 3.2).
Рис. 3.2. Пример изменения состояния кнопки при нажатии
Обычно принято выделять два типа состояния: App state — состояние приложения в целом и Ephemeral state — локальное состояние объекта пользовательского
интерфейса.
3.1.1. Состояние приложения (App state)
App state — общее (глобальное) состояние. Оно представляет собой данные, распространяемые на все объекты приложения (светлая или темная тема, текущий язык
текста в интерфейсе и т. д.). Существует также понятие глобальных переменных
состояния приложения. Они отвечают за небольшую часть приложения, но доступны
в коде из любого места. Допустим, вам необходимо посчитать количество нажатий пользователем кнопок во всем приложении. Для этого заводится глобальный
счетчик, чье значение увеличивается на единицу при нажатии на любую кнопку.
Опытные разработчики не особо любят прибегать к глобальным переменным
или глобальным состояниям. Это связано с тем, что их использование в дальнейшем
вызывает проблемы с тестированием, отладкой и т. д. Поэтому добавляйте их в свой
проект только в том случае, когда точно уверены, что делаете, и если нет другого
способа реализовать необходимую функциональность.
3.1.2. Эфемерное состояние (Ephemeral state)
Эфемерное состояние — состояние локального объекта. В контексте Flutter данное
определение можно применить к внутренним свойствам виджетов (цвет кнопки,
рисунок значка, текущее положение ползунка и т. д.). При знакомстве с типами
состояний у начинающих разработчиков часто возникает вопрос: «А для чего
нужно локальное состояние, если есть глобальное?» Все дело в том, что не всегда
необходимо менять глобальное состояние приложения, когда требуется изменить
параметры только одного виджета.
В основном Flutter-разработчики стараются проектировать свое приложение
таким образом, чтобы лишний раз не перестраивать дерево виджетов, если в нем нет
изменений. Фреймворк умен и защищает от подобного выстрела в ногу. Но определение «кривые руки» никто не отменял. 😉
3.1. Типы состояния приложения 401
Чтобы лучше понять, как используется эфемерное состояние, рассмотрим пример с изменением состояния нижней навигационной панели BottomNavigationBar
виджета Scaffold:
// base_url/3/ephemeral_state/lib/main.dart
import 'package:flutter/material.dart';
/// Пример использования эфемерного состояния
/// на нижней навигационной панели
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Ephemeral state',
home: MyHomepage(),
);
}
}
class MyHomepage extends StatefulWidget {
const MyHomepage({super.key});
@override
State<MyHomepage> createState() = > _MyHomepageState();
}
class _MyHomepageState extends State<MyHomepage> {
// Текущий индекс навигационной панели
int _index = 0;
}
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: BottomNavigationBar(
currentIndex: _index,
onTap: (newIndex) {
// При нажатии кнопки меняет текущий индекс
// и меняется текущее состояние
setState(() {
_index = newIndex;
});
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Дом',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Поиск',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Профиль',
),
]));
}
402 Глава 3 Управление состоянием
В приведенном примере при нажатии на элемент навигационной панели меняется
текущий индекс панели и перестраивается ее интерфейс (рис. 3.3), но сам экран,
на котором находится навигационная панель, не изменяется и не перестраивается.
Рис. 3.3. Пример изменения эфемерного состояния
3.2. Инструменты Flutter для работы с состоянием приложения
В главе 1 мы уже касались инструментов Flutter, о которых пойдет речь в текущем
разделе. Почему снова возвращаемся к ним? Дело в том, что тогда акцент был
сделан на передаче данных по дереву виджетов. Сейчас же часть из них будут рассмотрены с другой точки зрения — управления состоянием приложения. Очень
часто при знакомстве с этой темой всплывают различные пакеты: BLoC, Provider,
GetX и т. д. Мы же не будем их использовать. Это связано с тем, что Flutter и так
предоставляет разработчику все необходимые инструменты для организации гибкого управления состоянием.
3.2.1. setState
Самый простой и, наверное, наиболее используемый метод локального обновления
состояния виджетов — встроенный в StatefulWidget метод setState. После его вызова Flutter повторно вызовет метод build, тем самым обновив данные в графическом
пользовательском интерфейсе приложения.
В предыдущем примере этот метод отвечал за обновление локального состояния
нижней навигационной панели и использовался в анонимной функции, передаваемой на вход аргумента onTap виджета BottomNavigationBar. То есть обновление
состояния панели происходило только тогда, когда пользователь нажимал на соответствующий пункт меню, тем самым вызывая метод setState:
// base_url/3/ephemeral_state/lib/main.dart
bottomNavigationBar: BottomNavigationBar(
currentIndex: _index,
onTap: (newIndex) {
3.2. Инструменты Flutter для работы с состоянием приложения 403
},
// При нажатии кнопки меняет текущий индекс
// и меняется текущее состояние
setState(() {
_index = newIndex;
});
Поскольку setState используется для обновления локального состояния
виджета, изменение состояния должно привести к обновлению интерфейса. Его
рекомендуется применять для управления состоянием в небольших приложениях
и виджетах.
3.2.2. ChangeNotifier
Следующий по простоте применения — класс-миксин ChangeNotifier, который
предоставляет разработчику уже готовую реализацию шаблона «наблюдатель».
Он позволяет уведомлять слушателей об изменениях состояния, чтобы они могли
обновить данные в своих пользовательских интерфейсах, и часто применяется в случаях, когда нет необходимости реализовывать сложную логику работы приложения.
Минимальный джентльменский набор при использовании ChangeNotifier состоит из пары методов и одного геттера.
1. Метод addListener(VoidCallback listener) добавляет слушателя к списку
слушателей. Он будет вызваться каждый раз при использовании метода
notifyListeners():
final myNotifier = ChangeNotifier();
myNotifier.addListener(() = > print('Слушаемый объект изменился'));
2. Метод removeListener(VoidCallback listener) удаляет слушателя из списка,
после чего тот перестает получать уведомления:
final myNotifier = ChangeNotifier();
myNotifier.removeListener(() = > print('Уведомитель был вызван'));
3. Метод notifyListeners() уведомляет всех зарегистрированных слушателей
о том, что произошло изменение. Он должен вызываться каждый раз, когда
данные, зависящие от слушателей, изменяются:
myNotifier.n0otifyListeners();
4. Метод dispose() отвечает за удаление всех слушателей из списка и освобо
ждение ресурсов. Во избежание утечек памяти его рекомендуется вызывать
в тех случаях, когда ChangeNotifier больше не нужен:
myNotifier.dispose();
5. Геттер hasListeners класса ChangeNotifier возвращает true, если в списке
объекта есть хотя бы один слушатель, и false — в противном случае. С его
помощью можно избежать ненужных вычислений или вызовов — достаточно
простой проверки наличия подписчиков перед выполнением операции:
if (hasListeners) {
notifyListeners();
}
404 Глава 3 Управление состоянием
Чтобы ближе познакомиться с данным способом управления состоянием
приложения, напишем простой счетчик. Создайте новое приложение, например
change_notifier_count, удалив файлы из каталога test и заменив весь код в файле
main.dart папки lib на объявление класса Counter:
// base_url/3/change_notifier_count/lib/main.dart
import 'package:flutter/material.dart';
/// Создаем класс Counter, который использует миксин ChangeNotifier.
/// Миксин добавляет классу функциональности
/// для управления подписчиками и уведомлениями.
class Counter with ChangeNotifier {
/// Определяем приватное поле _count, которое будет
/// хранить текущее значение счетчика.
int _count = 0;
///
///
///
int
}
Определяем публичный геттер для _count,
который позволяет другим частям программы
получать текущее значение _count, не изменяя его напрямую.
get count = > _count;
/// Метод для увеличения значение _count.
void increment() {
_count++;
// Вызываем метод notifyListeners(), наследуемый от ChangeNotifier.
// Он уведомляет всех подписчиков о том, что состояние
// изменилось и они должны обновить свои данные.
notifyListeners();
}
Отлично, счетчик готов и он очень простой! Далее реализуем экран с кнопкой,
после нажатия на которую будет производиться обращение к его методу increment(), отвечающему за два действия: увеличение значения счетчика на единицу
и вызов метода notifyListeners(), чья главная задача — уведомить всех подписчиков, что нужно перестроить интерфейс. Для этого воспользуемся виджетом
AnimatedBuilder , передав в его аргумент animation экземпляр класса Counter .
Этим действием мы выполним подписку виджета AnimatedBuilder на оповещение
об изменении значения счетчика, что вызовет его перестройку для отображения
актуальных данных:
void main() {
runApp(MaterialApp(home: CounterScreen(counter: Counter())));
}
class CounterScreen extends StatelessWidget {
final Counter counter;
const CounterScreen({super.key, required this.counter});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AnimatedBuilder(
animation: counter,
builder: (context, child) = > Text('${counter.count}'),
),
),
3.2. Инструменты Flutter для работы с состоянием приложения 405
}
}
floatingActionButton: FloatingActionButton(
onPressed: counter.increment,
child: const Icon(Icons.add),
),
);
AnimatedBuilder — виджет, который облегчает создание анимации во Flutter
и может быть использован в связке с ChangeNotifier. Он принимает на вход своего
аргумента animation анимационный контроллер или анимацию и каждый раз при
их обновлении перерисовывает часть пользовательского интерфейса. Это позволяет
отделить логику анимации от логики построения интерфейса.
Теперь откройте приложение и пару раз нажмите кнопку (рис. 3.4), запустив
изменение значения счетчика и оповещение об этом событии подписавшихся на
него объектов.
Рис. 3.4. Пример использования ChangeNotifier
Можно задействовать и другие вариации класса Builder, например ListenableBuilder. Это более общий виджет, который может прослушивать изменения в любом
объекте, реализующем интерфейс Listenable (Animation, ChangeNotifier и т. д.):
// base_url/3/change_notifier_count/lib/main_v2.dart
// код без изменений
void main() {
runApp(MaterialApp(
home: CounterScreenWithBuilder(
counter: Counter(),
)));
}
class CounterScreenWithBuilder extends StatelessWidget {
final Counter counter;
const CounterScreenWithBuilder({super.key, required this.counter});
@override
Widget build(BuildContext context) {
debugPrint('build');
406 Глава 3 Управление состоянием
}
}
return Scaffold(
body: Center(
child: ListenableBuilder(
listenable: counter,
builder: (context, child) = > Text('${counter.count}'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: counter.increment,
child: const Icon(Icons.add),
),
);
AnimatedBuilder и ListenableBuilder используются разработчиками для создания
анимации и реактивных интерфейсов. Оба этих виджета имеют аргумент builder,
на вход которого должна быть передана анонимная функция с сигнатурой Widget
Function(context, child){}, отвечающая за построение виджета на основе текущего
состояния прослушиваемого объекта. То есть такой тип виджетов позволяет автоматически обновлять данные в графическом пользовательском интерфейсе, если
состояние прослушиваемого объекта было изменено.
3.2.3. ValueNotifier
В отличие от ChangeNotifier, который более гибок и может использоваться для
управления сложными состояниями, ValueNotifier фокусируется на простоте
и удобстве в ходе работы с одним значением. Другими словами, он управляет состоянием только одного значения, уведомляя слушателей при его изменении.
Важно отметить, что для работы с ValueNotifier должен быть использован
определенный тип класса Builder — ValueListenableBuilder, который, в отличие от
ListenableBuilder и AnimatedBuilder, принимает на вход своего аргумента builder
анонимную функцию с сигнатурой из трех параметров Widget Function(context,
value, child) {}, где:
y context — текущий контекст;
y value — прослушиваемое значение;
y child — дочерний виджет, включаемый в возвращаемое дерево виджетов.
В качестве примера использования ValueNotifier изменим предыдущий пример
с ChangeNotifier. Создайте новое приложение (в моем случае value_notifier), удалив
файлы из каталога test и заменив весь код в файле main.dart папки lib на следующий:
// base_url/3/value_notifier/lib/main.dart
import 'package:flutter/material.dart';
class _Counter {
// Создаем ValueNotifier для хранения значения счетчика
final ValueNotifier<int> _count = ValueNotifier<int>(0);
// Геттер для получения текущего значения счетчика
ValueNotifier<int> get count = > _count;
// Метод для увеличения значения счетчика
void increment() {
3.2. Инструменты Flutter для работы с состоянием приложения 407
}
}
_count.value++; // Увеличиваем значение счетчика на 1
void main() {
runApp(MaterialApp(home: CounterScreen()));
}
class CounterScreen extends StatelessWidget {
// Создаем экземпляр модели состояния
final counter = _Counter();
CounterScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
// Используем ValueListenableBuilder для автоматического обновления
child: ValueListenableBuilder<int>(
// Слушаем значение счетчика
valueListenable: counter.count,
// Отображаем текущее значение счетчика
builder: (context, value, child) = > Text('$value'),
),
),
floatingActionButton: FloatingActionButton(
// Увеличиваем счетчик при нажатии
onPressed: counter.increment,
child: const Icon(Icons.add),
),
);
}
ValueNotifier и ValueListenableBuilder предоставляют простой и эффективный
способ управления состоянием и обновления интерфейса, когда требуется отслеживать изменение одного значения. По сравнению с использованием ChangeNotifier
такой подход минимизирует количество кода и упрощает управление состоянием,
но это менее гибкое решение. Выбор между ValueNotifier и ChangeNotifier зависит
от сложности состояния и требований, предъявляемых к приложению.
3.2.4. InheritedWidget
позволяет передавать вниз по дереву виджетов и обновлять данные в дочерних виджетах, когда они изменяются. Его можно использовать также
для управления состоянием без необходимости затаскивать в проект стороннюю
библиотеку в качестве внешней зависимости.
Мы довольно плотно познакомились с InheritedWidget и его производными
классами в главе 1, поэтому в текущем разделе сосредоточимся на том, чтобы
переписать предыдущий пример со счетчиком, добавив возможность производить не только его инкремент, но и декремент. Так как во Flutter InheritedWidget
объявлен как immutable (неизменяемый объект), то все его пользовательские поля
должны быть final. А это значит, что данные могут передаваться только в одном
направлении — от родительского элемента к дочернему и не могут быть изменены
InheritedWidget
408 Глава 3 Управление состоянием
из дочернего элемента, а только при пересоздании InheritedWidget на уровне его
объявления. Поэтому воспользуемся щепоткой «уличной магии» и реализуем
виджет CounterEmbedder на основе StatefulWidget, принимающий на свой вход
поддерево виджетов, перед которым будут внедрены InheritedWidget и экземпляр
класса Counter. Этот виджет будет располагаться в корне дерева и передавать
в инхерит не только данные по счетчику, но и ссылку на методы для инкремента
и декремента его значения.
Создайте новое приложение (в нашем случае inherited_widget), удалив файлы
из каталога test. Чтобы не писать в main.dart много кода, разобьем его по отдельным
файлам. И на первом шаге добавим в каталог lib файл counter.dart со следующим
содержимым:
// base_url/3/inherited_widget/lib/counter.dart
// Модель данных для состояния счетчика
class Counter {
Counter([int value = 0]) : _value = value;
/// Поле для хранения текущего значения счетчика
int _value = 0;
/// Геттер для получения текущего значения счетчика
int get value = > _value;
/// Метод increment для увеличения значения _value
void increment() {
_value++;
}
/// Метод decrement для уменьшения значения _value
void decrement() {
_value--;
}
}
/// Метод copyWith возвращает новый экземпляр Counter
Counter copyWith({int? value}) {
return Counter(
_value = value ?? _value,
);
}
Далее в той же папке создадим файл counter_provider.dart, объявив в нем пользовательский производный класс от InheritedWidget — CounterProvider. Он будет
использоваться для управления состоянием счетчика. Чтобы поиск CounterProvider по дереву элементов занимал константное время O(1), добавим в тело класса
статический метод CounterProvider.of. Это нужно для использования особенности
InheritedWidget, когда каждый элемент содержит прямую ссылку на все родительские InheritedWidget в дереве:
// base_url/3/inherited_widget/lib/counter_provider.dart
import 'package:flutter/material.dart';
import 'counter.dart';
// CounterProvider — InheritedWidget для управления состоянием счетчика
class CounterProvider extends InheritedWidget {
// Поле counter для хранения состояния счетчика
final Counter counter;
3.2. Инструменты Flutter для работы с состоянием приложения 409
// callback-функции для увеличения и уменьшения счетчика
final VoidCallback incrementFunction;
final VoidCallback decrementFunction;
// Конструктор CounterProvider принимает модель counter и child
const CounterProvider({
super.key,
required this.counter,
required super.child,
required this.incrementFunction,
required this.decrementFunction,
});
void increment() {
incrementFunction();
}
void decrement() {
decrementFunction();
}
// Метод updateShouldNotify вызывается, когда необходимо
// определить, нужно ли обновить виджеты-потомки
@override
bool updateShouldNotify(covariant CounterProvider oldWidget) {
return counter.value ! = oldWidget.counter.value;
}
// Метод of позволяет получить текущий экземпляр CounterProvider из контекста
static CounterProvider of(BuildContext context) {
final provider = context.dependOnInheritedWidgetOfExactType<CounterProvider>();
assert(provider ! = null, 'CounterProvider не найден');
return provider!;
}
}
Теперь создадим файл counter_di.dart и добавим в него объявление класса Counter
Embedder, в методе build которого внедрим InheritedWidget в дерево элементов:
// base_url/3/inherited_widget/lib/counter_di.dart
import 'package:flutter/material.dart';
import 'counter.dart';
import 'counter_provider.dart';
class CounterEmbedder extends StatefulWidget {
final Widget child; // Дочерний виджет
final Counter counter; // Счетчик
const CounterEmbedder({
super.key,
required this.child,
required this.counter,
});
}
@override
State<CounterEmbedder> createState() = > _CounterEmbedderState();
class _CounterEmbedderState extends State<CounterEmbedder> {
late Counter counter;
@override
void initState() {
super.initState();
410 Глава 3 Управление состоянием
}
// Инициализируем счетчик
counter = widget.counter;
void increment() {
setState(() {
counter.increment();
});
}
void decrement() {
setState(() {
counter.decrement();
});
}
}
@override
Widget build(BuildContext context) {
return CounterProvider(
// Так как счетчик передается по ссылке, то для правильной
// работы CounterProvider необходимо передавать копию счетчика
counter: counter.copyWith(),
incrementFunction: increment,
decrementFunction: decrement,
child: widget.child,
);
}
На последнем шаге внесем изменения в код main.dart, внедрив подготовленный
ранее виджет в корень дерева приложения:
// base_url/3/inherited_widget/lib/main.dart
import 'package:flutter/material.dart';
import 'counter.dart';
import 'counter_provider.dart';
import 'counter_di.dart';
void main() = > runApp(
CounterEmbedder(
counter: Counter(5),
child: const MaterialApp(
home: _CounterScreen(),
),
),
);
/// Пример использования InheritedWidget
class _CounterScreen extends StatefulWidget {
const _CounterScreen();
}
@override
State<_CounterScreen> createState() = > _CounterScreenState();
class _CounterScreenState extends State<_CounterScreen> {
@override
Widget build(BuildContext context) {
// Используем InheritedWidget для доступа к счетчику
final provider = CounterProvider.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Пример InheritedWidget'),
),
3.2. Инструменты Flutter для работы с состоянием приложения 411
body: const _CounterView(),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
// Кнопка увеличения счетчика
onPressed: provider.increment,
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
// Кнопка уменьшения счетчика
onPressed: provider.decrement,
child: const Icon(Icons.remove),
),
],
),
}
}
);
/// Виджет, отображающий текущее значение счетчика
class _CounterView extends StatelessWidget {
const _CounterView();
}
@override
Widget build(BuildContext context) {
final counter = CounterProvider.of(context).counter;
return Center(
child: Text(
// Выводим текущее значение счетчика
'${counter.value}',
style: const TextStyle(fontSize: 48),
),
);
}
В результате запуска приложения при нажатии кнопки будет увеличиваться
и уменьшаться значение счетчика с последующей перерисовкой актуальных данных
в графическом пользовательском интерфейсе (рис. 3.5).
Рис. 3.5. Пример использования InheritedWidget
412 Глава 3 Управление состоянием
Если же вам необходимо выделить данные в отдельный класс (модель) и перестроить виджет, который подписывается на изменения, только при наличии необходимых событий (например, при изменении значения переменной при вызове
методов модели) и без указания аспектов, следует использовать InheritedNotifier.
Такой подход позволяет отделить бизнес-логику приложения от графического
пользовательского интерфейса, что скажется на удобстве восприятия кода и его
последующей поддержке.
3.3. Паттерны для управления состоянием
Поскольку Flutter — декларативный фреймворк, он строит графический пользовательский интерфейс согласно текущему состоянию приложения. Поэтому при
каждом его изменении происходит перерисовка виджетов на экране. Существует
множество пакетов для управления отображаемыми данными на основе состояния.
Но, как говорилось ранее, знакомство с ними произойдет только в главе 8. А в текущем разделе рассмотрим основные архитектурные паттерны управления состоянием
приложения и реализуем их средствами самого Dart/Flutter.
3.3.1. BLoC (Business Logic Component)
На момент написания книги это, наверное, самый популярный паттерн для управления состоянием приложения. BLoC (компонент бизнес-логики) отделяет бизнес-логику от пользовательского интерфейса, управления состоянием и обработки
событий. Такой подход позволяет повторно использовать выделенные компоненты
бизнес-логики в различных программных продуктах и делает код приложения более
простым в сопровождении и тестировании.
К основным элементам данного шаблона проектирования относятся:
y Events (события) — действия, выполняемые пользователем (нажатие кнопки,
ввод текста и т. д.);
y State (состояние) — текущее состояние всего графического пользовательского
интерфейса приложения или его части, которое меняется в ответ на события
(ошибка, ожидание получения данных и т. д.);
y BLoC (бизнес-логика) — компонент, принимающий на свой вход события для
их последующей обработки и генерации нового состояния. В своей работе
использует потоки (stream).
Принцип работы BLoC можно представить в виде блок-схемы (рис. 3.6).
Рис. 3.6. Принципиальная схема паттерна BLoC
3.3. Паттерны для управления состоянием 413
Давайте по шагам разберем, что изображено на рис. 3.6.
1. Пользователь взаимодействует с UI (User Interface), например нажимает на
выключенный переключатель (switch).
2. UI отправляет это событие в BLoC.
3. BLoC обрабатывает событие (если надо, делает запрос в хранилище или
Интернет) и генерирует новое состояние в зависимости от ответа или заложенной логики.
4. Новое состояние переключателя передается обратно в UI.
5. В зависимости от нового состояния происходит перестроение UI.
Выглядит все немного запутанным, но на самом деле это довольно простой
в освоении и реализации шаблон проектирования для управления состоянием
приложения. В качестве примера перепишем на BLoC приложение с возможностью
увеличения и уменьшения счетчика из предыдущего раздела.
Создайте новое приложение vanilla_bloc, удалив файлы из каталога test. Следующим действием добавьте в папку lib файл counter_bloc.dart. В нем будет храниться
реализация паттерна BLoC. Первым делом объявим класс BlocEvent, описывающий
событие, которые мы будем отправлять в компонент бизнес-логики CounterBloc.
Для работы приложения необходимы всего два события:
y increment — увеличение счетчика;
y decrement — уменьшение счетчика.
// base_url/3/vanilla_bloc/lib/counter_bloc.dart
import 'dart:async';
/// Событие, которое может принимать блок
enum BlocEvent { increment, decrement }
Далее в этом же файле объявим класс-состояние BlocState, отвечающий за хранение текущего состояния счетчика. Обычно бывает несколько состояний (ожидание,
ошибка и т. д.), но для нашего примера хватит и одного:
/// Состояние блока
final class BlocState {
final int count;
BlocState(this.count);
}
Последним объявим класс CounterBloc, представляющий собой компонент бизнес-логики, который принимает события, изменяет свое состояние и отправляет
измененное состояние в UI:
/// Реализация бизнес-логики в bloc
class CounterBloc {
/// Внутреннее состояние блока
BlocState _state = BlocState(0);
BlocState get state = > _state;
/// Поток для состояний
final _stateController = StreamController<BlocState>.broadcast();
Stream<BlocState> get stream = > _stateController.stream;
/// Поток для событий
final _eventController = StreamController<BlocEvent>.broadcast();
414 Глава 3 Управление состоянием
/// Подписка на события
late StreamSubscription _eventSubscription;
CounterBloc() {
// Подписываемся на события
_eventSubscription = _eventController.stream.listen(
_mapEventToState,
);
}
/// Добавление события в блок
void add(BlocEvent event) {
_eventController.add(event);
}
/// Преобразование события в состояние
void _mapEventToState(BlocEvent event) {
switch (event) {
// Если событие увеличения счетчика
case BlocEvent.increment:
// Увеличиваем счетчик
final count = _state.count + 1;
_saveAndUpdateState(count);
// Если событие уменьшения счетчика
case BlocEvent.decrement:
// уменьшаем счетчик
final count = _state.count - 1;
_saveAndUpdateState(count);
}
}
/// Сохраняем состояние в блоке и передаем его подписчикам
void _saveAndUpdateState(int count) {
// Создаем новое состояние
final newState = BlocState(count);
// Уведомляем подписчиков и передаем новое состояние
_stateController.add(newState);
// Сохраняем новое состояние в блоке
_state = newState;
}
}
// Освобождение ресурсов
void dispose() {
_stateController.close();
_eventController.close();
_eventSubscription.cancel();
}
Настало время реализовать пользовательский интерфейс и подписку посредством виджета StreamBuilder<T> на событие об изменении значения счетчика,
после которого будет запускаться процесс перестроения виджета, отображающего
актуальные данные счетчика. Для этого откройте файл main.dart и добавьте в него
следующий код:
// base_url/3/vanilla_bloc/lib/main.dart
import 'package:flutter/material.dart';
import 'counter_bloc.dart';
void main() = > runApp(MaterialApp(home: _CounterScreen()));
3.3. Паттерны для управления состоянием 415
class _CounterScreen extends StatefulWidget {
@override
_CounterScreenState createState() = > _CounterScreenState();
}
class _CounterScreenState extends State<_CounterScreen> {
final CounterBloc _bloc = CounterBloc();
@override
void dispose() {
// При удалении виджета освобождаем ресурсы
_bloc.dispose();
super.dispose();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
// Используем StreamBuilder для отслеживания изменений
child: StreamBuilder<BlocState>(
// Слушаем поток событий счетчика
stream: _bloc.stream,
// Начальное состояние счетчика
initialData: _bloc.state,
builder: (_, __) {
// Отображаем текущее значение счетчика
// при изменении состояния
return Text(
'${_bloc.state.count}',
style: const TextStyle(fontSize: 48),
);
},
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
// Отправляем событие увеличения счетчика
onPressed: () = > _bloc.add(BlocEvent.increment),
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
// Отправляем событие уменьшения счетчика
onPressed: () = > _bloc.add(BlocEvent.decrement),
child: const Icon(Icons.remove),
),
],
));
}
Главными недостатками BLoC принято считать сложность его изучения начинающими разработчиками и увеличение количества файлов с кодом, то есть разрастание структуры проекта. В своем примере мы все уместили в одну библиотеку
counter_bloc.dart, но, так как в реальных приложениях кода куда больше, каждый
компонент шаблона выносится в отдельный файл, затем они объединяются посредством механизма part of.
416 Глава 3 Управление состоянием
3.3.2. MVP (Model-View-Presenter)
Паттерн Model-View-Presenter позволяет разделить обязанности между компонентами приложения, обеспечивая более чистую, тестируемую и поддерживаемую
структуру кода. В основе его концепции лежит разделение кода на три основные
части: Model, View и Presenter, каждая из которых имеет четкие обязанности.
y View отвечает за отображение данных и взаимодействие с пользователем.
Не содержит бизнес-логики. Отправляет событие в Presenter для изменения
Model.
y Presenter — посредник между Model и View. Получает данные из Model и передает их View для отображения. Обрабатывает события пользовательского
интерфейса и обновляет Model.
y Model отвечает за управление данными, включая бизнес-логику и взаимодействие с источниками данных. Не зависит от пользовательского интерфейса.
Принцип работы MVP можно представить в виде блок-схемы (рис. 3.7).
Рис. 3.7. Принципиальная схема паттерна MVP
При первом знакомстве с MVP может показаться, что он очень похож на BLoC,
но это не так. Рассмотрим на примере переключателя, какие шаги работы шаблона
представлены на рис. 3.7.
1. Пользователь взаимодействует с UI (нажимает на выключенный переключатель — switch).
2. View отправляет это событие в Presenter.
3. Presenter делает запрос в Model, оповещая его о том, что пользователь нажал
на переключатель.
4. Модель изменяет состояние переключателя и уведомляет Presenter о том,
что состояние переключателя изменилось.
5. Presenter уведомляет View об изменении состояния переключателя.
6. View перестраивает интерфейс.
3.3. Паттерны для управления состоянием 417
Для более детального погружения в реализацию рассматриваемого паттерна
перепишем на MVP пример из предыдущего раздела. Создайте новое приложение
mvp, удалив файлы из каталога test. Убедитесь, что в папку lib были добавлены следующие файлы:
lib/
├──
├──
├──
└──
model.dart
presenter.dart
view.dart
main.dart
Начнем с объявления модели. Для этого откройте файл model.dart и добавьте
в него следующий код:
/* base_url/3/mvp/lib/model.dart
*/
/// Модель счетчика
/// хранит текущее значение счетчика
class Model {
int value;
}
Model(this.value);
На следующем шаге в файле view.dart объявим интерфейс View, который должен будет реализовать пользовательский виджет, запуская в момент его вызова
свое перестроение:
// base_url/3/mvp/lib/view.dart
/// Представление (View). Интерфейс для View-компонентов.
/// Уведомляет UI-компонент об изменении значения счетчика.
abstract interface class View {
void updateCounter(int value);
}
Перейдем к последнему компоненту паттерна MVP — Presenter. В файле
объявим одноименный класс, который будет принимать на вход
конструктора ссылку на View и экземпляр Model. Так как не во всех случаях из
одного места могут быть переданы сразу два аргумента, добавим сеттер, с помощью
которого можно передать ссылку на View. А ответственность за работу с моделью
и уведомление View о новом значении счетчика возложим на методы increment()
и decrement():
presenter.dart
// base_url/3/mvp/lib/presenter.dart
import 'model.dart';
import 'view.dart';
/// Увеличивает или уменьшает значение счетчика в модели.
/// Уведомляет View об изменении значения счетчика.
class Presenter {
View? _view;
final Model _counter;
Presenter({View? view, required Model model})
: _view = view,
_counter = model;
int get countValue = > _counter.value;
set view (View? view) = > _view = view;
418 Глава 3 Управление состоянием
void increment() {
_counter.value++;
_view?.updateCounter(_counter.value);
}
}
void decrement() {
_counter.value--;
_view?.updateCounter(_counter.value);
}
Настало время реализовать пользовательский интерфейс и склеить разрозненные
компоненты шаблона в одно целое. Перейдите в файл main.dart и замените в нем
код на следующий:
// base_url/3/mvp/lib/main.dart
// Поскольку в material.dart входит компонет View,
// скроем его из функционала импортируемой библиотеки
import 'package:flutter/material.dart' hide View;
import 'presenter.dart';
import 'view.dart';
import 'model.dart';
void main() = > runApp(
MaterialApp(
home: CounterPage(
presenter: Presenter(model: Model(0)),
),
),
);
class CounterPage extends StatefulWidget {
final Presenter _presenter;
const CounterPage({super.key, required Presenter presenter})
: _presenter = presenter;
}
@override
State<CounterPage> createState() = > _CounterPageState();
// Реализуем интерфейс View
class _CounterPageState extends State<CounterPage> implements View {
/// Ссылка на presenter
late final Presenter _presenter;
/// Текущее значение счетчика
late int _counterValue;
@override
void initState() {
super.initState();
_presenter = widget._presenter;
/// получаем начальное значение счетчика
_counterValue = _presenter.countValue;
/// передаем presenter ссылку на текущий экземпляр View
_presenter.view = this;
}
// Реализуем интерфейс View
@override
void updateCounter(int value) {
3.3. Паттерны для управления состоянием 419
}
}
// Обновляем значение счетчика виджета
// при получении события из View
setState(() {
_counterValue = value;
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text(
_counterValue.toString(),
style: const TextStyle(fontSize: 48),
)),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
// Отправляем событие увеличения счетчика
onPressed: () = > _presenter.increment(),
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
// Отправляем событие уменьшения счетчика
onPressed: () = > _presenter.decrement(),
child: const Icon(Icons.remove),
),
],
),
);
}
Главный недостаток рассматриваемого шаблона управления состоянием
приложения заключается в том, что при несоблюдении в коде приложения принципа единой ответственности Presenter может превратиться в «божественный
объект».
3.3.3. MVC (Model-View-Controller)
Паттерн Model-View-Controller, как и рассмотренный ранее MVP, позволяет разделить обязанности между компонентами приложения, обеспечивая более чистую,
тестируемую и поддерживаемую структуру кода. Различие же заключается в том,
что вся работа шаблона стартует с компонента Controller, который принимает
события от пользователя и отправляет запрос на изменение данных в Model. Тот,
в свою очередь, уведомляет View (пользовательский интерфейс) о том, что данные изменились, запуская тем самым процесс обновления состояния интерфейса
пользователя.
Принцип работы MVC можно представить в виде блок-схемы (рис. 3.8).
Из нее вырисовывается следующий алгоритм работы рассматриваемого шаблона.
1. Пользователь нажимает кнопку.
2. Controller делает запрос в Model.
420 Глава 3 Управление состоянием
3. Model изменяет состояние и уведомляет об этом View.
4. View перестраивает интерфейс. Во Flutter роль View можно полностью возложить на StatefulWidget.
Рис. 3.8. Принципиальная схема паттерна MVC
Для более детального погружения в реализацию рассматриваемого паттерна
перепишем на MVC пример из предыдущего раздела. Создайте новое приложение
mvc, удалив файлы из каталога test, и убедитесь в том, что в папку lib были добавлены
следующие файлы:
lib/
├── model.dart
├── controller.dart
└── main.dart
Начнем с объявления модели. Для этого откройте файл model.dart и добавьте
в него следующий код:
// base_url/3/mvc/lib/model.dart
// Модель, управляющая данными счетчика
class Model {
int _counter = 0;
// Геттер для получения текущего значения счетчика
int get counter = > _counter;
// Метод для увеличения значения счетчика
void increment() {
_counter++;
}
}
// Метод для уменьшения значения счетчика
void decrement() {
_counter--;
}
3.3. Паттерны для управления состоянием 421
На следующем шаге в файле controller.dart объявим одноименный класс, который будет принимать на вход конструктора экземпляр Model. Ответственность
за работу с моделью возложим на его методы increment() и decrement():
// base_url/3/mvc/lib/controller.dart
// Контроллер, управляющий логикой счетчика
import 'model.dart';
class Controller {
final Model _model;
Controller(this._model);
// Геттер для получения текущего значения счетчика из модели
int get counter = > _model.counter;
// Метод для увеличения значения счетчика
void increment() {
_model.increment();
}
}
// Метод для уменьшения значения счетчика
void decrement() {
_model.decrement();
}
Теперь перейдите в файл main.dart и замените в нем код на тот, что приведен
далее:
// base_url/3/mvc/lib/main.dart
import 'package:flutter/material.dart';
import 'controller.dart';
import 'model.dart';
void main() = > runApp(
MaterialApp(
home: CounterPage(
controller: Controller(Model()),
),
),
);
// Виджет, отображающий интерфейс счетчика
class CounterPage extends StatefulWidget {
final Controller _controller;
const CounterPage({super.key, required Controller controller})
: _controller = controller;
}
@override
State<CounterPage> createState() = > _CounterPageState();
// Состояние виджета CounterPage, реализующее обновление UI
class _CounterPageState extends State<CounterPage> {
late final Controller _controller;
@override
void initState() {
super.initState();
422 Глава 3 Управление состоянием
}
}
_controller = widget._controller;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text(
_controller.counter.toString(),
style: const TextStyle(fontSize: 48),
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
// Отправляем событие увеличения счетчика
onPressed: () {
setState(() {
_controller.increment();
});
},
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
// Отправляем событие уменьшения счетчика
onPressed: () {
setState(() {
_controller.decrement();
});
},
child: const Icon(Icons.remove),
),
],
),
);
}
Главный недостаток MVC заключается в том, что даже в случае идеальной
реализации его компоненты будут зависеть друг от друга.
3.3.4. MVVM (Model-View-ViewModel)
Как и в случае с рассмотренными ранее шаблонами управления состоянием приложения, паттерн MVVM обеспечивает разделение интерфейса, бизнес-логики
и данных, что упрощает тестирование и поддержку приложения, делая его более
модульным и гибким. Несмотря на некоторое сходство с MVP (роль Presenter
выполняет ViewModel), MVVM лишен его главного недостатка. Этого удалось
добиться за счет следующего:
y ViewModel не содержит ссылок на View, поэтому не имеет возможности напрямую обновлять данные на пользовательском интерфейсе. Его основная
обязанность — обработать данные из Model и подготовить их для отображения в View;
y между View (представлением) и ViewModel (моделью представления) существует связь «один ко многим»;
3.3. Паттерны для управления состоянием 423
y взаимодействие между View и ViewModel организуется через биндинги (data
binding). Это уменьшает объем кода и облегчает синхронизацию состояния.
Принцип работы MVVM можно представить в виде блок-схемы (рис. 3.9).
Рис. 3.9. Принципиальная схема паттерна MVVM
Из нее вырисовывается следующий алгоритм работы рассматриваемого шаблона.
1. View отправляет в ViewModel события пользовательского интерфейса — нажатие кнопки, ввод текста и т. д.
2. ViewModel взаимодействует с Model — читает данные и записывает их обратно.
3. Model генерирует для ViewModel событие об изменении данных.
4. ViewModel передает данные для отображения в View или запускает команду
на обновление интерфейса.
5. Когда данные в ViewModel изменяются, он генерирует об этом событие
для View, запуская процесс обновления данных в пользовательском интерфейсе.
Для более детального погружения в реализацию рассматриваемого паттерна
перепишем на MVVM пример из предыдущего раздела. Создайте новое приложение mvvm, удалив файлы из каталога test, и убедитесь в том, что в папку lib были
добавлены следующие файлы:
lib/
├──
├──
├──
└──
model.dart
view.dart
view_model.dart
main.dart
Начнем с объявления модели. Для этого откройте файл model.dart и добавьте
в него следующий код:
// base_url/3/mvvm/lib/model.dart
// Модель, управляющая данными счетчика
class Model {
int _counter;
424 Глава 3 Управление состоянием
Model(this._counter);
// Получение текущего значения счетчика
int get counter = > _counter;
// Увеличение значения счетчика
void increment() {
_counter++;
}
}
// Уменьшение значения счетчика
void decrement() {
_counter--;
}
На следующем шаге в файле view_model.dart объявим одноименный класс, который будет принимать на вход конструктора экземпляр Model. Ответственность
за работу с моделью возложим на его методы increment() и decrement(). А для
оповещения слоя View будем наследовать от класса ChangeNotifier:
// base_url/3/mvvm/lib/view_model.dart
import 'package:flutter/material.dart';
import 'model.dart';
class ViewModel extends ChangeNotifier {
final Model _counterModel;
ViewModel(this._counterModel);
// Получение текущего значения счетчика из модели
int get counter = > _counterModel.counter;
// Метод для увеличения счетчика
void increment() {
_counterModel.increment();
// Уведомление слушателей об изменении состояния
notifyListeners();
}
}
// Метод для уменьшения счетчика
void decrement() {
_counterModel.decrement();
// Уведомление слушателей об изменении состояния
notifyListeners();
}
Перейдем к последнему компоненту паттерна MVVM — View. В файле view.dart
объявим виджет CounterView, на вход конструктора которого будет передаваться
экземпляр ViewModel. Для получения событий от ViewModel и запуска перерисовки
данных на экране воспользуемся виджетом ListenableBuilder:
// base_url/3/mvvm/lib/view.dart
import 'package:flutter/material.dart';
import 'view_model.dart';
class CounterView extends StatelessWidget {
// У View есть привязка к ViewModel, но нет доступа к Model
final ViewModel viewModel;
3.3. Паттерны для управления состоянием 425
const CounterView({super.key, required this.viewModel});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
// Используем AnimatedBuilder
// для отслеживания изменений
child: ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
return Text(
'${viewModel.counter}',
style: const TextStyle(fontSize: 48),
);
},
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
// Отправляем событие увеличения счетчика
onPressed: viewModel.increment,
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
// Отправляем событие уменьшения счетчика
onPressed: viewModel.decrement,
child: const Icon(Icons.remove),
),
],
));
}
Теперь перейдите в файл main.dart и замените в нем код на такой:
// base_url/3/mvvm/lib/main.dart
import 'package:flutter/material.dart';
import 'view.dart';
import 'view_model.dart';
import 'model.dart';
void main() = > runApp(
MaterialApp(
home: CounterView(
viewModel: ViewModel(
Model(0),
),
),
),
);
Главный недостаток MVVM заключается в том, что его использование может
вызывать неудобства в проектах с большой кодовой базой. Так, например, при
сложной организации биндинга между View и ViewModel могут возникать трудности при отладке приложения.
426 Глава 3 Управление состоянием
3.3.5. Сравнение MVP, MVC и MVVM
Давайте подведем некоторую черту, сравнив в табл. 3.1 ключевые различия рассмотренных шаблонов семейства MV*.
В целом данные паттерны не пользуются большой популярностью у Flutterразработчиков. Но знать об их существовании и понимать, как они работают,
не будет лишним. Так, например, в основе работы виджета TextFormField лежит
концепция MVC.
Таблица 3.1. Сравнение MVP, MVC и MVVM
Шаблон Свойства
MVP
• View и Presenter взаимодействуют напрямую.
• Presenter полностью контролирует обновление View.
• View не содержит логики, она только отображает данные, предоставленные Presenter
MVC
• View и Controller взаимодействуют напрямую.
• Controller обрабатывает действия пользователя и обновляет Model.
• Model уведомляет View об изменениях, запуская процесс обновления отображаемых данных
MVVM
• ViewModel не содержит ссылок на View. Его основная обязанность — обработать данные из Model
и подготовить их для отображения в View.
• Между View (представлением) и ViewModel (моделью представления) существует связь «один ко многим».
• Взаимодействие между View и ViewModel организуется через биндинги (data binding)
Проект: игра «Тетрис» v. 3. Переход на ChangeNotifier
Вот и пришла пора выпилить из нашей игры работу механизма перестройки интерфейса setState и заменить его инструментом ChangeNotifier. Первым делом
внесем небольшие изменения в класс Board, добавив обратный вызов функции
updateBlock в метод для обработки нажатий клавиш. Это позволит моментально
менять положение фигуры на игровом поле:
// base_url/3/tetris/lib/src/board.dart
import 'blocks/blocks.dart';
class Board {
// поля класса и конструктор без изменений
// обработка нажатий клавиш по их ASCII-коду
void keyboardEventHandler(int key) {
var x = currentBlock.x;
var y = currentBlock.y;
switch (key) {
case 119: // W — поворот фигуры
rotateBlock();
case 97: // A — влево
if (!isFilledBlock(x - 1, y)) {
moveBlock(x - 1, y);
}
case 115: // S — вниз
if (!isFilledBlock(x, y + 1)) {
Проект: игра «Тетрис» v. 3. Переход на ChangeNotifier 427
}
}
}
moveBlock(x, y + 1);
}
case 100: // D — вправо
if (!isFilledBlock(x + 1, y)) {
moveBlock(x + 1, y);
}
// Добавляем обратный вызов в Game,
// так как необходимо моментально обновить текущую фигуру
updateBlock(currentBlock);
// остальной код без изменений
С классом Game придется повозиться подольше и внести более кардинальные
изменения. Начнем с того, что он должен будет наследовать от ChangeNotifier. Это
даст возможность подписаться на изменения экземпляра класса Game и уже с учетом
текущего состояния игры отрисовывать на экране необходимые виджеты (виджет с отрисовкой игрового процесса — TetrisGame или информирующий о завершении игры).
Далее добавим вызов метода notifyListeners(), отвечающего за уведомление подписчиков об изменении состояния класса, в методы updateBlock, updateScore, gameOver
и nextStep. Аналогичные изменения коснутся и метода start(). Поскольку мы перешли
на использование ChangeNotifier, необходимость в обратном вызове onUpdate отпадает.
Добавим также метод для перезапуска игры restart, в котором будет обнуляться
счет и устанавливаться значение false переменной _isGameOver:
// base_url/3/tetris/lib/src/game.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'blocks/blocks.dart';
import 'board.dart';
// Наследуем класс от ChangeNotifier, чтобы иметь
// возможность уведомлять об изменениях внутри класса
final class Game extends ChangeNotifier {
late Board board; // Сделаем открытым
late Block currentBlock;
late Block nextBlock;
bool _isGameOver = false;
int score = 0;
// Обратный вызов при окончании игры
final Function(String scores) onGameOver;
Game({required this.onGameOver}) {
currentBlock = getNewRandomBlock();
nextBlock = getNewRandomBlock();
}
board = Board(
currentBlock: currentBlock,
newBlockFunc: newBlock,
updateScore: updateScore,
updateBlock: updateBlock,
gameOver: gameOver,
);
428 Глава 3 Управление состоянием
// Метод обновления блока фигуры
void updateBlock(Block block) {
currentBlock = block;
// Уведомляем слушателей об изменениях в блоке,
// когда пользователь нажимает кнопку
notifyListeners();
}
// Метод обновления счета
void updateScore() {
score += 10;
// Уведомляем слушателей об изменениях в счете,
// чтобы иметь возможность обновлять счет на экране
notifyListeners();
}
// Метод генерации новой фигуры
Block newBlock() {
currentBlock = nextBlock;
nextBlock = getNewRandomBlock();
return currentBlock;
}
// Метод запуска игры
Future<void> start() async {
// Запускаем игровой цикл
while (!_isGameOver) {
nextStep();
await Future.delayed(const Duration(milliseconds: 500));
}
}
Future<void> restart() async {
_isGameOver = false;
score = 0;
board = Board(
currentBlock: currentBlock,
newBlockFunc: newBlock,
updateScore: updateScore,
updateBlock: updateBlock,
gameOver: gameOver,
);
start();
}
// Метод вывода текущего счета в игре
void printScore() {}
bool get isGameOver = > _isGameOver;
void gameOver() {
_isGameOver = true;
// Уведомляем слушателей об окончании игры
notifyListeners();
}
// Метод обработки шага игрового цикла
void nextStep() {
var x = currentBlock.x;
var y = currentBlock.y;
Проект: игра «Тетрис» v. 3. Переход на ChangeNotifier 429
if (!board.isFilledBlock(x, y + 1)) {
board.moveBlock(x, y + 1);
} else {
board.clearLine();
board.savePresentBoardToCpy();
board.newBlock();
board.drawBoard();
}
}
}
// Уведомляем слушателей после каждого шага
notifyListeners();
Для отображения набранных пользователем очков и кнопки Перезапустить игру
создадим виджет GameScores, принимающий на вход конструктора функцию обратного
вызова onRestart. Для этого создайте в папке lib файл game_scores.dart и добавьте
в него следующий код:
// base_url/3/tetris/lib/src/game_scores.dart
import 'package:flutter/material.dart';
// Виджет для отображения количества набранных
// очков и кнопки для перезапуска игры
// после завершения игры
class GameScores extends StatelessWidget {
// Количество очков
final int score;
// Обработчик события перезапуска игры
final VoidCallback onRestart;
const GameScores({
super.key,
required this.score,
required this.onRestart,
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Заработанные очки: $score',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: onRestart,
child: Text('Перезапустить игру'),
),
],
);
}
Следующим под каток рефакторинга отправится класс TetrisGame. Из его метода
initState и процесса обработки нажатий клавиш (см. виджет Focus) уберем вызов
setState.
А в методе build обернем виджет Focus в ListenableBuilder, передав его
430 Глава 3 Управление состоянием
аргументу listenable экземпляр класса Game, а аргументу builder — анонимную
функцию, отвечающую за перезапуск игры после ее завершения:
// base_url/3/tetris/lib/src/tetris_game.dart
import 'dart:math';
import
import
import
import
import
'package:flutter/material.dart';
'package:flutter/services.dart';
'package:tetris/game_scores.dart';
'package:tetris/src/board.dart';
'package:tetris/src/game.dart';
/// Реализация игры "Тетрис"
class TetrisGame extends StatefulWidget {
const TetrisGame({super.key});
}
@override
State<TetrisGame> createState() = > _TetrisGameState();
class _TetrisGameState extends State<TetrisGame> {
late Game game;
@override
void initState() {
super.initState();
game = Game(onGameOver: (scores) {});
game.start();
}
@override
Widget build(BuildContext context) {
// Добавляем слушатель для обновления состояния виджета
return ListenableBuilder(
// Передаем игру в качестве объекта, реализующего Listenable
listenable: game,
// Перестраиваем виджет при изменении состояния игры
builder: (context, _) {
if (game.isGameOver) {
return Center(
child: GameScores(
score: game.score,
onRestart: () {
// Перезапускаем игру
game.restart();
},
),
);
}
return Focus(
autofocus: true,
onKeyEvent: (FocusNode node, KeyEvent event) {
// Обработка нажатий клавиш
// Обрабатываем как нажатие, так и удержание клавиши
if (event is KeyDownEvent || event is KeyRepeatEvent) {
game.board.keyboardEventHandler(event.logicalKey.keyId);
return KeyEventResult.handled;
}
// Если событие не обработано, возвращаем ignored
return KeyEventResult.ignored;
},
Проект: игра «Тетрис» v. 3. Переход на ChangeNotifier 431
);
},
}
}
child: Align(
// код без изменений
),
);
Если вы все сделали правильно, то после завершения игры можете посмотреть,
сколько очков заработали, и начать новую игру (рис. 3.10).
Рис. 3.10. Пример работы приложения
Задания на модификацию проекта
В следующий раз мы добавим в приложение элементы навигации. А пока можете
выполнить следующие задания по внесению изменений в существующую кодовую
базу, используя знания, полученные в ходе этой главы.
1. Добавьте команду завершения текущей игровой сессии.
2. Добавьте команду установки игры на паузу и снятия с нее.
3. Добавьте игроку возможность просматривать набранные очки в процессе
самой игры, а не только по ее завершении.
4. Введите понятие уровней и при каждом достижении порога (допустим,
с шагом 50 очков) переводите игрока на следующий уровень, увеличивая
скорость игры.
5. Добавьте меню с выбором уровня сложности (скорости падения блоков)
перед стартом игры.
432 Глава 3 Управление состоянием
6. Доработайте функционал таким образом, чтобы рядом с игровым полем отображалась фигура, которая появится следующей.
7. Поскольку мы уведомляем слушателей при обновлении очков, попробуйте
вывести количество набранных очков в реальном времени.
Резюме
В этой главе мы рассмотрели различные варианты управления состоянием приложения. Не так давно (в конце 2024 года) команда Flutter официально «предала»
шаблон BLoC и объявила о том, что для новых приложений следует брать MVVM.
Сложно сказать, плохо это или хорошо. Да и вообще поднятие темы выбора паттерна или пакета для управления состоянием приложения во Flutter-сообществе
способно привести не только к взаимным оскорблениям, но и к угрозам «вычислить
по IP». Другими словами, это настолько сакральная область, что лучше не пытаться
навязывать свое мнение, а постараться подстроиться под тот стек, который будет
в компании либо на работе вашей мечты. А что касается проектов для души, тут вы
вольны выбирать что угодно: BloC, Provider, GetX и т. д.
Вопросы для самопроверки
1. Что такое состояние приложения? Какие типы состояний вы знаете?
2. Чем отличается императивный стиль управления состоянием от декларативного?
3. Какие инструменты для работы с состоянием приложения предоставляет
Flutter?
4. Какие паттерны управления состоянием вы знаете? В чем отличие BLoC от
MVVM?
5. Чем отличаются друг от друга паттерны управления состоянием приложения
MVP и MVC?
6. Как можно реализовать паттерн BLoC средствами самого Flutter?
7. Какой из паттернов и пакетов управления состоянием лучше всего взять для
нового проекта?
Глава 4
НАВИГАЦИЯ
Как часто вы мысленно проклинали приложение или сайт за неудобные или
неоднозначные элементы навигации между страницами (экранами), вспоминая
при этом разработчиков и то, откуда у них растут руки? Так вот, чтобы не почувствовать себя на их месте, желательно не только постигать азы UI/UX, но и иметь
представление о механизмах навигации, которые предоставляет используемый
вами фреймворк.
Навигация во Flutter основана на управлении стеком. За это отвечают класс
Navigator и маршруты (routes). Понимание того, как работает этот стек, важно для
создания отзывчивых и интуитивно понятных приложений.
4.1. Основные концепции навигации во Flutter
Flutter предоставляет разработчикам несколько вариантов реализации навигации.
y Императивная навигация, или Navigator 1.0. До Flutter 1.22 это был основной вид навигации.
y Декларативная навигация, или Navigator 2.0. Данный тип навигации был
добавлен во Flutter 1.22 и преследовал цель — предоставить разработчикам
более гибкие варианты навигации в приложениях и переход на более простое, декларативное описание сценариев навигации. Однако, как говорится, благие намерения не всегда приводят к желаемому результату. Даже
опытные программисты, пережившие множество сложных ситуаций, при
упоминании о Navigator 2.0 испытывают негативные эмоции, связанные
с прошлым опытом. А все из-за того, что концепция Navigator 2.0 не так
очевидна, как кажется.
4.1.1. Разница между императивной и декларативной навигацией
Чтобы лучше понять, чем отличается императивный подход в навигации от декларативного, попробуем поставить себя на место системы навигации и ответить
на ряд вопросов, которые возникают в процессе работы приложения при переходе
между его экранами (маршрутами).
В императивном подходе разработчик напрямую управляет навигацией с по
мощью вызовов методов. Он указывает приложению, как должно произойти
434 Глава 4 Навигация
изменение, фокусируясь на последовательности действий. В связи с этим перед
императивной системой навигации встают следующие вопросы.
y Как выполнить переход? То есть как программно перейти с текущего экрана
на экран настроек при нажатии кнопки?
y Что нужно сделать, чтобы изменить состояние навигации? То есть какие
методы навигации вызвать, чтобы открыть новый экран или вернуться назад?
y Какой метод вызвать для достижения желаемого результата? То есть что
нужно использовать в данном сценарии, Navigator.push() или Navigator.pop()?
В декларативном подходе разработчик описывает, что должно быть отображено,
основываясь на состоянии приложения. По сути, он фокусируется на конечном
результате, а не на шагах для его достижения. Значит, и перед декларативной системой навигации встают совершенно другие вопросы.
y Какое состояние должно быть у приложения сейчас? То есть какие экраны
должны быть отображены в данный момент, если исходить из текущего состояния приложения?
y Какое представление должно быть у пользователя в зависимости от состояния? Например, если пользователь авторизован, должен отображаться
экран профиля или экран входа?
y Как описать навигационное состояние как функцию от состояния приложения? Другими словами, как обновить список страниц в стеке навигации
в соответствии с изменениями в состоянии приложения?
Если сравнивать вопросы, на которые отвечают императивный и декларативный
подходы, то можно получить табл. 4.1.
Таблица 4.1. Сравнение вопросов, на которые отвечают подходы
Подход
Императивный
Декларативный
Вопросы
Как выполнить переход?
Какие методы вызвать?
Каково текущее состояние?
Что должно быть отображено?
Активность
Действия и процедуры
Состояние и представление
Таким образом, императивный и декларативный подходы к навигации во Flutter
отвечают на разные типы вопросов.
y Императивный подход сосредоточен на действиях и отвечает на вопросы
о том, как выполнить переходы и какие методы использовать. Допустим, вам
необходимо перейти с одного экрана на другой. Это можно сформулировать
так: «Как выполнить переход на новый экран?» Ответом на вопрос будет
вызов метода Navigator.push() с нужным маршрутом.
y Декларативный подход ориентирован на состояние и отвечает на вопросы
о том, что должно быть отображено в зависимости от текущего состояния приложения. Например, перед вами стоит задача: если пользователь авторизован,
показать экран профиля, если нет — экран входа. Ее формулировка в виде
вопроса может звучать так: «Какое представление должно быть отображено
4.1. Основные концепции навигации во Flutter 435
при текущем состоянии пользователя в приложении?» А ответ — обновление
состояния приложения таким образом, чтобы в стеке навигации отображался
соответствующий экран: профиль пользователя или вход в приложение.
Понимание того, на какие вопросы отвечает каждый подход, поможет вам выбрать наиболее подходящий для вашего приложения и эффективно использовать
его в разработке.
4.1.2. Императивная навигация (Navigator 1.0)
Императивный подход за счет прямого управления стеком маршрутов позволяет
быстро и просто реализовать навигацию в небольших приложениях. Это происходит
посредством явных вызовов методов push и pop класса Navigator.
Для большего удобства восприятия представьте, что навигация — это колода
карт. Каждый раз при переходе на новый экран вы добавляете его карту (маршрут)
на вершину колоды, а когда возвращаетесь назад на предыдущий экран, убираете
ее из колоды. Такое сравнение позволяет довольно просто визуализировать работу
императивной навигации во Flutter.
1. Добавление экрана — кладем карту на вершину колоды (рис. 4.1).
2. Возврат к предыдущему экрану — снимаем верхнюю карту (рис. 4.2).
Как видно из рисунков, навигация во Flutter работает в соответствии с принципом LIFO (Last In, First Out — «последним пришел, первым вышел»).
Далее приведен пример кода чистого императивного подхода:
// base_url/4/imperative_navigation/lib/main.dart
void main() { runApp(MaterialApp(home: _HomeScreen()));}
/// Основной экран приложения
class _HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Главный экран'),
),
body: Center(
child: ElevatedButton(
child: const Text('Перейти к деталям'),
onPressed: () {
// Императивный переход на новый экран
Navigator.push(
context,
MaterialPageRoute(builder: (context) = > _DetailScreen()),
);
},
),
),
);
}
}
/// Экран деталей
class _DetailScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
436 Глава 4 Навигация
}
return Scaffold(
appBar: AppBar(
title: const Text('Экран деталей'),
),
body: Center(
child: ElevatedButton(
child: const Text('Вернуться назад'),
onPressed: () {
// Императивный возврат к предыдущему экрану
Navigator.pop(context);
},
),
),
);
}
Рис. 4.1. Добавление маршрута в стек навигации
Рис. 4.2. Удаление маршрута из стека навигации
4.1. Основные концепции навигации во Flutter 437
В представленном примере новое окно открывается и закрывается за счет
методов push и pop класса Navigator. Все довольно просто и понятно. Обратите
внимание: мы «пушим» не экран, а экземпляр класса MaterialPageRoute, который,
в свою очередь, с помощью метода builder создает экран. Пока не задумывайтесь
о том, что это за класс и каковы его свойства. Он будет рассмотрен более детально
в одном из следующих разделов главы.
4.1.3. Декларативная навигация (Navigator 2.0)
С выпуском Flutter 1.22 был представлен Navigator 2.0, который позволил описывать
навигационное состояние как часть состояния приложения и по задумке разработчиков Flutter должен был облегчить реализацию сложных сценариев навигации,
таких как глубокие ссылки (deep links) и веб-навигация. Для этого в состав фреймворка добавили классы Router, RouteInformationParser, RouteInformationProvider
и RouterDelegate.
Важно отметить, что вы не найдете во Flutter SDK класса Navigator 2.0, ведь изменился не сам класс, а концепция в навигации и подход к ее реализации.
Далее рассмотрим пример декларативной навигации.
// base_url/4/declarative_navigation/lib/main.dart
// [_isAuthenticated] фейковое состояние авторизации
// Может быть [true] или [false] в реальной жизни
const bool _isAuthenticated = false;
void main() = > runApp(const _MyApp());
class _MyApp extends StatelessWidget {
const _MyApp();
@override
Widget build(BuildContext context) {
// Добавляем условие:
// если [isAuthenticated] = = true, то отображается экран _HomeScreen
// если [isAuthenticated] = = false, то отображается экран _AuthScreen
// Таким образом, приложение будет декларативно
// отображать экраны в зависимости от состояния
// [isAuthenticated].
return MaterialApp(
home: _isAuthenticated
? _HomeScreen()
: const _AuthScreen());
}
}
// Домашний экран
class _HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Главный экран'),
),
body: const Center(child: Text('Главный экран')),
);
}
}
438 Глава 4 Навигация
// Экран авторизации
// [_isAuthenticated] состояние авторизации
class _AuthScreen extends StatelessWidget {
const _AuthScreen();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Экран авторизации'),
),
body: const Center(child: Text('Экран авторизации')),
);
}
В данном примере мы говорим приложению не куда идти, а что отображать (как
менять состояние навигации) в зависимости от статуса авторизации пользователя.
Для разнообразия попробуйте изменить значение переменной _isAuthenticated
с false на true.
4.1.4. Направление навигации
В зависимости от приложения и его архитектуры выделяют три вида навигации:
боковую, прямую и обратную.
Боковая навигация представляет собой переход между экранами на одном
уровне иерархии. Главный элемент навигации в приложении должен предоставлять
доступ ко всем разделам на верхнем уровне иерархии. На рис. 4.3 приведен пример
структуры музыкального приложения, когда боковая навигация позволяет перемещаться между экранами верхнего уровня.
Рис. 4.3. Боковая навигация
Прямая навигация позволяет переходить между экранами в рамках одного уровня иерархии, между этапами процесса или между приложениями. В рамках прямой
навигации применяются различные элементы перемещения: кнопки, ссылки, поиск,
а также карточки, списки и изображения. На рис. 4.4 приведен пример структуры
4.1. Основные концепции навигации во Flutter 439
музыкального приложения, где пользователи могут быстро найти нужную песню
двумя способами.
1. Перейти к альбому, а затем выбрать в нем песню.
2. Воспользоваться поиском и сразу перейти к нужной композиции, минуя
экраны с информацией об альбоме и библиотеке.
Рис. 4.4. Прямая навигация
Обратная навигация дает возможность перемещаться по экранам в определенном порядке: хронологическом (внутри одного приложения или между разными
приложениями) или иерархическом (внутри одного приложения). Платформа
устанавливает четкие правила обратной навигации в приложении.
На рис. 4.5 приведен пример структуры музыкального приложения, в котором
существуют два способа вернуться на экран с песней.
1. Перейти вверх по иерархии к родительской композиции, в данном случае
к альбому, в котором она находится.
2. Вернуться в хронологическом порядке на экран результатов поиска, но только
если пользователь перешел на песню с этого экрана.
Рис. 4.5. Обратная навигация
440 Глава 4 Навигация
4.2. Основные элементы навигации во Flutter
К основным элементам навигации, доступным со времен Navigator 1.0, можно отнести три класса (виджета): Navigator, Route и NavigatorObserver. Перейдем к их
детальному рассмотрению.
4.2.1. Класс Navigator
За управление навигацией (переходами) между экранами и стеком маршрутов отвечает класс Navigator. Он позволяет добавлять (push), удалять (pop) и заменять
(replace) страницы в стеке, обеспечивая плавный анимированный переход между
страницами. Далее мы рассмотрим его основные методы и примеры их использования.
Navigator.push предназначен для добавления нового маршрута в стек. Он принимает BuildContext и объект типа Route:
static Future<T?> push<T extends Object?>(
BuildContext context,
Route<T> route,
) {
return Navigator.of(context).push(route);
}
Далее приведен пример его использования в коде. Чаще всего он располагается
в лямбда-функции обработки нажатия какой-нибудь кнопки:
Navigator.push(
context,
MaterialPageRoute(builder: (context) = > NewScreen()),
);
Navigator.pop применяется для удаления верхнего маршрута из стека. Если
в стеке больше нет маршрутов, приложение будет закрыто:
static void pop<T extends Object?>(
BuildContext context, [
T? result,
]) {
Navigator.of(context).pop<T>(result);
}
Как видно из сигнатуры, при вызове этого метода на его вход подается экземпляр BuildContext:
Navigator.pop(context);
Navigator.pushReplacement используется для навигации к новому экрану, заменяя текущий маршрут в стеке навигатора. В отличие от Navigator.push, который
добавляет новый маршрут поверх текущего, pushReplacement удаляет текущий
маршрут, чтобы освободить место для нового. Это полезно, когда вам нужно
перейти на новый экран и не позволить пользователю вернуться на предыдущий
с помощью кнопки Назад:
static Future<T?> pushReplacement<T extends Object?, TO extends Object?>(
BuildContext context,
Route<T> newRoute, {
4.2. Основные элементы навигации во Flutter 441
TO? result,
}) {
return Navigator.of(context).pushReplacement<T, TO>(
newRoute,
result: result,
);
}
Вызов данного метода незначительно отличается от вызова Navigator.push:
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) = > const HomeScreen()),
);
Navigator.pushAndRemoveUntil позволяет добавить новый маршрут в стек
навигатора и удалить все предыдущие маршруты, пока не будет выполнено определенное условие (предикат типа RoutePredicate). Это полезно, когда необходимо
сбросить стек навигации и сделать так, чтобы пользователь не мог вернуться на
предыдущие экраны, например, после успешной авторизации.
RoutePredicate — функция с обратным вызовом, которая возвращает текущий маршрут и передает в вызывающую функцию результат в виде логического
булева значения. До тех пор пока она возвращает false , все маршруты будут
удаляться:
typedef RoutePredicate = bool Function(Route<dynamic> route);
Далее приведена сигнатура метода Navigator.pushAndRemoveUntil:
static Future<T?> pushAndRemoveUntil<T extends Object?>(
BuildContext context,
Route<T> newRoute,
RoutePredicate predicate,
) {
return Navigator.of(context).pushAndRemoveUntil<T>(
newRoute,
predicate,
);
}
Рассмотрим несколько примеров использования данного метода навигации.
Установка предиката типа (route) => route.settings.name == 'ProfilePage' говорит
о том, что мы хотим оставить в стеке только маршрут 'ProfilePage', все остальные
маршруты будут удалены:
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) = > const HomeScreen()),
(route) = > route.settings.name = = 'ProfilePage',
);
Если же будет установлен предикат (route) = > false, все маршруты будут удалены, так как он всегда возвращает false:
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) = > HomeScreen()),
(route) = > false,
);
442 Глава 4 Навигация
Navigator.popUntil позволяет закрывать несколько экранов в стеке навигации,
пока не будет достигнут определенный маршрут. Он полезен, когда нужно вернуться на определенный экран, закрыв все промежуточные. Как и предыдущий метод,
Navigator.popUntil использует предикат для установки условия:
static void popUntil(
BuildContext context,
RoutePredicate predicate,
) {
Navigator.of(context).popUntil(predicate);
}
Например, если в качестве предиката передадим (route) = > route.isFirst, навигатор будет закрывать все экраны, пока не достигнет первого (начальной страницы). Это полезно, когда пользователь должен вернуться на главный экран после
завершения какого-то действия, пройдя несколько вложенных экранов:
Navigator.popUntil(context, (route) = > route.isFirst);
Navigator.maybePop позволяет попытаться закрыть открытый экран, если
это возможно. В отличие от Navigator.pop, который вызывает закрытие экрана,
maybePop сначала проверяет, можно ли закрыть текущий маршрут, и только затем
закрывает его:
static Future<bool> maybePop<T extends Object?>(
BuildContext context, [
T? result,
]) {
return Navigator.of(context).maybePop<T>(result);
}
Далее приведен пример его использования:
Navigator.maybePop(context);
Navigator.canPop проверяет, можно ли удалить верхний маршрут, не вызывая
закрытие приложения, например, чтобы отобразить кнопку Назад, только когда
есть возможность вернуться обратно, и скрыть ее, если текущий экран — главный.
Или для того, чтобы избежать ошибок при возврате, когда текущий экран — последний в стеке:
static bool canPop(BuildContext context) {
final navigator = Navigator.maybeOf(context);
return navigator ! = null && navigator.canPop();
}
Далее приведен пример использования метода:
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
Метод Navigator.of используется для доступа к текущему навигатору в контексте виджета. Он принимает на свой вход BuildContext и возвращает экземпляр
NavigatorState, который можно применять для управления навигацией.
4.2. Основные элементы навигации во Flutter 443
Обратите внимание на то, что все методы, которые мы рассматривали прежде,
при реализации задействуют Navigator.of или Navigator.maybeOf и представляют
собой обертки над ними. Например, статический метод Navigator.pop(context)
в своем теле использует Navigator.of(context).pop<T>(result):
static void pop<T extends Object?>(
BuildContext context, [
T? result,
]) {
Navigator.of(context).pop<T>(result);
}
В большинстве случаев вы будете использовать обертки над Navigator.of или
Navigator.maybeOf. Но бывают моменты, когда необходимо задействовать корневой,
а не текущий навигатор в context. Это дает нам доступ к аргументу rootNavigator,
который по умолчанию false:
Navigator.of(context, rootNavigator: true);
Использование корневого навигатора гарантирует, что диалоговые или модальные окна, которые открыли поверх всего приложения, игнорируя вложенные
навигаторы, закроются правильно:
// Диалоговое окно с индикатором загрузки
// открывается поверх всех окон
showDialog(
context: context,
useRootNavigator: true,
builder: (context) {
return const Center(child: CircularProgressIndicator());
},
);
// Закрытие индикатора загрузки
Navigator.of(context, rootNavigator: true).pop();
Еще один пример использования Navigator.of — виджет TabBarView, где каждая
вкладка может иметь свой Navigator. Чтобы переместиться на страницу, которая
должна открыться поверх всех вкладок, например на страницу авторизации, нужно
обратиться к корневому навигатору:
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(builder: (context) = > LoginScreen()),
);
Передача аргументу навигатора rootNavigator значения true помогает работать
с навигацией на глобальном уровне, игнорируя вложенные навигаторы.
4.2.2. Класс Route
Возможно, вы уже обратили внимание на то, что в метод навигатора push вторым
параметром передается не экран (виджет), а экземпляр класса MaterialPageRoute.
Он наследуется от абстрактного класса Route и для дизайн-системы Material добавляет щепотку анимации в процесс перехода между страницами.
444 Глава 4 Навигация
Класс Route реализует связывание навигатора и маршрутов, а также отвечает
за определение содержимого, которое будет отображено на экране, управление
жизненным циклом страницы (инициализация, отображение, удаление) и обработку анимации переходов между маршрутами. Фреймворк Flutter предоставляет
несколько его реализаций:
y MaterialPageRoute — реализует анимации переходов в стиле Material Design.
Обычно используется для системы Android;
y CupertinoPageRoute — используется для анимации переходов в стиле iOS;
y PageRouteBuilder — виджет, с помощью которого задается нестандартная
анимация перехода.
Далее рассмотрим каждую из этих реализаций более подробно.
MaterialPageRoute предоставляет стандартную реализацию маршрута с переходами в стиле Material Design. Он используется для переходов между экранами,
обеспечивая анимации, максимально похожие на стандартные анимации в системе
Android:
// Сигнатура конструктора класса
MaterialPageRoute({
required this.builder,
super.settings,
this.maintainState = true,
super.fullscreenDialog,
super.allowSnapshotting = true,
super.barrierDismissible = false,
}) {
assert(opaque);
}
Разберем каждый из аргументов этого конструктора.
1. Обязательный аргумент builder — анонимная функция, создающая виджет
для отображения. Она принимает BuildContext и возвращает виджет, который
будет показан на экране:
Navigator.of(context).push(MaterialPageRoute(
builder: (context) = > const LoginScreen(),
));
2. Необязательный аргумент settings принимает на вход экземпляр класса
RouteSettings, в конструктор которого передаются имя маршрута (name) и его
параметры (arguments). Так как в качестве параметров может передаваться
любой объект, аргумент settings обычно используется для передачи данных
между различными экранами:
Navigator.of(context).push(MaterialPageRoute(
builder: (context) = > const LoginScreen(),
settings: const RouteSettings(
name: '/loginScreen',
arguments: {'id': 42}, // тип Object?
),
));
3. Необязательный аргумент maintainState используется для указания того,
должен ли маршрут, когда он находится за другим маршрутом, сохранять
4.2. Основные элементы навигации во Flutter 445
свое состояние. По умолчанию на вход аргумента подается значение true.
Если же передать false, то состояние маршрута уничтожается, что освобо
ждает память, но при возврате маршрут будет пересоздан. Чаще всего этот
аргумент не трогают и используют значение по умолчанию. Но всегда бывают
исключения, например необходимость освобождать ресурсы для тяжелых
страниц, где идет работа с видео или картами местности.
4. Необязательный аргумент fullscreenDialog указывает, должен ли передавае
мый маршрут открываться как полноэкранное диалоговое окно. По умолчанию передается значение false, а при fullscreenDialog = true новый маршрут
отображается как модальный экран (со значком Закрыть в виде крестика
вместо кнопки Назад).
5. Необязательный аргумент allowSnapshotting определяет, будет ли при
переходах между маршрутами, например, при анимации масштабирования
страницы в Android использоваться предварительно созданный графический
снимок (snapshot) вместо динамического отображения виджетов. Этот аргумент может принять одно из следующих значений:
• true (по умолчанию). Создается снимок текущей страницы, который
используется для визуализации анимации. Это позволяет ускорить
переходы, поскольку вместо отрисовки всех виджетов маршрута анимируется статичное изображение. В связи с этим улучшается производительность, особенно для сложных страниц. Переходы выглядят
плавно, поскольку система избегает нагрузки, связанной с перерисовкой
содержимого;
• false. Анимация или изменения содержимого, происходящие на маршруте
в момент перехода, могут «заморозиться». Это связано с тем, что отображается снимок страницы. Исключения составляют Hero-анимации, которые
работают отдельно от основной страницы, и элементы, отображаемые
в отдельном слое, например, посредством Overlay.
Данный аргумент следует устанавливать в false только в тех случаях, когда
это действительно необходимо, например, если вы хотите показывать изменения в индикаторе прогресса, даже когда происходит смена страниц.
6. Необязательный аргумент barrierDismissible используется для указания
того, можно ли закрыть маршрут, нажав на область вне его содержимого.
Этот параметр полезен для маршрутов, которые отображаются как модальные
или диалоговые окна, где пользователь может захотеть закрыть окно, просто
нажав на затемненный фон. По умолчанию равен false.
CupertinoPageRoute обеспечивает переходы между экранами в стиле iOS
с анимацией и поведением, соответствующими принципам дизайна Cupertino. Этот
маршрут часто используется в приложениях, нацеленных на продукцию Apple, чтобы
пользовательский интерфейс выглядел и вел себя как нативное iOS-приложение:
CupertinoPageRoute({
required this.builder,
this.title,
446 Глава 4 Навигация
super.settings,
this.maintainState = true,
super.fullscreenDialog,
super.allowSnapshotting = true,
super.barrierDismissible = false,
}) {
assert(opaque);
}
Все аргументы конструктора аналогичны рассмотренным ранее в
Material-
PageRoute.
Класс PageRouteBuilder позволяет создавать маршруты с настраиваемой анимацией переходов между страницами. Он используется, когда стандартная анимация
из MaterialPageRoute или CupertinoPageRoute не подходит или вы хотите полностью
контролировать то, как происходят переходы:
PageRouteBuilder({
super.settings,
required this.pageBuilder,
this.transitionsBuilder = _defaultTransitionsBuilder,
this.transitionDuration = const Duration(milliseconds: 300),
this.reverseTransitionDuration = const Duration(milliseconds: 300),
this.opaque = true,
this.barrierDismissible = false,
this.barrierColor,
this.barrierLabel,
this.maintainState = true,
super.fullscreenDialog,
super.allowSnapshotting = true,
});
Как видно из сигнатуры конструктора класса, часть аргументов пересекается
с рассмотренными ранее в MaterialPageRoute. Поэтому сосредоточимся лишь на
некоторых новых аргументах.
1. Обязательный аргумент pageBuilder — анонимная функция, возвращающая
содержимое нового маршрута. Согласно сигнатуре, на ее вход поступают три
аргумента:
•
context: BuildContext — текущий контекст;
•
animation: Animation<double> — анимация для перехода вперед;
•
secondaryAnimation: Animation<double> — анимация для перехода назад.
В отличие от аргумента builder, который используется в конструкторе MaterialPageRoute и CupertinoPageRoute, в pageBuilder у нас появляется возможность добавить свою анимацию для переходов:
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) {
return const HomeScreen();
},
),
);
4.2. Основные элементы навигации во Flutter 447
2. Необязательный аргумент transitionsBuilder — анонимная функция, задающая анимацию перехода. Согласно сигнатуре, на ее вход поступают четыре
аргумента:
• context: BuildContext — текущий контекст;
• animation: Animation<double> — анимация для перехода вперед;
• secondaryAnimation: Animation<double> — анимация для перехода назад;
• child: Widget — виджет страницы.
Navigator.of(context).push(PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) = >
const HomeScreen(),
transitionsBuilder: (
context,
animation,
secondaryAnimation,
child,
) {
return FadeTransition(opacity: animation, child: child);
},
));
В данном примере функция возвращает экземпляр FadeTransition, отвечающий за плавное появление страницы. Еще можно использовать Slide
Transition (сдвиг страницы) или ScaleTransition (масштабирование страницы). Поэкспериментируйте с различными настройками анимации, чтобы
лучше понять механизм ее работы.
3. Необязательный аргумент transitionDuration применятся для задания длительности анимации перехода на маршрут (следующий экран). По умолчанию
задано 300 мс.
4. Необязательный аргумент reverseTransitionDuration используется для задания длительности анимации возврата на маршрут (предыдущий экран).
По умолчанию 300 мс.
5. Необязательный аргумент opaque используется для определения того, можно ли
закрыть маршрут, нажав на барьер (затемненный фон). По умолчанию true.
6. Необязательный аргумент barrierColor позволяет задать цвет барьера (затемненного фона) за диалоговым или модальным окном. Используется только
в том случае, если аргументу opaque передается значение false.
PageRouteBuilder позволяет нам получить полный контроль над тем, как страница
появляется, исчезает и взаимодействует с пользователем. Задействуйте его, если
необходимо отклониться от стандартных переходов и создать что-то уникальное.
4.2.3. Класс NavigatorObserver
Очень часто разработчикам необходимо мониторить изменения маршрутов, например, для отправки аналитики или отладки навигации. Для этого можно использовать класс NavigatorObserver, который позволяет отслеживать изменения
448 Глава 4 Навигация
в стеке маршрутов (Navigator). Он предоставляет уведомления о таких событиях,
как добавление нового, удаление или замена маршрута.
Рассмотрим пример создания такого наблюдателя. Создайте новый проект example_navigator_observer и удалите все файлы из папки test. На следующем шаге
приведите структуру папки lib в соответствие с представленной далее:
example_navigator_observer
├── lib/
│
├── screens/
│
│
├── home_screen.dart
│
│
├── login_screen.dart
│
│
├── main_screen.dart
│
│
└── profile_screen.dart
│
├── app_navigator_observer.dart
│
└── main.dart
├── test/
└── pubspec.yaml
Первым делом займемся библиотеками экранов из каталога screens:
// base_url/4/example_navigator_observer/…/home_screen.dart
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('HomeScreen')),
);
}
// base_url/4/example_navigator_observer/…/login_screen.dart
import 'package:flutter/material.dart';
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('LoginScreen')),
);
}
// base_url/4/example_navigator_observer/…/main_screen.dart
import 'package:example_navigator_observer/screens/profile_screen.dart';
import 'package:flutter/material.dart';
class MainScreen extends StatelessWidget {
const MainScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('MainScreen')),
body: Center(
child: ElevatedButton(
onPressed: () {
4.2. Основные элементы навигации во Flutter 449
Navigator.of(context).push(MaterialPageRoute(
builder: (context) = > const ProfileScreen(),
settings: const RouteSettings(name: '/profile'),
));
),
}
}
},
child: const Text('Переход на ProfileScreen')),
);
// base_url/4/example_navigator_observer/…/profile_screen.dart
import 'package:example_navigator_observer/screens/home_screen.dart';
import 'package:flutter/material.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('ProfileScreen')),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) = > const HomeScreen(),
settings: const RouteSettings(name: '/home'),
));
},
child: const Text('Переход на HomeScreen')),
),
);
}
Такая передача экземпляра RouteSettings в аргумент settings:
Navigator.of(context).push(MaterialPageRoute(
builder: (context) = > const ProfileScreen(),
settings: const RouteSettings(name: '/profile'),
));
позволит определить, какой маршрут является текущим. А делать это будем в биб
лиотеке app_navigator_observer.dart, указав, что наш пользовательский класс наследуется от NavigatorObserver, и переопределив часть его методов:
// base_url/4/example_navigator_observer/…/ app_navigator_observer.dart
import 'package:flutter/material.dart';
class AppNavigatorObserver extends NavigatorObserver {
@override
void didPush(Route route, Route? previousRoute) async {
super.didPush(route, previousRoute);
}
// Отправить имя маршрута в систему аналитики
final rStr = route.settings.name;
debugPrint('Маршрут добавлен в стек: $rStr');
@override
void didPop(Route route, Route? previousRoute) {
super.didPop(route, previousRoute);
450 Глава 4 Навигация
}
// Отправить имя маршрута в систему аналитики
final rStr = route.settings.name;
debugPrint('Маршрут удален из стека: $rStr');
@override
void didReplace({Route? newRoute, Route? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
}
}
// Отправить имя нового маршрута в систему аналитики
final oldStr = oldRoute?.settings.name;
final newStr = newRoute?.settings.name;
debugPrint('Маршрут заменен: $oldStr на $newStr');
Перейдите к файлу main.dart и добавьте в него следующий код:
// base_url/4/example_navigator_observer/lib/main.dart
import 'package:example_navigator_observer/app_navigator_observer.dart';
import 'package:example_navigator_observer/screens/main_screen.dart';
import 'package:flutter/material.dart';
void main() {
runApp(
MaterialApp(
navigatorObservers: [AppNavigatorObserver()],
home: const MainScreen(),
),
);
}
После запуска приложения обратите внимание на изменения в стеке навигатора
при переходе/закрытии маршрутов:
flutter:
flutter:
flutter:
flutter:
flutter:
Маршрут
Маршрут
Маршрут
Маршрут
Маршрут
добавлен в стек:
добавлен в стек:
добавлен в стек:
удален из стека:
удален из стека:
/
/profile
/home
/home
/profile
Еще один вариант использования NavigatorObserver заключается в добавлении
дополнительных проверок. Например, мы можем не давать пользователю переходить
на экран профиля, если он не авторизован в приложении:
// base_url/4/example_navigator_observer/…/ app_navigator_observer.dart
import 'package:example_navigator_observer/screens/login_screen.dart';
import 'package:flutter/material.dart';
// Флаг авторизации (для примера)
bool _isUserAuthenticated = false;
class AppNavigatorObserver extends NavigatorObserver {
@override
void didPush(Route route, Route? previousRoute) async {
super.didPush(route, previousRoute);
// Проверяем, если пользователь пытается перейти на профиль
if (route.settings.name = = '/profile' && !_isUserAuthenticated) {
debugPrint(
'Доступ запрещен, переход на экран аутентификации',
);
4.3. Именованные маршруты 451
// Отменяем переход
WidgetsBinding.instance.addPostFrameCallback((_) {
navigator?.pop();
}
}
// Перенаправляем на экран аутентификации
navigator?.push(MaterialPageRoute(
builder: (context) = > const LoginScreen(),
settings: const RouteSettings(name: '/login'),
));
});
// Отправить имя маршрута в систему аналитики
final rStr = route.settings.name;
debugPrint('Маршрут добавлен в стек: $rStr');
@override
void didPop(Route route, Route? previousRoute) {
// без изменений
}
}
@override
void didReplace({Route? newRoute, Route? oldRoute}) {
// без изменений
}
Запустите приложение и попробуйте перейти на экран профиля пользователя.
После добавления проверок приложение будет перенаправлять пользователя на
экран авторизации:
flutter:
flutter:
flutter:
flutter:
flutter:
flutter:
Маршрут добавлен в стек:
Доступ запрещен, переход
Маршрут добавлен в стек:
Маршрут удален из стека:
Маршрут добавлен в стек:
Маршрут удален из стека:
/
на экран аутентификации
/profile
/profile
/login
/login
Использование NavigatorObserver для контроля доступа к маршрутам — эффективный способ добавления проверки авторизации в приложение, который позволяет предотвратить нежелательные переходы и обеспечивает централизованное
управление процессом навигации.
4.3. Именованные маршруты
Для того чтобы реализовать минимальную логику проверки или перенаправление
маршрутов, мы присваивали имена маршрутам с помощью аргумента settings. Но это
крайне неудобно и может вызвать ошибки или несоответствия имен. Разработчик
при создании нового маршрута может просто ошибиться с названием.
И вот тут в игру вступают именованные маршруты — удобный способ навигации, который позволяет обращаться к маршрутам по их именам вместо создания
их вручную с помощью Navigator.push. Они особенно полезны в больших приложениях с несколькими экранами, где маршруты часто используются повторно.
452 Глава 4 Навигация
Следует отметить, что именованные маршруты не реализуют декларативную
навигацию, о которой поговорим чуть позже. Мы все так же работаем в императивной парадигме.
Сама идея довольно проста в реализации. Именованные маршруты определяются в таблице маршрутов (routes) и создаются автоматически с помощью функции
onGenerateRoute. А теперь создайте новый проект named_routes и удалите все файлы
из каталога test. На следующем шаге приведите структуру папки lib в соответствие
с представленной далее:
named_routes
├── lib/
│
├── screens/
│
│
├── default_screen.dart
│
│
├── home_screen.dart
│
│
├── login_screen.dart
│
│
├── main_screen.dart
│
│
├── profile_screen.dart
│
│
└── test_screen.dart
│
├── app_routes_name.dart
│
└── main.dart
├── test/
└── pubspec.yaml
Хорошим тоном является вынос имен маршрутов в устойчивые константы. Это
делается для удобства перехода по ним. Поэтому первым делом откройте библиотеку
app_routes_name.dart и добавьте в нее следующий код:
// base_url/4/named_routes/lib/app_routes_name.dart.dart
abstract final class AppRoutesName {
static const String main = '/';
static const String profile = '/profile';
static const String login = '/login';
static const String home = '/home';
}
Теперь займемся библиотеками экранов из каталога screens:
// base_url/4/named_routes/…/default_screen.dart
import 'package:flutter/material.dart';
class DefaultScreen extends StatelessWidget {
const DefaultScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('DefaultScreen'),
),
);
}
// base_url/4/named_routes/…/home_screen.dart
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
//см. предыдущий раздел. Код не менялся
}
4.3. Именованные маршруты 453
// base_url/4/named_routes/…/login_screen.dart
import 'package:flutter/material.dart';
class LoginScreen extends StatelessWidget {
//см. предыдущий раздел. Код не менялся
}
// base_url/4/named_routes/…/profile_screen.dart
import 'package:flutter/material.dart';
import 'package:named_routes/app_routes_name.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ProfileScreen'),
),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pushNamed(
AppRoutesName.home,
);
},
child: const Text('Переход на HomeScreen'),
),
),
);
}
// base_url/4/named_routes/…/test_screen.dart
import 'package:flutter/material.dart';
class TestScreen extends StatelessWidget {
const TestScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TestScreen'),
),
);
}
Обратите внимание, что при использовании именованных маршрутов вместо
или Navigator.pop вызываются методы Navigator.pushNamed или
Navigator.popAndPushNamed, принимающие на свой вход имя маршрута.
На следующем шаге реализуем главный экран:
Navigator.push
// base_url/4/named_routes/…/main_screen.dart
import 'package:flutter/material.dart';
import 'package:named_routes/app_routes_name.dart';
class MainScreen extends StatelessWidget {
const MainScreen({super.key});
454 Глава 4 Навигация
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('MainScreen')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: () {
Navigator.of(context).pushNamed(
AppRoutesName.profile,
);
},
child: const Text(
'Переход на ProfileScreen',
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).pushNamed('/unknown');
},
child: const Text(
'Переход на неизвестный маршрут',
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).pushNamed('/test');
},
child: const Text('Переход на тестовый маршрут'),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).pushNamed(
'/test',
arguments: {
'test': true,
},
);
},
child: const Text(
'Переход на тестовый маршрут с аргументами',
),
),
],
),
),
);
}
Без выноса имен маршрутов в отдельную библиотеку код для запуска приложения мог бы выглядеть следующим образом:
void main() {
runApp(MaterialApp(
initialRoute: '/',
routes: {
'/': (context) = > const MainScreen(),
'/profile': (context) = > const ProfileScreen(),
4.3. Именованные маршруты 455
}
},
));
'/login': (context) = > const LoginScreen(),
'/home': (context) = > const HomeScreen(),
Но, так как у нас все маршруты удобно хранятся в карте маршрутов routes, мы
можем исключить прописывание их имен вручную. Это защитит проект от неправильного указания имени маршрута:
// base_url/4/named_routes/lib/main.dart
import 'package:flutter/material.dart';
import 'package:named_routes/app_routes_name.dart';
import 'package:named_routes/screens/home_screen.dart';
import 'package:named_routes/screens/login_screen.dart';
import 'package:named_routes/screens/main_screen.dart';
import 'package:named_routes/screens/profile_screen.dart';
void main() {
runApp(MaterialApp(
initialRoute: '/',
routes: {
AppRoutesName.main: (context) = > const MainScreen(),
AppRoutesName.profile: (context) = > const ProfileScreen(),
AppRoutesName.login: (context) = > const LoginScreen(),
AppRoutesName.home: (context) = > const HomeScreen(),
},
));
}
Если сейчас запустите приложение, то на главном экране будет доступна только
навигация на страницу с профилем (рис. 4.6). Как заставить работать другие кнопочки, рассмотрим в следующих двух разделах. 😉
Рис. 4.6. Внешний вид запущенного приложения
456 Глава 4 Навигация
4.3.1. onUnknownRoute
Бывают ситуации, когда пользователь пытается перейти на маршрут, не инициализированный в routes. Это может случиться по разным причинам, например, если
у вас реализованы глубокие ссылки или разработчик ошибся с названием маршрута:
Navigator.of(context).pushNamed('/catalog'); // catalog нет в routes
Такие просчеты способны привести к следующему исключению:
Exception has occurred.
FlutterError (Could not find a generator for route RouteSettings("/catalog", null)
in the _WidgetsAppState.
Make sure your root app widget has provided a way to generate
this route.
Generators for routes are searched for in the following order:
1. For the "/" route, the "home" property, if non-null, is used.
2. Otherwise, the "routes" table is used, if it has an entry for the route.
3. Otherwise, onGenerateRoute is called. It should return a non-null value for any
valid route not handled by "home" and "routes".
4. Finally if all else fails onUnknownRoute is called.
Unfortunately, onUnknownRoute was not set.)
Чтобы при вызове неправильного маршрута приложение не «падало» с исключением, а продолжало работать и переадресовало пользователя на заранее подготовленную для таких ситуаций страницу, воспользуемся аргументом onUnknownRoute
класса MaterialApp:
// base_url/4/named_routes/lib/main.dart
// предыдущий импорт без изменений
// новый импорт
import 'package:named_routes/screens/default_screen.dart';
void main() {
runApp(MaterialApp(
initialRoute: '/',
routes: {
AppRoutesName.main: (context) = > const MainScreen(),
AppRoutesName.profile: (context) = > const ProfileScreen(),
AppRoutesName.login: (context) = > const LoginScreen(),
AppRoutesName.home: (context) = > const HomeScreen(),
},
onUnknownRoute: (settings) = > MaterialPageRoute(
builder: (context) = > const DefaultScreen(),
),
));
}
Теперь, если вдруг будет сделан вызов для перехода на неизвестный маршрут,
Flutter безопасно перенаправит пользователя на тот экран, который вы определите
по умолчанию.
4.3.2. onGenerateRoute
Аргумент onGenerateRoute класса MaterialApp позволяет динамически генерировать маршруты на основе имени маршрута (RouteSettings.name) и переданных
аргументов (RouteSettings.arguments). Этот метод частенько используется, если
в приложении необходимо реализовать сложную логику переходов, например
4.3. Именованные маршруты 457
проверить, были переданы аргументы при переходе или нет, и в зависимости от
этого выбирать нужный маршрут.
Рассмотрим пример с маршрутом /test, который не был указан в routes:
// base_url/4/named_routes/lib/main.dart
// предыдущий импорт без изменений
// новый импорт
import 'package:named_routes/screens/test_screen.dart';
void main() {
runApp(MaterialApp(
initialRoute: '/',
routes: {
// код без изменений
},
onUnknownRoute: (settings) = > MaterialPageRoute(
builder: (context) = > const DefaultScreen(),
),
onGenerateRoute: (settings) {
// В данном случае в карте маршрутов routes
// не прописан /test_route.
// Но мы можем в onGenerateRoute проверить
// и построить нужный маршрут.
if (settings.name = = '/test') {
return MaterialPageRoute(
builder: (context) = > const TestScreen(),
);
}
return null;
}
},
));
В принципе такой способ позволяет обрабатывать любой вариант маршрутизации.
А теперь представим, что нам необходимо переходить на тестовый экран только
тогда, когда в аргументах есть необходимые для этого данные:
Navigator.of(context).pushNamed('/test', arguments: {'test': true});
Для этого перепишем анонимную функцию, подаваемую на вход аргумента
onGenerateRoute, добавив дополнительное условие:
onGenerateRoute: (settings) {
// Проверка наличия аргументов при переходе
if (settings.name = = '/test') {
// извлекаем нужный аргумент
final args = (settings.arguments as Map?)?['test'];
if (args = = true) {
return MaterialPageRoute(
builder: (context) = > const HomeScreen(),
);
} else {
return MaterialPageRoute(
builder: (context) = > const TestScreen(),
);
}
}
return null;
},
458 Глава 4 Навигация
onGenerateRoute — очень полезный инструмент для настройки навигации
во Flutter. Он идеально подходит для приложений, где маршруты генерируются динамически или требуют сложной логики. Используйте его в сочетании
с onUnknownRoute для полной обработки всех возможных сценариев навигации.
4.4. Навигация без контекста — GlobalKey и NavigatorState
Сразу хотелось бы отметить, что это не лучший вариант реализации навигации, но
иногда разработчикам необходим доступ к навигатору вне BuildContext. Поэтому
не лишним будет познакомить вас с построением своего «навигационного велосипеда», который может быть полезен в работе с менеджерами состояний (Bloc,
Provider и т. д.), задачах управления навигацией или обработке глобальных событий, например уведомлений или глубоких ссылок. Для этого нам потребуется
использовать классы GlobalKey и NavigatorState.
Ключ GlobalKey предоставляет доступ к состоянию виджета, связанного с этим
ключом, а NavigatorState — это состояние Navigator, которое, в свою очередь, управляет стеком маршрутов (route) в приложении. Благодаря ему можно выполнять
навигационные операции (push, pop и т. д.) без привязки к контексту.
Для использования этого подхода сначала добавим глобальный ключ, связанный с NavigatorState. Он позволит управлять навигацией на глобальном уровне
приложения:
final navigatorKey = GlobalKey<NavigatorState>();
Далее передадим ключ в MaterialApp, связав его с навигатором:
runApp(MaterialApp(
navigatorKey: navigatorKey,
home: const HomeScreen(),
));
Теперь у нас появляется возможность управлять навигацией на любом уровне
приложения, откуда имеется доступ к ключу:
navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (context) = > const SecondScreen(),
),
);
Далее приведен полный пример кода приложения, который необходимо поместить в библиотеку main.dart:
// base_url/4/globalkey_navigatorstate/lib/main.dart
import 'package:flutter/material.dart';
/// Глобальный ключ, связанный с NavigatorState.
/// Позволяет управлять навигацией глобально.
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
4.4. Навигация без контекста — GlobalKey и NavigatorState 459
void main() {
runApp(MaterialApp(
// Передаем ключ в MaterialApp:
navigatorKey: navigatorKey,
home: const HomeScreen(),
));
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('HomeScreen')),
body: Center(
child: ElevatedButton(
onPressed: () {
// Обращаемся к глобальному ключу, затем
// к его текущему состоянию и вызываем
// знакомый метод push()
navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (context) = > const SecondScreen(),
),
);
},
child: const Text('Перейти на SecondScreen'),
),
),
);
}
class SecondScreen extends StatelessWidget {
const SecondScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('SecondScreen')),
body: Center(
child: ElevatedButton(
onPressed: () {
// Обращаемся к глобальному ключу, затем
// к его текущему состоянию и вызываем метод pop()
navigatorKey.currentState?.pop();
},
child: const Text('Вернуться назад'),
),
),
);
}
Кроме того, с помощью глобального ключа можно получить доступ к текущему
контексту и виджету:
navigatorKey.currentContext;
navigatorKey.currentState;
navigatorKey.currentWidget;
460 Глава 4 Навигация
Хотелось бы отметить, что стоит избегать чрезмерного использования Global
так как это может привести к сложностям в управлении состоянием приложения. Особенно важно не использовать его в динамически создаваемых
виджетах, например в списках, где один ключ может быть присвоен нескольким
виджетам.
Key,
4.5. Инструменты декларативной навигации
Допустим, вы написали приложение на Flutter для Web и перешли в браузере по
ссылке https://shop/product/1. В этот момент откроется экран с продуктом № 1. Но если
вы измените URL на https://shop/product/2, то весь экран автоматически перестроится
на страницу с продуктом № 2, и наоборот.
Как это происходит? Как Flutter понимает, что введено в поисковой строке?
Как платформа понимает, куда в приложении перешел пользователь, чтобы
отрисовать необходимый URL-адрес? Вот тут в игру и вступает первый из рассматриваемых нами инструментов декларативной навигации — класс Route
InformationProvider.
В этом разделе весь код будет рассматриваться в рамках одного приложения.
Поэтому создайте новый проект route_api и удалите все файлы из папки test.
На следующем шаге приведите структуру папки lib в соответствие с представленной далее:
route_api
├── lib/
│
├── screens/
│
│
├── error_screen.dart
│
│
├── home_screen.dart
│
│
├── profile_screen.dart
│
│
└── root_screen.dart
│
├── example/
│
│
├── my_route_config.dart
│
│
├── my_route_delegate.dart
│
│
├── my_route_information_parser.dart
│
│
└── my_route_information_provider.dart
│
└── main.dart
├── test/
└── pubspec.yaml
Следом за этим добавьте приведенный далее код в библиотеки двух экранов,
где нет зависимостей от файлов из папки example:
// base_url/4/route_api/…/home_screen.dart
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('HomeScreen')),
body: const Center(child: Text('HomeScreen')));
}
4.5. Инструменты декларативной навигации 461
// base_url/4/route_api/…/root_screen.dart
import 'package:flutter/material.dart';
class RootScreen extends StatelessWidget {
// аналогично HomeScreen
}
// base_url/4/route_api/…/profile_screen.dart
import 'package:flutter/material.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('ProfileScreen')),
);
}
4.5.1. RouteInformationProvider
Данный класс можно использовать для прослушивания всех изменений и получения
информации о маршруте в реальном времени, например, когда изменили URL-адрес
с https://shop/product/1 на https://shop/product/2. Другими словами, RouteInformationProvider предоставляет информацию о текущем маршруте.
Давайте объявим пользовательскую реализацию RouteInformationProvider .
Для этого откройте файл my_route_information_provider.dart и добавьте в него
следующий код:
// base_url/4/route_api/…/my_route_information_provider.dart
import 'package:flutter/material.dart';
final myRouteInformationProvider = PlatformRouteInformationProvider(
initialRouteInformation: RouteInformation(uri: Uri.parse('/')),
);
Как видно из примера, на вход именованного аргумента initialRouteInformation конструктора класса PlatformRouteInformationProvider передается объект
RouteInformation, содержащий начальную информацию о маршруте. В нашем случае
строка '/' используется для указания главного маршрута приложения.
Отлично! Мы немного приоткрыли завесу тайны и теперь знаем, кто следит за
обновлениями в маршрутах. Но что происходит дальше? Кто преобразует информацию о маршруте, полученную из RouteInformationProvider, в состояние навигации?
Ответы на эти вопросы ждут вас в следующем подразделе. 😉
4.5.2. RouteInformationParser
Данный класс отвечает за преобразование информации о маршруте (обычно в виде
строки URL) в объект маршрута — пользовательский класс, представляющий состояние вашего приложения. И наоборот, для обновления URL-адреса он может
преобразовать состояние в объект RouteInformation.
462 Глава 4 Навигация
В качестве примера использования RouteInformationParser напишем парсер,
который умеет извлекать из URL информацию о маршруте и, наоборот, преобразовывать текущий маршрут в объект RouteInformation:
// Пользовательский класс для работы с маршрутами.
// Наследуется от [RouteInformationParser]
class MyRouteInformationParser extends RouteInformationParser<String> {
@override
Future<String> parseRouteInformation(
RouteInformation routeInformation,
) async {
// Извлекаем объект Uri из информации о маршруте
final uri = routeInformation.uri;
// Проверяем путь в URI
// Если путь равен "/main", возвращаем строку "/main"
if (uri.path = = '/main') {
return '/main';
}
// Если путь не "/main", возвращаем корневой маршрут "/"
return '/';
}
}
@override
RouteInformation? restoreRouteInformation(String configuration) {
// Преобразуем строку маршрута обратно в RouteInformation
return RouteInformation(uri: Uri.parse(configuration));
}
Ознакомившись с приведенным кодом, вы можете спросить: «Почему мы сразу
просто не вернем объект URI из RouteInformation, а возвращаем '/' или '/main'?» —
например, так:
return routeInformation.uri.path;
В целом такой подход тоже будет работать, но потеряется гибкость, из-за чего
не получится избежать ошибок, связанных с валидацией маршрутов. Чтобы не становиться заложниками такой ситуации, стоит добавлять проверки, которые будут
возвращать маршрут, сигнализирующий об ошибке.
Откройте файл my_route_information_parser.dart и перенесите в него реализованный ранее MyRouteInformationParser, добавив в его метод дополнительные
проверки по маршрутам, которые могут существовать в разрабатываемом приложении:
// base_url/4/route_api/…/my_route_information_parser.dart
import 'package:flutter/widgets.dart';
// Пользовательский класс для работы с маршрутами.
// Наследуется от [RouteInformationParser]
class MyRouteInformationParser extends RouteInformationParser<String> {
@override
Future<String> parseRouteInformation(
RouteInformation routeInformation,
) async {
// Извлекаем объект URI из информации о маршруте
final uri = routeInformation.uri;
4.5. Инструменты декларативной навигации 463
}
}
if (uri.path = = '/') {
return '/';
} else if (uri.path = = '/home') {
return '/home';
} else if (uri.path = = '/profile') {
// Обработка маршрутов профиля
return '/profile';
}
// Обработка несуществующих маршрутов
return '/error';
@override
RouteInformation? restoreRouteInformation(String configuration) {
// Преобразуем строку маршрута обратно в RouteInformation
return RouteInformation(uri: Uri.parse(configuration));
}
Вот мы и разобрались, как полученную информацию о маршруте преобразовывать в URL и обратно. Но что же происходит дальше? Кто меняет состояние
навигации вашего приложения и наоборот? Эту работу берет на себя следующий
рассматриваемый класс — RouterDelegate.
4.5.3. RouterDelegate
Этот класс управляет состоянием навигации, отвечает за построение интерфейса
в зависимости от текущего маршрута и, наоборот, обновляет состояние маршрута в зависимости от интерфейса. Используем его для создания экрана RootScreen.
Откройте my_route_delegate.dart и добавьте в него приведенный далее код:
// base_url/4/route_api/…/my_route_delegate.dart
import 'package:flutter/material.dart';
import 'package:route_api/screen/root_screen.dart';
/// Делегат маршрутов
class MyRouterDelegate extends RouterDelegate<String>
with ChangeNotifier {
@override
Widget build(BuildContext context) {
return const Navigator(
pages: [MaterialPage(child: RootScreen())],
);
}
@override
Future<void> setNewRoutePath(String configuration) async {}
}
@override
Future<bool> popRoute() {
return Future.value(true);
}
Реализованный нами пользовательский делегат очень простой. Он наследуется от RouterDelegate и смешивается с ChangeNotifier. К методам popRoute()
и setNewRoutePath(String configuration) вернемся немного позднее, а пока рассмотрим метод build(). Он возвращает Navigator, у которого, в свою очередь, есть параметр pages. Это, по сути, простой стек, хранящий элементы типа Page (страницы).
464 Глава 4 Навигация
Page — абстрактный класс, описывающий конфигурацию маршрута для состоя
ния одной страницы. У него есть реализации для систем дизайна как Cupertino
(CupertinoPage), так и Material (MaterialPage).
Теперь откройте файл main.dart. Вместо привычного по множеству предыдущих
примеров виджета MaterialApp воспользуемся его именованным конструктором
MaterialApp.router, которому передадим на вход реализованные ранее MyRouterDelegate, MyRouteInformationParser и myRouteInformationProvider:
// base_url/4/route_api/lib/main.dart
import 'package:flutter/material.dart';
import 'package:route_api/example/my_route_information_parser.dart';
import 'package:route_api/example/my_route_information_provider.dart';
import 'package:route_api/example/my_route_delegate.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerDelegate: MyRouterDelegate(),
routeInformationParser: MyRouteInformationParser(),
routeInformationProvider: myRouteInformationProvider,
);
}
Если сейчас запустите приложение, то увидите корневой экран RootScreen.
На ошибки в терминале не обращайте внимания, к концу раздела они исчезнут.
4.5.4. Настройка MyRouterDelegate
Чтобы проверить, как работает маршрутизатор, создадим два экрана, добавив две
кнопки в RootScreen:
// base_url/4/route_api/…/root_screen.dart
import 'package:flutter/material.dart';
import 'package:route_api/example/my_route_delegate.dart';
class RootScreen extends StatelessWidget {
const RootScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('RootScreen'),
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
4.5. Инструменты декларативной навигации 465
onPressed: () {
// Получаем из контекста MyRouterDelegate
// и вызываем setNewRoutePath с новой конфигурацией
(Router.of(context).routerDelegate as MyRouterDelegate)
.setNewRoutePath('/home');
},
child: const Text('Перейти на HomeScreen'),
),
const SizedBox(
height: 16,
),
ElevatedButton(
onPressed: () {
(Router.of(context).routerDelegate as MyRouterDelegate)
.setNewRoutePath('/profile');
},
child: const Text('Перейти на ProfileScreen'),
),
],
),
),
}
}
);
На следующем шаге добавим код последнего экрана — ErrorScreen, на который
будет перенаправляться пользователь при некорректных данных в маршруте:
// base_url/4/route_api/…/error_screen.dart
import 'package:flutter/material.dart';
import 'package:route_api/example/my_route_delegate.dart';
class ErrorScreen extends StatelessWidget {
const ErrorScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('ErrorScreen')),
body: Center(
child: ElevatedButton(
onPressed: () {
(Router.of(context).routerDelegate as MyRouterDelegate)
.setNewRoutePath('/');
},
child: const Text('Перейти на RootScreen')),
));
}
Теперь перейдем в my_route_delegate.dart и удалим из него весь код. После чего
импортируем необходимые библиотеки и по новой объявим класс:
// base_url/4/route_api/…/my_route_delegate.dart
import 'package:flutter/material.dart';
import 'package:route_api/screen/error_screen.dart';
import 'package:route_api/screen/home_screen.dart';
import 'package:route_api/screen/profile_screen.dart';
import 'package:route_api/screen/root_screen.dart';
// Делегат маршрутов
class MyRouterDelegate extends RouterDelegate<String>
with ChangeNotifier {
// сюда будем добавлять код
}
466 Глава 4 Навигация
В область объявления переменных экземпляра класса внесем список страниц,
которые будем передавать в Navigator, и переменную для хранения текущего состояния маршрута:
// Список страниц
List<Page> pages = [];
// Текущее состояние маршрута
String _currentPath = '/';
Следом за объявлением переменных переопределим геттер для получения текущей конфигурации:
/// Геттер для текущей конфигурации маршрута
@override
String? get currentConfiguration = > _currentPath;
На очереди метод build. Первым делом передадим список страниц аргумента
конструктору класса Navigator, после чего обработаем обратный вызов,
когда страница закрывается посредством метода pop() . Для этого аргументу
передадим анонимную функцию, которая будет получать текущую страницу,
удалять ее из списка и с помощью метода notifyListeners() обновлять состояние
навигации:
pages
@override
Widget build(BuildContext context) {
return Navigator(
pages: [const MaterialPage(child: RootScreen()), ...pages],
// Обратный вызов, который срабатывает при удалении страницы
onDidRemovePage: (page) {
// Удаляем страницу из списка страниц
pages.remove(page);
// Обновляем состояние
if (pages.isEmpty) {
setNewRoutePath('/');
}
notifyListeners();
},
);
}
Далее переопределим метод setNewRoutePath(String configuration). Не будем
сильно заморачиваться с его реализацией. В теле метода создадим новую страницу согласно поступившей конфигурации, очистим список или добавим в него эту
страницу, не забыв про обновление состояния:
@override
Future<void> setNewRoutePath(String configuration) async {
// Создаем новую страницу
Page? newPage = switch (configuration) {
'/home' = > const MaterialPage(child: HomeScreen()),
'/profile' = > const MaterialPage(child: ProfileScreen()),
'/error' = > const MaterialPage(child: ErrorScreen()),
_ = > null,
};
// Обновляем состояние
_currentPath = configuration;
if (configuration = = '/') {
// Если текущая страница корневая, то очищаем список
pages.clear();
}
4.5. Инструменты декларативной навигации 467
}
if (newPage ! = null) {
// конфигурируем новую страницу, добавляем новую страницу в список
pages.add(newPage);
}
// Обновляем состояние
notifyListeners();
Future<bool> popRoute() {
return Future.value(true);
}
Запустим проект в браузере и посмотрим, как работает реализованный нами
маршрутизатор (рис. 4.7).
Рис. 4.7. Внешний вид запущенного приложения
После нажатия кнопки Перейти на HomeScreen откроется нужный экран, а в адресной строке поменяется состояние URL (рис. 4.8).
Рис. 4.8. Переход на экран HomeScreen
468 Глава 4 Навигация
Теперь измените в адресной строке браузера URL home на profile (рис. 4.9).
Рис. 4.9. Переход на экран ProfileScreen
Или поменяйте URL на любой неизвестный маршрутизатору (рис. 4.10).
Рис. 4.10. Переход на экран ErrorScreen
Таким образом, мы реализовали синхронизацию состояния платформы с состоянием приложения.
4.5.5. PopNavigatorRouterDelegateMixin
Вроде бы до этого момента было все хорошо, но мы забыли обработать платформенную кнопку Назад (например, для Android). Для этого классу MyRouterDelegate необходимо добавить миксин PopNavigatorRouterDelegateMixin, который
4.5. Инструменты декларативной навигации 469
используется с классом RouterDelegate для обработки событий, связанных с кнопкой
Назад и с управлением стеком навигации:
// base_url/4/route_api/…/my_route_delegate.dart
class MyRouterDelegate extends RouterDelegate<String>
with ChangeNotifier, PopNavigatorRouterDelegateMixin
Поскольку миксин использует ключ типа GlobalKey<NavigatorState>? для взаимо
действия с маршрутизацией, его необходимо переопределить в теле класса:
// Переопределяем параметр ключа навигатора
@override
GlobalKey<NavigatorState>? get navigatorKey = >
GlobalKey<NavigatorState>();
и передать аргументу key конструктора класса Navigator в методе build:
return Navigator(
key: navigatorKey,
pages: [const MaterialPage(child: RootScreen()), ...pages],
// Обратный вызов, который срабатывает при удалении страницы
onDidRemovePage: (page) {
Использование миксина PopNavigatorRouterDelegateMixin предполагает удаление
ранее переопределенного метода popRoute():
//
//
//
//
//
Удаляем, так как используем PopNavigatorRouterDelegateMixin
@override
Future<bool> popRoute() {
return Future.value(true);
}
На следующем шаге добавим в файле main.dart в MaterialApp.router обработчик
события нажатия кнопки Назад на уровне всего приложения — RootBackButton
Dispatcher:
// base_url/4/route_api/lib/main.dart
return MaterialApp.router(
routerDelegate: MyRouterDelegate(),
routeInformationParser: MyRouteInformationParser(),
routeInformationProvider: myRouteInformationProvider,
backButtonDispatcher: RootBackButtonDispatcher(),
);
}
Если у вас Android 13 и выше, не забудьте добавить в AndroidManifest.xml, в раздел application, следующую строчку:
android:enableOnBackInvokedCallback="true"
Этот атрибут активирует использование нового API кнопки Назад на уровне
системы.
4.5.6. RouterConfig
Текущий класс позволяет настроить маршруты приложения, указав их начальное
состояние и обработчики навигации, а также объединять в себе RouterDelegate,
RouteInformationParser, RouteInformationProvider. Это удобно для управления
маршрутизацией из одного места.
470 Глава 4 Навигация
Добавим эту возможность нашему проекту. Для этого откройте файл my_route_
config.dart и добавьте в него приведенный далее код:
// base_url/4/route_api/…/my_route_config.dart
import 'package:flutter/material.dart';
class MyRouterConfig<T> extends RouterConfig<T> {
MyRouterConfig({
required super.routerDelegate,
required super.routeInformationParser,
required super.routeInformationProvider,
required super.backButtonDispatcher,
});
}
Далее откроем main.dart и перепишем его код таким образом, чтобы за маршрутизацию отвечал только один объект — myRouterConfig:
// base_url/4/route_api/lib/main.dart
import 'package:flutter/material.dart';
import 'package:route_api/example/my_route_config.dart';
import 'package:route_api/example/my_route_information_parser.dart';
import 'package:route_api/example/my_route_information_provider.dart';
import 'package:route_api/example/my_route_delegate.dart';
final RouterConfig<Object> myRouterConfig = MyRouterConfig<Object>(
routerDelegate: MyRouterDelegate(),
routeInformationParser: MyRouteInformationParser(),
backButtonDispatcher: RootBackButtonDispatcher(),
routeInformationProvider: myRouteInformationProvider,
);
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: myRouterConfig,
);
}
4.6. Передача информации между экранами
При разработке приложений вы можете столкнуться с ситуацией, когда необходимо передать данные между экранами. В качестве таких данных может выступать
как простая строка, так и сложносоставной пользовательский класс. Хоть и нет
ограничений для передачи сложных объектов, лучше этого избегать. Старайтесь
не передавать объекты с помощью навигации. Хорошим тоном является передавать только строки.
Что касается способов передачи, их всего два: императивный и декларативный
(неожиданно, правда? 😉).
4.6. Передача информации между экранами 471
4.6.1. Императивная передача с помощью RouteSettings
При императивном подходе (Navigator.push) для передачи информации между
экранами следует использовать класс RouteSettings. Он позволяет передавать
посредством аргументов любой объект: примитивные данные, реализации классов и т. д. Но имейте в виду, что лучшим подходом будет передавать исключительно
строковые данные.
Создайте новый проект imperative_data_transfer и удалите все файлы из папки
test. На следующем шаге приведите структуру папки lib в соответствие с представленной далее:
imperative_data_transfer
├── lib/
│
├── screen1.dart
│
├── screen2.dart
│
├── test_data.dart
│
└── main.dart
├── test/
└── pubspec.yaml
Первый экран (Screen1) будет принимать от корневого экрана строку и выводить ее на текстовый виджет:
// base_url/4/imperative_data_transfer/lib/screen1.dart
import 'package:flutter/material.dart';
class Screen1 extends StatelessWidget {
const Screen1({super.key});
@override
Widget build(BuildContext context) {
// Получаем аргументы из RouteSettings
final args = ModalRoute.of(context)?.settings.arguments ?? 'No data';
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(args.toString()),
),
);
}
}
Что же касается второго экрана, сделаем так, чтобы он принимал экземпляр
пользовательского класса TestData от корневого экрана и выводил его данные на
виджет:
// base_url/4/imperative_data_transfer/lib/test_data.dart
class TestData {
final String data;
}
TestData({required this.data});
// base_url/4/imperative_data_transfer/lib/screen2.dart
import 'package:flutter/material.dart';
import 'test_data.dart';
class Screen2 extends StatelessWidget {
const Screen2({super.key});
472 Глава 4 Навигация
}
@override
Widget build(BuildContext context) {
// Получаем аргументы из RouteSettings
final args = ModalRoute.of(context)?.settings.arguments;
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(args = = null ? 'No data' : (args as TestData).data),
),
);
}
Теперь откройте файл main.dart, импортируйте реализованный ранее функционал и объявите корневой экран приложения, с которого и будем отправлять
данные открываемым экранам:
// base_url/4/imperative_data_transfer/lib/main.dart
import 'package:flutter/material.dart';
import 'test_data.dart';
import 'screen1.dart';
import 'screen2.dart';
class RootScreen extends StatelessWidget {
const RootScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Передать императивно данные на другой экран',
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) {
return const Screen1();
},
// Передаем строку в аргументах
settings: const RouteSettings(
arguments: 'Переданные данные из RootScreen',
),
));
},
child: const Text('Передать строку')),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) {
return const Screen2();
},
settings: RouteSettings(
arguments: TestData(data: 'Тестовые данные'),
),
));
},
4.6. Передача информации между экранами 473
child: const Text('Передать объект')),
const SizedBox(height: 32),
],
),
),
}
}
);
void main() {
runApp(MaterialApp(
home: const RootScreen(),
));
}
Итог приведен на рис. 4.11.
Рис. 4.11. Пример работы приложения
4.6.2. Декларативный подход к передаче информации
При декларативном подходе на уровне делегата (см. MyRouterDelegate) создается
метод, на вход которого совместно с маршрутом передаются данные. Для демонстрации этого варианта передачи данных между экранами нам потребуется немного модифицировать код из папки example из предыдущего раздела. Создайте
новый проект declarative_data_transfer, удалите все файлы из папки test. На следующем шаге приведите структуру папки lib в соответствие с представленной
далее и перенесите одноименные файлы с кодом из проекта предыдущего раздела,
разрешив конфликты импортирования, если таковые появились:
declarative_data_transfer
├── lib/
│
├── screens/
│
│
├── home_screen.dart
│
│
├── root_screen.dart
│
│
└── profile_screen.dart
│
├── router/
│
│
├── my_route_config.dart // без изменений
│
│
├── my_route_delegate.dart
│
│
├── my_route_information_parser.dart // без изменений
│
│
└── my_route_information_provider.dart //без изменений
│
├── test_data.dart // без изменений
│
└── main.dart // без изменений
├── test/
└── pubspec.yaml
474 Глава 4 Навигация
Для начала откройте файл my_route_delegate.dart. Нам предстоит добавить
в тело класса MyRouterDelegate новый метод navigateTo(), вынеся в него всю логику
из метода setNewRoutePath(path):
// base_url/4/declarative_data_transfer/…/my_route_delegate.dart
/// Навигация на другой экран с возможностью передачи аргументов
Future<void> navigateTo(String url, {String? args}) async {
Page? newPage;
final path = url;
if (path = = '/home') {
// Передаем аргументы в HomeScreen
newPage = MaterialPage(child: HomeScreen(date: args));
}
if (newPage ! = null) {
// конфигурируем новую страницу
// добавляем новую страницу в список
pages.add(newPage);
}
setNewRoutePath(path);
}
// Меняем реализацию метода на то, что приведено далее
@override
Future<void> setNewRoutePath(String configuration) async {
// Обновляем состояние
_currentPath = configuration;
if (configuration = = '/') {
// Если текущая страница корневая, то очищаем список
pages.clear();
}
}
// Обновляем состояние
notifyListeners();
Иногда бывает необходимо передать данные, которые будут храниться в самом
пути (например, /details/:id). Подготовим к такому стечению обстоятельств метод navigateTo, модифицировав его реализацию таким образом, чтобы появилась
возможность гибко обрабатывать абсолютно любой вариант передачи информации
между маршрутами:
Future<void> navigateTo(String url, {String? args}) async {
Page? newPage;
// Получаем путь до первого символа ":"
final path = url.split('/:').first;
if (path = = '/home') {
newPage = MaterialPage(child: HomeScreen(date: args));
} else if (path = = '/profile') {
// Получаем путь до первого символа ":" и получаем id
final id = url.split('/:').last;
// Передаем id в ProfileScreen
newPage = MaterialPage(child: ProfileScreen(id: id));
}
if (newPage ! = null) {
// конфигурируем новую страницу
// добавляем новую страницу в список
pages.add(newPage);
}
}
setNewRoutePath(path);
4.6. Передача информации между экранами 475
Далее реализуем экраны, в которые будут передаваться данные, — HomeScreen
и ProfileScreen:
// base_url/4/declarative_data_transfer/…/home_screen.dart
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, this.date});
final String? date;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(date ?? 'Нет переданных данных'),
),
);
}
// base_url/4/declarative_data_transfer/…/profile_screen.dart
import 'package:flutter/material.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key, this.id});
final String? id;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(id ?? 'Нет переданных данных'),
),
);
}
На последнем шаге займемся реализацией корневого экрана, из которого и будем
вызывать маршрут с передачей в него данных:
// base_url/4/declarative_data_transfer/…/root_screen.dart
import 'package:flutter/material.dart';
import '../router/my_route_delegate.dart';
class RootScreen extends StatelessWidget {
const RootScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Передать декларативно данные на другой экран',
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
476 Глава 4 Навигация
(Router.of(context).routerDelegate as MyRouterDelegate)
.navigateTo(
'/home',
args: 'Переданные данные из RootScreen',
);
},
child: const Text('Передать строку')),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () {
(Router.of(context).routerDelegate as MyRouterDelegate)
.navigateTo(
'/profile/:10',
);
},
child: const Text('Передать объект'),
),
],
),
),
}
}
);
Теперь можете запустить приложение. Визуально оно будет выглядеть как
и при императивной реализации (рис. 4.11), но мы-то с вами знаем, что в основе его
работы лежит совершенно другой подход к передаче информации между экранами
приложения. 😉
4.7. Вложенная навигация (nested navigation)
Довольно часто приложение будет содержать нижнее навигационное меню. Для этого Flutter предоставляет специальные виджеты BottomNavigationBar и NavigationBar.
Но возникает вопрос: «Как связать между собой эти виджеты и маршрутизацию
в приложении?» Ответу на него и посвящен текущий раздел главы.
4.7.1. Императивная реализация
Виджет BottomNavigationBar работает так же, как и TabBar. И для того, чтобы реа
лизовать вложенную навигацию, нам необходимо создать для каждого таба свой
стек навигации, доступ к которому будет осуществляться посредством глобальных
ключей.
Рассмотрим, как это будет выглядеть в коде. Создайте новый проект nested_
navigation и удалите все файлы из папки test. Затем приведите структуру папки lib
в соответствие с представленной далее:
nested_navigation
├── lib/
│
├── root_screen.dart
│
├── screens.dart
│
└── main.dart
├── test/
└── pubspec.yaml
4.7. Вложенная навигация (nested navigation) 477
В библиотеку
жения:
screens.dart
добавим реализацию различных экранов прило-
// base_url/4/nested_navigation/lib/screens.dart
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Center(
child: ElevatedButton(
child: const Text('Открыть детальный экран'),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) = > const DetailsScreen()),
);
},
),
));
}
class SearchScreen extends StatelessWidget {
const SearchScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Search')),
body: const Center(child: Text('Поиск')));
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: const Center(child: Text('Профиль пользователя')));
}
// Пример дополнительного экрана, чтобы показать,
// что на каждой вкладке есть свой независимый стек
class DetailsScreen extends StatelessWidget {
const DetailsScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Детали')),
body: const Center(
child: Text('Экран для детального описания'),
));
}
478 Глава 4 Навигация
Далее создадим корневой экран, который и будет содержать BottomNavigationBar:
// base_url/4/nested_navigation/lib/root_screen.dart
import 'package:flutter/material.dart';
import 'screens.dart';
/// "Корневой" виджет, который содержит BottomNavigationBar
class RootScreen extends StatefulWidget {
const RootScreen({super.key});
}
@override
State<RootScreen> createState() = > _RootScreenState();
class _RootScreenState extends State<RootScreen> {
/// Текущий индекс выбранной вкладки
int _selectedIndex = 0;
/// Список ключей навигаторов для каждой вкладки
final List<GlobalKey<NavigatorState>> _navigatorKeys = [
GlobalKey<NavigatorState>(), // для вкладки HomeScreen
GlobalKey<NavigatorState>(), // для вкладки SearchScreen
GlobalKey<NavigatorState>(), // для вкладки ProfileScreen
];
// Метод, который вызывается при нажатии на значок
// в BottomNavigationBar
void _onItemTapped(int index) {
// Если нажали на уже активную вкладку,
// сбрасываем ее стек на первый экран (popUntil isFirst).
if (index = = _selectedIndex) {
_navigatorKeys[index].currentState?.popUntil(
(route) = > route.isFirst,
);
} else {
setState(() {
_selectedIndex = index;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
_StackNavigation(_selectedIndex, 0, _navigatorKeys),
_StackNavigation(_selectedIndex, 1, _navigatorKeys),
_StackNavigation(_selectedIndex, 2, _navigatorKeys),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
4.7. Вложенная навигация (nested navigation) 479
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profile',
),
],
),
}
}
);
class _StackNavigation extends StatelessWidget {
const _StackNavigation(
this.selectedIndex,
this.index,
this.navigatorKeys,
);
final int selectedIndex;
final int index;
final List<GlobalKey<NavigatorState>> navigatorKeys;
@override
Widget build(BuildContext context) {
// Создаем отдельный Navigator для каждой вкладки,
// используя Offstage, чтобы скрывать остальные Navigators,
// когда активен только один.
return Offstage(
offstage: selectedIndex ! = index,
child: Navigator(
key: navigatorKeys[index],
onGenerateRoute: (RouteSettings settings) {
// Определяем, какой стартовый экран показывать на каждой вкладке
return switch (index) {
1 = > MaterialPageRoute(
builder: (context) = > const SearchScreen(),
),
2 = > MaterialPageRoute(
builder: (context) = > const ProfileScreen(),
),
_ = > MaterialPageRoute(
builder: (context) = > const HomeScreen(),
),
};
},
),
);
}
}
На последнем шаге откройте main.dart и добавьте в него следующий код:
// base_url/4/nested_navigation/lib/main.dart
import 'package:flutter/material.dart';
import 'root_screen.dart';
void main() = > runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
480 Глава 4 Навигация
}
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Пример вложенной навигации',
home: RootScreen(),
);
}
А теперь попробуем разобраться, что же происходит в нашем приложении, на
примере корневого экрана.
1. Мы создали список ключей _navigatorKeys, хранящий все глобальные ключи
типа NavigatorState.
2. Далее объявили виджет, реализующий стек навигации _StackNavigation.
В его методе build() использовали виджет Offstage, позволяющий убрать
дочерний виджет за пределы экрана (по сути, не отображать его), но при
этом не уничтожать.
3. И под конец добавили в виджет Stack виджеты _StackNavigation, а в виджет
Scaffold — BottomNavigationBar.
При обновлении состояния происходит изменение индекса, что запускает процесс обновления текущего стека навигатора.
4.7.2. Декларативная реализация
То, что мы получили при императивной реализации, можно сделать и при декларативном подходе. В этом случае для каждого стека (вкладки/таба) навигации будем
использовать свой список Page.
Создайте новый проект declarative_nested_navigation и удалите все файлы из
папки test. Затем приведите структуру папки lib в соответствие с представленной
далее:
declarative_nested_navigation
├── lib/
│
├── root_screen.dart
│
├── screens.dart
│
├── stack_navigation.dart
│
└── main.dart //без изменений относительно императивного подхода
├── test/
└── pubspec.yaml
Первым делом реализуем класс стека навигации StackNavigation, выделив его
в отдельную библиотеку stack_navigation.dart:
// base_url/4/declarative_nested_navigation/lib/stack_navigation.dart
import 'package:flutter/material.dart';
/// Виджет для навигации по стеку страниц
class StackNavigation extends StatelessWidget {
const StackNavigation({
super.key,
required this.index,
required this.selectedIndex,
4.7. Вложенная навигация (nested navigation) 481
required this.pages,
required this.onResetStack,
});
/// Индекс текущей вкладки
final int index;
/// Индекс выбранной вкладки
final int selectedIndex;
/// Список страниц
final List<Page> pages;
/// Обратный вызов для сброса стека
final VoidCallback onResetStack;
}
@override
Widget build(BuildContext context) {
return Offstage(
offstage: selectedIndex ! = index, // Скрываем, если не выбрана
child: Navigator(
// Список страниц
pages: List.of(pages),
// Метод, который вызывается при нажатии "назад"
onDidRemovePage: (Page<Object?> removedPage) {
// Если удаленная страница совпадает
// с верхней, обрабатываем удаление
if (pages.isNotEmpty && pages.last = = removedPage) {
pages.removeLast();
// Сбрасываем стек
onResetStack();
}
},
),
);
}
Ключевое отличие файла со вспомогательными экранами будет заключаться
не только в изменении их структуры, но и в том, что файл screens.dart с помощью
механизма part of станет частью корневого экрана root_screen.dart:
// base_url/4/declarative_nested_navigation/lib/screens.dart
part of 'root_screen.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home (Declarative)')),
body: Center(
child: ElevatedButton(
onPressed: () {
// Находим родительский виджет типа _RootScreenState
final state = context.findAncestorStateOfType<_RootScreenState>();
// Добавляем новую страницу в стек
state?._homePages.add(
MaterialPage(
key: UniqueKey(), // ключ, чтобы страница была уникальной
482 Глава 4 Навигация
child: const DetailsScreen(
title: 'Детали (из Home)',
),
),
);
state?.setState(() {});
},
child: const Text('Открыть детальный экран'),
),
),
}
}
);
class SearchScreen extends StatelessWidget {
const SearchScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Search (Declarative)')),
body: Center(
child: ElevatedButton(
onPressed: () {
// Находим родительский виджет типа _RootScreenState
final state = context.findAncestorStateOfType<_RootScreenState>();
// Добавляем новую страницу в стек
state?._searchPages.add(
MaterialPage(
key: UniqueKey(),
child: const DetailsScreen(
title: 'Детали (из Search)',
),
),
);
state?.setState(() {});
},
child: const Text('Открыть детальный экран'),
),
),
);
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile (Declarative)')),
body: Center(
child: ElevatedButton(
onPressed: () {
// Находим родительский виджет типа _RootScreenState
final state = context.findAncestorStateOfType<_RootScreenState>();
// Добавляем новую страницу в стек
state?._profilePages.add(
MaterialPage(
key: UniqueKey(),
child: const DetailsScreen(
title: 'Детали (из Profile)',
),
),
);
4.7. Вложенная навигация (nested navigation) 483
state?.setState(() {});
},
child: const Text('Открыть детальный экран'),
),
),
}
}
);
class DetailsScreen extends StatelessWidget {
final String title;
const DetailsScreen({super.key, required this.title});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: const Center(child: Text('Детальная информация')),
);
}
Далее займемся самим корневым экраном RootScreen:
// base_url/4/declarative_nested_navigation/lib/root_screen.dart
import 'package:flutter/material.dart';
import 'stack_navigation.dart';
part 'screens.dart';
class RootScreen extends StatefulWidget {
const RootScreen({super.key});
}
@override
State<RootScreen> createState() = > _RootScreenState();
class _RootScreenState extends State<RootScreen> {
// Текущий индекс выбранной вкладки
int _selectedIndex = 0;
// Для каждой вкладки храним собственный список Page
final List<Page> _homePages = [
const MaterialPage(
child: HomeScreen(),
key: ValueKey('HomeRootPage'),
),
];
final List<Page> _searchPages = [
const MaterialPage(
child: SearchScreen(),
key: ValueKey('SearchRootPage'),
),
];
final List<Page> _profilePages = [
const MaterialPage(
child: ProfileScreen(),
key: ValueKey('ProfileRootPage'),
),
];
484 Глава 4 Навигация
// Метод для сброса стека (например, при повторном
// щелчке на значке вкладки)
void _resetStack(int index) {
setState(() {
switch (index) {
case 0:
_homePages
..clear()
..add(const MaterialPage(
child: HomeScreen(),
key: ValueKey('HomeRootPage'),
));
break;
case 1:
_searchPages
..clear()
..add(const MaterialPage(
child: SearchScreen(),
key: ValueKey('SearchRootPage'),
));
break;
case 2:
_profilePages
..clear()
..add(const MaterialPage(
child: ProfileScreen(),
key: ValueKey('ProfileRootPage'),
));
break;
}
});
}
void _onItemTapped(int index) {
if (index = = _selectedIndex) {
// Если снова нажали на активную вкладку, сбрасываем стек
_resetStack(index);
} else {
setState(() {
_selectedIndex = index;
});
}
}
@override
Widget build(BuildContext context) {
// Список страниц для текущей вкладки
final listPages = switch (_selectedIndex) {
0 = > _homePages,
1 = > _searchPages,
2 = > _profilePages,
_ = > throw Exception('Unknown index: $_selectedIndex'),
};
return Scaffold(
body: Stack(
children: [
for (var i = 0; i < 3; i++)
StackNavigation(
index: i,
selectedIndex: _selectedIndex,
pages: listPages,
Проект: игра «Тетрис» v. 4. Добавление навигации 485
onResetStack: () = > _resetStack(i),
),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profile',
),
],
),
}
}
);
На последнем шаге перенесите в main.dart код из примера реализации императивной вложенной навигации, и приложение будет готово к запуску.
Проект: игра «Тетрис» v. 4. Добавление навигации
Пришла пора прокачать «Тетрис» и добавить ему несколько экранов — стартовый,
основной и экран подведения итогов, как представлено на рис. 4.12.
Рис. 4.12. Пример работы приложения
486 Глава 4 Навигация
Важно отметить, что нет смысла и необходимости использовать в игре навигацию.
Это связано с тем, что всей логикой игр и виджетами следует управлять в контексте
самого игрового цикла, так, например, мы добавили виджет отображения набранных очков при завершении игры. Но, так как перед нами стоит задача обучения
фреймворку Flutter, закроем глаза на это нравоучение. 😉
Первым делом в папке lib создадим каталог screens, а в нем — три файла:
y game_over_screen.dart — экран для отображения набранных очков и перезапуска игры;
y game_screen.dart — экран, где будет реализована сама игра;
y main_menu_screen.dart — экран с отображением главного меню игры.
Самый простой из перечисленных экранов — game_screen.dart. Его и реализуем
первым. Здесь не будет никакой логики, просто вернем в методе build() виджет
TetrisGame:
// base_url/4/tetris/lib/screens/game_screen.dart
import 'package:flutter/material.dart';
import 'package:tetris/tetris_game.dart';
/// Экран игры
class GameScreen extends StatelessWidget {
const GameScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(body: TetrisGame());
}
Далее добавим реализацию экрана с главным меню игры:
// base_url/4/tetris/lib/screens/main_menu_screen.dart
import 'package:flutter/material.dart';
import 'package:tetris/main.dart';
/// Главное меню игры
class MainMenuScreen extends StatelessWidget {
const MainMenuScreen({super.key});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () {
// Переход на экран игры
Navigator.pushReplacementNamed(
context,
GameRouter.gameRoute,
);
},
child: Text('Начать игру')),
));
}
Проект: игра «Тетрис» v. 4. Добавление навигации 487
Последний экран, GameOverScreen, тоже не представляет собой ничего сложного.
На нем выведем количество заработанных очков, переданное с помощью механизма
навигации, и добавим переход на экран GameScreen, если пользователь решит перезапустить игру:
// base_url/4/tetris/lib/screens/game_over_screen.dart
import 'package:flutter/material.dart';
import 'package:tetris/game_scores.dart';
import 'package:tetris/main.dart';
/// Экран окончания игры
class GameOverScreen extends StatelessWidget {
const GameOverScreen({super.key});
@override
Widget build(BuildContext context) {
// Получаем заработанные очки, переданные в маршрут
final args = ModalRoute.of(context)?.settings.arguments;
final scores = int.tryParse(args.toString()) ?? 0;
}
}
return Scaffold(
body: GameScores(
score: scores,
onRestart: () {
// Переход на экран игры
Navigator.pushReplacementNamed(
context,
GameRouter.gameRoute,
);
}));
Экраны готовы, приступим к реализации GameRouter. Для этого в папке lib рядом
с main.dart создайте новый файл — game_router.dart, который будет представлять
собой его расширение посредством механизма part of. Для удобства и исключения
ошибок в названиях маршрутов объявим их с помощью статических констант.
Далее для хранения именованных маршрутов объявим таблицу Map<String, WidgetBuilder> _appRoutes:
// base_url/4/tetris/lib/game_router.dart
part of 'main.dart';
// Маршрут игры
abstract final class GameRouter {
// Начальный маршрут
static const String initialRoute = '/';
// Маршрут игры
static const String gameRoute = '/game';
// Маршрут окончания игры
static const String gameOverRoute = '/game_over';
// Маршруты приложения. Объявляются приватными,
// чтобы исключить доступ к ним вне навигатора
static final Map<String, WidgetBuilder> _appRoutes = {
488 Глава 4 Навигация
}
};
// Стартовый экран — главное меню
initialRoute: (_) = > const MainMenuScreen(),
// Экран игры
gameRoute: (_) = > const GameScreen(),
// Экран окончания игры
gameOverRoute: (_) = > const GameOverScreen(),
На следующем шаге инициализируем роутер в виджете MaterialApp:
// base_url/4/tetris/lib/main.dart
import 'package:flutter/material.dart';
import 'package:tetris/screens/game_over_screen.dart';
import 'package:tetris/screens/game_screen.dart';
import 'package:tetris/screens/main_menu_screen.dart';
part 'game_router.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
initialRoute: GameRouter.initialRoute,
routes: GameRouter._appRoutes,
);
}
Осталось немного поправить виджеты игры, реализованные в предыдущей главе. И начнем с GameScores. Чтобы заработанные очки отображались более красиво,
обернем виджеты тела (body) Scaffold в виджет Center:
// base_url/4/tetris/lib/game_scores.dart
import 'package:flutter/material.dart';
// Виджет для отображения количества набранных
// очков и кнопки для перезапуска игры после
// ее завершения
class GameScores extends StatelessWidget {
// поля и конструктор класса без изменений
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// код без изменений
],
),
),
);
}
Проект: игра «Тетрис» v. 4. Добавление навигации 489
Следующим под наш косметический каток попадет класс Game. Удалим из него
уведомления слушателей в методе gameOver(), заменив последний вызовом callbackфункции onGameOver(String scores):
// base_url/4/tetris/lib/src/game.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'blocks/blocks.dart';
import 'board.dart';
final class Game extends ChangeNotifier {
// поля и конструктор класса без изменений
void gameOver() {
_isGameOver = true;
// // Уведомляем слушателей об окончании игры
// notifyListeners();
onGameOver(score.toString());
}
}
// остальные методы без изменений
Вот мы и добрались до последнего модифицируемого класса — TetrisGame.
В его метод initState() , в обратный вызов onGameOver , необходимо добавить
переход на экран завершения игры. Из метода build() удалим условие для отображения виджета GameScores и добавим в нем на экран игры текущее количество
набранных очков. Для этого обернем CustomPaint в Column → Expanded и добавим
виджет Text:
// base_url/4/tetris/lib/tetris_game.dart
import 'dart:math';
import
import
import
import
import
'package:flutter/material.dart';
'package:flutter/services.dart';
'package:tetris/main.dart';
'package:tetris/src/board.dart';
'package:tetris/src/game.dart';
/// Реализация игры "Тетрис"
class TetrisGame extends StatefulWidget {
const TetrisGame({super.key});
}
@override
State<TetrisGame> createState() = > _TetrisGameState();
class _TetrisGameState extends State<TetrisGame> {
late Game game;
@override
void initState() {
super.initState();
game = Game(
onGameOver: (scores) {
// Переход на экран окончания игры
// Передаем scores в аргументах
Navigator.pushReplacementNamed(
context,
490 Глава 4 Навигация
GameRouter.gameOverRoute,
arguments: scores,
);
},
}
);
game.start();
@override
Widget build(BuildContext context) {
// Добавляем слушатель для обновления состояния виджета
return ListenableBuilder(
// Передаем игру в качестве объекта, реализующего Listenable
listenable: game,
// Перестраиваем виджет при изменении состояния игры
builder: (context, _) {
if (game.isGameOver) {
return Center(
child: GameScores(
score: game.score,
onRestart: () {
// Перезапускаем игру
game.restart();
},
),
);
}
return Focus(
autofocus: true,
onKeyEvent: (FocusNode node, KeyEvent event) {
// Обработка нажатий клавиш
// Обрабатываем как нажатие, так и удержание клавиши
if (event is KeyDownEvent || event is KeyRepeatEvent) {
game.board.keyboardEventHandler(event.logicalKey.keyId);
return KeyEventResult.handled;
}
// Если событие не обработано, возвращаем ignored
return KeyEventResult.ignored;
},
child: Align(
alignment: Alignment.center,
// Получаем размеры виджета
child: LayoutBuilder(
builder: (context, constraints) {
final board = game.board.mainBoard;
// Вычисляем размер клетки поля
double blockSize = min(
constraints.maxWidth / board[0].length,
constraints.maxHeight / board.length);
return Column(
children: [
Expanded(
child: CustomPaint(
painter: _GamePainter(board, blockSize),
size: Size(board[0].length * blockSize,
board.length * blockSize),
),
),
// Отображение текущего счета
Text('Очки: ${game.score}',
style: TextStyle(fontSize: 24)),
],
Резюме 491
);
},
),
),
);
},
}
}
);
// Класс отрисовки игрового поля
class _GamePainter extends CustomPainter {
// без изменений
}
Теперь можете запустить игру и насладиться полученным результатом.
Задания на модификацию проекта
В следующий раз мы добавим в приложение функционал работы по сети. А пока
можете выполнить следующие задания по внесению изменений в существующую
кодовую базу, используя знания, полученные в ходе этой главы.
1. Добавьте в главное меню выбор уровня сложности (скорости падения блоков)
перед стартом игры.
2. Доработайте функционал таким образом, чтобы рядом с игровым полем отображалась фигура, которая появится следующей.
3. Добавьте на игровое поле новую фигуру.
4. Дайте пользователю возможность выбирать фигуры блоков, которые будут
использоваться в игре. Минимальное количество блоков разного типа — 3.
5. Добавьте вариативность цветовой палитры у игровых блоков (2–4 цвета).
Резюме
В этой главе мы рассмотрели, как устроена навигация во Flutter и какие инструменты имеются для ее организации в приложении, когда нет необходимости
прибегать к внешней зависимости в виде библиотеки auto_route или go_route.
Наличие двух типов навигации и различных вариантов их организации наложило
на эту тему во Flutter-сообществе некоторую тень таинственности и породило примету: без наличия огромных запасов алкоголя лучше не погружаться в тонкости
работы встроенных механизмов навигации. Тем не менее на собеседованиях то
и дело всплывают вопросы о различиях первого и второго механизмов навигации,
о том, какие классы при этом задействованы, и т. д. Да что там говорить, те же
самые навигационные библиотеки не способны охватить все варианты навигации, из-за чего разработчикам приходится закатывать рукава и реализовывать
их самостоятельно.
Ну а то, сколько и чего было нами выпито при написании этой главы, пусть
останется тайной. 😉
492 Глава 4 Навигация
Вопросы для самопроверки
1. В чем разница между императивной и декларативной навигацией? Какой из
механизмов навигации Flutter их реализует?
2. Перечислите основные элементы навигации во Flutter. За что они отвечают?
3. Что такое именованные маршруты? Как с ними работать?
4. Какие существуют способы реализации в приложении навигации без контекста?
5. Как во Flutter работает декларативная навигация? Какие классы при этом
задействованы?
6. Как в приложении организовать передачу информации между экранами?
7. Что такое вложенная навигация (nested navigation)? Зачем она нужна и как
реализуется?
Глава 5
РАБОТА С СЕТЬЮ
Большинство приложений, установленных на вашем телефоне, работают только
при подключении к Интернету. Онлайн-банкинг, мессенджеры, социальные сети
и видеохостинги просто не могут существовать без него. Даже приложение с заметками предлагает вам создать аккаунт и синхронизировать данные на нескольких
устройствах через Интернет (организовать удаленное хранилище, как они любят
это называть). Так что если собираетесь отдать всего себя разработке мобильных
приложений в современных реалиях, то знайте: без навыка работы с Сетью это
просто невозможно.
В текущей главе мы разберемся в том, что такое клиент-серверная архитектура,
Application Programming Interface (API) и HTTP-запросы, как осуществлять эти
запросы во Flutter-проекте и напоследок — как реализовать механизм взаимодействия вашего приложения с бэкендом с использованием веб-сокетов. Проверьте
наличие антипригарного покрытия на кресле, запаситесь попкорном — и поехали!
5.1. Клиент-серверная архитектура
Клиент-серверная архитектура — модель взаимодействия, где одна сторона (клиент) отправляет запросы, а другая сторона (сервер) обрабатывает их и отправляет
ответы (рис. 5.1).
Рис. 5.1. Клиент-серверная архитектура
494 Глава 5 Работа с сетью
Клиент — программа или устройство, которое начинает взаимодействие с сервером и запрашивает информацию или услуги. Это, например, браузер, который
запрашивает загрузку страницы, или, как в нашем случае, мобильное приложение,
запрашивающее информацию для отображения на экране. Различают два вида
клиентов: толстый и тонкий. В первом случае большинство операций по обработке
данных (вычисление, преобразования и т. д.) производится на стороне клиента, а во
втором все операции над данными выносятся на сервер.
Под сервером принято понимать программу или устройство, которые обрабатывают запросы клиентов. Он принимает запрос, выполняет нужные операции,
например, извлекает данные из базы и отправляет результат обратно клиенту.
5.1.1. API
Чтобы обеспечить упорядоченное стандартизированное общение между клиентом
и сервером, используется Application Programming Interface (API). Он определяет
набор правил и методов, с помощью которых клиенты будут взаимодействовать
с сервером. При таком подходе приложение знает конкретные названия удаленных
методов сервера и вызывает их при наступлении определенных событий в соответствии со своей бизнес-логикой. Другими словами, API — это своего рода контракт —
гарантия того, что на запрос клиента будет приходить строго определенный набор
ответов от сервера (рис. 5.2).
Рис. 5.2. Application Programming Interface
5.1.2. HTTP-запросы
Чаще всего при разработке клиент-серверных приложений используется REST API.
REST — это стиль архитектуры, который базируется на шести ключевых принципах:
клиент-серверной архитектуре, отсутствии состояния (stateless), кэшировании,
унифицированном интерфейсе, слоистой системе и возможности выполнения кода
на стороне клиента (по желанию).
При таком архитектурном подходе для взаимодействия между клиентом и сервером применяется HTTP-протокол, где за выполнение определенных операций
отвечают HTTP-методы. В табл. 5.1 приведены наиболее широко распространенные
из них.
5.1. Клиент-серверная архитектура 495
Таблица 5.1. Самые широко используемые HTTP-методы
Метод
GET
POST
PUT
PATH
DELETE
Описание
Запрашивает данные с сервера
Отправляет данные на сервер для создания нового
ресурса. Вызывает изменение состояния или какие-то
побочные эффекты на сервере. Используется для
создания новых сущностей
Если ресурс существует, то данный запрос заменит его
данные. В противном случае будет создан новый ресурс
(запись в базе данных).
Используется для обновления данных сущности
Частично обновляет данные существующего ресурса.
Используется для обновления данных сущности.
Но в отличие от PUT нужно передавать не все данные модели, а только те поля, которые требуется
заменить
Удаляет ресурс на сервере. То есть используется для
того, чтобы полностью очистить данные о какой-то
сущности
Пример использования
GET /users/ — получает список пользователей из базы данных
POST /users/ — создает нового пользователя
в базе данных
PUT /users/ — обновляет данные о пользо-
вателе или создает нового пользователя
PATH /users/:id — обновляет некоторые
поля данных пользователя с ID, равным переданному в маршруте запроса, например только
имя или фамилию
DELETE /users/:id — удаляет пользователя
с ID, равным переданному в маршруте запроса
Существуют и другие методы, которые вы нечасто будете видеть в коде Flutterприложений. Но знать об их существовании все равно нужно как минимум для
того, чтобы в ходе собеседования блеснуть своей эрудицией, отвечая на каверзный
вопрос. Это методы HEAD, OPTIONS и CONNECT. В контексте HTTP-протокола их называют служебными методами, ввиду чего они задействуются для решения специа
лизированных задач и имеют особое предназначение, отличное от предназначения
рассмотренных ранее. В табл. 5.2 приведены их описание и примеры использования.
Таблица 5.2. Редко используемые HTTP-методы
Метод
HEAD
OPTIONS
CONNECT
Описание
Запрашивает только заголовки ответа без тела и полезен для получения метаинформации о ресурсе, например для проверки его существования, проверки заголовков и получения информации о типе содержимого
Определяет, какие HTTP-методы поддерживаются для
конкретного ресурса. Полезен для получения информации о поддерживаемых действиях и разрешенных
методах, особенно в контексте кросс-доменных запросов (CORS)
Устанавливает туннель для связи с удаленным сервером
через прокси-сервер
Пример использования
HEAD /users/1 — проверка заголовков ответа на запрос списка пользователей
OPTIONS /users — получение списка поддерживаемых методов для ресурса /users
CONNECT www.example.com:443 — установка туннеля к серверу www.example.com
на порте 443 (HTTPS)
496 Глава 5 Работа с сетью
5.1.3. HTTP-статусы ответов сервера
В HTTP-протоколе статусы ответов от сервера указывают на результат выполнения
запроса клиента. Каждый статус-код состоит из трех цифр, где первая цифра определяет категорию ответа. Для дальнейшего погружения в работу с HTTP-запросами
нам нужно познакомиться с основными из них.
Если разделить HTTP-статусы по назначению, то получатся вот такие группы:
y информационные (100–105);
y успешные (200–226);
y перенаправление (300–307);
y ошибка клиента (400–499);
y ошибка сервера (500–510).
Теперь кратко разберем самые популярные статусы, которые вы чаще всего
будете встречать при разработке клиент-серверных мобильных приложений.
y 200 OK — запрос успешно обработан и сервер отправляет запрашиваемые
данные в теле ответа.
y 201 Created — запрос успешно выполнен и в результате был создан новый ресурс.
y 400 Bad Request — запрос неверный или не может быть обработан сервером
из-за ошибок в синтаксисе. Возникает в том случае, когда ошибка произошла
по вине клиента: какие-то данные не были переданы или переданы с ошибкой
в названии аргумента.
y 401 Unauthorized — запрос требует аутентификации. Клиент должен предоставить правильные учетные данные для доступа к ресурсу.
y 403 Forbidden — у клиента не хватает прав доступа для получения данных
этого ресурса. Например, в админ-панель сайта мы даем допуск только администраторам.
y 404 Not Found — запрашиваемый ресурс не найден на сервере.
y 405 Method Not Allowed — метод не поддерживается целевым ресурсом. Напри
мер, вы вызвали метод DELETE, а для этого ресурса он запрещен, так как работать с ним можно только с помощью метода GET.
y 500 Internal Server Error — на сервере произошла ошибка, в результате
которой он не может успешно обработать запрос. Проще говоря, бэкендер
накосячил, и это теперь не наша проблема — идем пить кофе. 😉
y 501 Not Implemented — метод запроса не поддерживается сервером и поэтому
не может быть обработан.
y 502 Bad Gateway — сервер, через который идет запрос, не может получить ответ
от другого сервера. Это как если бы один сервер пытался передать сообщение
другому, но тот не ответил.
y 503 Service Unavailable — в данный момент или временно сервер не может
обработать запрос. В качестве аналогии представьте, что сервер занят или
на техническом обслуживании и попросил зайти позже.
5.2. Встроенный инструмент Flutter для работы с HTTP 497
5.2. Встроенный инструмент Flutter для работы с HTTP
Чаще всего вы будете работать с удаленными серверами, бэкендами и API посредством протокола HTTP, используя какую-нибудь стороннюю библиотеку. Почему
так? Попробуем с этим разобраться в текущем разделе книги.
5.2.1. Стандартная библиотека dart:io
Фреймворк Flutter «из коробки» предоставляет разработчикам стандартный
механизм работы с HTTP-вызовами — встроенную библиотеку dart:io. Самый
большой плюс такого подхода заключается в том, что вам не нужно устанавливать
дополнительные зависимости в проект. В чем же минус? Ну… для этого надо поближе познакомиться с возможностями dart:io по работе с HTTP. И начнем мы
его с реализации получения данных пользователя с id=1 из сервиса JSONPlaceholder,
который предоставляет возможность доступа к бесплатным фейковым данным для
тестирования и прототипирования API:
import 'dart:io';
import 'dart:convert'; // Для декодирования JSON
void main(){
fetchUsersList();
}
void fetchUsersList() async {
// Создаем URI для запроса в API сервиса
final uri = Uri.parse(
'https://jsonplaceholder.typicode.com/users/1'
);
// Выполняем запрос
final response = await HttpClient().getUrl(uri).then(
(request) = > request.close());
// Проверяем статус-код ответа
if (response.statusCode = = HttpStatus.ok) {
// Читаем данные из ответа и трансформируем в Map<String, dynamic>
final responseBody = await response.transform(utf8.decoder).join();
final data = jsonDecode(responseBody);
}
print('Response data: $data');
} else {
print('Failed to load data');
}
В приведенном примере мы сначала создаем экземпляр класса Uri, который
хранит ссылку на запрашиваемый ресурс, после чего создаем экземпляр HttpClient
и вызываем его метод getUrl (реализует HTTP-метод GET) с передачей параметра uri
(только что созданная нами ссылка). Когда получен ответ от сервиса, его результат
записывается в переменную типа данных HttpClientResponse — response. Далее
мы проверяем HTTP-статус ответа на запрос и, если статус подходящий, вручную
498 Глава 5 Работа с сетью
трансформируем его из массива байтов сначала в JSON-строку, а после в формат
Map<String, dynamic>. Это необходимо, чтобы в дальнейшем было удобно работать
с полученными данными в нашем приложении.
5.2.2. Ограничения и проблемы dart:io
Согласитесь, подход с использованием встроенной библиотеки dart:io воспринимается так, как будто кто-то хочет заставить нас страдать, принуждая таким образом
обрабатывать простой HTTP-запрос. Кроме того, у использования этой библиотеки
есть и другие темные стороны.
y Уровень абстракции. Встроенная библиотека dart:io предоставляет разработчику инструмент для низкоуровневой работы с HTTP-запросами, что
сложнее по сравнению с использованием высокоуровневых библиотек.
y Платформенные ограничения. Встроенная библиотека dart:io работает
только в среде Dart VM и не поддерживается в браузере. Для веб-приложений
потребуются другие способы работы с HTTP-запросами, такие как package:web
(https://pub.dev/packages/web).
Несмотря на то что dart:io позволяет работать с сетевыми запросами без сторонних библиотек, для большинства приложений будет куда удобнее и функцио
нальнее задействовать специализированные библиотеки. По этой причине для
упрощения кода взаимодействия с HTTP-запросами инженеры из команды Google,
занимающиеся экосистемой dart, разработали пакет-обертку package:http (https://
pub.dev/packages/http). Его применение нивелирует вопросы с деталями реализации,
переключая весь фокус на бизнес-логику вашего приложения.
5.3. Пакет (библиотека) HTTP
Хотя этот пакет представляет собой стандарт для большинства приложений и более продвинутых HTTP-клиентов, таких как dio, retrofit и chopper, он не входит
в состав фреймворка и может рассматриваться как дополнение к встраиваемой
библиотеке dart:io, предоставляющее более удобные механизмы работы с HTTPзапросами. Поэтому для начала работы с пакетом http нужно установить его как
зависимость в файле проекта pubspec.yaml. Делается это просто. Можно вручную
добавить пакет http: <latest–version> в тег dependencies файла pubspec.yaml
(см. пример далее) и загрузить зависимости, вызвав команду:
flutter pub get
либо прописать в консоль команду:
flutter pub add http
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
http: 1.2.2 # укажите актуальную версию
5.3. Пакет (библиотека) HTTP 499
5.3.1. Первый GET-запрос в сеть
В качестве первого примера использования пакета http перепишем предыдущее приложение, где GET-запрос был выполнен средствами встроенной библиотеки dart:io.
Так мы сможем познакомиться с основными возможностями пакета и сравнить
два подхода — с http и без него.
Для начала заменим импорт:
import 'dart:io';
import 'dart:convert';
// Для работы с JSON
на:
import 'package:http/http.dart' as http;
import 'dart:convert'; // Для работы с JSON
Поскольку библиотека http поставляет высокоуровневые функции, которые при
ее импорте будут напрямую доступны в клиентском коде, разумнее всего экранировать к ним доступ с помощью объекта-имени http, воспользовавшись ключевым
словом as . При таком подходе любое обращение к функциям импортируемой
библиотеки будет осуществляться через заданное имя, например http.get('url'),
http.post('url'), http.delete('url').
Функцию main оставим без изменений, а вот тело функции fetchUsersList перепишем с dart:io на http:
void fetchUsersList() async {
// URL, на который будет отправлен запрос
final url = Uri.parse(
'https://jsonplaceholder.typicode.com/users/1'
);
// Выполнение GET-запроса
final response = await http.get(url);
}
// Проверка успешности запроса
if (response.statusCode = = 200) {
// Декодирование ответа из JSON
final data = jsonDecode(response.body);
print('Data: $data');
return;
}
print('Failed to load data');
Как и в предыдущем случае, сначала мы создаем экземпляр класса Uri, который
хранит ссылку на запрашиваемый ресурс. А дальше настает звездный час библиотеки
http. Используя ее функцию верхнего уровня get, передаем в нее uri — параметр
маршрута из API и совершаем асинхронный запрос на сервис, после чего дожидаемся ответа и проверяем его статус-код. Если он равен 200, то есть все отработало
правильно, данные из параметра тела запроса body декодируются с помощью метода jsonDecode библиотеки convert и выводятся в консоль. В противном случае
в консоль будет выведено сообщение об ошибке.
Все операции с сетью во Flutter изначально асинхронные. Это связано с тем,
что сетевые запросы могут занимать значительное время из-за непредсказуемости
500 Глава 5 Работа с сетью
сети, задержек, обработки на сервере и других факторов. Если бы они выполнялись
синхронно, то есть блокировали выполнение программы до получения ответа, это
могло бы привести к зависанию или замедлению работы приложения. В отличие
от них асинхронные операции позволяют приложению выполнять другие задачи,
пока ожидается ответ от сервера.
5.3.2. Обработка ответа сервера
Результатом работы функции get да и всех остальных функций библиотеки http
является объект типа Response, содержащий всю информацию, возвращаемую сервером. В табл. 5.3 приведены его основные поля и их описание.
Таблица 5.3. Поля Response
Имя поля
Описание
body
Представляет собой строку типа String и содержит тело ответа (response body),
который сервер отправил в ответ на наш запрос. Чаще всего это строка в формате
JSON, которую нужно конвертировать в Map<String, dynamic> для дальнейшей
работы данными
statusCode
HTTP-статус-код, указывающий на результат запроса (200, 201, 404 и т. д.)
headers
Заголовки ответа сервера содержат метаинформацию о данных, такую как тип содержимого (Content–Type), длина содержимого (Content–Length), сведения
о сервере, дата ответа (Date), и другую служебную информацию
persistentConnection
Булево значение, указывающее, поддерживает ли соединение постоянное подключение (keep–alive)
isRedirect
Булево значение, указывающее, является ли ответ перенаправлением
contentLength
Длина содержимого ответа в байтах. Если длина неизвестна, возвращает null
Все эти поля могут быть полезны при написании бизнес-логики приложения.
Так, например, по данным в поле statusCode вы можете понять, с каким результатом
завершилась обработка вашего запроса. И если в ответе пришел статус-код 401,
нужно показать экран авторизации, а если 404, то экран «Такого пользователя нет
в базе». Когда необходимо подсчитать количество килобайтов интернет-трафика,
которое тратит ваше приложение на работу с API, можно суммировать значение
поля contentLength всех ответов сервера.
Но самое важное все-таки поле body. Оно хранит в себе всю полезную нагрузку, все данные, которые передает нам в ответе сервер. И для дальнейшей работы
с этими данными: отображения, редактирования, изменения — нам потребуется
перевести их из строкового представления в модели приложения, но этим займемся в следующем разделе. А пока выведем получаемые от сервера данные на экран
приложения, чтобы удостовериться в правильной реализации работы метода их
получения — _fetchUsersList().
5.3. Пакет (библиотека) HTTP 501
Для работы с последующими примерами главы вам потребуется скачать репозиторий книги и запустить проект backend из каталога с номером 5. Чтобы
сделать это, установите dart_frog_cli, введя в терминал следующую команду:
dart pub global activate dart_frog_cli
Добавьте в переменные среды в Path C:\Users\<USER_NAME> \AppData\Local\Pub\
Cache\bin, после чего откройте в VS Code проект backend и введите в терминал:
dart_frog dev
Во всех последующих примерах при организации запроса вместо https://
[changedomain] используйте http://localhost:8080, а для Flutter-проектов — отдельный инстанс VS Code.
Для этого создадим новый проект и реализуем максимально простой пример
проверки работоспособности метода _fetchUsersList(). После нажатия кнопки
будет отправляться запрос на сервер для получения данных пользователя с id=1.
Если они получены, то будут выведены на виджет Text, в противном случае, когда
_userData = = null, выведем «Данных нет» (рис. 5.3):
// base_url/5/http_request/lib/main.dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
void main() = > runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return const MaterialApp(home: HomePage());
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
}
@override
State<HomePage> createState() = > _HomePageState();
class _HomePageState extends State<HomePage> {
Map<String, dynamic>? _userData;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
502 Глава 5 Работа с сетью
onPressed: _fetchUsersList,
child: const Text('Получить пользователя'),
),
const SizedBox(height: 8),
Text(
_userData ! = null ?
_userData!.toString()
: 'Данных нет')
],
),
),
}
}
);
void _fetchUsersList() async {
final url = Uri.parse('https://[changedomain]/users/1');
final response = await http.get(url);
if (response.statusCode = = 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
setState(() = > _userData = data);
return;
}
}
Рис. 5.3. Пример отображения ответа от сервера
5.3.3. Парсинг JSON-моделей
В предыдущем разделе мы сделали запрос к серверу (GET /users/1), чтобы получить данные о пользователе с id=1. В ответе получили statusCode 200. Значит, все
хорошо. Затем декодировали поле body в Map<String, dynamic> с помощью метода
jsonDescode. Но что теперь? Если оставить все как есть и каким-то образом работать с этим неструктурированным набором данных, изменять значения полей,
наблюдая за «счастливой жизнью» приложения, мы рискуем нарваться на целый
ряд неприятностей.
5.3. Пакет (библиотека) HTTP 503
y Отсутствие типизации. Утрата статической типизации приводит к ошибкам,
которые проявляются только в runtime.
y Неопределенная структура данных. В Map могут отсутствовать данные по
нужному нам ключу, что усложняет работу с данными.
y Сложности поддержки. Код становится менее организованным, его сложнее
поддерживать и расширять.
y Низкая производительность. Обращение к значениям в Map медленнее, чем
к полям объектов.
y Ограничение возможностей. Многие виджеты и API Flutter ориентированы
на работу с объектами, а не с Map.
Поэтому для работы с данными в Dart- и Flutter-приложениях принято создавать
модели. Речь идет о данных, получаемых из сети, локального хранилища, различных
баз данных, файлов… в общем, из любого источника.
Данные, которые мы получаем в ответ на HTTP-запросы, чаще всего приходят от
сервера в формате JSON. Для того чтобы эффективно работать с ними в приложении,
их необходимо преобразовать в удобные модели (классы данных) Dart. Этот процесс
называется парсингом (преобразованием) JSON. Он позволяет легко и безопасно
обращаться к данным, используя преимущества статической типизации и других
инструментов, которые делают код лучше поддерживаемым и более надежным.
Под моделью будем подразумевать класс, который должен хранить в себе данные, пришедшие в ответе от сервера. Поэтому в нем будут дублироваться имена
полей из JSON:
{
}
"id": 1,
"name": "Алексей",
"surname": "Иванович",
"lastname": "Смирнов",
"email": "alexey.smirnov@example.com",
"age": 32,
"height": 180.5,
"weight": 75.0
Такие данные о пользователе с id = 1 мы получаем, сделав HTTP-запрос GET /users/1.
Если сделаете запрос для получения данных о пользователях с идентификаторами 2, 3 и т. д., то можете заметить, что у всех полученных от сервера моделей одинаковые поля. И это правильно! Когда мы получаем из API данные о пользователе
(GET /users/id) или список с какой-то информацией (GET /users/), у этих данных
должна быть четкая структура. Обычно о ней сообщается в документации, например
в спецификации в формате OpenAPI (Swagger). Либо разработчики договариваются
устно, а через месяц-другой готовы подраться. Что касается нашего случая, можем
заверить вас: данные всегда будут с одинаковыми полями.
Теперь, когда мы определились с форматом данных, самое время перейти к созданию первой модели — User:
// base_url/5/http_models/lib/main.dart
class User {
const User({
504 Глава 5 Работа с сетью
required
required
required
required
required
required
required
required
});
final
final
final
final
final
final
final
final
this.id,
this.name,
this.surname,
this.lastname,
this.email,
this.age,
this.height,
this.weight,
int id;
String name;
String surname;
String lastname;
String email;
int age;
double height;
double weight;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'name': name,
'surname': surname,
'lastname': lastname,
'email': email,
'age': age,
'height': height,
'weight': weight,
};
}
}
factory User.fromJson(Map<String, dynamic> map) {
return User(
id: map['id'] as int,
name: map['name'] as String,
surname: map['surname'] as String,
lastname: map['lastname'] as String,
email: map['email'] as String,
age: map['age'] as int,
height: map['height'] as double,
weight: map['weight'] as double,
);
}
Класс User, помимо стандартного конструктора и полей, содержит именованный фабричный метод toJson и конструктор fromJson. Как раз они и дают нашей
модели возможность работать с преобразованием данных в JSON-формат и из
JSON-формата соответственно. Фабричный конструктор fromJson позволяет создать экземпляр класса User из полученных данных в формате Map<String, dynamic>.
А метод toJson может использоваться для приведения данных объекта в JSONформат.
Обновим код из предыдущего примера, чтобы по итогу запроса получить не экземпляр типа Map с неопределенными данными, а понятную и структурированную
модель — User:
// base_url/5/http_models/lib/main.dart
import 'dart:convert';
5.3. Пакет (библиотека) HTTP 505
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() = > runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return const MaterialApp(home: HomePage());
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
}
@override
State<HomePage> createState() = > _HomePageState();
class _HomePageState extends State<HomePage> {
User? _user;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _fetchUsersList,
child: const Text('Получить пользователя'),
),
const SizedBox(height: 8),
Text(
_user ! = null ?
'${_user!.name} ${_user!.surname}' :
'Данных нет',
)
],
),
),
);
}
}
void _fetchUsersList() async {
final url = Uri.parse(''http://localhost:8080/users/1');
final response = await http.get(url);
if (response.statusCode = = 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final user = User.fromJson(data);
setState(() = > _user = user);
return;
}
}
В результате запуска приложения после нажатия кнопки на экран должны быть
выведены имя и фамилия запрашиваемого в методе _fetchUsersList() пользователя
с идентификатором, равным единице (рис. 5.4).
506 Глава 5 Работа с сетью
Рис. 5.4. Отображение модели User на экране
5.3.4. Метод POST
Этот метод используется для создания новых ресурсов в базе данных на сервере.
В нашем случае — пользователей:
// base_url/5/http_methods/lib/main.dart
void _createUser() async {
final url = Uri.parse('http://localhost:8080/users');
final data = {
"id": 6,
"name": "Екатерина",
"surname": "Владимировна",
"lastname": "Федорова",
"email": "ekaterina.fedorova@example.com",
"age": 35,
"height": 168.0,
"weight": 64.0
};
final response = await http.post(
url,
body: jsonEncode(data),
);
print(response.statusCode);
}
В приведенном примере для отправки данных пользователя, которого необходимо добавить в базу данных, мы задействовали функцию post из библиотеки http,
после чего вывели статус ответа сервера в консоль. Как и в случае с GET-запросом,
на вход функции post необходимо передать url (путь до end-point на сервере)
и аргумент body, отвечающий за полезную нагрузку запроса. Обратите внимание на
то, что в тело запроса был передан экземпляр Map, приведенный к строке в JSONформате. Такой же словарь мы получали в первом примере — еще до того момента,
когда научились работать с моделями данных.
Теперь разберем более продвинутый пример с применением модели (класса
данных) User:
// base_url/5/http_methods/lib/main.dart
void _createUser() async {
final url = Uri.parse('http://localhost:8080/users');
5.3. Пакет (библиотека) HTTP 507
}
final user = User(
id: 6,
name: 'Игорь',
surname: 'Валерьевич',
lastname: 'Ватюнин',
email: 'vatunin@example.com',
age: 25,
height: 180,
weight: 70,
);
final data = jsonEncode(user.toJson());
final response = await http.post(url, body: data);
print(response.statusCode);
В этом примере сначала создается экземпляр класса User с заполненными данными. Далее у него вызывается метод toJson, который конвертирует данные объекта
в таблицу типа Map<String, dynamic> и возвращает в качестве данных, передаваемых
в функцию верхнего уровня jsonEncode, которая отвечает за их приведение в строку
в JSON-формате. Полученные в ходе такого преобразования данные далее отправляются на сервер.
API, используемое в примере, не имеет ограничений по количеству создаваемых сущностей и валидации полей модели. На самом деле оно только имитирует
работу настоящего API. При взаимодействии с реальным бэкендом вы встретитесь
с разными ограничениями и валидацией данных, например, длина имени, фамилии
и отчества может быть ограничена каким-то конкретным количеством символов.
Обычно такие моменты указываются в спецификации к API или выявляются в ходе
диалога «паяльника» с бэкенд-разработчиками проекта (если они не особо активно
ведут документацию).
5.3.5. Методы PUT и PATH
Несмотря на то что методы POST, PUT и PATCH используются для изменения или
создания данных на сервере, у них есть разные технические и семантические особенности. Метод PUT обычно применяется для замены текущего ресурса целиком.
В своем запросе клиент отправляет полное представление ресурса, а сервер заменяет
существующие на нем данные этим представлением. Например, если у вас есть объект с несколькими полями, то при отправке PUT-запроса нужно включить в него все
поля. Это обусловлено тем, что при исключении некоторых полей связанные с ними
данные могут быть удалены на сервере. Метод PUT идемпотентный, поэтому неоднократное выполнение одного и того же PUT-запроса даст один и тот же результат.
Вернемся к предыдущему примеру и изменим тип запроса с POST на PUT. Для
этого нам понадобится скопировать метод _createUser() , переименовать его
в _updateUser() и заменить функцию http.post на http.put. Также не стоит забывать
про url-путь до обновляемого ресурса (в нашем случае PUT users/1):
// base_url/5/http_methods/lib/main.dart
void _updateUser() async {
final url = Uri.parse('http://localhost:8080/users/1');
final user = User(
id: 1,
name: 'Игорь',
surname: 'Валерьевич',
lastname: 'Ватюнин',
508 Глава 5 Работа с сетью
email: 'vatunin@example.com',
age: 25,
height: 180,
weight: 70,
}
);
final data = jsonEncode(user.toJson());
final response = await http.put(url, body: data);
print(response.statusCode);
В отличие от метода PUT, PATCH используется для частичного обновления ресурса.
Это означает, что вы можете отправить на сервер данные только тех полей объекта,
которые хотите изменить, а остальные останутся неизменными. Из-за этой особенности PATCH-метод не всегда идемпотентен. В некоторых реализациях, если применить
один и тот же PATCH несколько раз, в зависимости от того, как сервер обрабатывает
частичные обновления, будут получены различные результаты.
Далее приведен пример реализации PATCH-запроса для обновления имени пользователя:
// base_url/5/http_methods/lib/main.dart
void _patchUser() async {
final url = Uri.parse('http://localhost:8080/users/1');
final updates = {'name': 'Игорь'};
final data = jsonEncode(updates);
final response = await http.patch(url, body: data);
print(response.statusCode);
}
В данном случае с помощью PATCH-запроса мы обновляем имя пользователя. Если
необходимо обновить фамилию, сделайте такой же вызов, только в Map укажите
ключ 'surname'. Важно отметить, что в отличие от PUT-метода данные, которые мы
не будем передавать в запрос, не сотрутся из базы. Другими словами, параметры,
которые мы не передаем, не будут обновлены.
5.3.6. Метод DELETE
Метод DELETE используется только для удаления ресурса на сервере, а не для создания или изменения данных. В отличие от POST, PUT и PATH, в тело запроса body
не нужно передавать никаких данных. Но для того, чтобы сервер понял, какую
конкретно запись следует удалить из базы данных, необходимо передать id этой
записи в маршруте url:
// base_url/5/http_methods/lib/main.dart
void _deleteUser() async {
final url = Uri.parse('http://localhost:8080/users/1');
final response = await http.delete(url);
print(response.statusCode);
}
В отличие от POST, который не является идемпотентным (может создавать новые
ресурсы при каждом запросе), метод DELETE при повторном вызове не будет изменять
состояние системы, если ресурс уже был удален.
5.3. Пакет (библиотека) HTTP 509
5.3.7. Обработка HTTP-статусов ответов от сервера
Логика приложения, взаимодействующего с каким-либо сервером, во многом
зависит от HTTP-статусов принятых сообщений. По сути, первое, что необходимо сделать после получения ответа от сервера, — проверить номер его статуса.
Если получен статус-код 200 для GET-метода или 201 для POST-метода, это означает, что все сработало верно. Но если был получен статус-код из промежутка
от 400 до 600, необходимо сообщить пользователю о произошедшей ошибке
либо совершить какое-то другое действие, предусмотренное бизнес-логикой
приложения.
Например, если в ответ на вызов DELETE-метода мы получаем от сервера статускод 500, это означает, что пользователь не был удален из базы данных. В таком случае
можно сообщить ему о проблемах с сервисом, чтобы он мог попробовать совершить
те же действия чуть позже:
// base_url/5/http_status_code_handling/lib/main.dart
void _deleteUser(BuildContext context) async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final url = Uri.parse('http://localhost:8080/users/5');
final response = await http.delete(url);
if (response.statusCode = = HttpStatus.accepted) {
scaffoldMessenger.showSnackBar(
const SnackBar(content: Text('Пользователь удален')),
);
return;
}
if (response.statusCode = = HttpStatus.notFound) {
scaffoldMessenger.showSnackBar(
const SnackBar(content: Text('Пользователь не найден')),
);
return;
}
scaffoldMessenger.showSnackBar(
const SnackBar(content: Text('Что-то пошло не так')),
);
}
В этом примере мы отправляем запрос на удаление пользователя с id = 5. После
получения ответа сервера сравниваем его поле statusCode со значениями 200 и 404.
Стандартная библиотека, включенная во Flutter SDK, предоставляет набор заранее
прописанных статус-кодов в виде перечисления HttpStatus. Поэтому не обязательно
хранить все значения статусов ответов в голове.
Вернемся к коду примера. Первым выполняется сравнение со статус-кодом 200
(HttpStatus.accepted). Если все отработало корректно, то показываем пользователю виджет Snackbar с текстом «Пользователь удален». В противном случае
сравниваем статус-код ответа с 404 (HttpStatus.notFound, то есть пользователя
с таким идентификатором нет в базе данных) и запускаем показ виджета Snackbar
с текстом «Пользователь не найден». В конечном счете, если статус-код ответа
не равен 200 или 404, покажем пользователю Snackbar c текстом «Что-то пошло
не так».
510 Глава 5 Работа с сетью
5.3.8. Заголовки (headers)
Практически всю полезную нагрузку в запросе мы отправляем в параметре body.
В некоторых ситуациях настройки фильтрации и данные можно отправлять в самом
url-маршруте или в его query-параметрах. Но, помимо этого, для передачи данных
у запросов есть такой параметр, как заголовки (headers). Зачем они нужны? Чаще
всего заголовки в запросах используются для передачи служебной и общей информации на сервер и в ответах от сервера. Они передают метаданные, которые помогают
серверу понять, как обработать запрос, а клиенту — как интерпретировать ответ.
В табл. 5.4 приведены основные типы заголовков, их описание и примеры.
Таблица 5.4. Основные типы заголовков
Тип заголовка
Общие заголовки
(general headers)
Для чего применяется
Могут быть как в запросах,
так и в ответах, применимы
к связи в целом
Примеры
Date: дата и время отправки запроса.
Connection: управляет поддержанием соединения
(например, keep-alive)
Заголовки запросов Используются для передачи Host: указывает доменное имя сервера (и при необходимости
(request headers)
информации о запросе
номер порта).
User-Agent: передает информацию о клиентском приложении
(например, браузере или устройстве), которое отправляет запрос
Заголовки содержи- Используются для описания Content-Type: указывает тип отправляемых данных
мого (entity headers) тела сообщения (payload),
(application/json, text/html, multipart/form-data).
передаваемого вместе с за- Content-Length: указывает длину тела сообщения в байтах
просом
Заголовки авториИспользуются для переAuthorization: используется для отправки токезации (authorization дачи данных авторизации на доступа или других данных для аутентификации
headers)
и аутентификации
(например, Bearer <token>)
Заголовки кэширова- Помогают управлять кэши- Cache-Control: управляет правилами кэширования
ния (caching headers) рованием контента клиен- (no-cache, max-age)
том или сервером
В качестве примера рассмотрим, как передать пользовательские заголовки
в HTTP-запрос в Flutter-приложении. На нашем сервере есть метод GET /hello/.
С одной стороны, он весьма прост и в качестве ответа возвращает только строку
приветствия (например, Hello!). Но с другой — его сложно назвать простым, ведь
фраза приветствия может быть отправлена клиенту на разных языках. Эту возможность локализации он обрел благодаря стандартному заголовку из спецификации
HTTP — Accept–Language. В нем принято передавать ISO-код языка, на котором
«говорит» клиентская сторона. Все современные браузеры добавляют этот заголовок в запрос автоматически (в зависимости от настроек браузера), но в мобильном
приложении его приходится прописывать вручную:
// base_url/5/http_headers/lib/main.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
5.3. Пакет (библиотека) HTTP 511
void main() = > runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return const MaterialApp(home: HomePage());
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
}
@override
State<HomePage> createState() = > _HomePageState();
class _HomePageState extends State<HomePage> {
String? _helloStr;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _fetchUsersList,
child: const Text('Привет!'),
),
const SizedBox(height: 8),
Text(_helloStr ! = null ? _helloStr! : '...')
],
),
),
);
}
}
void _fetchUsersList() async {
final url = Uri.parse('http://localhost:8080/hello');
final response = await http.get(
url,
headers: {HttpHeaders.acceptLanguageHeader: 'en'},
);
if (response.statusCode = = 200) {
final data = jsonDecode(response.body) as String;
setState(() = > _helloStr = data);
return;
}
}
В приведенном примере аргументу headers GET-запроса передается заголовок
{HttpHeaders.acceptLanguageHeader: 'en'}, явно указывающий, какой язык сейчас
задействован пользователем приложения. В качестве ответа сервер отправляет
строку-приветствие, впоследствии выводимую на экран. Поскольку в запрос мы
передали английскую локализацию 'en', в ответе пришла строка Hello!. Для того
чтобы сменить локализацию, поменяйте значение этого заголовка на 'ru', и тогда
в ответе от сервера придет строка Привет! (рис. 5.5).
512 Глава 5 Работа с сетью
Рис. 5.5. Локализация ответа от сервера с помощью заголовков запроса
5.3.9. Перехватчик запросов Interceptor
В предыдущем подразделе мы рассмотрели вариант локализации ответов от сервера
с помощью отправки специальных header-заголовков в HTTP-запросах. Но если
к каждому клиентскому запросу добавлять один и тот же заголовок, тем самым
дублируя код, достижение «повторюшка» — самое безобидное, что может с вами
произойти. Просто вдумайтесь, какой объем работы при таком подходе может дать
любое изменение в структуре заголовка!
Чтобы этого избежать и в одной точке кода реализовать какую-то общую логику
для всех запросов, существует паттерн (шаблон) проектирования Interceptor («перехватчик»). Он представляет собой программный компонент, который позволяет
перехватывать и изменять данные запроса или ответа на этапе до или после его
выполнения. Этот механизм широко используется в разработке клиент-серверных
приложений: при взаимодействии с API, для добавления повторяющихся операций
(логирование, аутентификация, обработка ошибок и кэширование) без изменения
основной бизнес-логики приложения. На рис. 5.6 приведена структурная схема
работы этого шаблона проектирования.
Рис. 5.6. Структурная схема работы перехватчика запросов
Если вы знакомы с бэкенд-разработкой, то этот паттерн работает аналогично шаблону Middleware . Он перехватывает каждый запрос, отправляемый на
сервер, и каким-то образом модифицирует его перед отправкой или, наоборот,
5.3. Пакет (библиотека) HTTP 513
модифицирует полученный ответ. Interceptor может работать аналогично шаблону Observer. В этом случае он наблюдает за исходящими и входящими запросами
и, например, выводит их в терминал.
Поскольку библиотека http предоставляет только стандартные методы для взаи
модействия с сервером по HTTP-протоколу, в ней нет реализации этого паттерна
проектирования. Поэтому разработчики предпочитают ей библиотеку dio. Но за
предоставляемые ею удобства приходится платить своими нервными клетками. Это
связано с тем, что API библиотеки dio меняется в разы чаще (и более извращенно),
чем библиотеки http.
К великому счастью, никто нам не мешает запилить свою велосипедокостыльную
реализацию HTTP-клиента для работы с сетью поверх библиотеки http, добавив
в него поддержку паттерна проектирования Interceptor для GET-запросов.
В первую очередь объявим интерфейс HttpInterceptor, задающий два метода
для перехвата запроса и ответа от сервера — onRequest и onReponse. В дальнейшем
мы сможем использовать его для создания всевозможных перехватчиков запросов:
// base_url/5/http_interceptor/lib/main.dart
abstract interface class HttpInterceptor {
/// Вызывается, когда запрос вот-вот будет отправлен
void onRequest(http.BaseRequest request);
}
/// Вызывается, когда получен ответ от сервера
void onResponse(http.StreamedResponse response);
Метод onRequest принимает на свой вход объект типа http.BaseRequest, содержащий параметры оправляемого запроса: URL, метод, заголовки и т. д. А метод
onResponse принимает объект типа http.StreamedResponse, описывающий ответ
сервера: статус-код, тело, заголовки и т. д.
Теперь объявим класс LoggerInterceptor, реализующий интерфейс HttpInterceptor, являющийся одним из самых необходимых кирпичиков для построения
качественной и предсказуемой архитектуры большого проекта и отвечающий за
логирование:
// base_url/5/http_interceptor/lib/main.dart
class LoggerInterceptor implements HttpInterceptor {
@override
void onRequest(http.BaseRequest request) {
print('Отправляем ${request.method} запрос${request.url}');
}
}
@override
void onResponse(http.StreamedResponse response) {
print('Статус ответа от сервера:${response.statusCode}');
}
У нас получился простой логгер, выводящий в консоль информацию о том, какой запрос был отправлен (на какой URL и с каким HTTP-методом), и об ответах
сервера на эти запросы, а вернее, только статус-код. Конечно, мы могли бы выдать
в консоль гораздо больше информации о запросах и ответах, но для нашего примера
и демонстрации его работы этого будет достаточно.
514 Глава 5 Работа с сетью
На следующем шаге внедрим объявленный ранее LoggerInterceptor в работу
HTTP-клиента. Для этого нам придется сделать небольшую надстройку над стандартным клиентом http.BaseClient:
// base_url/5/http_interceptor/lib/main.dart
class HttpInterceptorClient extends http.BaseClient {
HttpInterceptorClient(
this._inner, {
this.interceptor,
});
final http.Client _inner;
final HttpInterceptor? interceptor;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async{
/// Сообщаем в intercetor о сделанном на сервер запросе
interceptor?.onRequest(request);
final response = await _inner.send(request);
}
}
/// Сообщаем в intercetor о пришедшем от сервера ответе
interceptor?.onResponse(response);
return response;
В теле класса HttpInterceptorClient нам не пришлось писать огромное количество кода для каждого вида запросов, ведь за их отправку в библиотеке http отвечает
метод send. То есть хотя вы и вызываете методы, которые называются GET, POST или
DELETE, «под капотом» запрос все равно будет отправлен именно из метода SEND. Таким образом, сложно представить более подходящее место для внедрения перехватчика. Именно поэтому класс HttpInterceptorClient наследуется от http.BaseClient
и переопределяет метод SEND, делегируя обработку запроса непосредственно методу
SEND экземпляра класса _inner типа http.Client, который передается в конструктор
HttpInterceptorClient. Что касается перехватчика, его экземпляр, приведенный
к интерфейсу HttpInterceptor, вызывается как до, так и после вызова метода SEND.
Это позволяет добавить какие-либо повторяющиеся данные в запрос и сделать
предварительную обработку полученного от сервера ответа (рис. 5.7):
// base_url/5/http_interceptor/lib/main.dart
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
}
@override
State<HomeScreen> createState() = > _HomeScreenState();
class _HomeScreenState extends State<HomeScreen> {
final _client = HttpInterceptorClient(
http.Client(),
interceptor: LoggerInterceptor(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
await _client.get(Uri.parse('http://localhost:8080/hello'));
},
5.4. Веб-сокеты 515
child: const Text('Отправить запрос'),
),
),
}
}
);
Рис. 5.7. Пример HTTP-логирования с помощью Interceptor
Если вы запускаете данный пример под веб-платформу, обратите внимание
на то, что логи будут показываться как в терминале IDE, так и в консоли браузера.
В иных случаях — только в терминале IDE.
5.4. Веб-сокеты
HTTP-запросы закрывают большинство задач, где необходимо получить какую-то
информацию, хранящуюся удаленно, например отобразить список покупок, данные
профиля пользователя, карточки товара и т. д. Но когда нам нужно оперативно
получать какие-либо обновления контента, начинаются проблемы. Допустим, приложение получает новые сообщения в чате. Если бы оно обходилось только HTTPзапросами GET, приходилось бы раз в какое-то n-е время загружать всю историю
чата. При n, равном 1 с, это вызывало бы большие проблемы с производительностью
приложения и давало бы бешеную нагрузку на сеть. А если n принять равным 5 с,
приложение тормозило бы и отображало новые сообщения с задержкой. Это приводило бы к таким ситуациям, когда пользователь отправляет новое сообщение,
а на экране его собеседника оно появляется только через 5 с (в худшем случае).
Для решения такой проблемы и были созданы веб-сокеты, которые позволяют
клиенту, например Flutter-приложению, и серверу общаться в реальном времени. В отличие от обычных HTTP-запросов веб-сокет предоставляет постоянное
516 Глава 5 Работа с сетью
соединение, по которому можно передавать данные в обоих направлениях (как от
клиента к серверу, так и обратно) без необходимости заново открывать соединение
для каждого сообщения. Это особенно полезно для приложений, где важен обмен
данными в реальном времени, например чатов, игр или финансовых приложений.
5.4.1. Начало работы с веб-сокетами
Чтобы реализовать взаимодействие с серверной частью через веб-сокеты, во Flutterприложениях можно задействовать несколько библиотек. Мы же воспользуемся
той, поддержкой которой занимается основная команда Dart, — web_socket_channel.
Эта библиотека дает возможность реализовать основной функционал для работы
с веб-сокетами — чтения и отправки данных.
Реализуем небольшой пример чтения данных из веб-сокета и их отправки через
него на сервер. Для этого создайте новый консольный Dart-проект web_socket_dart
и в файле pubspec.yaml добавьте в секцию depedencies следующую строку:
dependencies:
web_socket_channel: ^3.0.1 #укажите актуальную версию
Сохраните изменения, нажав Ctrl+S. Если зависимости проекта не подкачались
автоматически, то воспользуйтесь командой dart pub get.
5.4.2. Чтение данных из веб-сокета
Для того чтобы начать чтение данных из веб-сокета, к нему необходимо подключиться. Для этого, как и в работе с HTTP-запросами, используются маршруты.
Отличие же заключается в том, что они начинаются с аббревиатуры ws (web socket),
а не http или https, например, маршрут веб-сокета может выглядеть так:
y ws://localhost:8080/ws;
y ws://localhost:8080/socket;
y ws://localhost:8080/other–path.
По сути, меняется только path нашего url. Что же касается подключения, то
в коде Dart- или Flutter-приложения оно будет выглядеть как в следующем примере:
import 'package:web_socket_channel/web_socket_channel.dart';
void main() {
final uri = Uri.parse('ws://localhost:8080/ws/read');
final channel = WebSocketChannel.connect(uri);
}
Здесь мы сначала создаем экземпляр класса uri с маршрутом до определенного
веб-сокета, после чего, используя статический метод connect, создаем новый экземпляр класса WebSocketChannel. Созданный таким образом объект реализует в себе
логику подключения к веб-сокету и позволяет организовать получение и отправку
данных через него.
Сначала разберемся с чтением данных. Для этого у объекта типа WebSocketShannel есть поле stream. Оно позволяет читать данные из сокета как из обычного
потока Stream:
5.4. Веб-сокеты 517
// base_url/5/web_socket_dart/bin/read.dart
import 'package:web_socket_channel/web_socket_channel.dart';
void main() {
final uri = Uri.parse('ws://localhost:8080/ws/read');
final channel = WebSocketChannel.connect(uri);
channel.stream.listen((message) {
print(message);
});
}
Веб-сокет, который мы прослушиваем в коде примера, настроен так, что раз
в 1 с отправляет сообщение клиентской стороне от сервера. В нем хранится общее
количество секунд, прошедших с момента, когда пользователь чата (вы) писал
в него последний раз. Короче говоря, это примитивный таймер, который отправляет
данные по сокету.
В результате запуска приложения в терминал IDE должен выводиться следующий результат:
Почему
Почему
Почему
Почему
Почему
Почему
Почему
не
не
не
не
не
не
не
отвечаешь?
отвечаешь?
отвечаешь?
отвечаешь?
отвечаешь?
отвечаешь?
отвечаешь?
Уже
Уже
Уже
Уже
Уже
Уже
Уже
1
2
3
4
5
6
7
сек.
сек.
сек.
сек.
сек.
сек.
сек.
прошло
прошло
прошло
прошло
прошло
прошло
прошло
5.4.3. Отправка данных через сокет
Важно помнить о том, что сокеты — это не односторонний формат взаимодействия.
Точно так же, как получить данные по сокету, клиент может их еще и отправить,
чем мы сейчас и займемся. За основу возьмем предыдущий пример, изменив в нем
маршрут для подключения к веб-сокету:
import 'package:web_socket_channel/web_socket_channel.dart';
void main() {
final uri = Uri.parse('ws://localhost:8080/ws/write');
final channel = WebSocketChannel.connect(uri);
channel.stream.listen((message) {
print(message);
});
}
В отличие от предыдущего раза при запуске приложения в терминал IDE не будут
ежесекундно выводиться сообщения от сервера. При текущем подключении сервер
работает по принципу «пинг-понг». В тот момент, когда вы отправите сообщение
на сервер, он вышлет вам его же в ответ. Проверим это!
Для отправки данных на сервер воспользуемся геттером sink экземпляра класса
WebSocketChannel. Этот геттер возвращает экземпляр класса WebSocketSink, который
и задействуем для отправки данных по созданному подключению:
// base_url/5/web_socket_dart/bin/write.dart
import 'package:web_socket_channel/web_socket_channel.dart';
void main() {
final uri = Uri.parse('ws://localhost:8080/ws/write');
final channel = WebSocketChannel.connect(uri);
518 Глава 5 Работа с сетью
}
channel.stream.listen((message) {
print(message);
});
channel.sink.add('Салют, сервер!');
После запуска приложения в терминал будет выведено сообщение: «Получил
сообщение “Салют, сервер!”». Таким образом, мы удостоверились, что сервер получил наше приветствие и переслал его в ответном сообщении. Важно отметить,
что данные, отправляемые через такое соединение, могут быть разных типов и не
обязательно это строки. Точно так же, как в HTTP-запросах, вы можете отправлять
типы int, double, bool, JSON-модели и т. д.
5.4.4. Отображение данных из сокета на экране
После достижения дзен по отправке и приему сообщений через веб-сокетное соединение нам нужно понять, как перенести полученный опыт во Flutter-приложение.
Поскольку класс WebSocketChannel предоставляет геттер stream, воспользуемся для
этого дела виджетом StreamBuilder. В качестве примера его применения разработаем
небольшое приложение чата. В нем будут две дорожки сообщений: пользовательская и ответы от сервера. Для простоты реализации сделаем так, чтобы в ответ
на любое наше сообщение в чат сервер, как NPC в компьютерной игре, выдавал
какую-нибудь мудрость.
Для начала создайте новый Flutter-проект web_socket_flutter и добавьте в него
в качестве внешней зависимости библиотеку web_socket_channel . После этого
удалите файлы из папки test и весь код из main.dart и добавьте в заглавие файла
следующий импорт:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
Обязанность отображать пользовательские сообщения и сообщения от сервера
возложим на два StatelessWidget: ClientMessages и ServerMessages. В первом виджете
будем отображать список с помощью ListView.builder:
class ClientMessages extends StatelessWidget {
const ClientMessages({
super.key,
required List<String> clientMessages,
}) : _clientMessages = clientMessages;
final List<String> _clientMessages;
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Мои сообщения:',
style: TextStyle(fontWeight: FontWeight.bold),
),
Expanded(
5.4. Веб-сокеты 519
child: ListView.builder(
itemCount: _clientMessages.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(_clientMessages[index]),
);
},
),
),
],
),
}
}
);
А в виджете ServerMessages используем StreamBuilder, который подпишем на
прослушивание новых сообщений от сервера:
class ServerMessages extends StatelessWidget {
const ServerMessages({
super.key,
required this.channel,
required this.serverMessages,
required this.onNewMessage,
});
final WebSocketChannel channel;
final List<String> serverMessages;
// callback-функция для оповещения верхнего уровня о новом сообщении
final Function(String message) onNewMessage;
}
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Сообщения Мудреца:',
style: TextStyle(fontWeight: FontWeight.bold),
),
Expanded(
// Прослушивание сообщений от сервера
child: StreamBuilder(
stream: channel.stream,
builder: (context, snapshot) {
if (snapshot.hasData) { // есть данные?
onNewMessage(snapshot.data.toString());
}
return ListView.builder(
itemCount: serverMessages.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(serverMessages[index]),
);
},
);
},
),
),
],
),
);
}
520 Глава 5 Работа с сетью
В этом коде мы передаем геттер stream в конструктор StreamBuilder, тем самым
сообщая этому виджету, по какому потоку данных он будет перестраиваться. Далее
в его методе builder все новые сообщения от сервера передаются callback-функцией
onNewMessage на верхний уровень приложения, где будут сохраняться в список
и вызывать перестроение ServerMessages с передачей его экземпляру обновленного списка сообщений для их отрисовки на экране посредством ListView.builder.
Если бы нужно было отобразить только последнее сообщение из веб-сокета, мы
могли бы обратиться к snapshot.data и не использовать дополнительные списки.
Перейдем к основному виджету разрабатываемого приложения — ChatScreen. Так
как он будет располагаться на верхнем уровне и включать в себя предыдущие два,
а также логику обработки сообщений от сервера, объявим его как StatefulWidget:
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
}
@override
State<ChatScreen> createState() = > _ChatScreenState();
class _ChatScreenState extends State<ChatScreen> {
// подключение к веб-сокету
final channel = WebSocketChannel.connect(
Uri.parse('ws://localhost:8080/ws/chat'),
);
StreamSubscription<dynamic>? _subscription;
final _controller = TextEditingController();
final _clientMessages = <String>[];
final _serverMessages = <String>[];
}
// сюда будем добавлять последующий код
Чтобы проникнуться мудростью сервера, нам необходимо отправить ему сообщение. Для этого используем обычное текстовое поле TextFormField, предварительно
расположив в верстке ServerMessages и ClientMessages и передав в их конструкторы
необходимые объекты. Отправлять же сообщение будем, нажимая кнопку IconButton:
void _sendMessage() { // метод отправки сообщения
if (_controller.text.isNotEmpty) {
setState(() {
//Добавляем сообщение клиента
_clientMessages.add(_controller.text);
});
// Отправляем сообщение на сервер
channel.sink.add(_controller.text);
_controller.clear(); // Очищаем текстовое поле
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Чат с Мудрецом')),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
Expanded(
child: Row(
5.4. Веб-сокеты 521
children: [
ServerMessages(
channel: channel,
serverMessages: _serverMessages,
onNewMessage: _serverMessages.add,
),
const SizedBox(width: 20),
ClientMessages(clientMessages: _clientMessages),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 20.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Введите свое сообщение',
),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: _sendMessage, // Отправляем сообщение
),
],
),
),
],
),
),
);
Чтобы не отправлять пустые сообщения на сервер, в методе _sendMessage производится валидация текста. Если пользователь отправляет непустое сообщение,
оно добавляется в созданный нами список клиентских сообщений. Для большей
аутентичности приложения с мессенджером последняя строчка кода в методе _sendMessage очищает виджет TextField от текста.
Нам нужно предусмотреть закрытие любого открытого соединения, будь то файл,
база данных или какой-нибудь сокет. Сделать это можно в методе dispose любого
StatefullWidget. В нашем случае для закрытия подключения вызовем у объекта
channel метод sink.close():
@override
void dispose() {
channel.sink.close();// Закрываем WebSocket при завершении работы
super.dispose();
}
Помимо обработки в StreamBuilder, как и в любом другом экземпляре Stream,
вы можете подписаться на веб-сокет-соединение с помощью метода listen, создав
тем самым StreamSubscription. В таком случае код обработки сообщений из сокета
будет выглядеть следующим образом:
StreamSubscription<dynamic>? _subscription;
@override
void initState() {
522 Глава 5 Работа с сетью
}
_subscription = channel.stream.listen((data) {
_serverMessages.add(data.toString());
setState(() {});
});
super.initState();
Подписка на Stream создает связь между подписчиком и потоком данных. Если
не отменить подписку, когда виджет удаляется из дерева виджетов, то поток продолжит существовать и потреблять память, даже если виджет больше не нужен.
Это может привести к утечкам памяти. Поэтому, как и любую другую подписку,
ее необходимо закрыть в методе dispose:
@override
void dispose() {
channel.sink.close();
_subscription?.cancel();
super.dispose();
}
На последнем шаге объявим функцию верхнего уровня main и укажем в качестве
виджета, помещаемого в корень дерева, ChatApp:
void main() = > runApp(const ChatApp());
Теперь запустите приложение и проникнитесь всей мудростью сообщений от
сервера, приходящих в ответ на отправляемый вами текст (рис. 5.8).
Рис. 5.8. Примитивная реализация чата на веб-сокете
Проект: игра «Тетрис» v. 5. Работа с сетью 523
Проект: игра «Тетрис» v. 5. Работа с сетью
Практически все приложения в современном мире работают с сетью, и наш «Тетрис» не будет исключением! Добавим ему возможность взаимодействовать с сервером по REST API, чтобы сохранять результаты игр пользователя, запрашивать
таблицу лучших игроков и т. д. Так как книга посвящена в основном разработке
кросс-платформенных клиентских приложений, то в нюансы проектирования
серверного ПО на Dart погружаться не будем, а возьмем уже готовую реализацию
сервера.
А в качестве дополнительного челленджа откажемся от использования сторонних
библиотек и будем применять исключительно инструменты от команды Flutter.
Запуск серверной части
Скачайте из корневой папки репозитория книги проект tetris_backend, откройте
его через VS Code и установите все зависимости. На следующем шаге нам предстоит глобально активировать библиотеку dart_frog. Для этого откройте терминал и введите команду (возможно, придется отдельно прописать в переменной
среды path путь до Dart, поставляемый вместе с Flutter, — Flutter SDK\bin\cache\
dart-sdk\bin):
dart pub global activate dart_frog_cli
После установки в том же самом окне терминала введите команду для запуска
сервера:
dart_frog dev
Если сервер успешно запущен, вы увидите следующее оповещение:
✓ Running on http://localhost:8080 (0.3s)
Это сообщение означает, что бэкенд запущен на локальном хосте и слушает
80-й порт. Если вы захотите развернуть его на удаленной машине (сервере), необходимо использовать ее IP-адрес.
Базовая концепция приложения и структура папок проекта
С запуском сервера разобрались, значит, пора переключиться на сам «Тетрис».
В результате добавления функционала работы с сетью мы разработаем или видоизменим следующие экраны (рис. 5.9).
1. Экран ввода никнейма и подключения пользователя к серверу.
2. Основной экран с игрой, где будет добавлен вывод имени текущего пользователя.
3. Экран с лучшими результатами игроков.
524 Глава 5 Работа с сетью
Рис. 5.9. Пример реализуемых в приложении экранов
Начнем, пожалуй, с наведения порядка в папке lib проекта. Для этого будем
придерживаться features-подхода, когда каждый отдельный функционал должен
находиться в определенном каталоге features/<Название функционала>.
Начиная с этой части сквозного проекта, мы будем стараться использовать чистую
архитектуру и работать с интерфейсами. Даже если это покажется избыточным,
помните: данный проект реализован для обучения.
Создайте в каталоге lib две новые папки —
features и app (рис. 5.10).
В каталоге app будут храниться общие для
всей игры ресурсы и классы, например, в папке http расположим код HTTP-клиента для
работы с сетью (рис. 5.11).
А в папке features будут располагаться разбитые по отдельным каталогам функциональные модули (фичи) игры (рис. 5.12).
Рис. 5.10. Структура каталога lib
Далее приведено назначение вложенных
каталогов (фич):
y game — хранит все экраны и логику, которые связаны с игрой;
y leaderboard — отображает таблицу результата;
y main_menu — экран и код логики работы главного меню приложения;
y user — все, что касается пользователя.
Проект: игра «Тетрис» v. 5. Работа с сетью 525
Рис. 5.11. Структура каталога app
Рис. 5.12. Структура каталога features
Разработка DI-контейнера (Dependency Injection)
Начнем с реализации контейнера для хранения зависимостей и объектов в рамках
BuildContext на основе InheritedWidget. Это позволит практически за константное
время O(1) получать с помощью контекста зависимости проекта, внедряемые в дерево на более высоких уровнях.
Создайте в папке app файл di_container.dart со следующим содержимым:
// base_url/5/tetris/lib/app/di_container.dart
import 'package:flutter/widgets.dart';
final class DiContainer extends InheritedWidget {
const DiContainer({super.key, required super.child});
/// Так как контейнер зависимостей нужен только для доступа
/// к зависимостям, возвращаем false, чтобы виджеты-потомки
/// не перестраивались при изменении контекста
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) = > false;
}
/// Получение контейнера зависимостей из контекста
static DiContainer of(BuildContext context) {
// Ищем контейнер зависимостей в контексте
// Если не нашли, то выбрасываем исключение
final DiContainer? container =
context.getInheritedWidgetOfExactType<DiContainer>();
if (container = = null) {
throw Exception('Контейнер зависимостей не найден в контексте');
}
return container;
}
526 Глава 5 Работа с сетью
Далее откройте main.dart и оберните MaterialApp в реализованный ранее
DI-контейнер, тем самым внедрив его в дерево виджетов:
// base_url/5/tetris/lib/main.dart
import 'package:flutter/material.dart';
import 'package:tetris/screens/game_over_screen.dart';
import 'package:tetris/screens/game_screen.dart';
import 'package:tetris/screens/main_menu_screen.dart';
import 'app/di_container.dart';
part 'game_router.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
}
@override
Widget build(BuildContext context) {
return DiContainer(
child: MaterialApp(
debugShowCheckedModeBanner: false,
initialRoute: GameRouter.initialRoute,
routes: GameRouter._appRoutes),
);
}
Теперь в любом месте приложения мы сможем получить доступ к контейнеру
с помощью контекста:
DiContainer.of(this)
На следующем шаге добавим в BuildContext расширение для быстрого доступа
к контейнеру. Для этого в папке app создайте файл context_ext.dart и добавьте
в него приведенный далее код:
// base_url/5/tetris/lib/app/context_ext.dart
import 'package:flutter/material.dart';
import 'di_container.dart';
/// Удобный доступ к контейнеру зависимостей
/// из любого места приложения посредством BuildContext
extension ContextExt on BuildContext {
DiContainer get di = > DiContainer.of(this);
}
Такой подход позволит нам вместо записи:
DiContainer.of(this)
использовать:
context.di;
Согласитесь, так куда удобнее!
Проект: игра «Тетрис» v. 5. Работа с сетью 527
Разработка HTTP-клиента
Теперь реализуем собственный HTTP-клиент для работы с сетью. Будем создавать
его реализацию в DI-контейнере, а в самом приложении — работать с ним исключительно через интерфейс. Такой подход позволит нам не зависеть от реализации
(можем спокойно переписывать) и облегчит написание тестов. Так как приложение
собирается в различных средах: prod, dev, stage или test, — без этого никак. Но мы
пока опустим аспекты сборки и тестирования, поскольку погружение в них раньше
времени и так усложнит понимание кода и структуры проекта.
Прежде чем перейти к коду и созданию новых файлов в проекте, подключим в качестве внешней зависимости в раздел dependencies файла pubspec.yaml один-единственный пакет — http. Он нам понадобится для поддержки кросс-платформенности
проекта. Можно, конечно, обойтись и без него, но тогда придется писать много
лишнего кода, который не особо пригодится в реальных проектах:
# base_url/5/tetris/lib/pubspec.yaml
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
http: ^1.3.0 # укажите актуальную версию
В папке app создайте новый каталог http с файлом интерфейса клиента i_http_
префикс i — указание того, что в библиотеке объявляется только
интерфейс (контракт):
client.dart, где
// base_url/5/tetris/lib/app/http/i_http_client.dart
import 'package:http/http.dart';
/// Интерфейс HTTP-клиента
/// Используется для отправки запросов на сервер
abstract interface class IHttpClient {
/// Базовый URL для запросов
String get baseUrl;
/// Отправляет POST-запрос на указанный URL с телом запроса
/// Возвращает ответ от сервера
Future<Response> post(String url, {Object? body});
/// Отправляет GET-запрос на указанный URL
/// Возвращает ответ от сервера
Future<Response> get(String url);
/// Отправляет PUT-запрос на указанный URL с телом запроса
/// Возвращает ответ от сервера
Future<Response> put(String url, {Object? body});
}
На следующем шаге в той же папке создайте файл с реализацией HTTP-клиента —
base_http_client.dart:
// base_url/5/tetris/lib/app/http/base_http_client.dart
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'i_http_client.dart';
/// Базовая реализация интерфейса IHttpClient
/// для отправки HTTP-запросов
class BaseHttpClient implements IHttpClient {
528 Глава 5 Работа с сетью
/// Определяет хост в зависимости от платформы,
/// так как Android-эмулятор работает на localhost
String get host {
if (kIsWeb) {
return 'localhost';
} else if (Platform.isAndroid) {
return '10.0.2.2';
} else {
return 'localhost';
}
}
/// Переопределяет базовый URL для запросов
@override
String get baseUrl = > 'http://$host:8080';
/// Отправляет GET-запрос на указанный путь
@override
Future<http.Response> get(String path) async {
final uri = Uri.parse('$baseUrl$path');
final response = await http.get(uri);
return response;
}
@override
Future<http.Response> post(String path, {Object? body}) async {
final uri = Uri.parse('$baseUrl$path');
final response = await http.post(uri, body: jsonEncode(body));
return response;
}
}
@override
Future<http.Response> put(String path, {Object? body}) async {
final uri = Uri.parse('$baseUrl$path');
final response = await http.put(uri, body: jsonEncode(body));
return response;
}
Обратите внимание на геттер host. Возвращаемые им данные зависят от типа
платформы. Это делается для того, чтобы не поймать ошибку в процессе запуска
проекта на Android-эмуляторе при попытке обращения к localhost.
Далее внедрим HTTP-клиент в дерево виджетов приложения, инициализировав
его в классе DiContainer. Для этого перейдите в файл di_container.dart и добавьте
в нем приватное поле _httpClient, которое будет инициализироваться его базовой
реализацией в конструкторе класса:
// base_url/5/tetris/lib/app/di_container.dart
import 'package:flutter/widgets.dart';
import 'http/base_http_client.dart';
import 'http/i_http_client.dart';
final class DiContainer extends InheritedWidget {
DiContainer({super.key, required super.child}){
// Инициализируем контейнер зависимостей
_httpClient = BaseHttpClient();
}
late final IHttpClient _httpClient;
}
// остальной код без изменений
Проект: игра «Тетрис» v. 5. Работа с сетью 529
У вас может возникнуть закономерный вопрос: «Почему поле _httpClient приватное?» Все дело в том, что HTTP-клиент не должен быть доступен всему приложению! В дальнейшем мы будем прокидывать его в репозитории — поставщики
данных.
Отображение списка лучших результатов
Пришла пора перейти к реализации отображения списка лучших результатов игроков
в «Тетрис», подключенных к серверу. Для
этого в папке features создайте новый каталог leaderboard с тремя вложенными папками
(рис. 5.13):
y
data — будет использоваться для хра-
нения поставщиков данных (репозиториев) и объектов, необходимых для
преобразования данных (data transfer
objects, DTO);
y domain — хранит объекты, работающие
с бизнес-логикой: блоки (управление
состоянием — реализация паттерна
BLoC), сущности, интерфейсы;
y presentation — агрегирует в себе виджеты, экраны и различные компоненты,
то есть все, что связано с графическим
пользовательским интерфейсом.
Но для начала в папке app создадим файл
Рис. 5.13. Структура каталога leaderboard
equals_mixin.dart. В него добавим миксин, используемый при объявлении сущностей, которые должны поддерживать операцию
проверки на равенство. Поскольку нам нужно универсальное решение, учтем различные варианты сравнения экземпляров классов в зависимости от объявленных
в них полей:
// base_url/5/tetris/lib/app/equals_mixin.dart
// Миксин для сравнения объектов по их полям
mixin EqualsMixin {
/// Список полей для сравнения, который
/// должен быть переопределен в классе, использующем миксин
List<Object?> get fields;
@override
bool operator = = (Object other) {
// Проверяем идентичность объектов или сравниваем их поля
return identical(this, other) ||
other is EqualsMixin &&
runtimeType = = other.runtimeType &&
_areListsEqual(fields, other.fields);
}
530 Глава 5 Работа с сетью
@override
int get hashCode {
// Вычисляем хеш-код на основе типа объекта и его полей
return runtimeType.hashCode ^ _combineHashCodes(fields);
}
// Приватный метод для сравнения двух списков полей
bool _areListsEqual(List<Object?> a, List<Object?> b) {
// Проверяем, равны ли длины списков и их элементы
if (a.length ! = b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i] ! = b[i]) return false;
}
return true;
}
}
// Приватный метод для комбинирования хеш-кодов всех полей
int _combineHashCodes(List<Object?> objects) {
// Используем fold для последовательного комбинирования хеш-кодов
return objects.fold(
0,
(hash, object) = > hash ^ (object?.hashCode ?? 0),
);
}
Далее создадим в папке domain файл leaderboard_entity.dart с сущностью LeaderboardEntity. Она будет использоваться для описания отдельного элемента списка
таблицы результатов и реализовывать миксин EqualsMixin:
/* base_url/5/tetris/lib/features/leaderboard/domain/
leaderboard_entity.dart */
import 'package:flutter/foundation.dart';
import 'package:tetris/app/equals_mixin.dart';
@immutable
class LeaderboardEntity with EqualsMixin {
/// Идентификатор пользователя
final int id;
/// Имя пользователя
final String username;
/// Лучший счет пользователя
final int score;
const LeaderboardEntity({
required this.id,
required this.username,
required this.score,
});
}
/// Переопределяем поля для сравнения объектов
/// Используем для сравнения объектов в EqualsMixin
@override
List<Object?> get fields = > [id, username, score];
Обратите внимание на аннотацию класса сущности — @immutable. Она гарантирует, что сущности не будут мутабельными, то есть их данные нельзя будет
изменить.
Проект: игра «Тетрис» v. 5. Работа с сетью 531
На следующем шаге в каталоге domain создадим файл i_leaderboard_repository.
dart с интерфейсом репозитория ILeaderboardRepository:
/* base_url/5/tetris/lib/features/leaderboard/domain/
i_leaderboard_repository.dart */
import 'leaderboard_entity.dart';
/// Интерфейс репозитория для работы с таблицей
/// лидеров
abstract interface class ILeaderboardRepository {
/// Получение таблицы лидеров.
Future<Iterable<LeaderboardEntity>> fetchLeaderboard();
}
Теперь перейдем к папке data и создадим в ней файл leaderboard_dto.dart.
Он будет содержать объявление класса LeaderboardDto, который в последующем
используем для парсинга ответа от сервера и преобразования его в сущность
LeaderboardEntity:
/* base_url/5/tetris/lib/features/leaderboard/data/
leaderboard_dto.dart */
import 'package:flutter/foundation.dart';
import '../domain/leaderboard_entity.dart';
/// Data Transfer Object для лучших результатов
@immutable
final class LeaderboardDto {
final int id;
final String username;
final int score;
const LeaderboardDto({
required this.id,
required this.username,
required this.score,
});
/// Создание объекта из JSON
/// [json] — JSON-объект, полученный из API бэкенда
factory LeaderboardDto.fromJson(Map<String, dynamic> json) {
return LeaderboardDto(
id: json['id'] as int,
username: json['username'] as String,
score: json['score'] ?? 0,
);
}
}
/// Преобразование DTO в сущность LeaderboardEntity
LeaderboardEntity toEntity() {
return LeaderboardEntity(
id: id,
username: username,
score: score,
);
}
Далее, не покидая папки data, создадим файл leaderboard_repository.dart и объявим в нем реализацию интерфейса репозитория ILeaderboardRepository — LeaderboardRepository. Обратите внимание на то, что интерфейс и реализация лежат
532 Глава 5 Работа с сетью
в разных папках! Такой подход позволяет внести в процесс проектирования программного продукта разделение на абстрактные слои. Слой domain ничего не должен знать о том, как реализован поставщик данных, в то же самое время слой data
должен понимать, чего от него ждет слой domain.
Поскольку репозиторий будет работать с сетью, передадим в его конструктор
экземпляр HTTP-клиента:
/* base_url/5/tetris/lib/features/leaderboard/data/
leaderboard_repository.dart */
import 'dart:convert';
import 'package:tetris/app/http/i_http_client.dart';
import '../domain/i_leaderboard_repository.dart';
import '../domain/leaderboard_entity.dart';
import 'leaderboard_dto.dart';
/// Реализация репозитория для таблицы лидеров
final class LeaderboardRepository implements ILeaderboardRepository {
/// HTTP-клиент для отправки запросов к API
final IHttpClient httpClient;
LeaderboardRepository({required this.httpClient});
}
@override
Future<Iterable<LeaderboardEntity>> fetchLeaderboard() async {
// Получение данных
final response = await httpClient.get('/users/');
// Проверка статуса ответа
if (response.statusCode ! = 200) {
throw Exception('Ошибка при загрузке: ${response.statusCode}');
}
final Iterable data = json.decode(response.body);
// Преобразование данных в список сущностей
final resList = data.map((item) {
return LeaderboardDto.fromJson(item).toEntity();
}).toList();
// Возвращаем список сущностей
return resList;
}
Репозиторий готов! А еще он так и просится добавить его в DiContainer:
// base_url/5/tetris/lib/app/di_container.dart
import 'package:flutter/widgets.dart';
import
import
import
import
'../features/leaderboard/data/leaderboard_repository.dart';
'../features/leaderboard/domain/i_leaderboard_repository.dart';
'http/base_http_client.dart';
'http/i_http_client.dart';
final class DiContainer extends InheritedWidget {
DiContainer({super.key, required super.child}) {
// Инициализируем контейнер зависимостей
_httpClient = BaseHttpClient();
// Инициализируем репозиторий таблицы лидеров
leaderRepository = LeaderboardRepository(httpClient: _httpClient);
}
/// Интерфейс HTTP клиента
late final IHttpClient _httpClient;
Проект: игра «Тетрис» v. 5. Работа с сетью 533
/// Интерфейс репозитория для работы с таблицей лидеров
late final ILeaderboardRepository leaderRepository;
}
// Остальной код без изменений
Для того чтобы управлять данными на экране и запускать процесс их получения
с сервера, займемся реализацией менеджера состояния. В папке domain создайте
каталог state с двумя файлами:
y leaderboard_cubit.dart — в нем объявим класс управления состоянием —
LeaderboardCubit;
y leaderboard_state.dart — библиотека состояний, с которыми будет работать
LeaderboardCubit.
Начнем с объявления состояний. Откройте файл leaderboard_state.dart и добавьте в него следующий код:
/* base_url/5/tetris/lib/features/leaderboard/data/state/
leaderboard_state.dart */
import 'package:tetris/app/equals_mixin.dart';
import '../../domain/leaderboard_entity.dart';
/// Состояние блока
sealed class LeaderboardState with EqualsMixin {
const LeaderboardState();
}
@override
List<Object?> get fields = > [];
/// Состояние инициализации
final class LeaderboardInitState extends LeaderboardState {
const LeaderboardInitState();
}
/// Состояние загрузки
final class LeaderboardLoading extends LeaderboardState {
const LeaderboardLoading();
}
/// Состояние успешной загрузки
final class LeaderboardSuccessState extends LeaderboardState {
/// При успешной загрузке получаем список
/// сущностей таблицы лидеров
final List<LeaderboardEntity> leaderboard;
const LeaderboardSuccessState(this.leaderboard);
}
@override
List<Object?> get fields = > [leaderboard];
/// Состояние ошибки
final class LeaderboardErrorState extends LeaderboardState {
final String message;
final Object error;
final StackTrace? stackTrace;
LeaderboardErrorState(
this.message, {
534 Глава 5 Работа с сетью
required this.error,
this.stackTrace,
});
}
@override
List<Object?> get fields = > [message, error, stackTrace];
Теперь откройте файл leaderboard_cubit.dart . В нем будет располагаться
объявление класса, отвечающего за управление состоянием в реализуемой части
функционала игры «Тетрис» — LeaderboardCubit:
/* base_url/5/tetris/lib/features/leaderboard/data/state/
leaderboard_cubit.dart */
import 'package:flutter/foundation.dart';
import '../i_leaderboard_repository.dart';
import 'leaderboard_state.dart';
/// Класс, использующий паттерн Cubit для управления состоянием
/// таблицы лидеров. Сами состояния таблицы хранятся в ValueNotifier
class LeaderboardCubit {
final ILeaderboardRepository repository;
/// Состояние таблицы лидеров
/// Используем ValueNotifier для отслеживания состояния
final ValueNotifier<LeaderboardState> stateNotifier =
ValueNotifier(LeaderboardInitState());
LeaderboardCubit({required this.repository});
/// Установка текущего состояния
void emit(LeaderboardState cubitState) {
stateNotifier.value = cubitState;
}
/// Получение таблицы лидеров
Future<void> fetchLeaderboard() async {
// Проверяем текущее состояние
// Если уже состояние загрузки, то ничего не делаем
if (stateNotifier.value is LeaderboardLoading) {
return;
}
}
}
try {
emit(const LeaderboardLoading());
final leaderboard = await repository.fetchLeaderboard();
emit(LeaderboardSuccessState(leaderboard.toList()));
} on Object catch (e, stackTrace) {
emit(LeaderboardErrorState(
'Ошибка загрузки таблицы лидеров',
error: e,
stackTrace: stackTrace,
));
}
/// Освобождение ресурсов
/// Закрываем ValueNotifier, чтобы избежать утечек памяти
void dispose() {
stateNotifier.dispose();
}
Проект: игра «Тетрис» v. 5. Работа с сетью 535
Пришло время приступить к реализации экрана отображения таблицы результатов. Перейдите в папку presentation разрабатываемого функционала и создайте
файл leaderboard_screen.dart со следующим наполнением:
/* base_url/5/tetris/lib/features/leaderboard/presentation/
leaderboard_screen.dart */
import 'package:flutter/material.dart';
import 'package:tetris/app/context_ext.dart';
import '../domain/leaderboard_entity.dart';
import '../domain/state/leaderboard_cubit.dart';
import '../domain/state/leaderboard_state.dart';
/// Экран таблицы лидеров
/// Здесь отображается таблица лидеров
class LeaderboardScreen extends StatefulWidget {
const LeaderboardScreen({super.key});
}
@override
State<LeaderboardScreen> createState() = > _LeaderboardScreenState();
class _LeaderboardScreenState extends State<LeaderboardScreen> {
/// Объявляем кубит для работы с таблицей лидеров
late final LeaderboardCubit leaderboardCubit;
@override
void initState() {
super.initState();
// Инициализируем кубит и получаем таблицу
// лидеров с помощью репозитория, который
// получаем из контейнера зависимостей
leaderboardCubit = LeaderboardCubit(
repository: context.di.leaderRepository,
)..fetchLeaderboard();
}
@override
Widget build(BuildContext context) {
// Используем ValueListenableBuilder
// для отслеживания изменений состояния
// и перестраиваем виджет при изменении
// состояния кубита
return Scaffold(
appBar: AppBar(
title: const Text('Таблица лидеров'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
// Обновляем таблицу лидеров при нажатии кнопки
leaderboardCubit.fetchLeaderboard();
},
),
],
),
body: ValueListenableBuilder(
valueListenable: leaderboardCubit.stateNotifier,
builder: (context, state, child) = > switch (state) {
// Инициализация состояния
LeaderboardInitState() = >
const Center(child: Text('Инициализация...')),
536 Глава 5 Работа с сетью
// Загрузка состояния
// Здесь можно добавить анимацию загрузки
// или что-то подобное
LeaderboardLoading() = >
const Center(child: CircularProgressIndicator()),
// Успешное состояние
// Здесь отображаем таблицу лидеров
LeaderboardSuccessState() = > _ListRecords(state.leaderboard),
// Ошибка состояния
// Здесь можно добавить обработку ошибок
LeaderboardErrorState() = > Center(
child: Text(
'Ошибка: ${state.message}',
style: const TextStyle(color: Colors.red),
),
),
},
),
}
}
);
@override
void dispose() {
// При завершении работы виджета освобождаем
// ресурсы кубита
leaderboardCubit.dispose();
super.dispose();
}
/// Виджет для отображения списка записей
/// таблицы лидеров
class _ListRecords extends StatelessWidget {
const _ListRecords(this.leaderboard);
final List<LeaderboardEntity> leaderboard;
@override
Widget build(BuildContext context) {
// Проверяем, есть ли записи в таблице лидеров
if (leaderboard.isEmpty) {
return const Center(child: Text('Нет записей в таблице лидеров'));
}
}
}
return ListView.builder(
itemCount: leaderboard.length,
itemBuilder: (context, index) {
final LeaderboardEntity item = leaderboard[index];
return ListTile(
title: Text(item.username),
subtitle: Text('Очки: ${item.score}'),
);
},
);
Возможно, верстка экрана покажется вам сложной, но на самом деле все очень
просто: создаем объект кубита, получаем репозиторий из контейнера зависимостей
и при изменении состояния кубита с помощью ValueListenableBuilder перестраиваем графический пользовательский интерфейс. Если что-то непонятно, рекомендуем
перечитать главу про управление состоянием.
Проект: игра «Тетрис» v. 5. Работа с сетью 537
Функционал leaderboard практически готов, за исключением того момента,
что необходимо добавить реализованный ранее экран в GameRouter и на главной
странице сделать кнопку для перехода на него.
Перед тем как добавить в роутер LeaderboardScreen, импортируем его в файле
main.dart и перенесем game_router.dart в папку app. Одна из наших с вами задач —
оставить точку входа в приложение наедине с папками app и features, переместив
из нее файлы в те части проекта, которые несут за них «смысловую ответственность»:
// base_url/5/tetris/lib/app/game_router.dart
part of '../main.dart';
// Роутер игры.
abstract final class GameRouter {
// Начальный маршрут.
static const String initialRoute = '/';
// Маршрут игры
static const String gameRoute = '/game';
// Маршрут окончания игры
static const String gameOverRoute = '/game_over';
// Маршрут таблицы лучших результатов
static const String leaderboardRoute = '/leaderboard';
}
// Маршруты приложения. Объявляются приватными,
// чтобы исключить доступ к ним вне навигатора
static final Map<String, WidgetBuilder> _appRoutes = {
// Стартовый экран - главное меню
initialRoute: (_) = > const MainMenuScreen(),
// Экран игры
gameRoute: (_) = > const GameScreen(),
// Экран окончания игры
gameOverRoute: (_) = > const GameOverScreen(),
// Экран таблицы лучших результатов
leaderboardRoute: (_) = > const LeaderboardScreen(),
};
Разработка экрана с главным меню
Теперь добавим в главное меню кнопку перехода на таблицу результатов. Для этого
создайте в папке features каталог main_menu и переместите в него файл main_menu_
screen.dart из каталога screen. После чего откройте его и приведите к следующему
виду:
// base_url/5/tetris/lib/features/main_menu/main_menu_screen.dart
import 'package:flutter/material.dart';
import 'package:tetris/main.dart';
class MainMenuScreen extends StatelessWidget {
const MainMenuScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
538 Глава 5 Работа с сетью
}
}
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: () {
// Переход на экран игры
Navigator.pushReplacementNamed(
context,
GameRouter.gameRoute,
);
},
child: Text('Начать игру')),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Переход на экран ввода имени игрока
Navigator.pushNamed(
context,
GameRouter.leaderboardRoute,
);
},
child: Text('Лучшие результаты')),
],
),
));
Запустите локальный сервер, саму игру и перейдите на страницу с таблицей
лучших результатов. Если вы все сделали правильно, то при переходе на этот экран
увидите, что таблица пока пустая (рис. 5.14). Так и должно быть, потому что нам
еще предстоит добавить пользователей в базу данных.
Рис. 5.14. Пример перехода на страницу с таблицей лидеров
Проект: игра «Тетрис» v. 5. Работа с сетью 539
Создание нового пользователя
Теперь займемся функционалом для создания
пользователя и обновления его результатов
на сервере. А так как данные о пользователе
должны быть доступны с любого экрана игры,
мы будем хранить их в DI-контейнере.
Создайте в папке features каталог user, а в
нем по аналогии с предыдущим функционалом объявите слои data, domain и presentation
(рис. 5.15).
Начнем с того, что в каталоге domain создадим файл user_entity.dart, где объявим
сущность, описывающую пользователя, —
UserEntity:
// base_url/5/tetris/lib/features/user/domain/
user_entity.dart
import 'package:flutter/foundation.dart';
import 'package:tetris/app/equals_mixin.dart';
/// Сущность пользователя
@immutable
class UserEntity with EqualsMixin {
/// Идентификатор пользователя
final int id;
/// Имя пользователя
final String username;
/// Лучший счет пользователя
final int score;
const UserEntity({
required this.id,
required this.username,
required this.score,
});
}
Рис. 5.15. Структура папки use
@override
List<Object?> get fields => [id, username, score];
На следующем шаге в этой же папке создадим файл i_user_repository.dart,
задающий интерфейс создания пользователя и передачи набранных им очков на
сервер:
// base_url/5/tetris/lib/features/user/domain/i_user_repository.dart
import 'user_entity.dart';
/// Интерфейс репозитория пользователя
abstract interface class IUserRepository {
/// Создание пользователя. Если пользователь с таким
/// именем уже существует, то возвращается именно он
Future<UserEntity> createUser(String username);
}
// Установка счета пользователя
Future<UserEntity> setScores(String username, int scores);
540 Глава 5 Работа с сетью
Теперь перейдем в слой data и создадим файл user_dto.dart. Он будет содержать класс UserDto , который представляет собой Data Transfer Object для
парсинга данных о пользователе, поступающих с сервера, и перевода их в объект
типа UserEntity:
// base_url/5/tetris/lib/features/user/data/user_dto.dart
import '../domain/user_entity.dart';
/// Data Transfer Object для парсинга данных пользователя
final class UserDto {
/// Идентификатор пользователя
final int id;
/// Имя пользователя
final String username;
/// Лучший счет пользователя
final int score;
const UserDto({
required this.id,
required this.username,
required this.score,
});
/// Преобразование JSON в DTO
/// [json] - JSON-данные, полученные от сервера
factory UserDto.fromJson(Map<String, dynamic> json) {
return UserDto(
id: json['id'] as int,
username: json['username'] as String,
score: json['score'] ?? 0,
);
}
}
/// Преобразование DTO в сущность [UserEntity]
UserEntity toEntity() {
return UserEntity(
id: id,
username: username,
score: score,
);
}
Не покидая папки data, создадим еще один файл — user_repository.dart. В нем
объявим реализацию интерфейса IUserRepository:
// base_url/5/tetris/lib/features/user/data/user_repository.dart
import 'dart:convert';
import
import
import
import
'package:tetris/app/http/i_http_client.dart';
'user_dto.dart';
'../domain/i_user_repository.dart';
'../domain/user_entity.dart';
/// Репозиторий для работы с пользователем
final class UserRepository implements IUserRepository {
final IHttpClient httpClient;
UserRepository({required this.httpClient});
Проект: игра «Тетрис» v. 5. Работа с сетью 541
@override
Future<UserEntity> createUser(String username) async {
// Получение данных
final response = await httpClient.post(
'/users/',
body: {"username": username},
);
// Проверка статуса ответа
if (response.statusCode ! = 200) {
throw Exception(
'Ошибка при создании пользователя: ${response.statusCode}'
);
}
// Преобразование данных в список сущностей
return UserDto.fromJson(json.decode(response.body)).toEntity();
}
}
@override
Future<UserEntity> setScores(String username, int scores) async {
final response = await httpClient.put(
'/users/scores/',
body: {
'username': username,
'score': scores,
},
);
// Проверка статуса ответа
if (response.statusCode ! = 200) {
throw Exception(
'Ошибка при обновлении пользователя: ${response.statusCode}'
);
}
// Преобразование данных в список сущностей
return UserDto.fromJson(json.decode(response.body)).toEntity();
}
Поскольку теперь у нас есть все необходимое для работы с сетью, пора перейти
к реализации менеджера состояния. Для этого вернемся в папку domain и создадим
в ней каталог state с двумя файлами: user_cubit.dart и user_state.dart. В первом
файле объявим класс UserCubit, реализующий для управления состоянием шаблон
Cubit (кубит, упрощенный вариант шаблона BLoC), а во втором — все возможные
состояния, с которыми должен работать UserCubit.
Начнем с объявления состояний в файле user_state.dart:
// base_url/5/tetris/lib/features/user/data/state/user_state.dart
import 'package:tetris/app/equals_mixin.dart';
import '../user_entity.dart';
/// Состояние блока
sealed class UserState with EqualsMixin {
const UserState();
}
@override
List<Object?> get fields = > [];
/// Состояние инициализации
final class UserInitState extends UserState {
const UserInitState();
}
542 Глава 5 Работа с сетью
/// Состояние загрузки
final class UserLoadingState extends UserState {
const UserLoadingState();
}
/// Состояние успешной загрузки
final class UserSuccessState extends UserState {
final UserEntity userEntity;
const UserSuccessState(this.userEntity);
}
@override
List<Object?> get fields = > [userEntity];
/// Состояние ошибки
final class UserErrorState extends UserState {
final String message;
final Object error;
final StackTrace? stackTrace;
const UserErrorState(
this.message, {
required this.error,
this.stackTrace,
});
}
@override
List<Object?> get fields = > [message, error, stackTrace];
Что касается файла user_cubit.dart, то класс UserCubit будет содержать в себе
методы для создания пользователя, установки результатов и нового состояния,
а также выхода из аккаунта:
// base_url/5/tetris/lib/features/user/data/state/user_cubit.dart
import 'package:flutter/foundation.dart';
import '../i_user_repository.dart';
import 'user_state.dart';
/// Класс для управления состоянием пользователя
/// и взаимодействия с удаленным репозиторием
class UserCubit {
final IUserRepository repository;
UserCubit({required this.repository});
final ValueNotifier<UserState> stateNotifier =
ValueNotifier(UserInitState());
Future<void> createUser(String username) async {
// Проверка состояния. Если состояние загрузки,
// то не выполнять запрос и не перезаписывать состояние
if (stateNotifier.value is UserLoadingState) return;
try {
// Установка состояния загрузки
emit(UserLoadingState());
// Создание пользователя
// Если пользователь с таким именем уже существует,
final entity = await repository.createUser(username);
// Установка состояния успешной загрузки
// и передача сущности пользователя
emit(UserSuccessState(entity));
} on Object catch (error, stackTrace) {
Проект: игра «Тетрис» v. 5. Работа с сетью 543
}
}
// Установка состояния ошибки и передача сообщения об ошибке
emit(UserErrorState('Ошибка создания пользователя',
error: error, stackTrace: stackTrace));
/// Установка счета пользователя
/// [username] — имя пользователя
/// [scores] — счет пользователя
Future<void> setScores(String username, int scores) async {
if (stateNotifier.value is UserLoadingState) return;
}
try {
emit(UserLoadingState());
final entity = await repository.setScores(username, scores);
emit(UserSuccessState(entity));
} on Object catch (error, stackTrace) {
emit(UserErrorState(
'Ошибка обновления результата пользователя',
error: error,
stackTrace: stackTrace,
));
}
/// Выход из аккаунта
/// Удаление текущего состояния
void signOut() {
emit(UserInitState());
}
/// Сброс состояния кубита
/// Пригодится для сброса состояния при повторном
/// входе в аккаунт
void reset() {
emit(UserInitState());
}
}
/// Установка текущего состояния
void emit(UserState cubitState) {
stateNotifier.value = cubitState;
}
Настало время обновить класс DiContainer, добавив в него инициализацию
классов UserRepository и UserCubit, что позволит обращаться к ним из любой точки
приложения с помощью контекста:
// base_url/5/tetris/lib/app/di_container.dart
import 'package:flutter/widgets.dart';
import
import
import
import
import
import
import
'../features/leaderboard/data/leaderboard_repository.dart';
'../features/leaderboard/domain/i_leaderboard_repository.dart';
'../features/user/data/user_repository.dart';
'../features/user/domain/i_user_repository.dart';
'../features/user/domain/state/user_cubit.dart';
'http/base_http_client.dart';
'http/i_http_client.dart';
final class DiContainer extends InheritedWidget {
DiContainer({super.key, required super.child}) {
// Инициализируем контейнер зависимостей
_httpClient = BaseHttpClient();
544 Глава 5 Работа с сетью
// Инициализируем репозиторий таблицы лидеров
leaderRepository = LeaderboardRepository(httpClient: _httpClient);
// Инициализируем репозиторий пользователя
_userRepository = UserRepository(httpClient: _httpClient);
// Инициализируем менеджер состояния пользователя
userCubit = UserCubit(repository: _userRepository);
}
/// Интерфейс HTTP-клиента
late final IHttpClient _httpClient;
/// Интерфейс репозитория для работы с таблицей лидеров
late final ILeaderboardRepository leaderRepository;
/// Интерфейс репозитория для работы с пользователем
late final IUserRepository _userRepository;
/// Менеджер состояния пользователя
late final UserCubit userCubit;
}
// Остальной код без изменений
Единственное, что нам осталось реализовать для разрабатываемой функциональности, — слой presentation. Он будет состоять из одного экрана с полем ввода
и кнопкой Создать пользователя. А для большего приближения проекта к «кровавому
энтерпрайзу» декомпозируем экран на части и поместим его элементы в подпапку
components. Начнем этот процесс с добавления файла username_field.dart, отвечающего за поле для ввода имени пользователя и кнопку:
/* base_url/5/tetris/lib/features/user/presentation/components/
username_field.dart */
import 'package:flutter/material.dart';
import 'package:tetris/app/context_ext.dart';
/// Поле для ввода имени пользователя с кнопкой,
/// запускающей процесс его создания
class UsernameField extends StatelessWidget {
const UsernameField({
super.key,
required TextEditingController controller,
}) : _controller = controller;
/// Контроллер текстового поля
/// Используется для получения текста из текстового поля
final TextEditingController _controller;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Введите ваш никнейм:'),
SizedBox(height: 16),
TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
hintText: 'Никнейм',
),
Проект: игра «Тетрис» v. 5. Работа с сетью 545
controller: _controller,
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_controller.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Введите ваш никнейм'),
),
);
return;
}
/// Вызываем метод создания пользователя
/// из кубита и передаем никнейм
/// из текстового поля с помощью контроллера
context.di.userCubit.createUser(_controller.text);
},
child: Text('Создать пользователя'),
}
}
],
);
),
SizedBox(height: 16),
Поскольку у нас нет гарантии того, что всегда будет связь с сервером или пользователь изначально ввел корректные данные, необходимо обработать ситуации,
когда создание пользователя завершилось ошибкой. Для этого в папке components
создадим файл user_error.dart и возложим на него эту обязанность, предоставив
возможность повторного запуска процесса создания пользователя и выхода в главное меню:
/* base_url/5/tetris/lib/features/user/presentation/components/
user_error.dart */
import 'package:flutter/material.dart';
import 'package:tetris/app/context_ext.dart';
import 'package:tetris/main.dart';
/// Компонент для отображения ошибки при создании пользователя
class UserError extends StatelessWidget {
const UserError({
super.key,
required this.message,
required this.username,
});
/// Сообщение об ошибке
final String message;
/// Имя пользователя
final String username;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message),
SizedBox(height: 16),
546 Глава 5 Работа с сетью
// Кнопка для перезапуска создания пользователя
ElevatedButton(
onPressed: () {
// Сбрасываем состояние кубита, чтобы
// начать процесс создания пользователя заново
context.di.userCubit.reset();
// Пробуем снова создать пользователя
context.di.userCubit.createUser(username);
},
child: Text('Попробовать снова'),
),
SizedBox(height: 16),
// Кнопка для возврата в главное меню
ElevatedButton(
onPressed: () {
// Переход на главный экран приложения
Navigator.pushReplacementNamed(
context,
GameRouter.initialRoute,
);
},
child: Text('Вернуться в главное меню'),
),
],
}
}
);
Последним в этой папке (components) создадим файл user_created.dart. Он будет содержать компоненты для оповещения пользователя об успешном создании
учетной записи на сервере:
/* base_url/5/tetris/lib/features/user/presentation/components/
user_created.dart */
import 'package:flutter/material.dart';
import 'package:tetris/app/context_ext.dart';
import 'package:tetris/main.dart';
import '../../domain/user_entity.dart';
/// Компонент для отображения данных пользователя
/// при успешном создании
/// [userEntity] — сущность пользователя
class UserCreated extends StatelessWidget {
const UserCreated({
super.key,
required this.userEntity,
});
final UserEntity userEntity;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Добро пожаловать, ${userEntity.username}!'),
SizedBox(height: 16),
Text('Ваш лучший результат: ${userEntity.score}'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Проект: игра «Тетрис» v. 5. Работа с сетью 547
// Переход на экран игры при нажатии кнопки "Начать игру"
Navigator.pushReplacementNamed(
context,
GameRouter.gameRoute,
);
},
child: Text('Начать игру'),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Выход из аккаунта
context.di.userCubit.signOut();
},
child: Text('Выйти из аккаунта'),
),
],
}
}
);
Теперь соберем все компоненты экрана воедино. Для этого в папке presentation
создайте файл user_screen.dart со следующим содержимым:
// base_url/5/tetris/lib/features/user/presentation/user_screen.dart
import 'package:flutter/material.dart';
import 'package:tetris/app/context_ext.dart';
import
import
import
import
'../domain/state/user_state.dart';
'components/user_created.dart';
'components/user_error.dart';
'components/username_field.dart';
/// Экран создания пользователя
class UserScreen extends StatefulWidget {
const UserScreen({super.key});
}
@override
State<UserScreen> createState() = > _UserScreenState();
class _UserScreenState extends State<UserScreen> {
/// Контроллер для текстового поля ввода имени пользователя
/// Используется для получения текста из текстового поля
late final TextEditingController _controller;
@override
void initState() {
super.initState();
// Инициализируем контроллер текстового поля
_controller = TextEditingController();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(16),
child: Center(
// Используем ValueListenableBuilder для отслеживания состояния
// кубита и обновления UI при изменении состояния
child: ValueListenableBuilder(
valueListenable: context.di.userCubit.stateNotifier,
548 Глава 5 Работа с сетью
builder: (context, state, child) {
return switch (state) {
// Если состояние кубита — инициализация,
// то отображаем поле ввода имени
// пользователя
UserInitState() = > UsernameField(
controller: _controller,
),
// Если состояние кубита — загрузка,
// то отображаем индикатор загрузки
UserLoadingState() = > CircularProgressIndicator(),
// Если пользователь успешно создан,
// то отображаем его данные
UserSuccessState() = > UserCreated(
userEntity: state.userEntity,
),
// Если состояние кубита — ошибка,
// то отображаем сообщение об ошибке
UserErrorState() = > UserError(
message: state.message,
username: _controller.text,
),
};
},
),
),
),
}
}
);
Осталось всего ничего — добавить экран в GameRouter и реализовать переход на
него из главного меню. Сначала расширим функционал навигатора:
// base_url/5/tetris/lib/app/game_router.dart
part of '../main.dart';
// Роутер игры.
abstract final class GameRouter {
// Начальный маршрут.
static const String initialRoute = '/';
// Маршрут игры
static const String gameRoute = '/game';
// Маршрут окончания игры
static const String gameOverRoute = '/game_over';
// Маршрут таблицы лучших результатов
static const String leaderboardRoute = '/leaderboard';
// Маршрут пользователя
static const String userRoute = '/user';
// Маршруты приложения. Объявляются приватными,
// чтобы исключить доступ к ним вне навигатора
static final Map<String, WidgetBuilder> _appRoutes = {
// Стартовый экран — главное меню
initialRoute: (_) = > const MainMenuScreen(),
// Экран игры
gameRoute: (_) = > const GameScreen(),
// Экран окончания игры
gameOverRoute: (_) = > const GameOverScreen(),
Проект: игра «Тетрис» v. 5. Работа с сетью 549
}
};
// Экран таблицы лучших результатов
leaderboardRoute: (_) = > const LeaderboardScreen(),
// Экран пользователя
userRoute: (_) = > const UserScreen(),
На следующем шаге перейдите в файл main_menu_screen.dart и замените:
Navigator.pushReplacementNamed(context, GameRouter.gameRoute);
на:
Navigator.pushReplacementNamed(context, GameRouter.userRoute);
Запустите игру и проверьте, как создается пользователь и обрабатываются
ошибки. Это можно сделать, отключив сервер на некоторое время (рис. 5.16).
Рис. 5.16. Пример работы приложения
550 Глава 5 Работа с сетью
Модификация экрана с игрой
Последнее, что нам осталось, — добавить никнейм пользователя на игровое поле и реализовать сохранение его результата. Но сначала
перенесем в папку features/game все файлы, которые относятся к игре: tetris_game.dart, game_
scores.dart , каталог src, game_over_screen.
dart и game_screen.dart. После этого можно
удалить папку screens.
В результате у вас должна получиться следующая структура каталогов функции, отвечающей за игровой процесс (рис. 5.17).
Поскольку в ходе реализации игры нам
часто придется извлекать username текущего пользователя из контекста, вынесем этот
функционал в утилиты игры. Для этого в каталоге app создадим файл utils.dart и объявим
в нем абстрактный класс Utils, гарантирующий, что его экземпляр не может быть создан:
// base_url/5/tetris/lib/app/utils.dart
import 'package:flutter/widgets.dart';
import 'package:tetris/app/context_ext.dart';
Рис. 5.17. Структура папки game
import '../features/user/domain/state/user_
state.dart';
/// Утилита для работы с приложением
/// Содержит методы, которые могут быть полезны в разных его частях
abstract class Utils {
// Получаем имя пользователя из состояния кубита
// Если состояние кубита — успешная загрузка,
// то возвращаем имя пользователя
// Если любое другое состояние, то возвращаем 'Гость'
static String getUsername(
BuildContext context,
) {
final state = context.di.userCubit.stateNotifier.value;
if (state is UserSuccessState) {
return state.userEntity.username;
} else {
return 'Гость';
}
}
}
Далее в файле tetris_game.dart добавим отображение имени игрока следом за
заработанными им игровыми очками. А в методе initState реализуем сохранение
результатов игры после ее окончания. Для этого в теле callback-функции onGameOver
удалим передачу данных аргументу arguments и добавим выполнение запроса на
сохранение результатов игры на сервере, откуда пользователь и будет запрашивать
результат своей завершенной игры:
Проект: игра «Тетрис» v. 5. Работа с сетью 551
// base_url/5/tetris/lib/features/game/tetris_game.dart
import 'dart:math';
import
import
import
import
import
import
import
'package:tetris/app/utils.dart';
'package:tetris/app/context_ext.dart';
'package:flutter/material.dart';
'package:flutter/services.dart';
'package:tetris/main.dart';
'package:tetris/features/game/src/board.dart';
'package:tetris/features/game/src/game.dart';
/// Реализация игры "Тетрис"
class TetrisGame extends StatefulWidget {
// без изменений
}
class _TetrisGameState extends State<TetrisGame> {
late Game game;
@override
void initState() {
super.initState();
game = Game(
onGameOver: (scores) {
// Переход на экран окончания игры
Navigator.pushReplacementNamed(
context,
GameRouter.gameOverRoute,
arguments: scores,
);
context.di.userCubit.setScores(
Utils.getUsername(context),
int.tryParse(scores.toString()) ?? 0,
);
},
);
game.start();
}
@override
Widget build(BuildContext context) {
// Добавляем слушателя для обновления состояния виджета
return ListenableBuilder(
// Передаем игру в качестве объекта, реализующего Listenable
listenable: game,
// Перестраиваем виджет при изменении состояния игры
builder: (context, _) {
// без изменений
},
child: Align(
alignment: Alignment.center,
// Получаем размеры виджета
child: LayoutBuilder(
builder: (context, constraints) {
final board = game.board.mainBoard;
// Вычисляем размер клетки поля
double blockSize = min(
constraints.maxWidth / board[0].length,
constraints.maxHeight / board.length,
);
return Column(
children: [
Expanded(
552 Глава 5 Работа с сетью
child: CustomPaint(
painter: _GamePainter(board, blockSize),
size: Size(board[0].length * blockSize,
board.length * blockSize),
),
),
// Отображение текущего счета
Text(
'Очки: ${game.score}',
style: TextStyle(fontSize: 24),
),
Text(
'Играет: ${Utils.getUsername(context)}',
style: TextStyle(fontSize: 24),
),
],
);
},
),
),
}
}
);
});
// Класс отрисовки игрового поля
class _GamePainter extends CustomPainter {
// без изменений
}
Последнее, что нам осталось, — внести изменения в класс GameOverScreen, добавив обработку состояния кубита UserCubit:
// base_url/5/tetris/lib/features/game/tetris_game.dart
import 'package:flutter/material.dart';
import 'package:tetris/app/context_ext.dart';
import 'package:tetris/features/game/game_scores.dart';
import 'package:tetris/main.dart';
import '../user/domain/state/user_state.dart';
/// Экран окончания игры
class GameOverScreen extends StatelessWidget {
const GameOverScreen({super.key});
@override
Widget build(BuildContext context) {
//final args = ModalRoute.of(context)?.settings.arguments;
//final scores = int.tryParse(args.toString()) ?? 0;
return Scaffold(
/// Слушатель состояния кубита пользователя
/// и отображение соответствующего виджета
/// в зависимости от состояния
return Scaffold(
/// Слушатель состояния кубита пользователя
/// и отображение соответствующего виджета
/// в зависимости от состояния
body: Center(
child: ValueListenableBuilder(
builder: (context, state, child) {
return switch (state) {
UserLoadingState() = > CircularProgressIndicator(),
Проект: игра «Тетрис» v. 5. Работа с сетью 553
UserSuccessState() = > GameScores(
score: state.userEntity.score,
onRestart: () {
Navigator.pushReplacementNamed(context, GameRouter.gameRoute);
}),
_ = > SizedBox.shrink(),
};
},
}
}
),
));
valueListenable: context.di.userCubit.stateNotifier,
Теперь запустите игру и проверьте, сохраняются ли результаты при ее завершении.
Для этого придется сыграть как минимум одну
партию и перезапустить «Тетрис», после чего
посмотреть результаты в таблице лидеров
либо снова залогиниться в игре, введя свой
никнейм. В таком случае на экране отобразится ваш лучший результат (рис. 5.18).
Согласны, эта часть сквозного проекта получилась большой и сложной. Несмотря на
все наши попытки сделать ее как можно проще, организация структуры каталогов приложения и работы с сетью по заветам «чистой
архитектуры» способна вызвать недоумение
у начинающих разработчиков. Тем не менее
игра стала намного функциональнее и в ее
кодовую базу стало проще вносить изменения
или добавлять новые функции.
Рис. 5.18. Экран с таблицей лидеров
Задания на модификацию проекта
Вас сейчас может раздражать то, что вводить имя пользователя при каждом запуске игры неудобно. В следующей главе мы исправим эту ситуацию, сохраняя
и восстанавливая состояние игрока из памяти устройства. А пока можете выполнить следующие задания по внесению изменений в существующую кодовую базу,
используя знания, полученные в этой главе.
1. Разработайте новую функцию с отдельным экраном, которая позволит
пользователю выбрать уровень сложности (скорости падения блоков) перед
стартом игры.
2. Разработайте новую функцию с отдельным экраном, позволяющую пользователю добавлять новую фигуру на игровое поле.
3. Разработайте новую функцию с отдельным экраном, позволяющую пользователю выбирать фигуры блоков, которые будут применяться в игре.
Минимальное количество блоков разного типа — 3.
554 Глава 5 Работа с сетью
Резюме
В текущей главе мы рассмотрели, что такое клиент-серверная архитектура и Appli
cation Programming Interface (API), а также как организовать взаимодействие вашего
приложения с сервером через веб-сокет и HTTP-запросы. В целом было затронуто
не так уж много аспектов, о которых хотелось бы поговорить. Так, например, мы нарочно опустили архитектурные детали. Во-первых, их слишком долго разжевывать,
а книга не резиновая; во-вторых, если вы только изучаете программирование, такой
материал способен запутать вас и отпугнуть от этого замечательного процесса, что
не в наших с вами интересах.
Поэтому, как только почувствуете, что готовы и познали дзен изложенного
в главе материала, смело переходите к лабораторному практикуму или изучению
того, как в каком-нибудь известном проекте, написанном на Flutter, архитектурно
выстроена работа с сервером.
Вопросы для самопроверки
1. Что такое клиент-серверная архитектура? Какие клиенты существуют? В чем
их различия?
2. Какие вам известны ограничения и проблемы dart:io, возникающие при
организации работы по сети?
3. Что такое REST API? Какие виды HTTP-запросов существуют?
4. Перечислите самые распространенные статусы ответов на HTTP-запросы.
5. Какие данные можно передавать через заголовки (headers) HTTP-запросов?
6. Что такое веб-сокет? Чем взаимодействие с сервером по веб-сокету отличается от HTTP-запросов?
Глава 6
ЛОКАЛЬНОЕ ХРАНЕНИЕ ДАННЫХ
В предыдущей главе мы рассмотрели, как на уровне приложения организовать
работу с сетью: отправлять запросы на сервер и получать данные от него по
REST API. Допустим, перед вами стоит задача разработать приложение, в котором
пользователи могут покупать билеты на пригородные электрички. Компания, занимающаяся продажей билетов, предоставила доступ к их API, вследствие чего
удалось реализовать оплату билетов в приложении и загрузку списка билетов
пользователя с сервера.
Но что произойдет, когда в поездке в какой-то глуши, куда еще не добрался
Интернет, в поезд зайдет контролер и попросит пользователя предъявить билет?
Да, можно в приложении включить режим «паника» и показать баннер «Отсутствует
подключение к Интернету» или «Извините, у нас лапки» и заставить пользователя
краснеть перед контролером. Но гораздо круче сделать так, чтобы приложение даже
без связи с Интернетом продолжало работать как швейцарские часы.
И вот тут начинается самое интересное! Локальное хранение данных — это как
коробочка под кроватью, куда откладывают деньги на непредвиденные случаи. Оно
сохраняет все, что дорого пользователю, и позволяет приложению работать даже
при отсутствии подключения к Интернету. Более того, умение правильно организовать локальное хранилище может избавить от большой головной боли, а заодно
ускорить приложение.
6.1. SharedPreferences
Начнем знакомство с локальными хранилищами с самого простого, но при этом
наиболее популярного варианта — SharedPreferences. Это название — наследие из
мира Android, где изначально и появился этот термин, введенный для описания
способа хранения небольших данных в формате «ключ — значение», где слово
shared означает, что сохраненные данные являются общими и доступны разным
компонентам одного приложения.
Реализация этого пакета для Flutter концептуально не отличается от своего
прародителя из Android. То есть это хранилище сохраняет данные в формате
«ключ — значение», и его можно использовать в разных частях вашего приложения. Кроме того, при запуске на Android Flutter-библиотека shared_preferences
задействует нативный для этой операционной системы SharedPreferences. А при
556 Глава 6 Локальное хранение данных
запуске на iOS данные в формате «ключ — значение» содержатся в специальном
хранилище, доступ к которому предоставляется с помощью интерфейса NSUserDefaults. Он идеально подходит для хранения настроек, флагов или других данных,
которые нужно быстро записать и прочитать.
6.1.1. Начало работы
Чтобы начать работать с пакетом shared_preferences, установите его в качестве
зависимости проекта в файле pubspec.yaml:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
shared_preferences: 2.3.3 # укажите актуальную версию
или загрузите в зависимости, вызвав в терминале проекта следующую команду:
flutter pub get
Чтобы начать записывать данные в это хранилище и читать их из него, необходимо проинициализировать экземпляр SharedPreferences. Во время этого процесса пакет загружает в оперативную память все данные из хранилища, так что это
асинхронная операция:
import 'package:shared_preferences/shared_preferences.dart';
final prefs = await SharedPreferences.getInstance();
Учитывая то, что мы работаем с Flutter-приложением, инициализация может
быть выполнена в функции main. В таком случае хранилище будет загружено в оперативную память в момент запуска приложения. Если вам требуется как можно
быстрее получить данные из хранилища, например, когда нужно показать заранее
выбранную пользователем тему, этот вариант подходит лучше всего:
// base_url/6/shared_prefs/lib/main.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> main() async {
final preferences = await SharedPreferences.getInstance();
}
// Передаем созданный инстанс SharedPreferences в приложение
runApp(SharedPrefsExampleApp(preferences: preferences));
class SharedPrefsExampleApp extends StatelessWidget {
const SharedPrefsExampleApp({super.key, required this.preferences});
final SharedPreferences preferences;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Shared Preferences',
6.1. SharedPreferences 557
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FloatingActionButton(
onPressed: null, // #1 потом добавим вызов при нажатии кнопки
child: const Icon(Icons.remove_red_eye),
),
const SizedBox(width: 16),
FloatingActionButton(
onPressed: null, // #2 потом добавим вызов при нажатии кнопки
child: const Icon(Icons.save),
),
],
),
),
}
}
);
// сюда будем добавлять код
В приведенном примере мы передаем экземпляр SharedPreferences в конструктор класса SharedPrefsExampleApp, представляющий собой корневой элемент дерева
элементов нашего приложения. В дальнейшем, чтобы обратиться к SharedPreferences, вы можете использовать подход c применением InheritedWidget (см. раздел 3.2.4) или библиотеки для инъекции зависимостей (Dependency Injection, DI)
provider, injectable и т. д.
Поскольку в примере, который мы будем реализовывать в этом разделе, планируется всего один экран, обойдемся без специализированных библиотек.
6.1.2. Запись данных
В SharedPreferences можно хранить данные самых простых типов Dart: String, int,
double, bool, List<String>. Для каждого из них есть свой метод, в который нужно
передать ключ key (тип String) и значение value (табл. 6.1).
Таблица 6.1. Методы записи в SharedPreferences
Тип данных
Метод записи
String
setString(key, value)
int
setInt(key, value)
double
setDouble(key, value)
bool
setBool(key, value)
List<String>
setStringList(key, value)
558 Глава 6 Локальное хранение данных
Название для ключа стоит подбирать таким образом, чтобы оно было понятным
и читаемым. Это позволит понять, какие данные по нему хранятся, не погружаясь
в детали и документацию проекта.
Чтобы лучше разобраться в том, как записывать данные в SharedPreferences, реа
лизуем самый подходящий для этой библиотеки функционал — хранение настроек
приложения. Добавьте следующий метод в тело класса SharedPrefsExampleApp и организуйте его вызов, передав имя метода в аргумент onPressed кнопки с ключом #1:
// base_url/6/shared_prefs/lib/main.dart
Future<void> saveAppSettings() async {
// Устанавливаем язык приложения — русский
await preferences.setString('selected_language', 'ru');
// Устанавливаем размер шрифта — 18.0
await preferences.setDouble('selected_font_size', 18.0);
// Устанавливаем количество баннеров для показа — 3
await preferences.setInt('showing_banners_count', 3);
// Устанавливаем темную тему — true
await preferences.setBool('is_dark_mode', true);
}
// Устанавливаем список предпочтительных для пользователя тем новостей
final categories = ['sport', 'music', 'busines'];
await preferences.setStringList(
'favorite_news_categories', categories,
);
Теперь добавьте в приложении, в SharedPreferences, вызов метода записи при
нажатии второй кнопки:
FloatingActionButton(
onPressed: saveAppSettings(), // #2
child: const Icon(Icons.save),
),
6.1.3. Запись пользовательских данных
Поскольку SharedPreferences рассчитан на хранение значений примитивных типов
данных, более сложные, например DateTime, придется сериализовать в строку для
записи и десериализовать в объект при чтении из хранилища. В случае с датами
класс DateTime предоставляет метод toIso8601String(), который переводит объект в строку международного формата ISO, например '1989–09–20 20:38:04Z'.
Для обратного преобразования необходимо использовать статический метод
DateTime.parse('1989–09–20 20:38:04Z') либо tryParse, который возвращает null
в случае неудачного парсинга входных данных.
Рассмотрим пример записи времени последнего открытия приложения пользователем:
Future<void> saveAppSstartTime() async {
// Сохраняем время последнего открытия приложения
final nowTime = DateTime.now().toIso8601String();
await preferences.setString('app_start_time', nowTime);
}
6.1. SharedPreferences 559
Еще один вариант сохранения DateTime заключается в том, что его можно
преобразовать в число типа int посредством обращения к геттеру millisecondsSinceEpoch. Он возвращает значение в миллисекундах, которое прошло с даты
1970–01–01T00:00:00Z. Для обратного же преобразования необходимо использовать
фабричный конструктор DateTime.fromMillisecondsSinceEpoch:
Future<void> saveAppStartTimeInt() async {
// Сохраняем время последнего открытия приложения
final nowTime = DateTime.now().millisecondsSinceEpoch;
await preferences.setInt('app_start_time', nowTime);
}
С одной стороны, такой вариант хранения даты и времени занимает меньше
времени, а с другой — без преобразования числа в экземпляр DateTime нет возможности без дополнительных усилий узнать о том, какая дата там записана. Поэтому
выбирайте вариант, наиболее подходящий для решения конкретных задач.
6.1.4. Чтение данных
SharedPreferences поддерживает чтение тех же типов данных, которые могут быть
записаны, но требует обработки возможного отсутствия значения. Для их извлечения библиотека предоставляет методы, представленные в табл. 6.2.
Таблица 6.2. Методы чтения из SharedPreferences
Тип данных
Метод чтения
String?
getString(key)
Int?
getInt(key)
Double?
getDouble(key)
Bool?
getBool(key)
List<String>?
getStringList(key)
Поскольку при запросе данных из хранилища может оказаться, что их нет,
и библиотека вернет null, придется каждый раз подстраховываться и обрабатывать такую ситуацию. Благо на этот случай Dart предоставляет оператор ??,
позволяющий при отсутствии запрашиваемых данных подставить значение по
умолчанию:
// base_url/6/shared_prefs/lib/main.dart
void loadAppSettings() {
// Получаем язык приложения, по умолчанию русский
final language = preferences.getString('selected_language') ?? 'ru';
print(language);
// Получаем размер шрифта, по умолчанию 18.0
final fontSize = preferences.getDouble('selected_font_size') ?? 18;
print(fontSize);
// Получаем количество баннеров для показа, по умолчанию 3
final bannersCount = preferences.getInt('showing_banners_count') ?? 3;
print(bannersCount);
560 Глава 6 Локальное хранение данных
// Получаем тему приложения, по умолчанию true
final isDarkMode = preferences.getBool('is_dark_mode') ?? true;
print(isDarkMode);
}
// Получаем список предпочтительных для пользователя тем новостей
final categories = preferences.getStringList('favorite_news_categories');
print(categories ?? ['sport', 'music', 'busines']);
Далее добавьте в приложение вызов метода чтения из SharedPreferences при
нажатии на первую кнопку:
FloatingActionButton(
onPressed: loadAppSettings(), // #1
child: const Icon(Icons.remove_red_eye),
),
В этом примере мы считали из SharedPreferences все данные, которые записывали
ранее. Если метод чтения данных будет вызван до того, как произойдет запись в хранилище, в переменных будут храниться значения по умолчанию. А если данные по
таким ключам уже существуют, в терминал будут выведены сохраненные значения.
6.1.5. Чтение кастомных данных
Shared Prefrences не может полностью заменить базу данных для приложения, но
иногда будут возникать ситуации, когда в этом хранилище необходимо сохранить
данные в более сложном формате. Чтобы поближе познакомиться с этими возможностями хранилища, в нескольких следующих разделах мы разработаем приложение, выделив его конфигурацию в отдельный класс без создания отдельных
методов чтения и записи каждого из его параметров. У нас будет всего один метод
для обновления и чтения данных.
Создайте новый проект shared_prefs_custom_data, удалите все из папки test и добавьте в качестве внешней зависимости shared_preferences в файл pubspec.yaml:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
shared_preferences: 2.3.3 # укажите актуальную версию
Далее перейдите в файл
импорт:
main.dart,
удалите весь код и объявите следующий
// base_url/6/shared_prefs_custom_data/lib/main.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
Весь последующий код станем добавлять в этот файл и начнем с объявления
класса AppSettings, который будет отвечать за хранение базовой конфигурации
приложения:
// base_url/6/shared_prefs_custom_data/lib/main.dart
class AppSettings {
const AppSettings({
required this.language,
6.1. SharedPreferences 561
required this.fontSize,
required this.isDarkMode,
});
}
final String language; // язык интерфейса
final double fontSize; // размер шрифта интерфейса
final bool isDarkMode; // выбрана ли темная тема
Для организации чтения и записи этих данных посредством shared_prefrences
можно использовать несколько подходов. Первый из них подразумевает разде
ление параметров AppSettings и хранение их в разных ячейках SharedPreferences
по разным ключам. При чтении эти параметры будут объединяться в общую
модель:
/// Запись кастомных данных в SharedPreferences
/// Записываем параметры отдельно по ключам
Future<void> saveAppSettings(AppSettings settings) async {
// Устанавливаем язык приложения
await preferences.setString('selected_language', settings.language);
// Устанавливаем размер шрифта
await preferences.setDouble('selected_font_size', settings.fontSize);
// Устанавливаем темную тему
await preferences.setBool('is_dark_mode', settings.isDarkMode);
}
/// Чтение кастомных данных из SharedPreferences
AppSettings loadAppSettings() {
// Получаем язык приложения, по умолчанию русский
final language = preferences.getString('selected_language') ?? 'ru';
// Получаем размер шрифта, по умолчанию 18.0
final fontSize = preferences.getDouble('selected_font_size') ?? 18;
// Получаем тему приложения, по умолчанию true
final isDarkMode = preferences.getBool('is_dark_mode') ?? true;
}
// Возвращаем в ответе метода объединенную модель данных
return AppSettings(
language: language,
fontSize: fontSize,
isDarkMode: isDarkMode,
);
Такой вариант реализации подходит в том случае, когда нужно четко разграничить данные по отдельным ячейкам памяти. А если подобное разделение некритично, есть более простое решение — сделать объект сериализуемым в формат JSON
и сохранять строку в SharedPreferences:
/// Модель данных настроек с JSON-сериализацией
class AppSettings {
const AppSettings({
required this.language,
required this.fontSize,
required this.isDarkMode,
});
final String language;
final double fontSize;
final bool isDarkMode;
562 Глава 6 Локальное хранение данных
Map<String, dynamic> toMap() {
return <String, dynamic>{
'language': language,
'fontSize': fontSize,
'isDarkMode': isDarkMode,
};
}
factory AppSettings.fromMap(Map<String, dynamic> map) {
return AppSettings(
language: map['language'] as String,
fontSize: map['fontSize'] as double,
isDarkMode: map['isDarkMode'] as bool,
);
}
String toJson() = > json.encode(toMap());
}
factory AppSettings.fromJson(String source) = >
AppSettings.fromMap(
json.decode(source) as Map<String, dynamic>,
);
/// Запись кастомных данных в SharedPreferences
/// Записываем параметры в JSON-формате
Future<void> saveAppSettings(AppSettings settings) async {
final json = settings.toJson();
await preferences.setString('app_settings', json);
}
/// Чтение кастомных данных из SharedPreferences
/// Читаем параметры в JSON-формате
AppSettings loadAppSettings() {
final json = preferences.getString('app_settings');
if (json = = null) {
// Если в хранилище еще нет данных, возвращаем значение по умолчанию
return const AppSettings(
language: 'ru',
fontSize: 18,
isDarkMode: false,
);
}
return AppSettings.fromJson(json);
}
6.1.6. Удаление данных и очистка
После того как пользователь настроит приложение под себя, хорошим тоном будет
предоставить ему возможность сбросить настройки приложения до заводских,
то есть вернуть приложение к изначальному виду. В том случае, когда мы храним все
настройки в JSON-формате по одному ключу, для удаления подойдет метод remove:
/// Удаление данных по ключу из SharedPreferences
Future<void> deleteAppSettings() async {
await preferences.remove('app_settings');
}
Этот метод полезен, когда необходимо удалить всего одну запись из хранилища.
Если же нужно удалить все данные из SharedPreferences, подойдет метод clear. Его
вызов безвозвратно очистит хранилище приложения. Такой финт ушами подходит
6.1. SharedPreferences 563
для случаев, когда вы храните настройки конкретного пользователя, а он выходит
из своей учетной записи, или когда даете пользователю возможность очистить
(сбросить) свои данные в рамках приложения:
/// Удаление всех данных из SharedPreferences
Future<void> clearSharedPreferencesData() async {
await preferences.clear();
}
6.1.7. Пример интеграции с интерфейсом
Чтобы лучше понять аспекты взаимодействия с локальным хранилищем, разберем
пример внедрения работы с данными в интерфейс приложения. За основу возьмем
редактирование настроек приложения и те методы, которые рассматривали ранее.
На экране (рис. 6.1) у нас будут находиться два текстовых поля для ввода языка
приложения и размера шрифта, а также виджет Switch для переключения темы
приложения с темной на светлую и обратно. Кроме того, вниз добавим две кнопки,
одна из которых отвечает за сохранение данных из формы, а другая — за их удаление
из локального хранилища.
Рис. 6.1. Пример настроек приложения с SharedPreferences
Начнем же с написания точки входа в приложение верхнеуровневого виджета:
// base_url/6/shared_prefs_custom_data/lib/main.dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final preferences = await SharedPreferences.getInstance();
}
/// Передаем созданный экземпляр SharedPreferences в приложение
runApp(SharedPrefsExampleApp(preferences: preferences));
564 Глава 6 Локальное хранение данных
class SharedPrefsExampleApp extends StatelessWidget {
const SharedPrefsExampleApp({super.key, required this.preferences});
final SharedPreferences preferences;
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Shared Preferences',
home: AppSettingsPage(preferences: preferences),
);
}
В момент запуска проекта производится инициализация SharedPreferences
и его инстанс (экземпляр/объект) передается по дереву виджетов прямиком
в конструктор экрана AppSettingsPage. На самом же экране реализована форма для
редактирования настроек с использованием стандартных элементов интерфейса.
// base_url/6/shared_prefs_custom_data/lib/main.dart
class AppSettingsPage extends StatefulWidget {
const AppSettingsPage({super.key, required this.preferences});
final SharedPreferences preferences;
}
@override
State<AppSettingsPage> createState() = > _AppSettingsPageState();
class _AppSettingsPageState extends State<AppSettingsPage> {
late TextEditingController _languageController;
late TextEditingController _fontSizeController;
var _isDarkMode = false;
@override
void initState() {
super.initState();
final settings = loadAppSettings();
_languageController = TextEditingController(
text: settings.language,
);
_fontSizeController = TextEditingController(
text: settings.fontSize.toString(),
);
_isDarkMode = settings.isDarkMode;
}
@override
void dispose() {
_languageController.dispose();
_fontSizeController.dispose();
super.dispose();
}
AppSettings loadAppSettings() {
final json = widget.preferences.getString('app_settings');
if (json = = null) {
return const AppSettings(
language: 'ru', fontSize: 18, isDarkMode: false);
}
return AppSettings.fromJson(json);
}
6.1. SharedPreferences 565
Future<void> saveAppSettings(AppSettings settings) async {
final json = settings.toJson();
await widget.preferences.setString('app_settings', json);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Настройки приложения'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _languageController,
decoration: const InputDecoration(labelText: 'Язык'),
),
const SizedBox(height: 16),
TextFormField(
controller: _fontSizeController,
decoration: const InputDecoration(
labelText: 'Размер шрифта',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Темная тема'),
value: _isDarkMode,
onChanged: (value) = > setState(()= > _isDarkMode = value),
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton(
onPressed: () = > _onClearTap(context),
child: const Text('Сбросить'),
),
ElevatedButton(
onPressed: () = > _onSaveTap(context),
child: const Text('Сохранить'),
),
],
),
],
),
),
),
);
}
void _onSaveTap(BuildContext context) {
}
void _onClearTap(BuildContext context) {
}
}
566 Глава 6 Локальное хранение данных
Последнее, что нам осталось добавить в проект, — это реализация методов, которые вызываются при нажатии на соответствующие кнопки:
void _onSaveTap(BuildContext context) {
final settings = AppSettings(
language: _languageController.text,
fontSize: double.parse(_fontSizeController.text),
isDarkMode: _isDarkMode,
);
saveAppSettings(settings);
}
void _onClearTap(BuildContext context) {
setState(() {
widget.preferences.clear();
_languageController.text = 'ru';
_fontSizeController.text = '18';
_isDarkMode = false;
});
}
6.1.8. Как давать имена ключам
Поскольку название для ключа вы выбираете сами, очень важно не путать их в вашем
приложении. Это связано с тем, что взаимодействовать с хранилищем можно из любой
части приложения, а такое поведение способно привести к конфликту ключей. Допустим, вы занимаетесь реализацией уведомлений в приложении и перед вами стоит
задача создать кнопку для их отключения. Результат отключения нужно сохранить
в локальное хранилище, и вы решили назвать ключ очень незамысловато, оторванно
от контекста реализуемого функционала — disabled. В это же время в соседней папке
проекта расположилась функциональность отправки SMS-оповещений и выбрано
аналогичное имя ключа. Выходит, две кнопки будут переключать одно и то же значение.
В нашем абстрактном примере есть хотя бы один плюс — у записываемых
в хранилище и читаемых оттуда данных один и тот же тип. А если будете записывать данные типа String, но читать их как bool, это непременно приведет к более
серьезной ошибке. Для того чтобы не столкнуться с подобными проблемами, придерживайтесь нескольких правил именования ключей.
y Давайте им названия, понятные и зависимые от контекста решаемой задачи.
y Выносите ключи в константы.
y Сверяйтесь с уже существующими в приложении ключами.
6.1.9. Что принято и что не принято хранить в SharedPreferences
В SharedPreferences принято хранить легковесные (небольшого объема) открытые
данные. Поскольку они представлены в незашифрованном виде и доступ к ним
может получить любой человек, потратив определенное время, в этом хранилище
не стоит хранить чувствительные данные, а именно:
y пароли;
y токены (access token, refresh token);
6.1. SharedPreferences 567
секретные ключи API;
персональные данные пользователя;
финансовые данные;
медицинскую информацию;
идентификаторы устройств;
сессионные данные;
конфиденциальные настройки приложения;
файлы конфигурации;
данные для авторизации в сторонних сервисах.
Кроме чувствительных данных, в SharedPreferences не стоит сохранять большие
объемы данных и массивные объекты. На это есть целый ряд причин.
1. Плагин shared_preferences загружает весь файл с данными в память при старте приложения. Если файл большой, это может замедлить запуск и повлиять
на производительность приложения. Например, если вы будете загружать
целую базу данных пользователей при старте приложения, то этот процесс
займет не миллисекунды, а секунды или в худшем случае минуты! Если
при таком раскладе кто-то продолжит пользоваться вашим приложением,
значит, оно решает очень серьезную проблему пользователя.
2. Плагин shared_preferences работает только с примитивными типами данных
(int, String, bool, double, List<String>). Для хранения сложных объектов их
нужно сериализовать (например, в JSON), из-за чего их объем увеличивается
и появляются накладные расходы на преобразование. Кроме того, вам придется писать дополнительные надстройки поверх shared_preferences, которые
будут добавлять функционал базы данных, изначально не предоставлямый
пакетом shared_preferences. Поверьте, мы в свое время поэкспериментировали с такими вещами и совершенно не советуем вам заниматься разработкой
собственного пятиколесного велосипеда.
3. Поскольку shared_preferences записывает данные на диск синхронно, часто
выполняемая запись больших данных может привести к замедлению операций
ввода-вывода. Не просто так у этой библиотеки есть методы для сохранения
только примитивных типов данных.
С запретами разобрались. Осталось перечислить то, что можно хранить в SharedPreferences:
y настройки приложения;
y небольшой объем открытых данных, к которому нужен быстрый доступ
(легкий кэш);
y выбранная тема (светлая/темная);
y предпочтительный язык;
y включение/выключение уведомлений;
y настройки отображения (например, размер шрифта);
y флаги состояний (признаки);
y
y
y
y
y
y
y
y
y
568 Глава 6 Локальное хранение данных
y
y
y
y
последняя открытая вкладка или экран;
время последнего обновления данных из API;
количество запусков приложения;
последняя дата использования.
6.1.10. Новые API — SharedPreferencesAsync и WithCache
В последние обновления плагина shared_preferences были добавлены два новых
API: SharedPreferencesAsync и SharedPreferencesWithCache, а также вместо устаревшего механизма SharedPreferences на Android теперь используется Preferences
DataStore. Эти изменения увеличивают производительность работы с данными
и делают взаимодействие с хранилищем более надежным.
SharedPreferencesAsync позволяет выполнять асинхронные операции с хранилищем данных. Вместо обращения к кэшу данные читаются напрямую из платформенного хранилища, что делает этот подход чуть медленнее, но точнее. Асинхронный
доступ гарантирует получение самых свежих значений, даже если данные были
обновлены другими процессом, системой или изолятом. Это исключает ситуации,
когда кэшированные данные устаревают.
Что касается изменений в кодовой базе проекта при использовании SharedPre
ferencesAsync вместо стандартного SharedPreferences, для начала вам потребуется
изменить код создания экземпляра класса хранилища в функции main:
// base_url/6/shared_prefs_async/lib/main.dart
import 'package:shared_preferences/shared_preferences.dart';
void main() {
final preferences = SharedPreferencesAsync();
}
/// Передаем созданный инстанс SharedPreferencesAsync в приложение
runApp(SharedPrefsExampleApp(preferences: preferences));
Поскольку в ходе работы с SharedPreferencesAsync все данные хранилища не загружаются в память в момент инициализации, пропадает необходимость помечать
функцию main ключевым словом async. По этой же причине придется обновить код
загрузки данных в приложении, добавив в него щепотку асинхронности:
// base_url/6/shared_prefs_async/lib/main.dart
Future<void> loadAppSettings() async {
// Получаем язык приложения, по умолчанию русский
final language = await preferences.getString('selected_language') ?? 'ru';
print(language);
// Получаем размер шрифта, по умолчанию 18.0
final fontSize = await preferences.getDouble('selected_font_size') ?? 18;
print(fontSize);
}
//...
SharedPreferencesWithCache работает поверх SharedPreferencesAsync и предоставляет интерфейс для синхронного доступа к локально кэшированным данным. Это
позволило сохранить привычный интерфейс для работы с данными, одновременно
6.2. Secure Storage 569
обеспечивая производительность и гибкость. Благодаря кэшу доступ к данным
происходит быстрее, чем при прямом асинхронном запросе. Данная функциональность может вам пригодиться в тех случаях, когда необходимо загружать данные,
которые редко изменяются или к которым нужен быстрый доступ.
6.2. Secure Storage
Библиотека shared_prefences идеально подходит для хранения примитивных и открытых данных. Но когда мы говорим о хранении приватных и чувствительных
данных, ни о какой shared_preferences не может быть и речи! Как сказано ранее,
SharedPreferences хранит данные в общем открытом хранилище устройства, то есть
любой пользователь без особых затруднений может достать из него данные.
В связи с этим для безопасного хранения данных придется выбрать альтернативное решение. Вы ведь не хотите собирать данные пользователей по всему Интернету?
Хотя просто собирать данные — еще не самая плохая ситуация. Представьте, что
вы разрабатываете банковское приложение для клиентов и храните JWT-токен
авторизации в SharedPreferences в открытом виде. Любой злоумышленник, получив доступ к устройству пользователя, без труда сможет получить доступ и к самому приложению с его функциональностью, а в самом плачевном случае — еще
и к сбережениям. Испугались? Вот и хорошо! К утечкам данных нужно относиться
с полной серьезностью!
К счастью, решение есть, и для его реализации не придется писать кучу кода,
разбираться в алгоритмах шифрования и прочих сложных штуках. Достаточно
воспользоваться плагином flutter_secure_storage.
6.2.1. Начало работы
Чтобы начать работать с пакетом flutter_secure_storage, установите его в качестве
зависимости проекта в файле pubspec.yaml:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_secure_storage: 9.2.0 # укажите актуальную версию
или загрузите в зависимости, вызвав в терминале проекта следующую команду:
flutter pub get
Для записи и чтения данных с использованием этого плагина необходимо создать
экземпляр класса FlutterSecureStorage:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
final secureStorage = const FlutterSecureStorage();
Поскольку SecureStorage не загружает все хранимые данные в оперативную память, не обязательно инициализировать его перед запуском приложения. Инстанс
570 Глава 6 Локальное хранение данных
этого хранилища можно инициализировать как в функции main, так и используя
lazy-инициализацию (по мере необходимости). Далее представлен пример базовой
инициализации:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
Future<void> main() async {
/// Создаем экземпляр FlutterSecureStorage
const secureStorage = FlutterSecureStorage();
}
/// Передаем созданный экземпляр в приложение
runApp(SecureStorageExampleApp(secureStorage: secureStorage));
Этот плагин имеет огромное количество способов настройки, но их мы рассмотрим
немного позже. А пока сосредоточимся на самом простом — чтении и записи данных.
6.2.2. Запись и чтение данных
API flutter_secure_storage отдаленно похож на shared_prefrerences — данные сохраняются в формате «ключ — значение», где в качестве ключа выступает строка
типа String. Но есть одно очень важное исключение — SecureStorage поддерживает
хранение только строковых типов данных. Проще говоря, в безопасное хранилище
могут помещаться только данные типа String. Поэтому для записи данных используется метод write:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
Future<void> main() async {
final email = ''example@mail.ru'';
}
/// Создаем экземпляр FlutterSecureStorage
const secureStorage = FlutterSecureStorage();
await secureStorage.write(key: 'user_email', value: email);
print('Адрес электронной почты сохранен: $email');
В этом примере по ключу user_email была сохранена почта пользователя —
Казалось бы, это ключевой шаг, но безопасное сохранение данных — лишь половина задачи. Вторая половина — чтение из хранилища:
example@mail.ru.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
Future<void> main() async {
/// Читаем адрес электронной почты
final savedEmail = await secureStorage.read(key: 'user_email');
print('Сохраненный адрес электронной почты: $savedEmail');
}
Но, как и в примере с shared_prefrences, всегда нужно обрабатывать ситуацию
отсутствия данных по этому ключу, ведь если мы будем запрашивать несуществующие данные, то получим null в консоли, а в худшем случае и на экране:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
Future<void> main() async {
final email = 'example@mail.ru';
6.2. Secure Storage 571
/// Создаем экземпляр FlutterSecureStorage
const secureStorage = FlutterSecureStorage();
/// Сохраняем адрес электронной почты
await secureStorage.write(key: 'user_email', value: email);
print('Адрес электронной почты сохранен: $email');
}
/// Читаем адрес электронной почты
final savedEmail = await secureStorage.read(key: 'user_email') ?? 'wtf-mail';
print('Сохраненный адрес электронной почты: $savedEmail');
6.2.3. Чтение и запись типизированных данных
Для записи в SecureStorage других типов данных (int, bool, double и т. д.) вам предстоит вручную преобразовывать их в строки:
Future<void> main() async {
// Создаем экземпляр FlutterSecureStorage
const secureStorage = FlutterSecureStorage();
// Сохранение данных
await secureStorage.write(key: 'user_name', value: 'John Doe');
// int → String
await secureStorage.write(key: 'user_age', value: 30.toString());
// bool → String
await secureStorage.write(key: 'is_logged_in', value: true.toString());
// double → String
await secureStorage.write(key: 'user_balance', value: 1234.56.toString());
await secureStorage.write(
key: 'last_login',
value: DateTime.now().toIso8601String(), // DateTime → ISO 8601 String
);
}
print('Персональные данные сохранены.');
Что касается чтения — тут ситуация немного сложнее. Перед приведением типов
необходимо будет выполнять проверку на null:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
Future<void> main() async {
/// Создаем экземпляр FlutterSecureStorage
const secureStorage = FlutterSecureStorage();
// Чтение данных
final userName = await secureStorage.read(key: 'user_name');
final userAge = int.tryParse(await secureStorage.read(key: 'user_age') ?? '');
final isLoggedIn = (await secureStorage.read(key: 'is_logged_in')) = = 'true';
final userBalance = double.tryParse(
await secureStorage.read(key: 'user_balance') ?? '');
final lastLoginString = await secureStorage.read(key: 'last_login');
final lastLogin = lastLoginString ! = null
? DateTime.parse(lastLoginString)
: null;
572 Глава 6 Локальное хранение данных
}
print('Имя пользователя: $userName');
print('Возраст пользователя: $userAge');
print('Авторизован: $isLoggedIn');
print('Баланс пользователя: $userBalance');
print('Последний вход: $lastLogin');
Кроме обычных чтения и записи данных, у этого плагина есть много параметров
кастомизации для более продвинутого использования. Далее разберем самые полезные из них.
6.2.4. Параметры кастомизации — AndroidOptions
В операционной системе Android плагин flutter_secure_storage позволяет использовать два механизма хранения чувствительных данных:
y Keystore — защищенное хранилище, предназначенное для криптографических ключей;
y EncryptedSharedPreferences — зашифрованную версию SharedPreferences,
которая используется для хранения полноценных зашифрованных данных
(строк, чисел, флагов).
Как Flutter-разработчик, разницы между этими двумя механизмами хранения
вы, скорее всего, не заметите. В современных Android-приложениях приоритет отдается EncryptedSharedPreferences. Это связано с тем, что он автоматически шифрует
данные, используя ключи из Keystore и обеспечивая при этом простоту применения
и высокий уровень безопасности. Но для доступа к этому механизму хранения
данных требуется задействовать Android SDK, начиная с 23-й версии.
Чтобы выбрать, какой из типов хранения данных будет использоваться в вашем
приложении, воспользуйтесь аргументом aOptions при инициализации экземпляра
хранилища на операционной системе Android:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
void main() {
final storage = FlutterSecureStorage(
aOptions: AndroidOptions(
// EncryptedSharedPreferences доступно только на API 23 и выше
encryptedSharedPreferences: true,
),
);
}
Для более тонкой настройки вы можете воспользоваться аргументами key
и storageCipherAlgorithm конструктора AndroidOptions, конфигурируемыми перечислениями определенного типа. Первый позволяет выбрать,
какой алгоритм шифрования будет применяться для шифрования ключей, а второй — для шифрования данных. Доступные «из коробки» способы шифрования
представлены в табл. 6.3.
Аргумент preferencesKeyPrefix предоставляет очень полезный функционал
автоподстановки префиксов для ключей. Самый простой и жизненный пример его
CipherAlgorithm
6.2. Secure Storage 573
применения — предоставление пользователю в вашем приложении функционала
мультиаккаунта, то есть возможности регистрировать и задействовать несколько
профилей. Текущий аргумент позволит в рамках одного приложения хранить одни
и те же данные, но для каждого пользователя отдельно:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
Future<void> main() async {
final user1Storage = FlutterSecureStorage(
aOptions: AndroidOptions(preferencesKeyPrefix: 'user_1_'),
);
final user2Storage = FlutterSecureStorage(
aOptions: AndroidOptions(preferencesKeyPrefix: 'user_2_'),
);
}
// Сохраняет по ключу user_1_email
await user1Storage.write(key: 'email', value: 'test@mail.ru');
// Сохраняет по ключу user_2_email
await user2Storage.write(key: 'email', value: 'test@gmail.com');
Таблица 6.3. Способы шифрования в Android
Тип перечисления
Тип шифра
keyCipherAlgorithm
RSA_ECB_PKCS1Padding
RSA_ECB_
OAEPwithSHA_256andMGF1Padding
storageCipherAlgorithm
AES_CBC_PKCS7Padding
AES_GCM_NoPadding
Описание
Стандартный тип шифрования, но не очень
хорошо защищенный
Более безопасный, но работает только
с Android 6+
Стандартный тип шифрования, совместимый со старыми Android
Более безопасный и быстрый, но требует
Android 6+
Последний крайне важный аргумент AndroidOptions — resetOnError. Он не зря
по умолчанию установлен в значение false. Все дело в том, что при передаче в него
true любая ошибка будет приводить к удалению всех данных из хранилища. Это
помогает избежать ситуаций, когда данные становятся недоступными из-за сбоя.
Поэтому его рекомендуется применять в случаях, когда безопасность важнее сохранения данных, например в банковских приложениях.
6.2.5. Параметры кастомизации — IOSOptions
Как и для Android-устройств, плагин flutter_secure_storage имеет набор параметров для настройки под iOS посредством аргументов класса IOSOptions .
Главный из них — accessibility . Он принимает на вход перечисление типа
KeychainAccessibility и определяет, когда данные в Keychain становятся доступными (табл. 6.4).
574 Глава 6 Локальное хранение данных
Таблица 6.4. Поля перечисления KeychainAccessibility
Имя поля
passcode
unlocked (по умолчанию)
unlocked_this_device_only
first_unlock
first_unlock_this_device_only
Описание
Данные доступны только при разблокированном устройстве. Данные, зашифрованные с этим атрибутом, не могут быть перенесены на другое устройство
Данные доступны только при разблокированном устройстве
Доступ только при разблокированном устройстве, но данные не восстанавливаются из iCloud Backup
Данные доступны после первого разблокирования устройства после перезагрузки
То же самое, что и first_unlock, но без восстановления из iCloud Backup
Когда включен iCloud Backup, данные в Keychain могут восстанавливаться после
переустановки приложения или при переходе на новое устройство из резервной
копии iCloud. Если вы разрабатывали приложения с помощью Firebase Auth (авторизации посредством Firebase), то могли заметить нестандартное поведение при
базовой конфигурации пакета firebase_auth. При удалении приложения на iOS без
выхода из аккаунта и повторной установке пользователь с ходу попадает в авторизованный аккаунт. Это происходит из-за того, что «под капотом» firebase_auth
используется Keychain с iCloud Backup.
Еще один способ сохранять авторизацию после удаления приложения и переносить ее с устройства на устройство заключается в использовании аргумента
synchronizable (по умолчанию false). Если ему передается значение true, данные
в Keychain будут автоматически синхронизироваться с помощью iCloud между
устройствами пользователя (iPhone, iPad, Mac):
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
Future<void> main() async {
final storage = FlutterSecureStorage(
iOptions: IOSOptions(
accessibility: KeychainAccessibility.unlocked,
synchronizable: true,
),
);
}
// Данные будут доступны на всех устройствах пользователя
await storage.write(key: 'access_token', value: 'qwerty...');
А теперь представим, что перед нами стоит задача разработать группу приложений. Известно, что для каждого приложения нужен профиль Gmail, и всякий раз,
когда пользователь устанавливает новое приложение из этой группы, в момент его
открытия в меню авторизации будут показаны данные пользователя для входа.
Даже в iOS, сторонней операционной системе! Там есть имя пользователя, его
почта и аватарка. Как и почему это происходит? Как приложение узнает данные
авторизации, которые были указаны в стороннем приложении?
Специально для таких случаев в инфраструктуре Apple существуют группы.
А аргумент groupId используется для совместного доступа к данным между разными
приложениями на одном устройстве:
6.3. SQLite 575
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
Future<void> main() async {
final storage = FlutterSecureStorage(
iOptions: IOSOptions(
groupId: 'group.com.example.shared',
),
);
}
// Данные будут доступны во всех приложениях с таким же groupId
await storage.write(key: 'access_token', value: 'qwerty...');
Работая с таким функционалом, важно не забыть перед началом интеграции
создать группу с необходимым именем в консоли AppStore Connect и включить ее
в вашем проекте в настройках XCode.
6.2.6. Параметры кастомизации — другие платформы
Для других платформ (Web, MacOS, Windows, Linux) тоже существуют свои
аргументы и экземпляры класса Options. В случае с MacOs это аргумент mOptions,
которому на вход передается MacOsOptions. Он в основном дублирует настройки для
iOS и отличается от него только наличием аргумента useDataProtectionKeyChain
(по умолчанию true), который отвечает за использование Data Protection Keychain
на macOS. Если пользователь разлогинился или заблокировал Mac, доступ к его
данным будет ограничен.
В отличие от мобильных платформ, где flutter_secure_storage задействует
защищенные системные хранилища (Keychain на iOS, Keystore на Android), в Web
такой возможности нет. Поэтому для хранения данных используется встроенное
хранилище браузера — IndexedDB или localStorage, из-за чего данные могут быть
удалены браузером при очистке кэша или в режиме инкогнито. Безопасность
в таком случае гарантируется клиентским шифрованием, при котором используются встроенные механизмы защиты браузера, например Web Crypto API. Перед
сохранением данные зашифровываются алгоритмом AES, а затем записываются
в IndexedDB. Это позволяет защитить их от простого просмотра в инструментах
разработчика.
6.3. SQLite
Ранее мы рассмотрели способы хранения пользовательских настроек, а также
чувствительных и не очень данных. Теперь же перейдем к более крупному калибру — реляционным системам управления базами данных (СУБД). А точнее,
к самому известному их представителю — SQLite. Когда вам требуется обеспечить
оптимальное хранение и обработку больших объемов данных, для таких задач реляционные БД — стандарт индустрии. Большинство проектов на стороне сервера
используют их из-за структурированности, надежности и гибкости. Проще говоря,
реляционные базы данных — это такой автомат Калашникова в мире разработки.
Решения с их использованием работают десятилетиями, а надежность и высокая
скорость проверены миллионами проектов!
576 Глава 6 Локальное хранение данных
На Flutter есть и множество других No-SQL решений для хранения данных:
Hive, Isaar, Realm, ObjectStorage. Когда-то эти проекты были очень популярны,
но сейчас некоторые из них либо перестали поддерживаться разработчиками,
либо потеряли актуальность. То ли дело SQLite. При использовании этой СУБД
просто невозможно оказаться в такой ситуации! Этот факт дает вашему проекту
независимость, а вам самим — спокойствие, что не придется «перепиливать» хранилище приложения.
Весь исходный код из текущего раздела вы можете найти в репозитории главы 6,
в проекте sqlite.
6.3.1. Реляционные базы данных и язык SQL
Реляционная база данных (Relational Database, RDB) — это система хранения
данных, где информация организована в таблицы и связана между собой по определенным правилам. В табл. 6.5 представлен пример такой базы.
Таблица 6.5. Пример базы данных
id
1
2
3
name
Елена
Юрий
Ольга
email
elena@example.com
yura@example.com
olga@example.com
age
26
33
36
В такой базе данных каждая графа — это поле с данными определенного типа.
А каждая строка — отдельная запись (комбинация данных в разных колонках).
Ключевой особенностью реляционных БД являются их структура и строгие правила.
В shared_preferences можно записать данные разных типов по одному и тому же
ключу, а в реляционной БД нельзя записать данные разных типов в одну и ту же
ячейку таблицы.
Для управления реляционными БД используется язык SQL (Structured Query
Language). С его помощью можно создавать таблицы, добавлять, изменять, удалять
и выбирать данные. Далее приведен пример создания с помощью SQL таблицы
пользователей (users):
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
age INTEGER
);
А так будет выглядеть пример запроса на получение всех пользователей из
таблицы users:
SELECT * FROM users;
6.3. SQLite 577
Если запустить этот код и визуализировать ответ в виде таблицы, то получится
что-то похожее на табл. 6.5.
Несмотря на множество существующих реляционных СУБД, в мобильной разработке на стороне клиента принято использовать SQLite. Все дело в том, что эта
СУБД предназначена для локального хранения данных в реляционном представлении в одном файле, с которым и будет взаимодействовать приложение во время
работы. Помимо этого, SQLite отличается от других СУБД еще рядом аспектов.
Например, в других СУБД (MySQL, PostgreSQL и т. д.) сразу несколько пользователей могут одновременно читать и записывать данные благодаря многопоточности и блокировкам на уровне строк. В SQLite можно одновременно выполнять
множество запросов на чтение (SELECT), но при запросах на редактирование база
данных блокируется и другие процессы будут ждать завершения.
6.3.2. Начало работы
Для работы с SQLite во Flutter-приложениях принято использовать пакет sqflite.
В случае разработки под мобильные платформы вам больше ничего не понадобится.
Для десктопных операционных систем еще придется в качестве dev-зависимости
поставить пакет sqflite_common_ffi, под Web — sqflite_common_ffi_web. К тому же
при релизной сборке для Windows необходимо будет разжиться из репозитория или
с официального сайта SQLite (https://www.sqlite.org/download.html) файлом sqlite3.dll
и разместить его в папке с приложением.
В нашем случае ограничимся только мобильной платформой. Для начала работы с пакетом sqflite установите его в качестве зависимости проекта в файле
pubspec.yaml:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
sqflite: 2.4.2 # укажите актуальную версию
и загрузите зависимости, сохранив изменения в файле нажатием клавиш Ctrl+S или
введя в терминале следующую команду:
flutter pub get
Чтобы создать первые таблицы, начать записывать в них данные и читать их
оттуда, необходимо программно (в функции main) проинициализировать базу
данных:
import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await openDatabase('database_name.db');
// ...
}
578 Глава 6 Локальное хранение данных
В асинхронную функцию openDatabase передается название файла с базой данных, с которой будет идти работа в приложении (в большинстве случаев это одна
база данных). После создания файла с БД, если он еще не был создан, или открытия
существующего openDatabase вернет объект типа Database, содержащий основные
методы хранилища. В последующем именно с этими методами мы и будем работать.
На случай, если у вас в голове мелькнула мысль открывать доступ к БД в момент
совершения запросов в отдельных функциях или методах классов, предупредим:
этого делать не рекомендуется. Лучше всего открыть соединение с базой данных при
запуске и использовать его на протяжении всего жизненного цикла приложения.
Это связано с тем, что открытие и закрытие базы данных — ресурсоемкие операции
и их частое выполнение может негативно сказаться на производительности.
При наличии в приложении только одной базы данных реализуйте подключение
к ней с помощью шаблона проектирования Singleton («Одиночка»). Это один из
тех случаев, когда он идеально подходит для решения задачи. А чтобы у соединения
с базой данных был только один экземпляр в приложении, можно воспользоваться
регистрацией в context посредством InheritedWidget, который был рассмотрен
в главах 1 и 3, или прибегнуть к пакетам, реализующим архитектурный шаблон —
инъекцию зависимостей (Dependency Injection, DI).
Если вы планируете работать в приложении с несколькими базами или хотите
создавать инстанс БД только тогда, когда вам нужно с ней взаимодействовать,
поможет метод close() экземпляра класса Database. Далее представлен пример
открытия и закрытия двух разных баз данных:
void main() async {
final database1 = await openDatabase('database_1.db');
final database2 = await openDatabase('database_2.db');
/// Сначала закрываем вторую базу просто потому, что можем
await database2.close();
}
/// Потом закрываем первую базу
await database1.close();
6.3.3. Создание первой таблицы
В отличие от shared_prefrences, перед тем как начать записывать данные в базу,
необходимо создать таблицу, с которой и будет идти работа:
/// Метод инициализации базы данных
Future<void> _initDatabase() async {
final db = await openDatabase('app_database.db');
await db.execute('''
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
age INTEGER
);
''');
}
6.3. SQLite 579
В этом примере после создания экземпляра класса Database вызывается его
метод execute, способный выполнить любой SQL-запрос. В нашем случае это
запрос на создание таблицы пользователей с четырьмя параметрами: id, name,
email и age . В дальнейшем в этих полях будут храниться данные реальных
пользователей. Поначалу такая запись может показаться непонятной, особенно
если у вас до этого не было опыта работы с SQL. Но не переживайте — все дело
в практике!
Давайте разберем, за что отвечают ключевые слова этого запроса (табл. 6.6).
Таблица 6.6. Разбор ключевых слов (конструкций) SQL
Ключевое слово
CREATE TABLE
Описание
Создает новую таблицу
INTEGER
Целочисленный тип переменной (аналог int в Dart)
TEXT
Строковый тип переменной (аналог String в Dart)
NOT NULL
Говорит о том, что значение, хранимое в этом поле, не может быть null
Значение поля будет уникальным для каждой новой записи. В этом случае электронный адрес
не может быть одним и тем же для двух пользователей
Помечает поле как первичный ключ. Обычно это самый главный идентификатор, который
используется для получения конкретной записи. Гарантирует уникальность (UNIQUE) и то,
что поле не может быть null (NOT NULL)
UNIQUE
PRIMARY KEY
SQLite насчитывает около 147 ключевых слов. В этой книге не хватит места,
чтобы рассказать о каждом из них. Да оно вам и не надо! В ходе погружения мы
познакомимся с основными ключевыми словами (конструкциями) SQL, которые
обеспечат около 80 % рабочих моментов любого мобильного приложения.
Вернемся к примеру создания таблицы. При первом запуске метода _initDatabase() будут созданы база данных app_database и таблица users. Но в этом скрипте
есть небольшая проблема. При повторном запуске кода выполнение метода завершится следующей ошибкой времени выполнения (runtime error) (рис. 6.2).
Рис. 6.2. Ошибка при повторной попытке создать таблицу
580 Глава 6 Локальное хранение данных
Подобная ошибка возникает из-за того, что SQL-запрос пытается создать таб
лицу, которая уже имеется в базе данных. Если мы хотим избавиться от ошибки,
не удаляя каждый раз таблицу из БД перед ее открытием, необходимо перед именем
создаваемой таблицы добавить IF NOT EXISTS:
/// Метод инициализации базы данных
Future<void> _initDatabase() async {
final db = await openDatabase('app_database.db');
await db.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
age INTEGER
);
''');
}
6.3.4. Запись данных
После того как таблица создана, в нее можно добавлять новые записи. Для этого
в SQL предназначено ключевое слово INSERT. С его помощью вы можете помещать
в таблицу новые записи, если, конечно, они не конфликтуют с находящимися в ней
данными. Далее представлен пример запроса для создания новой записи в таблице
пользователей:
/// Метод создания нового пользователя
Future<void> _addUser() async {
final db = await openDatabase('app_database.db');
await db.execute(
'INSERT INTO users (name, email, age) VALUES (?, ?, ?)',
['Елена1', 'elena1@gmail.com', 25],
);
}
В ней мы указываем, в какую таблицу БД будут добавлены данные, — INSERT INTO
А после идет перечисление параметров, которые хотим записать, — (name,
email, age) VALUES (?, ?, ?). В первых скобках передаются имена граф таблицы,
а во-вторых — параметры, которые будут туда добавлены. Естественно, можно напрямую передать данные в запрос, но лучше использовать подстановочный знак ?,
позволяющий разделить этот процесс на две части. С одной стороны, это повышает
безопасность запроса, а с другой — делает код более гибким и читаемым.
В табл. 6.7 приведены используемые в ходе запроса ключевые слова и их описание.
users.
Таблица 6.7. Анализ ключевых слов (конструкций) SQL
Ключевое слово
INSERT
INTO
VALUES
Описание
Оператор для создания новых записей в таблице
Указывает, в какую таблицу будут записаны данные
Ключевое слово, указывающее значения, которые будут добавлены в таблицу
6.3. SQLite 581
Но это еще не все! Рассматриваемая библиотека предоставляет собственные реализации некоторых методов SQL-запросов, что упрощает жизнь разработчика, так как
ему не надо собственноручно описывать тело создаваемого запроса в SQL-нотации:
/// Метод создания нового пользователя
Future<void> _addUser() async {
final db = await openDatabase('app_database.db');
await db.insert('users', {
'name': 'Елена',
'email': 'elena@gmail.com',
'age': 25,
});
}
Параметры в метод insert передаются в формате таблицы (тип данных Map),
что избавляет нас от написания стандартных конструкций наподобие INSERT INTO
и перечисления вручную всех параметров в VALUES. А если в приложении используется DTO (см. главу 5), не обязательно в методе запроса прописывать эту Map
вручную — достаточно реализовать метод toJson или toMap у DTO-модели:
/// Метод создания нового пользователя
Future<void> main() async {
final newUser = CreateUserDto(
name: 'Елена',
email: 'elena@eample.com',
age: 25,
);
await createUser(newUser);
}
Future<void> createUser(CreateUserDto user) async {
await db.insert('users', user.toMap());
}
class CreateUserDto {
CreateUserDto({
required this.name,
required this.email,
required this.age,
});
final String name;
final String email;
final int age;
factory CreateUserDto.fromMap(Map<String, dynamic> map) {
return CreateUserDto(
name: map['name'] as String,
email: map['email'] as String,
age: map['age'] as int,
);
}
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'name': name,
'email': email,
'age': age,
};
}
582 Глава 6 Локальное хранение данных
Такая реализация обеспечивает больше возможностей для расширяемости
и избавляет вас от большого количества потенциальных ошибок. Например, когда
в таблице пользователей появится новое поле (допустим, фамилия), не придется
менять передаваемые параметры для каждого запроса на запись в базу данных.
В таком случае, обновив метод toMap(), мы сразу обновим маппинг данных (перевод данных из одного вида в другой путем их сопоставления) во всех местах, где
он используется. В принципе любую имеющуюся возможность для уменьшения
количества и объема изменений кодовой базы нужно использовать на все 100 %.
6.3.5. Чтение данных
Для чтения и извлечения данных в языке SQL есть специальная команда SELECT:
Future<void> getUsers() async {
final data = await db.rawQuery('SELECT * FROM users');
print(data); // Вывод в терминал всех записей из таблицы
}
/* [{id: 1, name: Сергей, email: sergei@gmail.com, age: 28}, {id: 2, name: Рената,
email: renata@gmail.com, age: 24}] */
В этом примере мы получаем все записи из таблицы users в формате List<Map
<String, dynamic>> и выводим их в терминал. Сохранение данных в переменной
data позволяет нам в случае необходимости проитерироваться по ним и произвести маппинг Map<String, dynamic> -> DTO, получив на выходе список объектов,
с которыми и будет идти работа в приложении.
Разбор ключевых слов, использованных в запросе, представлен в табл. 6.8.
Таблица 6.8. Разбор ключевых слов (конструкций) SQL
Ключевое слово
SELECT
*
FROM
Описание
Команда получения данных из указанной таблицы БД
Говорит о том, что мы собираемся получить данные по всем параметрам таблицы users (id,
name, email, age)
Ключевое слово, после которого нужно указать, из какой таблицы запрашиваются данные
Поскольку в запросе после SELECT стоит звездочка *, программа загрузит данные
по всем полям из таблицы users. Но у нас также есть возможность влиять на то,
какие параметры будут получены из таблицы. Это особенно полезно в случаях,
когда стоит задача оптимизировать выгрузку из БД или в некоторых из получаемых данных нет необходимости. Допустим, в приложении, которое мы проектируем, есть список с карточками пользователей, где указываются только ID,
имя и электронная почта. Для получения лишь этих параметров придется немного
изменить запрос:
Future<void> getUsers() async {
/// ID нужно получать всегда, так как это идентификатор
final data = await db.rawQuery('SELECT id, name, email FROM users');
6.3. SQLite 583
print(data);
}
/* [{id: 1, name: Сергей, email: sergei@gmail.com},
{id: 2, name: Рената, email: renata@gmail.com}] */
Как и в случае добавления данных, sqflite предоставляет более удобный API
для их чтения. Чтобы вновь и вновь не прописывать команды и ключевые слова,
которые повторяются для каждого нового запроса, в библиотеке воспользуемся
методом query, в который, чтобы получить все данные по всем полям таблицы
users, следует передать только ее название:
Future<void> getUsers() async {
final data = await db.query('users');
print(data);
}
/* [{id: 1, name: Сергей, email: sergei@gmail.com, age: 28},
{id: 2, name: Рената, email: renata@gmail.com, age: 24}] */
А чтобы получать данные только по интересующим нас полям таблицы, воспользуемся аргументом columns:
Future<void> getUsers() async {
final data = await db.query(
'users',
columns: ['id', 'name', 'email'],
);
print(data);
}
/* [{id: 1, name: Сергей, email: sergei@gmail.com},
{id: 2, name: Рената, email: renata@gmail.com}] */
Вывод данных в терминал — это, конечно, полезно, но только в случаях разработки консольных утилит или CLI-приложений. Для того чтобы мы могли полноценно
работать с данными во Flutter-приложении, нужно уметь организовывать маппинг
данных в объекты, то есть приводить их к строго типизированным объектам. В объявляемых моделях или DTO за это обычно отвечает фабричный именованный
конструктор fromMap или fromJson:
/// Класс, описывающий модель пользователя
class User {
User({
required this.id,
required this.name,
required this.email,
required this.age,
});
final
final
final
final
int id;
String name;
String email;
int age;
factory User.fromMap(Map<String, dynamic> map) {
return User(
id: map['id'] as int,
name: map['name'] as String,
email: map['email'] as String,
584 Глава 6 Локальное хранение данных
age: map['age'] as int,
}
}
);
@override
String toString() {
return 'User{id: $id, name: $name, email: $email, age: $age}';
}
Класс User описывает модель пользователя со всеми полями, которые хранятся
в таблице. Для преобразования запрашиваемых из таблицы данных в список объектов User воспользуемся следующим подходом:
Future<List<User>> getUsers() async {
final data = await db.query('users');
return data.map((e) = > User.fromMap(e)).toList();
}
Маппинг данных в приведенном методе напоминает работу с ответом от сервера
(см. главу 5). И это действительно так! Меняется только источник данных (data source).
Иногда могут возникать задачи, где необходимо возвращать не весь список сохраненных данных, а только одну или несколько конкретных записей. Чтобы получить выборку данных по определенному условию, в SQL используется ключевое
слово WHERE, а в методе query библиотеки sqflite — аргументы where и whereArgs.
Первому необходимо передать условие выборки, которому должны удовлетворять
возвращаемые запросом данные, а второму — список параметров, подставляемых
в условие. При таком подходе параметры из whereArgs в порядке очереди будут
подставляться на те места запроса, где используются знаки вопроса (?).
Далее представлен пример получения одного пользователя по его ID:
Future<User> getUser(int id) async {
final data = await db.query('users',
where: 'id = ?',
whereArgs: [id]);
if (data.isEmpty) throw Exception('Пользователь не найден');
return User.fromMap(data.first);
}
Поскольку поле id хранит уникальное значение, мы можем гарантировать, что
запись, удовлетворяющая условию, будет всего одна. Поэтому метод будет возвращать один объект типа User, а не список пользователей List<User>. Если же
в переменной data нет ни одного объекта типа Map, будет выброшено исключение.
Предстоит обработать его на более высоком уровне и оповестить пользователя об
отсутствии в таблице записей, удовлетворяющих условию запроса.
Кроме того, у нас имеется возможность составлять и другие условия поиска
в запросе. Далее приведен пример, возвращающий из БД только пользователей
младше 18 лет:
Future<List<User>> getYoungUsers() async {
final data = await db.query('users',
where: 'age < ?',
whereArgs: [18]);
return data.map((e) = > User.fromMap(e)).toList();
}
6.3. SQLite 585
6.3.6. Обновление данных
С течением времени все меняется. Некоторые пользователи, данные о которых
хранятся в таблице, взрослеют, другие могут поменять имя или адрес электронной
почты. Поэтому нужно иметь возможность обновлять и редактировать сведения,
хранящиеся в БД. Для этого в языке SQL существует команда UPDATE.
Далее приведен пример обновления данных пользователя, в котором на вход
метода updateUser поступает экземпляр класса User и на основе его идентификатора
в таблице users обновляется запись о пользователе:
Future<void> updateUser(User user) async {
await db.rawUpdate(
"UPDATE users SET name = ?, email = ?, age = ? WHERE id = ?",
[user.name, user.email, user.age, user.id],
);
}
Разбор ключевых слов, использованных в запросе, представлен в табл. 6.9.
Таблица 6.9. Анализ ключевых слов (конструкций) SQL
Ключевое слово
UPDATE
SET
Описание
Команда для обновления данных в таблице
Ключевое слово, после которого нужно указать список параметров таблицы, требующих
обновления
Чтобы не писать для каждого запроса на обновление километры текста, воспользуемся более простым способом обновления данных средствами sqflite —
методом update:
Future<void> updateUser(User user) async {
await db.update(
'users',
user.toMap(),
where: 'id = ?',
whereArgs: [user.id],
);
}
Первым аргументом передается название таблицы, далее — экземпляр Map, содержащий в себе все параметры, которые необходимо обновить в записи. В нашем
случае за создание такого объекта из экземпляра класса User отвечает его метод
toMap. А аргументы where и whereArgs позволяют определить, какую именно запись
следует обновить в таблице users.
6.3.7. Удаление данных
Иногда из базы данных требуется убирать устаревшую или ненужную информацию.
Это может быть удаление пользователя, который больше не работает в приложении,
или очистка временных данных. Для этого в SQL предназначена команда DELETE,
позволяющая удалить одну или несколько записей из таблицы.
586 Глава 6 Локальное хранение данных
Для начала приведем хардкорный пример удаления конкретного пользователя
по его идентификатору с помощью метода rawDelete:
Future<void> deleteUser(int id) async {
await db.rawDelete('DELETE FROM users WHERE id = ?', [id]);
}
Но не переживайте! И в этом случае разработчики sqflite побеспокоились
о любителях смузи, не желающих постигать всех нюансов работы с SQL-запросами:
Future<void> deleteUser(int id) async {
await db.delete('users', where: 'id = ?', whereArgs: [id]);
}
Порой может прилететь задача на удаление абсолютно всех данных из определенной таблицы. Допустим, у нас есть мобильное приложение, которое хранит данные
пользователя в локальной БД. Когда он выходит из аккаунта, логично удалить все
его локальные записи, чтобы при следующем входе загрузились актуальные данные
с сервера. Вот пример такого запроса:
Future<void> deleteAllUsers() async {
await db.delete('users');
}
6.3.8. Миграции
Со временем любое мобильное приложение эволюционирует: появляются новые
фичи, новые данные, а вместе с этим — необходимость менять структуру БД. Изначально у вас может быть простая таблица с пользователями, а спустя пару месяцев
внезапно понадобится добавить туда дату рождения, принадлежность к определенному полу (М/Ж), номер телефона или вообще прикрутить систему заказов. 😉
Но во всем этом потоке будущих развлечений имеется одна проблема. Если
просто взять и изменить CREATE TABLE, у старых пользователей все останется без
изменений! База данных уже создана, и приложение даже не узнает о том, что ее
схема должна была измениться. Для обновления структуры БД без удаления данных sqflite предоставляет разработчику механизм миграций, который следит за
версией схемы БД и автоматически добавляет новые изменения при обновлении
приложения. Далее мы разберем, как работает этот механизм.
Каждый раз при запуске приложения sqflite проверяет версию схемы БД. Если
она устарела, вызывается callback-функция onUpgrade(), в которой и задаются все
необходимые изменения. Допустим, в изначальную схему таблицы users необходимо добавить новое поле — номер телефона:
Future<Database> initDatabase() async {
final path = await getDatabasesPath();
final dbPath = join(path, 'app.db');
return await openDatabase(
dbPath,
version: 2, // Меняем версию базы
// будет вызвана при создании БД, если таковой нет
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS users (
Проект: игра «Тетрис» v. 6. Сохранение, кэширование данных и игра без Интернета 587
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
age INTEGER
);
''');
},
// миграция
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
// Добавляем колонку phone
await db.execute('ALTER TABLE users ADD COLUMN phone TEXT');
}
if (oldVersion < 3) { }
// Последующие миграции...
},
}
);
Когда схема базы данных в приложении обновляется, чтобы не потерять старые
данные и не нарушить логику хранения данных, изменения должны применяться
поэтапно. Поэтому миграции в sqflite представлены последовательными шагами, выполняемыми в момент запуска приложения только при увеличении версии
схемы БД. Что же касается новой версии схемы, просто добавьте еще один if,
описывающий правила текущей миграции, в код callback-функции onUpgrade().
Главное — не удалять предыдущие проверки, иначе пользователи со старыми версиями БД не получат нужные обновления.
Проект: игра «Тетрис» v. 6. Сохранение, кэширование данных
и игра без Интернета
В главе 5 мы успешно реализовали работу с сетью. Однако каждый раз при старте
приложения пользователю требуется вводить свое имя (никнейм) и молиться о наличии подключения к серверу, чтобы запустить игру.
Пришло время избавиться от этих недостатков и дать возможность пользователю
не только играть в «Тетрис» без Интернета, но и сохранять результаты на сервер при
его появлении. И начнем мы с сохранения данных пользователя при его успешной
авторизации, одновременно с этим реализовав проверку на их наличие при старте
приложения. Так, например, если данные имеются в локальном хранилище, будем
пропускать шаг ввода имени. Для этого нам потребуется сервис, который умеет
читать, сохранять и обновлять данные в памяти.
Разработка сервиса локального хранилища
Перейдите в папку app и создайте в ней новый каталог — storage с двумя файлами:
y i_storage_service.dart — библиотека с объявлением интерфейса хранилища;
y storage_service.dart — библиотека реализации хранилища с использованием
shared_preferences.
588 Глава 6 Локальное хранение данных
Теперь откройте pubspec.yaml и укажите требующийся пакет в качестве внешней
зависимости проекта в разделе dependencies:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
http: ^1.3.0
shared_preferences: ^2.5.3 # выберите актуальную версию
На следующем шаге откроем файл i_storage_service.dart и объявим в нем интерфейс, который должен реализовывать сервис работы с локальным хранилищем:
// base_url/6/tetris/lib/app/storage/i_storage_service.dart
/// Интерфейс для работы с локальным хранилищем.
abstract interface class IStorageService {
/// Инициализация локального хранилища.
/// Вызывается в самом начале приложения.
Future<void> init();
/// Сохранить значение по ключу.
Future<bool> setString(String key, String value);
/// Получить значение по ключу.
String? getString(String key);
}
/// Удалить все значения.
Future<bool> clear();
Далее в файле storage_service.dart объявим класс StorageService, реализующий
интерфейс IStorageService и инкапсулирующий в себе логику работы с объектом
типа SharedPreferences:
// base_url/6/tetris/lib/app/storage/storage_service.dart
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tetris/app/storage/i_storage_service.dart';
class StorageService implements IStorageService {
/// Объект SharedPreferences для работы с локальным хранилищем
late final SharedPreferences _sharedPreferences;
/// Инициализация SharedPreferences
@override
Future<void> init() async {
_sharedPreferences = await SharedPreferences.getInstance();
}
@override
Future<bool> clear() = > _sharedPreferences.clear();
@override
String? getString(String key) = > _sharedPreferences.getString(key);
}
@override
Future<bool> setString(String key, String value) = >
_sharedPreferences.setString(key, value);
Теперь откройте файл user_repository.dart (в папке features/user/data/) и добавьте
объявленный ранее сервис в конструктор класса UserRepository:
Проект: игра «Тетрис» v. 6. Сохранение, кэширование данных и игра без Интернета 589
// base_url/6/tetris/lib/features/user/data/user_repository.dart
import 'dart:convert';
import
import
import
import
import
'package:tetris/app/http/i_http_client.dart';
'package:tetris/app/storage/i_storage_service.dart';
'user_dto.dart';
'../domain/i_user_repository.dart';
'../domain/user_entity.dart';
/// Репозиторий для работы с пользователем
final class UserRepository implements IUserRepository {
final IHttpClient httpClient;
final IStorageService storageService;
UserRepository({
required this.httpClient,
required this.storageService,
});
// далее код без изменений
}
Рефакторинг контейнера зависимостей
Поскольку доступ к сервису должен предоставляться из любой точки приложения,
его необходимо внедрить в DiContainer. Но проблема в том, что для инициализации SharedPreferences нужен асинхронный вызов. Чтобы выйти сухими из воды,
перепишем механизм внедрения зависимостей. Для этого в папке app создадим
каталог di, содержащий два файла:
y depends.dart (необходимо создать) — библиотека с классом Depends, чья задача — инициализировать и хранить в себе зависимости игры;
y di_container.dart (переносим из папки app) — библиотека с контейнером
зависимостей.
Начнем с реализации класса Depends. Перейдите в файл depends.dart и добавьте
в него следующий код:
// base_url/6/tetris/lib/app/di/depends.dart
import 'package:tetris/app/http/base_http_client.dart';
import 'package:tetris/app/http/i_http_client.dart';
import 'package:tetris/app/storage/i_storage_service.dart';
import 'package:tetris/app/storage/storage_service.dart';
import
import
import
import
import
'../../features/user/data/user_repository.dart';
'../../features/user/domain/i_user_repository.dart';
'../../features/user/domain/state/user_cubit.dart';
'../../features/leaderboard/data/leaderboard_repository.dart';
'../../features/leaderboard/domain/i_leaderboard_repository.dart';
/// Синглтон для инициализации зависимостей
/// и предоставления доступа к ним в приложении
class Depends {
/// Статический экземпляр класса Depends
/// Используется для создания синглтона
static final Depends _instance = Depends._internal();
590 Глава 6 Локальное хранение данных
/// Фабричный метод для получения экземпляра класса Depends
factory Depends() {
return _instance;
}
/// Приватный конструктор для создания экземпляра класса Depends
Depends._internal();
/// Метод для инициализации зависимостей
Future<void> init() async {
// Инициализируем контейнер зависимостей
_httpClient = BaseHttpClient();
// Инициализируем сервис для работы с локальным хранилищем
storageService = StorageService();
await storageService.init();
// Инициализируем репозиторий таблицы лидеров
leaderRepository = LeaderboardRepository(
httpClient: _httpClient,
);
// Инициализируем репозиторий пользователя
// Передаем в репозиторий сервис для работы с локальным хранилищем
_userRepository = UserRepository(
httpClient: _httpClient,
storageService: storageService,
);
}
// Инициализируем менеджер состояния пользователя
userCubit = UserCubit(repository: _userRepository);
late final IStorageService storageService;
/// Интерфейс HTTP-клиента
late final IHttpClient _httpClient;
/// Интерфейс репозитория для работы с таблицей лидеров
late final ILeaderboardRepository leaderRepository;
/// Интерфейс репозитория для работы с пользователем
late final IUserRepository _userRepository;
}
/// Менеджер состояния пользователя
late final UserCubit userCubit;
Поскольку часть кода из di_container.dart была перенесена в depends.dart, удалим все ненужное из класса DiContainer, передав в его конструктор экземпляр Depends:
// base_url/6/tetris/lib/app/di/di_container.dart
import 'package:flutter/widgets.dart';
import 'package:tetris/app/di/depends.dart';
/// Контейнер зависимостей для приложения
final class DiContainer extends InheritedWidget {
const DiContainer({
super.key,
required super.child,
required this.depends,
});
final Depends depends;
Проект: игра «Тетрис» v. 6. Сохранение, кэширование данных и игра без Интернета 591
/// Поскольку контейнер зависимостей нужен только для доступа
/// к зависимостям, возвращаем false, чтобы виджеты-потомки
/// не перестраивались при изменении контекста
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) = > false;
}
/// Получение контейнера зависимостей из контекста
static DiContainer of(BuildContext context) {
// Ищем контейнер зависимостей в контексте
// Если не нашли, то выбрасываем исключение
final DiContainer? container =
context.getInheritedWidgetOfExactType<DiContainer>();
if (container = = null) {
throw Exception('Контейнер зависимостей не найден в контексте');
}
return container;
}
Рефакторинг запуска приложения
Следующая порция изменений коснется файла main.dart. Сюда предстоит добавить
создание экземпляра класса Depends, вызов метода его инициализации и проверку
на ошибки инициализации зависимостей:
// base_url/6/tetris/lib/main.dart
import 'package:flutter/material.dart';
import 'package:tetris/app/di/depends.dart';
import
import
import
import
import
import
'app/di/di_container.dart';
'features/leaderboard/presentation/leaderboard_screen.dart';
'features/user/presentation/user_screen.dart';
'features/game/game_over_screen.dart';
'features/game/game_screen.dart';
'features/main_menu/main_menu_screen.dart';
part 'app/game_router.dart';
void main() async {
// Инициализируем Flutter binding
WidgetsFlutterBinding.ensureInitialized();
}
// Создаем экземпляр класса Depends
final Depends depends = Depends();
try {
// Инициализируем зависимости
await depends.init();
// При успешной инициализации зависимостей запускаем приложение
// Передаем зависимости в контейнер зависимостей
runApp(_MyApp(
depends: depends,
));
} on Object catch (error, stackTrace) {
// В случае ошибки при инициализации
// зависимостей запускаем приложение с экраном ошибки
runApp(AppError(
error: error,
stackTrace: stackTrace,
));
}
592 Глава 6 Локальное хранение данных
/// Экран ошибки приложения
class AppError extends StatelessWidget {
const AppError({
super.key,
required this.error,
required this.stackTrace,
});
final Object error;
final StackTrace stackTrace;
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Произошла ошибка:'),
Text(error.toString()),
Text(stackTrace.toString()),
],
),
),
),
);
}
class _MyApp extends StatelessWidget {
const _MyApp({required this.depends});
/// Передаем зависимости в приложение
/// и используем их в контейнере зависимостей
final Depends depends;
}
@override
Widget build(BuildContext context) {
return DiContainer(
depends: depends,
child: MaterialApp(
debugShowCheckedModeBanner: false,
initialRoute: GameRouter.initialRoute,
routes: GameRouter._appRoutes),
);
}
Обрабатывать ошибки в момент запуска приложения — хорошая практика.
В нашем случае это позволяет при любом стечении обстоятельств показать пользователю или экран игры, или экран ошибки.
Вы могли заметить, что после проведенных манипуляций в проекте появилось
множество ошибок. Это легко исправить — достаточно изменить расширение BuildContext. Для этого перейдем в файл context_ext.dart и исправим целевой запрос
для получения зависимостей:
// base_url/6/tetris/lib/app/context_ext.dart
import 'package:flutter/material.dart';
import 'di/di_container.dart';
Проект: игра «Тетрис» v. 6. Сохранение, кэширование данных и игра без Интернета 593
/// Удобный доступ к контейнеру зависимостей
/// из любого места приложения посредством BuildContext
extension ContextExt on BuildContext {
DiContainer get di = > DiContainer.of(this);
Depends get di = > DiContainer.of(this).depends;
}
Если до этого момента вы не допустили никаких недочетов при переносе кода,
все ошибки должны пропасть. А значит, можно запустить игру и проверить, как
все работает. В этом и кроется сила расширений!
Рефакторинг HTTP-клиента
Поскольку реализованный HTTP-клиент может по-разному вести себя на различных платформах, нам надо предусмотреть ситуацию, когда ответ на отправленный
приложением запрос так и не приходит. В данный момент это вызовет зависание
приложения на этапе ввода имени пользователя и при окончании игровой сессии.
Чтобы не попасть в такую неприятную ситуацию, внесем изменения в код HTTPклиента и явно укажем тайм-ауты для отправляемых запросов. То есть если приложение будет ждать ответа от сервера дольше указанного времени, ожидание ответа
прервется и мы перейдем к коду обработки сложившейся ситуации:
// base_url/6/tetris/lib/app/http/base_http_client.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'i_http_client.dart';
class BaseHttpClient implements IHttpClient {
String get host {
// без изменений
}
/// Переопределяет базовый URL для запросов
@override
String get baseUrl = > 'http://$host:8080';
/// Отправляет GET-запрос на указанный путь
@override
Future<http.Response> get(String path) async {
final uri = Uri.parse('$baseUrl$path');
final response = await http.get(uri)
.timeout(Duration(seconds: 3));
return response;
}
@override
Future<http.Response> post(String path, {Object? body}) async {
final uri = Uri.parse('$baseUrl$path');
final response = await http
.post(uri, body: jsonEncode(body))
.timeout(Duration(seconds: 3));
}
return response;
594 Глава 6 Локальное хранение данных
@override
Future<http.Response> put(String path, {Object? body}) async {
final uri = Uri.parse('$baseUrl$path');
final response = await http
.put(uri, body: jsonEncode(body))
.timeout(Duration(seconds: 3));
}
}
return response;
Рефакторинг функционала UserRepository
На следующем шаге нам предстоит добавить метод toJson в класс UserDto. Он необходим для того, чтобы преобразовывать экземпляр класса UserDto в JSON-файл
для дальнейшего сохранения в локальном хранилище:
// base_url/6/tetris/lib/features/user/data/user_dto.dart
import '../domain/user_entity.dart';
/// Data Transfer Object для парсинга данных пользователя
final class UserDto {
/// Идентификатор пользователя
final int id;
/// Имя пользователя
final String username;
/// Лучший счет пользователя
final int score;
const UserDto({
// без изменений
});
/// Преобразование JSON в DTO
/// [json] — JSON-данные, полученные от сервера
factory UserDto.fromJson(Map<String, dynamic> json) {
// без изменений
}
/// Преобразование DTO в JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'score': score,
};
}
}
/// Преобразование DTO в сущность [UserEntity]
UserEntity toEntity() {
// без изменений
}
Далее внесем изменения в интерфейс IUserRepository, добавив в него объявление
методов для получения данных пользователя и их удаления:
// base_url/6/tetris/lib/features/user/domain/i_user_repository.dart
import 'user_entity.dart';
Проект: игра «Тетрис» v. 6. Сохранение, кэширование данных и игра без Интернета 595
/// Интерфейс репозитория пользователя
abstract interface class IUserRepository {
/// Создание пользователя
/// Если пользователь с таким именем уже
/// существует,то возвращается именно он
Future<UserEntity> createUser(String username);
// Установка счета пользователя
Future<UserEntity> setScores(String username, int scores);
/// Получение пользователя из локального хранилища
Future<UserEntity?> getUserFromStorage();
}
/// Удаление пользователя из локального хранилища
Future<void> deleteUserFromStorage();
Теперь перейдем в файл user_repository.dart и реализуем объявленные методы
в классе UserRepository. Нам также предстоит изменить код методов createUser
и setScores, добавив в них обработку ситуации отсутствия соединения с сервером
при отправке запросов и сохранение данных пользователя:
// base_url/6/tetris/lib/features/user/data/user_repository.dart
import 'dart:convert';
import
import
import
import
import
'package:tetris/app/http/i_http_client.dart';
'package:tetris/app/storage/i_storage_service.dart';
'user_dto.dart';
'../domain/i_user_repository.dart';
'../domain/user_entity.dart';
/// Репозиторий для работы с пользователем
final class UserRepository implements IUserRepository {
final IHttpClient httpClient;
final IStorageService storageService;
UserRepository({
required this.httpClient,
required this.storageService,
});
@override
Future<UserEntity> createUser(String username) async {
// JSON-данные c отложенной инициализацией
// Получаем данные от сервера или создаем пользователя
// с id = 0 и score = 0
late final Map<String, dynamic> resultJson;
// Получение данных
try {
final response = await httpClient.post(
'/users/',
body: {"username": username},
);
// Проверка статуса ответа
if (response.statusCode ! = 200) {
throw Exception(
'Ошибка при создании пользователя: ${response.statusCode}',
);
}
resultJson = json.decode(response.body);
} on Object catch (_) {
596 Глава 6 Локальное хранение данных
// Если произошла ошибка, то создаем пользователя
// с id = 0 и score = 0 и сохраняем его в локальном хранилище
resultJson = {
'id': 0,
'username': username,
'score': 0,
};
}
}
final userDto = UserDto.fromJson(resultJson);
// Сохранение пользователя в локальном хранилище
await storageService.setString(
'user',
jsonEncode(userDto.toJson()),
);
// Преобразование данных в список сущностей
return userDto.toEntity();
@override
Future<UserEntity> setScores(String username, int scores) async {
// JSON-данные c отложенной инициализацией
// Получаем данные от сервера или создаем
// пользователя с id = 0 и score = 0
late final Map<String, dynamic> resultJson;
}
try {
final response = await httpClient.put(
'/users/scores/',
body: {
'username': username,
'score': scores,
},
);
// Проверка статуса ответа
if (response.statusCode ! = 200) {
throw Exception(
'Ошибка при обновлении пользователя: ${response.statusCode}',
);
}
} on Object catch (_) {
// Если произошла ошибка, то сохраняем его
// в локальном хранилище
resultJson = {
'id': 0,
'username': username,
'score': scores,
};
}
final userDto = UserDto.fromJson(resultJson);
// Сохранение пользователя в локальном хранилище
await storageService.setString(
'user',
jsonEncode(userDto.toJson()),
);
// Преобразование данных в список сущностей
return userDto.toEntity();
@override
Future<void> deleteUserFromStorage() async {
// Очистка локального хранилища
await storageService.clear();
}
Проект: игра «Тетрис» v. 6. Сохранение, кэширование данных и игра без Интернета 597
}
@override
Future<UserEntity?> getUserFromStorage() async {
// Получение данных из локального хранилища
final userString = storageService.getString('user');
if (userString = = null) {
return null;
}
// Преобразование данных в JSON
final userJson = json.decode(userString);
// Преобразование JSON в DTO
final userDto = UserDto.fromJson(userJson);
// Преобразование DTO в сущность
return userDto.toEntity();
}
Далее в файле user_cubit.dart реализуем бизнес-логику очистки данных пользователя и восстановления данных при создании экземпляра класса UserCubit:
// base_url/6/tetris/lib/features/user/domain/state/user_cubit.dart
import 'package:flutter/foundation.dart';
import '../i_user_repository.dart';
import 'user_state.dart';
/// Класс для управления состоянием пользователя
/// и взаимодействия с удаленным репозиторием
class UserCubit {
final IUserRepository repository;
UserCubit({required this.repository});
final ValueNotifier<UserState> stateNotifier = ValueNotifier(
UserInitState(),
);
Future<void> createUser(String username) async {
// код без изменений
}
Future<void> setScores(String username, int scores) async {
// код без изменений
}
/// Выход из аккаунта
/// Удаление текущего состояния
void signOut() {
// Очищаем состояние пользователя
repository.deleteUserFromStorage();
emit(UserInitState());
}
/// Сброс состояния кубита
/// Пригодится для сброса состояния при повторном входе в аккаунт
void reset() {
// Очищаем состояние пользователя
repository.deleteUserFromStorage();
emit(UserInitState());
}
/// Установка текущего состояния
void emit(UserState cubitState) {
stateNotifier.value = cubitState;
}
598 Глава 6 Локальное хранение данных
}
/// Получение пользователя из локального хранилища
/// Если пользователь найден, то устанавливаем состояние
/// успешной загрузки и передаем сущность пользователя
Future<void> restoreUser() async {
try {
// Получение пользователя из локального хранилища
final entity = await repository.getUserFromStorage();
if (entity ! = null) {
// Установка состояния успешной загрузки
// и передача сущности пользователя
emit(UserSuccessState(entity));
}
} on Object catch (_) {
emit(UserInitState());
}
}
Последнее, что нам осталось, — добавить в конец метода init класса
восстановление данных пользователя:
Depends
// base_url/6/tetris/lib/app/di/depends.dart
// импорт без изменений
class Depends {
Future<void> init() async {
// без изменений
}
}
// Инициализируем менеджер состояния пользователя
userCubit = UserCubit(repository: _userRepository);
await userCubit.restoreUser();
// остальной код без изменений
Вот и все! Начиная с этого момента, пользователю достаточно один раз ввести
и до тех пор, пока данные по нему не будут удалены из приложения,
не придется вводить его заново.
username
Задания на модификацию проекта
В следующей главе мы займемся написанием тестового покрытия приложения.
А пока можете выполнить задания по внесению изменений в существующую кодовую базу, используя знания, полученные в этой главе.
1. При наличии учетной записи, созданной без подключения к серверу, придумайте механизм добавления пользователя на сервере при появлении подключения.
2. Кэшируйте таблицу лидеров и выводите эти данные в случае отсутствия
соединения с сервером.
3. Перепишите сервис локального хранилища для работы с sqflite. Добавьте
возможность переключения между локальным хранилищем, с которым будет
работать приложение.
4. Выведите таймер, оповещающий о следующем кадре игры, из главного игрового цикла «Тетриса» в отдельный изолят.
Вопросы для самопроверки 599
Резюме
В этой главе мы рассмотрели различные варианты организации хранения данных
пользователя — как чувствительных, так и не очень. Правильно выбранный вариант
хранилища убережет не только ваши нервы, но и личную информацию доверившегося вам человека, который скачал и установил приложение. В большинстве
современных приложений используются все доступные варианты хранилищ, что
позволяет разделить данные пользователя по их чувствительности, объему и необходимой скорости доступа к информации. Не пренебрегайте таким подходом
и четко разделяйте на этапе проектирования приложения, какими данными будет
оперировать ваш программный продукт и какой тип хранилища лучше всего подойдет для работы с ними.
Вопросы для самопроверки
1.
2.
3.
4.
5.
6.
7.
Какие типы локальных хранилищ вы знаете?
В чем разница между SharedPreferences и Secure Storage?
Что принято и что не принято хранить в SharedPreferences? Почему?
Что принято и что не принято хранить в Secure Storage? Почему?
Чем Drift отличается от sqflite?
Что такое миграция и зачем она нужна?
В каком случае оправданно использование шаблона проектирования Singleton
(«Одиночка») в работе с БД? Почему?
8. Какие параметры кастомизации существуют у Secure Storage? За что отвечает
каждый из них?
Глава 7
ТЕСТИРОВАНИЕ ПРИЛОЖЕНИЙ
В IT-сообществе многие говорят о важности тестирования, но как только вы приступаете к своему пет-проекту, начинают гореть сроки на работе или старший коллега ослабляет свою диктаторскую хватку, про тесты моментально все забывают.
Можно долго и нудно рассуждать об их важности и о том, какие плюшки вас ждут
при покрытии разрабатываемого программного обеспечения необходимым объемом тестов, но пока вы не захотите обучаться на чужих ошибках или не набьете
собственные шишки, тестирование так и останется чем-то мифическим…
Да, не обязательно покрывать тестами свой домашний проект, но чем больше
становится кода в программном продукте, тем выше вероятность появления багов
в тех местах, которые даже в мыслях не мелькали при добавлении того или иного
кода. И если вы хотите проводить очередной вечер с семьей или друзьями, а не за
дебагом, орудуя клавиатурой и мышью и периодически взывая к чьей-то матери,
от написания тестов на старте своей карьеры не отвертеться. Почему только на
старте? Потому что, если в штате нет тестировщика, именно младшим разработчикам и прилетает большинство задач по написанию тестового окружения проекта.
Вы можете возразить, что это теперь удел ИИ-агентов. Но если вы не в силах
проверить предлагаемый код на корректность, найти в нем косяки и адаптировать
к кодовой базе проекта, уверены, что вас не уволят в ближайшее время?
7.1. Теория тестирования
Чтобы лучше понять концепцию тестирования, представим, что вы строите дом
и вроде бы все выглядит отлично: стены ровные, крыша целая, окна на месте. Но как
проверить, что он выдержит сильный ветер, дождь или ураган? С приложением
то же самое — даже при идеальном внешнем виде, удовлетворяющем всем требованиям самого прожженного эстета из мира IT, код может содержать логические
ошибки, которые проявляются в самый неожиданный момент. Чтобы этого избежать, и нужно тестирование — процесс проверки, который помогает убедиться
в корректности работы приложения в разных условиях.
Другими словами, тестирование — это не просто формальность. Оно позволяет
удостовериться в том, что ваш код работает стабильно, ведь любое изменение, новая
функция или исправление могут неожиданно нарушить работу других частей приложения. Тесты быстро находят такие проблемы, экономя ваши время и силы. Они
работают как индикатор: «Все в порядке? Отлично!» или «Что-то пошло не так?
Лови описание проблемы!»
7.1. Теория тестирования 601
7.1.1. Какими бывают тесты
Обычно выделяют три группы тестов.
y Модульные тесты (unit-тесты). Используются для проверки работы отдельных функций, методов или классов. Например, тест для калькулятора может
проверить, что сложение 2 + 2 возвращает 4, а деление на ноль вызывает
ошибку. Эти тесты просты, быстры и применяются чаще всего.
y Интеграционные тесты. Используются для проверки того, как разные части
приложения работают вместе. Например, если это интернет-магазин, тест
проверяет, что добавление товара в корзину корректно обновляет ее содержимое и цену. Такие тесты выявляют конфликты между компонентами
программного продукта.
y E2E-тесты (End-to-End). Призваны имитировать действия пользователя:
заполняют формы, нажимают кнопки, проверяют цепочку действий, например покупку товара в магазине. Эти тесты долгие, но они максимально
приближены к реальной работе приложения.
7.1.2. Подходы к тестированию
Как и в любом другом деле, существуют разные подходы к тестированию. Условно
их можно разделить на функциональный (Test-Driven), поведенческий (BehaviorDriven) и регрессионный.
В функциональном тестировании сначала пишется набор тестов для функции,
которую необходимо разработать. Из-за этого приложение с его текущим функцио
налом не способно их пройти. Далее перед программистом стоит задача добавить
такой код, который не будет вызывать «падения» приложения при тестировании.
То есть вы идете от обратного, сначала определяя результат функции, а потом добавляя ее реализацию. Яркий пример такого подхода — методология разработки
через тестирование (Test-Driven Development).
При поведенческом подходе тестируются не определенная функция или алгоритм, а система в целом, ее поведение и обработка различных сценариев. Поэтому
тесты формулируются в духе: «Если пользователь ввел неправильный пароль, он
увидит сообщение об ошибке». Такая формулировка позволяет разработчикам
и тестировщикам общаться на одном языке предметной области. Яркий пример
такого подхода — методология разработки через поведение (Behavior-Driven
Development).
А что же за зверь такой — регрессионное тестирование? Предположим, у вас
есть работающее приложение, которое ежедневно используют миллионы людей,
и вы, набравшись смелости, все-таки решили добавить в него новый функционал.
Но после публикации обновления было замечено, что количество ошибок в аналитике резко увеличилось. Анализ сложившейся ситуации привел к пониманию
того, что новые функции при определенных условиях влияют на основную функциональность приложения и вызывают ошибки. Это пример регрессии вашего приложения, и именно хорошее тестовое покрытие проекта минимизирует вероятность
появления таких проблем.
602 Глава 7 Тестирование приложений
Помимо различных подходов к тестированию, вам также полезно знать о пирамиде тестирования и понимать, что это такое (рис. 7.1).
Рис. 7.1. Пирамида тестирования
На рисунке видно, что количество и вид тестов прямо пропорциональны стоимости и времени. А самих слоев три:
y основание — модульные (unit) тесты (быстрые и эффективные) в огромном
количестве;
y средний уровень — интеграционные тесты (для проверки взаимодействия).
Обычно их немного — на небольшой проект от 3 до 10;
y вершина — E2E-тесты. Они медленные и сложные, поэтому их очень мало.
Существует еще один слой, который не принято отображать на пирамиде, —
приемочное тестирование. Возможно, это связано с тем, что разработчики не любят
проводить ручное тестирование. Чаще всего приемочное тестирование — ручное,
но его не отображают в пирамиде.
Перед тем как перейти к следующему разделу, закрепим основной тезис.
Тестирование — это не необходимость. Оно не устраняет ошибки полностью, но
значительно снижает их вероятность. Смотрите на него как на ремень безопасности
в автомобиле: вы надеетесь, что он не понадобится, но лучше его надеть. Чем раньше
начнете писать тесты, тем проще будет развивать проект. В следующей части мы
узнаем, как эти принципы применяются в приложениях Flutter.
7.1.3. Паттерн AAA
Паттерн AAA (Arrange, Act, Assert) — широко используемый шаблон организации
тестов, который помогает сделать их структурированными, понятными и поддерживаемыми. Разберем каждую часть более подробно.
7.1. Теория тестирования 603
y Arrange (Подготовка). На этом этапе вы инициализируете данные, настраи-
ваете окружение теста, а также создаете необходимые объекты и зависимости.
Цель этапа — подготовить все, что понадобится для выполнения тестируемой
логики.
y Act (Действие). Вы выполняете действия, которые хотите протестировать:
вызываете функции, инициируете события, имитируете действия пользователя и т. д. Важно, чтобы действие было максимально конкретным и отражало
тестируемый сценарий.
y Assert (Проверка). На этом этапе полученные результаты сравниваются
с ожидаемыми. Цель этих действий — убедиться в том, что тестируемый код
работает согласно ожиданиям.
Далее приведен пример простой реализации рассматриваемого паттерна:
test('Функция возвращает нужное значение', () {
// Arrange — подготовка данных
final input = 'Hello';
final expectedOutput = 'HELLO';
// Act — вызов функции или действия
final actualOutput = input.toUpperCase();
// Assert — проверка результата
expect(actualOutput, equals(expectedOutput));
});
7.1.4. Тестирование во Flutter
Фреймворк Flutter предоставляет разработчику полный набор инструментов для
тестирования приложений, каждый из которых решает определенную задачу на
своем уровне.
y Unit test (модульный тест). Используется для проверки правильности работы
определенных функции, метода, класса или сервиса. Внешние зависимости
для тестируемого модуля обычно передаются как параметр.
y Widget test (виджет-тест). Используется для тестирования виджетов. Цель
такого теста — убедиться в том, что пользовательский интерфейс виджета
выглядит и работает, как запланировано. Тестирование виджета происходит
в тестовой среде, которая обеспечивает контекст его жизненного цикла. Кроме
того, тестируемый виджет должен иметь возможность получать действия
и события пользователя и отвечать на них.
y Integration test (интеграционный тест). Тестирует все приложение или
его большую часть. Цель интеграционного теста — убедиться в том, что все
тестируемые виджеты и сервисы работают вместе, как ожидалось. Кроме
того, вы можете использовать такие тесты для проверки производительности
вашего приложения. Важно отметить, что интеграционный тест выполняется
на реальном устройстве или эмуляторе.
Обычно большие продуктовые приложения хорошо покрыты различными тестами, или как минимум покрыты их основные сценарии использования. Но, так как
604 Глава 7 Тестирование приложений
различия в реализации тех или иных тестов обычно очень велики, всегда приходится
искать компромиссы. Например, для того, чтобы покрыть полностью приложения
или сервисы модульными тестами, требуется гораздо меньше времени и человекочасов, чем если бы вы охватили все приложения интеграционными тестами. В связи
с этим у команды Flutter даже есть специальная таблица, которая явно отражает
эту зависимость в тестах (табл. 7.1).
Таблица 7.1. Компромиссы тестирования
Характеристика
Модульные тесты
Виджет-тесты
Интеграционные тесты
Уверенность
Низкая
Высокая
Самая высокая
Реализация
Легко
Сложно
Очень сложно
Зависимости
Мало
Много
Очень много
Выполнение
Быстро
Быстро
Медленно
Из нее можно приблизительно понять сложность и необходимую глубину покрытия тестами. Например, покрытие приложения только модульными тестами —
задача простая, не требует большого количества зависимостей, а выполняются такие
тесты очень быстро. Но если вы решили покрыть приложения интеграционными
тестами, то должны понимать, что на их разработку нужно больше времени, в таких
тестах будет много различных зависимостей и они будут медленно выполняться.
Именно поэтому надо без фанатизма подходить к выбору глубины покрытия тестами и тщательно подбирать компромиссный для себя набор инструментов для
организации тестового окружения проекта.
7.2. Unit-тесты
Unit-тест (или модульный) — это тест, который проверяет отдельный блок кода, например функции или метода. В идеальном мире он должен быть строго изолирован
от остальной системы и не зависеть от других компонентов, сетевых запросов, баз
данных и т. д., поскольку это очень важно для валидации тестов.
В качестве основных целей этого вида тестирования могут выступать следующие.
1. Проверка корректности логики. Позволяет удостовериться в том, что написанный метод или класс работают правильно.
2. Поддержка стабильности кода. Наличие тестов позволяет более уверенно
вносить изменения.
3. Упрощение рефакторинга. При наличии хорошего набора тестов кодовой
базы приложения рефакторинг становится проще.
4. Улучшение проектирования. Разработка через тестирование (Test-Driven
Development, TDD) заставляет вас проектировать более модульную и изолированную архитектуру приложения.
7.2. Unit-тесты 605
Для написания тестов во Flutter используется библиотека flutter_test. Она по
умолчанию добавляется в зависимости проектов, создаваемых с помощью команды
flutter create. Чтобы убедиться в этом, перейдите в файл pubspec.yaml и обратите
свое внимание на раздел devdependencies:
devdependencies:
flutter_test:
sdk: flutter
Файлы с тестами хранятся в каталоге test в корневой папке проекта, а их названия, как правило, совпадают с названиями файлов, которые они проверяют, но
с добавлением постфикса _test.dart.
Чтобы запустить все Unit-тесты вашего проекта, достаточно открыть терминал
и выполнить команду:
flutter test
Flutter обнаружит все файлы с постфиксом _test.dart и выполнит их.
7.2.1. Основные функции для написания unit-тестов
Далее рассмотрены основные функции, которые применяются при написании
unit-тестов.
Функция test() предназначена для описания конкретного сценария проверки
и имеет сигнатуру вида test(String description, dynamic Function() body), куда
в качестве первого аргумента передается текстовое описание (что именно мы тестируем), а в качестве второго — функция (тест-кейс):
test('Описание теста', () {
// Тело теста
});
Функция group() используется для группировки нескольких тестов и имеет
сигнатуру вида group(String description, void Function() body). Ее удобно задействовать при наличии множества тестов, которые необходимо сгруппировать по
какому-то признаку, например по контексту:
group('Группа тестов', () {
test('Тест 1', () {
// Тело теста
});
test('Тест 2', () {
// Тело теста
});
});
Функция expect() предназначена для проверки результатов и имеет сигнатуру
вида expect(actual, matcher). Она принимает фактическое значение (actual) и мэтчер (matcher), который сравнивает ответ с ожидаемым нами значением:
final testVal = 'itemValue';
expect(1 + 2, equals(3)); // Проверка на равенство
expect(testVal, contains('item')); // Проверка на вхождение
expect(testVal, isNotNull); // Проверка на не-null-значение
606 Глава 7 Тестирование приложений
Функция setUp() вызывается перед каждым тестом в группе, в которой он
определен, а tearDown() — после каждого теста:
setUp(() {
// Код, который выполняется перед каждым тестом
// Например, инициализация переменных
});
tearDown(() {
// Код, который выполняется после каждого теста
// Например, очистка переменных
});
test('Первый тест', () {
// Выполняется код из setUp
// Тело теста
// Выполняется код из tearDown
});
test('Второй тест', () {
// Выполняется код из setUp
// Тело теста
// Выполняется код из tearDown
});
Функция setUpAll() вызывается один раз перед запуском всех тестов в group
или во всем тест-файле (если объявлена на верхнем уровне). Ее обычно используют при наличии тяжелых или продолжительных операций, которые достаточно
настроить всего один раз, а затем пользоваться результатом их работы при тестировании. Например, такова инициализация базы данных. При использовании этой
функции важно помнить, что любой объект, созданный в теле анонимной функции
в setUpAll(), будет общим для всех тестов в группе. При самом неприятном раскладе
это может привести к тому, что тесты начнут влиять друг на друга.
А tearDownAll() вызывается один раз после выполнения всех тестов в group или во
всем тест-файле (если объявлена на верхнем уровне). Ее обычно используют, когда
в функции setUpAll() создается какой-либо глобальный ресурс (например, соединение с базой данных) и его нужно закрыть один раз после выполнения всех тестов:
void main() {
setUpAll(() {
// Код, который выполняется при запуске тестов
});
tearDownAll(() {
// Код, который выполняется после завершения всех тестов
});
/// Выполняется код
test('Первый тест',
test('Второй тест',
/// Выполняется код
}
в setUpAll
() {});
() {});
в tearDownAll
7.2.2. Тестирование калькулятора
Попробуем применить полученные знания на практике и напишем самый простой
unit-тест к приложению «Калькулятор» (рис. 7.2).
7.2. Unit-тесты 607
Рис. 7.2. Графический пользовательский интерфейс калькулятора
Для начала создайте новый проект и назовите его calculator. Сама реализация
очень простая. Обойдемся всего двумя файлами: main.dart и calculator.dart.
Во второй файл добавим класс Calculator с набором поддерживаемых им операций:
// base_url/7/unit/calculator/lib/calculator.dart
/// Класс, реализующий основные методы калькулятора
class Calculator {
String add(int a, int b) = > (a + b).toString();
String subtract(int a, int b) = > (a - b).toString();
String multiply(int a, int b) = > (a * b).toString();
/// Если b = = 0, вернем ошибку.
String divide(int a, int b) {
if (b = = 0) {
throw ArgumentError('На ноль делить нельзя');
}
return (a / b).toString();
}
}
А в main.dart
Calculator:
реализуем верстку приложения и работу с экземпляром класса
// base_url/7/unit/calculator/lib/main.dart
import 'package:flutter/material.dart';
import 'calculator.dart';
void main() = > runApp(MaterialApp(
home: const CalculatorScreen(),
));
class CalculatorScreen extends StatefulWidget {
const CalculatorScreen({super.key});
}
@override
State<CalculatorScreen> createState() = > _CalculatorScreenState();
608 Глава 7 Тестирование приложений
class _CalculatorScreenState extends State<CalculatorScreen> {
final _controllerA = TextEditingController();
final _controllerB = TextEditingController();
final _calculator = Calculator();
String _result = '';
void _calculate(String operation) {
final a = int.tryParse(_controllerA.text) ?? 0;
final b = int.tryParse(_controllerB.text) ?? 0;
}
_result = switch (operation) {
'+' = > _calculator.add(a, b),
'-' = > _calculator.subtract(a, b),
'*' = > _calculator.multiply(a, b),
'/' = > _calculator.divide(a, b),
_ = > 'Неизвестная операция',
};
setState(() {});
@override
void dispose() {
_controllerA.dispose();
_controllerB.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Simple Calculator'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _controllerA,
decoration: const InputDecoration(
labelText: 'Первое число',
),
),
TextField(
controller: _controllerB,
decoration: const InputDecoration(
labelText: 'Второе число',
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: [
ElevatedButton(
onPressed: () = > _calculate('+'),
child: const Text('+'),
),
ElevatedButton(
onPressed: () = > _calculate('-'),
child: const Text('-'),
),
ElevatedButton(
onPressed: () = > _calculate('*'),
7.2. Unit-тесты 609
child: const Text('*'),
),
ElevatedButton(
onPressed: () = > _calculate('/'),
child: const Text('/'),
),
],
),
const SizedBox(height: 16),
Text(
'Результат: $_result',
)
],
),
),
);
}
}
Теперь перейдем к тестированию класса Calculator. Он очень простой, но, как
видите, в методе String divide(int a, int b) может возникнуть ошибка, если второй
параметр будет равен нулю. Попробуем отловить это в ходе тестирования. Для этого откройте папку test и создайте в ней файл calculator_test.dart со следующим
содержимым:
// base_url/7/unit/calculator/test/calculator_test.dart
void main() {
// Для удобства создадим группу тестов
group('Тест класса Calculator', () {
// Arrange — подготовка данных
final calculator = Calculator();
test('Тест сложения', () {
// Act — вызов функции или действия
final result = calculator.add(2, 3);
// Assert — проверка результата
expect(result, equals('5'));
});
test('Тест вычитания', () {
final result = calculator.subtract(5, 3);
expect(result, equals('2'));
});
test('Тест умножения', () {
final result = calculator.multiply(3, 4);
expect(result, equals('12'));
});
test('Тест деления', () {
final result = calculator.divide(6, 3);
expect(result, equals('2.0'));
});
});
}
Для запуска тестов можно выполнить функцию main в файле calculator_test.dart
или запустить в терминале команду flutter test:
:calculator yura$ flutter test
00:01 +4: All tests passed!
610 Глава 7 Тестирование приложений
Если вы используете VSCode, то можете просмотреть результаты тестов на
вкладке TEST RESULTS (рис. 7.3).
Рис. 7.3. Результаты тестирования
Здесь видно, что тесты завершились успешно. Значит, основные методы работают так, как мы и ожидали. Далее попробуем усложнить тесты, добавив обработку
ошибки:
test('Тест деления на 0', () {
expect(
() = > calculator.divide(6, 0),
throwsA(isA<ArgumentError>()),
);
});
В результате повторного запуска тестов информация на вкладке TEST RESULTS
должна выглядеть как на рис. 7.4.
Рис. 7.4. Результаты тестирования
Разберем этот тест более детально. Его первый аргумент — это анонимная функция, вызывающая метод divide с параметрами 6 и 0. А вторым аргументом является
условие throwsA(isA<ArgumentError>()), которое проверяет, что вызов функции
приводит к выбросу исключения типа ArgumentError. Таким образом, вы можете
сравнивать не только такие примитивы, как строки или цифры, но и выражения.
Теперь для тестирования воспользуемся функциями setUp и tearDown. Но сначала добавьте в класс Calculator состояние последнего значения, которое будет
обновляться при каждом вызове любого метода:
7.2. Unit-тесты 611
// base_url/7/unit/calculator_set_up/lib/calculator.dart
/// Класс, реализующий основные методы калькулятора
class Calculator {
// Последнее вычисленное значение
String lastValue = 'Не определено';
String add(int a, int b) {
lastValue = (a + b).toString();
return lastValue;
}
String subtract(int a, int b) {
lastValue = (a - b).toString();
return lastValue;
}
String multiply(int a, int b) {
lastValue = (a * b).toString();
return lastValue;
}
/// Если b = = 0, вернем ошибку.
String divide(int a, int b) {
if (b = = 0) {
lastValue = 'Ошибка';
throw ArgumentError('На ноль делить нельзя');
}
lastValue = (a / b).toString();
return lastValue;
}
}
На следующем шаге добавим в файл calculator_test.dart тест, который будет
выводить текущее состояние в консоль:
// base_url/7/unit/calculator_set_up/test/calculator_test.dart
test('Тест сложения', () {
// Выведем в консоль начальное состояние
print('Last value: ${calculator.lastValue}');
final result = calculator.add(2, 3);
expect(result, equals('5'));
// Убедимся, что последнее вычисленное значение равно 5
expect(calculator.lastValue, equals('5'));
print('Last value: ${calculator.lastValue}');
});
Теперь при запуске тестов вы можете увидеть, что при их выполнении переменная lastValue проинициализирована заранее (рис. 7.5).
Рис. 7.5. Результаты тестирования
612 Глава 7 Тестирование приложений
Но такое поведение — ошибка. Это связано с тем, что каждый тест должен выполняться изолированно и не иметь объектов, которые были созданы или изменены
другими тестами. Чтобы это исправить, воспользуемся функциями setUp и tearDown.
Для начала добавьте setUp в группу тестов:
setUp(() {
// Перед каждым тестом обнулим последнее значение
calculator.lastValue = 'Не определено';
});
При запуске тестирования должен наблюдаться результат, представленный на
рис. 7.6. Из него видно, что перед каждым тестом срабатывает метод setUp и сбрасывает данные.
Рис. 7.6. Результаты тестирования
Такое поведение можно реализовать и с помощью функции tearDown:
// закомментируйте setUp
// setUp(() {
//
// Перед каждым тестом обнулим последнее значение
//
calculator.lastValue = 'Не определено';
// });
tearDown(() {
// После каждого теста обнулим последнее значение
calculator.lastValue = 'Не определено';
});
А в качестве вишенки на торте попробуем реализовать пример с функциями
и setUpAll, в котором посчитаем, сколько миллисекунд выполнялся
каждый тест. Для этого добавим в класс Calculator таймер его работы и после
выполнения каждого метода будем отправлять в консоль затраченное количество
миллисекунд. Запуск таймера и моделирование долгой инициализации сервиса
возложим на метод init():
tearDownAll
// base_url/7/unit/calculator_set_up_all/lib/calculator.dart
/// Класс, реализующий основные методы калькулятора
class Calculator {
// Последнее вычисленное значение
String lastValue = 'Не определено';
// Таймер для отслеживания времени выполнения методов
late Stopwatch timer;
Future<void> init() async {
timer = Stopwatch()..start();
// Имитация длительной инициализации
await Future.delayed(Duration(seconds: 5));
7.2. Unit-тесты 613
}
}
print('Инициализация завершена');
// далее код без изменений
На следующем шаге изменим тестовое окружение проекта. Откройте файл
calculator_test и внесите в него следующие изменения:
// base_url/7/unit/calculator_set_up_all/test/calculator_test.dart
import 'package:calculator/calculator.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// Для удобства создадим группу тестов
group('Тест класса Calculator', () {
// Остальной код тестов
final Calculator calculator = Calculator();
setUp(() async {
await calculator.init();
});
test('Тест сложения', () {
final result = calculator.add(2, 3);
expect(result, equals('5'));
print(
'Elapsed time: ${calculator.timer.elapsedMilliseconds} ms',
);
});
// Остальной код тестов
test('Тест вычитания', () {
final result = calculator.subtract(5, 3);
expect(result, equals('2'));
print(
'Elapsed time: ${calculator.timer.elapsedMilliseconds} ms',
);
});
test('Тест умножения', () {
final result = calculator.multiply(3, 4);
expect(result, equals('12'));
print(
'Elapsed time: ${calculator.timer.elapsedMilliseconds} ms',
);
});
test('Тест деления', () {
final result = calculator.divide(6, 3);
expect(result, equals('2.0'));
print(
'Elapsed time: ${calculator.timer.elapsedMilliseconds} ms',
);
});
}
test('Тест деления на 0', () {
expect(
() = > calculator.divide(6, 0),
throwsA(isA<ArgumentError>()),
);
});
});
614 Глава 7 Тестирование приложений
При запуске тестов вы будете наблюдать результат, представленный на рис. 7.7.
Рис. 7.7. Результаты тестирования
Если посмотрите на рисунок внимательно, то заметите, что для выполнения
всех тестов нам понадобилось более 20 с! Это совершено не тот результат, который
ожидался от работы тестового окружения. Так произошло из-за того, что перед
каждым тестом мы запускаем метод init. Чтобы это исправить, заменим функцию
setUp на setUpAll:
setUpAll(() async {
await calculator.init();
});
Поскольку функция setUpAll выполняется один раз, тесты завершатся практически за 5 с (рис. 7.8).
Рис. 7.8. Результаты тестирования
В целом функции tearDownAll и setUpAll очень полезны, но используйте их
с осторожностью и только тогда, когда без этого не обойтись.
7.3. Виджет-тесты
Виджет-тесты (иногда их называют UI-тестами) позволяют проверять поведение
отдельных виджетов в специальной изолированной среде. Это промежуточное
звено между unit-тестами и интеграционными тестами. Такие тесты запускаются
не на реальном устройстве, а в эмуляции (headless mode), что позволяет выполнять
их очень быстро.
7.3. Виджет-тесты 615
Основными инструментами в виджет-тестировании являются:
y класс WidgetTester — с его помощью можно добавлять (pump) виджет в эмуляцию, находить его и взаимодействовать с ним;
y функция testWidgets() — автоматически создает новый экземпляр Widget
Tester для каждого тестового сценария и используется вместо обычной
функции test() (см. unit-тестирование);
y Finder-классы — позволяют выполнять поиск виджетов в тестовой среде;
y Matcher-классы — используются для сравнения и проверки Finder-классов
по определенным условиям.
Далее мы разберем каждый из этих инструментов по отдельности.
7.3.1. WidgetTester
Это ключевой класс в библиотеке flutter_test, предназначенный для тестирования виджетов в эмуляции. Он позволяет взаимодействовать с UI-компонентами,
имитировать пользовательские действия и проверять состояние виджетов. К его
основным возможностям относят:
y создание виджетов;
y имитирование действий пользователей;
y управление состоянием;
y получение данных Render-объектов.
За создание виджетов отвечает метод pumpWidget(). Он загружает и рендерит
виджет в тестовой среде:
void main() {
testWidgets('Тестирование счетчика', (WidgetTester tester) async {
// Создаем приложение и запускаем его в тестовой среде.
await tester.pumpWidget(const MyApp());
});
}
Имитирование действий пользователей предоставляет нам возможность эмулировать тапы, вводить текст, выполнять прокрутку и другие жесты:
// Имитируем нажатие кнопки увеличения счетчика и ждем перерисовки.
// Находим кнопку по значку Icons.add.
await tester.tap(find.byIcon(Icons.add));
// Имитируем нажатие кнопки по ключу 'add'.
await tester.tap(find.byKey(const Key('add')) );
Для управления состоянием тестировщику приходится жонглировать методами
pump() и pumpAndSettle(). Они контролируют анимации и асинхронные операции:
// Перерисовываем виджет.
await tester.pump();
// Перерисовываем виджет до тех пор, пока не завершится анимация.
await tester.pumpAndSettle();
А чтобы получить данные по экземпляру Render, используйте метод renderObject:
final renderObject = tester.renderObject(find.byIcon(Icons.add));
616 Глава 7 Тестирование приложений
При использовании
его работы.
WidgetTester
необходимо помнить о некоторых нюансах
y Виджеты тестируются без реальных зависимостей.
y Ключевое слово await всегда должно сопровождать методы pump для обработки Futures.
y При изменении состояния с помощью
setState() ,
когда вы тестируете
Stateful
Widget, требуется явный вызов метода pump() для обновления поль-
зовательского интерфейса.
7.3.2. testWidgets()
Функция testWidgets() позволяет запускать тесты в изолированной среде, взаимодействовать с виджетами и проверять их состояние. Она выполняет тест в специальной среде WidgetTester, которая эмулирует поведение Flutter-приложения,
так что нет необходимости запускать его на реальном устройстве или в эмуляторе.
На вход этой функции можно подавать различные аргументы, а именно:
void testWidgets(
String description,
WidgetTesterCallback callback, {
bool? skip,
test_package.Timeout? timeout,
bool semanticsEnabled = true,
TestVariant<Object?> variant = const DefaultTestVariant(),
dynamic tags,
})
Давайте разберем каждый из аргументов сигнатуры функции testWidgets.
y Аргументу description передается краткое описание теста, которое помогает
понять, что именно проверяется в ходе тестирования. Это описание отображается в выводе тестов, что облегчает поиск теста, завершившегося с ошибкой.
y На вход callback передается асинхронная функция, содержащая логику теста
и принимающая экземпляр WidgetTester, который предоставляет методы для
взаимодействия с виджетами, их построения и проверки.
y Необязательный аргумент skip может быть установлен в значение true для
пропуска текущего теста. Это очень полезно, когда нужно временно отключить тесты, не удаляя их.
y Необязательный аргумент timeout позволяет задать максимальное время выполнения теста, в случае превышения которого тест завершается с ошибкой.
Полезно для предотвращения зависания тестов.
y Необязательный аргумент semanticsEnabled (по умолчанию true) отвечает
за тестирование доступности и проверку семантических свойств виджетов.
y Необязательный аргумент variant (по умолчанию DefaultTestVariant())
позволяет запускать тест с различными вариантами окружения. Полезно
для проверки поведения виджетов в разнообразных условиях, например при
разных локалях или разной ориентации экрана.
7.3. Виджет-тесты 617
y Необязательный аргумент tags представляет собой пользовательский тег,
который можно присвоить тесту и использовать для фильтрации тестов при
их запуске.
Далее приведен простой пример запуска функции:
testWidgets('Тестирование счетчика', (WidgetTester tester) async {
// Создаем приложение и запускаем его в тестовой среде.
await tester.pumpWidget(const MyApp());
});
7.3.3. Finder-классы
Finder-классы используются для поиска виджетов в дереве элементов во время
тестирования. Они предоставляют гибкие методы локации виджетов по тексту,
ключам, типам и другим параметрам:
await tester.tap(find.byKey(const Key('add')));
Из примера видно, что с помощью обращения к методу find.byKey можно найти
в тестовой среде определенный виджет по ключу. Здесь find — экземпляр производного класс от Finder, который предоставляет удобные методы поиска виджетов
в дереве виджетов.
В табл. 7.2 приведены существующие методы поиска и описана их работа.
Таблица 7.2. Сигнатура и описание работы методов Finder
Имя и сигнатура метода
find.byType(Type)
find.byKey(Key)
find.byWidget(Widget)
find.text(String)
find.byIcon(IconData)
find.widgetWithText(Type, String)
find.descendant({Finder, Finder,
bool = false})
find.ancestor({Finder, Finder, bool = false})
find.byWidgetPredicate(WidgetPredicate,
{String?})
find.byElementType(Type,{bool = true})
find.bySemanticsLabel(Pattern, {bool = true})
find.byElementPredicate(ElementPredicate,
{String?, bool = true})
Описание
Поиск виджета по типу
Поиск виджета по ключу
Поиск конкретного экземпляра виджета
Поиск виджета Text с определенным содержимым
Поиск виджета Icon с определенным значением значка
Поиск виджета определенного типа, содержащего дочерний виджет Text с заданным текстом
Поиск виджета, который является потомком другого
виджета
Поиск виджета, который является предком другого
виджета
Поиск виджетов, удовлетворяющих заданному предикату
Поиск виджетов, соответствующих определенному типу
элемента
Поиск виджетов, соответствующих заданной метке
семантики
Поиск виджетов, удовлетворяющих заданному предикату
на уровне элементов
618 Глава 7 Тестирование приложений
Если вам нужно организовать поиск виджета со сложными условиями, воспользуйтесь методами byElementPredicate и byWidgetPredicate. Допустим, перед вами
стоит задача найти кнопку синего цвета с текстом «Отправить»:
// Создаем объект Finder для поиска виджета по предикату
final Finder customFinder = find.byWidgetPredicate(
// Предикат, который проверяет виджет
(Widget widget) = >
// Проверяем, является ли виджет кнопкой ElevatedButton
widget is ElevatedButton &&
// Проверяем, является ли его дочерний виджет
// текстовым (Text)
widget.child is Text &&
// Проверяем, содержит ли текстовое поле
// нужное значение "Отправить"
(widget.child as Text).data = = 'Отправить' &&
// Проверяем, является ли цвет фона кнопки
// синим (Colors.blue)
widget.style?.backgroundColor?.resolve({}) = = Colors.blue,
// Описание поиска (для отладки и понимания кода)
description: 'ElevatedButton с текстом "Отправить",
);
Таким образом, у вас имеется возможность создавать любые условия поиска
виджетов. К тому же вы можете написать свой класс, наследуемый от Finder, с уже
определенным условием. Это удобно, если оно часто встречается в ваших тестах.
Далее приведен пример пользовательского Finder для поиска виджета Text с требуемым текстом:
/// Кастомный Finder, который находит
/// [Text] виджеты с заданным текстом.
class MyCustomFinder extends Finder {
final String textToFind;
MyCustomFinder(this.textToFind);
@override
Iterable<Element> apply(Iterable<Element> candidates) {
// Проходим по всем кандидатам и фильтруем те,
// что удовлетворяют условию.
return candidates.where((element) {
final widget = element.widget;
if (widget is Text) {
// Если свойство data (или, например, текст
// может быть в параметре 'textSpan') совпадает
// с искомым текстом, возвращаем этот элемент.
return widget.data = = textToFind;
}
return false;
});
}
@override
String get description = > 'Находит Text виджеты с текстом';
}
Применение пользовательского экземпляра
стандартного:
final finder = MyCustomFinder('Привет');
expect(finder, findsOneWidget);
Finder
ничем не отличается от
7.3. Виджет-тесты 619
7.3.4. Matcher-классы
Последний инструмент тестирования, который нам осталось изучить, — Matcherклассы. Они широко используются для написания тестов, позволяющих описывать
ожидания от тестируемых значений, и применяются в функции expect(Finder,
Matcher). По сути, у нас есть что-то, что ищет нужный объект (Finder), и есть Matcher,
который проверяет, соответствует ли этот объект нужному условию.
Для примера рассмотрим стандартный Matcher, который проверяет то, что Finder
нашел только один виджет — findsOneWidget:
expect(
// Ищем виджет с ключом 'TestWidget'.
find.byKey(const ValueKey('TestWidget')),
// Matcher — убеждаемся, что найден только один виджет с ключом 'TestWidget'.
findsOneWidget,
);
Помимо рассмотренного экземпляра Matcher, к самым широко используемым
можно отнести:
y findsWidgets — проверяет, находит ли Finder по крайней мере один виджет;
y isSameColorAs(Color color) — проверяет, имеет ли найденный объект определенный цвет;
y findsNothing — показывает, что Finder не находит виджета;
y isNotNull — показывает, что объект не null.
В качестве примера реализации пользовательского Matcher напишем аналог
findsOneWidget:
class FindsExactlyOneWidget extends Matcher {
// Метод matches проверяет, соответствует ли переданный item критерию
@override
bool matches(item, Map matchState) {
// Проверяем, является ли item объектом Finder
if (item is! Finder) {
// Если нет, записываем ошибку в matchState и возвращаем false
matchState['error'] = 'Ожидался Finder, но получено: ${item.runtimeType}';
return false;
}
// Получаем количество найденных виджетов
final count = item.evaluate().length;
// Если найден ровно один виджет, возвращаем true
if (count = = 1) {
return true;
}
}
// В противном случае сохраняем количество
// найденных виджетов в matchState и возвращаем false
matchState['count'] = count;
return false;
// Метод describe описывает, что именно проверяет этот matcher
@override
Description describe(Description description) {
return description.add('Найден только один виджет');
}
620 Глава 7 Тестирование приложений
// Метод describeMismatch описывает, почему проверка не прошла
@override
Description describeMismatch(
item,
Description mismatchDescription,
Map matchState,
bool verbose,
) {
// Если в matchState есть ошибка (например, item не Finder),
// добавляем ее в описание несоответствия
if (matchState.containsKey('error')) {
return mismatchDescription.add(matchState['error']);
}
// В остальных случаях указываем, сколько виджетов было найдено
return mismatchDescription.add(
'Найдено ${matchState['count']} виджетов',
);
}
}
Чтобы задействовать пользовательскую реализацию Matcher, объявите создание
экземпляра до вызова функции expect, передав его в качестве второго аргумента:
final findsExactlyOneWidget = FindsExactlyOneWidget();
expect(
// Ищем виджет с ключом 'TestWidget'.
find.byKey(const ValueKey('TestWidget')),
// Matcher — убеждаемся, что найден только один виджет
// с ключом 'TestWidget'.
findsExactlyOneWidget,
);
7.3.5. Тестируем калькулятор
В качестве практического примера реализуем виджет-тест, который будет имитировать ввод цифр в поля и суммирование. Откройте пример с калькулятором, реализованным в предыдущем разделе, а точнее, его файл calculator_test и замените
в нем код на приведенный далее:
// base_url/7/widget/calculator/test/calculator_test.dart
import 'package:calculator/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// Собираем все тесты, относящиеся к CalculatorScreen, в одну группу.
group('Widget тест для калькулятора', () {
// Тест проверяет корректность работы операции сложения.
testWidgets('Проверка сложения', (WidgetTester tester) async {
// Arrange: строим CalculatorScreen внутри MaterialApp
// (для корректного Material-окружения).
await tester.pumpWidget(MaterialApp(
home: CalculatorScreen(),
));
// Arrange: находим первое текстовое поле по тексту "Первое число".
final firstNumberField = find.widgetWithText(
TextField,
'Первое число',
);
7.4. Асинхронные виджет-тесты 621
// Arrange: находим второе текстовое поле
// по тексту "Второе число".
final secondNumberField = find.widgetWithText(
TextField,
'Второе число',
);
// Act: вводим '2' в первое текстовое поле.
await tester.enterText(firstNumberField, '2');
// Act: вводим '3' во второе текстовое поле.
await tester.enterText(secondNumberField, '3');
// Act: симулируем нажатие кнопки '+'.
await tester.tap(find.text('+'));
// Act: обновляем дерево виджетов, чтобы
// отобразить изменения (перерисовка
// после setState).
await tester.pump();
// Assert: проверяем, отображается ли на экране
// текст с результатом "Результат: 5".
expect(
find.text('Результат: 5'),
findsOneWidget,
);
});
});
}
Чтобы увидеть, как выполняется тест, запустите его и откройте debug-консоль
(рис. 7.9).
Рис. 7.9. Результаты тестирования
Полный код тестов, которыми проверяются все возможности калькулятора, вы
можете посмотреть в репозитории книги.
7.4. Асинхронные виджет-тесты
С синхронными тестами разобрались, а что, если нужно протестировать асинхронную функцию, которая выполняется продолжительное время? Или, например, протестировать анимацию загрузки данных? Попробуем в этом разобраться с помощью
простого примера, который имитирует получение данных из сети. При запросе
данных на экране приложения будет отображаться круглый индикатор прогресса,
а при успешном получении — сообщение, что данные загружены (рис. 7.10).
622 Глава 7 Тестирование приложений
Рис. 7.10. Графический интерфейс примера
Создайте проект async_example и добавьте в файл main.dart следующий код:
// base_url/7/widget/async_example/lib/main.dart
import 'package:flutter/material.dart';
void main() = > runApp(Example());
class Example extends StatefulWidget {
const Example({super.key});
}
@override
State<Example> createState() = > _ExampleState();
class _ExampleState extends State<Example> {
Future<String>? _futureData;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FutureBuilder<String>(
future: _futureData,
builder: (context, snapshot) {
if (snapshot.connectionState = = ConnectionState.waiting) {
// Если данные загружаются, отображаем индикатор загрузки
return CircularProgressIndicator();
}
if (snapshot.hasData) {
return Text(snapshot.data!);
}
return Text('Нужно загрузить данные');
},
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
_futureData = _downloadData();
7.4. Асинхронные виджет-тесты 623
setState(() {});
},
child: Text('Загрузить данные'))
]))));
}
}
/// Метод имитирует загрузку данных
Future<String> _downloadData() async {
await Future.delayed(Duration(seconds: 2));
return 'Данные загружены';
}
Теперь откройте файл widget_test.dart в папке test и добавьте в него код для
тестирования:
// base_url/7/widget/async_example/test/widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:async_example/main.dart';
void main() {
testWidgets(
'Нажатие кнопки запускает загрузку данных',
(tester) async {
await tester.pumpWidget(Example());
// Нажимаем кнопку
await tester.tap(find.text('Загрузить данные'));
// Перерисовываем UI после изменения состояния
await tester.pump();
// Проверяем, отображается ли индикатор загрузки
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Ждем завершения загрузки (2 с в будущем)
await tester.pump();
// Убеждаемся, что после загрузки отображается текст "Данные загружены"
expect(find.text('Данные загружены'), findsOneWidget);
},
}
);
Вроде бы все готово, но вот беда — запуск теста выдаст ошибку (рис. 7.11).
Рис. 7.11. Пример ошибки в ходе тестирования
624 Глава 7 Тестирование приложений
Там говорится, что функция expect ожидала сообщения о загрузке данных, но
finder такого сообщения не нашел. Почему так произошло? Все дело в том, что мы
использовали метод pump(), а он моментально перестраивает интерфейс в тестовой
среде. И так как загрузка данных должна происходить за какое-то время (2 с), после
перестройки пользовательского интерфейса тест их не находит. Существует два
подхода к решению этой проблемы .
Первый, правильный — вместо pump() взять метод pumpAndSettle(). Он будет
перестраивать интерфейс до тех пор, пока все Future не закончат работу. То есть
до тех пор, пока Future, имитирующая загрузку данных, не завершит работу, метод
pumpAndSettle() не будет перестраивать интерфейс:
// Ждем завершения загрузки (2 с в будущем)
await tester.pumpAndSettle();
Второй вариант заключается в том, чтобы указать необходимое время задержки
в самом методе pump(). Однако это не убережет вас от ситуаций, когда точно неизвестно, сколько будет выполняться та или иная операция в приложении:
// Ждем завершения загрузки (2 с в будущем)
await tester.pump(Duration(seconds: 2));
7.5. Интеграционные тесты
Из теоретического раздела главы мы знаем, что интеграционные тесты выполняются
не в тестовой среде, а в эмуляторах или на реальных устройствах. К тому же слово
«интеграция» звучит как-то зловеще и, казалось бы, не предвещает ничего хорошего.
Но не стоит пугаться раньше времени! Все дело в том, что виджет-тесты — это уже
полноценные интеграционные тесты (с небольшими дополнениями).
Для примера интеграционного тестирования давайте покроем этим типом тестов
разработанный в начале главы калькулятор. Первым делом в проекте calculator
нам понадобится добавить в файл pubspec.yaml поддержку интеграционных тестов:
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
Далее создайте в корневой папке проекта каталог integration_test и добавьте в него
файл app_test.dart (рис. 7.12).
На следующем шаге скопируйте из папки test написанный ранее виджет-тест
в файл app_test.dart, добавьте импорт библиотеки integration_test, а в самом начале функции main — строку с инициализацией связок для интеграционных тестов:
// base_url/7/integration/calculator/integration_test/app_test.dart
import 'package:calculator/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
void main() {
7.5. Интеграционные тесты 625
// Инициализация биндингов, необходимых
// для выполнения интеграционных тестов во Flutter.
// Он обеспечивает корректную работу виджетов
// и других компонентов Flutter.
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Собираем все тесты, относящиеся к CalculatorScreen,
// в одну группу.
group('Widget тест для калькулятора', () {
// Тест проверяет корректность работы операции сложения.
testWidgets('Проверка сложения', (WidgetTester tester) async {
}
// Остальной код без изменений...
После запуска тестов вы увидите, что приложение запущено и начался процесс тестирования (рис. 7.13): нажимаются кнопки и проверяются результаты
(рис. 7.14). Чтобы явно наблюдать за процессом тестирования, добавьте задержки
перед каждым тестом.
Рис. 7.12. Добавление каталога integration_test
Рис. 7.13. Начало тестирования
Рис. 7.14. Процесс имитации нажатия кнопки
626 Глава 7 Тестирование приложений
По завершении тестирования вы увидите
следующее сообщение (рис. 7.15).
В целом интеграционные тесты особо
не отличаются от виджет-тестов и могут служить метрикой качества их написания, ведь
правильно реализованный виджет-тест долРис. 7.15. Завершение тестирования
жен выполняться без ошибок и в интеграционных тестах. А если для его переноса в разряд интеграционных тестов приходится
браться за лом, вы явно где-то свернули не туда…
Проект: игра «Тетрис» v. 7. Тестирование
Вот мы и добрались до написания тестового окружения разрабатываемой в ходе
книги игры. Надо признаться, это самое нелюбимое занятие разработчиков! В то же
самое время хорошо написанные тесты способны уберечь вас от непредвиденных
багов и бессонных ночей.
Прежде чем перейти к написанию тестов, добавим в проект пару зависимостей —
библиотеки Mockito и build_runner. Первая содержит удобные механизмы для тестирования, а вторая представляет собой подходящий инструмент для генерации
кода. Откройте файл в pubspec.yaml и добавьте их в раздел dependencies:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
http: ^1.3.0
shared_preferences: ^2.5.3
mockito: ^5.4.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.15
В качестве жертвы выберем функционал
leaderboard, работающий с таблицей лидеров.
Перейдите в папку test и создайте в ней каталог features с вложенной папкой leaderboard.
К концу этого раздела у вас должна получиться следующая структура реализованного
тестового окружения (рис. 7.16).
Такой подход позволяет разграничить тестируемый функционал приложения и практически полностью повторяет концепцию Рис. 7.16. Тестовое окружение фичи leaderboard
разделения по фичам.
Поскольку нам важно проверить корректность парсинга данных, то первое, что
покроем тестами, — класс LeaderboardDto. Перейдите в папку leaderboard и создайте
Проект: игра «Тетрис» v. 7. Тестирование 627
файл leaderboard_dto_test.dart, содержащий тестовое окружение для проверки
работы метода fromJson:
/* base_url/7/tetris/test/features/leaderboard/leaderboard_dto_test.dart */
import 'package:flutter_test/flutter_test.dart';
import 'package:tetris/features/leaderboard/data/leaderboard_dto.dart';
void main() {
group('Тестирование LeaderboardDto', () {
group('Тестирование функции fromJson', () {
test('Проверка с правильным входящим JSON', () {
// Подготовка
final json = {
'id': 1,
'username': 'testUser',
'score': 150,
};
// Действие
final dto = LeaderboardDto.fromJson(json);
// Проверка
expect(dto.id, 1);
expect(dto.username, 'testUser');
expect(dto.score, 150);
});
test('Проверка отсутствия поля [score] в json', () {
// Подготовка
final json = {
'id': 2,
'username': 'anotherUser',
};
// Действие
final dto = LeaderboardDto.fromJson(json);
// Проверка
expect(dto.score, 0);
});
test('Проверка, если id не типа int', () {
// Подготовка
final json = {
// Неправильный тип
'id': 'invalid_id',
'username': 'user',
'score': 100,
};
// Действие и проверка
// [ throwsA(isA<TypeError>())] — проверяет,
// выбрасывается ли ошибка типа TypeError
expect(
() = > LeaderboardDto.fromJson(json),
throwsA(
isA<TypeError>(),
));
});
test('Проверка, json пустой', () {
// Подготовка
final json = <String, dynamic>{};
628 Глава 7 Тестирование приложений
}
// Действие и проверка
// [ throwsA(isA<TypeError>())] — проверяет,
// выбрасывается ли ошибка типа TypeError
expect(
() = > LeaderboardDto.fromJson(json),
throwsA(
isA<TypeError>(),
));
});
});
});
Для проверки работоспособности реализованных тестов откройте терминал
и введите в него команду flutter test:
PS C:\Users\dev\tetris> flutter test
00:05 +4: All tests passed!
На следующем шаге покроем тестами методы toEntity() и toJson(). Для этого
в leaderboard_dto_test.dart добавим две новые группы:
/* base_url/7/tetris/test/features/leaderboard/leaderboard_dto_test.dart */
import 'package:flutter_test/flutter_test.dart';
import 'package:tetris/features/leaderboard/data/leaderboard_dto.dart';
Future<void> main() async {
group('Тестирование LeaderboardDto', () {
group('Тестирование функции toEntity', () {
test('Проверка преобразования DTO в сущность', () {
// Подготовка
final dto = LeaderboardDto(
id: 1,
username: 'testUser',
score: 150,
);
// Действие
final entity = dto.toEntity();
// Проверка
expect(entity.id, 1);
expect(entity.username, 'testUser');
expect(entity.score, 150);
});
});
group('Тестирование функции toJson', () {
test('Проверка преобразования DTO в JSON', () {
// Подготовка
final dto = LeaderboardDto(
id: 1,
username: 'testUser',
score: 150,
);
// Действие
final json = {
'id': dto.id,
'username': dto.username,
'score': dto.score,
};
Проект: игра «Тетрис» v. 7. Тестирование 629
// Проверка
expect(json['id'], 1);
expect(json['username'], 'testUser');
expect(json['score'], 150);
});
});
// Остальной код без изменений
}
}
В результате запуска тестов вы должны увидеть в терминале следующий результат:
PS C:\Users\dev\tetris> flutter test
00:01 +6: All tests passed!
Отлично! Все тесты проходят, а значит, можно переходить к тестовому покрытию класса LeaderboardEntity. Для этих целей в папке leaderboard создадим
новый файл — leaderboard_entity_test.dart. Так как у сущности нет никаких
преобразований, покроем тестами ее самый важный функционал — сравнение
объектов:
/* base_url/7/tetris/test/features/leaderboard/leaderboard_entity_test.dart */
import 'package:flutter_test/flutter_test.dart';
import 'package:tetris/features/leaderboard/domain/leaderboard_entity.dart';
Future<void> main() async {
group('Тестирование LeaderboardEntity', () {
test('Проверка правильного сравнения LeaderboardEntity', () {
// Подготовка и действие
// Создание двух одинаковых объектов LeaderboardEntity
const dto1 = LeaderboardEntity(
id: 1,
username: 'user',
score: 100,
);
const dto2 = LeaderboardEntity(
id: 1,
username: 'user',
score: 100,
);
// Проверка
expect(dto1, equals(dto2));
});
test('Проверка неправильного сравнения LeaderboardEntity', () {
// Подготовка и действие
// Создание двух разных объектов LeaderboardEntity
// с одинаковыми значениями
const dto1 = LeaderboardEntity(
id: 1,
username: 'user',
score: 100,
);
const dto2 = LeaderboardEntity(
id: 2,
username: 'user',
score: 100,
);
630 Глава 7 Тестирование приложений
}
// Проверка
expect(dto1, isNot(equals(dto2)));
});
});
Перейдем к более сложным тестам и с использованием ложного (фейкового)
репозитория и данных реализуем тестовое покрытие класса LeaderboardCubit.
Для начала создадим в папке leaderboard файл leaderboard_entity_test.dart со
следующим содержимым:
/* base_url/7/tetris/test/features/leaderboard/leaderboard_cubit_test.dart */
import
import
import
import
import
import
import
'package:flutter_test/flutter_test.dart';
'package:mockito/annotations.dart';
'package:mockito/mockito.dart';
'package:tetris/features/leaderboard/domain/i_leaderboard_repository.dart';
'package:tetris/features/leaderboard/domain/leaderboard_entity.dart';
'package:tetris/features/leaderboard/domain/state/leaderboard_cubit.dart';
'package:tetris/features/leaderboard/domain/state/leaderboard_state.dart';
import 'leaderboard_cubit_test.mocks.dart';
/// Фейковые данные для тестирования
final List<LeaderboardEntity> _fakeLeaderboard = [
LeaderboardEntity(id: 1, username: 'User1', score: 100),
LeaderboardEntity(id: 2, username: 'User2', score: 200),
LeaderboardEntity(id: 3, username: 'User3', score: 300),
];
/// Генерация моков (mock) для ILeaderboardRepository
@GenerateNiceMocks([MockSpec<ILeaderboardRepository>()])
Future<void> main() async {
group('Тестирование LeaderboardCubit', () {});
}
Благодаря аннотации @GenerateNiceMocks в процессе генерации кода будет создан
mock-репозиторий, реализующий интерфейс ILeaderboardRepository. Для этого
необходимо перейти в терминал и ввести следующую команду:
dart run build_runner build --delete-conflicting-outputs
После успешной генерации кода в папке leaderboard появится файл leaderboard_
cubit_test.mocks.dart с функционалом, имитирующим работу с репозиторием. Как
и в любых генерируемых файлах, в нем нельзя ничего менять.
Теперь вернемся к файлу leaderboard_entity_test.dart и добавим в функцию
main инициализацию объектов при старте каждого теста и освобождение ресурсов,
а также тесты для проверки начального состояния и получения таблицы лидеров.
В самих же тестах воспользуемся функциями when и verify, входящими в состав
библиотеки Mockito. Первая задействуется нами для имитации как успешного выполнения метода fetchLeaderboard(), так и возникновения в нем ошибки. А вторая
позволит убедиться, что fetchLeaderboard() был вызван один раз и успешно завершился:
Проект: игра «Тетрис» v. 7. Тестирование 631
/* base_url/7/tetris/test/features/leaderboard/leaderboard_cubit_test.dart */
// импорт без изменений
Future<void> main() async {
late ILeaderboardRepository repository;
late LeaderboardCubit cubit;
setUp(() {
// Инициализация mock-репозитория
repository = MockILeaderboardRepository();
// Инициализация кубита с mock-репозиторием
cubit = LeaderboardCubit(repository: repository);
});
tearDown(() {
// Освобождение ресурсов
cubit.dispose();
});
group('Тестирование LeaderboardCubit', () {
test('Проверка получения таблицы лидеров с ошибкой', () async {
// Проверка того, что состояние кубита — LeaderboardInitState
expect(cubit.stateNotifier.value, isA<LeaderboardInitState>());
// Имитация метода fetchLeaderboard с ошибкой
when(repository.fetchLeaderboard()).thenThrow(
Exception('Ошибка загрузки таблицы лидеров'),
);
/// Проверка того, что метод fetchLeaderboard
/// не был завершен успешно
verifyNever(repository.fetchLeaderboard());
// Запуск метода fetchLeaderboard
await cubit.fetchLeaderboard();
// Проверка того, что состояние кубита — LeaderboardErrorState
expect(
cubit.stateNotifier.value,
isA<LeaderboardErrorState>(),
);
});
test('Проверка успешного получения таблицы лидеров', () async {
// Проверка начального состояния
expect(cubit.stateNotifier.value, isA<LeaderboardInitState>());
// Имитация успешного получения таблицы лидеров
when(repository.fetchLeaderboard()).thenAnswer(
(_) async = > _fakeLeaderboard,
);
// Запуск метода
await cubit.fetchLeaderboard();
// Проверка того, что метод fetchLeaderboard был вызван один раз
verify(repository.fetchLeaderboard()).called(1);
// Проверка конечного состояния
expect(
cubit.stateNotifier.value,
isA<LeaderboardSuccessState>(),
);
632 Глава 7 Тестирование приложений
expect(
(cubit.stateNotifier.value as LeaderboardSuccessState
).leaderboard,
_fakeLeaderboard,
);
});
}
test('Проверка начального состояния LeaderboardCubit', () {
// Проверка начального состояния кубита
expect(cubit.stateNotifier.value, isA<LeaderboardInitState>());
});
});
Запустите все тесты и попробуйте насладиться зелеными галочкам, отобра
жаемыми на вкладке Testing на левой панели VS Code (рис. 7.17).
Рис. 7.17. Результат запуска тестов
Задания на модификацию проекта
В следующей главе мы займемся локализацией приложения на разные языки. А пока
можете выполнить задания по внесению изменений в существующую кодовую базу,
используя знания, полученные в этой главе.
1. Напишите тестовое окружение для фичи user.
2. Реализуйте виджет-тесты для главного экрана и экрана ввода имени пользователя.
3. Напишите интеграционный тест для приложения.
Вопросы для самопроверки 633
Резюме
В этой главе мы рассмотрели различные подходы к тестированию и поговорили
о том, как их можно реализовать средствами Flutter. Писать тесты или нет — сугубо
ваш выбор (или требование компании). Мы же рекомендуем не пренебрегать ими,
как минимум когда речь идет о программных продуктах с большой аудиторией
пользователей, тем более что в эру развития ИИ-ассистентов реализовать тестовое
покрытие для проекта — не такая уж большая проблема. Только, пожалуйста, не во
всем полагайтесь на этот инструмент и развивайте собственные навыки, чтобы уметь
не только адаптировать предлагаемый ассистентом код к кодовой базе проекта, но
и валидировать его.
Вопросы для самопроверки
1. Какие существуют виды тестирования? В чем их различие? Когда и какой
тип тестов лучше всего применять?
2. Что такое паттерн AAA? Из каких частей он состоит и за что отвечает каждая
из них?
3. Какие виды тестирования поддерживает Flutter?
4. Зачем нужны unit-тесты? Какой код они должны покрывать и как реализуются во Flutter?
5. Зачем нужны виджет-тесты? Какие их типы существуют? Какой код они
должны покрывать и как реализуются во Flutter?
6. Зачем нужны интеграционные тесты? Как они реализуются во Flutter?
Глава 8
ЛОКАЛИЗАЦИЯ ПРИЛОЖЕНИЯ
Рано или поздно после написания приложения, которое начнет пользоваться популярностью на локальном рынке, у вас могут зачесаться руки начать его экспансию
в другие страны, добавив поддержку разных языков. Либо вы можете сразу заложить
этот функционал в разрабатываемый программный продукт и распространять его
через такие магазины приложений, как RuStore, Google Play, App Store и т. д.
В этой главе мы рассмотрим процесс локализации Flutter-приложения с помощью библиотеки flutter_localizations. Настроим генерацию классов локализации на основе файлов переводов (ARB-файлов) и интегрируем их в следующий
пользовательский интерфейс (рис. 8.1).
Рис. 8.1. Пример финального приложения
Чтобы задействовать больше особенностей этой библиотеки локализации, будем
добавлять виджеты в приложение постепенно и растянем этот процесс на всю главу.
8.2. Подключение и настройка библиотеки flutter_localizations 635
8.1. Интернационализация vs локализация
Приложение, которое можно скачать из любой точки мира, должно предоставлять
возможность не только перейти на международный или родной язык пользователя,
но и учитывать культуру разных стран, а в некоторых случаях — и региональные
стандарты. При разработке программных продуктов за это отвечают интернационализация (internationalization, или i18n) и локализация (localization, или l10n).
Эти термины очень тесно связаны между собой и порой используются в одном
контексте — для обозначения поддержки приложением нескольких языков. Но все
не так просто, как может показаться на первый взгляд.
Интернационализация (i18n) представляет собой процесс проектирования и разработки приложения, который предусматривает архитектурные подходы для его
более легкой адаптации к различным языкам и странам без изменения исходного
кода. Одна из основных задач интернационализации — обеспечение разработчиков
всеми необходимыми для поддержки локализации механизмами.
Локализация (l10n) — процесс адаптации интернационализированного приложения под конкретные язык, культуру и региональные стандарты. Другими
словами, реализация интернационализации под определенный регион мира. Сюда
входят перевод текстов, форматирование чисел, времени, валют и другие аспекты,
связанные с культурными особенностями.
Что же касается главной цели, которую преследуют эти два процесса, тут все
просто — удобство и привлекательность продукта.
8.2. Подключение и настройка
библиотеки flutter_localizations
Создайте приложение app_localization, добавьте в папку lib выделенные файлы
и папку, а в корневую папку проекта — файл l10n.yaml, как сделано далее:
app_localization
├── .../
├── lib/
│
└── l10n/
│
│
├── app_en.arb
│
│
└── app_ru.arb
│
├── app_screen.dart.dart
│
└── main.dart
├── .../
├── l10n.yaml
└── pubspec.yaml
На следующем шаге откроем файл pubspec.yaml и добавим в него необходимые
зависимости (flutter_localizations и intl), а также флаг generate: true, что позволит при сборке проекта генерировать файлы локализации на основе ARB-файлов
(Application Resource Bundle). Библиотека flutter_localizations содержит готовые
636 Глава 8 Локализация приложения
переводы стандартных компонентов Flutter, а intl поможет форматировать даты,
числа и валюты в соответствии с региональными стандартами:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.19.0 # укажите актуальную версию
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
generate: true
В качестве первого функционала приложения реализуем смену локали. Для
этого нам не потребуется огромного количества виджетов, поэтому остановимся
на следующем варианте пользовательского интерфейса (рис. 8.2).
Рис. 8.2. Пример реализуемого приложения
Откройте файл l10n.yaml и добавьте в него следующую конфигурацию интернационализации проекта:
#l10n.yaml
# Папка с ARB-файлами
arb-dir: lib/l10n
# Шаблон ARB-файла, содержащий параметры с описанием строк
template-arb-file: app_en.arb
# Папка для генерируемых файлов
output-dir: lib/l10n/gen
8.2. Подключение и настройка библиотеки flutter_localizations 637
# Корневой генерируемый файл локализации, который
# будет использоваться в приложении
output-localization-file: app_localizations.dart
# Метод доступа к строкам посредством of(context) будет
# возвращать null-safety-строку (String, а не String?)
nullable-getter: false
# Классы, которые не будут генерироваться
# Данное ствойство локализатора скоро перестанет
# поддерживаться,поэтому стелем соломку заранее
synthetic-package: false
Далее займемся ARB-файлами. В app_en.arb будут храниться данные с их описанием, а в app_ru.arb ограничимся простой связкой «ключ:значение»:
// base_url/8/8.2/lib/l10n/app_en.arb
{
"@@locale": "en",
"title": "Pizza Details",
"@title": {
"description": "Title for the pizza details screen"
},
"pizzaName": "Pepperoni Pizza",
"@pizzaName": {
"description": "Name of the pizza"
},
"pizzaIngredients": "Ingredients: Pepperoni, Cheese, Tomato Sauce",
"@pizzaIngredients": {
"description": "List of pizza ingredients"
},
"orderPizzaButton": "Order 1 more pizza",
"@orderPizzaButton": {
"description": "Order additional pizzas"
}
}
// base_url/8/8.2/lib/l10n/app_ru.arb
{
"@@locale": "ru",
"title": "Детали пиццы",
"pizzaName": "Пицца Пеперони",
"pizzaIngredients": "Ингредиенты: пеперони, сыр, томатный соус",
"orderPizzaButton": "Заказать еще 1 пиццу"
}
Перед тем как перейти к компоновке виджетов, на основе сформированных
ARB-файлов необходимо сгенерировать Dart-код, который будет использоваться
для доступа к переведенным строкам. Для этого откройте терминал и введите в него
следующую команду:
PS C:\...\app_localization> flutter gen-l10n
Because l10n.yaml exists, the options defined there will be used instead. To use the
command line arguments, delete the l10n.yaml file in the Flutter project.
В результате работы генератора в подпапке gen папки l10n должны появиться
три файла с расширением .dart : app_localizations_en , app_localizations_ru
и app_localizations.
Теперь откроем main.dart и приступим к интеграции локализации в приложение. Для этого заведем переменную для хранения текущей локали и передадим
638 Глава 8 Локализация приложения
в аргумент localizationsDelegates виджета MaterialApp делегаты локализации
стандартных виджетов Flutter и AppLocalizations.delegate, сгенерированный на
предыдущем этапе. Смена локали будет осуществляться с вложенного экрана вызовом callback-функции onLocaleToggle:
// base_url/8/8.2/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'app_screen.dart';
import 'l10n/gen/app_localizations.dart';
void main() {
runApp(DemoApp());
}
class DemoApp extends StatefulWidget {
const DemoApp({super.key});
}
@override
State<DemoApp> createState() = > _DemoAppState();
class _DemoAppState extends State<DemoApp> {
Locale _locale = const Locale('en');
void _toggleLocale() {
setState(() {
_locale = _locale.languageCode = = 'en'
? const Locale('ru')
: const Locale('en');
});
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
onGenerateTitle: (context) = > AppLocalizations.of(context).title,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [ // поддерживаемые локали
Locale('en'),
Locale('ru'),
],
locale: _locale, // текущая локаль приложения
debugShowCheckedModeBanner: false,
home: DemoAppScreen(
onLocaleToggle: _toggleLocale, // смена локали
),
);
}
Последнее, что нам осталось, — реализовать виджет DemoAppScreen. Перейдите
в файл app_screen.dart.dart и добавьте в него следующий код:
// base_url/8/8.2/lib/app_screen.dart.dart
import 'package:flutter/material.dart';
import 'l10n/gen/app_localizations.dart';
8.2. Подключение и настройка библиотеки flutter_localizations 639
class DemoAppScreen extends StatefulWidget {
const DemoAppScreen({
super.key,
required this.onLocaleToggle,
});
final VoidCallback onLocaleToggle;
}
@override
State<DemoAppScreen> createState() = > _DemoAppScreenState();
class _DemoAppScreenState extends State<DemoAppScreen> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(localizations.title),
actions: [
IconButton(
icon: const Icon(Icons.language),
// Вызываем функцию переключения языка
onPressed: widget.onLocaleToggle,
),
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Детали пиццы
Text(
localizations.pizzaName,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Text(
localizations.pizzaIngredients,
style: const TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
Text(
_counter.toString(),
style: const TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
// Кнопка заказа пиццы
ElevatedButton(
onPressed: () {
640 Глава 8 Локализация приложения
),
],
// Обновляем счетчик заказанных пицц
_incrementCounter();
},
child: Text(localizations.orderPizzaButton),
),
),
),
}
}
);
8.3. Интерполяция строк (placeholder)
Вывод текста — это, конечно, хорошо, но всегда хочется чего-то большего! И первое,
что приходит на ум, — подставить введенное или сформированное в ходе работы
приложения значение в отображаемую на экране строку. Этот механизм и называется интерполяцией. На уровне библиотек локализации он реализуется посредством
добавления в метаданные строки (ключ повторяет имя, но начинается с символа @)
информации о подставляемом значении в части placeholders.
Чтобы продемонстрировать, как добавить интерполяцию в процесс локализации,
немного модифицируем приложение, предоставив пользователю возможность ввести свое имя и зафиксировать его в строке приветствия нажатием кнопки (рис. 8.3).
Рис. 8.3. Пример реализуемого приложения
Первым делом внесем изменения в ARB-файлы:
// base_url/8/8.3/lib/l10n/app_en.arb
{
"@@locale": "en",
8.3. Интерполяция строк (placeholder) 641
}
"nameHint": "Enter your name",
"@nameHint": {
"description": "Hint text for the name input field"
},
"hello": "Hello {userName}!",
"@hello": {
"description": "Simple hello greeting with user's name",
"placeholders": {
"userName": {
"type": "String",
"example": "John"
}
}
},
"send": "Send",
"@send": {
"description": "Text for send button"
},
"title": "Pizza Details",
// далее без изменений
// base_url/8/8.3/lib/l10n/app_ru.arb
{
"@@locale": "ru",
"nameHint": "Введите ваше имя",
"hello": "Привет, {userName}!",
"@hello": {
"placeholders": {
"userName": {
"type": "String",
"example": "Иван"
}
}
},
"send": "Отправить",
"title": "Детали пиццы",
// далее без изменений
}
Таким образом вы можете подставлять различные данные. Только убедитесь,
что имя заменителя, например {userName}, совпадает с именем ключа в области
placeholders, где происходит уточнение типа подставляемого в текст значения.
Теперь откройте файл app_screen.dart.dart и добавьте в класс _DemoAppScreenState следующую функциональность:
// base_url/8/8.3/lib/app_screen.dart.dart
class _DemoAppScreenState extends State<DemoAppScreen> {
final _nameController = TextEditingController();
String? _greeting;
int _counter = 0;
void _incrementCounter() {
// без изменений
}
// Освобождаем ресурсы
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
642 Глава 8 Локализация приложения
// Считываем введенные пользователем данные
// и обновляем текст приветствия
void _updateGreeting(AppLocalizations localizations) {
if (_nameController.text.trim().isNotEmpty) {
setState(() {
_greeting = localizations.hello(_nameController.text.trim());
});
}
}
// Для обновления текста приветствия
// в соответствии с изменением локали
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_greeting ! = null && _nameController.text.trim().isNotEmpty) {
_updateGreeting(AppLocalizations.of(context));
}
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(localizations.title),
actions: [
// без изменений
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
// Поле ввода имени
Expanded(
child: TextField(
controller: _nameController,
decoration: InputDecoration(
hintText: localizations.nameHint,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
// Кнопка отправки
ElevatedButton(
onPressed: () {
_updateGreeting(localizations);
},
child: Text(localizations.send),
),
],
),
// Отображение приветствия, если оно есть
if (_greeting ! = null) ...[
8.4. Плюрализация строк (plural) 643
const SizedBox(height: 16),
Text(
_greeting!,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
const SizedBox(height: 24),
// Детали пиццы
//
],
далее без изменений
),
),
),
}
}
);
8.4. Плюрализация строк (plural)
Если мы попробуем на основе интерполяции выводить количество заказанной
пиццы, случится конфуз, связанный с невозможностью изменения склонения
в зависимости от их количества (1 пицца, 2 пиццы, 5 пицц). Чтобы разработчики
не попадали в такие ситуации, существует механизм плюрализации строк. Для его
добавления используйте фигурные скобки в тексте локализации, указав в качестве
второго аргумента ключевое слово plural:
"orderPizza": "Здесь может быть текст {count, plural, =0{You haven't ordered any
pizzas yet} =1{You ordered 1 pizza} other{You ordered {count} pizzas}} здесь тоже
может быть текст",
Давайте подробнее рассмотрим параметры плюрализируемой строки:
y count — значение, передаваемое посредством механизма интерполяции;
y plural — тип выбора;
y =0{You haven't ordered any pizzas yet} — текст, который будет использоваться,
если не заказали ни одной пиццы;
y =1{You ordered 1 pizza} — текст, используемый при заказе одной пиццы;
y other{You ordered {count} pizzas} — текст, используемый при других значениях.
Помимо other, существуют параметры few и many. Первый обозначает категорию
«несколько». В русском языке это обычно от двух до четырех и все, что больше 20,
когда в разряде единиц стоят 2, 3 и 4. Второй параметр используется для больших
значений (≥ 5).
Для демонстрации того, как добавить плюрализацию строк в процесс локализации, немного модифицируем приложение, добавив склонения в текст кнопки
и количества заказанной пиццы (рис. 8.4).
644 Глава 8 Локализация приложения
Рис. 8.4. Пример реализуемого приложения
Начнем с внесения изменений в конец ARB-файлов:
// base_url/8/8.4/lib/l10n/app_en.arb
{
"@@locale": "en",
"nameHint": "Enter your name",
... , // без изменений
"orderPizzaButton": "{count, plural, =0{Order first pizza} other{Order 1 more
pizza}}",
"@orderPizzaButton": {
"description": "Order additional pizzas",
"placeholders": {
"count": {
"type": "int",
"example": "1"
}
}
},
"orderPizza": "{count, plural, =0{You haven't ordered any pizzas yet} =1{You
ordered 1 pizza} other{You ordered {count} pizzas}}",
"@orderPizza": {
"description": "Shows the number of ordered pizzas",
"placeholders": {
"count": {
"type": "int",
"example": "1"
}
}
}
}
// base_url/8/8.4/lib/l10n/app_ru.arb
{
"@@locale": "ru",
"nameHint": "Введите ваше имя",
... , // без изменений
"orderPizzaButton": "{count, plural, =0{Заказать первую пиццу}
other{Заказать еще 1 пиццу}}",
8.5. Еще один механизм плюрализации (select) 645
"@orderPizzaButton": {
"placeholders": {
"count": {
"type": "int",
"example": "1"
}
}
},
"orderPizza": "{count, plural, =0{Вы еще не заказали ни одной пиццы} =1{Вы
заказали 1 пиццу} few{Вы заказали {count} пиццы} other{Вы заказали {count} пицц}}",
"@orderPizza": {
"placeholders": {
"count": {
"type": "int",
"example": "1"
}
}
}
}
Теперь откройте файл app_screen.dart.dart и в классе
модифицируйте код последних двух виджетов в Column:
_DemoAppScreenState
// base_url/8/8.4/lib/app_screen.dart.dart
Text(
localizations.orderPizzaButton(_counter),
style: const TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
// Кнопка заказа пиццы
ElevatedButton(
onPressed: () {
// Обновляем счетчик заказанных пицц
_incrementCounter();
},
child: Text(localizations.orderPizza(_counter)),
),
8.5. Еще один механизм плюрализации (select)
Библиотеки локализации, помимо базового функционала плюрализации, предоставляют возможность явно указать, какой текст будет выводиться при подаче того
или иного значения плейсхолдера:
"pizzaSize": " Здесь может быть текст {size, select, mini{Mini} small{Small}
other{Unknown}} здесь тоже может быть текст ",
"@pizzaSize": {
"placeholders": {
"size": {
"type": "String"
}
}
}
Рассмотрим параметры плюрализируемой строки подробнее:
y size — значение, передаваемое посредством механизма интерполяции;
y select — тип выбора;
646 Глава 8 Локализация приложения
mini{Mini} small{Small} other{Unknown} — варианты строк, которые будут
использоваться для разных значений плейсхолдера size. При size=mini
вернется текст Mini и т. д.;
y other{Unknown} — обязательный параметр. Выведет строку, которая будет
использована в том случае, если ни одно из переданных значений size не подошло.
Для демонстрации работы механизма выбора немного модифицируем приложение, добавив в него возможность выбрать размер заказываемой пиццы (рис. 8.5).
y
Рис. 8.5. Пример реализуемого приложения
Начнем с того, что добавим в конец ARB-файлов следующие поля:
// base_url/8/8.5/lib/l10n/app_en.arb
{
"@@locale": "en",
"nameHint": "Enter your name",
... , // без изменений
"pizzaSizeLabel": "Select pizza size",
"@pizzaSizeLabel": {
"description": "Label for pizza size dropdown"
},
"selectedSize": "Selected pizza size - {size}",
"@selectedSize": {
"description": "Text showing the selected pizza size",
"placeholders": {
"size": {
"type": "String",
"example": "Medium"
}
}
},
8.5. Еще один механизм плюрализации (select) 647
}
"pizzaSize": "{size, select, mini{Mini} small{Small} medium{Medium}
large{Large} supersize{Supersize} other{Unknown}}",
"@pizzaSize": {
"description": "Pizza size selection options",
"placeholders": {
"size": {
"type": "String"
}
}
}
// base_url/8/8.5/lib/l10n/app_ru.arb
{
"@@locale": "ru",
"nameHint": "Введите ваше имя",
... , // без изменений
"pizzaSizeLabel": "Выберите размер пиццы",
"selectedSize": "Выбранный размер пиццы - {size}",
"@selectedSize": {
"placeholders": {
"size": {
"type": "String",
"example": "Средний"
}
}
},
"pizzaSize": "{size, select, mini{Мини} small{Маленький} medium{Средний}
large{Большой} supersize{Суперсайз} other{Неизвестный}}",
"@pizzaSize": {
"placeholders": {
"size": {
"type": "String"
}
}
}
}
На следующем шаге откройте файл app_screen.dart.dart и в класс _Demo
AppScreenState добавьте поле _selectedPizzaSize для хранения выбранного размера
пиццы:
// base_url/8/8.5/lib/app_screen.dart.dart
class _DemoAppScreenState extends State<DemoAppScreen> {
final _nameController = TextEditingController();
String? _greeting;
int _counter = 0;
String _selectedPizzaSize = 'medium'; // значение по умолчанию
}
...
После чего в конец списка дочерних элементов виджета
пару виджетов:
// base_url/8/8.5/lib/app_screen.dart.dart
const SizedBox(height: 24),
// Выбор размера пиццы
Text(
localizations.pizzaSizeLabel,
style: const TextStyle(fontSize: 18),
),
const SizedBox(height: 8),
DropdownButton<String>(
Column
добавьте еще
648 Глава 8 Локализация приложения
value: _selectedPizzaSize,
onChanged: (String? newValue) {
if (newValue ! = null) {
setState(() {
_selectedPizzaSize = newValue;
});
}
},
items: <String>['mini', 'small', 'medium', 'large', 'supersize']
.map<DropdownMenuItem<String>>(
(String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(localizations.pizzaSize(value)),
);
},
).toList(),
),
const SizedBox(height: 16),
// Отображение выбранного размера
Text(
localizations.selectedSize(
localizations.pizzaSize(_selectedPizzaSize),
),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
8.6. Форматирование даты и времени
Еще один вариант использования локализации — форматирование даты и времени.
За это в библиотеке intl отвечает класс DateFormat. Но мы подойдем к решению
этой задачи со стороны не написания кода, а конфигурирования ARB-файлов,
чтобы за нас отработала команда flutter gen-l10n. В качестве примера реализуем
приложение, где при нажатии кнопки будут получены текущие дата и время, а затем переданы через слой локализации виджетам Text (рис. 8.6).
Рис. 8.6. Пример реализуемого приложения
8.6. Форматирование даты и времени 649
Вся магия форматирования даты и времени сокрыта в конфигурации интерполируемого в строку значения (плейсхолдера). Существует множество поддерживаемых
«из коробки» форматов. Часть из них приведена в табл. 8.1.
Таблица 8.1. Поддерживаемые форматы преобразования даты и времени
Имя международного компонента для Юникода (ICU)
Скелет формата
DAY
d
NUM_MONTH
M
ABBR_MONTH
MMM
YEAR
y
YEAR_ABBR_MONTH
yMMM
HOUR24
H
MINUTE
m
SECOND
s
HOUR_MINUTE_SECOND
jms
С более полным списком поддерживаемых форматов отображения даты и времени вы можете ознакомиться в документации к классу DateFormat библиотеки
intl: https://api.flutter.dev/flutter/intl/DateFormat-class.html.
Для первого виджета Text используем следующий формат работы с экземпляром
класса DateTime:
// base_url/8/8.6/lib/l10n/app_en.arb
"dateFormat1": "Date: {date}",
"@dateFormat1": {
"description": "Simple date format",
"placeholders": {
"date": {
"type": "DateTime",
"format": "yMd"
}
}
},
Конфигурация плейсхолдера для второго виджета (Full date) будет выглядеть
так:
"placeholders": {
"date": {
"type": "DateTime",
"format": "yMMMMd"
}
}
Чтобы отобразить только время в часах и минутах, воспользуемся следующей
настройкой плейсхолдера:
"placeholders": {
"time": {
"type": "DateTime",
"format": "Hm"
}
}
650 Глава 8 Локализация приложения
Иногда может возникнуть ситуация, когда базовых возможностей форматирования окажется недостаточно. В этом случае вы можете на основе скелетной формы
обозначить собственный формат отображения, как сделано для вывода текущих
даты и времени для последнего виджета Text:
"dateTimeFormat": "Date and time: {dateTime}",
"@dateTimeFormat": {
"description": "Combined date and time format",
"placeholders": {
"dateTime": {
"type": "DateTime",
"format": "y/M/d H:mm",
"isCustomDateFormat": true
}
}
},
На последнем шаге запустите команду на генерацию кода слоя локализации из
ARB-файлов и перепишите класс _DemoAppScreenState в файле app_screen.dart.dart:
// base_url/8/8.6/lib/app_screen.dart.dart
class _DemoAppScreenState extends State<DemoAppScreen> {
DateTime? _currentDateTime;
void _updateDateTime() {
setState(() {
_currentDateTime = DateTime.now();
});
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(localizations.title),
actions: [
IconButton(
icon: const Icon(Icons.language),
onPressed: widget.onLocaleToggle,
),
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Обычный формат даты
Text(
_currentDateTime = = null
? 'Date not selected'
: localizations.dateFormat1(_currentDateTime!),
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: 16),
// Полный формат даты
Text(
_currentDateTime = = null
? 'Date not selected'
: localizations.dateFormat2(_currentDateTime!),
8.7. Форматирование чисел и данных о валюте 651
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: 16),
// Отображаем время
Text(
_currentDateTime = = null
? 'Date not selected'
: localizations.timeFormat(_currentDateTime!),
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: 16),
// Объединенный формат даты и времени
Text(
_currentDateTime = = null
? 'Date not selected'
: localizations.dateTimeFormat(_currentDateTime!),
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: 32),
// Кнопка для обновления даты и времени
ElevatedButton(
onPressed: _updateDateTime,
child: Text(localizations.updateDateTime),
),
],
),
),
),
}
}
);
8.7. Форматирование чисел и данных о валюте
За работу с числами и валютой в библиотеке intl отвечает класс NumberFormat.
Как и в предыдущем случае, мы не будем его использовать в коде, а пойдем путем
конфигурирования ARB-файла. В качестве примера реализуем приложение, где
при нажатии кнопки значения различного типа данных увеличиваются, а затем
передаются через слой локализации виджетам Text (рис. 8.7).
Рис. 8.7. Пример реализуемого приложения
652 Глава 8 Локализация приложения
Некоторые варианты форматирования чисел и валюты приведены в табл. 8.2.
Таблица 8.2. Поддерживаемые форматы преобразования даты и времени
Значение поля format в плейсхолдере/именованный конструктор NumberFormat Вывод для числа 1 500 000
1.5M
compact
1,500,000
decimalPattern
150,000,000%
percentPattern
USD1,500,000.00
currency
$1,200,000
simpleCurrency
С более полным списком поддерживаемых форматов отображения чисел и валюты вы можете ознакомиться в документации к классу NumberFormat библиотеки
intl: https://api.flutter.dev/flutter/intl/NumberFormat-class.html.
Для первого и второго виджетов Text используем следующий формат работы
с экземпляром целого числа:
// base_url/8/8.7/lib/l10n/app_en.arb
"formatInteger": "Integer: {number}",
"@formatInteger": {
"description": "Basic integer number format",
"placeholders": {
"number": {
"type": "int",
"format": "decimalPattern"
}
}
},
"formatDecimal": "Decimal: {number}",
"@formatDecimal": {
"description": "Decimal number format with 2 decimal places",
"placeholders": {
"number": {
"type": "double",
"format": "decimalPattern"
}
}
},
На третьем виджете Text должны отображаться проценты. Значит, в качестве
значения поля format укажем currency:
"formatCurrency": "Currency: {amount}",
"@formatCurrency": {
"description": "Currency format",
"placeholders": {
"amount": {
"type": "double",
"format": "currency"
}
}
},
Далее объявим форматирование для валюты:
"formatCurrency": "Currency: {amount}",
"@formatCurrency": {
"description": "Currency format",
8.7. Форматирование чисел и данных о валюте 653
},
"placeholders": {
"amount": {
"type": "double",
"format": "currency"
}
}
На последнем шаге запустите команду на генерацию кода слоя локализации из
ARB-файлов и перепишите класс _DemoAppScreenState в файле app_screen.dart.dart:
// base_url/8/8.7/lib/app_screen.dart.dart
class _DemoAppScreenState extends State<DemoAppScreen> {
// Стартовые значения
int _integerValue = 1234567;
double _decimalValue = 1234.56;
double _percentValue = 0.75;
double _currencyValue = 1234.56;
// Метод для обновления значений
void _updateValues() {
setState(() {
_integerValue = (_integerValue * 1.5).round();
_decimalValue = _decimalValue * 1.25;
//Между 0 и 1 (1 - 100%)
_percentValue = (_percentValue + 0.1) % 1.0;
_currencyValue = _currencyValue * 1.3;
});
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(localizations.title),
actions: [
IconButton(
icon: const Icon(Icons.language),
onPressed: widget.onLocaleToggle,
),
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Целое число
Text(
localizations.formatInteger(_integerValue),
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: 16),
// Дробное число
Text(
localizations.formatDecimal(_decimalValue),
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: 16),
654 Глава 8 Локализация приложения
// Проценты
Text(
localizations.formatPercent(_percentValue),
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: 16),
// Валюта
Text(
localizations.formatCurrency(_currencyValue),
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: 32),
// Кнопка для обновления значений
ElevatedButton(
onPressed: _updateValues,
child: Text(localizations.updateValues),
),
],
),
),
),
}
}
);
8.8. Локализация для iOS (продуктов Apple)
Apple была бы идеальной компанией, если бы не их политика относительно аксессуаров к продаваемой продукции, сумасшедшие цены за увеличенный объем
оперативной памяти или SSD и лоббирование своих программных продуктов
с затаскиванием всего и вся на MacOS. Вот и в этот раз они отличились: требуется
дополнительно настраивать локализацию, если необходимо, чтобы ваше приложение
собиралось под их продукты.
Чтобы решить эту проблему, просто откройте в проекте файл ios/Runner/
Info.plist в VS Code и добавьте внутрь его тегов <dict> </dict> перечисление
с поддерживаемыми приложением языками:
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ru</string>
</array>
8.9. Экранирование фигурных скобок
Иногда вам потребуется, чтобы в тексте оставались фигурные скобки, которые
в ARB-файлах мы используем для обозначения плейсхолдера. Чтобы их игнорировал механизм локализации, достаточно сделать две вещи:
y добавить флаг use-escaping: true в файл l10n.yaml;
y экранировать фигурные скобки одинарными кавычками: Привет! '{Oo}'
{userName}!
Проект: игра «Тетрис» v. 8. Локализация игры 655
Проект: игра «Тетрис» v. 8. Локализация игры
Настала пора применить полученные знания и локализовать разрабатываемую
игру. В качестве примера добавим выбор локали приложения на экране главного
меню и локализуем только элементы этого экрана. Остальные части вам придется
локализовать самостоятельно!
Для начала добавим в структуру проекта новый каталог — l10n и файл l10n.yaml:
app_localization
├── .../
├── lib/
│
└── l10n/
│
│
├── app_en.arb
│
│
└── app_ru.arb
│
├── app/
│
├── features/
│
└── main.dart
├── .../
├── l10n.yaml
└── pubspec.yaml
На следующем шаге откроем файл pubspec.yaml и добавим в него необходимые зависимости (flutter_localizations и intl), а также флаг generate: true,
что позволит при сборке проекта генерировать файлы локализации на основе
ARB-файлов:
# pubspec.yaml
environment:
sdk: "> =3.6.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
http: ^1.3.0
shared_preferences: ^2.5.3
flutter_localizations:
sdk: flutter
intl: ^0.19.0 # укажите актуальную версию
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
generate: true
Теперь откроем l10n.yaml и добавим в него конфигурацию интернационализации
проекта, как и в начале главы:
#l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-dir: lib/l10n/gen
output-localization-file: app_localizations.dart
nullable-getter: false
synthetic-package: false
656 Глава 8 Локализация приложения
Далее займемся ARB-файлами. В app_en.arb будут храниться строковые данные элементов главного экрана с описанием, а в app_ru.arb ограничимся простой
связкой «ключ — значение»:
// base_url/8/tetris/lib/l10n/app_en.arb
{
"@@locale": "en",
"startGame": "Start Game",
"@startGame": {
"description": "Label for the start game button"
},
"bestResults": "Best Results",
"@bestResults": {
"description": "Label for the best results button"
},
"settings": "Settings",
"@settings": {
"description": "Label for the settings button"
},
"language": "Language",
"@language": {
"description": "Label for language selection"
},
"english": "English",
"@english": {
"description": "Name of English language"
},
"russian": "Russian",
"@russian": {
"description": "Name of Russian language"
}
}
// base_url/8/tetris/lib/l10n/app_ru.arb
{
"@@locale": "ru",
"startGame": "Начать игру",
"bestResults": "Лучшие результаты",
"settings": "Настройки",
"language": "Язык",
"english": "Английский",
"russian": "Русский"
}
Перед тем как перейти к рефакторингу проекта, необходимо запустить генератор
кода. Для этого откроем терминал и введем в него следующую команду:
PS C:\...\app_localization> flutter gen-l10n
Because l10n.yaml exists, the options defined there will be used instead. To use the
command line arguments, delete the l10n.yaml file in the Flutter project.
В результате работы генератора в подпапке gen папки l10n должны появиться три файла с расширением .dart: app_localizations_en, app_localizations_ru
и app_localizations.
Теперь откроем main.dart и приступим к интеграции локализации в приложение. Для этого нам понадобится изменить StatelessWidget _MyApp на StatefulWidget
и реализовать в нем методы изменения, загрузки и сохранения данных используе
мого языка:
Проект: игра «Тетрис» v. 8. Локализация игры 657
// base_url/8/tetris/lib/main.dart
import 'package:flutter/material.dart';
import 'package:tetris/app/di/depends.dart';
import 'package:tetris/l10n/gen/app_localizations.dart';
import
import
import
import
import
import
'app/di/di_container.dart';
'features/leaderboard/presentation/leaderboard_screen.dart';
'features/user/presentation/user_screen.dart';
'features/game/game_over_screen.dart';
'features/game/game_screen.dart';
'features/main_menu/main_menu_screen.dart';
part 'app/game_router.dart';
void main() async {
// без изменений
}
/// Экран ошибки приложения
class AppError extends StatelessWidget {
// без изменений
}
class _MyApp extends StatefulWidget {
const _MyApp({required this.depends});
/// Передаем зависимости в приложение
/// и используем их в контейнере зависимостей
final Depends depends;
}
@override
State<_MyApp> createState() = > MyAppState();
class MyAppState extends State<_MyApp> {
Locale _locale = const Locale('ru');
@override
void initState() {
super.initState();
_loadLocale();
}
Future<void> _loadLocale() async {
final storage = widget.depends.storageService;
final localeCode = storage.getString('locale') ?? 'ru';
setState(() {
_locale = Locale(localeCode);
});
}
void setLocale(Locale locale) {
setState(() {
_locale = locale;
});
_saveLocale(locale.languageCode);
}
Future<void> _saveLocale(String localeCode) async {
final storage = widget.depends.storageService;
await storage.setString('locale', localeCode);
}
658 Глава 8 Локализация приложения
}
@override
Widget build(BuildContext context) {
return DiContainer(
depends: widget.depends,
child: MaterialApp(
debugShowCheckedModeBanner: false,
locale: _locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
initialRoute: GameRouter.initialRoute,
routes: GameRouter._appRoutes,
),
);
}
Последнее, что нам осталось, — добавить функционал переключения выбранного
языка в приложении, который будет искать MyAppState в дереве виджетов и вызовет
его метод setLocale, что приведет к перестроению экрана. Поэтому закатаем рукава
и займемся рефакторингом экрана главного меню в файле main_menu_screen.dart:
// base_url/8/tetris/lib/features/main_menu/main_menu_screen.dart
import 'package:flutter/material.dart';
import 'package:tetris/l10n/gen/app_localizations.dart';
import 'package:tetris/main.dart';
class MainMenuScreen extends StatelessWidget {
const MainMenuScreen({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
}
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: () {
// Переход на экран игры
Navigator.pushReplacementNamed(
context,
GameRouter.userRoute,
);
},
child: Text(l10n.startGame)),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Переход на экран ввода имени игрока
Navigator.pushNamed(
context,
GameRouter.leaderboardRoute,
);
},
child: Text(l10n.bestResults)),
SizedBox(height: 16),
// Добавляем переключатель языка в приложении
_buildLanguageSwitcher(context),
],
),
));
Проект: игра «Тетрис» v. 8. Локализация игры 659
Widget _buildLanguageSwitcher(BuildContext context) {
final l10n = AppLocalizations.of(context);
final currentLocale = Localizations.localeOf(context).languageCode;
}
return Column(
children: [
Text(
l10n.language,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
SizedBox(height: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Английский язык
_buildLanguageOption(
context: context,
languageCode: 'en',
languageName: l10n.english,
isSelected: currentLocale = = 'en',
),
SizedBox(width: 16),
// Русский язык
_buildLanguageOption(
context: context,
languageCode: 'ru',
languageName: l10n.russian,
isSelected: currentLocale = = 'ru',
),
],
),
],
);
/// Метод принимает на вход строковый код языка,
/// его имя и признак того, выбран ли язык.
/// Если язык выбран, то он будет отображаться
/// жирным шрифтом, а если нет — обычным
///
/// При нажатии кнопки с языком будет вызван
/// метод [MyAppState.setLocale] для изменения языка
Widget _buildLanguageOption({
required BuildContext context,
required String languageCode,
required String languageName,
required bool isSelected,
}) {
return GestureDetector(
onTap: () {
// Ищем ближайший к текущему контексту
// экземпляр класса [MyAppState] и вызываем у него
// метод [setLocale],чтобы изменить язык.
final state = context.findAncestorStateOfType<MyAppState>();
if (state ! = null) {
state.setLocale(Locale(languageCode));
}
},
child: Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
660 Глава 8 Локализация приложения
// Если язык выбран, то фон будет основного цвета, если нет — серого.
color: isSelected
? Theme.of(
context,
).primaryColor
: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Text(
languageName,
style: TextStyle(
color: isSelected ? Colors.white : Colors.black,
fontWeight: isSelected ?
FontWeight.bold : FontWeight.normal,
),
),
),
}
}
);
Готово! Запустите приложение и поиграйте с переключением его локали (рис. 8.8).
Рис. 8.8. Пример работы приложения
Задания на модификацию проекта
Игра «Тетрис» завершена и готова к сборке. Но прежде, чем вы доберетесь до соответствующей главы книги, выполните следующие задания по внесению изменений
в существующую кодовую базу, используя знания, полученные в этой главе.
1. Полностью локализуйте приложение.
2. Добавьте возможность менять язык приложения с экрана запущенной игры
при ее установке на паузу.
3. Перепишите сервис локального хранилища на работу с Drift. Добавьте возможность переключения между ним и реализованным ранее локальным хранилищем посредством пакета sqflite, с которым будет работать приложение.
4. Добавьте анимацию переходов между экранами.
Вопросы для самопроверки 661
Резюме
В данной главе мы рассмотрели, как с помощью библиотек flutter_localizations
и intl на шаг приблизить свое приложение к покорению мира! Сделав описанные
шаги и применяя полученные знания на практике, вы сможете создать приложение, адаптированное под множество языков и культурные особенности различных
регионов.
Благодаря механизму генерации кода слоя локализации из ARB-файлов можно
не переживать о том, что изначально не предусмотрели поддержку разных языков.
Да, придется немного пострадать: создать файлы перевода, интегрировать сгенерированный код в приложение, пару раз слетать туда и обратно на собственной тяге
на Татуин и много чего еще… Но это сторицей окупится легкостью добавления
поддержки нового языка при необходимости экспансии приложения в очередной
регион мира.
Вопросы для самопроверки
1. В чем отличие интернационализации от локализации?
2. Какие библиотеки используются во Flutter для локализации?
3. Что такое интерполяция и плюрализация? В чем их различие? Как они реа
лизуются средствами библиотек для локализации?
4. Как в процессе локализации форматировать числа и даты? Зачем это делать?
5. Как подключить локализацию для iOS-приложений, написанных на Flutter?
Глава 9
СБОРКА ПРИЛОЖЕНИЯ
Вот мы и добрались до последней главы книги, которая посвящена финальному
этапу разработки приложений на Flutter — сборке. Этот момент можно рассматривать как подведение итогов или ваш личный Magnum opus (лат. «великая работа»),
который рано или поздно настигает любого разработчика перед последующей отгрузкой приложения в какой-нибудь магазин приложений или файловое хранилище.
Но это действительно только для тех случаев, когда разработанное приложение
не представляет собой продукт с длительным циклом поддержки и постоянной
доработкой новых функций, ведь при таком раскладе сборка — просто еще одна
стадия итерационного процесса разработки программного продукта перед его доставкой конечному пользователю.
Flutter — мощный кросс-платформенный фреймворк, благодаря которому вы
можете собрать разработанное приложение под платформы Android, iOS, Web,
Windows, macOS, Linux и даже под экзотическую операционную систему «Аврора»
(искренне надеемся, что с каждым годом экзотического в ней будет все меньше).
Для начала мы детально разберем основные режимы сборки (build modes) Flutterприложений: debug (отладочный), release (релизный), profile (профилировочный).
Они помогают разработчикам на разных этапах создания приложения, от написания
кода до его финального развертывания. Каждый из них влияет на производительность, размер и возможности отладки приложения.
После знакомства с режимами сборки нас ждет увлекательное путешествие в мир
подписи собираемого приложения перед его выгрузкой в популярные магазины, например App Store или Google Play. Сам процесс подписи можно рассматривать как
некий контракт между вами и магазином приложений. Своей подписью вы гарантируете, что приложение валидно и не менялось после сборки — это легко проверяется.
Без этого шага у вас не будет возможности загрузить его в магазин приложений.
9.1. Режимы сборок
9.1.1. Debug — отладочный режим
Это самый популярный у программистов режим. Он используется на этапе разработки, когда вы создаете, тестируете и исправляете код. Без него вы бы не смогли написать более или менее сложное приложение, а любая попытка найти баг, не говоря
о его «плавающих» версиях, заканчивалась бы в кабинете психотерапевта. Данный
9.1. Режимы сборок 663
режим сборки предоставляет возможность останавливать код в любом месте выполнения приложения и смотреть, какие данные содержатся в переменных и как
протекает поток выполнения программы. А самое главное, он поддерживает такой
крутой механизм, как Hot Reload (горячая перезагрузка), который избавляет вас
от длительного ожидания пересборки приложения для тестирования внесенных
в код изменений в процессе его работы. Другими словами, Hot Reload — суперфишка
Flutter, позволяющая мгновенно внедрять изменения в код запущенного приложения
без изменения его состояния. Это равносильно замене двигателя в машине прямо
во время поездки на дачу — никаких остановок, только магия!
Далее представлен пример отладки приложения с помощью специальных точек
останова. Как видно из рис. 9.1, такой подход позволяет проверить, какие данные
хранятся в переменной data в текущий момент.
Рис. 9.1. Пример отладки с точками останова
В рассматриваемом режиме сборки используется JIT-компиляция (Just-In-Time).
Это предоставляет разработчиками доступ DevTools для отладки приложения
и делает возможным мгновенное применение изменений кодовой базы (горячая
перезагрузка). JIT-компиляция — это подход, при котором код компилируется во
время выполнения программы, а не заранее. В контексте Flutter это ключевая особенность debug-режима.
Что же касается Flutter DevTools, то это набор инструментов, представленный
в виде веб-приложения, которое запускается в браузере и подключается к запущенному Flutter-приложению. Этот инструмент предназначен для отладки, анализа
производительности и оптимизации приложений, работающих на разных платформах: мобильных (Android, iOS), настольных (Windows, macOS, Linux) и Web.
Помимо отладки, для проверки корректности работы кода вы можете использовать проверочные утверждения assert(), которые выбрасывают ошибки, если
выражение внутри них возвращает false:
void setCount(int value) {
assert(value > = 0, "Значение не может быть отрицательным");
// ...
}
664 Глава 9 Сборка приложения
А для вывода в терминал имеется специализированная функция debugPrint().
Это такая версия print(), которая, как и в случае с assert, работает только в debugрежиме:
void fetchData() {
// Сработает только в debug-режиме
debugPrint("Данные загружены", wrapWidth: 1024);
}
Из-за этой особенности assert и debugPrint не используйте в них вызовы методов экземпляров классов, которые запускают операции и возвращают значения.
Это может привести к тому, что часть функционала вашего приложения в других
версиях сборок будет работать некорректно, так как строчки кода с этими функция
ми компилятор будет пропускать, то есть они не попадут на исполнение.
Основные особенности рассматриваемого режима сборки приведены в табл. 9.1.
Таблица 9.1. Основные особенности сборки в debug-режиме
Особенность debug-режима
Отладочные средства
Описание
Поддерживает точки останова, пошаговое выполнение и просмотр переменных
в IDE, например Android Studio или VS Code.
Включает проверки типов, assert, логирование и другие инструменты, которые
упрощают поиск ошибок
Hot Reload и Hot Restart
Позволяет быстро обновлять код без полной пересборки (Hot Reload) или перезапускать приложение (Hot Restart)
Размер файла
APK- или IPA-файлы из-за отладочной информации занимают больше места
на диске
JIT-компиляция
Позволяет мгновенно применять изменения кодовой базы проекта к запущенному
приложению (горячая перезагрузка) и использовать DevTools для отладки
Динамическая оптимизация
JIT может оптимизировать часто используемые участки кода в процессе работы
(на основе профиля выполнения)
Не подходит для публикации при- Содержит много отладочной информации и работает медленно
ложения в магазинах
Запустить Flutter-приложение в debug-режиме можно посредством горячих
клавиш IDE (F5 для VS Code) либо введя в терминал команду:
flutter run -debug
или без указания режима запуска, так как флаг –debug используется по умолчанию:
flutter run
Следующая команда соберет APK-файл (установщик для Android) в debugрежиме:
flutter build apk --debug
9.1. Режимы сборок 665
Как вы, наверное, заметили в ходе изучения книги, при запуске приложения
в этом режиме сборки в правом верхнем
углу приложения появляется баннер Debug
(рис. 9.2).
Отключить его можно так:
Рис. 9.2. Пример баннера Debug
void main() {
// Отключаем баннер Debug
WidgetsApp.debugAllowBannerOverride = false;
runApp(MyApp());
}
или передав аргументу debugShowCheckedModeBanner виджета MaterialApp значение
false.
Еще одной особенностью debug-режима является инструмент отладки overlay
с отладочной информацией. Для его включения необходимо импортировать встроенную библиотеку rendering и в функции main() до момента запуска приложения
активировать специальный флаг (рис. 9.3):
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
debugPaintSizeEnabled = true;
runApp(const MyApp());
}
Рис. 9.3. Пример работы флага debugPaintSizeEnabled
666 Глава 9 Сборка приложения
Существующие флаги конфигурации этого режима отладки приложения и их
описание приведены в табл. 9.2.
Таблица 9.2. Настройки overlay
Флаг
debugPaintSizeEnabled
debugPaintBaselinesEnabled
debugPaintPointersEnabled
debugRepaintRainbowEnabled
debugPrintLayouts
Описание
Отображает контуры виджетов и их размеры. Это полезно при настройке и проверке размеров и размещении виджетов в UI
Рисует линии для текстовых элементов. Это полезно для точной настройки выравнивания текста и элементов в приложении
Мигает при нажатии на области виджетов. Это полезно для отладки областей нажатия, особенно для интерактивных элементов
Overlay с изменяющимися цветами при перерисовке слоев. Это полезно для анализа и оптимизации производительности перерисовки визуальных элементов
Логирует информацию о перерасчетах layout. Используется для локализации
и исправления частых перерасчетов layout в приложении
С более полным списком overlay-флагов и их возможностями вы можете ознакомиться в документации к библиотеке flutter/rendering.dart: https://api.flutter.dev/
flutter/rendering/#properties.
9.1.2. Profile — режим для анализа производительности
Данный режим необходим исключительно для анализа производительности приложения средствами DevTools. Важно отметить, что в profile-режиме приложение
собирается с помощью AOT-компиляции (Ahead-Of-Time), из-за чего не работают
такие дополнительные возможности debug-режима, как Hot Reload, но при этом
добавляются возможности анализа, например собираются метрики CPU, памяти,
времени рендеринга и обновления виджетов. Под AOT-компиляцией понимается
преобразование Dart-кода в нативный машинный код до запуска приложения.
Поскольку такой код оптимизирован под целевую платформу, под которую выполняется сборка, это улучшает производительность и уменьшает время запуска
вашего приложения.
Далее приведены доступные виды профилирования Flutter-приложений с использованием DevTools.
y Timeline Events. Записываются события рендеринга, работы Dart-кода
и анимации.
y Memory Profiler. Позволяет анализировать потребление памяти и искать
ее утечки.
y CPU Profiler. Замеряет нагрузку на процессор и ищет «тяжелые» функции,
которые дольше всего исполняются в приложении.
Для того чтобы собрать приложение или запустить его в profile-режиме, необходимо добавить соответствующий флаг:
flutter run --profile # Для запуска на подключенном устройстве
flutter build apk --profile # Сборка APK
9.1. Режимы сборок 667
Profile-режим — это мост между стадией разработки приложения и его релизом.
Он позволяет находить узкие места в производительности, анализировать потреб
ление памяти и тестировать приложение в условиях, близких к боевым.
9.1.3. Release — режим для публикации
Release — это финальная сборка приложения, оптимизированная для публикации
в такие магазины приложений, как Google Play, App Store, AppGallery (Huawei) и др.
Именно в этом режиме Flutter обеспечивает максимальную производительность,
минимальный размер приложения и безопасность кода.
В табл. 9.3 приведены его основные особенности.
Таблица 9.3. Основные особенности сборки в release-режиме
Особенность releaseрежима
Максимальная оптимизация
Отключенные отладочные
инструменты
Отсутствие отладочных инструментов
Уменьшенный размер сборки
Описание
Код Dart компилируется в нативный машинный код с помощью AOT-компиляции, что
уменьшает размер и улучшает производительность, которая стремится к нативной.
Код на Dart и размер приложения минифицируются (размер уменьшается удалением
отладочных и неиспользуемых символов)
Отсутствует горячая перезагрузка (Hot Reload). Удаляются вызовы print(), debugPrint(), assert(). Недоступен Flutter DevTools
В этом режиме нет поддержки отладки, профилирования или инструментов разработчика, например DevTools
Благодаря оптимизации и удалению отладочных данных размер APK (Android) или IPA
(iOS) меньше, чем в других режимах
Для того чтобы собрать приложение или запустить его в release-режиме, необходимо добавить соответствующий флаг:
flutter run --release # Для запуска на подключенном устройстве
flutter build apk --release # Сборка APK
Файл сборки, полученный после выполнения команды, ищите в папке build.
9.1.4. Основные различия release, profile и debug
В табл. 9.4 приведены основные различия рассмотренных вариантов сборки приложений.
Таблица 9.4. Основные различия release, profile и debug
Параметр
Компиляция
Логирование
Инструменты
Производительность
Release
AOT
Выключено
Выключено
Максимальная
Profile
AOT
Выключено
Инструменты для анализа
Высокая
Debug
JIT
Включено
Отладочные инструменты
Низкая
668 Глава 9 Сборка приложения
9.2. Подпись сборки под Android
Когда вы разрабатываете под Android или одну из операционных систем, которая
должна поддерживаться приложением, перед вами может встать дилемма двух стульев. А точнее, два варианта сборки приложения: abb и apk. То, какая между ними
разница и какой формат результирующего файла сборки лучше всего выбрать, мы
обсудим в следующих разделах.
9.2.1. Android APK
APK (Android Package Kit) — стандартный формат пакетов приложений для
Android, который используется для их установки на устройства пользователей.
Любой APK-файл содержит:
y скомпилированный код приложения (байт-код Java/Kotlin или нативный
y
y
y
y
код C/C++);
ресурсы приложения (изображения, файлы локализации, макеты интерфейсов и т. д.);
файл манифеста (AndroidManifest.xml), содержащий метаданные, разрешения
и конфигурацию приложения;
подпись разработчика (цифровая подпись, удостоверяющая авторство
и целостность APK);
полный набор сборок для различных архитектур, таких как armeabi-v7a (ARM,
32-разрядная версия), arm64-v8a (ARM, 64-разрядная версия) и x86-64
(x86, 64-разрядная версия). В связи с этим не рекомендуется публиковать
APK-сборку, так как размер приложения, который пользователь установит
себе на смартфон, будет больше.
APK-файлы могут устанавливаться напрямую на устройство пользователя
и распространяться различными способами: через Google Play, сторонние магазины
или сайт разработчика.
9.2.2. Android ABB
AAB (Android App Bundle) — современный формат, рекомендованный Google
для публикации приложений в Google Play и других магазинах — RuStore или
AppGallery (Huawei). Основное преимущество таких файлов сборки — оптимизация размера для конечного пользователя. Это достигается тем, что в AAB
реализована динамическая доставка ресурсов. В него так же, как и в APK-файл,
включается весь контент, но для распространения приложения магазин создает
оптимизированные APK под каждое конкретное устройство, учитывая архитектуру смартфона, язык, плотность пикселов и другие характеристики. Данный
подход позволяет значительно уменьшить размер скачиваемого пользователем
приложения.
9.2. Подпись сборки под Android 669
9.2.3. APK vs AAB
Чтобы сравнить эти два формата сборки под Android, воспользуемся табличным
представлением (табл. 9.5).
Таблица 9.5. Сравнение APK и AAB
Параметр
Формат
APK
Готовый установочный файл
AAB
Исходный комплект, из которого Play Store формирует оптимизированные APK
Размер приложения
Больше (включает все ресурсы Меньше (доставка ресурсов под устройство)
и архитектуры)
Поддержка динамических модулей Ограничена
Полная
Установка
Можно установить напрямую Только с помощью Google Play или специальных
инструментов (bundletool)
9.2.4. Процесс подписи release-сборки приложения
Чтобы подписать приложение, потребуется создать приватный и открытый ключи.
Но для того, чтобы не усложнять текст, будем использовать понятие «ключ загрузки».
Если у вас его еще нет, можете создать ключ загрузки с помощью Android Studio.
Для этого необходимо открыть Flutter-проект с помощью этой IDE, перейдя в нее
из VS Code (рис. 9.4).
Рис. 9.4. Открытие Flutter-проекта в Android Studio из VS Code
После того как проект откроется в Android Studio, подождите, пока система
сборки Gradle его проинициализирует, и по завершении этого процесса перейдите
в меню buildGenerate Signed App Bundle or APK (рис. 9.5).
670 Глава 9 Сборка приложения
Рис. 9.5. Переход к подписи приложения
На следующем шаге выберите, какой вариант сборки вам нужен (рис. 9.6).
Рис. 9.6. Выбор типа подписываемой сборки
Поскольку вы еще не создавали свое хранилище ключей, студия не предложит
вам его использовать. Поэтому создаем новое, нажав кнопку Create new (рис. 9.7).
Далее заполните форму по шаблону, указав:
y путь к месту сохранения хранилища;
y пароль для хранилища;
y alias и пароль для alias.
Поскольку при потере паролей вы не сможете восстановить доступ к хранилищу,
постарайтесь не доводить до такой ситуации! Вы также можете внести необязательную дополнительную информацию (рис. 9.8).
9.2. Подпись сборки под Android 671
Рис. 9.7. Создание хранилища ключей
Рис. 9.8. Параметры хранилища ключей
672 Глава 9 Сборка приложения
После заполнения полей ключа хранилища, спрятав за пазухой листок с паролем, нажмите кнопку OK. Это запустит процесс создания хранилища, по завершении которого вас перебросит на предыдущий экран с уже заполненными полями
(рис. 9.9).
Рис. 9.9. Создание хранилища ключей
Теперь выберите тип подписываемого режима сборки (рис. 9.10).
Рис. 9.10. Выбор типа подписываемого режима сборки
9.2. Подпись сборки под Android 673
Чтобы в последующем у вас была возможность подписывать сборки приложения
без запуска Android Studio, перенесите хранилище ключей в папку android Flutterпроекта (рис. 9.11).
Рис. 9.11. Перенос хранилища ключей
Далее в той же папке необходимо создать файл key.properties, указав в нем
данные для открытия хранилища и использования ключа загрузки (рис. 9.12).
Рис. 9.12. Конфигурация файла key.properties
674 Глава 9 Сборка приложения
На следующем шаге пропишите в файле системы сборки проекта <project>/
android/app/build.gradle.kts путь до хранилища ключей:
import java.util.Properties
import java.io.FileInputStream
plugins {
...
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
...
}
Единственное, что нам осталось, — добавить настройки подписи перед свойством
buildTypes внутри блока android:
android {
// ...
...
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now,
// so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
Вот и все! Теперь вы можете собрать подписанную сборку из Flutter-проекта.
Для этого сначала вызовите очистку проекта, чтобы система сборки Gradle заново
инициализировалась:
flutter clean
После чего соберите проект в release-режиме (результирующий файл ищите
в папке build). Система сборки Gradle автоматически извлечет ключ из хранилища
и подпишет им ваше приложение:
flutter build appbundle --release
Аналогичным образом подписываются приложения, собранные для RuStore
и AppGallery (Huawei).
9.3. Подпись сборки под iOS/MacOS 675
9.3. Подпись сборки под iOS/MacOS
Если вы думали, что компания Apple очень хорошо продумывает все детали, такие
как дизайн очередного iPhone или MacBook Air, для удобства разработки и подписи сборок приложений под свои платформы, то у нас плохие новости… Процесс
подписи сборки приложения под iO/MacOS кардинально отличается от такового
под Android. Он больше похож на конфигурацию приложения в Xcode. К сожалению, легальных способов подписать и развернуть приложения на iOS, не имея
MacBook, не существует, так как iOS — это проприетарная закрытая система. Возможно, в скором времени это изменится, но пока придется разжиться отдельным
устройством для этих целей.
К тому же для того, чтобы зарегистрировать приложение, вам необходимо:
y зарегистрировать уникальный идентификатор Bundle ID;
y создать приложение в App Store Connect.
9.3.1. Регистрация уникального идентификатора Bundle ID
В операционной системе iOS каждое приложение имеет уникальный идентификатор Bundle ID. Чтобы его зарегистрировать для своего приложения, выполните
следующие шаги.
1. Откройте страницу с идентификаторами приложений в своей учетной записи
разработчика: https://developer.apple.com/account/resources/certificates/list.
2. Нажмите кнопку +, чтобы создать новый идентификатор пакета.
3. Введите название приложения, выберите и введите явный идентификатор
приложения.
4. Выберите службы, которые использует ваше приложение, и нажмите Продолжить.
5. Для завершения регистрации Bundle ID на следующей странице подтвердите
данные и нажмите Зарегистрировать.
9.3.2. Создание приложения в App Store Connect
После регистрации Bundle ID необходимо создать приложение в App Store Connect.
Для этого выполните следующие шаги.
Зайдите в App Store Connect через браузер по следующему URL: https://
appstoreconnect.apple.com/.
1. На главной странице App Store Connect нажмите Мои приложения.
2. В верхнем левом углу страницы Мои приложения нажмите +, после чего выберите Создать новое приложение.
3. Заполните все поля в открывшейся форме. В разделе Платформы убедитесь,
что отмечена iOS.
4. Перейдите к информации о приложении и выберите Сведения о приложении
на боковой панели.
5. В разделе Общая информация выберите идентификатор пакета, который вы
зарегистрировали ранее.
676 Глава 9 Сборка приложения
9.3.3. Настройка проекта приложения в XCode перед релиз-сборкой
Прежде чем опубликовать свое приложение, необходимо в Xcode выполнить некоторые настройки проекта. Для этого откройте его в VS Code (рис. 9.13), выделите
папку ios, щелкнув на ней правой кнопкой мыши, и выберите Open in Xcode.
Рис. 9.13. Открытие Flutter-проекта в Xcode из VS Code
В открывшемся окне и будете настраивать проект (рис. 9.14).
Рис. 9.14. Главное окно настройки проекта в Xcode
9.3. Подпись сборки под iOS/MacOS 677
В разделе Identity (вкладка General) добавьте:
y
y
Display Name — название приложения;
Bundle Identifier — идентификатор приложения, который ранее зарегистриро-
вали в App Store Connect.
Далее перейдите на вкладку Signing & Capabilities.
y Automatically manage signing — включение автоматической подписи и подготовки
приложений. Xcode должен автоматически управлять процессом подписания и подготовки приложений. По умолчанию эта опция включена, чего
достаточно в большинстве случаев. При более сложном сценарии см. раздел
Руководство по подписанию кода.
y Team. Выберите команду, соответствующую вашей зарегистрированной учетной записи разработчика Apple. Если потребуется, выберите Добавить учетную
запись, после чего обновите этот параметр.
На последнем шаге перейдите на вкладку Build Settings и в разделе Deployment
укажите минимальную версию iOS, которую поддерживает приложение, — iOS
Deployment Target. Фреймворк Flutter поддерживает iOS, начиная с 12-й версии. Если
приложение или плагины содержат код Objective-C или Swift, который использует
API более новой версии операционной системы (iOS 12+), обновите этот параметр
до самой последней нужной версии.
9.3.4. Создание релизного архива
Для отправки приложения в AppStore Connect вам необходимо создать архив
сборки — файл с расширением ipa. Самый простой вариант получить его — ввести
в терминале VS Code команду:
flutter build ipa --release
Если же вы любите преодолевать трудности, откройте проект в Xcode и перей
дите в раздел ProductArchive (рис. 9.15).
Рис. 9.15. Запуск процесса сборки архива в Xcode
После успешной сборки вас перекинет на экран дистрибуции приложения
(рис. 9.16).
678 Глава 9 Сборка приложения
Рис. 9.16. Экран дистрибуции приложения
На следующем шаге выберите то, что вам нужно: валидацию или дистрибуцию
приложения. При дистрибуции в зависимости от текущей задачи отправьте сборку
в App Store Connect или в TestFlight для тестирования (рис. 9.17).
Рис. 9.17. Выбор варианта дистрибуции приложения
9.4. Подпись сборки под OC «Аврора»
Для операционной системы «Аврора» необходимо использовать установочные пакеты приложений, которые должны быть подписаны сертификатами. Эти
сертификаты позволяют идентифицировать поставщиков программного обеспечения и предохраняют мобильные устройства от установки нежелательных
приложений.
9.4. Подпись сборки под OC «Аврора» 679
Такой сертификат разработчика может быть выдан только один раз для одного
верифицированного аккаунта разработчика в RuStore.
9.4.1. Генерация файла-запроса на сертификат
Прежде чем запросить сертификат у RuStore, необходимо создать файл запроса, который включает в себя ключ для подписи. Сам ключ можно сгенерировать несколькими способами: используя криптографический алгоритм RSA или
ГОСТ 34.10–2012. Согласно документации для подписи пакетов рекомендуется
второй вариант. Но для начала убедитесь, что у вас установлен openssl, так как
приведенную далее команду необходимо ввести в терминал:
openssl genpkey \
-engine gost \
-algorithm gost2012_256 \
-pkeyopt paramset:A \
-aes256 \
-out {key}.pem
По мере выполнения команды будут запрашиваться пароли для шифрования
файлов с закрытыми ключами, а по завершении он сохранится в файл {key}.pem.
Если не хотите, чтобы ключ был зашифрован, удалите из предыдущей команды
флаг –gost89.
На следующем шаге в ту же папку, где был создан ключ, добавим файл {csr}.
conf с конфигурацией запроса:
[ req ]
distinguished_name = req_distinguished_name
prompt = no
string_mask = utf8only
x509_extensions = v3_usr
[ req_distinguished_name ]
CN = {COMMON_NAME}
[ v3_usr ]
basicConstraints=critical,CA:FALSE
keyUsage=digitalSignature
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid
Единственное, что вам нужно поменять в файле запроса, — название компании
в поле {COMMON_NAME}.
Для самой же генерации запроса введите в терминале следующую команду:
openssl req -new -utf8 -batch \
-config {csr}.conf \
-key {key}.pem \
-out {csr}.pem
Здесь:
y
y
y
{csr}.conf — путь к вашему файлу конфигурации;
{key}.pem — ключ;
out {csr}.pem — сгенерированный файл запроса.
680 Глава 9 Сборка приложения
9.4.2. Запрос сертификата
Запросить сам сертификат можно в консоли разработчика RuStore. Для этого
сделайте следующее.
1. Откройте RuStore Консоль.
2. Перейдите на вкладку Разработчик.
3. Выберите Сертификат для ОС Аврора в боковом меню.
4. Нажмите Подать заявку (рис. 9.18).
Рис. 9.18. Подача заявки на получение сертификата для ОС «Аврора»
В открывшемся окне необходимо прикрепить сформированный файл запроса
на создание сертификата, который после проверки RuStore будет отправлен на
указанный вами электронный адрес (рис. 9.19).
9.4.3. Сборка и подпись приложения
Для сборки Flutter-приложения под операционную систему «Аврора» вам понадобится специальная версия фреймворка Flutter, портированная под эту ОС
компанией «Открытая мобильная платформа». Получить ее можно в официальном
репозитории компании: https://gitlab.com/omprussia/flutter/flutter.
Для ее установки и настройки воспользуйтесь следующей документацией: https://
omprussia.gitlab.io/flutter/docs/start/install/.
Данная версия Flutter позволяет собрать приложение для следующих типов
ОС «Аврора»:
y aurora-arm — 32-разрядная система;
y aurora-arm64 — 64-разрядная система;
y aurora-x64 — сборка для эмулятора.
Чтобы запустить сборку проекта, перейдите в VS Code на вкладку терминала
и введите следующую команду:
flutter build aurora --target-platform
aurora-arm64
9.4. Подпись сборки под OC «Аврора» 681
Рис. 9.19. Подача заявки на получение сертификата для ОС «Аврора»
На следующем шаге с помощью утилиты rpmsign-external запустите процесс
подписи собранного ранее RPM-пакета, который будет располагаться в папке build:
rpmsign-external sign \
--key {key}.pem \
--cert {cert}.pem \
{package-name}.rpm
Здесь:
y
y
y
{key}.pem — ключ;
{cert}.pem — полученный сертификат;
{package-name}.rpm — путь к пакету, который надо подписать.
У вас также есть возможность проверить подпись:
rpmsign-external verify –r rootcacert-omp.pem {package_name}.rpm
и посмотреть важные атрибуты подписанного RPM-пакета:
rpmsign-external dump {package_name}.rpm
682 Глава 9 Сборка приложения
Резюме
В этой главе мы разобрались с тем, как выполнить полноценную сборку приложения
и какие в этом процессе существуют нюансы для различных операционных систем.
Если вы печетесь об удобстве пользования приложением, стабильности и скорости его работы, никогда не пренебрегайте profile-сборкой!
Последнее напутствие касается безопасности: не заливайте хранилище ключей
в репозиторий и не храните там пароли для ключей! Неприятно будет обнаружить,
что под вашим авторством в магазинах выходят приложения, срок жизни которых
из-за политики RuStore, Google или Apple крайне мал. Последующие проблемы
наподобие блокировки аккаунта или разговора со следователем того не стоят.
Вопросы для самопроверки
1. Что такое сборка приложения? Зачем ее делать?
2. Какие типы сборок приложения вы знаете? Чем они различаются?
3. Как подписать собираемое приложение под Android? Чем вариант сборки приложения ABB отличается от APK? Когда и какой лучше всего использовать?
4. Как подписать собираемое приложение под iOS/MacOS? Чем этот процесс
отличается от подписи сборки под Android?
5. Как подписать собираемое приложение под OC «Аврора»? При чем здесь
RuStore?
ЗАКЛЮЧЕНИЕ
Поздравляем! Вот вы и завершили курс по основам Flutter. Вами освоен довольно
большой объем материала и, надеемся, проведен не один час за выполнением заданий
по доработке сквозного проекта. Если до этого момента вы не засматривались на
лабораторный практикум, который в дополнение к книге разработан компаниями
Surf и Mad Brains, — сейчас самое время! Либо воплотите в жизнь планы по захвату
мира и разработайте свое первое приложение. Чем больше у вас практики и прокачки
навыка поиска информации для решения той или иной ситуации, сложившейся
в процессе разработки, тем быстрее вы растете как специалист и приобретаете навыки насмотренности и временной оценки задач.
Обычно у начинающих разработчиков после прочтения любой обучающей литературы возникает вопрос: «А что дальше?» Мы предлагаем вам больше узнать
о возможностях языка программирования Dart, взяв книгу Станислава Чернышева «Основы Dart» или подождать издания его следующей книги по Dart — «Dart
Concurrency». Дополнительно можете вступить в чат, ссылка на который приводится
далее, и задать интересующие вопросы как авторам, так и сформированному вокруг
нее сообществу.
Сейчас вы сделали первый шаг в изучении и использовании Flutter. Несмотря
на то что он самый важный, главное — не останавливайтесь и продолжайте творить!
Перед вами всего лишь приоткрылась дверь в мир кросс-платформенной разработки. Да, за ней вас ждут не только интересные вызовы, крутые проекты и т. д.,
но и такие нюансы, при постижении которых не раз придется сменить на кресле
антипригарное покрытие. Кто знает, может, из-за этих моментов мы в свое время
и влюбились в IT, а сейчас — ваша очередь. 😉
684 Заключение
И конечно же, присоединяйтесь к нам для формирования самого крутого Flutterсообщества в мире.
Мы в Telegram
https://t.me/FlutterBasics
ТГ-канал авторского коллектива книги «Основы Flutter», в котором публикуются новости
о книге и курсе на Stepik
Чат сообщества
https://t.me/+Q_otDObSvYMyMTgy
Если при чтении книги у вас появляются вопросы, их можно задать в нашем чате
в Telegram
Электронный курс на Stepik
https://stepik.org/a/197817
Курс «Основы Flutter» — электронная версия книги на платформе Stepik с тестами и интерактивными задачами на программирование (только Dart)
ТГ-канал Станислава Чернышева, где он делится своими мыслями о творящемся
в образовании, мире IT и Dart/Flutter.
https://t.me/madteacher_channel
ТГ-канал Юрия Петрова, где вы найдете все, что касается мобильной разработки
и Flutter.
https://t.me/mobile_developing
ТГ-канал Станислава Ильина: новости IT и Dart/Flutter, в частности о разработке проектов, работе, обучении и немного о жизни. Лютый замес пользы и кринжа.
https://t.me/frezycode
ТГ-канал Павла Гершевича посвящен не только разработке мобильных приложений
на Flutter — без нее никак, но и различным около-IT-темам и лайфстайлу.
https://t.me/ftl_notes
ЛАБОРАТОРНЫЕ ПРАКТИКУМЫ
К КНИГЕ
К книге прилагаются один лабораторный практикум по Dart от Станислава Чернышева и два лабораторных практикума по Flutter от компаний-партнеров Surf и Mad
Brains. Их прохождение позволит закрепить практические навыки разработки и даст
больший толчок в развитии, чем выполнение одного сквозного проекта по всему
тексту. К тому же наличие таких методических материалов к книге позволяет использовать ее как полноценный учебник в высшем и среднем профессиональном
образовании.
Лабораторный практикум по Dart
PDF-файл с лабораторным практикумом по Dart можно скачать с ресурсов Станислава Чернышева, перейдя по ссылке https://vk.cc/cKPiAE
или воспользовавшись QR-кодом.
686 Лабораторные практикумы к книге
Лабораторный практикум по Flutter
Далее приведены краткие описания лабораторных практикумов по Flutter от компаний-партнеров и ссылки на их расположение.
Компания Surf — ведущий разработчик мобильных приложений и сложных IT-систем
для крупного бизнеса. В Surf разрабатывают приложения на Android и iOS, а также
кросс-платформенные решения на Flutter. Для книги Surf разработали лабораторный практикум, который поможет специалистам и студентам погрузиться в технологию, отточить навыки и получить максимум пользы от теоретических знаний.
В Surf регулярно проходят обучающие мероприятия и стажерские программы, так
что лучших практикантов они будут рады видеть у себя в команде.
Лабораторный .
практикум от Surf: .
Канал, посвященный
Flutter-разработке:
https://github.com/surfstudio/
flutter-lab-practicum
https://t.me/surf_flutter
Аккредитованная IT-компания, входящая в топ-10 ведущих мобильных Flutterразработчиков России. Компания разрабатывает web-, mobile- и AI-решения для
крупнейших клиентов из E-commerce, Fintech, Telecom и Pharma.
Команда Mad Brains создала практикум, структура которого соответствует главам книги, дополняя ее и превращая в полноценный учебник. В рамках практикума
участникам предстоит выполнить девять лабораторных работ, каждая из которых
посвящена ключевым аспектам разработки на Flutter. Пройдя все задания, можно
приобрести базовые навыки программирования приложений и смело называть себя
начинающим Flutter-разработчиком.
Если вы хотите развиваться в сфере IT и работать над интересными проектами,
присоединяйтесь к команде Mad Brains!
Лабораторный практикум
от Mad Brains: .
https://github.com/MadBrains/
Mad-Flutter-Practicum
Присоединяйтесь .
к сообществу .
Flutter-разработчиков
Mad Flutter Fans: .
https://t.me/flutter_mad_fans
Эта книга издана при поддержке Friflex (ООО «Фрифлекс»).
Friflex
IТ-компания, разработчик цифровых продуктов.
Мы создаем передовые технологические решения для миллионов
людей и каждый день работаем с Flutter. Это фреймворк с открытым
исходным кодом, который уже стал одним из главных стандартов
кросс-платформенной мобильной разработки.
Команда «Фрифлекс» — активный участник Flutter-cooбщecтвa.
Мы стараемся разными способами вносить вклад в развитие фреймворка.
Например, открываем его для новых платформ (первыми портировали
Fluttеr-приложение на ОС «Аврора», выложили множество плагинов
в опенсорс), проводим конференцию CrossConf с большой программой
докладов по Flutter, где делимся практикой.
Еще наша команда разработки ведет популярный
телеграм-канал Flutter Friendly. Присоединяйтесь
к каналу Flutter Friendly по QR-коду.
А по промокоду FlutterBook для читателей будет
действовать скидка 10 % на билеты на CrossConf.
Flutter Friendly
Станислав Чернышев, Юрий Петров,
Станислав Ильин, Павел Гершевич
Основы Flutter
Руководитель дивизиона
Руководитель проекта
Ведущий редактор
Литературный редактор
Художественный редактор
Корректоры
Верстка
Ю. Сергиенко
Н. Михеева
Н. Гринчик
Н. Рощина
В. Мостипан
О. Андриевич, Е. Павлович
Г. Блинов
Изготовлено в России. Изготовитель: ООО «Прогресс книга». Место нахождения и фактический адрес:
194044, Россия, г. Санкт-Петербург, Б. Сампсониевский пр., д. 29А, пом. 52. Тел.: +78127037373.
Дата изготовления: 11.2025. Наименование: книжная продукция. Срок годности: не ограничен.
Налоговая льгота — общероссийский классификатор продукции ОК 034-2014, 58.11.12 — Книги печатные
профессиональные, технические и научные.
Импортер в Беларусь: ООО «ПИТЕР М», 220020, РБ, г. Минск, ул. Тимирязева, д. 121/3, к. 214, тел./факс: 208 80 01.
Подписано в печать 19.09.25. Формат 70×100/16. Бумага офсетная. Усл. п. л. 55,470. Тираж 700. Заказ 0000.