Введение
Когда список содержит тысячи или десятки тысяч элементов, наивное создание визуального делегата для каждого элемента сразу привело бы к колоссальному расходу памяти и деградации производительности. ListView в QML по умолчанию виртуализирует содержимое: создаёт делегаты только для элементов, реально видимых (плюс небольшой буфер за пределами видимой области), и уничтожает делегаты, ушедшие далеко за пределы видимости, по мере скролла. В статье разберём, как эта виртуализация работает и как не сломать её неосторожным кодом.
Концепция
ListView отслеживает видимую область и динамически создаёт/уничтожает экземпляры delegate по мере скролла, переиспользуя освобождённые делегаты для новых элементов, появляющихся в области видимости (механизм похож на переиспользование ячеек в UITableView/RecyclerView в мобильной разработке). Свойство cacheBuffer управляет тем, насколько «заранее» за пределами видимой области создаются делегаты — больший буфер означает более плавный скролл (меньше резкого создания делегатов «на лету»), но и больший постоянный расход памяти.
Тяжёлые операции внутри делегата (сложные привязки, дорогие вычисления, создание множества дочерних визуальных элементов) умножаются на число одновременно существующих делегатов — даже при виртуализации, если видимая область большая или cacheBuffer велик, число активных делегатов может быть значительным.
Пример кода: Виртуализация списков в QML
import QtQuick
import QtQuick.Controls
ApplicationWindow {
visible: true
width: 360
height: 640
ListModel {
id: contactsModel
Component.onCompleted: {
for (let i = 0; i < 10000; i++) {
append({ name: "Контакт " + i, status: i % 3 === 0 ? "online" : "offline" });
}
}
}
ListView {
id: listView
anchors.fill: parent
model: contactsModel
// Буфер виртуализации: создаёт делегаты немного заранее за пределами видимости
cacheBuffer: 400
// Явно указываем ожидаемую высоту делегата —
// это позволяет ListView точнее предсказывать общую высоту
// содержимого без необходимости создавать все делегаты сразу
delegate: Item {
width: listView.width
height: 56
Row {
anchors.fill: parent
anchors.margins: 8
spacing: 12
Rectangle {
width: 40
height: 40
radius: 20
color: status === "online" ? "#4caf50" : "#9e9e9e"
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: name
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: 16
}
}
// Разделитель — лёгкий элемент, минимальные накладные расходы
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: "#e0e0e0"
}
}
// ScrollBar для визуальной индикации позиции в большом списке
ScrollBar.vertical: ScrollBar {}
}
}
Пояснения к коду
ListModel заполняется 10 000 записей при инициализации, но ListView не создаёт 10 000 экземпляров делегата одновременно — в любой момент существует лишь то количество, которое реально видимо плюс буфер, заданный cacheBuffer. Делегат спроектирован максимально «легковесным»: фиксированная высота (height: 56), простая структура из Row с цветным индикатором статуса и текстом, без тяжёлых вычислений или сложных вложенных привязок.
Указание явной height для каждого делегата (вместо вычисляемой на основе содержимого) важно для производительности — ListView может заранее точно вычислить общую высоту содержимого и положение скроллбара без необходимости предварительно «посетить» каждый элемент модели для измерения его реального размера.
Подводные камни
- Делегаты с переменной, заранее неизвестной высотой заметно усложняют работу виртуализации —
ListViewдолжен либо вычислять размер каждого делегата (что дороже для очень больших списков), либо использовать приближённые оценки, которые могут приводить к «скачкам» скроллбара при быстром скролле через ещё не отрендеренные элементы. - Тяжёлые операции в
Component.onCompletedделегата, выполняемые при каждом создании нового экземпляра делегата по мере скролла (а не один раз для всего списка) — если делегат выполняет, например, сетевой запрос или сложное вычисление при создании, частое создание/уничтожение делегатов во время быстрого скролла может вызвать заметные просадки производительности или избыточную нагрузку на сеть. - Слишком большой
cacheBuffer«на всякий случай». Увеличение буфера снижает заметность создания делегатов при обычном плавном скролле, но при очень резком скролле (быстрый flick через весь список) или при большом значении буфера на слабом оборудовании постоянно поддерживаемое количество активных делегатов может стать неоправданно большим, съедая память без реальной пользы. - Привязки (bindings) внутри делегата, обращающиеся к дорогим вычисляемым свойствам родительского контекста. Если каждый из множества одновременно активных делегатов содержит привязку, заново пересчитывающую что-то дорогое при каждом изменении внешнего состояния, общая стоимость пересчёта масштабируется с числом видимых делегатов — стоит профилировать сложные списки с помощью QML Profiler при подозрении на просадки FPS.