WPF 窗口外壳整合:标题栏、系统菜单与 DWM 边框
前四篇分别完成了边框着色、窗口状态、标题栏布局和交互排除。最后一篇把它们放进同一个独立 Demo,明确窗口、控制器与状态类各自负责什么。
这个 Demo 只包含窗口外壳,不包含设备连接、业务模型、聊天记录或接口配置。迁移到工业软件时,先让空白窗口的外壳稳定,再接业务页面,排错会容易得多。
本文整合项目名为 ChromeBlogDemo。它沿用本系列的固定 Windows App SDK 1.6.241114003 和 .NET 10。不是把第四篇的三个文件直接复制到第二篇,而是整理成一组分工明确的文件。
1. 先运行完整 Demo
准备 Windows 11、.NET 10 SDK、同架构的 .NET Desktop Runtime、含 mt.exe 的 Windows SDK。解压本篇源码后进入 ChromeBlogDemo 目录:
dotnet restore -r win-x64
dotnet build -c Release -r win-x64 --no-restore
.\bin\Release\net10.0-windows10.0.17763.0\win-x64\ChromeBlogDemo.exe
Windows ARM64 把命令及路径中的 win-x64 改成 win-arm64。启动时保留整个输出目录,不要单独复制 exe。第三篇介绍过非打包自包含载荷的部署步骤;本篇源码也带齐项目文件、manifest 和 target。

2. 文件之间的关系
| 文件 | 责任 |
|---|---|
| DemoWindow.xaml | 标题行、正文、降级 Border 和状态文字 |
| DemoWindow.xaml.cs | 配色按钮、对照窗、验收状态及模拟开关 |
| WindowChromeController | 原生标题栏初始化、拖动区、窗口事件与生命周期 |
| WindowChromeState | WPF 附加属性,供 XAML 绑定尺寸和画刷 |
| DwmBorderController | DWM 颜色请求与返回结果 |
| DragRegionGeometry | 从单行标题区域扣除交互区间 |
| NativeMethods | 原生函数声明和常量 |
配套工程共有以下文件;后面的代码按同名文件提供完整内容。先复制目录,再逐份替换,不要根据类名自行改文件夹命名空间。
ChromeBlogDemo/
├─ ChromeBlogDemo.csproj
├─ app.manifest
├─ App.xaml
├─ App.xaml.cs
├─ DemoWindow.xaml
├─ DemoWindow.xaml.cs
├─ Build/WindowsAppSdk.targets
├─ Interop/NativeMethods.cs
└─ Chrome/
├─ WindowChromeController.cs
├─ WindowChromeState.cs
├─ DwmBorderController.cs
└─ DragRegionGeometry.cs
3. 启动、部署和原生声明
App.xaml 内的按钮样式仅用于本篇演示,与标题栏原生命中测试无关。需要系统默认按钮样式时,可以移除该 Style,但保留 StartupUri。
ChromeBlogDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows10.0.17763.0</TargetFramework>
<UseWPF>true</UseWPF>
<SelfContained>false</SelfContained>
<PlatformTarget>AnyCPU</PlatformTarget>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<WindowsAppSdkUndockedRegFreeWinRTInitialize>false</WindowsAppSdkUndockedRegFreeWinRTInitialize>
<EnableCoreMrtTooling>false</EnableCoreMrtTooling>
<AppxGeneratePriEnabled>false</AppxGeneratePriEnabled>
<ApplicationManifest>app.manifest</ApplicationManifest>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-arm64</RuntimeIdentifier>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.241114003" />
</ItemGroup>
<Import Project="Build/WindowsAppSdk.targets" />
</Project>
app.manifest
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ChromeBlogDemo.app" />
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
WindowsAppSdk.targets
<Project>
<!-- 纯 WPF 已禁用 MrtCore/PRI,不需要 VS AppxPackage 任务 DLL。 -->
<Target Name="PrepareWindowsAppSdkDeployment" Condition="'$(OS)' == 'Windows_NT'">
<PropertyGroup>
<_WindowsAppSdkIntermediate>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)'))</_WindowsAppSdkIntermediate>
<_InstalledWindowsSdkVersion>$([Microsoft.Build.Utilities.ToolLocationHelper]::GetLatestSDKTargetPlatformVersion('Windows', '10.0'))</_InstalledWindowsSdkVersion>
<_ManifestToolPath Condition="Exists('$(ManifestTool)')">$(ManifestTool)</_ManifestToolPath>
<_ManifestToolPath Condition="'$(_ManifestToolPath)' == ''">$(MSBuildProgramFiles32)\Windows Kits\10\bin\$(_InstalledWindowsSdkVersion)\x86\mt.exe</_ManifestToolPath>
</PropertyGroup>
<Error Condition="!Exists('$(_ManifestToolPath)')" Text="找不到 Windows SDK Manifest Tool: $(_ManifestToolPath)" />
</Target>
<Target Name="CopyWindowsAppSdkRuntimeToBuildOutput" AfterTargets="Build" DependsOnTargets="PrepareWindowsAppSdkDeployment" Condition="'$(OS)' == 'Windows_NT'">
<ItemGroup>
<_WindowsAppSdkRuntime Include="$(_WindowsAppSdkIntermediate)MsixContent\**\*" Exclude="$(_WindowsAppSdkIntermediate)MsixContent\AppxBlockMap.xml;$(_WindowsAppSdkIntermediate)MsixContent\AppxManifest.xml;$(_WindowsAppSdkIntermediate)MsixContent\AppxSignature.p7x;$(_WindowsAppSdkIntermediate)MsixContent\[Content_Types].xml" />
</ItemGroup>
<Exec Condition="Exists('$(_WindowsAppSdkIntermediate)Manifests\app.manifest') and Exists('$(TargetDir)$(AssemblyName).exe')" Command=""$(_ManifestToolPath)" -nologo -manifest "$(_WindowsAppSdkIntermediate)Manifests\app.manifest" -outputresource:"$(TargetDir)$(AssemblyName).exe";#1" />
<Copy SourceFiles="@(_WindowsAppSdkRuntime)" DestinationFiles="@(_WindowsAppSdkRuntime->'$(TargetDir)%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
<Target Name="CopyWindowsAppSdkRuntimeToPublishOutput" AfterTargets="Publish" DependsOnTargets="PrepareWindowsAppSdkDeployment" Condition="'$(OS)' == 'Windows_NT' and ('$(PublishDir)' != '')">
<ItemGroup>
<_WindowsAppSdkPublishRuntime Include="$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)'))MsixContent\**\*" Exclude="$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)'))MsixContent\AppxBlockMap.xml;$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)'))MsixContent\AppxManifest.xml;$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)'))MsixContent\AppxSignature.p7x;$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)'))MsixContent\[Content_Types].xml" />
</ItemGroup>
<Exec Condition="Exists('$(_WindowsAppSdkIntermediate)Manifests\app.manifest') and Exists('$(PublishDir)$(AssemblyName).exe')" Command=""$(_ManifestToolPath)" -nologo -manifest "$(_WindowsAppSdkIntermediate)Manifests\app.manifest" -outputresource:"$(PublishDir)$(AssemblyName).exe";#1" />
<Copy SourceFiles="@(_WindowsAppSdkPublishRuntime)" DestinationFiles="@(_WindowsAppSdkPublishRuntime->'$(PublishDir)%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>
App.xaml
<Application x:Class="ChromeBlogDemo.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="DemoWindow.xaml">
<Application.Resources>
<SolidColorBrush x:Key="Surface" Color="#202124"/>
<Style TargetType="Button">
<Setter Property="Foreground" Value="#ECEDEF"/>
<Setter Property="Background" Value="#30343B"/>
<Setter Property="BorderBrush" Value="#4B5260"/>
<Setter Property="Padding" Value="14,8"/>
<Setter Property="Margin" Value="0,0,12,0"/>
<Setter Property="Template"><Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Surface" CornerRadius="6" BorderThickness="1" BorderBrush="{TemplateBinding BorderBrush}" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="Surface" Property="Background" Value="#424B59"/></Trigger>
<Trigger Property="IsPressed" Value="True"><Setter TargetName="Surface" Property="Background" Value="#52627B"/></Trigger>
<Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="Surface" Property="BorderBrush" Value="#96BEDB"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value></Setter>
</Style>
</Application.Resources>
</Application>
App.xaml.cs
using System.Windows;
namespace ChromeBlogDemo;
public partial class App : Application { }
NativeMethods.cs
using System.Runtime.InteropServices;
namespace ChromeBlogDemo.Interop;
internal static partial class NativeMethods
{
[StructLayout(LayoutKind.Sequential)]
internal struct ClientRect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool GetClientRect(nint hwnd, out ClientRect rect);
internal const int DwmwaBorderColor = 34;
internal const uint DwmColorNone = 0xFFFFFFFE;
[LibraryImport("Microsoft.WindowsAppRuntime.dll", EntryPoint = "WindowsAppRuntime_EnsureIsLoaded")]
internal static partial int EnsureWindowsAppRuntimeLoaded();
[LibraryImport("dwmapi.dll")]
internal static partial int DwmSetWindowAttribute(nint hwnd, int attribute, in uint value, uint valueSize);
}
4. 把运行时状态暴露给 XAML
状态类只引用 WPF 类型。标题高度和左右预留区以 DIP 暴露,内部降级描边以 Brush、Thickness 暴露。XAML 不必直接引用 AppWindow,也不会在设计器中尝试加载 Windows App Runtime。
只读附加属性的 DependencyPropertyKey 保留在本程序集内部。视图可以绑定这些值,但不会随意写入标题栏的原生测量状态。
WindowChromeState.cs
using System.Windows;
using System.Windows.Media;
namespace ChromeBlogDemo.Chrome;
// Pure WPF attached state: safe for the XAML designer; no Windows App SDK types.
public static class WindowChromeState
{
public static readonly DependencyProperty ActiveBorderColorProperty = DependencyProperty.RegisterAttached(
"ActiveBorderColor", typeof(Color), typeof(WindowChromeState), new PropertyMetadata(Color.FromRgb(0, 122, 204)));
public static readonly DependencyProperty InactiveBorderColorProperty = DependencyProperty.RegisterAttached(
"InactiveBorderColor", typeof(Color), typeof(WindowChromeState), new PropertyMetadata(Color.FromRgb(130, 130, 140)));
internal static readonly DependencyPropertyKey TitleBarHeightPropertyKey = DependencyProperty.RegisterAttachedReadOnly(
"TitleBarHeight", typeof(double), typeof(WindowChromeState), new PropertyMetadata(32d));
public static readonly DependencyProperty TitleBarHeightProperty = TitleBarHeightPropertyKey.DependencyProperty;
internal static readonly DependencyPropertyKey CaptionInsetPropertyKey = DependencyProperty.RegisterAttachedReadOnly(
"CaptionInset", typeof(double), typeof(WindowChromeState), new PropertyMetadata(138d));
public static readonly DependencyProperty CaptionInsetProperty = CaptionInsetPropertyKey.DependencyProperty;
internal static readonly DependencyPropertyKey LeftCaptionInsetPropertyKey = DependencyProperty.RegisterAttachedReadOnly(
"LeftCaptionInset", typeof(double), typeof(WindowChromeState), new PropertyMetadata(0d));
public static readonly DependencyProperty LeftCaptionInsetProperty = LeftCaptionInsetPropertyKey.DependencyProperty;
internal static readonly DependencyPropertyKey FallbackBorderBrushPropertyKey = DependencyProperty.RegisterAttachedReadOnly(
"FallbackBorderBrush", typeof(Brush), typeof(WindowChromeState), new PropertyMetadata(Brushes.Transparent));
public static readonly DependencyProperty FallbackBorderBrushProperty = FallbackBorderBrushPropertyKey.DependencyProperty;
internal static readonly DependencyPropertyKey FallbackBorderThicknessPropertyKey = DependencyProperty.RegisterAttachedReadOnly(
"FallbackBorderThickness", typeof(Thickness), typeof(WindowChromeState), new PropertyMetadata(default(Thickness)));
public static readonly DependencyProperty FallbackBorderThicknessProperty = FallbackBorderThicknessPropertyKey.DependencyProperty;
public static Color GetActiveBorderColor(DependencyObject window) => (Color)window.GetValue(ActiveBorderColorProperty);
public static void SetActiveBorderColor(DependencyObject window, Color value) => window.SetValue(ActiveBorderColorProperty, value);
public static Color GetInactiveBorderColor(DependencyObject window) => (Color)window.GetValue(InactiveBorderColorProperty);
public static void SetInactiveBorderColor(DependencyObject window, Color value) => window.SetValue(InactiveBorderColorProperty, value);
public static double GetTitleBarHeight(DependencyObject window) => (double)window.GetValue(TitleBarHeightProperty);
public static double GetCaptionInset(DependencyObject window) => (double)window.GetValue(CaptionInsetProperty);
public static double GetLeftCaptionInset(DependencyObject window) => (double)window.GetValue(LeftCaptionInsetProperty);
public static Brush GetFallbackBorderBrush(DependencyObject window) => (Brush)window.GetValue(FallbackBorderBrushProperty);
public static Thickness GetFallbackBorderThickness(DependencyObject window) => (Thickness)window.GetValue(FallbackBorderThicknessProperty);
}
5. 边框与拖动区域
DwmBorderController 返回的是本次设置调用结果。状态栏中的请求 COLORREF 是提交的参数,不能当成从 DWM 读回的实际颜色。Hide 的调用结果尚未向状态栏公开;后续接入项目日志时也应记录隐藏请求的失败。关闭后不会再向失效句柄发送恢复请求。
几何算法仍是单行横向区间相减,适合本篇的图标、输入框和按钮容器。它不覆盖任意形状的二维工具栏。
DwmBorderController.cs
using System.Windows;
using System.Windows.Media;
using ChromeBlogDemo.Interop;
namespace ChromeBlogDemo.Chrome;
internal enum BorderBackend
{
Dwm,
InWindowFallback,
Maximized
}
internal readonly record struct BorderUpdate(BorderBackend Backend, int HResult)
{
internal string Description => Backend switch
{
BorderBackend.Dwm => "Windows 11 DWM(DWMWA_BORDER_COLOR = 34)",
BorderBackend.Maximized => "最大化:DWM 外边框已隐藏",
_ when HResult != 0 => $"窗口内 1 DIP 降级边框(HRESULT 0x{HResult:X8})",
_ => "窗口内 1 DIP 降级边框(系统不支持属性 34)"
};
}
internal static class DwmBorderController
{
internal static void Hide(nint hwnd)
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000))
return;
uint hiddenColor = NativeMethods.DwmColorNone;
_ = NativeMethods.DwmSetWindowAttribute(
hwnd,
NativeMethods.DwmwaBorderColor,
in hiddenColor,
sizeof(uint));
}
internal static BorderUpdate Apply(nint hwnd, Color color, bool maximized)
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000))
return new BorderUpdate(BorderBackend.InWindowFallback, 0);
uint nativeColor = maximized
? NativeMethods.DwmColorNone
: ToColorRef(color);
var result = NativeMethods.DwmSetWindowAttribute(
hwnd,
NativeMethods.DwmwaBorderColor,
in nativeColor,
sizeof(uint));
if (result < 0)
return new BorderUpdate(BorderBackend.InWindowFallback, result);
return new BorderUpdate(
maximized ? BorderBackend.Maximized : BorderBackend.Dwm,
result);
}
private static uint ToColorRef(Color color) =>
color.R | ((uint)color.G << 8) | ((uint)color.B << 16);
}
DragRegionGeometry.cs
namespace ChromeBlogDemo.Chrome;
internal readonly record struct PixelInterval(int Start, int End);
internal static class DragRegionGeometry
{
public static PixelInterval[] Subtract(int start, int end, IEnumerable<PixelInterval> exclusions)
{
if (end <= start) return [];
var result = new List<PixelInterval>();
var cursor = start;
foreach (var interval in exclusions.OrderBy(x => x.Start))
{
var left = Math.Clamp(interval.Start, start, end);
var right = Math.Clamp(interval.End, start, end);
if (right <= left || right <= cursor) continue;
if (left > cursor) result.Add(new(cursor, left));
cursor = Math.Max(cursor, right);
}
if (cursor < end) result.Add(new(cursor, end));
return result.ToArray();
}
}
6. 控制器负责初始化与清理
本篇控制器沿用已采集效果的整合版本,并补齐第四篇的布局更新和注册生命周期。它在 SourceInitialized 时初始化标题栏,在 ContentRendered 后通过一次 250 ms 延迟开启边框。这是降低首帧视觉跳变的一种工程策略,不保证所有机器都无闪烁;若应用有耗时启动页面,应结合实际首帧验证。
第四篇已经解决的位置变化、显隐变化、重复注册和真实客户区宽度,在这里继续保留。控制器增加独立注销方法,窗口关闭时解除布局监听,并停止延迟任务。Windows App Runtime 初始化失败时保留系统标题栏,同时报告错误。模拟降级的原生调用和 WPF 描边也集中在控制器里,避免计时器与激活事件争抢显示状态。
WindowChromeController.cs
using System.Runtime.InteropServices;
using System.ComponentModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using ChromeBlogDemo.Interop;
namespace ChromeBlogDemo.Chrome;
/// <summary>Runtime controller for the existing native title bar, drag regions and DWM border.</summary>
public sealed class WindowChromeController
{
private readonly Window window;
private readonly List<FrameworkElement> interactiveElements = [];
private readonly DispatcherTimer? borderDelay;
private AppWindow? appWindow;
private nint hwnd;
private DispatcherOperation? pendingDragUpdate;
private RectInt32[] lastDragRectangles = [];
private bool firstFrameReady;
private bool closed;
private bool simulateFallback;
public string TitleInitializationStatus { get; private set; } = "等待标题栏初始化";
public string? LastDragError { get; private set; }
public bool SimulateFallback
{
get => simulateFallback;
set { if (simulateFallback == value) return; simulateFallback = value; ApplyBorder(); }
}
public WindowChromeController(Window window)
{
this.window = window;
window.WindowStyle = WindowStyle.SingleBorderWindow;
window.ResizeMode = ResizeMode.CanResize;
window.AllowsTransparency = false;
window.SourceInitialized += OnSourceInitialized;
window.DpiChanged += OnDpiChanged;
window.Closed += OnClosed;
DependencyPropertyDescriptor.FromProperty(WindowChromeState.ActiveBorderColorProperty, typeof(Window)).AddValueChanged(window, OnBorderChanged);
DependencyPropertyDescriptor.FromProperty(WindowChromeState.InactiveBorderColorProperty, typeof(Window)).AddValueChanged(window, OnBorderChanged);
borderDelay = new DispatcherTimer(TimeSpan.FromMilliseconds(250), DispatcherPriority.ApplicationIdle,
OnBorderDelay, window.Dispatcher);
borderDelay?.Stop();
window.Activated += OnBorderChanged;
window.Deactivated += OnBorderChanged;
window.StateChanged += OnBorderChanged;
window.SizeChanged += OnTitleLayoutChanged;
window.ContentRendered += OnFirstFrame;
}
public Color ActiveBorderColor { get => WindowChromeState.GetActiveBorderColor(window); set => WindowChromeState.SetActiveBorderColor(window, value); }
public Color InactiveBorderColor { get => WindowChromeState.GetInactiveBorderColor(window); set => WindowChromeState.SetInactiveBorderColor(window, value); }
internal BorderUpdate? LastBorderUpdate { get; private set; }
public double TitleBarHeight => WindowChromeState.GetTitleBarHeight(window);
public void RegisterTitleBarInteraction(FrameworkElement element)
{
if (closed) throw new ObjectDisposedException(nameof(WindowChromeController));
if (interactiveElements.Contains(element)) return;
interactiveElements.Add(element);
element.Loaded += OnTitleLayoutChanged;
element.SizeChanged += OnTitleLayoutChanged;
element.LayoutUpdated += OnTitleLayoutChanged;
element.IsVisibleChanged += OnVisibilityChanged;
ScheduleDragUpdate();
}
public void UnregisterTitleBarInteraction(FrameworkElement element)
{
if (!interactiveElements.Remove(element)) return;
element.Loaded -= OnTitleLayoutChanged;
element.SizeChanged -= OnTitleLayoutChanged;
element.LayoutUpdated -= OnTitleLayoutChanged;
element.IsVisibleChanged -= OnVisibilityChanged;
ScheduleDragUpdate();
}
private void OnVisibilityChanged(object sender, DependencyPropertyChangedEventArgs e) => ScheduleDragUpdate();
private void ResetTitleLayout()
{
window.SetValue(WindowChromeState.CaptionInsetPropertyKey, 0d);
window.SetValue(WindowChromeState.LeftCaptionInsetPropertyKey, 0d);
window.SetValue(WindowChromeState.TitleBarHeightPropertyKey, 36d);
}
private void OnSourceInitialized(object? sender, EventArgs e)
{
hwnd = new WindowInteropHelper(window).Handle;
try
{
// 按 SDK 自动初始化器的语义延迟加载;设计器只加载托管类型,不加载 Native 载荷。
Environment.SetEnvironmentVariable("MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", AppContext.BaseDirectory);
Marshal.ThrowExceptionForHR(NativeMethods.EnsureWindowsAppRuntimeLoaded());
if (HwndSource.FromHwnd(hwnd)?.CompositionTarget is { } target)
target.BackgroundColor = Color.FromRgb(32, 33, 36);
if (AppWindowTitleBar.IsCustomizationSupported())
{
appWindow = AppWindow.GetFromWindowId(Microsoft.UI.Win32Interop.GetWindowIdFromWindow(hwnd));
appWindow.Changed += OnAppWindowChanged;
var titleBar = appWindow.TitleBar;
titleBar.ExtendsContentIntoTitleBar = true;
titleBar.PreferredHeightOption = TitleBarHeightOption.Standard;
titleBar.BackgroundColor = Windows.UI.Color.FromArgb(255, 24, 24, 24);
titleBar.InactiveBackgroundColor = Windows.UI.Color.FromArgb(255, 24, 24, 24);
titleBar.ForegroundColor = Windows.UI.Color.FromArgb(255, 236, 237, 239);
titleBar.InactiveForegroundColor = Windows.UI.Color.FromArgb(255, 162, 166, 175);
titleBar.ButtonBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0);
titleBar.ButtonInactiveBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0);
titleBar.ButtonForegroundColor = Windows.UI.Color.FromArgb(255, 236, 237, 239);
titleBar.ButtonInactiveForegroundColor = Windows.UI.Color.FromArgb(255, 162, 166, 175);
titleBar.ButtonHoverBackgroundColor = Windows.UI.Color.FromArgb(255, 53, 53, 53);
titleBar.ButtonPressedBackgroundColor = Windows.UI.Color.FromArgb(255, 80, 80, 80);
TitleInitializationStatus = "系统标题栏定制已启用";
ScheduleDragUpdate();
}
else
{
// 不支持定制时保留完整系统标题栏,WPF 行仍提供菜单。
ResetTitleLayout();
TitleInitializationStatus = "系统不支持定制,保留默认标题栏";
}
}
catch (Exception error) when (error is DllNotFoundException or BadImageFormatException or COMException)
{
if (appWindow is not null)
{
appWindow.Changed -= OnAppWindowChanged;
appWindow.TitleBar.ExtendsContentIntoTitleBar = false;
appWindow = null;
}
ResetTitleLayout();
TitleInitializationStatus = $"初始化失败,保留系统标题栏:{error.GetType().Name} / 0x{error.HResult:X8}";
Trace.WriteLine(TitleInitializationStatus);
}
DwmBorderController.Hide(hwnd);
}
private void OnDpiChanged(object sender, DpiChangedEventArgs e) => ScheduleDragUpdate();
private void OnAppWindowChanged(AppWindow sender, AppWindowChangedEventArgs args) => ScheduleDragUpdate();
private void OnTitleLayoutChanged(object? sender, EventArgs e) => ScheduleDragUpdate();
private void ScheduleDragUpdate()
{
if (closed || pendingDragUpdate?.Status == DispatcherOperationStatus.Pending) return;
pendingDragUpdate = window.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(UpdateDragRectangles));
}
private void UpdateDragRectangles()
{
if (closed || appWindow is null || !window.IsLoaded || window.ActualWidth <= 0) return;
var titleBar = appWindow.TitleBar;
var dpi = VisualTreeHelper.GetDpi(window);
window.SetValue(WindowChromeState.TitleBarHeightPropertyKey, titleBar.Height / dpi.DpiScaleY);
window.SetValue(WindowChromeState.CaptionInsetPropertyKey, titleBar.RightInset / dpi.DpiScaleX);
window.SetValue(WindowChromeState.LeftCaptionInsetPropertyKey, titleBar.LeftInset / dpi.DpiScaleX);
if (!NativeMethods.GetClientRect(hwnd, out var client)) return;
var width = client.Right - client.Left;
var height = titleBar.Height;
if (height <= 0) return;
var exclusions = new List<PixelInterval>(interactiveElements.Count);
foreach (var element in interactiveElements)
{
if (!element.IsLoaded || !element.IsVisible) continue;
var origin = element.TranslatePoint(new Point(), window);
exclusions.Add(new((int)Math.Floor(origin.X * dpi.DpiScaleX),
(int)Math.Ceiling((origin.X + element.ActualWidth) * dpi.DpiScaleX)));
}
var intervals = DragRegionGeometry.Subtract(titleBar.LeftInset, width - titleBar.RightInset, exclusions);
var rectangles = intervals.Select(x => new RectInt32(x.Start, 0, x.End - x.Start, height)).ToArray();
if (lastDragRectangles.SequenceEqual(rectangles)) return;
try
{
titleBar.SetDragRectangles(rectangles); // 空集合也必须提交,清除旧的拖动区。
lastDragRectangles = rectangles;
LastDragError = null;
}
catch (Exception error) when (error is ArgumentException or COMException)
{
LastDragError = $"拖动区更新失败:0x{error.HResult:X8}";
Trace.WriteLine(LastDragError);
// 保留上次区域;后续布局事件重试。
}
}
public void ShowSystemMenu(Point clientLocation)
{
var screen = window.PointToScreen(clientLocation);
var dpi = VisualTreeHelper.GetDpi(window);
SystemCommands.ShowSystemMenu(window, new(screen.X / dpi.DpiScaleX, screen.Y / dpi.DpiScaleY));
}
private void OnBorderChanged(object? sender, EventArgs e) => ApplyBorder();
private void OnFirstFrame(object? sender, EventArgs e)
{
window.ContentRendered -= OnFirstFrame;
borderDelay?.Start();
}
private void OnBorderDelay(object? sender, EventArgs e)
{
borderDelay?.Stop();
firstFrameReady = true;
ApplyBorder();
}
private void ApplyBorder()
{
if (hwnd == 0 || closed) return;
if (!firstFrameReady) { DwmBorderController.Hide(hwnd); return; }
var color = window.IsActive ? ActiveBorderColor : InactiveBorderColor;
bool normal = window.WindowState == WindowState.Normal;
BorderUpdate result;
if (simulateFallback)
{
DwmBorderController.Hide(hwnd);
result = new BorderUpdate(normal ? BorderBackend.InWindowFallback : BorderBackend.Maximized, 0);
}
else result = DwmBorderController.Apply(hwnd, color, !normal);
LastBorderUpdate = result;
var fallback = result.Backend == BorderBackend.InWindowFallback && window.WindowState == WindowState.Normal;
Brush brush = Brushes.Transparent;
if (fallback) { var solid = new SolidColorBrush(color); solid.Freeze(); brush = solid; }
window.SetValue(WindowChromeState.FallbackBorderBrushPropertyKey, brush);
window.SetValue(WindowChromeState.FallbackBorderThicknessPropertyKey, fallback ? new Thickness(1) : default(Thickness));
}
private void OnClosed(object? sender, EventArgs e)
{
window.SourceInitialized -= OnSourceInitialized;
window.DpiChanged -= OnDpiChanged;
window.Closed -= OnClosed;
DependencyPropertyDescriptor.FromProperty(WindowChromeState.ActiveBorderColorProperty, typeof(Window)).RemoveValueChanged(window, OnBorderChanged);
DependencyPropertyDescriptor.FromProperty(WindowChromeState.InactiveBorderColorProperty, typeof(Window)).RemoveValueChanged(window, OnBorderChanged);
closed = true;
pendingDragUpdate?.Abort();
borderDelay?.Stop();
if (borderDelay is not null) borderDelay.Tick -= OnBorderDelay;
// Closed 之后不再向可能已销毁的 HWND 提交恢复请求。
hwnd = 0;
if (appWindow is not null) appWindow.Changed -= OnAppWindowChanged;
window.Activated -= OnBorderChanged;
window.Deactivated -= OnBorderChanged;
window.StateChanged -= OnBorderChanged;
window.SizeChanged -= OnTitleLayoutChanged;
window.ContentRendered -= OnFirstFrame;
foreach (var element in interactiveElements)
{
element.Loaded -= OnTitleLayoutChanged;
element.SizeChanged -= OnTitleLayoutChanged;
element.LayoutUpdated -= OnTitleLayoutChanged;
element.IsVisibleChanged -= OnVisibilityChanged;
}
interactiveElements.Clear();
appWindow = null;
}
}
7. 窗口视图与按钮事件
XAML 外层 Border 绑定降级画刷与厚度。标题行绑定动态高度,右侧列绑定 CaptionInset,左侧也保留系统区域。图标、输入框、配色按钮容器需要分别注册为交互元素。
构造函数先 InitializeComponent,再判断设计模式。判断放在前面会让设计器连控件都没有;放在后面意味着 XAML 引用的类型必须可以在设计器中加载,所以状态类只依赖 WPF。
DemoWindow.xaml
<Window x:Class="ChromeBlogDemo.DemoWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:chrome="clr-namespace:ChromeBlogDemo.Chrome"
Title="Window Chrome / DWM Border Demo" Width="1000" Height="740"
MinWidth="820" MinHeight="640" WindowStartupLocation="CenterScreen"
Background="#202124" Foreground="#ECEDEF" FontFamily="Segoe UI, Microsoft YaHei UI"
FontSize="14" UseLayoutRounding="True" SnapsToDevicePixels="True">
<Border BorderBrush="{Binding Path=(chrome:WindowChromeState.FallbackBorderBrush), RelativeSource={RelativeSource AncestorType=Window}}"
BorderThickness="{Binding Path=(chrome:WindowChromeState.FallbackBorderThickness), RelativeSource={RelativeSource AncestorType=Window}}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="{Binding Path=(chrome:WindowChromeState.TitleBarHeight), RelativeSource={RelativeSource AncestorType=Window}}"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Background="#181818">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{Binding Path=(chrome:WindowChromeState.LeftCaptionInset), RelativeSource={RelativeSource AncestorType=Window}}"/>
<ColumnDefinition Width="44"/><ColumnDefinition Width="*"/>
<ColumnDefinition Width="170"/><ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="{Binding Path=(chrome:WindowChromeState.CaptionInset), RelativeSource={RelativeSource AncestorType=Window}}"/>
</Grid.ColumnDefinitions>
<Button x:Name="TitleIcon" Grid.Column="1" Content="W" Padding="0" Margin="8,4" ToolTip="系统菜单" Click="ShowSystemMenu"/>
<TextBlock Grid.Column="2" Text="WPF · WINDOW CHROME" VerticalAlignment="Center" Margin="12,0" FontSize="11" TextTrimming="CharacterEllipsis"/>
<TextBox x:Name="TitleSearch" Grid.Column="3" Text="标题栏可输入" Margin="0,5,10,5" Background="#30343B" Foreground="#ECEDEF" BorderBrush="#4B5260" FontSize="11" VerticalContentAlignment="Center" Padding="5,0"/>
<StackPanel x:Name="TitleActions" Grid.Column="4" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="蓝色" Click="UseBlue" Margin="0,4,5,4" Padding="8,0" FontSize="11"/>
<Button Content="紫色" Click="UsePurple" Margin="0,4,8,4" Padding="8,0" FontSize="11"/>
</StackPanel>
</Grid>
<Grid Grid.Row="1" Margin="34,30,34,28">
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
<StackPanel>
<TextBlock Text="WPF 原生标题栏与 DWM 边框" FontSize="27" FontWeight="SemiBold"/>
<TextBlock Text="保留系统窗口行为,定制标题内容与活动边框。" Foreground="#A8B0BE" Margin="0,12,0,0" TextWrapping="Wrap"/>
</StackPanel>
<WrapPanel Grid.Row="1" Margin="0,26,0,24">
<Button Content="蓝色边框" Click="UseBlue"/>
<Button Content="紫色边框" Click="UsePurple"/>
<Button Content="打开非活动对照窗" Click="OpenPeer"/>
<Button Content="最大化 / 还原" Click="ToggleMaximize"/>
</WrapPanel>
<Border Grid.Row="2" Background="#292D34" BorderBrush="#3B4550" BorderThickness="1" CornerRadius="10" Padding="24">
<ScrollViewer VerticalScrollBarVisibility="Auto"><StackPanel>
<TextBlock Text="交互验证" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,16"/>
<TextBlock Text="1. 拖动标题文字附近的空白;双击空白最大化或还原。" Foreground="#CAD1DD" Margin="0,0,0,12" TextWrapping="Wrap"/>
<TextBlock Text="2. 点击标题栏蓝色 / 紫色按钮;输入框应能获得键盘焦点。" Foreground="#CAD1DD" Margin="0,0,0,12" TextWrapping="Wrap"/>
<TextBlock Text="3. 点击 W 或按 Alt+Space 查看系统菜单。" Foreground="#CAD1DD" Margin="0,0,0,12" TextWrapping="Wrap"/>
<TextBlock Text="4. 激活对照窗,观察主窗细边框切换为非活动颜色。" Foreground="#CAD1DD" Margin="0,0,0,18" TextWrapping="Wrap"/>
<CheckBox x:Name="FallbackSimulation" Content="模拟降级:隐藏 DWM 边框,显示窗口内 1 DIP 描边" Foreground="#ECEDEF" Checked="ChangeSimulation" Unchecked="ChangeSimulation"/>
<TextBlock Text="这是 Windows 11 上的演示开关,不代表已在 Windows 10 实测。" Foreground="#939DAE" FontSize="12" Margin="22,8,0,0" TextWrapping="Wrap"/>
</StackPanel></ScrollViewer>
</Border>
<StackPanel Grid.Row="3" Margin="0,20,0,0">
<TextBlock x:Name="BackendInfo" Foreground="#96BEDB" FontFamily="Consolas" FontSize="12" TextWrapping="Wrap"/>
<TextBlock x:Name="GeometryInfo" Foreground="#A8B0BE" FontFamily="Consolas" FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap"/>
</StackPanel>
</Grid>
</Grid>
</Border>
</Window>
DemoWindow.xaml.cs
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
using ChromeBlogDemo.Chrome;
namespace ChromeBlogDemo;
public partial class DemoWindow : Window
{
private readonly WindowChromeController chrome = null!;
private readonly DispatcherTimer? statusTimer;
private Window? peer;
public DemoWindow()
{
InitializeComponent();
if (DesignerProperties.GetIsInDesignMode(this)) return;
chrome = new WindowChromeController(this);
chrome.RegisterTitleBarInteraction(TitleIcon);
chrome.RegisterTitleBarInteraction(TitleSearch);
chrome.RegisterTitleBarInteraction(TitleActions);
UseBlue(this, new RoutedEventArgs());
statusTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(350), DispatcherPriority.Background,
(_, _) => RefreshStatus(), Dispatcher);
Closed += (_, _) => { statusTimer?.Stop(); peer?.Close(); };
}
private void UseBlue(object sender, RoutedEventArgs e)
{
if (chrome is null) return;
chrome.ActiveBorderColor = Color.FromRgb(0, 122, 204);
chrome.InactiveBorderColor = Color.FromRgb(87, 87, 87);
RefreshStatus();
}
private void UsePurple(object sender, RoutedEventArgs e)
{
if (chrome is null) return;
chrome.ActiveBorderColor = Color.FromRgb(155, 114, 207);
chrome.InactiveBorderColor = Color.FromRgb(85, 80, 91);
RefreshStatus();
}
private void ShowSystemMenu(object sender, RoutedEventArgs e) =>
chrome.ShowSystemMenu(new Point(8, chrome.TitleBarHeight));
private void ToggleMaximize(object sender, RoutedEventArgs e) =>
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
private void OpenPeer(object sender, RoutedEventArgs e)
{
if (peer is not null) { peer.Activate(); return; }
peer = new Window
{
Title = "非活动状态对照窗", Width = 360, Height = 220,
Left = Math.Min(Left + Width + 45, SystemParameters.WorkArea.Right - 360), Top = Top + 120,
Background = new SolidColorBrush(Color.FromRgb(36, 39, 45)),
Foreground = Brushes.White,
Content = new System.Windows.Controls.TextBlock
{
Text = "点击此窗,使主窗口失去焦点。\n主窗口边框应变为非活动颜色。",
Margin = new Thickness(26), TextWrapping = TextWrapping.Wrap, FontSize = 16
}
};
peer.Closed += (_, _) => peer = null;
peer.Show();
}
private void ChangeSimulation(object sender, RoutedEventArgs e)
{
if (chrome is null) return;
chrome.SimulateFallback = FallbackSimulation.IsChecked == true;
RefreshStatus();
}
private void RefreshStatus()
{
if (chrome is null || !IsLoaded) return;
bool simulate = chrome.SimulateFallback;
var update = chrome.LastBorderUpdate;
var requested = IsActive ? chrome.ActiveBorderColor : chrome.InactiveBorderColor;
uint borderColor = WindowState == WindowState.Maximized || simulate ? 0xFFFFFFFEu :
requested.R | ((uint)requested.G << 8) | ((uint)requested.B << 16);
string callResult = simulate ? "SIMULATION" : update is { } result ? $"{result.Backend} / HRESULT=0x{result.HResult:X8}" : "PENDING";
string backend = simulate ? "SIMULATED FALLBACK / 1 DIP" :
WindowState == WindowState.Maximized ? "DWM / MAXIMIZED" :
WindowChromeState.GetFallbackBorderThickness(this).Left > 0 ? "IN-WINDOW FALLBACK" : "DWM BORDER";
BackendInfo.Text = $"{backend} | Active={IsActive} | State={WindowState} | Set={callResult} | Requested COLORREF=0x{borderColor:X8}";
var dpi = VisualTreeHelper.GetDpi(this);
GeometryInfo.Text = $"{chrome.TitleInitializationStatus} | {chrome.LastDragError} | DPI scale={dpi.DpiScaleX:F2} | TitleBar={chrome.TitleBarHeight:F1} DIP | CaptionInset={WindowChromeState.GetCaptionInset(this):F1} DIP | OS={Environment.OSVersion.Version} | {RuntimeInformation.ProcessArchitecture}";
}
}
8. 系统菜单的坐标
点击 W 图标会调用 ShowSystemMenu。传入的点来自窗口客户区 DIP,PointToScreen 转换成屏幕像素,再按当前窗口 DPI 比例换算成 SystemCommands 接收的逻辑坐标。
系统菜单的存在不代表坐标在所有混合 DPI 桌面都已验证。移动到副屏后要检查菜单是否仍出现在图标附近。Alt+Space 走系统键盘入口,可以作为功能对照,但不能代替图标定位检查。

9. 按窗口状态验证整合结果



蓝色、非活动、最大化和模拟降级图片已按本篇修订工程重新采集,来自 Windows 11 ARM64、200% 缩放。系统菜单图来自同布局的先前实拍。四个独立工程均已完成 Windows 构建与运行验收;模拟降级仍不证明 Windows 10 或真实失败分支已经通过。
| 步骤 | 检查点 |
|---|---|
| 蓝色、紫色切换 | 请求值与活动颜色对应,真实 setter HRESULT 为 0 |
| 输入框输入与拖选 | 窗口不跟随拖动 |
| 标题文字拖动和双击 | 移动、最大化、还原正常 |
| 点击图标与 Alt+Space | 系统菜单内容可用,图标入口位置正确 |
| 打开对照窗 | 主窗失焦配色正确,重新激活恢复原配色 |
| 最大化后还原 | 外边框与内部降级厚度符合规则 |
| 模拟开关开启与关闭 | 状态明确标为模拟,取消后恢复原生分支 |
| 退出与重新打开 | 无残留窗口、定时器或旧状态 |
Snap 悬停、多屏不同 DPI、高对比度、远程桌面与旧版 Windows 都需要在目标设备补测。一次 ARM64 虚拟机截图不能替代设备工作站的兼容性验收。
10. 从 Demo 迁移到实际项目
先建一个不含业务依赖的普通 Window,把项目部署、标题栏和边框运行通。然后把业务 UserControl 放入正文区域。窗口控制器不应负责串口、相机、运动控制或采集任务;关闭外壳时解绑 UI 事件,与停止设备任务是两个不同层次。
本篇已完成去重、独立注销、LayoutUpdated/IsVisibleChanged 监听、GetClientRect 宽度以及空矩形提交。窗口关闭后不再尝试恢复无效 HWND。迁入动态页面时,新控件调用 RegisterTitleBarInteraction;移除前调用 UnregisterTitleBarInteraction,不要等整个窗口关闭才清理。
剩余的生产环境工作主要是:把诊断接入项目日志系统,验证系统菜单在混合 DPI 下的位置,为运行时部署准备安装与升级方案,并在目标设备检查高对比度、远程桌面、Snap 和多屏行为。Windows App SDK 升级也应作为单独任务,不要与业务页面替换同时进行。
模拟开关只改变控制器的 SimulateFallback;状态计时器只负责显示当前结果,不再写 Border 或反复调用 DWM。这样失焦和最大化时,模拟分支也走同一个更新入口。
第三、四篇的项目名是 ChromeTutorial,本篇是 ChromeBlogDemo。最稳妥的整合方法是打开本篇完整工程,再把业务 UserControl 放入正文。若手动迁移,按下表统一名称,不要让 XAML 的 x:Class 与后台命名空间混在两个工程之间。
| 前面示例 | 整合工程 |
|---|---|
| MainWindow.xaml / MainWindow | DemoWindow.xaml / DemoWindow |
| TitleBarController | Chrome/WindowChromeController |
| 标题 RowDefinition 与 ColumnDefinition 直接更新 | WindowChromeState 附加属性绑定 |
| RefreshBorder | DwmBorderController + 控制器 ApplyBorder |
| NativeMethods | Interop/NativeMethods |
完成这个系列后,可以在不改动系统窗口按钮的前提下定制标题内容,让窗口边框跟随状态,并明确知道哪些部分由 WPF 绘制、哪些部分交给系统。

这个 Demo 只包含窗口外壳,不包含设备连接、业务模型、聊天记录或接口配置。迁移到工业软件时,先让空白窗口的外壳稳定,再接业务页面,排错会容易得多。
浙公网安备 33010602011771号