Reactive ExtensionsのThrottleで一定時間の間に発行されたイベントから最後のイベントのみを取り出す。

MSDNフォーラムで行ったやり取りが元ネタです。

この質問では最終的に以下の点が問題になっていることが分かりました。

  • DataGridのSizeChangedイベントのタイミングで、DataGridの行の再描画を行いたい。
  • しかし、SizeChangedイベントのたびに再描画を行うのはパフォーマンス上よろしくない。

で、解決策は

  • 一定時間の間にSizeChangedイベントが発生しなかったら、最後に発生したSizeChangedイベントに基づいてDataGridの行の再描画を行う。

となりました。
フォーラムの質問者は業務でSilverlightを使っていたようなのでReactive Extensionsを提案することは避けましたが、これってまんまRxのThrottleの動作なんですよね。
というわけで、Throttleを使って、書き直してみました。

準備

最初にSilverlight用のReactive Extensionsをサイトからダウンロードしインストールしましょう。

Silverlightプリプロジェクトに以下のアセンブリの参照を追加します。

  • System.Interactive.dll
  • System.Reactive.dll
  • System.CoreEx.dll
  • System.Observable.dll

Before

フォーラムで提案したコードです。自分で作っておいて何なのですが、クロージャを使ってゴニョゴニョしたりしているあたり将来バグを生む温床にならないか心配になります。

private DateTime lastSizeChangeTime = DateTime.MinValue;
private void DataGrid1_SizeChanged(object sender, SizeChangedEventArgs e)
{
    var dataGrid = ((DataGrid)sender);
    if (dataGrid.ItemsSource != null
        && !e.NewSize.IsEmpty
        && !e.PreviousSize.IsEmpty
        && e.NewSize.Width > e.PreviousSize.Width)
    {
        var now = DateTime.Now;
        this.lastSizeChangeTime = now;

        var sb = new Storyboard();

        // 100msの間にDataGrid1_SizeChangedが再度呼ばれないか検査する。
        // 100msは任意の値に変更可能。
        sb.Duration = new Duration(TimeSpan.FromMilliseconds(100));

        sb.Completed += (s1, e1) =>
        {
            // nowの値はこのラムダ式が生成されたタイミングの時間が保持される。
            // lastSizeChangeTimeは、最後にDataGrid1_SizeChangedが呼ばれたタイミングの時間となる。
            // nowとlastSizeChangeTimeが異なる場合は、DataGrid1_SizeChangedが再度呼ばれたこととなるので、
            // DataGridの再描画をスキップする。
            if (now == this.lastSizeChangeTime)
            {
                // ItemsSourceをリフレッシュしてDataGridを再描画する
                var temp = dataGrid1.ItemsSource;
                dataGrid1.ItemsSource = null;
                dataGrid1.ItemsSource = temp;

                System.Diagnostics.Debug.WriteLine("ItemsSource Update!");
            }
        };

        sb.Begin();
    }
}

After

Reactive Extensionsを使って書き直したコードです。

Observable.FromEvent<SizeChangedEventArgs>(
                            h => this.DataGrid1SizeChanged += h,
                            h => this.DataGrid1SizeChanged -= h) // SizeChangedイベントにイベントハンドラ登録
                    .Where(e => IsSpreadDataGridWidth(e)) // DataGridの横幅が広がった時のみ次の処理を行う。
                    .Throttle(TimeSpan.FromMilliseconds(100))// 指定時間の間に値が通過しなかった場合、最後の一つを通す
                    .ObserveOnDispatcher() // UIスレッドに戻す。
                    .Subscribe(e =>
                    {
                        // ItemsSourceをリフレッシュしてDataGridを再描画する
                        var temp = dataGrid1.ItemsSource;
                        dataGrid1.ItemsSource = null;
                        dataGrid1.ItemsSource = temp;

                        System.Diagnostics.Debug.WriteLine("ItemsSource Update!");
                    });

Afterのほうが見通しが良くなったと思うのですが、どうでしょうか?

コードの全体像

コード全体はこんな感じになります。

public partial class MainPage : UserControl
{
    /// <summary>
    /// Observable.FromEventにSizeChangedEventHandlerを登録する方法がわからなかったので、ラップイベントを作った
    /// </summary>
    public event EventHandler<SizeChangedEventArgs> DataGrid1SizeChanged;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public MainPage()
    {
        InitializeComponent();

        var tds = new ObservableCollection<TestData>();
        var r = new Random();
        for (int i = 0; i < 1000000; i++)
        {
            TestData td1 = new TestData();
            td1.Value = "hogehogehogehogehogehogehogehoge" + r.Next();
            tds.Add(td1);
        }

        this.dataGrid1.ItemsSource = tds;

        this.dataGrid1.SizeChanged += new SizeChangedEventHandler(dataGrid1_SizeChanged);
        Observable.FromEvent<SizeChangedEventArgs>(
                                    h => this.DataGrid1SizeChanged += h,
                                    h => this.DataGrid1SizeChanged -= h) // SizeChangedイベントにイベントハンドラ登録
                            .Where(e => IsSpreadDataGridWidth(e)) // DataGridの横幅が広がった時のみ次の処理を行う。
                            .Throttle(TimeSpan.FromMilliseconds(100))// 指定時間の間に値が通過しなかった場合、最後の一つを通す
                            .ObserveOnDispatcher() // UIスレッドに戻す。
                            .Subscribe(e =>
                            {
                                // ItemsSourceをリフレッシュしてDataGridを再描画する
                                var temp = dataGrid1.ItemsSource;
                                dataGrid1.ItemsSource = null;
                                dataGrid1.ItemsSource = temp;

                                System.Diagnostics.Debug.WriteLine("ItemsSource Update!");
                            });
    }

    /// <summary>
    /// データグリッドの幅が広がったかを判定する。
    /// </summary>
    /// <param name="e"></param>
    /// <returns></returns>
    private static bool IsSpreadDataGridWidth(IEvent<SizeChangedEventArgs> e)
    {
        return !e.EventArgs.NewSize.IsEmpty
            && !e.EventArgs.PreviousSize.IsEmpty
            && e.EventArgs.NewSize.Width > e.EventArgs.PreviousSize.Width;
    }

    private void dataGrid1_SizeChanged(object sender, SizeChangedEventArgs e)
    {
        if (this.DataGrid1SizeChanged != null)
        {
            this.DataGrid1SizeChanged(sender, e);
        }
    }

    /// <summary>
    /// DataGridバインド用クラス
    /// </summary>
    public class TestData
    {
        public string Value { get; set; }
    }
}
<UserControl x:Class="DataGridAndGridSplitterSample.MainPage"
    xmlns:sdk="http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    d:DesignHeight="300" d:DesignWidth="400">

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="100" />
            <ColumnDefinition Width="5" />
            <ColumnDefinition Width="300" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition />
        </Grid.RowDefinitions>
        <sdk:DataGrid Name="dataGrid1" Grid.Column="0" Grid.Row="0" Width="Auto" AutoGenerateColumns="False">
            <sdk:DataGrid.Columns>
                <sdk:DataGridTextColumn Binding="{Binding Value}" Width="*">
                    <sdk:DataGridTextColumn.ElementStyle>
                        <Style TargetType="TextBlock">
                            <Setter Property="TextWrapping" Value="Wrap"/>
                        </Style>
                    </sdk:DataGridTextColumn.ElementStyle>
                </sdk:DataGridTextColumn>
            </sdk:DataGrid.Columns>
        </sdk:DataGrid>
        <sdk:GridSplitter x:Name="gridSplitter1" Grid.Column="1" Grid.Row="0" VerticalAlignment="Stretch" HorizontalAlignment="Center" />
        <StackPanel Grid.Column="2" Grid.Row="0">
            <TextBlock Text="hoge" />
        </StackPanel>
    </Grid>
</UserControl>

雑感

フォーラムでは、Reactive Extensionsが提供している機能をReactive Extensionsなしで実現するためにはどうすればよいか?を考えたわけですが、車輪の再発明は楽しいですね。勉強にもなりますし。同時に、業務アプリに対してReactive Extensionsを採用する提案はしづらい状態にあるなぁ、とも思いました。早くReactive ExtensionsがLINQのように一般的になればよいのに。