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。

image

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="&quot;$(_ManifestToolPath)&quot; -nologo -manifest &quot;$(_WindowsAppSdkIntermediate)Manifests\app.manifest&quot; -outputresource:&quot;$(TargetDir)$(AssemblyName).exe&quot;;#1" />
    <Copy SourceFiles="@(_WindowsAppSdkRuntime)" DestinationFiles="@(_WindowsAppSdkRuntime-&gt;'$(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="&quot;$(_ManifestToolPath)&quot; -nologo -manifest &quot;$(_WindowsAppSdkIntermediate)Manifests\app.manifest&quot; -outputresource:&quot;$(PublishDir)$(AssemblyName).exe&quot;;#1" />
    <Copy SourceFiles="@(_WindowsAppSdkPublishRuntime)" DestinationFiles="@(_WindowsAppSdkPublishRuntime-&gt;'$(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 走系统键盘入口,可以作为功能对照,但不能代替图标定位检查。

image

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

image

image

image

蓝色、非活动、最大化和模拟降级图片已按本篇修订工程重新采集,来自 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 绘制、哪些部分交给系统。

参考资料

posted @ 2026-10-07 23:27  JacePan  阅读(4)  评论(0)    收藏  举报