[Из песочницы] Упрощаем конвертеры для WPF
Ситуацию усугубляет MVVM, благодаря которому не использовать это чудо науки получается довольно редко. Что-же, пришло время немного облегчить рутину создания и использования конвертеров, поехали.
Сразу оговорюсь, что я не против часто используемых конвертеров типа BooleanToVisibilityConverter и тому подобных, их вполне можно запомнить и переиспользовать во многих местах. Но часто бывает так, что конвертер нужен какой-то очень специфический, и делать из него целый компонент как-то не хочется. И долго это, и засоряет глобальную область видимости, потом сложно выискивать нужное во всем этом мусоре.
Конвертеры используются при работе с binding-ами и позволяют преобразовывать значения в одностороннем или двустороннем порядке (В зависимости от режима биндинга). Конвертеры также бывают двух типов — с одним значением и со множеством. За них отвечают интерфейсы IValueConverter и IMultiValueConverter соответственно.
При одиночном значении мы используем обычные биндинги, обычно через встроенное в XAML расширение разметки BindingBase:
В случае мульти-значения, используется такая монструозная конструкция:
Сами конвертеры же будут выглядеть так (Тут сразу два конвертера в одном классе, но можно и по-отдельности):
public class IntToStringConverter : IValueConverter, IMultiValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> (string)parameter == "plusOne" ? ((int)value + 1).ToString() : value.ToString();
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
=> $"{Convert(values[0], targetType, parameter, culture) as string} {values[1]}";
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
Это крайне коротко, так как пример синтетический, но уже тут без пол литра ни черта не понятно, что за массивы, что за приведения типов, какие-то левые targetType и culture, что за ConvertBack без реализации.
Идей по упрощению этого у меня несколько:
- Конвертеры в виде кусков c# кода прямо в xaml для простых вычислений;
- Конвертеры в виде ссылок на методы в code-behind, для случаев с очень конкретными/частными случаями конвертации, то есть тогда, когда нет смысла эту конвертацию где-либо еще переиспользовать;
- Конвертеры в виде того-же самого, что в стандартной реализации, но чтобы это не выглядело так стремно, чтобы каждый раз при написании нового конвертера не приходилось лезть в гугл и искать пример реализации конвертера.
Сразу обломаю тех из вас, кто думает, что я буду рассказывать как реализовать пункт 1. Я не буду. В сети есть несколько реализаций подобного, например тут. Также я видел варианты с expression tree и кажется еще какие-то. Такие вещи годятся только для простейших случаев — для работы с арифметическими и логическими операциями. Если же там нужно будет вызывать какие-то классы, использовать строки и так далее, то вылезут проблемы с экранированием внутри xml и проблема включения namespace-ов. Однако в простейших случаях подобные вещи использовать вполне можно.
А вот 2 и 3 пункты рассмотрим поподробнее. Допустим, понадобилось определить метод конвертации в code-behind. Как это должно выглядеть? Я думаю что примерно так:
private string ConvertIntToString(int intValue, string options)
=> options == "plusOne" ? (intValue + 1).ToString() : intValue.ToString();
private string ConvertIntAndStringToString(int intValue, string stringValue, string options)
=> $"{ConvertIntToString(intValue, options)} {stringValue}";
Сравните это с предыдущим вариантом. Кода меньше — ясности больше. Аналогично может выглядеть вариант с отдельным переиспользуемым конвертером:
public static class ConvertLib
{
public static string IntToString(int intValue, string options)
=> options == "plusOne" ? (intValue + 1).ToString() : intValue.ToString();
public static string IntAndStringToString(int intValue, string stringValue, string options)
=> $"{IntToString(intValue, options)} {stringValue}";
}
Неплохо, да? Хорошо, а как подружить xaml с этим, ведь он понимает только стандартные интерфейсы конвертеров? Можно конечно для каждого такого класса делать обертку в виде стандартных IValueConverter/IMultiValueConverter которая будет уже использовать красивые методы, но тогда теряется весь смысл, если придется объявлять по обертке на каждый читабельный конвертер. Одно из решений — сделать подобную обертку универсальной, типа такой:
public class GenericConverter : IValueConverter, IMultiValueConverter
{
public GenericConverter(/* Описания методов конвертации в виде делегатов или еще как-то */)
{
// Сохраняем методы конвертации
}
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
// Вызываем конвертацию по делегату, преобразуя входные параметры
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
// Вызываем конвертацию по делегату, преобразуя входные параметры
}
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
// Вызываем конвертацию по делегату, преобразуя входные параметры
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
// Вызываем конвертацию по делегату, преобразуя входные параметры
}
}
Это все в теории, как же практически передать в конвертер делегаты, и как их взять имея только XAML?
На помощь приходит механизм расширения разметки, MarkupExtension. Достаточно унаследовать класс MarkupExtension и переопределить метод ProvideValue и в XAML можно будет писать Binding-подобные выражения в фигурных скобочках, но со своими механизмами работы.
Для того, чтобы через расширения разметки передать ссылку на методы конвертации — самое простое, это использовать их строковые названия. Условимся что code-behind методы будем определять просто названием метода, а статические методы во внешних библиотеках будут идти вида ClrNamespace.ClassName.MethodName, отличить их можно будет по наличию точки у последнего (Как минимум одна точка будет между названием класса и метода, если класс лежит в глобальном пространстве имен).
Как идентифицировать методы разобрались, как же их получить в расширении разметки в виде делегатов, чтобы передать в конвертер? Расширение разметки (MarkupExtension) имеет метод ProvideValue для переопределения, который выглядит так:
public class GenericConvExtension : MarkupExtension
{
public override object ProvideValue(IServiceProvider serviceProvider)
{
// Какой-то код
}
}
Переопределяемый метод должен вернуть то, что в итоге присвоится в то свойство, в значении которого в XAML разметке определяется это расширение разметки. Метод этот может вернуть любое значение, но так как мы это расширение разметки будем подставлять в свойство Converter у биндинга (или мульти-биндинга), то возвращаемое значение должно быть конвертером, то есть экземпляром типа IValueConverter/IMultiValueConverter. Опять же, нет смысла делать разные конвертеры, можно сделать один класс и реализовать сразу два этих интерфейса, чтобы конвертер подходил как под одиночный биндинг, так и под множественный.
Для того, чтобы передать в расширение разметки строку, определяющую название функции из code-behind или статической библиотеки, которую должен вызывать конвертер, нужно определить в экземпляре MarkupExtension-а публичное строковое свойство:
public string FunctionName { get; set; }
После этого в разметке можно будет писать так:
Однако и это можно упростить, для начала, необязательно писать Extension в названии класса расширения conv: GenericConvExtension в XAML-е, достаточно просто conv: GenericConv. Далее в расширении можно определить конструктор, для того чтобы не указывать явно название свойства с именем функции:
public GenericConvExtension(string functionName)
{
FunctionName = functionName;
}
Теперь выражение в XAML-е стало еще проще:
Обратите также внимание на отсутствие кавычек в названии функции конвертации. В случаях, когда в строке нет пробелов и других нездоровых символов — одинарные кавычки не обязательны.
Теперь осталось только получить в методе ProvideValue ссылку на метод, создать экземпляр конвертера и передать в него эту ссылку. Ссылку на метод можно получить через механизм Reflection, однако для этого нужно знать runtime-тип, в котором объявлен этот метод. В случае с реализацией методов конвертации в статических классах передается полное имя статического метода (С указанием полного имени класса), соответственно можно распарсить эту строку, по полному имени типа через Reflection получить тип, и уже из типа также получить определение метода в виде экземпляра MethodInfo.
В случае с code-behind нужен не только тип, но и экземпляр этого типа (Ведь метод может быть не статическим и учитывать состояние Window экземпляра при выдаче результата конвертации). К счастью это не проблема, так как его можно получить через входной параметр метода ProvideValue:
public override object ProvideValue(IServiceProvider serviceProvider)
{
object rootObject = (serviceProvider.GetService(typeof(IRootObjectProvider)) as IRootObjectProvider).RootObject;
// ...
}
rootObject это и будет тот объект, в котором пишется code-behind, в случае окна это будет объект Window. Вызвав у него GetType можно через рефлексию получить интересующий нас метод конвертации, так как его имя задано в определенном ранее свойстве FunctionName. Далее просто нужно создать экземпляр GenericConverter, передав в него полученный MethodInfo и вернуть этот конвертер в результате ProvideValue.
Вот и вся теория, в конце статьи я приведу код своей реализации всего этого дела. Моя реализация в строке с названием метода принимает как метод конвертации так и опционально метод обратной конвертации, синтаксис примерно такой:
Общий вид:
'[Общее_имя_статического_класса] [Имя_статического_класса.]Имя_метода_конвертации, [Имя_статического_класса.]Имя_метода_обратной_конвертации'
Пример для статической библиотеки с методами-конвертерами:
'Converters.ConvertLib IntToString, StringToInt' = 'Converters.ConvertLib.IntToString, Converters.ConvertLib.StringToInt'
Пример для code-behind:
'IntToString' для one-way binding, 'IntToString, StringToInt' для two-way binding
Смешанный вариант (прямой метод в code-behind, обратный в статической либе):
'IntToString, Converters.ConvertLib.StringToInt'
Также все это работает и с мульти-биндингами, различие будет только в сигнатуре функций для конвертации (она должна соответствовать тому, что идет в биндинге). Также ConverterParameter может присутствовать в сигнатуре функции конвертации, а может отсутствовать, для этого его просто надо указать, либо не указывать, он определяется как просто последний параметр в сигнатуре.
Пример, рассмотренный в статье в случае с моей реализацией будет выглядеть в XAML вот так:
Минусы моей реализации, которые я нашел:
- В момент вызова метода идут всякие проверки, создание массивов для параметров, и вообще я не уверен что MethodInfo.Invoke () работает так же быстро, как вызов метода напрямую, однако я бы не сказал что это большой минус в условиях работы с WPF/MVVM.
- Нет возможности использовать перегрузки, так как на момент получения MethodInfo неизвестны типы значений, которые будут приходить, а значит нельзя получить нужную перегрузку метода в этот момент (Возможно можно как-то, но я не знаю как). Есть еще вариант каждый раз лезть в рефлексию при непосредственно вызове метода и находить перегрузку, но это уже будет неоправданная трата проц. времени ради каких-то перегрузок.
- Невозможность в мульти-биндингах делать различное поведение конвертера в зависимости от числа переданных параметров. То есть, если функция конвертации определена для 3 параметров, то количество мульти-биндингов должно быть именно таким, в стандартном конвертере же можно сделать вариативное количество.
using System;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Windows.Data;
using System.Windows.Markup;
using System.Xaml;
namespace Converters
{
public class ConvertFuncExtension : MarkupExtension
{
public ConvertFuncExtension()
{
}
public ConvertFuncExtension(string functionsExpression)
{
FunctionsExpression = functionsExpression;
}
public string FunctionsExpression { get; set; }
public override object ProvideValue(IServiceProvider serviceProvider)
{
object rootObject = (serviceProvider.GetService(typeof(IRootObjectProvider)) as IRootObjectProvider).RootObject;
MethodInfo convertMethod = null;
MethodInfo convertBackMethod = null;
ParseFunctionsExpression(out var convertType, out var convertMethodName, out var convertBackType, out var convertBackMethodName);
if (convertMethodName != null) {
var type = convertType ?? rootObject.GetType();
var flags = convertType != null ?
BindingFlags.Public | BindingFlags.Static :
BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
if ((convertMethod = type.GetMethod(convertMethodName, flags)) == null)
throw new ArgumentException($"Specified convert method {convertMethodName} not found on type {type.FullName}");
}
if (convertBackMethodName != null) {
var type = convertBackType ?? rootObject.GetType();
var flags = convertBackType != null ?
BindingFlags.Public | BindingFlags.Static :
BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
if ((convertBackMethod = type.GetMethod(convertBackMethodName, flags)) == null)
throw new ArgumentException($"Specified convert method {convertBackMethodName} not found on type {type.FullName}");
}
return new Converter(rootObject, convertMethod, convertBackMethod);
}
void ParseFunctionsExpression(out Type convertType, out string convertMethodName, out Type convertBackType, out string convertBackMethodName)
{
if (!ParseFunctionsExpressionWithRegex(out string commonConvertTypeName, out string fullConvertMethodName, out string fullConvertBackMethodName))
throw new ArgumentException("Error parsing functions expression");
Lazy allTypes = new Lazy(GetAllTypes);
Type commonConvertType = null;
if (commonConvertTypeName != null) {
commonConvertType = FindType(allTypes.Value, commonConvertTypeName);
if (commonConvertType == null)
throw new ArgumentException($"Error parsing functions expression: type {commonConvertTypeName} not found");
}
convertType = commonConvertType;
convertBackType = commonConvertType;
if (fullConvertMethodName != null)
ParseFullMethodName(allTypes, fullConvertMethodName, ref convertType, out convertMethodName);
else {
convertMethodName = null;
convertBackMethodName = null;
}
if (fullConvertBackMethodName != null)
ParseFullMethodName(allTypes, fullConvertBackMethodName, ref convertBackType, out convertBackMethodName);
else
convertBackMethodName = null;
}
bool ParseFunctionsExpressionWithRegex(out string commonConvertTypeName, out string fullConvertMethodName, out string fullConvertBackMethodName)
{
if (FunctionsExpression == null) {
commonConvertTypeName = null;
fullConvertMethodName = null;
fullConvertBackMethodName = null;
return true;
}
var match = _functionsExpressionRegex.Match(FunctionsExpression.Trim());
if (!match.Success) {
commonConvertTypeName = null;
fullConvertMethodName = null;
fullConvertBackMethodName = null;
return false;
}
commonConvertTypeName = match.Groups[1].Value;
if (commonConvertTypeName == "")
commonConvertTypeName = null;
fullConvertMethodName = match.Groups[2].Value.Trim();
if (fullConvertMethodName == "")
fullConvertMethodName = null;
fullConvertBackMethodName = match.Groups[3].Value.Trim();
if (fullConvertBackMethodName == "")
fullConvertBackMethodName = null;
return true;
}
static void ParseFullMethodName(Lazy allTypes, string fullMethodName, ref Type type, out string methodName)
{
var delimiterPos = fullMethodName.LastIndexOf('.');
if (delimiterPos == -1) {
methodName = fullMethodName;
return;
}
methodName = fullMethodName.Substring(delimiterPos + 1, fullMethodName.Length - (delimiterPos + 1));
var typeName = fullMethodName.Substring(0, delimiterPos);
var foundType = FindType(allTypes.Value, typeName);
type = foundType ?? throw new ArgumentException($"Error parsing functions expression: type {typeName} not found");
}
static Type FindType(Type[] types, string fullName)
=> types.FirstOrDefault(t => t.FullName.Equals(fullName));
static Type[] GetAllTypes()
=> AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()).ToArray();
readonly Regex _functionsExpressionRegex = new Regex(
@"^(?:([^ ,]+) )?([^,]+)(?:,([^,]+))?(?:[\s\S]*)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
class Converter : IValueConverter, IMultiValueConverter
{
public Converter(object rootObject, MethodInfo convertMethod, MethodInfo convertBackMethod)
{
_rootObject = rootObject;
_convertMethod = convertMethod;
_convertBackMethod = convertBackMethod;
_convertMethodParametersCount = _convertMethod != null ? _convertMethod.GetParameters().Length : 0;
_convertBackMethodParametersCount = _convertBackMethod != null ? _convertBackMethod.GetParameters().Length : 0;
}
#region IValueConverter
object IValueConverter.Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (_convertMethod == null)
return value;
if (_convertMethodParametersCount == 1)
return _convertMethod.Invoke(_rootObject, new[] { value });
else if (_convertMethodParametersCount == 2)
return _convertMethod.Invoke(_rootObject, new[] { value, parameter });
else
throw new InvalidOperationException("Method has invalid parameters");
}
object IValueConverter.ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (_convertBackMethod == null)
return value;
if (_convertBackMethodParametersCount == 1)
return _convertBackMethod.Invoke(_rootObject, new[] { value });
else if (_convertBackMethodParametersCount == 2)
return _convertBackMethod.Invoke(_rootObject, new[] { value, parameter });
else
throw new InvalidOperationException("Method has invalid parameters");
}
#endregion
#region IMultiValueConverter
object IMultiValueConverter.Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (_convertMethod == null)
throw new ArgumentException("Convert function is not defined");
if (_convertMethodParametersCount == values.Length)
return _convertMethod.Invoke(_rootObject, values);
else if (_convertMethodParametersCount == values.Length + 1)
return _convertMethod.Invoke(_rootObject, ConcatParameters(values, parameter));
else
throw new InvalidOperationException("Method has invalid parameters");
}
object[] IMultiValueConverter.ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
if (_convertBackMethod == null)
throw new ArgumentException("ConvertBack function is not defined");
object converted;
if (_convertBackMethodParametersCount == 1)
converted = _convertBackMethod.Invoke(_rootObject, new[] { value });
else if (_convertBackMethodParametersCount == 2)
converted = _convertBackMethod.Invoke(_rootObject, new[] { value, parameter });
else
throw new InvalidOperationException("Method has invalid parameters");
if (converted is object[] convertedAsArray)
return convertedAsArray;
// ToDo: Convert to object[] from Tuple<> and System.ValueTuple
return null;
}
static object[] ConcatParameters(object[] parameters, object converterParameter)
{
object[] result = new object[parameters.Length + 1];
parameters.CopyTo(result, 0);
result[parameters.Length] = converterParameter;
return result;
}
#endregion
object _rootObject;
MethodInfo _convertMethod;
MethodInfo _convertBackMethod;
int _convertMethodParametersCount;
int _convertBackMethodParametersCount;
}
}
}
Спасибо за внимание!
Комментарии (4)
17 апреля 2017 в 11:13
+1↑
↓
В MVVM можно сделать всё проще — заставить VM предоставлять данные в нужном типе и всё.
Никаких конвертеров вам больше не надо.17 апреля 2017 в 11:24
0↑
↓
Это не очень практичный вариант. Допустим, у вас есть форма с единственной кнопкой — нажимаем на нее, запускается длительное действие. В это время кнопка задизаблена, а на форме появляется текст «Идет обработка, пожалуйста подождите…». В вашем случае придется делать два свойства — одноbool IsButtonDisabled
, другоеVisibility TextVisibility
и не забывать их оба обновлять. На примерах из реального мира таких свойств будет еще больше.
17 апреля 2017 в 11:25
0↑
↓
Что-то итоговая простыня не особо проще пачки, пусть даже и одноразовых, конвертеров. Как все это дружит с x: Bind?17 апреля 2017 в 11:29 (комментарий был изменён)
0↑
↓
для MVVM вообще не рекомендуется использовать code-behind от View, мне кажется конвертеры должны быть во ViewModel