资讯动态

WinUI 3 墨迹输入架构解析:从系统 XAML 的 DirectInk 到 Lifted XAML 的 InkCanvas 移植

发布时间:2026/9/17 17:38:29 来源:尧图企业网站定制
WinUI 3 墨迹输入架构解析从系统 XAML 的 DirectInk 到 Lifted XAML 的 InkCanvas 移植【免费下载链接】microsoft-ui-xamlWinUI: a modern UI framework with a rich set of controls and styles to build dynamic and high-performing Windows applications.项目地址: https://gitcode.com/GitHub_Trending/mi/microsoft-ui-xaml墨迹Inking是 Windows 上笔输入体验的核心能力而在 WinUI 3 的架构大迁移中它经历了一次从系统 XAML 直通到Lifted XAML 曲线救国的艰难重生。本文以 docs/design-notes/ink.md 设计笔记为主体结合本仓库中 InkCanvas / InkToolbar 的真实实现controls/dev/InkCanvas、controls/dev/InkToolbar完整梳理墨迹在 XAML 中的工作原理、系统 XAML 与 Lifted XAML 两条实现路径的本质差异以及 WinUI 3 1.2 最终采纳的 InkCanvas 落地方案。读完本文你将理解为什么墨迹的本质是 DirectInk Composition以及 WinUI 3 中 InkCanvas 为何必须置于所有 XAML 内容之上、为何要依赖 windowed popup 来承载浮层内容。概述XAML 只是搭台DirectInk 与 Composition 才是唱戏的设计笔记开篇就给出了一句直白的剧透真正承担墨迹捕获与渲染重活的是 DirectInk 和 Composition合成器XAML 只需要把一切正确设置好、并且不挡路。这一定位贯穿了整个墨迹架构输入与渲染都在 XAML 之外笔/触摸输入在 DWM 进程内完成命中测试与低延迟湿墨wet ink绘制XAML 控件树本身不参与墨迹绘制XAML 的职责是接线把系统 InkPresenter 通过一个系统合成 Visual挂进合成树并把布局尺寸喂给墨迹呈现层Lifted XAML 的挑战恰恰来自这里一旦墨迹依赖的是系统 Visual 作为根那么在应用进程内合成的 Lifted 树里系统 Visual不再天然存在这就引出了下文一整章关于 airspace空域的讨论。从源码结构看这一点也得到了印证InkCanvas的公开 API 面刻意保持极小——只有默认构造函数和一个只读的InkPresenter属性见 InkCanvas.idl所有墨迹配置都通过InkPresenter流转InkCanvas自身不实现任何绘制逻辑。墨迹相关类InkPresenter / InkCanvas / InkToolbar 的分工InkPresenter捕获与渲染的真正主体InkPresenter位于Windows.UI.Input.Inking命名空间即 XAML 之外的系统 API负责捕获并渲染墨迹笔画。设计笔记特别强调它支持两类自定义途径辅助输入处理策略通过InkPresenter.InputProcessingConfiguration属性控制次要输入例如按住笔杆侧键的笔划的行为——可以像主输入一样绘制墨迹、可以擦除墨迹笔画也可以原样透传给应用LeaveUnprocessed自定义干燥custom drying应用可通过调用InkPresenter.ActivateCustomDrying接管干墨已提交的 wet→dry 笔画的渲染。在 WinUI 3 中这两点都被完整镜像进了Microsoft.UI.Xaml.Controls命名空间。本仓库 InkCanvas.idl 定义了一整套与系统类型一一对应的代理类型InkInputProcessingModeNone0 / Inking1 / Erasing2与InkInputRightDragActionLeaveUnprocessed0 / AllowProcessing1它们是系统枚举的镜像InkInputProcessingConfiguration暴露Mode与RightDragAction两个属性InkCanvas.idlInkInputConfiguration暴露IsPrimaryBarrelButtonInputEnabled与IsEraserInputEnabledInkCanvas.idlInkStrokeInput/InkUnprocessedInput前者在笔划开始/继续/结束/取消时抛事件后者在输入被置为不处理例如套索选择场景时抛出原始指针事件InkCanvas.idlInkSynchronizer由ActivateCustomDrying()返回应用用BeginDry()/EndDry()包住自己对已提交笔画的渲染InkCanvas.idlInkStrokeContainerClear / GetStrokes / AddStroke / SaveAsync / LoadAsync / SelectWithLine / DeleteSelected / PasteFromClipboard等笔画容器操作InkCanvas.idl。为什么需要这一整套镜像而非直接暴露系统类型源码注释给出了答案系统的 OS 对象是墨迹线程亲和thread-affine的只能在专用墨迹线程上被创建和调用而应用运行在 UI 线程。因此仓库实现了一个封送代理marshaling proxyInkPresenter见 InkPresenter.h它持有IInkDesktopHost引用、在墨迹线程上创建 OS presenter并通过自己的工作队列把每一次操作派发到墨迹线程执行getter 则返回 UI 线程上的缓存值以免阻塞。InkCanvas把 InkPresenter 插进 XAML 树的插座InkCanvas是一个轻量级 XAML 元素继承自FrameworkElement职责有二把 InkPresenter 接入 XAML 树的其余部分——它是系统墨迹 Visual 与 XAML 合成树之间的桥梁通过参与 XAML 布局来为 InkPresenter 定尺寸——InkPresenter 需要以物理像素为单位的大小而布局发生在 XAML 侧。仓库中的InkCanvas实现完全遵循这一分工InkCanvas.cppInkPresenter()访问器是惰性创建代理的入口InkCanvas.cpp代理在EnsureInkPresenter()中通过CoCreateInstance(InkDesktopHost)获取共享的墨迹宿主并启动 OS presenter 的创建UpdateInkPresenterSize()在SizeChanged、XamlRoot.Changed时触发把ActualWidth()/ActualHeight()本地 DIP 值通过代理队列派发到墨迹线程最终调用IInkPresenterDesktop::SetSize(width, height)InkCanvas.cpp。有趣的是源码里还有一个未竟事项代码注释指出目前没有可靠的事件来感知元素之上 XAML 缩放因子的变化而缩放因子正是配置墨迹区域物理像素尺寸所必需的见 InkCanvas.cpp因此当前实现监听XamlRoot.Changed与SizeChanged作为近似方案。InkToolbar配置 InkCanvas / InkPresenter 的工具栏InkToolbar是一个 XAMLControl可用来配置InkCanvas/InkPresenter。关键约束是它可以放在树中的任意位置但必须通过TargetInkCanvas与TargetInkPresenter属性显式指向目标InkToolbar.idl。从 IDL 可以看到InkToolbar的完整配置面InitialControlsAll / None / PensOnly / AllExceptPens、ActiveTool、InkDrawingAttributes、IsRulerButtonChecked、IsStencilButtonChecked、ButtonFlyoutPlacement、Orientation默认 Horizontal以及ActiveToolChanged / InkDrawingAttributesChanged / EraseAllClicked / IsStencilButtonCheckedChanged等事件和GetToolButton / GetToggleButton / GetMenuButton查询接口。源码实现中还有一个值得注意的优先级规则TargetInkPresenter优先于TargetInkCanvas。在 InkToolbar.cpp 中当TargetInkCanvas属性变化时如果TargetInkPresenter已被显式设置则直接忽略该变化per API review避免两个目标产生歧义。WinUI 3 1.2 的裁剪决策设计笔记明确记录了 1.2 版本的目标形态lift InkCanvas 但不 lift InkPresenter。由于不希望暴露系统InkPresenterlifted 的InkToolbar应当只指向 lifted 的InkCanvas。仓库中 InkToolbar.idl 同时保留TargetInkCanvas与TargetInkPresenter两个属性但后者由前者解析而来正是这一决策的落地形态——工具栏永远通过 lifted 的 InkCanvas 间接获得系统 presenter。系统 XAML 的墨迹入口InkPresenterInterop::SetRootVisual系统 XAML 进入墨迹的入口是InkPresenterInterop::SetRootVisual方法它把一个系统的IDCompositionVisual2标记为墨迹区域的根。设计笔记特别提醒这里期望的是传统 COM 接口IDCompositionVisual2而不是 WinRT 的Visual。一旦设置完成所有输入与渲染都发生在 XAML 之外的其他进程中即 DWM。系统 XAML 通过一个合成节点comp nodeHWCompInkCanvasNode与InkPresenterInterop通信与所有 comp node 一样它内部持有一个IDCompositionVisual2它把这个 visual 传给InkPresenterInterop::SetRootVisual从而把 InkPresenter 挂进合成树它还把 InkCanvas 的布局尺寸传递给充当 InkPresenter 根 visual 的那个IDCompositionVisual2。这一系统 visual 作为墨迹根 布局尺寸下行的模式正是后续 Lifted 路径必须复刻或绕开的核心契约。Lifted XAML 的墨迹两个路线与一个 airspace 难题为什么 Lifted 做不了同样的入口墨迹入口依赖系统 Visual而 Lifted XAML 的合成树活在应用进程内——因此 1.2 之前 WinUI 3砍掉了全部墨迹能力。如今有客户需要墨迹方案有二lift DirectInk 和 InkPresenter 本身——成本太高1.2 不采用从 lifted XAML/IXP 消费系统 DirectInk——IXPInteractiveExperiences Platform正在原型验证工作量与可行性这正是 1.2 的做法。在设计上XAML 与 IXP 之间需要某种方式把系统 InkPresenter 与一个 lifted 的WinRT但愿如此Visual 关联起来因此需要 IXP 提供 API。文档作者 George 的大胆猜测是某个 lifted Visual 上的方法参数是一个 InkPresenter——从仓库现状看这个思路最终演变成了IExpCompositorInterop2::CreateDCompVisualUnderMUCVisual见下文。Airspace输入必须直达 DWM 中的系统墨迹 Visual要理解 Lifted 路径的困难先看系统 XAML 为什么没有这个问题系统 XAML 的整棵视觉树都活在 dwm.exe 中包括墨迹使能的 visual 和应用其余 UI 的 visual输入直接到达 dwm.exe可以就地对照整棵树做命中测试如果命中墨迹使能的 visual就能在 DWM 里绘制低延迟墨迹。Lifted XAML 完全不同lifted 视觉树活在应用进程中由进程内合成器渲染dwm.exe 看到的树通常只有一个孤零零的 visual上面挂着一块CompositionSurface把 lifted 视觉树画了进去输入先到 dwm.exe命中这个单一 visual再被路由回应用进程由 lifted 合成器对 lifted 树做命中测试一旦输入被路由进应用进程就再也无法传回 DWM 用于墨迹绘制。因此结论是dwm 看到的树里必须预先包含一个已经为墨迹设置好的系统 Visual。而且这个 visual 不能被任何东西遮挡——包括其他透明 visual因为透明 visual 同样会挡住命中测试使输入到不了墨迹 visual。树展开splaying把 lifted 树撕开成三段由于墨迹 visual 必须作为一个独立的系统 Visual 存在于 DWM 树中把 InkCanvas 放进 lifted XAML 就等效于把 lifted 树展开成三段InkCanvas 下方的内容InkCanvas 本身InkCanvas 上方的内容。lifted 合成器可以把这三段分别转成三个系统 Visual 送给 DWMDWM 就能对墨迹 visual 做命中测试。1.2 的两个现实障碍设计笔记直言 1.2 阶段实施自动树展开有两个问题时间不够目前 lifted XAML 没有任何机制展开树一切内容都渲染进 island 对应的单一 content bridge 中自动展开会制造巨大的透明 visual 阻塞输入假设 InkCanvas 上方有两个按钮一个在 (0,0)一个在 (1800,900)把上方所有内容并入一个渲染表面就会产生一块左上角一个按钮、右下角一个按钮、中间大片透明的表面它转成系统 Visual 后会盖住墨迹使能的系统 Visual墨迹就此失效。1.2 的落地策略InkCanvas 置顶 windowed popup 承载浮层绕开自动展开XAML 对 1.2 采取了更简单、可实施的策略InkCanvas 位于 island 内所有 XAML 内容之上唯一的例外是 windowed popup窗口化弹出windowed popup 创建自己的 content bridge在其内部渲染。这套策略的收益非常清晰墨迹得以存活在独立于其余树的系统 Visual 中应用仍可通过 windowed popup 在 InkCanvas 之上渲染内容每个 popup 预期都很小因此不存在不相邻的 UI 被合并成大透明表面的场景XAML 为 windowed popup 内容提供显式尺寸避免不必要地阻塞输入它覆盖了需要在墨迹之上渲染内容的大部分场景ToolTips、菜单、悬浮的 InkToolbar。仓库里的真实实现InkPresenter 代理与双合成器路径设计笔记描述的方案在仓库中已经落地为可运行的 C 实现。InkCanvas的附着逻辑InkCanvas.cpp在Loaded时按窗口XamlRoot的宿主 HWND建立连接然后根据合成引擎分叉检测合成引擎IsSystemCompositor()通过CompositionEngine::GetForSystemEngine探测进程是否运行在系统合成引擎上结果按进程缓存一次InkCanvas.cpp。据此每个 InkCanvas 选择系统拼接或lifted 桥接两条路径之一。系统合成器路径splice拼接AttachToSystemCompositor()InkCanvas.cpp走的是设计笔记中IXP 提供 API 关联系统 InkPresenter 与 lifted Visual的路线具体为在共享的系统 DirectComposition 设备上创建墨迹 visual并在墨迹线程上调用IInkPresenterDesktop::SetRootVisual(rootVisual, compositionDevice)绑定 presenter通过IExpCompositorInterop2::CreateDCompVisualUnderMUCVisual在 lifted MUC visual 之下创建一个系统 DComp target把墨迹 visual 的根挂进去用ElementCompositionPreview::SetElementChildVisual把 MUC visual 挂到 InkCanvas 元素上——lifted XAML 因此能原生地对墨迹做裁剪、滚动、Z 排序。Lifted 合成器路径ContentExternalOutputLink 桥接AttachToLiftedCompositor()InkCanvas.cpp针对纯 lifted 进程使用ContentExternalOutputLink同样先创建墨迹 visual 并绑定 presenter创建一个OutputLink并调用IsAboveContent(true)让墨迹视觉层位于内容之上——这正是设计笔记 1.2 策略InkCanvas 置于所有 XAML 内容之上的实现把墨迹 visual 的根挂到 link 的 DComp target再把 link 的PlacementVisual()通过SetElementChildVisual挂进元素树PositionInkVisual()在每次LayoutUpdated时把PlacementVisual的尺寸物理像素ActualWidth/Height × RasterizationScale同步过去保证它有命中测试区域InkCanvas.cpp。湿墨→干墨提交InkCommitRequestHandler两个路径共用同一个关键机制InkCommitRequestHandlerInkPresenter.h。系统 presenter 在每次 wet→dry 转换正常干燥或自定义干燥的EndDry时回调OnCommitRequested代理据此对共享的 DComp 设备执行Commit()保证移除湿墨层 呈现新内容落在同一帧内InkCanvas.cpp。线程模型墨迹线程与 UI 线程的封送整个实现的骨架是墨迹线程/UI 线程的双线程模型InkPresenter.cpp每个 UI 线程共享一个InkDesktopHost其内部拥有专用墨迹线程由该线程上的第一个 InkCanvas 创建最后一个 InkCanvas 销毁时释放thread_local std::weak_ptrThreadDataInkCanvas.cpp异步变更通过QueueWorkItem派发到墨迹线程fire-and-forget同步 getter / Save / Load 通过事件等待阻塞等待结果且支持 COM-pumping 等待COWAIT_DISPATCH_CALLS避免 OLE 剪贴板操作回调 UI 线程时死锁InkPresenter.hStrokesCollected / StrokesErased事件在墨迹线程快照笔画后经 UI 线程 DispatcherQueue 回抛InkPresenter.h。测试与示例从 API 到真实页面仓库为该功能提供了三层验证API 测试APITests/InkCanvasTests.cs 验证 InkCanvas 构造、进入视觉树、InkPresenter访问器可调用、多实例独立性以及一组专门针对弱引用/回调生命周期的测试——覆盖 C/WinRTget_weak()对聚合对象的过度释放缺陷cppwinrt #1431通过make_weak模式规避APITests/InkToolbarTests.cs 覆盖工具栏目标绑定等行为交互测试InteractionTests/InkCanvasTests.cs 与 InkToolbar 的对应交互测试验证真实输入场景测试 UI 示例TestUI/InkCanvasPage.xaml 给出了最直接的用法——InkToolbar通过TargetInkCanvas{x:Bind TestInkCanvas}绑定到InkCanvas页面注释点明InkCanvas 开箱即自绘墨迹无需任何应用代码即可获得湿墨。总结一条从系统 XAML 到 Lifted XAML 的可行路径把设计笔记与仓库实现合起来看WinUI 3 的墨迹移植是一条清晰的推理链墨迹的输入与渲染由 DirectInk DWM 完成XAML 只负责把系统墨迹 visual 挂进合成树 喂尺寸系统 XAML 通过InkPresenterInterop::SetRootVisual直接完成这一挂接因为整棵树本来就在 DWMLifted XAML 的树在应用进程内墨迹必须存在于 DWM 可见树中的独立系统 Visual 里且不能被任何包括透明的visual 遮挡自动树展开理论上可行但 1.2 阶段既没时间实现又会因大透明表面阻塞输入最终采用务实策略InkCanvas 始终位于内容之上 windowed popup 自建 content bridge既保证墨迹独立成层又保留 ToolTips、菜单、悬浮 InkToolbar 等浮层能力仓库中的双合成器路径系统 splice / liftedContentExternalOutputLink正是这套设计在代码层面的完整实现。对开发者而言这套架构的实践含义是在 WinUI 3 中放置 InkCanvas 时应遵循置顶 用 windowed popup 承载需要浮在墨迹上的 UI的约束其余体验湿墨低延迟渲染、笔画容器、自定义干燥、工具栏配置均由框架接管你只需通过InkCanvas.InkPresenter这一个入口完成所有墨迹配置。【免费下载链接】microsoft-ui-xamlWinUI: a modern UI framework with a rich set of controls and styles to build dynamic and high-performing Windows applications.项目地址: https://gitcode.com/GitHub_Trending/mi/microsoft-ui-xaml创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价