1. 重新认识QCompleter:它到底帮你做了什么
1.1 一套编辑框补全机制,不是一个简单弹层
大多数人对QCompleter的印象停留在"给QLineEdit加个自动补全"这个层面,无非是准备好一个QStringList,setCompleter一行代码接上,然后下拉列表里能出候选词。但真正到了进阶应用场景,你会发现QCompleter是一套由四个部分协同工作的完整补全机制:
- 数据源 Model:决定候选数据从哪来、怎么组织、携带哪些隐藏信息
- 过滤器 Filter:决定输入什么文本时,哪些行能出现在候选列表里
- 弹层视图 Popup:决定候选内容怎么展示、长什么样、是否支持复杂布局
- 编辑器联动 Editor:决定选中候选后,如何回填文本、如何触发业务逻辑
这四部分里,默认实现帮我们做了最基础的拼接:QStringListModel当作数据源,Matcher按前缀匹配过滤,popup是一个基础QListView,editor联动则是把选中项文本写回输入框。这套默认链路在小数据量、单列文本的场景下表现尚可,一旦遇到多列数据、图标、拼音匹配、分组展示、异步加载,就必须深入到每个环节去做定制。
我在实际项目中接手过一个"本地文件搜索器"的补全框,初期只用默认QCompleter配QStringList,出来的效果是:候选列表只有纯粹的文件名,没有路径提示,没有图标区分,匹配逻辑对中文拼音完全无效,几千条数据滚动时还有明显卡顿。后来把四层全部拆开重新设计,效果才算是真正像一个"智能搜索框"。
1.2 三个补全模式与过滤行为的内在差异
很多人只知道setCompletionMode存在,却说不清三种模式的实际体验差异。这里直接讲清楚:
| 模式 | 行为 | 适用场景 |
|---|---|---|
| PopupCompletion | 输入时弹出候选列表,回车或点击后选中项替换输入框文本 | 最常见,适合大多数搜索补全场景 |
| InlineCompletion | 不弹列表,直接在输入框内补全剩余部分,需要用户按Tab或回车确认 | 输入速度要求高、不想弹层的场景 |
| UnfilteredPopupCompletion | 弹层一经触发就不过滤,始终显示全部数据 | 下拉选择器、历史记录列表,不需要随输入收窄时 |
三种模式可以动态切换。我之前做过一个需求:用户输入时先走InlineCompletion快速补全,同时按Down键希望弹出完整候选列表,这时就需要在事件处理里临时切到PopupCompletion模式,再配合eventFilter把两次补全行为衔接起来。默认模式下,切换模式是实时生效的,不需要重建QCompleter,这为交互层留了非常大的操作空间。
过滤行为的细节同样容易被忽略。QCompleter默认的匹配策略是MatchStartsWith,也就是只看前缀。对中文用户来说,这意味着输入"be"匹配不到"北京",输入"微信"匹配不到"weixin"。真正的进阶用法一定绕不开自定义过滤器,而setFilterMode和setCaseSensitivity只是参数层面的开关。真正要改的是 QSortFilterProxyModel 的 filterAcceptsRow,或者自己写一个Matcher挂到setMatcher上。这两个入口是补全逻辑的核心分水岭,后面章节会展开细说。
2. 数据源从QStringList到自定义Model的进阶
2.1 QStringListModel为什么不够用
QStringListModel在进阶场景下几乎立刻碰壁。第一是它只能展示单列字符串,没法携带id、路径、拼音、图标这些隐藏信息;第二是它不能分组,候选列表里塞了二三十条相似内容时,用户根本分不清哪些是历史记录、哪些是搜索结果、哪些是热门推荐;第三是它的数据更新方式太粗暴,setStringList会触发全量reset,数据量一大,弹层滚动位置和选中状态全部丢失。
如果你只是做一个几十条数据的静态城市列表,QStringListModel完全没问题。但一旦候选数据来自数据库、接口、本地文件索引,或者需要做多字段匹配,就必须切换到自定义Model。
这里我特别想强调"携带隐藏数据"的价值。搜索场景里,用户看到的候选文本是"北京",但回车触发业务逻辑时,需要的其实是城市id。如果你用QStringListModel,selected文本回填后你还得反向查一遍id;而自定义Model可以直接在Qt::UserRole里存id,activated信号里拿到QModelIndex再取UserRole,省掉一次映射查询。这个习惯早期养成,后面数据量大了会非常省心。
2.2 实现自定义Model的关键点
自己写一个继承自QAbstractListModel的类其实比想象中简单,真正要命的是几个细节。
先说必须实现的接口:
- rowCount:返回总行数,注意model被proxy过滤时,这个接口返回的是源model的行数
- data:根据index.row()和role返回对应字段,DisplayRole返回候选显示文本,DecorationRole返回图标,UserRole返回隐藏数据
- index:默认继承自QAbstractListModel的实现没问题,唯一要注意的是行号对应关系
再放一个可以直接用的示例:
class EntryModel : public QAbstractListModel { Q_OBJECT public: struct Entry { QString displayText; QString pinyin; QString secondaryText; QIcon icon; QVariant userData; }; int rowCount(const QModelIndex &parent = QModelIndex()) const override { return parent.isValid() ? 0 : m_entries.size(); } QVariant data(const QModelIndex &index, int role) const override { if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) return QVariant(); const Entry &e = m_entries.at(index.row()); switch (role) { case Qt::DisplayRole: return e.displayText; case Qt::DecorationRole: return e.icon; case Qt::UserRole: return e.userData; default: return QVariant(); } } };有人会问:多列数据怎么办?比如候选里同时要显示"城市名"和"所在省份"。两个办法:一种是把两段文本用分隔符拼进displayText,另一种是把第一列作为主文本、其他列通过弹层视图展开。后者更适合弹层用QTableView的场景,稍后在弹层定制章节展开。
2.3 搭配QSortFilterProxyModel实现多字段匹配
真正进阶的匹配逻辑,是让用户既能用中文名搜,又能用拼音首字母搜,还能用英文名搜。这种多字段匹配在QCompleter默认Matcher下做不到,但配合QSortFilterProxyModel就非常顺手。
原理是:QCompleter内部使用QCompleter::setFilterMode来过滤,而filterMode最终委托给proxy model的filterAcceptsRow。所以只要在proxy里自己实现过滤逻辑,就能完全接管"哪些行该出现"。
class SearchFilterProxy : public QSortFilterProxyModel { Q_OBJECT public: void setFilterText(const QString &text) { if (m_filterText == text) return; m_filterText = text; invalidateFilter(); } protected: bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override { if (m_filterText.isEmpty()) return true; const QModelIndex idx = sourceModel()->index(sourceRow, 0, sourceParent); const QString display = idx.data(Qt::DisplayRole).toString(); const QString pinyin = idx.data(Qt::UserRole + 1).toString(); const QString secondary = idx.data(Qt::UserRole + 2).toString(); const QString lower = m_filterText.toLower(); return display.toLower().contains(lower) || pinyin.toLower().startsWith(lower) || secondary.toLower().contains(lower); } private: QString m_filterText; };注意filterAcceptsRow里不要直接操作sourceModel的index()以外的接口,也不要在这儿做耗时计算,因为它会在每次输入变化时被调用多次。拼音字段最好在数据加载时就生成好存进内存,临时转换主文本耗时不可控。
还要注意一个坑:默认情况下,QCompleter会调用proxy的setFilterFixedString或setFilterWildcard来设置过滤串,这会把我们的自定义filterText覆盖掉。所以设置了自定义proxy后,不要再调用setFilterMode,而是在QCompleter的文本变化信号里直接调用proxy的setFilterText。这一步是很多自定义proxy"明明写了但就是不生效"的根源。
3. 定制候选弹层:不止是改一改样式
3.1 替换popup视图的两种方式
QCompleter默认的popup是一个QListView,很多项目只通过样式表改改背景色就结束了。但进阶需求往往是:候选里要显示两列文本、要显示缩略图、要有"最近搜索"和"热门推荐"的分组标题。这个时候QListView就撑不住了。
第一个做法是直接用setPopup(QTableView)。注意这里不只是换一个视图类型,而是要设置对应的Model和Column:
QTableView *tableView = new QTableView; tableView->setSelectionBehavior(QTableView::SelectRows); tableView->setEditTriggers(QAbstractItemView::NoEditTriggers); tableView->setShowGrid(false); tableView->verticalHeader()->hide(); tableView->horizontalHeader()->hide(); QCompleter *completer = new QCompleter(model, this); completer->setPopup(tableView); completer->setCompletionColumn(0);这里completer内部的ProxyModel需要把第二列数据也带过来,建议在自定义proxy的data实现里,针对DecorationRole和DisplayRole做列转发,而不是真的让源model变成多列。
第二个做法是彻底接管弹层内容,做一个自定义QWidget塞进去。这个方案适合弹层里嵌入按钮、链接、加载进度条等复杂交互组件。做法是:
QWidget *popup = new SearchPopupWidget; completer->setPopup(popup);但要注意,自定义popup必须自己处理showEvent时的相对位置计算,还要把QCompleter内部的popup导航事件接住,否则键盘上下键无法在列表里移动。这个方案自由度最高,工作量也最大,非必要不建议第一个项目就搞这么重。
3.2 让弹层具备"无结果提示"和分组感
"无结果提示"是很容易被忽略的体验细节。用户输入关键词后弹层里一片空白,体验上就像功能坏了。解决思路是在proxy过滤之后判断源数据是否为空,然后动态往model里插入一行占位文本。
我在项目里的做法是:自定义一个TotalModel包一层占位逻辑,过滤前记录有效行数,如果filter后没有行,则临时追加一行内容为"没有找到匹配项"且禁用点击。占位行在下次过滤时清掉,避免残留。
分组感方面,如果数据本身带有"类型"字段,比如"城市""景点""酒店",最好的办法不是强行让QListView做分组,而是把分组名显示在每行文本的左侧或右侧,用样式表里的颜色区分。用QTableView的话,可以画出第一个分组行跨整行的代理效果,不细分单元格背景,视觉上就有分组的味道。
3.3 样式表实战:弹层里踩过的坑
给popup写样式表时,最容易踩的坑是选择器写不对。因为QCompleter的popup是在menu的frame里的,直接给completer->popup()设置样式经常不生效,正确姿势是先拿到底层视图再设置:
QAbstractItemView *view = completer->popup()->findChild<QAbstractItemView *>(); view->setStyleSheet(R"( QListView { background: #ffffff; border: 1px solid #dce0e6; outline: 0; } QListView::item { height: 36px; padding-left: 12px; color: #333; } QListView::item:selected { background: #e8f0fe; color: #1a73e8; } )");另一个经验是给popup设置固定宽度。默认情况下popup宽度由QCompleter根据输入框和内容自动决定,但内容长度差异大时宽度会跳来跳去,观感非常不专业。我一般会在输入框resizeEvent里同步设置:
completer->popup()->setFixedWidth(lineEdit->width());这样弹层宽度稳定,滚动条也不飘。
4. 交互细节:文本联动、选中行为与键盘事件
4.1 activated和highlighted不是一回事
QCompleter有两个容易混淆的信号:activated和highlighted。activated在用户按下回车、双击选中项或点击选中项时触发,是"我确定了这个候选";highlighted则是在鼠标悬浮或者键盘上下移动时触发的,是"我暂时看着这个候选"。
进阶场景里这两个信号的用途完全不同。highlighted适合用来做"预览",比如在搜索框下方的详情面板里实时显示候选对应的摘要信息;activated才适合做真正的业务跳转或文本回填。如果一个逻辑挂在highlighted上,用户只是上下浏览键盘方向键,你的业务代码就会被触发七八次。
实际遇到过一个很典型的问题:用户用键盘选择候选时,highlighted信号连续触发三次,导致详情面板反复刷新、接口被重复请求。解决方法是给highlighted槽函数加一个QtConcurrent去抖或者简单加个滞后QTimer,等用户停止移动150ms再刷新。真正落地的逻辑放到activated里处理。
4.2 阻止编辑器文本被"粗暴替换"的InsertPolicy技巧
有个行为很多人初用时很不适应:选中候选后,编辑器里的整段文本被替换成候选文本,哪怕用户原本输入了"北"字样前缀。默认的InsertText policy确实是全量替换,在搜索框场景里往往不是我们想要的。
要保留前缀、只补充剩余部分,可以用InsertInline模式配合inline completion。但如果你希望"选中的候选文本作为最终文本进入输入框"且保留光标位置,最稳妥的方式是自己接管输入框的setCompleter默认回填逻辑:
QCompleter的activated(QString)信号里,槽函数手动setText到编辑框,并在setText后主动调用setCursorPosition到末尾。注意在activated里setText之后,补全器内部的当前文本状态需要同步,否则下一次输入时过滤器还拿旧状态去算。
还有一种不太好找文档的CustomInsertPolicy用法:
class CustomCompleter : public QCompleter { protected: void insertText(const QString &text) const override { QLineEdit *edit = qobject_cast<QLineEdit *>(widget()); if (!edit) { QCompleter::insertText(text); return; } edit->setText(text + edit->text().mid(edit->cursorPosition())); edit->setCursorPosition(text.size()); } };这里覆盖insertText,可以在选中回填时精确控制文本插入位置,适合代码编辑器、命令输入的场景。
4.3 输入法与键盘事件的冲突处理
中文输入法状态下,QCompleter经常会出现"拼音还没上屏就触发补全"的尴尬。比如用户输入"bei",输入法处于组字状态,但QCompleter已经拿"bei"去做匹配了,说白了就是"还在组词的时候就跳候选"。
常规解法是给输入框装eventFilter,监听QEvent::InputMethodStart和QEvent::InputMethodEnd。在preedit进入时,暂时断开completer的弹层;preedit结束时再恢复。断开方式不是setWidget(nullptr),而是用一个bool开关在proxy里控制是否接受过滤,或者直接调用completer->popup()->hide()。
键盘方面,最需要注意的是Down键默认会让QCompleter切换高亮项,但在某些编辑器里Down键应该滚动,而不是切换候选。一般做法是在eventFilter里判断:如果弹层没显示,Down键走默认编辑器行为;如果弹层显示了,Down键交给completer处理。QCompleter内部对方向键的navigation已经做了封装,不要去重新实现一份,容易跟补全器内部状态冲突。
5. 大数据量下的性能与异步补全
5.1 哪些操作会造成卡顿
补全卡顿的来源主要不是"候选数据多",而是"每次输入都全量走一遍过滤+重建弹层"。最典型的三类:
- 在大数组上设置了setStringList,每次输入变化都触发全量列表替换
- proxy里的filterAcceptsRow做了实时文本转换、正则匹配,甚至查数据库
- popup是QTableView但没有开启uniformRowHeights,滚动时频繁计算行高
前两者属于逻辑层性能问题,第三者是绘制层性能问题。先把这三类问题对号入座,再去谈优化。
性能优化的核心原则是:让"每次输入触发"的工作量降到最低,把能缓存的都缓存起来。数据总量一万条并不算大,但如果你在过滤函数里做了IO或者正则,一万条乘以每次输入的调用次数,照样能卡死主线程。
5.2 延迟搜索与取消过期请求
高频输入场景下,补全请求不应该在每一次textChanged时都立刻执行。我惯用的防抖做法:
QTimer *m_searchTimer = new QTimer(this); m_searchTimer->setSingleShot(true); m_searchTimer->setInterval(300); connect(m_input, &QLineEdit::textChanged, m_searchTimer, qOverload<>(&QTimer::start)); connect(m_searchTimer, &QTimer::timeout, this, &ThisClass::performSearch);300ms是在"响应速度"和"请求频率"之间的平衡点。用户连续打字时,只有停顿超过300ms才真正去搜索,这个体感在搜索框里是符合预期的。
异步搜索场景还要处理"过期请求"。假设用户先输入"abc",发起一个耗时搜索,紧接着改成"abcd",又发起一个新搜索。当第一个搜索返回结果时,它的关键词已经过时了,绝对不能把结果覆盖到界面。解决办法是维护一个自增serial:
void startSearch(const QString &keyword) { const int serial = ++m_searchSerial; QtConcurrent::run([this, keyword, serial]() { auto results = searchData(keyword); QMetaObject::invokeMethod(this, [this, keyword, results, serial]() { if (serial != m_searchSerial) return; applyResults(results); }, Qt::QueuedConnection); }); }这里serial语义是"本次最新请求的编号",返回结果和当前编号不一致直接丢弃。凡是做过异步补全项目的人,基本都在这上面栽过跟头。
5.3 批量刷新技巧与弹层滚动优化
数据模型更新时,不要边加载边调用beginInsertRows/endInsertRows。一次加载一万条,正确方式是先构造完整容器,再一次beginResetModel/endResetModel。否则会触发几千次信号,主线程光应付model信号就卡成PPT。
对于proxy过滤,如果源数据本身不变、只是换了一个filter文本,放弃resetter,用invalidateFilter()并伴以数据批量更新。QCompleter内部会识别到model的变化并更新popup,只要不触发源数据reset,弹层的选中状态就能尽量保持。
弹层滚动优化上,QListView在固定行高下滚动效率很高。如果你的候选行只有单行文本和固定图标,把uniformItemSizes设为true,滚动性能立竿见影。QTableView的话记得同步开启setUniformRowHeights(true)。
6. 实战组合:一个带图标、分组、拼音匹配的搜索框
6.1 需求设定
综合前面的全部知识点,收尾做一个真正可以放进项目的组合案例。需求很简单直接:
- 候选数据一万条,每条包含名称、拼音、分类、图标
- 支持中文名模糊匹配、拼音首字母前缀匹配
- 弹层用QTableView展示两列:名称和分类说明
- 输入300ms防抖后异步过滤
- 无结果时弹层显示占位行
- 选中候选后回填名称并触发业务逻辑
6.2 代码骨架:模型、过滤器、弹层组装
数据模型沿用2.2节的EntryModel,扩展一下UserRole存放分类信息和拼音数据。核心组装都在一个初始化函数里:
void setupSearchBox(QLineEdit *edit, const QVector<EntryModel::Entry> &entries) { auto *model = new EntryModel(entries, edit); auto *proxy = new SearchFilterProxy(edit); proxy->setSourceModel(model); auto *completer = new QCompleter(proxy, edit); completer->setCompletionMode(QCompleter::PopupCompletion); completer->setCaseSensitivity(Qt::CaseInsensitive); completer->setCompletionColumn(0); completer->setFilterMode(Qt::MatchContains); completer->setMaxVisibleItems(10); auto *table = new QTableView; table->setModel(proxy); table->setSelectionBehavior(QAbstractItemView::SelectRows); table->setEditTriggers(QAbstractItemView::NoEditTriggers); table->setShowGrid(false); table->verticalHeader()->hide(); table->horizontalHeader()->hide(); table->horizontalHeader()->setStretchLastSection(true); table->setUniformRowHeights(true); completer->setPopup(table); edit->setCompleter(completer); table->hideColumn(0); }注意table->hideColumn(0):QTableView的显示列和QCompleter的completionColumn是独立的,这里把源数据的显示文本列藏在第0列,而第二列放分类说明。实际上更完整的做法是在代理里把两列内容都映射到同一列,避免hideColumn的副作用。
防抖逻辑直接放onTextChanged,里面调用proxy->setFilterText,并通过serial丢弃过期请求。占位行的处理可以放在EntryModel::filterAccepted行数不足时自动追加一条特殊Entry,并把UserRole标记为无效。
6.3 运行效果观察与两个容易被忽略的边界情况
组合之后实测,输入"bj"能出现"北京",输入"商场"能匹配到"北京朝阳大悦城"这类名称含"商场"的条目,图标和分类说明在第二列显示,无结果时弹层显示灰色提示行。键盘上下键移动时highlighted触发预览面板更新,回车触发activated。
两个边界情况要特别提醒:一是空关键词状态下的行为。"过滤前显示全部数据"和"过滤前空白弹层"是两种产品策略,前者适合有历史记录功能,后者适合纯粹的搜索框。如果选前者,记得在代理的filterAcceptsRow里对空文本返回true。
二是选中文本进入编辑器后的二次过滤问题。用户选完"北京朝阳大悦城"后,如果手滑又输入了一个字,此时编辑器文本变成了"北京朝阳大悦城新",而补全器的当前文本状态应该是对应新关键词的。如果发现过滤结果跟预期不一致,检查completer->widget()返回的编辑框是否一直在使用当前文本作为过滤依据。常规情况下QCompleter会自己同步,但手动setText后需要主动发一个textChanged的信号,很多"选中后继续输入不刷新"的怪现象就是这么来的。
7. 个人经验谈:QCompleter进阶的几个思维转变
我用了三年QCompleter,前后在三个项目里把它改造得完全不像默认控件。最大的体会是:别把QCompleter当"一个控件"来用,而是把它当成"一条数据处理链"来理解。数据怎么存、怎么过滤、怎么展示、怎么回填,每一步都是独立设计点。
早期看网上很多示例,都是给QCompleter塞一个QStringList就收工,导致我一度以为它就是个轻量级玩具。后来自己踩过拼音匹配失效、popup宽度漂移、异步结果覆盖、输入法预编辑冲突这一堆坑之后,才慢慢摸清它真正的能力边界。
最后分享一个不算技巧但非常实用的习惯:凡是用了自定义Model的场景,一定要把UserRole这一层用起来。哪怕现阶段只有显示文本,也把id或者原始JSON塞进UserRole,后续要加逻辑的时候就不用回头再查一遍。这个习惯帮我省了非常多返工时间。