Улучшаем биндинги в CSharpForMarkup +5


Недавно мне пришлось разбираться с Xamarin Forms и на глаза попалась такая штука как CSharpForMarkup. Она показалась очень интересной, поскольку позволяет использовать стандарный C# вместо XAML, тем самым нивелируякучу неудобств связаных с XAML. Но реализация биндингов мне показался недостаточно хорошой. Поэтому я начал её улучшать при помощи expression-ов и Roslyn анализаторов. Кому интересно что с этого получилось прошу под кат.


Улучшаем CSharpForMarkup


Как я уже говорил CSharpForMarkup позволяет использовать стандарный C# вместо XAML. Если мы, например, захочем отобразить список элементов, то view для это будет иметь приблизительно следующий вид:


// ...

Content = new ListView()
    .Bind(ListView.ItemSourceProperty, nameof(ViewModel.Items))
    .Bind(ListView.ItemTemplateProperty, () => 
      new DataTemplate(() => new ViewCell 
        { 
          View = new Label { TextColor = Color.RoyalBlue }
                    .Bind(Label.TextProperty, nameof(ViewModel.Item.Text))
                    .TextCenterHorizontal()
                    .TextCenterVertical() 
         }))
// ...

Как по мне довольно простой и прямолинейный код, но уж больно много слов получается. Поскольку это обычный C#, это можно очень просто починить. Давайте скроем boilerplate код и оставим только, то что мы действительно хотим менять/видеть. Для это-то создадим статичный класс XamarinElements и определим в нем следующее:



public static class XamarinElements
{
    public static ListView ListView<T>(string path, Func<T, View> itemTemplate = null)
    {
        return new ListView
        {
            ItemTemplate = new DataTemplate(() => new ViewCell {View = itemTemplate?.Invoke()})
        }.Bind(ListView.ItemsSourceProperty, path);
    }
}

Дальше мы можем открыть XamarinElements через using static XamarinElements и использовать его вот так:


// ...
using static XamarinElements;
// ...

Content = ListView(nameof(ViewModel.Items), () => 
          new Label { TextColor = Color.RoyalBlue }
            .Bind(Label.TextProperty, nameof(ViewModel.Item.Text))
            .TextCenterHorizontal()
            .TextCenterVertical() 
         )
// ...

На мой взгляд стало намного лучше. Но мы все ещё используем nameof(), что имеет свои нюансы. Например, нету простого способа сделать "длиный" биндинг такой как 'Item.Date.Hour'. Чтобы его определить нужно будет конкатенировать строки, а это уже не удобно.


Кроме этого, у нас нету никакой зависимости между тем что мы передали в ListView и тем к какой модели мы биндим ItemTemplate. Т.е. если мы решим изменить содержимое ViewModel.Items, то ItemTemplate об этом никак не узнает и он может биндиться к тому, чего уже не существует.


Чтобы избежать этого мы можем использовать Expression<Func>. Это сразу упрощает построение длиных биндингов и позволит через джененрик установить связь между тем к какой колекции мы забиндились и тем к каким элементам мы будим биндиться. Новая реализация будет иметь следующий вид:


public static class XamarinElements
{
  public static ListView ListView<T>(Expression<Func<IEnumerable<T>>> path, Func<T, View> itemTemplate = null)
  {
      var pathFromExpression = path.GetBindingPath();
      return new ListView
      {
          ItemTemplate = new DataTemplate(() => new ViewCell {View = itemTemplate?.Invoke(default)})
      }.Bind(ListView.ItemsSourceProperty, pathFromExpression);
  }

  public static TView Bind<TView, T>(this TView view, BindableProperty property, Expression<Func<T>> expression)
    where TView: BindableObject
  {
      view.Bind(property, expression.GetBindingPath());
      return view;
  }
}

// ...
using static XamarinElements;
// ...

Content = ListView(() => ViewModel.Items, o => 
          new Label { TextColor = Color.RoyalBlue }
            .Bind(Label.TextProperty, () => o.Item.Date.Hour))
            .TextCenterHorizontal()
            .TextCenterVertical() 
         )
// ...

Обратите внимание что в itemTemplate мы передаем пустой инстанс элемента из колекции. Хоть он и пустой и обращаться к нему на прямую смысла нет вообще, но это позволяет нам использовать его при создании биндингов внутри ItemTemplate. Если содержимое колекции кардинально изменится, то биндинг сломается тоже. Но здесь есть своя ложка дегтя. Поскольку это Expression, то нам ничего не мешает написать следующее () => o.Item.Date.Hour + 1. С точки зрения компилятора все окей, но мы не можем сделать биндиг к такой штуке.


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


Пишем анализатор


Я не буду описывать как настроить проект для анализатора и как его тестировать. Это уже описано в моих предыдущих статьях. Желающие могут почитать их или посмотреть полный код анализатора в репозитории.


Сам анализатор получился очень простой:


// ...
 public override void Initialize(AnalysisContext context)
        {
            context.EnableConcurrentExecution();
            context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze |
                                                   GeneratedCodeAnalysisFlags.ReportDiagnostics);

            context.RegisterOperationAction(o => Execute(o), OperationKind.Invocation);
        }

        private void Execute(OperationAnalysisContext context)
        {
            if (context.Operation is IInvocationOperation invocation)
            {
                var bindingExpressionAttribute =
                    context.Compilation.GetTypeByMetadataName("BindingExpression.BindingExpressionAttribute");

                var methodWithBindingExpressions = invocation.TargetMethod.Parameters
                    .Any(o =>
                        o.GetAttributes()
                            .Any(oo => oo?.AttributeClass?.Equals(bindingExpressionAttribute) ?? false));

                if (!methodWithBindingExpressions)
                {
                    return;
                }

                foreach (var argument in invocation.Arguments)
                {
                    var parameter = argument.Parameter;
                    if (!parameter
                        .GetAttributes()
                        .Any(o => o?.AttributeClass?.Equals(bindingExpressionAttribute) ?? false))
                    {
                        continue;
                    }

                    if (argument.Syntax is ArgumentSyntax argumentSyntax &&
                        argumentSyntax.Expression is ParenthesizedLambdaExpressionSyntax lambda)
                    {
                        switch (lambda.ExpressionBody)
                        {
                            case MemberAccessExpressionSyntax memberAccessExpressionSyntax:
                                continue;
                            default:
                                context.ReportDiagnostic(
                                    Diagnostic.Create(BindingExpressionAnalyzerDescription,
                                        argumentSyntax.GetLocation()));
                                break;
                        }
                    }
                }
            }
        }

// ...

Всё что мы делаем это смотрим на вызовы методов и ищем там аргименты которые имеют аттрибут BindingExpression. Если такой аргумент есть, то смотрим состоит ли наш expression только из MemberAccessExpressionSyntax, если нет — генерим ошибку.


Финализируем


Что-бы заставить его работать в текущем примере нужно будет поставить nuget BindingExpression и немного подредактировать наш XamarinElements.


Обновленая версия имеет следующий вид:


public static class XamarinElements
{
  public static ListView ListView<T>(
    [BindingExpression]Expression<Func<IEnumerable<T>>> path,  // check this like
    Func<T, View> itemTemplate = null)
  {
      var pathFromExpression = path.GetBindingPath();
      return new ListView
      {
          ItemTemplate = new DataTemplate(() => new ViewCell {View = itemTemplate?.Invoke(default)})
      }.Bind(ListView.ItemsSourceProperty, pathFromExpression);
  }

  public static TView Bind<TView, T>(
    this TView view, BindableProperty property, 
    [BindingExpression]Expression<Func<T>> expression) // check this like
    where TView: BindableObject
  {
      view.Bind(property, expression.GetBindingPath());
      return view;
  }
}

После чего следующий пример уже не скомпилируется:


// ...
using static XamarinElements;
// ...

Content = ListView(() => ViewModel.Items, o => 
          new Label { TextColor = Color.RoyalBlue }
            .Bind(Label.TextProperty, () => o.Item.Date.Hour + 1)) // error here
            .TextCenterHorizontal()
            .TextCenterVertical() 
         )
// ...

Вот таким относительно простым в использовани способом можно упростить и обезопасить написание xamarin приложений с использованием CSharpForMarkup.


На этом думаю всё. Пожелания и идеи преветствуются.


Исходники анализатора лежат тут: GitHub




К сожалению, не доступен сервер mySQL