使用 Prism 的可扩展屏幕保护程序





5.00/5 (2投票s)
在 WPF 中使用 Prism 模式编写的屏幕保护程序应用程序。
引言
本文介绍了一个使用 WPF 和 Prism 模式编写的屏幕保护程序应用程序。提供的代码是一个完整的应用程序,其中包含两种不同的动画模式,这两种模式在两个模块中实现。该应用程序是模块化的,可以通过添加新模块轻松扩展。
特点
该应用程序展示了以下功能:
- 可以作为屏幕保护程序安装
- 支持辅助显示器
- 包含两个动画模块:闪烁条纹和动画网格
- 用户可以动态切换模块
- 用户可以重新启动动画并更改模块设置
- 设置保存为 XML 文件
- 使用 log4net 库进行日志记录
背景
该解决方案使用 C#6、.NET 4.5.1、WPF(带 Prism 模式)、NuGet 包 Extended.Wpf.Toolkit、Ikc5.Prism.Settings 和 Ikc5.TypeLibrary。
屏幕保护程序
![]() | ![]() |
![]() | ![]() |
![]() | ![]() |
屏幕保护程序是一个普通的 GUI 应用程序,但具有特殊的启动方式。它应该能够接受输入参数并在三种模式下执行:显示、预览和配置。
有一些文章描述了 WPF 屏幕保护程序:
使用 Prism 模式可以创建一个模块化应用程序,其中动画模块可以轻松添加并动态切换。模块中的视图实现 IActiveAware
接口,该接口允许控制视图的活动并执行命令。模块和 WPF 应用程序具有包含大小、颜色、迭代延迟的设置。该应用程序使用 Examples of using Ikc5.Prism.Settings 中描述的 Ikc5.Prism.Settings
包,并将应用程序和模块的设置保存到 %AppData% 文件夹中的 XML 文件中。
解决方案
该解决方案具有以下结构:
- Common -
Common.Models
包含枚举、模型类和接口;Common.ViewModels
包含附加属性、转换器、样式和视图模型的层次结构 - 第一个模块 -
FirstModule.Models
包含设置类和模型类;FirstModule.Views
包含模块类、视图和视图模型 - 第二个模块 - 结构与第一个模块相同
- ScreenSaver - 主 WPF 应用程序
通用类库
动画模块基于 Grid with dynamic number of rows and columns, part 2 中描述的动态网格。该文章描述了 WPF datagrid
,其单元格具有固定的定义大小,但行数和列数动态更新以填充所有可用空间。此处及以下,我们引用该代码中的类。
Common.Models
包含 ICell
接口、Cell
和 CellSet
类。Cell
未更改,具有一个布尔属性,单元格集获得一个额外的用于迭代的 InvertPoint
方法。
public void InvertPoints(IEnumerable<Point> newPoints)
{
if (newPoints == null)
return;
foreach (var point in newPoints)
{
Cells[point.X, point.Y].State = !Cells[point.X, point.Y].State;
}
}
Common.Models
包含 IDynamicGridViewModel
和 IBaseCellViewModel
接口、设计和基视图模型。IBaseCellViewModel
由显示单元格模型的视图使用,并且可以在模块中通过颜色等附加属性进行继承和扩展。它很简单。
public interface IBaseCellViewModel
{
/// <summary>
/// Cell model.
/// </summary>
ICell Cell { get; set; }
}
IDynamicGridViewModel
接口通过 iterate
命令和 IActiveAware
接口进行了扩展。
public interface IDynamicGridViewModel<TCellViewModel> :
IActiveAware where TCellViewModel : IBaseCellViewModel
{
/// <summary>
/// Width of current view - expected to be bound to view's actual
/// width in OneWay binding.
/// </summary>
int ViewWidth { get; set; }
/// <summary>
/// Height of current view - expected to be bound to view's actual
/// height in OneWay binding.
/// </summary>
int ViewHeight { get; set; }
/// <summary>
/// Width of the cell.
/// </summary>
int CellWidth { get; set; }
/// <summary>
/// Height of the cell.
/// </summary>
int CellHeight { get; set; }
/// <summary>
/// Count of grid columns.
/// </summary>
int GridWidth { get; }
/// <summary>
/// Count of grid rows.
/// </summary>
int GridHeight { get; }
/// <summary>
/// Data model.
/// </summary>
CellSet CellSet { get; }
/// <summary>
/// 2-dimensional collections for CellViewModels.
/// </summary>
ObservableCollection<ObservableCollection<TCellViewModel>> Cells { get; }
/// <summary>
/// Iterates screen saver at one cycle.
/// </summary>
ICommand IterateCommand { get; }
/// <summary>
/// Command starts iterating.
/// </summary>
ICommand StartIteratingCommand { get; }
/// <summary>
/// Command stops iterating.
/// </summary>
ICommand StopIteratingCommand { get; }
/// <summary>
/// Set new initial set of points.
/// </summary>
ICommand RestartCommand { get; }
}
接口的实现是一个 abstract
DynamicGridViewModel
类。它保留了上述文章中的代码,实现了 IActiveAware
并通过迭代方法和命令进行了扩展。IActiveAware
接口的实现和目标在 Detecting the Active View in a Prism App 中有描述。
视图模型包含迭代计时器。
_iterateTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(IterationDelay),
};
_iterateTimer.Tick += IterateTimerTick;
当计时器嘀嗒时,调用 IterateTimerTick
方法,其中随机生成的单元格集反转状态。
private void IterateTimerTick(object sender, EventArgs e)
{
Iterate();
}
private void Iterate()
{
if (CellSet == null)
return;
using (Application.Current.Dispatcher.DisableProcessing())
{
var points = GenerateRandomPoints(11);
CellSet.InvertPoints(points);
}
}
此外,如果由于视图大小更改而重新创建单元格集,迭代计时器应暂停然后继续。因此,类包含 StartTimer
、StopTimer
、PauseIteration
和 ContinueIteration
等方法,并具有自然实现。
protected void PauseIteration()
{
if (_iterateTimer.IsEnabled)
{
_postponedTimer = true;
_iterateTimer.Stop();
}
}
protected void ContinueIteration()
{
if (_postponedTimer)
StartTimer();
}
/// <summary>
/// Start the timer for screen saver iterations. But method could have not effect
/// if cell set still waits for all necessary data for creating. Then timer will
/// start after cell set has been created.
/// </summary>
private void StartTimer()
{
if (CellSet == null || Cells == null)
_postponedTimer = true;
else
{
_iterateTimer.Start();
_postponedTimer = false;
SetCommandProviderMode(CommandProviderMode.Iterating);
}
}
/// <summary>
/// Stop iteration timer.
/// </summary>
private void StopTimer()
{
_iterateTimer.Stop();
SetCommandProviderMode(CommandProviderMode.Init);
}
模块
该应用程序包含两个模块。第一个模块显示具有两种颜色的网格单元格:如果 State
等于 true
,则为 StartColor
;否则为 FinishColor
。第二个模块显示带有渐变填充的垂直条纹,与 State
等于 true
的单元格模型对应。动画由 CellView
类实现。
<Grid x:Name="MainPanel">
<Border
BorderThickness="1"
BorderBrush="{Binding BorderColor,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type UserControl}},
Converter={StaticResource ColorToBrushConverter},
FallbackValue=#FF000000}"
Background="{Binding StartColor,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type UserControl}},
Converter={StaticResource ColorToBrushConverter},
FallbackValue=#FF40FF40}">
<Border
BorderThickness="0"
Background="{Binding FinishColor,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type UserControl}},
Converter={StaticResource ColorToBrushConverter},
FallbackValue=#FFFF4040}"
Visibility="{Binding Path=Cell.State, Mode=OneWay,
Converter={StaticResource BooleanToVisibilityConverter},
FallbackValue=Hidden}"
attached:VisibilityAnimation.AnimationType=
"{Binding Path=Settings.AnimationType, Mode=OneWay}"
attached:VisibilityAnimation.AnimationDuration=
"{Binding Path=Settings.AnimationDelay, Mode=OneWay}"/>
</Border>
</Grid>
<Grid x:Name="MainPanel">
<Border
BorderThickness="0"
Background="Transparent">
<Border
BorderThickness="1"
BorderBrush="{Binding BorderColor,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type UserControl}},
Converter={StaticResource ColorToBrushConverter},
FallbackValue=#FF000000}"
Visibility="{Binding Path=Cell.State, Mode=OneWay,
Converter={StaticResource BooleanToVisibilityConverter},
FallbackValue=Visible}"
attached:VisibilityAnimation.AnimationType=
"{Binding Path=Settings.AnimationType, Mode=OneWay}"
attached:VisibilityAnimation.AnimationDuration=
"{Binding Path=Settings.AnimationDelay, Mode=OneWay}">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="{Binding StartColor,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type UserControl}},
FallbackValue=#FF40FF40}" Offset="0"/>
<GradientStop Color="{Binding FinishColor,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type UserControl}},
FallbackValue=#FFFF4040}" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
</Border>
</Border>
</Grid>
模块以类似的方式构建,所以让我们考虑其中一个。FirstModule.Models
类库包含 ISettings
接口,该接口列出了模块的设置:颜色、大小和延迟,以及该接口的默认实现 - Settings
类。FirstModule.Views
包含 MainView
、SettingsView
和 CellView
视图以及相应的视图模型。MainView
代码与 所述文章 中描述的相同。SettingsView
在应用程序的 SettingsWindow
窗口区域中使用,并向用户提供当前设置以及更改它们的可能性。
IMainViewModel
和 ICellViewModel
接口分别派生自 IDynamicGridViewModel
和 IBaseCellViewModel
。派生接口包含 ISettings
实例,允许根据模块设置形成表示。
此外,MainViewModel
视图模型派生自 DynamicGridViewModel
,并且只实现 ISettings
属性和 PropertyChanged
事件的订阅者。
private ISettings _settings;
public ISettings Settings
{
get { return _settings; }
private set
{
var userSettings = _settings as IUserSettings;
if (userSettings != null)
userSettings.PropertyChanged -= UserSettingsOnPropertyChanged;
SetProperty(ref _settings, value);
userSettings = _settings as IUserSettings;
if (userSettings != null)
userSettings.PropertyChanged += UserSettingsOnPropertyChanged;
}
}
private void UserSettingsOnPropertyChanged(object sender, PropertyChangedEventArgs args)
{
if (CellSet == null)
return;
PauseIteration();
switch (args.PropertyName)
{
case nameof(Settings.CellWidth):
CellWidth = Settings.CellWidth;
break;
case nameof(Settings.CellHeight):
CellHeight = Settings.CellHeight;
break;
case nameof(Settings.IterationDelay):
IterationDelay = Settings.IterationDelay;
break;
default:
break;
}
ContinueIteration();
}
模块之间的相互关系
Prism 模式的原则之一是模块的独立性,但应用程序需要与模块交互。在此示例中,主窗口显示一个包含“重新启动”项的上下文菜单。它需要找到活动视图并重新启动其动画。此任务通过使用 CompositeCommand
和 DelegateCommand
来解决。
模块主视图实现了 Prism 基础设施支持的 IActiveAware
接口。然后,视图将调用传递给视图模型和命令,视图模型知道模块的活动。
通用库包含 ICommandProvider
接口
public interface ICommandProvider : INotifyPropertyChanged
{
CompositeCommand IterateCommand { get; }
CompositeCommand StartIteratingCommand { get; }
CompositeCommand StopIteratingCommand { get; }
CompositeCommand RestartCommand { get; }
}
在主应用程序中,此接口由 CommandProvider
类实现,其中复合命令是在了解活动模块的情况下创建的。
public class CommandProvider : BindableBase, ICommandProvider
{
public CompositeCommand IterateCommand { get; }
= new CompositeCommand(monitorCommandActivity: true);
public CompositeCommand StartIteratingCommand { get; }
= new CompositeCommand(monitorCommandActivity: true);
public CompositeCommand StopIteratingCommand { get; }
= new CompositeCommand(monitorCommandActivity: true);
public CompositeCommand RestartCommand { get; }
= new CompositeCommand(monitorCommandActivity: true);
}
每个模块中主视图的视图模型创建委托命令并注册它们。
// create commands
IterateCommand = new DelegateCommand(Iterate, () => CanIterate)
{ IsActive = IsActive };
StartIteratingCommand = new DelegateCommand(StartTimer, () => CanStartIterating)
{ IsActive = IsActive };
StopIteratingCommand = new DelegateCommand(StopTimer, () => CanStopIterating)
{ IsActive = IsActive };
RestartCommand = new DelegateCommand(Restart, () => CanRestart)
{ IsActive = IsActive };
// register command in composite commands
commandProvider.IterateCommand.RegisterCommand(IterateCommand);
commandProvider.StartIteratingCommand.RegisterCommand(StartIteratingCommand);
commandProvider.StopIteratingCommand.RegisterCommand(StopIteratingCommand);
commandProvider.RestartCommand.RegisterCommand(RestartCommand);
CanExecute
属性根据迭代和创建模型的不同状态进行更新。
添加新模块
如上所述,新模块可以轻松添加。应用程序向用户提供所有已注册模块的列表,并允许选择活动模块。设置窗口包含所有已注册模块的设置选项卡。因此,让我们考虑添加名为 ThirdModule
的模块的步骤:
- 创建新的类库
ThirdLibrary.Views
和ThirdLibrary.Views
,或复制SecondModule.*
库并将其中的所有文件和类从“Second
”重命名为“Third
”。 - 更新
ISettings
接口中的属性以对应模块设置;更新所有派生类,如Settings
、DesignSettings
、ISettingsViewModel
接口、SettingsViewModel
,并更新SettingsView
视图中的元素和绑定。 - 在应用程序的区域中注册模块的视图
public void Initialize() { _regionManager.RegisterViewWithRegion(PrismNames.MainRegionName, typeof(MainView)); _regionManager.RegisterViewWithRegion($"{GetType().Name} {RegionNames.ModuleSettingsRegion}", typeof(SettingsView)); }
- 在
Bootstrapper
类中注册模块protected override void ConfigureModuleCatalog() { var catalog = (ModuleCatalog)ModuleCatalog; // add all modules catalog.AddModule(typeof(FirstModule.FirstModule)); catalog.AddModule(typeof(SecondModule.SecondModule)); catalog.AddModule(typeof(ThirdModule.ThirdModule)); }
- 如果模块动画基于动态网格,则只需更新
CellView
视图;否则,需要编写MainView
和CellView
视图的代码。
屏幕保护程序应用程序
WPF 应用程序包含视图、视图模型、引导程序和应用程序类。MainWindow
和 EmptyWindow
是屏幕保护程序的主窗口。Settings
类和 SettingsWindow
在 Examples of using Ikc5.Prism.Settings 中有描述。主窗口具有占用整个空间的主区域和上下文菜单。
<Grid x:Name="MainGrid"
d:DataContext="{d:DesignInstance Type=viewModels:DesignMainWindowModel,
IsDesignTimeCreatable=True}">
<Grid.Background>
<SolidColorBrush Color="{Binding Path=Settings.BackgroundColor,
Mode=OneWay, FallbackValue=#FFC0C0C0}"/>
</Grid.Background>
<Grid.ContextMenu>
<ContextMenu>
<MenuItem Header="Restart"
Command="{Binding RestartCommand, Mode=OneWay}"
Click="MenuItem_OnClick"/>
<Separator />
<MenuItem Header="Settings"
Command="{Binding SettingsCommand, Mode=OneWay}"
Click="MenuItem_OnClick"/>
<MenuItem Header="About"
Command="{Binding AboutCommand, Mode=OneWay}"
Click="MenuItem_OnClick"/>
</ContextMenu>
</Grid.ContextMenu>
<ContentControl
regions:RegionManager.RegionName="MainRegion"/>
</Grid>
Context menu
Restart
- 使用随机活动单元格集重新启动当前视图Settings
- 显示设置窗口,允许用户在不停止屏幕保护程序的情况下更改设置About
- 显示关于对话框
根据 Prism 模式,应用程序使用 Bootstrapper
类,其中执行所有必要的初始化,OnStartup
方法通常如下所示:
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// create and launch bootstrapper
var bootstrapper = new Bootstrapper();
bootstrapper.Run();
}
对于屏幕保护程序应用程序,应在 OnStartup
方法中执行以下步骤:
- 创建引导程序,但不显示主窗口。
- 根据屏幕保护程序的启动类型设置
Shutdown
模式。 - 解析输入参数并选择应用程序的必要行为。
- 在显示模式下,为辅助显示器创建附加窗口。
- 在设置模式下,显示设置窗口并更正关闭模式。
- 在预览模式下,在 Win32 窗口中显示 WPF 窗口,并注意资源清理。
下面,我们将考虑这些步骤的实现。
初始化
主窗口在 Prism 中称为 Shell
,通常在 CreateShell
方法中创建并在 InitializeShell
方法中初始化。
protected override DependencyObject CreateShell()
{
Window mainWindow = Container.Resolve<MainWindow>();
return mainWindow;
}
protected override void InitializeShell()
{
var regionManager = Container.Resolve<IRegionManager>();
// add some views to region adapter
// ...
// show window
Application.Current.MainWindow.Show();
}
在屏幕保护程序中,主窗口应完全创建,但保持隐藏,因为屏幕保护程序可以以预览或配置模式启动。因此,InitializeShell
不会显示主窗口。
protected override void InitializeShell()
{
var regionManager = Container.Resolve<IRegionManager>();
// add some views to region adapter
// ...
// don't show window now - application may runs in settings mode
// Application.Current.MainWindow.Show();
}
如果应用程序以显示模式运行,它只需在 OnStartup
模式下显示主窗口。
Application.Current.MainWindow.Show();
输入参数
所有屏幕保护程序都需要处理以下命令行参数:
/s
– 显示屏幕保护程序/p
– 预览屏幕保护程序/c
– 配置屏幕保护程序
此外,参数可以用冒号分隔,例如:/c:1234567
或 /P:1234567
。应用程序使用以下枚举作为输入参数:
public enum LaunchType
{
[Description("No parameters")]
Default = 0,
[Description("\\s, Show the screen saver")]
Show,
[Description("\\c, Configure settings")]
Configure,
[Description("\\p, Show in preview mode")]
Preview
}
输入参数被小写,用冒号分隔,并与预期的 string
进行比较。结果设置了两个变量:启动类型和窗口描述符,后者在预览模式下使用。
var launchType = LaunchType.Default;
var previewWindowDescriptor = 0;
logger.Log($"Start parameters: {string.Join("; ", e.Args)}", Category.Info);
if (e.Args.Length > 0)
{
var firstArgument = e.Args[0].ToLower().Trim();
string secondArgument = null;
// Handle cases where arguments are separated by colon.
// Examples: /c:1234567 or /P:1234567
if (firstArgument.Length > 2)
{
secondArgument = firstArgument.Substring(3).Trim();
firstArgument = firstArgument.Substring(0, 2);
}
else if (e.Args.Length > 1)
secondArgument = e.Args[1];
if (string.Equals("/c", firstArgument))
launchType = LaunchType.Configure;
else if (string.Equals("/s", firstArgument))
launchType = LaunchType.Show;
else if (string.Equals("/p", firstArgument))
launchType = LaunchType.Preview;
if (!string.IsNullOrEmpty(secondArgument))
previewWindowDescriptor = Convert.ToInt32(secondArgument);
}
logger.Log($"Converted start parameters: launchType={launchType},
previewWindowDescriptor={previewWindowDescriptor}");
然后,使用 switch 根据启动类型提供不同的行为。
显示屏幕保护程序
由于主窗口已创建,因此可以显示它。但是,如果计算机有多个显示器,则存在问题。默认情况下,操作系统会使除主显示器之外的所有其他显示器变黑,因此只需在主屏幕上显示主窗口即可。根据应用程序设置,用户可能希望在所有显示器上显示屏幕保护程序。WPF windows on two screens 这篇文章展示了如何将 WPF 窗口定位到辅助显示器或在两个显示器上显示两个窗口。
因此,应用程序在主显示器上显示主窗口,为所有辅助显示器创建并定位 EmptyWindow
或 MainWindow
的新实例。然后将 Shutdown
模式设置为默认值。
Current.ShutdownMode = ShutdownMode.OnMainWindowClose;
配置屏幕保护程序
在此模式下,应用程序显示设置窗口。由于应用程序使用 Ikc5.Prism.Settings 包,因此它包含 SettingsWindow
,允许用户设置应用程序的设置和选项。但主窗口不会显示,应该在不关闭应用程序的情况下关闭。另一方面,如果主窗口不关闭,应用程序将继续在后台执行。这就是为什么应用程序显示 SettingsWindow
,将关闭模式设置为 ShutdownMode.OnLastWindowClose
,然后关闭主窗口。当用户关闭设置窗口时,它被视为应用程序中的最后一个窗口,应用程序退出。
var settingsWindow = bootstrapper.Container.Resolve<SettingsWindow>();
settingsWindow.Show();
Current.ShutdownMode = ShutdownMode.OnLastWindowClose;
Current.MainWindow.Close();
预览屏幕保护程序
在此模式下,屏幕保护程序显示在一个小窗口中。此模式要求应用程序应适应屏幕的小尺寸,并且 WPF 窗口显示在 Win32 窗口中。另一个问题是,有必要捕获父窗口被释放时的事件,并关闭应用程序。否则,应用程序的当前实例将继续在后台执行。必要的代码已涵盖在上述关于 WPF 屏幕保护程序的文章中,因此这里有一些稍作修饰的代码。
var mainWindow = Current.MainWindow as MainWindow;
if (mainWindow == null)
{
Current.Shutdown();
return;
}
logger.Log("Init objects for preview mode");
var pPreviewHandle = new IntPtr(previewWindowDescriptor);
var lpRect = new RECT();
var bGetRect = Win32API.GetClientRect(pPreviewHandle, ref lpRect);
var sourceParams = new HwndSourceParameters("sourceParams")
{
PositionX = 0,
PositionY = 0,
Width = lpRect.Right - lpRect.Left,
Height = lpRect.Bottom - lpRect.Top,
ParentWindow = pPreviewHandle,
WindowStyle = (int)(WindowStyles.WS_VISIBLE |
WindowStyles.WS_CHILD | WindowStyles.WS_CLIPCHILDREN)
};
logger.Log($"Source param size = ({0}, {0}, {lpRect.Right - lpRect.Left},
{lpRect.Bottom - lpRect.Top})");
_winWpfContent = new HwndSource(sourceParams)
{
RootVisual = mainWindow.MainGrid
};
// Event that triggers when parent window is disposed - used when doing
// screen saver preview, so that we know when to exit. If we didn't
// do this, Task Manager would get a new .scr instance every time
// we opened Screen Saver dialog or switched dropdown to this saver.
_winWpfContent.Disposed += (o, args) =>
{
logger.Log("_winWpfContent is Disposed, close main window and application");
mainWindow.Close();
Current.Shutdown();
};
logger.Log(
$"MainWindow is shown in preview, IsVisible={mainWindow.IsVisible},
IsActive={mainWindow.IsActive}, Owner={mainWindow.Owner?.Title}" +
$", Rect=({mainWindow.Left}, {mainWindow.Top},
{mainWindow.Width}, {mainWindow.Height})");
安装屏幕保护程序
该解决方案包含 Publish
配置,该配置在构建后步骤中将可执行文件重命名为 Ikc5.ScreenSaver.scr。
if $(ConfigurationName) NEQ Publish Exit 0
cd "$(TargetDir)"
del "$(TargetName).scr"
del "$(TargetName).scr.config"
ren "$(TargetFileName)" "$(TargetName).scr"
ren "$(TargetFileName).config" "$(TargetName).scr.config"
要安装屏幕保护程序,需要右键单击文件资源管理器中的 Ikc5 ScreenSaver.scr 文件,然后单击 Install
项。屏幕保护程序窗口允许设置超时、屏幕保护程序设置和预览。下面的图片展示了这些步骤。
![]() | ![]() |
历史
- 2017 年 1 月 28 日 - 初次发布
- 2017 年 1 月 29 日 - 更新了 zip 文件(包括来自 GIT 仓库的修复)