Qt纯代码自绘登录界面:无边框阴影与控件细节全攻略 最近接了个活儿要拿 Qt 写一个登录界面视觉上要尽量贴近某款知名办公软件大家习惯叫它 WPS 风格的登录页。对方提了一个很硬的要求尽量不要用 QSS 文件不要贴外部图片素材所有界面元素都用纯代码生成。一开始我也觉得这是给自己找事——Qt 生态里 QSS 改外观、设计师拖控件多方便何必绕远路。但真做下来我发现纯代码自绘这条路不仅走得通而且在一些场景下反而是唯一靠谱的选择。这篇文章就把整个开发过程、实现思路、以及中间踩过的几个坑完整记录下来给同样需要纯代码还原高保真界面的朋友做个参考。先说结论用 Qt 纯代码模仿这种登录界面核心工作分三块——无边框窗口和阴影处理、表单控件的自绘、以及字体间距这些看起来不重要但决定像不像的细节。三块都不难但都有各自的坑。1. 为什么选纯代码自绘而不是 QSS 或 UI 文件1.1 三种常见实现方案的对比做 Qt 界面常规思路有三种集成 Qt Designer 生成的.ui文件、用 QSS 写样式表、以及纯代码构建包含 paintevent 自绘。先把我这次对比的结果列出来方案优点缺点适合场景.ui 文件 设计器拖拽直观、生成代码快布局嵌套深了很难维护自定义样式仍需 QSS多人协作时 ui 文件易冲突业务型后台、表单类界面QSS 样式表语法简单、改主题快border-radius等属性现成复杂动效做不了有些细节比如阴影扩散QSS 无能为力类名选择器一多就乱常规界面美化、换肤系统纯代码 QPainter完全可控坐标、颜色、绘制顺序尽在掌握开发量大布局需要自己算代码里硬编码尺寸容易散高保真还原设计稿、组件库、特殊动效我这次选纯代码最直接的原因是目标界面有大量细节状态输入框聚焦时要浮现一条淡蓝色的底线鼠标悬停在登录按钮上要渐变加深点击要有一个微小的下压位移密码框右侧要有眼睛图标切换明文——这些东西用 QSS 写起来很别扭但用 QPainter 画就是几行代码的事。另一个原因是分发形态。Qt 程序如果引用了外部 QSS 文件和图片资源最终发布时要记得一并带上路径稍有闪失界面就裸奔。纯代码把所有绘制逻辑编译进二进制跑起来就是独立的一套丢给谁都能直接开。1.2 模仿的三个层次像素级、行为级、结构级定像不像的标准时我给自己分了三个层次像素级和原界面在相同分辨率下各控件的位置、尺寸、圆角、颜色几乎一致。行为级交互反馈一致比如聚焦高亮、按钮按压动画、窗口拖拽手感。结构级窗口缩放时各区域相对关系保持一致而不是一拉伸就错位。大多数模仿项目做到像素级就停了但用户真正感知到的其实是行为级——你界面画得再像点登录按钮没有下压反馈看起来就死了。2. 先拆后建把登录界面拆成可绘制的四层2.1 目标界面的视觉层级动手写代码之前我用截图工具把目标登录界面抠下来放大看拆出了四个层级最底层窗口背景带 20 像素左右的圆角四周有明显阴影整体是近乎纯白的浅色。左侧品牌区一张浅紫色渐变配图上面有大Logo、产品名和一句口号这部分通常由图片构成。右侧表单区包含账号登录/二维码登录页签、手机号/邮箱输入框、密码输入框、验证码条可点击切换、协议勾选项、超大登录按钮。底部辅助区版本号、隐私政策、找回密码等链接字号很小颜色偏灰。左侧的品牌区在原版里是个图片。我们要求纯代码最简单的方法是用QLinearGradient画一个近似渐变再叠加几行文字充当Logo区。如果设计稿给了精确的色值纯代码画出来的渐变完全可以乱真。2.2 建立坐标与间距规范自绘最容易犯的错误是凭感觉摆放一会儿这儿差 2px一会儿那儿差 5px等全部写完回头一比对整个界面歪得不行。我的做法是先定一个基准设计尺寸。这次我选960 x 600逻辑坐标不考虑高分屏缩放后面会单独处理 DPI。然后把所有间距、字号、控件尺寸写成一个常量表放在文件顶部struct LoginMetrics { int windowWidth 960; int windowHeight 600; int borderRadius 20; // 圆角半径 int shadowMargin 40; // 阴影扩展区给 QGraphicsDropShadowEffect 留空间 int leftPanelWidth 440; // 左侧品牌区宽度 int formMargin 54; // 右侧表单区左右边距 int formTop 88; // 表单区顶部到窗口顶部的距离 int inputHeight 46; // 输入框高度 int inputSpacing 28; // 输入框之间的垂直间距 int loginButtonHeight 46; // 登录按钮高度 int fontSizeTitle 24; // 标题字号 int fontSizeNormal 14; // 正常字号 int fontSizeSmall 12; // 辅助文字字号 };画任何东西都不直接写数字一律引用常量。这样调整起来方便更关键的是算坐标的时候有依据——比如表单第一个输入框的y坐标就可以算出来formTop 标题高度 标题与输入框间距而不是拍脑袋定一个数。2.3 用什么承载布局新手最常见的纠结到底用QHBoxLayout/QVBoxLayout布局还是直接在paintEvent里手写坐标我的经验是登录弹窗这种尺寸固定、元素固定的界面不要用布局器。布局器的优势是动态伸缩但登录界面根本不需要伸缩至少在线宽模式下不需要。直接用QWidget作为容器重写resizeEvent在窗口尺寸变化时按比例重新定位子控件反而更可控。右侧表单区和左侧品牌区的分界我用了一个QWidget作为右侧容器把输入框、按钮作为它的子控件用setGeometry精确摆放。这样左侧自绘、右侧子控件互不干扰。3. 无边框窗口与圆角阴影第一个大坑3.1 窗口无边框后的三个连锁问题目标界面是一个圆角、带阴影的窗口标准的 Windows 标题栏显然不甘心。所以第一步是去掉系统边框setWindowFlags(Qt::FramelessWindowHint); // 去边框 setAttribute(Qt::WA_TranslucentBackground); // 背景透明阴影才能露出来这两行设置完连锁问题马上来窗口拖不动了因为标题栏没了鼠标按哪里都由系统决定需要手动处理mousePressEvent/mouseMoveEvent。没有最大化/最小化/关闭按钮需要自己画右上角三个小按钮或者放弃关闭按钮反正登录界面用不上最大化。背景透明会让整个窗口变脆任何绘制瑕疵都会暴露无遗。3.2 拖动与关闭按钮的手写处理拖动逻辑很经典按下时记录全局坐标和窗口坐标的偏移移动时设置新位置void LoginWindow::mousePressEvent(QMouseEvent* e) { if (e-button() Qt::LeftButton) { m_pressOffset e-globalPosition().toPoint() - frameGeometry().topLeft(); } } void LoginWindow::mouseMoveEvent(QMouseEvent* e) { if (e-buttons() Qt::LeftButton) { move(e-globalPosition().toPoint() - m_pressOffset); } }注意一个细节frameGeometry().topLeft()才是窗口的左上角而不是pos()。如果窗口有透明边距我们加了 40px 阴影区pos()返回的仍然是外框位置两者在本例中一致但不保证所有平台一致养成用frameGeometry的习惯没坏处。关闭按钮我用了一个简单的QPushButton重写paintEvent画一个叉鼠标悬停时背景变浅灰。这种小按钮不值得单独造一个类继承QPushButton重写绘制就行。3.3 圆角背景用 painter 画阴影用特效做圆角背景在paintEvent里用QPainter画void LoginWindow::paintEvent(QPaintEvent*) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); // 抗锯齿圆角边缘才平滑 p.setPen(Qt::NoPen); p.setBrush(QColor(255, 255, 255)); QRectF rect QRectF(rect()).adjusted(shadowMargin, shadowMargin, -shadowMargin, -shadowMargin); p.drawRoundedRect(rect, borderRadius, borderRadius); }重点是shadowMargin。这个 40px 的留白是给阴影特效用的——QGraphicsDropShadowEffect如果要显示完整的模糊扩散区域窗口本身必须留出足够的边界否则阴影会被裁剪掉。很多新人画完圆角发现阴影只有半截十有八九是没留这个边距。阴影挂在窗口的中央 widget 上而不是窗口本身auto* shadow new QGraphicsDropShadowEffect(this); shadow-setBlurRadius(34); shadow-setOffset(0, 8); shadow-setColor(QColor(0, 0, 0, 45)); ui-centralWidget-setGraphicsEffect(shadow);这里要特别说一句不要把阴影挂在整个窗口上不然圆角背景和阴影会一起被模糊效果直接崩。正确做法是窗口透明中央 widget 画圆角背景阴影挂在中央 widget 上。这样阴影从圆角边缘往外扩散才符合直觉。3.4 左侧品牌区的渐变绘制左侧 440px 宽的区域我用QLinearGradient画一个从左下到右上的紫色渐变再叠加几行白色文字void LeftPanel::paintEvent(QPaintEvent*) { QPainter p(this); QLinearGradient g(0, height(), width(), 0); g.setColorAt(0.0, QColor(90, 68, 178)); g.setColorAt(0.6, QColor(130, 84, 212)); g.setColorAt(1.0, QColor(177, 108, 235)); p.fillRect(rect(), g); // 画 Logo 和文字 p.setPen(Qt::white); p.setFont(QFont(Microsoft YaHei, 24, QFont::Bold)); // ... }渐变方向和色值我是照着原版截图一点点试出来的这没什么捷径就是比对、调整、再比对。4. 表单控件的自绘输入框、密码框和登录按钮4.1 输入框无边框 底部高亮线默认的QLineEdit有内阴影很难看。我的做法是继承QLineEdit在paintEvent里先画一条浅灰色的底线再根据聚焦状态决定是否加亮色。class FlatLineEdit : public QLineEdit { protected: void paintEvent(QPaintEvent* e) override { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); p.setPen(Qt::NoPen); p.setBrush(QColor(247, 248, 250)); // 画浅灰圆角背景让输入框有浅凹陷感 p.drawRoundedRect(rect(), 4, 4); // 画底部线 QColor lineColor hasFocus() ? QColor(80, 120, 255) : QColor(220, 222, 226); p.setPen(QPen(lineColor, hasFocus() ? 2 : 1)); p.drawLine(0, height() - 2, width(), height() - 2); // 文本和光标必须手动绘制否则 QLineEdit 默认绘制会覆盖掉上面画的背景 QStyleOptionFrame opt; opt.initFrom(this); style()-drawPrimitive(QStyle::PE_FrameLineEdit, opt, p, this); // QLineEdit::paintEvent 需要调用不然文本不显示 QLineEdit::paintEvent(e); } };这里有个容易踩的坑如果先画背景再调QLineEdit::paintEvent默认的边框绘制会把我们画的内容盖住部分区域。所以要么像上面那样先画背景、再调父类父类会绘制文本部分要么干脆完全自己画文本——自己算contentRect用drawText绘制再把光标画出来。后者更彻底但工作量大不少。我最终选了折中方案背景和底线自己画文本部分交给父类。注意paintEvent里要先设置字体颜色和 placeholder 颜色否则聚焦时文字颜色和背景色会冲突。4.2 密码框的眼睛图标与验证码切换密码框右边有个小眼睛图标点击切换明文/密文。纯代码实现方式auto* action new QAction(this); QIcon eyeIcon; // 用 QPainter 画一个睁眼图标给自定义的 QIcon 生成函数 eyeIcon.addPixmap(renderEyeIcon(QColor(150,150,150))); action-setIcon(eyeIcon); edit-addAction(action, QLineEdit::TrailingPosition);图标我用一个辅助函数renderEyeIcon(color)返回QPixmap里面就是几段QPainter路径。点击时切换setEchoMode即可。验证码条也是标配。我用QPainter在QPixmap上随机画了 4 个字符加了点干扰线和噪点。点击时重新生成并强制刷新。字符需要先用QFontMetrics拿到文本宽度再居中绘制否则每次字符宽度不同会左右乱跳。4.3 登录按钮的渐变与按压回弹登录按钮是最体现手感和细节的控件。我用一个继承QPushButton的类内置三种状态颜色void LoginButton::paintEvent(QPaintEvent*) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); QColor bg; if (!isEnabled()) { bg QColor(140, 150, 170); } else if (isDown()) { bg QColor(58, 100, 220); // 按压时更深 } else if (underMouse()) { bg QColor(78, 120, 235); // 悬停时亮一点 } else { bg QColor(70, 110, 230); // 默认 } p.setBrush(bg); p.setPen(Qt::NoPen); p.drawRoundedRect(rect(), borderRadius, borderRadius); // 文字居中按压时向下偏移 1px 模拟下压 QRect labelRect rect().translated(0, isDown() ? 1 : 0); p.setPen(Qt::white); p.setFont(font()); p.drawText(labelRect, Qt::AlignCenter, text()); }按下的 1px 位移是让按钮活起来的点睛之笔——用户反馈特别明显。4.4 记住密码与自动登录的联动这俩勾选项联动逻辑很简单勾了自动登录就强制勾选记住密码取消记住密码就自动取消自动登录。用QCheckBox的toggled信号接一个 lambda 即可。真正的坑在样式QCheckBox自带的勾选框在某些风格下特别粗糙我直接隐藏了系统指示器用setStyleSheet配合自定义图标。ui-rememberCheckBox-setStyleSheet( QCheckBox::indicator { width: 16px; height: 16px; });或者更极端一点整个QCheckBox也自己画——但我实测下来没必要隐藏默认指示器、加一个干净的 icon 基本就够了。5. 布局健壮性缩放、字体与高 DPI 适配5.1 窗口大小可调但内容不散的实现登录窗口本身没有必要支持任意缩放但 Windows 上用户会拖拽窗口边缘改变大小。如果什么都不做子控件坐标就错乱了。我采用了以中心为锚点的策略窗口宽度变化时把右侧表单区域整体往右平移左侧品牌区宽度保持不变窗口高度变化时上下间距按比例伸缩。void LoginWindow::resizeEvent(QResizeEvent* e) { // 右侧表单容器保持在窗口右侧偏左一点的位置 int newX (width() - shadowMargin * 2 - formWidget-width()) * 0.72; formWidget-move(newX, formWidget-y()); }这种伪自适应其实比用QHBoxLayout更符合登录界面的真实感知——用户拉伸窗口看到的是右侧表单整体挪动左侧品牌图区跟着变宽而不是控件被拉得七扭八歪。5.2 高分屏缩放Qt6 的默认行为与 Qt5 的坑写这篇文章时我用的 Qt 6.5Qt::AA_EnableHighDpiScaling已经默认开启不用手动设置。但如果你是 Qt 5.15 或者更早版本务必在main函数里加上这几行QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);不加的后果是在 150% 缩放的屏幕上整个界面糊成一团边缘发虚。加了之后所有坐标会自动乘上缩放系数但注意自绘代码里QPainter::drawRoundedRect的圆角半径不会自动缩放高分屏下圆角看起来会偏小。需要自己在devicePixelRatio变化时重新计算圆角。qreal dpr devicePixelRatio(); int sharpRadius qRound(borderRadius * dpr);这不是大问题但能注意到这个细节的人做出来的界面就是会更锐一些。5.3 字体渲染为什么文字总是显得飘文字是自绘界面最容易翻车的一环。我碰到的情况是按钮文字老是偏上 1-2px用drawText(rect, Qt::AlignCenter, text)之后依然怪怪的——因为Qt::AlignCenter是把字体的ascent/descent框居中而不是视觉重心居中。解决方案是用QFontMetrics拿到文字的实际哈希高度手动微调QFontMetrics fm(font()); int textHeight fm.height(); int baselineY rect.center().y() (fm.ascent() - fm.descent()) / 2 - 1; p.drawText(QPointF(rect.center().x(), baselineY), Qt::AlignHCenter, text);这个-1或-2的偏移属于玄学调优但确实让文字更正。不同字体差异很大微软雅黑和思源黑体的视觉重心就不一样建议用一个常量统一全局微调量。6. 调试过程中踩过的四个典型问题6.1 回车键没有触发登录登录界面用户肯定希望回车直接登录但默认情况下QLineEdit的回车信号没有接到按钮上。我一开始在LineEdit里returnPressed信号连到登录函数但发现有时候没触发——尤其是焦点在记住密码勾选框上时回车会被QCheckBox吞掉。后来我在LoginWindow里重写keyPressEventvoid LoginWindow::keyPressEvent(QKeyEvent* e) { if (e-key() Qt::Key_Return || e-key() Qt::Key_Enter) { onLoginClicked(); return; } QWidget::keyPressEvent(e); }同时给每个输入框设置setFocusPolicy(Qt::StrongFocus)保证焦点能正确落在输入框上。6.2 阴影被窗口边缘裁剪这个问题前面提到过但我想再说一次完整的排查过程。现象窗口右下角阴影特别淡左上角几乎看不到把blurRadius调大后更明显。排查链路先怀疑是阴影颜色太浅调了setColor透明度无效。再怀疑是特效作用对象不对从 window 换到 centralWidget仍然无效。最后对比了窗口的实际尺寸和阴影的模糊范围才发现问题透明窗口的可见区域是从 0 到 width()而阴影要在 x0 到 xblurRadius 之间扩展——如果窗口本身没有额外边界阴影的模糊扩散部分就被窗口边界吃掉了。解决就是给所有绘制区域加统一的shadowMargin并把这个 margin 写进窗口的最小尺寸计算里。圆角矩形要adjusted(shadowMargin, ...)子控件坐标也要基于这个 margin 的偏移算。6.3 输入框文字颜色和背景色打架默认的QLineEdit文字颜色跟随系统主题浅色背景没问题但当我画了深色聚焦底线时文字颜色在某些 Windows 高对比度模式下会变成深色导致看不清。后来在FlatLineEdit的构造函数里统一固定setStyleSheet(QLineEdit { color: #333333; background: transparent; });background: transparent很关键否则我们自绘的圆角背景会被样式表背景盖掉。6.4 抗锯齿设置不一致导致圆角边缘发白自绘圆角矩形如果不加抗锯齿边缘会有明显锯齿加了抗锯齿在浅色背景上看不出问题但如果圆角矩形下面还有阴影边缘会出现一条半透明白边。这是因为抗锯齿使得边缘像素的 alpha 是渐变的如果 QPainter 的合成模式不对边缘就会叠出不自然的颜色。我自己的处理方式是所有绘制操作都统一setRenderHint(QPainter::Antialiasing)并且背景填充和阴影绘制所在的 widget 不要重叠太多。如果发白明显可以试试在画圆角之前先把painter.setCompositionMode(QPainter::CompositionMode_Source)设上不过这个操作要谨慎会影响后续所有绘制。7. 做完之后的几点心得这个项目最后交付时对方最满意的不是登录按钮的动效也不是圆角阴影的细腻而是打开程序一瞬间的像——窗口位置、尺寸、圆角、阴影、左侧品牌色的整体观感第一眼就让人觉得对味了。我自己做完最大的体会是纯代码自绘界面看着像是从头画一遍实际上省掉了样式表和图片资源的维护成本把界面的最终控制权完全握在自己手里。只要把布局指标抽成常量、把绘制逻辑集中到各自的paintEvent里这套代码完全可以复制到其他项目——无非是改改颜色、改改文案。最后再分享一个小技巧写这类模仿界面前先把目标截图按 100% 比例铺在一个同尺寸的空白窗体下面关掉所有系统修饰直接对着截图调坐标和颜色效率比反复切窗口高得多。等你调完再把截图去掉界面已经像了七八成了。代码这种东西模仿的从来不只是视觉效果更是背后那一套对手感的追求。希望这篇记录能帮到正在为登录界面发愁的 Qt 开发者。