Иногда в приложении необходимо отобразить список c данными. Если это простой статический список, он редко вызывает сложности. Однако создание списков, содержимое которых должно меняться, часто приводит к ошибкам, сложным для выявления и отладки.

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

Статья написана в виде инструкции и ориентирована на Flutter-разработчиков с небольшим опытом, хотя, надеюсь, и опытным разработчикам она будет полезна. Для лучшего усвоения материала желательно знать основы реактивного программирования, иметь минимальный опыт работы с библиотекой rxdart, понимать, как работает ChangeNotifier.

Взяв за основу приложение с примитивным списком, мы будем наращивать его функциональность, переходя от решения простых и частых задач к более сложным и редким. Чтобы уменьшить объём кода и упростить понимание темы, я не буду брать какой-то пример из жизни, а сконцентрируюсь на написании абстрактного приложения. Статья разбита на части таким образом, что по завершении прохождения очередной части вы будете иметь рабочее приложение. Из-за такого подхода читать статью можно не полностью, а лишь до момента с описанием решения проблемы, интересной вам. В начале каждой части статьи я буду размещать ссылку на код приложения, соответствующий данной части.

Содержание

  1. Используемая терминология и обозначения

  2. Структура проекта

  3. Подготовка приложения

  4. Асинхронная загрузка данных

  5. Фильтр и сортировка

  6. Порционная загрузка данных

  7. Актуализация данных

  8. Работа со списком записей, которые ссылаются на другие записи

  9. Список из самостоятельно обновляемых записей

  10. Заключение

Используемая терминология и обозначения

  • Запись — объект, на основании которого будет формироваться отображение одной строки списка;

  • Тизер — графическое представление записи списка;

  • Кубит — объект, который всегда хранит актуальное состояние записи списка;

  • Состояние списка — структура данных с содержимым списка и описанием, на какой стадии получения данных он находится;

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

  • База данных — в рамках статьи это переменная, которая хранит список с записями;

  • // + — строка добавлена;

  • // - — строка удалена;

  • // * — строка изменена.

Структура проекта

  • models.dart — описание основных структур данных, которые используются в приложении;

  • repository.dart — содержит источник для получения данных и взаимодействия с ними;

  • list_state.dart — описание структуры состояния списка;

  • list_controller.dart — контроллер управления списком;

  • widgets\record_teaser.dart — виджет для представления записи списка (тизера);

  • widgets\list_status_indicator.dart — виджет для представления состояния списка;

  • main.dart — точка входа в приложение.

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

Подготовка приложения

Открыть код на GitHub

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

Выполним команду создания нового приложения.

> flutter create fl_list_example

main.dart

Изменим файл main.dart следующим образом:

/* file: main.dart */

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: HomePage());
  }
}

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("List Demo")),
      body: ListView.builder(
        itemBuilder: (context, index) {
          return ListTile(title: Text("Item $index"));
        },
      ),
    );
  }
}

Созданное приложение имеет лишь один экран со списком. Виджет HomePage, конечно, можно было бы реализовать на базе StatelessWidget, однако в будущем нам понадобится именно StatefullWidget, поэтому оставим как есть.

Асинхронная загрузка данных

Открыть код на GitHub

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

pubspec.yaml

Добавим необходимые пакеты в файл pubspec.yaml и в командной строке выполним flutter pub get.

dependencies:
  english_words: ^4.0.0 # 1
  provider: ^6.0.0 # 2
  ...

Описание кода:

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

  2. Используется для получения доступа к контроллеру списка.

models.dart

Добавим класс для описания элемента списка. Создадим файл models.dart с таким содержимым:

/* file: models.dart */

class ExampleRecord {
  final String title;

  const ExampleRecord({required this.title});
}

Пока что объекты этот класса будут хранить только слово для отображения в строках виджета ListView.

repository.dart

Все операции над записями базы данных будут производиться через репозиторий. Его реализацию разместим в файле repository.dart.

/* file: repository.dart */

import 'dart:math';
import 'package:english_words/english_words.dart';
import 'package:fl_list_example/models.dart';

const kRecordsToGenerate = 100;

class MockRepository {
  // 1
  final List<ExampleRecord> _store = List<ExampleRecord>.generate(
            kRecordsToGenerate,
            (i) => ExampleRecord(
                title: nouns[Random().nextInt(nouns.length)],
              ));

  // 2
  static final MockRepository _instance = MockRepository._internal();
  factory MockRepository() => _instance;
  MockRepository._internal() : super();

  // 3
  Future<List<ExampleRecord>> queryRecords() async {
    await Future.delayed(const Duration(seconds: 2)); // 4
    return _store;
  }
}

Описание кода:

  1. При создании объекта репозитория заполним private-переменную _store (условную базу данных) записями со случайными словами.

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

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

  4. Строка добавляет имитацию задержки при получении данных.

list_state.dart

Для создания класса, ответственного за хранение состояния списка, создадим файл list_state.dart со следующим содержимым:

/* file: list_state.dart */

import 'package:fl_list_example/models.dart';

class ListState {
  ListState({
    List<ExampleRecord>? records,
    this.isLoading = false,
    this.error = '', // 1
  }) : recordsStore = records; // 2

  final List<ExampleRecord>? recordsStore; // 3

  bool get isInitialized => recordsStore != null; // 3

  List<ExampleRecord> get records => recordsStore ?? List<ExampleRecord>.empty(); // 4

  final String error;

  bool get hasError => error.isNotEmpty; // 5

  final bool isLoading; // 6
  
  // 7
  ListState copyWith({
    List<ExampleRecord>? records,
    bool? isLoading,
    String? error,
  }) {
    return ListState(
      records: records ?? recordsStore,
      isLoading: isLoading ?? this.isLoading,
      error: error ?? this.error,
    );
  }
}

Описание кода:

  1. В случае возникновения ошибки при получении данных из MockRepository, в эту переменную будет записываться описание ошибки для дальнейшего её отображения. Эта переменная не может быть null и по умолчанию хранит пустую строку. Такой подход позволяет избежать неопределённого толкования значения этой переменной. Поясню. Если бы переменная была null — это бы, скорее всего, означало отсутствие ошибки. Но если бы эта переменная хранила пустую строку и, в то же время, имела бы возможность иметь значение null, то интерпретация такого состояния была бы не очевидна.

  2. Строка добавлена лишь для более понятного и эстетичного наименования параметров конструктора класса.

  3. Переменная recordsStore хранит список записей для отображения на экране. Возможность хранения значения null сделано для того, чтобы различать причины, по которым список записей пуст. Если переменная имеет значение null, значит список пуст, так как ещё не было успешных обращений к MockRepository на получение записей. Если же в переменной сохранён пустой список, значит такое обращение было, однако MockRepository не вернул ни одной записи. Для более удобного различия этих состояний используется переменная isInitialized.

  4. Параметр records всегда возвращает список, вне зависимости от того, сохранено ли значение null в recordsStore. Этот параметр удобно использовать, чтобы избежать проверки значения recordsStore на null, при получении списка записей.

  5. Параметр hasError удобно использовать, когда надо проверить, произошла ли ошибка в ходе получения записей из репозитория. Для этих целей можно было бы анализировать переменную error, однако в ходе разработки приложения способ хранения ошибки в состоянии списка может измениться и тогда придётся переписывать некоторую часть кода. Дальнейшее использование этого параметра также приведёт к более краткому и понятному коду.

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

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

list_controller.dart

На базе ValueNotifier создадим контроллер управления списком. Он будет изменять состояние списка ListState, сохранённое в переменной value. Вместо ValueNotifier можно использовать и любой другой механизм для управления состоянием, например, Bloc.

Создадим файл list_controller.dart с таким содержимым:

/* file: list_controller.dart */

import 'package:fl_list_example/list_state.dart';
import 'package:fl_list_example/models.dart';
import 'package:fl_list_example/repository.dart';
import 'package:flutter/foundation.dart';

class ListController extends ValueNotifier<ListState> {
  ListController() : super(ListState()) { // 1
    loadRecords(); // 2
  }

  // 3
  Future<List<ExampleRecord>> fetchRecords() async {
    final loadedRecords = await MockRepository().queryRecords();
    return loadedRecords;
  }

  // 4
  Future<void> loadRecords() async {
    if (value.isLoading) return; // 5

    value = value.copyWith(isLoading: true, error: ""); // 6

    try {
      final fetchResult = await fetchRecords();

      value = value.copyWith(isLoading: false, records: fetchResult); // 7
    } catch (e) {
      value = value.copyWith(isLoading: false, error: e.toString()); // 8
      rethrow;
    }
  }

  // 9
  repeatQuery() {
    return loadRecords();
  }
}

Описание кода:

  1. Вызов super устанавливает исходное состояние списка.

  2. При создании контроллера инициируем процесс получения содержимого списка.

  3. Функция получения данных, которыми будет заполняться список. Не получаем данные напрямую из репозитория, так как кроме самих данных иногда требуется определять дополнительных характеристики этих данных, например, является ли возвращаемый результат окончанием списка (в случае, если получаем список частями).

  4. Функция, ответственная за загрузку данных для списка и изменения состояния списка в процессе загрузки.

  5. Игнорируем новые запросы на загрузку списка, пока предыдущий запрос не завершён.

  6. В состоянии списка указываем, что список в процессе загрузки. Сбрасываем описание ошибки, если такая имелась после предыдущей загрузки.

  7. Если получение записей прошло успешно, добавляем их в состояние списка. Также в состоянии списка указываем, что загрузка списка завершена.

  8. Если что-то пошло не так, добавляем в состояние списка сообщение об ошибке.

  9. Функция будет использоваться для повторной попытки получения списка записей, если во время предыдущей попытки возникла ошибка. Не используем функцию loadRecords, так как в следующих частях статьи функция repeatQuery будет дорабатываться.

widgets/list_status_indicator.dart

Добавим виджет для отображения текущего состояния списка ListState. Дополнительно этот виджет может принимать указатель на функцию onRepeat. Если этот указатель установлен, то в случае ошибки загрузки списка, виджет отобразит кнопку повторной загрузки.

Создадим файл widgets/list_status_indicator.dart с таким содержимым:

/* file: widgets/list_status_indicator.dart */

import 'package:fl_list_example/list_state.dart';
import 'package:flutter/material.dart';

class ListStatusIndicator extends StatelessWidget {
  final ListState listState;
  final Function()? onRepeat;

  const ListStatusIndicator(this.listState, {this.onRepeat, Key? key}) : super(key: key);

  static bool hasStatus(ListState listState) => listState.hasError || listState.isLoading || (listState.isInitialized && listState.records.isEmpty); // 1

  @override
  Widget build(BuildContext context) {
    Widget? stateIndicator;
    if (listState.hasError) {
      stateIndicator = const Text("Loading Error", textAlign: TextAlign.center);
      if (onRepeat != null) {
        stateIndicator = Row(
          mainAxisSize: MainAxisSize.min,
          children: [stateIndicator, const SizedBox(width: 8), IconButton(onPressed: onRepeat, icon: const Icon(Icons.refresh))],
        );
      }
    } else if (listState.isLoading) {
      stateIndicator = const CircularProgressIndicator();
    } else if (listState.isInitialized && listState.records.isEmpty) {
      stateIndicator = const Text("No results", textAlign: TextAlign.center);
    }

    if (stateIndicator == null) return Container();

    return Container(alignment: Alignment.center, child: stateIndicator);
  }
}

Описание кода:

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

main.dart

Внесём изменения в файл main.dart. Так как изменений много, привожу его полное содержимое:

/* file: main.dart */

import 'package:fl_list_example/list_controller.dart'; // +
import 'package:fl_list_example/widgets/list_status_indicator.dart'; // +
import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; // +

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp( // +
      home: ChangeNotifierProvider( // + 1
        create: (_) => ListController(), // +
        child: const HomePage(), // +
      ), //+
    ); // +
  }
}

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    final listController = context.watch<ListController>(); // + 2
    final listState = listController.value; // + 3
    final itemCount = listState.records.length + (ListStatusIndicator.hasStatus(listState) ? 1 : 0); // + 4
    return Scaffold(
      appBar: AppBar(title: const Text("List Demo")),
      body: ListView.builder( // +
        itemBuilder: (context, index) {
          if (index == listState.records.length && ListStatusIndicator.hasStatus(listState)) { // + 5
            return ListStatusIndicator(listState, onRepeat: listController.repeatQuery); // + 6
          } // +

          final record = listState.records[index]; // + 7
          return ListTile(title: Text(record.title)); // +
        }, // +
        itemCount: itemCount,
      ),
    );
  }
}

Описание кода:

  1. Виджет создаёт экземпляр класса ListController и предоставляет доступ к нему в других виджетах, которые находятся ниже по иерархии.

  2. Наблюдаем за изменением состояния контроллера ListController. Если его состояние изменилось (было установлено новое значение value и, следовательно, вызвана функция notifyListeners()), то произойдёт новый вызов функции build. Через переменную listController мы получаем доступ к этому контроллеру.

  3. В переменную listState помещаем актуальное состояние контроллера для формирования более читаемого кода далее.

  4. Виджет состояния списка ListStatusIndicator отображается дополнительной строкой в виджете списка ListView. Однако нет смысла отображать состояние списка постоянно. В зависимости от результата выполнения hasStatus определяем, нужно ли отображать виджет состояние списка и, следовательно, нужно ли резервировать для него дополнительную строку. Конечно, можно было бы отображать виджет состояния и поверх виджета ListView, но выбранный подход более удобен, когда мы чуть позже будем реализовывать загрузку данных списка частями.

  5. Если необходимо, отображаем виджет состояния списка в последней строке виджета ListView.

  6. Если во время загрузки списка произошла ошибка, отобразится виджет состояния списка с кнопкой обновления. После нажатия на эту кнопку вызовется метод repeatQuery контроллера, который повторит последний запрос.

  7. Получаем данные, необходимые для отображения соответствующей строки списка, и формируем эту строку.

Фильтр и сортировка

Открыть код на GitHub

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

models.dart

Добавим в модель записи списка ExampleRecord переменную weight. Эта переменная будет обозначать порядок следования записей списка.

Также создадим класс ExampleRecordQuery, ответственный за фильтрацию элементов списка по подстроке в title и его сортировку по значению переменной weight.

/* file: models.dart */

class ExampleRecord {
  final String title;
  final int weight; // +

  const ExampleRecord({
    required this.title,
    required this.weight, // +
  });
}

class ExampleRecordQuery {
  final String? contains; // 1

  const ExampleRecordQuery({
    this.contains,
  });

  // 2
  bool suits(ExampleRecord obj) {
    if (contains != null && contains!.isNotEmpty && !obj.title.contains(contains!)) return false;
    return true;
  }

  // 3
  int compareRecords(ExampleRecord record1, ExampleRecord record2) {
    return record1.weight.compareTo(record2.weight);
  }
}

Описание кода:

  1. Переменная для хранения строки поиска.

  2. Функция проверки соответствия объекта obj условиями фильтрации.

  3. Функция сравнения и определения порядка следования записей record1 и record2.

repository.dart

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

/* file: repository.dart */

import 'dart:math';
import 'package:english_words/english_words.dart';
import 'package:fl_list_example/models.dart';

const kRecordsToGenerate = 100;

class MockRepository {
  final List<ExampleRecord> _store = List<ExampleRecord>.generate(
      kRecordsToGenerate,
      (i) => ExampleRecord(
            weight: i * 10, // + 1
            title: nouns[Random().nextInt(nouns.length)],
          ))
    ..shuffle(); // + 2

  static final MockRepository _instance = MockRepository._internal();
  factory MockRepository() => _instance;
  MockRepository._internal() : super();

  Future<List<ExampleRecord>> queryRecords(ExampleRecordQuery? query) async { // *
    await Future.delayed(const Duration(seconds: 2)); 

    final sortedList = List.of(_store); // + 3
    if (query != null) sortedList.sort(query.compareRecords);  // + 4

    return sortedList.where((record) => query == null || query.suits(record)).toList();  // * 5
  }
}

Описание кода:

  1. Установим вес записи, кратный 10, чтобы при создании записей, была возможность их вставки между другими записями.

  2. Перемешаем список, чтобы убедиться в работоспособности сортировки.

  3. Чтобы не модифицировать базу записей, создадим её копию.

  4. Если есть необходимость, выполняем сортировку списка. В реальном проекте вместо вызова функции sortedList.sort должно происходить преобразование объекта ExampleRecordQuery в формат, принимаемый источником данных, будь то SQL ORDER BY или параметр GET-запроса.

  5. Если есть необходимость, фильтруем список, чтобы вернуть только те записи, которые удовлетворяют запросу. В реальном проекте вместо вызова функции query.suits должно происходить преобразование объекта ExampleRecordQuery в формат, принимаемый источником данных, будь то SQL WHERE-условие или HTTP GET-запрос.

list_controller.dart

Далее модифицируем контроллер списка так, чтобы он мог работать с объектом запроса ExampleRecordQuery.

/* file: list_controller.dart */

import 'package:fl_list_example/list_state.dart';
import 'package:fl_list_example/models.dart';
import 'package:fl_list_example/repository.dart';
import 'package:flutter/foundation.dart';

class ListController extends ValueNotifier<ListState> {
  final ExampleRecordQuery query; // +

  ListController({required this.query}) : super(ListState()) {
    loadRecords(query: query); // *
  }

  Future<List<ExampleRecord>> fetchRecords(ExampleRecordQuery? query) async { // *
    final loadedRecords = await MockRepository().queryRecords(query); // *
    return loadedRecords;
  }

  Future<void> loadRecords({ExampleRecordQuery? query}) async { // *
    if (value.isLoading) return;

    value = value.copyWith(isLoading: true, error: "");

    try {
      final fetchResult = await fetchRecords(query); // *

      value = value.copyWith(isLoading: false, records: fetchResult);
    } catch (e) {
      value = value.copyWith(isLoading: false, error: e.toString());
      rethrow;
    }
  }

  repeatQuery() {
    return loadRecords(query: query); // *
  }
}

widgets/record_teaser.dart

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

Создадим файл widgets/record_teaser.dart с таким содержимым:

/* file: widgets/record_teaser.dart */

import 'package:fl_list_example/models.dart';
import 'package:flutter/material.dart';

class RecordTeaser extends StatelessWidget {
  final ExampleRecord record;

  const RecordTeaser({required this.record, Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(record.title),
      subtitle: Text("weight: ${record.weight}"),
    );
  }
}

main.dart

В строке создания контроллера файла main.dart укажем условия фильтрации записей. Добавим, например, что в списке должны быть только записи, содержащие подстроку "ea".

/* file: main.dart */
import 'package:fl_list_example/models.dart'; // +

  ...
  home: ChangeNotifierProvider(
    create: (_) => ListController(query: const ExampleRecordQuery(contains: "ea")), // *
    child: const HomePage(),
  ),
  ...

Изменим функцию build файла main.dart таким образом, чтобы для формирования элемента списка использовался только что созданный нами виджет RecordTeaser.

/* file: main.dart */
import 'package:fl_list_example/widgets/record_teaser.dart'; // +

  ...
  final record = listState.records[index];
  return RecordTeaser(record: record); // *
  ...

Порционная загрузка данных

Открыть код на GitHub

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

models.dart

Так как при загрузке списка частями в запросе на получение данных нужно будет указывать, какой вес записей должен быть у очередной страницы данных, в класс ExampleRecordQuery добавим переменную weightGt (в другом проекте это может быть переменная, например, с номером страницы page).

Так же нам понадобится функция копирования объекта запроса, но с изменёнными параметрами.

/* file: models.dart */
class ExampleRecordQuery {
  final String? contains;
  final int? weightGt; // +

  const ExampleRecordQuery({
    this.contains,
    this.weightGt, // +
  });
  ...
  ExampleRecordQuery copyWith({int? weightGt}) { // +
    return ExampleRecordQuery( // +
      weightGt: weightGt ?? this.weightGt, // +
    ); // +
  }// +
}

repository.dart

В начале файла repository.dart добавим константу, определяющую максимальное количество записей, возвращаемых функцией queryRecords за раз.

/* file: repository.dart */
...
const kBatchSize = 15;
...

Последнюю строку функции queryRecords надо заменить на:

/* file: repository.dart */
   ...
   // if ((query?.weightGt ?? 0) > 400) throw "Test Exception"; // 1
   return sortedList.where((record) => query == null || query.suits(record)).take(kBatchSize).toList();
   ...

Описание кода:

  1. Раскомментировать строку можно для проверки того, как реагирует интерфейс программы, если возникла ошибка на стадии получения данных. В этом случае, когда мы пролистаем список до записи с весом 400, должно появиться сообщение об ошибке с кнопкой повторения получения результатов.

list_state.dart

Далее переходим к модернизации класса описания состояния списка ListState.

/* file: list_state.dart */

import 'package:fl_list_example/models.dart';

enum LoadingFor { idle, replace, add } // + 1

class ListState {
  ListState({
    List<ExampleRecord>? records,
    this.loadingFor = LoadingFor.idle,
    this.hasLoadedAllRecords = false, // + 2
    this.error = '',
  }) : recordsStore = records {
    // 3
    if (isInitialized && !hasLoadedAllRecords && this.records.isEmpty) { // + 
      throw Exception("Wrong list state: list is empty but has no loadedAllRecords marker"); // +
    } // + 
    if (hasLoadedAllRecords && hasError) { // + 
      throw Exception("Wrong list state: state with hasLoadedAllRecords marker must not contain error ($error)"); // +
    } // + 
  }

  final LoadingFor loadingFor; // *
  final bool hasLoadedAllRecords; // +

  final List<ExampleRecord>? recordsStore;

  bool get isInitialized => recordsStore != null;

  List<ExampleRecord> get records => recordsStore ?? List<ExampleRecord>.empty();

  final String error;

  bool get hasError => error.isNotEmpty;

  bool get isLoading => loadingFor != LoadingFor.idle; // +

  bool canLoadMore() => !hasLoadedAllRecords && !isLoading && !hasError; // +

  ListState copyWith({
    List<ExampleRecord>? records,
    LoadingFor? loadingFor, // *
    bool? hasLoadedAllRecords, // +
    String? error,
  }) {
    return ListState(
      records: records ?? recordsStore,
      loadingFor: loadingFor ?? this.loadingFor, // +
      hasLoadedAllRecords: hasLoadedAllRecords ?? this.hasLoadedAllRecords, // +
      error: error ?? this.error,
    );
  }
}

Описание кода:

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

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

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

list_controller.dart

Приступаем к модификации контроллера списка ListController из файла list_controller.dart. Изменений много, поэтому снова приведу всё содержимое файла.

/* file: list_controller.dart */

import 'package:fl_list_example/list_state.dart';
import 'package:fl_list_example/models.dart';
import 'package:fl_list_example/repository.dart';
import 'package:flutter/foundation.dart';

// 1
class _FetchRecordsResult<T> { // +
  final List<ExampleRecord> records; // +
  final bool loadedAllRecords; // +

  _FetchRecordsResult({required this.records, required this.loadedAllRecords}); // +
} // +

class ListController extends ValueNotifier<ListState> {
  final ExampleRecordQuery query;

  ListController({required this.query}) : super(ListState()) {
    loadRecords(query: query);
  }

  Future<_FetchRecordsResult> fetchRecords(ExampleRecordQuery? query) async { // * 1
    final loadedRecords = await MockRepository().queryRecords(query);
    return _FetchRecordsResult(records: loadedRecords, loadedAllRecords: loadedRecords.length < kBatchSize); // *
  }

  Future<void> loadRecords({ExampleRecordQuery? query, bool replace = true}) async { // * 2
    if (value.isLoading) return;

    value = value.copyWith(loadingFor: replace ? LoadingFor.replace : LoadingFor.add, error: ""); // * 3

    try {
      final fetchResult = await fetchRecords(query);

      final records = [ // +
        if (!replace) ...value.records, // +
        ...fetchResult.records, // +
      ]; // +

      value = value.copyWith(loadingFor: LoadingFor.idle, records: records, hasLoadedAllRecords: fetchResult.loadedAllRecords); // *
    } catch (e) {
      value = value.copyWith(loadingFor: LoadingFor.idle, error: e.toString()); // *
      rethrow;
    }
  }

  directionalLoad() async { // + 4
    final query = getNextRecordsQuery(); // +
    await loadRecords(query: query, replace: false); // +
  } // +

  ExampleRecordQuery getNextRecordsQuery() { // + 5
    if (value.records.isEmpty) throw Exception("Impossible to create query"); // + 6
    return query.copyWith(weightGt: value.records.last.weight); // + 7
  } // +

  repeatQuery() {
    return value.records.isEmpty ? loadRecords(query: query) : directionalLoad(); // * 8
  }
}

Описание кода:

  1. Для того, чтобы функция fetchRecords могла возвращать не только список записей, но ещё и информацию о том, является ли возвращаемый результат окончанием списка, используем в качестве возвращаемого значения этой функции объект класса _FetchRecordsResult.

  2. Так как теперь список может загружаться и целиком, и частями, указываем это через параметр replace функции loadRecords.

  3. Отображаем цель загрузки списка (целиком или частями) в его состоянии.

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

  5. Функция формирует объект запроса для получения следующей страницы списка.

  6. Дополнительная проверка на уместность вызова функции getNextRecordsQuery. Нет смысла получать следующую страницу списка, если он пуст.

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

  8. Во время повторной попытки получения данных определяем способ, которым они получались в предыдущий раз. Делается это на основании того, есть ли записи в текущем состоянии списка. Альтернативный подход — сохранять последний выполненный запрос ExampleRecordQuery в состоянии списка ListState.

main.dart

Переходим к модификации файла main.dart. Так как нам понадобится функция определения большего из двух чисел, подключим библиотеку import 'dart:math'.

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

/* file: main.dart */

  ...
  create: (_) => ListController(query: const ExampleRecordQuery(/*contains: "ea"*/)),
  ...

Модифицируем класс _HomePageState до вида:

/* file: main.dart */

class _HomePageState extends State<HomePage> {
  final ScrollController _scrollController = ScrollController(); // 1 +

  static const double loadExtent = 80.0; // 2 +

  double _oldScrollOffset = 0.0; // 3 +

  @override // +
  initState() { // +
    _scrollController.addListener(_scrollControllerListener); // + 4
    super.initState(); // +
  } // +

  _scrollControllerListener() { // 4 +
    if (!_scrollController.hasClients) return; // +
    final offset = _scrollController.position.pixels; // +
    final bool scrollingDown = _oldScrollOffset < offset; // +
    _oldScrollOffset = _scrollController.position.pixels; // +
    final maxExtent = _scrollController.position.maxScrollExtent; // +
    final double positiveReloadBorder = max(maxExtent - loadExtent, 0); // +

    final listController = context.read<ListController>(); // +
    if (((scrollingDown && offset > positiveReloadBorder) || positiveReloadBorder == 0) && listController.value.canLoadMore()) { // +
      listController.directionalLoad(); // +
    } // +
  } // +

  @override // +
  void dispose() { // +
    if (_scrollController.hasClients == true) _scrollController.removeListener(_scrollControllerListener); // +
    super.dispose(); // +
  } // +

  @override
  Widget build(BuildContext context) {
    final listController = context.watch<ListController>();
    final listState = listController.value;
    final itemCount = listState.records.length + (ListStatusIndicator.hasStatus(listState) ? 1 : 0);
    return Scaffold(
      appBar: AppBar(title: const Text("List Demo")),
      body: ListView.builder(
        controller: _scrollController, // + 1
        itemBuilder: (context, index) {
          if (index == listState.records.length && ListStatusIndicator.hasStatus(listState)) {
            return ListStatusIndicator(listState, onRepeat: listController.directionalLoad);
          }

          final record = listState.records[index];
          return RecordTeaser(record: record);
        },
        itemCount: itemCount,
      ),
    );
  }
}

Описание кода:

  1. Создаём контроллер, чтобы была возможность отслеживать прокрутку списка.

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

  3. Используется лишь для определения направления прокрутки списка: вверх или вниз.

  4. Эта функция вызывается каждый раз, когда пользователь делает прокрутку списка. Функция инициирует загрузку очередной страницы данных, когда список докрутили до позиции loadExtent с конца.

Актуализация данных

Открыть код на GitHub

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

pubspec.yaml

Добавим необходимые пакеты в файл pubspec.yaml и в командной строке выполним flutter pub get.

dependencies:
  rxdart: ^0.27.3 # 1
  collection: ^1.15.0 # 2
  ...

Описание кода:

  1. Будет использоваться для обработки потока событий над объектами базы данных.

  2. Для уменьшения объема кода, из этой библиотеки будем использовать функцию поиска элемента в списке firstWhereOrNull.

models.dart

Заменим содержимое файла models.dart.

/* file: models.dart */

typedef ID = int; // + 1

class ExampleRecord {
  final ID id; // + 2
  final String title;
  final int weight;

  const ExampleRecord({
    required this.id, // + 2
    required this.title,
    required this.weight,
  });
}

class ExampleRecordQuery {
  final String? contains;
  final int? weightGt;
  final int? weightLte; // + 3

  const ExampleRecordQuery({
    this.contains,
    this.weightGt,
    this.weightLte, // + 3
  });

  bool suits(ExampleRecord obj) {
    if (contains != null && contains!.isNotEmpty && !obj.title.contains(contains!)) return false;
    if (weightGt != null && obj.weight <= weightGt!) return false;
    if (weightLte != null && obj.weight > weightLte!) return false; // + 3
    return true;
  }

  int compareRecords(ExampleRecord record1, ExampleRecord record2) {
    return record1.weight.compareTo(record2.weight);
  }

  ExampleRecordQuery copyWith({int? weightGt, int? weightLte}) { // *
    return ExampleRecordQuery(
      weightGt: weightGt ?? this.weightGt,
      weightLte: weightLte ?? this.weightLte, // + 3
    );
  }
}

// 4:
abstract class RecordEvent { // +
  final ID id; // +

  RecordEvent(this.id); // +
} // +

class RecordCreatedEvent extends RecordEvent { // +
  RecordCreatedEvent(ID id) : super(id); // +
} // +

class RecordUpdatedEvent extends RecordEvent { // +
  RecordUpdatedEvent(ID id) : super(id); // +
} // +

class RecordDeletedEvent extends RecordEvent { // +
  RecordDeletedEvent(ID id) : super(id); // +
} // +

// 5:
class WeightDuplicate {} // +

class RecordDoesNotExist {} // +

Описание кода:

  1. Определяет тип идентификатора записи. Это сделано через typedef, чтобы:

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

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

  1. Переменная для хранения идентификатора записи. С её помощью будут находиться одни и те же записи в списке.

  2. Будет использоваться для фильтрации записей списка, если их вес меньше или равен установленному значению weightLte.

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

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

repository.dart

В файле repository.dart подключим библиотеку collection. Из неё нам понадобится функция фильтрации списка firstWhereOrNull.

Для отправки уведомлений о действиях над записями из хранилища данных _store в другие части приложения будет использоваться StreamController, поэтому подключим библиотеку dart:async и создадим контроллер и поток поток уведомлений о событиях в классе MockRepository.

/* file: repository.dart */

import 'package:collection/collection.dart'; // +
import 'dart:async'; // +

...

class MockRepository {
  final StreamController<RecordEvent> eventController = StreamController<RecordEvent>(); // +
  late Stream<RecordEvent> rawEvents = eventController.stream.asBroadcastStream(); // +
  ...

В код, который наполняет базу записей, добавим строку присвоения идентификатора для записи.

      ...
      (i) => ExampleRecord(
          id: i, // +
          weight: i * 10,
          title: nouns[Random().nextInt(nouns.length)],
        ))
      ...

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

/* file: repository.dart */
  ...
  Future<List<ExampleRecord>> getByIds(Iterable<ID> ids) async {
    return _store.where((record) => ids.contains(record.id)).toList();
  }

  Future<ID> createRecord({required String title, required int weight}) async {
    if (_store.where((element) => element.weight == weight).isNotEmpty) throw WeightDuplicate(); // 1
    final maxId = _store.isNotEmpty ? _store.map((e) => e.id).reduce(max) : 0; // 2
    final newId = maxId + 1;
    _store.add(ExampleRecord( // 3
      id: newId,
      title: title,
      weight: weight,
    ));
    eventController.add(RecordCreatedEvent(newId)); // 5
    return newId;
  }

  Future<void> updateRecord(ID id, {String? title, int? weight}) async {
    final ExampleRecord? record = _store.firstWhereOrNull((element) => element.id == id); // 4
    if (record == null) throw RecordDoesNotExist();

    if (_store.where((element) => element != record && element.weight == weight).isNotEmpty) throw WeightDuplicate(); // 1
    final _storeIndex = _store.indexOf(record);
    _store[_storeIndex] = ExampleRecord( // 3
      id: id,
      title: title ?? record.title,
      weight: weight ?? record.weight,
    );
    eventController.add(RecordUpdatedEvent(id)); // 5
  }

  Future<void> deleteRecord(ID id) async {
    final ExampleRecord? record = _store.firstWhereOrNull((element) => element.id == id); // 4
    if (record == null) throw RecordDoesNotExist();
    _store.remove(record);
    eventController.add(RecordDeletedEvent(id)); // 5
  }
  ...

Описание кода:

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

  2. Определение максимального идентификатора записи в базе данных. Если же все записи из базы данных были удалены, считаем, что максимальный идентификатор равняется 0. Новой записи присваиваем идентификатор на единицу больше максимального.

  3. Добавление записи в базу данных.

  4. Находим существующую запись в базе данных. Если же записи нет, вызываем исключение RecordDoesNotExist.

  5. Информируем подписчиков потока событий о произведенном действии над записью базы данных.

list_controller.dart

В файл list_controller.dart добавим необходимые зависимости.

/* file: list_contoller.dart */

import 'dart:async';
import 'package:rxdart/rxdart.dart';
import 'package:collection/collection.dart';

В этот же файл добавим классы, которые будут использоваться для передачи данных между разными частями контроллера. Класс RecordsUpdates нужен для передачи записей, над которыми были произведены действия. Класс _ListChange содержит записи, которые необходимо удалить из списка, а также которые нужно добавить в него, чтобы список стал актуальным.

/* file: list_contoller.dart */
...
class RecordsUpdates {
  final Set<ID> deletedKeys;
  final Set<ExampleRecord> insertedRecords;
  final Set<ExampleRecord> updatedRecords;

  RecordsUpdates({this.deletedKeys = const {}, this.insertedRecords = const {}, this.updatedRecords = const {}});
}

class _ListChange {
  final Iterable<ExampleRecord> recordsToInsert;
  final Iterable<ExampleRecord> recordsToRemove;

  _ListChange({this.recordsToInsert = const {}, this.recordsToRemove = const {}});
}
...

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

/* file: list_contoller.dart */

...
class ListController extends ValueNotifier<ListState> {
  late StreamSubscription _changesSubscription; // +
  ...

  @override // +
  void dispose() { // +
    _changesSubscription.cancel(); // +
    super.dispose(); // +
  } // +
  ...

В конструктор класса ListController после строки loadRecords(query: query) добавим подписку на события:

/* file: list_contoller.dart */
    ...

    _changesSubscription = MockRepository()
      .rawEvents
      .where((event) => value.isInitialized) // 1
      .bufferTime(const Duration(milliseconds: 300)) // 2
      .where((event) => event.isNotEmpty) // 3
      .asyncMap((event) async {
        // 4:
        final createdIds = event.whereType<RecordCreatedEvent>().map((e) => e.id);
        final updatedIds = event.whereType<RecordUpdatedEvent>().map((e) => e.id);
        final idsToResolve = {...createdIds, ...updatedIds};
        final resolvedRecords = (await MockRepository().getByIds(idsToResolve)).toSet();
        
        return RecordsUpdates(
          insertedRecords: resolvedRecords.where((r) => createdIds.contains(r.id)).toSet(),
          updatedRecords: resolvedRecords.where((r) => updatedIds.contains(r.id)).toSet(),
          deletedKeys: event.whereType<RecordDeletedEvent>().map((e) => e.id).toSet(), // 5
        );
      })
      .map(_filterRecords) // 6
      .where((event) => event.recordsToInsert.isNotEmpty || event.recordsToRemove.isNotEmpty) // 7
      .map((change) { // 8
        final result = List.of(value.records)..removeWhere((r) => change.recordsToRemove.contains(r));
        return result..insertAll(0, change.recordsToInsert);
      })
      .map((updatedList) {
        return updatedList..sort(query.compareRecords); // 9
      })
      .listen((updatedList) { // 10
        value = value.copyWith(records: updatedList);
      });
    ...

Описание кода:

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

  2. От потока rawEvents уведомления о событиях приходят одно за другим. Если каждое уведомление обрабатывать индивидуально, это будет дополнительно нагружать источник данных и добавит задержки обновления списка. От этого он может обновляться построчно. В особой степени это может проявиться при удалённом нахождении источника данных. Простым решением этой проблемы является использование буфера, который будет собирать все события в течение определенного времени, что позволит обрабатывать изменённые записи не по одной, а группами. Из-за несовпадении времени буферизации событий с реальным временем поступления событий, некоторая задержка с отображением результата всё равно останется. Для её исключения период группировки событий можно определять не временным интервалом, а, например, дополнительными уведомлениями о том, что действие, влияющее на содержимое списка, завершено.

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

  4. На основании пришедших уведомлениях о событиях получаем записи, которые были созданы или изменены. Для оптимизации, сначала собираем все идентификаторы записей, которые необходимо получить из репозитория. Затем получаем эти записи одним запросом.

  5. Для удаления записей из списка достаточно лишь их идентификаторов. Поэтому просто создаём список с идентификаторами записей на основании пришедших уведомлениях о событиях.

  6. Структуру с информацией о созданных, изменённых и удалённых записях, сформированную в прошлом шаге, преобразуем в структуру, которая содержит списки записей для добавления и удаления из списка.

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

  8. На основании списков записей, которые надо добавить и убрать, формируем новый актуальный список записей.

  9. Выполняем сортировку нового списка. Сортировка не была добавлена в предыдущий шаг, так как иногда процесс сортировки списка может быть асинхронным. В таком случае необходимо использовать обработчик *asyncMap* вместо *map*.

  10. Обновляем состояние списка новыми записями.

У нас осталась нереализованная функция _filterRecords. Для её реализации понадобится другая функция, которая будет отвечать за проверку, может ли запись находиться в списке. Если список загружен полностью, решение принимается объектом query. Если же список полностью не загружен, то необходимо создать новый query с дополнительным ограничением на вхождение записи в текущий интервал загруженных записей.

/* file: list_contoller.dart */
  ...
  bool _recordSuits(ExampleRecord record) {
    if (value.hasLoadedAllRecords) return query.suits(record);
    return query.copyWith(weightLte: value.records.last.weight).suits(record);
  }
  ...

Теперь создадим функцию _filterRecords, которая на основании изменений, произошедших с записями, будет формировать объект _ListChange со списками записей, которые нужно добавить или удалить из результирующего списка.

/* file: list_controller.dart */
  ...

  _ListChange _filterRecords(RecordsUpdates change) {
    final recordsToRemove = value.records.where((r) => change.deletedKeys.contains(r.id)).toSet();

    final Set<ExampleRecord> rawRecordsToInsert = change.insertedRecords.where((r) => _recordSuits(r)).toSet();

    for (final r in change.updatedRecords) {
      final recordInList = value.records.firstWhereOrNull((recFromList) => recFromList.id == r.id);

      if (recordInList != null && !_recordSuits(r)) {
        recordsToRemove.add(recordInList);
      } else if (recordInList == null && _recordSuits(r)) {
        final ExampleRecord? inR = rawRecordsToInsert.firstWhereOrNull((recFromList) => recFromList.id == r.id); // 1
        if (inR != null) rawRecordsToInsert.remove(inR);

        rawRecordsToInsert.add(r);
      } else if (recordInList != null && _recordSuits(r)) { // 2
        // Can we remove repetition of the line
        final ExampleRecord? inR = rawRecordsToInsert.firstWhereOrNull((recFromList) => recFromList.id == r.id);
        if (inR != null) rawRecordsToInsert.remove(inR);

        recordsToRemove.add(recordInList);
        rawRecordsToInsert.add(r);
      }
    }
    return _ListChange(
      recordsToInsert: rawRecordsToInsert,
      recordsToRemove: recordsToRemove,
    );
  }

  ...

Описание кода:

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

  2. Обрабатывается ситуация, когда запись была изменена, но она присутствует ещё и в текущем списке из объекта состояния списка. В этом случае из результирующего списка убирается старая запись и добавляется новая.

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

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

  2. Кнопка изменения записи будет увеличивать вес соответствующей записи на 1.

  3. Кнопка удаления будет удалять текущую запись.

record_teaser.dart

В файл record_teaser.dart подключим необходимые библиотеки:

/* file: record_teaser.dart */

import 'dart:math';
import 'package:english_words/english_words.dart';
import 'package:fl_list_example/repository.dart';
...

Теперь в класс RecordTeaser добавим функции операций над записями:

/* file: record_teaser.dart */

  ...

  _createRecord(BuildContext context) async {
    try {
      await MockRepository().createRecord(title: nouns[Random().nextInt(nouns.length)].toUpperCase(), weight: record.weight + 1);
    } on WeightDuplicate {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Weight duplicate')));
    } on RecordDoesNotExist {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('RecordDoesNotExist exception')));
    }
  }

  _updateRecord(BuildContext context) async {
    int newWeight = record.weight + 1;
    try {
      while (true) {
        try {
          await MockRepository().updateRecord(record.id, weight: newWeight);
          break;
        } on WeightDuplicate {
          newWeight++;
        }
      }
    } on RecordDoesNotExist {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('RecordDoesNotExist exception')));
    }
  }

  _deleteRecord(BuildContext context) async {
    try {
      await MockRepository().deleteRecord(record.id);
    } on RecordDoesNotExist {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('RecordDoesNotExist exception')));
    }
  }

  ...

Добавим отображение кнопок в тизере записи:

/* file: record_teaser.dart */
  ...

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(record.title),
      subtitle: Text("weight: ${record.weight}"),
      trailing: Row(
        mainAxisSize: MainAxisSize.min,
        children: [ // +
          IconButton(onPressed: () => _createRecord(context), icon: const Icon(Icons.new_label)), // +// +
          IconButton(onPressed: () => _updateRecord(context), icon: const Icon(Icons.edit)), // +
          IconButton(onPressed: () => _deleteRecord(context), icon: const Icon(Icons.delete)), // +
        ], // +
      ),
    );
  }

  ...

Работа со списком записей, которые ссылаются на другие записи

Иногда записи списка могут иметь поля, для получения значений которых необходимы дополнительные запросы. Если эти поля используются для решения о необходимости включения записи в список, то их нужно получать на этапе формирования объекта RecordsUpdates. Однако если эти поля не участвуют в принятии решения на включение записи в список и мы не хотим запрашивать у источника данных избыточную информацию, то значения этих полей должны получаться на этапе, который следует уже после вызова функции _filterRecords. Но в таком случае за время, пока происходит получение значений связных полей, основное содержимое списка может полностью обновиться и результат актуализации данных списка будет не соответствовать новому содержимому списка. Это может привести к дублированию записей. Чтобы этого избежать, необходимо предотвратить параллельное исполнение функции получения данных списка и функции получения связных полей. Это будем делать с помощью библиотеки synchronized.

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

Открыть код на GitHub

pubspec.yaml

Добавим необходимый пакет в файл pubspec.yaml и в командной строке выполним flutter pub get.

dependencies:
  synchronized: ^3.0.0
  ...

models.dart

В файл models.dart добавим модель нового типа записи.

/* file: models.dart */

...
class ExtendedExampleRecord {
  final ExampleRecord base; // 1
  final bool isFavourite;

  const ExtendedExampleRecord({required this.base, required this.isFavourite});

  // 2
  ID get id => base.id;
  int get weight => base.weight;
  String get title => base.title;
}
...

Описание кода:

  1. Переменная хранит объект базовой записи, с которой мы работали ранее. Чтобы избежать дублирование определения типов параметров класса, используется методика комбинирования объектов класса вместо наследования. Такой подход особенно удобен при работе с классами, содержащими множество переменных.

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

repository.dart

В класс MockRepository добавим переменную, содержащую идентификаторы записей, которые являются избранными.

/* file: repository.dart */
  ...
  final Set<ID> _favourites = List.generate(kRecordsToGenerate ~/ 3, (_) => Random().nextInt(kRecordsToGenerate)).toSet();
  ...

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

/* file: repository.dart */

  ...
  // 1
  Future<List<ID>> getFavourites(Iterable<ID> idsToCheck) async {
    return idsToCheck.where((id) => _favourites.contains(id)).toList();
  }

  // 2
  Future<List<ExtendedExampleRecord>> extendRecords(Iterable<ExampleRecord> records) async {
    final idsToResolve = records.map((r) => r.id);
    final favouriteIds = await getFavourites(idsToResolve); // 3
    return records
        .map((r) => ExtendedExampleRecord(
              base: r,
              isFavourite: favouriteIds.contains(r.id),
            ))
        .toList();
  }
  ...

Описание кода:

  1. Функция возвращает, какие из идентификаторов списка idsToCheck находятся в избранном.

  2. Функция преобразует список записей ExampleRecord в ExtendedExampleRecord.

  3. Для того, чтобы получать необходимые нам данные минимальным количеством обращений к источнику данных, сначала получаем значения isFavourite для всех записей, а потом их используем для формирования объектов ExtendedExampleRecord.

list_state.dart

В классе ListState необходимо все переменные, определённые с использованием типа ExampleRecord, заменить на ExtendedExampleRecord.

list_controller.dart

В котроллер списка добавим импорт библиотеки synchronized: import 'package:synchronized/synchronized.dart'.

В классе _ListChange также заменим ExampleRecord на ExtendedExampleRecord для переменной recordsToRemove.

/* list_controller.dart */
  ...
  final Iterable<ExampleRecord> recordsToRemove; // -
  final Iterable<ExtendedExampleRecord> recordsToRemove; // +
  ...

То же самое сделаем для переменной records класса _FetchRecordsResult.

/* list_controller.dart */
  ...
  final List<ExampleRecord> records; // -
  final List<ExtendedExampleRecord> records; // +
  ...

В классе ListController создадим объект, с помощью которого будут синхронизироваться разные участки кода:

/* file: list_controller.dart */
  ...
  final lock = Lock();
  ...

Сейчас в этом же файле надо найти строки:

/* file: list_controller.dart */

        .map((change) {
          final result = List.of(value.records)..removeWhere((r) => change.recordsToRemove.contains(r));
          return result..insertAll(0, change.recordsToInsert);
        })

и заменить их на:

/* file: list_controller.dart */

        .asyncMap((change) async {
          return lock.synchronized(() async { // 1
            final result = List.of(value.records)..removeWhere((r) => change.recordsToRemove.contains(r));
            return result..insertAll(0, await MockRepository().extendRecords(change.recordsToInsert)); // 2
          });
        })

Описание кода:

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

  2. Строка для получения от источника данных дополнительных полей. Она и является главным виновником модификации нашего приложения.

Объект ExampleRecordQuery может сравнивать только записи типа ExampleRecord, а мы поменяли тип записей списка на ExtendedExampleRecord, поэтому придётся подправить вызов сортировки записей.

/* file: list_controller.dart */

  ...
  .map((updatedList) {
    // return updatedList..sort(query.compareRecords); // -
    return updatedList..sort((r1, r2) => query.compareRecords(r1.base, r2.base)); // +
  })
  ...

Так как репозиторий на запрос queryRecords возвращает записи типа ExampleRecord, а список должен состоять из ExtendedExampleRecord, необходимо преобразовать эти данные в ExtendedExampleRecord.

/* file: list_controller.dart */
  ...
  Future<_FetchRecordsResult> fetchRecords(ExampleRecordQuery? query) async {
    final loadedRecords = await MockRepository().queryRecords(query);
    // return _FetchRecordsResult(records: loadedRecords, loadedAllRecords: loadedRecords.length < kBatchSize); // -
    return _FetchRecordsResult(records: await MockRepository().extendRecords(loadedRecords), // +
                               loadedAllRecords: loadedRecords.length < kBatchSize); // +
  }
  ...

Осталось запретить функции loadRecords выполняться параллельно с процессом актуализации списка и наоборот.

/* file: list_controller.dart */
  ...
  Future<void> loadRecords({ExampleRecordQuery? query, required bool replace}) async {
    if (value.isLoading) return;
    lock.synchronized(() async { // +
     ...
    }); // +
  }
  ...

record_teaser.dart

В классе виджета тизера RecordTeaser изменим тип переменной для хранения элемента списка и добавим отображение элемента списка другим цветом, если запись находится в избранном:

/* file: record_teaser.dart */

class RecordTeaser extends StatelessWidget {
  final ExampleRecord record; // -
  final ExtendedExampleRecord record; // +

  ...

  return ListTile(
    selected: record.isFavourite, // +
    title: Text(record.title),
    ...

Список из самостоятельно обновляемых записей

Открыть код на GitHub

Иногда очень удобно поручить отслеживание изменений записей не контроллеру списка, а самой записи. По аналогии с библиотекой Bloc я называю такие записи кубитами. Смысл в том, что кубит всегда должен хранить актуальную запись. Для этого кубит самостоятельно отслеживает изменения в базе данных и меняет своё состояние, если запись изменяется. Такой подход удобно использовать, когда записи не меняются часто, например, в списках пользователей. Некоторым недостатком такого подхода является то, что сортировка списка при изменении веса записи работать не будет. Эта проблема решаема, однако не адресуется в рамках этой статьи.

record_cubit.dart

Создадим файл record_cubit.dart со следующим содержанием:

/* file: record_cubit.dart */

import 'dart:async';

import 'package:fl_list_example/models.dart';
import 'package:fl_list_example/repository.dart';
import 'package:flutter/foundation.dart';

class ExampleRecordCubit extends ValueNotifier {
  late StreamSubscription _changesSubscription;

  ExampleRecordCubit(ExampleRecord initState) : super(initState) {
    // 1
    _changesSubscription = MockRepository()
        .rawEvents
        .where((event) => event is RecordUpdatedEvent && event.id == value.id)
        .asyncMap((event) => MockRepository().getByIds([event.id]).then((value) => value.first))
        .listen((event) => value = event);
  }

  // 2
  ID get id => value.id;
  int get weight => value.weight;
  String get title => value.title;

  // 3
  close() => _changesSubscription.cancel(); 
}

Описание кода:

  1. Подписываемся на события изменения записи. Если идентификатор события совпадает с идентификатором записи в value, значит, кубит должен обновить своё состояние, записав новую запись в переменную value.

  2. Для тех же целей, как это было ранее, чтобы не пришлось адаптировать уже написанный код в нашем приложении, делаем ExampleRecordCubit совместимым с ExampleRecord.

  3. Так как кубит использует подписку на поток событий, в случае, если запись более не используется, он должен отменить эту подписку.

lib/list_state.dart

Заменим в файле lib/list_state.dart импорт библиотеки import 'package:fl_list_example/models.dart' на import 'package:fl_list_example/record_cubit.dart', так же заменим все типы ExtendedExampleRecord на ExampleRecordCubit.

list_controller.dart

В начале файла list_controller.dart добавим import 'package:fl_list_example/record_cubit.dart'.

В классе _ListChange для переменной recordsToRemove укажем тип Iterable<ExampleRecordCubit>.

В классе _FetchRecordsResult для переменной records укажем тип List<ExampleRecordCubit>.

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

/* file: list_controller.dart */

    ...
    _changesSubscription = MockRepository()
        .rawEvents
        .where((event) => value.isInitialized)
        .bufferTime(const Duration(milliseconds: 300))
        .where((event) => event.isNotEmpty)
        .asyncMap((event) async {
          // 1
          final createdIds = event.whereType<RecordCreatedEvent>().map((e) => e.id);
          // final updatedIds = event.whereType<RecordUpdatedEvent>().map((e) => e.id) // -
          // final idsToResolve = {...createdIds, ...updatedIds}; // -
          final resolvedRecords = (await MockRepository().getByIds(createdIds)).toSet();

          return RecordsUpdates(
            insertedRecords: resolvedRecords.where((r) => createdIds.contains(r.id)).toSet(),
            // updatedRecords: resolvedRecords.where((r) => updatedIds.contains(r.id)).toSet(), // -
            updatedRecords: {}, // +
            deletedKeys: event.whereType<RecordDeletedEvent>().map((e) => e.id).toSet(),
          );
        })
        .map(_filterRecords)
        .where((event) => event.recordsToInsert.isNotEmpty || event.recordsToRemove.isNotEmpty)
        // .asyncMap((change) async { // 2
        //   return lock.synchronized(() async {
        //     final result = List.of(state.records)..removeWhere((r) => change.recordsToRemove.contains(r));
        //     return result..insertAll(0, await MockRepository().extendRecords(change.recordsToInsert));
        //   });
        // }
        .map((change) {
          change.recordsToRemove.forEach((r) => r.close()); // 3
          final result = List.of(value.records)..removeWhere((r) => change.recordsToRemove.contains(r));
          return result..insertAll(0, change.recordsToInsert.map((r) => ExampleRecordCubit(r)));
        })
        .map((updatedList) {
          return updatedList..sort((r1, r2) => query.compareRecords(r1.value, r2.value)); // *
        })
        .listen((updatedList) {
          value = value.copyWith(records: updatedList);
        });
        ...

Описание кода:

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

  2. Убираем часть, ответственную за загрузку связных записей.

  3. У кубитов, записи которых были удалены и будут убраны из списка, необходимо вызвать функцию close. Этим кубиты отпишутся от отслеживания изменений записей, за которые они ответственны.

В функции получения записей из репозиторий добавим код преобразования ExampleRecord в ExampleRecordCubit.

  /* list_controller.dart */
  Future<_FetchRecordsResult> fetchRecords(ExampleRecordQuery? query) async {
    final loadedRecords = await MockRepository().queryRecords(query);
    return _FetchRecordsResult(
        records: loadedRecords.map((r) => ExampleRecordCubit(r)).toList(), // *
        loadedAllRecords: loadedRecords.length < kBatchSize);
  }

В класс ListController добавим метод _closeAllRecords. Он будет использоваться во время удаления всех записей из списка либо перед уничтожением самого контроллера.

/* list_controller.dart */
  ...
  _closeAllRecords() {
    value.records.every((r) => r.close()); 
  }
  ...

Добавим вызов только что добавленного метода _closeAllRecords в методы dispose и loadRecords.

/* file: list_controller.dart */

  @override
  void dispose() {
    _closeAllRecords(); // +
    _changesSubscription.cancel();
    super.dispose();
  }
/* file: list_controller.dart */

  ...
  final fetchResult = await fetchRecords(query);

  if (replace) _closeAllRecords(); // +

  final records = [
  ...

widgets/record_teaser.dart

Подключим зависимости в файл widgets/record_teaser.dart:

/* file: widgets/record_teaser.dart */

import 'package:fl_list_example/record_cubit.dart';
import 'package:provider/provider.dart';
...

Тип переменной record класса RecordTeaser меняем с ExtendedExampleRecord на ExampleRecordCubit и модифицируем функцию build. После этой модификации виджет тизера будет автоматически обновляться каждый раз, когда изменилось значение в кубите.

/* file: widgets/record_teaser.dart */

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider.value( // +
      value: record, // +
      child: Builder(builder: (context) { // +
        final record = context.watch<ExampleRecordCubit>(); // +
        return ListTile(
          // selected: record.isFavourite, // *
          title: Text(record.title),
          subtitle: Text("weight: ${record.weight}"),
          trailing: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              IconButton(onPressed: () => _createRecord(context), icon: const Icon(Icons.new_label)),
              IconButton(onPressed: () => _updateRecord(context), icon: const Icon(Icons.edit)),
              IconButton(onPressed: () => _deleteRecord(context), icon: const Icon(Icons.delete)),
            ],
          ),
        );
      }), // +
    ); // +
  }

Заключение

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

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

Комментарии (0)