Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

概要

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away