19 January 2015

WPF: Binding без тривиальных конвертеров

.NET
Sandbox
Добрый день!

Всякий раз, когда я начинал писать новый проект на WPF, меня мучала мысль: почему для того, чтобы привязаться к отрицанию булевой переменной или перевести булеву переменную в тип Visibility, необходимо писать свой конвертер, который потом еще указывать в каждом Binding? А уж если нам необходимо вывести сумму двух чисел, или просто поделить число на 2, требуется написать столько кода, что уже складывать и делить ничего не хочется.

Для решения этой проблемы раз и навсегда я написал аналог стандартного биндинга, позволяющий привязываться к любому выражению от одного или нескольких источников привязки. О том, как это работает и как этим пользоваться, я хочу рассказать подробнее.

Сравните, одна и та же привязка, выполненная с использованием стандартного Binding и нового Binding (c:Binding):

До:

<Label>
  <Label.Content>
    <MultiBinding Conveter={x:StaticResource MyCustomConverter2}> 
    <Binding A/> 
    <Binding B/> 
    <Binding C/> 
    </MultiBinding> 
  </Label.Content>
<Label> 

После:

<Label Content="{c:Binding A+B+C }" />

До:

<Button Visibility="{Binding IsChecked Converter={x:StaticResource NegativeBoolToVisibilityConveter}}" />

После:

<Button Visibility="{c:Binding !IsChecked}" />

Как видно из этих примеров, новый Binding может принимать в свойство Path любое выражение от одного или нескольких Source Property. Поддерживаются все основные арифметические и логические операции, а также операции сложения строк и тернарный оператор.

Другие примеры:


Математические операции:
<TextBox Text="{c:Binding A+B+C}"/>
<TextBox Text="{c:Binding A-B-C}"/>
<TextBox Text="{c:Binding A*(B+C)}"/>
<TextBox Text="{c:Binding 2*A-B*0.5}"/>
<TextBox Text="{c:Binding A/B, StringFormat={}{0:n2} --StringFormat is used}"/>
<TextBox Text="{c:Binding A%B}"/>
<TextBox Text="{c:Binding '(A == 1) ? 10 : 20'}"/>

Логические операции:
<CheckBox Content="!IsChecked" IsChecked="{c:Binding !IsChecked}"/>
<TextBox Text="{c:Binding 'IsChecked and IsFull'}"/> {'and' эквивалент '&&' см. ниже}
<TextBox Text="{c:Binding '!IsChecked or (A > B)'}"/> {'or' эквивалент '||', но можно оставить '||'}
<TextBox Text="{c:Binding '(A == 1) and (B less= 5)'}"/> {'less=' эквивалент '<=' см. ниже}
<TextBox Text="{c:Binding (IsChecked || !IsFull)}"/>

Работа с bool и visibility:
<Button Content="TargetButton" Visibility="{c:Binding HasPrivileges, FalseToVisibility=Collapsed}"/>
<Button Content="TargetButton" Visibility="{c:Binding !HasPrivileges}"/>
<Button Content="TargetButton" Visibility="{c:Binding !HasPrivileges, FalseToVisibility=Hidden}"/>

Работа со строками:
<TextBox Text="{c:Binding (Name + \' \' + Surname)}" />
<TextBox Text="{c:Binding (IsMan?\'Mr\':\'Ms\') + \' \' + Surname + \' \' + Name}"/>

Работа с классом Math
<TextBox Text="{c:Binding Math.Sin(A*Math.PI/180), StringFormat={}{0:n5}}"/>
<TextBox Text="{c:Binding A*Math.PI}" />
<TextBox Text="{c:Binding 'Math.Sin(Math.Cos(A))'}"/>

Возможности


Ниже перечислены все возможности нового Binding, которые есть на данный момент:

Возможность Подробнее Пример
Арифметические операции + — / * % <Label Content="{c:Binding A*0.5+(B/C — B%C) }" />
Логические операции ! && || == !=, <, >, <=, >= <TextBox Text="{c:Binding (IsChecked || !IsFull)}"/>
Работа со строками + <TextBox Text="{c:Binding (Name + \' \' + Surname)}"/>
Тернарный оператор ? <TextBox Text="{c:Binding '(A == 1)? 10: 20'}"/>
Автоматический перевод из bool в Visibility FalseToVisibility определяет как отображать false <Button Visibility="{c:Binding !IsChecked}" />
Поддержка класса Math Все методы и константы <TextBox Text="{c:Binding Math.Sin(A*Math.PI/180)}"/>

Автоматическое вычисление обратной функции и обратное связывание Если по выражению можно построить обратное, то это будет сделано автоматически и биндинг станет двунаправленным
0% потери в скорости работы Исходный Path компилируется в анонимную функцию только 1 раз, в которую затем постоянно подставляются измененные значения свойств

Bool to visibility


Новый Binding автоматически конвертирует тип bool в тип Visibility, если связываются свойства с такими типами. По умолчанию считается, что false переводится в Visibility.Collapsed, но это поведение можно поменять, если задать необязательное свойство FalseToVisibility (Collapsed или Hidden):

<Button Visibility="{c:Binding IsChecked, FalseToVisibility=Hidden}" />

Как устроен CalcBinding внутри


Для тех, кто впервые узнал о возможности расширения xaml, я сделаю небольшое отступление. Всё, к чему мы обращаемся из xaml, используя фигурные скобки, является вполне себе обычными классами, написанными на C#, но обязательно наследуемыми от класса MarkupExtension. Такие классы так и называются — Markup Extension (расширение разметки). WPF позволяет дополнить набор стандартных Markup Extension собственными пользовательскими. Написанию таких дополнений посвящен ряд статей, например [1] и писать их действительно несложно.

Когда я начал изучать класс Binding(который естественно является Markup Extension) первой мыслью было пронаследоваться от него, а в определенных местах поменять поведение связывания. Но, к сожалению, оказалось, что метод ProvideValue, поведение которого требовалось изменить, помечен как запечатанный (объявлен с ключевым словом sealed) еще в родительском классе BindingBase. Такое расположение дел вынудило меня создать новый пустой Markup Extension и скопировать в него все свойства, используемые в стандартных Binding и MultiBinding. Конечно, такое решение требует модификации класса при любом изменении Binding и MultiBinding, но если вспомнить, что WPF уже давным давном не развивается, я думаю это мне не грозит.

Как устроен CalcBinding? В начале метода ProvideValue анализируется свойство Path и если Path содержит одну переменную, то создаётся Binding, иначе создаётся MultiBinding, содержащий по одному Binding на каждую переменную. В созданный Binding или MultiBinding прокидываются значения всех свойств CalcBinding, а в качестве конвертера передаётся мой конвертер, реализующий интерфейсы IConverter и IMultiConverter и выполняющий работу по компиляции Path и запуску полученной анонимной функции. У полученного Binding или MultiBinding вызывается метод ProvideValue и результат работы возвращается в качестве результата работы внешного ProvideValue. Таким образом WPF работает со своими стандартными классами для связывания, а мой класс выступает в роли своеобразной фабрики.

Как уже было сказано выше, для того чтобы новое решение не проигрывало по скорости работы старому, исходное выражение, заданное в свойстве Path, компилируется всего один раз, при первом вызове методов конвертера Convert или ConvertBack. В результате компиляции получается анонимная функция, принимающая в качестве параметров source property, к которым и привязывается target property. При изменении любого из source property полученная функция вызывается с новыми параметрами и соответственно возвращает новое значение.

Парсинг строкового выражения происходит в две стадии: на первой из строки строится дерево выражений (System.Linq.Expressions.Expression), на второй полученное выражение компилируется стандартными методами. Для создания Expression из строки существует парсер от Microsoft, который расположен в библиотеке DynamicExpression[2]. К сожалению, в нём обнаружились проблемы, которые не позволили использовать это решение в неизмененном виде, например баг [3]. К счастью парсер был выложен в opensource, и я использовал его fork под названием DynamicExpresso [4], в котором решена эта и несколько других проблем, а также расширен список поддерживаемых типов.

Опуская детали, можно представить логику метода Convert конвертера следующим образом:

private Lambda compiledExpression;
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
    if (compiledExpression == null)
    {
        var expressionTemplate = (string)parameter;
        compiledExpression = new Interpreter().Parse(expressionTemplate, values.Select(v => getParameter(v)).ToList());
    }
    var result = compiledExpression.Invoke(values);		
    return result;
}	


Обратное связывание


После того как было успешно протестировано связывание в сторону от источников к целевому свойству, мне захотелось иметь возможность автоматического поиска обратной функции и связывания в обратную сторону.

Понятно, что для того, чтобы можно было построить обратную функцию, требуется чтобы у неё был всего один аргумент и этот аргумент встречался ровно 1 раз. Это необходимые, но недостаточные условия. Из курса школьной математики мы помним, что если Y = F(X) сложная функция, представимая как Y = F1(F2(F3...(FN(X)))), то X = F-1(Y) = FN-1(FN-1-1(FN-2-1(...(F1-1(Y)))))

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

Вернёмся в программирование. С программной точки зрения нам требуется по известному дереву выражений, реализующему прямую функцию, построить дерево выражений, реализующее обратную функцию. Ограничения, вводимые на Expression таковы, что модифицировать созданное дерево выражений нельзя, поэтому придется строить новые.

Посмотрим на примере, что нам необходимо сделать для этого. Например, исходная функция выглядит следующим образом:
Path = 10 + (6+5)*(3+X) - Math.Sin(6*5)


По данному выражению построится следующее дерево:



Представим его в таком виде, что путь от листа, содержащего Х, до вершины Path, был прямым, а остальные узлы расположим сверху и снизу:



На таком рисунке становится видно, что для того чтобы построить обратное дерево выражений, требуется заменить все функции, стоящие в узлах на пути от листа с переменной X до корня с результатом Path на обратные, а затем поменять порядок их применения на обратный:



Ответвления, вычисляющие констатные значения, инвертировать не нужно. В результате мы получим обратное дерево выражений, из которого получается обратная функция:
X = ((Path - 10) + Math.Sin(6*5)) / (6 + 5) - 3

Поиск переменной, валидацию и построение обратного дерева выполняется всего одной рекурсивной функцией.

В настоящее время поддерживается следующий список функций, для которых автоматически определяются обратные:
  • +, -, *, /
  • !
  • Math.Sin, Math.ASin
  • Math.Cos, Math.ACos
  • Math.Tan, Math.ATan
  • Math.Pow
  • Math.Log

Полученное дерево выражений вычисляется и компилируется в анонимную функцию также всего 1 раз, при первом срабатывании обратного связывания.

Недостатки решения


Как и любое другое, данное решение обладает рядом ограничений и недостатков. Выявленные недостатки перечислены ниже.

1.) Если одно из sourceProperty имеет значение null, то у него становится невозможным определить тип на стадии создания Expression, поскольку typeof(null) не возвращает ничего. Это делает невозможным корректную обработку например такого выражения:
<Label Content="{c:Binding Path = (A == null) ? 3 : 4}" /> 

К сожалению, узнать тип source property из класса Binding непосредственно не представляется возможным, поэтому единственным возможным решением является узнавание типа source property с помощью рефлексии, но в этом случае придется реализовать половину функциональности Binding по поиску Source (RelativeSource, ElementName и т д). Возможно существуют альтернативные решения данной проблемы, поэтому буду рад вашим предложениям по этой теме.

2.) Поскольку разметка xaml является xml разметкой, в ней запрещен ряд символов, например открывающий тэг или значок амперсанда, означающие также и знаки «больше» и «логическое И». Для того, чтобы уйти от запрещенных символов, в Path используется ряд замен этих операторов, представленных ниже:

Оператор Замена в Path Примечание
&& and
|| or Введено для симметрии, необязательно
< less
<= less=


3.) В CalcBinding нельзя задать свой собственный конвертер. Я не придумал сценария, в котором требовалась бы такая возможность, поэтому если у вас есть предложения, буду рад их прочитать.

Ссылки на проект:


Библиотека доступна на github. В проекте находятся исходные коды библиотеки, полноценный пример использования всех возможностей и тесты. Для библиотеки создан nuget пакет, доступный по адресу:
www.nuget.org/packages/CalcBinding

Ссылки на использованные источники:


[1] 10rem.net/blog/2011/03/09/creating-a-custom-markup-extension-in-wpf-and-soon-silverlight
[2] weblogs.asp.net/scottgu/dynamic-linq-part-1-using-the-linq-dynamic-query-library Статья
msdn.microsoft.com/en-us/vstudio/bb894665.aspx Ссылка для скачивания
[3] connect.microsoft.com/VisualStudio/feedback/details/677766/system-linq-dynamic-culture-related-floating-point-parsing-error
[4] github.com/davideicardi/DynamicExpresso
Tags:.netwpf xaml конвертерыbindingexpression
Hubs: .NET
+35
34k 138
Comments 44
Popular right now
Разработчик .NET
from 60,000 ₽GMCSКазаньRemote job
.Net Developer
to 250,000 ₽e-POSМоскваRemote job
Разработчик .NET
from 60,000 to 120,000 ₽GMCSТулаRemote job
Ведущий программист .net
from 70,000 to 120,000 ₽Мечел-СервисЧелябинскRemote job
Разработчик .Net Core
from 90,000 ₽ГК InnoSTageRemote job