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

Catel - 第 5 部分 (共 n 部分):在 1 小时内构建一个 Catel WPF 示例应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (20投票s)

2011年1月28日

CPOL

27分钟阅读

viewsIcon

110640

downloadIcon

3519

本文介绍了如何使用 Catel 编写一个真实世界的应用程序。

Catel 是一个全新的框架(或者说企业级库,随你喜欢),它提供数据处理、诊断、日志记录、WPF 控件以及一个 MVVM 框架。因此,Catel 不仅仅是“又一个”MVVM 框架或一些可以使用的不错的扩展方法。它更像是一个你希望在不久的将来开发的所有 (WPF) 应用程序中都包含的库。

本文介绍了如何使用 Catel 为 MVVM 编写单元测试。

文章浏览器

目录

  1. 引言
  2. 功能需求
    1. 内容提供者
    2. 用户界面
  3. 创建项目
    1. 已处理功能
    2. 目录结构
    3. 创建项目
      1. SocialMediaStream
      2. SocialMediaStream.Library
      3. SocialMediaStream.Providers.Facebook
      4. SocialMediaStream.Providers.Twitter
      5. SocialMediaStream.Test
    4. 启用日志
    5. 设置主题和样式
  4. 创建模型
    1. 已处理功能
    2. 接口
      1. ISocialMediaEntry
      2. ISocialMediaProvider
      3. ISocialMediaProviderFactory
    3. 基础提供者
    4. Twitter 和 Facebook 提供者
    5. SocialMediaProviderFactory
  5. 创建视图模型
    1. 已处理功能
    2. SocialMediaEntryViewModel
    3. MainWindowViewModel
  6. 创建视图
    1. 已处理功能
    2. SocialMediaEntry
    3. MainWindow
    4. 设置 IoC 容器
    5. 用于额外调试信息的 TraceOutputWindow
    6. 动态主题切换
  7. 结论

引言

欢迎来到 Catel 系列文章的第 5 部分。本文介绍了如何使用 Catel 编写一个真实世界的应用程序。如果您还没有阅读 Catel 的前几篇文章,建议您阅读。它们都有编号,所以查找起来应该不难。

本文的目标如下图所示

从想法到应用

哦天哪,标题是不是夸大其词了?我不这么认为!本文将向您展示如何使用 Catel 编写应用程序。本文包括在以下领域使用 Catel:

  • 数据处理

    模型使用 DataObjectBase 编写,并包含验证以确保不会保存无效的族(族怎么会无效呢,这是另一个问题,以后再说)。该示例还开箱即用地使用了序列化来从磁盘加载/保存数据。

  • WPF 控件

    该应用程序是使用 Catel 库中的控件构建的。它包括弹出窗口、控件和错误处理。

  • MVVM

    该应用程序使用了 Catel 附带的 MVVM 框架。在此应用程序中,该框架的真正强大之处变得显而易见。此示例使用了视图模型、服务、嵌套控件、验证等。此外,视图模型与智能模型在后台结合的强大功能也将在本文中展现出来。

  • 单元测试

    单元测试对于提高软件质量非常重要。MVVM 模式最强大的优点之一是它可以与单元测试完美结合,在本文中,我们将看到如何实现。

本文的每个部分都逐步解释了要做什么,但也解释了在特定步骤中使用了 Catel 的哪些部分。如果需要,可以跳过每个步骤(例如,如果您只对如何创建视图模型感兴趣)。这样,您可以完全专注于框架中您需要的点。

本文将使用 MVVM 模式实现应用程序。如果您不知道 MVVM 模式是什么,或者您还不熟悉它,请阅读这篇关于 MVVM 的非常简短的介绍。还建议您首先阅读 Catel 的其他文章,但如果您足够聪明,应该只通过查看此示例应用程序就能掌握该框架。

在本文中,我们将首先在 Visual Studio 中创建项目。然后,我们将从头开始构建应用程序,从模型(数据对象本身)开始。在模型之后,将创建视图模型(我们将如何向用户展示模型)。最后,我们将创建视图,这将是用户实际看到的 UI。在实际生活中,如果幸运的话,有 GUI 设计师的项目中,设计师会根据视图模型完成最后一步。

此示例不包括编写单元测试,因为在包含单元测试的情况下在一小时内编写整个应用程序是不公平的。但是,MVVM 框架使您能够随时编写单元测试。此外,由于我不是设计师,我不想在 UI 本身投入太多时间。因此,应用程序可能看起来有点迟钝,但是,嘿,所有真实世界的应用程序不都有些迟钝吗?

使用 Catel 时,使用提供的代码片段非常重要。首先,它为您节省了大量时间,而且还可以避免错误。我见过太多的开发人员复制/粘贴依赖项属性,却忘记更改属性的所有者。这将导致非常奇怪和不可预测的错误,因为只有当您两次命中同一所有者上注册依赖项属性的两个控件时,才会收到错误。Catel 的一个优点是它会自动确定所有者,因此使用 Catel 时不会出现依赖项属性错误。尽管如此,使用代码片段正确实现使用 Catel 的代码仍然非常重要。

2. 功能需求

与每个项目一样,我们需要从功能需求开始。本文的功能需求是制作一个时髦但功能齐全且不太复杂的应用程序。这让我们思考:什么是时髦,但又相当容易构建和理解。答案是社交媒体。也许你会想:不,不是又一个社交媒体应用程序。但是,你不必使用或购买此应用程序,它仅用于演示目的,所以如果你不喜欢这个概念,只需将其作为一个学习点即可。

我有一部带有 Android 的 HTC Desire 手机,它有一个朋友动态小部件。这是一个非常酷的小部件,可以让我及时了解 Twitter 和 Facebook 的动态。我想将此应用程序用作社交媒体应如何实现的良好示例。我不想让它功能太全,那会偏离本文的实际目的。

2.1. 内容提供者

为了支持多个社交媒体网站,必须创建一个内容提供者。我们将创建一个接口(或基类),每个社交网络的每个内容提供者都需要提供该接口。

2.2. 用户界面

接下来是一些关于 UI 最终应如何呈现的草图,但如果我不能让它看起来漂亮,请不要责怪我。

消息应按日期/时间排序,最新的在顶部。

3. 创建项目

我们将真正从零开始。我们将创建解决方案和项目。在本部分中,将创建正确的项目类型,并将库链接到项目。包含这些步骤的原因是为了展示将第三方库包含到解决方案中并保持解决方案组织良好和整洁的推荐方法。

3.1. 已处理功能

  1. log4net(日志);
  2. 主题和样式。

3.2. 目录结构

在组织项目时保持一致性非常重要。多年来,我发现了一种我非常喜欢的结构,因为它让我(以及所有使用我开发的开源软件的人)对包中包含的内容有一个非常清晰的概述。下面是目录结构的屏幕截图

  • 设计:包含项目的设计,例如徽标文件、图标等。我喜欢将它们与软件项目放在一起,以确保所有内容都保存在源代码仓库中。
  • 文档:包含项目的文档。这可以像一个简单的 readme.txthistory.txt 一样,也可以包括功能和技术设计。
  • :将项目中使用的库保存在同一目录中是一个好习惯。这样,您可以简单地用新版本替换库,并一次性更新所有项目,而不是到处乱放文件。
  • 输出:输出文件夹不是您创建的。如果您为项目正确设置了输出目录(发布版本为“..\..\output\Release\”,调试版本为“..\..\output\Debug\”),Visual Studio 或编译器将创建这些目录。但很高兴不必深入到每个项目的 bin 目录中,而是所有文件都写入相同的输出目录。
  • :包含源代码,即解决方案、项目文件和实际源文件。

3.3. 创建项目

创建可维护和可测试的项目非常重要。因此,我们希望确保应用程序使用“关注点分离”(SoC)。MVVM 以其自身的方式也是 SoC 的实现,即通过将 UI、UI 逻辑和业务逻辑彼此分离。

为了使该项目遵循 SoC 规则,我们需要创建以下项目结构。它被分成单独的文件夹,因为我喜欢保持事情简单明了。当项目变得非常大时(考虑一个解决方案中包含 20-25 个项目),这可能会派上用场。首先,让我们看看项目结构是什么样子的;之后,我可以告诉您每个项目的含义。

3.3.1. SocialMediaStream

这是主应用程序。它是一个 WPF 应用程序(.NET 3.5 SP1),并设置为启动项目,因此每次我们调试应用程序时都会启动它。该项目包含 UI,但也包含视图模型,因为视图模型定义了 UI 的行为。

该项目还将负责通过控制反转“耦合”所有行为。

3.3.2. SocialMediaStream.Library

这个 .NET 类库是应用程序的 SDK。希望为应用程序编写提供程序的开发人员将需要此库。它包含接口定义和一些“便利”类,例如 ISocialMediaProvider 接口的基本实现。

3.3.3. SocialMediaStream.Providers.Facebook

这个 .NET 类库负责提供 Facebook 实现。

3.3.4. SocialMediaStream.Providers.Twitter

这个 .NET 类库负责提供 Twitter 实现。

3.3.5. SocialMediaStream.Test

这个单元测试项目包含为应用程序编写的所有单元测试。编写单元测试超出了本文的范围,但由于我需要它们来确保应用程序相对稳定,我认为包含它们不会损害任何人。

3.4. 启用日志

我们希望能够在将软件部署到所有客户端后也能看到实际发生了什么。因此,日志记录非常重要。Catel 使用 log4net,甚至提供额外的扩展方法,使其更容易记录异常和格式化的消息。

log4net 的使用非常简单,可以通过配置完成。下面是一个配置示例

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="log4net" 
      type="log4net.Config.Log4NetConfigurationSectionHandler, log4net"/>
  </configSections>

  <log4net>
    <!-- Rolling file (actual log file) -->
    <appender name="RollingFileAppender" 
         type="log4net.Appender.RollingFileAppender">
      <file 
         value="${ALLUSERSPROFILE}\Catel\SocialMediaStream\SocialMediaStream.log"/>
      <appendToFile value="true"/>
      <maxSizeRollBackups value="20"/>
      <maximumFileSize value="10MB"/>
      <rollingStyle value="Size"/>
      <staticLogFileName value="true"/>
      <layout type="log4net.Layout.PatternLayout">
        <header value="[Application start]
"/>
        <footer value="[Application end]
"/>
        <conversionPattern value="%date - %-5level - %logger - %message%newline"/>
      </layout>
    </appender>

    <!-- Trace -->
    <appender name="TraceAppender" 
         type="log4net.Appender.TraceAppenderEx, Catel.Core">
      <layout type="log4net.Layout.PatternLayout">
        <conversionPattern value="%date - %message%newline"/>
      </layout>
    </appender>

    <!-- Setup the root category, add the appenders and set the default level -->
    <root>
      <!-- Level can be FATAL, ERROR, WARN, INFO, DEBUG, ALL -->
      <level value="ALL"/>
      <appender-ref ref="RollingFileAppender"/>
      <appender-ref ref="TraceAppender"/>
    </root>
  </log4net>

</configuration>

为了确保 log4net 读取配置,请将 XmlConfigurator 属性添加到您的程序集信息中

[assembly: log4net.Config.XmlConfigurator(Watch = true)]

3.5. 设置主题和样式

要设置主题,我们需要做两件事。首先,让我们将主题添加到应用程序字典中

<Application x:Class="SocialMediaStream.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="/UI/Windows/MainWindow.xaml">
    <Application.Resources>
        <ResourceDictionary Source="/Catel.Windows;component/themes/generic.xaml" />
    </Application.Resources>
</Application>

Catel 的一个优点是它能够修复用户控件的边距。这样,您不再需要担心为用户控件设置边距以纠正间距。负责此行为的 StyleHelper 类的使用非常简单

/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
    protected override void OnStartup(StartupEventArgs e)
    {
        StyleHelper.CreateStyleForwardersForDefaultStyles();

        base.OnStartup(e);
    }
}

我们将在本文稍后实现动态主题切换,因为这是要做的最后一件事之一。

4. 创建模型

创建模型很重要。在我们的案例中,我们将创建可保存的模型。在大多数业务应用程序中,会使用 ORM 映射器(如 Entity FrameworkNHibernateLLBLGen Pro 等),但我们的示例中不需要包含数据库,并且只会分散软件核心功能的注意力。一个不错的副作用是 DataObjectBase 类(所有类中最重要的类)用于模型,因此此示例很好地展示了该类的功能。但是,我想确保提及 Catel 完全兼容任何模型实现,只要模型实现 INotifyPropertyChanged 接口,如果它没有实现,甚至不应该称之为模型。

4.1. 已处理功能

  • DataObjectBase (Catel.Core);
  • 通过接口的契约。

4.2. 接口

为了关注点分离(SoC),创建接口以定义应用程序不同层之间的契约非常重要。

4.2.1. ISocialMediaEntry

为了让主应用程序能够与提供者通信,需要某种契约。这通常以接口的形式完成,以便在单元测试中可以轻松地模拟对象。下面是社交媒体条目的接口

4.2.2. ISocialMediaProvider

现在我们已经定义了社交媒体条目可以包含哪些信息,是时候决定如何与提供者通信了。因此,让我们创建一个接口来定义这些属性以及从特定提供者获取更新的方法

我们现在在主应用程序和可以作为插件添加的提供者之间建立了一个契约。提供者唯一需要做的就是实现 ISocialMediaProvider 接口,它就可以供应用程序使用了。

由于社交媒体提供商(至少是本文中包含的)使用 OAuth,因此无需提供任何用户名和/或密码。

4.2.3. ISocialMediaProviderFactory

正如我之前在本文中告诉您的那样,分离您的关注点非常重要。因此,我们希望能够创建不同的工厂来查找社交媒体提供商。例如,在此应用程序中,我们只是想列出当前工作目录中的所有程序集,并查看它们是否包含任何实现 SocialMediaProviderBase 类的类型。但是想象一下,您想搜索 Web 服务、数据库或其他东西。因此,我们首先需要定义工厂应该做什么并为其创建一个接口

现在我们已经为工厂创建了一个接口,我们可以创建几个实现,并通过配置决定使用哪个工厂。这允许我们将工厂从 LocalSocialMediaProviderFactory 更改为 WebServicesSocialMediaProviderDatabaseSocialMediaProvider

4.3. 基础提供者

为了简化提供商的开发,我们将创建一个基类,它将已经实现 DataObjectBase 类。这样,提供商开发人员只需实现 GetLast10Updates 方法,并关注与他们负责的社交媒体的实际连接。多亏了 dataobjectdataproperty 代码片段,下面的类仅在几分钟内就创建出来了

/// <summary>
/// SocialMediaProviderBase Data object class which fully
/// supports serialization, property changed notifications,
/// backwards compatibility and error checking.
/// </summary>
#if !SILVERLIGHT
[Serializable]
#endif
public abstract class SocialMediaProviderBase : 
       DataObjectBase<SocialMediaProviderBase>, ISocialMediaProvider
{
    #region Variables
    #endregion

    #region Constructor & destructor
    /// <summary>
    /// Initializes a new object from scratch.
    /// </summary>
    /// <param name="providerName">Name of the provider.</param>
    protected SocialMediaProviderBase(string providerName)
    {
        Name = providerName;
    }

#if !SILVERLIGHT
    /// <summary>
    /// Initializes a new object based on <see cref="SerializationInfo"/>.
    /// </summary>
    /// <param name="info"><see cref="SerializationInfo"/>
    /// that contains the information.</param>
    /// <param name="context"><see cref="StreamingContext"/>.</param>
    protected SocialMediaProviderBase(SerializationInfo info, 
                                      StreamingContext context)
        : base(info, context) { }
#endif
    #endregion

    #region Properties
    /// <summary>
    /// Gets the name of the provider.
    /// </summary>
    /// <value>The name of the provider.</value>
    public string Name
    {
        get { return GetValue<string>(NameProperty); }
        private set { SetValue(NameProperty, value); }
    }

    /// <summary>
    /// Register the Name property so it is known in the class.
    /// </summary>
    public static readonly PropertyData NameProperty = 
           RegisterProperty("Name", typeof(string), string.Empty);
    #endregion

    #region Methods
    /// <summary>
    /// Gets the last 10 updates from the provider source.
    /// </summary>
    /// <returns>
    ///    <see cref="IEnumerable{ISocialMediaEntry}"/> containing all the updates.
    /// </returns>
    public abstract IEnumerable<ISocialMediaEntry> GetLast10Updates();
    #endregion
}

4.4. Twitter 和 Facebook 提供者

我不想深入探讨对 Twitter 和 Facebook 的支持,因为这不是本文的重点。我快速搜索了这两个提供商的开源库,并找到了这两个:

  • Tweetsharp:TweetSharp 是与 Twitter 通信最完整、最有效的客户端库。TweetSharp 以您想要的方式工作:简单的服务、流利接口或 LINQ 提供程序。它旨在让您从 Twitter 的 API 中编写更少代码,获得更多功能。
  • Facebook:Facebook C# SDK 帮助 .NET 开发人员构建与 Facebook 集成的 Web、桌面、Silverlight 和 Windows Phone 7 应用程序。

如果您对这些库的工作原理感兴趣,请查看本文中的源代码或访问官方网站以获取更多信息。

我必须承认,我有点“偷工减料”了。编写 Twitter 和 Facebook 提供者真是一件痛苦的事情。这是我第一次接触 OAuth,而且我选择的两个库都包含我遇到的错误。你可能会称之为命运,我称之为运气不好。无论如何,在一小时内编写一个包含提供者的完整应用程序是不可行的,但如果有一个好的 SDK 附带好的示例,理论上应该是可行的。尽管如此,在一小时内编写没有提供者的这个应用程序还是非常快的(我希望你现在仍然相信这一点:))。

4.5. SocialMediaProviderFactory

我们如何确定要使用哪些提供者?我们不想创建对 Facebook 和 Twitter 提供者的引用。我们还希望使我们的应用程序“面向未来”,这样当创建新的提供者时,我们就不必重新编译应用程序。一个很好的解决方案是工厂模式。这样,我们创建一个负责查找和实例化提供者的单一类。

为了简单起见,此示例应用程序将只提供一个工厂,该工厂列出当前工作目录中的程序集。实现非常简单,不言自明

/// <summary>
/// Factory responsible to create all
/// <see cref="ISocialMediaProvider"/> instances.
/// </summary>
public class SocialMediaProviderFactory : ISocialMediaProviderFactory
{
    #region Variables
    private static readonly List<ISocialMediaProvider> _providers = 
                            new List<ISocialMediaProvider>();
    #endregion

    /// <summary>
    /// Gets the <see cref="ISocialMediaProvider"/>
    /// instances in the current working directory.
    /// </summary>
    /// <returns><see cref="IEnumerable{ISocialMediaProvider}"/>
    ///   containing all providers in the current working directory.</returns>
    public IEnumerable<ISocialMediaProvider> GetProviders()
    {
        lock (_providers)
        {
            if (_providers.Count > 0)
            {
                return _providers;
            }

            string[] files = 
              Directory.GetFiles(Environment.CurrentDirectory, "*.dll");
            foreach (string file in files)
            {
                try
                {
                    Assembly assembly = Assembly.LoadFile(file);
                    foreach (Type type in assembly.GetTypes())
                    {
                        if (type.IsSubclassOf(typeof(SocialMediaProviderBase)))
                        {
                            _providers.Add((ISocialMediaProvider)
                                    Activator.CreateInstance(type));
                        }
                    }
                }
                catch (Exception)
                {
                    // Continue
                }
            }

            return _providers;
        }
    }
}

5. 创建视图模型

现在模型都已设置完毕,是时候考虑应用程序以及用户应该如何与数据(模型)交互了。由于您需要能够对 UI 功能进行单元测试,因此您需要使逻辑在没有用户交互的情况下可测试。视图模型是放置视图逻辑并确定模型的哪些部分应该发布到 UI(视图,最终是用户)以及它们应该如何发布(用户应该从列表中选择,还是文本,或者特定字段应该只读?)的好地方。视图模型非常容易创建,特别是当模型正确实现时。

5.1. 已处理功能

  • ViewModelBase (Catel.Windows)。

5.2. SocialMediaEntryViewModel

有几种划分代码和 UI 的方法。有些人喜欢将 ItemTemplate 用于 ItemsControl,我更喜欢为这些项创建单独的用户控件。我将 UI(及其逻辑)分离到单独的控件和视图模型中有以下原因:

  • ItemsControl 内的项目的单独用户控件允许您创建单独的视图模型。
  • 迟早,客户会要求对 ItemsControl 中的项目执行命令。这很难实现,因为要知道要为哪个项目执行命令的唯一方法是创建额外的 Selectedxxx 属性或通过特殊的介导器类绑定命令参数。
  • 有时,在包含集合的视图模型中完成所有事情是不可能的。例如,在我们的应用程序中,ISocialMediaEntry 有一个 Photo 属性,其类型为 System.Drawing.Bitmap。我们不能直接在 WPF 中显示此图像,因此我们需要将其转换为 BitmapSource 类。这不应该是包含项目的视图模型的责任,而应该是项目本身的责任。

关于我决定使用单独的项目用户控件的原因就说这么多。我们来看看它是如何实现的。视图模型看起来非常大,但多亏了 vmvmpropvmpropmodelvmpropviewmodeltomodelvmpropcommandwithcanexecute 代码片段,这个类仅在几分钟内就创建出来了

/// <summary>
/// SocialMediaEntry view model.
/// </summary>
public class SocialMediaEntryViewModel : ViewModelBase
{
    #region Constructor & destructor
    /// <summary>
    /// Initializes a new instance
    /// of the <see cref="SocialMediaEntryViewModel"/> class.
    /// </summary>
    /// <param name="socialMediaEntry">The social media entry.</param>
    /// <exception cref="ArgumentNullException">The
    /// <param name="socialMediaEntry"/> is <c>null</c>.</exception>
    public SocialMediaEntryViewModel(ISocialMediaEntry socialMediaEntry)
        : base()
    {
        if (socialMediaEntry == null)
        {
            throw new ArgumentNullException("socialMediaEntry");
        }

        SocialMediaEntry = socialMediaEntry;

        if (socialMediaEntry.Photo != null)
        {
            Photo = socialMediaEntry.Photo.ConvertBitmapToBitmapSource();
        }

        OpenLink = new Command<object, object>(OnOpenLinkExecute, 
                                               OnOpenLinkCanExecute);
    }
    #endregion

    #region Properties
    /// <summary>
    /// Gets the title of the view model.
    /// </summary>
    /// <value>The title.</value>
    public override string Title { get { return "Social Media Entry"; } }

    #region Models
    /// <summary>
    /// Gets or sets the social media entry.
    /// </summary>
    [Model]
    private ISocialMediaEntry SocialMediaEntry
    {
        get { return GetValue<ISocialMediaEntry>(SocialMediaEntryProperty); }
        set { SetValue(SocialMediaEntryProperty, value); }
    }

    /// <summary>
    /// Register the SocialMediaEntry property so it is known in the class.
    /// </summary>
    public static readonly PropertyData SocialMediaEntryProperty = 
           RegisterProperty("SocialMediaEntry", typeof(ISocialMediaEntry));
    #endregion

    #region View model
    /// <summary>
    /// Gets or sets the photo.
    /// </summary>
    /// <remarks>
    /// This is not an automatic ViewModelToModel property mapping
    /// because we need to convert a <see cref="System.Drawing.Bitmap"/>
    /// into a <see cref="BitmapSource"/>.
    /// </remarks>
    public BitmapSource Photo
    {
        get { return GetValue<BitmapSource>(PhotoProperty); }
        set { SetValue(PhotoProperty, value); }
    }

    /// <summary>
    /// Register the Photo property so it is known in the class.
    /// </summary>
    public static readonly PropertyData PhotoProperty = 
                  RegisterProperty("Photo", typeof(BitmapSource));

    /// <summary>
    /// Gets or sets the message.
    /// </summary>
    [ViewModelToModel("SocialMediaEntry")]
    public string Message
    {
        get { return GetValue<string>(MessageProperty); }
        set { SetValue(MessageProperty, value); }
    }

    /// <summary>
    /// Register the Message property so it is known in the class.
    /// </summary>
    public static readonly PropertyData MessageProperty = 
                  RegisterProperty("Message", typeof(string));

    /// <summary>
    /// Gets or sets the message preview.
    /// </summary>
    [ViewModelToModel("SocialMediaEntry")]
    public string MessagePreview
    {
        get { return GetValue<string>(MessagePreviewProperty); }
        set { SetValue(MessagePreviewProperty, value); }
    }

    /// <summary>
    /// Register the MessagePreview property so it is known in the class.
    /// </summary>
    public static readonly PropertyData MessagePreviewProperty = 
                  RegisterProperty("MessagePreview", typeof(string));

    /// <summary>
    /// Gets or sets the author.
    /// </summary>
    [ViewModelToModel("SocialMediaEntry")]
    public string Author
    {
        get { return GetValue<string>(AuthorProperty); }
        set { SetValue(AuthorProperty, value); }
    }

    /// <summary>
    /// Register the Author property so it is known in the class.
    /// </summary>
    public static readonly PropertyData AuthorProperty = 
                  RegisterProperty("Author", typeof(string));

    /// <summary>
    /// Gets or sets the timestamp.
    /// </summary>
    [ViewModelToModel("SocialMediaEntry")]
    public DateTime Timestamp
    {
        get { return GetValue<DateTime>(TimestampProperty); }
        set { SetValue(TimestampProperty, value); }
    }

    /// <summary>
    /// Register the Timestamp property so it is known in the class.
    /// </summary>
    public static readonly PropertyData TimestampProperty = 
                  RegisterProperty("Timestamp", typeof(DateTime));

    /// <summary>
    /// Gets or sets the url.
    /// </summary>
    [ViewModelToModel("SocialMediaEntry")]
    public string Url
    {
        get { return GetValue<string>(UrlProperty); }
        set { SetValue(UrlProperty, value); }
    }

    /// <summary>
    /// Register the Url property so it is known in the class.
    /// </summary>
    public static readonly PropertyData UrlProperty = 
                  RegisterProperty("Url", typeof(string));
    #endregion

    #region Commands
    /// <summary>
    /// Gets the OpenLink command.
    /// </summary>
    public Command<object, object> OpenLink { get; private set; }

    /// <summary>
    /// Method to check whether the OpenLink command can be executed.
    /// </summary>
    /// <param name="parameter">The parameter of the command.</param>
    private bool OnOpenLinkCanExecute(object parameter)
    {
        return !string.IsNullOrEmpty(Url);
    }

    /// <summary>
    /// Method to invoke when the OpenLink command is executed.
    /// </summary>
    /// <param name="parameter">The parameter of the command.</param>
    private void OnOpenLinkExecute(object parameter)
    {
        var processService = GetService<IProcessService>();
        processService.StartProcess(Url);
    }
    #endregion
    #endregion

    #region Methods
    #endregion
}

此视图模型由几个部分组成。我将逐一解释它们。让我们从构造函数开始。如您所见,只有一个构造函数接受 ISocialMediaEntry 的实例。无法通过传递 null 来欺骗视图模型,它会立即抛出 ArgumentNullException。只有一个构造函数的原因是 UserControl<TViewModel> 和视图模型的组合。如果视图模型只包含一个构造函数,UserControl<TViewModel> 将自动尝试根据用户控件的当前数据上下文构造视图模型。听起来很复杂,但在实践中并非如此。此控件将用于 ItemsControl 中。每个项目的数据上下文都设置为它所代表的项目(在我们的案例中是 ISocialMediaEntry 的实例)。一旦数据上下文设置在用户控件上,它就会检查附加的视图模型以查看它是否包含接受数据上下文的构造函数。如果它包含(在我们的案例中确实包含),它将创建视图模型并将其设置为新的数据上下文。

接下来,您会看到我们有一个用 Model 属性声明的属性。它是通过构造函数注入到视图模型中的 IsocialMediaEntry 实例。由于 Model 属性,可以使用 ViewModelToModel 属性。

除了 TitlePhoto 之外,所有其他属性都用 ViewModelToModel 属性修饰。这是一个方便的属性,因此您不必将属性从模型或映射到模型。换句话说,当属性 UrlViewModelToModel 属性修饰时,它会告诉视图模型在模型或模型上的属性更改时将 Url 的值设置为 SocialMediaEntry.Url 值。如果您正在创建一个可编辑的视图模型,此属性还负责将 UI 中的更改映射回模型。

我们还定义了一个命令。它包含 CanExecute 方法(只有当有链接时才能打开链接)和 Execute 方法,该方法使用 IProcessService 为 URL 启动一个新进程。这导致默认浏览器打开 URL。

最后但并非最不重要的是,让我们再次仔细看看构造函数。如前所述,一旦设置了 SocialMediaEntry 属性,所有用 ViewModelToModel 装饰的属性都会自动设置。由于 ISocialMediaEntryPhoto 属性是 System.Drawing.Bitmap 类型,我们需要将其转换为 BitmapSource。因此,我们不想使用自动映射,而是通过 Catel 中可用的扩展方法自行转换 Photo 对象。

5.3. MainWindowViewModel

我们已经在上一段讨论了应用程序中最复杂的视图模型,如果可以称之为复杂的话。让我们看看应用程序主窗口的视图模型。同样,我们先展示代码,然后我将逐部分解释代码。

同样,这听起来可能不可能,但这个视图模型是在 10 分钟内创建的。最复杂的是实现 InitializeRefresh 方法,这需要一些实际编码。视图模型的其余部分是使用 vmvmpropvmcommand 代码片段创建的。

/// <summary>
/// MainWindow view model.
/// </summary>
public class MainWindowViewModel : ViewModelBase
{
    #region Variables
    private readonly ISocialMediaProviderFactory _socialMediaProviderFactor;
    private readonly List<ISocialMediaProvider> 
      _enabledSocialMediaProviders = new List<ISocialMediaProvider>();
    #endregion

    #region Constructor & destructor

    /// <summary>
    /// Initializes a new instance
    /// of the <see cref="MainWindowViewModel"/> class.
    /// </summary>
    /// <remarks>
    /// This constructor is created to use an IoC container.
    /// </remarks>
    public MainWindowViewModel(): this(Catel.IoC.UnityContainer.
      Instance.Container.Resolve<ISocialMediaProviderFactory>()) { }

    /// <summary>
    /// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
    /// </summary>
    /// <param name="socialMediaProviderFactory">
    ///   The social media provider factory.</param>
    /// <remarks>
    /// This constructor is created to allow custom dependency injection.
    /// </remarks>
    public MainWindowViewModel(ISocialMediaProviderFactory 
                               socialMediaProviderFactory)
    {
        _socialMediaProviderFactor = socialMediaProviderFactory;

        VisitCatel = new Command<object>(OnVisitCatelExecute);
        Refresh = new Command<object>(OnRefreshExecute);
    }
    #endregion

    #region Properties
    /// <summary>
    /// Gets the title of the view model.
    /// </summary>
    /// <value>The title.</value>
    public override string Title { get { return "Social Media Stream"; } }

    #region Models
    #endregion

    #region View model
    /// <summary>
    /// Gets or sets items.
    /// </summary>
    public ObservableCollection<ISocialMediaEntry> SocialMediaEntries
    {
        get { return GetValue<ObservableCollection<ISocialMediaEntry>>(
                     SocialMediaEntriesProperty); }
        set { SetValue(SocialMediaEntriesProperty, value); }
    }

    /// <summary>
    /// Register the SocialMediaEntries property so it is known in the class.
    /// </summary>
    public static readonly PropertyData SocialMediaEntriesProperty = 
      RegisterProperty("SocialMediaEntries", 
      typeof(ObservableCollection<ISocialMediaEntry>));
    #endregion
    #endregion

    #region Commands
    /// <summary>
    /// Gets the VisitCatel command.
    /// </summary>
    public Command<object> VisitCatel { get; private set; }

    /// <summary>
    /// Method to invoke when the VisitCatel command is executed.
    /// </summary>
    /// <param name="parameter">The parameter of the command.</param>
    private void OnVisitCatelExecute(object parameter)
    {
        var processService = GetService<IProcessService>();
        processService.StartProcess("http://catel.codeplex.com");
    }

    /// <summary>
    /// Gets the Refresh command.
    /// </summary>
    public Command<object> Refresh { get; private set; }

    /// <summary>
    /// Method to invoke when the Refresh command is executed.
    /// </summary>
    /// <param name="parameter">The parameter of the command.</param>
    private void OnRefreshExecute(object parameter)
    {
        RefreshSocialMedia();
    }
    #endregion

    #region Methods
    /// <summary>
    /// Initializes the object by setting default values.
    /// </summary>     
    protected override void Initialize()
    {
        IEnumerable<ISocialMediaProvider> socialMediaProviders = 
                  _socialMediaProviderFactor.GetProviders();
        foreach (ISocialMediaProvider socialMediaProvider in socialMediaProviders)
        {
            var messageService = GetService<IMessageService>();
            if (messageService.Show(string.Format(
                "Do you want to support '{0}'?", socialMediaProvider.Name),
                string.Format("{0} support", socialMediaProvider.Name), 
                              MessageButton.YesNo) == MessageResult.Yes)
            {
                _enabledSocialMediaProviders.Add(socialMediaProvider);
            }
        }

        RefreshSocialMedia();
    }

    /// <summary>
    /// Refreshes all the social media entries.
    /// </summary>
    private void RefreshSocialMedia()
    {
        var pleaseWaitService = GetService<IPleaseWaitService>();
        pleaseWaitService.Show("Retrieving updates from all providers");

        List<ISocialMediaEntry> entries = new List<ISocialMediaEntry>();

        foreach (ISocialMediaProvider socialMediaProvider 
                 in _enabledSocialMediaProviders)
        {
            pleaseWaitService.UpdateStatus("Retrieving updates from " + 
                                           socialMediaProvider.Name);
            entries.AddRange(socialMediaProvider.GetLast10Updates());
        }

        SocialMediaEntries = new ObservableCollection<ISocialMediaEntry>(
                entries.OrderByDescending(entry => entry.Timestamp));

        pleaseWaitService.Hide();
    }
    #endregion
}

像上一章一样,让我们从构造函数开始。由于这是视图模型,它不需要任何特殊的构造函数。 DataWindow<TViewModel> 尝试使用空构造函数构造视图模型并将其设置为数据上下文。但是,在我们的示例中,我们有两个构造函数,一个带参数,一个不带参数。原因将在本文稍后的设置 IoC 容器部分中解释。因此,在此之前,只需假设有一个不带参数的默认构造函数,以便视图模型可以自动构建。

接下来,我们有一个 ISocialMediaEntry 对象的集合,这很明显,因为我们需要在视图中显示一个对象列表。还有两个命令,一个用于访问 Catel 网站,另一个用于手动刷新社交媒体。

这使我们进入视图模型目前最有趣的部分:工厂的使用和社交媒体更新的检索。正如本文前面所解释的,我们确实希望我们的视图模型尽可能可测试和松耦合。因此,我们只使用允许我们这样做的接口。视图模型通过 ISocialMediaProviderFactory 实例注入,因此视图模型能够检索可用 ISocialMediaProvider 实例的列表。在 Initialize 方法中,视图模型会询问用户每个社交媒体提供商,用户是否希望应用程序支持该特定提供商。如果是,它将存储在一个包含所有已启用的社交媒体提供商的特殊列表中。

最后,我们有一个 RefreshSocialMedia 方法,它通过视图模型的 IPleaseWaitService 显示 PleaseWaitWindow。然后它检索所有已启用社交媒体提供商的所有更新。请记住,所有这一切都发生在 UI 线程中,因此当更新需要很长时间时,这就是原因。最好使用异步方法,但这只会增加我们示例应用程序的难度。我将异步更新的实现留给读者作为很好的练习。

6. 创建视图

视图是用户体验应用程序以及查看和修改数据的方式。在理想情况下,视图不是由开发人员创建的,而是由设计师创建的。但是,没有多少客户真正愿意在设计师身上花钱(测试人员也是如此,尽管他们非常重要!)。我不想在创建非常好的用户界面上花费太多时间,所以 UI 会有点基础。尽管 UI 基础,但它功能齐全,并使用 Catel 的一些控件和窗口。

6.1. 已处理功能

  • DataWindow (Catel.Windows);
  • IoC 容器;
  • UserControl (Catel.Windows);
  • TraceOutputWindow (Catel.Windows)。

6.2. SocialMediaEntry

用户控件由一些简单的 XAML 组成(注意它派生自 Catel.Windows.UserControl<TViewModel> 而不是 UserControl

<Controls:UserControl x:Class="SocialMediaStream.UI.Controls.SocialMediaEntry"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:Controls="clr-namespace:Catel.Windows.Controls;assembly=Catel.Windows"
      xmlns:ViewModels="clr-namespace:SocialMediaStream.UI.ViewModels"
      xmlns:i="clr-namespace:System.Windows.Interactivity;
               assembly=System.Windows.Interactivity"
      xmlns:Commands="clr-namespace:Catel.MVVM.Commands;assembly=Catel.Windows"
      x:TypeArguments="ViewModels:SocialMediaEntryViewModel">

    <ContentControl>
        <i:Interaction.Triggers>
            <i:EventTrigger EventName="MouseDoubleClick">
                <Commands:EventToCommand 
                   Command="{Binding OpenLink}" 
                   DisableAssociatedObjectOnCannotExecute="False" />
            </i:EventTrigger>
        </i:Interaction.Triggers>
        
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>

            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="32" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>

            <Image Grid.Row="0" Grid.Column="0" 
               Source="{Binding Photo}" Width="32" Height="32" />

            <Label Grid.Row="0" Grid.Column="1">
                <TextBlock TextWrapping="Wrap" 
                  Text="{Binding MessagePreview}" ToolTip="{Binding Message}" />
            </Label>

            <Label Grid.Row="1" Grid.ColumnSpan="2" 
               Content="{Binding Author}" HorizontalAlignment="Left" />
            <Label Grid.Row="1" Grid.ColumnSpan="2" 
               Content="{Binding Timestamp}" HorizontalAlignment="Right" />
        </Grid>
    </ContentControl>

</Controls:UserControl>

有趣的是,这个类也实现了一些 Interaction.Triggers。这是为了 EventToCommand 触发器,它允许将事件转换为视图模型的命令。在这种情况下,MouseDoubleClick 事件将被转换为视图模型的 OpenLink 命令。

多亏了 MVVM,用户控件的代码隐藏非常干净

/// <summary>
/// Interaction logic for SocialMediaEntry.xaml
/// </summary>
public partial class SocialMediaEntry : UserControl<SocialMediaEntryViewModel>
{
    /// <summary>
    /// Initializes a new instance of the <see cref="SocialMediaEntry"/> class.
    /// </summary>
    public SocialMediaEntry()
    {
        InitializeComponent();
    }
}

6.3. MainWindow

与之前讨论的用户控件一样,主窗口由两部分组成。第一部分是 XAML(注意它派生自 Catel.Windows.DataWindow<TViewModel> 而不是 Window

<Windows:DataWindow x:Class="SocialMediaStream.UI.Windows.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:Windows="clr-namespace:Catel.Windows;assembly=Catel.Windows"
        xmlns:ViewModels="clr-namespace:SocialMediaStream.UI.ViewModels"
        xmlns:LocalControls="clr-namespace:SocialMediaStream.UI.Controls"
        x:TypeArguments="ViewModels:MainWindowViewModel"
        SizeToContent="Manual" Width="400" Height="600" ShowInTaskbar="True"
        Icon="/SocialMediaStream;component/Resources/Images/Catel.png">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        
        <!-- Settings -->
        <WrapPanel Orientation="Horizontal" 
               Style="{StaticResource RightAlignedButtonsWrapPanelStyle}">
            <Button Command="{Binding VisitCatel}" 
                   Style="{StaticResource MediumRightAlignedImageButtonStyle}" 
                   ToolTip="Visit Catel homepage">
                <Image Source="/SocialMediaStream;component/Resources/Images/Catel.png" />
            </Button>
            
            <Button Command="{Binding Refresh}" 
                    Style="{StaticResource MediumRightAlignedImageButtonStyle}" 
                    ToolTip="Refresh">
                <Image Source="/SocialMediaStream;component/Resources/Images/Refresh.png" />
            </Button>
        </WrapPanel>

        <!-- Listbox with contents -->
        <ListBox Grid.Row="1" ItemsSource="{Binding SocialMediaEntries}"
                 ScrollViewer.HorizontalScrollBarVisibility="Disabled"
                 x:Name="socialMediaEntriesListBox">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel>
                        <LocalControls:SocialMediaEntry DataContext="{Binding}" />
                        <Separator Width="{Binding 
                              ElementName=socialMediaEntriesListBox, 
                              Path=ActualWidth}"/>
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </Grid>
</Windows:DataWindow>

XAML 代码非常简单。我唯一想提的是 SocialMediaEntry 控件的使用。如您所见,ListBox 控件的 ItemTemplate 定义了一个用户控件实例并绑定了用户控件的 DataContext 属性。这非常重要,这样 UserControl<TViewModel> 将使用该数据上下文来实例化前面讨论的视图模型。

多亏了 MVVM,窗口的代码隐藏非常干净。它将 DataWindowMode.Custom 传递给基构造函数,以告诉 DataWindow<TViewModel> 不生成默认的“确定”和“取消”按钮。

/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : DataWindow<MainWindowViewModel>
    : base(DataWindowMode.Custom)
{
    /// <summary>
    /// Initializes a new instance of the <see cref="MainWindow"/> class.
    /// </summary>
    public MainWindow()
    {
        InitializeComponent();
    }
}

6.4. 设置 IoC 容器

现在我们已经准备了一个非常松耦合的系统,是时候将松散的部分耦合起来了。Catel 使用 Unity 来实现 IoC 容器。目前,我们只需要配置一件事,那就是用于收集各个提供者的 ISocialMediaProviderFactory

首先,我们需要打开 app.config 文件并为 Unity 配置指定节

<configSections>
  <section name="unity" 
      type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection, 
            Microsoft.Practices.Unity.Configuration" />
</configSections>

之后,我们可以创建自己的配置节,用于定义 Factory 要实例化的类型

<unity>
  <containers>
    <container>
      <types>
        <type type="SocialMediaStream.ISocialMediaProviderFactory, SocialMediaStream" 
           mapTo="SocialMediaStream.SocialMediaProviderFactory, SocialMediaStream"/>
      </types>
    </container>
  </containers>
</unity>

上述 Unity 配置部分告诉 Unity 在从容器请求 ISocialMediaProviderFactory 时,创建我们在上一段中创建的 SocialMediaProviderFactory 实例。

为了确保 Unity 加载配置,我们需要将这段代码添加到应用程序的代码隐藏文件 (App.xaml.cs) 中

/// <summary>
/// Raises the <see cref="E:System.Windows.Application.Startup"/> event.
/// </summary>
/// <param name="e">A <see
/// cref="T:System.Windows.StartupEventArgs"/>
/// that contains the event data.</param>
protected override void OnStartup(StartupEventArgs e)
{
    StyleHelper.CreateStyleForwardersForDefaultStyles();

    ConfigureIoCContainer();

    base.OnStartup(e);
}

/// <summary>
/// Configures the IoC container.
/// </summary>
private static void ConfigureIoCContainer()
{
    // Load from config, overrides defaults
    UnityConfigurationSection section = 
      (UnityConfigurationSection)ConfigurationManager.GetSection("unity");
    if ((section != null) && (section.Containers.Count > 0))
    {
        section.Configure(Catel.IoC.UnityContainer.Instance.Container);
    }
}

上面的代码在应用程序启动时配置 IoC 配置。执行的代码不言自明。

我们快完成了,坚持住。我们已经设置了工厂接口,一个示例实现,并配置了 IoC 容器来实例化正确的类。剩下的唯一一件事就是实际实例化工厂。我们在主窗口视图模型的构造函数中执行此操作。我们创建了两个构造函数,以便在单元测试期间将工厂注入到主窗口视图模型中(当然,这也可以通过配置单元测试项目的 app.config 中的 IoC 容器来实现)

/// <summary>
/// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
/// </summary>
/// <remarks>
/// This constructor is created to use an IoC container.
/// </remarks>
public MainWindowViewModel()
    : this(Catel.IoC.UnityContainer.Instance.
           Container.Resolve<ISocialMediaProviderFactory>()) { }

/// <summary>
/// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
/// </summary>
/// <param name="socialMediaProviderFactory">The social media provider factory.</param>
/// <remarks>
/// This constructor is created to allow custom dependency injection.
/// </remarks>
public MainWindowViewModel(ISocialMediaProviderFactory socialMediaProviderFactory)
{
    _socialMediaProviderFactor = socialMediaProviderFactory;

    // Other code left out for sake of simplicity
}

不带任何参数的第一个构造函数使用 Catel 的 IoC 容器来解析 ISocialMediaProviderFactory 的配置实现,在我们的案例中将是 SocialMediaProviderFactory

6.5. TraceOutputWindow 用于额外的调试信息

Visual Studio 的输出窗口并非始终可见(我们都想看自己的东西),它也不提供彩色消息。因此,Catel 提供了 TraceOutputWindow。这个窗口的使用非常简单,我们将在调试会话期间用它来显示日志消息。

我们希望 TraceOutputWindow 在应用程序启动时显示,并在用户退出应用程序时立即关闭。有什么比主窗口更好的地方呢?下面是我们用于在调试构建期间显示 TraceOutputWindow 的额外代码。首先,我们需要将窗口声明为字段

#if DEBUG
    private readonly TraceOutputWindow _traceOutputWindow;
#endif

最后,我们需要在主窗口的构造函数中添加代码来显示和关闭 TraceOutputWindow

/// <summary>
/// Initializes a new instance of the <see cref="MainWindow"/> class.
/// </summary>
public MainWindow()
    : base(DataWindowMode.Custom)
{
    InitializeComponent();

#if DEBUG
    _traceOutputWindow = new TraceOutputWindow();
    _traceOutputWindow.Show();

    Closed += (sender, e) =>
                    {
                        if (_traceOutputWindow != null)
                        {
                            _traceOutputWindow.Close();
                        }
                    };
#endif
}

多亏了 #if#endif 指令,在发布构建期间不会有这个窗口的任何痕迹。

6.6. 动态主题切换

我不想把动态主题切换功能对你隐藏起来,所以让我们讨论一下。它不难理解,所以它是本文的一个很好的收尾。动态主题切换是在代码隐藏中实现的。这样做的原因是因为它与数据操作无关,而且我们无法在不创建 Application 对象的情况下对其进行正确的单元测试,也无法查看它是否实际工作。因此,在代码隐藏中实现此行为是完全合法的(因为这就是代码隐藏的用途)。如果你仍然不相信为什么这在代码隐藏中实现,请考虑在 Silverlight 中使用相同的视图模型。Silverlight 使用不同的方法进行主题处理,但实际功能对 Silverlight 来说是相同的。这允许我们在 Silverlight 中为主窗口使用完全相同的视图模型,并且 Silverlight 版本的开发人员可以自行决定是否要实现动态主题切换支持。

要实现动态主题切换,我们需要做几件事。让我们从代码隐藏开始。我们需要一个定义可用主题和选定主题的依赖项属性

/// <summary>
/// Gets or sets the available themes.
/// </summary>
/// <value>The available themes.</value>
public ObservableCollection<ThemeInfo> AvailableThemes
{
    get { return (ObservableCollection<ThemeInfo>)
                  GetValue(AvailableThemesProperty); }
    set { SetValue(AvailableThemesProperty, value); }
}

// Using a DependencyProperty as the backing store for AvailableThemes.
// This enables animation, styling, binding, etc...
public static readonly DependencyProperty AvailableThemesProperty =
    DependencyProperty.Register("AvailableThemes", 
       typeof(ObservableCollection<ThemeInfo>), 
       typeof(MainWindow), new UIPropertyMetadata(null));

/// <summary>
/// Gets or sets the selected theme.
/// </summary>
/// <value>The selected theme.</value>
public ThemeInfo SelectedTheme
{
    get { return (ThemeInfo)GetValue(SelectedThemeProperty); }
    set { SetValue(SelectedThemeProperty, value); }
}

// Using a DependencyProperty as the backing store for SelectedTheme.
// This enables animation, styling, binding, etc...
public static readonly DependencyProperty SelectedThemeProperty =
    DependencyProperty.Register("SelectedTheme", 
    typeof(ThemeInfo), typeof(MainWindow), 
    new UIPropertyMetadata(null, 
        (sender, e) => ((MainWindow)sender).OnSelectedThemeChanged()));

如您所见,一旦 SelectedTheme 属性更改,就会调用 OnSelectedThemeChanged 方法。让我们看看它是怎样的

/// <summary>
/// Called when the <see cref="SelectedTheme"/> property has changed.
/// </summary>
private void OnSelectedThemeChanged()
{
    var currentApp = Application.Current;
    if (currentApp == null)
    {
        return;
    }

    // Need to call this twice because the first update
    // fixes the dictionaries, and the second one actually updates the UI
    UpdateApplicationResources(currentApp);
    UpdateApplicationResources(currentApp);
}

/// <summary>
/// Updates the application resources.
/// </summary>
/// <param name="currentApp">The current application.</param>
private void UpdateApplicationResources(Application currentApp)
{
    currentApp.Resources.Clear();
    currentApp.Resources.MergedDictionaries.Clear();

    ResourceDictionary resourceDictionary = new ResourceDictionary();
    resourceDictionary.Source = 
      new Uri(SelectedTheme.Source, UriKind.RelativeOrAbsolute);

    currentApp.Resources.MergedDictionaries.Add(resourceDictionary);

    StyleHelper.CreateStyleForwardersForDefaultStyles();
}

现在我们已经实现了负责重新建立应用程序资源字典的逻辑,让我们看看可用主题的集合是如何初始化的。在构造函数的末尾,我们添加了这段代码

AvailableThemes = new ObservableCollection<ThemeInfo>();
AvailableThemes.Add(new ThemeInfo("Aero - normal", 
  "/Catel.Windows;component/themes/aero/catel.aero.normal.xaml"));
AvailableThemes.Add(new ThemeInfo("Aero - large", 
  "/Catel.Windows;component/themes/aero/catel.aero.large.xaml"));
AvailableThemes.Add(new ThemeInfo("Expression Dark - normal", 
  "/Catel.Windows;component/themes/expressiondark/catel.expressiondark.normal.xaml"));
AvailableThemes.Add(new ThemeInfo("Expression Dark - large", 
  "/Catel.Windows;component/themes/expressiondark/catel.expressiondark.large.xaml"));
AvailableThemes.Add(new ThemeInfo("Sunny Orange", 
  "/Catel.Windows;component/themes/sunnyorange/generic.xaml"));
SelectedTheme = AvailableThemes[2];

默认情况下,Expression Dark - normal 被设置为主题。

最后但同样重要的是,我们需要在主窗口上添加控件以启用动态主题切换。在第一个网格的行定义之后,我们添加了这段代码

<!-- Theme switching, not in view model because it has nothing to do with that -->
<WrapPanel Orientation="Horizontal" VerticalAlignment="Center"
       DataContext="{Binding RelativeSource=
                    {RelativeSource AncestorType={x:Type Windows:DataWindow}}}">
    <Label Content="Theme" VerticalAlignment="Center" />
    <ComboBox SelectedItem="{Binding SelectedTheme}" ItemsSource="{Binding AvailableThemes}"
                DisplayMemberPath="Name" VerticalAlignment="Center" />
</WrapPanel>

如您所见,WrapPanelDataContext 设置为 DataWindow,而不是视图模型。因此,我们可以绑定到窗口的 AvailableThemesSelectedTheme 依赖项属性。

7. 结论

我希望本文向您展示了如果正确使用,Catel 框架可以多么强大。通过结合数据处理、验证和 WPF 用户控件,您能够以极快的速度创建应用程序,并且仍然完全掌控所发生的一切。

此应用程序未包含单元测试。这并不意味着单元测试一点都不重要。未包含单元测试的原因是因为已经有一篇单独的文章专门介绍单元测试。如果所有这些内容都在本文中再次处理,那么本文将变得太大。

© . All rights reserved.