Упрощаем конвертеры для WPF

от автора

Около года уже работаю с WPF и некоторые вещи в нем откровенно выбешивают. Одна из таких вещей — конвертеры. Ради каждого чиха объявлять реализацию сомнительно выглядящего интерфейса где-то в недрах проекта, а потом искать его через Ctrl+F по названию, когда он вдруг понадобится. В мульти-конвертерах так вообще сам черт запутается.

Ситуацию усугубляет MVVM, благодаря которому не использовать это чудо науки получается довольно редко. Что-же, пришло время немного облегчить рутину создания и использования конвертеров, поехали.

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

Конвертеры используются при работе с binding-ами и позволяют преобразовывать значения в одностороннем или двустороннем порядке (В зависимости от режима биндинга). Конвертеры также бывают двух типов — с одним значением и со множеством. За них отвечают интерфейсы IValueConverter и IMultiValueConverter соответственно.

При одиночном значении мы используем обычные биндинги, обычно через встроенное в XAML расширение разметки BindingBase:

<TextBlock Text="{Binding IntProp, Converter={StaticResource conv:IntToStringConverter}, ConverterParameter=plusOne}" /> 

В случае мульти-значения, используется такая монструозная конструкция:

<TextBlock> 	<TextBlock.Text> 		<MultiBinding 			Converter="{StaticResource conv:IntToStringConverter}" 			ConverterParameter="plusOne"> 			<Binding Path="IntProp" /> 			<Binding Path="StringProp" /> 		</MultiBinding> 	</TextBlock.Text> </TextBlock> 

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

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 без реализации.

Идей по упрощению этого у меня несколько:

  1. Конвертеры в виде кусков c# кода прямо в xaml для простых вычислений;
  2. Конвертеры в виде ссылок на методы в code-behind, для случаев с очень конкретными/частными случаями конвертации, то есть тогда, когда нет смысла эту конвертацию где-либо еще переиспользовать;
  3. Конвертеры в виде того-же самого, что в стандартной реализации, но чтобы это не выглядело так стремно, чтобы каждый раз при написании нового конвертера не приходилось лезть в гугл и искать пример реализации конвертера.

Сразу обломаю тех из вас, кто думает, что я буду рассказывать как реализовать пункт 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; } 

После этого в разметке можно будет писать так:

<TextBlock Text="{Binding IntProp, Converter={conv:GenericConvExtension FunctionName='ConvertIntToString'}, ConverterParameter=plusOne}" /> 

Однако и это можно упростить, для начала, необязательно писать Extension в названии класса расширения conv:GenericConvExtension в XAML-е, достаточно просто conv:GenericConv. Далее в расширении можно определить конструктор, для того чтобы не указывать явно название свойства с именем функции:

public GenericConvExtension(string functionName) { 	FunctionName = functionName; } 

Теперь выражение в XAML-е стало еще проще:

<TextBlock Text="{Binding IntProp, Converter={conv:GenericConv ConvertIntToString}, ConverterParameter=plusOne}" /> 

Обратите также внимание на отсутствие кавычек в названии функции конвертации. В случаях, когда в строке нет пробелов и других нездоровых символов — одинарные кавычки не обязательны.

Теперь осталось только получить в методе 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 вот так:

<TextBlock Text="{Binding IntProp, Converter={conv:ConvertFunc 'ConvertIntToString'}, ConverterParameter=plusOne}" />  <TextBlock> 	<TextBlock.Text> 		<MultiBinding 			Converter="{conv:ConvertFunc 'ConvertIntAndStringToString'}" 			ConverterParameter="plusOne"> 			<Binding Path="IntProp" /> 			<Binding Path="StringProp" /> 		</MultiBinding> 	</TextBlock.Text> </TextBlock> 

Минусы моей реализации, которые я нашел:

  1. В момент вызова метода идут всякие проверки, создание массивов для параметров, и вообще я не уверен что MethodInfo.Invoke() работает так же быстро, как вызов метода напрямую, однако я бы не сказал что это большой минус в условиях работы с WPF/MVVM.
  2. Нет возможности использовать перегрузки, так как на момент получения MethodInfo неизвестны типы значений, которые будут приходить, а значит нельзя получить нужную перегрузку метода в этот момент (Возможно можно как-то, но я не знаю как). Есть еще вариант каждый раз лезть в рефлексию при непосредственно вызове метода и находить перегрузку, но это уже будет неоправданная трата проц. времени ради каких-то перегрузок.
  3. Невозможность в мульти-биндингах делать различное поведение конвертера в зависимости от числа переданных параметров. То есть, если функция конвертации определена для 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<Type[]> allTypes = new Lazy<Type[]>(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<Type[]> 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;     }   } } 

Спасибо за внимание!
ссылка на оригинал статьи https://habrahabr.ru/post/326646/