Кастомный список с виртуализацией в FireMonkey: практический разбор
FireMonkey представляет собой кроссплатформенный фреймворк, который позволяет Delphi-разработчикам создавать приложения для настольных и мобильных систем из единой кодовой базы. Списки — один из самых распространённых элементов интерфейса: ленты новостей, каталоги товаров, табличные отчёты, списки контактов. Когда количество элементов исчисляется сотнями или тысячами, производительность стандартных решений начинает проседать, и разработчик сталкивается с необходимостью оптимизации.
Классический подход с размещением всех строк в контейнере приводит к высокому потреблению памяти и замедлению скроллинга. Каждое визуальное представление строки потребляет ресурсы, занимает место в иерархии форм и замедляет отрисовку. На слабых мобильных устройствах такие списки становятся практически непригодными для использования, а частота кадров падает до некомфортных значений.
Виртуализация решает эту проблему радикально: вместо создания визуальных элементов для каждой записи создаются только те, которые видны пользователю в текущий момент. По мере скроллинга элементы переиспользуются — заполняются новыми данными и перемещаются на нужные позиции. Такой подход даёт стабильную частоту кадров независимо от объёма набора данных.
В рамках этого материала разбирается построение кастомного компонента с поддержкой виртуализации. Используются стандартные средства FireMonkey: TScrollBox как контейнер скроллинга, TLayout как виртуальный холст и пользовательский фрейм в роли переиспользуемой ячейки. Полученное решение подходит для проектов, где встроенный TListView не справляется с задачами по функциональности или стилю.
Архитектура виртуализированного списка
Основная идея состоит в разделении логических данных и их визуального представления. Внутреннее хранилище содержит только массив записей, тогда как графическая часть оперирует ограниченным пулом ячеек. Такой подход снижает нагрузку на сборщик мусора и уменьшает время инициализации компонента.
Контейнером служит TScrollBox, в который помещается TLayout, растянутый на полную высоту виртуального списка. Это позволяет системе обрабатывать скроллинг штатными средствами, а разработчику — сосредоточиться на логике обновления видимых элементов. Высота виртуального холста рассчитывается как произведение высоты одной ячейки на общее число записей.
Пул ячеек создаётся заранее: достаточно небольшого количества элементов, чтобы покрыть видимую область с запасом. Типичное значение — удвоенное число строк, помещающихся на экране. Такой резерв обеспечивает плавность при быстром скролле и устраняет мерцание при появлении новых элементов.
Ключевым элементом архитектуры является обработчик события OnViewportPositionChange у TScrollBox. Он вызывается при каждом изменении позиции прокрутки и отвечает за определение того, какие записи сейчас должны быть видны, а также за обновление соответствующих ячеек из пула.
Базовый класс и контейнер элементов
Реализация начинается с потомка TFrame или TLayout, представляющего отдельную строку списка. Внутри размещаются визуальные элементы, привязанные к свойствам фрейма: заголовок, подзаголовок, иконка, дополнительные метки. Для обновления содержимого удобно использовать метод вроде ApplyData, принимающий запись из модели данных.
Класс списка наследует TScrollBox и хранит три ключевых поля: массив данных FItems, пул видимых ячеек FVisibleCells и высоту одной строки FItemHeight. При первом отображении инициализируется пул — создаются фреймы и помещаются в FVisibleCells, после чего вызывается метод UpdateVisible, выполняющий первичное наполнение.
Расчёт видимого диапазона опирается на свойства ViewportPosition и ViewportSize. Верхняя граница определяется делением позиции скролла на высоту строки, нижняя — аналогично с учётом количества видимых строк. Полученные индексы преобразуются в смещения внутри массива данных, после чего каждая ячейка пула получает свою запись.
Важным нюансом становится обработка изменения размеров контейнера. При повороте экрана, изменении размера окна или работе в режиме разделения экрана количество видимых строк меняется. Метод Resize пересчитывает размер пула и при необходимости добавляет или убирает фреймы, поддерживая актуальное состояние.
Механизм переиспользования ячеек
Переиспользование — центральный элемент виртуализации. Вместо создания нового фрейма для каждой записи берётся существующая ячейка из пула и перемещается на нужную позицию. Это устраняет расходы на создание объектов и снижает нагрузку на систему отрисовки.
Алгоритм работает следующим образом: при изменении скролла вычисляется диапазон видимых индексов. Для каждого индекса ищется свободная ячейка в пуле. Если подходящая ячейка отсутствует, используется наименее удалённая от новой позиции. Такой подход сохраняет локальность ссылок и уменьшает визуальные артефакты.
Каждая ячейка запоминает, какой индекс записи она сейчас отображает. Это позволяет избежать повторного заполнения при скролле на небольшие расстояния, когда набор видимых индексов не изменился. Оптимизация особенно заметна на устройствах с медленной графической подсистемой.
Дополнительным приёмом служит отложенное обновление тяжёлых элементов интерфейса, таких как изображения. Если ячейка ушла за пределы видимой области, фоновая загрузка картинки отменяется. Когда ячейка возвращается в зону видимости, загрузка инициируется заново. Это снижает потребление сетевого трафика и оперативной памяти.
Обработка скроллинга и обновление данных
Событие OnViewportPositionChange в TScrollBox срабатывает довольно часто, особенно при жестах с высокой частотой дискретизации. Чтобы не перегружать основной поток, вычисления нового состояния и обновление ячеек разделяются. Позиция скролла обновляется немедленно, а тяжёлые операции переносятся в обработчик таймера с малым интервалом.
Таймер на 16 миллисекунд — типичный выбор, соответствующий 60 кадрам в секунду. При каждом срабатывании он проверяет, изменился ли целевой диапазон, и при необходимости инициирует обновление. Такой приём называется coalescing и широко используется в мобильной разработке.
Для сценариев, где данные меняются динамически — например, в чатах или лентах обновлений — предусматривается метод Refresh, выполняющий полный пересчёт состояния. Если количество записей изменилось, корректируется высота виртуального холста, и пул ячеек приводится в соответствие с новой раскладкой.
Особое внимание уделяется взаимодействию с пользователем. Обработчики нажатий в ячейках получают индекс записи, а не саму ячейку. Это упрощает работу с моделью данных и предотвращает ошибки, связанные с переиспользованием. При удалении записи, находящейся в фокусе, фокус корректно передаётся соседнему элементу.
Динамическая подгрузка и кеширование
В сценариях с очень большими наборами данных — каталоги с миллионами позиций, ленты социальных сетей — даже виртуализированный список требует поэтапной загрузки. Реализуется паттерн бесконечной прокрутки: когда пользователь приближается к концу списка, инициируется запрос на получение следующей порции данных.
Пороговым значением обычно служит расстояние в одну-две высоты экрана до нижней границы. Это даёт системе время загрузить и обработать новые записи до того, как пользователь увидит пустое пространство. Состояние загрузки отображается специальным индикатором в нижней части списка.
Кеширование играет важную роль при работе с удалёнными источниками. Загруженные записи сохраняются в локальном хранилище, что позволяет отображать историю просмотра без сетевых запросов. В мобильных приложениях часто применяется гибридная стратегия: сначала показываются кешированные данные, затем они заменяются свежими по мере получения.
Для управления памятью используется ограничение размера кеша. При превышении лимита самые старые записи вытесняются. Такой подход сочетает преимущества быстрого доступа к недавним данным с контролируемым потреблением ресурсов, что критично для мобильных устройств с ограниченным объёмом оперативной памяти.
Оптимизация рендеринга и тестирование
Финальный этап — оптимизация отрисовки. Использование TImageList для иконок, TBrush вместо растровых заливок, минимизация числа вложенных контейнеров — стандартные приёмы, дающие ощутимый прирост производительности. На устройствах с низкой частотой обновления экрана эти оптимизации особенно заметны.
Профилирование выполняется с помощью встроенных в RAD Studio инструментов: аудита памяти, отображения времени отрисовки кадра, счётчиков использования GPU. Целевая частота кадров — 60 FPS на современных устройствах и минимум 30 FPS на устройствах начального уровня.
Тестирование охватывает несколько сценариев: быстрый скролл, изменение ориентации экрана, появление и исчезновение виртуальной клавиатуры, переключение между приложениями. Каждый из этих сценариев способен выявить проблемы, незаметные в спокойном состоянии.
| Подход | Потребление памяти | Скорость скролла | Гибкость оформления | Сложность реализации |
|---|---|---|---|---|
| TListView (стандартный) | Среднее | Высокая | Ограниченная | Низкая |
| TScrollBox + TLayout с полным набором строк | Высокое | Низкая | Полная | Средняя |
| Кастомный виртуализированный список | Низкое | Высокая | Полная | Высокая |