65.9K
CodeProject 正在变化。 阅读更多。
Home

另一个 WPF 屏幕保护程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (15投票s)

2009年12月7日

CDDL

8分钟阅读

viewsIcon

74561

downloadIcon

4357

编写 WPF 屏幕保护程序时吸取的教训。

引言

本项目旨在帮助我学习 .NET WPF 编程。屏保可以通过两种方式显示:幻灯片放映或照片拼贴。后者受到了“桌面拼贴”(来自 stardock.com 的 Deskscape)和 Picasa 的照片拼贴的启发。受 Deskscape 的启发,本程序还可以以桌面背景模式运行(但由于我的知识有限,它还不能放在桌面图标后面……)。

然而,这并不是一篇关于 WPF 操作方法的文章。我想指出一些超出了我常规理解的技术。:) 如果您有任何问题,请在评论区提问。

致谢

我正在使用 WPF 的 NotifyIcon([https://codeproject.org.cn/KB/WPF/wpf_notifyicon.aspx](https://codeproject.org.cn/KB/WPF/wpf_notifyicon.aspx))作为桌面模式下的应用程序托盘图标。感谢这个库,它真的节省了很多时间。:]

此外,也感谢这篇文章:创建一个自定义设置提供程序([https://codeproject.org.cn/KB/vb/CustomSettingsProvider.aspx](https://codeproject.org.cn/KB/vb/CustomSettingsProvider.aspx))。

应用程序功能

  • 桌面模式:将屏保作为最底层的窗口运行。
  • 屏保作为幻灯片放映
  • 屏保作为照片拼贴
    • 可以设置初始背景图片。
    • 旧照片会随着时间褪色。
  • 它有几种图片排序模式。
  • 支持四种图片集(运行时按 1-4 键切换)。
  • F2 - 切换图片文件路径、大小和文件日期。
  • F9 - 删除/移动/重命名当前显示的图片。
  • F11 - 使用当前图片的目录作为临时集合。F10 - 恢复到原始集合。
  • F12 - 显示配置对话框。
  • 不带参数运行,用于桌面模式(需要在同一目录下放置 Hardcodet.Wpf.TaskbarNotification.dll)。
  • 等等。

背景

Windows 屏保如何工作?

屏保是 MSDN 帮助/平台 SDK 中提及最少的特性之一。据我了解,自 Windows XP 以来,屏保实现已从 DLL 变为带参数的 EXE。您现在可以编写一个屏保作为 exe 文件,然后将其重命名为 .scr,复制到 %WINDIR%(例如 c:\windows),这样就安装了一个新屏保!

但是,为了正确显示预览屏幕和设置对话框,应用程序必须检查命令行参数。Windows 可以传递三种可能的选项。

/S 启动屏保
/C:99999 显示配置对话框(在父窗口之上,句柄 = 99999)
/P 99999 显示预览屏幕(在父窗口之上,句柄 = 99999)

关于代码

类图

wpfscreensaver_cd.png

ScreenSaverEngine 类是一个“半神”的控制器类。它包含了所有决定显示内容的逻辑,如 MainApp.cs 中所示。

存在三个主要概念,可以粗略解释如下:

  • 图片源是任何继承自 IPictureSource 的类。它的职责是:
    • 维护和排序图片列表。
    • 定期触发 PictureChanged 事件,该事件包含一个要显示的 ImageSource(WPF 类)。
    • 对当前图片执行文件操作(!!- 好的,不如它应有的那样内聚,但目前我不想过度复杂化项目……)
  • 幻灯片页面是任何继承自 ISlidePage 的类。它的主要职责是显示屏保内容,因此它还应该继承任何 WPF 可视化组件(我的类是 Page)。
  • 窗口宿主是 PageHost 类及其派生类。它只是一个幻灯片页面的容器。

为 WPF(或甚至是 WinForms)窗口应用 Aero 毛玻璃效果

我从《Windows Presentation Foundation: Unleased》一书中借鉴了这种技术。GlassHelper 类包含以下代码片段:

public bool ExtendGlassFrame(){
    var isVistable = Environment.OSVersion.Version.Major >= 6;
    if (!isVistable || !DwmIsCompositionEnabled())
        return false;
    var hwnd = new WindowInteropHelper(window).Handle;
    if (hwnd == IntPtr.Zero)
        throw new InvalidOperationException(
          "The Window must be shown before extending glass.");
    window.Background = Brushes.Transparent;

    var hwndSource = HwndSource.FromHwnd(hwnd);
    Debug.Assert(hwndSource != null);
    hwndSource.CompositionTarget.BackgroundColor = Colors.Transparent;

    var marginParam = margin;
    DwmExtendFrameIntoClientArea(hwnd, ref marginParam);

    hwndSource.AddHook(wndProc);

    return true;
}

IntPtr wndProc(IntPtr hwnd, int msg, IntPtr wParam, 
               IntPtr lParam, ref bool handled){
    if (msg == DwmCompositionChanged){
        ExtendGlassFrame();
        handled = true;
    }
    return IntPtr.Zero;
}
const int DwmCompositionChanged = 0x031E;
[DllImport("dwmapi.dll", PreserveSig = false)]
static extern void DwmExtendFrameIntoClientArea(IntPtr hWnd, 
                   ref Win32.MARGINS pMarInset);
[DllImport("dwmapi.dll", PreserveSig = false)]
static extern bool DwmIsCompositionEnabled();

关键在于通过 .NET 互操作使用 Vista 的桌面窗口管理器 API 将效果应用于窗口句柄。因此,该方法必须检查操作系统版本,然后才能尝试调用这些函数(Vista 是 Windows 版本 6.0,而 Windows 7 是 6.1)。

有趣的类是 WindowInteropHelper。在 WPF 中,我们使用 WPF 的 Window 类获取窗口句柄。但是,WPF 就像 WinForms 库一样,您需要执行实际的窗口操作才能创建实际的窗口句柄(例如,通过调用 Show(),或者,不确定这是否有效,调用 Hide() 方法)。

另一个有用的类是 HwndSource。这个类是 WPF 世界和 Win32 API 世界之间的桥梁。它可以接受任何窗口句柄并将其视为 WPF 窗口。它确切的工作原理对我来说仍然不清楚。:/ 但在这里,我们使用它来强制设置我们的窗口类的透明背景,这样 WPF 渲染引擎就不会在 DWM 的玻璃区域上绘制颜色。

要使用这个类,请重写 Window 类的 OnSourceInitialized 方法并像这样调用它:

protected override void OnSourceInitialized(EventArgs e) {
    base.OnSourceInitialized(e);
    glassMaker = new GlassHelper(this, new Thickness(-1));
    glassMaker.ExtendGlassFrame();
}

我无法在 MSDN 上快速找到关于 OnSourceInitialized 的有用信息。它被简要描述为与 HwndSource 类相关。我猜它会在 Window 的句柄创建时被调用。

桌面模式

目前,桌面模式的想法很简单。只需将窗口推到底层,对吗?但这并不容易。WPF 只提供顶层模式。好吧,我们可以使用 Win32 API 的 SetWindowPos() 来实现。来自 WindowExtension 类的以下代码显示了这样做有多么简单:

static public void SetBottomMost(this Window w){
    var hwnd = new WindowInteropHelper(w).Handle;
    Debug.Assert(hwnd != IntPtr.Zero);
    Win32.SetWindowPos(hwnd, Win32.HWND_BOTTOM, 0, 0, 0, 0, 
       Win32.SWP_NOSIZE | Win32.SWP_NOMOVE | Win32.SWP_NOACTIVATE);
}

我们还没做完。您会发现我们的“桌面模式”窗口就像一个普通窗口,您可以用 Alt+F4 手动切换和关闭它。这看起来不够专业,不是吗?^^; 所以这里是另一个技巧,设置 WS_EX_NOACTIVATE 标志。

static public void SetNoActivate(this Window w){
    var hwnd = new WindowInteropHelper(w).Handle;
    Debug.Assert(hwnd != IntPtr.Zero);

    var newValue = Win32.GetWindowLong(hwnd, 
        Win32.GWL_EXSTYLE)|Win32.WS_EX_NOACTIVATE;

    Win32.SetWindowLong(hwnd, Win32.GWL_EXSTYLE, newValue);
}

并且用法在 PageHost 类中:

public void SendToBottom(){
    this.SetNoActivate();
    this.SetBottomMost();
}

该标志可以防止任何窗口成为前台窗口。它还会将其从任务栏中移除。听起来不错。然而,另一个问题出现了:

当我使用关于对话框创建托盘图标时,每当托盘图标组件显示弹出菜单并关闭时,焦点就会回到我们的背景窗口并将其激活为前台窗口!这可以通过在窗口激活时调用 SendToBottom() 来快速修复,但它仍然会在短暂时间内被带到前台,并且效果很明显。

我通过在桌面模式窗口和托盘图标组件之间创建单独的线程来修复了这个问题,但由于 WPF 的 Application 类是每个应用程序域的单例,所以我不得不创建另一个单独的 AppDomain。:( 这个 appdomain 创建代码可以在 BackgroundSlideShowEngine 类中找到。

public void Start(PageHost[] slideShowList){
    var engineAssemblyPath = Assembly.GetExecutingAssembly().Location;
    Debug.Assert(engineAssemblyPath != null);
    var aboutDomain = AppDomain.CreateDomain("Background Domain");
    var helperTypeName = typeof (ForegroundDomain).FullName;
    var foregroundDomain = 
      (ForegroundDomain) aboutDomain.CreateInstanceFromAndUnwrap(
       engineAssemblyPath,helperTypeName);
    foregroundDomain.MainApplication = new SaverEngine(pictureSource, slideShowList);

    var aboutThread = new Thread(foregroundDomain.RunAbout);
    aboutThread.SetApartmentState(ApartmentState.STA);
    aboutThread.Start();

    screenSaverCheck.Start();
}

请注意,与 WinForms 一样,WPF 使用的所有线程都必须设置为单线程单元(Single Thread Apartment)。

WPF 中的位图

由于 WPF 中的大多数图形操作都基于矢量,因此只有少数类可以处理位图,其中大多数都派生自 BitmapSource 类。最实用的类是 RenderTargetBitmap,据我所知,它是唯一可以将矢量图形转换为位图的类。它在照片拼贴屏保中起主要作用,尤其是在褪色效果方面。

PhotoCollagePage 类使用 RenderTargetBitmap 类执行图形操作,并使用 WriteableBitmap 类显示背景。这个页面类重写了 ArrangeOverride(),以便在页面大小改变时调用 resetViewBitmaps() 并传入新大小。

void resetViewBitmaps(Size result) {
    viewBitmap = new RenderTargetBitmap
    (pageWidth, pageHeight, 96, 96, PixelFormats.Default);
    rebuildSurfaceBackground();
    // ...snipped...
}
void rebuildSurfaceBackground(){
    if (background != null){
        var imageRenderer = new Image
    {Source = background, Stretch = Stretch.UniformToFill};
        imageRenderer.Measure(new Size(viewBitmap.Width,viewBitmap.Height));
        imageRenderer.Arrange(new Rect(new Point(0,0), imageRenderer.DesiredSize));
        viewBitmap.Render(imageRenderer);
    }
    pageSurface.Source = currentViewBitmap = new WriteableBitmap(viewBitmap);
}
RenderTargetBitmap viewBitmap;
WriteableBitmap currentViewBitmap;
BitmapSource background;

rebuildSurfaceBackgroun() 中的代码展示了如何将位图(background)绘制到 viewBitmap 对象中。它首先创建一个“WPF 视觉”对象 Image,设置图像源和拉伸模式,然后分别调用 Measure()Arrange() 来模仿 WPF 的布局机制,最后调用 Render() 将*调整后*的 Image 对象转换为位图。

Measure()Arrange() 都是 UIElement 类的成员。通过显式调用这些方法,您可以按照所需的任何大小和位置来安排 UI 元素及其子元素,这是一种在屏幕后面绘制元素的便捷技术。:)

图片褪色

目前 WPF 中没有褪色效果,我们必须自己实现。在我看来,没有直接访问位图内存的方法(如 WinForm 的 Bitmap 类)。但是,BitmapSource 类提供了 CopyPixels() 来将位图内容复制到数组,以及 Create() 来从数组创建位图。我创建了 RawBitmap 类来封装这些想法。这个类的使用可以在 PhotoCollagePage.desaturate() 中看到。

static RawBitmap desaturate(BitmapSource bitmap){
    var raw = new RawBitmap(bitmap);
    var hsl = Hsla32.FromPbra32(raw.Data, raw.Width).Desaturate(0.87F, 1e-2F);
    return raw.CloneWithData(Rgba32.FromHsl(hsl));
}

BitmapSource 复制的像素数据的格式取决于 BitmapSource.Format 属性。为简单起见,我只使用 PixelFormats.Pbgra32,它包含 8 位 x 4 种颜色通道(红、绿、蓝、 Alpha = 不透明度)。Hsla32 类从 Pbgra32 数组创建另一个 8 位 x 4 通道的数组(色相、饱和度、亮度、 Alpha),并且它的 Desaturate() 只是根据特定因子(代码中为 0.87F)减少亮度值,然后再转换回 Pbgra32(我才注意到 Rgba32 不是正确名称……实际上应该是 Bgra32..)

这种方法效果很好,但褪色效果很明显(如果您仔细看屏幕:也许可以通过将其设置为 WPF 的 Effect 并对其应用动画来改进。)

未来改进

  • 桌面模式下托盘图标响应意外缓慢
  • 增强桌面模式,使其显示在桌面图标下方

历史

  • 2009.12.07 - 初次发布,时间有限;稍后将补充更多主题
  • 2009.12.08 - 添加主题“WPF 中的位图”
© . All rights reserved.