Совсем недавно мы опубликовали статью про особенности и проблемы популярного мобильного фреймворка Xamarin. Сегодня же мы продолжим рассказ и сосредоточимся на нюансах библиотеки Xamarin.Forms. Под катом вас ждёт история о том, какие грабли поджидают решившего сделать кроссплатформенный UI.
Для начала, вёрстку можно готовить как в коде, так и в формате XAML. К сожалению, превью интерфейса в реальном времени вы посмотреть не сможете, хотя для нативных средств разработки такая возможность доступна. Поэтому мы выбрали разработку интерфейса из кода. Выглядеть это будет немного громоздко, но в целом — удобно:
Далее, набор компонентов в Xamarin.Forms не очень большой. Не хватает таких, казалось бы, банальных вещей, как например «карусели» для кастомного содержимого. Есть полноэкранный контроллер-карусель, но нам нужен был подобный компонент, занимающий только часть экрана. Пришлось немного погнуть один из сторонних велосипедов.
У тех компонентов, что имеются в наличии, часто не хватает свойств или событий, имеющихся на iOS или Android. Может отсутствовать возможность поменять шрифт placeholder'а или цвет курсора, установить максимальную длину у текстового поля и так далее, подобные вещи приходится дописывать самостоятельно. В вышедшей в середине ноября 2015 года версии Xamarin.Forms 2.0 часть таких свойств добавлена, но до 100% покрытия всех возможностей нативных платформ ещё далеко.
Не радует и невозможность выставлять у всех компонентов отступы (padding и margin) — они есть только у контейнеров. Хотите у кнопки или поля ввода сделать отступ? Оберните её в контейнер:
Но слишком глубокая иерархия замедляет процесс рендеринга, который у Forms в принципе несколько более медленней, чем у нативных компонентов. Особенно замедление заметно на списках, но и просто достаточно сложные формы для некоторых приложений могут стать серьёзной проблемой.
Не меньше радует, что часть возможностей реализована некорректно и это является “фичей”. Например, при использовании стандартной навигации в Android у контроллеров при переходе на новый экран не будет вызываться часть событий жизненного цикла, т.к. навигация происходит не по реальным экранам или фрагментам, а банальной сменой вьюшки в рамках одного физического экрана.
Так же у компонентов часто встречаются баги. Например, у ScrollView была проблема — при появлении клавиатуры можно было прокрутить скролл дальше, чем нужно, в область без контента.
Источник проблемы — содержимое ScrollView по высоте меньше, чем контейнер. Размеры области для прокрутки содержимого определяет вот такой код:
В результате появилась идея как быстро (и грязно) можно порешать проблему — создать наследника ScrollView с перекрытием нужного метода:
Просто? Как бы не так — свойство ContentSize имеет приватный сеттер и в наследнике его значение просто так не изменить. Но раз уж мы пошли по кривой дорожке — всегда можно позвать на помощь рефлексию и таки изменить значение свойства.
В какой-то момент нас окончательно добил следующий баг: при изменении значения свойства видимости для пачки элементов управления (выставляли для нескольких полей на экране свойство IsVisible, одним в False, другим в True) элемент мог просто не появиться на экране! При этом он занимал своё месту в иерархии (в форме на экране появлялась дыра), но реально он оказался скрыт. Проблема возникала не только у нас, можно найти несколько обсуждений на форуме Xamarin — вот примеры раз или два.
Баг оказался плавающим, причем появился он в Xamarin.Forms 1.3.3.6323 и более поздних, проблема возникала из-за состояния гонки внутри самих Forms. Поэтому мы некотороые время оставались на более старой, но зато не имеющией этого бага версии — 1.3.1.6296. К сожалению в этой версии тоже имелись свои баги, исправленные в более поздних.
Так что в конце концов мы пришли к таком решению:
Данный код не только решает упомянутую проблему, но и является рекомендуемым при изменении нескольких свойств компонента сразу. Скажем, если код написан так:
То компонент будет перерисован трижды, после каждого изменения свойства. А вот если обернуть его в BatchBegin/BatchCommit — перерисовка (и пересчёт размера) произойдёт только один раз, что позитивно скажется на скорости.
Бывают и другие баги, например, TextView может повлиять на размер своего контейнера, хотя у того выставлен параметр «растягиваться на всю ширину»:
Возникает это, если вертикальный контейнер лежит в другом контейнере с горизонтальной ориентацией.
Встроенная поддержка двухстороннего биндинга между моделью и вьюшкой нас тоже не порадовала. Вот первый вариант указания связи:
Если ошибиться, и вместо “Text” написать другое имя — то ни на этапе компиляции, ни в рантайме ничего не взорвётся. Просто Label отобразится без текста.
Есть конечно чуть лучший вариант установки связи:
Но и он не спасает нас от ситуации, когда в Label будет помещён другой объект:
В этом случае опять таки ничего при выполнении не упадёт.
Но и это ещё не всё. Если вам нужны взаимосвязанные поля в модели (когда при изменении одного изменяется и другое) — для работы UI придётся дописать немного довольно скучного кода — реализовать интерфейс INotifyPropertyChanged и самостоятельно сообщать список изменившихся полей:
По этим причинам биндинг между моделью и контроллами мы написали свой — проверяющий соответствие типов полей, автоматически обновляющий связанные поля и т.п.
Ну и отдельная головная боль — списки. Начнём с мелочей: у списка есть заголовок и подвал (footer и header), этакие уникальные ячейки, которые прокручиваются вместе с обычными строчками. Это хорошо. Но при замене контента заголовка тот не пересчитывает свою высоту, если новый заголовок больше или меньше предшественника, а высота строк таблицы зафиксирована. Приходится делать это вручную
Если писать на нативных 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 исключительно для бизнес-логики, а вёрстку для каждой из платформ делать нативной.
Если у вас остались вопросы или есть замечания — с удовольствием ответим на них в комментариях.
Базовые проблемы
Для начала, вёрстку можно готовить как в коде, так и в формате 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)
kekekeks
15.12.2015 15:20+1Ну и напоследок, хотя список в качестве источника данных принимае IEnumerable, “подгрузки по мере прокрутки” по-умолчанию нет — в момент определения источника компонент вычитывает данные до конца.
Списки умеют работать с INotifyCollectionChanged и вполне адекватно реагируют на добавление элементов.Newbilius
15.12.2015 15:32Да, умеют. Но всё-равно руками нужно проверять момент достижение конца списка и докидывать элементы в конец.
kekekeks
Newbilius
За Fody спасибо!
nameof тоже в целом рабочий вариант, если не определять связи в XAML'е — там то код уже не вызывать, если я ничего не упустил.
igofed
Xamarin.Forms тоже пишет в лог
Newbilius
Любопытно, надо будет перепроверить. Раньше он ничего не делал молча.