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

в 7:29, , рубрики: .net, C#, converter, MarkupExtensions, wpf, разработка под windows

Около года уже работаю с 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(
      @"^(?:([^ ,]+) )?([^,]+)(?:,([^,]+))?(?:[sS]*)$",
      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;
    }
  }
}

Спасибо за внимание!

Автор: ultimabear

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js