Иногда в приложении необходимо отобразить список c данными. Если это простой статический список, он редко вызывает сложности. Однако создание списков, содержимое которых должно меняться, часто приводит к ошибкам, сложным для выявления и отладки. В статье я хочу рассказать о своём опыте работы со списками и о том, как я реализую в них функции асинхронной загрузки, фильтрации, актуализации данных и другие. Статья написана в виде инструкции и ориентирована на Flutter-разработчиков с небольшим опытом, хотя, надеюсь, и опытным разработчикам она будет полезна. Для лучшего усвоения материала желательно знать основы реактивного программирования, иметь минимальный опыт работы с библиотекой rxdart, понимать, как работает ChangeNotifier. Взяв за основу приложение с примитивным списком, мы будем наращивать его функциональность, переходя от решения простых и частых задач к более сложным и редким. Чтобы уменьшить объём кода и упростить понимание темы, я не буду брать какой-то пример из жизни, а сконцентрируюсь на написании абстрактного приложения. Статья разбита на части таким образом, что по завершении прохождения очередной части вы будете иметь рабочее приложение. Из-за такого подхода читать статью можно не полностью, а лишь до момента с описанием решения проблемы, интересной вам. В начале каждой части статьи я буду размещать ссылку на код приложения, соответствующий данной части. Используемая терминология и обозначения Структура проекта Подготовка приложения Асинхронная загрузка данных Фильтр и сортировка Порционная загрузка данных Актуализация данных Работа со списком записей, которые ссылаются на другие записи Список из самостоятельно обновляемых записей Заключение Запись — объект, на основании которого будет формироваться отображение одной строки списка; Тизер — графическое представление записи списка; Кубит — объект, который всегда хранит актуальное состояние записи списка; Состояние списка — структура данных с содержимым списка и описанием, на какой стадии получения данных он находится; Источник данных — любое хранилище, из которого можно получить данные. Может быть как встроенная в язык программирования структура данных, так и любой сервис, отправляющий данные на запрос от клиента. База данных — в рамках статьи это переменная, которая хранит список с записями; // + — строка добавлена; // - — строка удалена; // * — строка изменена. models.dart — описание основных структур данных, которые используются в приложении; repository.dart — содержит источник для получения данных и взаимодействия с ними; list_state.dart — описание структуры состояния списка; list_controller.dart — контроллер управления списком; widgets\record_teaser.dart — виджет для представления записи списка (тизера); widgets\list_status_indicator.dart — виджет для представления состояния списка; main.dart — точка входа в приложение. Список файлов отсортирован в порядке зависимостей одних файлов от других. Другими словами, чем выше файл, тем меньше зависимостей он имеет от других файлов. Так же данный список иллюстрирует последовательность, в которой мы будем изменять файлы в каждой части статьи. Открыть код на GitHub Подготовим каркас для будущего приложения. В нём будет реализован бесконечный список без каких-либо данных. Выполним команду создания нового приложения. Изменим файл main.dart следующим образом: Созданное приложение имеет лишь один экран со списком. Виджет HomePage, конечно, можно было бы реализовать на базе StatelessWidget, однако в будущем нам понадобится именно StatefullWidget, поэтому оставим как есть. Открыть код на GitHub В приложении нечасто имеются данные, которые можно сразу отобразить в списке. Обычно данные нужно получить из некого источника и подготовить.  Одновременно с этим пользователя целесообразно информировать о процессе загрузки и возникающих ошибках. В этой части мы организуем эти процессы. Добавим необходимые пакеты в файл pubspec.yaml и в командной строке выполним flutter pub get. Описание кода: Предоставляет список английских слов для наполнения базы данных, с которой мы будем работать. Используется для получения доступа к контроллеру списка. Добавим класс для описания элемента списка. Создадим файл models.dart с таким содержимым: Пока что объекты этот класса будут хранить только слово для отображения в строках виджета ListView. Все операции над записями базы данных будут производиться через репозиторий. Его реализацию разместим в файле repository.dart. Описание кода: При создании объекта репозитория заполним private-переменную _store (условную базу данных) записями со случайными словами. Чтобы всегда работать с одним и тем же источником данных, сделаем так, чтобы класс репозитория был синглтоном, то есть мог иметь только один экземпляр. Функция, к которой будем обращаться для получения записей из базы данных. В реальном проекте в коде этой функции должно происходить обращение к «настоящей» базе данных или http-запрос. Строка добавляет имитацию задержки при получении данных. Для создания класса, ответственного за хранение состояния списка, создадим файл list_state.dart со следующим содержимым: Описание кода: Строки служат для указания текущего действия над списком. Строка добавлена лишь для более понятного и эстетичного наименования параметров конструктора класса. Код "отлавливания" невозможных состояний списка. Предназначен для упрощения отладки. Переменная recordsStore хранит список записей для отображения на экране. Возможность хранения значения null сделано для того, чтобы различать причины, по которым список записей пуст. Если переменная имеет значение null, значит список пуст, так как ещё не было успешных обращений к MockRepository на получение записей. Если же в переменной сохранён пустой список, значит такое обращение было, однако MockRepository не вернул ни одной записи. Для более удобного различия этих состояний используется переменная isInitialized. Параметр records всегда возвращает список, вне зависимости от того, сохранено ли значение null в recordsStore. Этот параметр удобно использовать, чтобы избежать проверки значения recordsStore на null, при получении списка записей. Параметр hasError удобно использовать, когда надо проверить, произошла ли ошибка в ходе получения записей из репозитория. Для этих целей можно было бы анализировать переменную error, однако в ходе разработки приложения способ хранения ошибки в состоянии списка может измениться и тогда придётся переписывать некоторую часть кода. Дальнейшее использование этого параметра также приведёт к более краткому и понятному коду. Параметр isLoading сигнализирует о том, происходит ли загрузка списка в данный момент. Этот параметр будет использоваться для определения необходимости отображения индикатора загрузки списка, а также для того, чтобы избежать параллельного выполнения функции получения данных списка из репозитория несколько раз. Так как объект класса ListState неизменяемый, функция copyWith добавляет возможность копирования этого объекта с нужными изменениями. На базе ValueNotifier создадим контроллер управления списком. Он будет изменять состояние списка ListState, сохранённое в переменной value. Вместо ValueNotifier можно использовать и любой другой механизм для управления состоянием, например, Bloc. Создадим файл list_controller.dart с таким содержимым: Описание кода: Вызов super устанавливает исходное состояние списка. При создании контроллера инициируем процесс получения содержимого списка. Функция получения данных, которыми будет заполняться список. Не получаем данные напрямую из репозитория, так как кроме самих данных иногда требуется определять дополнительных характеристики этих данных, например, является ли возвращаемый результат окончанием списка (в случае, если получаем список частями). Функция, ответственная за загрузку данных для списка и изменения состояния списка в процессе загрузки. Игнорируем новые запросы на загрузку списка, пока предыдущий запрос не завершён. В состоянии списка указываем, что список в процессе загрузки. Сбрасываем описание ошибки, если такая имелась после предыдущей загрузки. Если получение записей прошло успешно, добавляем их в состояние списка. Также в состоянии списка указываем, что загрузка списка завершена. Если что-то пошло не так, добавляем в состояние списка сообщение об ошибке. Функция будет использоваться для повторной попытки получения списка записей, если во время предыдущей попытки возникла ошибка. Не используем функцию loadRecords, так как в следующих частях статьи функция repeatQuery будет дорабатываться. Добавим виджет для отображения текущего состояния списка ListState. Дополнительно этот виджет может принимать указатель на функцию onRepeat. Если этот указатель установлен, то в случае ошибки загрузки списка, виджет отобразит кнопку повторной загрузки. Создадим файл widgets/list_status_indicator.dart с таким содержимым: Описание кода: Так как этот виджет будет отображаться в дополнительной строке списка ListView, необходимо определять, есть ли в ней необходимость и будет ли данный виджет что-то отображать. Это делается при помощи функции hasStatus, которая сообщает о целесообразности использования этого виджета. Внесём изменения в файл main.dart. Так как изменений много, привожу его полное содержимое: Описание кода: Виджет создаёт экземпляр класса ListController и предоставляет доступ к нему в других виджетах, которые находятся ниже по иерархии. Наблюдаем за изменением состояния контроллера ListController. Если его состояние изменилось (было установлено новое значение value и, следовательно, вызвана функция notifyListeners()), то произойдёт новый вызов функции build. Через переменную listController мы получаем доступ к этому контроллеру. В переменную listState помещаем актуальное состояние контроллера для формирования более читаемого кода далее. Виджет состояния списка ListStatusIndicator отображается дополнительной строкой в виджете списка ListView. Однако нет смысла отображать состояние списка постоянно. В зависимости от результата выполнения hasStatus определяем, нужно ли отображать виджет состояние списка и, следовательно, нужно ли резервировать для него дополнительную строку. Конечно, можно было бы отображать виджет состояния и поверх виджета ListView, но выбранный подход более удобен, когда мы чуть позже будем реализовывать загрузку данных списка частями. Если необходимо, отображаем виджет состояния списка в последней строке виджета ListView. Если во время загрузки списка произошла ошибка, отобразится виджет состояния списка с кнопкой обновления. После нажатия на эту кнопку вызовется метод repeatQuery контроллера, который повторит последний запрос. Получаем данные, необходимые для отображения соответствующей строки списка, и формируем эту строку. Открыть код на GitHub В этой части мы добавим возможности фильтрации и сортировки данных в списке. Фильтрацию будем делать на основании наличия подстроки поиска в заголовке записи, а сортировку — на основании значения веса записи. Добавим в модель записи списка ExampleRecord переменную weight. Эта переменная будет обозначать порядок следования записей списка. Также создадим класс ExampleRecordQuery, ответственный за фильтрацию элементов списка по подстроке в title и его сортировку по значению переменной weight. Описание кода: Переменная для хранения строки поиска. Функция проверки соответствия объекта obj условиями фильтрации. Функция сравнения и определения порядка следования записей record1 и record2. В класс репозитория MockRepository добавим возможность фильтрации и сортировки записей из базы данных. Описание кода: Установим вес записи, кратный 10, чтобы при создании записей, была возможность их вставки между другими записями. Перемешаем список, чтобы убедиться в работоспособности сортировки. Чтобы не модифицировать базу записей, создадим её копию. Если есть необходимость, выполняем сортировку списка. В реальном проекте вместо вызова функции sortedList.sort должно происходить преобразование объекта ExampleRecordQuery в формат, принимаемый источником данных, будь то SQL ORDER BY или параметр GET-запроса. Если есть необходимость, фильтруем список, чтобы вернуть только те записи, которые удовлетворяют запросу. В реальном проекте вместо вызова функции query.suits  должно происходить преобразование объекта ExampleRecordQuery в формат, принимаемый источником данных, будь то SQL WHERE-условие или HTTP GET-запрос. Далее модифицируем контроллер списка так, чтобы он мог работать с объектом запроса ExampleRecordQuery. Для удобства вынесем формирование элемента списка в отдельный виджет. Чтобы визуально контролировать правильность сортировки, добавим отображение в виджете веса записи. Создадим файл widgets/record_teaser.dart с таким содержимым: В строке создания контроллера файла main.dart укажем условия фильтрации записей. Добавим, например, что в списке должны быть только записи, содержащие подстроку "ea". Изменим функцию build файла main.dart таким образом, чтобы для формирования элемента списка использовался только что созданный нами виджет RecordTeaser. Открыть код на GitHub Когда данных для отображения много, нет смысла загружать их все сразу. В этой части мы добавим возможность загрузки новых данных по мере прокрутки списка пользователем. Так как при загрузке списка частями в запросе на получение данных нужно будет указывать, какой вес записей должен быть у очередной страницы данных, в класс ExampleRecordQuery добавим переменную weightGt (в другом проекте это может быть переменная, например, с номером страницы page). Так же нам понадобится функция копирования объекта запроса, но с изменёнными параметрами. В начале файла repository.dart добавим константу, определяющую максимальное количество записей, возвращаемых функцией queryRecords за раз. Последнюю строку функции queryRecords надо заменить на: Описание кода: Раскомментировать строку можно для проверки того, как реагирует интерфейс программы, если возникла ошибка на стадии получения данных. В этом случае, когда мы пролистаем список до записи с весом 400, должно появиться сообщение об ошибке с кнопкой повторения получения результатов. Приступаем к модификации контроллера списка ListController из файла list_controller.dart. Изменений много, поэтому снова приведу всё содержимое файла. Описание кода: Для того, чтобы функция _fetchRecords могла возвращать не только список записей, но ещё и информацию о том, является ли возвращаемый результат окончанием списка, используем в качестве возвращаемого значения этой функции объект класса _FetchRecordsResult. Функция будет вызываться, когда необходимо получить следующую страницу записей списка. Функция формирует объект запроса для получения следующей страницы списка. Дополнительная проверка на уместность вызова функции getNextRecordsQuery. Нет смысла получать следующую страницу списка, если он пуст. Модифицируем запрос таким образом, чтобы функция queryRecords вернула данные, у которых значение параметра, по которому идёт сортировка, было больше, чем у такого же параметра последней записи текущего списка. Во время повторной попытки получения данных определяем способ, которым они получались в предыдущий раз. Делается это на основании того, есть ли записи в текущем состоянии списка. Альтернативный подход — сохранять последний выполненный запрос ExampleRecordQuery в состоянии списка ListState. Переходим к модификации файла main.dart. Так как нам понадобится функция определения большего из двух чисел, подключим библиотеку import 'dart:math'. Чтобы увеличить количество строк для отображения в списке, уберём фильтр записей по подстроке: Модифицируем класс _HomePageState до вида: Описание кода: Создаём контроллер, чтобы была возможность отслеживать прокрутку списка. Переменная определяет отступ от конца списка. Когда пользователь прокручивает список до этой позиции, необходимо загружать следующую страницу. Используется лишь для определения направления прокрутки списка: вверх или вниз. Эта функция вызывается каждый раз, когда пользователь делает прокрутку списка. Функция инициирует загрузку очередной страницы данных, когда список докрутили до позиции loadExtent с конца. Открыть код на GitHub Так как данные, которые отображаются в списке, со временем могут устаревать, необходимо добавить возможность обновления этих данных. При этом не эффективно каждый раз запрашивать у источника данных все записи для формирования списка. В этой части мы добавим отслеживание изменения данных и обновление только тех строк списка, данные в которых изменились. Добавим необходимые пакеты в файл pubspec.yaml и в командной строке выполним flutter pub get. Описание кода: Будет использоваться для обработки потока событий над объектами базы данных. Для уменьшения объема кода, из этой библиотеки будем использовать функцию поиска элемента в списке firstWhereOrNull. Заменим содержимое файла models.dart. Описание кода: Определяет тип идентификатора записи. Это сделано через typedef, чтобы: в случае необходимости можно было просто поменять тип идентификатора на другой, без необходимости корректировки типа переменной во всех местах кода, в которых используется идентификатор; упростить понимание целей, для которых будут использоваться переменные этого типа. Классы для описания действий, производимых над записями в источнике данных. Классы для описания исключений, возникших при взаимодействии с источником данных. В файле repository.dart подключим библиотеку collection. Из неё нам понадобится функция фильтрации списка firstWhereOrNull. Для отправки уведомлений о действиях над записями из хранилища данных _store в другие части приложения будет использоваться StreamController, поэтому подключим библиотеку dart:async и создадим контроллер и поток поток уведомлений о событиях в классе MockRepository.  В код, который наполняет базу записей, добавим строку присвоения идентификатора для записи. В этот же класс добавим функцию получения записей по их идентификаторам. Так же добавим функции создания, редактирования и удаления записей из базы данных. Описание кода: Вес записи должен быть уникальным, чтобы не было неоднозначных ситуаций с сортировкой списка. В противном случае функция вызовет исключение WeightDuplicate. Определение максимального идентификатора записи в базе данных. Если же все записи из базы данных были удалены, считаем, что максимальный идентификатор равняется 0. Новой записи присваиваем идентификатор на единицу больше максимального. Добавление записи в базу данных. Находим существующую запись в базе данных. Если же записи нет, вызываем исключение RecordDoesNotExist. Информируем подписчиков потока событий о произведенном действии над записью базы данных. В файл list_controller.dart добавим необходимые зависимости. В этот же файл добавим классы, которые будут использоваться для передачи данных между разными частями контроллера. Класс RecordsUpdates нужен для передачи записей, над которыми были произведены действия. Класс _ListChange содержит записи, которые необходимо удалить из списка, а также которые нужно добавить в него, чтобы список стал актуальным. Так как контроллер списка во время создания будет подписываться на события изменения записей, добавим в контроллер механизм отмены этой подписки, если контроллер больше не используется. В конструктор класса ListController после строки loadRecords(query) добавим подписку на события: Описание кода: Игнорируем все события до момента пока не произведена первоначальная загрузка списка. От потока rawEvents уведомления о событиях приходят одно за другим. Если каждое уведомление обрабатывать индивидуально, это будет дополнительно нагружать источник данных и добавит задержки обновления списка. От этого он может обновляться построчно. В особой степени это может проявиться при удалённом нахождении источника данных. Простым решением этой проблемы является использование буфера, который будет собирать все события в течение определенного времени, что позволит обрабатывать изменённые записи не по одной, а группами. Из-за несовпадении времени буферизации событий с реальным временем поступления событий, некоторая задержка с отображением результата всё равно останется. Для её исключения период группировки событий можно определять не временным интервалом, а, например, дополнительными уведомлениями о том, что действие, влияющее на содержимое списка, завершено. Прекращаем дальнейшую обработку данных, если за время сбора данных буфера не произошло ни одного события. На основании пришедших уведомлениях о событиях получаем записи, которые были созданы или изменены. Для оптимизации, сначала собираем все идентификаторы записей, которые необходимо получить из репозитория. Затем получаем эти записи одним запросом. Для удаления записей из списка достаточно лишь их идентификаторов. Поэтому просто создаём список с идентификаторами записей на основании пришедших уведомлениях о событиях. Структуру с информацией о созданных, изменённых и удалённых записях, сформированную в прошлом шаге, преобразуем в структуру, которая содержит списки записей для добавления и удаления из списка. Если результат работы прошлой функции не содержит записей для добавления или удаления, прекращаем процесс обработки пришедших уведомлениях о событиях. На основании списков записей, которые надо добавить и убрать, формируем новый актуальный список записей. Выполняем сортировку нового списка. Сортировка не была добавлена в предыдущий шаг, так как иногда процесс сортировки списка может быть асинхронным. В таком случае необходимо использовать обработчик *asyncMap* вместо *map*. Обновляем состояние списка новыми записями. У нас осталась нереализованная функция _filterRecords. Для её реализации понадобится другая функция, которая будет отвечать за проверку, может ли запись находиться в списке. Если список загружен полностью, решение принимается объектом query. Если же список полностью не загружен, то необходимо создать новый query с дополнительным ограничением на вхождение записи в текущий интервал загруженных записей. Теперь создадим функцию  _filterRecords, которая на основании изменений, произошедших с записями, будет формировать объект _ListChange со списками записей, которые нужно добавить или удалить из результирующего списка. Для проверки работы списка нужно добавить возможность совершения операций создания, изменения и удаления записей. Для упрощения поднимания и уменьшения количества кода я предлагаю на тизерах записей добавить три кнопки: Кнопка создания записи будет создавать запись с новым словом и весом на 1 больше, чем вес записи, на кнопку которой было нажатие. Кнопка изменения записи будет увеличивать вес соответствующей записи на 1. Кнопка удаления будет удалять текущую запись. В файл record_teaser.dart подключим необходимые библиотеки: Теперь в класс RecordTeaser добавим функции операций над записями: Добавим отображение кнопок в тизере записи: Иногда записи списка могут иметь поля, для получения значений которых необходимы дополнительные запросы. Если эти поля используются для решения о необходимости включения записи в список, то их нужно получать на этапе формирования объекта RecordsUpdates. Однако если эти поля не участвуют в принятии решения на включение записи в список и мы не хотим запрашивать у источника данных избыточную информацию, то значения этих полей должны получаться на этапе, который следует уже после вызова функции _filterRecords. Но в таком случае за время, пока происходит получение значений связных полей, основное содержимое списка может полностью обновиться и результат актуализации данных списка будет не соответствовать новому содержимому списка. Это может привести к дублированию записей. Чтобы этого избежать, необходимо предотвратить параллельное исполнение функции получения данных списка и функции получения связных полей. Это будем делать с помощью библиотеки synchronized. В следующем примере мы будем работать с записями списка, которые содержат параметр isFavourite. Для получения значения этого параметра приложение будет производить отдельный запрос к источнику данных. Открыть код на GitHub Добавим необходимый пакет в файл pubspec.yaml и в командной строке выполним flutter pub get. В файл models.dart добавим модель нового типа записи. Описание кода: Переменная хранит объект базовой записи, с которой мы работали ранее. Чтобы избежать дублирование определения типов параметров класса, используется методика комбинирования объектов класса вместо наследования. Такой подход особенно удобен при работе с классами, содержащими множество переменных. Для того, чтобы не пришлось адаптировать уже написанный в нашем приложении код для работы с новой моделью, делаем новую модель совместимой с ExampleRecord. Это сделано только для удобства в рамках данной статьи и в реальном проекте не обязательно. В класс MockRepository добавим переменную, содержащую идентификаторы записей, которые являются избранными. В этот же класс добавим функции получения данных из только что созданной переменной. Описание кода: Функция возвращает, какие из идентификаторов списка idsToCheck находятся в избранном. Функция преобразует список записей ExampleRecord в ExtendedExampleRecord. Для того, чтобы получать необходимые нам данные минимальным количеством обращений к источнику данных, сначала получаем значения isFavourite для всех записей, а потом их используем для формирования объектов ExtendedExampleRecord. В классе ListState необходимо все переменные, определённые с использованием типа ExampleRecord, заменить на ExtendedExampleRecord. В котроллер списка добавим импорт библиотеки synchronized: import 'package:synchronized/synchronized.dart'. В классе _FetchRecordsResult также заменим ExampleRecord на ExtendedExampleRecord для переменной records. В классе ListController создадим объект, с помощью которого будут синхронизироваться разные участки кода: Сейчас в этом же файле надо найти строки: и заменить их на: Описание кода: Обозначаем область кода, который не должен выполняться параллельно с другим кодом, помеченным таким же образом. Строка для получения от источника данных дополнительных полей. Она и является главным виновником модификации нашего приложения. Объект ExampleRecordQuery может сравнивать только записи типа ExampleRecord, а мы поменяли тип записей списка на ExtendedExampleRecord, поэтому придётся подправить вызов сортировки записей. Так как репозиторий на запрос queryRecords возвращает записи типа ExampleRecord, а список должен состоять из ExtendedExampleRecord, необходимо преобразовать эти данные в ExtendedExampleRecord. Осталось запретить функции loadRecords выполняться параллельно с процессом актуализации списка и наоборот. В классе виджета тизера RecordTeaser изменим тип переменной для хранения элемента списка и добавим отображение элемента списка другим цветом, если запись находится в избранном: Открыть код на GitHub Иногда очень удобно поручить отслеживание изменений записей не контроллеру списка, а самой записи. По аналогии с библиотекой Bloc я называю такие записи кубитами. Смысл в том, что кубит всегда должен хранить актуальную запись. Для этого кубит самостоятельно отслеживает изменения в базе данных и меняет своё состояние, если запись изменяется. Такой подход удобно использовать, когда записи не меняются часто, например, в списках пользователей. Некоторым недостатком такого подхода является то, что сортировка списка при изменении веса записи работать не будет. Эта проблема решаема, однако не адресуется в рамках этой статьи. Создадим файл record_cubit.dart со следующим содержанием: Описание кода: Подписываемся на события изменения записи. Если идентификатор события совпадает с идентификатором записи в value, значит, кубит должен обновить своё состояние, записав новую запись в переменную value. Для тех же целей, как это было ранее, чтобы не пришлось адаптировать уже написанный код в нашем приложении, делаем ExampleRecordCubit совместимым с ExampleRecord. Так как кубит использует подписку на поток событий, в случае, если запись более не используется, он должен отменить эту подписку. Заменим в файле lib/list_state.dart импорт библиотеки import 'package:fl_list_example/models.dart' на import 'package:fl_list_example/record_cubit.dart', так же заменим все типы ExtendedExampleRecord на ExampleRecordCubit. В начале файла list_controller.dart добавим import 'package:fl_list_example/record_cubit.dart'. В классе _FetchRecordsResult для переменной records укажем тип List<ExampleRecordCubit>. Исключим из кода обработки уведомлений об операциях над записями участки, связанные с актуализацией списка в виду изменений объектов в источнике данных. Описание кода: Перестаём анализировать любые изменения записей, оставляем только анализ событий создания и удаления записей. Убираем часть, ответственную за загрузку связных записей. У кубитов, записи которых были удалены и будут убраны из списка, необходимо вызвать функцию close. Этим кубиты отпишутся от отслеживания изменений записей, за которые они ответственны. В функции получения записей из репозиторий добавим код преобразования ExampleRecord в ExampleRecordCubit. В класс ListController добавим метод _closeAllRecords. Он будет использоваться во время удаления всех записей из списка либо перед уничтожением самого контроллера. Добавим вызов только что добавленного метода _closeAllRecords в методы dispose и loadRecords. Подключим зависимости в файл widgets/record_teaser.dart: Тип переменной record класса RecordTeaser меняем с ExtendedExampleRecord на ExampleRecordCubit и модифицируем функцию build. После этой модификации виджет тизера будет автоматически обновляться каждый раз, когда изменилось значение в кубите. В этой статье я привел несколько примеров реализации контроллеров списков. Некоторые из предложенных решений могут показаться сложными на первый взгляд, но на практике с ними невероятно легко работать. В реальных проектах я создавал набор базовых классов, реализующих функциональность, описанную в статье. Эти базовые классы использовались для написания простого и чистого кода. Позже, чтобы упростить создание контроллеров списка, я написал библиотеку, в которой реализованы многие принципы, описанные в этой статье. Благодарю за прочтение статьи. Буду признателен любым замечаниям, советам и вопросам.