Introduction
When I first teamed up with Christian, the screensaver didn't have a very elegant way of persisting its settings. In the constructor for the screensaver form settings were loaded from the registry, and the same code was copied and pasted into the constructor for the Options dialog.
After my failure of trying to speed up the drawing process (curse you Microsoft for making CachedBitmap
an internal class), the first task I brought myself to was to try to clean up the settings code. What resulted is the Settings
class; which can be used for storing settings for the user or application.
This class differs slightly from the one in the screensaver because it has been refactored a bit but the premise behind it is the same.
Class Make-up
Unless specified, members are assumed to be public
instance.
- Properties
string SettingsDirectory
- (
static
) This is where settings will be saved if no filename is given to the Save/Load functions. This directory is the default ApplicationData
directory for the user.
- Methods
void SaveSettingsToFile(Settings settings)
void SaveSettingsToFile(string filename, Settings settings)
- (
static
) Saves the settings into the file, if a filename isn't specified, it defaults to SettingsDirectory\config.dat
Settings LoadSettingsFromFile()
Settings LoadSettingsFromFile(string filename)
- (
static
) Loads the settings from the file, if a filename isn't specified, it defaults to SettingsDirectory\config.dat. If the file doesn't exist or there is an error deserializing the file, it will return a new Settings
object with the default values.
void LoadDefaultValues()
- Loads the default settings into the properties.
- Fields
Hashtable settings
- (
protected
) Used to store the actual settings, based on a key; the key should be a string
representing the property name. This makes updating the class fast and easy because you don't have to mess with private
variables.
string companyName
- (private
, static
)
- Contains the Company Name that will be used to create the
SettingsDirectory
property.
string productName
- (private
, static
)
- Contains the name of the
Product
that will be used to create the SettingsDirectory
property.
How to Use the Class
- Alter the class to include your own properties:
- Add a new
public get
/set
property to the class. - In the set part, have it set the value in the
Hashtable
using the property name as a key. - In the get part, have it return the value stored in the
Hashtable
using the property name as the key (you will probably need to cast the value from the Hashtable
).
- Test to ensure there is no problem with the underlying types; if there are no problems, then the rest of the article is just information for you to digest. If there were problems, then read on to discover why there were problems and how you can work around them.
The XmlSerializer Class
The XmlSerializer
class does most of the work needed for saving the data in a class to a file. Unlike the other serializers, the XmlSerializer
requires the classes it serializes to have a public
default constructor (a public
constructor that takes no parameters) and it only serializes public
properties and fields.
Because of this, it isn't well suited for classes with lots of internal data, unless those can be completely rebuilt via public
properties/field. Because of this limitation, certain types won't be serialized by it, a couple examples are System.Drawing.Color
and System.Drawing.Font
. I don't have a list of all the classes that fail so you'll just have to use trial and error to find them, if serialization of the class isn't supported, an exception will be thrown when you try to create the XmlSerializer
or the resulting serialization will be an empty tag. I describe a couple techniques to work around this, and I provided fixes for both Color
and Font
classes so they work, with little effort.
Serializing and deserializing a class with the XmlSerializer
is really easy, as a result, I'm not going to spend much time on it; but instead I'll spend most of my time describing how to work around its limitations. Simply create a new instance of the XmlSerializer
class passing in the Type
that corresponds to your class.
XmlSerializer xs = new XmlSerializer(typeof(Settings));
To serialize a class, call Serialize()
and pass in a Stream
and an instance of the class you wish to serialize. To deserialize, call Deserialize()
and pass in the Stream
to read from, and cast the return value back to your class type.
Working Around the Limitations
Custom Parsing
System.Drawing.Color
is the first limitation I worked around. I did this by first deciding how I was going to store the data in the XML file. I chose a string
delimited by colons (:
). This string
is made up of two parts, the first part tells what type of data follows, either a color name or ARGB values. The second part is either a name or the values split up by colons.
First, I created an enumeration that would signify each type.
public enum ColorFormat
{
NamedColor,
ARGBColor
}
Then with Color
in hand, I can reference the IsNamedColor
property to decide which of the two formats to return. I do that with this bit of code.
public string SerializeColor(Color color)
{
if( color.IsNamedColor )
return string.Format("{0}:{1}",
ColorFormat.NamedColor, color.Name);
else
return string.Format("{0}:{1}:{2}:{3}:{4}",
ColorFormat.ARGBColor,
color.A, color.R, color.G, color.B);
}
If you inspect the return value, you'll see that what is written for the ColorFormat
specifier is the name of the enum
value. This causes a slight problem, but is quickly remedied by the Enum
class having a Parse
method which will convert the name back to a value.
Speaking of reading the values back, here is how I did it. First, I take the string
in and split it up using colon as the separation character. This results in a string
array with 2 or 5 elements in it. I then take the first element and run it through Enum
's Parse()
method to convert it back to a value suitable for the enum
. Then I check that value to determine which of the two formats it is.
public Color DeserializeColor(string color)
{
byte a, r, g, b;
string [] pieces = color.Split(new char[] {':'});
ColorFormat colorType = (ColorFormat)
Enum.Parse(typeof(ColorFormat), pieces[0], true);
switch(colorType)
{
case ColorFormat.NamedColor:
return Color.FromName(pieces[1]);
case ColorFormat.ARGBColor:
a = byte.Parse(pieces[1]);
r = byte.Parse(pieces[2]);
g = byte.Parse(pieces[3]);
b = byte.Parse(pieces[4]);
return Color.FromArgb(a, r, g, b);
}
return Color.Empty;
}
With that code, it is now possible serialize a Color
object as a string
; which the XmlSerializer
will handle. How does one go about doing that, and make the resulting XML look as though it handled it natively? By using two attributes, you can change the name an element will have in the XML document and tell it ignore other elements as well.
The attribute XmlIgnoreAttribute
when applied to a property or field tells the XmlSerializer
to ignore that field/property when serializing the class. The attribute XmlElementAttribute
does various functions, but the one we're interested in is renaming a serialized element to something else. For our purposes, we will apply the XmlIgnore
attribute to the original property which can't be serialized; then we'll apply the XmlAttribute
to the XmlSerializer
friendly property to rename it to something more suitable.
A Small Example
[XmlIgnore()]
public Color ColorType
{
get
{
return (Color) settings["color"];
}
set
{
settings["color"] = value;
}
}
[XmlElement("ColorType")]
public string XmlColorType
{
get
{
return Settings.SerializeColor(ColorType);
}
set
{
ColorType = Settings.DeserializeColor(value);
}
}
Here, you see that I have a public
property named ColorType
that will return the Color
stored in the settings object. There is also a string
property that is used by the XmlSerializer
to store the underlying value.
ColorType
is the property that the user of the class is to use for setting/retrieving properties. XmlColorType
is used by the XmlSerializer
to get/set the underlying value and should not be used by the programmer.
Wrapper Class/Struct
Another way to work around the limitation is to create a class that exposes only the properties needed to recreate the object, and use that to serialize.
This is what I did for the Font
class; exposing the FontFamily
, Size
, FontStyle
, and the GraphicsUnit
associated with a Font
instance. In keeping with the pattern I used for Color
; I supply a property that uses this class, and I have two protected
methods which handle moving back and forth between the two types.
XmlFont Struct
public struct XmlFont
{
public string FontFamily;
public GraphicsUnit GraphicsUnit;
public float Size;
public FontStyle Style;
public XmlFont(Font f)
{
FontFamily = f.FontFamily.Name;
GraphicsUnit = f.Unit;
Size = f.Size;
Style = f.Style;
}
public Font ToFont()
{
return new Font(FontFamily, Size, Style,
GraphicsUnit);
}
}
This is an extremely simple struct
. Its entire purpose is to be a lightweight container for the values that will be persisted to the file I don't bother defining properties, and I made it a struct
instead of a class so it *is* lightweight. Depending on your needs, you could use a heavier implementation; but for this case, this works great.
This works because the XmlSerializer
will attempt to serialize every property and field, if the property or field is a simple datatype
, it puts it inline, for struct
s and classes, it serializes its public
properties/fields.
Conclusion
I've often wondered why it is that MS didn't let the XmlSerializer
work like the Formatter
s, serializing both public
and private
data. After toying around with the Settings
class a bit, I think I discovered why. There isn't a need! The XmlSerializer
was made to persist data in a user-readable way, but still be read easily by a program. Publishing private
data would be considered a Bad Thing™ and thus would make life difficult for those trying to keep private
data private
. Though you could go through and add NonSerializable
attributes to all your private
data, you would be SOL when it came to remoting.
Considering that the workaround for the deficiencies isn't all that difficult to get working, I'm coming to agree with Microsoft's decision.
As always, bug reports should be posted below, comments or questions can be posted or e-mailed to me.