Skip to content

MS_DoubleAddedControls

nishi_74322014 edited this page Aug 21, 2026 · 1 revision

コンストラクタで子コントロール追加すると二重追加される件

概要

  • (子コントロールを持つ Form やカスタム コントロールの)
    子コントロールを生成する処理を、コンストラクタに実装した場合、
    デザイン時と実行時の表示が乖離する現象に対する対処方法等を纏めている。

  • この問題は、

    など、デザイナを持つ UI サブシステムの開発環境で発生する。

  • Form やカスタム コントロールのコンストラクタは、
    実行時だけでなく、デザイン時(Visual Studio デザイナ上で)も動作する。

    • このため、子コントロールの二重登録のような問題が発生する。
    • 結果として、デザイン時と実行時の表示が異なることになる。
  • この問題は、コンストラクタに実装していた処理を、ロード イベントに移動することで
    解決できるが、この場合、デザイナに子コントロールが表示がされなくなる。

補足(なぜコンストラクタが「デザイン時にも動く」のか): この現象の
根本にあるのは、Visual Studio デザイナが実際にコードを実行しているという事実である。

【デザイナの正体】★
   Visual Studio の Windows Forms / Web Forms デザイナは、
   画面の絵を描いているのではなく、
   【コントロールのインスタンスを本当に生成して
     描画させている】

     → だから【コンストラクタが走る】
     → だから DB 接続や外部通信を書くと
       【デザイナを開いた瞬間に接続しにいく】★
     → だから例外を投げると
       「デザイナがコントロールを読み込めません」になる

【設計としての含意】
   コンストラクタには
   【デザイン時に実行されても安全なことしか書けない】★
     ・重い処理を書かない
     ・外部リソースに触らない
     ・状態を持つ副作用を起こさない

原因

  • Visual Studio デザイナにより生成されたコードは *.Designer.cs(vb) に出力される。

    • 当該コントロールの子コントロール毎にメンバ変数が定義され
    • InitializeComponent に子コントロールの設定処理が実装される。
  • コンストラクタは実行時だけでなく、デザイン時にも実行される。
    コンストラクタでは、上記の InitializeComponent を呼び出している。

  • InitializeComponent は子コントロール毎に定義されたメンバ変数に
    子コントロールを new してからプロパティ設定をしているため、
    子コントロールの二重登録のような問題は起きない。

  • しかし、コンストラクタのコードから子コントロールを追加するような処理を実装すると

    • メンバに直接子コントロールを Add したりする場合
    • DataSource を設定して DataBind したりする場合

    この結果が、*.Designer.cs(vb) に出力される
    Visual Studio デザイナの仕様であるため、
    子コントロールの二重登録のような問題が起きてしまう。

移行メモ(誤字): 移行元の「InitializeComponent を呼び出してる」を
「呼び出している」に修正した。

対策

InitLayoutイベント・ハンドラ

このため、対策としては、InitLayout イベント・ハンドラをオーバーライドして
Form やカスタム コントロールの初期化処理を同梱するという方法が有用である。

  • InitLayout は、コントロール配置時にのみ発生するイベントのハンドラーである。

  • このため、デザイン時・実行時に処理が実行されて二重登録が起きる問題は発生しなくなる。

  • ただし、コントロールのリサイズなどによって InitLayout イベントが再発生することがある。
    この際、同様に結果が *.Designer.cs(vb) に出力され、
    二重登録のような問題が発生することがあるようなので、
    制御用のコードを別途、実装する必要がある。

  • 参考

補足(初期化処理を置く場所の選択肢): InitLayout は有効な手だが、
他にも定石があるので整理しておく。

【Windows Forms の初期化タイミング】★
   コンストラクタ
     → 【デザイン時にも走る】★
     → InitializeComponent() 以外は書かないのが安全
   OnHandleCreated
     → ウィンドウ ハンドルが作られた時
     → Win32 API を叩く初期化はここ
   【InitLayout】(本ページの対策)
     → 親に追加され、レイアウトが始まる時
     → デザイン時には走らない
     → ただし原文の指摘どおり【再発生しうる】★
   OnLoad / Load
     → 【最も一般的】。Form の場合はここ ★
     → デザイン時には走らない
     → ただしコントロール(UserControl)には Load がない
       (正確には存在するが発火が Form ほど自明でない)

【再入を防ぐ定石】★
   private bool _initialized;
   protected override void InitLayout() {
     base.InitLayout();
     if (_initialized) return;      // ← 【これが原文の言う
     _initialized = true;           //     「制御用のコード」】
     // 初期化処理
   }
【もう一段よい設計】★
   そもそも【コードで子コントロールを追加しない】
     → ItemsSource 相当のプロパティを公開し、
       利用側が設定する
     → 既定値が要るなら
       【プロパティの getter で遅延生成する】
     → デザイナが吐くコードにも
       プロパティ設定として自然に残る

DesignModeプロパティ

また、DesignMode プロパティを使用してデザイン時と実行時を判別できる。

補足(DesignMode は当てにならないことがある): 判別手段として
挙げられているが、有名な落とし穴がある。

【DesignMode が false になる場面】★
 ① 【コンストラクタの中】
     → まだサイトに接続されていないため
       常に false を返す ★
     → 本ページの主題(コンストラクタ)では
       【役に立たない】
 ② 【入れ子のコントロール】
     → デザイナに直接置かれていない
       子コントロールでは false になる
 ③ .NET Core 以降の
     プロセス分離デザイナでは挙動が異なることがある

【より確実な判定】★
   // 入れ子でも効く
   protected static bool IsDesignMode =>
       LicenseManager.UsageMode == LicenseUsageMode.Designtime;

   // 実行プロセス名で判定する荒業(確実だが泥臭い)
   Process.GetCurrentProcess().ProcessName == "devenv"

   ※ .NET Core 以降のデザイナは
     DesignToolsServer.exe など別プロセスで動くため、
     プロセス名による判定は【バージョン依存】になる ★

考察

Windows Forms では、デザイン専用コードを実装する必要が無いため、
後述の ASP.NET Web Forms のケースより
簡単にデザイン時と実行時の表示を一致させることが出来るが、
製品レベルの作り込みで、通常、ユーザ・プログラムでここまでの実装は行わない方が
無難であると考える。

対策の概要

ロード イベントに処理を移動

  • コンストラクタに実装した処理を、ロード イベントに移動する。
  • この場合、デザイナに子コントロールが表示がされなくなるので、
    必要に応じて、以下の「デザイン サポート コードを実装」を追加する。

移行メモ(重複): 移行元では「この場合、**この場合、**デザイナに…」と
語句が重複していたため、一方を削除した。

デザイン サポート コードを実装

GetDesignTimeHtml メソッドから Visual Studio デザイナに表示する HTML を返すことで
対応する。

詳細

原因

  • Web サイトでなく Web アプリケーションであれば *.Designer.cs(vb) も出力されるが、ここは、

    • メンバ変数の定義のみが生成され、
    • 子コントロールの設定処理は、*.aspx のマークアップ側に実装される。
  • 通常、*.aspx のマークアップを使用して子コントロールの設定処理が行われる。

  • コンストラクタで Add や DataBind で子コントロールを追加すると、
    Windows Forms と同じように、子コントロールがマークアップに追加され問題が発生する。

  • 例えば、
    下記のようなコンストラクタの実装によって、
    選択項目 1 ~ 3 を生成するカスタム RadioButtonList コントロールを作成した場合、

/// <summary>コンストラクタ</summary>
/// <summary>コンストラクタでプロジェクトなどでの標準スタイルを適用する。</summary>
public WebCustRadioButtonList() {
  // 初期設定のプロパティ値を設定する。
  // ※デザインタイム・プロパティのほうが優先される。

  // WebCustRadioButtonListの初期化(データバインド)
  string[] itemlist = { "選択項目1", "選択項目2", "選択項目3" };
  this.DataSource = itemlist;
  this.DataBind();

  // 横方向にオプションボタンを並べて表示する。
  this.RepeatDirection = RepeatDirection.Horizontal;
}

カスタムの RadioButtonList コントロールのタグ内に、
子コントロール(ListItem)のコレクションのタグが出力される。

<my_wcc:WebCustRadioButtonList ID="WebCustRadioButtonList1" runat="server" Width="340px">
  <asp:ListItem Value="選択項目1">選択項目1</asp:ListItem>
  <asp:ListItem Value="選択項目2">選択項目2</asp:ListItem>
  <asp:ListItem Value="選択項目3">選択項目3</asp:ListItem>
</my_wcc:WebCustRadioButtonList>

この実装の状態で、Visual Studio デザイナでコントロールのサイズを変更したところ、
Visual Studio デザイナ上での表示が下記のような、意図せぬ表示となる問題が発生する。

<my_wcc:WebCustRadioButtonList ID="WebCustRadioButtonList1" runat="server" Width="340px">
  <asp:ListItem Value="選択項目1">選択項目1</asp:ListItem>
  <asp:ListItem Value="選択項目2">選択項目2</asp:ListItem>
  <asp:ListItem Value="選択項目3">選択項目3</asp:ListItem>
  <asp:ListItem Value="選択項目1">選択項目1</asp:ListItem>
  <asp:ListItem Value="選択項目2">選択項目2</asp:ListItem>
  <asp:ListItem Value="選択項目3">選択項目3</asp:ListItem>
</my_wcc:WebCustRadioButtonList>

これは、Visual Studio デザイナ上でコンストラクタが 2 回実行され、
DataSource に ListItem が 2 回追加されたためである。

移行メモ(XML コメントの重複): 上記コンストラクタの <summary> タグが
2 行続けて記述されている。
.NETコントロールのカスタマイズ方法
同種のコードにも同じ記述があり、作者の記述をそのまま残したが、
実際にはいずれか一方にすべきである。

補足(「コンストラクタが 2 回実行され」の読み解き): 現象の説明としては
正しいが、もう少し正確にしておく。

【起きていること】★
   ① デザイナがコントロールを生成
      → コンストラクタで DataBind → ListItem ×3
   ② デザイナが【プロパティを変更】(Width 等)
      → 変更内容を .aspx へ【シリアライズし直す】
      → この時点の Items(3 件)がタグとして書き出される
   ③ .aspx を再読込 → タグの 3 件が読まれる
      → さらにコンストラクタが走って DataBind → 【+3 件】★
   → 結果、6 件になる

【本質】
   ・Items は【デザイナが永続化する対象のプロパティ】である
   ・そこへコンストラクタが【毎回追加する】
   → 「永続化される状態」と
     「毎回生成される状態」が【二重に足し算される】★

【最も単純な回避】
   DataBind する前に this.Items.Clear() する
     → 追加ではなく【置換】にすれば累積しない ★
     → ただし利用者がマークアップで書いた項目も
       消える点に注意

対策

/// <summary>WebCustRadioButtonListのVisual Studioデザインサポートを実装する</summary>
internal class WebCustRadioButtonListDesigner : ControlDesigner {
  /// <summary>メンバ変数にWebCustRadioButtonListを保持</summary>
  protected WebCustRadioButtonList wcrbl;

  /// <summary>初期化</summary>
  public override void Initialize(IComponent component) {
    if (component is WebCustRadioButtonList) {
      base.Initialize(component);
      this.wcrbl = (WebCustRadioButtonList)component;
    }
  }

  /// <summary>Visual Studioデザイナに表示するHTMLを返す。</summary>
  public override string GetDesignTimeHtml() {
    try {
      // 非常に単純なコードであるため、
      // スタイル関係のプロパティ設定、子コントロールのインスタンス数のプロパティ設定を反映しない。
      string ret = ""
      + "<table>"
      + "  <tr>"
      + "    <td>"
      + "      <input type=\"radio\" value=\"選択項目1\" />"
      + "      <label >選択項目1</label>"
      + "    </td>"
      + "    <td>"
      + "      <input type=\"radio\" value=\"選択項目2\" />"
      + "      <label>選択項目2</label>"
      + "    </td>"
      + "    <td>"
      + "      <input type=\"radio\" value=\"選択項目3\" />"
      + "      <label >選択項目3</label>"
      + "    </td>"
      + "  </tr>"
      + "</table>";

      return ret;
    }
    catch (Exception ex) {
      // エラーの場合
      return String
        .Concat("<h3>Error</h3>Stack Trace:<br>", ex.StackTrace);
    }
  }
}
  • 最後に、上記のデザイナ クラスを使用するように、
    カスタム ラベル コントロール(Ctrl.WebCustLabel)のクラス定義に
    Designer
    属性を使用してデザイナ クラスを指定する。
/// <summary>System.Web.UI.RadioButtonListのカスタム・コントロール</summary>
[Designer("Ctrl.WebCustRadioButtonListDesigner"),
ToolboxData("<{0}:WebCustRadioButtonList runat=server></{0}:WebCustRadioButtonList>")]
public class WebCustRadioButtonList : RadioButtonList

移行メモ(正誤): 上記の説明文が「カスタム ラベル コントロール
(Ctrl.WebCustLabel)のクラス定義に」となっているが、
直後のコードは RadioButtonListWebCustRadioButtonList)である。
.NETコントロールのカスタマイズ方法 からの
文言の流用と思われる。移行元の記述をそのまま残したが、
本節の対象は WebCustRadioButtonList である。

補足(GetDesignTimeHtml を書く際の実務的な注意): サンプルの
「エラー時にスタック トレースを返す」という作りは、実は定石である。

【なぜスタック トレースを返すのか】★
   デザイナは例外を握り潰し、
   「コントロールを読み込めません」としか出さない
     → 原因が分からない
   → 【HTML としてエラーを描いて返す】ことで
     デザイナ上に原因が表示される
   → ControlDesigner には
     【GetErrorDesignTimeHtml(Exception)】という
     専用のメソッドもある(そちらを使う方が正式)★

【空表示への配慮】
   子要素が 0 件だと
   【デザイナ上で高さ 0 になり選択できなくなる】
     → GetEmptyDesignTimeHtml() をオーバーライドして
       プレースホルダを描く ★

【デザイナ クラスの置き場所】
   ・System.Design.dll への参照が必要
   ・【実行時には不要】なアセンブリなので、
     本来は【別プロジェクト(デザイン アセンブリ)】に
     分けるのが正しい ★
     → 同居させると配布物に
       デザイン専用コードが混入する

考察

  • この ControlDesigner クラスの GetDesignTimeHtml メソッドのオーバーライドの
    サンプルは、非常に簡素な実装であるため、
    文字のフォント、サイズ、色などの、スタイル関係のプロパティ設定、
    子コントロールのインスタンス数のプロパティ設定は反映されない。

  • これらを HTML タグに反映させる場合は、
    Visual Studio デザイン サポート コードを実装する必要がある。
    しかし、デザインタイム・プロパティ設定により可変となる

    • 「実際の Web アプリケーション上での外観」と
    • 「Visual Studio デザイナ上での外観」を

    一致させるには複雑な実装が必要になる。

  • プロジェクト部品でのサポートは考えなくて良いと考える。
    また、製品レベルの作り込みであっても、
    これらを「完全に」一致させるようなコードを実装するのは、無駄を含むため、
    避けた方が良い(デザインを行なうのに必要となる範囲で一致させるレベルに留める)。

補足(この結論は今読んでも妥当): 「WYSIWYG を完全に一致させようとするな」
という判断は、その後の技術の流れが裏付けている

【デザイナという方式そのものが後退した】★
   ・ASP.NET Web Forms のデザイナ
       → VS 2022 では【存在しない】(Web Forms 自体が対象外)
   ・ASP.NET Core MVC / Razor Pages
       → 【デザイナがない】。マークアップを直接書く ★
   ・Blazor
       → デザイナなし。ホット リロードで確認する
   ・WPF / WinUI
       → デザイナはあるが、
         実務では【XAML を手で書く】人が多い

【なぜそうなったか】
   ・「絵で作る」ためのコストが
     【生成されるマークアップの汚さ】に見合わなかった
   ・レスポンシブ対応により
     「1 つの固定的な見た目」が意味を失った ★
   ・【ホット リロード】が実用化し、
     「書いて即座に実物を見る」方が速くなった

【現在の等価物】
   ・Blazor / Razor の【ホット リロード】
   ・Storybook 的な【コンポーネント カタログ】★
     → 部品を一覧表示して見た目を確認する専用ページを作る
     → デザイナを再現するより
       【実物を並べて見る】方が確実で安上がり

Tags: 移行, .NET開発, UIサブシステム, Windows Forms, ASP.NET Web Forms

NetDevInfraWiki

マイクロソフト系技術情報 Wiki
Open 棟梁 Wiki

(未着手)

開発基盤部会 Wiki

移行管理: DONETODO

Clone this wiki locally