ARTICLE DETAIL

资讯详情

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

Qt 5.15 登录页实战:QSS、信号槽、QSettings 与线程校验

Qt 5.15 登录页实战:QSS、信号槽、QSettings 与线程校验 现在做桌面端的同学十个里有八个第一个像样的界面就是登录页。看起来简单两个输入框、一个按钮、一个勾选框。可真动手写你会发现它把 Qt 里最容易踩的几块内容全串起来了——布局、样式表、信号槽、焦点管理、事件过滤、配置文件、甚至连数据库线程都躲不掉。这篇就把我自己做过的几版登录页从零到能打包发布的全过程摊开讲包含控件怎么摆、QSS 怎么写才能生效、回车登录怎么接、密码怎么存、记住我该存什么不该存什么以及那些文档里不会写但一定会让你卡半天的坑。适合刚学完 Qt 基础、想做一个能拿得出手的登录界面的同学也适合做了几年但界面一直灰扑扑、想补一补样式和交互细节的开发者。下面所有代码基于 Qt 5.15 CQt 6 下有差异的地方我会单独点出来。1. 从一张草图到能跑的窗口登录页要先拆成四层1.1 别急着拖控件先把界面拆成容器—输入—动作—反馈很多人拿到登录页的需求第一反应是打开 Designer 往上拖 QLineEdit 和 QPushButton拖完发现想加个圆角卡片背景、想在输入框里塞个小图标就开始一层一层套 QWidget最后层级乱成一团改个间距要动五六个地方。问题出在没先分层。我习惯把登录页固定拆成四层从外到内页面层整个窗口负责背景纯色、渐变或者一张大图、窗口尺寸和居中逻辑。卡片层一块带圆角和阴影的容器里面装所有交互控件。它的作用是视觉聚焦让用户一眼知道要操作的就是这一块。输入层账号框、密码框、以及可选验证码框。它们负责收集和初步过滤用户输入。动作与反馈层登录按钮、记住我勾选框、忘记密码链接、以及一行错误提示文字。这样拆的好处是QSS 写起来是干净的。页面层管背景卡片层管圆角和边距输入层管边框和聚焦态动作层管按钮三态。层级一清楚样式表基本一次就能写对不用反复试选择器。具体尺寸上我用的是 8 像素栅格卡片内边距 32控件之间垂直间距 16输入框高度 44按钮高度 44圆角 8。这几个数不是随便定的44 是手指和鼠标都舒服的最小点击高度8 的倍数是为了在不同缩放比例下不出现半像素模糊。1.2 用 Designer 搭骨架还是纯代码写我选了混合方案这个问题吵了很多年。我的结论是静态结构用 Designer动态行为和细节用代码。Designer 的优势是所见即所得摆四个控件比写四十行布局代码快得多而且它生成的 .ui 文件在编译期会被 uic 转成 C运行时几乎没有额外开销。但它的劣势也很明显QSS 里引用资源图片要写全路径、动态创建的子控件没法可视化、以及无边框窗口这种需要在构造函数里改窗口标志的东西它管不了。所以我通常的流程是在 Designer 里拖出主窗口、卡片容器、两个 QLineEdit、两个 QPushButton登录、显示密码、一个 QCheckBox、一个 QLabel错误提示。在 Designer 的样式编辑器里先贴一版基础 QSS保证预览能看到大概样子。编译一次把真正的样式表挪到独立的 .qss 文件通过资源系统加载。剩下的逻辑包括回车触发、密码回显切换、异步校验全部在 C 里写。这里有个容易被忽略的点.qss 文件一定要放进 .qrc 资源里不要用相对路径去读磁盘文件。你用QFile(style.qss)在开发机跑得好好的打包发布之后路径一变界面立刻变回系统默认样式而且因为加载失败通常只是静默返回空字符串你不会收到任何报错。// main.cpp 里加载全局样式 QFile qssFile(:/style/app.qss); if (qssFile.open(QFile::ReadOnly | QFile::Text)) { qApp-setStyleSheet(QString::fromUtf8(qssFile.readAll())); qssFile.close(); }注意QString::fromUtf8这一步别省。QSS 里只要有中文注释或者中文字体名用默认编码读进来就是乱码样式表解析器碰到乱码会静默丢弃整条规则表现就是我明明写了但没生效。还有一个编译期的坑要先说清楚如果你在 .pro 里没写QT widgets或者在 CMake 里漏了Widgets编译会直接报unknown module(s) in qt: widgets。这个报错和热词里常见的unknown module(s) in qt: serialport是同一类问题——模块没装或者没在工程文件里声明。串口那个还多一种可能Qt 5.15 的串口模块在安装时是可选组件离线安装包里默认不勾需要重新运行安装器补上。2. 输入框的手感决定了登录页好不好用2.1 密码回显切换图标按钮和 QAction 的取舍密码框默认是 QLineEdit 加上setEchoMode(QLineEdit::Password)。要加小眼睛切换有两种常见做法。做法一在 QLineEdit 内部右侧嵌一个 QAction。QLineEdit 自带addAction接口可以指定图标出现在左侧还是右侧点击后触发triggered信号。好处是图标永远贴着输入框的右边缘输入框宽度变化时跟着走不用手动算位置。缺点是图标的绘制样式受 QAction 影响你想在悬停时换图或者改透明度会比较别扭。// 在构造函数里 ui-editPassword-setEchoMode(QLineEdit::Password); QAction *actEye ui-editPassword-addAction( QIcon(:/icons/eye_off.svg), QLineEdit::TrailingPosition); connect(actEye, QAction::triggered, this, [this, actEye]{ const bool hidden (ui-editPassword-echoMode() QLineEdit::Password); ui-editPassword-setEchoMode(hidden ? QLineEdit::Normal : QLineEdit::Password); actEye-setIcon(QIcon(hidden ? :/icons/eye_on.svg : :/icons/eye_off.svg)); });做法二独立 QPushButton用绝对定位或者覆盖布局。控制力更强能自由写 QSS 的三态样式。代价是要自己处理 resize 事件重算位置或者用 QHBoxLayout 把按钮塞进一个和输入框同高的容器里。我一般选做法一因为它零布局成本。但有个细节必须处理切换回密码模式后光标位置要保持不动。QLineEdit 在setEchoMode时部分版本会重置光标到末尾。稳妥的写法是先int pos edit-cursorPosition();切换完再edit-setCursorPosition(pos);。2.2 returnPressed键盘回车直接登录的正确接法用户输完密码顺手敲回车这是肌肉记忆。不接这个信号体验会立刻掉一档。connect(ui-editPassword, QLineEdit::returnPressed, this, LoginDialog::onLoginClicked);两个注意点。第一如果你用的是 QDialog 而不是 QWidget不要在 QDialog 里同时接 returnPressed 和给某个按钮设setDefault(true)。QDialog 默认会把回车事件交给 default 按钮同时又可能触发 QLineEdit 的 returnPressed结果就是登录逻辑跑两遍你会看到两次请求或者两次错误提示闪一下。二选一我通常用 returnPressed然后ui-btnLogin-setAutoDefault(false);。第二如果你在输入框上装了事件过滤器做特殊按键处理记得在 filter 里对回车返回 false让它继续往下传否则你自己把事件吃掉信号永远不发。2.3 占位符、输入限制与中文输入法的那些毛病占位符用setPlaceholderText(请输入手机号或邮箱)。看着简单但配上 QSS 之后占位符文字的颜色是通过placeholder-text-color或者color属性间接控制的不同 Qt 版本表现略有差异建议在样式表里显式指定一次。输入长度限制用setMaxLength(32)。这里有个真实的坑中文输入法在预编辑状态下输入的字数可能超过 maxLength导致用户正在拼的字被截断。更麻烦的是如果同时用了setValidator配合正则做实时校验预编辑的拼音串会直接触发校验失败错误提示在用户还没输完就弹出来非常难受。我的处理办法是在校验前先判断输入法状态。Qt 提供了QInputMethod更简单的做法是监听QLineEdit::inputRejected信号只在真正被拒绝时才报错而不是每次 textChanged 都校验一遍。connect(ui-editAccount, QLineEdit::inputRejected, this, [this]{ showTip(输入内容包含不支持的字符); });另外账号框我一般不加 QValidator只在点登录时统一校验。实时校验看着高级实际上在中文输入法环境下误报率很高得不偿失。密码框就更不用加了几乎所有字符都应该被接受。3. QSS 把界面拉满从灰扑扑到能发朋友圈3.1 为什么你的 QSS 对 QLineEdit 不生效选择器与继承机制这是新手问得最多的问题我给 QLineEdit 写了样式为什么没变Qt 的样式表有三个反直觉的地方必须搞清楚。第一子控件要用双冒号。QLineEdit 的边框其实是由内部子控件绘制的很多效果必须写成QLineEdit:hover这种伪状态或者对 QComboBox 用QComboBox::drop-down。写QComboBox drop-down是无效的。第二一旦给父控件设了带边框的样式子控件可能被接管。最典型的是给 QWidget 设border: 1px solid #ccc;之后它所有的子控件都会带上边框。原因是样式表会向下传播而 QLineEdit 默认没有自己绘制边框的能力就继承了父级的 border。解决办法是给子控件显式写border: none;或者更干脆——不要给纯容器 QWidget 设边框用一个 QFrame 包一层。第三自定义 QWidget 子类默认不吃样式表。如果你继承 QWidget 自己写了个卡片类直接setStyleSheet(background: #fff;)是不生效的。必须在类里重写paintEvent加上 QStyleOption 那几行样板代码void CardWidget::paintEvent(QPaintEvent *) { QStyleOption opt; opt.initFrom(this); QPainter p(this); style()-drawPrimitive(QStyle::PE_Widget, opt, p, this); // 之后再画自己的圆角背景 }这几行没有为什么可讲不加就是不生效Qt 官方文档里藏在Custom Widgets and Painting那节。3.2 圆角、悬停、按下三态按钮状态机怎么写按钮的质感几乎全靠伪状态。我的模板大概是这样的QPushButton#btnLogin { min-height: 44px; border: none; border-radius: 8px; background-color: #2f6fed; color: #ffffff; font-size: 15px; letter-spacing: 2px; } QPushButton#btnLogin:hover { background-color: #3b7bf5; } QPushButton#btnLogin:pressed { background-color: #2559c4; } QPushButton#btnLogin:disabled { background-color: #b9c6dd; color: #eef2f8; }这里的关键是:disabled一定要写。登录按钮在请求发出后应该被禁用防止重复提交如果不写 disabled 样式按钮会变得和可点击状态一样用户会以为没反应然后疯狂点击。输入框的聚焦态同理QLineEdit#editAccount, QLineEdit#editPassword { min-height: 44px; padding: 0 12px; border: 1px solid #dfe3ea; border-radius: 8px; background-color: #fafbfd; selection-background-color: #2f6fed; } QLineEdit:focus { border: 1px solid #2f6fed; background-color: #ffffff; }padding里的水平值要留够否则文字会贴在边框上视觉上很挤。圆角半径不要超过高度的一半44 的高度配 8 到 12 的圆角最稳超过 22 之后圆角会看起来像胶囊和输入框的矩形语义冲突。3.3 高 DPI 与不同分辨率下的排版保命技巧现在显示器缩放五花八门125%、150%、175% 都有人用。Qt 5.15 里如果不做处理你的 44 像素按钮在 150% 缩放下可能只有视觉上的 29 像素字挤在一起。在 main.cpp 最开头加上QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);这必须在QApplication对象构造之前调用写在后面无效。Qt 6 里AA_EnableHighDpiScaling已经默认开启并被标记废弃写了会报警告可以直接删掉只保留AA_UseHighDpiPixmaps也基本不需要了。图标资源尽量用 SVG。PNG 在高 DPI 下要么模糊要么要准备多套倍图SVG 让 Qt 的渲染器自己缩放省心得多。如果你的图标是彩色的、SVG 里带渐变记得确认 Qt SVG 模块已安装QT svg否则图标会渲染成空白。还有一个和分辨率无关但很影响观感的问题窗口居中。别用固定的move(500, 300)用const QRect screen QApplication::primaryScreen()-availableGeometry(); move(screen.center() - rect().center());注意是availableGeometry不是geometry前者会排除任务栏区域否则在多任务栏布局的机器上窗口会有一半被压在任务栏下面。4. 校验逻辑分层界面校验、业务校验、数据校验各管一段4.1 三层校验各管什么别混在一起写登录流程里的校验如果全塞在一个槽函数里很快就会变成两百行的 if-else 泥球。我习惯分三层层级触发时机负责内容失败表现界面层焦点离开、点击登录非空、长度、格式邮箱/手机号输入框红框 行内提示业务层点击登录后账号是否被锁定、是否需要验证码、密码错误次数弹提示条不清空输入数据层查询返回后用户是否存在、密码哈希是否匹配、账号状态统一话术账号或密码不正确重点说数据层的最后一条用户不存在和密码错误对外的提示必须完全一致。如果分开提示攻击者可以拿一个账号字典把这个账号存在筛出来这是很基础的安全常识但很多内部工具都忽略了。4.2 密码不能明文存加盐哈希的实现只要涉及自己存账号密码就绝不能明文。哪怕是个内部小工具也不要用MD5(password)这种写法——MD5 和 SHA1 早就不适合做口令存储了显卡跑彩虹表的速度快到离谱。最低配也得是随机盐 慢哈希。Qt 自带的QCryptographicHash只有 SHA 系列属于快哈希严格来说不够。如果项目允许引第三方库首选 bcrypt 或 Argon2。如果只能靠 Qt那就用多轮迭代把成本拉上去QString makePasswordHash(const QString password, const QByteArray salt) { QByteArray data salt password.toUtf8(); QByteArray digest data; // 迭代 10 万次拉高暴力破解成本 for (int i 0; i 100000; i) { QCryptographicHash h(QCryptographicHash::Sha256); h.addData(digest); h.addData(salt); digest h.result(); } return QString(%1$%2).arg(QString::fromLatin1(salt.toHex()), QString::fromLatin1(digest.toHex())); }盐的生成用QRandomGenerator::system()不要用qrand()后者可预测且在 Qt 5.15 里已被标记过时。盐长度 16 字节起步每个用户一条独立盐和哈希值一起存。验证时不能用比字符串要用恒定时间比较虽然在这个场景下时序攻击的可能性很低但写成习惯没坏处bool safeEqual(const QByteArray a, const QByteArray b) { if (a.size() ! b.size()) return false; unsigned char diff 0; for (int i 0; i a.size(); i) diff | static_castunsigned char(a[i] ^ b[i]); return diff 0; }4.3 数据库查询放主线程还是子线程一次卡顿换来的教训本地 SQLite 查询通常几毫秒就回来了放主线程看起来毫无问题。但我在一个项目里遇到过这种情况用户表两万多行登录时还要联查一次权限表再加上第一次打开时数据库文件在机械盘上冷启动一次查询卡了将近 400 毫秒。界面直接白屏 400 毫秒用户以为程序崩了紧接着又点了一次。从那次之后我把登录校验统一放到工作线程用信号槽回传结果class AuthWorker : public QObject { Q_OBJECT public slots: void doLogin(const QString account, const QString password); signals: void finished(bool ok, const QString message); };doLogin里创建自己的 QSqlDatabase 连接注意数据库连接不能跨线程复用每个线程要用不同的连接名void AuthWorker::doLogin(const QString account, const QString password) { const QString connName QString(auth_%1) .arg(reinterpret_castquintptr(QThread::currentThreadId())); QSqlDatabase db; if (QSqlDatabase::contains(connName)) { db QSqlDatabase::database(connName); } else { db QSqlDatabase::addDatabase(QSQLITE, connName); db.setDatabaseName(m_dbPath); } if (!db.open()) { emit finished(false, 数据库连接失败); return; } // ... 查询与校验 emit finished(ok, msg); }主线这边接finished信号时必须用Qt::QueuedConnection跨线程默认就是队列连接不用手动写并且在做 UI 更新前先判断窗口是否还存在。用户完全有可能在查询返回之前就把登录窗口关了这时候再去操作已经析构的控件就是崩溃。5. 记住我与自动登录QSettings 的边界在哪5.1 存什么、不存什么记住我这个功能坑最多的不是实现是存了什么。我的原则很明确内容是否落盘说明用户名可以方便下次带出来密码明文绝对不行任何情况下都不行密码哈希不行哈希是给服务端校验用的本地存了等于给了离线爆破的样本登录令牌可以服务端签发、有有效期、能主动吊销勾选状态可以就是个小开关上次登录时间可以用来做展示或者过期判断也就是说记住我不等于记住密码。如果是纯本地单机工具、没有服务端那就别做自动登录只记住用户名和勾选状态密码每次都手输。这是我踩过坑之后给自己定的规矩本地能拿到的凭据等于没有凭据。真有服务端的话流程是登录成功后拿到一个短期令牌存进 QSettings下次启动时拿令牌去换会话。令牌按天过期过期后静默失败回到登录页。令牌可以被服务端吊销安全性完全不在一个层级。5.2 配置文件落在哪、什么时候读写QSettings 的默认行为有点绕值得说清楚。用QSettings(MyCompany, MyApp)这种双参数形式Windows 下会写进注册表macOS 和 Linux 下写进 plist 或 ini。位置跟用户走重装程序不会丢但调试时不好找。我更推荐显式指定 ini 格式QSettings settings(QCoreApplication::applicationDirPath() /config.ini, QSettings::IniFormat); settings.setIniCodec(UTF-8); // Qt 5 必须写否则中文乱码setIniCodec这行在 Qt 6 里已经移除Qt 6 默认就是 UTF-8如果你在用 Qt 6写了会编译不过。Qt 5.15 下如果不写用户名里带中文的话下次读出来就是一堆问号。读写的时机也有讲究。读放在 UI 构造完成之后、show 之前写放在登录成功之后立刻写。不要在析构函数里写因为登录窗口的析构不一定会发生——如果它是主窗口之上的模态对话框用户点 X 关闭的时候可能只是 hide对象还活着配置永远不会落盘。// 启动时读取 QSettings st(cfgPath, QSettings::IniFormat); st.setIniCodec(UTF-8); ui-editAccount-setText(st.value(login/lastUser).toString()); ui-checkRemember-setChecked(st.value(login/remember, false).toBool()); // 登录成功后写入 st.setValue(login/lastUser, ui-editAccount-text().trimmed()); st.setValue(login/remember, ui-checkRemember-isChecked()); st.sync(); // 显式落盘别指望析构时自动写sync()值得手动调一次。QSettings 有内部缓存靠析构写盘在程序异常退出时会丢数据。用户刚登录成功你就把进程崩了配置没写上下次还得重新输账号——这种事发生一次就够烦了。6. 那些没人写在文档里的坑6.1 登录窗口一闪而过与事件循环的关系这是每个 Qt 新手都遇到过的我写了个登录窗口编译通过运行之后窗口闪一下就没了。原因通常是两种。第一种你在main里写成了栈对象// 错误作用域结束就析构窗口还没来得及显示 int main(int argc, char *argv[]) { QApplication a(argc, argv); LoginDialog dlg; dlg.show(); return 0; // 少了 a.exec() }少了a.exec()就是没有事件循环程序立刻走完 main 返回窗口自然一闪而过。正确写法是dlg.show(); return a.exec();。第二种登录窗口是模态的exec()返回之后你没检查返回值就直接用了主窗口LoginDialog dlg; if (dlg.exec() QDialog::Accepted) { MainWindow w; w.show(); return a.exec(); } return 0; // 用户取消登录正常退出别返回 -1注意最后那个return 0。如果用户取消了登录返回 0 是正常结束有人习惯写return -1某些启动器或者系统会把它当成异常退出处理弹一个错误报告框很尴尬。6.2 无边框窗口的拖动、圆角失效和阴影错位想要圆角卡片加投影的完整效果基本都要走无边框路线setWindowFlags(Qt::FramelessWindowHint | Qt::WindowMinimizeButtonHint); setAttribute(Qt::WA_TranslucentBackground);加完之后三个问题立刻出现。第一个窗口没法拖了。无边框之后系统标题栏没了拖动得自己实现。标准的做法是重写 mousePressEvent 记录偏移mouseMoveEvent 里移动窗口void LoginDialog::mousePressEvent(QMouseEvent *e) { if (e-button() Qt::LeftButton) { m_dragOffset e-globalPos() - frameGeometry().topLeft(); e-accept(); } } void LoginDialog::mouseMoveEvent(QMouseEvent *e) { if (e-buttons() Qt::LeftButton) { move(e-globalPos() - m_dragOffset); e-accept(); } }但这样写有个副作用卡片区域内的输入框选字、按钮点击也被算成拖动区。用户想选中输入框里的文字结果整个窗口跟着跑。解决办法是只在卡片外的空白区域响应拖动判断childAt(e-pos())是不是卡片或者在卡片控件上装事件过滤器把鼠标事件截下来。第二个圆角失效。给最外层 QWidget 设了border-radius但窗口还是直角。原因是WA_TranslucentBackground需要配合真正绘制圆角的 paintEvent光靠 QSS 有时候在 Windows 上会被系统合成器忽略。稳妥方案是让卡片本身留出 12 像素外边距圆角画在卡片上最外层窗口保持全透明。投影同理用QGraphicsDropShadowEffect加在卡片上效果是模糊半径 20、偏移 (0, 4)、颜色半透明黑。注意QGraphicsDropShadowEffect加在容器上会影响子控件的渲染性能如果卡片里有动画建议换成自己画阴影。第三个无边框窗口在任务栏上没有最小化动画。这个没太好办法属于系统层面的行为能接受就接受。如果客户特别在意那就别做无边框保留系统标题栏最多用setWindowTitle加个好看的图标。6.3 打包发布之后 QSS 图片丢失、图标变白块这几乎是我见过最多的在我电脑上好好的问题。三种典型表现样式表整套失效界面回到系统默认外观。按钮上该有图标的地方变成空白。中文字体变成方框。第一种前面说过多半是 .qss 没进 .qrc。还有一种更隐蔽QSS 里用url(:/icons/xxx.png)引用资源时路径大小写必须和 .qrc 里的完全一致。Windows 文件系统大小写不敏感你在开发机上写:/Icons/xxx.png也能跑打包到 Linux 上立刻失效。第二种图标变白块通常是 SVG 模块没打包进去。用 windeployqt 的话默认会带上 Qt5Svg.dll但如果你的 QSS 是在资源里加载的 SVG而工程文件里没写QT svg编译期不会报错运行期静默失败。补上QT svg重新构建就行。第三种字体方框。你指定了一个系统里没装的字体Qt 回退到一个不支持中文的字体上。解决办法是要么用系统自带的中文字体微软雅黑、思源黑体要么把字体文件打进资源用QFontDatabase::addApplicationFont运行时加载int id QFontDatabase::addApplicationFont(:/fonts/SourceHanSansCN-Regular.otf); if (id ! -1) { const QString family QFontDatabase::applicationFontFamilies(id).value(0); qApp-setFont(QFont(family, 10)); }注意addApplicationFont的返回值必须判断加载失败返回 -1这时候如果还去取 families 会拿到空列表后面value(0)就是空字符串最后整个应用的字体都被设成空名反而比不设置更糟。另外提醒一句打包时容易漏的事QSettings如果用的是applicationDirPath()拼路径而程序装在Program Files下面普通用户没有写权限配置会写失败而且不报错。这种情况要改用QStandardPaths::AppConfigLocation或者干脆在安装时就把配置文件放到用户目录。关于登录页这件事我自己最大的体会是真正花时间的从来不是把控件摆上去而是摆完之后那些不显眼的地方——按钮禁用态、输入框聚焦态、查询放到哪个线程、配置写到哪个目录。这些细节单看都不难但漏掉任何一个用户都会在某个瞬间觉得这个软件有点糙。我现在的习惯是先写死交互流程再动界面把点下去之后会发生什么列一遍再回头补样式顺序反过来的话样式改十遍也改不完。
返回列表