Edited at

C#で汎用的なアプリケーション設定の編集画面を作成する


概要

C#のアプリケーション設定は便利ですよね。

取得・変更・保存が簡単にできます。

ところが、編集画面やインポート/エクスポートを作ろうとしたら結構ハマってしまいました。

解決策がなかなか見つからなかったので、書き残しておきます。


サンプルコード

以下に実際に動作するコードを置いてます。

https://github.com/minoru-nagasawa/SampleApplicationSettingsDialog

以下のような画面でアプリケーション設定を変更できます。

image.png


PropertyGridにオブジェクトのコピーを設定

PropertyGridにアプリケーション設定の実体(Settings.Default)をセットすると、保存しなくても実体が変更されてしまいます。

それを防ぐため、コピーを作成して設定します。

ただし、StringCollection型は注意が必要です。

これをコピーしただけでは、その中の個々のstringは同じ実体を参照してしまいます。

それにより、コピーしたテキストを変更したつもりが、本物の設定が変更されてしまいます。

それを防ぐためにディープコピーしたオブジェクトを設定します。


SettingsEditForm.cs

        /// <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型の問題があるため、ディープコピーしたオブジェクトを設定します。


SettingsEditForm.cs

        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を表示する機能があります。

image.png

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を追加します。


Settings.cs

        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型のプロパティを変更しようとした場合、以下のダイアログが表示されます。

ですが、このダイアログでは「追加」を押すとエラーになってしまいます。

image.png

その対処として以下を実行します。


SettingsEditForm.cs

            // これを追加しないと、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)));

これにより表示されるダイアログも以下のように変わります。

image.png


エクスポート機能を追加する

設定のエクスポートがあった方がいいので作ります。

まず、ユーザスコープの設定ファイルのパスを取得できるようにするため、

[プロパティを右クリック] → [追加] → [参照]としてダイアログを開きます。

image.png

その後、System.Configurationを選択してOKします。

image.png

そして、ユーザスコープの設定ファイルを、SaveFileDialogで選んだパスにコピーすれば完了です。

ただし、保存を1度も実行してない場合はファイルが存在しないため、その場合は保存を実行します。


SettingsEditForm.cs

        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でアプリケーション設定が取得できます。


SettingsEditForm.cs

            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


SettingsEditForm.cs

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


これらをつなげて、結局インポートは以下のコードとなります。


SettingsEditForm.cs

        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