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

# 概要
C#のアプリケーション設定は便利ですよね。
取得・変更・保存が簡単にできます。
ところが、編集画面やインポート/エクスポートを作ろうとしたら結構ハマってしまいました。
解決策がなかなか見つからなかったので、書き残しておきます。

# サンプルコード
以下に実際に動作するコードを置いてます。
https://github.com/minoru-nagasawa/SampleApplicationSettingsDialog

以下のような画面でアプリケーション設定を変更できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178867/bbd3911d-a960-03e2-19a9-60c53de4148d.png)

# PropertyGridにオブジェクトのコピーを設定
PropertyGridにアプリケーション設定の実体(Settings.Default)をセットすると、保存しなくても実体が変更されてしまいます。
それを防ぐため、コピーを作成して設定します。

ただし、StringCollection型は注意が必要です。
これをコピーしただけでは、その中の個々のstringは同じ実体を参照してしまいます。
それにより、コピーしたテキストを変更したつもりが、本物の設定が変更されてしまいます。
それを防ぐためにディープコピーしたオブジェクトを設定します。

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

```c#: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178867/cdc6cdd6-e514-9673-d1b6-d985a53be0ad.png)

CategoryにはプロパティのCategory属性、HelpにはDescription属性が表示されます。

```c#
        [System.ComponentModel.Category("ここがCategoryに表示される")]
        [System.ComponentModel.Description("ここがHelpに表示される")]
        public bool BoolSetting {
            get {
                return ((bool)(this["BoolSetting"]));
            }
            set {
                this["BoolSetting"] = value;
            }
        }
```

ですが、Settingsのプロパティが書かれているコードは自動生成のため、この方法では指定できません。
その対処として、以下のようにSettingsのコンストラクタで動的にCategoryAttributeとDescriptionAttributeを追加します。

```c#: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178867/1a08e5c8-13e8-42d0-06c2-27c2c1f85f8a.png)

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

```c#: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178867/11f456b6-25e2-666b-62fb-d3c14d785a52.png)

## エクスポート機能を追加する
設定のエクスポートがあった方がいいので作ります。

まず、ユーザスコープの設定ファイルのパスを取得できるようにするため、
[プロパティを右クリック] → [追加] → [参照]としてダイアログを開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178867/11716776-a009-a6c9-5360-543fdd493ff0.png)

その後、System.Configurationを選択してOKします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178867/47ae15cb-9d55-1433-a475-71e81ced4510.png)

そして、ユーザスコープの設定ファイルを、SaveFileDialogで選んだパスにコピーすれば完了です。
ただし、保存を1度も実行してない場合はファイルが存在しないため、その場合は保存を実行します。

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

```c#: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

```c#: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;
            }
```

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


```c#: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