📜 ⬆️ ⬇️

MVVM implementation of a WPF application configuration built on the basis of the Catel framework

Implementing software settings management is probably one of those things that they implement in their own way in almost every application. Most frameworks and other add-ons usually provide their own means for saving / loading values ​​from some key-value of parameter storage.


However, in most cases, the implementation of a specific settings window and related many things is left to the discretion of the user. In this article I want to share the approach that I managed to come to. In my case, I need to implement work with settings in the MVVM-friendly style and using the specifics of the Catel framework used in this case.


Disclaimer : in this note there will be no technical subtleties more difficult than basic reflection. This is just a description of the approach to solving a small problem that I got over the weekend. I wanted to think about how to get rid of the standard boilerplate code and copy-paste related to saving / loading application settings. The solution itself turned out to be rather trivial thanks to the convenient .NET / Catel tools available, but maybe someone will save a couple of hours of time or suggest useful thoughts.


Catel Framework Brief

Like other WPF frameworks (Prism, MVVM Light, Caliburn.Micro, etc.), Catel provides convenient tools for building applications in the MVVM style.
The main components:


  • IoC (integrated with MVVM components)
  • ModelBase: a base class that provides an automatic implementation of PropertyChanged (especially in conjunction with Catel.Fody), serialization, and BeginEdit / CancelEdit / EndEdit (classic "apply" / "cancel").
  • ViewModelBase, able to bind to the model, wrapping its properties.
  • Working with views that can automatically create and bind to ViewModel. Nested controls are supported.

Requirements


We will proceed from the fact that we want the following from configuration tools:



Standard Tools


Catel provides the IConfigurationService service, which allows storing and loading values ​​of string keys from local storage (a file on disk in the standard implementation).


If we want to use this service in its pure form, we will have to declare these keys ourselves, for example, by setting such constants:


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

Then we can get these parameters like this:


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

It’s a lot and tedious to write, it is easy to make typos when there are many settings. In addition, the service supports only simple types, for example CultureInfo cannot be saved without additional transformations.


To simplify the work with this service, a wrapper consisting of several components was obtained.


The full sample code is available in the GitHub repository . It contains the simplest application with the ability to edit a couple of parameters in the settings and make sure that everything works. I didn’t bother with localization, the “Language” parameter in the settings is used solely to demonstrate the operation of the configuration. If interested, Catel has convenient localization mechanisms , including at the WPF level. If you don’t like resource files, you can make your own implementation working with GNU gettext, for example.


For ease of reading, in the code examples in the text of this publication, all xml-doc comments have been removed.



Configuration service


A service that can be embedded through IoC and have access to work with settings from anywhere in the application.


The main objective of the service is to provide a settings model, which in turn provides a simple and structured way to access them.


In addition to the settings model, the service also provides the ability to undo or save changes made to the settings.


Interface:


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

Implementation:


 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); } } 

The implementation is trivial, the contents of the ConfigurationModel described in the following sections. The only thing that probably attracts attention is the ApplyMigrations method.


In the new version of the program, something may change, for example, the method of storing some complex parameter or its name. If we do not want to lose our settings after each update that changes existing parameters, we need a migration mechanism. The ApplyMigrations method ApplyMigrations very simple support for performing any actions during the transition between versions.


If something has changed in the new version of the application, we simply add the necessary actions (for example, saving the parameter under a new name) in the new version to the list of migrations contained in the neighboring file:


  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; } } 

Settings Model


Automation of routine operations is as follows. The configuration is described as a regular model (data-object). Catel provides a convenient base class ModelBase , which is the core of all its MVVM tools, for example, automatic bindings between all three MVVM components. In particular, it allows you to easily access the model properties that we want to save.


By declaring such a model, we can get its properties, map string keys to them, create them from property names, and then automatically load and save values ​​from the configuration. In other words, bind properties and values ​​in a configuration.


Declaring configuration options


This is the root model:


 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; } } 

ApplicationConfiguration and PerfomanceConfiguration are subclasses that describe their settings groups:


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

Under the hood, this property will be associated with the parameter "Performance.MaxUpdatesPerSecond" , the name of which is generated from the name of the type PerformanceConfiguration .


It should be noted that the ability to declare these properties was so concise thanks to the use of Catel.Fody , a plug-in to the well-known .NET code generator Fody . If for some reason you do not want to use it, properties should be declared as usual, according to the documentation (visually similar to DependencyProperty from WPF).


If desired, the level of nesting can be increased.


Implement Property Binding with IConfigurationService


Binding occurs in the base class ConfigurationGroupBase , which in turn is inherited from ModelBase. Consider its contents in more detail.


First of all, we make a list of properties that we want to save:


 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; } } } 

Here we simply turn to the analogue of reflection for Catel models, get properties (by filtering utility or those that we explicitly marked with the [ExcludeFromBackup] attribute) and generate string keys for them. Properties that themselves are of type ConfigurationGroupBase listed in a separate list.


The LoadFromStorage() method writes values ​​from the configuration to standard properties that were previously obtained or standard if they were not previously saved. For subgroups, their LoadFromStorage() called:


 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); } 

The LoadPropertyFromStorage method determines how the value is transferred from the configuration to the property. It is virtual and can be redefined for non-trivial properties.


A small feature of the internal operation of the IConfigurationService service: you can notice the use of IObjectConverterService . It is needed because IConfigurationService.GetValue in this case is called with a generic parameter of type Object and in this case it will not convert the loaded strings to numbers, for example, therefore, we need to do this ourselves.


Similarly with saving parameters:


 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); } 

It should be noted that inside the configuration model, you need to follow simple naming conventions to get uniform parameter string keys:



Setting save individual properties


The auto- IConfigurationService Catel.Fody and IConfigurationService (direct saving of the value in IConfigurationService and the [DefaultValue] attribute) will work only for simple types and constant default values. For complex properties, you have to paint a little more authentic:


 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; } } } } 

Now we can, for example, in the settings window bind to any of the model properties:


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

It remains to remember to override the operations when closing the ViewModel settings window:


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

With an increase in the number of parameters and accordingly the complexity of the interface, you can easily create separate View and ViewModel for each section of the settings.


')

Source: https://habr.com/ru/post/460981/


All Articles