Выпускать приложения для лишь одной мобильной платформы — не актуально и нужно заботиться о разработке сразу двух версий, для iOS и Android. И здесь можно выбрать два пути: работать на «нативных» языках программирования для каждой операционной системы или использовать кроссплатформенные фреймворки.
При разработке одного из мобильных приложений в компании DD Planet я сделал ставку на последний вариант. И в этой статье расскажу об опыте разработки кроссплатформенного приложения, проблемах, с которыми мы столкнулись, и найденных решениях.
Для начала рассмотрим, какие подходы используются, когда нужно получить сразу два приложения: под iOS и Android.
Первый — cамый затратный, как по времени, так и по ресурсам: разработка отдельного приложения для каждой из платформ. Сложность этого подхода заключается в том, что каждая из операционных систем требует своего подхода: это выражается как в языке, на котором ведется разработка (для Android — Java или Kotlin, для iOS — Objective-C или Swift), так и способах описания UI части приложения (axml и xib или storyboard файлы соответственно).
Уже этот факт подводит нас к тому, что для такого подхода необходимо формирование двух команд разработчиков. Помимо этого придется дублировать логику для каждой из платформ: взаимодействие с api и бизнес-логику.
А что, если количество используемых API будет расти?
Из этого возникает вопрос: как уменьшить необходимое количество человеческих ресурсов? Избавиться от необходимости дублировать код для каждой платформы. Существует достаточное количество фреймворков и технологий, решающих эту задачу.
Использование кроссплатформенного фреймворка (Xamarin.Forms, например) дает возможность писать код на одном языке программирования и описать логику данных и логику UI один раз, в одном месте. Поэтому необходимость использовать две команды разработчиков отпадает. А по итогу компиляции проекта на выходе получаем два нативных приложения. И это — второй подход.
Многие, думаю, знают, что такое Xamarin, или хотя бы слышали о нем, но как это работает? Xamarin основан на open-source реализации платформы .NET — Mono. Mono включает в себя собственный компилятор C#, среду выполнения, а также ряд библиотек, включая реализацию WinForms и ASP.Net.
Цель проекта — позволить запускать программы, написанные на C#, на операционных системах, отличных от Windows — Unix-системах, Mac OS и других. Сам же фреймворк Xamarin, по сути своей — библиотека классов, предоставляющей разработчику доступ к SDK платформы и компиляторы для этих них. Xamarin.Forms, в свою очередь, позволяет не только писать под обе платформы на одном языке, но проектировать дизайн экранов с использованием XAML разметки, привычной тем, кто уже имел опыт работы с WPF приложениями. В итоге сборки проекта получаем практически идентичный вид на всех платформах, так как на этапе компиляции все XF контролы преобразовываются в нативные для каждой платформы.
Писать код для каждой платформы разработчик вынужден только в том случае, если нужен доступ к каким-либо платформенным фичам (например, дактилоскопический сканер или уровень заряда батареи) или же необходимо более тонко настроить поведение контрола. В некоторых случаях при разработке приложения может потребоваться написание платформозависимого кода, но даже в этом случае никто не запрещает вынести платформенные функции в интерфейс и взаимодействовать в дальнейшем с ним из общего проекта.
Один язык программирования, мало кода и так далее. Все это звучит красиво но, Xamarin.Forms — не серебряная пуля, и вся его красота разбивается о камни реальности. Как только возникает ситуация, когда встроенные в XF-контролы уже не отвечают предъявленным к ним требованием, структура экранов и контролов становится всё сложнее и сложнее. Для обеспечения комфортной работы с экранами из общего проекта приходится писать все больше и больше кастомных рендеров.
На этом перейду к третьему подходу, который мы и используем при разработке приложений.
Мы уже выяснили, что использование Xamarin Forms может осложнить работу, а не упростить ее. Поэтому для реализации архитектурно сложных экранов, дизайнерских элементов и контролов, кардинально отличающихся от нативных, нашелся компромисс и возможность объединения первого и второго подхода.
Имеем все те же три проекта: общий PCL проект, но уже без Xamarin Forms, и два проекта Xamarin Android и Xamarin iOS. По-прежнему имеется возможность писать всё на одном языке, общая логику между двумя проектами, но нет ограничений единой XAML разметки. UI-составляющая контролируется каждой из платформ и использует нативные средства, на Android — нативный AXML, на iOS — XIB-файлы. Каждая платформа имеет возможность соблюдать свои гайдлайны, так как связь между Core и платформенными проектами организовывается только на уровне данных.
Для организации такой связи можно использовать паттерн проектирования MVVM и достаточно популярную его реализацию для Xamarin — MVVMCross. Его использование позволяет держать общую ViewModel для каждого экрана, где описана вся «бизнес-логика» работы, а его отрисовку доверить платформе. Так же это позволяет двум разработчикам работать с одним и тем же экраном (один с логикой — другой с UI) и не мешать друг другу. Кроме реализации паттерна, получаем достаточное количество инструментов для работы: реализация DI и IoC. Для подъема взаимодействия с платформой на уровень общего кода разработчику достаточно объявить интерфейс и реализовать его на платформе. Для типовых вещей MvvmCross уже предоставляет набор собственных плагинов. В команде мы используем плагин мессенджера для обмена сообщениями между платформой и общим кодом и плагин для работы с файлами (выбор изображений из галереи и др.).
Как уже было сказано раньше, при использовании сложных представлений на экране фреймворк может больше усложнить жизнь, чем облегчить. Но что назвать сложным элементом? Так как я занимаюсь преимущественно разработкой iOS, пример этой платформы и будет рассмотрен. Например, такая тривиальная вещь, как поле ввода, может иметь несколько состояний и достаточное количество логики для переключения и визуализации.
В ходе работы с пользовательским вводом был разработан вот такой контрол инпута. Он умеет поднимать свое название над полем ввода, работать с масками, ставить префиксы, постфиксы, уведомлять о нажатом CapsLock’е, валидировать информацию в двух режимах: запрет ввода и вывод информации об ошибке. Логика внутри контрола занимает примерно ~1000 строк. И, казалось бы: чего сложного может быть в дизайне поля ввода? Простой пример сложного контрола мы увидели. А что с экранами?
Для начала уточню, что один экран приложения в большинстве случаев является одним классом — UIViewController, описывающим его поведение. В ходе разработки потребовалась создание многоуровневой навигации. Концепция разрабатываемого приложения сводится к управлению своей недвижимостью и взаимодействию с соседями и муниципальными организациями. Поэтому были построены три уровня навигации: собственность, уровень представления (дом, город, регион) и тип контента. Все переключение осуществляется в рамках одного экрана.
Делалось это для того, чтобы пользователь мобильного приложения, где бы он ни находился, понимал, какой контент он видит. Для организации такой навигации главный экран приложения состоит далеко не из одного контроллера. Визуально его можно разделить на 3 части, но кто-нибудь сможет попробовать предположить сколько здесь использовано контроллеров?
Пятнадцать основных контроллеров. И это только для контента.
Вот такой монстр живёт на главном экране и неплохо себя чувствует. Пятнадцать контроллеров для одного экрана это, конечно, очень много. Это сказывается на скорости работы всего приложения, и нужно как-то это оптимизировать.
Мы отказались от синхронной инициализации: все вью-модели инициализируются в фоне и только тогда, когда это потребуется. Чтобы снизить время отрисовки, так же отказались от xib-файлов для этих экранов: абсолютное позиционирование и математика всегда быстрее, чем просчет зависимостей между элементами.
Чтобы уследить за таким количеством контроллеров нужно понимать:
Для этого я написал отдельный процессор навигации, который хранит информацию о местоположении пользователя, тип контента, который он просматривает, историю навигации и т.д. Он же управляет порядком и необходимостью инициализации.
Так как каждый таб представляет собой слайдер контроллеров (для того, чтобы создать на них свайповый переход), нужно понимать: каждый из них может находится в своем состоянии (например на одном открыты «Новости», на другом «Голосования»). За этим следит всё тот же процессор навигации. Даже сменив уровень представления с дома на регион, мы останемся на том же типе контента.
Работая с таким количеством данных в приложении, нужно организовать поставку актуальной информации по всем разделам в реальном времени. Для решения этой задачи можно выделить 3 способа:
У каждого из подходов есть свои плюсы и минусы, поэтому лучше использовать все три, выбрав только сильные стороны каждого. Мы условно разбили контент внутри приложения на несколько типов: горячий, обычный и сервисный. Сделано это для того, чтобы определить допустимое время между событием и уведомлением пользователя. Например, сообщение в чате мы хотим увидеть сразу после того, как нам ей вариант: опрос от соседей. Нет разницы, когда мы его увидим, сейчас или через минуту, ведь это — обычный контент. Мелкие уведомления внутри приложения (непрочитанные сообщения, команды и т.д.) — это сервисный контент, нуждающийся в срочной доставке, но не занимающий большого объема данных.
Выходит:
Самое интересное — это поддержание постоянного соединения. Написание собственного клиента для работы с веб-сокетами — это шаг в кроличью нору, поэтому нужно искать другие решения. В итоге остановились на библиотеке SignalR. Давайте разберемся что это такое.
ASP.Net SignalR — библиотека от компании Microsoft, которая упрощает клиент-серверное взаимодействие в реальном времени, обеспечивая двунаправленную связь между клиентом и сервером. Сервер включает в себя полноценное API для управления соединением, события подключения-отключения, механизм объединения подключенных клиентов в группы, авторизацию.
SignalR может использовать в качестве транспорта и websockets, и LongPolling, и http-запросы. Тип транспорта можно указать принудительно или же довериться библиотеке: если можно использовать websocket, то он будет работать через websocket, если такой возможности нет, то он будет спускаться дальше, пока не найдет приемлемый транспорт. Этот факт оказался очень практичным, учитывая то, что планируется использовать его на мобильных устройствах.
Итого, какую выгоду мы получаем:
Это, конечно, всем потребностям не удовлетворяет, но заметно делает жизнь проще.
Внутри проекта используется обертка над SignalR библиотекой, которая еще больше упрощает работу с ним, а именно:
Каждая из таких оберток (мы их называем клиентами) работает в паре с системой кэширования, и, в случае разрыва соединения, умеет запрашивать только те данные, которые она могла пропустить за это время. «Каждая» потому, что одновременно держатся несколько активных соединений. Внутри приложения имеется полноценный мессенджер, и для его обслуживания используется отдельный клиент.
Второй клиент отвечает за получение нотификаций. Как я уже говорил, контент обычного типа получается посредством http-запросов, в дальнейшем его актуализация ложится на этот клиент, который и сообщает о всех важных изменениях в нём (например голосование перешло из одного статуса в другой, публикация новой новости).
Одно дело данные получить, другое — показать. В обновлении данных в реальном времени есть свои сложности. Как минимум надо решить, как эти самые обновления презентовать пользователю. В приложении мы используем три вида нотификаций:
Самый привычный и обыденный способ показать, что где-то есть новый контент — подсветить иконку раздела. Таким образом практически все иконки имеют возможность показать нотификатор непрочитанного контента в виде красной точки. Интереснее дела обстоят с автоматическим обновлением.
Автоматически обновлять данные можно только тогда, когда новый контент не перестраивает экран и не меняет размер контролов. Например, на экране опроса: информация о голосах всего лишь изменит значение прогресс-бара и проценты. Такие изменения не повлекут никакого изменения размеров их можно без проблем применить мгновенно.
Сложности возникают тогда, когда необходимо добавлять новый контент в списки. Все списки в приложении, по сути, являются ScrollView и обладают несколькими характеристиками: размер окна, размер контента и позиция скрола. Все они имеют статичное начало (верх экрана с координатами 0;0) и могут расширяться вниз. Добавить новый контент вниз списка, в конец, не представляет никаких проблем, список продлится. Но новый контент должен появиться вверху, и получается вот такая картина:
Находясь на 3 элементе, мы окажемся на 2 — скролл отскочит вверх. А так как новый контент может поступать постоянно — нормально скроллить пользователь не сможет. Вы, быть может, скажете: почему бы не рассчитать размер нового контента и не сместить скролл вниз на это значение? Да, так можно сделать. Но тогда придется вручную управлять позицией скролла, и если в этот момент юзер скроллил в каком-либо направлении, его действие прервется. Именно поэтому такие экраны нельзя обновлять в реальном времени без согласия пользователя.
Оптимальным решением в этой ситуации будет проинформировать пользователя, что, пока он скроллил ленту, кто-то опубликовал новый контент. В нашем дизайне это выглядит как красный круг в углу экрана. Кликнув на него, пользователь дает свое условное согласие на то, чтобы мы вернули его к началу экрана и показали свежий контент.
Таким подходом мы, конечно, избежали проблем «подсовывания» контента, но их все равно пришлось решать. А именно на экране чата, так как в ходе общения и взаимодействия с экраном новый контент необходимо отображать в разных местах.
Отличие чата от обычных списков заключается в том, что свежий контент находится внизу экрана. Так как это «хвост», добавлять контент туда можно без особых затруднений. Пользователь проводит здесь 90% времени, а это означает, что нужно постоянно держать позицию скролла и смещать его вниз при получении и отправке сообщений. В живой беседе такие действия приходится делать достаточно часто.
Второй момент: загрузка истории при скролле вверх. Как раз при подгрузке истории мы и попадаем в ситуацию, когда необходимо поместить сообщения выше уровня обзора (что повлечет смещение), чтобы скролл был плавным и непрерывным. А как мы уже знаем, чтобы не помешать пользователю, вручную управлять позицией скролла нельзя.
И мы нашли решение: мы его перевернули. Переворот экрана решил сразу две проблемы:
Такое решение помогло так же и ускорить отрисовку, избавив от лишних операций с управлением скроллом.
Кстати, о быстродействии. В первых вариантах экрана обнаружились заметные просадки при скролле сообщений. Так как контент в «баблах» разношерстный — текст, файлы, фотографии, — необходимо постоянно пересчитывать размер ячейки, добавлять и удалять элементы в бабл. Поэтому потребовалась оптимизация баблов. Мы поступили так же, как и с главным экраном, частично отрисовывая бабл абсолютным позиционированием.
При работе со списками в iOS перед отрисовкой ячейки необходимо знать её высоту. Поэтому, прежде чем добавлять новое сообщение в список, нужно в отдельном потоке приготовить всю нужную информацию для отображения, рассчитать высоту ячеек, обработать данные пользователя и, только после того, как мы узнаем и закэшируем всё что нужно, добавить ячейку в список.
В итоге мы получаем плавный скролл и не перегруженный UI поток.