次の方法で共有



MARCH 2009

Volume 24 Number 03

Wicked Code - Silverlight での開発に関する 3 つの重要なヒント

Jeff Prosise | MARCH 2009

コードは MSDN コード ギャラリーからダウンロードできます。
オンラインでのコードの参照

目次
オンデマンドでアセンブリを読み込む
ジャストインタイムでレンダリングする
地域への依存性を回避する
ページをめくる

これを書いている時点で、Silverlight 2 はホット ニュースであり、多くの人が Web プログラミングの未来を表すと信じている技術を開発者は初めて目にしているところです。Silverlight の支持者でも、Adobe Flex などの競合技術の方に魅力を感じる人でも、HTML、JavaScript、AJAX などに代わる技術が誕生し、Web アプリケーションの作成手段として人の心をつかむのを目にするのは刺激的なことです。マイクロソフトはと言えば、早くも Silverlight 3 に懸命に取り組んでおり、将来はかつてないほど明るいものです。

どのようなプラットフォームでも同じですが、Silverlight 開発者に至る道にはいくつかの落とし穴が待ちかまえています。たとえば、米国の PC でテストすると何も問題のない XamlReader.Load の呼び出しの多くが、他の国の PC では失敗することをご存じですか。Silverlight のレンダリング エンジンは UI スレッドと密接に結びついており、そのことがコードの構造に大きく影響する場合があることを理解していますか。アセンブリを動的に読み込むことで XAP ファイルのサイズを小さくできますが、厳密な型指定の利点を手放すことなくこれを行うには CLR の内部についての知識が必要であることを知っていますか。興味を引かれたなら、このまま読み進んでください。ここでは、Silverlight にかかわる生活がもう少し円滑になり、Silverlight プログラマとしての能力と知識も高めることのできるヒントと秘訣をいくつかお教えします。

配布を高速化するための Silverlight コンテンツのパッケージ化の詳細については、2009 年 1 月号の「Cutting Edge」コラムを参照してください。

オンデマンドでアセンブリを読み込む

適切に設計された Silverlight アプリケーションの特徴の 1 つは、以前はアプリケーション パッケージと呼ばれていた XAP ファイルが小さいことです。XAP ファイルは、埋め込みリソース (特にイメージ) とアセンブリ参照のために、管理するのが難しいほどのサイズに膨れ上がることがよくあります。XAP ファイルが大きいほどダウンロードに時間がかかり、大きくなりすぎると Silverlight で読み込むことができなくなることさえあります。

アプリケーションが使用するリソースとアセンブリを慎重に分割し、読み込みやダウンロードに時間がかかりそうなものを必要になるまで Web サーバーに置いておくことで、大きいアプリケーションであっても小さい XAP ファイルにパッケージ化できます。アプリケーション パッケージをダウンロードした後は、Silverlight のネットワーク スタックの WebClient や他のクラスを使用して、他のリソースやアセンブリをダウンロードできます。一般に、100 MB の XAP ファイルをポストして進行状況のインジケータが 100% に達するまでユーザーに 5 分間待つことを強いるよりも、アプリケーションの UI をすばやく起動して実行させておいて、必要な他の資産の非同期ネットワーク要求を実行する方が好ましい方法です。

Silverlight でのオンデマンドのリソース読み込みはどちらかといえば単純で簡単です。たとえば、図 1 のコードでは、起点のサイトに配置された JPEG をダウンロードし、MyImage という名前の XAML イメージにダウンロード済みビットを割り当てることで表示しています。

図 1 起点のサイトからのイメージのダウンロード

WebClient wc = new WebClient(); wc.OpenReadCompleted += new OpenReadCompletedEventHandler(wc_OpenReadCompleted); wc.OpenReadAsync(new Uri("JetCat.jpg", UriKind.Relative)); ... void wc_OpenReadCompleted(object sender, OpenReadCompletedEventArgs e) { if (e.Error == null) { BitmapImage bi = new BitmapImage(); bi.SetSource(e.Result); MyImage.Source = bi; } }

一方、アセンブリのオンデマンド読み込みはこれほど簡単ではありません。一見すると容易に見えます。WebClient を使用してアセンブリをダウンロードし、AssemblyPart.Load を使用して appdomain に読み込むだけです。問題は Silverlight の JIT コンパイラが邪魔をすることであり、開発者の多くは、オンデマンドでアセンブリをダウンロードすることや、厳密な型指定の恩恵を受けることが不可能であると思い込んでしまいます。しかし、実際にはどちらも可能です。ただし、自分が何をしているのかを認識し、CLR でのアセンブリ読み込みのしくみを基本的に理解している必要があります。

例として、次のコードについて考えてみましょう。

private void CreateCalendarButton_Click(object sender, RoutedEventArgs e) { Calendar cal = new Calendar(); cal.Width = 300.0; cal.Height = 200.0; cal.SelectedDatesChanged += new EventHandler<SelectionChangedEventArgs>(cal_SelectedDatesChanged); LayoutRoot.Children.RemoveAt(0); LayoutRoot.Children.Add(cal); }

fig02.gif

図 2 XAP からのアセンブリの分離

これはボタン クリック ハンドラであり、動的に Calendar コントロールを作成して XAML シーンに追加します。また、イベントを発生させたボタンの削除も行います。これは、LayoutRoot の Children コレクションの 0 番目のアイテムと見なされます。Calendar は System.Windows.Controls.dll に実装されているので、Silverlight のプラグインに埋め込まれるのではなく、拡張 BCL の一部になります。System.Windows.Controls.dll への参照をプロジェクトに追加すると、このコードは問題なく動作します。参照により System.Windows.Controls.dll は XAP ファイルに組み込まれて、appdomain に自動的に読み込まれます。

ここで、System.Windows.Controls.dll が必要になった場合、つまりユーザーがボタンをクリックした場合にのみ、この dll を読み込むようにするとします。そのため、System.Windows.Controls.dll への参照をプロジェクトに追加してコンパイラの要求を満たし (そうしないと、コンパイラは Calendar 型が何かわからないので Calendar への参照をコンパイルしません)、Visual Studio の [プロパティ] ウィンドウで System.Windows.Controls.dll の [ローカル コピー] プロパティを false に設定して、XAP ファイルに埋め込まれないようにします (図 2 で行ったのと同じです)。

次に、サーバー上のアプリケーションの ClientBin フォルダで XAP ファイルと共に System.Windows.Controls.dll のコピーを配置します。最後に、コードを図 3 のように変更します。今度は、ボタン クリック ハンドラは System.Windows.Controls.dll を Web サーバーからダウンロードし、AssemblyPart.Load で appdomain に読み込んで、Calendar コントロールをインスタンス化します。

図 3 動作しないオンデマンド アセンブリ読み込み

private void CreateCalendarButton_Click(object sender, RoutedEventArgs e) { WebClient wc = new WebClient(); wc.OpenReadCompleted += new OpenReadCompletedEventHandler(wc_OpenReadCompleted); wc.OpenReadAsync(new Uri("System.Windows.Controls.dll", UriKind.Relative)); } void wc_OpenReadCompleted(object sender, OpenReadCompletedEventArgs e) { if (e.Error == null) { // Load the downloaded assembly AssemblyPart part = new AssemblyPart(); part.Load(e.Result); // Create a Calendar control Calendar cal = new Calendar(); cal.Width = 300.0; cal.Height = 200.0; cal.SelectedDatesChanged += new EventHandler<SelectionChangedEventArgs>(cal_SelectedDatesChanged); LayoutRoot.Children.RemoveAt(0); LayoutRoot.Children.Add(cal); } }

この方法は妥当そうに見え、コードのコンパイルも問題なく行われますが、実行時になると、クリック ハンドラは図 4 のような例外を生成します。コンパイルしたコードには問題がありません。例外をスローしたコードに問題があります。いったいどうなっているのでしょう。エラー メッセージは CLR が System.Windows.Controls.dll を読み込もうとしていることを示しているように見えますが、プログラムで読み込んでいるので必要ないはずです。

これは、CLR の内部を知ることで優れた Silverlight プログラマになることができる格好の例です。この場合の問題は、JIT コンパイラが wc_OpenReadCompleted メソッドをコンパイルするときに、メソッドをスキャンし、Calendar という名前の型を参照していることを知って、参照を解決できるように System.Windows.Controls.dll の読み込みを試みることです。

fig04.gif

図 4 エラー メッセージ

残念ながらこれはメソッドが実行される前に発生するので、AssemblyPart.Load を呼び出す機会はありません。これは昔からある、ニワトリが先か卵が先か、という問題です。AssemblyPart.Load を呼び出してアセンブリを読み込む必要がありますが、呼び出す前に JIT コンパイラが介入し、気を利かせて読み込もうとします。System.Windows.Controls.dll はアプリケーション パッケージの中にはないので、この試みは失敗します。

この時点で多くのプログラマはあきらめて、Silverlight ではオンデマンドでアセンブリを読み込むことはできないという結論を下すか、リフレクションに頼って Calendar 型をインスタンス化することになります。

AssemblyPart part = new AssemblyPart(); Assembly a = part.Load(e.Result); Object cal = (Object)a.CreateInstance("Calendar");

これは不器用な方法ですが、動作はします。Assembly.CreateInstance によって返される参照を Calendar にキャストしようとしても、メソッドを実行する前に JIT コンパイラがアセンブリの読み込みを試みるのでうまくいきません。そして、Calendar にキャストできない場合は、コントロールのメソッド、プロパティ、およびイベントもリフレクションによってアクセスする必要があります。コードはたちまち扱いにくいほどの大きさに拡大するので、あきらめて、System.Windows.Controls.dll をアプリケーション パッケージに埋め込み、大きくなった XAP サイズを我慢することになります。

良いニュースがあります。動的なアセンブリの読み込みと厳密な型指定は組み合わせることができるのです。図 5 の行のようにコードを変更するだけです。wc_OpenReadCompleted が Calendar 型を参照しなくなっていることに注意してください。参照はすべて CreateCalendar という名前の別のメソッドに移動されています。さらに、CreateCalendar には JIT コンパイラがメソッドのインライン化を試みないという特徴があります (インライン化が行われる場合は、wc_OpenReadCompleted に Calendar 型の暗黙の参照が含まれるので、振り出しに戻ってしまいます)。このようにすると、JIT コンパイラは CreateCalendar が呼び出されるまで System.Windows.Controls.dll が読み込まれているかどうかを調べず、その時点では既に dll を appdomain に読み込んでいます。

図 5 動作するオンデマンド アセンブリ読み込み

private void CreateCalendarButton_Click(object sender, RoutedEventArgs e) { WebClient wc = new WebClient(); wc.OpenReadCompleted += new OpenReadCompletedEventHandler(wc_OpenReadCompleted); wc.OpenReadAsync(new Uri("System.Windows.Controls.dll", UriKind.Relative)); } void wc_OpenReadCompleted(object sender, OpenReadCompletedEventArgs e) { if (e.Error == null) { // Load the downloaded assembly AssemblyPart part = new AssemblyPart(); part.Load(e.Result); // Create a Calendar control CreateCalendar(); } } [MethodImpl(MethodImplOptions.NoInlining)] private void CreateCalendar() { Calendar cal = new Calendar(); cal.Width = 300.0; cal.Height = 200.0; cal.SelectedDatesChanged += new EventHandler<SelectionChangedEventArgs>(cal_SelectedDatesChanged); LayoutRoot.Children.RemoveAt(0); LayoutRoot.Children.Add(cal); }

インサイト: レンダリングと UI スレッド

あまり注目されることのない Silverlight の側面の 1 つは、Silverlight でのすべてのレンダリングはアプリケーションの UI スレッドで行われることであり、UI スレッドを占有するとレンダリングが行われなくなる、という Jeff の説明に注意してください。WPF ではレンダリング スレッドが提供されるので、Silverlight では提供されないことに驚くかもしれません。その理由に興味はないですか。

この決定は、システムのオーバーヘッドとフレームレートの分離の間でのトレードオフによるものです。Silverlight では、より軽量のオンスレッド アプローチが採用されており、アプリケーションのコードとレンダリング システムは分離されていません。つまり、アニメーションで実行できることが増え (レイアウト ベースのアニメーションやカスタム コードの実行など)、レンダリング システムに至るまでの待ち時間とオーバーヘッドは最小限です。この方法の欠点は、行うことが多すぎると、ビデオの再生などの操作を妨げる可能性があることです。

とは言っても、Silverlight のレンダリング システムはマルチコア処理を利用し、多数のスレッドを使用してレンダリングを高速化します。したがって、レンダリングは "オンスレッド" になることはあまりありませんが、アプリケーションと同期がとられて、同期に加えてデータのコピーが行われることを防ぎます。

— Ashraf Michail、Silverlight の主席アーキテクト

ちなみに、これが Silverlight ではなく Windows Presentation Foundation (WPF) であれば、AppDomain.AssemblyResolve イベントに対するハンドラを登録してそこで System.Windows.Controls.dll を読み込むという、もっと洗練された方法で問題を解決できます。AppDomain.AssemblyResolve は Silverlight にもありますが、SecurityCritical になっており、これはユーザー コードはそれに対するハンドラを登録できないことを意味します。

図 5 では、プロジェクトに System.Windows.Controls.dll への参照を組み込みますが、ローカル コピーを false に設定して (図 2 を参照) アセンブリを ClientBin フォルダに配置するものと想定しました。これが動作することを検証するため、このコラムに付属する OnDemandAssemblyDemo アプリケーションをダウンロードし、[Create Calendar Control] というラベルのボタンをクリックしてみてください。Calendar コントロールがボタンの位置に表示されます。重要なのは、OnDemandAssemblyDemo.xap には System.Windows.Controls.dll のコピーが含まれていないことで、これは XAP ファイルを WinZip で開くと簡単に確認できます。不思議です。これは次の Silverlight パーティで場をなごませるのにもってこいのネタになるでしょう。

ジャストインタイムでレンダリングする

あまり話題にならない Silverlight の側面の 1 つは、Silverlight でのすべてのレンダリングはアプリケーションの UI スレッドで行われることであり、UI スレッドを占有するとレンダリングが行われなくなります。つまり、ループの中で XAML シーンを変更する場合、または同時にアニメーションを実行する場合は、UI スレッドでの長時間のループを避ける必要があります。

UI スレッドでの長時間のループを避けることは簡単なようですが、実際には作成するコードに大きな影響を与える場合があります。図 6 に示すような OpenFileDialogDemo という名前のアプリケーションについて考えます。このアプリケーションは、Silverlight の OpenFileDialog クラスを使用するとハード ディスクにあるイメージ ファイルを参照し、イメージを XAML イメージ オブジェクトに読み込めることを実演するものです。アプリケーションを実行し、ページの上部にある [Open] ボタンをクリックして、いくつかイメージ ファイル (できれば大きいもの) を選択した後、OpenFileDialog の [Open] ボタンをクリックします。

fig06.gif

図 6 動作中の OpenFileDialogDemo

XamlReader.Load で動的に作成されたオブジェクトを使用して、選択したイメージが 1 つずつシーンにポップアップし、ページのランダムな位置を占めます。イメージが表示されると、イメージをクリックして前面に移動させることができ、マウスを使用してページ上をドラッグすることさえできます。

見た目は単純ですが、OpenFileDialogDemo は UI スレッドの賢明な使い方についての実践的な教訓を示しています。筆者が最初に作成した OpenFileDialog を表示してイメージ ファイルを読み込むコードは、図 7 のような構造をしていました。ユーザーがダイアログを閉じると、選択されたファイルを簡単な foreach ループで反復処理して 1 つずつ読み込みます。

図 7 イメージ ファイルを読み込む簡単な方法

OpenFileDialog ofd = new OpenFileDialog(); ofd.Filter = "JPEG Files (*.jpg;*.jpeg)|*.jpg;*.jpeg|" + "PNG Files (*.png)|*.png|All Files (*.*)|*.*"; ofd.FilterIndex = 1; ofd.Multiselect = true; if ((bool)ofd.ShowDialog()) { foreach (FileInfo fi in ofd.Files) { using (Stream stream = fi.OpenRead()) { BitmapImage bi = new BitmapImage(); bi.SetSource(stream); GetNextImage().Source = bi; } } }

残念ながら、すべてのイメージが読み込まれるまで、画面には何も表示されません。ユーザーが選択したイメージ ファイルが 1 つか 2 つなら待ち時間はたいしたことはありませんが、選択されたファイルが 40 とか 50 になると耐えられなくなります。要するに、イメージを読み込みながら画面に "ポップ表示" したかったので、このアプリケーションは設定した最低限の要件を満たしていませんでした。おわかりですか。ポン、ポン、ポンと表示したいのです。

当然のことながら、foreach ループは UI スレッドで実行し、ループが実行している間、Silverlight はシーンに追加されたイメージをレンダリングできないことが問題でした。一歩後退し、一息入れて、イメージをタイミングよくレンダリングできるようにコードを作り直す必要がありました。

図 8 はこの問題に対する 1 つの解決策です。修正した foreach ループでは、FileInfo オブジェクトを System.Collections.Generic.Queue に追加することしか行っていません。これにより、ループの実行が速くなり、Silverlight に制御を戻してレンダリングに取りかかることができるようになります。おそらく、変更後のコードで最も興味深い部分は、CompositionTarget.Rendering イベントに対応して FileInfo オブジェクトをキューから取り出して処理する方法でしょう。

図 8 イメージ ファイルを読み込むための、より優れた方法

private Queue<FileInfo> _files = new Queue<FileInfo>(); ... public Page() { InitializeComponent(); // Register a handler for Rendering events CompositionTarget.Rendering += new EventHandler(CompositionTarget_Rendering); } ... OpenFileDialog ofd = new OpenFileDialog(); ofd.Filter = "JPEG Files (*.jpg;*.jpeg)|*.jpg;*.jpeg|" + "PNG Files (*.png)|*.png|All Files (*.*)|*.*"; ofd.FilterIndex = 1; ofd.Multiselect = true; if ((bool)ofd.ShowDialog()) { // Reset the queue _files.Clear(); // Place each FileInfo in a queue foreach (FileInfo fi in ofd.Files) { _files.Enqueue(fi); } } ... private void CompositionTarget_Rendering(Object sender, EventArgs e) { if (_files.Count != 0) { FileInfo fi = _files.Dequeue(); using (Stream stream = fi.OpenRead()) { BitmapImage bi = new BitmapImage(); bi.SetSource(stream); GetNextImage().Source = bi; } } }

CompositionTarget.Rendering はフレーム単位のレンダリング コールバックで、ゲーム ループの実装に従来から使用されているものです。WPF から借用されて、Silverlight 2 の開発サイクルの終わりになって登場しました。イベントは Silverlight がシーンを再レンダリングする準備ができるたびに発生します。

OpenFileDialogDemo は CompositionTarget.Rendering イベントに対するハンドラを登録し (CompositionTarget_Rendering)、ハンドラが呼び出されるたびに、FileInfo オブジェクトを 1 つキューから取り出して、XAML イメージに変換します。その結果、新しいイメージが追加されるたびに Silverlight にはシーンを更新する機会があるので、イメージは読み込まれると画面に表示されます。これが OpenFileDialogDemo の最終バージョンでの foreach ループの構造であり、実行したときに、すべてが一度に表示されるのではなく、画面に 1 つずつ表示される理由です。

CompositionTarget.Rendering は使いすぎないように注意する必要があります。OpenFileDialogDemo に、イメージをシーンに追加するときに実行するアニメーションがある場合、イメージを読み込んでイメージ オブジェクトに割り当てるために必要な時間だけフレームごとに遅延があるので、アニメーションがぎくしゃくした動きになります。ただし、どうしても必要な場合は、OpenFileDialogDemo は CompositionTarget.Rendering の受け入れられる使用方法の適切な例になります。実際、他の方法で目的を達成するのは困難でしょう。

地域への依存性を回避する

最後のヒントは、XamlReader.Load を使用して XAML オブジェクトを動的に作成することです。次のコードのどこが間違っているか、わかりますか。

 

Rectangle rect = (Rectangle)XamlReader.Load( String.Format( "<Rectangle xmlns=\"https://schemas.microsoft.com/client/2007\" " + "Width=\"{0}\" Height=\"{1}\" Stroke=\"Black\" Fill=\"Yellow\" />", 100.5, 100.0 ) );

このコードが米国のほとんどの PC では動作しても、ヨーロッパや世界の他の地域にあるほとんどの PC では失敗することに気付いたなら、賞賛に値します。実際に試してみるには、まず、そうなっていない場合は、オペレーティング システムを米国の形式で数字、通貨、日付、時刻を表示するように構成します (Vista では、[コントロール パネル] から [地域と言語のオプション] ダイアログ ボックスを開いて、[形式] タブをクリックします)。XamlReader.Load の呼び出しを実行し、呼び出しの実行が成功することを確認します。次に、地域の形式をフランスに変更して呼び出しを再び実行します。今度は、XamlReader.Load は "プロパティ Width の属性値 100,5 が無効です" という例外をスローします (図 9)。問題は、100.5 のような小数が多くの国では 100,5 (小数点がコンマになっていることに注目) と記述されることです。そして、String.Format はホスト PC の地域設定を優先するので、小数 100.5 は "100,5" になります。残念ながら、XamlReader.Load は "100,5" をどうすればよいのかわからないので、例外をスローします。

fig09.gif

図 9 XamlReader.Load がスローする例外

次のコードは、Silverlight を実行するすべての PC で動作するように XamlReader.Load を呼び出す正しい方法を示しています。

 

Rectangle rect = (Rectangle)XamlReader.Load( String.Format( CultureInfo.InvariantCulture, "<Rectangle xmlns=\"https://schemas.microsoft.com/client/2007\" " + "Width=\"{0}\" Height=\"{1}\" Stroke=\"Black\" Fill=\"Yellow\" />", 100.5, 100.0 ) );

 

String.Format に渡す最初のパラメータは、不変のカルチャを参照する CultureInfo オブジェクトです。XamlReader.Load はカルチャで不変の文字列を要求するので、CultureInfo.InvariantCulture を使用して String.Format が適切な形式の小数値を生成するようにします (日付と時刻を使用している場合はこれらについても正しい形式にします)。偶然ではなく、前のセクションの OpenFileDialogDemo アプリケーションでは、この技法を使用して地域の設定に関係なく XamlReader.Load の呼び出しが動作するようにしています。

XamlReader.Load に渡す文字列に String.Format で生成された小数値が含まれる場合は、常に CultureInfo.InvariantCulture を使用して適切な形式にします。世の中で実行されるものである以上、アプリケーションはさまざまな地域設定で動作する必要があります。

ページをめくる

Silverlight を使い始めたばかりでも、.NET での開発経験があれば、知っている必要のあることの 90% を既に知っているといえます。ただし、Silverlight と .NET には微妙な違いがあり、それを理解する必要があります。

ページに関しては、多くの読者から、筆者が 2008 年 5 月号で紹介した Silverlight 1.0 のページめくりフレームワークをいつ Silverlight 2 用に更新するのかという問い合わせがありました。やっと移植が完了しました。更新されたフレームワークを使用するサンプル アプリケーションは wintellect.com/silverlight/pageturndemo/ で見ることができ、ソース コードは wintellect.com/Downloads/PageTurnDemo2.zip からダウンロードできます。

ページめくりフレームワークは PageTurn.cs の中にあり、Page.xaml.cs のコードを見るとすべての動作がわかります。API は Silverlight 1.0 バージョンと似ており (JavaScript より C# で)、ページめくりが向上するような変更を行いました。ページめくりが完了するたびにフレームワークによって生成される PageTurned イベントを追加したので、たとえば現在のページ番号を表示するような UI の更新に使用できます。

ご質問やご意見は、Jeff (wicked@microsoft.com) まで英語でお送りください。

Jeff Prosise は、MSDN Magazine に寄稿している編集者で、『プログラミング Microsoft .NET』(日経 BP ソフトプレス、2002 年) など数冊の著書があります。また、ソフトウェアのコンサルティングおよび Microsoft .NET 専門の教育機関である Wintellect (wintellect.com) の共同創立者でもあります。Jeff の連絡先は、wicked@microsoft.com です。