次の方法で共有


フォームの勝者

Windows フォーム アプリケーションのパフォーマンス向上のための実践的ヒント

Milena Salman


この記事で取り上げる話題:

  • アプリケーションの起動時間の改善
  • コントロールの作成およびデータ取り込みのチューンアップ
  • 描画のパフォーマンスの改善
  • テキストおよびイメージのレンダリング
  • 効率のよいリソース管理

この記事で使用する技術:

  • Windows Forms、.NET Framework 2.0

サンプルコードのダウンロード: WindowsFormsPerformance.exe (605KB)
翻訳元: Practical Tips For Boosting The Performance Of Windows Forms Apps (英語)


目次

  1. 速やかな起動
  2. UI のロードのスピードアップ
  3. コントロールへのデータの取り込み
  4. データ バインディングの最適化
  5. 軽量レイアウト
  6. 描画のパフォーマンス
  7. テキストおよびイメージ
  8. 描画技法の適用
  9. リソース管理
  10. まとめ

Windows フォームでは、アプリケーション用の応答性能の高い充実したユーザー インターフェイスを構築することができます。この記事では、Windows フォームベースのアプリケーションが、それに対応する最大限のパフォーマンスを達成するのに使用できるいくつかの技法を取り上げています。起動、コントロールへのデータの取り込み、およびコントロールの描画といった、パフォーマンスを中心とした一般的なシナリオを取り上げます。それ以外に、アプリケーションでのパフォーマンス向上のための設計およびコーディングの方法も取り上げています。これらの技法をまとめれば、UI の機能を最大化するのに適した根本的な足がかりとなるはずです。


1. 速やかな起動

起動時間は、たいていのアプリケーションにとってパフォーマンスの重要な目安となります。起動が迅速であれば、アプリケーションのパフォーマンスと使いやすさという点でユーザーに好印象を与えます。アプリケーションの起動方法を言い表す用語には、ウォーム スタートアップと コールド スタートアップの 2 種類があります。コンピュータの再起動の直後にマネージ アプリケーションを開始させ、その後アプリケーションをいったん閉じてからもう一度開始します。通常は、起動時間にかなりの違いがあることに気付かれることでしょう。最初の (コールド) 起動では、必要なすべてのページをメモリに取り込むためのディスク I/O 操作を実行しなければなりません。2 番目の (ウォーム) 起動は、既にメモリ内にあるページを再使用するので、速度は大幅に速くなります。

ウォーム スタートアップの効果を得るためには、同じアプリケーションを事前に実行する必要があるとは限りません。どのマネージ アプリケーションの起動でも、Microsoft .NET Framework のバイナリから多数のページがメモリ内に持ち込まれます。そのため、その後で起動するマネージ アプリケーションではすべて、起動時間が改善されることになります。

パフォーマンスに重点をおいて設計する場合、ここに示す 2 つのシナリオを区別し、それぞれ別々の目標を設定することが大切です。ウォーム スタートアップは、CPU の使用率を削減することで改善できますが、コールド スタートアップは、ほとんどディスク I/O による影響を受けます。したがって、最適化によっては、シナリオが改善されることもされないこともあります。

まず、任意のマネージ コードで作成された開始アプリケーションに当てはまる、パフォーマンス上の一般的なアドバイスを述べることから始めます。その後、Windows フォームベース アプリケーションのパフォーマンスを改善するための具体的なヒントを示します。マネージ アプリケーションの起動時のパフォーマンスの改善に関するガイドラインは、2006 年 2 月の MSDNMagazine の Claudio Caldato 著「CLR Inside Out」という記事 (CLR Inside Out: Improving Application Startup Time (英語)) を参照してください。

コールド スタートアップを改善する 1 つの方法として、アプリケーションにロードする DLL の数を減らします。それによって、アプリケーションの起動時に取り扱う必要のあるページ数が直接影響を受けます。それ以外に、ロードするモジュール数を減らすことによって、モジュールのロードに関連した CPU のオーバーヘッドが削減され、ウォーム スタートアップの時間も改善されます。

Platform SDK の WinDbg デバッガ内で Load Module イベント フィルタを使用して、モジュールがどのようにアプリケーションにロードされているかを診断することができます。あるいは、次のような WinDbg コマンドを使用して、個々のモジュールがなぜロードされたかを確認することもできます。

sxe ld:foo.dll

コール スタックを見直して、いずれかのモジュールをロードしなくて済んだかどうかを確かめます。また、.NET Framework SDK の一部として用意されている MDbg マネージ デバッガの "ca ml" オプションを使用することもできます。場合によっては、単にコードに手を加えるだけで、追加のモジュールをロードしないで済ませることができます。手持ちの DLL を数多くアプリケーションでロードする場合、それらをマージして、1 つずつのサイズは大きくなっても総数を減らしたほうが得策かどうかを検討してみてください。

ジャストインタイム (JIT) でメソッドをコンパイルすると、多数の CPU サイクルが使用されて、アプリケーションの起動時に簡単にボトルネックが生じます。このオーバーヘッドが起きないようにするために、Native Image Generator の NGen.exe を使用して、アセンブリをプリコンパイルすることができます (NGen の強力な新機能によるアプリケーションのパフォーマンス向上を参照)。NGen は、JIT コンパイラの作業をすべて実行しますが、その作業を前もって完遂してから、ディスクに加えられた変更を保存して、実行時の CPU サイクルを節約します。NGen を使用してバイナリをプリコンパイルすれば、パフォーマンス上のさらに別の利点があります。ネイティブのコード ページをプロセスどうしの間で共用できる一方で、JIT コンパイル後のコード ページをプロセスにとってプライベート化して再利用できないようにすることができます。

このような利点があっても、プリコンパイルは起動時間に対して常に万能であるとは限りません。ウォーム スタートアップは NGen プリコンパイルの利点を活用できると考えられるのに対して、コールド スタートアップは何の改善につながることもなく、若干速度が落ちることさえあります。それは、コンパイル後のコードを収めたページを、ディスクからロードする必要が生じるからです。ただし、JIT コンパイルを全面的に排除した場合、JIT コンパイラそのものに必要なページをロードする必要がなくなるので、コールド スタートアップでさえ利点を活用できることになります。コールド スタートアップとウォーム スタートアップの所要時間を測定し、NGen の影響を評価するのが良いと思われます。

別の技法として、厳密な名前のアセンブリをグローバル アセンブリ キャッシュ (GAC) にインストールして、厳密な名前のシグニチャの検証を回避します。これは、ロードの前にアセンブリのページを 1 つずつ処理するので、手間のかかるプロセスです。NGen を使用することを決定した場合、厳密な名前のアセンブリを GAC に入れておいて、DLL のリベースが起きないようにすることがさらに重要になります。

どのバイナリ (DLL あるいは EXE) にも、優先ベース アドレスがあります。それは、そのロード先の仮想メモリ内にある場所です。そのアドレスは、作成時に指定します。Visual Basic あるいは C# コンパイラを使用する場合、別の作成指示を明示的に出さない限り、すべてのバイナリに同じベース アドレス (0x400000) が与えられます。このように、ロード時に EXE はこのアドレスに置かれるので、すべての DLL は、仮想メモリ内のどこか他の場所に置かれる (リベースされる) 必要があります。DLL の優先ベース アドレスは既にふさがっているからです。

DLL のリベース時に、ローダーは、新規のロード アドレスを反映するように DLL 内のすべての絶対アドレスを更新します。それは、調整を必要とするアドレスを収容するページはすべて、DLL がリベースされるときに処理されることを意味します。さらに、ページを書き込み可能にするために、そのページは、ページ ファイルによってコピーおよびバックアップされる必要があります。この時点で、ページは、プロセスにとってプライベートなページとなるので、他のプロセスとの間で共用できなくなります。

リベースによってパフォーマンスが影響を受けないようにするために、アプリケーション内の各 DLL ごとに /baseaddress コンパイラ スイッチを使用して、優先ベース アドレスを明示的に指定することができます。いずれかの事前指定の開始点 (たとえば 0x10000000) から始めて、各 DLL にベース アドレスを割り当て、該当範囲内にある DLL の巨大化を見込んだ余裕を設けるために、十分な大きさのギャップをアドレスどうしの間にあけておきます。

NGen イメージは、中間言語 (IL) イメージよりもかなり大きくなる傾向にあり、そのため、リベースの影響も大きくなります。NGen は、作成するイメージに対して、IL イメージに指定されているアドレスと同じベース アドレスを設定します。したがって、アセンブリに対して NGen を使用することを決定した場合、IL イメージのベース アドレスを割り当てる際には、NGen イメージを収容するのに十分なスペースをとっておく必要があります。通常、NGen イメージの場合、同等の IL イメージの場合よりも 2 ~ 3 倍のスペースが必要になります。

実際のロード アドレスを検証し、優先ロード アドレスと比較することによって、ベース アドレスの割り当てが順調に作動するかどうかを確かめることができます。そのために、タスク リストのサンプル アプリケーション TList.exe と、Microsoft COFF バイナリ ファイル ダンパ Dumpbin.exe を使用することができます。このコマンドは、次のように、1240 のプロセス ID のアプリケーションのコンテキストでロードされた各 DLL ごとに、実際のロード アドレスを示します。

tlist 1240

以下に示されているとおり、Dumpbin を使用して、DLL の優先ベース アドレスを調べることができます。

dumpbin /headers test.dll

このコマンドの出力を見れば、次のような、イメージ ベースを一覧で示したオプション ヘッダーを確認することができます。

75F70000 image base (75F70000 to 75F78FFF).

その意味は、test.dll の優先ベース アドレスは 75F70000 であり、その優先ベース アドレスにこの DLL をロードするときは、75F70000 ~ 75F78FFF の範囲のアドレスを指定できるはずであるということです。

ページのトップへ


2. UI のロードのスピードアップ

起動のパフォーマンスの印象は、最初の UI が表示されるまでの遅延時間によって大きく異なります。UI を表示するのに必要な論理を最小化する必要があります。その場合、フォームの Load イベント内で実行されるすべての作業が対象になります。なぜなら、フォームが自身を表示するプロセス中にその評価が行われるからです。

手間のかかるネットワークあるいはデータベースの呼び出しを行う場合、それぞれの呼び出しを別々のスレッド上で非同期化して、UI をブロックしないようにしてください。Windows フォーム 2.0 では、BackgroundWorker コンポーネントを使用して、作業をバックグラウンド・スレッドにプッシュすることができます。図 1 では、BackgroundWorker を使用して、特定のフォルダとそのサブフォルダの中で、特定のパターン (たとえば "*.txt") に一致するすべてのファイルを再帰的に検索しています。バックグラウンド スレッドは、進行状況に合わせて UI スレッドを更新します。すると、そのような進行状況に沿った更新に合わせて、TreeView 表示にファイルあるいはサブフォルダが UI スレッドによって追加されます。その結果の TreeView はフィルタされます。それによって、一致するファイルを収容していないサブフォルダは表示されなくなります。

図 1 バックグラウンド スレッドの使用

Imports System.IO

Public Class Form1
    Private DirInfo As DirectoryInfo
    Private Pattern As String

    Private Sub Form1_Load(ByVal sender As System.Object, _
            ByVal e As System.EventArgs) Handles MyBase.Load
        DirInfo = My.Computer.FileSystem.GetDirectoryInfo("C:\\")
        Pattern = "*.txt"
        Me.Text = "Searching for " & Pattern & " files"
        Me.TreeView1.Sorted = True
        Me.BackgroundWorker1.WorkerReportsProgress = True
        Me.BackgroundWorker1.RunWorkerAsync()
    End Sub

    Private Sub BackgroundWorker1_DoWork( _
        ByVal sender As System.Object, _
        ByVal e As System.ComponentModel.DoWorkEventArgs) _
        Handles BackgroundWorker1.DoWork

        For Each f As FileInfo In DirInfo.GetFiles(Pattern)
            Dim treeNode As TreeNode = New TreeNode(f.ToString)
            Me.BackgroundWorker1.ReportProgress(0, treeNode)
        Next

        For Each SubDir As DirectoryInfo In DirInfo.GetDirectories()
            Dim treeNode As TreeNode = GetMatchingFiles(SubDir)
            If Not treeNode Is Nothing Then
                Me.BackgroundWorker1.ReportProgress(0, treeNode)
            End If
        Next
    End Sub

    Private Sub BackgroundWorker1_ProgressChanged(_
        ByVal sender As Object, _
        ByVal e As System.ComponentModel.ProgressChangedEventArgs) _
        Handles BackgroundWorker1.ProgressChanged

        Me.TreeView1.Nodes.Add((CType(e.UserState, TreeNode)))
    End Sub

    Private Sub BackgroundWorker1_RunWorkerCompleted( _
        ByVal sender As Object, _
        ByVal e As System.ComponentModel.RunWorkerCompletedEventArgs) _
        Handles BackgroundWorker1.RunWorkerCompleted

        Me.Text = "Ready"
    End Sub

    Private Function GetMatchingFiles(ByVal dir As DirectoryInfo) _
        As TreeNode

        Dim treeNode As TreeNode = Nothing
        For Each f As FileInfo In dir.GetFiles(Pattern)
            If treeNode Is Nothing Then
                treeNode = New TreeNode(dir.Name)
            End If
            treeNode.Nodes.Add(New TreeNode(f.ToString))
        Next
        For Each SubDir As DirectoryInfo In dir.GetDirectories()
            Dim subNode As TreeNode = GetMatchingFiles(SubDir)
            If Not subNode Is Nothing Then
                If treeNode Is Nothing Then
                    treeNode = New TreeNode(dir.Name)
                End If
                treeNode.Nodes.Add(subNode)
            End If
        Next
        Return treeNode

    End Function
End Class

ここでバックグラウンド スレッドを使用すると、検索の進行中に UI が完全にアクセス可能になるという利点があります。ユーザーは、その時点までに見つかったファイルのリストをスクロールし、ツリー ビューにこれまでに追加されたサブフォルダ ノードを展開することができます。

実行する必要はあっても最初の UI を表示するときには必要ない操作は、システムのアイドル期間中か、あるいは最初の UI が表示された後で実行することができます。たとえば、タブ コントロールを扱う場合、起動時には一番上のページにのみデータを取り込み、他のページの情報は、必要になったときに検索します。図 2 のコードは、6 つの TabPage からなる TabControl を備えたフォームを示しています。各 TabPage ごとに必要なすべてのコントロールを UserControl 上に置き、TabPage を表示しようというときに、TabPage のコントロール コレクションにその UserControl を追加します。分かりやすいように、どの TabPage でも同じ UserControl を使用し、一部の子コントロールのコンテキストのみを若干変更しましたが、各 TabPage に対応する別々の UserControl を簡単に設けることもできます。

図 2 Idle でのコントロールの初期化

public partial class Form2 : Form
{
    int currentIndex = 1;
    public Form2(bool onDemand)
    {
        InitializeComponent();
        if (!onDemand) InitTab(-1);
        else
        {
            InitTab(0);
            Application.Idle += new EventHandler(Application_Idle);
        }
    }

    private void InitTab(int index)
    {
        if (index == -1)
        {
            for (int i = 0; i < tabControl1.TabCount; i++)
            {
                InitTab(i);
            }
            return;
        }

        // TabPage に既にデータが取り込まれている場合
        if (tabControl1.TabPages[index].Controls.Count > 0) return;

        UserControl control = new myTabPage(index);
        if (control != null)
        {
            control.Dock = DockStyle.Fill;
            tabControl1.TabPages[index].Controls.Add(control);
        }
    }

    private void tabControl1_SelectedIndexChanged(
        object sender, System.EventArgs e)
    {
        InitTab(tabControl1.SelectedIndex);
    }

    private void Application_Idle(object sender, EventArgs e)
    {
        if (currentIndex >= tabControl1.TabCount)
        {
            Application.Idle -= new EventHandler(Application_Idle);
        }
        else InitTab(currentIndex++);
    }
}

一番上のページでは、フォームのコンストラクタ内に UserControl を追加しました。他のページでは、それぞれ対応するページが表示されるときに、TabControl.SelectedIndexChanged イベントの起動に応じて追加されるようにしました。ユーザーが TabPage を初めて選択したときは、いくらかの遅延が起きることになります。次に選択したときは、TabPage はすでに初期化済みなので、遅延は起きません。

この例では、ListView の表示のときに示す項目を増やすことで、TabPage の作成を意図的にスロー ダウンしました。すべての TabPage があらかじめ初期化済みになっている場合、私のシステム (Pentium III、800 MHz、512 MB RAM) でフォームの表示に要する時間は 9 秒です。TabPage にオンデマンドでデータを取り込むことで、最初に各 TabPage を表示するのに要する時間は約 1.5 秒になります。一番上のページだけにデータを取り込む場合、フォーム全体は 1.5 秒以内で表示されます。

これを一歩先に推し進めることができます。それには、Application.Idle イベントをリッスンしながら、Idle イベントが発生したら、一度に 1 つずつ TabPage にデータを取り込みます。フォームが表示されるか、あるいは新規の TabPage が表示されたときに、ユーザーがそのページを見てから、マウスを移動して、別のページを選択するまでには少々時間がかかります。アプリケーション操作におけるこのような人的遅延の間に、Idle イベントが処理され、後続の TabPage にデータが取り込まれます。

他の技法を使用して、起動時のパフォーマンスを十分に改善できない場合、Creating a Splash Screen Form on a Separate Thread (英語) に掲載されている例のように、スプラッシュ画面および進行状況バーを使用して、常にユーザーに対して情報を提供することを検討してみてください。アプリケーションを Visual Basic で作成する場合、.NET Framework 2.0 の My.Application.SplashScreen プロパティを使用することができます (My.Application.SplashScreen プロパティを参照)。

ページのトップへ


3. コントロールへのデータの取り込み

ListView、TreeView、および Combobox などの多くの Windows フォーム コントロールは、項目のコレクションを表示します。このコレクションに追加する項目数が多すぎると、最適化を適用しないかぎり、ボトルネックを生じることになります。さいわいにも、.NET Framework 2.0 では、コントロールへのデータ取り込みに対して何箇所かの改善が Windows フォームに加えられました。その一環として、AddRange メソッド内で BeginUpdate および EndUpdate メソッドを内部的に使用して、AddRange を使用する TreeView へのデータの取り込みの最適化や、すべてのコントロールへのソート済みデータの取り込みの最適化などが図られています。

コントロールへのデータ取り込みが低速化する最も一般的な原因は、各変更後にその都度コントロールが再描画されることにあります。Windows フォームの多くのコントロールは BeginUpdate および EndUpdate メソッドを実装しています。これらのメソッドは、基盤となるデータあるいはコントロールのプロパティに手が加えられている間、再描画を抑止します。これらのメソッドを使用すれば、コントロールに対して大幅な変更 (コレクションへの項目の追加など) を加えても、継続的な再描画を行わなくて済みます。これらのメソッドの使用例を以下に示してあります。

listView1.BeginUpdate();
for(int i = 0; i < 10000; i++)
{
    ListViewItem listItem = new ListViewItem("Item"+i.ToString() );
    listView1.Items.Add(listItem);
}
listView1.EndUpdate();

どのような場合でも、バルク操作のほうが個別変更よりも効率は高くなります。ComboBox、ListView、あるいは TreeView などのコントロール コレクションに項目を追加する望ましい方法としては、AddRange メソッドを使用します。このメソッドを使用すれば、事前作成した項目の配列を一度に追加することができます。Windows フォームによって、可能な限りすべての最適化が行われて、この操作が効率化されます。多くの場合、これは、BeginUpdate および EndUpdate が自動的に呼び出されるというだけにすぎません。ただし、さらに別の最適化が行われることもあります。たとえば、TreeView コントロールにとっては、そのような最適化は重要です。

ListView へのデータの取り込みは、ListView のハンドルの作成後に実行すると、作業が大幅に高速化します。多数の項目をかかえた ListView を示すフォームを表示する必要がある場合、Form.Load あるいは Form.Show イベント ハンドラ内で ListView に項目を追加します。この時点で、ListView ハンドルは既に作成済みになります。フォームのコンストラクタ内で (つまり、ListView ハンドルの作成前に) ListView 項目を追加する場合、項目は速やかに追加されますが、フォームの表示にはかなりの時間を要します。それは、ListView のレンダリングが低速であるからです。

TreeView の場合は、ListView とは違って、ハンドルの作成前にノードを追加すれば、データの取り込みは大幅に早くなります。TreeView.AddRange をご使用の場合は、その差に気付かれないでしょうが、どの時点でデータの取り込みを実行するかに関係なく、TreeView にはデータが迅速に取り込まれます。ただし、Add メソッドを使用してフォームのコンストラクタ内でノードを追加してから、TreeView のハンドルを作成すると、AddRange メソッドを使用した場合と同じように迅速に TreeView にデータが取り込まれて表示されます。

ページのトップへ


4. データ バインディングの最適化

ComboBox あるいは ListBox などのデータ バインド コントロールにデータを取り込むときは、ValueMember および DisplayMember を設定した後、DataSource プロパティを最後に設定したほうが効率は向上します。そうしないと、次のように、ValueMember の変更の結果として、コントロールへ再度データが取り込まれます。

comboBox1.ValueMember = "Name";
comboBox1.DisplayMember = "Name";
comboBox1.DataSource = test;

この理由から、上記のコードのほうが、以下のコードよりも効率が高くなります。

comboBox1.DataSource = test;
comboBox1.ValueMember = "Name";
comboBox1.DisplayMember = "Name"

BindingSource.SuspendBinding および BindingSource.ResumeBinding は、データのバインディングを一時中断および再開するための 2 つのメソッドです。SuspendBinding は、ResumeBinding が呼び出されるまでの間、データ ソースに変更がプッシュされないようにします。

これらのメソッドは、TextBox あるいは ComboBox データ バインディングなどの、単純なバインド シナリオに沿って使用されるように工夫されています。図 3 のコードは、バインディング作業の中断および再開を行わない反復処理よりも約 5 倍の早さで、バインディング作業を中断および再開する反復処理を示しています。

図 3 データ バインディングの一時中断および再開

private void button1_Click(object sender, EventArgs e)
{
    DataTable tbl = this.bindingSource1.DataSource as DataTable;
    Stopwatch sw1 = new Stopwatch();
    sw1.Start();
    for (int i = 0; i < 1000; i++)
    {
        tbl.Rows[0][0] = "row " + i.ToString();
    }
    sw1.Stop();

    Stopwatch sw2 = new Stopwatch();
    sw2.Start();
    this.bindingSource1.SuspendBinding();
    for (int i = 0; i < 1000; i++)
    {
        tbl.Rows[0][0] = "suspend row " + i.ToString();
    }
    this.bindingSource1.ResumeBinding();
    sw2.Stop();
    MessageBox.Show(String.Format("Trial 1 {0}\r\nTrial 2 {1}",
        sw1.ElapsedMilliseconds, sw2.ElapsedMilliseconds));
}

private void Form1_Load(object sender, EventArgs e)
{
    DataTable tbl = new DataTable();
    tbl.Columns.Add("col1");
    tbl.Rows.Add("one");
    tbl.Rows.Add("two");
    this.bindingSource1.DataSource = tbl;
    this.textBox1.DataBindings.Add("Text", this.bindingSource1, "col1");
}

DataGridView などの、複合データ バインディングを実装するコントロールは、ListChanged などの変更イベントに基づいて自身の値を更新するので、SuspendBinding を呼び出しても、データ ソースに加えられた変更がそのコントロールで受信されなくなることはありません。RaiseListChangedEvents プロパティを false に設定することによって、ListChanged イベントを抑止した場合のみ、これらのメソッドを複合バインディング シナリオで使用することができます。

ページのトップへ


5. 軽量レイアウト

コントロールのサイズ変更や再整列などの変更も、問題の原因になることがあります。たとえば、子コントロールをフォームあるいは ToolStrip に追加すると、Control.Layout イベントが起動されます。子コントロールのサイズあるいはロケーションを変更すると、親コントロールに対する Layout イベントの原因になります。また、フォント サイズの変更も、Layout イベントの原因になります。Layout イベントに対する応答として、フォームではコントロールのスケーリングと配置が行われます。コントロールの作成あるいはサイズ変更の間に処理される Layout イベントの数が多すぎると、パフォーマンスに対して深刻な影響を与えることがあります。

Windows フォームのデザイナによって生成されるコンポーネント コードは、SuspendLayout で開始し、ResumeLayout で終了します。この措置がとられるのは、フォームの作成およびコントロールの取り込み中に、フォームに対して Layout が実行されないようにするためです。SuspendLayout メソッドを使用すれば、1 つのコントロールに対して複数のアクションを実行することができ、しかも各変更ごとに Layout イベントが生成されることはありません。可能な限り常にこの技法を使用して、Layout イベントの数を最小化してください。

注意が必要なのは、SuspendLayout は、特定のコントロールに対して Layout イベントが実行されないようにするだけだということです。たとえば、パネルにコントロールを追加する場合、親フォームではなくそのパネルに対して、SuspendLayout および ResumeLayout が呼び出される必要があります。

AutoSize コントロールの Bounds、Size、Location、Visible、および Text といったプロパティを変更すると、Layout イベントが起動されます。そのようなプロパティを Form.Load 内で変更すると、さらに面倒なことになります。すべてのハンドルが事前に作成されるので、大量のメッセージが処理されることになるからです。このような場合にも、SuspendLayout および ResumeLayout を追加して、余計な Layout イベントが発生しないようにする必要があります。可能であれば、変更はすべて InitializeComponent 内で行います。そうすれば、Layout イベントは 1 つしか必要なくなります。

コントロールのサイズやロケーションを指示するプロパティはいくつかあります。たとえば、Width、Height、Top、Bottom、Left、Right、Size、Location、および Bounds などです。panel1.Width を設定してから panel1.Height を設定すると、panel1.Size を使用してこの両者を一緒に設定する場合よりも作業量は 2 倍になります。図 4 のコードを実行すると、その違いが示されます。

図 4 レイアウトのパフォーマンスのテスト

private void button1_Click(object sender, EventArgs e)
{
    Stopwatch sw = new Stopwatch();

    sw.Start();
    for (int i = 1; i < 1000; i++) {
        panel1.Width = i;
        panel1.Height = i;
    }
    sw.Stop();

    Stopwatch sw2 = new Stopwatch();
    sw2.Start();
    for (int i = 1; i < 1000; i++) {
        panel1.Size = new Size(i, i);
    }
    sw2.Stop();
    MessageBox.Show(String.Format("Trial 1 {0}\r\nTrial 2 {1}",
        sw.ElapsedMilliseconds, sw2.ElapsedMilliseconds));
}

SuspendLayout および ResumeLayout の呼び出しを使用する場合でも、ハンドルが作成されると、違いが分かります。SuspendLayout は、Windows フォームの OnLayout が呼び出されないようにするにすぎません。サイズの変更を知らせるメッセージの送信および処理が行われないようにすることはありません。お手元にある情報の大半を反映するプロパティの設定を試みてください。サイズを変更するだけの場合、Size を設定します。サイズとロケーションを変更する場合は、Bounds を変更します。

ページのトップへ

6. 描画のパフォーマンス

描画を軸とした Windows フォーム アプリケーションでは、描画のパフォーマンスの強化によって恩恵を受けることができます。まず、各 Paint イベントごとに実行する作業を最小化します。 Paint イベント ハンドラを可能なかぎり軽量化します。それには、煩雑なすべての操作を排除します。たとえば、現在の ClientSize に基づいてテキストをレンダリングする場合、各 Paint イベントではなく、Resize イベント ハンドラ内で該当サイズの Font オブジェクトを作成することができます。描画用のブラシが必要な場合、それをキャッシュに入れておけば、何回も再作成しなくて済みます。

コントロールの再描画を行いたいときは、それに対して Invalidate メソッドを呼び出すことができます。その呼び出しに対して何も引数を渡さなければ、コントロール全体が再描画されます。多くの場合、再描画を必要とする領域を慎重に計算し、その領域を引数として Invalidate に引き渡すことで、描画のパフォーマンスを最適化することができます。

それが十分に功を奏さない場合は、OnPaint イベントの PaintEventArgs パラメータに組み込まれる ClipRectangle 構造を使用することができます。たとえば、次のようにして、無効化されたイメージの一部を描画することができます。

Protected Overrides Sub OnPaint(_
    ByVal e As System.Windows.Forms.PaintEventArgs)

    e.Graphics.DrawImage(Me.RenderBmp, e.ClipRectangle, _
        e.ClipRectangle, GraphicsUnit.Pixel)

End Sub

ただし、この技法を使用するには、慎重な計算が必要な場合がよくあります (スクロールやスケーリングが作業の一環となっている場合など)。

Windows フォームおよびその基礎を成す Win32 アーキテクチャは、すべてのコントロールに対して、バックグラウンドおよびフォアグラウンドという 2 つの描画層を公開しています。Control.OnPaintBackground 関数は、バックグラウンド効果 (一般的には背景イメージおよび背景色) の描画を担当し、Control.OnPaint 関数は、フォアグラウンド効果 (イメージおよびテキスト) の描画を担当します。背景イメージあるいは背景色を使用しないで、代わりにカスタム描画を OnPaint 内のあらゆるものに対して使用するコントロールの場合、Opaque コントロール スタイルを用いれば、使用していない描画論理はスキップされるので、パフォーマンスにとっては都合がよいかもしれません。コントロール上で ControlStyles.Opaque を true に設定する場合は特にそうです。その場合、OnPaint 関数がすべての描画作業 (バックグラウンドの描画も含めて) を実行することが前提になるので、OnPaintBackground 関数はスキップされるからです。それ以外に、コントロールが完全に他のコントロールによって覆われるために、そのバックグラウンドをペイントする必要がない場合にも、このスタイルは有用と考えられます。

ページのトップへ


7. テキストおよびイメージ

TextRenderer クラスを使用して、.NET Framework 2.0 において Windows フォームのコントロール上でテキストを測定および描画することができます。TextRenderer は、MeasureText および DrawText という 2 つのメソッドを持っていますが、このどちらのメソッドも、いくつかのオーバーロードを持っています。レンダリング操作の効率は、選択するオーバーロードによって、および TextFormatFlags 引数に設定するオプションによって決まります。

単一行の文字列の測定の場合は、TextFormatFlag.WordBreak フラグを使用しないほうが楽です。このフラグを設定すると、信頼性は高くても煩雑なアルゴリズムが GDI によって実行されて、テキストを分割して置く場所が決定されます。同様に、Graphics オブジェクトに対してクリッピングや変換が適用されていない場合は、TextFormatFlags.PreserveGraphicsClipping オプションおよび TextFormatFlags.PreserveGraphicsTranslateTransform オプションを使用しないでください。これらのオプションは、煩雑な計算が行われる原因になるからです。

IDeviceContext を引数としてとらない TextRenderer メソッド オーバーロードを使用したほうが得策です。これらのメソッドのほうが効率は良くなります。なぜなら、これらのメソッドは、内部 DeviceContext から装置コンテキストのネイティブ ハンドルを取り出して、それをラップする内部オブジェクトを作成するのではなく、スクリーン対応のキャッシュされたメモリ装置コンテキストを使用するからです。

イメージ ファイルを表示する一方で、アルファ コンポーネント (ブレンド) を収容しているアプリケーションの場合、イメージを事前にレンダリングして、積算済みの特殊なビットマップ フォーマットにすることによって、パフォーマンスを大幅に改善することができます。イメージがアルファ コンポーネントを持っている場合、そのイメージを表示するときには、そのカラー要素にアルファを乗算する必要があります。積算済みアルファを使用してイメージを事前レンダリングすれば、イメージ内の各ピクセルごとに何回も (RGB イメージの場合は 3 回) 乗算操作を行わなくて済みます。

この技法を使用するには、まずファイルからイメージをロードし、PixelFormat.Format32bppPArgb を使用してそのイメージをビットマップにレンダリングします。図 5 は、この技法を使用してコントロール上の背景イメージを設定しています。

図 5 背景イメージのレンダリング

Public Overrides Property BackgroundImage() As System.Drawing.Image
    Get
        Return Me.RenderBmp
    End Get
    Set(ByVal value As System.Drawing.Image)
        Me.RenderBmp = New Bitmap(Me.Width, Me.Height, _
            Imaging.PixelFormat.Format32bppPArgb)
        Dim g As Graphics = Graphics.FromImage(Me.RenderBmp)
        g.InterpolationMode = Drawing.Drawing2D.InterpolationMode.High
        g.DrawImage(value, New Rectangle(0, 0, Me.RenderBmp.Width, _
            Me.RenderBmp.Height))
        g.Dispose()
    End Set

End Property

Windows フォーム 2.0 では、BackgroundImage プロパティには、BackgroundImageLayout という仲間のプロパティがあります。前のバージョンの .NET Framework では、背景イメージは Windows フォームによって自動的に並べて表示されていました。そのため、クライアント域全体にそのイメージが反復表示されていました。新規の BackgroundImageLayout プロパティでは、並べて表示、中央、伸縮、あるいはズームのいずれかに背景イメージを設定することができます。前のバージョンの .NET Framework とのアプリケーション互換性を保つために、並べて表示のレイアウトがこれまでどおり既定値になっています。

一連の充実した背景イメージ機能が Windows フォームに付け加えられたことに加えて、BackgroundImageLayout という既定外の値によって、背景イメージの描画のパフォーマンスが改善されます。並べて表示の設定での主な短所は、イメージ パターンを反復するのに TextureBrush を必要とする点にあります。TextureBrush の作成は、その一環としてイメージ全体をスキャンするので、面倒な作業になります。他のレイアウトであれば、Graphics.DrawImage メソッドを使用するだけなので、結果としてこちらのほうがパフォーマンスははるかに良くなります。

並べて表示以外のレイアウトの場合、BackgroundImage および BackgroundImageLayout プロパティを設定することによって、DoubleBuffered プロパティが自動的にオンになります。特に、イメージが何らかの透過性を持っている (ImageFlagsHasAlpha を介して検出できます) 場合、コントロールはダブル バッファリングを使用してパフォーマンスを向上します。並べて表示のレイアウトの場合、DoubleBuffered プロパティは自動的にはオンになりませんが、常に手動でオンにすることができます。

ダブル バッファリングとは、フリッカーを削減することによって、描画を高速化し画像をスムーズにするために使用される技法のことです。その基本的な考え方としては、コントロールを描画するのに使用する描画操作方法にのっとって、その方法をスクリーン外のバッファに対して適用します。描画操作がすべて完了したら、このバッファが、1 つのイメージとしてコントロール上に描画されます。その場合は、通常、フリッカーが削減されているので、アプリケーションが高速化したように見えます。この動作を .NET Framework 2.0 で実現するには、コントロール スタイルを OptimizedDoubleBuffer に設定します。これは、前のバージョンの Framework で DoubleBuffer および UserPaint スタイルを設定するのに相当します。

ダブル バッファリングを完全に有効化するには、AllPaintingInWmPaint を true に設定する必要もあります。そのように設定することによって、ウィンドウ メッセージ WM_ERASEBKGRND が無視され、OnPaintBackground と OnPaint の両方のメソッドが WM_PAINT メッセージから直接呼び出されます。DoubleBuffering および AllPaintingInWmPaint を true に設定した場合、バッファに入れられた同じグラフィックス オブジェクトを使用して OnPaintBackground および OnPaint が呼び出され、何もかもがまとめてスクリーン外で描写されてすべてが一度に更新されます。

ダブル バッファリングを有効に利用するには、この両方のスタイルを true に設定するか、あるいは次のように、コントロールに対して DoubleBuffered プロパティを設定します。

SetStyle(ControlStyles.OptimizedDoubleBuffer |
    ControlStyles.AllPaintingInWmPaint, true);
// あるいは、次のようにします。
DoubleBufferred = true;  // 両方のフラグを設定します。

ここで、大事なことを言っておきます。DoubleBuffered プロパティの設定が、常に描画上の問題に対する最上の解決策とはかぎりません。このプロパティは、アプリケーションおよび目標とするシナリオに応じて、慎重に使用する必要があります。DoubleBuffered を設定した場合の主な短所は、大量のメモリが描画に割り振られる点にあります。ダブル バッファリングを使用する必要がある場合、コントロールの少数の部分を無効にして、最も重要な部分だけを再描画すればよいようにすることも検討してみてください。

AllPaintingInWmPaint を true に設定することの別の副次作用として、ご使用のウィンドウ上を別のアプリケーションに属するウィンドウが通過したときに、ご自分のコントロールの子コントロール上に白色のトレースを表示することができます。これが可能な理由は、子コントロールが受け取る WM_ERASEBKGND メッセージは多数あっても、受け取る WM_PAINT メッセージは 1 つだけだからです。WM_ERASEBKGND メッセージは無視されるので、このメッセージの再描画が行われることはめったにありません。

ページのトップへ


8. 描画技法の適用

図 6 の画面は、背景イメージを設定されたユーザー コントロール上のテキスト アニメーションを示しています。別の描画オプションを設定して、描画のスムーズさと速度に対するその効果を見ることができます (このサンプル アプリケーションは、MSDN マガジン Web サイトからダウンロードできます)。

図 6 パフォーマンステストのサンプル アプリケーション
図 6 パフォーマンステストのサンプル アプリケーション

メイン フォームには、DrawingSurface というユーザー コントロールがあります。このコントロールには、1 つの背景イメージ、いくつかのテキスト (既定では "Hello World!")、および 2 つのタイマがあります。最初のタイマはアニメーション速度を制御しますが、これは既定では 10ms に設定されています。これは、[Interval] の設定を使用して変更することができます。設定した間隔期間が経過したら、テキストは描画面上の別の位置に再描画されます。テキストは、アニメーション効果に応じて、バウンドするかあるいはスピンします。

2 番目のタイマは、描画面の右下隅に表示される実際のアニメーション速度を更新するのに使用されます。実際のアニメーション速度とは、直前の 1 秒間に処理されたペイント イベントの数を意味します。実際のアニメーション速度のほうが、所定のアニメーション速度より低いことがあります。たとえば、10ms の間隔とは、テキストの位置を 1 秒間に 100 回変更しようとしていることを意味します。

[Background Image Layout] のオプションを使用して、[Tile] と、その他のイメージ レイアウト フラグの描画パフォーマンスを比較することができます。ここでは、参考として [Center] レイアウトを選択しました ([Zoom] あるいは [Stretch] でも、[Center] と同じ効果を生じるはずです)。このアプリケーションでは、選択するイメージ レイアウトに関係なく、描画面サイズに収まりきるようにイメージを再配置しました。ただし、背景がペイントされるときに、[Tile] レイアウトによってパフォーマンスが影響を受けます。ここでも、[Center] レイアウトを選択すると、DoubleBuffered オプションが自動的に設定されます。ただし、[Tile] レイアウトを選択した場合には自動設定されません。

フリッカー効果を確認し、描画面の右下隅に示される実際のアニメーション速度を調べれば、それぞれのオプションがどのようにパフォーマンスに影響を与えるかが分かります。アニメーション速度は、描画オプション、設定した更新間隔、およびご使用のマシンの物理的な制限事項によって決まります。

このテストで最善の結果が得られたのは、[Double Buffering] および [Smart Invalidation] 技法を適用し、[Background Image Layout] を [Center] (具体的に言うと、[Tile] 以外の任意のレイアウト) に設定し、そして PixelFormat.Format32bppPArgb を使用して [BackgroundImage] をレンダリングしたときでした。これらの技法をすべて使用した場合、描画はスムーズになり、フリッカーが排除されます。しかも、アニメーションが高速化します。

ページのトップへ


9. リソース管理

アプリケーションで使用する物理メモリは、パフォーマンス上の重要な鍵を握ります。使用するメモリおよびリソースを最小限にとどめて、他のプロセスでそれらを最大限に活かせるようにします。それは、行儀よくするということだけではありません。メモリのフットプリントを小さくすれば、それはアプリケーションにとって利点となり、しかも、使用可能な物理メモリを使い切って、マシンでページングが発生するほどのメモリ量を使用している場合は、その利点は劇的なものになります。しかし、ハイエンド マシンをターゲットとしていて、ページングが脅威になることはありえない場合でも、メモリを賢明に使用するのがよいと考えられます。アプリケーションでメモリを無造作に割りふった場合、共通言語ランタイム (CLR) でのメモリ管理の手間は膨大なものになります。

メモリ フットプリント、ガベージ コレクション、および、ウィンドウ ハンドルや GDI ハンドルなどのネイティブ リソースの管理は、Windows フォーム アプリケーションの場合は相互に依存し合っています。アンマネージ リソースを解放しないでいると、ガベージ コレクタ (GC) が負う負担が増加します。Windows フォームは、ハンドルの使用を追跡し、リソース不足の危機に陥った場合は GC.Collect を呼び出して、別のガベージ コレクション サイクルを強制的に追加することがあります。それ以外に、そのようなリソースを確保しているマネージ オブジェクトを GC によってファイナライズする必要もあります。これは、アプリケーションで IDisposable インターフェイスを介して、先行措置としてリソースを解放するよりもはるかに面倒な作業です。

オブジェクトの存続期間が明示的に示されている場合、そのオブジェクトに関連したアンマネージ リソースを解放する必要があります。使用しているクラスが Dispose パターンを実装している場合に、オブジェクトをいつ処分するかが明示的に示されていれば、必ず Dispose を呼び出してください。Dispose メソッドでは、ファイナライザ内のものと同じクリーンアップ コードを呼び出して、今後はそのオブジェクトをファイナライズする必要はなくなったことを GC に通知します。それには、GC.SuppressFinalization メソッドを呼び出します。そうすれば、パフォーマンスが向上します。なぜなら、ゼロ世代のすべての非参照オブジェクトが、1 つの GC サイクルで回収されるからです。回収可能オブジェクトをファイナライズする必要がある場合、その回収には少なくとも 2 つの GC サイクルが必要です。

通常、IDisposable を実装するオブジェクトでは、そのような措置がとられます。それは、そのようなオブジェクトは、必ず解放する必要のあるリソースを確保しているからです。たとえば、Windows フォーム コントロールは、解放する必要のあるウィンドウ ハンドルを確保しています。フォームでコントロールを動的に追加および除去する場合、その都度 Dispose を呼び出す必要があります。そうしないと、プロセス内に不要なハンドルが累積されます。処分する必要があるのは一番上のコントロールだけです。その子は自動的に処分されるからです。

モーダル (ShowDialog メソッドを使用します) でフォームを表示する場合、フォームを閉じた後で処分する必要があります。この場合は、フォームは自動的には処分されないので、閉じた後でもその状態を問い合わせることができます。他のフォームの場合は、閉じると自動的に処分されることに注意してください。

ブラシ、ペン、およびフォントなどのグラフィックス オブジェクトも、IDisposable を実装します。これらのオブジェクトも GDI ハンドルを保持するからです。通常、このようなオブジェクトは OnPaint メソッドで作成しますが、このメソッドは、コントロールの再描画が必要になるたびに呼び出されます。このようなオブジェクトをすぐに処分しないと、GC によってファイナライズされるまでに、多数のオブジェクトが累積されて、多数の GDI ハンドルを保持することになります。C# あるいは Visual Basic でコードを作成する場合、using ステートメントは、次のように、簡単にそれを行う手段になります。

using (Pen p = new Pen(Color.Red, 1))
{
    e.Graphics.DrawLine(p, 0,0, 10,10);
}

Using p As Pen = New Pen(Color.Red, 1)
    e.Graphics.DrawLine(p, 0,0, 10,10)
End Using

C++ を使用する場合、次のように、そのスタック割り振りセマンティクスも、上記と同じ措置をとるのに有用です。

Pen p(Color::Red, 1);
e->Graphics->DrawLine(%p, 0,0, 10,10);

可能な限り、GC.Collect を呼び出さないようにする必要があります。GC は自己調整機能であるため、アプリケーションのメモリ所要量に合わせて自身を調整します。たいていの場合、GC をプログラマチックに呼び出すと、この自動調整が妨げられます。GC.Collect が役立つと考えられる唯一の時点は、大量のメモリが解放されたと分かった直後に、すぐにそのメモリを回収したい場合です。ただし、一般的には、GC が単純に自身を管理できるようにしたほうが効率は高くなります。

フォームが、200 を超える子コントロールを持っている場合、それらをさらに効率よく設計する方法を検討してみてはいかがでしょうか。どのコントロールもネイティブ リソースを利用するので、多数のコントロールの管理は大変な作業になります。それに代えて、ListView、TreeView、DataGridView、ListBox、ToolStrip、および MenuStrip の使用を検討してみてください。一般的に、それらのコントロールの個々の項目にとっては、ネイティブ ハンドルが各項目をバックアップする必要はありません。

物理メモリが不足してページングが発生した場合、物理ディスク使用率は高くなります (ページはディスクに書き込まれるからです)。その場合、アプリケーションの稼働は容認できない速度に低下し、メモリ使用量を削減する必要に迫られます。このような場合には、Visual Studio Team System のプロファイリング ツールが役に立ちます。また、このツールを使用して、マネージ メモリの割り振りを調べることができますが、CLR のプロファイリング サポート (CLR Profiler: どんなコードも見逃さない .NET Framework 2.0 のプロファイリング API) を使用することもできます。さらに、次のように、仮想アドレス ダンプ (VaDump) ツールを使用して、処理の実効ページ セットを調べることもできます。

VaDump ?sop <pid>

10. まとめ

サイドバー「パフォーマンスに関する 12 のヒント」は、アプリケーションのパフォーマンスを最適化する方法を詰め込んだ福袋です。それらはすべて、個別に使用してもあるいはまとめて使用しても、確実にパフォーマンス上の利点につながります。それによって、高速でしかも効率のよい Windows フォーム アプリケーションを作成するための技能が身に付くはずです。そのようなアプリケーションはユーザーにとって印象深いものになり、その使用によってユーザーは満足を得ることになります。

ページのトップへ


パフォーマンスに関する 12 のヒント

  • 起動時にロードするモジュール数を削減します。
  • NGen を使用してアセンブリをプリコンパイルします。
  • 厳密な名前のアセンブリを GAC 内に入れます。
  • ベース アドレスが衝突しないようにします。
  • UI スレッド上でブロッキングが起きないようにします。
  • レイジープロセスを実行します。
  • コントロールへのデータの取り込みを迅速化します。
  • データ バインディングに対する制御を強化します。
  • 再描画を削減します。
  • ダブル バッファリングを使用します。
  • メモリ使用を管理します。
  • リフレクションの思慮深い使用を心がけます。

ページのトップへ


Milena Salman は、Microsoft の .NET クライアント (Windows フォーム) チームのソフトウェア設計エンジニアです。同氏は、Windows フォームのパフォーマンス調整の専任担当者です。同氏の連絡先は、msalman@microsoft.com です。この記事に対する Windows フォーム チームの Shawn Burke および Jessica Fosler の貴重なご協力に謝意を表します。


この記事は、MSDN マガジン - 2006 年 3 月からの翻訳です。

QJ: 060301

ページのトップへ