缓解 WPF 应用程序中的空域问题

105 篇文章 6 订阅

介绍

WPF 为构建 Windows 应用程序提供了一种现代方法,但它直接构建在 Win32(Windows 中的传统 UI 基础结构)之上。因为 Win32 是在 CPU/GPU 马力比现在更加有限的时代开发的,所以它利用了许多渲染优化。这些包括使用反向画家算法,跟踪无效区域以进行最少的更新,在屏幕周围复制像素以避免不必要的重绘,广泛的剪辑以避免过度绘制,对 alpha 混合的有限支持以及其他此类限制。大多数这些优化都是为 GDI 构建的,这是多年来 Windows 中的标准渲染技术。DirectX 是一种完全不同的渲染技术,最初用于 Windows 上的 PC 游戏,但最近在桌面应用程序中发现了更多的存在(例如,考虑 IE9)。DirectX 提供了对现代 GPU 强大功能的访问,而 WPF 采用了这项技术——而不是 GDI——以构建我们设想的那种丰富的合成框架。因此,WPF 开发了自己的转换、剪辑和组合实现,这与 Win32 实现的完全不同。

在 Win32 中,“窗口”是用户界面构成的基本单位。以编程方式,窗口由其句柄或 HWND 引用。Windows 有两种主要形式:顶级窗口和子窗口。顶级窗口是您通常与之交互的对象,它们浮动在桌面上,通常具有标准的最小化/最大化/恢复/关闭按钮、标题栏等。这是 WPF 的主要场景;这里是一个顶级窗口的客户区的专有内容,这是你在创建 WPF Window 对象时得到的。另一方面,子窗口是顶层窗口中用户界面组件的组合单元。子窗口非常有用;它们可以在不同的线程(甚至进程)上运行,它们可以有自己的状态,它们可以响应特殊消息等。许多技术建立在子窗口上,例如 ActiveX,因此将它们合并到您的 WPF 应用程序中是一个常见的要求。要在 WPF 元素树中正确包含子窗口,您必须从 HwndHost 派生并实现构造和销毁子窗口的逻辑,以及连接键盘处理和处理任何特殊消息。虽然 WPF 的大多数用户界面元素不使用子窗口,因为我们有自己的元素树,但也有一些例外;WebBrowser 控件可能是最广为人知的 WPF 控件示例,它实际上是一个托管的子窗口。WPF 还包括对托管 Windows 窗体组件的支持,这些组件是通过子窗口实现的。要在 WPF 元素树中正确包含子窗口,您必须从 HwndHost 派生并实现构造和销毁子窗口的逻辑,以及连接键盘处理和处理任何特殊消息。虽然 WPF 的大多数用户界面元素不使用子窗口,因为我们有自己的元素树,但也有一些例外;WebBrowser 控件可能是最广为人知的 WPF 控件示例,它实际上是一个托管的子窗口。WPF 还包括对托管 Windows 窗体组件的支持,这些组件是通过子窗口实现的。要在 WPF 元素树中正确包含子窗口,您必须从 HwndHost 派生并实现构造和销毁子窗口的逻辑,以及连接键盘处理和处理任何特殊消息。虽然 WPF 的大多数用户界面元素不使用子窗口,因为我们有自己的元素树,但也有一些例外;WebBrowser 控件可能是最广为人知的 WPF 控件示例,它实际上是一个托管的子窗口。WPF 还包括对托管 Windows 窗体组件的支持,这些组件是通过子窗口实现的。虽然 WPF 的大多数用户界面元素不使用子窗口,因为我们有自己的元素树,但也有一些例外;WebBrowser 控件可能是最广为人知的 WPF 控件示例,它实际上是一个托管的子窗口。WPF 还包括对托管 Windows 窗体组件的支持,这些组件是通过子窗口实现的。虽然 WPF 的大多数用户界面元素不使用子窗口,因为我们有自己的元素树,但也有一些例外;WebBrowser 控件可能是最广为人知的 WPF 控件示例,它实际上是一个托管的子窗口。WPF 还包括对托管 Windows 窗体组件的支持,这些组件是通过子窗口实现的。

您甚至可以通过直接创建 HwndSource 实例将 WPF 内容嵌入到子窗口中。这是一种强大的技术,我们将在本文中进一步探讨,它通常用于使用 WPF 为另一个应用程序开发插件的场景。注意事项:子窗口有许多 WPF 不能很好支持的奇异设置;例如修补子/兄弟剪辑或合成绘画。

WPF 支持的那种丰富的用户界面表示在很大程度上是通过我们统一组成元素树的方式来实现的。合成允许 WPF 以有趣的方式渲染元素:对不透明蒙版进行剪辑、通过变换扭曲、与透明度等效果混合、通过像素着色器处理、通过画笔重定向、投影到 3D 对象等。子窗口被设计为工作,编写它们是出了名的困难。实际上,当 WPF 托管子窗口时,我们会一起跳过合成,只需将其放置在其布局槽上,然后让它自己做事。正如您可能想象的那样,如果没有构图,可以观察到许多问题:缺少内容、不正确的剪辑、缺乏透明度、z 顺序问题等。尼克克莱默

在某些情况下,您可能还会在屏幕上遇到奇怪的渲染伪影;特别是在移动子窗口时。这些渲染工件通常是由于 GDI 和 DirectX 之间的冲突以及 DWM 中的一些限制或错误造成的。

本文详细讨论了所有这些问题,并探讨了一些缓解这些问题的方法。

空域问题

我们听到的最常见的与空域问题有关的投诉是关于裁剪问题。为了在屏幕上可见,WPF 元素树总是与某个窗口相关联;通常是顶级窗口,但也可以是子窗口。WPF 组成元素树并呈现到该窗口。子窗口始终呈现在其父窗口之上,并始终被裁剪到其父窗口的边界。回想一下,当在元素树中托管子窗口时,WPF 所做的就是将子窗口定位在其布局槽上。重要的是要意识到 WPF 不会呈现子窗口。子窗口正在呈现自己——或者更准确地说,子窗口正在绘制到与其窗口关联的 GDI 设备上下文,而不通过 WPF 进行协调。因此,即使 HwndHost 看起来是一个可以与元素树中的其他元素交错的元素,托管的子窗口实际上放置在 WPF 呈现的任何内容之上。这导致了可预见的问题:子窗口不会被滚动查看器等容器裁剪,并且没有任何东西可以渲染它们,甚至装饰器也不行。

事实上,WPF 没有很好的方法来渲染子窗口。有些人尝试过放松 Win32 的裁剪行为,但这会导致更严重的问题。最好使用 Win32 剪辑规则,这就是 WPF 强制执行 WS_CLIPSIBLINGS 和 WS_CLIPCHILDREN 的原因。

由于子窗口正在绘制自己,而不是由 WPF 呈现,因此应该清楚 WPF 无权访问窗口的内容。例如,如果您使用 VisualBrush 呈现元素树,则任何托管子窗口的内容都将丢失。另一个示例是您不能将 ShaderEffect 应用于托管子窗口,因为该窗口的内容实际上不可用于 WPF 进行处理。

人们注意到的另一个常见症状是,即使是像透明度这样简单的事情也不起作用。这是因为 Win32 绘制模型通常不支持透明度,因此放置在 WPF 内容之上的子窗口无法使用部分透明的像素来绘制自身。使用 WS_EX_TRANSPARENT 之类的样式不起作用,原因将在后面讨论。

最后一个细微差别是,当 HwndHost 元素检测到它已插入 HwndSource 时,将创建一个托管子窗口。如果从一个元素树中删除 HwndHost 元素,然后将其添加到另一个元素树中,则托管子窗口将重新设置为在目标 HwndSource 父窗口中。结果,z 顺序被重置到底部。此外,虽然设置 ZIndex 属性会导致 WPF 面板以不同的顺序呈现其子窗口,但 HwndHost 不会调整其托管子窗口的相应 z 顺序。并不是说它相对于它浮动的 WPF 内容很重要,但 z 顺序甚至相对于其他托管窗口也会不同步。

渲染线程注意事项

WPF 使用了一种在传统用户界面线程和专用渲染线程之间拆分用户界面职责的新技术。用户界面线程(或多个线程,可能不止一个)负责处理用户输入和更新元素树。渲染线程负责对元素树描述的矢量图形进行光栅化并更新显示。这种设计甚至允许渲染线程处于单独的进程中,甚至是另一台机器上的进程。这是允许 WPF 在 Vista 上支持高保真缩放的基础结构,保留了清晰的矢量图形,并且与基于位图的远程处理相比,远程桌面连接的高效性能真正改善了体验。很遗憾,WPF 3.5 SP1

此设计的另一个目标是允许渲染任务具有一定程度的独立性,这样它们就不会被用户界面线程上的停顿所阻塞。显而易见的场景是用户界面可以启动然后忘记的“独立动画”,它们将在渲染线程上进行动画处理,不会因用户界面线程上的处理而出现任何故障或卡顿。这对于转换来说是理想的,因为转换通常需要在用户界面线程上进行大量工作以加载新内容。不幸的是,WPF 从未完成独立动画所需的功能工作。事实上,单独的渲染线程唯一真正的好处是一定程度的并行性,一旦开始播放,MediaElement 可以独立于用户界面线程渲染帧。银光 5

不幸的是,这种设计也有其自身的问题。渲染数据需要为用户界面和渲染线程复制,并且必须发送和接收更新,并且必须仔细协调对象的生命周期。这增加了与大量图形内容相关的 CPU 和内存成本。WPF 为高效的局部更新实施了多项优化,但仍有很大的改进空间。另一个问题是在某些情况下,用户界面线程需要同步完成渲染操作。两个突出的例子是 PrintWindow 和自下而上的绘画。PrintWindow 重定向窗口的设备上下文并向窗口发送 WM_PAINT,窗口应将其内容绘制到重定向的设备上下文,并且当窗口进程从处理 WM_PAINT 返回时,窗口恢复到其原始状态。PrintWindow 在具有 WPF 内容的窗口上使用时不可靠,因为用户界面线程实际上并不绘制,因此内容丢失。通过在子窗口上指定 WS_EX_TRANSPARENT 样式来配置自下而上的绘制。当存在这种样式时,Win32 将首先绘制窗口下方的兄弟,然后绘制窗口。这允许子窗口有选择地让该内容显示出来,尽管它可以是 通过在子窗口上指定 WS_EX_TRANSPARENT 样式来配置自下而上的绘制。当存在这种样式时,Win32 将首先绘制窗口下方的兄弟,然后绘制窗口。这允许子窗口有选择地让该内容显示出来,尽管它可以是 通过在子窗口上指定 WS_EX_TRANSPARENT 样式来配置自下而上的绘制。当存在这种样式时,Win32 将首先绘制窗口下方的兄弟,然后绘制窗口。这允许子窗口有选择地让该内容显示出来,尽管它可以是很难很好地实施。但是因为 WPF 不绘制用户界面线程,所以我们窗口的内容不能可靠地供我们之上的任何兄弟使用。还有一种不寻常的 WS_EX_COMPOSITED 样式也不适用于 WPF 内容。

最后,有时用户界面线程需要从屏幕复制位,这对 WPF 来说是个问题。一个例子是在移动没有 SWP_NOCOPYBITS 标志的子窗口时(注意双重否定;这意味着移动带有复制位的窗口)。为避免在窗口移动时不必要地重新绘制窗口,Win32 会将窗口的现有内容从旧位置复制到新位置,并且只重新绘制留下的内容。但是由于 GDI 试图复制的旧内容可能随时由单独的渲染线程通过 DirectX 更新,因此缺少必要的协调,有时会复制垃圾. 另一个示例是 WPF 窗口上的顶级窗口具有 CS_SAVEBITS 样式。使用这种样式,Win32 将在显示窗口之前复制这些位,然后在窗口隐藏时将这些位放回原处。菜单和消息框是这种风格的典型窗口。正如 Raymond Chen所讨论的那样,Win32 会尝试检测何时使保存的位无效,并且将回退到仅重新绘制背景。但是 WPF 使用 DirectX 的单独渲染线程可能无法检测到,因此 Win32 可能会放回旧内容

在 Win32 应用程序中,用户界面处理和渲染在不同线程之间的分离是不寻常的,并且无疑是许多细微错误的根源。如果将子窗口集成到您的 WPF 应用程序中是主要问题,我强烈建议禁用 WPF 硬件加速。通过禁用硬件加速,WPF 将使用 GDI 进行渲染。这消除了许多问题,因为 GDI 甚至可以从多个线程正确协调。如果禁用硬件加速不是一个选项,我们将回顾一些解决我所知道的大多数渲染工件的缓解措施。不幸的是,这些缓解措施并不总是微不足道的。

DWM 注意事项

最初,窗口的绘制操作被剪辑到该窗口的可见区域,然后允许直接更新屏幕的视频内存。这是非常有效的,但排除了诸如透明度之类的影响,并且可能导致滞后的视觉伪影,因为对屏幕的更新是由应用程序的响应性决定的。

Windows 2000/XP通过 WS_EX_LAYERED 样式引入了分层窗口。对于分层窗口,操作系统保留了窗口内容的位图,可以根据需要在屏幕上进行合成,无需等待应用程序响应,可以应用透明等效果。该位图的内容既可以由应用程序直接提供,也可以通过“重定向”窗口的传统绘画操作来填充。SetLayeredWindowAttributes控制带有重定向内容的窗口的外观;但它仅限于简单的效果,如恒定透明度或颜色键。可以提供自己的位图而不是使用重定向的应用程序可以使用UpdateLayeredWindow反而。此 API 可以接受具有每像素 Alpha 通道的位图,因此当您将Window.AllowsTransparency属性设置为 true 时,这是 WPF 使用的 API。请注意,这种分层窗口只显示应用程序明确提供的位图内容;没有显示通过 GDI 或 DirectX 绘制到此窗口或任何子窗口的设备上下文。这是 WPF WebBrowser 控件无法在分层窗口中显示的问题的根本原因。

重定向分层窗口提供“输出重定向”;其中操作系统将通常用于屏幕的渲染操作的输出重定向到位图;这允许它以各种方式灵活地组合位图。但是,如果组合不重要,操作系统还必须提供某种“输入重定向”,其中与进入应用程序的输入相关的消息和 API 会解释组合效果。同样重要的是,与位置、大小和坐标变换相关的 API 也考虑了组合。到目前为止,操作系统并没有以需要输入重定向的方式组成分层窗口。

Windows Vista 引入了桌面窗口管理器,它重定向所有顶级窗口(尚未分层),然后将所有窗口的内容组合到桌面上。DWM 能够以比 XP 更有趣的方式组合窗口内容;Flip3D功能可能是最引人注目的——但请注意,它会禁用对窗口的输入,因此无需担心输入重定向。DWM 还可以缩放窗口以适应更高的 DPI 设置;但这是一个简单的比例变换,只需要对输入处理坐标变换 API 进行适度更新。DWM 的重定向服务仍然仅限于顶级窗口。

如果 GDI 和 DirectX 都渲染到同一个窗口,则在 Vista 上会遇到困难。Vista 通过为 GDI 和 DirectX 内容维护单独的表面来处理 DWM 中的这种情况。这种模式称为“Vista Blt”,DWM 根据最后渲染的启发式方法选择要在屏幕上显示的内容。DWM 将从一个或另一个呈现,或者它甚至可能显示来自两者的一些内容。Windows 7 引入了一种更好的模式,称为“设备位图”,它允许将 GDI 和 DirectX 捕获到同一个支持表面中。不幸的是,设备位图与所有 API 不兼容,如果调用这些 API 将被禁用(主要示例是DwmGetCompositionTimingInfo),并且它有其自身的问题

Windows 8 引入了直接组合,它将 DWM 的重定向和组合服务扩展到子窗口。这是一个重大的发展,最终使子窗口可以分层。不幸的是,Direct Composition 仍然使用 DWM 作为外部合成器,而不是与应用程序紧密集成。换句话说,您可以配置所需的内容,然后 DWM 会为您编写。这排除了某些类型的合成效果,例如在应用程序中围绕 3D 模型对子窗口进行纹理处理或通过像素着色器对其进行处理。还值得一提的是,尽管 Direct Composition 允许脱离输入和协调 API 的合成效果,但仍然不支持输入重定向。

过去,如果 DWM 不能很好地支持特定场景,您可以禁用它;尽管这很少是一个好主意。但是,从 Windows 8 开始,不能再禁用 DWM。

重定向

正如上面可能暗示的那样,这些问题的理想解决方案是让像 WPF 这样的应用程序框架自己组成子窗口的内容。这种“内部”合成器可以实现更丰富的合成效果。但是,还必须为任何此类组合系统提供“输入重定向”,以便用于被动“转换”。

在开发 WPF 4.5 时,我们决定接受实现完整重定向解决方案的挑战,以便 WPF 应用程序可以像任何其他类型的内容一样自然地组合子窗口的内容。我们探索了许多技术,但最终决定使用某种形式的 API 拦截来解决有问题的 GDI、DirectX 和 Win32 API。我们选择了Detours,因为它是微软开发的一个非常健壮和完整的拦截库。需要注意的是,这是一个进程内库,我们所做的任何事情都不会在选择加入此新功能的 WPF 进程之外产生任何影响。

DirectX 的输出重定向非常简单。在这方面,DirectX 是一个很棒的 AP​​I,因为所有渲染操作都有效地发生在屏幕外,直到调用最终的交换链(或设备)“存在”。我们的基本技术是拦截这个当前调用并通过将内容复制到另一个视频表面来实现它;然后我们将通过D3DImage在 WPF 中显示。但是,我们确实必须支持 Direct3D 9、10、11 以及可能的未来版本。这只是站得住脚的,因为 DirectX 团队有远见,在DXGI之上重构现代 Direct3D API这就是重要的当前 API 存在的地方。但即使进行了这种重构,我们的代码也必须创建中间表面等,这需要特定的 Direct3D 版本。再次,DirectX 团队通过 DirectX 11 模拟以前版本的设备状态的能力。剩下的复杂性在于处理所有可能的表面格式和使用限制。我们也很难处理 D3DSWAPEFFECT_FLIPEX,最终在创建交换链时删除了该标志。

GDI 的输出重定向涉及更多。GDI 的 API 表面积很大,GDI 没有“现在”的等价物。相反,只要驱动程序决定刷新命令流,GDI 呈现 API 就会生效。我们最终截获了 200 多个 GDI 函数,并将它们回放到屏幕外 DC。这些拦截垫片中的绝大多数是由工具根据它们是读取状态、设置状态还是实际呈现到设备上下文而机械生成的。最困难的挑战是处理剪辑。我们需要将真实设备上下文中的一些(但不是全部)剪辑状态镜像到重定向的设备上下文中。一些剪辑状态设置在操作系统的深处,因此检测剪辑何时更改超出了我们的拦截库的范围。需要排除其他剪辑状态,例如当子窗口部分位于其父窗口的边界之外,但组合时这些区域是可见的。在这种情况下,我们必须以某种方式让 WM_PAINT 和相关消息仍然生成。剪裁太多显然是一个问题,但剪裁太少也是一个问题。我们最终为重定向窗口开发了我们自己的扩展剪辑模型,试图模拟 Win32 在内部所做的事情,但这是一个持续不断的错误来源。

输入重定向带来了另一个艰巨的挑战。任何依赖元素位置来引导输入流的输入设备都需要考虑合成效果。当然,WPF 已经为此进行了测试,因为我们的元素始终是完全组合的,我们的输入事件被适当地路由。但是 Win32 固执地认为窗口是与水平轴和垂直轴严格对齐的矩形。这个限制被深入到保留窗口树数据结构的内核中。内核从鼠标等设备获取原始输入,并执行初始命中测试来决定输入应该去哪里。由于我们的解决方案仅在进程内,因此我们必须保留顶级窗口遵守这些规则的限制。(请注意,使用分层窗口,WPF 可以渲染任何形状,这让我们可以自由地做一些事情,比如旋转我们的顶层窗口。要查看此操作,请旋转 ComboBox 和下拉列表。)

内核会将“指针”输入传递给拥有它认为在鼠标下的窗口(包括子窗口)的线程队列。拥有队列的线程最终将获取该输入并再次执行命中测试处理,然后传递适当的消息,更新状态等。我们必须遵守这些规则,但仍然找到一些方法来适当地处理复合子视窗。我们探索了两种基本方法:防止内核找到任何复合的子窗口,或者确保内核找到正确的复合窗口。

子窗口可以通过多种方式对内核的输入处理隐藏,但指定 WS_EX_TRANSPARENT 样式或禁用窗口是最简单的。内核将找到顶层窗口并将输入传递到该队列。从那里我们可以执行我们自己的命中测试来找到合适的子窗口,并生成我们自己的窗口消息来发送它。这种技术最大的挑战是在正确的时间以正确的顺序发送所有正确的消息和正确的有效载荷,并以某种方式更新所有正确的全局状态。完成后,您将有效地对 Win32 的很大一部分进行逆向工程!此外,由于子窗口可以在其他线程和进程中运行,因此我们必须跨这些边界同步消息和状态。

我们的另一个选择是确保内核找到我们知道是正确的子窗口。这种技术是自己急切地执行一次命中测试,然后将相应的窗口移动到鼠标位置下,以便内核找到它。然后所有的消息和状态都由 Win32 正常处理,包括跨线程和进程边界。例如,假设我们通过元素树对鼠标位置进行命中测试 - 考虑所有合成效果 - 并确定子窗口的右下角是找到的。然后,我们将对齐整个窗口层次结构,以便内核在命中测试鼠标坐标时会找到适当的子窗口的右下角。我们需要在内核之前做这个对齐,所以我们选择了一个鼠标钩子。我们从一个低级的鼠标钩子开始,因为它们非常简单,而且它们被调用得足够早。但是低级鼠标钩子会阻塞内核,直到应用程序响应。因此,如果您的应用程序很忙,每个人的鼠标处理都会停止。开发人员的一个常见示例是,当您调试应用程序时,鼠标会停止工作!这是不可接受的,所以我们改用普通的鼠标钩子。这种鼠标钩子在线程进一步处理内核给它的鼠标输入之前被调用。事实证明这已经足够了,因为 Win32 将正确地适应在内核将输入发送到队列和队列被线程处理之间的窗口树的变化。因此,只要内核找到属于我们线程的某个窗口,它就可以将输入传递到我们的队列以进行进一步处理。现在,无响应的应用程序只会为自己停止输入处理。有很多细节需要整理,但这种技术效果很好。这种技术的主要限制是我们不能支持任何类型的多点触控,因为一个窗口不能同时在两个地方。我们决定接受这个限制来编写子窗口,并希望新的多点触控控件可以用纯 WPF 编写。因为一个窗口不能同时在两个地方。我们决定接受这个限制来编写子窗口,并希望新的多点触控控件可以用纯 WPF 编写。因为一个窗口不能同时在两个地方。我们决定接受这个限制来编写子窗口,并希望新的多点触控控件可以用纯 WPF 编写。

分层窗口(回想一下,WPF 使用我们自己提供内容位图的分层窗口类型)仍然是一个问题。即使我们可以重定向内部子窗口的输出,操作系统也拒绝向它们传递绘制消息,因此它们根本不会费心绘制。像视频这样的内容可以正常工作,因为它们通常会启动自己的绘画而不是等待 WM_PAINT 消息。我们探索了几个选项,包括发送我们自己的虚假绘制消息,或者将这些子窗口托管在它们自己的不可见的顶级系统重定向分层窗口中,但在我们的窗口中编写它们的内容。我们将在本文后面重新讨论这种技术。

有了这个系统,我们能够成功地将子窗口 - 没有任何空域限制 - 集成到以前根本不可能的场景中:

  • 每个面上都有标准 WebBrowser 控件的 3D 立方体,很像 Chris Cavanagh 的YouCube.YouCube
  • 简单的过渡效果,如旋转和褪色
  • 标准的东西,比如将 ActiveX 控件放在滚动查看器中并使用 AdornerLayer 来装饰它们。
  • 一个轮播控件,上面混合了 WinForms、WPF、ActiveX 和直接的 Win32 控件;具有镜面效果和更流畅的动画效果。
  • 一种 MDI 解决方案,可以在每个浮动窗口中托管旧控件,具有透明度、缩放和可视画笔。
  • 在分层窗口中运行的 RemoteDesktop ActiveX 客户端。
  • 使用其他视频播放控件而不是我们麻烦的 MediaElement。
  • 还有很多很多的例子。

我不愿夸大这一点,但这很可能是我在微软工作的 15 年中做过的最激动人心的工作。我为我的工程团队在解决困扰原本出色的 WPF 平台的最棘手问题之一方面所取得的成就感到非常自豪。

你可以想象我的心碎,经过广泛的审查,我们决定我们实际上不能发布这个功能。我们担心的是,我们必须以难以解释的方式侵入系统太深——更不用说维护了。尽管我们要求开发人员为每个 HwndHost 明确启用此功能,但我们认为他们会遇到的各种问题会让他们感到困惑,并且培训我们的支持工程师来处理升级将非常困难。即使在我们的开发接近尾声时,我们也一直在为一个长长的错误尾巴和性能问题而苦苦挣扎。

这正是Win32平台团队需要做的那种深度系统集成,得到官方认可和支持。正如之前在 DirectComposition API 中所指出的,在 Win8 中,我们开始在这个领域看到一些渐进式的改进。不幸的是,仍然无法构建我们开发的那种丰富的复合体验。

本文的其余部分将重点介绍像您这样的外部开发人员如何构建其中的一些功能,而无需像我们那样深入研究。我将利用我们在开发过程中学到的经验教训,为最常见的问题提供合理的现实解决方法。我将构建一个示例 MDI 框架,作为我们将讨论的缓解措施的实际应用。请注意,这只是一个示例,它并不是一个功能完整的解决方案,您可以直接放入项目中。但它应该可以很好地展示问题和解决方法。

子类化 HWND

在我们深入研究缓解空域问题之前,我们应该首先讨论拦截窗口消息。WPF 提供了几个钩子来拦截我们集成的窗口接收到的窗口消息。HwndSource.AddHookHwndHost.MessageHook就是示例。但是,有时您需要拦截发送到其他窗口的消息。Win32 程序员长期以来一直使用一种称为子类化的技术用他们自己的替换 HWND 的窗口 proc,从而允许他们访问分派到窗口的消息。这是一项非常强大的技术,但在托管代码中执行起来却异常棘手。由于新窗口过程通常在托管代码中实现,但函数指针存储在非托管内存中,因此您必须小心控制委托的生命周期,以确保 GC 在引用仍在执行时不会决定收集委托保存在非托管内存中。这个要求导致了一个流行的神话,声称您实际上需要固定您的委托,以防止 GC 重新定位委托。事实证明,这实际上并不是必需的,正如这些事务的权威 Chris Brumme在他的博客中解释的那样:

同样,托管委托可以编组到非托管代码,在那里它们作为非托管函数指针公开。对这些指针的调用将执行非托管到托管的转换;调用约定的改变;进入正确的 AppDomain;以及任何必要的参数编组。显然,非托管函数指针必须指向一个固定地址。如果 GC 重新定位它,那将是一场灾难!这导致许多应用程序为委托创建固定句柄。这是完全没有必要的。非托管函数指针实际上是指我们动态生成以执行转换和封送处理的本机代码存根。此存根存在于 GC 堆外的固定内存中。

但是,应用程序负责以某种方式延长委托的生命周期,直到不再有来自非托管代码的调用。本机代码存根的生命周期与委托的生命周期直接相关。一旦委托被收集,通过非托管函数指针的后续调用将崩溃或以其他方式破坏进程。在我们最近的版本中,我们添加了一个客户调试探针,它允许您干净地检测代码中的这个 - 这太常见了 - 错误。如果您在开发过程中还没有开始使用 Customer Debug Probes,请看一下!

只要本机代码引用函数指针,就让托管委托保持活动状态在实践中实际上很难做到,因为像 AppDomain.Unload 这样的事情会非常突然地拆除托管对象。处理子类化时更复杂的是其他人可以在您之后对窗口进行子类化。由于子类化只是用另一个函数指针替换窗口过程,并且由该实现来调用前一个函数指针,因此该模式不支持从窗口过程“链”的中间取消子类化。最后一个问题是终结器和诸如 AppDomain.Unloaded 之类的事件在单独的线程上运行,这几乎总是与 Win32(和 WPF)的线程关联模型不一致。

Microsoft 改进了 ComCtl32 v6 中的情况,增加了像 SetWindowSubclass 这样的安全子化API。令人失望的是,这些新的 API 仍然相对不为人知。在本文附带的代码中,我提供了一个名为 HwndHook 的托管类,它使用这些 API 允许您任意挂钩进程中任何窗口的窗口 proc,并由您的线程拥有。这将有助于缓解我们将在 MDI 解决方案中发现的一些问题。

减轻渲染伪影

如前所述,当改变子窗口的位置时,Win32有一个优化,将内容从旧位置复制到新位置,然后重新绘制旧位置。当 WPF 从我们单独的渲染线程使用 DirectX 进行渲染时,从 UI 线程上的 GDI 读取屏幕内容是不可靠的。首选的解决方案是禁用 WPF 硬件加速,这会导致 WPF 使用 GDI,而 GDI 可以适当地跨线程协调;但由于其他原因,这可能是不可取的。

避免此渲染伪影问题的另一种方法是禁用复制像素的有问题的优化。关键是 WM_WINDOWPOSCHANGING 消息。该消息在实际移动或调整大小之前发送到窗口,使其有机会调整各种参数;并且其中一个参数包含可以设置为包含 SWP_NOCOPYBITS 标志的标志。

HwndHost 已经响应此消息,以强制 HWND 具有由布局确定的大小和位置;但它目前没有设置这个标志。通过覆盖WndProc虚拟,为您创建的 HwndHost 实例执行此操作很容易。但是现有的 HwndHost 派生类(如 WebBrowser)呢?由于 HwndHost 公开了 MessageHook 事件,因此添加扩展方法以向该事件添加处理程序、查找 WM_WINDOWPOSCHANGING 消息并设置 SWP_NOCOPYBITS 标志相对简单。你也可以使用上面介绍的 HwndHook 类来完成同样的事情。

当您包含 SWP_NOCOPYBITS 标志时,Win32 将移动窗口,然后向它发送绘制消息以在新位置呈现内容。在 Win32 中绘制分为两步:绘制背景,然后绘制其他所有内容。在创建窗口时,可以通过指定适当的画笔来自动完成背景绘制。窗口程序也可以响应 WM_ERASEBKGND 消息并进行自己的绘制。背景绘画通常是通过用纯色填充窗口来完成的。WM_PAINT 消息在 WM_ERASEBKND 之后发送,这是绘制窗口大部分“外观”的地方。这种两遍绘制方案可能会导致屏幕闪烁,因为 GDI 可以随时将其命令刷新到屏幕上,因为您可以看到窗口被清除然后重新绘制。

知识库文章969728中提到了这种闪烁。本文建议清除 SWP_NOCOPYBITS 标志以避免 Windows 窗体控件出现这种闪烁。当然,这与我给出的建议相反!确实,清除此标志会减少闪烁,但如果您随后在托管窗口中移动,则会增加渲染伪影的风险。

然而,WebBrowser 控件是一个有趣的例子,我们可以对闪烁做一些事情。安装 IE8 时,似乎响应 WM_ERASEBKND 消息清除了背景。但是,它似乎也完全绘制了窗口的全部内容以响应 WM_PAINT 消息。可以通过简单地丢弃 WM_ERASEBKND 消息并让 WM_PAINT 处理所有内容来避免闪烁。事实证明,实际进行绘制的窗口被隐藏在 HwndHost 所托管的窗口内的多个级别,因此需要一些繁琐的代码来等到创建窗口树并挖掘到适当的后代窗口。一旦我们找到了实际绘制的窗口,我们就可以使用 HwndHook 类拦截 WM_ERASEBKND 消息并将其丢弃。

请注意,IE9 更改了绘画行为,使其不再闪烁,这表明它不再清除背景以响应 WM_ERASEBKND。另请注意,IE9 现在支持硬件加速渲染,这大大改变了事情。此硬件加速渲染由FEATURE_GPU_RENDERING键控制,默认禁用。不幸的是,启用此设置的唯一方法是通过注册表设置,这可以在示例应用程序中完成。我还包括一个禁用脚本错误的选项,这在使用 WebBrowser 控件时真的很烦人。

请记住 - 如果您禁用 WPF 的硬件加速,则不需要这些。

另一个有趣的发现是,移动 HwndHost 会导致旧内容在屏幕上停留的时间比平时更长。如果您以交互方式四处移动子窗口,这可能会导致旧内容的“痕迹”在窗口周围涂抹 - 例如通过拖动其中包含子窗口的 MDI 元素。原因是当窗口移动时,Win32 使窗口原来所在的区域无效。当线程完成处理大多数其他事情时,会更新无效区域,但输入处理具有更高的优先级,因此在窗口周围拖动只会继续累积脏区域以供以后使用。当 WM_PAINT 消息最终发送到我们的窗口 proc 时,我们只是将请求排队到渲染线程进行更新,这是另一个延迟来源。HwndHost.OnRender方法。现在,当元素移动时,WPF 知道它需要立即更新旧位置和新位置 - 无需等待 Win32 向我们发送 WM_PAINT 消息。

缓解剪裁问题

HwndHost 元素不像其他视觉效果那样重叠或剪辑的事实可能是人们遇到的最明显的空域问题。在 Win32 中,重叠几乎总是通过裁剪来处理(也有例外,例如 WS_EX_TRANSPARENT)。在 WPF 中,重叠几乎总是由 overdraw 处理。Win32 方式可以更有效(尽管对于复杂的几何图形,裁剪本身可能很昂贵),而 WPF 方式允许像透明度这样的效果。Win32 通常会强制执行剪辑(窗口样式有一些提示),而 WPF 通常会进行剪辑,您必须明确请求。为了避免这类常见的空域问题,我们需要让 WPF 和 Win32 使用相同的重叠/剪切规则。

如果我们有一个重定向模型,我们可以通过使用 WPF 将其位图与场景的其余部分组合起来,使 Win32 内容按照 WPF 的规则播放。如果没有重定向模型,我们需要让 WPF 按照 Win32 的规则运行,这就是我们将在此处研究的内容。

Win32 剪辑发生在以下情况:

  • 绘画操作被剪辑到正在绘画的窗口。
    Win32 窗口总是这样做,但 WPF 实际上很少剪辑渲染指令。这部分是因为元素的“形状”并不那么容易确定 - 并且通常由元素实际呈现的内容定义。那么你怎么知道要将渲染剪辑到什么位置呢?但是,所有元素都有一个Clip属性,可用于强制剪辑。
  • 绘画操作被剪裁以避免儿童。
    这意味着父窗口不能(通常 - 参见WS_CLIPCHILDREN)在其子窗口之上绘制。在 WPF 中也是如此 - 除了父级的渲染操作实际上是在子级之后渲染的,而不是被裁剪的。在 WPF 中,AdornerLayers提供了在子级上渲染的功能。
  • 绘画操作被剪裁以避免重叠兄弟姐妹。
    这意味着窗口不能(通常 - 参见WS_CLIPSIBLINGS)在 z 顺序较高的兄弟姐妹之上绘制。在 WPF 中也是如此 - 除了元素的渲染操作实际上是根据其 z 顺序在其兄弟姐妹之后渲染的。
  • 绘画操作被裁剪到祖先的可见区域。
    这意味着子窗口不能在其父窗口之外绘制。令人惊讶的是,这不是 WPF 通常强制执行的限制。同样,这部分是因为元素的“形状”取决于它呈现的内容——而它呈现的内容通常是更简单元素的子树的结果。但是,面板可以设置ClipToBounds属性以使 WPF 将子级剪辑到其布局. 在祖先的范围之外绘图是各种效果的重要技术,例如过渡。默认情况下,有几个面板会夹住他们的​​孩子;ScrollViewer 是一个示例,其中剪辑对控件的核心功能非常重要,并且空域问题特别引人注目。

窗口的“形状”要么是一个简单的矩形,要么是由明确设置在窗口上的区域定义的。此形状用于剪辑以及输入命中测试。例如,区域是如何为顶级窗口赋予圆角的(尽管该区域是由系统提供的,而不是由应用程序代码提供的)。请注意,分层窗口(尤其是应用程序提供带有 alpha 通道的位图的类型)略有不同,因为操作系统在命中测试时会忽略完全透明的像素。这不适用于任何其他类型的窗口。

可以想象从 WPF 的布局和渲染数据中计算出足够的信息来确定托管子窗口的可见部分的适当形状,并设置一个区域来反映该形状。但是,由于内容重叠而在 WPF 中出现的大部分内容实际上只是过度绘制。WPF 实现画家算法(记住 Win32/GDI 通常实现相反的画家算法),并且乐于透支内容。这必须被检测到并被解释为通用解决方案。另一个问题是性能。WPF 可以对可视化树的内容进行动画处理,并且 HwndHost 的可视区域的有效形状可以不断变化。我没有采用这种方法,因为以高性能的方式从 WPF 中提取必要的信息很复杂。对于感兴趣的读者来说,这可能是一个有趣的练习。

子窗口总是被他们的父母剪掉。这可用于向需要将其内容剪辑到视口的容器(例如 ScrollViewer)添加剪辑行为。ScrollViewer 可以配置为使用子窗口作为视口,所有内容(包括其他子窗口)都将放置在这个中间子窗口中,Win32 自然会强制执行剪辑。

假设设置了适当的窗口样式,子窗口也被剪裁到它们的兄弟窗口。如果我们有两个应该相互重叠的元素,我们可以将每个元素包含在它们自己的中间子窗口中,然后这些元素将正确地相互剪辑。当然,形状可以是简单的矩形或显式区域,但 Win32 会使用剪切处理重叠。如果子树中有一个托管子窗口,这将解决问题,但它会禁用过度绘制,因此会丢失透明度等效果。

这种“中间子窗口”技术的关键是创建一个 WPF 控件,该控件将承载一个子窗口,进而在子窗口中承载更多的 WPF 内容。此类控件的内容将被裁剪到子窗口的边界,并且在与其他此类控件重叠时会正确裁剪。WPF 实际上在 HwndHost 和 HwndSource 类中提供了两个主要的必需功能;我们基本上要做的就是将它们连接在一起。HwndHost 在元素树中承载一个子窗口,而 HwndSource 在一个窗口中承载一个元素树。通常 HwndSource 用于在顶级窗口中托管 WPF 元素树,但它也适用于子窗口。

诀窍是保持所有正常的 WPF 功能正常工作,以便内部元素树似乎完全连接到外部元素树。我们无法将视觉树连接在一起,因为元素没有视觉连接。但是,WPF 旨在支持通过所谓的“逻辑”树连接的元素之间的大多数功能。一旦我们连接了逻辑树,事件就会正确路由并且属性会按预期继承。

输入事件从它们相应的窗口消息启动,例如 WM_MOUSEMOVE 或 WM_KEYDOWN,Win32 根据鼠标在谁上方或谁拥有键盘焦点将其分派到特定窗口。引入子窗口会导致其中一些消息直接发送到子窗口,而不是发送到顶层窗口。我们在子窗口中使用的 HwndSource 具有响应这些消息并驱动 WPF 输入系统的所有逻辑,并且由于不相交的元素树在逻辑上是连接的,因此输入事件按预期通过树进行路由。

键盘输入的处理方式与鼠标输入不同。在表现良好的应用程序中,当相应的消息通过ComponentDispatcher处理时,WPF 会对键盘事件做出反应。这应该在消息发送到任何特定窗口之前发生;但这取决于应用程序消息泵的配合。顶级 WPF 窗口将在焦点位于其中任何位置时响应来自 ComponentDispatcher 的键盘事件,并通过IKeyboardInputSink将消息转换为调用. HwndHost 具有此接口的存根实现,但从 HwndHost 派生的类负责提供完整的实现。方便的是,HwndSource 也实现了这个接口,所以我们需要做的就是再次将两者连接在一起。不过,有一个有趣的皱纹。HwndHost 上的 IKeyboardInputSync 接口被调用以响应路由到它的键盘事件,并且 HwndSource 将在调用其 IKeyboardInputSync 时引发新的键盘事件。问题在于,一旦我们将内部元素树逻辑连接到外部元素树,键盘事件路由就统一了。这意味着由内部 HwndSource 引发的键盘事件实际上将通过外部 HwndHost,然后再次调用 IKeyboardInputSync,从而创建无限循环。

在本文附带的示例代码中,这种“中间子窗口”技术是在一个名为 HwndSourceHost 的类中实现的。

缓解 Z 顺序问题

上面我们展示了通过引入中间子窗口可以缓解 WPF 内容与 Win32 内容重叠和剪切的问题。但是,这些窗口的 z-order 仍然需要与 WPF 的 z-order 感保持同步。

当 WPF 面板允许其子级重叠时,子级的“z 顺序”变得很重要,因为这决定了哪些子级在其他子级之前。WPF 根据从 GetVisualChild 返回的顺序确定它正在呈现的视觉对象的 z 顺序。对 GetVisualChild 的调用通常只是传递给 UIElementCollection 底层的控件,因此从 UIElementCollection 添加删除元素显然会影响元素的 z 顺序。

当 HwndHost 第一次连接到根在 HwndSource 下的元素树时,会要求 HwndHost 提供托管窗口。如果从树中移除 HwndHost,则现有窗口不会被破坏,它只是在一个不可见的窗口下重新设置父级;并且当 HwndHost 被添加到另一个元素树时,该窗口只是在新的 HwndSource 下重新设置父级。重新设置子窗口的父级时,它被放置在现有子窗口的顶部,这可能无法正确反映集合中 WPF 元素的顺序。

WPF 面板还支持一种更有效的机制,即使用间接索引将集合中元素的索引映射到适当的可视索引。Panel.ZIndex属性是此间接索引功能的公开展示简单地设置 ZIndex 属性将导致面板调整从 GetVisualChild 返回子级的顺序。可能可以监视对此属性的更改,但它似乎不完整,因为面板可以随意调整它们从 GetVisualChild 返回元素的顺序。

由于自动检测可能影响面板中元素 z 顺序的更改似乎并不实用,因此我们依赖应用程序知道可能重叠的 HwndSourceHost 元素的 z 顺序何时发生变化。然后,应用程序可以调用静态方法 HwndSourceHost.UpdateZOrder,传入重叠 HwndSourceHost 元素的面板。此静态方法通过调用 GetVisualChild 简单地枚举子级并更正 Win32 z-order 以匹配。GetVisualChild 返回的元素顺序必须是正确的 z 顺序,所以这是正确的,如果对应用程序有轻微的负担的话。

重新访问重定向

到目前为止提出的缓解措施非常简单,但仍然限制了在 WPF 元素树中组合托管 Win32 窗口的保真度。事实上,我们进一步限制了树的常规 WPF 部分,以符合 Win32 的裁剪规则。不再可能出现需要透支的效果,例如透明度。需要通过中间表面进行光栅化的效果(例如视觉画笔或着色器效果)也会被破坏。为了提供这些功能,我们需要使用某种形式的重定向。

在本节中,我们将注意力转向实现一种简单的重定向形式。我们将在 Windows 7 及更高版本上处理 GDI 和 DirectX 的输出重定向。我们还将处理鼠标的输入重定向;回想一下,键盘输入根本不需要重定向,因为它不依赖于元素的呈现外观。我们不会使用任何形式的 API 拦截;相反,我们将使用重定向分层窗口的完全支持的功能。我们将支付的罚款主要是绩效;因此,请确保您调查了场景对目标硬件性能的影响。

这种技术的基本思想是将我们要重定向的每个子窗口放在一个单独的顶层重定向分层窗口中,该窗口的透明度设置为 0。这是一个具有 WS_EX_LAYERED 样式的标准顶层窗口,透明度有已配置调用 SetLayeredWindowAttributes。需要注意的是,这是官方支持的窗口类型,我们不会强制系统进入某种异常状态。此顶级窗口是透明的,因此在屏幕上不可见。由于此窗口的透明度随时可能改变,Win32 继续重定向并保留此顶级窗口的内容,即使它是完全透明的。这是我们使用的最重要的功能——窗口正在绘画,内容被捕获到位图中,

为了在 WPF 中显示托管窗口,我们将内容从不可见的顶级窗口复制到位图中,并将该位图显示为 WPF 元素树中的常规元素。这使得完整的合成成为可能——过度绘制、剪辑、变换、着色器效果等。即使顶层窗口是不可见的,Win32 也允许通过调用 PrintWindow 或仅从顶层的 GDI 设备上下文进行 blitting 来访问内容窗户。理想情况下,只有在内容实际发生变化时,我们才会将内容复制到位图中。不幸的是,我还没有找到一种可靠地检测 GDI 和 DirectX 内容的技术。相反,我们不得不求助于在计时器上更新位图。正如您可以想象的那样,这会导致不必要的工作并导致该技术的性能损失。

复制位图的内容对 GDI 内容非常有效,但 DirectX 内容的问题更大。在 Windows XP 中,重定向的窗口根本不重定向 DirectX 内容。从 Windows Vista 开始,DWM(当然,当它启用时)负责管理支持顶层窗口的表面。Vista 使用“VistaBlt”模式,其中 GDI 和 DirectX 被重定向到单独的表面,然后组合在一起呈现。直到 Windows 7 引入了设备位图,GDI 和 DirectX 内容才被重定向到同一个表面。GDI 方法(如 PrintWindow)或直接从 GDI 设备上下文读取,访问包含 GDI 内容的表面。只有使用设备位图(Windows 7 及更高版本),我们的技术才能适用于 DirectX 内容。这很重要,因为如果您实际上在重定向的子窗口中托管某些 WPF 内容,除非您禁用硬件加速,否则 WPF 将使用 DirectX 呈现。当然,DirectX 内容可以来自 WPF 以外的其他技术;例如 XNA、Direct2D、Windows Media Player 等。还记得使用 D3DSWAPEFFECT_FLIPEX 呈现的 DirectX 表面是由 DWM 专门处理的;它们实际上并没有组合到重定向的表面中,而是在 DWM 呈现窗口时组合在顶部。我们的重定向技术无法访问这些表面,因此您必须配置您托管的组件以禁用此模式或回退到前面讨论的剪辑技术。使用 D3DSWAPEFFECT_FLIPEX 的一个常见组件是 Windows Media Player,而且我不知道有什么方法可以禁用它。在使用此技术时仔细考虑需要满足的所有要求,并进行彻底测试以避免因某些限制而感到意外,这一点非常重要。

对于鼠标输入重定向,我们使用通过重新定位包含它的相应顶级分层窗口来对齐鼠标下方子窗口的适当点的基本技术。我们在 WPF 中对我们正在渲染的托管窗口的图像执行命中测试。图像与托管窗口具有相同的尺寸,因此图像的偏移量为我们提供了托管子窗口中需要位于鼠标下方的适当位置。一点简单的数学和坐标变换就可以计算出包含顶级分层窗口的位置。然后我们移动窗口,将其按 z 顺序排列到顶部,并将透明度更改为 256 中的 1。可见性的一小部分足以让 Win32 将鼠标输入传递给它,但足够透明,以至于人眼无法察觉(据我所知)。请注意,在分层窗口中移动,甚至改变它的透明度,都是相当便宜的,因为应用程序不需要重新绘制内容。

因为我们正在定位窗口,以便鼠标输入将到达正确的目的地,所以使用鼠标钩子可能是合适的。但是,如果您想托管来自其他进程(例如包含 XBAP 的 WebBrowser)的子窗口,这会使事情变得复杂。为简单起见,并且因为我们已经使用定时器来更新位图,所以我们还使用定时器来同步顶层分层窗口的位置和透明度。这并不完美,但效果相当不错。我将鼠标钩子的实现留给感兴趣的读者。

在本文随附的示例代码中,此技术在 RedirectedHwndHost 类中实现。

MDI 演示

许多人已经注意到 WPF 不支持开箱即用的用户界面的多文档界面 (MDI) 样式。其原因是我们认为我们的时间最好花在其他功能上的古老合理化。当时,MDI 用户界面被淡化,取而代之的是选项卡式界面等其他选项。MSDN 甚至在其关于多文档界面的部分中有这种令人沮丧的语言:

许多新用户和中级用户发现学习使用 MDI 应用程序很困难。因此,您应该为您的用户界面考虑其他模型。但是,您可以将 MDI 用于不适合现有模型的应用程序。

早在 2005 年,我们就发表了我们对此事的立场。然而,MDI 在某些类型的应用程序中仍然是一种非常流行的用户界面模型。由于 WPF 是一个非常强大的平台,因此推出自己的 MDI 解决方案相当简单。事实上,已经有很多第三方提供的 WPF 的 MDI 解决方案。有些是非常简单的技术演练,而另一些则是专业品质的产品。快速 Bing 搜索显示了一些可用的内容(这不是一个详尽的列表!):

其中许多解决方案都受到本文讨论的空域问题的困扰,因此我认为展示如何将各种缓解技术整合到 MDI 应用程序中会很有启发性。在http://microsoftdwayneneed.codeplex.com上的演示应用程序中,我演示了构建专有的 MDI-ish 解决方案,主要是因为我不想认可任何特定的第三方产品。虽然我们提供的指南旨在帮助所有开发人员缓解 WPF 应用程序中的空域问题,但它应该特别适用于 MDI 解决方案的开发人员,以及愿意推出自己的解决方案的企业开发人员。

演示应用程序允许您以交互方式调整许多设置以打开/关闭本文中讨论的各种技术。但是,公平地说,仍然存在一些重大问题,尤其是在重定向技术方面。一个特别令人烦恼的问题是某些序列导致菜单不再响应鼠标。在我解决问题之前,我通常会推迟发布此白皮书。但是,我认为这篇文章的大部分内容会对你们中的一些人感兴趣(甚至可能会用到),并且不想无限期地推迟发布。如果有足够兴趣和积极性的读者能够接受我提供的信息并与之一起运行,那就太好了。

  • 0
    点赞
  • 2
    收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:技术黑板 设计师:CSDN官方博客 返回首页
评论
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值