玩转QtQuick(2)-默认渲染器

简介 Qt Quick的默认渲染器 批次渲染 不透明图元 Alpha混合图元 混合3D图元 纹理集 批次渲染的根节点 变换的节点 裁剪 顶点缓冲 抗锯齿 顶点抗锯齿 多采样抗锯齿 性能 可视化 批次可视化 裁剪可视化 变更可视化 OverDraw 过渡绘制可视化 通过QtRHI(硬件渲染接口) 进行渲染 简介 这是《玩转QtQuick》系列文章的第二篇,主要是介绍Qt Quick的默认渲染器。 本文会涉及到一些图形学的基本概念,例如:材质、纹理、光栅化、图元等,建议参考相关资料,本文不做进一步的解释。 因为Qt官方文档写的比较全面,所以本文主要是对官方文档的翻译,同时会补充一些个人理解。 翻译主要参考Qt5.15的文档,适当做了一些调整,尽量信达雅,尽量说人话。 下面翻译开始 Qt Quick的默认渲染器 本文介绍默认渲染器在内部的工作方式,以方便开发者们以最佳的方式 使用它(编写代码),包括性能和功能。 通常无需了解渲染器的内部结构,就能够获得良好的性能。 但是,在与场景图集成或弄清楚为什么无法从图形芯片中挤出最大效率时,这可能会有所帮助。 (即使在每个帧都是唯一的并且所有内容都是从头开始上传的情况下,默认渲染器也将表现良好) Qml场景中的Item将填充QSGNode实例树。一旦实例树创建好之后,此树将完整描述如何渲染特定的帧。 它不会包含对任何Item的反向引用,并且在大多数平台上将通过单独的线程进行处理和渲染。 渲染器是“场景图”的自包含部分,它遍历QSGNode树,并使用QSGGeometryNode中定义 的几何形状和QSGMaterial中定义的着色器状态来更新图形状态并生成DrawCall。 如果有需要,可以使用内部的“场景图”后端API完全替换渲染器。 对于希望利用非标准硬件功能的平台供应商来说,这最为有趣。 对于大多数用例,默认渲染器就足够了。 默认渲染器着重于优化渲染的两种主要策略:批量处理调用和在GPU上保留几何图元。 批次渲染 传统的2D API (例如QPainter,Cairo或者Context2D)被设计为每帧处理大量单独的DrawCall,而当DrawCall的次数 非常少且状态更改保持在一定水平时,OpenGL和其它硬件加速的API表现最佳。 考虑以下用例: 绘制此列表的最简单方法是逐行进行。 首先,绘制背景。背景是特定颜色的矩形。在OpenGL术语中,这意味着使用一个着色器程序进行纯色填充,设置填充颜色, 设置包含x和y偏移量的转换矩阵,然后使用例如glDrawArrays绘制组成矩形的两个三角形。 接下来绘制图标。用OpenGL术语来说,这意味着使用一个着色器程序来绘制纹理,激活要使用的纹理,设置转换矩阵,启用alpha混合, 然后使用例如glDrawArrays绘制组成图标边界矩形的两个三角形。 行 之间的文本和分隔线遵循类似的模式。 对于列表中的每一行都重复此过程,因此对于更长的列表,OpenGL状态变更和DrawCall所带来的 开销完全超过了使用硬件加速API所能提供的好处。 当每个图元都很大时,此开销可以忽略不计,但是在典型的UI环境中,有许多小项加起来会产生相当大的开销。 默认的“场景图”渲染器也在这些限制内运行,并且会尝试将单个图元合并到批次中,同时保留完全相同的视觉效果。 结果是更少的OpenGL状态变更和最少的DrawCall调用,从而实现了最佳性能。 不透明图元 渲染器将不透明图元和需要透明度的图元进行了分类。 通过使用OpenGL的Z缓冲为每一个图元赋予唯一的z值,渲染器可以自由地对不透明图元进行重新排序,而无需考虑 它们在屏幕上的位置以及与它们重叠的其它元素。通过查看每个图元的材质状态,渲染器将创建不透明的批次渲染。 在QtQuick的主要Item中,属于不透明图元的包括不透明颜色的Rectangle和完全不透明的Image,主要是JPEG和BMP格式。 使用不透明图元的另一个好处是,不透明图元不需要启用GL_BLEND,这个操作可能会非常耗性能,尤其是在移动端和嵌入式GPU上。 不透明图元在启用glDepthMask和GL_DEPTH_TEST的情况下以从前到后的方式渲染。在内部进行early-z校验的GPU上,这意味着 片元着色器不需要针对被遮盖的像素或像素块运行。 请注意,渲染器仍需要考虑这些节点,并且顶点着色器仍将为这些图元的每个顶点运行,因此,如果应用程序知道某些东西被完全遮盖,则 最好的办法是设置Item::visible或Item::opacity隐藏它。 Item::z用来控制Item相对于其同级元素的“堆叠顺序”,它与渲染器和OpenGL的z缓冲没有直接关系。 Alpha混合图元 一旦绘制了不透明的图元,渲染器将禁用glDepthMask,启用GL_BLEND并以从后到前的方式渲染所有alpha混合图元。 alpha混合图元的批次渲染在渲染器内需要更多的工作,因为重叠的元素需要以正确的顺序进行渲染,以使alpha看起来正确。 仅仅依靠Z缓冲是不够的。渲染器在所有alpha混合图元上进行传递,除了其材质状态外,还将查询其边框,以确定哪些元素 可以批次渲染,哪些元素不能批次。 上图左边的情况,可以在一次DrawCall中渲染蓝色背景,而在另一次DrawCall中渲染两个文本元素,因为这些文本仅与其 同一层的背景重叠。 右边的情况,Item 4的背景覆盖了Item 3的背景,因此每一个背景和文本需要在不同的DrawCall中渲染。 在Z方向上,alpha节点与不透明节点交错,且在可用时触发early-z。同样的,将Item::visible设置为false会快很多。 混合3D图元 “场景图”支持伪3D和适当的3D图元。 例如,可以用ShaderEffect来实现“页面卷曲”效果,或者可以使用QSGGeometry和自定义材质来实现凹凸贴图。实现这 些功能时,开发者需要意识到默认渲染器已经使用了深度缓冲区。 渲染器修改了QSGMaterialShader::vertexShader()返回的顶点着色器,并在应用了模型视图和投影矩阵之后压缩了 顶点的z值,然后在z上添加了一个小平移以将其放置在正确的z位置。 压缩时会假定z值在0到1的范围内。 纹理集 激活的纹理在OpenGL中是个唯一的状态,这意味着使用不同纹理的多个图元无法批次渲染。因此,Qt Quick“场景图”允许 将多个QSGTexture实例分配为较大纹理的较小子区域,也就是“纹理集”。 纹理集的最大好处是多个QSGTexture实例引用同一个OpenGL纹理实例。这样还可以批量处理带纹理的DrawCall,例如 Qml中的Image,BorderImage,ShaderEffect等,以及C++中的QSGSimpleTextureNode和自定义 的QSGGeometryNode都使用了纹理。 尺寸过大的纹理不会进入纹理集。 纹理集使用带参数QQuickWindow::TextureCanUseAtlas的函数调用QQuickWindow::createTextureFromImage()创建。 纹理集没有范围从0到1的坐标。使用QSGTexture::normalizedTextureSubRect()获取纹理坐标。 “场景图”使用试探法来确定纹理集应该多大以及输入大小的阈值。如果需要不同的值,可以通过设置环境变量 QSG_ATLAS_WIDTH=[width], QSG_ATLAS_HEIGHT=[height]和QSG_ATLAS_SIZE_LIMIT=[size]来覆盖试探法。 对于平台供应商而言,更改这些数值通常是有趣的。 批次渲染的根节点 除了将兼容的图元合并到一个批次,默认渲染器还尝试将每帧需要发送到GPU的数据量减到最少。 默认渲染器会标记在一起的子树,并尝试将它们放入单独的批次中。 识别批次后,即可使用顶点缓冲对象将其合并,上传并存储在GPU内存中。 变换的节点 每个QtQuick中的Item会往场景树中插入一个QSGTransformNode来管理其x、y坐标、缩放比例。子Item会 附加在此变换节点之下。默认渲染器会跟踪帧之间变换节点的状态,并将查看子树以确定:变换节点作为一个 批次渲染的根节点是否良好。在帧之间变化且具有相当复杂的子树的变换节点可以成为批次渲染的根节点。 批次渲染根节点的子树中QSGGeometryNodes相对于CPU上的根节点已经预先转换过了,然后讲它们上传 并保留在GPU上。当变换发生时,渲染器仅需要更新根节点的矩阵,而无需更新每个单独的节点,从而使列表 和网格滚动非常快。对于连续的帧,只要不添加或删除节点,就可以快速地、不增加消耗地渲染。当新内容 进入子树时,将对其进行重建,但这仍然相当较快。 在Grid或List中滚动时,会有节点添加或者删除,但也总会有一些帧是不变的。 将变换节点 标记为 批次根节点的另一个好处是,它允许渲染器保留树中未更改的部分。 例如:UI由一个List和一行按钮 组成。滚动List并添加或删除Delegate时,UI的其余 部分(一行按钮)保持不变,可以使用存储在GPU上的几何图元进行绘制。 可以使用环境变量QSG_RENDERER_BATCH_NODE_THRESHOLD=[count]和QSG_RENDERER_BATCH_VERTEX_THRESHOLD=[count]来 覆盖要成为批次根节点的转换节点和顶点阈值。覆盖这些标志对平台供应商最有用。 在批次渲染根节点之下,会为每个唯一的材质状态集和几何图元类型创建一个批次节点。 裁剪 将Item::clip设置为true时,将创建一个QSGClipNode,其几何形状为矩形。 默认渲染器将通过在OpenGL中使用scissoring来应用此裁剪操作。如果将Item 旋转了非90°角,则使用OpenGL的模版缓冲区。QtQuick的Item仅支持通过Qml启用 矩形的裁剪,“场景图”API和渲染器则支持任何形状的裁剪。 将裁剪应用于子树时,该子树需要使用唯一的OpenGL状态进行渲染。这意味着当Item::clip 为true时,该Item的批次渲染仅限于其子Item。当有许多子级(例如ListView或GridView)或 复杂的子级(例如TextArea)时,这是好事。 应该避免在较小的Item上使用裁剪,因为它会阻止批次渲染。这包括Button上面的Label,TextField和Table中的Delegate。 顶点缓冲 每个批次渲染都会使用顶点缓冲区对象(VBO)将其数据存储在GPU上。该顶点缓冲区保留在帧之间,并在“场景图”所 表示的部分发生更改是更新。 默认情况下,渲染器将使用GL_STATIC_DRAW将数据上传到VBO。 通过环境变量QSG_RENDERER_BUFFER_STRATEGY=[strategy]可以选择其它上传策略,有效的策略还包括stream和dynamic。 更改此值对平台供应商最有用。 抗锯齿 “场景图”支持两种类型的抗锯齿。 默认情况下,诸如Rectangle和Image之类的图元,将通过沿图元的边缘添加顶点的方式,使 边缘淡化到透明,以实现抗锯齿。我们称此方法为顶点抗锯齿。 如果用户通过QQuickWindow::setFormat()将QSurfaceFormat设置为大于0的值,请求OpenGL多重采样,“场景图”将首选 基于多重采样的抗锯齿(MSAA)。 这两种技术将影响渲染器的内部实现方式,并且具有不同的限制。 通过设置环境变量QSG_ANTIALIASING_METHOD为msaa或者vertex也可以覆盖使用的抗锯齿方法。 即使两个图元的边在数学上相同,顶点抗锯齿也会在相邻图元的边缘之间产生接缝。多重采样抗锯齿则不会如此。 顶点抗锯齿 可以使用Item::antialiasing属性启用和禁用单个Item的顶点抗锯齿。在硬件支持的前提下,无论是正常渲染的图元,还是捕获到 帧缓冲区对象中的图元(例如使用ShaderEffectSource),顶点抗锯齿都可以正常运行并产生更高质量的抗锯齿功能。 使用顶点抗锯齿的不利之处在于,每个启用了抗锯齿的图元都必须进行混合。在批次渲染方面,这意味着渲染器需要做更多的工作来确定 图元是否可以进行批次渲染。如果和场景中其它元素重叠,也可能导致更少的批次渲染,从而影响性能。 在低端硬件上,混合操作也可能会非常耗性能。对于覆盖屏幕大部分区域的图像或者圆角矩形,这些图元内部所需要的混合操作数量 可能会导致严重的性能损耗,因为必须混合整个图元。 多采样抗锯齿 多采样抗锯齿是一项硬件功能,其中硬件会计算图元中每个像素的覆盖值。一部分硬件可以以非常低的成本进行多次采样,而另一些 硬件需要更多的内存和GPU周期来渲染一帧, 使用多采样可以对许多图元进行抗锯齿(例如圆角矩形和图片),并且在“场景图”中仍然是不透明的。这意味着在创建渲染批次时, 渲染器的工作会更加轻松,并且可以依赖early-z来避免过渡渲染。 使用多重采样抗锯齿时,渲染到帧缓冲区对象中的内容需要额外的扩展以支持帧缓冲区的多重采样。通常是GL_EXT_framebuffer_multisample 和GL_EXT_framebuffer_blit。大多数台式机芯片都具有这些扩展,但是在嵌入式芯片中却很少见。 如果硬件中不提供帧缓冲区多采样,则不会进行多采样抗锯齿,包括ShaderEffectSource。 性能 如文章开头所说,不需要了解渲染器的详细信息就能够获得良好的性能。默认渲染器在设计时就针对常见用例进行了优化, 并且在几乎任何情况下都将表现良好。 有效的批次渲染可带来良好的性能,并尽可能少地上传几何图形。通过设置环境变量QSG_RENDERER_DEBUG=render,渲染 器将输出相应的统计信息,包括:批次渲染进行的程度,使用的批次数量,保留的批次及不透明和透明的批次数量等。 追求最佳性能时,应仅在真正需要时上传数据,批次数量应该少于10个,且至少3-4个不透明批次。 默认渲染器不执行任何CPU端的视口裁剪或遮挡检测。如果某些内容不可见,则不应该显示,使用Item::visible将其设置 为false。不添加这样的逻辑的主要原因是,它增加了额外的成本,这也将损害那些表现良好的应用程序。 确保纹理集被使用。除非图像特别大,否则Image和BorderImage 将使用它。C++代码中想要创建纹理集,需在调用 QQuickWindow::createTexture()时传递QQuickWindow::TextureCanUseAtlas参数。通过设置环境变量 QSG_ATLAS_OVERLAY,所有纹理集将被着色,以便在应用程序中轻松识别它们。 尽可能使用不透明图元。不透明图元在渲染器中处理速度更快,在GPU上绘制速度更快。例如,即使每个像素都是不透明的,PNG 文件也会经常具有alpha通道。JPG文件始终是不透明的。当图像提供给QQuickImageProvider或者使用 QQuickWindow::createTextureFromImage()创建图像时,请尽可能使用QImage::Format_RGB32格式。 如前文所示,重叠的复合Item无法批次渲染。 裁剪会中断批次渲染。切勿在表格内的单元格,delegate或者类似的元素中使用裁剪。使用省略代替文本裁剪。 创建一个返回裁剪后图像的QQuickImageProvider,代替图像裁剪。 批次渲染仅适用于16位索引。所有QtQuick内置的Item都使用了16位索引,但是自定义几何图元也可以自由使用32位索引。 一些材质的标志会阻止批次渲染,其中最受限制的一个是QSGMaterial::RequiresFullMatrix,它阻止了所有批次渲染。 具有单色背景的应用程序应使用QQuickWindow::setColor()而不是顶级带颜色的Rectangle。 QQuickWindow::setColor()将在glClear()的调用中使用,这是比较快的。 生成Mipmap的Image不会放在纹理集中,也不会进行批次渲染。 存在一个OpenGL驱动程序相关的Bug:帧缓冲对象(FBO)回读时发生的一些错误会损坏渲染的字形。如果在环境变量 中设置QML_USE_GLYPHCACHE_WORKAROUND,则Qt会在RAM中保留该字形的其它副本。这意味着当渲染以前 未渲染的字形时,性能会稍低,因为Qt通过CPU访问额外的副本。这也意味着字形缓存将使用两倍的内存。渲染质量不受影响。 如果应用程序性能不佳,需要确认瓶颈是否在渲染。此时可以使用探查器Profiler! 设置环境变量QSG_RENDER_TIMING=1 将输出许多有用的时序参数,这些参数可以用来查明问题所在。 可视化 为了可视化“场景图”默认渲染器的各个方面,可以将QSG_VISUALIZE环境变量设置为下面每个部分中详细介绍的值其中之一。 下面这段Qml代码提供了一些变量输出的示例: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 import QtQuick 2.2 Rectangle { width: 200 height: 140 ListView { id: clippedList x: 20 y: 20 width: 70 height: 100 clip: true model: ["Item A", "Item B", "Item C", "Item D"] delegate: Rectangle { color: "lightblue" width: parent.width height: 25 Text { text: modelData anchors.fill: parent horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } } } ListView { id: clippedDelegateList x: clippedList.x + clippedList.width + 20 y: 20 width: 70 height: 100 clip: true model: ["Item A", "Item B", "Item C", "Item D"] delegate: Rectangle { color: "lightblue" width: parent.width height: 25 clip: true Text { text: modelData anchors.fill: parent horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } } } } 左侧的ListView,我们将其clip属性设置为true。右侧的ListView,我们将每个delegate的clip属性设置为true。 以此来说明裁剪对批次渲染的影响。 这是正常的运行结果 可视化元素不考虑裁剪,并且渲染顺序是任意的。 批次可视化 设置环境变量QSG_VISUALIZE为batches可以在渲染器中可视化查看批次。 合并过的批次以纯色渲染,未合并的批次以对角线图案渲染。独立的颜色越少意味着批次分配的越好。 如果未合并的批次中包含许多单独的节点,则是比较糟糕的。 QSG_VISUALIZE=batches 裁剪可视化 设置环境变量QSG_VISUALIZE为clip,渲染器会在“场景图”中渲染红色区域来指示裁剪区域。 默认情况下Item不裁剪,因此不会显示裁剪区域。 QSG_VISUALIZE=clip 变更可视化 设置环境变量QSG_VISUALIZE为changes,可以在“场景图”中看到变更。“场景图”中的变更以随机颜色的 闪烁叠加显示。图元上的变更以纯色显示,而批次渲染根节点的变更以特定的pattern显示。 OverDraw 过渡绘制可视化 设置环境变量QSG_VISUALIZE为overdraw,可以在“场景图”中看到过渡绘制。可视化的3D视图中, 所有过渡绘制的Item会高亮显示。此模式也可以用来检测视口之外的几何图元。不透明的Item以绿色显示, 而半透明的Item以红色显示。视口的边框为蓝色显示。不透明的内容使“场景图”更易于处理,渲染速度更快。 请注意,上面的代码中顶层矩形框Rectangle是多余的,因为窗口也是白色的,在这种情况下渲染矩形框会造成资源浪费。 将其更改为Item会略微提高性能。 QSG_VISUALIZE=overdraw 通过QtRHI(硬件渲染接口) 进行渲染 从Qt5.14开始,默认适配层增加了一个选项,可以使用 QtGui模块提供的图形抽象层Qt Rendering Hardware Interface (RHI) 进行渲染。启用后,将不进行OpenGL调用,而是使用抽象层提供的API来渲染“场景图”,然后将其转换为OpenGL, Vulkan,Metal或者Direct3D调用。 通过一次编写着色器代码,编译为SPIR-V,然后转换为适用于各种图形API的语法,实现着色器的统一处理。 要启用此功能,代替直接OpenGL调用,可以通过下面的变量: 环境变量有效值描述 QSG_RHI1启用通过RHI的渲染。除非被QSG_RHI_BACKEND覆盖,否则将根据平台选择目标图像API。默认值为window平台使用Direct3D 11, MacOS平台使用metal,其它平台使用OpenGL QSG_RHI_BACKENDvulkan,metal,opengl,d2d11请求使用指定的图形API QSG_INFO1与基于OpenGL的渲染路径一样,设置此选项将在初始化Qt Quick“场景图”时启用打印信息。 这对于故障排除非常有用。 QSG_RHI_DEBUG_LAYER1在适用的情况下(Vulkan,Direct3D),启用图形API实现的调试或验证层(如果有)。 QSG_RHI_PREFER_SOFTWARE_RENDERER1请求使用软光栅化的适配器或物理设备。仅在API支持枚举适配器(Direct3D或Vulkan)时适用,否则会被忽略 希望始终使用单个指定的图形API运行应用程序,也可以通过C++代码来设置。 例如,在构造任何QQuickWindow之前,在main函数的早期进行以下调用将强制使用vulkan 1 QQuickWindow::setSceneGraphBackend(QSGRendererInterface::VulkanRhi); 可以查看QSGRendererInterface::GraphicsApi文档。以Rhi结尾的枚举值等价于设置QSG_RHI和QSG_RHI_BACKEND。 除非被QSG_RHI_PREFER_SOFTWARE_RENDERER或特定后端的变量(例如QT_D3D_ADAPTER_INDEX 或者 QT_VK_PHYSICAL_DEVICE_INDEX)覆盖, 否则所有QRhi后端都会选择系统默认的GPU适配器或物理设备。目前没有进一步的适配器相关配置项。

2021/1/21
articleCard.readMore

玩转QtQuick(1)-SceneGraph场景图简介

简介 Qt Quick 中的“场景图” Qt Quick “场景图”的结构 Scene Graph API / “场景图”接口 节点 预处理 节点所有权 材质 便捷的节点 “场景图”和渲染 渲染循环 线程渲染循环 非线程渲染循环 (基本渲染循环和窗口渲染循环) 使用QQuickRenderControl自定义渲染控制 “场景图”和原生图形API的混合使用 自定义Item使用QPainter 日志支持 “场景图”后端 简介 这是《玩转QtQuick》系列文章的第一篇,主要是介绍Qt Quick Scene Graph “场景图”的关键特性、主要架构及实现原理等等。 (不是QWidget 框架中那个 QGraphicsView哦,是Qt Quick的Scene Graph,不一样) Scene Graph 是QtQuick/Qml所依赖的渲染框架。 本文会涉及到一些图形学的基本概念,例如:材质、纹理、光栅化、图元等,建议参考相关资料,本文不做进一步的解释。 因为Qt官方文档写的比较全面,所以本文主要是对官方文档的翻译,同时会补充一些个人理解。 翻译主要参考Qt5.15的文档,适当做了一些调整,尽量信达雅,尽量说人话。 下面翻译开始 Qt Quick 中的“场景图” Qt Quick 2 使用了专用的“场景图”,然后遍历并通过图形API(例如OpenGL、OpenGL ES、Vulkan、Metal 或Direct 3D)渲染该“场景图”。 将“场景图”用于图形渲染而不是传统的命令式绘图系统(QPainter之类的),意味着可以在帧之间保留要渲染的场景,并且在渲染开始之前就知道要 渲染的完整图元集。这为许多优化打开了大门,例如:通过批量渲染最大程度减少状态变化、丢弃被遮挡的图元。 再举个具体的例子,假设用户界面包含一个列表,列表有10个节点,其中每个节点都有背景色、图标和文本。 使用传统的绘图技术,这将导致30次DrawCall和30次状态更改。 而“场景图”可以重组原始图元进行渲染,以便在第一次DrawCall中渲染所有背景,第二次DrawCall渲染所有图标,第三次DrawCall渲染所有文本, 从而将DrawCall的总数减少到3次。这样可以显著提高硬件的性能。 “场景图”与Qt Quick 2.0 紧密相关,不能单独使用。“场景图”由QQuickWindow类管理和渲染,自定义Item类型 可以通过调用QQuickItem::updatePaintNode()将其图元添加到“场景图”中。 “场景图”是Item场景的图形表示,它是一个独立的结构,其中包含足以渲染所有节点的信息。 设置完成后,就可以独立于Item状态对其进行操作和渲染。 在许多平台上,“场景图”会在GUI线程准备下一帧状态时,在专用渲染线程上进行渲染。 注意:本文列出的许多信息特定于 Qt “场景图”的内置默认行为。如果使用替代的方案时,并非所有概念都适用。 Qt Quick “场景图”的结构 “场景图” 由许多预定义的节点类型组成,每种类型都有专门的用途。 尽管我们将其称为“场景图”,但更精确的定义是“节点树”。 该树根据Qml场景中的QQuickItem类型构建,然后在内部对该场景进行渲染,最终呈现该场景。 “节点” 本身不包含任何 绘制 或者 paint() 虚函数。 “节点树”主要由内建的预定义类型组成,用户也可以添加具有自定义内容的完整子树,包括表示3D模型的子树。 Scene Graph API / “场景图”接口 一般是指Qt Quick中 QSG开头的所有类。 节点 对用户而言,最重要的节点是QSGGeometryNode。它用来实现自定义图形中的几何形状和材质。 使用QSGGeometry可以定义几何坐标,并描述形状或者图元网格。它可以是直线,矩形,多边形,许多 不连续的矩形或者复杂的3D网格。材质定义如何填充此图形的每个像素。 一个节点可以有任意数量的子节点,并且几何节点将被渲染,以便它们以子顺序出现,且父级位于其子级之后。 注意:这并未说明渲染器中的实际渲染顺序,仅保证视觉顺序。 有效的节点如下: 节点名称描述 QSGNode“场景图”中所有节点的基类 QSGGeometryNode用于“场景图”中所有可渲染的内容 QSGClipNode“场景图”中实现“切割”功能 QSGOpacityNode用来改变透明度 QSGTransformNode实现旋转、平移、缩放等几何变换 自定义节点通过继承QQuickItem类,重写QQuickItem::updatePaintNode(),并且设置 QQuickItem::ItemHasContents 标志的方式,添加到“场景图”。 警告:至关重要的是, 原生图形(OpenGL,Vulkan,Metal等)操作以及与“场景图”的交互只能在渲染线程中进行,主要 在updatePaintNode()调用期间进行。经验法则是仅在QQuickItem::updatePaintNode()函数内使用带有“QSG”前缀的类。 更多详细的信息,可以参考Qt文档: Scene Graph - Custom Geometry 预处理 节点具有虚函数QSGNode::preprocess(),该函数将在渲染“场景图”之前被调用。 节点子类可以设置标志QSGNode::UsePreprocess并重写QSGNode::preprocess()函数以对其节点进行预处理。 例如, 更新纹理的一部分, 或者将贝塞尔曲线划分为当前比例因子的正确细节级别。 节点所有权 节点的所有权归创建者,或者设置标志QSGNode::OwnedByParent后归“场景图”。 通常将所有权分配给“场景图”是可取的,因为这样可以简化“场景图”位于GUI线程之外时的清理操作。 材质 材质描述如何填充QSGGeometryNode中几何图形的内部。它封装了图形管线中顶点和片元阶段的着色器,并提供了足够的灵活性, 尽管大多数Qt Quick 项目本身仅使用了非常基本的材质,例如纯色和纹理填充。 想要对Qml中Item使用自定义着色的用户,可以直接在Qml中使用ShaderEffect。 下面是一个完整的材质类列表: 材质名称描述 QSGMaterial封装了“着色器程序”的渲染状态 QSGMaterialRhiShader表示独立于图形API的“着色器程序” QSGMaterialShader表示渲染器中的OpenGL“着色器程序” QSGMaterialType与QSGMaterial结合用作唯一类型标记 QSGFlatColorMaterial“场景图”中渲染纯色图元的便捷方法 QSGOpaqueTextureMaterial“场景图”中渲染不透明纹理图元的便捷方法 QSGTextureMaterial“场景图”中渲染纹理图元的便捷方法 QSGVertexColorMaterial“场景图”中渲染 逐顶点彩色图元的便捷方法 更多详细的信息,可以参考Qt文档: Scene Graph - Simple Material 便捷的节点 “场景图”API是一套 偏底层的接口,专注于性能而不是易用性。 从头开始编写自定义的几何图形和材质,即使是最基本的几何图形和材质,也需要大量的代码。 因此,“场景图”API包含了一些节点类,以使最常用自定义节点的开发更便捷。 节点名称描述 QSGSimpleRectNodeQSGGeometryNode的子类,定义了矩形图元和纯色材质 QSGSimpleTextureNodeQSGGeometryNode的子类,定义了矩形图元和纹理材质 “场景图”和渲染 “场景图”的渲染发生在QQuickWindow类的内部,并且没有公共API可以访问它。 但是,渲染管线中有一些地方可以让用户附加应用程序代码。 可通过直接调用“场景图”使用的图形API(OpenGL、Vulkan、Metal等)来添加自定义“场景图”内容,或插入 任意渲染命令。插入点由“渲染循环”定义。 有关“场景图”渲染器如何工作的详细说明,可以参考Qt文档: Qt Quick Scene Graph Default Renderer。 渲染循环 共有三种渲染循环变体: 基本渲染循环(basic),窗口渲染循环(windows)和线程渲染循环(threaded)。 其中,基本渲染循环和窗口渲染循环是单线程的,线程渲染循环在专用线程上执行“场景图”渲染。 Qt尝试根据平台及可能使用的图形驱动程序选择合适的渲染循环。如果这不能满足你的需求,或者处于测试的目的,可以使用环境变量 QSG_RENDER_LOOP强制使用指定的渲染循环。要验证使用哪个渲染循环,请启用qt.scenegraph.general日志类别。 注意:线程渲染循环和窗口渲染循环 依赖于图形API实现来进行节流,例如,在OpenGL环境下,“请求交换间隔”为1。 一些图形驱动程序允许用户忽略此设置并将其关闭,而忽略Qt的请求。 在不阻塞“交换缓冲区”操作(或其它位置)的情况下,渲染循环将以尽快的速度运行动画并使CPU 100%运转。 如果已知系统无法提供基于vsync的限制,请通过设置环境变量QSG_RENDER_LOOP = basic使用 基本渲染循环。 线程渲染循环 在许多环境中,“场景图”将在专用渲染线程上进行。这样做是为了增加多核处理器的并行度,并更好地利用停顿时间。 这可以显著提高性能,但是与“场景图”进行交互的位置和时间加了一些限制。 以下是关于OpenGL环境下如何使用线程渲染循环的简单概述。除了OpenGL上下文的特定要求外,其它图形API的步骤也是相同的。 Qml场景中发生变化,触发调用QQuickItem::update(), 这可能是动画或者用户操作的结果。 一个 事件会被post到渲染线程来启动新的一帧。 渲染线程准备渲染新的一帧,GUI线程会启动阻塞。 当渲染线程准备新的一帧时,GUI线程调用QQuickItem::updatePolish() 对场景中节点进行最终的“润色”,再渲染它们。 GUI 线程阻塞。 QQuickWindow::beforeSynchronizing()信号发出。应用程序可以对此信号进行直连(Qt::DirectConnection), 以进行QQuickItem::updatePaintNode()之前所需的任何准备工作。 将Qml状态同步到“场景图”中。自上一帧以来,所有已更改的节点上调用QQuickItem::updatePaintNode()函数完成同步。 这是Qml与“场景图”中的节点唯一的交互时机。 GUI线程不再阻塞。 渲染“场景图”: a. QQuickWindow::beforeRendering() 信号发出。应用程序可以直连(Qt::DirectConnection)此信号,来 调用自定义图形API,然后将其可视化渲染在Qml场景之下。 b. 指定了QSGNode::UsePreprocess标志的节点将调用其QSGNode::preprocess()函数。 c. 渲染器处理节点。 d. 渲染器生成状态并记录使用中的图形API的绘制调用。 e. QQuickWindow::afterRendering 信号发出。应用程序可以直连(Qt::DirectConnection)此信号,来 调用自定义图形API,然后将其可视化渲染在Qml场景之上。 f. 新的一帧准备就绪。交换缓冲区(OpenGL),或者记录当前命令,然后将命令缓冲区提交到图形队列(Vulkan,Metal)。 QQuickWindow::frameSwapped()信号发出。 渲染线程正在渲染时,GUI可以自由地进行动画、处理事件等。 当前默认情况下,线程渲染循环工作在 带opengl32.dll的Windows平台,具有Metal的MacOS平台,移动平台, 具有EGLFS的嵌入式Linux,以及平台无关的Vulkan环境,但这可能会有所改变。 通过在环境变量中设置QSG_RENDER_LOOP=threaded,可以强制使用线程渲染器。 非线程渲染循环 (基本渲染循环和窗口渲染循环) 当前默认在使用非线程渲染循环的环境,包括使用ANGLE及非默认opengl32实现的windows平台,使用OpenGL的MacOS, 以及一些特殊驱动的linux环境。 这主要是一种预防措施,因为并非所有的OpenGL驱动和窗口系统的组合都经过测试。同时,诸如ANGLE 或 Mesa llvmpipe之类的实现根本无法在线程渲染中正常运行。因此,对于这些环境,不能使用线程渲染。 在MacOS OpenGL环境下,使用XCode 10 (10.14 SDK) 或更高版本进行构建时不支持线程渲染循环,因为这会选择在 MacOS 10.14上使用“基于图层的视图”。你可以使用XCode 9 (10.13 SDK)进行构建,以避开“基于图层的视图”,这种 情况下,线程渲染循环可以用并且默认会启用。 Metal没有这样的限制。 非线程渲染循环默认在使用ANGLE的windows平台,而“基本渲染循环”用于其它需要非线程渲染循环的平台。 即使使用非线程渲染循环,也应像使用线程渲染循环一样编写代码,否则将使代码不可移植。 以下是非线程渲染循环中帧渲染序列的简化图示。 使用QQuickRenderControl自定义渲染控制 使用QQuickRenderControl时,驱动渲染循环的责任将转移到应用程序中。 在这种情况下,不使用内置的渲染循环。 取而代之的是,由应用程序在适当的时候调用 polish synchronize rendering等渲染步骤,实现类似于上述 行为的线程渲染循环或非线程渲染循环。 “场景图”和原生图形API的混合使用 “场景图”提供了两种方法,来集成应用程序提供的图形命令: 直接发出OpenGL、Vulkan、Metal等命令,以及在“场景图”中创建纹理化节点。 通过连接到QQuickWindow::beforeRendering 和 QQuickWindow::afterRendering()信号,应用程序可以直接在“场景图” 渲染的同一上下文中进行OpenGL调用。 使用Vulkan或者Metal之类的API,应用程序可以通过QSGRendererInterface来查询本机对象,例如“场景图”的命令缓冲区, 并在认为合适的情况下,向其记录命令。 如信号的名称所示,用户随后可以在Qt Quick “场景图”下方或者上方渲染内容。 以这种方式集成的好处是不需要额外的帧缓冲区或者内存来执行渲染,并且消除了可能昂贵的纹理化步骤。 缺点是Qt Quick 决定何时调用信号,这也是唯一允许OpenGL应用程序绘制的时间点。 Qt提供了一些 “场景图”相关的示例,可在examples中找到: 例子名称描述 Scene Graph - OpenGL Under QML示例通过“场景图”的信号使用OpenGL Scene Graph - Direct3D 11 Under QML示例通过“场景图”的信号使用Direct3D Scene Graph - Metal Under QML示例通过“场景图”的信号使用Metal Scene Graph - Vulkan Under QML示例通过“场景图”的信号使用Vulkan 另一个替代方式,是创建一个 QQuickFrameBufferObject (当前仅适用OpenGL),在这个FBO内部渲染,然后将其 作为纹理显示在“场景图”中。 “Scene Graph - Rendering FBOs” 示例如何完成此操作。 还可以组合多个渲染上下文和多个线程以创建要在“场景图”中显示的内容。 “The Scene Graph - Rendering FBOs in a thread” 示例如何完成此操作。 “Scene Graph - Metal Texture Import”示例直接使用基础API创建和渲染纹理,然后在自定义QQuickItem中的 “场景图”中包装和使用此资源。该示例适用了Metal,但是概念也适用于所有其它图形API。 尽管QQuickFrameBufferObject当前不支持,除OpenGL之外的其它图形API也可以采用这种方法。 警告:当在“场景图”中混合渲染OpenGL内容时,重要的一个点是应用程序不要使OpenGL上下文 处在缓冲区绑定状态,“属性启用”,特殊值处在z缓冲区或模板缓冲区等。这样做会导致无法预测的行为。 警告:自定义渲染代码必须具有多线程意识,它不应该假设应用程序在GUI线程中运行。 自定义Item使用QPainter QQuickItem提供一个子类QQuickPaintedItem,它允许用户使用QPainter渲染内容。 警告: QQuickPaintedItem通过“间接2D 表面”渲染它的内容,“间接2D 表面”可以是软件光栅化,也可以是 “OpenGL帧缓冲对象(FBO)”。这种渲染包含2步操作。第一步是光栅化表面,第二步是渲染表面。 因此,直接使用“场景图” 接口渲染,速度比QQuickPaintedItem快。 日志支持 “场景图”支持很多种日志类别。这些日志除了对Qt贡献者有帮助之外,还可用于追踪性能问题和缺陷。 日志类别描述 qt.scenegraph.time.texture纹理上传的耗时 qt.scenegraph.time.compilation编译着色器耗时 qt.scenegraph.time.renderer渲染器不同步骤耗时 qt.scenegraph.time.renderloop渲染循环不同阶段耗时 qt.scenegraph.time.glyph准备字形的距离场耗时 qt.scenegraph.general“场景图”和图形栈中的常规信息 qt.scenegraph.renderloop渲染循环相关的信息。这个日志模式是Qt开发者主要使用的 旧版QSG_INFO环境变量也可以用。将其设置为非零值将启用qt.scengraph.general类别。 注意:遇到图形问题时,或不确定正在使用哪个渲染循环或图形API时,请至少启用qt.scenegraph.general和qt.rhi,或者 设置QSG_INFO=1的情况下启动应用程序。然后这将在初始化期间将一些基本信息打印到调试输出。 “场景图”后端 除了公共API外,“场景图”还具有适配层,该适配层用以实现特定硬件的适配。这是一个未公开的、内部的、私有实现的插件, 可以让硬件适配团队充分利用其硬件。这包括: 自定义纹理; 特别是QQuickWindow::createTextureFromImage的实现以及Image和BorderImage类型使用的纹理的内部表示。 自定义渲染器;适配层使插件可以决定如何遍历和渲染“场景图”,从而有可能针对特定硬件优化渲染 算法或 使用可提高性能的扩展。 许多默认Qml类型的自定义“场景图”实现,包括其文本和字体渲染。 自定义动画驱动程序;允许动画系统连接到低级“垂直同步”的显示设备,以获得平滑的渲染。 自定义渲染循环;可以更好地控制Qml如果处理多个窗口。

2021/1/20
articleCard.readMore

QQuickWidget中文输入法问题的正确解法

QQuickWidget中文输入法问题的正确解法 Qt的bug 旧的解法 正确的解法 QQuickWidget中文输入法问题的正确解法 本文分享特定问题的解法,用不到的可以忽略。 Qt的bug 使用QQuickWidget的时候,遇到过这个问题:界面的TextInput 或者TextEdit, 鼠标点击聚焦后,切换为光标输入状态,此时切换系统中文输入法,会发现无法输入。 (系统任务栏的输入法状态是正确的,界面上输入字符,直接显示英文,无法显示输入法的候选框) 需要把界面切到其它软件,再切换回来,之后就能够输入了。 可以参考Qt官方bug报告: https://bugreports.qt.io/browse/QTBUG-61475 旧的解法 这个Bug是2018年报告的,我们当时做项目,也被这个Bug坑到了。 当时我给出了一个弱化版本的解法,原理是在第一次聚焦的时候,清理掉QQuickWidget的焦点。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 QuickWidget::QuickWidget(QWidget *parent) : QQuickWidget(parent) { ... connect(quickWindow(), &QQuickWindow::activeFocusItemChanged, this, &QuickWidget::onClearFocus); ... } void QuickWidget::onClearFocus() { QQuickItem *pItem = quickWindow()->activeFocusItem(); if (pItem && (pItem->inherits("QQuickTextInput") || pItem->inherits("QQuickTextField"))) { disconnect(quickWindow(), &QQuickWindow::activeFocusItemChanged, this, &QuickWidget::onClearFocus); QuickWidget::clearFocus(); } } 此方法勉强能用,一些细节上体验不太好。 当时找不到更好的方法,就这样用着了。 正确的解法 2020年Qt官方终于派出了资深的专家,在Qt5.15.2中,彻底解决了这个问题。 (看到有不少博客、论坛,还在流传我提供的旧版本,于心不忍) 于是我从新版本里面,提炼出来了代码,给使用旧版本的同学解决此问题。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 QuickWidget::QuickWidget(QWidget *parent) : QQuickWidget(parent) { ... #if QT_VERSION < QT_VERSION_CHECK(5, 15, 2) connect(quickWindow(), &QQuickWindow::focusObjectChanged, this, &QuickWidget::propagateFocusObjectChanged); #endif ... } #if QT_VERSION < QT_VERSION_CHECK(5, 15, 2) bool QuickWidget::event(QEvent *e) { switch (e->type()) { case QEvent::FocusAboutToChange: return QCoreApplication::sendEvent(quickWindow(), e); default: break; } return Super::event(e); } void QuickWidget::propagateFocusObjectChanged(QObject *focusObject) { if (QApplication::focusObject() != this) return; if (this->window()->windowHandle()) { emit this->window()->windowHandle()->focusObjectChanged(focusObject); } } #endif

2020/11/30
articleCard.readMore

玩转Qml(18)-用户向导

简介 效果预览 源码 原理说明 需求说明 原理 Qml对象作用域 QObject对象树 实现 最小改动 获取焦点区域 找到对象 取坐标 向导页 向导组件 向导数据源 简介 很多现代化的软件,都会有向导功能,相信大家并不陌生。 “用户向导”的作用,可以帮助新用户快速了解产品,也可以用来提醒用户该如何操作。 这次涛哥就分享一个Qml制作“用户向导”的方案。 效果预览 看一下最终效果 在整个软件界面上,覆盖一层遮罩,只保留一部分镂空区域,并用箭头指向镂空区域, 以此,将用户的眼球聚焦到镂空区域,并用适当的文字,说明镂空区域的作用。 这个遮罩层是不能操作到软件界面的,点击任意区域,会转到下一步的向导,直到最后向导退出。 源码 《玩转Qml》系列文章,配套了一个优秀的开源项目:TaoQuick github https://github.com/jaredtao/TaoQuick 访问不了或者速度太慢,可以用国内的镜像网站gitee https://gitee.com/jaredtao/TaoQuick 原理说明 向导的实现方案有很多,比如贴图之类的。按不同的需求,方案也不尽相同。 这里先列一个涛哥的需求,再讨论方案才有意义。 需求说明 窗口大小不固定 焦点区域可以是Qml界面中的任意可视组件 向导功能要独立实现,且对已有代码改动最小 原理 遮罩的制作,在Qml中就是几个半透明的Rectangle拼在一起,附带一个顶层MouseArea, 将所有鼠标事件都过滤掉,这些都比较简单。 焦点区域才是本文的核心。 Qml对象作用域 先来说一下Qml中的对象作用域吧。 如果你阅读过《Qml Book》或者有一定的Qml经验,就知道Qml中的对象,都是通过id相互引用的。 像html / Android,都有类似findElementById这样的接口,获取任意的组件。 在Qml,省去了find这一步,可以直接使用id进行访问。 不过呢,Qml这个id的可访问性,不是任何场景都能直接用的,有诸多情况下,不能直接使用, 例如: 平级的两个组件,它们的子组件不能互相访问到(同一个文件作用域中的除外) 各种View的delegate中的组件id,view外部不能直接访问(这是因为delegate有多个实例的情况下,id不能唯一标识) Loader/Component动态加载后的子组件,不能被直接访问(动态加载,都不知道它什么时候创建,当然不能用它) 诸如此类,都不能直接访问。 扩展说明: 顶层组件的id,可以被所有子组件访问,因此可以用来存储全局对象; Qml单例组件,或者C++上下文对象,也可以被所有子组件访问。 这两种方式也可以用来实现向导功能,有兴趣的读者可自己探索。 QObject对象树 Qml中的所有可视化组件,大都是继承于Item,Item的继承关系是 Item ==> QQuickItem ==> QObject 因此呢,Qml中的所有对象,都在一个QObject树上,具有父子关系。 我们可以从任意节点,向上找到根节点,也可以通过根节点的findChild的方式,找到任意一个带objectName的节点。 涛哥的向导方案,便是基于此实现。 实现 最小改动 在已有的Qml代码中,对于想当作焦点区域的组件,最小改动是增加一个objectName 例如涛哥想把已有的标题栏按钮,作为焦点区域: 改动前: 1 2 3 4 5 6 7 ... SkinBtn { width: 24 height: 24 anchors.verticalCenter: parent.verticalCenter } ... 改动后: 1 2 3 4 5 6 7 8 ... SkinBtn { width: 24 height: 24 anchors.verticalCenter: parent.verticalCenter objectName: "skinBtn" } ... 获取焦点区域 找到对象 像前面的组件,只要有了objectName,就可以从任意位置获取到它了 这需要一点C++代码扩展 1 2 3 4 5 6 7 QQuickItem *getObject(QObject * pRootObject, const QString &targetObjName) { if (!pRootObject) { return nullptr; } return pRootObject->findChild<QQuickItem *>(targetObjName); } 通过findChild就可以拿到目标对象了, rootObject是根节点,可以从任意节点向上找到, 也可以通过QQmlContex直接获取到。 取坐标 找到了对象,就可以计算它的坐标,用于向导了。 为了方便计算,同时避免处理繁杂的父子关系,可以直接把坐标映射到顶层窗口。向导的坐标也以顶层窗口为准即可。 这里进一步封装了C++代码,直接计算好坐标 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 QRect getItemGeometryToScene(const QString &targetObjName) const { if (!pRootObject) { return {}; } auto pItem = pRootObject->findChild<QQuickItem *>(targetObjName); if (pItem) { if (pItem->parentItem()) { //如果有父节点,调用父节点的mapToScene将坐标映射到顶层窗口 auto pos = pItem->parentItem()->mapToScene(pItem->position()); return QRectF { pos.x(), pos.y(), pItem->width(), pItem->height() }.toRect(); } else { //如果没有父节点,坐标本身就是相对顶层窗口的 return pItem->boundingRect().toRect(); } } return {}; } 完整的代码,可以在TaoQuick项目中看到, 封装了一个QuickTool类, 所在路径是TaoQuick/3rdparty/TaoCommon/QuickTool 向导页 接下来就是封装一个向导页面了,遮罩围绕在焦点四周,同时带上箭头和文字说明即可 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 //CusWizardPage.qml import QtQuick 2.9 import QtQuick.Controls 2.2 import QtGraphicalEffects 1.0 import ".." import "../.." Item { id: pageItem z: 998 anchors.fill: parent property string wizardName property string wizardDescript property string targetObjectName property int pageType: pageTypeDown property color maskColor: "black" property real maskOpacity: 0.75 property rect focusRect //int 做枚举,定义4种方向的箭头 readonly property int pageTypeDown: Qt.DownArrow readonly property int pageTypeUp: Qt.UpArrow readonly property int pageTypeLeft: Qt.LeftArrow readonly property int pageTypeRight: Qt.RightArrow Component.onCompleted: { //组件创建时,通过C++扩展,获取到焦点区域坐标 var rect = quickTool.getItemGeometryToScene(targetObjectName) focusRect = rect } //焦点区域的同步item,用来给四周的组件定位 Item { id: focusItem x: focusRect.x y: focusRect.y width: focusRect.width height: focusRect.height RadialGradient { anchors.fill: parent gradient: Gradient { GradientStop { position: 0.0; color: "transparent" } GradientStop { position: 0.6; color: maskColor } } opacity: maskOpacity / 2 } } //left Rectangle { x: 0 y: 0 width: focusRect.x height: parent.height color: maskColor opacity: maskOpacity } //right Rectangle { x: focusRect.x + focusRect.width y: 0 width: pageItem.width - x height: parent.height color: maskColor opacity: maskOpacity } //top Rectangle { x: focusRect.x width: focusRect.width y: 0 height: focusRect.y color: maskColor opacity: maskOpacity } //bottom Rectangle { x: focusRect.x width: focusRect.width y: focusRect.y + focusRect.height height: pageItem.height - y color: maskColor opacity: maskOpacity } Row { id: leftRow spacing: 10 visible: pageType === pageTypeLeft z: 998 anchors { left: focusItem.right leftMargin: 5 verticalCenter: focusItem.verticalCenter } CusImage { source: CusConfig.imagePathPrefix + "arrow-left.png" anchors.verticalCenter: parent.verticalCenter } } Row { id: rightRow spacing: 10 layoutDirection: Qt.RightToLeft visible: pageType === pageTypeRight z: 998 anchors { right: focusItem.left rightMargin: 5 verticalCenter: focusItem.verticalCenter } CusImage { source: CusConfig.imagePathPrefix + "arrow-right.png" anchors.verticalCenter: parent.verticalCenter } } Column { id: downColumn spacing: 10 visible: pageType === pageTypeDown width: 300 z: 998 anchors { bottom: focusItem.top bottomMargin: 5 horizontalCenter: focusItem.horizontalCenter } CusImage { source: CusConfig.imagePathPrefix + "arrow-down.png" anchors.horizontalCenter: parent.horizontalCenter } } Column { id: upColumn spacing: 10 visible: pageType === pageTypeUp width: 300 z: 998 anchors { top: focusItem.bottom topMargin: 5 horizontalCenter: focusItem.horizontalCenter } CusImage { source: CusConfig.imagePathPrefix + "arrow-up.png" anchors.horizontalCenter: parent.horizontalCenter } } CusLabel { id: wizardDescriptLabel z: 998 text: qsTr(wizardDescript) + CusConfig.transString font.pixelSize: 16 color: "white" anchors { horizontalCenter: parent.horizontalCenter verticalCenter: parent.verticalCenter verticalCenterOffset: 50 horizontalCenterOffset: -50 } } CusLabel { z: 998 text: qsTr(wizardName) + CusConfig.transString font.pixelSize: 26 color: "white" anchors { left: wizardDescriptLabel.left bottom: wizardDescriptLabel.top bottomMargin: 30 } } } 向导组件 向导一般不止一页,而是很多页,这里就封装了一个向导组件。 使用model-view的方式,数据源由外部设置,组件只管按照model去实例化对应的向导页即可 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 //CusWizard.qml import QtQuick 2.9 import QtQuick.Controls 2.2 import ".." import "../.." Item { id: cusWizardItem signal wizardFinished property string totlaString: qsTr("Wizard %1/%2 >").arg(currentIndex + 1).arg(count) + CusConfig.transString property string operatorString: qsTr("Click any area to show next") + CusConfig.transString MouseArea { anchors.fill: parent hoverEnabled: true onClicked: { currentIndex++ if (currentIndex >= count) { wizardFinished() } } } property var model property int count: model.count property int currentIndex: 0 Repeater { model: cusWizardItem.model delegate: CusWizardPage { anchors.fill: parent visible: index === currentIndex wizardName: model.name wizardDescript: model.descript targetObjectName: model.targetObjectName pageType: model.arrowType } } CusLabel { z: 999 id: centerLabel anchors { centerIn: parent horizontalCenterOffset: 300 verticalCenterOffset: 150 } text: totlaString font.pixelSize: 22 color: "white" } CusLabel { z: 999 anchors { centerIn: parent horizontalCenterOffset: 300 verticalCenterOffset: 150 + centerLabel.height } text: operatorString color: "white" } } 向导数据源 最后来看一下,TaoQuick项目的首页,提供的向导数据model 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 ListModel { id:wizardModel ListElement { name: "TitleBar" descript: "drag change window pos, double click change window state" targetObjectName: "blankItem" arrowType: Qt.UpArrow } ListElement { name: "Control Buttons" descript: "minimize, maximize, normal or close main window" targetObjectName: "controlButtonsRow" arrowType: Qt.UpArrow } ListElement { name: "Skin Button" descript: "switch theme" targetObjectName: "skinBtn" arrowType: Qt.UpArrow } ListElement { name: "Language Button" descript: "switch language" targetObjectName: "langBtn" arrowType: Qt.UpArrow } ListElement { name: "Left Pane" descript: "show content list" targetObjectName: "leftPane" arrowType: Qt.LeftArrow } ListElement { name: "Drawer Button" descript: "show or hide left pane" targetObjectName: "menuBtn" arrowType: Qt.LeftArrow } ListElement { name: "Search Input" descript: "search content" targetObjectName: "searchInput" arrowType: Qt.LeftArrow } ListElement { name: "Home Button" descript: "go back home page" targetObjectName: "homeBtn" arrowType: Qt.LeftArrow } ListElement { name: "Content List" descript: "switch content" targetObjectName: "contentListView" arrowType: Qt.LeftArrow } ListElement { name: "Content Pane" descript: "show current selected content by list" targetObjectName: "contentRect" arrowType: Qt.RightArrow } ListElement { name: "Tool Buttons" descript: "special function buttons such as Wizard, View Source Code and so on" targetObjectName: "wizardBtn" arrowType: Qt.RightArrow } }

2020/11/10
articleCard.readMore

玩转Qml(17)-树组件的定制

简介 发行说明 效果预览 Qt本身的国际化 存在翻译不全的问题 新的方案 关于批量翻译 总结 简介 最近遇到一些需求,要在Qt/Qml中开发树结构,并能够导入、导出json格式。 于是我写了一个简易的Demo,并做了一些性能测试。 在这里将源码、实现原理、以及性能测试都记录、分享出来,算是抛砖引玉吧,希望有更多人来讨论、交流。 TreeEdit源码 起初的代码在单独的仓库 github https://github.com/jaredtao/TreeEdit gitee镜像 https://gitee.com/jaredtao/Tree 后续会收录到《玩转Qml》配套的开源项目TaoQuick中 github https://github.com/jaredtao/TaoQuick gitee镜像 https://gitee.com/jaredtao/TaoQuick 效果预览 看一下最终效果 Qml实现的树结构编辑器, 功能包括: 树结构的缩进 节点展开、折叠 添加节点 删除节点 重命名节点 搜索 导入 导出 节点属性编辑(完善中) 原理说明 数据model的实现,使用C++,继承于QAbstractListModel,并实现rowCount、data等方法。 model本身是List结构的,在此基础上,对model数据进行扩展以模拟树结构,例如增加了 “节点深度”、“是否有子节点”等数据段。 view使用Qml Controls 2中的ListView模拟实现(Controls 1 中的TreeView即将被废弃)。 关键代码 model 基本model的声明如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 template <typename T> class TaoListModel : public QAbstractListModel { public: //声明父类 using Super = QAbstractListModel; TaoListModel(QObject* parent = nullptr); TaoListModel(const QList<T>& nodeList, QObject* parent = nullptr); const QList<T>& nodeList() const { return m_nodeList; } void setNodeList(const QList<T>& nodeList); int rowCount(const QModelIndex& parent) const override; QVariant data(const QModelIndex& index, int role) const override; bool setData(const QModelIndex& index, const QVariant& value, int role) override; bool insertRows(int row, int count, const QModelIndex& parent = QModelIndex()) override; bool removeRows(int row, int count, const QModelIndex& parent = QModelIndex()) override; Qt::ItemFlags flags(const QModelIndex& index) const override; Qt::DropActions supportedDropActions() const override; protected: QList<T> m_nodeList; }; 其中数据成员使用 QList m_nodeList 存储, 大部分成员函数是对此数据的操作。 Json格式的model声明如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 const static QString cDepthKey = QStringLiteral("TModel_depth"); const static QString cExpendKey = QStringLiteral("TModel_expend"); const static QString cChildrenExpendKey = QStringLiteral("TModel_childrenExpend"); const static QString cHasChildendKey = QStringLiteral("TModel_hasChildren"); const static QString cParentKey = QStringLiteral("TModel_parent"); const static QString cChildrenKey = QStringLiteral("TModel_children"); const static QString cRecursionKey = QStringLiteral("subType"); const static QStringList cFilterKeyList = { cDepthKey, cExpendKey, cChildrenExpendKey, cHasChildendKey, cParentKey, cChildrenKey }; class TaoJsonTreeModel : public TaoListModel<QJsonObject> { Q_OBJECT Q_PROPERTY(int count READ count NOTIFY countChanged) public: //声明父类 using Super = TaoListModel<QJsonObject>; //从json文件读入数据 Q_INVOKABLE void loadFromJson(const QString& jsonPath, const QString& recursionKey = cRecursionKey); //导出到json文件 Q_INVOKABLE bool saveToJson(const QString& jsonPath, bool compact = false) const; Q_INVOKABLE void clear(); //设置指定节点的数值 Q_INVOKABLE void setNodeValue(int index, const QString &key, const QVariant &value); //在index添加子节点。刷新父级,返回新项index Q_INVOKABLE int addNode(int index, const QJsonObject& json); Q_INVOKABLE int addNode(const QModelIndex& index, const QJsonObject& json) { return addNode(index.row(), json); } //删除。递归删除所有子级,刷新父级 Q_INVOKABLE void remove(int index); Q_INVOKABLE void remove(const QModelIndex& index) { remove(index.row()); } Q_INVOKABLE QList<int> search(const QString& key, const QString& value, Qt::CaseSensitivity cs = Qt::CaseInsensitive) const; //展开子级。只展开一级,不递归 Q_INVOKABLE void expand(int index); Q_INVOKABLE void expand(const QModelIndex& index) { expand(index.row()); } //折叠子级。递归全部子级。 Q_INVOKABLE void collapse(int index); Q_INVOKABLE void collapse(const QModelIndex& index) { collapse(index.row()); } //展开到指定项。递归 Q_INVOKABLE void expandTo(int index); Q_INVOKABLE void expandTo(const QModelIndex& index) { expandTo(index.row()); } //展开全部 Q_INVOKABLE void expandAll(); //折叠全部 Q_INVOKABLE void collapseAll(); int count() const; Q_INVOKABLE QVariant data(int idx, int role = Qt::DisplayRole) const { return Super::data(Super::index(idx), role); } signals: void countChanged(); ... }; TaoJsonTreeModel继承于TaoListModel,并提供大量Q_INVOKABLE函数,以供Qml调用。 view TreeView的模拟实现如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 Item { id: root readonly property string __depthKey: "TModel_depth" readonly property string __expendKey: "TModel_expend" readonly property string __childrenExpendKey: "TModel_childrenExpend" readonly property string __hasChildendKey: "TModel_hasChildren" readonly property string __parentKey: "TModel_parent" readonly property string __childrenKey: "TModel_children" ... ListView { id: listView anchors.fill: parent currentIndex: -1 delegate: Rectangle { id: delegateRect width: listView.width color: (listView.currentIndex === index || area.hovered) ? config.normalColor : config.darkerColor // 根据 expaned 判断是否展开,不展开的情况下高度为0 height: model.display[__expendKey] === true ? 35 : 0 // 优化。高度为0时visible为false,不渲染。 visible: height > 0 property alias editable: nameEdit.editable property alias editItem: nameEdit TTextInput { id: nameEdit anchors.verticalCenter: parent.verticalCenter //按深度缩进 x: root.basePadding + model.display[__depthKey] * root.subPadding text: model.display["name"] height: parent.height width: parent.width * 0.8 - x editable: false onTEditFinished: { sourceModel.setNodeValue(index, "name", displayText) } } TTransArea { id: area height: parent.height width: parent.width - controlIcon.x hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton onPressed: { //单击时切换当前选中项 if (listView.currentIndex !== index) { listView.currentIndex = index; } else { listView.currentIndex = -1; } } onTDoubleClicked: { //双击进入编辑状态 delegateRect.editable = true; nameEdit.forceActiveFocus() nameEdit.ensureVisible(0) } } Image { id: controlIcon anchors { verticalCenter: parent.verticalCenter right: parent.right rightMargin: 20 } //有子节点时,显示小图标 visible: model.display[__hasChildendKey] source: model.display[__childrenExpendKey] ? "qrc:/img/collapse.png" : "qrc:/img/expand.png" MouseArea { anchors.fill: parent onClicked: { //点击小图标时,切换折叠、展开的状态 if (model.display[__hasChildendKey]) { if( true === model.display[__childrenExpendKey]) { collapse(index) } else { expand(index) } } } } } } } ... } model层并没有扩展role,而是在data函数的role为display时直接返回json数据, 所以delegate中统一使用model.display[xxx]的方式访问数据。 性能测试 测试环境 CPU: Intel i5-8400 2.8GHz 六核 内存: 16GB OS: Windows10 1909 Qt: 5.12.6 编译器: msvc 2017 x64 测试框架: QTest 测试方法 数据生成 使用node表示根节点的数量,depth表示每个根节点下面嵌套节点的层数。 例如: node 等于 100, depth 等于10,则数据如下: 顶层有100个节点,每个节点下面再嵌套10层,共计节点 100 + 100 * 10 = 1100. 生成json数据的代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 ... //单元测试类 class LoadTest : public QObject { Q_OBJECT public: LoadTest(); ~LoadTest(); static void genJson(const QPoint& point); ... //私有槽函数会被QTest调用 private slots: //初始化 void initTestCase(); //清理 void cleanupTestCase(); //测试导入 void test_load(); //测试导入前,准备数据 void test_load_data(); //测试导出 void test_save(); //测试导出前,准备数据 void test_save_data(); }; ... //节点最大值 const int nodeMax = 10000; //嵌套深度最大值 const int depthMax = 100; void LoadTest::genJson(const QPoint& point) { using namespace TaoCommon; int node = point.x(); int depth = point.y(); QJsonArray arr; for (int i = 0; i < node; ++i) { QJsonObject obj; obj["name"] = QString("node_%1").arg(i); QVector<QJsonArray> childrenArr = { depth, QJsonArray { QJsonObject {} } }; //最后一个节点,嵌套层级最深的。 childrenArr[depth - 1][0] = QJsonObject { { "name", QString("node_%1_%2").arg(i).arg(depth - 1) } }; //从后往前倒推。 for (int j = depth - 2; j >= 0; --j) { childrenArr[j][0] = QJsonObject { { cRecursionKey, childrenArr[j + 1] }, { "name", QString("node_%1_%2").arg(i).arg(j) } }; } obj[cRecursionKey] = childrenArr[0]; arr.append(obj); } writeJsonFile(qApp->applicationDirPath() + QString("/%1_%2.json").arg(node).arg(depth), arr); } void LoadTest::initTestCase() { QList<QPoint> list; for (int i = 1; i <= nodeMax; i *= 10) { for (int j = 1; j <= depthMax; j *= 10) { list.append({ i, j }); } } auto result = QtConcurrent::map(list, &LoadTest::genJson); result.waitForFinished(); } 初始化函数initTestCase中,组织了一个QList,然后使用QtConcurrent::map并发调用genJson函数,生成数据json文件。 node和depth每次扩大10倍。 经过测试,嵌套层数在100以上时,Qt可能会崩溃。要么是QJsonDocument无法解析,要么是Qml挂掉。所以不使用100以上的嵌套级别。 测试过程 QTest十分好用,简单易上手,参考帮助文件即可 例如测试加载的代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 void LoadTest::prepareData() { //添加两列数据 QTest::addColumn<int>("node"); QTest::addColumn<int>("depth"); //添加行 for (int i = 1; i <= nodeMax; i *= 10) { for (int j = 1; j <= depthMax; j *= 10) { QTest::newRow(QString("%1_%2").arg(i).arg(j).toStdString().c_str()) << i << j; } } } void LoadTest::test_load_data() { //准备数据 prepareData(); } void LoadTest::test_load() { using namespace TaoCommon; //取数据 QFETCH(int, node); QFETCH(int, depth); TaoJsonTreeModel model; //性能测试 QBENCHMARK { model.loadFromJson(qApp->applicationDirPath() + QString("/%1_%2.json").arg(node).arg(depth)); } } 测试结果 一秒内最多可以加载的数据量在十万级别,包括 10000 x 10耗时在 386毫秒,1000 x 100 耗时在671毫秒。

2020/6/15
articleCard.readMore

玩转Qt(15)-操控Web小车案例

前言 简介 Qt与Web嵌套 MiniBrowser 半透明测试 渲染原理 小结 Qt与Web分离 Qt小车 原版小车 改进小车 必要的知识 WebSocket和 QWebSocket WebChannel Qt启动系统浏览器 Qt的OpenUrl C# .net的 Process::Start Web控制端 目录结构 Html TypeScript TypeScript中的QObject TypeScript中连接websocket TypeScript中的QWebChannel TypeScript中使用javaScript 改进qwebchannel.js以支持await QObject to Typescript 前言 这次讨论Qt与Web混合开发相关技术。 这类技术存在适用场景,例如:Qt项目使用Web大量现成的组件/方案做功能扩展, Qt项目中性能无关/频繁更新迭代的页面用html单独实现,Qt项目提供Web形式的SDK给 用户做二次开发等等,或者是Web开发人员齐全而Qt/C++人手不足,此类非技术问题, 都可以使用Qt + Web混合开发。 (不适用的请忽略本文) 简介 上次的文章《Qt与Web混合开发》,讨论了Qt与Web混合开发相关技术。 这次通过一个web控制小车的案例,继续讨论相关技术。 本文会先介绍Qt与Web嵌套使用,再介绍Qt与Web分开使用,之后着重讨论分开使用 的一些实现细节,特别是WebChannel通信、WebChannel在Web/typescript中的使用。 Qt与Web嵌套 MiniBrowser 这里以Qt官方的例子MiniBrowser来说明吧。 打开方式如下: 运行效果如下: 这个例子是在Qml中嵌套了WebView。 半透明测试 涛哥做了一个简单的半透明测试。 增加了两个半透明的小方块,蓝色的在WebView上面,红色的在WebView下面。 运行效果也是正确的: 代码是这样的: 红色框中是我增加的代码。 为什么要做半透明测试呢?根据以往的经验,不同渲染方式的两种窗口/组件嵌套在一起,总会出现透明失效之类的问题,例如 qml与Widget嵌套。 渲染原理 涛哥翻了一下Qt源码,了解到渲染的实现方式,Windows平台大致如下: chromium在单独的进程处理html渲染,并将渲染结果存储在共享内存中;主窗口在需要重绘的时候,从共享内存中获取内容并渲染。 小结 这里的WebView内部封装好了WebEngine,其本身也是一个Item,就和普通的Qml一样,属性绑定、js function都可以正常使用,暂时不深入讨论了。 Qt与Web分离 Qt与Web分离,就是字面意思,Web在单独的浏览器或者App中运行,不和Qt堆在一起。两者通过socket进行通信。 这里用我自己做的例子来说明吧。 先看看效果: 左边是Qt实现的一个简易小车,可以前进和转向。右边是Html5实现的控制端,控制左边的小车。 源码在github上: https://github.com/jaredtao/QtWeb Qt小车 原版小车 小车来自Qt的D-Bus Remote Controller 例子 原版的例子,实现了通过QDBus 跨进程 控制小车。 (吐槽:这是一个古老的例子,使用了GraphicsView 和QDBus) (知识拓展1: DBus是unix系统特有的一种进程间通信机制,使用有些复杂。Qt对DBus机制进行了封装/简化,即QDBus模块, 通过xml文件的配置后,把DBus的使用转换成了信号-槽的形式。类似于现在的Qt Remote Objects) (知识拓展2: Windows本身不支持DBus,网上有socket模拟DBus的方案。参考: https://www.freedesktop.org/wiki/Software/dbus/) 改进小车 我做了一些修改,主要如下: 去掉了DBus 增加控制按钮 增加WebChannel 修改Car的实现,导出一些属性和函数。 注册Car到WebChannel 这里贴一些关键代码 Car的头文件: 其中要说明的是: speed和angle属性具备 读、写、change信号。 还有加速、减速、左转、右转四个公开的槽函数。 必要的知识 WebSocket和 QWebSocket WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。 WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。 Qt为我们封装好了WebSocket,即QWebSocket和QWebSocketServer,简单易用。 如果你了解socket编程,就看作TCP好了;如果不了解,请先去补充一下知识吧。 WebChannel 按涛哥的理解,WebChannel是在socket上建立的一种通信协议,这个协议的作用是把QObject暴露给远端的HTML。 大致使用流程: Qt程序中,要暴露的QObject全部注册到WebChannel。 Qt程序中,启动一个WebSocketServer,等待Html的连接。 Html加载好qwebchannel.js文件, 然后去连接WebSocket。 连接建立以后,Qt程序中,由WebChannel接手这个WebSocket,按协议将QObject的各种“元数据”传输给远端Html。 Html端,qwebchannel.js处理WebSocket收到的各种“元数据”,用js的Object 动态创建出对应的QObject。 到这里两边算是做好了准备,可以互相调用了。 Qt端QObject数据变化只要发出信号,就会由WebChannel自动通知Web端; Web端可以主动调用QObject的public的 invok函数、槽函数,以及读、写属性。 Qt启动系统浏览器 在使用WebChannel的时候,Qt端建立了WebSocketServer,之后要把server的路径(例如:ws://127.0.0.1:12345)告诉Html。 一般就是在打开Html的时候带上Query参数,例如: F:\QtWeb\index.html?webChannelBaseUrl=ws://127.0.0.1:12345 Qt的OpenUrl Qml中有 Qt.openUrlExternally, C++ 中有 QDesktopServices::openUrl,本质一样, 都可以打开一个本地的html网页。 其在Windows平台的底层实现是Win32 API。这里有个Win32 API的缺陷,传Query参数会被丢掉。 C# .net的 Process::Start 涛哥找到了替代的方案: .net framework / .net core有个启动进程的函数: System.Diagnostics.Process::Start, 可以调用浏览器并传query参数 1 2 3 4 5 6 //C# 启动chrome System.Diagnostics.Process.Start('chrome', 'F:\QtWeb\index.html?webChannelBaseUrl=ws://127.0.0.1:12345'); //C# 启动firefox System.Diagnostics.Process.Start('firefox', 'F:\QtWeb\index.html?webChannelBaseUrl=ws://127.0.0.1:12345'); //C# 启动IE System.Diagnostics.Process.Start('IExplore', 'F:\QtWeb\index.html?webChannelBaseUrl=ws://127.0.0.1:12345'); Qt中直接写C#当然不太好,不过呢,Win7/Win10 系统都带有Powershell,而powershell依赖于.net framework, 我们可以调用powershell来间接使用.net framework。 所以有了下面的代码: 1 2 3 4 5 6 7 8 ... QString psCmd = QString("powershell -noprofile -command \"[void][System.Diagnostics.Process]::Start('%1', '%2')\"").arg(browser).arg(url.toString()); bool ok = QProcess::startDetached(psCmd); qWarning() << psCmd; if (!ok) { qWarning() << "failed"; } ... 结果完美运行。 Web控制端 目录结构 Web端就按照Web常规流程开发。 Web部分的源码也在前文提到的github仓库,子路径是QtWeb\WebChannelCar\Web 如下是Web部分的目录结构: 脚本用typescript,包管理用npm,打包用webpack,编辑器用vs code, 都中规中矩。 内容比较简单,暂时不需要前端框架,手(复)写(制)的html和css。 Html html部分比较简单 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 //index.html <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; chartset=utf-8" /> <link rel="stylesheet" type="text/css" href="../style/style.css" /> <link rel="stylesheet" type="text/css" href="../style/layout.css" /> </head> <body> <button id="up" class="green button">加速</button> <button id="down" class="red button">减速</button> <button id="left" class="blue button">左转</button> <button id="right" class="blue button">右转</button> <img id="img" src="../img/disconnected.svg" /> <div> <div> <label>速度: </label> <label id="speed">0</label> </div> <div> <label>角度: </label> <label id="angle">0</label> </div> </div> </body> <script src="../out/main.js"> </script> </html> 样式和布局全靠css,这里就不贴了。 TypeScript 脚本部分需要细说了。 src文件夹为全部脚本,目录结构如下: TypeScript中的QObject 从main开始, 加点注释: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 //main.ts import WebChannelCore from "./webchannelCore"; //window加载时回调,入口 window.onload = () => { //初始化WebChannel,传参为两个回调,分别对应WebChannel建立连接和连接断开。 WebChannelCore.initialize(onInit, onUninit); } //WebChannel建立连接的处理 function onInit() { //换图标 (window as any).document.getElementById("img").src = "../img/connected.svg"; //获取QObject对象 let car = WebChannelCore.SDK.car; //取dom树上的组件 let upBtn = (window as any).document.getElementById("up"); let downBtn = (window as any).document.getElementById("down"); let leftBtn = (window as any).document.getElementById("left"); let rightBtn = (window as any).document.getElementById("right"); let speedLabel = (window as any).document.getElementById("speed"); let angleLabel = (window as any).document.getElementById("angle"); //绑定按钮点击事件 upBtn.onclick = () => { //调用QObject的接口 car.accelerate(); } downBtn.onclick = () => { car.decelerate(); } leftBtn.onclick = () => { car.turnLeft(); } rightBtn.onclick = () => { car.turnRight(); } //QObject的信号连接到js 回调 car.speedChanged.connect(onSpeedChanged); car.angleChanged.connect(onAngleChanged); } //WebChannel断开连接的处理 function onUninit() { //换图标 (window as any).document.getElementById("img").src = "../img/disconnected.svg"; } //异步更新 speed async function onSpeedChanged() { let speedLabel = (window as any).document.getElementById("speed"); let car = WebChannelCore.SDK.car; //获取speed,异步等待。 //注意这里改造过qwebchannel.js,才能使用await。 speedLabel.textContent = await car.getSpeed(); } //异步更新 angle async function onAngleChanged() { let angleLabel = (window as any).document.getElementById("angle"); let car = WebChannelCore.SDK.car; //获取angle,异步等待。 //注意这里改造过qwebchannel.js,才能使用await。 angleLabel.textContent = await car.getAngle(); } 可以看到我们从WebChannelCore.SDK 中获取了一个car对象,之后就当作QObject来用了,包括调用它的函数、连接change信号、访问属性等。 这一切都得益于WebSocket/WebChannel. TypeScript中连接websocket 接下来看一下WebChannelCore的实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 //WebChannelCore.ts import { QWebChannel } from './qwebchannel'; type callback = () => void; export default class WebChannelCore { public static SDK: any = undefined; private static connectedCb: callback; private static disconnectedCb: callback; private static socket: WebSocket; //初始化函数 public static initialize(connectedCb: callback = () => { }, disconnectedCb: callback = () => { }) { if (WebChannelCore.SDK != undefined) { return; } //保存两个回调 WebChannelCore.connectedCb = connectedCb; WebChannelCore.disconnectedCb = disconnectedCb; try { //调用link,并传入两个回调参数 WebChannelCore.link( (socket) => { //socket连接成功时,创建QWebChannel QWebChannel(socket, (channel: any) => { WebChannelCore.SDK = channel.objects; WebChannelCore.connectedCb(); }); } , (error) => { //socket出错 console.log("socket error", error); WebChannelCore.disconnectedCb(); }); } catch (error) { console.log("socket exception:", error); WebChannelCore.disconnectedCb(); WebChannelCore.SDK = undefined; } } private static link(resolve: (socket: WebSocket) => void, reject: (error: Event | CloseEvent) => void) { //获取Query参数中的websocket地址 let baseUrl = "ws://localhost:12345"; if (window.location.search != "") { baseUrl = (/[?&]webChannelBaseUrl=([A-Za-z0-9\-:/\.]+)/.exec(window.location.search)![1]); } console.log("Connectiong to WebSocket server at: ", baseUrl); //创建WebSocket let socket = new WebSocket(baseUrl); WebChannelCore.socket = socket; //WebSocket的事件处理 socket.onopen = () => { resolve(socket); }; socket.onerror = (error) => { reject(error); }; socket.onclose = (error) => { reject(error); }; } } (window as any).SDK = WebChannelCore.SDK; 这部分代码不复杂,主要是连接WebSocket,连接好之后创建一个QWebChannel。 TypeScript中的QWebChannel 观察仔细的同学会发现,src文件夹下面,没有叫‘qwebchannel.ts’的文件,而是‘qwebchannel.js’,和一个‘qwebchannel.d.ts’ 这涉及到另一个话题: TypeScript中使用javaScript ‘qwebchannel.js’是Qt官方提供的,在js中用足够了。 而我们这里是用TypeScript,按照TypeScript的规则,直接引入js是不行的,需要一个声明文件 xxx.d.ts 所以我们增加了一个qwebchannel.d.ts文件。 (熟悉C/C++的同学,可以把d.ts看作typescript的头文件) 内容如下: 1 2 //qwebchannel.d.ts export declare function QWebChannel(transport: any, initCallback: Function): void; 只是导出了一个函数。 这个函数的实现在‘qwebchannel.js’中: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 //qwebchannel.js "use strict"; var QWebChannelMessageTypes = { signal: 1, propertyUpdate: 2, init: 3, idle: 4, debug: 5, invokeMethod: 6, connectToSignal: 7, disconnectFromSignal: 8, setProperty: 9, response: 10, }; var QWebChannel = function(transport, initCallback) { if (typeof transport !== "object" || typeof transport.send !== "function") { console.error("The QWebChannel expects a transport object with a send function and onmessage callback property." + " Given is: transport: " + typeof(transport) + ", transport.send: " + typeof(transport.send)); return; } ... } function QObject(name, data, webChannel) { ... } 这个代码比较长,就不全部贴出来了。主要实现了两个类,QWebChannel和QObject。 QWebChannel就是用来接管websocket的,而QObject是用js Object模拟的 Qt的 QObject。 这一块不细说了,感兴趣的同学可以自己去研究源码。 改进qwebchannel.js以支持await Qt默认的qwebchannel.js在实际使用过程中,有些不好的地方,就是函数的返回值不是直接返回,而是要在回调函数中获取。 比如car.getAngle要这样用: 1 2 3 4 let angle = 0; car.getAngle((value:number)=> { angle = value; }); 我们的实际项目中,有大量带返回值的api,这样的用法每次都嵌套一个回调函数,很不友好,容易造成回调地狱。 我们同事的解决方案是,在typescript中把这些api再用Promise封装一层,外面用await调用。 例如这样封装一层: 1 2 3 4 5 6 7 function getAngle () { return new Promise((resolve)=>{ car.getAngle((value:number)=> { resolve(value); }); }); } 使用和前面的代码一样: 1 2 3 4 5 6 7 8 //异步更新 angle async function onAngleChanged() { let angleLabel = (window as any).document.getElementById("angle"); let car = WebChannelCore.SDK.car; //获取angle,异步等待。 //注意这里改造过qwebchannel.js,才能使用await。 angleLabel.textContent = await car.getAngle(); } 这种解决方案规避了回调地狱,但是工作量增加了。 涛哥思考良久,稍微改造一下qwebchannel.js,自动把Promise加进去,也不需要再额外封装了。 改动如下: QObject to Typescript 我们在Qt 程序中写了QObject,然后暴露给了ts。 在ts这边,一般也需要提供一个声明文件,明确有哪些api可用。 例如我们的car声明: 1 2 3 4 5 6 7 8 9 10 11 12 13 //CarObject.ts declare class Car { get speed():number; set speed(value:number); get angle():number; set angle(vlaue:number); public accelerate():void; public decelerate():void; public turnLeft():void; public turnRight():void; } 这里涛哥写了一个小工具,能够解析Qt中的QObject,并生成对应的ts文件。 当然还是实验阶段,有兴趣的也可以关注一下 https://github.com/jaredtao/QObject2TypeScript

2020/3/12
articleCard.readMore

玩转Qt(14)-Qt与Web混合开发

前言 简介 Qt的Web方案 Quick WebGL Stream Qt WebAssembly Qt WebEngine/WebView QtWebEngine的更新情况 WebEngine的架构 WebEngine的平台要求 Windows MacOS Linux WebView WebEngine的使用 WebEngine Widget最简Demo 源代码 运行结果 最小发布包 WebEngine Qml最简Demo 源码 运行结果 最小发布包 前言 这次讨论Qt与Web混合开发相关技术。 这类技术存在适用场景,例如:Qt项目使用Web大量现成的组件/方案做功能扩展, Qt项目中性能无关/频繁更新迭代的页面用html单独实现,Qt项目提供Web形式的SDK给 用户做二次开发等等,或者是Web开发人员齐全而Qt/C++人手不足,此类非技术问题, 都可以使用Qt + Web混合开发。 (不适用的请忽略本文) 简介 这篇文章,会先整体介绍一下Qt的各种Web方案,再提供简单的Demo,并做一些简要的说明。 之后的一篇文章,会通过一个Web控制Qt端小车的案例,来做进一步讨论。 Qt的Web方案 Qt提供的Web方案主要包括 WebEngine/WebView、Quick WebGL Stream、QtWebAssembly三种。 Quick WebGL Stream 可以参考Qt官方的WebGL Stream介绍文档 https://resources.qt.io/cn/qt-quick-webgl-release-512 ​resources.qt.io WebGL Stream在5.12中正式发布,其本质是一种通信技术,将已有的QtQuick程序中渲染指令和数据,通过socket传输给Web端,由WebGL实现界面渲染。 其使用方式非常的简单,无需修改源码,应用程序启动时,带上端口参数,例如: ./your-qt-application -platform webgl:port=8998 (相当于应用程序变成了一个服务器端程序) 这样程序就在后端运行,看不到界面了,之后浏览器打开本地网址 localhost:8998 或者内网地址/映射后的公网地址,就能在浏览器中看到程序页面。 WebGL Stream的应用不多,Qt官方给的案例是:欧洲某工厂的大量传感器监测设备,都以WebGL Stream的方式运行Qt 程序,本身都不带显卡和显示器,而在控制中心的显卡/显示器上,通过Web打开网页的方式,查看每个设备的运行状况。因此节约了大量显卡/显示器的成本。类比于网吧的无硬盘系统。 涛哥相信,未来结合5G技术会有不错的应用场景。 Qt WebAssembly Qt WebAssembly技术,在5.13中正式发布。本质是把Qt程序编译成浏览器支持的二进制文件,由浏览器加载运行。 一方面可以将现有的Qt程序编译成Web,另一方面可以用Qt/C++来弥补Web程序的性能短板。 Qt WebAssembly在使用细节上还有一些坑的地方,需要踩一踩。后续我再写文章吧。 Qt WebEngine/WebView Qt提供了WebEngine模块以支持Web功能。 Qt WebEngine基于google的开源浏览器chromium实现,类似的项目还有cef、miniblink等等。 QtWebEngine可以看作是一个完整的chromium浏览器。 (WebView是同类的方案,稍微有些区别。后文再说。) QtWebEngine的更新情况 浏览器技术十分的庞大,这里先不深入展开,先来关注一下Qt WebEngine对chromium的跟进情况。 数据来源于Qt wiki,Qt每个版本的change log Qt版本chromium后端chromium安全更新 5.9.056 5.9.1-59.0.3071.104 5.9.2-61.0.3163.79 5.9.3-62.0.3202.89 5.9.4-63.0.3239.132 5.9.5-65.0.3325.146 5.9.6-66.0.3359.170 5.9.7-69.0.3497.113 5.9.8-72.0.3626.121 5.9.9-78.0.3904.108 5.12.069 5.12.171.0.3578.94 5.12.272.0.3626.121 5.12.373.0.3683.75 5.12.474.0.3729.157 5.12.576.0.3809.87 5.12.677.0.3865.120 5.12.779.0.3945.130 5.14.077 5.14.179.0.3945.117 可以看到Qt在WebEngine模块,一直持续跟进Chromium的更新。 当前(2020/3/4)最新的chromium版本是80。 WebEngine的架构 QtWebEngine提供了C++和Qml的接口,可以在Widget/Qml中渲染HTML、XHTML、SVG, 也支持CSS样式表和JavaScript脚本。 QtWebEngine的架构图如下 基于Chromium封装了一个WebEngineCore模块,在此之上, WebEngine Widgets模块专门用于Widget项目, WebEngine 模块用于Qml项目, WebEngineProcess则是一个单独的进程,用来渲染页面、运行js脚本。 Web在单独的进程里,我们开发的时候知道这一点就好了,不需要额外关注, 只要在发布的时候,带上QTDIR目录下的可执行程序QtWebEngineProcess即可。 (这里提一下底层实现原理,使用了进程间共享OpenGL上下文的方式, 实现多个进程的UI混合在一起) WebEngine的平台要求 (以Qt5.12为参考) 首先一条是:不支持静态编译 (因为依赖的chromium、chromium本身的依赖库 不能静态编译) 接下来再看看各平台的要求和限制: Windows 编译器要 Visual Studio 2017 version 15.8 以上 系统环境要 Windows 10 SDK 默认只支持X64版本,如果要x86版本,要自己编译qt源码。 MacOS MacOS 10.12以上 XCode 8.3.3以上 MacOS 10.12以上 SDK 不支持32-bit 不兼容 Mac App Store (chromium使用了私有api,App Sandbox和chromium Sandbox优先级问题) Linux 编译器要 clang, 或者 gcc 5以上 需要pkg-config来探测依赖库,dbus-1和 fontconfig是必须的。 如果配置了xcb,还要额外配置相关库。 WebView Qt还提供了一个WebView组件,可以用来将Web内容嵌入到Qml程序中。(这个没有提供Widget的接口) WebView组件的实现,使用了平台原生api,在移动端意义重大,特别是在ios平台,使用 原生的web view,这样就能兼容App Store了。 在Windows/MacOS/Linux平台,则是渲染部分还是使用了WebEngine。 WebView的使用可以参考官方例子Minibrowser WebEngine的使用 WebEngine Widget最简Demo 源代码 这里示例一个最简单的demo, 使用WebEngine Widget模块提供的QWebEngineView: 1 2 3 4 5 6 7 //Demo.pro QT += core gui webenginewidgets CONFIG += c++11 SOURCES += \ main.cpp 注意pro文件中包含的Qt模块 1 2 3 4 5 6 7 8 9 10 11 12 13 //main.cpp #include <QApplication> #include <QWebEngineView> int main(int argc, char **argv) { QApplication app(argc, argv); QWebEngineView view; view.load(QUrl("https://www.zhihu.com/")); view.show(); return app.exec(); } 运行结果 上面代码以打开知乎首页为例,运行结果如下 最小发布包 涛哥尝试了在Windows平台,做出可用的最小发布包: 尺寸在170M左右。这些依赖项中,除了常见的Qt必备项platforms、Qt5Core、Qt5Gui等, Qt5WebEngineCore是最大的一个,有70M。QtWebEngineProcess.exe是新增加的一个exe程序, 前文说架构图时提到的单独进程就是这个程序实现。 resources/icudtl.dat在其它浏览器引擎中也常看到。 translations/qtwebengine_locales是WebEngine的翻译项,不带可能会发生翻译问题。 Qt5Positioning、Qt5PrintSupport一般不怎么用,但是不带这两个程序起不来。 同时发现Qml和Quick模块也是必须的,Qt5QuickWidgets也用上了。 涛哥查看源码后发现WebEngineCore模块依赖Quick和Qml模块。 WebEngine Qml最简Demo 再做一个纯Qml的Demo 源码 pro中增加webengine模块即可 1 2 3 4 5 6 7 8 9 10 //WebQml.pro QT += core gui quick qml webengine CONFIG += c++11 SOURCES += \ main.cpp RESOURCES += \ Qrc.qrc 注意初始化。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 //main.cpp #include <QGuiApplication> #include <QQuickView> #include <QtWebEngine> int main(int argc, char *argv[]) { QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QGuiApplication a(argc, argv); //初始化。时机在QApp之后,Window/View构造之前。 QtWebEngine::initialize(); QQuickView view; view.setSource(QUrl("qrc:/main.qml")); view.show(); return a.exec(); } qml导入模块,填入url 1 2 3 4 5 6 7 8 9 10 11 //main.qml import QtQuick 2.0 import QtWebEngine 1.8 Item { width: 800 height: 600 WebEngineView { anchors.fill: parent url: "https://www.zhihu.com" } } 运行结果 运行结果和上一个Demo一样 最小发布包 这回可以去掉Widget模块 同时也去掉不必要的翻译文件。包大小160M左右,和前面的差别不大。

2020/3/4
articleCard.readMore

玩转Qt(13)-安卓5.12.4环境搭建

简介 Qt for android 环境搭建,以Windows平台 Qt5.12为基准。 安装Qt 需要先安装Android版的Qt。 一般在Qt的安装包中都有集成,安装的时候勾上就行 安装必备工具链 Android开发必备的工具链包括: Java JDK Android SDK Android NDK 下载链接分别点击QtCreator中的按钮就能跳转到 如果不能访问,找镜像网站。 这里要注意版本,和Qt5.12适配的才能用。Qt官方也没有说,都是自己试出来的。 JDK: OpenJDK没有试过,Oracle 的JDK, LTS版本有 1.8 和1.11,但是目前的Android SDK都是基于1.8开发的,1.11还用不了。 SDK: SDK下最新的就好。 NDK: Qt5.12能用的NDK是R19C,当前最新的是R20,里面工具链修改了,Qt还没有跟上,得后续版本(Qt5.12.5) 都装好就行了,不要去修改环境变量。直接在QtCreator里面选好路径即可。 更新SDK,build Tool 到SDK 管理页面,安装一堆必要的工具和镜像。注意Android SDK Build Tool , 能用的是28.0.3,最新的29用不了。 创建项目 创建一个Qt项目,使用Andorid kit。 之后在项目管理页面中,点一下 “Create Template”按钮,生成一堆android相关配置文件。 之后去项目路径下,找到build.gradle文件 把buildToolsVersion改成”28.0.3” 最后编译运行就可以了。模拟器或真机连接,就不说了。

2019/12/8
articleCard.readMore

玩转Qt(12)-github-Actions缓存优化

简介 原理 缓存actions模板 缓存文档 缓存大小限制 缓存运作流程 Qt项目的缓存优化 无缓存的配置 加缓存 环境变量还原 最终配置 简介 在之前两篇文章《github-Actions自动化编译》《github-Actions自动化发行》中, 介绍了github-Actions的一些用法,其中有部分配置,已经有了缓存相关的步骤。 这里专门开一篇文章,来记录github-Actions的缓存优化相关的知识。 原理 缓存actions模板 github-Actions提供了缓存模板cache 缓存文档 官方文档也有说明 缓存文档 缓存大致原理就是把目标路径打包存储下来,并记录一个唯一key。 下次启动时,根据key去查找。找到了就再按路径解压开。 缓存大小限制 注意缓存有大小限制。对于免费用户,单个包不能超过500MB,整个仓库的缓存不能超过2G。 缓存运作流程 一般我们在任务步骤中增加一个cache 1 2 3 4 5 6 steps: ... - use: actions/cache@v1 with: ... ... 那么在这个地方,缓存执行的操作是restore。 在steps的末尾,会自动增加一个PostCache,执行的操作是record。 Qt项目的缓存优化 Qt项目每次运行Actions时,都是先通过install-qt-action模板,安装Qt,之后再获取代码,编译运行。 安装Qt这个步骤,可快可慢,涛哥在windows平台测试下来,平均要1分30秒左右。 加上cache后,平均只有25秒。 无缓存的配置 先看一个Qt项目的编译配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 name: Windows on: [push,pull_request] jobs: build: name: Build runs-on: windows-latest strategy: matrix: qt_ver: [5.12.6] qt_target: [desktop] qt_arch: [win64_msvc2017_64, win32_msvc2017] include: - qt_arch: win64_msvc2017_64 msvc_arch: x64 - qt_arch: win32_msvc2017 msvc_arch: x86 # 步骤 steps: # 安装Qt - name: Install Qt uses: jurplel/install-qt-action@v2.0.0 with: version: ${{ matrix.qt_ver }} target: ${{ matrix.qt_target }} arch: ${{ matrix.qt_arch }} # 拉取代码 - uses: actions/checkout@v1 with: fetch-depth: 1 # 编译msvc - name: build-msvc shell: cmd env: vc_arch: ${{ matrix.msvc_arch }} run: | call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" %vc_arch% qmake nmake 加缓存 缓存步骤,一般尽量写steps最前面。 1 2 3 4 5 6 7 8 9 # 步骤 steps: # 缓存 - name: cacheQt id: WindowsCacheQt uses: actions/cache@v1 with: path: ../Qt/${{matrix.qt_ver}}/${{matrix.qt_arch_install}} key: ${{ runner.os }}-Qt/${{matrix.qt_ver}}/${{matrix.qt_arch}} install-qt-action有默认的Qt安装路径${RUNNER_WORKSPACE},不过这个环境变量不一定能取到。 涛哥实际测试下来,以当前路径的上一级作为Qt路径即可。 环境变量还原 缓存只是把文件还原了,环境变量并没有还原,我们还需要手动还原环境变量。 install-qt-action这个模板增加了一个环境变量Qt5_Dir,值为Qt的安装路径,并把对应的bin添加到了Path。 我们要做的,就是在缓存恢复成功后,重新设置这两个变量,并去掉install-qt的步骤。 1 2 3 4 5 6 7 8 9 - name: setupQt if: steps.WindowsCacheQt.outputs.cache-hit == 'true' shell: pwsh env: QtPath: ../Qt/${{matrix.qt_ver}}/${{matrix.qt_arch_install}} run: | $qt_Path=${env:QtPath} echo "::set-env name=Qt5_Dir::$qt_Path" echo "::add-path::$qt_Path/bin" steps.WindowsCacheQt.outputs.cache-hit == ‘true’ 是缓存模板的输出值,可以作为后续步骤的条件判断。 最终配置 写个伪配置,简单示例一下缓存流程 steps: cache setupQt if: cache-hit == ‘true’ installQt if: cache-hit = ‘false’ 实际配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 name: Windows on: # push代码时触发workflow push: # 忽略README.md paths-ignore: - 'README.md' - 'LICENSE' # pull_request时触发workflow pull_request: # 忽略README.md paths-ignore: - 'README.md' - 'LICENSE' jobs: build: name: Build # 运行平台, windows-latest目前是windows server 2019 runs-on: windows-latest strategy: # 矩阵配置 matrix: qt_ver: [5.12.6] qt_target: [desktop] # mingw用不了 # qt_arch: [win64_msvc2017_64, win32_msvc2017, win32_mingw53,win32_mingw73] qt_arch: [win64_msvc2017_64, win32_msvc2017] # 额外设置msvc_arch include: - qt_arch: win64_msvc2017_64 msvc_arch: x64 qt_arch_install: msvc2017_64 - qt_arch: win32_msvc2017 msvc_arch: x86 qt_arch_install: msvc2017 env: targetName: HelloActions-Qt.exe # 步骤 steps: - name: cacheQt id: WindowsCacheQt uses: actions/cache@v1 with: path: ../Qt/${{matrix.qt_ver}}/${{matrix.qt_arch_install}} key: ${{ runner.os }}-Qt/${{matrix.qt_ver}}/${{matrix.qt_arch}} - name: setupQt if: steps.WindowsCacheQt.outputs.cache-hit == 'true' shell: pwsh env: QtPath: ../Qt/${{matrix.qt_ver}}/${{matrix.qt_arch_install}} run: | $qt_Path=${env:QtPath} echo "::set-env name=Qt5_Dir::$qt_Path" echo "::add-path::$qt_Path/bin" # 安装Qt - name: Install Qt if: steps.WindowsCacheQt.outputs.cache-hit != 'true' # 使用外部action。这个action专门用来安装Qt uses: jurplel/install-qt-action@v2.0.0 with: # Version of Qt to install version: ${{ matrix.qt_ver }} # Target platform for build target: ${{ matrix.qt_target }} # Architecture for Windows/Android arch: ${{ matrix.qt_arch }} # 拉取代码 - uses: actions/checkout@v1 with: fetch-depth: 1 # 编译msvc - name: build-msvc shell: cmd env: vc_arch: ${{ matrix.msvc_arch }} run: | call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" %vc_arch% qmake nmake

2019/12/4
articleCard.readMore

玩转Qt(11)-github-Actions自动化发行

简介 Qt项目的编译流程 Qt项目的发布流程 查找依赖 制作包 上传 定制发布流程 发布时机 打包步骤 多平台发布 最终配置 windows版的最终配置 MacOS最终配置 结果和代码 简介 在上一篇文章《github-Actions自动化编译》中,介绍了github-Actions的基本用法, 本文来介绍github-Actions的自动化发布。 Qt项目的编译流程 先来回顾一下,上一篇文章中的Qt项目的编译流程 安装Qt环境 这一步用第三方Action模板:install-qt-action 获取项目代码 这一步用Actions官方核心模板:actions/checkout 执行qmake、make 这一步用自定义脚本,也可以换成cmake、qbs、gn、ninja等构建工具 执行test 这一步可以引入单元测试、自动化UI测试等。暂无完善的方案,以后再说。 发布 见下文。 Qt项目的发布流程 Qt程序在编译完成后,发布的大致流程是: 1、 查找依赖库 2、制作压缩包或者安装包 3、上传压缩包或者安装包到网站、网盘。 查找依赖 Qt官方提供的查找依赖库的命令行工具,包括:Windows平台的Windeployqt、MacOS平台的Macosdeployqt。 在这两个平台,只使用Qt库的情况下,这两个工具足够了。 制作包 做压缩包比较简单。(我们常说的‘绿色软件’,就是一个压缩包) 一般安装7z、rar之类的压缩工具,用一条命令行就行了。 涛哥这里再说一下,github-Actions给所有平台都提供了PowerShell,而PowerShell内置了压缩命令Compress-Archive。 使用也很简单,只要路径和名字,例如: 1 Compress-Archive -Path .\MyFolder 'MyRelease.zip' 做安装包,Qt官方有功能很全面的安装包制作工具:QtInstallFrameWork, 稍微翻看一下文档或者例子即可。本文先不展开了。 上传 github 本身提供了’Release’功能,每个仓库都有一个’Release’页面 可以将打包好的压缩包或者安装包,直接上传上去, 供他人下载。 github-Actions还提供了 创建’Release’、上传’Release’的模板 actions/create-release actions/upload-release-asset 这两个模板的用法也很简单,在yml文件中直接use就行了,不赘述了。 定制发布流程 前面介绍了一些简单的理论,接下来通过实例,教大家github-Actions的使用。 以HelloActions-Qt项目为例,做一些定制。 需求如下: 1、每次提交代码,同时在Windows、MacOS、Ubuntu、Android、IOS五个平台编译 2、每次提交tag,在windows和MacOS平台制作软件包,并发布到同一个github-‘Release’ 需求1已经实现了,着重讨论一下需求2: 发布时机 ‘每次提交tag’限定了发布的时机。 涛哥尝试了一番,最终得到答案。 回顾一下, Windows平台的编译配置: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 name: Windows on: [push,pull_request] jobs: build: name: Build runs-on: windows-latest strategy: matrix: qt_ver: [5.12.6] qt_target: [desktop] qt_arch: [win64_msvc2017_64, win32_msvc2017] include: - qt_arch: win64_msvc2017_64 msvc_arch: x64 - qt_arch: win32_msvc2017 msvc_arch: x86 # 步骤 steps: # 安装Qt - name: Install Qt uses: jurplel/install-qt-action@v2.0.0 with: version: ${{ matrix.qt_ver }} target: ${{ matrix.qt_target }} arch: ${{ matrix.qt_arch }} # 拉取代码 - uses: actions/checkout@v1 with: fetch-depth: 1 # 编译msvc - name: build-msvc shell: cmd env: vc_arch: ${{ matrix.msvc_arch }} run: | call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" %vc_arch% qmake nmake steps中的每一个步骤,可以有触发条件。我们可以在这里指定,只有github的事件为tag时才执行: 1 2 3 4 5 6 7 steps: 。。。 # tag 打包 - name: package if: startsWith(github.event.ref, 'refs/tags/') run: | 。。。 打包步骤 这里给出一个实际的打包步骤: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # tag 打包 - name: package if: startsWith(github.event.ref, 'refs/tags/') env: VCINSTALLDIR: 'C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC' archiveName: ${{ matrix.qt_ver }}-${{ matrix.qt_target }}-${{ matrix.qt_arch }} targetName: HelloActions-Qt.exe shell: pwsh run: | # 创建文件夹 New-Item -ItemType Directory ${env:archiveName} # 拷贝exe Copy-Item bin\${env:targetName} ${env:archiveName}\ # 拷贝依赖 windeployqt --qmldir . ${env:archiveName}\${env:targetName} # 打包zip Compress-Archive -Path ${env:archiveName} ${env:archiveName}'.zip' # 记录环境变量packageName给后续step $name = ${env:archiveName} echo "::set-env name=packageName::$name" # 打印环境变量packageName Write-Host 'packageName:'${env:packageName} 做一些说明: vs运行时 其中的VCINSTALLDIR环境变量,是给windeployqt用的。有了这个环境变量,windeployqt会去msvc的安装路径提取‘运行时安装程序’。 记录包名称 打包完以后,将包名设置为环境变量,后续的步骤就可以通过环境变量拿到包名字了。 普通的设置环境变量,在步骤执行完成后就失效了, 这里使用github-Actions的‘记录命令’set-env ,具体可以参考文档github-Actions记录命令 文档说不要用双引号,应该都是针对linux的,我试出来的PowerShell用法如下: 1 2 $name = ${env:archiveName} echo "::set-env name=packageName::$name" 先取环境变量到一个局部变量,再在‘记录命令’中引用局部变量。 多平台发布 如果只有一个平台、一种配置,直接用那两个模板就能解决问题。 这是官方给的例子upload-release-asset: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 steps: - name: Checkout code uses: actions/checkout@master - name: Build project # This would actually build your project, using zip for an example artifact run: | zip --junk-paths my-artifact README.md - name: Create Release id: create_release uses: actions/create-release@v1.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: Release ${{ github.ref }} draft: false prerelease: false - name: Upload Release Asset id: upload-release-asset uses: actions/upload-release-asset@v1.0.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps asset_path: ./my-artifact.zip asset_name: my-artifact.zip asset_content_type: application/zip 在多平台 或者 多配置的情况下,同一个tag, 只有第一个执行create-release的任务可以成功,后续任务 再次执行create-release时,该tag下已经有了同名的‘Release’,所以会create失败。 这个问题折磨了涛哥好一阵子。找不到现成的解决方案,涛哥就自己实现了一种: 先用github的REST API去判断该tag下有没有‘Release’: 没有则执行create-release,并提取upload_url; 有则提取upload_url。 最后执行upload-release-asset 调用REST API,涛哥依旧使用了方便的PowerShell, 实际的配置如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 # tag 查询github-Release - name: queryReleaseWin id: queryReleaseWin if: startsWith(github.event.ref, 'refs/tags/') shell: pwsh env: githubFullName: ${{ github.event.repository.full_name }} ref: ${{ github.event.ref }} run: | [string]$tag = ${env:ref}.Substring(${env:ref}.LastIndexOf('/') + 1) [string]$url = 'https://api.github.com/repos/' + ${env:githubFullName} + '/releases/tags/' + ${tag} $response={} try { $response = Invoke-RestMethod -Uri $url -Method Get } catch { Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription # 没查到,输出 echo "::set-output name=needCreateRelease::true" return } [string]$latestUpUrl = $response.upload_url Write-Host 'latestUpUrl:'$latestUpUrl if ($latestUpUrl.Length -eq 0) { # 没查到,输出 echo "::set-output name=needCreateRelease::true" } # tag 创建github-Release - name: createReleaseWin id: createReleaseWin if: startsWith(github.event.ref, 'refs/tags/') && steps.queryReleaseWin.outputs.needCreateRelease == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: actions/create-release@v1.0.0 with: tag_name: ${{ github.ref }} release_name: Release ${{ github.ref }} body: ${{ github.event.head_commit.message }} draft: false prerelease: false # tag 重定向upload_url到环境变量uploadUrl。 - name: getLatestTagRelease if: startsWith(github.event.ref, 'refs/tags/') shell: pwsh env: githubFullName: ${{ github.event.repository.full_name }} upUrl: ${{ steps.createReleaseWin.outputs.upload_url }} ref: ${{ github.event.ref }} run: | # upUrl不为空,导出就完事 if (${env:upUrl}.Length -gt 0) { $v=${env:upUrl} echo "::set-env name=uploadUrl::$v" return } # upUrl为空则重新获取 [string]$tag = ${env:ref}.Substring(${env:ref}.LastIndexOf('/') + 1) [string]$url = 'https://api.github.com/repos/' + ${env:githubFullName} + '/releases/tags/' + ${tag} $response = Invoke-RestMethod -Uri $url -Method Get [string]$latestUpUrl = $response.upload_url Write-Host 'latestUpUrl:'$latestUpUrl # 导出 echo "::set-env name=uploadUrl::$latestUpUrl" Write-Host 'env uploadUrl:'${env:uploadUrl} # tag 上传Release - name: uploadRelease id: uploadRelease if: startsWith(github.event.ref, 'refs/tags/') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: actions/upload-release-asset@v1.0.1 with: upload_url: ${{ env.uploadUrl }} asset_path: ./${{ env.packageName }}.zip asset_name: ${{ env.packageName }}.zip asset_content_type: application/zip 最终配置 windows版的最终配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 name: Windows on: # push代码时触发workflow push: # 忽略README.md paths-ignore: - 'README.md' - 'LICENSE' # pull_request时触发workflow pull_request: # 忽略README.md paths-ignore: - 'README.md' - 'LICENSE' jobs: build: name: Build # 运行平台, windows-latest目前是windows server 2019 runs-on: windows-latest strategy: # 矩阵配置 matrix: qt_ver: [5.12.6] qt_target: [desktop] qt_arch: [win64_msvc2017_64, win32_msvc2017] # 额外设置msvc_arch include: - qt_arch: win64_msvc2017_64 msvc_arch: x64 qt_arch_install: msvc2017_64 - qt_arch: win32_msvc2017 msvc_arch: x86 qt_arch_install: msvc2017 env: targetName: HelloActions-Qt.exe # 步骤 steps: # 安装Qt - name: Install Qt # 使用外部action。这个action专门用来安装Qt uses: jurplel/install-qt-action@v2.0.0 with: # Version of Qt to install version: ${{ matrix.qt_ver }} # Target platform for build target: ${{ matrix.qt_target }} # Architecture for Windows/Android arch: ${{ matrix.qt_arch }} # 拉取代码 - uses: actions/checkout@v1 with: fetch-depth: 1 # 编译msvc - name: build-msvc shell: cmd env: vc_arch: ${{ matrix.msvc_arch }} run: | call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" %vc_arch% qmake nmake # tag 打包 - name: package if: startsWith(github.event.ref, 'refs/tags/') env: VCINSTALLDIR: 'C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC' archiveName: ${{ matrix.qt_ver }}-${{ matrix.qt_target }}-${{ matrix.qt_arch }} shell: pwsh run: | # 创建文件夹 New-Item -ItemType Directory ${env:archiveName} # 拷贝exe Copy-Item bin\${env:targetName} ${env:archiveName}\ # 拷贝依赖 windeployqt --qmldir . ${env:archiveName}\${env:targetName} # 打包zip Compress-Archive -Path ${env:archiveName} ${env:archiveName}'.zip' # 记录环境变量packageName给后续step $name = ${env:archiveName} echo "::set-env name=packageName::$name" # 打印环境变量packageName Write-Host 'packageName:'${env:packageName} # tag 查询github-Release - name: queryReleaseWin id: queryReleaseWin if: startsWith(github.event.ref, 'refs/tags/') shell: pwsh env: githubFullName: ${{ github.event.repository.full_name }} ref: ${{ github.event.ref }} run: | [string]$tag = ${env:ref}.Substring(${env:ref}.LastIndexOf('/') + 1) [string]$url = 'https://api.github.com/repos/' + ${env:githubFullName} + '/releases/tags/' + ${tag} $response={} try { $response = Invoke-RestMethod -Uri $url -Method Get } catch { Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription # 没查到,输出 echo "::set-output name=needCreateRelease::true" return } [string]$latestUpUrl = $response.upload_url Write-Host 'latestUpUrl:'$latestUpUrl if ($latestUpUrl.Length -eq 0) { # 没查到,输出 echo "::set-output name=needCreateRelease::true" } # tag 创建github-Release - name: createReleaseWin id: createReleaseWin if: startsWith(github.event.ref, 'refs/tags/') && steps.queryReleaseWin.outputs.needCreateRelease == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: actions/create-release@v1.0.0 with: tag_name: ${{ github.ref }} release_name: Release ${{ github.ref }} body: ${{ github.event.head_commit.message }} draft: false prerelease: false # 重定向upload_url到环境变量uploadUrl。 - name: getLatestTagRelease # tag 上一步无论成功还是失败都执行 if: startsWith(github.event.ref, 'refs/tags/') shell: pwsh env: githubFullName: ${{ github.event.repository.full_name }} upUrl: ${{ steps.createReleaseWin.outputs.upload_url }} ref: ${{ github.event.ref }} run: | # upUrl不为空,导出就完事 if (${env:upUrl}.Length -gt 0) { $v=${env:upUrl} echo "::set-env name=uploadUrl::$v" return } [string]$tag = ${env:ref}.Substring(${env:ref}.LastIndexOf('/') + 1) [string]$url = 'https://api.github.com/repos/' + ${env:githubFullName} + '/releases/tags/' + ${tag} $response = Invoke-RestMethod -Uri $url -Method Get [string]$latestUpUrl = $response.upload_url Write-Host 'latestUpUrl:'$latestUpUrl echo "::set-env name=uploadUrl::$latestUpUrl" Write-Host 'env uploadUrl:'${env:uploadUrl} # tag 上传Release - name: uploadRelease id: uploadRelease if: startsWith(github.event.ref, 'refs/tags/') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: actions/upload-release-asset@v1.0.1 with: upload_url: ${{ env.uploadUrl }} asset_path: ./${{ env.packageName }}.zip asset_name: ${{ env.packageName }}.zip asset_content_type: application/zip MacOS最终配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 name: MacOS on: push: paths-ignore: - 'README.md' - 'LICENSE' pull_request: paths-ignore: - 'README.md' - 'LICENSE' jobs: build: name: Build runs-on: ${{ matrix.os }} strategy: matrix: os: [macos-latest] qt_ver: [5.12.6] qt_arch: [clang_64] env: targetName: HelloActions-Qt steps: - name: Install Qt uses: jurplel/install-qt-action@v2.0.0 with: version: ${{ matrix.qt_ver }} - uses: actions/checkout@v1 with: fetch-depth: 1 - name: build macos run: | qmake make # tag 打包 - name: package if: startsWith(github.event.ref, 'refs/tags/') run: | # 拷贝依赖 macdeployqt bin/${targetName}.app -qmldir=. -verbose=1 -dmg # tag 查询github-Release - name: queryRelease id: queryReleaseMacos if: startsWith(github.event.ref, 'refs/tags/') shell: pwsh env: githubFullName: ${{ github.event.repository.full_name }} ref: ${{ github.event.ref }} run: | [string]$tag = ${env:ref}.Substring(${env:ref}.LastIndexOf('/') + 1) [string]$url = 'https://api.github.com/repos/' + ${env:githubFullName} + '/releases/tags/' + ${tag} $response={} try { $response = Invoke-RestMethod -Uri $url -Method Get } catch { Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription # 没查到,输出 echo "::set-output name=needCreateRelease::true" return } [string]$latestUpUrl = $response.upload_url Write-Host 'latestUpUrl:'$latestUpUrl if ($latestUpUrl.Length -eq 0) { # 没查到,输出 echo "::set-output name=needCreateRelease::true" } # tag 创建github-Release - name: createReleaseWin id: createReleaseWin if: startsWith(github.event.ref, 'refs/tags/') && steps.queryReleaseMacos.outputs.needCreateRelease == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: actions/create-release@v1.0.0 with: tag_name: ${{ github.ref }} release_name: Release ${{ github.ref }} body: ${{ github.event.head_commit.message }} draft: false prerelease: false # 重定向upload_url到环境变量uploadUrl。 - name: getLatestTagRelease # tag 上一步无论成功还是失败都执行 if: startsWith(github.event.ref, 'refs/tags/') shell: pwsh env: githubFullName: ${{ github.event.repository.full_name }} upUrl: ${{ steps.queryReleaseMacos.outputs.upload_url }} ref: ${{ github.event.ref }} run: | # upUrl不为空,导出就完事 if (${env:upUrl}.Length -gt 0) { $v=${env:upUrl} echo "::set-env name=uploadUrl::$v" return } [string]$tag = ${env:ref}.Substring(${env:ref}.LastIndexOf('/') + 1) [string]$url = 'https://api.github.com/repos/' + ${env:githubFullName} + '/releases/tags/' + ${tag} $response = Invoke-RestMethod -Uri $url -Method Get [string]$latestUpUrl = $response.upload_url Write-Host 'latestUpUrl:'$latestUpUrl echo "::set-env name=uploadUrl::$latestUpUrl" Write-Host 'env uploadUrl:'${env:uploadUrl} # tag 上传Release - name: uploadRelease id: uploadRelease if: startsWith(github.event.ref, 'refs/tags/') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: actions/upload-release-asset@v1.0.1 with: upload_url: ${{ env.uploadUrl }} asset_path: ./bin/${{ env.targetName }}.dmg asset_name: ${{ env.targetName }}.dmg asset_content_type: application/applefile 结果和代码 代码在github HelloActions-Qt 另外在涛哥的Qml控件库TaoQuick,也使用了这一套配置 TaoQuick

2019/12/3
articleCard.readMore

玩转Qt(10)-github-Actions自动化编译

前言 简介 代码仓库 效果预览 使用方式 原理 Actions官方文档 Actions的默认环境 Actions语法 Actions模板 Qt项目的编译流程 Qt相关的Actions模板 install-qt-action action-setup-qt 扩展 Windows平台 默认mingw不能用 Windows平台指定shell Ubuntu平台 MacOS平台 Android平台 IOS平台 前言 几个月前写过两篇持续集成的教程,当时使用的是travis和appveyor这两个第三方网址提供的服务。 由于配置比较复杂,劝退了很多同学…… 2019年8月份,github正式上线了Actions功能,提供了十分强大的CI(持续集成)/CD(持续部署)服务, 使用非常简单、方便,再加上github的Marketplace(github的应用商店)有各路大神开源的Actions模板, 完全可以抛弃那些落后的第三方服务了。 注:Actions也能在私有仓库上用(微软良心)。 简介 这回涛哥将给大家提供一个简易的Qt项目的Action模板,让每一个有追求的Qter,都能轻松地用上强大的CI/CD功能。 (本文先说自动化编译,自动化发布下次说。) 代码仓库 我创建了一个新的代码仓库,地址在这: https://github.com/jaredtao/HelloActions-Qt 效果预览 先来看看效果吧 这是github的Actions页面 图中可以看到,最后一次提交的代码,在Windows、Ubuntu、MacOS、Android、IOS五个平台都编译通过了(通过显示绿色的对勾✔,未通过显示红色的叉❌)。 涛哥是个徽章爱好者,把这些徽章都链接进了README文件中。别人在预览代码仓库的时候,很容易就能看到仓库的编译状态。 当然,在commit页面,还可以详细查看每一次commit的代码,是否都编译通过 使用方式 (这里假设各位读者会使用基本的git、github操作,不会的请去搜索相关教程) 下载涛哥的仓库HelloActions-Qt 1 git clone https://github.com/jaredtao/HelloActions-Qt 拷贝文件夹’.github’到你的代码仓库根目录 在你的仓库中commit并添加.github文件夹中的文件 push你的仓库到github push完就可以了,到你的github相应仓库页面-Actions子页面查看状态吧。 没错,复制、粘贴,就这么简单。 .github/workflows文件夹中包括写好的5个模板: 你也可以根据你的需要,只选择你需要的。 原理 授人以鱼,不如授人以渔 这里再来介绍一些基本的原理。 Actions官方文档 可以参考 github Actions官方文档 中文文档目前翻译不全面,建议优先看英文的。 Actions的默认环境 github-Actions 主要提供了windows server 2019、macos 10.15、ubuntu 18.04三个平台的docker环境, 并预装了大量开发者常用的软件,比如Java SDK、Android SDK、VisualStudio、python、golang、nodejs等, 可以在文档github Actions默认环境及预装软件 中看到详细的信息。 Actions语法 github-Actions和大部分docker环境一样,使用yaml/yml格式的配置文件。 同时github-Actions还提供了一些便利的功能、函数,可以参考 github Actions配置文件语法 更多细节请大家参考文档,这里就不赘述了。 Actions模板 每个github仓库,都有一个Actions页面,在这里可以创建、管理Actions 一般使用nodejs、python、golang等环境的项目,github提供了现成的Actions模板,可以 直接在Actions创建页面或者Marketplace(github的应用商店)进行搜索、引用。 有闲暇的开发者,也可以开发自己的Actions并提交到github商店,甚至可以赚点零花钱哦。 (Actions开发使用TypeScript) Qt项目的编译流程 简单总结一下Qt项目的编译流程 安装Qt环境 这一步用下文的Action模板:install-qt-action 获取项目代码 这一步用Actions官方核心模板:actions/checkout@v1 执行qmake、make 这一步用自定义脚本,可以换成qbs、cmake、gn、ninja等构建工具 执行test 这一步可以引入单元测试、自动化UI测试等。以后再说。 执行deployment 等我下一篇文章 Qt相关的Actions模板 install-qt-action Qt项目暂时没有公开、完整的Actions模板,不过有一个安装Qt的Actions,解决了在不同平台安装不同版本Qt的问题。 install-qt-action github的Actions有一个非常强大的功能,就是引用外部模板。 比如要引入这个install-qt-Actions模板,只要在配置文件中添加两行即可: 1 2 3 4 ... - name: Install Qt uses: jurplel/install-qt-action@v2 ... Qt的安装路径、版本、目标平台、目标架构都有默认配置,当然你也可以手动配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... - name: Install Qt uses: jurplel/install-qt-action@v2 with: # 安装目录,默认当前路径 #dir: # optional # 版本,默认最新的LTS(5.12.6) version: 5.12.6 # 编译平台。一般不修改。 #host: # optional # 目标平台。默认desktop,可选android、ios target: desktop # 架构 arch: win64_msvc2017_64 ... 这个Actions模板的实现,是按照Actions的工作原理(TypeScript),调用另一个python仓库aqtinstall, 把配置参数传递过去,由该库完成Qt的安装。 aqtinstall由一位日本的程序员使用python开发,直接访问Qt官方的发布仓库 http://download.qt.io/online/qtsdkrepository/ , 下载指定平台的各模块压缩包,并解压到指定目录。 直接绕过了我们平常使用的Qt安装器。 aqtinstall没有实现‘只安装指定模块’,默认全安装。希望后续能做支持,毕竟Qt全安装太大了。 action-setup-qt 涛哥还发现一个开源的action,并没有进商店,功能是适配所有平台的Qt环境变量 https://github.com/Skycoder42/action-setup-qt 可以在该作者的’Json序列化库’中,看到实际应用 https://github.com/Skycoder42/QtJsonSerializer 目前是固定在Qt5.13.2版本,包含winrt、wasm等所有平台。 扩展 接下来,说一下涛哥提供的模板,对各平台的配置。 以方便那些,需要对模板做修改的同学。 Windows平台 涛哥在这个配置文件中,写了一些注释。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 # windows.yml name: Windows on: # push代码时触发workflow push: # 忽略README.md paths-ignore: - 'README.md' - 'LICENSE' # pull_request时触发workflow pull_request: # 忽略README.md paths-ignore: - 'README.md' - 'LICENSE' jobs: build: name: Build # 运行平台, windows-latest目前是windows server 2019 runs-on: windows-latest strategy: # 矩阵配置 matrix: qt_ver: [5.9.8,5.12.6] qt_target: [desktop] # mingw用不了 # qt_arch: [win64_msvc2017_64, win32_msvc2017, win32_mingw53,win32_mingw73] qt_arch: [win64_msvc2017_64, win32_msvc2017] # 从矩阵中除外的配置 exclude: # 不存在5.9.8-win32_msvc2017的版本 - qt_ver: 5.9.8 qt_arch: win32_msvc2017 # mingw用不了 # - qt_ver: 5.9.8 # qt_arch: win32_mingw73 # - qt_ver: 5.12.6 # qt_arch: win32_mingw53 # 额外设置msvc_arch include: - qt_arch: win64_msvc2017_64 msvc_arch: x64 - qt_arch: win32_msvc2017 msvc_arch: x86 # 步骤 steps: # 安装Qt - name: Install Qt # 使用外部action。这个action专门用来安装Qt uses: jurplel/install-qt-action@v2.0.0 with: # Version of Qt to install version: ${{ matrix.qt_ver }} # Target platform for build target: ${{ matrix.qt_target }} # Architecture for Windows/Android arch: ${{ matrix.qt_arch }} # 拉取代码 - uses: actions/checkout@v1 with: fetch-depth: 1 # 编译msvc - name: build-msvc shell: cmd env: vc_arch: ${{ matrix.msvc_arch }} run: | call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" %vc_arch% qmake nmake 大部分配置都是显而易见的,这里对一些特殊情况做一些说明吧。 默认mingw不能用 windows平台优先推荐用msvc编译,不过有些情况不得不用mingw。 github-Actions提供的Windows Server 2019环境,预装Mingw为8.1.0,版本太高了。 Qt5.9需要的mingw版本是5.3,而5.12则需要7.3,涛哥试过简单的HelloWorld程序,都会报链接失败。 所以需要使用MinGW的同学,需要自己安装了。 Windows平台指定shell github-Actions在Windows平台默认的shell是PowerShell,其它平台是bash。 使用msvc命令行编译项目时,一般要先调用’vcvarsxxx.bat’脚本来设置环境变量。 Powershell虽然强大,却不太方便直接调用这个bat。要么安装Powershell扩展Pcsx,要么 用一些取巧的方式: https://stackoverflow.com/questions/2124753/how-can-i-use-powershell-with-the-visual-studio-command-prompt github-Actions当然也可以直接指定使用cmd。 1 2 3 4 5 6 7 8 9 10 11 ... # 编译msvc - name: build-msvc shell: cmd env: vc_arch: ${{ matrix.msvc_arch }} run: | call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" %vc_arch% qmake nmake ... Ubuntu平台 Ubuntu 平台看配置吧。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 # ubuntu.yml name: Ubuntu # Qt官方没有linux平台的x86包 on: push: paths-ignore: - 'README.md' - 'LICENSE' pull_request: paths-ignore: - 'README.md' - 'LICENSE' jobs: build: name: Build runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-16.04,ubuntu-18.04] qt_ver: [5.9.8,5.12.6] steps: - name: Install Qt uses: jurplel/install-qt-action@v2.0.0 with: version: ${{ matrix.qt_ver }} - name: ubuntu install GL library run: sudo apt-get install -y libglew-dev libglfw3-dev - uses: actions/checkout@v1 with: fetch-depth: 1 - name: build ubuntu run: | qmake make MacOS平台 MacOS平台和Ubuntu差别不大 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 # macos.yml name: MacOS on: push: paths-ignore: - 'README.md' - 'LICENSE' pull_request: paths-ignore: - 'README.md' - 'LICENSE' jobs: build: name: Build runs-on: ${{ matrix.os }} strategy: matrix: os: [macos-latest] qt_ver: [5.9.8,5.12.6] steps: - name: Install Qt uses: jurplel/install-qt-action@v2.0.0 with: version: ${{ matrix.qt_ver }} - uses: actions/checkout@v1 with: fetch-depth: 1 - name: build macos run: | qmake make Android平台 Android使用ubuntu编译,Windows那个ndk似乎没装,未尝试。 如果只使用Qt5.12.6,默认的配置可以直接用,编译前设置环境变量 ANDROID_SDK_ROOT 和ANDROID_NDK_ROOT就可以了。 Qt5.9.8要指定低版本的NDK、SDK才行,这里涛哥没有进一步尝试。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 # android.yml name: Android on: push: paths-ignore: - 'README.md' - 'LICENSE' pull_request: paths-ignore: - 'README.md' - 'LICENSE' jobs: build: name: Build runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] # 5.9.8 版本低,需要额外设置工具链。这里暂不支持。 qt_ver: [5.12.6] qt_target: [android] # android_arm64_v8a 暂时不支持. install-qt-action 依赖的aqtinstall版本为0.5*,需要升级 # qt_arch: [android_x86,android_armv7,android_arm64_v8a] qt_arch: [android_x86,android_armv7] # exclude: # - qt_ver: 5.9.8 # qt_arch: android_arm64_v8a steps: - name: Install Qt # if: steps.cacheqt.outputs.cache-hit != 'true' uses: jurplel/install-qt-action@v2.0.0 with: # Version of Qt to install version: ${{ matrix.qt_ver }} # Target platform for build target: ${{ matrix.qt_target }} # Architecture for Windows/Android arch: ${{ matrix.qt_arch }} - uses: actions/checkout@v1 with: fetch-depth: 1 - name: build android run: | export ANDROID_SDK_ROOT=$ANDROID_HOME export ANDROID_NDK_ROOT=$ANDROID_HOME/ndk-bundle qmake make IOS平台 ios只能使用MacOS编译。 qmake的时候要指定平台、release模式等。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 #ios.yml name: IOS on: push: paths-ignore: - 'README.md' pull_request: paths-ignore: - 'README.md' jobs: build: name: Build runs-on: ${{ matrix.os }} strategy: matrix: os: [macos-latest] qt_ver: [5.12.6] qt_target: [ios] steps: - name: Install Qt # if: steps.cacheqt.outputs.cache-hit != 'true' uses: jurplel/install-qt-action@v2.0.0 with: # Version of Qt to install version: ${{ matrix.qt_ver }} # Target platform for build target: ${{ matrix.qt_target }} - uses: actions/checkout@v1 with: fetch-depth: 1 - name: build ios run: | qmake -r -spec macx-ios-clang CONFIG+=release CONFIG+=iphoneos make

2019/11/19
articleCard.readMore

玩转Qt(9)-程序发布指南

简介 背景 Qt的安装 Qt的目录结构 Qt安装路径 Qt核心路径 HelloDeploy Window编译和发布 Window 编译 Window 发布 VS运行时库 常见的错误处理 应用程序无法正常启动 启动失败 - no Qt platform plugin OpenGL Context 创建失败 整理 简单裁剪 删减dll 删减plugins 删减qml 简介 这次讨论发布Qt应用程序的知识点。 背景 有很多人向涛哥询问,Qt程序发布的相关问题,网络上虽然可以搜到一大堆教程,但是可靠的比较少。 所以这次尽我所能,全面、详细地整理一些Qt程序发布的知识点,希望能帮助到更多人。 对老手来说,很多坑都踩过了,无非就是把正确的dll放在正确的路径。 对新手来说,细节上能多说几句,都将是莫大的帮助,少走弯路,节省几个小时、甚至几天都是有可能的。 如果有疏漏、错误,也欢迎大家补充、指正。 Qt的安装 Qt官网下载地址在这: http://download.qt.io/official_releases 离线安装包 或者 在线安装包 都行。 关于Qt版本的选择,涛哥建议: 体验新特性,就用最新版本;项目开发,用长期支持版(LTS)的最后一个修正版本,稳定、bug最少。 可以在Qt官方wiki上查看相关信息 https://wiki.qt.io/Main 目前为止(2019/9/2),最新版为5.13.0,LTS版本有5.9 和 5.12, 而5.9最后一个修正版本是5.9.8, 5.12则是到5.12.4 例如上图是5.9.8的离线安装包,提供了windows、mac以及linux三种系统的可执行程序。 其中windows的安装程序”qt-opensource-windoiws-x86-5.9.8.exe”, 大小有2.4G,里面 包含了msvc_x86、msvc_x64、mingw、Android等多个版本的Qt工具链。在下载完成,安装 过程中可以分别勾选。其它版本也是类似的。 如何安装Qt,就不细说了,搞不定的去参考入门级教程吧… Qt的目录结构 这里假设大家都装好了Qt,先来了解一下Qt的安装路径都有哪些东西。 涛哥用的是Windows 10系统,安装的Qt版本是5.12.4,以此为例来说明,其它系统和版本以实际为准。 Qt安装路径 涛哥安装在了D:\Qt\Online 路径下, 如图: 其中“vcredist”文件夹包含了msvc2015 和 msvc2017的运行时库安装程序(后面会说怎么用,不是msvc编译器不需要) “Tools”文件夹,包括QtCreator、OpenSSL库(可选)以及两种版本MinGW(可选)。 (图中还有Qt3DStudio,可忽略) “5.12.4”文件夹,是Qt的核心路径, 里面包含多个版本的Qt工具链、头文件、动态链接库等 这里涛哥安装了msvc2017、msvc2017_64、mingw73_64以及android_x86. 注意msvc2017是x86架构的Qt库,msvc2017_64则是x64架构的。 如果有msvc2013、msvc2015也同理。 Qt核心路径 接下来看一下重点,Qt的核心路径, 以msvc2017_64文件夹为例 bin文件夹包含了Qt提供的各种工具exe程序,以及动态链接库的dll 其中工具包括qmake.exe 和 windeployqt.exe,windeployqt.exe是我们今天主要讨论的工具。 动态链接库全部是两份dll,比如Qt5Cored.dll和Qt5Core.dll,文件名末尾带’d’表示debug版本的,另一个不带’d’的是release版本。 debug版本和release版本的主要区别:debug没有开编译器优化、携带了调试信息,release开了编译器优化O2,去掉了多余的信息 (图中还有pdb文件,是涛哥单独安装的,用来调试Qt源码,可以忽略) 和bin同级的,还有plugins文件夹,包含一些Qt用到的插件 比如imageformats文件夹中提供了jepg、gif、webp等图片格式的功能支持的插件,platforms文件夹则提供了平台插件,特别是 qwindows.dll这一个,在windows平台是必不可少的。 和bin同级的,另外一个文件夹是’qml’文件夹,包含Qml的各种功能模块。 和bin同级的其它文件夹,resources是WebEngine模块专用的,translations提供了 Qt内置的翻译文件,剩下的和发布无关,就不多说了。 HelloDeploy 这里新建一个简单的Hello World程序,名字就叫”HelloDeploy”。 同时为了说明问题,涛哥添加一些常用的模块。 在pro文件中,QT += 那一行该写的都写上: 在main.cpp中包含一下各个模块的头文件,再分别创建一个对象实例,调用一些简单的函数: 这样一个多模块依赖的程序就写好了。 Window编译和发布 Window 编译 这里要特别注意,编译器的选择, 以及编译用的是debug模式还是release模式。 涛哥这里是msvc2017_x64版本 一般发布用release模式。 编译完成后,默认在build-xxxx-release/release/文件夹中会生成我们的exe程序。 我们将这个exe复制出来,新建一个release文件夹,放进去 这时候可以尝试双击运行它,会提示缺少dll Window 发布 发布程序,其实就是把exe程序依赖的dll和相关资源都放在一起,保证双击运行即可。 我们前面提过的windeployqt.exe,是Qt提供的命令行工具,能帮助我们自动把需要的dll或资源复制过来。 我们先打开一个命令行 可以从开始菜单找到Qt提供的命令行 注意选对版本。这种命令行在启动时已经设置好了QT的环境变量,可以直接输入windeployqt.exe 也可以用普通的命令行,使用windeployqt.exe时带上绝对路径即可。 涛哥一般用普通的命令行,因为绝对路径不易出错。 cd到release目录 这里说一个windows启动命令行的小技巧:在release文件夹中,按住键盘shift键,然后按鼠标右键,弹出的右键菜单, 会比普通的右键菜单多一个“在此处打开命令窗口”,点击就能在release文件夹打开命令行窗口。 如果没有这个功能,就得手动输入cd指令,进入release路径。 执行windeployqt命令 这里通过绝对路径来使用windeployqt: d:\qt\Online\5.12.4\msvc2017_64\bin\windeployqt.exe HelloDeploy.exe HelloDeploy这个程序还用到了Qml,用到Qml的程序,要给windeployqt加上qmldir参数,写上你的Qml文件所在文件夹 (没用到qml的程序,不要加这一步) d:\qt\Online\5.12.4\msvc2017_64\bin\windeployqt.exe HelloDeploy.exe –qmldir .\qml 写好windeployqt命令后按回车执行 正确执行后,release文件夹下,多了很多dll,以及一些文件夹。 这时候我们双击运行HelloDeploy.exe, 就可以正常启动了。 将整个文件夹压缩或拷贝到其它没有Qt环境的电脑上,也是可以启动的。 只要dll齐备了,制作安装包也不是问题。(后续有时间,我再写安装包制作的教程) VS运行时库 如果是VS编译的程序,需要将QT路径下对应的vcredist_xxx.exe带上。 如果其它电脑上有vs运行时则可以直接运行,如果没有,就需要运行一下vs运行时安装包。 或者将运行时库里面的dll复制出来即可。 一般在VS的安装路径,都有展开的dll,可以直接拷贝。 例如,涛哥电脑上的vs2017路径如下: 按实际的路径找到这几个dll,全部拷贝即可。注意x86和x64,别拿错了。 常见的错误处理 一般使用windeployqt,大部分库都能自动拷贝,不需要手动处理, 只有极少数情况下,windeployqt跑完,会缺失一些库,还要手动处理一下。 遇到这种情况,用依赖检查工具Dependencies即可快速定位问题。 Dependencies下载链接: https://github.com/lucasg/Dependencies Dependencies 下载好,点击”DependenciesGui.exe”就可以打开界面。注意是名字带Gui的那个,不带gui的“Dependencies.exe”是命令行程序。 下面列举一些常见的错误信息 应用程序无法正常启动 最容易出现这种错误的情况是,程序是64位编译出来的,而同级目录下的dll是32位的, 或者同级目录下没有dll,但是环境变量中指向了32位的dll。(所以涛哥没有设置环境变量) 32位和64位倒过来也是。 如果dll版本是匹配的,还有可能出现的情况是缺少第三方库。 这里说一个检查依赖的方法: 将HelloDeploy.exe重命名为HelloDeploy.dll,然后用Dependencies打开,就可以查看少哪些库 如上图,红色问号的表示缺少的库。 找齐了依赖的库,再把程序的扩展名改回exe即可。 启动失败 - no Qt platform plugin 这种情况,是QT路径下的 plugins/platforms/qwindows.dll文件没有复制过来。 注意这个dll文件直接复制到exe同级是不起作用的,要放在exe程序同级的platforms文件夹里,或者同级 的plugins/platforms文件夹里 OpenGL Context 创建失败 这种情况,一般是OpenGL相关的库没有复制过来,补上就好了 整理 我们看到,exe同级目录下,windeployqt将一堆的文件夹放在了那里,有些混乱。 涛哥观察并验证了一下,其实可以做个简单的整理。 Qt开头的文件夹都是qml的模块,剩下的文件夹除了translations都是Qt的插件, 所以新建两个文件夹qml和plugins, 分别把qml模块和插件归入其中。 这样的结构,和QT安装路径下的结构是相似的。 这也正是Qt支持的插件加载路径、qml模块加载路径。 同级的dll则是windows系统默认的动态库加载规则,不方便修改 可以参考msdn: https://docs.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-search-order 简单裁剪 如果你熟悉Qt的各个模块,可以进行一些裁剪。以下都是些个人经验。 不熟悉请慎重! 不熟悉请慎重! 不熟悉请慎重! (当然静态编译也是一种裁剪的途径) 删减dll 首先可以把单元测试的dll去掉 Qt5Test.dll Qt5QuickTest.dll 如果没用到windows扩展,Qt5WinExtras.dll也可以去掉 其次,如果你不需要内置的翻译文件,translations文件夹也可以删掉 删减plugins 再来看一下plugins: 其中platforms是必不可少的,剩下的HelloDeploy都没用到,可以去掉。 常见程序会用的包括: imageformats 图片格式支持 iconengines 小图标功能 sqldrivers 数据库驱动,这个保留用到的数据库足够了 其他的看情况删减。 删减qml 最后看一下Qml文件夹,如果程序完全没用qml,直接删掉就好了。 按windeployqt给HelloDeploy提供的这些,逐个文件夹来说: Qt/labs 一般不推荐Qml中引入labs中的实验品,但是有些情况下功能缺失,只能引入。 如果Qml中使用了Quick.Dialog(不是labs.Dialog),它本身还是依赖的labs中的东西,一般是folderlistmodel和settings, 这时候还是不要动labs了,就按照windeployqt给的放着。 Qt/WebSockets Qml的Websocket功能,用了就放着,没用可以删掉。 QtGraphicalEffects Qml的一些ShaderEffect特效,用了就放着,没用到可以删掉 QtMultimedia Qml的多媒体模块,用了就放着,没用到可以删掉 QtQml/Models.2 数据Model, 经常用。 QtQuick 这里面大部分都是Qml中常用的,QtQuick/Extras可以按情况删掉 QtQuick.2 常用的 QtTest 单元测试,删掉吧 QtWinExtras Windows扩展,没用到可以去掉

2019/9/12
articleCard.readMore

玩转Qt(8)-掌握信号槽使用细节

简介 信号与槽的声明 信号-槽的使用 信号的使用 槽函数的使用 信号-槽的”元调用” 信号和信号的参数 注册元类型 信号-槽的连接 connect函数 连接的不同写法 元方法式 函数指针式 函数重载的处理 functor式 关于functor functor connect的连接类型 connect的返回值 简介 之前的文章《认清信号槽的本质》、《窥探信号槽的实现细节》讨论了一些原理, 这次我们来讨论一些信号-槽的使用细节。 信号与槽的声明 要使用信号-槽功能,先决条件是继承QObject类,并在类声明中增加Q_OBJECT宏。 之后在”signals:” 字段之后声明一些函数,这些函数就是信号。 在”public slots:” 之后声明的函数,就是槽函数。 例如下面的代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 //Tom.h #pragma once #include <QObject> #include <QDebug> class Tom : public QObject { Q_OBJECT public: Tom(QObject *parent = nullptr) : QObject(parent) { } void miaow() { qDebug() << u8"喵!" ; emit miao(); } signals: void miao(); }; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 //Jerry.h #pragma once #include <QObject> #include <QDebug> class Jerry : public QObject { Q_OBJECT public: Jerry(QObject *parent = nullptr) : QObject(parent) { } public slots: void runAway() { qDebug() << u8"那只猫又来了,快溜!" ; } }; 信号-槽的使用 使用比较简单,先说一下使用。 信号的使用 信号 就是普通的类成员函数,信号只要声明(declare),不需要实现(implement),实现由moc(元对象编译器)自动生成。 信号的触发,可以用emit,也可以直接调用函数。 例如: 1 2 3 4 5 6 //实例化一个tom对象 Tom tom; //通过emit发射信号 emit tom.miao(); //直接调用信号。效果和emit一样。 tom.miao(); Qt源码的qobejctdefs.h头文件中,可以看到emit宏其实是空的。 1 2 3 4 //qobejctdefs.h #ifndef QT_NO_EMIT # define emit #endif 槽函数的使用 槽函数和普通的成员函数一样。。。 信号-槽的”元调用” 信号-槽特殊的地方,是moc(元对象编译器)为其生成了一份”元信息”,可以通过QMetaObject::invokeMethod的方式调用 例如: 1 2 3 4 5 6 7 8 9 //实例化一个tom对象 Tom tom; //通过invok方式调用信号 QMetaObject::invokeMethod(&tom, "miao"); //实例化一个jerry对象 Jerry jerry; //通过invok方式调用槽 QMetaObject::invokeMethod(&jerry, "runAway"); 一般在知道如何声明qobject的场景,没必要多此一举用invoke。 在一些需要”运行期反射”的情况下(头文件都没有,只知道有这么个对象,和函数的名字),invoke十分有用。 invokeMethod还可以带参数、可以获取返回值,这不是本文的重点,这里就不展开了,详细的可以参考Qt帮助文档和元对象系统。 信号和信号的参数 信号可以带参数,参数的类型,必须是元对象系统能够识别的类型, 即元类型。(元对象系统后面再细说) 注册元类型 Qt已经将大部分常用的基础类型,都注册进了元对象系统,可以在QMetaType类中看到。 通常写的继承于QObject的子类,本身已经附带了元信息,可以直接在信号-槽中使用。 不是继承于QObject的结构体、类等自定义类型,可以通过Q_DECLARE_METATYPE宏 和 qRegisterMetaType函数进行注册,之后就可以在信号-槽中使用。 例如: 1 2 3 4 5 6 7 struct MyStruct { int i; ... }; Q_DECLARE_METATYPE(MyStruct) 或者带命名空间的: 1 2 3 4 5 6 namespace MyNamespace { ... } Q_DECLARE_METATYPE(MyNamespace::MyStruct) 这里说明一下细节,Q_DECLARE_METATYPE宏声明过后,只是生成了元信息,可以被QVariant识别,还不能 用于队列方式的信号、槽,需要用qRegisterMetaType进行注册。而qRegisterMetaType要求”全定义”,也就是 提供类的”复制构造函数”和”赋值操作符”。 前面那种简单类型,C++编译器默认提供浅拷贝的”复制构造函数”和”赋值操作符”实现,可以直接用。 1 2 3 4 struct MyStruct { int i; }; 而复杂一些的类,就要提供”全定义”。 (顺带一提,信号的参数可以是任意注册过的对象,而C++11的lambda、std::bind也是对象,只要注册过,也是可以通过信号参数发送出去的。) 信号-槽的连接 connect函数 信号与槽,通过connect函数进行连接,之后就可以用信号去触发槽函数了。 连接的一般格式是Connectin = connect(obj1, signal1, obj2, slot1, connectType); 连接的不同写法 connect函数重载实现了多种不同的参数写法,以Qt5.12为例,大致分为三类: 元方法式、函数指针式、functor式 元方法式 元方法式是最常用的写法,函数声明如下: 1 2 3 4 5 6 7 8 9 10 //connect(1) 字符串式信号槽 static QMetaObject::Connection connect(const QObject *sender, const char *signal, const QObject *receiver, const char *member, Qt::ConnectionType = Qt::AutoConnection); //connect(2) QMetaMethod式信号槽 static QMetaObject::Connection connect(const QObject *sender, const QMetaMethod &signal, const QObject *receiver, const QMetaMethod &method, Qt::ConnectionType type = Qt::AutoConnection); //connect(3) 对(1)的重载, 非static去掉receiver inline QMetaObject::Connection connect(const QObject *sender, const char *signal, const char *member, Qt::ConnectionType type = Qt::AutoConnection) const; Qt应用程序中用到最多的是connect(1)的写法,例如: 1 2 3 Tom tom; Jerry jerry; connect(&tom, SIGNAL(miao()), &jerry, SLOT(runAway())) 其中SIGNAL、SLOT两个宏, 作用是将函数转换成字符串。 connect(1)的实现是靠字符串去查找元方法,以实现连接。 connect(2) 则是把信号槽的字符串换成了元方法QMetaMethod, 一般不会直接用这种写法。 connect(3)是对connect(1)的重载,非静态成员函数,本身有this指针,所以省略了receiver参数。 函数指针式 函数指针式写法,声明如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 //connect(4) 连接信号到qobject的成员函数 template <typename Func1, typename Func2> static inline QMetaObject::Connection connect(const typename QtPrivate::FunctionPointer<Func1>::Object *sender, Func1 signal, const typename QtPrivate::FunctionPointer<Func2>::Object *receiver, Func2 slot, Qt::ConnectionType type = Qt::AutoConnection); //connect(5) 连接信号到非成员函数。 template <typename Func1, typename Func2> static inline typename std::enable_if<int(QtPrivate::FunctionPointer<Func2>::ArgumentCount) >= 0, QMetaObject::Connection>::type connect(const typename QtPrivate::FunctionPointer<Func1>::Object *sender, Func1 signal, Func2 slot); //connect(6) 连接信号到非成员函数。比(5)多一个context,可以设置连接类型 template <typename Func1, typename Func2> static inline typename std::enable_if<int(QtPrivate::FunctionPointer<Func2>::ArgumentCount) >= 0 && !QtPrivate::FunctionPointer<Func2>::IsPointerToMemberFunction, QMetaObject::Connection>::type connect(const typename QtPrivate::FunctionPointer<Func1>::Object *sender, Func1 signal, const QObject *context, Func2 slot, Qt::ConnectionType type = Qt::AutoConnection); connect(4)用的也比较多,用法如下: 1 2 3 Tom tom; Jerry jerry; connect(&tom, &Tom::miao, &jerry, &Jerry::runAway); 信号-槽换成了C++的 取成员函数指针 的形式。 connect(4)本身的实现,比connect(1)快一些,因为省去了字符串查找的过程。 而连接建立后,从信号触发到槽函数的执行,两种写法是没有区别的。 在一些需要”运行期反射”的情况下(头文件都没有,只知道有这么个对象,和函数的名字),只能用connect(1)。 connect(5)可以连接信号到任意非成员函数指针上。除了槽函数,普通的函数也可以连接。这种连接不支持设置连接类型,可以看作是单纯的函数调用。 connect(6)是对connect(5)的重载,增加了一个context对象代替reveicer对象的作用。这种连接是可以设置连接类型的。 函数重载的处理 信号-槽函数有重载的情况下,写函数指针式connect会报错,就需要类型转换。 比如:QLocalSocket有一个成员函数error,也有一个信号error,直接写connect会报错的。 Qt为我们提供了QOverload这个模板类,以解决这个问题。 1 2 //连接重载过的函数,使用QOverload做leixing 转换 connect(socket, QOverload<QLocalSocket::LocalSocketError>::of(&QLocalSocket::error), this, &XXX::onError); 编译器支持C++14,还可以用qOverload模板函数 1 2 //连接重载过的函数,使用QOverload做leixing 转换 connect(socket, qOverload<QLocalSocket::LocalSocketError>(&QLocalSocket::error), this, &XXX::onError); 还有像QNetworkReply::error、QProcess::finished等等,都有重载,用的时候要转换处理一下。 functor式 关于functor 问: 什么是functor?functor有什么用? 答: 在C++11之前, Qt通过自己的实现来推导函数指针及其参数,即QtPrivate::FunctionPointer, 用来处理信号-槽的连接。 C++11带来了lambda, 以及std::bind和std::function, std::function本身可以存储lambda、std::bind以及FunctionPointer。 这时候Qt已有的connect(4)、connect(5)、connect(6)是可以支持FunctionPointer的,而新出现的lambda以及std::bind是不支持的, QtPrivate::FunctionPointer推导不出这些类型。所以Qt把这些不支持的新类型(主要是lambda和std::bind)称为functor(文档和源码都这么命名), 并增加了connect(7)和connect(8)以支持functor。 functor functor式写法,声明如下: 1 2 3 4 5 6 7 8 9 10 //connect(7) 连接信号到任意functor template <typename Func1, typename Func2> static inline typename std::enable_if<QtPrivate::FunctionPointer<Func2>::ArgumentCount == -1, QMetaObject::Connection>::type connect(const typename QtPrivate::FunctionPointer<Func1>::Object *sender, Func1 signal, Func2 slot); //connect(8) 连接信号到任意functor。比(7)多一个context,可以设置连接类型 template <typename Func1, typename Func2> static inline typename std::enable_if<QtPrivate::FunctionPointer<Func2>::ArgumentCount == -1, QMetaObject::Connection>::type connect(const typename QtPrivate::FunctionPointer<Func1>::Object *sender, Func1 signal, const QObject *context, Func2 slot, Qt::ConnectionType type = Qt::AutoConnection); connect(7)可以连接信号到任意lambda、std::bind上。 connect(8)是对(7)的重载,增加了一个context对象代替reveicer对象的作用。这种连接是可以设置连接类型的。 connect的连接类型 connectType为连接类型,默认为AutoConnection,即Qt自动处理,大部分情况下也不用管。个别情况,需要手动指定。 可选的连接类型有 自动 AutoConnection 直连 DirectConnection 队列 QueuedConnection 唯一连接 UniqueConnection 自动处理的逻辑是,如果发送信号的线程和receiver在同一个线程,就是DirectConnection(直接函数调用),不是同一个线程,则转换为QueuedConnection。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 //引用自《Qt原理-窥探信号槽的实现细节》 如果信号-槽连接方式为QueuedConnection,不论是否在同一个线程,按队列处理。 如果信号-槽连接方式为Auto,且不在同一个线程,也按队列处理。 如果信号-槽连接方式为阻塞队列BlockingQueuedConnection,按阻塞处理。 (注意同一个线程就不要按阻塞队列调用了。因为同一个线程,同时只能做一件事, 本身就是阻塞的,直接调用就好了,如果走阻塞队列,则多了加锁的过程。如果槽中又发了 同样的信号,就会出现死锁:加锁之后还未解锁,又来申请加锁。) 队列处理,就是把槽函数的调用,转化成了QMetaCallEvent事件,通过QCoreApplication::postEvent 放进了事件循环, 等到下一次事件分发,相应的线程才会去调用槽函数。 下面举例一些需要手动指定连接类型的场景: 例1-跨多个线程: A线程中写connect,让B线程中的信号连到C线程的槽中,希望C的槽在C中执行。 这种情况要明确指定QueuedConnection,不写的话按照Auto处理,C中的槽会在A中执行。 例2-跨线程DirectConnection (这种用法在Qml的渲染引擎SceneGraph中比较常见)。 A线程为内部代码,不能修改,一些特定的节点会有信号发出。 B线程为用户代码,有一些功能函数,希望在A线程中去执行。 这种情况,将A的信号连接到B的函数,连接方式指定为DirectConnection,就可以把B的函数插入到A线程发信号的地方了。 效果类似于子类重载父类的函数。 connect的返回值 connect的返回值为QMetaObject::Connection,代表一个连接。大部分情况下,不用管返回值。 Connection可以用来验证链接是否有效,可以用来断开连接。 一般用disconnect函数就可以断开连接;而signal-functor的这种形式的连接,没有object的存在,只能用Connection断开。

2019/9/2
articleCard.readMore

玩转Qt(7)-窥探信号槽的实现细节

简介 猫和老鼠的故事 声明与实现 Q_OBJECT宏 信号的moc生成 信号的触发 槽和moc生成 第三方信号槽实现 简介 这次讨论Qt信号-槽的实现细节。 上次的文章《认清信号槽的本质》中介绍过,信号-槽是一种对象之间的通信机制,是 Qt在标准C++之外,使用元对象编译器(MOC)实现的语法糖。 这次通过一个简单的案例,学习一些信号-槽的实现细节。 猫和老鼠的故事 还是拿上次的设定来说明:Tom有个技能叫”喵”,就是发出猫叫,而正在偷吃东西的Jerry,听见猫叫声就会逃跑。 我们用信号-槽的方式写出来。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 //Tom.h #pragma once #include <QObject> #include <QDebug> class Tom : public QObject { Q_OBJECT public: Tom(QObject *parent = nullptr) : QObject(parent) { } void miaow() { qDebug() << u8"喵!" ; emit miao(); } signals: void miao(); }; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 //Jerry.h #pragma once #include <QObject> #include <QDebug> class Jerry : public QObject { Q_OBJECT public: Jerry(QObject *parent = nullptr) : QObject(parent) { } public slots: void runAway() { qDebug() << u8"那只猫又来了,快溜!" ; } }; 以上面的代码为例,要使用信号-槽功能,先决条件是继承QObject类,并在类声明中增加Q_OBJECT宏。 之后在”signals:” 字段之后声明一些函数,这些函数就是信号。 在”public slots:” 之后声明的函数,就是槽函数。 接下来看看我们的main函数: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 //main.cpp #include <QCoreApplication> #include "Tom.h" #include "Jerry.h" int main(int argc, char *argv[]) { QCoreApplication a(argc, argv); Tom tom; Jerry jerry; QObject::connect(&tom, &Tom::miao, &jerry, &Jerry::runAway); tom.miaow(); return a.exec(); } 信号-槽都准备好了,接下来创建两个对象实例,并使用QObject::connect将信号和槽连接起来。 最后使用emit发送信号,就会自动触发槽函数了。 运行结果: 声明与实现 信号和槽的本质都是函数。 我们知道C++中的函数要有声明(declare),也要有实现(implement), 而信号只要声明,不需要写实现。这是因为moc会为我们自动生成。 另外触发信号时,不写emit关键字,直接调用信号函数,也是没有问题的。 Q_OBJECT宏 我们来看一下Q_OBJECT宏,展开如下: (不同的Qt版本有些差异,涛哥这里用的是5.12.4,以此为例) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 public: \ QT_WARNING_PUSH \ Q_OBJECT_NO_OVERRIDE_WARNING \ static const QMetaObject staticMetaObject; \ virtual const QMetaObject *metaObject() const; \ virtual void *qt_metacast(const char *); \ virtual int qt_metacall(QMetaObject::Call, int, void **); \ QT_TR_FUNCTIONS \ private: \ Q_OBJECT_NO_ATTRIBUTES_WARNING \ Q_DECL_HIDDEN_STATIC_METACALL static void qt_static_metacall(QObject *, QMetaObject::Call, int, void **); \ QT_WARNING_POP \ struct QPrivateSignal {}; \ QT_ANNOTATE_CLASS(qt_qobject, "") 我们看到,关键的地方,是声明了一个只读的静态成员变量staticMetaObject,以及3个public的成员函数 1 2 3 4 5 6 7 static const QMetaObject staticMetaObject; virtual const QMetaObject *metaObject() const; virtual void *qt_metacast(const char *); virtual int qt_metacall(QMetaObject::Call, int, void **); 还有一个private的静态成员函数qt_static_metacall 1 static void qt_static_metacall(QObject *, QMetaObject::Call, int, void **) 那么声明的这些成员变量/函数,在哪里实现?答案是moc生成的cpp文件。 信号的moc生成 如上图所示目录结构,项目编译完成后,在build文件夹中,自动生成了moc_Jerry.cpp 和 moc_Tom.cpp两个文件 其中moc_Tom.cpp内容如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 /**************************************************************************** ** Meta object code from reading C++ file 'Tom.h' ** ** Created by: The Qt Meta Object Compiler version 67 (Qt 5.12.4) ** ** WARNING! All changes made in this file will be lost! *****************************************************************************/ #include "../../TomJerry/Tom.h" #include <QtCore/qbytearray.h> #include <QtCore/qmetatype.h> #if !defined(Q_MOC_OUTPUT_REVISION) #error "The header file 'Tom.h' doesn't include <QObject>." #elif Q_MOC_OUTPUT_REVISION != 67 #error "This file was generated using the moc from 5.12.4. It" #error "cannot be used with the include files from this version of Qt." #error "(The moc has changed too much.)" #endif QT_BEGIN_MOC_NAMESPACE QT_WARNING_PUSH QT_WARNING_DISABLE_DEPRECATED struct qt_meta_stringdata_Tom_t { QByteArrayData data[3]; char stringdata0[10]; }; #define QT_MOC_LITERAL(idx, ofs, len) \ Q_STATIC_BYTE_ARRAY_DATA_HEADER_INITIALIZER_WITH_OFFSET(len, \ qptrdiff(offsetof(qt_meta_stringdata_Tom_t, stringdata0) + ofs \ - idx * sizeof(QByteArrayData)) \ ) static const qt_meta_stringdata_Tom_t qt_meta_stringdata_Tom = { { QT_MOC_LITERAL(0, 0, 3), // "Tom" QT_MOC_LITERAL(1, 4, 4), // "miao" QT_MOC_LITERAL(2, 9, 0) // "" }, "Tom\0miao\0" }; #undef QT_MOC_LITERAL static const uint qt_meta_data_Tom[] = { // content: 8, // revision 0, // classname 0, 0, // classinfo 1, 14, // methods 0, 0, // properties 0, 0, // enums/sets 0, 0, // constructors 0, // flags 1, // signalCount // signals: name, argc, parameters, tag, flags 1, 0, 19, 2, 0x06 /* Public */, // signals: parameters QMetaType::Void, 0 // eod }; void Tom::qt_static_metacall(QObject *_o, QMetaObject::Call _c, int _id, void **_a) { if (_c == QMetaObject::InvokeMetaMethod) { auto *_t = static_cast<Tom *>(_o); Q_UNUSED(_t) switch (_id) { case 0: _t->miao(); break; default: ; } } else if (_c == QMetaObject::IndexOfMethod) { int *result = reinterpret_cast<int *>(_a[0]); { using _t = void (Tom::*)(); if (*reinterpret_cast<_t *>(_a[1]) == static_cast<_t>(&Tom::miao)) { *result = 0; return; } } } Q_UNUSED(_a); } QT_INIT_METAOBJECT const QMetaObject Tom::staticMetaObject = { { &QObject::staticMetaObject, qt_meta_stringdata_Tom.data, qt_meta_data_Tom, qt_static_metacall, nullptr, nullptr } }; const QMetaObject *Tom::metaObject() const { return QObject::d_ptr->metaObject ? QObject::d_ptr->dynamicMetaObject() : &staticMetaObject; } void *Tom::qt_metacast(const char *_clname) { if (!_clname) return nullptr; if (!strcmp(_clname, qt_meta_stringdata_Tom.stringdata0)) return static_cast<void*>(this); return QObject::qt_metacast(_clname); } int Tom::qt_metacall(QMetaObject::Call _c, int _id, void **_a) { _id = QObject::qt_metacall(_c, _id, _a); if (_id < 0) return _id; if (_c == QMetaObject::InvokeMetaMethod) { if (_id < 1) qt_static_metacall(this, _c, _id, _a); _id -= 1; } else if (_c == QMetaObject::RegisterMethodArgumentMetaType) { if (_id < 1) *reinterpret_cast<int*>(_a[0]) = -1; _id -= 1; } return _id; } // SIGNAL 0 void Tom::miao() { QMetaObject::activate(this, &staticMetaObject, 0, nullptr); } QT_WARNING_POP QT_END_MOC_NAMESPACE 可以大致看出,生成的cpp文件中,就是变量staticMetaObject以及 那几个函数的实现。 staticMetaObject是一个结构体,用来存储Tom这个类的信号、槽等元信息,并把 qt_static_metacall静态函数作为函数指针存储起来。 因为是静态成员,所以实例化多少个Tom对象,它们的元信息都是一样的。 qt_static_metacall函数提供了两种“元调用的实现”: 如果是InvokeMetaMethod类型的调用,则直接 把参数中的QObject对象, 转换成Tom类然后调用其miao函数 如果是IndexOfMethod类型的调用,即获取元函数的索引号,则计算miao函数的偏移并返回。 而moc_Tom.cpp末尾的 1 2 3 4 5 // SIGNAL 0 void Tom::miao() { QMetaObject::activate(this, &staticMetaObject, 0, nullptr); } 就是信号函数的实现。 信号的触发 miao信号的实现,直接调用了QMetaObject::activate函数。其中0代表miao这个函数的索引号。 QMetaObject::activate函数的实现,在Qt源码的QObject.cpp文件中,略微复杂一些, 且不同版本的Qt,实现差异都比较大,这里总结一下大致的实现: 先找出与当前信号连接的所有对象-槽函数,再逐个处理: 这里处理的方式,分为三种: 1 2 3 4 5 6 7 8 9 if((c->connectionType == Qt::AutoConnection && !receiverInSameThread) || (c->connectionType == Qt::QueuedConnection)) { // 队列处理 } else if (c->connectionType == Qt::BlockingQueuedConnection) { // 阻塞处理 // 如果同线程,打印潜在死锁。 } else { //直接调用槽函数或回调函数 } receiverInSameThread表示当前线程id和接收信号的对象的所在线程id是否相等。 如果信号-槽连接方式为QueuedConnection,不论是否在同一个线程,按队列处理。 如果信号-槽连接方式为Auto,且不在同一个线程,也按队列处理。 如果信号-槽连接方式为阻塞队列BlockingQueuedConnection,按阻塞处理。 (注意同一个线程就不要按阻塞队列调用了。因为同一个线程,同时只能做一件事, 本身就是阻塞的,直接调用就好了,如果走阻塞队列,则多了加锁的过程。如果槽中又发了 同样的信号,就会出现死锁:加锁之后还未解锁,又来申请加锁。) 队列处理,就是把槽函数的调用,转化成了QMetaCallEvent事件,通过QCoreApplication::postEvent 放进了事件循环, 等到下一次事件分发,相应的线程才会去调用槽函数。 关于事件循环,可以参考之前的文章《Qt实用技能3-理解事件循环》 槽和moc生成 slot函数我们自己实现了,moc不会做额外的处理,所以自动生成的moc_Jerry.cpp文件中, 只有Q_OBJECT宏的展开,和前面的moc_Tom.cpp是一致的,不赘述了。 第三方信号槽实现 信号-槽是非常优秀的通信机制,但Qt的moc实现方式,被一些人诟病,所以他们造了新的轮子,比如: https://woboq.com/blog/verdigris-qt-without-moc.html http://sigslot.sourceforge.net/ https://github.com/NoAvailableAlias/nano-signal-slot https://github.com/pbhogan/Signals

2019/8/30
articleCard.readMore

玩转Qt(6)-认清信号槽的本质

简介 猫和老鼠的故事 对象之间的通信机制 尝试一:直接调用 尝试二:回调函数+映射表 观察者模式 Qt的信号-槽 信号-槽简介 信号-槽分两种 信号-槽的实现 元对象编译器moc moc的本质-反射 参考文献 简介 这次讨论Qt信号-槽相关的知识点。 信号-槽是Qt框架中最核心的机制,也是每个Qt开发者必须掌握的技能。 网络上有很多介绍信号-槽的文章,也可以参考。 涛哥的专栏是《Qt进阶之路》,如果连信号-槽的文章都没有,将是没有灵魂的。 所以这次涛哥就由浅到深地说一说信号-槽。 猫和老鼠的故事 如果一上来就讲一大堆概念和定义,读者很容易读睡着。所以涛哥从一个故事/场景开始说起。 涛哥小时候喜欢看动画片《猫和老鼠》, 里面有汤姆猫(Tom)和杰瑞鼠(Jerry)斗智斗勇的故事。。。 现在做个简单的设定:Tom有个技能叫”喵”,就是发出猫叫,而正在偷吃东西的Jerry,听见猫叫声就会逃跑。 我们尝试用C++面向对象的思想,描述这个设定。 先是定义Tom和Jerry两种对象 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 //Tom的定义 class Tom { public: //猫叫 void Miaow() { cout << "喵!" << endl; } //省略其它 ... }; //Jerry的定义 class Jerry { public: //逃跑 void RunAway() { cout << "那只猫又来了,快溜!" << endl; } //省略其它 ... }; 接下来模拟场景 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 int main(int argc, char *argv[]) { //实例化tom Tom tom; //实例化jerry Jerry jerry; //tom发出叫声 tom.Miaow(); //jerry逃跑 jerry.RunAway(); return 0; } 这个场景看起来很简单,tom发出叫声之后手动调用了jerry的逃跑。 我们再看几种稍微复杂的场景: 场景一: 假如jerry逃跑后过段时间,又回来偷吃东西。Tom再次发出叫声,jerry再次逃跑。。。 这个场景要重复几十次。我们能否实现,只要tom的Miaow被调用了,jerry的RunAway就自动被调用,而不是每次都手动调用? 场景二: 假如jerry是藏在“厨房的柜子里的米袋子后面”,无法直接发现它(不能直接获取到jerry对象,并调用它的函数)。 这种情况下,该怎么建立 “猫叫-老鼠逃跑” 的模型? 场景三: 假如有多只jerry,一只tom发出叫声时,所有jerry都逃跑。这种模型该怎么建立? 假如有多只tom,任意一只发出叫声时,所有jerry都逃跑。这种模型又该怎么建立? 场景四: 假如不知道猫的确切品种或者名字,也不知道老鼠的品种或者名字,只要 猫 这种动物发出叫声,老鼠 这种动物就要逃跑。 这样的模型又该如何建立? … 还有很多场景,就不赘述了。 对象之间的通信机制 这里概括一下要实现的功能: 要提供一种对象之间的通信机制。这种机制,要能够给两个不同对象中的函数建立映射关系,前者被调用时后者也能被自动调用。 再深入一些,两个对象都互相不知道对方的存在,仍然可以建立联系。甚至一对一的映射可以扩展到多对多,具体对象之间的映射可以扩展到抽象概念之间。 尝试一:直接调用 应该会有人说, Miaow()的函数中直接调用RunAway()不就行了? 明显场景二就把这种方案pass掉了。 直接调用的问题是,猫要知道老鼠有个函数/接口叫逃跑,然后主动调用了它。 这就好比Tom叫了一声,然后Tom主动拧着Jerry的腿让它跑。这样是不合理的。(Jerry表示一脸懵逼!) 真实的逻辑是,猫的叫声在空气/介质中传播,传到了老鼠的耳朵里,老鼠就逃跑了。猫和老鼠互相都没看见呢。 尝试二:回调函数+映射表 似乎是可行的。 稍微思考一下,我们要做这两件事情: 1 把RunAway函数取出来存储在某个地方 2 建立Miaow函数和RunAway的映射关系,能够在前者被调用时,自动调用后者。 RunAway函数可以用 函数指针|成员函数指针 或者C++11-function 来存储,都可以称作 “回调函数”。 (下面的代码以C++11 function的写法为主,函数指针的写法稍微复杂一些,本质一样) 我们先用一个简单的Map来存储映射关系, 就用一个字符串作为映射关系的名字 1 std::map<std::string, std::function<void()>> callbackMap; 我们还要实现 “建立映射关系” 和 “调用”功能,所以这里封装一个Connections类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Connections { public: //按名称“建立映射关系” void connect(const std::string &name, const std::function<void()> &callback) { m_callbackMap[name] = callback; } //按名称“调用” void invok(const std::string &name) { auto it = m_callbackMap.find(name); //迭代器判断 if (it != m_callbackMap.end()) { //迭代器有效的情况,直接调用 it->second(); } } private: std::map<std::string, std::function<void()>> m_callbackMap; }; 那么这个映射关系存储在哪里呢? 显然是一个Tom和Jerry共有的”上下文环境”中。 我们用一个全局变量来表示,这样就可以简单地模拟了: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 //全局共享的Connections。 static Connections s_connections; //Tom的定义 class Tom { public: //猫叫 void Miaow() { cout << "喵!" << endl; //调用一下名字为mouse的回调 s_connections.invok("mouse"); } //省略其它 ... }; //Jerry的定义 class Jerry { public: Jerry() { //构造函数中,建立映射关系。std::bind属于基本用法。 s_connections.connect("mouse", std::bind(&Jerry::RunAway, this)); } //逃跑 void RunAway() { cout << "那只猫又来了,快溜!" << endl; } //省略其它 ... }; int main(int argc, char *argv[]) { //模拟嵌套层级很深的场景,外部不能直接访问到tom struct A { struct B { struct C { private: //Tom在很深的结构中 Tom tom; public: void MiaoMiaoMiao() { tom.Miaow(); } }c; void MiaoMiao() { c.MiaoMiaoMiao(); } }b; void Miao() { b.MiaoMiao(); } }a; //模拟嵌套层级很深的场景,外部不能直接访问到jerry struct D { struct E { struct F { private: //jerry在很深的结构中 Jerry jerry; }f; }e; }d; //A间接调用tom的MiaoW,发出猫叫声 a.Miao(); return 0; } 看一下运行结果: RunAway没有被直接调用,而是被自动触发。 分析:这里是以”mouse”这个字符串作为连接tom和jerry的关键。这只是一种简单、粗糙的示例实现。 观察者模式 在GOF四人帮的书籍《设计模式》中,有一种观察者模式,可以比较优雅地实现同样的功能。 (顺便说一下,GOF总结的设计模式一共有23种,涛哥曾经用C++11实现了全套的,github地址是:https://github.com/jaredtao/DesignPattern) 初级的观察者模式,涛哥就不重复了。这里涛哥用C++11搭配一点模板技巧,实现一个更加通用的观察者模式。 也可以叫发布-订阅模式。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 //Subject.hpp #pragma once #include <vector> #include <algorithm> //Subject 事件或消息的主体。模板参数为观察者类型 template<typename ObserverType> class Subject { public: //订阅 void subscibe(ObserverType *obs) { auto itor = std::find(m_observerList.begin(), m_observerList.end(), obs); if (m_observerList.end() == itor) { m_observerList.push_back(obs); } } //取消订阅 void unSubscibe(ObserverType *obs) { m_observerList.erase(std::remove(m_observerList.begin(), m_observerList.end(), obs)); } //发布。这里的模板参数为函数类型。 template <typename FuncType> void publish(FuncType func) { for (auto obs: m_observerList) { //调用回调函数,将obs作为第一个参数传递 func(obs); } } private: std::vector<ObserverType *> m_observerList; }; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 //main.cpp #include "Subject.hpp" #include <functional> #include <iostream> using std::cout; using std::endl; //CatObserver 接口 猫的观察者 class CatObserver { public: //猫叫事件 virtual void onMiaow() = 0; public: virtual ~CatObserver() {} }; //Tom 继承于Subject模板类,模板参数为CatObserver。这样Tom就拥有了订阅、发布的功能。 class Tom : public Subject<CatObserver> { public: void miaoW() { cout << "喵!" << endl; //发布"猫叫"。 //这里取CatObserver类的成员函数指针onMiaow。而成员函数指针调用时,要传递一个对象的this指针才行的。 //所以用std::bind 和 std::placeholders::_1将第一个参数 绑定为 函数被调用时的第一个参数,也就是前面Subject::publish中的obs publish(std::bind(&CatObserver::onMiaow, std::placeholders::_1)); } }; //Jerry 继承于 CatObserver class Jerry: public CatObserver { public: //重写“猫叫事件” void onMiaow() override { //发生 “猫叫”时 调用 逃跑 RunAway(); } void RunAway() { cout << "那只猫又来了,快溜!" << endl; } }; int main(int argc, char *argv[]) { Tom tom; Jerry jerry; //拿jerry去订阅Tom的 猫叫事件 tom.subscibe(&jerry); tom.miaoW(); return 0; } 任意类只要继承Subject模板类,提供观察者参数,就拥有了发布-订阅功能。 Qt的信号-槽 信号-槽简介 信号-槽 是Qt自定义的一种通信机制,它不同于标准C/C++ 语言。 信号-槽的使用方法,是在普通的函数声明之前,加上signal、slot标记,然后通过connect函数把信号与槽 连接起来。 后续只要调用 信号函数,就可以触发连接好的信号或槽函数。 连接的时候,前面的是发送者,后面的是接收者。信号与信号也可以连接,这种情况把接收者信号看做槽即可。 信号-槽分两种 信号-槽要分成两种来看待,一种是同一个线程内的信号-槽,另一种是跨线程的信号-槽。 同一个线程内的信号-槽,就相当于函数调用,和前面的观察者模式相似,只不过信号-槽稍微有些性能损耗(这个后面细说)。 跨线程的信号-槽,在信号触发时,发送者线程将槽函数的调用转化成了一次“调用事件”,放入事件循环中。 接收者线程执行到下一次事件处理时,处理“调用事件”,调用相应的函数。 (关于事件循环,可以参考专栏上一篇文章《Qt实用技能3-理解事件循环》) 信号-槽的实现 元对象编译器moc 信号-槽的实现,借助一个工具:元对象编译器MOC(Meta Object Compiler)。 这个工具被集成在了Qt的编译工具链qmake中,在开始编译Qt工程时,会先去执行MOC,从代码中 解析signals、slot、emit等等这些标准C/C++不存在的关键字,以及处理Q_OBJECT、Q_PROPERTY、 Q_INVOKABLE等相关的宏,生成一个moc_xxx.cpp的C++文件。(使用黑魔法来变现语法糖) 比如信号函数只要声明、不需要自己写实现,就是在这个moc_xxx.cpp文件中,自动生成的。 MOC之后就是常规的C/C++编译、链接流程了。 moc的本质-反射 MOC的本质,其实是一个反射器。标准C++没有反射功能(将来会有),所以Qt用moc实现了反射功能。 什么叫反射呢? 简单来说,就是运行过程中,获取对象的构造函数、成员函数、成员变量。 举个例子来说明,有下面这样一个类声明: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Tom { public: Tom() {} const std::string & getName() const { return m_name; } void setName(const std::string &name) { m_name = name; } private: std::string m_name; }; 类的使用者,看不到类的声明,头文件都拿不到,不能直接调用类的构造函数、成员函数。 从配置文件/网络拿到了一段字符串“Tom”,就要创建一个Tom类的对象实例。 然后又拿到一段“setName”的字符串,就要去调用Tom的setName函数。 面对这种需求,就需要把Tom类的构造函数、成员函数等信息存储起来,还要能够被调用到。 这些信息就是 “元信息”,使用者通过“元信息”就可以“使用这个类”。这便是反射了。 设计模式中的“工厂模式”,就是一个典型的反射案例。不过工厂模式只解决了构造函数的调用,没有成员函数、成员变量等信息。 反射包括 编译期静态反射 和 运行期动态反射。。。 文章有点长了,这次先到这里,剩下的下次再讨论。 参考文献 [1] Qt帮助文档, 搜索关键词 Signals & Slots [2] IBM文档库 https://www.ibm.com/developerworks/cn/linux/guitoolkit/qt/signal-slot/index.html

2019/7/23
articleCard.readMore

玩转Qt(5)-理解事件循环

简介 事件与事件循环 Hello World 循环处理 类比事件循环的概念 不同操作系统的事件循环 Windows Linux X11窗口 MacOS Cocoa Application Qt的事件循环 QEventLoop类 QCoreApplication 主事件循环 Qt的事件分发和事件处理 重载事件 QEvent 事件过滤器 事件循环的运用 processEvents不阻塞UI QEventLoop模拟同步调用 简介 这次讨论事件循环相关的知识点。 事件与事件循环 Hello World 从Hello World说起吧 1 2 3 4 5 6 #include <stdio.h> int main(int argc, char *argv[]) { printf("Hello World"); return 0; } 这是一段大家都很熟悉的命令行程序,运行起来会在终端输出”Hello World”,之后程序就退出了。 循环处理 我们稍微加点需求: 程序能够一直运行,每次用户输入一些信息并按下回车时,打印出用户的输入。直到输入的内容为“quit”时才退出。 按照这个需求,代码实现如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #include <stdio.h> #include <string.h> int main(int argc, char* argv[]) { char input[1024]; //假设输入长度不超过1024 const char quitStr[] = "quit"; bool quit = false; while (false == quit) { scanf_s("%s", input, sizeof input); printf("user input: %s\n", input); if (0 == memcmp(input, quitStr, sizeof quitStr)) { quit = true; } } return 0; } 我们使用了一个while循环。在这个循环体内,不停地处理用户的输入。当输入的内容为”quit”时,循环终止条件被设置为true,循环将终止。 类比事件循环的概念 在上面这个例子中,“用户输入并按下回车”这件事情,我们可以称作一个“事件”或者“用户输入事件”,不停的去处理“事件”的这段代码, 我们可以称作“事件循环”, 也可以叫做”消息循环”,是一回事。 一般对于带UI窗口的程序来说,“事件”是由操作系统或程序框架在不同的时刻发出的。 当用户按下鼠标、敲下键盘,或者是窗口需要重新绘制的时候,计时器触发的时候,都会发出一个相应的事件。 我们把“事件循环”的代码 提炼/抽象 如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 function loop() {   initialize(); bool shouldQuit = false; while(false == shouldQuit) { var message = get_next_message();    process_message(message); if (message == QUIT) { shouldQuit = true; }   } } 在事件循环中, 不停地去获取下一个事件,然后做出处理。直到quit事件发生,循环结束。 有“取事件”的过程,那么自然有“存储事件”的地方,要么是操作系统存储,要么是软件框架存储。 存储事件的地方,我们称作 “事件队列” Event Queue 处理事件,我们也称作 “事件分发” Event Dispatch 不同操作系统的事件循环 Windows 先来看一个Windows系统的事件循环示例(win32 API): 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 MSG msg = { 0 }; bool done = false; bool result = false; while (!done) { if (PeekMessage(&msg, 0, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } if (msg.message == WM_QUIT) { done = true; } } 思路和前面介绍的一致 Linux X11窗口 有些linux系统使用X11窗口系统,看看其窗口事件循环 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 Atom wmDeleteMessage = XInternAtom(mDisplay, "WM_DELETE_WINDOW", False); XSetWMProtocols(display, window, &wmDeleteMessage, 1); XEvent event; bool running = true; while (running) { XNextEvent(display, &event); switch (event.type) { case Expose: printf("Expose\n"); break; case ClientMessage: if (event.xclient.data.l[0] == wmDeleteMessage) running = false; break; default: break; } } 思路也是和前面一致的 MacOS Cocoa Application 在Cocoa Application中, 有一种获取事件的机制,叫做runloop(一个NSRunLoop对象,它允许进程接收窗口服务的各种事件) 一般的Cocoa Application运行流程是,从runloop的事件队列中获取一个事件(NSEvent) 派发事件(NSEvent)到合适的对象(Object) 事件被处理完成后,再取下一个事件(NSEvent),直到应用退出. 思路也是和前面一致的。 Qt的事件循环 Qt作为一个跨平台的UI框架,其事件循环实现原理, 就是把不同平台的事件循环进行了封装,并提供统一的抽象接口。 和Qt做了类似工作的,还有glfw、SDL等等很多开源库。 QEventLoop类 QEventLoop即Qt中的事件循环类,主要接口如下: 1 2 3 4 5 6 int exec(QEventLoop::ProcessEventsFlags flags = AllEvents) void exit(int returnCode = 0) bool isRunning() const bool processEvents(QEventLoop::ProcessEventsFlags flags = AllEvents) void processEvents(QEventLoop::ProcessEventsFlags flags, int maxTime) void wakeUp() 其中exec是启动事件循环,调用exec以后,调用exec的函数就会被“阻塞”,直到EventLoop里面的while循环结束。 这里画个简单的示意图: exit是退出事件循环(将EventLoop中的退出标识设为true) processEvents是及时处理队列中的事件(这个很有用,后面还会讲)。 这里有个问题,exec阻塞了当前函数,还怎么退出EventLoop呢? 答案是:在派发事件后,某个事件处理的函数中,达到事件退出条件时,调用exit函数,将EventLoop中的退出标识设为true。 这样的程序运行流程,我们叫做 “事件驱动”式的程序。 QCoreApplication 主事件循环 一般的Qt程序,main函数中都有一个QCoreApplication/QGuiApplication/QApplication,并在末尾调用 exec。 1 2 3 4 5 6 7 8 int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); //或者QGuiApplication, 或者 QApplication ... ... return app.exec(); } Application类中,除去启动参数、版本等相关东西后,关键就是维护了一个QEventLoop,Application的exec就是QEventLoop的exec。 不过Application中的这个EventLoop,我们称作“主事件循环”Main EventLoop。 所有的事件分发、事件处理都从这里开始。 Application还提供了sendEvent和poseEvent两个函数,分别用来发送事件。 sendEvent发出的事件会立即被处理,也就是“同步”执行。 postEvent发送的事件会被加入事件队列,在下一轮事件循环时才处理,也就是“异步”执行。 还有一个特殊的sendPostedEvents,是将已经加入队列中的准备异步执行的事件立即同步执行。 Qt的事件分发和事件处理 以QWidget为例来说明。 QWidget是Widget框架中,大部分UI组件的基类。QWidget类拥有一些名字为xxxEvent的虚函数,比如: 1 2 virtual void keyPressEvent(QKeyEvent *event) virtual void keyReleaseEvent(QKeyEvent *event) keyPressEvent就表示按键按下时的处理,keyReleaseEvent表示按键松开时的处理。 主事件循环中(注册过QWidget类之后),事件分发会在按键按下时调用QWidget的keyPressEvent函数,按键松开时调用QWidget的keyReleaseEvent函数。 重载事件 有了上面的事件处理机制,我们就可以在自己的QWidget子类中,通过重载keyPressEvent、keyReleaseEvent等等事件处理函数,做一些自定义的事件处理。 QEvent 每一个事件处理函数,都是带有参数的,这个参数是QEvent的子类,携带了各种事件的参数。比如 按键事件 void keyPressEvent(QKeyEvent *event) 中的QKeyEvent, 就包括了按下的按键值key、 count等等。 事件过滤器 Qt还提供了事件过滤机制,在事件分发之前先过滤一部分事件。 用法如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class KeyPressEater : public QObject { Q_OBJECT ... protected: bool eventFilter(QObject *obj, QEvent *event) override; }; bool KeyPressEater::eventFilter(QObject *obj, QEvent *event) { if (event->type() == QEvent::KeyPress) { QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event); qDebug("Ate key press %d", keyEvent->key()); return true; } else { // standard event processing return QObject::eventFilter(obj, event); } } 。。。 monitoredObj->installEventFilter(filterObj); 自定义一个QObject子类,重载eventFilter函数。之后在要过滤的QObject对象上,调用installEventFilter函数以安装过滤器上去。 过滤器函数的返回值为bool,true表示这个事件被过滤掉了,不用再往下分发了。false表示没有过滤。 事件循环的运用 processEvents不阻塞UI 我们的UI界面,要持续不断地刷新(对于QWidget就是触发paintEvent事件),以保证显示流畅、能及时响应用户输入。 一般要有一个良好的帧率,比如每秒刷新60帧, 即经常说的FPS 60, 换算一下 1000 ms/ 60 ≈ 16 ms,也就是每隔16毫秒刷新一次。 而我们有时候又需要做一些复杂的计算,这些计算的耗时远远超过了16毫秒。 在没有计算完成之前,函数不会退出(相当于阻塞),事件循环得不到及时处理,就会发生UI卡住的现象。 这种场景下,就可以使用Qt为我们提供的接口,立即处理一次事件循环,来保证UI的流畅 (后续再讨论多线程) 1 2 3 4 5 6 7 //耗时操作 someWork1() //适当的位置,插入一个processEvents,保证事件循环被处理 QCoreApplication::processEvents(); //耗时操作 someWork2() QEventLoop模拟同步调用 经常会有这种场景: “触发 ”了某项操作,必须等该操作完成后才能进行“ 下一步 ” 比如:软件的登录界面,向服务器发起登录请求后,必须等收到服务器返回的登录数据,才知道登录结果并决定下一步如何执行。 这种场景,如果设计成异步调用,直接用Qt的信号/槽即可,如果要设计成同步调用,就可以使用本地QEventLoop 这里写段伪代码示例一下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 bool login(const QString &userName, const QString &passwdHash, const QString &slat) { //声明本地EventLoop QEventLoop loop; bool result = false; //先连接好信号 connect(&network, &Network::result, [&](bool r, const QString &info){ result = r; qDebug() << info; //槽中退出事件循环 loop.quit(); }); //发起登录请求 sendLoginRequest(userName, passwdHash, slat); //启动事件循环。阻塞当前函数调用,但是事件循环还能运行。 //这里不会再往下运行,直到前面的槽中,调用loop.quit之后,才会继续往下走 loop.exec(); //返回result。loop退出之前,result中的值已经被更新了。 return result; }

2019/7/6
articleCard.readMore

玩转Qml(16)-移植ShaderToy

简介 源码 效果预览 穿云洞 星球之光 蜗牛 超级马里奥 关于ShaderToy 关于ShaderEffect ShaderToy原理 约定的变量 glsl版本号 glsl版本兼容 ShaderToy适配 TaoShaderToy 简介 这次涛哥将会教大家移植ShaderToy的特效到Qml 源码 《玩转Qml》系列文章,配套了一个优秀的开源项目:TaoQuick github https://github.com/jaredtao/TaoQuick 访问不了或者速度太慢,可以用国内的镜像网站gitee https://gitee.com/jaredtao/TaoQuick 效果预览 先看几个效果图 穿云洞 星球之光 蜗牛 超级马里奥 gif录制质量较低,可编译运行TaoQuick源码或使用涛哥打包好的可执行程序,查看实际运行效果。 可执行程序下载链接(包括windows 和 MacOS平台) https://github.com/jaredtao/TaoQuick/releases 关于ShaderToy 学习过计算机图形学的人,都应该知道大名鼎鼎的ShaderToy网站 用一些Shader代码和简单的纹理,就可以输出各种酷炫的图形效果和音频效果。 如果你还不知道,赶紧去看看吧https://www.shadertoy.com 顺便提一下,该网站的作者是IQ大神,这里有他的博客: http://www.iquilezles.org/www/articles/raymarchingdf/raymarchingdf.htm 本文主要讨论图形效果,音频效果以后再实现。 关于ShaderEffect Qml中实现ShaderToy,最快的途径就是ShaderEffect了。 上一篇文章《Qml特效-着色器效果ShaderEffect》已经介绍过ShaderEffect了, 本文重点是移植ShaderToy。 在涛哥写这篇文章之前,已经有两位前辈做过相关的研究。 陈锦明: https://zhuanlan.zhihu.com/p/38942460 qyvlik: https://zhuanlan.zhihu.com/p/44417680 涛哥参考了他们的实现,做了一些改进、完善。 在此感谢两位前辈。 下面正文开始 ShaderToy原理 OpenGL的可编程渲染管线中,着色器代码是可以动态编译、加载到GPU运行的。 而OpenGL又包括了桌面版(OpenGL Desktop)、嵌入式版(OpenGL ES)以及网页版(WebGL) ShaderToy网站是以WebGL 2.0为基础,提供内置函数、变量,并约定了一些输入变量,由用户按照约定编写着色器代码。 只要不是太老的OpenGL版本,内置函数、变量基本都是通用的。 约定的变量 ShaderToy网站约定的变量如下: 1 2 3 4 5 6 7 8 9 10 11 vec3 iResolution image/buffer The viewport resolution (z is pixel aspect ratio, usually 1.0) float iTime image/sound/bufferCurrent time in seconds float iTimeDelta image/buffer Time it takes to render a frame, in seconds int iFrame image/buffer Current frame float iFrameRate image/buffer Number of frames rendered per second float iChannelTime[4] image/buffer Time for channel (if video or sound), in seconds vec3 iChannelResolution[4]image/buffer/soundInput texture resolution for each channel vec4 iMouse image/buffer xy = current pixel coords (if LMB is down). zw = click pixel sampler2DiChannel{i} image/buffer/soundSampler for input textures i vec4 iDate image/buffer/soundYear, month, day, time in seconds in .xyzw float iSampleRate image/buffer/soundThe sound sample rate (typically 44100) Qml中的相应实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 ShaderEffect { id: shader //properties for shader //not pass to shader readonly property vector3d defaultResolution: Qt.vector3d(shader.width, shader.height, shader.width / shader.height) function calcResolution(channel) { if (channel) { return Qt.vector3d(channel.width, channel.height, channel.width / channel.height); } else { return defaultResolution; } } //pass readonly property vector3d iResolution: defaultResolution property real iTime: 0 property real iTimeDelta: 100 property int iFrame: 10 property real iFrameRate property vector4d iMouse; property var iChannel0; //only Image or ShaderEffectSource property var iChannel1; //only Image or ShaderEffectSource property var iChannel2; //only Image or ShaderEffectSource property var iChannel3; //only Image or ShaderEffectSource property var iChannelTime: [0, 1, 2, 3] property var iChannelResolution: [calcResolution(iChannel0), calcResolution(iChannel1), calcResolution(iChannel2), calcResolution(iChannel3)] property vector4d iDate; property real iSampleRate: 44100 ... } 其中时间、日期通过Timer刷新,鼠标位置用MouseArea刷新。 同时涛哥导出了hoverEnabled、running属性和restart函数,以方便Qml中控制Shader的运行。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 ShaderEffect { id: shader ... //properties for Qml controller property alias hoverEnabled: mouse.hoverEnabled property bool running: true function restart() { shader.iTime = 0 running = true timer1.restart() } Timer { id: timer1 running: shader.running triggeredOnStart: true interval: 16 repeat: true onTriggered: { shader.iTime += 0.016; } } Timer { running: shader.running interval: 1000 onTriggered: { var date = new Date(); shader.iDate.x = date.getFullYear(); shader.iDate.y = date.getMonth(); shader.iDate.z = date.getDay(); shader.iDate.w = date.getSeconds() } } MouseArea { id: mouse anchors.fill: parent onPositionChanged: { shader.iMouse.x = mouseX shader.iMouse.y = mouseY } onClicked: { shader.iMouse.z = mouseX shader.iMouse.w = mouseY } } ... } glsl版本号 GLSL Versions OpenGL VersionGLSL Version 2.0110 2.1120 3.0130 3.1140 3.2150 3.3330 4.0400 4.1410 4.2420 4.3430 GLSL ES Versions (Android, iOS, WebGL) OpenGL ES VersionGLSL ES Version 2.0100 3.0300 glsl版本兼容 ShaderToy限定了WebGL 2.0,而我们移植到Qml中,自然是希望能够在所有可以运行Qml的设备上运行ShaderToy效果。 所以要做一些glsl版本相关的处理。 涛哥研究了Qt的GraphicsEffects模块源码,它的版本处理要么默认,要么 150 core,显然是不够用的。 glsl各个版本的差异,可以参考这里 https://github.com/mattdesl/lwjgl-basics/wiki/glsl-versions 涛哥总结出了如下的代码和注释说明: 注意”#version xxx”必须是着色器的第一行,不能换行 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 // 如果环境是OpenGL ES2,默认的version是 version 110, 不需要写出来。 // 比ES2更老的版本是ES 1.0 和 ES 1.1, 这种古董设备,建议还是不要玩Shader了吧。 // ES2没有texture函数,要用旧的texture2D代替 // 精度限定要写成float readonly property string gles2Ver: " #define texture texture2D precision mediump float; " // 如果环境是OpenGL ES3,version是 version 300 es // ES 3.1 ES 3.2也可以。 // ES3可以用in out 关键字,gl_FragColor也可以用out fragColor取代 // 精度限定要写成float readonly property string gles3Ver: "#version 300 es #define varying in #define gl_FragColor fragColor precision mediump float; out vec4 fragColor; " // 如果环境是OpenGL Desktop 3.x,version这里参考Qt默认的version 150。大部分Desktop设备应该 // 都是150, 即3.2版本,第一个区分Core和Compatibility的版本。 // Core是核心模式,只有核心api以减轻负担。相应的Compatibility是兼容模式,保留全部API以兼容低版本。 // Desktop 3.x 可以用in out 关键字,gl_FragColor也可以用out fragColor取代 // 精度限定抹掉,用默认的。不抹掉有些情况下会报错,不能通用。 readonly property string gl3Ver: "#version 150 #define varying in #define gl_FragColor fragColor #define lowp #define mediump #define highp out vec4 fragColor; " // 如果环境是OpenGL Desktop 2.x,version这里就用2.0的version 110,即2.0版本 // 2.x 没有texture函数,要用旧的texture2D代替 readonly property string gl2Ver: "#version 110 #define texture texture2D " property string versionString: { if (Qt.platform.os === "android") { if (GraphicsInfo.majorVersion === 3) { console.log("android gles 3") return gles3Ver } else { console.log("android gles 2") return gles2Ver } } else { if (GraphicsInfo.majorVersion === 3 ||GraphicsInfo.majorVersion === 4) { return gl3Ver } else { return gl2Ver } } } readonly property string forwardString: versionString + " varying vec2 qt_TexCoord0; varying vec4 vertex; uniform lowp float qt_Opacity; uniform vec3 iResolution; uniform float iTime; uniform float iTimeDelta; uniform int iFrame; uniform float iFrameRate; uniform float iChannelTime[4]; uniform vec3 iChannelResolution[4]; uniform vec4 iMouse; uniform vec4 iDate; uniform float iSampleRate; uniform sampler2D iChannel0; uniform sampler2D iChannel1; uniform sampler2D iChannel2; uniform sampler2D iChannel3; " versionString 这里,主要测试了Desktop和 android设备,Desktop只要显卡不太搓,都能运行的。 Android ES3的也是全部支持,ES2的部分不能运行,比如iq大神的蜗牛Shader,使用了textureLod等一系列内置函数,就不能在ES2上面跑。 ShaderToy适配 本来是不需要写顶点着色器的。如果我们想把ShaderToy做成一个任意坐标开始的Item来用,就需要适配一下坐标。 涛哥写的顶点着色器如下,仅在默认着色器的基础上,传递qt_Vertex给下一阶段的vertex 1 2 3 4 5 6 7 8 9 10 11 12 vertexShader: " uniform mat4 qt_Matrix; attribute vec4 qt_Vertex; attribute vec2 qt_MultiTexCoord0; varying vec2 qt_TexCoord0; varying vec4 vertex; void main() { vertex = qt_Vertex; gl_Position = qt_Matrix * vertex; qt_TexCoord0 = qt_MultiTexCoord0; }" 片段着色器这里处理一下,适配出一个符合shaderToy的mainImage作为入口函数 1 2 3 4 5 6 7 8 9 10 11 12 readonly property string startCode: " void main(void) { mainImage(gl_FragColor, vec2(vertex.x, iResolution.y - vertex.y)); }" readonly property string defaultPixelShader: " void mainImage(out vec4 fragColor, in vec2 fragCoord) { fragColor = vec4(fragCoord, fragCoord.x, fragCoord.y); }" property string pixelShader: "" fragmentShader: forwardString + (pixelShader ? pixelShader : defaultPixelShader) + startCode 稍微说明一下,qyvlik大佬的Shader使用gl_FragCoord作为片段坐标传进去了,这种用法的ShaderToy坐标将会占据整个Qml的窗口, 而实际ShaderToy坐标不是整个窗口的时候,超出去的地方就会被切掉,显示出来的只有一小部分。 涛哥研究了一番后,顶点着色器把vertex传过来,vertex.x就是x坐标,vertex.y坐标从上到下是0 - height,而gl_FragCoord 从下到上是0 - height, 所以要翻一下。 TaoShaderToy 最后,看一下代码的全貌吧 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 //TaoShaderToy.qml import QtQuick 2.12 import QtQuick.Controls 2.12 /* vec3 iResolution image/buffer The viewport resolution (z is pixel aspect ratio, usually 1.0) float iTime image/sound/bufferCurrent time in seconds float iTimeDelta image/buffer Time it takes to render a frame, in seconds int iFrame image/buffer Current frame float iFrameRate image/buffer Number of frames rendered per second float iChannelTime[4] image/buffer Time for channel (if video or sound), in seconds vec3 iChannelResolution[4]image/buffer/soundInput texture resolution for each channel vec4 iMouse image/buffer xy = current pixel coords (if LMB is down). zw = click pixel sampler2DiChannel{i} image/buffer/soundSampler for input textures i vec4 iDate image/buffer/soundYear, month, day, time in seconds in .xyzw float iSampleRate image/buffer/soundThe sound sample rate (typically 44100) */ ShaderEffect { id: shader //properties for shader //not pass to shader readonly property vector3d defaultResolution: Qt.vector3d(shader.width, shader.height, shader.width / shader.height) function calcResolution(channel) { if (channel) { return Qt.vector3d(channel.width, channel.height, channel.width / channel.height); } else { return defaultResolution; } } //pass readonly property vector3d iResolution: defaultResolution property real iTime: 0 property real iTimeDelta: 100 property int iFrame: 10 property real iFrameRate property vector4d iMouse; property var iChannel0; //only Image or ShaderEffectSource property var iChannel1; //only Image or ShaderEffectSource property var iChannel2; //only Image or ShaderEffectSource property var iChannel3; //only Image or ShaderEffectSource property var iChannelTime: [0, 1, 2, 3] property var iChannelResolution: [calcResolution(iChannel0), calcResolution(iChannel1), calcResolution(iChannel2), calcResolution(iChannel3)] property vector4d iDate; property real iSampleRate: 44100 //properties for Qml controller property alias hoverEnabled: mouse.hoverEnabled property bool running: true function restart() { shader.iTime = 0 running = true timer1.restart() } Timer { id: timer1 running: shader.running triggeredOnStart: true interval: 16 repeat: true onTriggered: { shader.iTime += 0.016; } } Timer { running: shader.running interval: 1000 onTriggered: { var date = new Date(); shader.iDate.x = date.getFullYear(); shader.iDate.y = date.getMonth(); shader.iDate.z = date.getDay(); shader.iDate.w = date.getSeconds() } } MouseArea { id: mouse anchors.fill: parent onPositionChanged: { shader.iMouse.x = mouseX shader.iMouse.y = mouseY } onClicked: { shader.iMouse.z = mouseX shader.iMouse.w = mouseY } } // 如果环境是OpenGL ES2,默认的version是 version 110, 不需要写出来。 // 比ES2更老的版本是ES 1.0 和 ES 1.1, 这种古董设备,还是不要玩Shader了吧。 // ES2没有texture函数,要用旧的texture2D代替 // 精度限定要写成float readonly property string gles2Ver: " #define texture texture2D precision mediump float; " // 如果环境是OpenGL ES3,version是 version 300 es // ES 3.1 ES 3.2也可以。 // ES3可以用in out 关键字,gl_FragColor也可以用out fragColor取代 // 精度限定要写成float readonly property string gles3Ver: "#version 300 es #define varying in #define gl_FragColor fragColor precision mediump float; out vec4 fragColor; " // 如果环境是OpenGL Desktop 3.x,version这里参考Qt默认的version 150。大部分Desktop设备应该都是150 // 150 即3.2版本,第一个区分Core和Compatibility的版本。Core是核心模式,只有核心api以减轻负担。相应的Compatibility是兼容模式,保留全部API以兼容低版本。 // 可以用in out 关键字,gl_FragColor也可以用out fragColor取代 // 精度限定抹掉,用默认的。不抹掉有些情况下会报错,不能通用。 readonly property string gl3Ver: "#version 150 #define varying in #define gl_FragColor fragColor #define lowp #define mediump #define highp out vec4 fragColor; " // 如果环境是OpenGL Desktop 2.x,version这里就用2.0的version 110,即2.0版本 // 2.x 没有texture函数,要用旧的texture2D代替 readonly property string gl2Ver: "#version 110 #define texture texture2D " property string versionString: { if (Qt.platform.os === "android") { if (GraphicsInfo.majorVersion === 3) { console.log("android gles 3") return gles3Ver } else { console.log("android gles 2") return gles2Ver } } else { if (GraphicsInfo.majorVersion === 3 ||GraphicsInfo.majorVersion === 4) { return gl3Ver } else { return gl2Ver } } } vertexShader: " uniform mat4 qt_Matrix; attribute vec4 qt_Vertex; attribute vec2 qt_MultiTexCoord0; varying vec2 qt_TexCoord0; varying vec4 vertex; void main() { vertex = qt_Vertex; gl_Position = qt_Matrix * vertex; qt_TexCoord0 = qt_MultiTexCoord0; }" readonly property string forwardString: versionString + " varying vec2 qt_TexCoord0; varying vec4 vertex; uniform lowp float qt_Opacity; uniform vec3 iResolution; uniform float iTime; uniform float iTimeDelta; uniform int iFrame; uniform float iFrameRate; uniform float iChannelTime[4]; uniform vec3 iChannelResolution[4]; uniform vec4 iMouse; uniform vec4 iDate; uniform float iSampleRate; uniform sampler2D iChannel0; uniform sampler2D iChannel1; uniform sampler2D iChannel2; uniform sampler2D iChannel3; " readonly property string startCode: " void main(void) { mainImage(gl_FragColor, vec2(vertex.x, iResolution.y - vertex.y)); }" readonly property string defaultPixelShader: " void mainImage(out vec4 fragColor, in vec2 fragCoord) { fragColor = vec4(fragCoord, fragCoord.x, fragCoord.y); }" property string pixelShader: "" fragmentShader: forwardString + (pixelShader ? pixelShader : defaultPixelShader) + startCode }

2019/7/4
articleCard.readMore

玩转Qml(15)-着色器效果ShaderEffect

简介 关于文章 ShaderEffect 显示器如何显示色彩 GPU渲染流程 渲染管线图 并行管线示意图 着色器语言编码规范 着色器代码示例 示例 着色器代码 顶点着色器 片段着色器 参考文献 简介 这次涛哥将会教大家一些ShaderEffect(参考QmlBook,译作:着色器效果)的相关知识。 前面的文章,给大家展示了进场动画,以及页面切换动画,大部分都使用了ShaderEffect,所以这次专门来说一下ShaderEffect。 源码 《玩转Qml》系列文章,配套了一个优秀的开源项目:TaoQuick github https://github.com/jaredtao/TaoQuick 访问不了或者速度太慢,可以用国内的镜像网站gitee https://gitee.com/jaredtao/TaoQuick ShaderEffect 动画只能控制组件的属性整体的变化,做特效需要精确到像素。 Qml中提供了ShaderEffect这个组件,就能实现像素级别的操作。 ShaderEffect允许我们在Qml的渲染引擎SceneGraph上,利用强大的GPU进行渲染。 使用ShaderEffect,需要有一些图形学知识,了解GPU渲染管线,了解图形API如OpenGL、DirectX等,同时也需要一些数学知识。 图形学的知识体系还是非常庞大的,要系统的学习,需要看很多书籍。入门级的比如“红宝书”《OpenGL编程指南》、“蓝宝书”《OpenGL超级宝典》…… 一篇文章是说不完的,涛哥水平也有限。所以本文从实用的角度出发,按照涛哥自己的理解,提炼一些必要的知识点,省略一些无关的细节, 让各位Qt开发者能了解GPU原理,能看懂、甚至于自己写一些简单的着色器代码,就大功告成了。说的不对的地方,也欢迎大佬来指点。 显示器如何显示色彩 先来了解一下,显示器是如何显示出各种色彩的。 假如我们把显示器的画面放大100倍,就会看到很多整齐排列的像素点。 继续放大,就会发现每个像素点,由三种发光的元件组成,这三种元件分别发出红、绿、蓝三种颜色的光。三种颜色的光组合在一起, 就是人眼看到的颜色。这就是著名的RGB颜色模型。 如果把这三种光的亮度分为255个等级,就能组合出16777216种不同颜色的光。 GPU的任务,就是通过计算,给出每一个像素的红、绿、蓝 (简称r g b)三种颜色的数值,让显示器去”发出相应的光”。 (这样说可能不太严谨、不太专业,只是方便大家理解。另一方面,本文的目的, 是让大家学习如何写特效,不是去造显卡/造显示器。所以请专业人士见谅!) 注:参考[1] GPU渲染流程 我们以画一个填充色的三角形为例,来说明 渲染管线图 下图是一个简易的渲染管线,引用自 LearnOpenGL 画一个三角形,要经历顶点着色器、图元装配、几何着色器、片段着色器、光栅化等阶段。 其中蓝色部分是可以自定义的,自定义是指,按照图形API规范,写一段GPU能编译、运行的代码。 (这种代码就是着色器代码。可以自定义的这种渲染管线,就是可编程渲染管线,与之相对的是古老的固定渲染管线。) 这里各个阶段,分别引用一下,LearnOpenGL中的介绍(看不懂可以先跳过,看我画的图): 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 1 管线的第一个部分是顶点着色器(Vertex Shader),它把一个单独的顶点作为输入。顶点着色器主要的目的是 把3D坐标转为另一种3D坐标(后面会解释),同时顶点着色器允许我们对顶点属性进行一些基本处理。 2 图元装配(Primitive Assembly)阶段将顶点着色器输出的所有顶点作为输入(如果是GL_POINTS,那么就是一个顶点), 并所有的点装配成指定图元的形状;本节例子中是一个三角形。 3 图元装配阶段的输出会传递给几何着色器(Geometry Shader)。几何着色器把图元形式的一系列顶点的集合作为输入, 它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。例子中,它生成了另一个三角形。 4 几何着色器的输出会被传入光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素, 生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行裁切(Clipping)。 裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。 5 片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包 含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。 并行管线示意图 概念还是挺多的,而且很多教程都有渲染管线图。但是涛哥觉得,对于我们开发Shader来说,一定要有并行的意识,然而大部分 管线图,都没有体现出GPU的并行特性。所以涛哥自己画了一个草图: 解释一下吧,CPU传入了3个顶点到GPU,GPU将这三个顶点,传递给三个顶点着色器。 这里要意识到,顶点着色器开始,就是并行处理了。GPU是很强大的SIMD架构(单指令流多数据流)。 如果我们自定义了一段顶点着色器代码,则三个顶点会同时运行这段代码。(后面的片段着色器代码,就是N个点同时运行) 顶点着色器进行处理,传递给图元装配。 图元装配阶段,进行了顶点扩充,变成N个点,N看作三角形面积所在的点。 之后N个点依次传给 几何着色器->光栅化->片段着色器,最后经过测试与混合后,输出到屏幕。 可以自定义编程的,有顶点着色器、几何着色器、片段着色器(有的地方也叫像素着色器),顺带提一下,还有另外三种: 曲面控制着色器、曲面评估着色器 和 计算着色器。 一般我们的关注点,都会在片段着色器上。涛哥之前写的12种特效,就只用了自定义的片段着色器。 著名的ShaderToy网站,也是只关注片段着色器。ShaderToy 着色器语言编码规范 我们可以把着色器语言,当作运行在GPU上的C语言。 Qt的ShaderEffect支持的着色器语言包括OpenGL规范中的GLSL,和DirectX规范中的HLSL,这两种着色语法上有些细微的区别,但是可以互相转换。 我们就以glsl为主。详细的语言规范,在khronos的官网, 各个版本都有: https://www.khronos.org/registry/OpenGL/specs/gl/ 桌面版 OpenGL 版本众多,而嵌入式系统也有专用的OpenGL ES。 安卓手机、平板设备一般就是OpenGL ES,新的设备都支持ES 3.0,老的设备一般只支持到ES 2.0 OpenGL ES 的语言规范文档在这里: https://www.khronos.org/registry/OpenGL/specs/es/2.0/ 我们就用Qt默认的版本。 着色器代码示例 示例 这里用Qt帮助文档中的示例代码,来说明。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import QtQuick 2.0 Rectangle { width: 200; height: 100 Row { Image { id: img; sourceSize { width: 100; height: 100 } source: "qt-logo.png" } ShaderEffect { width: 100; height: 100 property variant src: img vertexShader: " uniform highp mat4 qt_Matrix; attribute highp vec4 qt_Vertex; attribute highp vec2 qt_MultiTexCoord0; varying highp vec2 coord; void main() { coord = qt_MultiTexCoord0; gl_Position = qt_Matrix * qt_Vertex; }" fragmentShader: " varying highp vec2 coord; uniform sampler2D src; uniform lowp float qt_Opacity; void main() { lowp vec4 tex = texture2D(src, coord); gl_FragColor = vec4(vec3(dot(tex.rgb, vec3(0.344, 0.5, 0.156))), tex.a) * qt_Opacity; }" } } } 这段代码的效果是 左边是本来的绿色的Qt的logo,右边是处理过后的灰色logo。 着色器代码 ShaderEffect的vertexShader属性就是顶点着色器了,其内容是一段字符串。按照着色器规范实现的。 同样的,fragmentShader属性 即片段着色器。 我们能在着色器中看到void main函数,这个便是着色器代码的入口函数,和C语言很像。 在main之前,还有一些全局变量,我们逐条来说明一下 在顶点着色器中,有这三种不同用处的变量:uniform、attribute、varying。 这些变量的值都是从CPU传递过来的。 如果你写过原生OpenGL的代码,就会知道,其中很大一部分工作,就是在处理CPU数据传递到GPU着色器中。 而Qml的ShaderEffect简化了这些工作,只要写一个property,名字、类型和着色器中的对应上,就可以了。 顶点着色器 1 attribute highp vec4 qt_Vertex; attribute是”属性”变量,按照前面涛哥画的管线图来说,三个顶点着色器同时运行时,每个着色器中 的attribute值都不一样。这里的qt_Vertex,可以理解为分别是三角形的三个顶点。 highp是精度限定符,这里先忽略,具体细节可以参考语言规范文档。后面的lowp、 medium也是精度限定符。 vec4就是四维向量,类似QVector4D。 qt_Vertex是变量的名字。 这条语句的作用,就是声明一个用来存储顶点的attribute变量qt_Vertex。 uniform是统一变量,三个顶点着色器同时运行时,它们取得的uniform变量值是一样的。 varying表示这个顶点着色器的输出数据,将传递给后面的渲染管线。 1 2 3 4 5 void main() { coord = qt_MultiTexCoord0; gl_Position = qt_Matrix * qt_Vertex; } 这段main函数,将CPU传进来的纹理坐标qt_MultiTexCoord0数据,通过varying变量coord,传递给了下一个阶段,然后使用矩阵进行了坐标转换, 并将结果存储在glsl的内置变量gl_Position中。 片段着色器 片段着色器中,就没有attribute了。uniform是一样的统一变量,varying是上一个阶段传递进来的数据。 1 uniform sampler2D src; sampler2D是二维纹理。所谓纹理嘛,可以理解成一张图片,一个Image。 src这个变量,就代表外面传进来的那个Image。 sampler2D也可以是任意可视的Item(通过ShaderEffectSource传递进来) 来看一下main函数 1 2 3 4 5 void main() { lowp vec4 tex = texture2D(src, coord); gl_FragColor = vec4(vec3(dot(tex.rgb,vec3(0.344, 0.5, 0.156))), tex.a) * qt_Opacity; } 这里使用了纹理 1 lowp vec4 tex = texture2D(src, coord); texture2D是一个内置函数,专业术语叫“对纹理进行采样”,什么意思呢? 假如coord的值是(0,0),那就是对src指代的这张图片,取x=0、y=0的坐标点的像素,作为返回值,存储在tex变量中。 这里注意一下纹理坐标的取值范围。假如Qml中图片的大小是100x100,其取值范围从(0, 0) -> (100, 100) 这里的传进来的纹理坐标,取值范围是(0, 0) -> (1, 1) ,GPU为了方便计算,都进行了归1化处理。将范围缩小到0 - 1 1 gl_FragColor = vec4(vec3(dot(tex.rgb, vec3(0.344, 0.5, 0.156) )), tex.a) * qt_Opacity; dot(tex.rgb, vec3(0.344, 0.5, 0.156) ) 是对两个三维向量进行了点乘。 tex.rgb是GLSL中的取值器语法。 tex是一个四维变量,可以用tex.r tex.g tex.b tex.a分别取出其中一维,也可以任意两个组合、三个 组合取值。 rgba可以取值,xyzw也可以取值, stpq也行,但只能三种选一种,不能混用。 vec4(vec3(), tex.a) 是用三维向量再加一个变量,构造四维向量。 这条语句其实是一个RGB转灰度的公式,可以自行搜索相关的资料。 gl_FragColor 是内置变量,表示所在片段着色器的最终的输出颜色。 参考文献 [1] https://zhuanlan.zhihu.com/p/43467096 [2] https://learnopengl-cn.github.io/

2019/6/22
articleCard.readMore

玩转Qml(14)-动画特效-梯度

简介 关于文章 梯度效果预览 实现原理 简介 这是《Qml特效-进场动画》系列文章的第二篇,涛哥将会教大家一些Qml进场动画相关的知识。 源码 《玩转Qml》系列文章,配套了一个优秀的开源项目:TaoQuick github https://github.com/jaredtao/TaoQuick 访问不了或者速度太慢,可以用国内的镜像网站gitee https://gitee.com/jaredtao/TaoQuick 梯度效果预览 梯度效果,支持从四个方向梯度出现 实现原理 通过数值动画,控制百分比属性percent从0 到100变化 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 //AGrad.qml import QtQuick 2.12 import QtQuick.Controls 2.12 ShaderEffect { ... //枚举声明四种方向 enum Direct { FromLeft = 0, FromRight = 1, FromTop = 2, FromBottom = 3 } property int dir: ASlowEnter.Direct.FromLeft property int percent: 0 opacity: percent > 0 ? 1 : 0 NumberAnimation { id: animation target: r property: "percent" from: 0 to: 100 alwaysRunToEnd: true loops: 1 duration: 1000 } ... } 在Shader中,使用glsl片段着色器实现像素的控制: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 in vec2 qt_TexCoord0; uniform float qt_Opacity; uniform sampler2D effectSource; uniform int dir; uniform int percent; out vec4 fragColor; void main() { vec4 color = texture2D(effectSource, qt_TexCoord0); float p = float(percent) / 100.0f; float alpha = 1.0f; if (dir == 0 ) { alpha = 1.0 - step(p, qt_TexCoord0.x); } else if (dir == 1){ alpha = 1.0 - step(p, 1.0 - qt_TexCoord0.x); } else if (dir == 2) { alpha = 1.0f - step(p, qt_TexCoord0.y); } else if (dir == 3) { alpha = 1.0f - step(p, 1.0 - qt_TexCoord0.y); } fragColor = vec4(color.rgb, alpha); } 效果比较简单,以从左向右为例(dir == 0), 说明一下: 先是把percent 归一化处理 (float p = percent / 100.0), 纹理坐标qt_TexCoord0.x的取值范围为 0 - 1,按照Qml的坐标系统,左边为0,右边为1。 之后纹理坐标与p进行比较,坐标小于p则显示(透明度为1),大于p则不显示(透明度为0). (也可以直接用discard丢弃片段) step是glsl内置函数,step(p, qt_TexCoord0.x) 就是x小于p返回0,大于等于p返回1。 结果正好与上面分析的相反,用1 减去即可: alpha = 1.0 - step(p, qt_TexCoord0.x); 最终输出颜色即可: fragColor = vec4(color.rgb, alpha);

2019/6/9
articleCard.readMore

玩转Qml(13)-动画特效-飞入

简介 关于文章 飞入效果预览 实现原理 QtQuick动画系统 动画组件 动画的使用 用例一 直接声明动画 用例二 on语法 用例三 Transitions或状态机 ShaderEffect 飞入效果源码 简介 这次涛哥将会教大家一些Qml动画相关的知识。 源码 《玩转Qml》系列文章,配套了一个优秀的开源项目:TaoQuick github https://github.com/jaredtao/TaoQuick 访问不了或者速度太慢,可以用国内的镜像网站gitee https://gitee.com/jaredtao/TaoQuick 飞入效果预览 第一篇文章,就放一个简单的动画效果 实现原理 进场动画,使用了QtQuick的动画系统,以及ShaderEffect特效。 Qml中有一个模块QtGraphicalEffects,提供了部分特效,就是使用ShaderEffect实现的。 使用ShaderEffect实现特效,需要有一些OpenGL/DirectX知识,了解GPU渲染管线,同时也需要一些数学知识。 QtQuick动画系统 动画组件 Qt动画系统,在帮助文档有详细的介绍,搜索关键词”Animation”,涛哥在这里说一些重点。 涛哥用思维导图列出了Qml中所有的动画组件: 右边带虚线框的部分比较常用,是做动画必须要掌握的,尤其是属性动画PropertyAnimation和数值动画NumberAinmation。 常见的各种坐标动画、宽高动画、透明度动画、颜色动画等等,都可以用这些组件来实现。 底下的States、Behavior 和 Traisitions,也是比较常用的和动画相关的组件。可在帮助文档搜索 关键词”Qt Quick States”、”Behavior”、”Animation and Transitions”。后续的文章,涛哥会专门讲解。 左边的Animator系列,属于Scene Graph渲染层面的优化,其属性Change信号只在最终值时发出,不发出中间值,使用的时候需要注意。 顶上的AnimationController,属于高端玩家,用来控制整个动画的进度。 动画的使用 用例一 直接声明动画 直接声明动画,指定target和property,之后可以在槽函数/js脚本中通过id控制动画的运行。 也可以通过设定loops 和 running属性来控制动画 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 Rectangle { id: flashingblob width: 75; height: 75 color: "blue" opacity: 1.0 MouseArea { anchors.fill: parent onClicked: { animateColor.start() animateOpacity.start() } } PropertyAnimation {id: animateColor; target: flashingblob; properties: "color"; to: "green"; duration: 100} NumberAnimation { id: animateOpacity target: flashingblob properties: "opacity" from: 0.99 to: 1.0 loops: Animation.Infinite easing {type: Easing.OutBack; overshoot: 500} } } 用例二 on语法 on语法可以使用动画组件,也可以用Behavior,直接on某个特定的属性即可。效果一样。 on动画中,如果直接指定了running属性,默认就会执行这个动画。 也可以不指定running属性,其它地方修改这个属性时,会自动按照动画来执行。 示例代码 on动画 1 2 3 4 5 6 7 8 9 Rectangle { width: 100; height: 100; color: "green" RotationAnimation on rotation { loops: Animation.Infinite from: 0 to: 360 running: true } } 示例代码 Behavior 动画 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import QtQuick 2.0 Rectangle { id: rect width: 100; height: 100 color: "red" Behavior on width { NumberAnimation { duration: 1000 } } MouseArea { anchors.fill: parent onClicked: rect.width = 50 } } 用例三 Transitions或状态机 过渡动画和状态机动画,本质还是直接使用动画组件。只不过是把动画声明并存储起来,以在状态切换时使用。 这里先不细说了,后面会有系列文章<Qml特效-页面切换动画>,会专门讲解。 ShaderEffect 动画只能控制组件的属性整体的变化,做特效需要精确到像素。 Qml中提供了ShaderEffect这个组件,就能实现像素级别的操作。 大名鼎鼎的ShaderToy网站,就是使用Shader实现各种像素级别的酷炫特效。 ShaderToy 作者iq大神 ShaderToy上面的特效都是可以移植到Qml中的。 使用Shader开发,需要一定的图形学知识。其中使用GLSL需要熟悉OpenGL, 使用HLSL需要熟悉DirectX。 飞入效果源码 封装了一个平移进入的动画组件,能够支持从四个方向进场。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 //ASlowEnter.qml import QtQuick 2.12 import QtQuick.Controls 2.12 import "../.." Item { id: r property int targetX: 0 property int targetY: 0 property alias animation: animation enum Direct { FromLeft = 0, FromRight = 1, FromTop = 2, FromBottom = 3 } property int dir: ASlowEnter.Direct.FromBottom property int duration: 2000 //额外的距离,组件在父Item之外时,额外移动一点,避免边缘暴露在父Item的边缘 property int extDistance: 10 property var __propList: ["x", "x", "y", "y"] property var __fromList: [ -r.parent.width - r.width - extDistance, r.parent.width + r.width + extDistance, -r.parent.height - r.height - extDistance, r.parent.height + r.height + extDistance] property var __toList: [targetX, targetX, targetY, targetY] NumberAnimation { id: animation target: r property: __propList[dir] from: __fromList[dir] to: __toList[dir] duration: r.duration loops: 1 alwaysRunToEnd: true } } 进场组件的使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 //Enter.qml import QtQuick 2.12 import QtQuick.Controls 2.12 import "../Animation/Enter" Item { anchors.fill: parent ASlowEnter { id: a1 width: 160 height: 108 x: (parent.width - width) / 2 targetY: parent.height / 2 dir: ASlowEnter.Direct.FromBottom Image { anchors.fill: parent source: "qrc:/EffectImage/Img/baby.jpg" } } ASlowEnter { id: a2 width: 160 height: 108 x: (parent.width - width) / 2 targetY: parent.height / 2 - height dir: ASlowEnter.Direct.FromTop Image { anchors.fill: parent source: "qrc:/EffectImage/Img/baby.jpg" } } ASlowEnter { id: a3 width: 160 height: 108 targetX: parent.width / 2 - width * 1.5 y: (parent.height - height) / 2 dir: ASlowEnter.Direct.FromLeft Image { anchors.fill: parent source: "qrc:/EffectImage/Img/baby.jpg" } } ASlowEnter { id: a4 width: 160 height: 108 targetX: parent.width / 2 + width / 2 y: (parent.height - height) / 2 dir: ASlowEnter.Direct.FromRight Image { anchors.fill: parent source: "qrc:/EffectImage/Img/baby.jpg" } } ParallelAnimation { id: ani ScriptAction{ script: {a1.animation.restart()} } ScriptAction{ script: {a2.animation.restart()} } ScriptAction{ script: {a3.animation.restart()} } ScriptAction{ script: {a4.animation.restart()} } } Component.onCompleted: { ani.restart() } Button { anchors.right: parent.right anchors.bottom: parent.bottom text: "replay" onClicked: { ani.restart() } } }

2019/6/8
articleCard.readMore