連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index
継承コントロールは便利だが、WinForms では Visual Studio デザイナもフォーム表示前にコントロール生成を行う。
そのため、コンストラクタや static 初期化に I/O や環境依存処理が入ると、フォームが開かない状態まで進みやすい。
さらに、プロパティグリッドには見えるのに .Designer.cs へ残らない、TypeConverter を入れた途端に不安定になる、といった詰まりも起きやすい。
今回は、その壊れ方を実行画面から追えるように疑似再現した比較用サンプルで、次の4点をまとめて確認する。
- static 初期化や ctor の早い段階で重い処理に触れると止まりやすい
- 実行時だけ必要な初期化を後ろへ送ると、生成だけは通しやすい
-
DefaultValueと実際の初期値がずれると保存差が不安定になりやすい - TypeConverter は厳密に失敗させるより、安全値へ戻す方が通しやすい
学習用サンプルはこちら。
サンプルコード(GitHub)
このサンプルの見方
このサンプルの見方
まずは次の順で触ると流れが追いやすい。
-
Broken を疑似設計時で生成- static 初期化や ctor の早い段階で止まる例を見る
- 表面例外ではなく、原因例外まで確認する
-
Safe を疑似設計時で生成- 生成だけ通し、重い初期化を後ろへ送る例を見る
-
DefaultValue 保存差- 属性値と実初期値のずれで保存判定がどう揺れるかを見る
-
TypeConverter 比較- 不正入力時に失敗で止めるか、安全値へ戻して継続するかを見る
この順で触ると、継承コントロールで詰まりやすい場所を、早い段階から順に追いやすい。
先に押さえておきたい前提
WinForms の継承コントロールで厄介なのは、見た目の複雑さではなく、どこで生成が止まるかだ。
フォームを開く前に失敗すると、フォーム側で吸収しにくい。
特に止まりやすいのは次のような場所だ。
- ctor で設定ファイルを読む
- ctor でサービス接続を始める
- static フィールド初期化でファイルを読む
- static コンストラクタで環境依存処理に触る
- TypeConverter の
ConvertFromで検証を強くしすぎる -
DesignMode単独判定で ctor 中の分岐を書く
この中でも特に厄介なのが、static 初期化と ctor は早すぎることだ。
ここで例外が出ると、フォーム編集そのものが止まりやすい。
static 初期化は何を指すのか
static 初期化 は、単に static 変数へ値を入れる話だけではない。
C# では、型を最初に使う直前に1回だけ走る初期化処理全体を指す。
ここには次が含まれる。
- static フィールド初期化子
- static コンストラクタ
たとえば次のようなコードは、型を最初に使った瞬間に走る。
public class SampleControl
{
private static readonly string ConfigText = System.IO.File.ReadAllText("config.txt");
static SampleControl()
{
// 型初期化
}
}
このどちらかで失敗すると、表面には TypeInitializationException が見えやすい。
ただし本当の原因は、InnerException 側に入っていることが多い。
今回のサンプルでも、Broken 側はここを観測しやすいようにしてある。
悪い例 1
最初の比較はもっとも分かりやすい。
設計時にも通る場所で外部依存へ触ると、フォーム全体へ影響が広がる。
using System;
using System.Drawing;
using System.Windows.Forms;
namespace DesignerSafeControlSample
{
public class BrokenCaptionControl : Control
{
static BrokenCaptionControl()
{
BrokenEnvironment.RequireRuntimeOnlyResource();
}
public BrokenCaptionControl()
{
BrokenEnvironment.RequireRuntimeOnlyResource();
Size = new Size(260, 44);
BackColor = Color.MistyRose;
}
}
internal static class BrokenEnvironment
{
public static void RequireRuntimeOnlyResource()
{
if (RuntimeEnvironment.CurrentMode == RuntimeMode.Design)
{
throw new InvalidOperationException("Runtime only resource was not available.");
}
}
}
}
この形が危ない理由は単純で、インスタンス生成前か生成途中で失敗が確定しやすいからだ。
static 初期化で失敗すると ctor まで届かない。
ctor で失敗すると、インスタンス生成が途中で止まる。
サンプル画面で何が見えるか
Broken 側のボタンを押すと、次のような流れを追える。
- 状態表示
- 失敗
- 発生段階
-
static 初期化またはctor
-
- 例外表示
- 表面例外
- 原因例外
- 詳細ログ
ex.ToString()
ここで特に見たいのは、TypeInitializationException だけで止まらず、原因例外の文面まで見ることだ。
たとえば Broken 側では、表面には次のような例外が見えやすい。
System.TypeInitializationException
The type initializer for 'DesignerSafeControlSample.BrokenCaptionControl' threw an exception.
ただし、本当に見たいのはその内側だ。
System.InvalidOperationException
Runtime only resource was not available.
表面例外だけだと「何か失敗した」で止まりやすい。
原因例外まで見ると、「早い場所で環境依存処理に触れた」が見えてくる。
改善例 1
ここからが今回の土台になる。
コンストラクタは軽い初期値だけにして、実行時だけ必要な処理は後ろで始める。
using System;
using System.ComponentModel;
using System.Windows.Forms;
namespace DesignerSafeControlSample
{
public abstract class DesignSafeControlBase : Control
{
private bool _runtimeInitialized;
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
if (DesignTimeHelper.IsInDesignMode(this))
{
return;
}
if (_runtimeInitialized)
{
return;
}
_runtimeInitialized = true;
InitializeRuntime();
}
protected abstract void InitializeRuntime();
}
}
using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;
namespace DesignerSafeControlSample
{
public class SafeCaptionControl : DesignSafeControlBase
{
private string _caption = string.Empty;
public SafeCaptionControl()
{
Size = new Size(260, 44);
BackColor = Color.Honeydew;
}
[Category("カスタム")]
[Description("表示用の文字列")]
[DefaultValue("")]
[Browsable(true)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
public string Caption
{
get { return _caption; }
set
{
var next = value ?? string.Empty;
if (_caption == next)
{
return;
}
_caption = next;
Invalidate();
}
}
protected override void InitializeRuntime()
{
RuntimeOnlyInitializer.Touch();
}
}
}
ここで見たいのは OnHandleCreated そのものではなく、設計時に通す処理と実行時だけでよい処理を分けたことだ。
設計時に生成だけ通れば、フォーム編集は続けられる。
設計時判定で DesignMode 単独が足りない理由
継承コントロールで詰まりやすいのがここだ。
DesignMode は便利だが、早い段階では期待どおりにならないことがある。
そのため、DesignMode だけで分岐を書くと、「止めたかったのに止まらない」が起きやすい。
今回のサンプルでは、判定を補助クラスへまとめている。
using System.ComponentModel;
using System.Windows.Forms;
namespace DesignerSafeControlSample
{
internal static class DesignTimeHelper
{
public static bool IsInDesignMode(IComponent component)
{
if (RuntimeEnvironment.CurrentMode == RuntimeMode.Design)
{
return true;
}
if (LicenseManager.UsageMode == LicenseUsageMode.Designtime)
{
return true;
}
if (component != null && component.Site != null && component.Site.DesignMode)
{
return true;
}
var control = component as Control;
if (control != null)
{
var parent = control.Parent;
while (parent != null)
{
if (parent.Site != null && parent.Site.DesignMode)
{
return true;
}
parent = parent.Parent;
}
}
return false;
}
}
}
この判定で見ているのは次の3点だ。
-
LicenseManager.UsageMode- 早い段階でも拾いやすい
-
Site.DesignMode- Site が張られた後の判定に使いやすい
- 親コントロール側の
Site.DesignMode- 子側だけでは取り切れない場面の保険になる
万能ではないが、DesignMode 単独よりは詰まりにくい。
プロパティは「見える」だけでは足りない
継承コントロールのプロパティは、プロパティグリッドへ見えても .Designer.cs に残らないことがある。
この差は見た目だけでは気づきにくい。
見るべき点は次の4つだ。
- public か
-
Browsable(true)があるか -
DefaultValueと初期値が一致しているか -
DesignerSerializationVisibilityが想定どおりか
今回のサンプルでは PaddingEx を使って保存差を見ている。
悪い例 2
using System.ComponentModel;
using System.Windows.Forms;
namespace DesignerSafeControlSample
{
public class BrokenPropertyControl : Control
{
private int _paddingEx = 8;
[DefaultValue(0)]
public int PaddingEx
{
get { return _paddingEx; }
set { _paddingEx = value; }
}
}
}
改善後
using System.ComponentModel;
using System.Windows.Forms;
namespace DesignerSafeControlSample
{
public class SafePropertyControl : Control
{
private int _paddingEx = 8;
[DefaultValue(8)]
public int PaddingEx
{
get { return _paddingEx; }
set { _paddingEx = value; }
}
}
}
ここで厄介なのは、属性値と実初期値がずれていても見た目では分かりにくいことだ。
サンプルでは ShouldSerializeValue の結果をログへ出すので、保存差がどこで揺れるかを追いやすい。
TypeConverter は変換成功より通過を優先する
TypeConverter を入れると便利になるが、設計時では context が十分にそろわない、サービス取得が失敗する、Instance が想定外になる、といった揺れが増える。
このため、変換中に検証を強くしすぎると、フォーム生成まで巻き込みやすい。
今回のサンプルでは、Broken 側と Safe 側を並べてある。
悪い例 3
using System;
using System.ComponentModel;
using System.Globalization;
namespace DesignerSafeControlSample
{
[TypeConverter(typeof(BrokenPathSettingConverter))]
public struct BrokenPathSetting
{
private readonly string _value;
public BrokenPathSetting(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException("Path value was empty.");
}
_value = value.Trim();
}
public override string ToString()
{
return _value ?? string.Empty;
}
}
public sealed class BrokenPathSettingConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
var text = value as string ?? string.Empty;
return new BrokenPathSetting(text);
}
}
}
改善後
using System;
using System.ComponentModel;
using System.Globalization;
namespace DesignerSafeControlSample
{
[TypeConverter(typeof(SafePathSettingConverter))]
public struct PathSetting
{
private readonly string _value;
private PathSetting(string value)
{
_value = value ?? string.Empty;
}
public static PathSetting Empty
{
get { return new PathSetting(string.Empty); }
}
public static PathSetting CreateUnsafe(string value)
{
return new PathSetting(value);
}
public static PathSetting Parse(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Empty;
}
return new PathSetting(value.Trim());
}
public override string ToString()
{
return _value ?? string.Empty;
}
}
public sealed class SafePathSettingConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
try
{
var text = value as string ?? string.Empty;
var component = context != null ? context.Instance as IComponent : null;
if (component != null && DesignTimeHelper.IsInDesignMode(component))
{
return PathSetting.CreateUnsafe(text);
}
if (RuntimeEnvironment.CurrentMode == RuntimeMode.Design)
{
return PathSetting.CreateUnsafe(text);
}
return PathSetting.Parse(text);
}
catch
{
return PathSetting.Empty;
}
}
}
}
ここで大事なのは、設計時に厳密検証を通すことではない。
フォーム編集を続けられることの方が優先度は高い。
実行時だけ強い検証を入れる方が全体の見通しは良くなりやすい。
ITypeDescriptorContext で最低限見ておく点
TypeConverter の話で引っかかりやすいので、最小限だけ整理しておく。
-
context自体がnullのことがある -
context.Instanceは単体とは限らない -
PropertyDescriptorが常に使えるとは限らない -
GetServiceは取れない前提で考えた方が安定しやすい
つまり、設計時の TypeConverter では「全部そろっている前提」で書かない方がよい。
最小限の判定だけで通す方が壊れにくい。
このサンプルをどう読むか
このページのコードは、次の順で追うとつながりやすい。
-
BrokenCaptionControl- 早い場所へ何を書くと止まりやすいかを見る
-
DesignTimeHelper- 設計時判定をどこまで見るか確認する
-
DesignSafeControlBase- 実行時初期化をどこへ移したかを見る
-
SafeCaptionControl- 生成だけ通す構造を見る
-
BrokenPropertyControl/SafePropertyControl-
DefaultValueと実初期値の差を見る
-
-
BrokenPathSettingConverter/SafePathSettingConverter- 変換時の止め方と戻し方の差を見る
実行画面では、Broken 側の停止点を先に見て、Safe 側でどこまで通るかを見たあと、保存差と Converter 差を見る流れが分かりやすい。
すぐ確認できるチェック項目
最後に、継承コントロール追加前後で確認しやすい観点を表にしておく。
| 観点 | 確認内容 |
|---|---|
| static 初期化 | ファイル、設定、サービス接続に触っていないか |
| ctor | 値初期化だけで終わっているか |
| 設計時判定 |
DesignMode 単独判定で終わっていないか |
| 実行時初期化 |
OnHandleCreated 後など後ろの場所へ送れているか |
| プロパティ表示 | public / Browsable / Category / Description があるか |
| 保存 |
DefaultValue と初期値が一致しているか |
| TypeConverter | 失敗時に安全値へ戻せるか |
| 例外確認 | 表面例外だけでなく原因例外まで見ているか |
どれか1つでも外れると不安定さが出やすい。
特に static 初期化と ctor は、最初に点検した方が早い。
まとめ
継承コントロールでデザイナが不安定になるときは、UI の複雑さより生成の早い場所へ何を書いたかを見る方が近い。
今回のサンプルで見たかったのは次の4点だ。
- static 初期化と ctor は軽く保つ
- 実行時だけ必要な処理は後ろへ送る
-
DefaultValueと実初期値を揃える - TypeConverter は設計時の通過を先に考える
今回の学習用サンプルは、Visual Studio デザイナで起きやすい壊れ方を、実行画面から疑似再現して追えるようにしてある。
文章だけで追うより、Broken 側の停止点、Safe 側の通過点、保存差、Converter 差を順に見た方が入りやすいテーマだった。
関連トピック
- 止血の作法と計測の考え方: E04
- メッセージループの構造: G11
- 非同期の整理: G13
- ルール化の落とし込み: R06
- 連載Index: S00_門前の誓い_総合Index
連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index