概要
C#のアプリケーション設定は便利ですよね。
取得・変更・保存が簡単にできます。
ところが、編集画面やインポート/エクスポートを作ろうとしたら結構ハマってしまいました。
解決策がなかなか見つからなかったので、書き残しておきます。
サンプルコード
以下に実際に動作するコードを置いてます。
https://github.com/minoru-nagasawa/SampleApplicationSettingsDialog
PropertyGridにオブジェクトのコピーを設定
PropertyGridにアプリケーション設定の実体(Settings.Default)をセットすると、保存しなくても実体が変更されてしまいます。
それを防ぐため、コピーを作成して設定します。
ただし、StringCollection型は注意が必要です。
これをコピーしただけでは、その中の個々のstringは同じ実体を参照してしまいます。
それにより、コピーしたテキストを変更したつもりが、本物の設定が変更されてしまいます。
それを防ぐためにディープコピーしたオブジェクトを設定します。
/// <summary>
/// オブジェクトのディープコピーを作成する
/// </summary>
private static T deepCopy<T>(T src)
{
using (var memoryStream = new System.IO.MemoryStream())
{
var binaryFormatter = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter();
binaryFormatter.Serialize(memoryStream, src);
memoryStream.Seek(0, System.IO.SeekOrigin.Begin);
return (T)binaryFormatter.Deserialize(memoryStream);
}
}
/// <summary>
/// 設定のコピーを保管する
/// </summary>
private Settings copiedSettings;
/// <summary>
/// コンストラクタ
/// </summary>
public SettingsEditForm()
{
InitializeComponent();
// StringCollectionの初期の編集画面では追加ができない。
// これを実行することで編集画面が変わり、追加できるようになる。
TypeDescriptor.AddAttributes(typeof(System.Collections.Specialized.StringCollection),
new EditorAttribute("System.Windows.Forms.Design.StringCollectionEditor, System.Design, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a",
typeof(System.Drawing.Design.UITypeEditor)));
// 設定のコピーを作成する
copiedSettings = new Settings();
foreach (SettingsProperty property in Settings.Default.Properties)
{
// StringCollection型のために必要
copiedSettings[property.Name] = deepCopy(Settings.Default[property.Name]);
}
// コピーしたオブジェクトを表示させる
propertyGrid1.SelectedObject = copiedSettings;
}
保存ボタンを押したときにデータを保存する
以下のように、Settings.Defaultに変更を反映してSvae()すれば保存できます。
ただし、この時もStringCollection型の問題があるため、ディープコピーしたオブジェクトを設定します。
private void buttonSave_Click(object sender, EventArgs e)
{
foreach (SettingsProperty property in Settings.Default.Properties)
{
// StringCollection型のために必要
Settings.Default[property.Name] = deepCopy(copiedSettings[property.Name]);
}
Settings.Default.Save();
MessageBox.Show("保存しました");
}
CategoryとHelpのテキストを変更する
PropertyGridにはプロパティのCategoryとHelpを表示する機能があります。
CategoryにはプロパティのCategory属性、HelpにはDescription属性が表示されます。
[System.ComponentModel.Category("ここがCategoryに表示される")]
[System.ComponentModel.Description("ここがHelpに表示される")]
public bool BoolSetting {
get {
return ((bool)(this["BoolSetting"]));
}
set {
this["BoolSetting"] = value;
}
}
ですが、Settingsのプロパティが書かれているコードは自動生成のため、この方法では指定できません。
その対処として、以下のようにSettingsのコンストラクタで動的にCategoryAttributeとDescriptionAttributeを追加します。
public Settings()
{
// PropertyGridのCategoryを設定する
var categoryTable = new Dictionary<string, Attribute>()
{
{ nameof(Settings.BoolSetting), new CategoryAttribute("組み込みのデータ型") },
{ nameof(Settings.StringSetting), new CategoryAttribute("組み込みのデータ型") },
{ nameof(Settings.StringCollectionSetting), new CategoryAttribute("複合データ型") },
{ nameof(Settings.DateTimeSetting), new CategoryAttribute("複合データ型") },
{ nameof(Settings.IntSetting), new CategoryAttribute("組み込みのデータ型") },
};
addAttribute(categoryTable);
// PropertyGridのHelpテキストを設定する
var descriptionTable = new Dictionary<string, Attribute>()
{
{ nameof(Settings.BoolSetting), new DescriptionAttribute("bool型の設定") },
{ nameof(Settings.StringSetting), new DescriptionAttribute("string型の設定") },
{ nameof(Settings.StringCollectionSetting), new DescriptionAttribute("複数のstring型の設定") },
{ nameof(Settings.DateTimeSetting), new DescriptionAttribute("DateTime型の設定") },
{ nameof(Settings.IntSetting), new DescriptionAttribute("int型の設定") },
};
addAttribute(descriptionTable);
}
/// <summary>
/// プロパティに属性を追加する
/// </summary>
/// <param name="attributeTable">Key:プロパティ名、Value:追加する属性</param>
private void addAttribute(Dictionary<string, Attribute> attributeTable)
{
if (attributeTable == null)
{
return;
}
var properties = TypeDescriptor.GetProperties(this);
foreach (PropertyDescriptor p in properties)
{
Attribute attribute;
if (attributeTable.TryGetValue(p.Name, out attribute))
{
// 属性を追加する。
// 本当はMemberDescriptor.Attributes.Addのようにしたいのだが、Attributes属性はgetだけ定義されている。
// そのためリフレクションを使って属性を追加する
var fi = p.Attributes.GetType().GetField("_attributes", BindingFlags.NonPublic | BindingFlags.Instance);
var attrs = fi.GetValue(p.Attributes) as Attribute[];
var listAttr = new List<Attribute>();
if (attrs != null)
{
listAttr.AddRange(attrs);
}
listAttr.Add(attribute);
fi.SetValue(p.Attributes, listAttr.ToArray());
}
}
}
StringCollection型のプロパティを変更できるようにする
StringCollection型のプロパティを変更しようとした場合、以下のダイアログが表示されます。
ですが、このダイアログでは「追加」を押すとエラーになってしまいます。
その対処として以下を実行します。
// これを追加しないと、StringCollectionの追加ができない
TypeDescriptor.AddAttributes(typeof(System.Collections.Specialized.StringCollection),
new EditorAttribute("System.Windows.Forms.Design.StringCollectionEditor, System.Design, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a",
typeof(System.Drawing.Design.UITypeEditor)));
エクスポート機能を追加する
設定のエクスポートがあった方がいいので作ります。
まず、ユーザスコープの設定ファイルのパスを取得できるようにするため、
[プロパティを右クリック] → [追加] → [参照]としてダイアログを開きます。
その後、System.Configurationを選択してOKします。
そして、ユーザスコープの設定ファイルを、SaveFileDialogで選んだパスにコピーすれば完了です。
ただし、保存を1度も実行してない場合はファイルが存在しないため、その場合は保存を実行します。
private void buttonExport_Click(object sender, EventArgs e)
{
// ファイルを選択
string fullPath;
using (var sfd = new SaveFileDialog())
{
sfd.FileName = "user.config";
sfd.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
sfd.Filter = "設定ファイル(*.config)|*.config";
sfd.FilterIndex = 1;
sfd.Title = "エクスポート先のファイルを選択してください";
sfd.RestoreDirectory = true;
if (sfd.ShowDialog() != DialogResult.OK)
{
return;
}
fullPath = sfd.FileName;
}
// ファイルをコピー
try
{
// user.configのパスを取得
string userConfigPath = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.PerUserRoamingAndLocal).FilePath;
// ファイルが無ければSave()して生成する
if (!File.Exists(userConfigPath))
{
Settings.Default.Save();
}
// エクスポートはファイルをコピーするだけ
File.Copy(userConfigPath, fullPath, true);
MessageBox.Show("エクスポートしました");
}
catch (Exception ex)
{
MessageBox.Show(ex.ToString(), "エクスポート失敗", MessageBoxButtons.OK);
}
}
インポート機能を追加する
エクスポートを作ったのでインポートも作ります。
インポートは、設定を読み込んで反映させるだけですが、なかなか癖があります。
まず、設定の読み込みは以下のコードになります。
読み込みはConfigurationManager.OpenMappedExeConfigurationで行います。
その引数としてファイルパスを指定するのですが、アプリの設定+現在の設定+読み込んだ設定、の3つを読み込ますと自然な動作になります。
読み込んだ後は、GetSectionでアプリケーション設定が取得できます。
ClientSettingsSection section = null;
try
{
// ExeConfigFilenameにインポートするファイルだけ指定しても、そのファイルにはセクション情報が書かれていないためGetSectionで正しく読めない。
// さらに、ExeConfigFilenameにアプリケーション設定、RoamingUserConfigFilenameにインポートするファイルを指定しても、正しく動かない場合がある。
// 例えばインポートするファイルに吐かれていない新規設定がある場合、本来は現在値を保持してほしいが、デフォルト値で上書きしてしまう。
// ということで、ExeConfigFilename/RoamingUserConfigFilenam/LocalUserConfigFilenameの3つを指定して読み込む。
var tmpAppConfig = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
var tmpUserCOnfig = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.PerUserRoamingAndLocal);
var exeFileMap = new ExeConfigurationFileMap
{
ExeConfigFilename = tmpAppConfig.FilePath,
RoamingUserConfigFilename = tmpUserCOnfig.FilePath,
LocalUserConfigFilename = fullPath
};
var config = ConfigurationManager.OpenMappedExeConfiguration(exeFileMap, ConfigurationUserLevel.PerUserRoamingAndLocal);
section = (ClientSettingsSection)config.GetSection($"userSettings/{typeof(Settings).FullName}");
}
catch (Exception ex)
{
MessageBox.Show(ex.ToString(), "インポート失敗", MessageBoxButtons.OK);
return;
}
続いて、データの更新は以下のコードになります。
読み込んだ設定の値はSettingElementのValue.ValueXml.InnerXmlに入ってます。
それを、画面に表示している変数(SettingsPropertyValue型)のSerializedValueに設定すればいいです。
ただし、SettingsPropertyValueを一度も参照していないと、値を更新しても元の値に戻ってしまいます。
そのため、_ChangedSinceLastSerializedを無理やりfalseに変更しています。
以下の実装を見るとわかると思います。
https://referencesource.microsoft.com/#System/sys/system/configuration/SettingsPropertyValue.cs,69
さらに、Deserializedにfalseを設定しておきます。
これにより、PropertyValueにアクセスしたときにDeserializeされます。
https://referencesource.microsoft.com/#System/sys/system/configuration/SettingsPropertyValue.cs,40
try
{
// Key:プロパティ名、Value:読み込んだファイルの該当プロパティのSettingElement、のDictionaryを作成する
var dict = new Dictionary<string, SettingElement>();
foreach (SettingElement v in section.Settings)
{
dict.Add(v.Name, v);
}
// 現在の設定を更新する
foreach (SettingsPropertyValue value in copiedSettings.PropertyValues)
{
SettingElement element;
if (dict.TryGetValue(value.Name, out element))
{
// SerializedValueを1度も参照していないと、参照したときの元の値に戻ってしまうという仕様になっている。
// https://referencesource.microsoft.com/#System/sys/system/configuration/SettingsPropertyValue.cs,69
// その対策として、リフレクションで無理やり内部のメンバをfalseに変更する。
// リフレクションを使わなくても、var dummy = value.SerializedValueを実行して1度参照する方法でもよい。
var _ChangedSinceLastSerialized = typeof(SettingsPropertyValue).GetField("_ChangedSinceLastSerialized", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Instance);
_ChangedSinceLastSerialized.SetValue(value, false);
// 値の設定
value.SerializedValue = element.Value.ValueXml.InnerXml;
// value.Deserializedをfalseにすると、value.PropertyValueにアクセスしたときにDeserializeされる
value.Deserialized = false;
}
}
}
catch (Exception ex)
{
MessageBox.Show(ex.ToString(), "インポート失敗", MessageBoxButtons.OK);
return;
}
これらをつなげて、結局インポートは以下のコードとなります。
private void buttonImport_Click(object sender, EventArgs e)
{
// ファイル選択
string fullPath = "";
using (var ofd = new OpenFileDialog())
{
ofd.FileName = "user.config";
ofd.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
ofd.Filter = "設定ファイル(*.config)|*.config";
ofd.FilterIndex = 1;
ofd.Title = "インポートするファイルを選択してください";
ofd.RestoreDirectory = true;
if (ofd.ShowDialog() != DialogResult.OK)
{
return;
}
fullPath = ofd.FileName;
}
// 読み込み
ClientSettingsSection section = null;
try
{
// ExeConfigFilenameにインポートするファイルだけ指定しても、そのファイルにはセクション情報が書かれていないためGetSectionで正しく読めない。
// さらに、ExeConfigFilenameにアプリケーション設定、RoamingUserConfigFilenameにインポートするファイルを指定しても、正しく動かない場合がある。
// 例えばインポートするファイルに吐かれていない新規設定がある場合、本来は現在値を保持してほしいが、デフォルト値で上書きしてしまう。
// ということで、ExeConfigFilename/RoamingUserConfigFilenam/LocalUserConfigFilenameの3つを指定して読み込む。
var tmpAppConfig = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
var tmpUserCOnfig = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.PerUserRoamingAndLocal);
var exeFileMap = new ExeConfigurationFileMap
{
ExeConfigFilename = tmpAppConfig.FilePath,
RoamingUserConfigFilename = tmpUserCOnfig.FilePath,
LocalUserConfigFilename = fullPath
};
var config = ConfigurationManager.OpenMappedExeConfiguration(exeFileMap, ConfigurationUserLevel.PerUserRoamingAndLocal);
section = (ClientSettingsSection)config.GetSection($"userSettings/{typeof(Settings).FullName}");
}
catch (Exception ex)
{
MessageBox.Show(ex.ToString(), "インポート失敗", MessageBoxButtons.OK);
return;
}
// データの更新
try
{
// Key:プロパティ名、Value:読み込んだファイルの該当プロパティのSettingElement、のDictionaryを作成する
var dict = new Dictionary<string, SettingElement>();
foreach (SettingElement v in section.Settings)
{
dict.Add(v.Name, v);
}
// 現在の設定を更新する
foreach (SettingsPropertyValue value in copiedSettings.PropertyValues)
{
SettingElement element;
if (dict.TryGetValue(value.Name, out element))
{
// SerializedValueを1度も参照していないと、参照したときの元の値に戻ってしまうという仕様になっている。
// https://referencesource.microsoft.com/#System/sys/system/configuration/SettingsPropertyValue.cs,69
// その対策として、リフレクションで無理やり内部のメンバをfalseに変更する。
// リフレクションを使わなくても、var dummy = value.SerializedValueを実行して1度参照する方法でもよい。
var _ChangedSinceLastSerialized = typeof(SettingsPropertyValue).GetField("_ChangedSinceLastSerialized", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Instance);
_ChangedSinceLastSerialized.SetValue(value, false);
// 値の設定
value.SerializedValue = element.Value.ValueXml.InnerXml;
// value.Deserializedをfalseにすると、value.PropertyValueにアクセスしたときにDeserializeされる.
// https://referencesource.microsoft.com/#System/sys/system/configuration/SettingsPropertyValue.cs,40
value.Deserialized = false;
}
}
}
catch (Exception ex)
{
MessageBox.Show(ex.ToString(), "インポート失敗", MessageBoxButtons.OK);
return;
}
// 画面を更新
propertyGrid1.SelectedObject = copiedSettings;
// メッセージ
MessageBox.Show("インポートした設定を反映するには保存を押してください");
}
最後に
これで汎用的なアプリケーション設定の編集画面ができました。
今まで直接XMLを変更させてたアプリに組み込んでみてください。
参考
Helpのテキストを変更する方法は、以下を参考にしました。
https://www.codeproject.com/Articles/415070/Dynamic-Type-Description-Framework-for-PropertyGri
StringCollection型のプロパティを変更できるようにする方法は、以下を参考にしました
https://stackoverflow.com/questions/2043579/adding-editor-editorattribute-at-run-time-dynamically-to-an-objects-propert