多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

QML圆角矩形进度条实现方案:从Rectangle到Canvas的性能优化指南

QML圆角矩形进度条实现方案:从Rectangle到Canvas的性能优化指南 做 QML 圆角矩形进度条这件事看起来就是一个控件的小改造但真正落到自己的项目里从拿到的视觉稿到最终在设备上流畅跑起来中间踩的坑一点都不少。上个月做设备端升级工具界面UI 同事丢过来的进度条就是圆角矩形加描边、内部带斜纹扫描效果要求在不同分辨率下都清晰。我一开始想直接改 Qt 自带的 ProgressBar 样式结果发现从抗锯齿、圆角裁剪、动画性能到文本层级每一步都是细节。这篇文章把我最终落地的方案、踩过的坑和几个可复用的代码都整理出来给同样被这类自定义控件折磨的人一个参考。1. 需求拆解与方案选型1.1 一个真实需求带来的三个要求那个设备升级页面里的进度条视觉稿长这样整体是一个圆角矩形外壳外壳带 1 像素白色细描边内部填充色是半透明蓝填充区域从左往右增长右上角还要叠一个当前进度百分比。另外还有一个“准备升级”状态进度条会来回扫描而不是固定百分比。把视觉拆开看其实就是三个要求第一要有多边形之外的元素也就是圆角并不是单纯把矩形四个角改圆而是整个填充范围都要跟随圆角边缘走第二要支持自定义外观颜色、描边、条纹都能随时换第三动画不能占太多性能因为升级页面还挂着日志输出、文件校验等一堆任务CPU 不能因为一个进度条飙升。这类需求在桌面软件里太常见了。很多人第一反应是用ProgressBar默认样式再改背景Rectangle的radius。但默认 ProgressBar 的 contentItem 用的掩膜方案在圆角、缩放、高 DPI 并存的环境里经常翻车边缘出锯齿还是小事更烦的是某些平台上clip裁剪圆角时会把描边也裁掉一半。所以我决定不直接依赖默认样式而是把它当做一个“圆角矩形进度”这个原子能力来处理。1.2 四条技术路线的对比我把可选的方案拉了一下大致有四条纯 Rectangle 组合、Canvas 自绘、Shape 矢量绘制、以及 Qt 自带 ProgressBar 加样式客制化。每条路线的核心权衡点是视觉效果、实现复杂度和运行开销。方案视觉效果实现复杂度运行开销适合场景Rectangle 组合中圆角边缘在低 DPI 下轻微锯齿低代码量少极低无额外绘制列表 delegate、简单界面自带 ProgressBar 改样式取决于平台掩膜容易出问题中需要理解 control 的层级低标准界面快速交付Canvas 自绘高抗锯齿好可做渐变/条纹中需要处理重绘时机中重绘时开销可控需要复杂视觉或动效Shape 绘制高任意缩放不糊较高路径更新稍繁琐中路径细分有成本高精度静态/低频更新场景我的默认建议是简单界面直接用 Rectangle 组合因为代码少、可控性强如果进度条有渐变、条纹、扫描光效等复杂视觉就上 Canvas如果有跨平台的高保真矢量需求比如将来要放大做展示再考虑 Shape。至于默认 ProgressBar如果你只需要它的语义不需要特殊圆角用用无妨但既然标题已经写了圆角矩形就不要再被它的掩膜限制住了。1.3 怎么选我的默认组合这次升级工具里的进度条最终用的是“Rectangle 背景 Rectangle 前景”的组合再加一个 Canvas 覆盖层画条纹。进度填充本身走轻量级矩形复杂视觉只放在特殊状态。这个组合的好处是底层控件不重状态切换时不会频繁触发 texture 重建Canvas 只在进入准备状态时才启动定时重绘平时处于 idle 状态不占 CPU。另外我把整个进度条封装成独立组件暴露value、from、to、radius、trackColor、progressColor等属性内部实现细节完全对调用方隐藏。这样界面工程师在使用时只需要关心业务值不需要理解圆角是怎么画出来的。这个设计理念很重要后面所有实现都围绕它展开。2. 基础实现Rectangle 组合也能做得像模像样2.1 组件对外 API先定好边界再写代码写这个组件之前我先把对外接口定下来。因为在 QML 项目里组件一旦被多个页面引用后期改属性名成本很高不如一开始就规划清楚。下面是我定的一套最小属性集// RoundedRectProgressBar.qml import QtQuick 2.15 Item { id: root // 业务数值范围 property real from: 0 property real to: 100 property real value: 0 // 视觉样式 property int radius: height / 2 property color trackColor: #263238 property color progressColor: #00BCD4 property int borderWidth: 0 property color borderColor: transparent // 辅助状态 property bool indeterminate: false // 内部只读比例 readonly property real ratio: { if (indeterminate) return 0 var range to - from if (range 0) return 0 return Math.max(0, Math.min(1, (value - from) / range)) } clip: false // 高度不搞花活交给外部布局决定 }一个容易被忽略的点是我把to和from都做成属性了。虽然很多进度条只会用到0~100但实际业务里经常出现-30~70这种区间或者动态百分比范围。直接暴露数值区间可以让调用方不用先做一次归一化。ratio用readonly属性来算内部所有视觉元素都以ratio为最终依据这样无论外部怎么改value绘制逻辑都只认一个数。还有一个容易踩坑的地方radius我给了默认值height / 2就是胶囊样式。但 QML 里属性绑定的优先级有讲究如果外部设置radius会覆盖内部的绑定这是正常的但是如果外部设置的radius大于高度的一半圆角看起来会非常奇怪。所以内部绘制时一定要对圆角做上限钳制这是很多初版实现容易漏掉的。2.2 前景宽度同步逻辑别让进度条“拖后腿”基础版本的绘制逻辑其实就两条背景 Rectangle 铺满整个组件前景 Rectangle 宽度按ratio变化。但这里有个细节进度前景和背景如果在同一个坐标系里直接用百分比的宽度乘上width前景右侧边缘偶尔会超出背景的圆角区域视觉上就像进度条“戳”出去了一截尤其是圆角较大、进度在 30% 到 80% 之间的时候外溢非常明显。解决思路是让前景本身也使用圆角矩形。当前景宽度变大时右侧圆角跟背景一致当进度接近满格时左右两侧都有圆角。如果只是简单地把前景的四个角都设为圆角在进度 10% 时左侧圆角也出来了跟背景重叠时可能看起来正常但有些视觉稿要求只有填充区域的右端是圆角左端是直角这时候就得手动计算。我实际使用的方式是让前景 Rectangle 的radius跟随进度动态变化Rectangle { id: background anchors.fill: parent radius: Math.min(root.radius, width / 2, height / 2) color: root.trackColor border.width: root.borderWidth border.color: root.borderColor } Rectangle { id: foreground width: root.ratio * parent.width height: parent.height radius: root.radius color: root.progressColor }如果要求“左直角、右圆角”可以拆成两个矩形一个是普通矩形宽度是ratio * width - radius另一个是右端圆角矩形边长是radius两者同色并排。这样在进度增长时右侧始终保持平滑的圆角头看起来自然得多。2.3 高度、圆角半径、边框宽度三者之间的换算边框是这个组件里最容易翻车的地方。指定borderWidth: 1和borderColor后如果背景和前景都设置了边框进度增长时两条边框会在边缘附近重叠出现一道明显的双线。更隐蔽的问题是边框线条的粗细会受局部坐标影响当组件在 layout 里被拉伸时边框宽度也会跟着变化这不是我们想要的效果。我的处理经验是外框的边框统一画在“背景层”进度填充层完全不要描边。这样进度无论到哪里视觉上都走在轨道内部。如果背景层描边本身有宽度那么前景的实际绘制范围应该向内缩一个边框宽度否则进度到达右边时会被边框边缘吃掉一截。具体换算如下property real innerLeft: root.borderWidth property real innerRight: width - root.borderWidth property real innerHeight: height - root.borderWidth * 2 property real innerProgressWidth: Math.max(0, (innerRight - innerLeft) * root.ratio)这样背景最外层是带描边的圆角矩形前景则从内部左边缘开始宽度按内部可用宽度计算。如果你只是做纯色背景没有描边需求那直接让前景平铺到外边缘即可。多出来的这个换算公式在视觉稿有描边时非常有用。3. 进阶视觉效果渐变、条纹、进度文字3.1 渐变填充的两个实现位置有些进度条希望填充色从左到右有渐变比如从浅蓝到深蓝。这个需求可以在 Rectangle 上用LinearGradient渐变填充也可以放到 Canvas 里统一绘制。如果你用的是 QtQuickLinearGradient在QtGraphicalEffects模块里。但在 Qt 6 之后这个模块有一部分被移除了而且LinearGradient作为独立 Item 叠加在进度条上方会导致整个组件多出一层混合计算性能一般。更推荐的做法是直接用 Rectangle 的gradient属性它不需要额外模块渲染更直接Rectangle { id: foreground gradient: Gradient { GradientStop { position: 0.0; color: #80D8FF } GradientStop { position: 1.0; color: #00B0FF } } }如果你希望渐变方向不是水平而是跟进度条整体成一定角度那就只能走 Canvas 的createLinearGradient在绘制函数里控制起止点坐标。我的经验是绝大多数圆角进度条的渐变都是水平方向用 Rectangle 的gradient就够了真要斜向渐变基本上是视觉稿的特殊要求单独给 Canvas 方案实现更稳。3.2 条纹动效跑马灯怎么画不卡演示“准备中”状态时常见的动效是斜条纹慢慢移动也就是老电影里那种“跑马灯”效果。用 QML 做这种效果最容易想到的是定义一堆斜向 Rectangle 循环移动但实际一看上百个 Item 同时做动画低端设备上立刻掉帧。更好的办法是把条纹绘制收敛到一个 Canvas 上。用 Canvas 的clip限定在进度区域内部再通过一个偏移量控制条纹位置每帧只重绘一个轻量场景。我实际参考过的画法如下Canvas { id: canvas anchors.fill: parent property real stripeOffset: 0 onPaint: { var ctx getContext(2d) ctx.reset() // 1. 画轨道背景 drawRoundRect(ctx, 0, 0, width, height, radius) ctx.fillStyle trackColor ctx.fill() // 2. 画进度填充含条纹 ctx.save() drawRoundRect(ctx, 0, 0, progressWidth, height, radius) ctx.clip() ctx.fillStyle progressColor ctx.fillRect(0, 0, progressWidth, height) // 3. 画斜条纹 ctx.save() ctx.beginPath() ctx.rect(0, 0, progressWidth, height) ctx.clip() ctx.translate(-stripeOffset, 0) ctx.fillStyle stripeColor var spacing 14 for (var x -height; x progressWidth height; x spacing) { ctx.beginPath() ctx.moveTo(x, 0) ctx.lineTo(x spacing * 0.6, 0) ctx.lineTo(x spacing * 0.6 height, height) ctx.lineTo(x height, height) ctx.closePath() ctx.fill() } ctx.restore() ctx.restore() } }定时推进stripeOffset时我建议用Timer而不是NumberAnimation去改这个属性因为NumberAnimation会产生每帧插值Canvas 的onPaint会被高频触发。实际测试下来一个 500ms 的 Timer 每隔 16ms 把stripeOffset加 1视觉上已经足够流畅CPU 占用却低很多。如果需要严格 60fps 的丝滑感再考虑requestAnimationFrame也不迟。3.3 进度文本放对层级才不会闪烁进度条内部经常要显示“42%”这样的文字。很多人直接把 Text 放进 contentItem 或者前景 Rectangle 内部结果发现进度增长时文字偶尔会闪一下仔细观察是父级clip把文字在圆角边缘裁掉了或者 Text 被前景颜色盖住。我踩过这个坑之后总结出一个原则文本必须放在“遮罩层之外”。也就是说不管进度区域如何裁剪、条纹如何动画文字控件应当作为进度条组件的顶层元素不参与前景区块的裁剪逻辑。具体来说Text { anchors.centerIn: parent text: Math.round(root.value) % color: white font.pixelSize: 12 z: 10 }z置顶只是其中一个手段更重要的是不要让 Text 成为前景 Rectangle 的 child。如果必须嵌套也要把前景的clip: false否则当 Text 靠近边缘时会突然缺角。还有一个性能细节进度条频繁刷新时Text 的text属性跟着变化它会触发 QQuickText 的 layout这在 delegate 中尤其昂贵。如果进度百分比只在整数变化时才需要显示建议对文本做一下整数再更新比如text: Math.round(root.ratio * 100) %这样比值只在百分比整数位变化时更新避免小数位每帧跳动导致文本 layout。4. 高保真自绘路线Canvas 与 Shape 实战4.1 Canvas 绘制圆角进度条的完整代码当视觉效果比较复杂比如要同时支持渐变背景、多色描边、圆角进度、条纹动效Canvas 是最省心的一个方向。全部逻辑都在一个onPaint里不用再堆叠一堆 QML 控件。下面是我在项目中用过的一个可运行基础版本import QtQuick 2.15 Item { id: root property real value: 0 property color trackColor: #E0E0E0 property color progressColor: #2196F3 property real progressWidth: 8 property bool normalized: false width: 200 height: 20 Canvas { id: canvas anchors.fill: parent antialiasing: true onPaint: { var ctx getContext(2d) ctx.reset() var r root.height / 2 var trackW root.width var trackH root.height // 轨道 drawRoundRect(ctx, 0, 0, trackW, trackH, r) ctx.fillStyle root.trackColor ctx.fill() // 前景 var ratio root.normalized ? root.value : Math.max(0, Math.min(1, root.value / 100)) var barW Math.max(0, ratio * trackW) if (barW 0) return ctx.save() drawRoundRect(ctx, 0, 0, barW, trackH, r) ctx.clip() ctx.fillStyle root.progressColor ctx.fillRect(0, 0, barW, trackH) ctx.restore() } function drawRoundRect(ctx, x, y, w, h, radius) { radius Math.min(radius, w / 2, h / 2) ctx.beginPath() ctx.moveTo(x radius, y) ctx.lineTo(x w - radius, y) ctx.arcTo(x w, y, x w, y radius, radius) ctx.lineTo(x w, y h - radius) ctx.arcTo(x w, y h, x w - radius, y h, radius) ctx.lineTo(x radius, y h) ctx.arcTo(x, y h, x, y h - radius, radius) ctx.lineTo(x, y radius) ctx.arcTo(x, y, x radius, y, radius) ctx.closePath() } } onValueChanged: canvas.requestPaint() }注意这里我用onValueChanged主动调用requestPaint不是把onPaint里的值做成属性绑定。这样当进度值不变化时Canvas 不会重绘。如果某些页面里做了定时器轮询数据但数值没变也不会触发无意义的绘制开销。4.2 高 DPI 和抗锯齿处理很多人做完 Canvas 版进度条放到 2K 或者 4K 屏上发现边缘发虚这是高 DPI 下纹理缩放导致的。QML Canvas 的默认行为在不同平台上并不统一为了保险我在Component.onCompleted里根据Screen.devicePixelRatio对绘制坐标系做缩放同时保持 Canvas 的逻辑宽高不变。property real dpr: Screen.devicePixelRatio onPaint: { var ctx getContext(2d) ctx.reset() ctx.scale(dpr, dpr) // 下面所有绘制代码都使用逻辑像素 }但我没有把 Canvas 元素本身的宽高乘上 dpr因为那会让布局里的尺寸也发生变化。只要让ctx.scale放大绘制坐标Canvas 的纹理分辨率就会按 dpr 增加最终显示效果会清晰很多。如果发现 Canvas 唇齿不够明显还可以在exportedCanvas或者renderTarget上做调整但那是极端情况常规控件用scale就够了。抗锯齿方面Canvas 默认就是反锯齿绘制所以只要不手动关闭antialiasing圆角边缘一般不会出现严重锯齿。反而是 Rectangle 的clip方案在某些平台下会有明显的硬边这也是我推荐 Canvas 处理高保真进度条的原因。4.3 Shape 路线适合哪些场景缩放不糊Shape 是另一条高保真路线原理是把图形描述为矢量路径由渲染引擎在任意大小时重新生成像素。如果你有一个进度条要应用在不同尺寸的屏幕上且不能接受逐帧重绘的开销Shape 值得考虑。不过 QML 中 Shape 的更新成本没有大家想的那么低。每次路径变化底层都要重新细分和光栅化。我用 Shape 重写一份进度条做测试路径元素不多时单个进度条的开销能接受但如果是 TableView 里上百个 cell 同时更新就会明显卡顿。所以我的结论是Shape 适合“低频更新但需要缩放保真”的场景比如展示大屏上的状态环、引导页动效进度条这种高频小面积元素还是 Canvas 或者 Rectangle 更现实。如果你一定要用 Shape 实现核心是维护两个ShapePath一个代表轨道一个代表进度弧线段通过动态更新路径坐标来显示进度。代码上需要小心的是更新路径时最好整体替换pathElements数组否则某些 Qt 版本不会触发重新渲染。5. 常见问题与避坑经验5.1 圆角进度条底部/顶部“漏色”问题这是新手最容易遇到的问题背景是圆角矩形前景是直角矩形。当前景进度覆盖背景时背景圆角的底部和顶部区域会露出一条背景色的边远看像是进度条下面多了一条线。最初我用clip: true去裁前景发现边缘锯齿严重而且在高 DPI 屏上特别明显。后来我换成了“前景也带圆角并且让前景圆角半径自适应”的方案基本上能解决漏色问题。极端情况下比如进度非常小只有 2px 宽这时候不要用固定圆角半径而是取Math.min(radius, progressWidth / 2)避免圆角把前景整个吞掉。5.2 clip 剪出来的锯齿边缘QML 的clip属性会创建一个矩形裁剪区域但这个裁剪跟圆角没有任何关系。很多人在 Rectangle 里用clip: true试图把子项裁成圆角结果得到的是带锯齿边缘的矩形框。如果你真的需要在 QML 中实现一个“子项按圆角裁剪”的容器可以用OpacityMask或者ShaderEffect写一个圆角 shader但那是另一个复杂度等级。对进度条而言最靠谱的方式是不要让子项跑到圆角外面去也就是从源头控制绘制范围。前景自身画成圆角背景自身也是圆角两者天然在一个形状体系里不需要额外裁剪。这比任何裁剪方案都干净。5.3 无限动画导致 CPU 高占用制作“扫描中”状态时很多人直接习惯性写NumberAnimation on x { loops: Animation.Infinite }。这种写法本身没问题但它会让动画 tick 一直驱动属性变化如果这个属性又直接绑定到 Canvas 的onPaint那 CPU 占用必然飙升。我处理的方式是把无限动画的驱动力和进度重绘解耦。条纹动画只驱动一个轻量级stripeOffset属性这个属性只影响 Canvas 绘制同时把动画放在独立Animation组件里只有进入 indeterminate 状态才启动。另外如果不需要动画就把running: false明确写出来不要依赖组件销毁来停止。这样整个进度条在非动画状态下Canvas 几乎零开销。5.4 在 delegate 中使用时的性能笔记最后说说跟标题热搜词里那些“qml tableview”“qml repeater”相关的经验。如果你打算在很多行的列表项里放这个圆角进度条比如文件传输列表里每行都显示进度我强烈建议不要在 delegate 里使用 Canvas 带独立 Timer 动画的方案。一屏可能有几十个 item每个 item 一个 Timer 和一个 Canvas滚动时性能会变得很难看。这种场景的最佳实践是进度条组件只负责静态展示进度值由模型驱动通过绑定更新。真正需要动画的只有一个全局状态比如“全部开始”时让每行的进度条同时驱动。同时列表项里的进度条不要设置独立clip尽量让圆角矩形本身简单避免额外混合。用 Rectangle 组合实现的版本在这个场景里表现最好因为它的绘制开销几乎是零。最后说两句实在的做这类小组件最难的不是把功能跑通而是别让它成为整个工程的隐患。我经历过一个进度条把 CPU 从 8% 拉到 60% 的惨痛教训也见过为了一个圆角效果引入大量图形效果组件导致启动时间翻倍的案例所以当你准备给一个进度条加各种效果时不妨先冷静问自己它是核心交互路线还是装饰品如果是装饰品就别过度设计把基础状态做扎实把切换动画做好看比什么都强。后来我又用同一套思路扩展了好几个变体静态百分比圆角条、不定长扫描条、还有只在特定状态显示条纹的混合模式。每个版本都是先定义属性接口再按视觉复杂程度选实现方案。这套思路已经帮我在三个项目里少走了不少弯路也希望对你有点用。
返回列表