拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Qt Address Book 示例精讲:Model/View 通讯录实战

Qt Address Book 示例精讲:Model/View 通讯录实战 1. 先搞清楚这个 Address Book 到底是什么东西如果你在 Qt 5.14.2 的 Examples 里翻过一圈会发现一个名字特别朴素、存在感却很高的示例Address Book。它不炫技没有花哨的动画也不涉及网络和多线程就是老老实实做了一件事——用 Qt Widgets 写一个能增删改查的通讯录。但恰恰是这种小而不简的例子把 Qt 里最值钱的那套东西Model/View 架构、信号与槽、自定义对话框、数据校验、代理模型全都压缩在十几个文件里讲完了。对刚从QLabelQPushButton拖控件阶段走过来的人来说它是性价比最高的一课对写了两三年 Qt 的老手来说它又是一份可以随时拿出来当模板的骨架代码。我自己第一次认真读这个示例是因为手上一个小工具要用表格展示几百条配置项当时用QTableWidget一行行setItem填数据代码写得又臭又长改个字段还要动三处。后来把 Address Book 的TableModel抄过来改成自己的模型类代码量掉了一半逻辑还清晰了。所以这篇文章不打算做示例说明书而是按我实际改造它的顺序把结构、代码、坑和二次开发路径完整讲一遍。适合手里已经有 Qt 5.14.2 环境、能跑通 Hello World 但还没真正理解 Model/View 的读者也适合想找一个干净脚手架做内部工具的人。需要先说明一点Qt 的 Examples 里与 Address Book 相关的示例并不是孤零零一个Widgets 版本在widgets/itemviews/addressbook下另外还有一套基于 Qt Quick Controls 的通讯录教程工程两者思路相通但技术栈完全不同。本文以 Widgets 版为主线最后一章再对比 QML 版避免刚上手就被两套东西绕晕。2. 示例位置、目录构成与打开方式2.1 在本地磁盘上把它找出来Qt 5.14.2 装好之后Examples 通常在编译套件目录下的examples文件夹里。常见的几个位置长这样WindowsMinGW 套件C:\Qt\Qt5.14.2\5.14.2\mingw73_64\examples\widgets\itemviews\addressbookWindowsMSVC 套件C:\Qt\Qt5.14.2\5.14.2\msvc2017_64\examples\widgets\itemviews\addressbookLinux~/Qt5.14.2/5.14.2/gcc_64/examples/widgets/itemviews/addressbookmacOS~/Qt5.14.2/5.14.2/clang_64/examples/widgets/itemviews/addressbook如果你装的时候勾掉了 Examples 组件这个目录就是空的别怀疑自己找错地方了回去用维护工具把对应的示例组件补上即可。另外还有一种更省事的找法打开 Qt Creator进欢迎页的 Examples 面板搜索框里敲addressWidgets 分类下就能直接看到双击打开工程什么都不用配。我一般推荐新手走 Qt Creator 这条路因为它会自动帮你选好编译套件和构建目录少踩一轮环境坑。顺带提一句路径习惯安装目录尽量别放中文路径和带空格的目录尤其 Windows 上某些工具链在解析路径时对空格的处理很容易出幺蛾子。这不是 Qt 独有的问题但我见过太多编译报一堆奇怪错、换个路径就好了的案例。2.2 目录里都有什么打开这个工程目录.pro文件之外通常是这几个成对的源文件main.cpp程序入口创建一个AddressWidget并显示addresswidget.h/cpp主窗口级的容器控件负责把模型、视图、对话框串起来tablemodel.h/cpp自定义表格模型数据实际存在这里adddialog.h/cpp新增联系人用的输入对话框newaddresstab.h/cpp通讯录为空时显示的引导页.pro文件qmake 工程描述文件数量不多但分层非常清楚数据层、视图层、交互层各管一摊。这种拆法不是为了看起来专业而是有实际收益——等你哪天要把通讯录从表格换成列表、或者把数据源从内存换成数据库改动只落在一个文件里其他部分不用动。这也是我在自己的项目里一直沿用这套命名习惯的原因。2.3 三条打开与编译的路径第一条是 Qt Creator 图形化打开.pro选套件点运行。第二条是纯命令行 qmake适合在没有 IDE 的机器或 CI 上跑cd addressbook /path/to/Qt5.14.2/5.14.2/gcc_64/bin/qmake addressbook.pro make -j4 # Linux / macOS mingw32-make # Windows MinGW第三条是直接用 CMake 重写工程描述。Qt 5.14.2 里 qmake 还是主力但如果你想把这套代码并进一个已经用 CMake 的项目find_package(Qt5 COMPONENTS Widgets REQUIRED)加target_link_libraries(app PRIVATE Qt5::Widgets)再把源文件列表抄过去就行代码本身一行不用改。我个人现在建新工程的默认做法就是 CMake因为后面接第三方库比如日志库、序列化库时比 qmake 顺手得多。注意示例工程默认是 qmake 的.pro不要直接把它拖进 CMake 工程里当子目录用容易因为QT widgets这类语句不生效而报找不到头文件。3. 整体设计拆解三个零件拼一辆车3.1 Model 与 View 的分工到底是怎么切的初学者最容易困惑的点是为什么不用QTableWidget直接塞数据非要绕一圈自定义模型答案在于谁持有数据这件事。QTableWidget把数据存在控件内部的 item 里控件和数据是绑死的而 Model/View 把数据抽到QAbstractTableModel子类里视图只负责画。这样一来同一份数据可以同时挂给表格、列表、下拉框甚至导出成 CSV 时直接遍历模型不用去问界面要数据。在这个示例里TableModel继承QAbstractTableModel内部持有一个QPairQString, QString的列表分别对应姓名和地址两列。视图是QTableView它通过rowCount()、columnCount()、data()、headerData()这几个虚函数向模型要数据模型通过dataChanged、rowsInserted这类信号通知视图刷新。双向的契约就这么多非常干净。理解这个契约后面所有疑惑都会自动消失。比如为什么我改完数据界面没变多半是你直接改了内部列表却没发dataChanged信号为什么删除行时程序崩了多半是忘了用beginRemoveRows/endRemoveRows成对包裹。3.2 为什么视图外面还要套一层 QTabWidget示例里真正显示在窗口上的不是QTableView而是一个继承QTabWidget的AddressWidget。它在构造时挂两个页签一个是表格页一个是新增联系人的引导页。这么做解决的是一个很实际的交互问题——通讯录一条数据都没有的时候一个空表格对着用户是非常不友好的什么都点不了也不知道该干嘛。用页签切换来实现空态和列表态是种很轻的写法比手动hide()/show()两个控件更省心因为页签的显示逻辑由QTabWidget自己管你只需要在新增第一条数据时把引导页removeTab掉或者在删空数据时把引导页加回来。我在类似场景里试过三种做法手动切换可见性、用QStackedWidget、用QTabWidget最后发现页签写法代码最少而且状态不容易错乱。有人会问页签栏露出来不丑吗切到空态时确实会看到一个孤零零的页签。想再干净一点可以把页签栏隐藏掉只借用它的切换机制。不过这属于美化范畴示例本身没做你自己动手时按需选择即可。3.3 信号与槽是怎么把零件串起来的整个示例的连接关系其实只有几条线引导页上的按钮点击 → 新建对话框 → 对话框确认 → 往模型插入一行工具栏或菜单里的删除动作 → 取当前选中行 → 从模型删除表格双击某行 → 打开编辑对话框 → 回写模型。每一条都是标准的一对一连接没有嵌套回调没有全局状态。这种扁平连接的写法值得刻意模仿。我刚写 Qt 那两年特别喜欢在MainWindow里堆一个巨大的onEverythingChanged()槽结果后期加功能就是噩梦。这个示例的做法是把职责下放到各自的类里模型只管数据对话框只管收集输入AddressWidget只做协调者。你去看它的代码会发现AddressWidget里的函数都很短因为脏活累活都分出去了。4. 核心类逐个过代码级别拆解4.1 TableModel把容器包装成表格模型的骨架逻辑就是那几个必须重写的虚函数。核心思路可以用下面这段我按同样思路重写的代码来理解行号细节和示例略有出入逻辑一致// tablemodel.h class TableModel : public QAbstractTableModel { Q_OBJECT public: explicit TableModel(QObject *parent nullptr); int rowCount(const QModelIndex parent QModelIndex()) const override; int columnCount(const QModelIndex parent QModelIndex()) const override; QVariant data(const QModelIndex index, int role) const override; QVariant headerData(int section, Qt::Orientation o, int role) const override; Qt::ItemFlags flags(const QModelIndex index) const override; bool setData(const QModelIndex index, const QVariant value, int role) override; bool insertRows(int row, int count, const QModelIndex parent QModelIndex()) override; bool removeRows(int row, int count, const QModelIndex parent QModelIndex()) override; private: QListQPairQString, QString m_contacts; // first 姓名, second 地址 };rowCount和columnCount决定表格的规模注意两个约定rowCount在parent.isValid()时应返回 0表格模型是平坦的没有子层级columnCount直接返回固定值 2。data()里根据role分流展示用返回Qt::DisplayRole编辑用返回Qt::EditRole对齐方式返回Qt::TextAlignmentRole这些角色不处理的话视图会有诡异的显示效果。headerData负责表头文字注意它对水平和垂直两个方向都会被调用所以要判断orientation。这一步新手常忘结果表格左边出现一堆 1、2、3 的行号。其实行号本身不算错但通讯录这种场景下左侧显示序号会让人误以为那是数据。4.2 行级增删begin/end 必须成对插入和删除是模型里最容易写崩的部分因为它们必须严格遵循通知—修改—通知的三段式bool TableModel::insertRows(int row, int count, const QModelIndex parent) { if (parent.isValid()) return false; beginInsertRows(QModelIndex(), row, row count - 1); for (int i 0; i count; i) m_contacts.insert(row, QPairQString, QString()); endInsertRows(); return true; } bool TableModel::removeRows(int row, int count, const QModelIndex parent) { if (parent.isValid() || row 0 || row count m_contacts.size()) return false; beginRemoveRows(QModelIndex(), row, row count - 1); for (int i 0; i count; i) m_contacts.removeAt(row); endRemoveRows(); return true; }beginInsertRows的第二个和第三个参数是新行插入后的区间beginRemoveRows是被删行删除前的区间。区间算错会直接导致视图内部索引错乱典型症状是删掉一行后表格里少了两行、或者程序在滚动时崩溃。我的经验是删行时永远从row开始重复removeAt(row)不要去算row i否则列表缩短后索引会越界。setData里做输入校验是最自然的落点。示例里对姓名列做了一次简单的正则过滤我按自己的需求改成了非空 长度限制顺便把提交逻辑写清楚bool TableModel::setData(const QModelIndex index, const QVariant value, int role) { if (!index.isValid() || role ! Qt::EditRole) return false; if (index.row() 0 || index.row() m_contacts.size()) return false; const QString text value.toString().trimmed(); if (text.isEmpty() || text.size() 64) return false; // 校验不过就静默拒绝视图会回滚编辑框内容 auto c m_contacts[index.row()]; if (index.column() 0) c.first text; else if (index.column() 1) c.second text; else return false; emit dataChanged(index, index, {Qt::DisplayRole, Qt::EditRole}); return true; }提示返回false时视图会放弃这次编辑但如果用户根本没看到反馈体验会很差。生产代码里建议在拒绝前弹个QToolTip或者状态栏提示让用户知道为什么没存上。4.3 AddDialog对话框的输入收集与信号回传对话框这部分没什么玄机但有两个细节值得说。第一用QDialogButtonBox而不是自己摆两个按钮这样确定/取消按钮在不同平台上的位置和顺序会自动符合系统习惯跨平台测试能省不少事。第二对话框不直接持有模型指针它只负责收集两个字符串通过信号或者提供 getter 把值交回去由AddressWidget决定往哪里写。这种对话框不碰数据的约束听起来像教条实际收益在测试阶段就体现出来了你可以单独实例化AddDialog模拟输入验证校验逻辑完全不需要构造整个模型和表格。我后来做的一个内部工具就吃了没这么干的亏对话框里直接model-insertRow()写单元测试时不得不把整个主窗口搭起来。输入校验的位置也有讲究。示例里姓名列用正则挡掉了空格和特殊字符。实践中我更推荐宽进严出输入时只挡明显非法的内容超长、纯空白在保存到磁盘或用它做文件名的环节再做一次严格过滤。这样用户输入带空格的姓名不会被无理由拒绝而真正会产生安全或兼容问题的地方依然有兜底。4.4 main.cpp 与资源文件入口文件短得可怜创建AddressWidget、resize一下、show然后进事件循环。这里唯一可能踩坑的是高 DPI 处理Qt 5.14.2 里默认的缩放策略在高分屏上可能让整个界面要么发糊要么过大。如果发现界面在 4K 屏上模糊可以在main里创建QApplication之前加上QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);示例工程本身可能没写这两行因为它是用来演示 Model/View 的不是演示屏幕适配的。但你自己拿它当脚手架时这两行基本是标配尤其是要发给别人用时。如果工程里有图标、翻译文件之类的资源resources.qrc也是入口的一部分。我建议从一开始就用.qrc管图标而不是在代码里写绝对路径原因很简单.qrc会把资源编进可执行文件拷到别的机器上不会因为找不到图标而显示空白。5. 动手改造把示例变成自己的项目5.1 改造一加一列电话并保持校验一致加列看起来只是改个常量实际涉及三个地方columnCount返回 3data和headerData里补上第三列的分支QPair换成结构体或三元组。我一般直接定义一个结构体比嵌套QPair好读太多struct Contact { QString name; QString address; QString phone; }; QListContact m_contacts;改完记得检查setData里的列判断漏掉一列的表现是能编辑但保存不进去而flags()里如果无条件返回Qt::ItemIsEditable用户会以为哪列都能改。更稳的做法是在flags里按列返回不同标志让地址列可编辑而电话列暂时只读等你把校验逻辑写完再放开。5.2 改造二接上 QSortFilterProxyModel 做搜索这是这个示例往上走一级的关键一步。业务量一上来几百条数据必须能搜。做法是在模型和视图之间插一层代理模型m_proxy new QSortFilterProxyModel(this); m_proxy-setSourceModel(m_model); m_proxy-setFilterKeyColumn(-1); // -1 表示所有列都参与匹配 m_proxy-setFilterCaseSensitivity(Qt::CaseInsensitive); m_proxy-setDynamicSortFilter(true); m_tableView-setModel(m_proxy); connect(m_searchEdit, QLineEdit::textChanged, m_proxy, QSortFilterProxyModel::setFilterFixedString);只要这几行搜索框敲字表格就实时过滤顺便setSortingEnabled(true)还能点表头排序几乎零成本。但这里有个必须先想清楚的坑视图现在挂的是代理模型所有视图给出来的索引都是代理索引而你的增删改操作面对的是源模型。新增一行时直接用m_model-insertRow(...)一般没问题但删除选中行时必须先转换const QModelIndex proxyIndex m_tableView-currentIndex(); if (!proxyIndex.isValid()) return; const QModelIndex srcIndex m_proxy-mapToSource(proxyIndex); m_model-removeRow(srcIndex.row());我见过有人直接拿proxyIndex.row()去删源模型的行在没过滤、没排序的时候侥幸能用一旦过滤了就删错行。这个 bug 特别隐蔽因为测试时往往数据少、随手一搜就恢复正常了上线后用户反馈删除联系人删掉的是另一个你还不一定复现得出来。另一个细节是过滤状态下新增数据。如果新增的行不满足当前过滤条件它不会显示出来用户以为没加上。稳妥方案是新增成功后清空搜索框或者给用户一个明确提示。这类小体验在示例里不会讲但实际项目里必须处理。5.3 改造三把数据落盘到 JSON示例的数据存在内存里关掉就没了。真要当工具用至少得存文件。Qt 自带QJsonDocument不引第三方库就能搞定void AddressWidget::saveToFile(const QString path) const { QJsonArray arr; for (const Contact c : m_model-contacts()) { QJsonObject o; o.insert(QStringLiteral(name), c.name); o.insert(QStringLiteral(address), c.address); o.insert(QStringLiteral(phone), c.phone); arr.append(o); } QFile f(path); if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { QMessageBox::warning(nullptr, tr(保存失败), f.errorString()); return; } f.write(QJsonDocument(arr).toJson(QJsonDocument::Indented)); }存储路径别硬编码用QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)拿到系统推荐目录再拼上文件名。这样在 Windows、Linux、macOS 上都能落到合规的位置不会出现往程序安装目录写文件导致权限失败的问题。加载侧就是反过来读 JSON 数组逐条构造Contact然后beginResetModel()/endResetModel()或者批量insertRows。数据量小的话直接beginResetModel最省事。写完保存后记得做一件事程序启动时如果文件不存在不要报错弹窗静默用空列表启动就行。首次运行的用户遇到文件未找到的报错框会觉得这软件坏了。6. 踩坑实录与常见问题速查6.1 编译期问题最常见的三类一是工程打不开报 Unknown module(s) in QT: widgets这是.pro里缺QT widgets或者你打开的套件根本没装 Widgets 模块——Qt 5.14.2 安装时如果只勾了 Qt Quick 相关组件Widgets 模块是不在的回维护工具补上。二是报找不到QApplication通常是套件选成了给嵌入式用的交叉编译套件换回桌面套件即可。三是 MinGW 和 MSVC 混用用 MSVC 套件却拿 MinGW 编出来的库去链接一堆undefined reference这种错误看第一行就够了不用往下翻。命令行编译时还有一个高频问题直接敲qmake用的是系统 PATH 里那个可能来自另一个 Qt 版本。稳妥写法是用绝对路径调对应版本 bin 目录下的qmake编完make出来的可执行文件才对应你想要的运行库。多版本共存是常态别指望 PATH 永远对。6.2 运行期问题数据改了界面不刷新排第一。原因几乎总是漏发dataChanged或者发了信号但区间和角色不对。判断方法很简单在data()里打个断点看看视图有没有重新来取数。如果没有问题在信号侧如果有但显示还是旧的问题在data()的返回值分支上。删行后崩溃排第二前面提过核心就是beginRemoveRows/endRemoveRows区间算错或者删除过程中触发了视图的重绘而你又在重绘回调里改了数据。解决思路是所有对内部容器的修改必须在这对函数之间且中间不要调用任何可能重入的代码。第三类是中文乱码。Qt 5 里字符串字面量默认按 UTF-8 解释但 MSVC 需要显式告诉编译器源文件编码否则中文字面量会变成乱码。办法是加/utf-8编译参数或者把源文件存成带 BOM 的 UTF-8。MinGW 下一般不用管但如果你在源码里看到了锟斤拷先查文件编码再查编译参数。6.3 常见问题速查表现象大概率原因处理办法界面显示旧数据修改容器后未发dataChanged修改完成后emit dataChanged(左上, 右下, roles)删除行后顺序错乱beginRemoveRows区间错误区间用row到row count - 1删时始终removeAt(row)表格可编辑但保存无效setData未覆盖该列或返回 false检查列分支和校验条件必要时加提示搜索后删除删错行代理索引未转源索引proxy-mapToSource()后再操作源模型程序关闭后数据丢失未做持久化用 JSON 或 SQLite 落盘路径走QStandardPaths中文显示为乱码源码编码或编译参数问题MSVC 加/utf-8源码保存为 UTF-8高分屏界面模糊未启用高 DPI 支持创建QApplication前设置AA_EnableHighDpiScaling新增后看不到新行当前有过滤条件新增后清空搜索框或提示用户6.4 几条只有动手才会知道的经验第一条别急着删示例里那个空态引导页。刚开始我觉得它多余删掉之后发现新增数据的入口没了——因为示例里新增按钮就在引导页上而工具栏菜单是可选的。删之前先确认新增入口有几处。第二条QTableView的列宽策略值得调一调。默认列宽会让地址列被截断加一句m_tableView-horizontalHeader()-setSectionResizeMode(1, QHeaderView::Stretch)让地址列自适应观感立刻不一样。第三条双击编辑和单击选中的冲突。默认策略下双击进编辑如果你自己重写了doubleClicked信号去弹编辑对话框会出现编辑框和对话框同时冒出来的诡异现象。要么用QAbstractItemView::NoEditTriggers关掉就地编辑全部走对话框要么保留就地编辑不要绑双击信号二选一别都上。7. Qt Quick 版 Address Book 与 Widgets 版怎么选7.1 QML 版的结构差异Qt 5.14.2 里另有一套基于 Qt Quick Controls 的通讯录工程它跟前文的 Widgets 版最大的区别在于数据层和界面层的组织方式。界面用ListView加delegate描述每一行长什么样数据用ListModel或者从 C 侧暴露的模型对象。搜索过滤这部分QML 版通常是从 C 侧注册一个继承QSortFilterProxyModel的类给 QML 用而不是在 QML 里手写过滤逻辑。这里有个容易被忽略的点C 类型要能在 QML 里new或者直接当模型用需要在main.cpp里注册比如qmlRegisterTypeSortFilterProxyModel(AddressBook, 1, 0, SortFilterProxyModel)然后 QML 里import AddressBook 1.0。注册名、版本号和 import 语句必须一致任一处拼错报的错都是类型未定义新手经常在三个文件之间来回找。ListView的delegate里做交互也很直观每个条目是一个Item里面塞Label和MouseArea点一下触发删除或编辑。但要注意delegate会被大量复用里面尽量避免创建昂贵的对象否则滚动时会卡。7.2 两套写法怎么选维度Widgets 版Qt Quick 版上手门槛需要理解 Model/View 契约需要理解 QML 绑定与上下文界面表现力传统桌面风格稳定克制动画、自定义样式更容易大数据量列表QTableView表现稳定需要留意delegate复用成本与现有 C 代码融合直接继承即可需要注册类型、处理上下文属性适合场景内部工具、工业软件、表单密集型触摸屏界面、消费级产品、需要动效我的实际建议是做内部工具、配置面板、数据录入这类信息密度高、动效无所谓的东西直接用 Widgets 版当底子改造路径最短。如果是给触摸屏设备做界面或者产品明确要求视觉上有滑动、淡入这类效果再考虑 QML 版。两者不是替代关系很多项目是主窗口用 Widgets、个别展示页塞QQuickWidget混合使用这条路线在 Qt 5.14.2 上是完全可行的代价是要处理好 C 与 QML 之间的数据传递边界。另外提醒一句Qt Quick Controls 的版本选择会影响可用控件。Qt 5.14.2 里 Controls 2 是主力样式系统跟 Controls 1 完全不同网上搜到的老代码里如果出现QtQuick.Controls 1.x的TableView大概率不能直接抄。看到 import 版本号先停一下确认是不是同一套东西能省掉很多明明照抄却报错的时间。最后分享一个我自己的习惯每次动这种示例工程第一件事是把它复制一份改名再改不直接在原目录上动刀。示例本身是很好的对照答案改坏了随时能翻回来看原始版本是怎么写的。这个习惯看起来土但在我改崩过三次之后就再也没丢过。
返回列表