пятница, 20 мая 2011 г.

Events vs. Rx

Есть замечательная библиотека «Reactive Extensions» (Rx) от Microsoft, которая предоставляет примитивы для реактивного программирования (в этой библиотеке вообще много всякого вкусного, но об этом как-нибудь в другой раз). В частности Rx позволяет представить .NET события в виде специальных коллекций — observable-коллекций. Такие коллекции представляют последовательность событий в виде потока данных. Самое замечательное, что с этими observable-коллекциями можно работать с помощью LINQ, так же как и с обычными коллекциями.

В этом посте я хочу сравнить два решения одной и той же задачи. Одна реализация строится на классических событиях; вторая — на observable-коллекциях из библиотеки Rx.

Постановка задачи

Задача очень простая:
  1. Есть WPF приложение с одним окном. Окно допускает изменение размеров и ограничено по минимальной вышине и ширине.
  2. В окне расположена картинка.
  3. Эту картинку нужно таскать внутри окна с помощью мышки.
  4. Нельзя допускать, чтобы картинка (или её часть) оказалась за границами окна, ни при перетаскивания, ни при изменения размеров окна.
По сути, это немного расширенный вариант реализации Drag and Drop отсюда.

Реализация через события

Разметка окна:
<Window x:Class="Window1"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Drag'n'Drop Sample (Events)" Height="400" Width="400"
        WindowStartupLocation="CenterScreen" MinHeight="150" MinWidth="150">
    <Canvas x:Name="canvas" SizeChanged="Canvas_OnSizeChanged">
        <Image x:Name="image"
               Source="http://upload.wikimedia.org/wikipedia/commons/7/7a/Rx_symbol.png"
               Width="100" Height="100" Canvas.Left="0" Canvas.Top="0"
               MouseDown="Image_OnMouseDown"
               MouseUp="Image_OnMouseUp"
               MouseMove="Image_OnMouseMove"/>
    </Canvas>
</Window>
Код:
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

public sealed partial class Window1
{
    private bool inDragMode;
    private Point offset;

    public Window1()
    {
        InitializeComponent();
    }

    private void Image_OnMouseDown(object sender, MouseButtonEventArgs e)
    {
        // Переходим в режим перетаскивания
        inDragMode = true;
        // Запоминаем позицию мыши относительно картинки
        offset = e.GetPosition(image);
        // Захватываем мышь
        image.CaptureMouse();
    }

    private void Image_OnMouseUp(object sender, MouseButtonEventArgs e)
    {
        // Отпускаем мышь
        image.ReleaseMouseCapture();
        // Выходим из режима перетаскивания
        inDragMode = false;
    }

    private void Image_OnMouseMove(object sender, MouseEventArgs e)
    {
        if (!inDragMode) // Только в режиме перетаскивания
            return;      // меняем позицию картинки

        // Позиция мыши относительно рабочей области
        var pointerPosition = e.GetPosition(canvas);

        // Корректируем новую позицию картинки ...
        var newPosition = 
            CoerceImagePosition(pointerPosition.X - offset.X, 
                                pointerPosition.Y - offset.Y);
        // ... и передвигаем её
        SetImagePosition(newPosition);
    }

    private void Canvas_OnSizeChanged(object sender, SizeChangedEventArgs e)
    {
        // Если после изменения размеров рабочей области
        // картинка вылезает за её границы вправо или вниз, то ...
        if (canvas.ActualWidth < Canvas.GetLeft(image) + image.Width ||
            canvas.ActualHeight < Canvas.GetTop(image) + image.Height)
        {
            // ... корректируем позицию картинки ...
            var newPosition = CoerceImagePosition(Canvas.GetLeft(image),
                                                  Canvas.GetTop(image));
            // ... и передвигаем её
            SetImagePosition(newPosition);
        }
    }

    private Point CoerceImagePosition(double x, double y)
    {
        // Корректируем позицию картинки таким образом,
        // чтобы она не вылезала за границы рабочей области
        return new Point(Math.Min(Math.Max(x, 0.0), 
                                  canvas.ActualWidth - image.Width),
                         Math.Min(Math.Max(y, 0.0),
                                  canvas.ActualHeight - image.Height));
    }

    private void SetImagePosition(Point position)
    {
        // Передвигаем картинку
        Canvas.SetLeft(image, position.X);
        Canvas.SetTop(image, position.Y);
    }
}

Реализация через Rx

Разметка окна:
<Window x:Class="Window1"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Drag'n'Drop Sample (Rx)" Height="400" Width="400"
        WindowStartupLocation="CenterScreen" MinHeight="150" MinWidth="150">
    <Canvas x:Name="canvas">
        <Image x:Name="image"
               Source="http://upload.wikimedia.org/wikipedia/commons/7/7a/Rx_symbol.png"
               Width="100" Height="100" Canvas.Left="0" Canvas.Top="0" />
    </Canvas>
</Window>
Код:
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

public sealed partial class Window1
{
    public Window1()
    {
        InitializeComponent();

        // 1) Нажатия кнопки мыши
        // 2) преобразуются в позицию мыши относительно картинки
        // 3) и приводят к захвату мыши
        var mouseDown =
/*1*/       Observable.FromEvent<MouseButtonEventArgs>(image, "MouseDown")
/*2*/           .Select(evt => evt.EventArgs.GetPosition(image))
/*3*/           .Do(_ => image.CaptureMouse());


        // 1) Отпускания кнопки мыши
        // 2) приводят к отпусканию мыши
        var mouseUp = 
/*1*/       Observable.FromEvent<MouseButtonEventArgs>(image, "MouseUp")
/*2*/           .Do(_ => image.ReleaseMouseCapture());


        // 1) Движения мыши
        // 2) преобразуются в позицию мыши относительно рабочей области
        var mouseMove =
/*1*/       Observable.FromEvent<MouseEventArgs>(image, "MouseMove")
/*2*/           .Select(evt => evt.EventArgs.GetPosition(canvas));


        // 1) Каждую позицию мыши относительно картинки,
        //    полученную в момент нажатия кнопки мыши
        // 2) и каждую позицию мыши относительно рабочей области,
        //    полученную при движении мыши с зажатой кнопкой
        // 3) преобразуем в новую позицию картинки
        var dragPositions =
/*1*/       from offset in mouseDown
/*2*/       from mousePosition in mouseMove.TakeUntil(mouseUp)
/*3*/       select CoerceImagePosition(mousePosition.X - offset.X,
                                       mousePosition.Y - offset.Y);


        // 1) Каждое изменение размеров рабочей области
        // 2) после которого картинка вылезает за её границы вправо или вниз
        // 3) преобразуем в откорректированную позицию картинки
        var canvasSizeChangedPositions =
/*1*/       from evt in Observable
                .FromEvent<SizeChangedEventArgs>(canvas, "SizeChanged")
/*2*/       where canvas.ActualWidth < Canvas.GetLeft(image) + image.Width ||
                  canvas.ActualHeight < Canvas.GetTop(image) + image.Height
/*3*/       select CoerceImagePosition(Canvas.GetLeft(image),
                                       Canvas.GetTop(image));


        // 1) Позиции картинки, полученные при перетаскивании,
        // 2) объединяем с позициями картинки,
        //    откорректированными после изменения размеров рабочей области,
        // 3) и передвигаем картинку в новую позицию.
/*1*/   dragPositions
/*2*/       .Merge(canvasSizeChangedPositions)
/*3*/       .Subscribe(SetImagePosition);
    }

    private Point CoerceImagePosition(double x, double y)
    {
        // Корректируем позицию картинки таким образом,
        // чтобы она не вылезала за границы рабочей области
        return new Point(Math.Min(Math.Max(x, 0.0),
                                  canvas.ActualWidth - image.Width),
                         Math.Min(Math.Max(y, 0.0),
                                  canvas.ActualHeight - image.Height));
    }

    private void SetImagePosition(Point position)
    {
        // Передвигаем картинку
        Canvas.SetLeft(image, position.X);
        Canvas.SetTop(image, position.Y);
    }
}

Кто победил?

На мой взгляд, the Oscar goes to Rx — и вот почему:
  • Решение более декларативное за счёт LINQ.
  • Отсутствует изменяемое состояние.
  • Код работы с потоками событий сосредоточен в одном месте, а не разбросан по обработчикам.
  • Код короче (не намного, но всё же).
А какой вариант нравится вам? Почему?

7 комментариев:

  1. Эдуард, спасибо за пример!

    Есть один вопрос, имена событий указываются как строковые "магические константы". Если события берутся из стандартной библиотеки, то их еще можно вынести в отдельные константы. Но что если события описываем мы сами, и в последствии, в результате рефакторинга, их имена заменят. Не возникнет ли здесь проблема при рантайме?

    Не думал ли ты над подходом получения имени события, как например:
    public static class PropertyHelper
    {
    public static string GetPropertyName(Expression> expr)
    {
    return (MemberExpression)expr.Body).Member.Name;
    }
    }

    ОтветитьУдалить
  2. ИМХО, Есть несколько но:
    а) придет какой-нибудь начинающий разработчик и хрен разберется с тем кодом, который у вас написан с Rx.
    б) Код будет посложнее - хрен его отдебажите нормально.

    Может быть я и не прав. Особо Rx не пользовался.

    ОтветитьУдалить
  3. linq дебажить та ещё забава, на самом деле

    ОтветитьУдалить
  4. To Тимур Ф.:
    У метода Observable.FromEvent есть три перегрузки:
    1. Observable.FromEvent<MouseButtonEventArgs>(
           image, "MouseDown");

    2. Observable.FromEvent<MouseButtonEventArgs>(
           rxHandler => image.MouseDown += rxHandler.Invoke,
           rxHandler => image.MouseDown -= rxHandler.Invoke);

    3. Observable.FromEvent<MouseButtonEventHandler, MouseButtonEventArgs>(
           rxHandler => rxHandler.Invoke,
           eventHandler => image.MouseDown += eventHandler,
           eventHandler => image.MouseDown -= eventHandler);

    Последние две reflection не используют.

    ОтветитьУдалить
  5. >Zagrebelin комментирует.
    Для этого существуют UnitTests, а Rx очень и очень здорово тестируется

    ОтветитьУдалить
  6. > а) придет какой-нибудь начинающий разработчик и хрен разберется с тем кодом, который у вас написан с Rx

    Сферический начинающий разработчик в вакууме в состоянии засрать код любой степени тривиальности, Rx тут особой погоды не делает. Но это совершенно не повод писать приложение через GoTo.

    ОтветитьУдалить
  7. Я может быть не прав, но выскажусь.
    Мне второй код вариант нравится - он, скажем так, изящный за счет своей декларативности и реализации всей логики в одном месте.
    Но это сферический код в вакууме. Реалиный код будет сложнее и сложнее для чтения/понимания. А насчет проблем с дебагом сдесь уже писали.

    ОтветитьУдалить