4 апреля 2018

Наш первый опыт разработки на Xamarin: трудности и их решения

Продолжаем делиться с вами нашим опытом освоения Xamarin!

Xamarin application by Noveo

Навигация

Первое, с чем мы столкнулись, – навигация.

По дизайну главная страница нашего приложения – это Tabbed page, страница с вкладками. Первая страница – список сотрудников с возможностью переключения в режим “Только фото” – сетку с более крупными фотографиями. Содержимое второй вкладки – группированный по дате список новостей компании (дни рождения, новые сотрудники, “happy client’s letter” и др.). Последняя вкладка – личная информация пользователя приложения.

 

При этом с каждой вкладки возможна навигация на страницу деталей/редактирования. Проблема в том, что на всех платформах реализация стандартной Xamarin.Forms-страницы TabbedPage различается. Это, конечно, правильно, ведь нативные “вкладки” на iOS – это панель  Tab Bar, которая располагается в нижней части экрана, на Android — это Tab Layout  в верхней части экрана (при этом над самими вкладками может располагаться App Bar с дополнительными элементами управления/навигации), а на UWP – это Pivot контрол:

https://docs.microsoft.com/en-us/xamarin/xamarin-forms/app-fundamentals/navigation/tabbed-page

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

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

https://docs.microsoft.com/en-us/xamarin/xamarin-forms/app-fundamentals/navigation/modal

https://docs.microsoft.com/en-us/xamarin/xamarin-forms/app-fundamentals/navigation/modal

Да, такая концепция немного не соответствует нашим задачам. Тем более что, применяя этот подход, нам пришлось бы реализовывать нативные элементы управления навигацией на каждой такой странице вручную. Поэтому было решено оставить страницы деталей/редактирования внутри табов.

Здесь важно сделать вывод, который скорее относится не к Xamarin, а к кросс-платформенной разработке в общем: самое важное – это момент дизайна и проектирования приложения. Нужно проектировать приложения с учетом особенностей всех платформ, при этом необходимы не только знания UX для каждой платформы, но и возможности фреймворка, с помощью которого будет реализовано приложение.

В нашем случае были только макеты для Android-приложения. Столкнувшись с описанной выше проблемой, мы сразу поняли, что, используя Xamarin.Forms, следовать им в полной точности не получится, и мы будем делать упор на максимальную “UI-нативность” на всех платформах. Признаемся, это довольно-таки увлекательное занятие – часы обсуждений и рисования макетов на доске. С точки зрения outsource-разработки это, конечно, может вызвать некоторые трудности, но, к счастью, этот проект – внутренний, и у нас была полная свобода действий.

ListView & GridView

Первая вкладка нашего приложения, которая встречает пользователя при запуске приложения, – это список сотрудников компании. Его особенность заключается в том, что над  ним расположен переключатель (switch), который превращает его из обычного списка в сетку (gridview) с более крупными фотографиями и дополнительными интерактивными элементами:

И, как ни удивительно, в Xamarin.Forms нет привычного для UWP GridView. В принципе это понятно – GridView достаточно большой, тяжелый элемент с огромным количеством нюансов. Тем более, на iOS нет как такового GridView, и реализовать полноценный универсальный контрол для всех платформ достаточно проблематично. Хотя есть несколько сторонних библиотек с более-менее удовлетворительной реализацией… но со своими проблемами. У одной, например, не было реализации под UWP.

Сначала решили попробовать легкую реализацию – FlowListView. Данный контрол реализован на чистом Xamarin.Forms и представляет из себя обычный ListView, который сам рассчитывает и добавляет элементы внутрь строк списка. С его помощью мы быстро и легко реализовали прототип списка с динамической подменой шаблона (смена режимов просмотра). Мы протестировали его на эмуляторах/симуляторах, и поначалу ничего не предвещало проблем. Да, были лаги при скроллинге, но мы были уверены, что все можно оптимизировать.

Вообще, судя по многочисленным статьям и вопросам на StackOverflow, проблема скроллинга длинных списков Xamarin.Forms – одна из самых болезненных. В нашем случае наибольшая просадка производительности оказалась оказалась вызвана картинками (фото сотрудников) в ячейках. Попробовали заменить стандартный Image на реализацию FFImageLoading. Она позволяет производить фоновую загрузку изображений, управлять ошибками загрузки, добавлять placeholder, более гибко управлять кэшированием картинок, обрезать картинки до круга и многое другое. С её помощью производительность выросла, но недостаточно. Кроме того, наши коллеги из iOS-отдела потом подсказали, что на этой платформе обрезание картинок до круга – очень трудозатратная операция, и лучший вариант – использование круглых масок.

Следующим шагом оптимизации списка было применение механизма переиспользования ячеек. Стандартный ListView и его обертка FlowListView имеют встроенную реализацию повторного использования созданных ячеек. Реализован он, естественно, на уровне каждой из платформ отдельно нативными средствами. Благодаря ему ячейки не создаются каждый раз при необходимости их отображения. Подключили – на эмуляторах работало хорошо, на девайсах терпимо, но хотелось лучше.

Затем нужно было добавить анимацию выезжающих панелей у элементов режима “Только фото”. И тут проявилась несовместимость FlowListView и переиспользования ячеек в нашем случае. При повторном использовании открытые панели оставались открытыми у ячеек, для которых не была произведена анимация. Кроме того, список просел настолько, что уже не оставалось выбора, как реализовать нативные рендереры.

До этого нам не приходилось писать больших рендереров. Мы надеялись, что Xamarin.Forms максимально оградит нас от углубления в тонкости новых для нас платформ – iOS и Android. Но деваться было некуда.

Стоит отметить, что кастомные рендереры можно написать для любого UI-элемента в Xamarin.Forms. В случае со списком можно написать рендерер для ячейки (ViewCell) и/или для всего ListView. Нам же нужен был не список, а полноценный GridView. Поэтому в PCL был создан “голый” контрол с определенным набором пропертей, унаследованный от Xamarin.Forms.View, который на Android и UWP превратится [KC5] с помощью рендереров в GridView, а на iOS – в UICollectionView.

Результат – полностью нативные списки с плавным скроллингом на всех платформах.

Опишем реализацию немного подробнее.

В PCL, как обычно, нам нужен один класс:

public class EmployeesView : View
{
    ...
    //Properties and EventHandlers
    ...
}

Для iOS структура классов выглядит следующим образом:

EmployeesViewRenderer – сам рендерер:

[assembly: ExportRenderer(typeof(EmployeesView), typeof(EmployeesViewRenderer))]
namespace CompanyStaff.iOS.Renderers.EmployeesViewRenderer
{
    public class EmployeesViewRenderer : ViewRenderer<EmployeesView, EmployeesCollectionView>
    {
        ...
    }
}

Инициализация и кастомизация нативного элемента – как и раньше в переопределенном методе OnElementChanged. Для iOS в нашем случае он выглядит примерно так:

protected override void OnElementChanged(ElementChangedEventArgs<EmployeesView> e)
{
    base.OnElementChanged(e);
    if (e.OldElement != null)
        // Отписка от событий у старого элемента
        Unbind(e.OldElement);
    if (e.NewElement != null)
        if (Control == null)
        {
            var collectionView = new EmployeesCollectionView
            {
                // выставление пропертей
            };
 
            // Подписка на собычтия для нового элемента
            Bind(e.NewElement);
 
            collectionView.Source = _dataSource ?? (_dataSource = new EmployeesViewSource(GetCell, RowsInSection, ItemSelected));
            collectionView.Delegate = new EmployeesDefaultViewDelegateFlowLayout();
 
            SetNativeControl(collectionView);
        }
}

Метод SetNativeControl используется для выставления нативного элемента в рендерере (метод также присваивает переданный объект в свойство Control).

Как видно из метода OnElementChanged и сигнатуры самого EmployeesViewRenderer, EmployeesCollectionView – и есть наш нативный контрол, который является реализацией UIKit.UICollectionView:

public sealed class EmployeesCollectionView : UICollectionView
{
    //...
 
    public EmployeesCollectionView(CGRect frm)
        : base(frm, new UICollectionViewFlowLayout())
    {
        RegisterClassForCell(typeof(EmployeesDefaultViewCell), new NSString(EmployeesDefaultViewCell.Key));
        RegisterClassForCell(typeof(EmployeesOnlyPhotoViewCell), new NSString(EmployeesOnlyPhotoViewCell.Key));
    }
 
    //...
}

Также необходимо реализовать собственный UICollectionViewSource:

public class EmployeesViewSource : UICollectionViewSource

В этом классе необходимо переопределить 2 метода:

  • RowsInSection – общее число строк коллекции.
  • GetCell – получение ячейки по заданному индексу. В нашем случае в зависимости от текущего режима возвращается переиспользуемая ячейка одного из двух типов.

Кроме того, для конфигурации коллекции в двух режимах нам потребовалось реализовать два класса, отвечающих за отображение ячеек:

Public class EmployeesDefaultViewCell : UICollectionViewCell
Public class EmployeesOnlyPhotoViewCell : UICollectionViewCell

и два класса, отвечающих за их размеры и расположение:

public class EmployeesDefaultViewDelegateFlowLayout : UICollectionViewDelegateFlowLayout
public class EmployeesOnlyPhotoViewDelegateFlowLayout : UICollectionViewDelegateFlowLayout

При смене режима коллекции происходит подмена делегата и перезагрузка всех данных UICollectionView для подмены ячеек:

private void SetDefaultMode()
{
    Control.Delegate = new EmployeesDefaultViewDelegateFlowLayout();
    Control.ReloadData();
}
 
private void SetOnlyPhotoMode()
{
    Control.Delegate = new EmployeesOnlyPhotoViewDelegateFlowLayout();
    Control.ReloadData();
}

Для Android потребовалось всего 3 сущности:

EmployeesViewRenderer:

public class EmployeesViewRenderer : ViewRenderer<EmployeesView, SwipeRefreshLayout>, SwipeRefreshLayout.IOnRefreshListener
{
    //...
}

Как видно из сигнатуры класса, нативный элемент в случае с Android – SwipeRefreshLayout:

protected override void OnElementChanged(ElementChangedEventArgs<EmployeesView> e)
{
    base.OnElementChanged(e);
    if (e.OldElement != null)
        // Отписка от событий у старого элемента
        Unbind(e.OldElement);
 
    if (e.NewElement == null) return;
 
    var context = Forms.Context;
    _collectionView = new GridView(context)
    {
        // выставление пропертей
    };
 
    _refresh = new SwipeRefreshLayout(context);
    _refresh.SetOnRefreshListener(this);
    _refresh.AddView(_collectionView, LayoutParams.MatchParent);
 
    //Выставление стандартного режима
    SetDefaultMode();
    // Подписка на собычтия для нового элемента
    Bind(e.NewElement);
 
    //Подписка на клик ячейки
    _collectionView.ItemClick += CollectionViewItemClick;
 
    SetNativeControl(_refresh);
}

Поскольку наши списки должны поддерживать обновление данных по свайпу вниз, для Android наш класс рендерит не сам GridView, а GridView, завернутый в SwipeRefreshLayout. В iOS же за это поведение отвечает сам UICollectionView.

Смена режимов списка реализована следующим образом:

private void SetDefaultMode()
{
    var numOfColumns = 1;
    _collectionView.SetNumColumns(numOfColumns);
    _collectionView.SmoothScrollBy(0, 0);
 
    _adapter = new EmployeesDefaultViewAdapter(Forms.Context, Element);
    _collectionView.Adapter = _adapter;
}
 
private void SetOnlyPhotoMode()
{
    var metrics = Resources.DisplayMetrics;
    var width = (int)(metrics.WidthPixels / metrics.Density);
    var numOfColumns = width / EmployeesOnlyPhotoViewAdapter.MinItemWidth;
 
    _collectionView.SetNumColumns(numOfColumns);
    _collectionView.SmoothScrollBy(0, 0);
 
    _adapter = new EmployeesOnlyPhotoViewAdapter(Forms.Context, _collectionView, Element);
    _collectionView.Adapter = _adapter;
}

Как видно из кода, всё, что нужно для смены – пересчитать количество колонок и подменить адаптер. Адаптеров, как ни странно, у нас два:

public class EmployeesDefaultViewAdapter : BaseAdapter<EmployeesListItemViewModel>
public class EmployeesOnlyPhotoViewAdapter : BaseAdapter<EmployeesListItemViewModel>

В каждом необходимо переопределить следующие члены:

  • Count – общее число элементов коллекции;
  • GetItemId – id элемента коллекции (обычно – номер в коллекции);
  • this[int] – получение модели по номеру в коллекции;
  • GetView – получение заполненной данными ячейки коллекции.

Для UWP всё еще проще – всего 2 класса:

EmployeesViewRenderer рендерит нативный GridView. А для смены режимов достаточно подменить 3 свойства:

public void SetDefaultMode()
{
    //layout
    GridView.ItemsPanel = Resources["EmployeesViewDefaultItemsPanelTemplate"] as ItemsPanelTemplate;
    //Стиль контейнера элемента
    GridView.ItemContainerStyle = Resources["EmployeesViewDefaultItemContainerStyle"] as Windows.UI.Xaml.Style;
    //Шаблон элемента
    GridView.ItemTemplate = Resources["EmployeesViewDefaultItemTemplate"] as Windows.UI.Xaml.DataTemplate;
}
 
public void SetOnlyPhotoMode()
{
    //layout
    GridView.ItemsPanel = Resources["EmployeesViewOnlyPhotoItemsPanelTemplate"] as ItemsPanelTemplate;
    //Стиль контейнера элемента
    GridView.ItemContainerStyle = Resources["EmployeesViewOnlyPhotoItemContainerStyle"] as Windows.UI.Xaml.Style;
    //Шаблон элемента
    GridView.ItemTemplate = Resources["EmployeesViewOnlyPhotoItemTemplate"] as Windows.UI.Xaml.DataTemplate;
}

Эти свойства определены в ресурсах XAML части класса EmployeesGridView.

Единственный момент – у GridView в UWP нет возможности обновлять коллекцию по жесту из коробки, и обработку жестов для этих целей необходимо писать вручную. Мы выбрали более простой путь и добавили отдельную кнопку для обновления в нижний AppBar страницы:

Xamarin application by Noveo

Работа с изображениями

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

Всю основную работу с изображениями в Xamarin.Forms можно разбить на 4 категории:

1) Локальные изображения

При таком подходе изображения хранятся в платформо-зависимых проектах, так же, как они хранились бы в проектах на “родной” платформе. Используются механизмы управления нативными разрешениями, такими как iOS Retina или Android high-DPI версии изображений.

Например, для iOS мы использовали Asset Catalogs:

Для Android достаточно поместить изображения с соответствующими разрешениями в папки Resources/drawable, drawable-hdpi, drawable-xhdpi и др.

2) Embedded-изображения.

При таком подходе  одна версия изображения хранится в PCL-проекте и встраивается в сборку как ресурс. К сожалению по ряду причин далеко не всегда возможно воспользоваться таким подходом. Некоторые стандартные контролы (например те же табы) не позволяют менять размер картинки. Не всегда при таком подходе картинки будут отображаться качественно на разных разрешениях.

3) Web-изображения.

Такие изображения автоматически загружаются из сети по заданному URL. В PCL проекте для них достаточно передать в объект Image или CachedImage от FFImageLoading URL ресурса.

В нативных рендерерах свои механизмы: для iOS мы использовали нативную библиотеку SDWebImage, для Android – Square.Picasso.

4) Иконки приложений и SpashScreens.

С точки зрения разработки все просто – добавить нативными средствами различные картинки с нужными разрешениями.

Из-за необходимости 1го и 4го варианта изображений и таргетирования приложения под 3 платформы возникает “Ад картинок”. Одна и таже картинка может понадобиться в 10-15 различных вариантах, т.к. на каждой платформе свои требования и гайдлайны по разрешениям. Из-за этого возникает большая путаница и много рутинной работы.

На iOS итого возникла следующая ситуация:

Изначально, иконки приложений мы задавали в настройках проекта CompanyStaff.iOS.

После обновления очередной версии Xamarin.Forms иконки стали отображаться размыто. Решение – использовать Assets Catalogs. А там уже совсем другие разрешения:

И снова нужно нарезать новые картинки.

Нас спасли наши дизайнеры. Спасибо им огромное за оперативное “нарезание” тонны картинок по нашим запросам =)

Анимация

Xamarin.Forms включает в себя собственную инфраструктуру для реализации анимации. С её помощью можно легко создавать простые анимации (Translation, scale, rotation и др.) и их комбинации с возможностью задания переходных функций (easing functions).

Визуально Xamarin.Forms-анимация работает достаточно неплохо. Мы добавляли её в режим “Только фото” для выезжающих панелей на FlowListView, но, как было сказано раньше, пришлось заменить FlowListView на кастомные рендереры, и реализовать данные “открывашки” нативно.

На данный момент пример анимации, реализованной в PCL с помощью Xamarin.Forms, можно увидеть на странице Login при фокусе полей ввода.

Проблема такой анимации – невозможность сымитировать нативное поведение контролов для всех платформ одновременно. Например, простейший способ реализовать кликабельные элементы, которые содержат в себе только одно изображение, – добавить к картинке GestureRecognizer (название, видимо, позаимствовано у iOS). На всех форумах так и советуют. Но по дефолту при клике на картинку никакого “отклика” приложения на действие пользователя не происходит. Единственный способ реализовать нативное поведение – кастомные рендереры. Конечно, правильным подходом в данном случае было бы использовать кнопку, если бы не одно но – в Xamarin.Forms оказалось не так просто поместить картинку внутрь кнопки. Основная проблема – невозможность задать размер изображения внутри. Кроме того, стандартные рендеры по-разному отображают кнопки (добавляются дополнительные margins и paddings, расположение картинок и текста отличается).  Это относится не только к кнопкам, но и к другим стандартным контролам. Для каждого элемента нужно искать свои пути решения.

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

Клавиатура

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

Нативные библиотеки

На сегодняшний момент невозможно представить какое-либо внятное приложение, при написании которого не использовались бы сторонние компоненты. Под Xamarin, к счастью, уже реализовано множество таких компонентов, которые легко подключить в проект с помощью Nuget package manager. Здесь также есть небольшая проблема: эти компоненты обновляются немного позже, чем обновляются “оригиналы”.

Естественно, что нативных библиотек намного больше. Для случаев, когда без таких библиотек обойтись невозможно, в Xamarin есть механизмы подключения бинарных библиотек, написанных на Objective-C или Java. Для этого придется написать специальные биндинг-проекты для связи с Xamarin. Есть специальные утилиты для автоматизации этого процесса, но сами мы их не использовали, и, судя по отзывам, нет гарантии 100% точности.

В нашем проекте внешних библиотек немного – SDWebImage и Square.Picasso (+ зависимые Square.OkIO и Square.OkHttp) для изображений и Microsoft.Toolkit.Uwp для кастомизации контролов UWP.

Выводы

На текущий момент Xamarin, на наш взгляд, — довольно мощная технология для разработки мобильных приложений и является одним из лучших кроссплатформенных решений по совокупности факторов (производительность, процент общего кода, “нативность” и др.). Особенно радует тот факт, что разработчики самой платформы активно её развивают и улучшают, появляется множество новых внешних компонентов. Об этом говорит и рост (ну или стабильность :) ) интересующихся Xamarin:

И, как видно из графика, большой пик произошел в марте-апреле 2016 — когда Microsoft сделал Xamarin бесплатным. И это, конечно же, тоже круто.

C Xamarin.Forms дела обстоят немного иначе. Да, формы также развиваются семимильными шагами. Даже во время разработки CompanyStaff много багов правилось именно обновлением фреймворка, новые версии которого выходили через приемлемое время после обнаружения недостатков. За это время вышло около 20 релизов, и примерно 7 из них – стабильные. Но, несмотря на частоту релизов, много косяков приходилось править самостоятельно, выясняя причину в недрах исходников платформы. Баги есть, и их пока довольно много.

Кроме того, не стоит думать, что Xamarin.Forms в действительности избавит вас от реализации платформо-специфичного кода хотя бы на 80%. Конечно, он идеально подходит для приложений с простым UI. С его помощью в очень короткие сроки можно реализовать макет приложения под несколько платформ. Но в дальнейшем на любое коммерческое приложение придется реализовывать кастомные рендереры для каждой ОС. Много рендереров. По крайней мере, пока.

В итоге ни Xamarin.Forms, ни, естественно, сам Xamarin не избавит от изучения специфики каждой мобильной платформы — но все же сильно упростит данный процесс если вы владеете .NET и знакомы с XAML и типичными архитектурными принципами (MVVM сильно упрощает разработку при использовании Xamarin.Forms).

В первой версии CS вышло примерно 65% общего кода. На наш взгляд, довольно неплохо. Производительность и внешний вид приложения при этом мало уступают “нативным” приложениям. Учитывая, что это наш первый опыт разработки на Xamarin, этот опыт можно считать крайне положительным. Темпы развития фреймворка дают явно понять, что технология никуда не пропадет, а будет лишь укрепляться — не только в сфере кроссплатформенной разработки, но и в мобильной в целом. Приятное ли впечатление осталось от этого опыта, и хотелось бы продолжать работать с Xamarin? Однозначно – да.

Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.

Читайте в нашем блоге

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: