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

Базовые проблемы


Для начала, вёрстку можно готовить как в коде, так и в формате XAML. К сожалению, превью интерфейса в реальном времени вы посмотреть не сможете, хотя для нативных средств разработки такая возможность доступна. Поэтому мы выбрали разработку интерфейса из кода. Выглядеть это будет немного громоздко, но в целом — удобно:

public class LoginViewController: ContentPage
{
    public LoginViewController()
    {
        Content = new StackLayout
                    {
                        Orientation = StackOrientation.Vertical,
                        Children =
                        {
                            new Entry
                            {
                                Placeholder = "Эл. почта",
                                Keyboard = Keyboard.Email,
                            },
                            new Entry
                            {
                                Placeholder = "Пароль",
                                IsPassword = true,
                            },
                            new Button
                            {
                                Text = "Войти"
                            },
                            new ActivityIndicator
                            {
                                IsRunning = true,
                                IsVisible = false,
                            }
                        }
                    };
    }
}

Далее, набор компонентов в Xamarin.Forms не очень большой. Не хватает таких, казалось бы, банальных вещей, как например «карусели» для кастомного содержимого. Есть полноэкранный контроллер-карусель, но нам нужен был подобный компонент, занимающий только часть экрана. Пришлось немного погнуть один из сторонних велосипедов.

У тех компонентов, что имеются в наличии, часто не хватает свойств или событий, имеющихся на iOS или Android. Может отсутствовать возможность поменять шрифт placeholder'а или цвет курсора, установить максимальную длину у текстового поля и так далее, подобные вещи приходится дописывать самостоятельно. В вышедшей в середине ноября 2015 года версии Xamarin.Forms 2.0 часть таких свойств добавлена, но до 100% покрытия всех возможностей нативных платформ ещё далеко.

Не радует и невозможность выставлять у всех компонентов отступы (padding и margin) — они есть только у контейнеров. Хотите у кнопки или поля ввода сделать отступ? Оберните её в контейнер:

new ContentView
    {
        Padding = new Thickness
                    {
                        Top = Sizes.StandartTopPadding
                        Left = Sizes.StandartLeftPadding
                    },
        Content = new Label
                    {
                        Text ="Текст с отступами"
                    }
    }

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

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

Баги


Так же у компонентов часто встречаются баги. Например, у ScrollView была проблема — при появлении клавиатуры можно было прокрутить скролл дальше, чем нужно, в область без контента.



Источник проблемы — содержимое ScrollView по высоте меньше, чем контейнер. Размеры области для прокрутки содержимого определяет вот такой код:

protected override void LayoutChildren(double x, double y, double width, double height)
{
    //[...]
    ContentSize=new Size(width, Math.Max(height, Content.Bounds.Bottom + Padding.Bottom));
}

В результате появилась идея как быстро (и грязно) можно порешать проблему — создать наследника ScrollView с перекрытием нужного метода:

protected override void LayoutChildren(double x, double y, double width, double height)
{
    //[...]
    //выкинули Max, размер контента всегда определяется размером контента
    ContentSize = new Size(width, Content.Bounds.Bottom + Padding.Bottom);
}

Просто? Как бы не так — свойство ContentSize имеет приватный сеттер и в наследнике его значение просто так не изменить. Но раз уж мы пошли по кривой дорожке — всегда можно позвать на помощь рефлексию и таки изменить значение свойства.

public class ScrollViewCopycat : ScrollView
{
    private readonly Action<Size> setContentSize;

    public ScrollViewCopycat()
    {
        var methodInfo = typeof(ScrollViewCopycat)
            .GetProperty("ContentSize", BindingFlags.Instance | BindingFlags.Public)
            .GetSetMethod(true);
        setContentSize = value => methodInfo.Invoke(this, new object[] { value });
    }

    protected override void LayoutChildren(double x, double y, double width, double height)
    {
    //[...]
    setContentSize(new Size(width, Content.Bounds.Bottom + Padding.Bottom));
    }
}

В какой-то момент нас окончательно добил следующий баг: при изменении значения свойства видимости для пачки элементов управления (выставляли для нескольких полей на экране свойство IsVisible, одним в False, другим в True) элемент мог просто не появиться на экране! При этом он занимал своё месту в иерархии (в форме на экране появлялась дыра), но реально он оказался скрыт. Проблема возникала не только у нас, можно найти несколько обсуждений на форуме Xamarin — вот примеры раз или два.

Баг оказался плавающим, причем появился он в Xamarin.Forms 1.3.3.6323 и более поздних, проблема возникала из-за состояния гонки внутри самих Forms. Поэтому мы некотороые время оставались на более старой, но зато не имеющией этого бага версии — 1.3.1.6296. К сожалению в этой версии тоже имелись свои баги, исправленные в более поздних.

Так что в конце концов мы пришли к таком решению:

  • у всех UI-контроллах, свойства которых мы хотим изменить, вызывается метод BatchBegin();
  • меняем необходимые свойства;
  • опять таки на всех контроллах вызываем BatchCommit().

Подробный код
public class Batch
{
    private readonly ILayoutController visualElement;

    public Batch(ILayoutController visualElement)
    {
        this.visualElement = visualElement;
    }

    public IDisposable Begin()
    {
        var animatables = GatherAnimatables(visualElement).ToArray();
        foreach (var animatable in animatables)
            animatable.BatchBegin();

        return new ActionDisposable(() =>
                                    {
                                        foreach (var animatable in animatables)
                                            animatable.BatchCommit();
                                    });
    }

    private static IEnumerable<IAnimatable> GatherAnimatables(ILayoutController root)
    {
        return root.Children.OfType<IAnimatable>()
                    .Concat(root.Children.OfType<ILayoutController>().SelectMany(GatherAnimatables));
    }
} 

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

if (alert)
    {
        errorlabel.IsVibislbe = true;
        errorlabel.TextColor = Colors.Red;
        errorlabel.Text = AlertText;
    }

То компонент будет перерисован трижды, после каждого изменения свойства. А вот если обернуть его в BatchBegin/BatchCommit — перерисовка (и пересчёт размера) произойдёт только один раз, что позитивно скажется на скорости.

Бывают и другие баги, например, TextView может повлиять на размер своего контейнера, хотя у того выставлен параметр «растягиваться на всю ширину»:



Возникает это, если вертикальный контейнер лежит в другом контейнере с горизонтальной ориентацией.

Код, приводящий к проблеме.
Content=new StackLayout
{
    Orientation = Orientation.Horizontal,
    BackgroundColor = Color.Green,
    Children =
              {
                  new StackLayout
                  {
                      Orientation = StackOrientation.Vertical,
                      VerticalOptions = LayoutOptions.FillAndExpand,
                      HorizontalOptions = LayoutOptions.FillAndExpand,
                      Children =
                      {
                          new Label
                          {
                              BackgroundColor = Color.Red,
                              HorizontalOptions = LayoutOptions.FillAndExpand,
                          }
                      }
                  }
              }
}


Связь моделей и UI-компонентов (биндинг)


Встроенная поддержка двухстороннего биндинга между моделью и вьюшкой нас тоже не порадовала. Вот первый вариант указания связи:

public class Model1
{
    public string Text { get; private set; }
    public Model1 (string text)
    {
        Text = text;
    }
}

var label1 = new Label
{
    BindingContext = new Model1("Hello, problems!")
}

label1.SetBinding(Label.TextProperty, "Text");

Если ошибиться, и вместо “Text” написать другое имя — то ни на этапе компиляции, ни в рантайме ничего не взорвётся. Просто Label отобразится без текста.

Есть конечно чуть лучший вариант установки связи:

label1.SetBinding<Model1>(Label.TextProperty, source => source.Text);

Но и он не спасает нас от ситуации, когда в Label будет помещён другой объект:

var label1 = new Label
{
    BindingContext = new Model2(),
};

В этом случае опять таки ничего при выполнении не упадёт.

Но и это ещё не всё. Если вам нужны взаимосвязанные поля в модели (когда при изменении одного изменяется и другое) — для работы UI придётся дописать немного довольно скучного кода — реализовать интерфейс INotifyPropertyChanged и самостоятельно сообщать список изменившихся полей:

public class Model : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private void OnPropertyChanged([CallerMemberName]string propertyName = null)
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }

    private int value1;
    public int Value1
    {
        get { return value1; }
        set
        {
            value1 = value;
            OnPropertyChanged();
            OnPropertyChanged("Value2");
        }
    }

    public int Value2
    {
        get { return Value1*2; }
    }
}

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

Списки


Ну и отдельная головная боль — списки. Начнём с мелочей: у списка есть заголовок и подвал (footer и header), этакие уникальные ячейки, которые прокручиваются вместе с обычными строчками. Это хорошо. Но при замене контента заголовка тот не пересчитывает свою высоту, если новый заголовок больше или меньше предшественника, а высота строк таблицы зафиксирована. Приходится делать это вручную

public interface IHeader
{
    Layout GetView();
    double GetHeight();
}

public void SetHeaderForm(IHeader value)
{
    value.GetView().Layout(new Rectangle(value.GetView().X, value.GetView().Y, Width, value.GetHeight()));
    list.Header = value;
}

Если писать на нативных iOS компонентах — такой проблемы не возникнет, размер пересчитается сам.

Другой неприятный момент – “контекстные действия”. Это меню как правило вызывается на Android долгим тапом, а на iOS – свайпом по ячейке. Неприятность ситуации в том, что для этих контекстных действий в Xamarin.Forms используется объект MenuItem, имеющий среди всего прочего свойство Icon. Но в данных менюшках никакие иконки не отображаются. И это фича.

Так что для показа иконок мы задействовали Object-C библиотеку MGSwipeTableCell, вокруг которой написали свою обёртку. Правда в результате мы потеряли возможность автоматического изменения размера ячеек в списке – все они теперь должны быть строго одной высоты, т.к написание корректного сложного кастомного рендера ячейки не так просто, как кажется.

Ну и напоследок, хотя список в качестве источника данных принимае IEnumerable, “подгрузки по мере прокрутки” по-умолчанию нет — в момент определения источника компонент вычитывает данные до конца. Не то что бы мы сильно ждали подобного поведения, т.к.«из коробки» бесконечных списков нет ни в iOS ни в Android, но лёгкая надежда всё-таки была. Увы, компоненты Xamarin.Forms реализуют исключительно прожиточный минимум возможностей — всё остальное придётся дописывать самим.

Выводы


Стоит или нет использовать Xamarin.Forms – нам покажет следующий этап, перенос уже написанного под Android Java-проекта на Forms. Но уже сейчас мы можем сказать, что Xamarin.Forms стоит использовать только для максимально простого UI. Если в планах есть использование всех до единой фишек конкретной платформы или хитрые дизайнерские решения – Xamarin.Forms будет больше мешать, чем помогать. В этом варианте лучше использовать Xamarin исключительно для бизнес-логики, а вёрстку для каждой из платформ делать нативной.

Если у вас остались вопросы или есть замечания — с удовольствием ответим на них в комментариях.

Комментарии (9)


  1. kekekeks
    15.12.2015 15:15

    Если ошибиться, и вместо “Text” написать другое имя — то ни на этапе компиляции, ни в рантайме ничего не взорвётся. Просто Label отобразится без текста.
    nameof. Эта проблема частично свойственна и WPF, но тот хотя бы в лог пишет.
    для работы UI придётся дописать немного довольно скучного кода — реализовать интерфейс INotifyPropertyChanged и самостоятельно сообщать список изменившихся полей
    Fody


    1. Newbilius
      15.12.2015 15:37

      За Fody спасибо!

      nameof тоже в целом рабочий вариант, если не определять связи в XAML'е — там то код уже не вызывать, если я ничего не упустил.


    1. igofed
      15.12.2015 20:41
      +1

      Xamarin.Forms тоже пишет в лог


      1. Newbilius
        15.12.2015 21:35

        Любопытно, надо будет перепроверить. Раньше он ничего не делал молча.


  1. kekekeks
    15.12.2015 15:20
    +1

    Ну и напоследок, хотя список в качестве источника данных принимае IEnumerable, “подгрузки по мере прокрутки” по-умолчанию нет — в момент определения источника компонент вычитывает данные до конца.
    Списки умеют работать с INotifyCollectionChanged и вполне адекватно реагируют на добавление элементов.


    1. Newbilius
      15.12.2015 15:32

      Да, умеют. Но всё-равно руками нужно проверять момент достижение конца списка и докидывать элементы в конец.


      1. igofed
        15.12.2015 21:00
        +1

        Это да, но в любом случае нужно писать логику показа спинера. Вроде это не дико критичная штука.


        1. Newbilius
          15.12.2015 21:35

          Ну да, к критичным недостатком я это не отнёс. :-)


  1. boblenin
    16.12.2015 05:02
    +1

    Такое же сложилось мнение, как у авторов поста.