基于Catel框架构建的WPF应用程序配置的MVVM实现

实施软件设置管理可能是在几乎每个应用程序中以不同方式实施的事情之一。 大多数框架和其他附件通常提供自己的工具,用于从参数存储的某些键值中保存/加载值。


但是,在大多数情况下,特定设置窗口的实现以及与之相关的许多事情都由用户决定。 在本文中,我想分享我设法实现的方法。 就我而言,我需要使用MVVM友好样式并使用本案例中使用的Catel框架的细节来实现设置。


免责声明 :在本说明中,没有比基本反映更困难的技术细节。 这只是对我周末解决小问题的方法的描述。 我想考虑如何摆脱标准样板代码并复制与保存/加载应用程序设置有关的粘贴。 得益于便捷的.NET / Catel工具,该解决方案本身显得微不足道,但也许有人会节省几个小时的时间或提出有用的想法。


卡特尔框架简介

像其他WPF框架(Prism,MVVM Light,Caliburn.Micro等)一样,Catel提供了方便的工具来构建MVVM风格的应用程序。
主要组成部分:


  • IoC(与MVVM组件集成)
  • ModelBase:一个基类,提供PropertyChanged(尤其是与Catel.Fody结合使用),序列化和BeginEdit / CancelEdit / EndEdit(经典的“ apply” /“ cancel”)的自动实现。
  • ViewModelBase,能够绑定到模型,并包装其属性。
  • 使用视图,可以自动创建并绑定到ViewModel。 支持嵌套控件

要求条件


我们将从需要以下配置工具这一事实出发:


  • 以简单的结构化方式访问配置。 举个例子
    CultureInfo culture = settings.Application.PreferredCulture;
    TimeSpan updateRate = settings.Perfomance.UpdateRate;
    • 所有参数均显示为常规属性。 存储方法封装在内部。 对于简单类型,一切都应该自动发生;对于更复杂的类型,应该可以将值的序列化配置为字符串。
  • 简单性和可靠性。 我不想使用易碎的工具,例如完全序列化整个配置模型或某些实体框架。 在较低级别,配置仍然是一个简单的参数-值对存储库。
  • 放弃配置更改的功能,例如,如果用户在设置窗口中单击“取消”。
  • 能够订阅配置更新。 例如,我们要在更改配置后立即更新应用程序语言。
  • 在应用程序版本之间迁移。 在应用程序版本之间进行切换(重命名参数等)时,应该可以设置操作。
  • 最小样板代码,最小错别字。 理想情况下,我们只想设置自动属性,而不考虑如何保存它,在哪个字符串键下等等。我们不想在设置窗口的视图模型中手动复制每个属性,所有内容都应该自动运行。

标准工具


Catel提供IConfigurationService服务,该服务允许从本地存储(标准实现中的磁盘文件)存储和加载字符串键的值。


如果我们想以纯格式使用此服务,则必须自己声明这些键,例如,通过设置以下常量:


 public static class Application { public const String PreferredCulture = "Application.PreferredCulture"; public static readonly String PreferredCultureDefaultValue = Thread.CurrentThread.CurrentUICulture.ToString(); } 

然后我们可以像下面这样获取这些参数:


 var preferredCulture = new CultureInfo(configurationService.GetRoamingValue( Application.PreferredCulture, Application.PreferredCultureDefaultValue)); 

编写起来很繁琐,而且当设置很多时很容易打错字。 另外,该服务仅支持简单类型,例如CultureInfo如果不进行其他转换就无法保存。


为了简化此服务的工作,获得了一个由几个组件组成的包装器。


完整的示例代码可在GitHub存储库中找到 。 它包含最简单的应用程序,能够编辑设置中的几个参数并确保一切正常。 我不关心本地化,设置中的“语言”参数仅用于演示配置。 如果有兴趣,Catel拥有便利的本地化机制 ,包括在WPF级别。 例如,如果您不喜欢资源文件,则可以使用GNU gettext来实现自己的实现。


为了便于阅读,在本出版物文本的代码示例中,已删除所有xml-doc注释。



配置服务


可以通过IoC嵌入的服务,可以访问应用程序中任何位置的设置。


该服务的主要目的是提供一个设置模型,该模型又提供了一种简单且结构化的方式来访问它们。


除设置模型外,该服务还提供撤消或保存对设置所做的更改的功能。


介面


 public interface IApplicationConfigurationProviderService { event TypedEventHandler<IApplicationConfigurationProviderService> ConfigurationSaved; ConfigurationModel Configuration { get; } void LoadSettingsFromStorage(); void SaveChanges(); } 

实现方式:


 public partial class ApplicationConfigurationProviderService : IApplicationConfigurationProviderService { private readonly IConfigurationService _configurationService; public ApplicationConfigurationProviderService(IConfigurationService configurationService) { _configurationService = configurationService; Configuration = new ConfigurationModel(); LoadSettingsFromStorage(); ApplyMigrations(); } public event TypedEventHandler<IApplicationConfigurationProviderService> ConfigurationSaved; public ConfigurationModel Configuration { get; } public void LoadSettingsFromStorage() { Configuration.LoadFromStorage(_configurationService); } public void SaveChanges() { Configuration.SaveToStorage(_configurationService); ConfigurationSaved?.Invoke(this); } private void ApplyMigrations() { var currentVersion = typeof(ApplicationConfigurationProviderService).Assembly.GetName().Version; String currentVersionString = currentVersion.ToString(); String storedVersionString = _configurationService.GetRoamingValue("SolutionVersion", currentVersionString); if (storedVersionString == currentVersionString) return; //Either migrations were already applied or we are on fresh install var storedVersion = new Version(storedVersionString); foreach (var migration in _migrations) { Int32 comparison = migration.Version.CompareTo(storedVersion); if (comparison <= 0) continue; migration.Action.Invoke(); } _configurationService.SetRoamingValue("SolutionVersion", currentVersionString); } } 

该实现非常简单,以下各节ConfigurationModel介绍ConfigurationModel的内容。 唯一可能引起注意的是ApplyMigrations方法。


在新版本的程序中,某些内容可能会发生变化,例如,存储某些复杂参数或其名称的方法。 如果不想在每次更改现有参数的更新后丢失设置,则需要一种迁移机制。 ApplyMigrations方法ApplyMigrations版本之间的过渡期间执行任何操作ApplyMigrations非常简单的支持。


如果应用程序的新版本中发生了某些更改,我们只需将新版本中的必要操作(例如,使用新名称保存参数)添加到相邻文件中包含的迁移列表中:


  private readonly IReadOnlyCollection<Migration> _migrations = new Migration[] { new Migration(new Version(1,1,0), () => { //... }) } .OrderBy(migration => migration.Version) .ToArray(); private class Migration { public readonly Version Version; public readonly Action Action; public Migration(Version version, Action action) { Version = version; Action = action; } } 

设定模型


常规操作的自动化如下。 该配置被描述为常规模型(数据对象)。 Catel提供了一个方便的基类ModelBase ,它是其所有MVVM工具的核心,例如所有三个MVVM组件之间的自动绑定。 特别是,它使您可以轻松访问我们要保存的模型属性。


通过声明这种模型,我们可以获得其属性,将字符串键映射到它们,从属性名称创建它们,然后自动从配置中加载和保存值。 换句话说,绑定配置中的属性和值。


声明配置选项


这是根模型:


 public partial class ConfigurationModel : ConfigurationGroupBase { public ConfigurationModel() { Application = new ApplicationConfiguration(); Performance = new PerformanceConfiguration(); } public ApplicationConfiguration Application { get; private set; } public PerformanceConfiguration Performance { get; private set; } } 

ApplicationConfigurationPerfomanceConfiguration是描述其设置组的子类:


 public partial class ConfigurationModel { public class PerformanceConfiguration : ConfigurationGroupBase { [DefaultValue(10)] public Int32 MaxUpdatesPerSecond { get; set; } } } 

在后台,此属性将绑定到"Performance.MaxUpdatesPerSecond"参数,该参数的名称是根据PerformanceConfiguration类型的名称生成的。


应当指出,由于使用了Catel.Fody (一个著名的.NET代码生成器Fody的插件),因此声明这些属性的能力非常简洁。 如果出于某种原因您不想使用它,则应根据文档 (通常与WPF中的DependencyProperty相似)声明属性。


如果需要,可以增加嵌套的级别。


使用IConfigurationService实现属性绑定


绑定发生在基类ConfigurationGroupBase ,而基类又继承自ModelBase。 更详细地考虑其内容。


首先,我们列出要保存的属性:


 public abstract class ConfigurationGroupBase : ModelBase { private readonly IReadOnlyCollection<ConfigurationProperty> _configurationProperties; private readonly IReadOnlyCollection<PropertyData> _nestedConfigurationGroups; protected ConfigurationGroupBase() { var properties = this.GetDependencyResolver() .Resolve<PropertyDataManager>() .GetCatelTypeInfo(GetType()) .GetCatelProperties() .Select(property => property.Value) .Where(property => property.IncludeInBackup && !property.IsModelBaseProperty) .ToArray(); _configurationProperties = properties .Where(property => !property.Type.IsSubclassOf(typeof(ConfigurationGroupBase))) .Select(property => { // ReSharper disable once PossibleNullReferenceException String configurationKeyBase = GetType() .FullName .Replace("+", ".") .Replace(typeof(ConfigurationModel).FullName + ".", string.Empty); configurationKeyBase = configurationKeyBase.Remove(configurationKeyBase.Length - "Configuration".Length); String configurationKey = $"{configurationKeyBase}.{property.Name}"; return new ConfigurationProperty(property, configurationKey); }) .ToArray(); _nestedConfigurationGroups = properties .Where(property => property.Type.IsSubclassOf(typeof(ConfigurationGroupBase))) .ToArray(); } ... private class ConfigurationProperty { public readonly PropertyData PropertyData; public readonly String ConfigurationKey; public ConfigurationProperty(PropertyData propertyData, String configurationKey) { PropertyData = propertyData; ConfigurationKey = configurationKey; } } } 

在这里,我们简单地转向Catel模型的反射模拟,获取属性(通过过滤实用程序或我们用[ExcludeFromBackup]属性明确标记的属性)并为它们生成字符串键。 本身属于ConfigurationGroupBase类型的属性在单独的列表中列出。


LoadFromStorage()方法将配置中的值写入到先前获得的属性中,或者将标准值写入LoadFromStorage()如果以前未保存的话)。 对于子组,其LoadFromStorage()称为:


 public void LoadFromStorage(IConfigurationService configurationService) { foreach (var property in _configurationProperties) { try { LoadPropertyFromStorage(configurationService, property.ConfigurationKey, property.PropertyData); } catch (Exception ex) { Log.Error(ex, "Can't load from storage nested configuration group {Name}", property.PropertyData.Name); } } foreach (var property in _nestedConfigurationGroups) { var configurationGroup = GetValue(property) as ConfigurationGroupBase; if (configurationGroup == null) { Log.Error("Can't load from storage configuration property {Name}", property.Name); continue; } configurationGroup.LoadFromStorage(configurationService); } } protected virtual void LoadPropertyFromStorage(IConfigurationService configurationService, String configurationKey, PropertyData propertyData) { var objectConverterService = this.GetDependencyResolver().Resolve<IObjectConverterService>(); Object value = configurationService.GetRoamingValue(configurationKey, propertyData.GetDefaultValue()); if (value is String stringValue) value = objectConverterService.ConvertFromStringToObject(stringValue, propertyData.Type, CultureInfo.InvariantCulture); SetValue(propertyData, value); } 

LoadPropertyFromStorage方法确定如何将值从配置转移到属性。 它是虚拟的,可以为非平凡的属性重新定义。


IConfigurationService服务的内部操作的一个小功能:您可以注意到IObjectConverterService的使用。 之所以需要它,是因为在这种情况下,使用类型为Object的通用参数IConfigurationService.GetValue Object并且在这种情况下,它不会将加载的字符串转换为数字,因此您需要自己执行此操作。


与保存参数类似:


 public void SaveToStorage(IConfigurationService configurationService) { foreach (var property in _configurationProperties) { try { SavePropertyToStorage(configurationService, property.ConfigurationKey, property.PropertyData); } catch (Exception ex) { Log.Error(ex, "Can't save to storage configuration property {Name}", property.PropertyData.Name); } } foreach (var property in _nestedConfigurationGroups) { var configurationGroup = GetValue(property) as ConfigurationGroupBase; if (configurationGroup == null) { Log.Error("Can't save to storage nested configuration group {Name}", property.Name); continue; } configurationGroup.SaveToStorage(configurationService); } } protected virtual void SavePropertyToStorage(IConfigurationService configurationService, String configurationKey, PropertyData propertyData) { Object value = GetValue(propertyData); configurationService.SetRoamingValue(configurationKey, value); } 

应该注意的是,在配置模型内部,您需要遵循简单的命名约定来获得统一的参数字符串键:


  • 设置组的类型(根目录除外)是“父”组的子类,其名称以“配置”结尾。
  • 对于每种此类类型,都有一个与之相对应的属性。 例如, ApplicationSettings组和Application属性。 该属性的名称不影响任何操作,但这是最合乎逻辑和预期的选项。

设置保存单个属性


Catel.Fody和IConfigurationService的自动IConfigurationService (直接将值保存在IConfigurationService[DefaultValue]属性中)仅适用于简单类型和恒定的默认值。 对于复杂的属性,您必须画一些真实的东西:


 public partial class ConfigurationModel { public class ApplicationConfiguration : ConfigurationGroupBase { public CultureInfo PreferredCulture { get; set; } [DefaultValue("User")] public String Username { get; set; } protected override void LoadPropertyFromStorage(IConfigurationService configurationService, String configurationKey, PropertyData propertyData) { switch (propertyData.Name) { case nameof(PreferredCulture): String preferredCultureDefaultValue = CultureInfo.CurrentUICulture.ToString(); if (preferredCultureDefaultValue != "en-US" || preferredCultureDefaultValue != "ru-RU") preferredCultureDefaultValue = "en-US"; String value = configurationService.GetRoamingValue(configurationKey, preferredCultureDefaultValue); SetValue(propertyData, new CultureInfo(value)); break; default: base.LoadPropertyFromStorage(configurationService, configurationKey, propertyData); break; } } protected override void SavePropertyToStorage(IConfigurationService configurationService, String configurationKey, PropertyData propertyData) { switch (propertyData.Name) { case nameof(PreferredCulture): Object value = GetValue(propertyData); configurationService.SetRoamingValue(configurationKey, value.ToString()); break; default: base.SavePropertyToStorage(configurationService, configurationKey, propertyData); break; } } } } 

现在,例如,我们可以在设置窗口中绑定到任何模型属性:


 <TextBox Text="{Binding Configuration.Application.Username}" /> 

关闭ViewModel设置窗口时,要记住要覆盖这些操作:


 protected override Task<Boolean> SaveAsync() { _applicationConfigurationProviderService.SaveChanges(); return base.SaveAsync(); } protected override Task<Boolean> CancelAsync() { _applicationConfigurationProviderService.LoadSettingsFromStorage(); return base.CancelAsync(); } 

随着参数数量的增加以及界面的复杂性的增加,您可以轻松地为每个设置部分创建单独的View和ViewModel。

Source: https://habr.com/ru/post/zh-CN460981/


All Articles