Windows Phone 7 + Reactive ExtensionsによるXml取得
- 2010-07-19
Windows Phone 7にはReactive Extensionsが標準搭載されていたりするのだよ!なんだってー!と、いうわけで、Real World Rx。じゃないですけれど、Rxを使って非同期処理をゴニョゴニョとしてみましょう。ネットワークからデータ取って何とかする、というと一昔前はRSSリーダーがサンプルの主役でしたが、最近だとTwitterリーダーなのでしょうね。というわけで、Twitterリーダーにします。といっても、ぶっちゃけただたんにデータ取ってリストボックスにバインドするだけです。そしてGUI部分はSilverlightを使用してWindows Phone 7でTwitterアプリケーションを構築 - @ITのものを丸ごと使います。手抜き!というわけで、差分としてはRxを使うか否かといったところしかありません。
なお、別に全然Windows Phone 7ならでは!なことはやらないので、WPFでもSilverlightでも同じように書けます。ちょっとしたRxのサンプルとしてどうぞ。今回は出たばかりのWindows Phone Developer Tools Betaを使います。Windows Phone用のBlendがついていたりと盛り沢山。
Xmlを読み込む
とりあえずLinq to XmlなのでXElement.Load(string uri)ですね。違います。そのオーバーロードはSilverlightでは使えないのであった。えー。なんでー。とはまあ、つまり、同期系APIの搭載はほとんどなくて、全部非同期系で操作するよう強要されているわけです。XElement.Loadは同期でネットワークからXMLを引っ張ってくる→ダウンロード時間中はUI固まる→許すまじ!ということのようで。みんな大好きBackgroundWorkerたん使えばいいぢゃない、みたいなのは通用しないそうだ。
MSDNにお聞きすれば方法 : LINQ to XML で任意の URI の場所から XML ファイルを読み込むとあります。ネットワークからデータを取ってくるときはWebClient/HttpWebRequest使えというお話。
では、とりあえず、MainPage.xamlにペタペタと書いて、MessageBox.Showで確認していくという原始人な手段を取っていきましょう。XElementの利用にはSystem.Xml.Linqの参照が別途必要です。
public MainPage()
{
InitializeComponent();
var wc = new WebClient();
wc.OpenReadCompleted += (sender, e) =>
{
var elem = XElement.Load(e.Result); // e.ResultにStreamが入ってる
MessageBox.Show(elem.ToString()); // 確認
};
wc.OpenReadAsync(new Uri("http://twitter.com/statuses/public_timeline.xml")); // 非同期読み込み呼び出し開始
}
別に難しいこともなくすんなりと表示されました。簡単なことが簡単に書けるって素晴らしい。で、WebClientのプロパティをマジマジと見ているとAllowReadStreamBufferingなんてものが。trueの場合はメモリにバッファリングされる。うーん、せっかくなので完全ストリーミングでやりたいなあ。これfalseならバッファリングなしってことですよね?じゃあ、バッファリング無しにしてみますか。
var wc = new WebClient();
wc.AllowReadStreamBuffering = false; // デフォはtrueでバッファリングあり、今回はfalseに変更
wc.OpenReadCompleted += (sender, e) =>
{
try
{
var elem = XElement.Load(e.Result); // ここで例外出るよ!
}
catch (Exception ex)
{
// Read is not supporeted on the main thread when buffering is disabled.
MessageBox.Show(ex.ToString());
}
};
例外で死にました。徹底して同期的にネットワーク絡みの処理が入るのは許しません、というわけですね、なるほど。じゃあ別スレッドでやるよ、ということでとりあえずThreadPoolに突っ込んでみた。
wc.OpenReadCompleted += (sender, e) =>
{
ThreadPool.QueueUserWorkItem(_ =>
{
try
{
var elem = XElement.Load(e.Result);
MessageBox.Show(elem.ToString()); // 今度はここで例外!
}
catch(Exception ex)
{
// Invalid cross-thread access.
Debug.WriteLine(ex.ToString());
}
});
};
読み込みは出来たけど、今度はMessageBox.Showのところで、Invalid Cross Thread Accessで死んだ。そっか、MessageBoxもUIスレッドなのか。うーむ、世の中難しいね!というわけで、とりあえずDispatcher.BeginInvokeしますか。
wc.OpenReadCompleted += (sender, e) =>
{
ThreadPool.QueueUserWorkItem(_ =>
{
var elem = XElement.Load(e.Result);
Dispatcher.BeginInvoke(() => MessageBox.Show(elem.ToString()));
});
};
これで完全なストリームで非同期呼び出しでのXmlロードが出来たわけですね。これは面倒くさいし、Invoke系の入れ子が酷いことになってますよ、うわぁぁ。
Rxを使う
というわけで、非Rxでやると大変なのがよく分かりました。そこでRxの出番です。標準搭載されているので、参照設定を開きMicrosoft.Phone.ReactiveとSystem.Observableを加えるだけで準備完了。
var wc = new WebClient { AllowReadStreamBuffering = false };
Observable.FromEvent<OpenReadCompletedEventArgs>(wc, "OpenReadCompleted")
.ObserveOn(Scheduler.ThreadPool) // ThreadPoolで動かすようにする
.Select(e => XElement.Load(e.EventArgs.Result))
.ObserveOnDispatcher() // UIスレッドに戻す
.Subscribe(x => MessageBox.Show(x.ToString()));
wc.OpenReadAsync(new Uri("http://twitter.com/statuses/public_timeline.xml"));
非常にすっきり。Rxについて説明は、必要か否か若干悩むところですが説明しますと、イベントをLinq化します。今回はOpenReadCompletedイベントをLinqにしました。Linq化すると何が嬉しいって、ネストがなくなることです。非常に見やすい。更にRxの豊富なメソッド群を使えば普通ではやりにくいことがいとも簡単に出来ます。今回はObserveOnを使って、どのスレッドで実行するかを設定しました。驚くほど簡単に、分かりやすく。メソッドの流れそのままです。
FromAsyncPattern
WebClientだけじゃなく、ついでなのでHttpWebRequestでもやってみましょう。(HttpWebRequest)WebRequest.Create()死ね、といつも言ってる私ですが、SilverlightにはWebRequest.CreateHttpでHttpWebRequestが作れるじゃありませんか。何ともホッコリとします。微妙にこの辺、破綻した気がしますがむしろ見なかったことにしよう。
var req = WebRequest.CreateHttp("http://twitter.com/statuses/public_timeline.xml");
req.AllowReadStreamBuffering = false;
req.BeginGetResponse(ar =>
{
using (var res = req.EndGetResponse(ar))
using (var stream = res.GetResponseStream())
{
var x = XElement.Load(res.GetResponseStream());
Dispatcher.BeginInvoke(() => MessageBox.Show(x.ToString()));
}
}, null);
非同期しかないのでBeginXxx-EndXxxを使うのですが、まあ、結構面倒くさい。そこで、ここでもまたRxの出番。BeginXxx-EndXxx、つまりAPM(Asynchronus Programming Model:非同期プログラミングモデル)の形式の非同期メソッドをラップするFromAsyncPatternが使えます。
var req = HttpWebRequest.CreateHttp("http://twitter.com/statuses/public_timeline.xml");
req.AllowReadStreamBuffering = false;
Observable.FromAsyncPattern<WebResponse>(req.BeginGetResponse, req.EndGetResponse)
.Invoke() // 非同期実行開始(Invoke()じゃなくて()でもOKです、ただのDelegateなので)
.Select(res => XElement.Load(res.GetResponseStream()))
.ObserveOnDispatcher()
.Subscribe(x => MessageBox.Show(x.ToString()));
ラップは簡単で型として戻り値を指定してBeginXxxとEndXxxを渡すだけ。あとはそのまま流れるように書けてしまいます。普通だと面倒くさいはずのHttpWebRequestのほうがWebClientよりも素直に書けてしまう不思議!FromAsyncPatter、恐ろしい子。WebClient+FromEventは先にイベントを設定してURLで発動でしたが、こちらはURLを指定してから実行開始という、より「同期的」と同じように書ける感じがあって好き。WebClient使うのやめて、みんなHttpWebRequest使おうぜ!(ふつーのアプリのほうでは逆のこと言ってるのですががが)
ところで、非同期処理の実行開始タイミングはInvokeした瞬間であって、Subscribeした時ではありません。どーなってるかというと、ぶっちゃけRxは実行結果をキャッシュしてます。細かい話はまた後日ちゃんと紹介するときにでも。
バインドする
GUIはScottGu氏のサンプルを丸々頂いてしまいます。リロードボタンを押したらPublicTLを呼ぶだけ、みたいなのに簡略化してしまいました。
<Grid x:Name="LayoutRoot" Background="Transparent">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Button Grid.Row="0" Height="72" Width="200" Content="Reload" Name="Reload"></Button>
<ListBox Grid.Row="1" Name="TweetList" DataContext="{Binding}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Image Source="{Binding Image}" Height="73" Width="73" VerticalAlignment="Top" />
<StackPanel Width="350">
<TextBlock Text="{Binding Name}" Foreground="Red" />
<TextBlock Text="{Binding Text}" TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
あとは、ボタンへのイベント設定と、Twitterのクラスを作る必要があります。
public class TwitterStatus
{
public long Id { get; set; }
public string Text { get; set; }
public string Name { get; set; }
public string Image { get; set; }
public TwitterStatus(XElement element)
{
Id = (long)element.Element("id");
Text = (string)element.Element("text");
Name = (string)element.Element("user").Element("screen_name");
Image = (string)element.Element("user").Element("profile_image_url");
}
}
public partial class MainPage : PhoneApplicationPage
{
public MainPage()
{
InitializeComponent();
Reload.Click += new RoutedEventHandler(Reload_Click); // XAMLに書いてもいいんですけど。
}
void Reload_Click(object sender, RoutedEventArgs e)
{
var req = HttpWebRequest.CreateHttp("http://twitter.com/statuses/public_timeline.xml");
req.AllowReadStreamBuffering = false;
Observable.FromAsyncPattern<WebResponse>(req.BeginGetResponse, req.EndGetResponse)
.Invoke()
.Select(res => XElement.Load(res.GetResponseStream()))
.Select(x => x.Descendants("status").Select(xe => new TwitterStatus(xe)))
.ObserveOnDispatcher()
.Subscribe(ts => TweetList.ItemsSource = ts);
}
}
実行するとこんな具合に表示されます。簡単ですねー。ただ、これだとリロードで20件しか表示されないので、リロードしたら継ぎ足されるように変更しましょう。
イベントを合成する
継ぎ足しの改善、のついでに、一定時間毎に更新も加えよう。基本は一定時間毎に更新だけど、リロードボタンしたら任意のタイミングでリロード。きっとよくあるパターン。Reload.Click+=でハンドラ足すのはやめて、その部分もFromEventでObservable化してしまいましょう。そして一定時間毎のイベント発動はObservable.Timerで。
// 30秒毎もしくはリロードボタンクリックでPublicTimeLineを更新
Observable.Merge(
Observable.Timer(TimeSpan.Zero, TimeSpan.FromSeconds(30), Scheduler.NewThread).Select(_ => (object)_),
Observable.FromEvent<RoutedEventArgs>(Reload, "Click").Select(_ => (object)_))
.SelectMany(_ =>
{
var req = HttpWebRequest.CreateHttp("http://twitter.com/statuses/public_timeline.xml");
req.AllowReadStreamBuffering = false;
return Observable.FromAsyncPattern<WebResponse>(req.BeginGetResponse, req.EndGetResponse)();
})
.Select(res => XStreamingReader.Load(res.GetResponseStream()))
.SelectMany(x => x
.Descendants("status")
.Select(xe => new TwitterStatus(xe))
.Reverse()) // 古い順にする
.Scan((before, current) => before.Id > current.Id ? before : current) // 最後に通した記事よりも古ければ通さない(で、同じ記事を返す)
.DistinctUntilChanged(t => t.Id) // 同じ記事が連続して来た場合は何もしないでreturn
.ObserveOnDispatcher()
.Subscribe(t => TweetList.Items.Insert(0, t)); // Insertだって...
流れるようにメソッド足しまくるの楽しい!楽しすぎて色々足しすぎて悪ノリしている感が否めません、とほほ。解説しますと、まず一行目のMerge。これは複数本のイベントを一本に統一します。統一するためには型が同じでなければならないのですが、今回はTimer(long)と、Click(RoutedEventArgs)なのでそのままでは合成出来ません。どちらも発火タイミングが必要なだけでlongもRoutedEventArgsも不必要なため、Objectにキャストしてやって合流させました。
こういう場合、Linq to Objectsなら.Cast<object>()なんですよね。Castないんですか?というと、一応あるにはあるんですが、実質無いようなもので。というわけで、今のところキャストしたければ.Select(=>(object))を使うしかありません。多分。もっとマシなやり方がある場合は教えてください。
続いてSelectMany。TimerもしくはClickは発火のタイミングだけで、後ろに流すのはFromAsyncPatternのデータ。こういった、最初のイベントは発火タイミングにだけ使って、実際に流すものは他のイベントに摩り替える(例えばマウスクリックで発動させて、あとはマウスムーブを使うとか)というのは定型文に近い感じでよく使うことになるんじゃないかと思います。SelectMany大事。
XMLの読み込み部は、せっかくなので、こないだ作ったバッファに貯めこむことなくXmlを読み込めるXStreamingReaderを使います。こんな風に、XMLを読み取ってクラスに変換する程度ならXElement.Loadで丸々全体のツリーを作るのも勿体無い。XStreamingReaderなら完全ストリーミングでクラスに変換出来ますよー。という実例。
その下は更にもう一個SelectMany。こっちはLinq to Objectsのものと同じ意味で、IEnumerableを平たくしています。で、ScanしたDistinctUntilChangedして(解説が面倒になってきた)先頭にInsert(ちょっとダサい)。これで古いものから上に足される = 新しい順番に表示される、という形になりました。XAML側のListBoxを直に触ってInsertとか、明らかにダサい感じなのですが、まあ今回はただのサンプルなので見逃してください。
RxのMergeに関しては、後日他のイベント合流系メソッド(CombineLatest, Zip, And/Then/Plan/Join)と一緒に紹介したいと思っています。合流系大事。
まとめ
驚くほどSilverlightで開発簡単。っぽいような印象。C#書ける人ならすぐにとっかかれますねー。素晴らしい開発環境だと思います。そして私は同時に、Silverlight全然分かってないや、という現実を改めて突きつけられて参ってます。XAMLあんま書けない。Blend使えない。MVVM分からない。モバイル開発云々の前に、基本的な技量が全然欠けているということが良く分かったし、それはそれで良い収穫でした。この秋なのか冬なのかの発売までには、ある程度は技術を身につけておきたいところです。
そしてそれよりなにより開発機欲すぃです。エミュレータの起動も速いし悪くないのですが、やっぱ実機ですよ、実機!配ってくれぇー。