Пользовательский интерфейс в Qt

Виртуализация списков в QML: ListView, делегаты и производительность на больших данных

Введение

Когда список содержит тысячи или десятки тысяч элементов, наивное создание визуального делегата для каждого элемента сразу привело бы к колоссальному расходу памяти и деградации производительности. 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.