QML ListView:C++ 数据模型与 ProxyModel 实时过滤

文章来源声明: 原文作者:Quz; 来源站点:掘金; 原文链接:https://juejin.cn/post/7688993872609607715; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

文中给出可直接参考的选型对比与踩坑点,适合正在用 QML 做列表、需要接入 C++ 后端或优化大数据量搜索的开发者。

只用QML中的 ListModel 写起来方便,但数据量大了或者数据来自后端接口,就显得力不从心。这时候就得上 C++ 模型,基于 `QAbstractListModel` 实现的一个数据层,QML端只处理UI展示,分工清晰。

如果还要支持搜索过滤,再加上一层 QSortFilterProxyModel(排序过滤模型),搜索性能和代码结构都能上一个台阶。

这篇文章用两个联系人列表的例子来演示:第一个用 QAbstractListModel 做一个支持搜索、增删改的基础 C++ 模型;第二个在它的基础上引入 QSortFilterProxyModel,做到 10 万条数据实时过滤不卡顿。

C++ 数据模型:联系人列表

一个由 C++ 模型驱动的联系人列表,支持搜索、添加、编辑、删除,数据量不大但功能齐全。

3.gif

QML 代码

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "./components"

// Demo:C++ 数据模型驱动的联系人列表(少量数据,含搜索/增删改)
FadeInAnimation {

    property int currentEditIndex: -1

    // ... 省略添加/编辑联系人对话框 ContactDialog ...

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 20
        spacing: 15

        // ... 省略标题组件 TitleSeparator ...

        // 工具栏
        Rectangle {
            Layout.fillWidth: true
            height: 44
            color: "#f0f0f0"
            radius: 5

            RowLayout {
                anchors.fill: parent
                anchors.margins: 6
                spacing: 8

                CustomTextField {
                    id: searchFieldCpp
                    Layout.fillWidth: true
                    placeholderText: "搜索联系人..."
                    leftIcon: "qrc:/icons/find.png"
                    onTextChanged: dataModelCpp.searchContacts(text)
                    onRightIconClicked: {
                        text = ""
                        dataModelCpp.clearSearch()
                    }
                }

                IconButton {
                    text: "添加"
                    iconSource: "qrc:/icons/add.png"
                    showBackground: true
                    backgroundColor: "#BBDEFB"
                    onClicked: addContactDialog.open()
                }
            }
        }

        // 列表
        ListView {
            id: cppListView
            Layout.fillWidth: true
            Layout.fillHeight: true
            model: dataModelCpp
            spacing: 6
            clip: true

            delegate: Rectangle {
                width: cppListView.width
                height: 55
                color: "#f5f5f5"
                radius: 5

                MouseArea {
                    anchors.fill: parent
                    hoverEnabled: true
                    onEntered: parent.color = "#e8e8e8"
                    onExited:  parent.color = "#f5f5f5"
                }

                RowLayout {
                    anchors.fill: parent
                    anchors.margins: 8
                    spacing: 10

                    // 首字母头像
                    Rectangle {
                        width: 36; height: 36; radius: 18
                        color: {
                            const colors = ["#FF6B6B","#4ECDC4","#45B7D1","#96CEB4","#D4A5A5","#9B59B6"]
                            return colors[firstLetter.charCodeAt(0) % colors.length]
                        }
                        Text {
                            anchors.centerIn: parent
                            text: firstLetter
                            color: "white"
                            font.pixelSize: 16
                            font.bold: true
                        }
                    }

                    ColumnLayout {
                        Layout.fillWidth: true
                        spacing: 4
                        Text { text: name;  font.bold: true; font.pixelSize: 14; Layout.fillWidth: true }
                        Text { text: phone; color: "#666"; font.pixelSize: 12; Layout.fillWidth: true }
                    }

                    RowLayout {
                        spacing: 6
                        IconButton {
                            iconSource: "qrc:/icons/edit.png"
                            onClicked: {
                                currentEditIndex = index
                                editContactDialog.currentName  = name
                                editContactDialog.currentPhone = phone
                                editContactDialog.open()
                            }
                        }
                        IconButton {
                            iconSource: "qrc:/icons/delete.png"
                            onClicked: dataModelCpp.removeContact(index)
                        }
                    }
                }
            }
        }
    }
}

QML 这边看起来跟用 ListModel 差不多,model 直接指向 dataModelCpp,delegate 里的 name、phone、firstLetter 这些字段直接用就行。区别在于这个 dataModelCpp 是从 C++ 那边注册进来的,搜索调用 searchContacts()、删除调用 removeContact()、编辑通过对话框回调 editContact(),全是 C++ 暴露的 Q_INVOKABLE 方法。

C++ 模型头文件

<span>class</span> <span>ContactItemCpp</span> {
<span>public</span>:
    <span>ContactItemCpp</span>(<span>const</span> QString &name, <span>const</span> QString &phone)
        : <span>m_name</span>(name), <span>m_phone</span>(phone) {}
    <span>QString <span>name</span><span>()</span>        <span>const</span> </span>{ <span>return</span> m_name; }
    <span>QString <span>phone</span><span>()</span>       <span>const</span> </span>{ <span>return</span> m_phone; }
    <span>QString <span>firstLetter</span><span>()</span> <span>const</span> </span>{ <span>return</span> m_name.<span>isEmpty</span>() ? <span>"?"</span> : m_name.<span>left</span>(<span>1</span>).<span>toUpper</span>(); }
<span>private</span>:
    QString m_name, m_phone;
};

<span>class</span> <span>DataModelCpp</span> : <span>public</span> QAbstractListModel
{
    Q_OBJECT
<span>public</span>:
    <span>enum</span> <span>Roles</span> {
        NameRole = Qt::UserRole + <span>1</span>,
        PhoneRole,
        FirstLetterRole
    };

    <span><span>explicit</span> <span>DataModelCpp</span><span>(QObject *parent = <span>nullptr</span>)</span></span>;

    <span><span>int</span>     <span>rowCount</span><span>(<span>const</span> QModelIndex &parent = QModelIndex())</span> <span>const</span> <span>override</span></span>;
    <span>QVariant <span>data</span><span>(<span>const</span> QModelIndex &index, <span>int</span> role = Qt::DisplayRole)</span> <span>const</span> <span>override</span></span>;
    <span>QHash<<span>int</span>, QByteArray> <span>roleNames</span><span>()</span> <span>const</span> <span>override</span></span>;

    <span>Q_INVOKABLE <span>bool</span> <span>addContact</span><span>(<span>const</span> QString &name, <span>const</span> QString &phone)</span></span>;
    <span>Q_INVOKABLE <span>bool</span> <span>removeContact</span><span>(<span>int</span> index)</span></span>;
    <span>Q_INVOKABLE <span>bool</span> <span>editContact</span><span>(<span>int</span> index, <span>const</span> QString &name, <span>const</span> QString &phone)</span></span>;
    <span>Q_INVOKABLE QVariantList <span>searchContacts</span><span>(<span>const</span> QString &keyword)</span></span>;
    <span>Q_INVOKABLE <span>void</span> <span>clearSearch</span><span>()</span></span>;

<span>private</span>:
    QList<ContactItemCpp> m_items;
    QList<ContactItemCpp> m_originalItems;
};

继承 QAbstractListModel 必须实现三个函数:rowCount() 告诉 ListView 有多少行,data() 根据索引和角色返回对应的数据,roleNames() 把角色枚举映射成 QML 里能用的字段名字符串——比如 NameRole 对应 "name",这样 QML 的 delegate 里直接写 name 就能取到值。

C++ 模型核心实现

<span>QHash<<span>int</span>, QByteArray> <span>DataModelCpp::roleNames</span><span>()</span> <span>const</span>
</span>{
    QHash<<span>int</span>, QByteArray> roles;
    roles[NameRole]        = <span>"name"</span>;
    roles[PhoneRole]       = <span>"phone"</span>;
    roles[FirstLetterRole] = <span>"firstLetter"</span>;
    <span>return</span> roles;
}

<span><span>bool</span> <span>DataModelCpp::addContact</span><span>(<span>const</span> QString &name, <span>const</span> QString &phone)</span>
</span>{
    <span>if</span> (name.<span>isEmpty</span>() || phone.<span>isEmpty</span>()) <span>return</span> <span>false</span>;
    <span>beginInsertRows</span>(<span>QModelIndex</span>(), m_items.<span>count</span>(), m_items.<span>count</span>());
    m_items.<span>append</span>(<span>ContactItemCpp</span>(name, phone));
    m_originalItems = m_items;
    <span>endInsertRows</span>();
    <span>return</span> <span>true</span>;
}

<span><span>bool</span> <span>DataModelCpp::removeContact</span><span>(<span>int</span> index)</span>
</span>{
    <span>if</span> (index < <span>0</span> || index >= m_items.<span>count</span>()) <span>return</span> <span>false</span>;
    <span>beginRemoveRows</span>(<span>QModelIndex</span>(), index, index);
    m_items.<span>removeAt</span>(index);
    <span>endRemoveRows</span>();
    <span>// ... 同步删除 m_originalItems 中的对应项 ...</span>
    <span>return</span> <span>true</span>;
}

<span><span>bool</span> <span>DataModelCpp::editContact</span><span>(<span>int</span> index, <span>const</span> QString &name, <span>const</span> QString &phone)</span>
</span>{
    <span>if</span> (index < <span>0</span> || index >= m_items.<span>count</span>() || name.<span>isEmpty</span>() || phone.<span>isEmpty</span>())
        <span>return</span> <span>false</span>;
    m_items[index] = <span>ContactItemCpp</span>(name, phone);
    <span>emit <span>dataChanged</span><span>(createIndex(index, <span>0</span>), createIndex(index, <span>0</span>))</span></span>;
    <span>// ... 同步更新 m_originalItems 中的对应项 ...</span>
    <span>return</span> <span>true</span>;
}

<span>QVariantList <span>DataModelCpp::searchContacts</span><span>(<span>const</span> QString &keyword)</span>
</span>{
    <span>if</span> (keyword.<span>isEmpty</span>()) {
        <span>beginResetModel</span>();
        m_items = m_originalItems;
        <span>endResetModel</span>();
        <span>return</span> <span>QVariantList</span>();
    }
    <span>beginResetModel</span>();
    m_items.<span>clear</span>();
    <span>for</span> (<span>const</span> ContactItemCpp &item : m_originalItems) {
        <span>if</span> (item.<span>name</span>().<span>contains</span>(keyword, Qt::CaseInsensitive) ||
            item.<span>phone</span>().<span>contains</span>(keyword, Qt::CaseInsensitive)) {
            m_items.<span>append</span>(item);
        }
    }
    <span>endResetModel</span>();
    <span>return</span> <span>QVariantList</span>();
}

写 C++ 模型最容易忘的就是那几对 begin/end 调用。添加数据前调 beginInsertRows(),加完调 endInsertRows();删除前调 beginRemoveRows(),删完调 endRemoveRows();修改完发 dataChanged() 信号。少了任何一步,ListView 都不会正确刷新。

搜索的实现比较直接——用 m_originalItems 存原始数据,搜索时把匹配结果放进 m_items,然后 beginResetModel() + endResetModel() 全量刷新。数据量小时没问题,但如果数据量很大,每次搜索都重置整个模型,视图得把所有项扔掉重建,会明显变卡,这时候就该上 ProxyModel 了。

适用场景:数据来自 C++ 后端、需要跟业务逻辑层对接的中小型列表。

ProxyModel 实时过滤:10 万条联系人

同样是联系人列表,但底层有 10 万条数据,搜索框输入一个字就实时过滤,滚动照样丝滑。靠的就是 QSortFilterProxyModel。

4.gif

QML 代码

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "./components"

// Demo:QSortFilterProxyModel 大数据量联系人列表(10万条,实时过滤)
FadeInAnimation {

    property int currentEditIndex: -1

    // ... 省略添加/编辑联系人对话框 ContactDialog ...

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 20
        spacing: 15

        // ... 省略标题组件 TitleSeparator ...

        // 工具栏
        Rectangle {
            Layout.fillWidth: true
            height: 44
            color: "#f0f0f0"
            radius: 5

            RowLayout {
                anchors.fill: parent
                anchors.margins: 6
                spacing: 8

                CustomTextField {
                    id: searchFieldModel
                    Layout.fillWidth: true
                    placeholderText: "搜索联系人..."
                    leftIcon: "qrc:/icons/find.png"
                    onTextChanged: contactProxyModel.filterString = text
                    onRightIconClicked: {
                        text = ""
                        contactProxyModel.filterString = ""
                    }
                }

                IconButton {
                    text: "添加"
                    iconSource: "qrc:/icons/add.png"
                    showBackground: true
                    backgroundColor: "#BBDEFB"
                    onClicked: addContactDialogModel.open()
                }
            }
        }

        // 列表区域(带滚动条)
        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#ffffff"
            radius: 5

            Item {
                anchors.fill: parent
                anchors.margins: 1

                ListView {
                    id: proxyListView
                    anchors.fill: parent
                    anchors.rightMargin: proxyScrollBar.width
                    model: contactProxyModel
                    spacing: 6
                    clip: true
                    ScrollBar.vertical: proxyScrollBar

                    delegate: Rectangle {
                        width: proxyListView.width
                        height: 55
                        color: "#f5f5f5"
                        radius: 5

                        MouseArea {
                            anchors.fill: parent
                            hoverEnabled: true
                            onEntered: { parent.color = "#e8e8e8"; rowTip.visible = true }
                            onExited:  { parent.color = "#f5f5f5"; rowTip.visible = false }
                        }

                        ToolTip {
                            id: rowTip
                            text: {
                                const n = index + 1
                                return "第 " + n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + " 行"
                            }
                            delay: 500
                            timeout: 5000
                            visible: false
                        }

                        RowLayout {
                            anchors.fill: parent
                            anchors.margins: 8
                            spacing: 10

                            Rectangle {
                                width: 36; height: 36; radius: 18
                                color: {
                                    const colors = ["#FF6B6B","#4ECDC4","#45B7D1","#96CEB4","#D4A5A5","#9B59B6"]
                                    return colors[firstLetter.charCodeAt(0) % colors.length]
                                }
                                Text {
                                    anchors.centerIn: parent
                                    text: firstLetter
                                    color: "white"
                                    font.pixelSize: 18
                                    font.bold: true
                                }
                            }

                            ColumnLayout {
                                Layout.fillWidth: true
                                spacing: 4
                                Text { text: name;  font.bold: true; font.pixelSize: 14; Layout.fillWidth: true }
                                Text { text: phone; color: "#666";  font.pixelSize: 12; Layout.fillWidth: true }
                            }

                            RowLayout {
                                spacing: 6
                                IconButton {
                                    iconSource: "qrc:/icons/edit.png"
                                    onClicked: {
                                        currentEditIndex = index
                                        editContactDialogModel.currentName  = name
                                        editContactDialogModel.currentPhone = phone
                                        editContactDialogModel.open()
                                    }
                                }
                                IconButton {
                                    iconSource: "qrc:/icons/delete.png"
                                    onClicked: contactProxyModel.removeContact(index)
                                }
                            }
                        }
                    }
                }

                ScrollBar {
                    id: proxyScrollBar
                    anchors.right: parent.right
                    anchors.top: parent.top
                    anchors.bottom: parent.bottom
                    active: true
                    interactive: true
                    orientation: Qt.Vertical
                }
            }
        }
    }
}

QML 层的变化不大,model 换成了 contactProxyModel,搜索时给 filterString 属性赋值就行,不用再调搜索函数。删除和编辑也是直接调 proxy 模型的方法,proxy 会自动把索引映射回源模型。

列表右边加了一个常驻的 ScrollBar,数据量大的时候拖动滚动条定位比滑快多了。每行悬停还会出一个 ToolTip 显示当前是第几行,10 万条数据里能有个位置感。

C++ ProxyModel 实现

<span>class</span> <span>ContactProxyModel</span> : <span>public</span> QSortFilterProxyModel
{
    <span>Q_OBJECT
    <span>Q_PROPERTY</span><span>(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged)</span>

<span>public</span>:
    explicit ContactProxyModel(QObject *parent =</span> <span>nullptr</span>);

    <span>QString <span>filterString</span><span>()</span> <span>const</span> </span>{ <span>return</span> m_filterString; }
    <span><span>void</span> <span>setFilterString</span><span>(<span>const</span> QString &filterString)</span></span>;

    <span>Q_INVOKABLE <span>bool</span> <span>removeContact</span><span>(<span>int</span> index)</span></span>;
    <span>Q_INVOKABLE <span>bool</span> <span>editContact</span><span>(<span>int</span> index, <span>const</span> QString &name, <span>const</span> QString &phone)</span></span>;

signals:
    <span><span>void</span> <span>filterStringChanged</span><span>()</span></span>;

<span>protected</span>:
    <span><span>bool</span> <span>filterAcceptsRow</span><span>(<span>int</span> sourceRow, <span>const</span> QModelIndex &sourceParent)</span> <span>const</span> <span>override</span></span>;

<span>private</span>:
    QString m_filterString;
};

QSortFilterProxyModel 是一个代理模型,它不存数据,而是套在源模型外面做过滤和排序。核心是重写 filterAcceptsRow(),返回 true 的行才会显示出来。

<span><span>bool</span> <span>ContactProxyModel::filterAcceptsRow</span><span>(<span>int</span> sourceRow, <span>const</span> QModelIndex &sourceParent)</span> <span>const</span>
</span>{
    <span>if</span> (m_filterString.<span>isEmpty</span>()) <span>return</span> <span>true</span>;
    QModelIndex nameIndex  = <span>sourceModel</span>()-><span>index</span>(sourceRow, <span>0</span>, sourceParent);
    QString name  = <span>sourceModel</span>()-><span>data</span>(nameIndex, DataModelProxy::NameRole).<span>toString</span>();
    QString phone = <span>sourceModel</span>()-><span>data</span>(nameIndex, DataModelProxy::PhoneRole).<span>toString</span>();
    <span>return</span> name.<span>contains</span>(m_filterString,  Qt::CaseInsensitive) ||
           phone.<span>contains</span>(m_filterString, Qt::CaseInsensitive);
}

<span><span>void</span> <span>ContactProxyModel::setFilterString</span><span>(<span>const</span> QString &filterString)</span>
</span>{
    <span>if</span> (m_filterString != filterString) {
        m_filterString = filterString;
        <span>emit <span>filterStringChanged</span><span>()</span></span>;
        <span>invalidateFilter</span>();
    }
}

<span><span>bool</span> <span>ContactProxyModel::removeContact</span><span>(<span>int</span> index)</span>
</span>{
    <span>if</span> (DataModelProxy *model = <span>qobject_cast</span><DataModelProxy*>(<span>sourceModel</span>())) {
        QModelIndex sourceIndex = <span>mapToSource</span>(<span>this</span>-><span>index</span>(index, <span>0</span>));
        <span>return</span> model-><span>removeContact</span>(sourceIndex.<span>row</span>());
    }
    <span>return</span> <span>false</span>;
}

过滤条件变了之后调 invalidateFilter(),proxy 会自动重新跑一遍过滤。它比 C++ 全量重建方案强的地方,在于不用 beginResetModel() + endResetModel() 触发视图整体重建——ListView 不用扔掉已有的项重建,滚动位置、正在显示的 delegate 都更稳,这是它在大数据量下体验好的关键原因。

增删改操作有个容易踩的坑:QML 里拿到的 index 是 proxy 模型的索引,不是源模型的。删除之前必须用 mapToSource() 把 proxy 索引转换成源模型索引,不然删的就不是你想删的那条了。编辑也是同理。

源模型那边就是标准的 QAbstractListModel 实现,构造函数里一次性生成 10 万条随机联系人数据,用 beginInsertRows() + endInsertRows() 批量插入,比一条一条加快很多。

适用场景:大数据量列表、需要实时搜索过滤、需要排序的场景。

两种方案怎么选

C++ 直接实现搜索QSortFilterProxyModel 过滤
数据量小到中等(千级以内)大(万级以上)
刷新方式每次 `beginResetModel()` 重建整个视图`invalidateFilter()` 重跑过滤,不重建视图
大数据下体验重置会丢掉滚动位置、重建项,会卡索引统一由 proxy 映射,滚动/编辑更稳
代码复杂度简单,一个类搞定多一层代理,索引要转换
排序支持自己实现自带,`setSortRole()` 就能用
适用场景简单业务列表、数据量可控通讯录、商品列表、日志列表

简单说:数据少、功能简单,直接在 QAbstractListModel 里写搜索就行;数据多或者以后可能要加排序,直接上 QSortFilterProxyModel,前期多写几行代码,后面省事很多。


已验证环境: