- Mastering Windows Presentation Foundation
- Sheridan Yuen
- 4743字
- 2021-06-24 16:49:06
Constructing a custom application framework
There will be different requirements for different components, but typically, the properties and functionality that we build into our Data Model base classes will be utilized and made more useful by our other base classes, so let's start by looking at the various Data Model base classes first.
One thing that we need to decide is whether we want any of our Data Model base classes to be generic or not. The difference can be subtle, but important. Imagine that we want to add some basic undo functionality into a base class. One way that we can achieve this would be to add an object into the base class that represents the unedited version of the Data Model. In an ordinary base class, it would look like this:
public abstract class BaseSynchronizableDataModel : BaseDataModel { private BaseSynchronizableDataModel originalState;
public BaseSynchronizableDataModel OriginalState
{ get { return originalState; } private set { originalState = value; } } }
In a generic base class, it would look like this:
public abstract class BaseSynchronizableDataModel<T> : BaseDataModel { private T originalState; public T OriginalState { get { return originalState; } private set { originalState = value; } } }
To make this property more useful, we'll need to add some further methods. First, we'll see the non-generic versions:
public abstract void CopyValuesFrom(BaseSynchronizableDataModel dataModel);
public virtual BaseSynchronizableDataModel Clone()
{
BaseSynchronizableDataModel clone =
Activator.CreateInstance(this.GetType()) as BaseSynchronizableDataModel;
clone.CopyValuesFrom(this);
return clone;
}
public abstract bool PropertiesEqual(BaseSynchronizableDataModel dataModel);
Now, let's look at the generic versions:
public abstract void CopyValuesFrom(T dataModel); public virtual T Clone() { T clone = new T(); clone.CopyValuesFrom(this as T); return clone; } public abstract bool PropertiesEqual(T dataModel);
The last few members of this base class would be the same for both versions:
public bool HasChanges { get { return originalState != null && !PropertiesEqual(originalState); } } public void Synchronize() { originalState = this.Clone(); NotifyPropertyChanged(nameof(HasChanges)); } public void RevertState() { Debug.Assert(originalState != null, "Object not yet synchronized."); CopyValuesFrom(originalState); Synchronize(); NotifyPropertyChanged(nameof(HasChanges)); }
We started with the OriginalState property which holds the unedited version of the Data Model. After that, we see the abstract CopyValuesFrom method that the developers will need to implement and we'll see an example of that implementation shortly. The Clone method simply calls the CopyValuesFrom method in order to perform a deep clone of the Data Model.
Next, we have the abstract PropertiesEqual method that the developers will need to implement in order to compare each property in their classes with those from the dataModel input parameter. Again, we'll see this implementation shortly, but you may be wondering why we don't just override the Equals method, or implement the IEquatable.Equals method for this purpose.
The reason why we don't want to use either of those methods is because they, along with the GetHashCode method, are used by the WPF Framework in various places and they expect the returned values to be immutable. As our object's properties are very much mutable, they cannot be used to return the values for those methods. Therefore, we have implemented our own version. Now, let's return to the description of the remainder of this code.
The HasChanges property is the property that we would want to data bind to a UI control to indicate whether a particular object had been edited. The Synchronize method sets a deep clone of the current Data Model to the originalState field and, importantly, notifies the WPF Framework of a change to the HasChanges property. This is done because the HasChanges property has no setter of its own and this operation will affect its value.
It is very important that we set a cloned version to the originalState field, rather than simply assigning the actual object reference to it. This is because we need to have a completely separate version of this object to represent the unedited version of the Data Model. If we simply assigned the actual object reference to the originalState field, then its property values would change along with the Data Model object and render it useless for this feature.
The RevertState method first checks that the Data Model has been synchronized and then copies the values back from the originalState field to the Model. Finally, it calls the Synchronize method to specify that this is the new, unedited version of the object and notifies the WPF Framework of a change to the HasChanges property.
So, as you can see, there are not many differences between these two versions of the base class. In fact, the differences can be seen more clearly in the implementation of the derived classes. Let's now focus on their implementations of the example abstract methods, starting with the non-generic versions:
public override bool PropertiesEqual(BaseClass genreObject) { Genre genre = genreObject as Genre;
if (genre == null) return false; return Name == genre.Name && Description == genre.Description; } public override void CopyValuesFrom(BaseClass genreObject) { Debug.Assert(genreObject.GetType() == typeof(Genre), "You are using the wrong type with this method."); Genre genre = (Genre)genreObject; Name = genre.Name; Description = genre.Description; }
Before discussing this code, let's first see the generic implementations:
public override bool PropertiesEqual(Genre genre) { return Name == genre.Name && Description == genre.Description; } public override void CopyValuesFrom(Genre genre) { Name = genre.Name; Description = genre.Description; }
At last, we can see the difference between using generic and non-generic base classes. Without using generics, we have to use base class input parameters, which will need to be cast to the appropriate type in each of the derived classes before we can access their properties. Attempting to cast inappropriate types causes Exceptions, so we generally try to avoid these situations.
On the other hand, when using a generic base class, there is no need to cast, as the input parameters are already of the correct type. In short, generics enable us to create type-safe Data Models and avoid duplicating type specific code. Now that we have seen the benefit of using generic classes, let's take a pause from generics for a moment and look at this base class a bit closer.
Some of you may have noticed that the only places where the WPF Framework is notified of changes to our HasChanges property is in the Synchronize and RevertState methods. However, in order for this functionality to work properly, we need to notify the framework every time the values of any properties are changed.
We could rely on the developers to call the NotifyPropertyChanged method, passing the HasChanges property name each time they call it for each property that changes, but if they forgot to do this, it could lead to errors that could be difficult for them to track down. Instead, a better solution would be for us to override the default implementation of the INotifyPropertyChanged interface from the base class and notify changes to the HasChanges property for them each time it is called:
#region INotifyPropertyChanged Members protected override void NotifyPropertyChanged( params string[] propertyNames) { if (PropertyChanged != null) { foreach (string propertyName in propertyNames) { if (propertyName != nameof(HasChanges)) PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); } PropertyChanged(this, new PropertyChangedEventArgs(nameof(HasChanges))); } } protected override void NotifyPropertyChanged( [CallerMemberName]string propertyName = "") { if (PropertyChanged != null) { if (propertyName != nameof(HasChanges)) PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); PropertyChanged(this, new PropertyChangedEventArgs(nameof(HasChanges))); } } #endregion
The first method will raise the PropertyChanged event, passing the name of the HasChanges property just once, regardless of how many property names were passed to the method. The second method also performs a check to ensure that it will refrain from raising the event with the HasChanges property name more than once, so these implementations remain efficient.
Now, our base class will work as expected and the HasChanges property will correctly update when other properties in the Data Model classes are changed. This technique can also be utilized in other scenarios; for example, when validating our property values, as we'll see later in Chapter 9, Implementing Responsive Data Validation. For now though, let's return to see what else we can achieve with generics.
Another area where generics are often used relates to collections. I'm sure that you're all aware that we tend to use the ObservableCollection<T> class in WPF applications because of its INotifyCollectionChanged and INotifyPropertyChanged implementations. It is customary, but not essential, to extend this class for each type of Data Model class that we have:
public class Users : ObservableCollection<User>
However, instead of doing this, we can declare a BaseCollection<T> class that extends the ObservableCollection<T> class and adds further functionality into our framework for us. The users of our framework can then extend this class instead:
public class Users : BaseCollection<User>
One really useful thing that we can do is to add a generic property of type T into our base class, that which will represent the currently selected item in a data bound collection control in the UI. We could also declare some delegates to notify developers of changes to either selection or property values. There are so many shortcuts and helper methods that we can provide here, dependent on requirements, so it's worth spending some time investigating this. Let's take a look at a few possibilities:
using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; using System.Runtime.CompilerServices; using CompanyName.ApplicationName.Extensions; namespace CompanyName.ApplicationName.DataModels.Collections { public class BaseCollection<T> : ObservableCollection<T>, INotifyPropertyChanged where T : class, INotifyPropertyChanged, new() { protected T currentItem; public BaseCollection(IEnumerable<T> collection) : this() { foreach (T item in collection) Add(item); } public BaseCollection(params T[] collection) : this(collection as IEnumerable<T>) { } public BaseCollection() : base() { currentItem = new T(); } public virtual T CurrentItem { get { return currentItem; } set { T oldCurrentItem = currentItem; currentItem = value; CurrentItemChanged?.Invoke(oldCurrentItem, currentItem); NotifyPropertyChanged(); } } public bool IsEmpty { get { return !this.Any(); } } public delegate void ItemPropertyChanged(T item, string propertyName); public virtual ItemPropertyChanged CurrentItemPropertyChanged { get; set; } public delegate void CurrentItemChange(T oldItem, T newItem); public virtual CurrentItemChange CurrentItemChanged { get; set; } public T GetNewItem() { return new T(); } public virtual void AddEmptyItem() { Add(new T()); } public virtual void Add(IEnumerable<T> collection) { collection.ForEach(i => base.Add(i)); } public virtual void Add(params T[] items) { if (items.Length == 1) base.Add(items[0]); else Add(items as IEnumerable<T>); } protected override void InsertItem(int index, T item) { if (item != null) { item.PropertyChanged += Item_PropertyChanged; base.InsertItem(index, item); if (Count == 1) CurrentItem = item; } } protected override void SetItem(int index, T item) { if (item != null) { item.PropertyChanged += Item_PropertyChanged; base.SetItem(index, item); if (Count == 1) CurrentItem = item; } } protected override void ClearItems() { foreach (T item in this) item.PropertyChanged -= Item_PropertyChanged; base.ClearItems(); } protected override void RemoveItem(int index) { T item = this[index]; if (item != null) item.PropertyChanged -= Item_PropertyChanged; base.RemoveItem(index); } public void ResetCurrentItemPosition() { if (this.Any()) CurrentItem = this.First(); } private void Item_PropertyChanged(object sender, PropertyChangedEventArgs e) { if ((sender as T) == CurrentItem)
CurrentItemPropertyChanged?.Invoke(currentItem, e.PropertyName); NotifyPropertyChanged(e.PropertyName); } #region INotifyPropertyChanged Members ... #endregion } }
There's quite a lot to digest here, so let's go over each part carefully. We start with our private member of type T that will back our CurrentItem property. We then find a few overloads of the constructor that enable us to initialize our collection from either a collection, or any number of input parameters of the relevant type.
Next, we see the CurrentItem property from Chapter 1, A Smarter Way of Working with WPF, again, but now with some further context. If a class has subscribed to the CurrentItemChanged property, we will call the delegate from here, passing both the new and old values of the current item. The IsEmpty property is just an efficient convenience property for our developers to call when they need to know whether the collection has any content or not.
After this, we see the collection delegates and the relevant property wrappers that enable the developers that will use our framework to make use of them. Next, we see the convenient GetNewItem and AddEmptyItem methods, which both generate a new item of the T generic type parameter, before returning or adding them to the collection, respectively. This is the reason that we needed to add the new() generic type constraint to the class definition; this type constraint specifies that the generic type used must have a parameterless constructor.
And now we reach the various Add methods of the collection; note that every way to add an item to the collection must be handled, so that we can attach our Item_PropertyChanged handler to the PropertyChanged event of each added item to ensure consistent behavior.
We therefore call our Add methods from all other overloads and helper methods and call the base Collection.Add method from there. Note that we actually attach our handler inside the protected InsertItem method, as this overridden method is called from the Add methods in the Collection class.
Likewise, the protected SetItem method will be called by the Collection class when items are set using the index notation, so we must handle that too. Similarly, when items are removed from the collection, it is equally, if not more, important to remove the reference to our event handler from each object. Failing to do so can result in memory leaks, as the reference to the event handler can keep the Data Model objects from being disposed by the garbage collector.
As such, we also need to handle every method of removing objects from our collection. To do this, we override a few more protected methods from the Collection base class. The ClearItems method will be called internally when users call the Clear method on our collection. Equally, the RemoveItem method will be called when users call any of the public removal methods, so it is the optimal place to remove our handler.
Skipping the ResetCurrentItemPosition method for now, at the bottom of the class, we reach the Item_PropertyChanged event handling method. If the item that has had the property changed is the current item in the collection, then we raise the ItemPropertyChanged delegate that is connected with the CurrentItemPropertyChanged property.
For every property change notification, regardless of whether the item is the current item or not, we then raise the INotifyPropertyChanged.PropertyChanged event. This enables developers that use our framework to be able to attach a handler to the PropertyChanged event directly on our collections and to be able to discover when any property has been changed on any of the items in the collection.
You may also have noticed a few places in the collection class code where we set the value of the CurrentItem property. The option chosen here is to always select the first item in the collection automatically, but it would be a simple change to have the last item selected instead, for example. As always, these kinds of details will depend on your specific requirements.
Another benefit of declaring these base collection classes is that we can utilize the properties and extend the functionality that is built into our base Data Model classes. Thinking back to the simple example of our BaseSynchronizableDataModel class, let's see what we could add into a new base collection class to improve this functionality.
Before we can do this however, we need to be able to specify that the objects in our new collection have implemented the properties and methods from the BaseSynchronizableDataModel class. One option would be to declare our new collection class like this:
public class BaseSynchronizableCollection<T> : BaseCollection<T> where T : BaseSynchronizableDataModel<T>
However, in C#, we can only extend a single base class, while we are free to implement as many interfaces as we like. A more preferable solution would therefore be for us to extract the relevant synchronization properties from our base class into an interface, and then add that to our base class definition:
public abstract class BaseSynchronizableDataModel<T> : BaseDataModel, ISynchronizableDataModel<T> where T : BaseDataModel, ISynchronizableDataModel<T>, new()
We could then specify this new generic constraint on our new collection class like this:
public class BaseSynchronizableCollection<T> : BaseCollection<T> where T : class, ISynchronizableDataModel<T>, new()
Note that any other generic constraints that are placed on the BaseSynchronizableDataModel class will also need to be added to the where T part of this declaration. If, for example, we needed to implement another interface in the base class and we did not add the same constraint for the T generic type parameter in the base collection class, then we would get a compilation error when attempting to use instances of our base class as the T parameter. Let's now look at this new base class:
using System.Collections.Generic; using System.ComponentModel; using System.Linq; using CompanyName.ApplicationName.DataModels.Interfaces; using CompanyName.ApplicationName.Extensions; namespace CompanyName.ApplicationName.DataModels.Collections { public class BaseSynchronizableCollection<T> : BaseCollection<T> where T : class, ISynchronizableDataModel<T>, INotifyPropertyChanged, new() { public BaseSynchronizableCollection(IEnumerable<T> collection) : base(collection) { } public BaseSynchronizableCollection(params T[] collection) : base(collection as IEnumerable<T>) { } public BaseSynchronizableCollection() : base() { } public virtual bool HasChanges { get { return this.Any(i => i.HasChanges); } } public virtual bool AreSynchronized { get { return this.All(i => i.IsSynchronized); } } public virtual IEnumerable<T> ChangedCollection { get { return this.Where(i => i.HasChanges); } } public virtual void Synchronize() { this.ForEach(i => i.Synchronize()); } public virtual void RevertState() { this.ForEach(i => i.RevertState()); } } }
While remaining simple, this base collection class provides some powerful functionality. We start off with the class declaration, with its generic type constraints that are inherited from both our target T type classes and our BaseCollection<T> class. We've then implemented the constructor overloads and passed initialization duties straight to the base class.
Note that had we wanted to attach an additional level of event handlers to our collection items, we would follow the pattern from the base class, rather than calling the base class constructors in this way.
The HasChanges property can be used as a flag to detect whether any item in the collection has any changes or not. This would typically be tied to the canExecute parameter of a save command, so that the save button would become enabled when any item in the collection had been edited and disabled if the changes were undone.
The AreSynchronized property simply specifies whether the items in the collection have all been synchronized or not, but the real beauty of this class is in the ChangedCollection property. Using a simple LINQ filter, we return only the items from the collection that have changes. Imagine a scenario where we enable the user to edit multiple items at once. With this property, our developers could extract just the items that they need to save from the collection with zero effort.
Finally, this class provides one method to enable the synchronization of all of the items in the collection at once and another to undo the changes of all of the edited items in the collection likewise. Note the use of the custom ForEach Extension Method in these last two methods; if you remember from the earlier With Extension Methods section, it enables us to perform an action on each item in the collection.
Through the use of the properties and methods of our Data Model base classes by other parts of our framework, we are able to extend their functionality further. While building composite functionality from different components in this way is generally optional, it can also be necessary, as we'll see later in the book.
The more common functionality that we can build into our application framework base classes, the less work the developers that use our framework will have to do when developing the application. However, we must plan carefully and not force the developers to have unwanted properties and methods in order to extend a particular base class that has some other functionality that they do want.
Typically, there will be different requirements for different components. The Data Model classes will generally have more base classes than View Models because they play a bigger role than View Models. The View Models simply provide the Views with the data and functionality that they require. However, the Data Model classes contain the data, along with validation, synchronization, and possibly animation methods and properties. With this in mind, let's look again at the View Model base class.
We have already seen that we will need an implementation of the INotifyPropertyChanged interface in our base class, but what else should we implement? If every View will be providing some specific functionality, such as saving and deleting items for example, then we can also add commands straight into our base class and abstract methods that each derived View Model class will have to implement:
public virtual ICommand Refresh { get { return new ActionCommand(action => RefreshData(), canExecute => CanRefreshData()); } } protected abstract void RefreshData(); protected abstract bool CanRefreshData();
Again, it is important to declare this command as being virtual, in case the developers need to provide their own, different implementation of it. An alternative to this arrangement would be to just add abstract properties for each command, so that the individual implementations would be completely up to the developers:
public abstract ICommand Save { get; }
While on the subject of commands, you may remember our basic implementation of ActionCommand from Chapter 1, A Smarter Way of Working with WPF. At this point, it is worth taking a short detour to investigate this further. Note that while the basic implementation shown works well most of the time, it can catch us out occasionally and we may notice that a button hasn't become enabled when it should have.
Let's look at an example of this. Imagine that we have a button in our UI that opens a folder for the user to view files from and is enabled when a certain condition is met in the ICommand.CanExecute method. Let's say that this condition is that the folder should have some content. After all, there's no point in opening an empty folder for the user.
Now, let's imagine that this folder will be filled when the user performs some other operation in the UI. The user clicks the button that starts this folder-filling function and the application begins to fill it. At the point that the filling function is complete and the folder now holds some content, the open folder button should become enabled, as its associated command's CanExecute condition is now true.
Nevertheless, the CanExecute method won't be called at that point and why should it? The button and, indeed, the CommandManager class has no idea that this background process was occurring and that the condition of the CanExecute method has now been met. Luckily, we have a couple of options to address this situation.
One option is to raise the CanExecuteChanged event manually to make the data bound command sources recheck the output of the CanExecute method and update their enabled state accordingly. To do this, we could add another method into our ActionCommand class, but we would have to rearrange a few things first.
The current implementation doesn't store any references to the event handlers that get attached to the CanExecuteChanged event. They're actually being stored in the CommandManager class, as they're just passed straight through for the RequerySuggested event to handle. In order to be able to raise the event manually, we'll need to store our own references to the handlers and, to do that, we'll need an EventHandler object:
private EventHandler eventHandler;
Next, we'll need to add the references to the handlers that get attached and remove those that get detached, while still passing references of them through to the RequerySuggested event of the CommandManager:
public event EventHandler CanExecuteChanged { add { eventHandler += value; CommandManager.RequerySuggested += value; } remove { eventHandler -= value; CommandManager.RequerySuggested -= value; } }
The final change to our ActionCommand class is to add the method that we can call to raise the CanExecuteChanged event when we want the command sources of the UI controls to retrieve the new CanExecute value and update their enabled states:
public void RaiseCanExecuteChanged() { eventHandler?.Invoke(this, new EventArgs()); }
We are now able to raise the CanExecuteChanged event whenever we need to, although we'll also need to change our use of the ActionCommand class to do so. Whereas previously, we were simply returning a new instance each time its getter was called, we'll now need to keep a reference to each command that we want to have this ability:
private ActionCommand saveCommand = null; ... public ICommand SaveCommand { get { return saveCommand ?? (saveCommand = new ActionCommand(action => Save(), canExecute => CanSave())); } }
If you are unfamiliar with the ?? operator shown in the preceding code, it is known as the null-coalescing operator and simply returns the left-hand operand if it is not null, or the right-hand operand if it is. In this case, the right-hand operand will initialize the command and set it to the saveCommand variable. Then, to raise the event, we call the new RaiseCanExecuteChanged method on our ActionCommand instance when we have completed our operation:
private void ExecuteSomeCommand() { // Perform some operation that fulfills the canExecute condition // then raise the CanExecuteChanged event of the ActionCommand saveCommand.RaiseCanExecuteChanged(); }
While our method is built into the ActionCommand class, at times we may not have access to the particular instance that we need to raise the event on. It should therefore be noted at this point that there is another, more direct way that we can get the CommandManager class to raise its RequerySuggested event.
In these cases, we can simply call the CommandManager.InvalidateRequerySuggested method. We should also be aware that these methods of raising the RequerySuggested event will only work on the UI thread, so care should be taken when using them with asynchronous code. Now that our short command-related detour is complete, let's return to take a look at what other common functionality we might want to put into our View Model base class.
If we have chosen to use generic base classes for our Data Models, then we can take advantage of that in our BaseViewModel class. We can provide generic methods that utilize members from these generic base classes. Let's take a look at some simple examples:
public T AddNewDataTypeToCollection<S, T>(S collection) where S : BaseSynchronizableCollection<T> where T : BaseSynchronizableDataModel<T>, new() { T item = collection.GetNewItem(); if (item is IAuditable) ((IAuditable)item).Auditable.CreatedOn = DateTime.Now; item.Synchronize(); collection.Add(item); collection.CurrentItem = item; return item; } public T InsertNewDataTypeToCollection<S, T>(int index, S collection) where S : BaseSynchronizableCollection<T> where T : BaseSynchronizableDataModel<T>, new() { T item = collection.GetNewItem(); if (item is IAuditable) ((IAuditable)item).Auditable.CreatedOn = DateTime.Now; item.Synchronize(); collection.Insert(index, item); collection.CurrentItem = item; return item; } public void RemoveDataTypeFromCollection<S, T>(S collection, T item) where S : BaseSynchronizableCollection<T> where T : BaseSynchronizableDataModel<T>, new() { int index = collection.IndexOf(item); collection.RemoveAt(index); if (index > collection.Count) index = collection.Count; else if (index < 0) index++; if (index > 0 && index < collection.Count && collection.CurrentItem != collection[index]) collection.CurrentItem = collection[index]; }
Here, we see three simple methods that encapsulate more common functionality. Note that we must specify the same generic type constraints that are declared on our bass classes. Failure to do so would either result in compilation errors or us not being able to use our Data Model classes with these methods.
The AddNewDataTypeToCollection and InsertNewDataTypeToCollection methods are almost identical and start by creating a new item of the relevant type using the GetNewItem method of our generic BaseSynchronizableCollection class. Next, we see another use for our IAuditable interface. In this case, we set the CreatedOn date of the new item if it implements this interface.
Because we declared the generic type constraint on the T-type parameter that specifies that it must be, or extend, the BaseSynchronizableDataModel class, we are able to call the Synchronize method to synchronize the new item. We then add the item to the collection and set it as the value of the CurrentItem property. Finally, both methods return the new item.
The last method performs the opposite action; it removes an item from the collection. Before doing so, it checks the item's position in the collection and sets the CurrentItem property to the next item if possible, or the next nearest item if the removed item was the last item in the collection.
Once again, we see how we can encapsulate commonly used functionality into our base class and save the users of our framework both time and effort in reimplementing this functionality in each View Model class. We can package up any common functionality that we require in this manner. Having now seen several examples of providing functionality in our base classes, let's now turn our attention to providing separation between the components of our framework.
- Intel Galileo Essentials
- 國際大學生程序設計競賽中山大學內部選拔真題解(二)
- Visual Basic程序開發(學習筆記)
- Magento 2 Theme Design(Second Edition)
- Python應用輕松入門
- Java Web基礎與實例教程
- MATLAB實用教程
- Python 3破冰人工智能:從入門到實戰
- Visual Basic程序設計上機實驗教程
- Python全棧數據工程師養成攻略(視頻講解版)
- Bootstrap for Rails
- Everyday Data Structures
- 交互式程序設計(第2版)
- INSTANT Apache Hive Essentials How-to
- Hands-On Dependency Injection in Go