ARTICLE DETAIL

资讯详情

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

Qt登录窗口完整指南:从QDialog交互到QSettings与QSS

Qt登录窗口完整指南:从QDialog交互到QSettings与QSS Qt 写登录窗口这件事说简单也简单一个 QDialog 里塞两个 QLineEdit 加一个按钮二十行代码就能跑起来说复杂也复杂等真正要交付给用户的时候密码明文切换、回车直接登录、记住账号、输入校验、失败提示、样式统一、高 DPI 下不乱版一项项加进去代码量翻三倍都不止。我前后在三个不同规模的桌面项目里做过登录模块从最开始只会堆控件到后来能把登录框做成一个独立可复用的组件中间踩的坑基本都集中在细节这两个字上。这篇就把我实际写过的这套 Qt 登录效果完整摊开讲一遍包含界面搭建、交互逻辑、配置持久化、样式美化和问题排查代码都是可以直接抄走运行的。不管你刚学 Qt 没多久还是已经能写业务但没系统整理过登录模块看完应该都能拿到点能马上用的东西。1. 一个登录窗口真正要解决的问题1.1 从能跑到敢交付的四层需求很多人对登录框的第一印象就是不就是两个输入框加一个按钮吗。如果你只是做课程作业确实够了。但只要这个软件要给真实用户用需求会一层层往上叠而且每一层都很具体。我把这些需求按难度分成四层你可以对照自己的项目看看停在哪一层。第一层是功能可用能输入账号、能输入密码、点击按钮能判断对错。这是最基础的靠 QLineEdit 和 QPushButton 就能完成。第二层是交互顺手焦点默认落在账号框、Tab 顺序符合直觉、回车就能登录、密码框有个小眼睛能看明文、输入框有占位提示文字。这些细节用户不会特意夸你但缺了就会觉得这软件有点糙。第三层是状态记忆记住账号、记住密码、下次打开自动填充。这一层会牵出配置持久化的问题QSettings 怎么用、跨平台存到哪、密码能不能明文落盘都是有讲究的。第四层是健壮性防止用户狂点登录按钮导致重复请求、连续输错几次要临时锁定、错误提示不能泄露这个账号是否存在、输入长度要有限制、窗口缩放时布局不能崩。我的经验是前三层决定用户觉得好不好用第四层决定这个模块会不会在线上出事故。绝大多数登录相关的 bug 都在第四层。1.2 Widgets 还是 QML先把技术路线定下来这个选择必须在动手前定中途换代价很大。Qt Widgets 是传统的 C 控件体系控件都是原生风格绘制的逻辑用信号槽串起来QML 是声明式 UI用类似 JavaScript 的语法写界面动画和触屏体验更顺。如果你的登录框是要嵌进一个已有的 C 桌面工具里或者需要直接调用本地数据库、加密库、硬件接口那 Widgets 是最省事的选择逻辑和界面都在 C 里不用跨语言传数据。反过来如果你是做触屏一体机、平板应用或者 UI 有大量转场动画、由设计师出视觉稿那 QML 更合适。登录框这种场景我个人更倾向 Widgets因为它的控件数量少、逻辑简单、需要和业务代码深度耦合Widgets 的开发效率反而更高。对比维度Qt WidgetsQML开发语言CQML JavaScript复杂逻辑仍需 C上手成本熟悉 C 就能写需额外学 QML 语法和绑定机制动画表现需 QPropertyAnimation 手写声明式动画写起来更自然触屏体验一般需自己处理手势原生友好滑动手势完善适合场景桌面工具、内部系统、嵌入式工控触屏设备、展示类应用、动画密集与 C 逻辑耦合天然一体需要注册类型或用信号桥接我这次讲的实现全部基于 Widgets用到的类都是 Qt 5 和 Qt 6 通用的版本上建议至少用 Qt 5.15.2 这个长期支持版本新项目直接上 Qt 6 也没问题。2. 界面骨架从空白工程到能看的登录框2.1 工程配置里最容易漏的几行先建工程。用 Qt Creator 的向导建一个 Widgets Application基类选 QDialog这样生成的就是一个对话框项目登录框用 QDialog 比 QMainWindow 更合适因为它天然支持模态显示、自带默认按钮机制。如果你用的是 qmake.pro 文件里保证有这几行QT core gui widgets greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET LoginDemo TEMPLATE app CONFIG c11如果走 CMake 路线Qt 6 的写法长这样cmake_minimum_required(VERSION 3.16) project(LoginDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) find_package(Qt6 REQUIRED COMPONENTS Widgets) add_executable(LoginDemo main.cpp logindialog.cpp logindialog.h) target_link_libraries(LoginDemo PRIVATE Qt6::Widgets)这里有个高频报错值得单独说一句编译时报unknown module(s) in QT: serialport这不是代码问题而是安装 Qt 的时候没有勾选对应模块。Qt 的模块是分装安装的串口、数据库、多媒体、图表这些都是可选项装的时候没勾后面加QT serialport就会报这个错。解决办法是重新运行 Qt 的维护工具把对应模块补上而不是去改代码。2.2 每个控件到底负责什么在动手摆布局之前先把控件清单列清楚这样后面写代码不会漏QLineEdit账号输入框和密码输入框。密码框通过setEchoMode切换明文密文。QLabel显示账号密码这类标签以及错误提示文字。QPushButton登录按钮、取消按钮。QToolButton 或 QAction密码框右侧的小眼睛图标。QCheckBox记住密码、自动登录。QFrame 或 QWidget作为卡片容器方便统一加样式和做抖动动画。这里有个细节小眼睛图标我更推荐用QLineEdit::addAction来加而不是自己摆一个 QToolButton 定位到输入框右边。原因很简单addAction是输入框内置的机制图标会自动贴在输入框内部、跟随输入框尺寸变化、点击热区也处理好了不用你手动算坐标。这是 Qt 5.2 之后提供的接口现在用完全没问题。2.3 布局拆解别再用绝对定位新手最容易犯的错是给每个控件setGeometry硬编码坐标。这样做在当时看着没问题一旦字体变了、系统缩放变了、窗口尺寸变了整个界面就散了。正确做法是用布局管理器一层层嵌套。我的拆法是三层最外层一个 QVBoxLayout 垂直排列中间放一个卡片 QWidget卡片内部再用 QVBoxLayout 纵向排标题、表单、按钮行表单部分用 QHBoxLayout 或者 QFormLayout 处理标签 输入框的左右结构按钮行用 QHBoxLayout 让两个按钮并排。LoginDialog::LoginDialog(QWidget *parent) : QDialog(parent) { setWindowTitle(QStringLiteral(登录)); setFixedSize(380, 300); // 去掉标题栏上的问号按钮登录框不需要上下文帮助 setWindowFlags(windowFlags() ~Qt::WindowContextHelpButtonHint); m_userEdit new QLineEdit(this); m_userEdit-setPlaceholderText(QStringLiteral(请输入账号)); m_userEdit-setMaxLength(32); m_userEdit-setClearButtonEnabled(true); m_pwdEdit new QLineEdit(this); m_pwdEdit-setPlaceholderText(QStringLiteral(请输入密码)); m_pwdEdit-setMaxLength(64); m_pwdEdit-setEchoMode(QLineEdit::Password); m_eyeAction m_pwdEdit-addAction( QIcon(QStringLiteral(:/icons/eye_off.svg)), QLineEdit::TrailingPosition); m_tipLabel new QLabel(this); m_tipLabel-setObjectName(QStringLiteral(tipLabel)); m_tipLabel-setMinimumHeight(20); m_rememberBox new QCheckBox(QStringLiteral(记住密码), this); m_loginBtn new QPushButton(QStringLiteral(登 录), this); m_loginBtn-setObjectName(QStringLiteral(loginBtn)); m_loginBtn-setDefault(true); m_loginBtn-setAutoDefault(true); m_cancelBtn new QPushButton(QStringLiteral(取消), this); m_cancelBtn-setObjectName(QStringLiteral(cancelBtn)); m_cancelBtn-setAutoDefault(false); auto *formLayout new QVBoxLayout; formLayout-setSpacing(12); formLayout-addWidget(m_userEdit); formLayout-addWidget(m_pwdEdit); formLayout-addWidget(m_tipLabel); auto *btnLayout new QHBoxLayout; btnLayout-setSpacing(12); btnLayout-addWidget(m_cancelBtn); btnLayout-addWidget(m_loginBtn); auto *card new QWidget(this); card-setObjectName(QStringLiteral(card)); auto *cardLayout new QVBoxLayout(card); cardLayout-setContentsMargins(28, 28, 28, 28); cardLayout-setSpacing(16); cardLayout-addWidget(new QLabel(QStringLiteral(欢迎回来), card)); cardLayout-addLayout(formLayout); cardLayout-addWidget(m_rememberBox); cardLayout-addLayout(btnLayout); auto *root new QVBoxLayout(this); root-setContentsMargins(20, 20, 20, 20); root-addWidget(card); m_userEdit-setFocus(); }这段代码里有几个地方是我特意加的。setMaxLength限制输入长度防止用户粘贴一长串内容进来setClearButtonEnabled(true)让账号框自带一个清除按钮m_cancelBtn-setAutoDefault(false)很关键如果不关掉取消按钮也会响应回车用户按回车可能直接退出而不是登录。3. 交互逻辑让登录框真正活起来3.1 密码明文切换的三个坑密码可见性切换看着简单实现方式有三种改 echoMode、用 QToolButton 叠在上面、用两个输入框来回切。我推荐第一种最干净。但这里有几个坑要提醒。第一个坑是切换后光标跳位。你把 echoMode 从 Password 切到 NormalQt 会重建内部显示光标经常跳到文本末尾用户本来在中间改一个字符一切换就跑到最后了。解决办法是切换前后记录cursorPosition()切换后setCursorPosition恢复回去。第二个坑是图标状态没跟着变。你可以用同一个 QAction 反复切换图标但更清晰的做法是准备两张图根据当前 echoMode 判断该显示哪张。void LoginDialog::togglePasswordVisible() { const bool visible (m_pwdEdit-echoMode() QLineEdit::Normal); const int pos m_pwdEdit-cursorPosition(); if (visible) { m_pwdEdit-setEchoMode(QLineEdit::Password); m_eyeAction-setIcon(QIcon(QStringLiteral(:/icons/eye_off.svg))); } else { m_pwdEdit-setEchoMode(QLineEdit::Normal); m_eyeAction-setIcon(QIcon(QStringLiteral(:/icons/eye_on.svg))); } m_pwdEdit-setCursorPosition(pos); }第三个坑是安全性。密码明文显示这件事本身有争议如果用户是在开放办公环境旁边有人看着明文显示就是个隐患。所以我一般会加个自动恢复机制明文显示 10 秒后自动切回密文用 QTimer::singleShot 就能做到成本很低但体验上更稳妥。3.2 回车即登录的两种接法用户输完密码顺手敲回车这是刻在肌肉记忆里的操作。如果你的登录框不支持用户会觉得别扭。实现方式有两种选哪个取决于场景。第一种是给登录按钮setDefault(true)这样在对话框里按回车就等于点这个按钮。这是 QDialog 的默认按钮机制也是最省事的做法。但它有个限制如果当前焦点在 QTextEdit 这种需要回车换行的控件上回车不会触发默认按钮。登录框里一般没有这种控件所以问题不大。第二种是监听密码框的returnPressed信号自己连到登录槽函数。这种更可控但要注意账号框和密码框都要连用户在账号框输完直接回车也得能登录。connect(m_pwdEdit, QLineEdit::returnPressed, this, LoginDialog::onLoginClicked); connect(m_userEdit, QLineEdit::returnPressed, this, LoginDialog::onLoginClicked);两种方式别同时用否则回车可能触发两次登录。我习惯用 returnPressed 显式连接逻辑更清楚也方便后面加日志。3.3 输入校验别等点了登录才报错校验分两层输入时的即时校验和提交时的整体校验。即时校验用 QRegularExpressionValidator 限制字符集比如账号只允许字母、数字、下划线长度 4 到 32 位。这样用户根本打不出非法字符比事后弹框友好得多。auto *userValidator new QRegularExpressionValidator( QRegularExpression(QStringLiteral(^[A-Za-z0-9_]{0,32}$)), this); m_userEdit-setValidator(userValidator);注意这里的正则我写的是{0,32}而不是{4,32}。原因是用 validator 时如果写成{4,32}用户输入第一个字符时就会被判定为不合法输入框直接拒绝输入等于一个字都打不进去。这是新手特别容易踩的坑。正确做法是范围从 0 开始长度下限留到提交时再判断。密码框我一般不加 validator因为密码允许的字符集通常比较宽限制太死反而影响用户。密码的强度规则应该用提示文字的方式告诉用户而不是直接禁止输入。提交时的校验就简单了按顺序判断账号空不空、密码空不空、账号格式对不对任何一个不通过就把焦点切回对应的输入框并在提示标签上给出具体原因。void LoginDialog::onLoginClicked() { if (m_busy) return; // 防连点 const QString user m_userEdit-text().trimmed(); const QString pwd m_pwdEdit-text(); if (user.isEmpty()) { showTip(QStringLiteral(请输入账号)); m_userEdit-setFocus(); return; } if (pwd.isEmpty()) { showTip(QStringLiteral(请输入密码)); m_pwdEdit-setFocus(); return; } if (pwd.length() 6) { showTip(QStringLiteral(密码长度不能少于 6 位)); m_pwdEdit-setFocus(); return; } showTip(QString()); doLogin(user, pwd); }3.4 登录请求与防连点处理真正的登录动作通常要发网络请求或者查数据库这个过程是耗时的必须处理用户狂点按钮的问题。我的做法是用一个m_busy布尔标志加按钮禁用双保险。void LoginDialog::doLogin(const QString user, const QString pwd) { m_busy true; m_loginBtn-setEnabled(false); m_loginBtn-setText(QStringLiteral(登录中...)); // 模拟一次异步校验真实项目换成网络请求或数据库查询 QTimer::singleShot(800, this, [this, user, pwd]() { m_busy false; m_loginBtn-setEnabled(true); m_loginBtn-setText(QStringLiteral(登 录)); if (verify(user, pwd)) { m_failCount 0; saveAccountIfNeeded(user); accept(); } else { m_failCount; m_pwdEdit-clear(); m_pwdEdit-setFocus(); shakeCard(); if (m_failCount 3) { lockForAWhile(); showTip(QStringLiteral(连续错误 3 次请 30 秒后再试)); } else { showTip(QStringLiteral(账号或密码不正确)); } } }); }这里有个安全上的小设计错误提示统一写账号或密码不正确而不是该账号不存在。前者不泄露账号是否注册过能有效防止攻击者用登录接口批量探测账号。这个习惯我建议从第一版就养成。连续错误锁定用 QTimer 实现锁定期间把登录按钮和两个输入框都禁用同时启动一个倒计时把剩余秒数显示在提示标签上时间到了再解禁。void LoginDialog::lockForAWhile() { m_remainSec 30; m_loginBtn-setEnabled(false); m_userEdit-setEnabled(false); m_pwdEdit-setEnabled(false); if (!m_lockTimer) { m_lockTimer new QTimer(this); connect(m_lockTimer, QTimer::timeout, this, [this]() { --m_remainSec; if (m_remainSec 0) { m_lockTimer-stop(); m_failCount 0; m_loginBtn-setEnabled(true); m_userEdit-setEnabled(true); m_pwdEdit-setEnabled(true); showTip(QString()); } else { showTip(QStringLiteral(请 %1 秒后再试).arg(m_remainSec)); } }); } m_lockTimer-start(1000); }4. 记住密码与配置持久化4.1 QSettings 的读写姿势与跨平台差异记住账号这个功能用 QSettings 是标准做法。它的好处是自动适配平台你不用关心底层到底存成了什么文件。QSettings settings(QStringLiteral(MyCompany), QStringLiteral(LoginDemo)); settings.setValue(QStringLiteral(login/user), user); settings.setValue(QStringLiteral(login/remember), m_rememberBox-isChecked());读取的时候要注意类型转换和默认值QSettings settings(QStringLiteral(MyCompany), QStringLiteral(LoginDemo)); const QString lastUser settings.value(QStringLiteral(login/user)).toString(); const bool remember settings.value(QStringLiteral(login/remember), false).toBool(); if (!lastUser.isEmpty()) { m_userEdit-setText(lastUser); m_pwdEdit-setFocus(); } m_rememberBox-setChecked(remember);不同平台的落盘位置不一样做兼容测试的时候心里要有数平台默认存储位置格式Windows当前用户注册表 Software\MyCompany\LoginDemo注册表键值Linux~/.config/MyCompany/LoginDemo.confINI 文本macOS~/Library/Preferences/com.mycompany.LoginDemo.plistplist如果你不想让它写注册表可以在构造 QSettings 时传QSettings::IniFormat强制用 ini 文件路径自己指定这样更方便排查和清理。用 ini 格式有个坑如果你的值里有中文Qt 5 需要显式settings.setIniCodec(UTF-8)不设置的话读出来会是乱码Qt 6 已经默认 UTF-8不用再管。4.2 密码落盘的安全红线这是我要重点提醒的一块。很多教程会教你直接把密码明文存进 QSettings本地跑起来确实能用但这是实打实的安全隐患。任何能访问这台机器的人打开配置文件就能看到明文密码。我的建议分三档。最保守的做法是只记住账号不记密码每次都要重新输安全性最高。折中做法是本地只存密码的哈希值用于快速校验但这只适用于纯本地校验的场景一旦密码要和服务器比对本地哈希就没意义了。更规范的做法是登录成功后由服务端返回一个有时效的令牌本地只存这个令牌下次启动用它换取登录态密码从不落盘。如果你确实要在本地存点东西至少用 QCryptographicHash 处理一下加个固定的盐值QByteArray salted (pwd QStringLiteral(your_fixed_salt)).toUtf8(); const QString hash QString::fromLatin1( QCryptographicHash::hash(salted, QCryptographicHash::Sha256).toHex());说句实在的这个加盐哈希只能防随手打开配置文件看一眼防不住有心的逆向分析者。真正的安全边界必须在服务端本地的任何存储都只能当作便利性措施来看待。5. 视觉打磨QSS 与动效5.1 样式表用得对界面立刻上一个档次Qt 的样式表语法和 CSS 很像但有几个特有的坑。第一个是选择器你可以直接用类名也可以用#objectName精确匹配某一个控件。我强烈建议用 objectName 做选择器不然所有 QPushButton 都会被同一套样式影响后面想单独改某一个就很难。第二个坑是全有或全无效应。只要你给某个控件类写了任何一条样式规则Qt 就会放弃绘制它的原生外观全部按你写的来。这意味着你得把 hover、pressed、disabled 这些状态都补全不然鼠标按下去是没有任何视觉反馈的。#card { background-color: #ffffff; border-radius: 12px; border: 1px solid #e5e7eb; } #loginBtn { background-color: #2563eb; color: #ffffff; border: none; border-radius: 6px; padding: 8px 20px; font-size: 14px; min-height: 20px; } #loginBtn:hover { background-color: #1d4ed8; } #loginBtn:pressed { background-color: #1e40af; } #loginBtn:disabled { background-color: #93c5fd; color: #f3f4f6; } #cancelBtn { background-color: transparent; color: #4b5563; border: 1px solid #d1d5db; border-radius: 6px; padding: 8px 20px; } #cancelBtn:hover { background-color: #f3f4f6; } #tipLabel { color: #dc2626; font-size: 12px; } QLineEdit { border: 1px solid #d1d5db; border-radius: 6px; padding: 8px 10px; background-color: #f9fafb; font-size: 14px; } QLineEdit:focus { border-color: #2563eb; background-color: #ffffff; }第三个坑是 border-radius 和输入框内边距的关系。圆角设了之后如果 padding 不够文字会贴着边框看着很挤。一般 padding 给到 8 到 10 像素比较舒服。第四个坑是资源路径用:/icons/eye_on.svg这种要求你把图片放进 qrc 资源文件里如果直接写磁盘路径打包发布出去在别人机器上就是空白。我习惯优先用 SVG因为它能跟着系统缩放自动变清晰在高 DPI 屏幕上不会糊。PNG 图标在 150% 或 200% 缩放下经常发虚。5.2 登录失败的抖动动画登录失败时给个抖动反馈用户的感知会强很多比单纯变一行红字直观。实现思路是用 QPropertyAnimation 让卡片容器在水平方向来回摆几下最后回到原位。关键点是别让多个动画同时跑不然连续快速触发会打架。我会在开始时先停掉上一个动画再重新启动。void LoginDialog::shakeCard() { if (!m_card) return; const QPoint origin m_card-pos(); auto *anim new QPropertyAnimation(m_card, pos, this); anim-setDuration(400); anim-setStartValue(origin); anim-setKeyValueAt(0.10, origin QPoint(-12, 0)); anim-setKeyValueAt(0.30, origin QPoint( 12, 0)); anim-setKeyValueAt(0.50, origin QPoint( -8, 0)); anim-setKeyValueAt(0.70, origin QPoint( 8, 0)); anim-setKeyValueAt(0.90, origin QPoint( -4, 0)); anim-setEndValue(origin); anim-setEasingCurve(QEasingCurve::OutQuad); anim-start(QAbstractAnimation::DeleteWhenStopped); }这里抖动作用在卡片上而不是整个窗口是因为如果窗口带系统标题栏整个窗口左右晃看着很怪而且某些窗口管理器会拦截窗口位置变化导致动画失效。作用在内部容器上就没这些问题。用setKeyValueAt而不是叠一串动画对象是因为关键帧方式代码更短不会出现前一个动画没结束下一个跟不上的问题。缓动曲线选 OutQuad抖动的收尾会显得自然一些不会像机械震动那样生硬。6. 高频问题排查速查表6.1 编译和运行期的典型坑实际写下来问题基本集中在几个固定位置我整理成一张表方便对照。现象大概率原因处理方式unknown module(s) in QT: serialport安装 Qt 时没勾选该模块运行维护工具补装对应模块中文显示乱码或问号源码编码与编译器不一致字符串统一用 QStringLiteral 包裹按钮点了没反应信号槽没连上或 Q_OBJECT 缺失检查 connect 返回值确认头文件有 Q_OBJECT样式表写了不生效选择器写错或没走 qrc 资源用 objectName 精确匹配图片放 qrc高 DPI 下界面模糊图标是位图未开启缩放换 SVGQt 5 加 AA_EnableHighDpiScaling回车触发两次登录同时用了默认按钮和 returnPressed只保留一种接法信号槽那条我要多补一句。connect其实有返回值是个 bool。调试阶段可以判断一下没连上就打印日志能省掉大量为什么点了没反应的时间。常见原因是槽函数签名和信号对不上比如信号带参数而槽没带或者参数类型无法自动转换。Qt 5 之后推荐用函数指针语法编译期就能查出大部分不匹配比旧的 SIGNAL/SLOT 宏安全得多。另外提一句 Q_OBJECT 宏。只要你用了自定义信号或者槽类声明里就必须有这个宏而且改完之后一定要重新构建不能只编译。原因是这个宏会触发 moc 生成额外的代码增量编译有时候不会重新跑 moc导致运行时信号槽连不上报找不到信号之类的错误。遇到这种情况先执行一次清理再重新构建。
返回列表