Русский
Русский
English
Статистика
Реклама

Mobile

История одного видео редактора

07.04.2021 18:21:37 | Автор: admin

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

Мой кнопочник Nokia 8110 4GМой кнопочник Nokia 8110 4G

В тот момент я искал кнопочный телефон, чтобы заменить им смартфон, который отнимал уйму времени. Так ко мне попал matrix-фон Nokia 8110 с KaiOS на борту и именно с этого момента когда я клал трубку после очередного разговора, приятели шутили, что мне опять звонил тот самый бог сновидений Морфеус.

Далее я стал узнавать все больше информации о самой операционной системе и устройствах на котором оно поставляется и в итоге в апреле 2020 года были опубликованы мои первые приложения в магазине KaiStore, одно из которых являлось проектной работой которую я реализовывал используя открытый стек WEB технологий в одной академии в 2017 году. Именно эту работу я адаптировал под данную платформу.

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

KaiOS - это мобильная операционная система базирующаяся на Linux и разработанная Kai OS Technologies. KaiOS была основана в 2016 году и запущена в 2017 с уникальным фокусом на кнопочные телефоны.

Данная продукция нацелена на определенный слой населения который по той или иной причине отделен от технологической революции.

В новом 2021 году, а точнее в первом его квартале мне удалось выпустить, как мне кажется, наиболее значимое для community приложение, по сравнению с предыдущими моими программами.

В сентябре 2020 года в комментариях под одной из моих публикаций в сообществе посвященном matrix-фону в социальной сети Facebook меня попросили реализовать видео редактор

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

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

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

Описав историю и критерии приемки я приступил к отрисовки графического интерфейса для приложения.

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

Интерфейс должен быть написан на JavaScript. Поскольку я активно изучаю работу Азата Мардана под названием React быстро. Веб-приложения на React, JSX, Redux и GraphQL, я решил взять именно этот инструмент для реализации лицевой части проекта.

Помимо Frontend части должен быть сервис для реализации обработки видео. Работу над медиа я решил реализовать через кроссплатформенное решение для записи, преобразования и потоковой передачи аудио и видео FFmpeg.

Поскольку все таки моим родным языком является PHP, в качестве Backend сервиса который будет оборачивать FFmpeg я выбрал именно синего слоника. Чтобы не писать сервис с нуля, моим решением стало воспользоваться легковесным Slim Framework. В качестве серверного решения я выбрал стек Nginx поверх Apache. Все это будет запущено на Ubuntu Server.

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

В момент разработки данного проекта я ожидал максимальное число загрузок в день равным 500, но был мягко скажем удивлен когда статистика довольно часто показывала свыше 4000 тысяч установок в сутки. В итоге только за один месяц число уникальных пользователей перешло границу в 46 тысяч человек.

Доход с рекламы встроенной в приложение нельзя назвать впечатляющим. За один месяц удалось заработать 9$ США. Если в течении года данный пассивный доход будет приносить такую же сумму, то, учитывая расходы на аренду сервера, удасться заработать около 60$ США.

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

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

Подробнее..

Детальный разбор навигации в Flutter

24.07.2020 14:12:14 | Автор: admin

Навигация во Flutter


image


Flutter набирает популярность среди разработчиков. Большенство подходов впостроении приложений уже устоялись иприменяются ежедневно вразработке E-commerce приложений. Тема навигации опускают навторой или третий план. Какой API навигации предоставляет Фреймворк? Какие подходы выработаны? Как использовать эти подходы иначто они годятся?


Введение


Начнём с того, что такое навигация? Навигация это метод который позволяет перемещаться между пользовательским интерфейсом с заданными параметрами.
К примеру в IOS мире организовывает навигацию UIViewController, а в Android Navigation component. А чтопредоставляет Flutter?



Экраны в Flutter называются route. Для перемещениями между route существует класс Navigator который имеющий обширный API для реализации различных видов навигации.



Начнём спростого.Навигация нановый экран(route) вызывается методом push() который принимает всебя один аргумент это Route.


Navigator.push(MaterialPageRoute(builder: (BuildContext context) => MyPage()));

Давайте детальнее разберёмся ввызове метода push:


Navigator Виджет, который управляет навигацией.
Navigator.push() метод который добавляет новый route виерархию виджетов.
MaterialPageRoute() Модальный route, который заменяет весь экран адаптивным кплатформе переходом анимации.
builder обязательный аргумент конструктора MaterialPageRoute, который возвращает пользовательский интерфейс Фреймворк для отрисовки.
[MyPage](https://miro.medium.com/max/664/1Xm96KtLeIAAMtAYWcr1-MA.png)* пользовательский интерфейс реализованный при помощи Stateful/Stateless Widget


Возвращение на предыдущий route


Для возвращения с экрана на предыдущий необходимо использовать метод pop().


Navigator.pop();

Переда данных между экранами


Данные на новый экран передаются через конструктор при создании экрана в builder методе. Этот принцип работает в методах Navigator, где нужно реализовать метод build.


Navigator.push(context, MaterialPageRoute(builder: (context) => MyPage(someData: data)));

В примере продемонстрирована передача данных в класс MyPage (в этом классе хранится пользовательский интерфейс).


Для того чтобы передать данные на предыдущий экран нужно вызвать метод pop() и передать опциональным аргументом туда данные.


Navigator.pop(data);


Состояние виджета Navigator, который вызван внутри одного из видов MaterialApp/CupertinoApp/WidgetsApp. State отвечает за хранение истории навигации и предоставляет API для управления историей.
Базовые методы навигации повторяют структуру данных Stack. В диаграмме можно наблюдать методы и "call flow" NavigatorState.


http://personeltest.ru/aways/habrastorage.org/webt/5w/dg/nb/5wdgnb-tjlngub4c8y4rlpqkeqi.png


Императивный vs Декларативный подход в навигации


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


Давайте на простом примере:


Императивный подход , отвечает на вопрос как?
Пример: Я вижу, что тот угловой столик свободен. Мы пойдём туда и сядем там.


Декларативный подход, отвечает на вопрос что?
Пример: Столик для двоих, пожалуйста.


Для более глубокого понимания разницы советую прочитать эту статью Imperative vs Declarative Programming


Императивная навигация


Вернёмся к реализации навигации. В императивном подходе описывается детали работы в вызывающем коде. В нашем случае это поля Route. В Flutter много типов route, например MaterialPageRoute и CupertinoPageRoute. Например в CupertinoPageRoute задаётся title, или settings.


Пример:


Navigator.push(    context,    CupertinoPageRoute<T>(        title: "Setting",        builder: (BuildContext context) => MyPage(),        settings: RouteSettings(name:"my_page"),    ),);

Этот код и знания о новом route будут хранитьсяв ViewModel/Controller/BLoC/ У этот подхода существует недостаток.


Представим что потребовалось внести изменения в конструкторе в MyPage или в CupertinoPageRoute. Нужно искать каждый вызов метода push в проекте и изменять кодовую базу.


Вывод:


Этот подход не имеет единообразный подход к навигации, и знание о реализации route проникает в бизнес логику.

Декларативная навигация


Принцип декларативной навигации заключается в использовании декларативного подхода в программировании. Давайте разберём на примере.


Пример:


Navigator.pushNamed(context, '/my_page');

Принцип императивной навигации выглядит куда проще. Говорите "Отправь пользователя на экран настроек" передавая путь одним из аргументов навигации.
Для хранении реализации роста в самом Фреймворке предусмотрен механизм у MaterialApp/CupertinoApp/WidgetsApp. Это 2 колбэка onGenerateRoute и onUnknownRoute отвеспющие за хранение деталей реализации.


Пример:


MaterialApp(    onUnknownRoute: (settings) => CupertinoPageRoute(      builder: (context) {                return UndefinedView(name: settings.name);            }    ),  onGenerateRoute: (settings) {    if (settings.name == '\my_page') {      return CupertinoPageRoute(                title: "MyPage",                settings: settings,        builder: (context) => MyPage(),      );    }        // Тут будут описание других роутов  },);

Разберёмся подробнее в реализации:
Метод onGenerateRoute данный метод срабатывает когда был вызван Navigator.pushNamed(). Метод должен вернуть route.
Метод onUnknownRoute срабатывает когда метод onGenerateRoute вернул null. должен вернуть дефолтный route, по аналогии с web сайтами 404 page.


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

Диалоговые и модальные окна


Для того чтобы вызвать диалоговое окна разного типа в Фреймворке предусмотрены глобальные методы. Давайте разберёмся в типах диалоговых окон.


Методы для вызова диалоговых и модальных окон:


  • showAboutDialog
  • showBottomSheet
  • showDatePicker
  • showGeneralDialog
  • showMenu
  • showModalBottomSheet
  • showSearch
  • showTimePicker
  • showCupertinoDialog
  • showDialog
  • showLicensePage
  • showCupertinoModalPopup

Эти методы покрывают базовые потребности разработчиков которые хотят работать с окнами. Если не хватает этого API, тогда стоит разобраться как эти методы работают.


Как работает это под капотом?


Давайте рассмотрим исходный код одного из методов, например showGeneralDialog.


Исходный код:


Future<T> showGeneralDialog<T>({  @required BuildContext context,  @required RoutePageBuilder pageBuilder,  bool barrierDismissible,  String barrierLabel,  Color barrierColor,  Duration transitionDuration,  RouteTransitionsBuilder transitionBuilder,  bool useRootNavigator = true,  RouteSettings routeSettings,}) {  assert(pageBuilder != null);  assert(useRootNavigator != null);  assert(!barrierDismissible || barrierLabel != null);  return Navigator.of(context, rootNavigator: useRootNavigator).push<T>(_DialogRoute<T>(    pageBuilder: pageBuilder,    barrierDismissible: barrierDismissible,    barrierLabel: barrierLabel,    barrierColor: barrierColor,    transitionDuration: transitionDuration,    transitionBuilder: transitionBuilder,    settings: routeSettings,  ));}

Давайте детальнее разберёмся в устройстве этого метода. showGeneralDialog вызывает метод push у NavigatorState с _DialogRoute(). Нижнее подчёркивание обозначает что этот класс приватный и используется только в пределах области видимости в которой сам описан, то есть в пределах этого файла.


Диалоговые и модальные окна которые отображаются при помощи глобальных методов это кастомные route которые реализованы разработчиками Фреймворка.

Типы route в фреймворке


Теперь понятно что"every thins is a route", то есть что связанное с навигацией. Давайте взглянем на то, какие route уже реализованы в Фреймворке.


Два основных route в Flutter это PageRoute и PopupRoute.


PageRoute Модальный route, который заменяет весь экран.
PopupRoute Модальный route, который накладывает виджет поверх текущего route.


Реализации PageRoute:


  • MaterialPageRoute
  • CupertinoPageRoute
  • _SearchPageRoute
  • PageRouteBuilder

Реализации PopupRoute:


  • _ContexMenuRoute
  • _DialogRoute
  • _ModalBottomSheetRoute
  • _CupertinoModalPopupRoute
  • _PopupMenuRoute

Реализация PopupRoute приватна и скрыты для внешних потребителей, но роуты используют глобальные методы для показа диалоговых и модальных окон.


Вывод:


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

Best practices


В этой части статьи можно задаться вопросом, "а насколько мне всё это нужно?". Ответить на этот вопрос можно легко, если у есть желание сделать масштабируемый API навигации которая в любой момент будет модифицирована под нужды проекта, то это один из вариантов. Если приложение будет в будущем расширяться со сложной логикой.


Начнём с того что мы сделаем некий сервис который будет будет соблюдать следующим аспектам:


  • Декларативный вызов навигации.
  • Отказ от использования BuildContext для навигации (Это критично если сервис навигации будет вызываться в компонентах, в которых нет возможности получить BuildContext).
  • Модульность. Можно вызвать любой route, CupertinoPageRoute, BottomSheetRoute, DialogRoute и т.д.

Для нашего сервиса навигации нам понадобится интерфейс:


abstract class IRouter {  Future<T> routeTo<T extends Object>(RouteBundle bundle);  Future<bool> back<T extends Object>({T data, bool rootNavigator});  GlobalKey<NavigatorState> rootNavigatorKey;}

Разберём методы:
routeTo - выполняет навигацию на новый экран.
back возвращает на предыдущий экран.
rootNavigatorKey GlobalKey умеющий вызывать методы NavigatorState.

После того как мы сделали интерфейс навигации, давайте сделаем реализацию этого интерфейса.


class Router implements IRouter {    @override  GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();    @override  Future<T> routeTo<T>(RouteBundle bundle) async {   // Push logic here  }    @override  Future<bool> back<T>({T data, bool rootNavigator = false}) async {        // Back logic here    }}

Супер, теперь нам нужно реализовать метод routeTo().


@override  Future<T> routeTo<T>(RouteBundle bundle) async {        assert(bundle != null, "The bundle [RouteBundle.bundle] is null");    NavigatorState rootState = rootNavigatorKey.currentState;    assert(rootState != null, 'rootState [NavigatorState] is null');    switch (bundle.route) {      case "/routeExample":        return await rootState.push(          _buildRoute<T>(            bundle: bundle,            child: RouteExample(),          ),        );      case "/my_page":        return await rootState.push(          _buildRoute<T>(            bundle: bundle,            child: MyPage(),          ),        );      default:        throw Exception('Route is not found');    }  }

Данный метод вызывает у root NavigatorState (который описан в WidgetsApp) метод push и конфигурирует его относительно RouteBundle который приходит одним из аргументов в данный метод.


Теперь нужно реализовать класс RouteBundle. Это просто модель, которая хранит в себе набор полей для конфигурации.


enum ContainerType {  /// The parent type is [Scaffold].  ///  /// In IOS route with an iOS transition [CupertinoPageRoute].  /// In Android route with an Android transition [MaterialPageRoute].  ///  scaffold,  /// Used for show child in dialog.  ///  /// Route with [DialogRoute].  dialog,  /// Used for show child in [BottomSheet].  ///  /// Route with [ModalBottomSheetRoute].  bottomSheet,  /// Used for show child only.  /// [AppBar] and other features is not implemented.  window,}class RouteBundle {  /// Creates a bundle that can be used for [Router].  RouteBundle({    this.route,    this.containerType,  });  /// The route for current navigation.  ///  /// See [Routes] for details.  final String route;  /// The current status of this animation.  final ContainerType containerType;}

enum ContainerType тип контейнера, котрый будет задаваться декларативно из вызываемого кода.
RouteBundle класс-холдер данных отвечающих конфигурацию нового route.


Как вы могли заметить у я использовал метод _buildRoute. Именно он отвечает за то, кой тип route будет вызван.


Route<T> _buildRoute<T>({@required RouteBundle bundle, @required Widget child}) {    assert(bundle.containerType != null, "The bundle.containerType [RouteBundle.containerType] is null");    switch (bundle.containerType) {      case ContainerType.scaffold:        return CupertinoPageRoute<T>(          title: bundle.title,          builder: (BuildContext context) => child,          settings: RouteSettings(name: bundle.route),        );      case ContainerType.dialog:        return DialogRoute<T>(          title: '123',          settings: RouteSettings(name: bundle.route),          pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {                        return child;                    },        );      case ContainerType.bottomSheet:        return ModalBottomSheetRoute<T>(          settings: RouteSettings(name: bundle.route),          isScrollControlled: true,          builder: (BuildContext context) => child,        );      case ContainerType.window:        return CupertinoPageRoute<T>(          settings: RouteSettings(name: bundle.route),          builder: (BuildContext context) => child,        );      default:        throw Exception('ContainerType is not found');    }  }

Думаю что в этой функции стоит рассказать о ModalBottomSheetRoute и DialogRoute, которые использую. Исходный код этих route позаимствован из раздела Material исходного кода Flutter.


Осталось сделать метод back.


@overrideFuture<bool> back<T>({T data, bool rootNavigator = false}) async {    NavigatorState rootState = rootNavigatorKey.currentState;  return await (rootState).maybePop<T>(data);}

Ну и конечно перед использованием сервиса необходимо передать rootNavigatorKey в App следующим образом:


MaterialApp(    navigatorKey: widget.router.rootNavigatorKey,    home: Home());

Кодовая база для нашего сервиса готова, давайте вызовем наш route. Для этого создадим инстанс нашего сервиса и каким-либо образом "прокинуть" в объект, который будет вызывать этот инстанс, например при помощи Dependency Injection.



router.routeTo(RouteBundle(route: '/my_page', containerType: ContainerType.window));

Теперь мы имеем единый подход к навигации, позволяющий решить вышеперечисленные проблемы:


  • Декларативный вызов навигации
  • Отказ от BuildContext по средствам GlobalKey
  • Модульность достигнута возможностью конфигурирования route относительно имени пути и контейнера для View

Итог


В Фреймворке Flutter существуют различные методы для навигации, которые дают преимущества и недостатки.


Ну и конечно полезные ссылки:
Мой телеграм канал
Мои друзья Flutter Dev Podcast
Вакансии Flutter разработчиков

Подробнее..

Стоп рефакторинг. Kotlin. Android

24.02.2021 00:19:22 | Автор: admin

Введение

Работая над одним проектом продолжительное время, я заметил как приходится переписывать код, который еще три месяца назад казался хорошим. Инженерам поступают новые требования, когда они не пересекаются с предыдущими - все отлично, добавили пару новых классов и побежали дальше. Иногда они касаются уже выпущенной части - взмах синей изолентой и все работает. Но наступает тот самый момент, когда цена внесения изменений катастрофически дорога и единственное рациональное решение - переписать все... А можно ли писать код, не переписывая его в будущем или хотя бы отложить этот момент на более долгий срок?

Я хочу рассказать про практики, которые не один раз уже выручали нас в проекте. Подборка примеров получилась не на пустом месте, все реальные примеры PullRequest-ов.
Все примеры НЕ выдуманные и тестировались на живых людях. В процессе сбора данных несколько людей пострадало.

Заменяйте if-else на when где это необходимо

Долгое время Java был предпочтительным языком программирования для платформы Android. Затем на арену пришел Kotlin, да вот привычки остались старые.

fun getNumberSign(num: Int): String = if (num < 0) {    "negative"} else if (num > 0) {    "positive"} else {    "zero"}

Красиво - 7 строк и получаем результат. Можно проще:

fun getNumberSign(num: Int): String = when {    num < 0 -> "negative"    num > 0 -> "positive"    else -> "zero"}

Тот же код, а строк 5.

Не забываем и про каскадное использованиеif-elseи его нечитабильность при разрастании кодобазы. Если в вашем проекте нет необходимости поддерживать 2 ЯП(Kotlin + Java), настоятельно рекомендую взять его себе на вооружение. Одна из самых популярных причин его игнорирования - "Не привычно"

Дело не в предпочтениях стилистики писания: семистопный дактиль или пятистопный хорей. Дело в том, что в Kotlin отсутствует операторelse-if. Упуская этот момент можно выстрелить себе в ногу. А вот и сам пазлер 9 отАнтона Кекса.

Я не рекомендую использоватьwhenвезде, где только можно. В Kotlin нет(и небудет) тернарного оператора, и стандартные булевы условия стоит использовать по классике. Когда условий больше двух, присмотритесь и сделайте код элегантнее.

Отряд булевых флажков

Рассмотрим следующее на примере поступающего ТЗ в динамике:

1. Пользователь должен иметь возможность видеть доставлено сообщение или нет

data class Message(  // ...  val isDelivered: Boolean)

Все ли здесь хорошо? Будет ли модель устойчива к изменениям? Есть ли гипотетическая возможность того, что в модели типаMessageне будут добавлены новые условия в будущем? Имеем ли мы право считать, что исходные условия ТЗ есть оконченный постулат, который нельзя нарушить?

2. Пользователь должен иметь возможность видеть прочитано сообщение или нет

data class Message(  // ...  val isDelivered: Boolean,  val isRead: Boolean) 

Не успели мы моргнуть глазом, как ProductOwner передумал и внес изменения в первоначальные условия. Неожиданно? Самое простое решение - добавить новое поле и "решить" проблему. Огорчу, не решить - отложить неизбежное. Избавление от проблемы здесь и сейчас - must have каждого IT инженера. Предсказание изменений и делать устойчивую систему - опыт, паттерны, а иногда, искусство.

Под "отложить неизбежное" я подразумеваю факт того, что рано или поздно система станет неустойчива и придет время рефакторинга. Рефакторинг -> дополнительное время на разработку -> затраты не по смете бюджета -> неудовлетворенность заказчика -> увольнение -> депрессия -> невозможность решить финансовый вопрос -> голод -> смерть. Все из-за Boolean флага?!!! COVID-19 не так уж страшен.

Что не так? Сам факт появления изменений не есть глупость PO, который не мог сразу сформулировать свою мысль. Не все то, что очевидно сейчас, было очевидно ранее. Чем меньше время на маневр, тем вы ценнее и конкурентнее. Далее включим фантазию и попробуем предугадать, что же еще может придумать менеджер?

3. Пользователь должен иметь возможность видеть отправлено ли сообщение

4. Пользователь должен иметь возможность видеть появилось ли сообщение в нотификациях e.t.c.

Если мы сложим воедино все новые требования, будет видно, что объектMessageможет находиться только в одном состоянии: отправлено, доставлено, появилось ли сообщение в нотификациях, прочитано Набор состоянийдетерминирован. Опишем их и заложим в наш объект:

data class Message(  // ...  val state: State) {    enum class State {        SENT,        DELIVERED,        SHOWN_IN_NOTIFICATION,        READ    }}

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

data class Message(  // ...  val states: Set<State>) {  fun hasState(state: State): Boolean = states.contains(state)}// либо data class Message(    // ...    val states: States) {    enum class State(internal val flag: Int) {        SENT(1),        DELIVERED(1 shl 1),        READ(1 shl 2),        SHOWN_IN_NOTIFICATION(1 shl 3)    }    data class States internal constructor(internal val flags: Int) {        init {          check(flags and (flags+1)) { "Expected value: flags=2^n-1" }        }        constructor(vararg states: State): this(            states.map(State::flag).reduce { acc, flag -> acc or flag }        )        fun hasState(state: State): Boolean = (flags and state.flag) == state.flag    }}

Выводы: перед тем как начать проектировать систему, задайте необходимые вопросы, которые помогут вам найти подходящее решение.Можно ли считать набор условий конечным? Не изменится ли он в будущем?Если ответы на эти вопросы ДА-ДА - смело вставляйте булево состояние. Если же хоть на один вопрос ответ НЕТ - заложите детерменированный набор состояний. Если объект в один момент времени может находиться в нескольких состояниях - закладывайте множество.

А теперь посмотрим на решение с булевыми флагами:

data class Message(  //..  val isSent: Boolean,  val isDelivered: Boolean  val isRead: Boolean,  val isShownInNotification: Boolean) //...fun drawStatusIcon(message: Message) {  when {    message.isSent && message.isDelivered && message.isRead && message.isShownInNotification ->     drawNotificationStatusIcon()    message.isSent && message.isDelivered && message.isRead -> drawReadStatusIcon()    message.isSent && message.isDelivered -> drawDeliviredStatusIcon()    else -> drawSentStatus()   }}

Попробуйте добавить еще одно состояние(ошибку) в конец и в середину списка приоритетов. Без чтения документации и без ознакомления работы с флагами это сделать будет проблематично.

Одно состояние

Одно состояние описывается несколькими независимыми переменными. Редкая проблема, которая открывается при потере фокуса над контекстом разрабатываемого компонента.

data class User(    val username: String?    val hasUsername: Boolean)

По условию контракта есть возможность не заполнить имя пользователя. На GUIне такое состояние должно подсветиться предложением. За состояние предложения, логично считать, переменнуюhasUsername. По объявленным соглашениям, легко допустить простую ошибку.

// OKval user1 = User(username = null, hasUsername = false) // Ошибка, имя пользователя естьval user2 = User(username = "user", hasUsername = false) // OKval user3 = User(username = "user", hasUsername = true) // Ошибка, имя пользователя не задано, а флаг говорит об обратномval user4 = User(username = null, hasUsername = true) // Ошибка, имя пользователя пустое, а флаг говорит об обратномval user5 = User(username = "", hasUsername = true) // Ошибка, имя пользователя пустое, а флаг говорит об обратномval user6 = User(username = " ", hasUsername = true) 

Узкие места в контракте открывают двери для совершения ошибки. Источником ответственности за наличие имени является только одно поле -username.

data class User(    val username: String?) {    fun hasUsername(): Boolean = !username.isNullOrBlank()}

Когда состояние может быть калькулируемым - вычисляйте его по требованию. Не стоит дублировать понятия, очень просто выстрелить себе в ногу на ровном месте. Исключением могут быть случаи, когда операция вычисления занимает длительное время либо требует кэширование состояния. В таких ситуациях можно воспользоваться иными практиками для определения статуса:

  • вычислить сразу либо заленивить состояние

data class User(    val username: String?) {    val hasUsername: Boolean = !username.isNullOrBlank()    val hasUsernameLazy: Boolean by lazy { !username.isNullOrBlank() }}
  • вынести вычисление в утилитарный класс. Используйте только в случае тяжеловесности операции

class UsernameHelper {    private val cache: MutableMap<User, Boolean> = WeakHashMap()        fun hasUsername(user: User): Boolean = cache.getOrPut(user) {       !user.username.isNullOrBlank()     }}

Абстракции - не лишнее

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

Ключи для 3rd party services получаем из backend. Клиент долженсохранитьэти ключи для дальнейшего использования в приложении.

// ...val result = remoteService.getConfig()if (result is Result.Success) {  val remoteConfig = result.value.clientConfig?.keys  for (localConfigKey: ConfigKey in configKeyProvider.getConfigKeys()) {    sharedPreferences.edit { putString(localConfigKey.key, remoteConfig[localConfigKey.key]) }    }}//...enum class ConfigKey(val key) {  FACEBOOK("facebook"),  MAPBOX("mapbox"),  THIRD_PARTY("some_service")}

Спустя N недель получаем предупреждение от службы безопасности, что ключи сервисаTHIRD_PARTYни в коем случае нельзя хранить на диске устройства. Не страшно, можем спокойно хранить ключи хранить InMemory. И по такой же стратегии нужно затронуть еще 20 компонентов приложения. Хм, и как поможет абстракция?

Завернем под абстракцию хранлище ключей и создадим имплементацию: InMemory / SharedPreferences / Database / WeakInMemory А дальше с помощью внедрения зависимостей. Таким образом мы не нарушимSOLID - в нашем примере актором будет являться алгоритм сбора данных, но не способ хранения; open-closed principle достигается тем, что мы "прикрываем" необходимость модификации алгоритма за счет абстракции.

// ...val result = remoteService.getConfig()if (result is Result.Success) {  val remoteConfig = result.value.clientConfig?.keys  for(localConfigKey: ConfigKey in configKeyProvider.getConfigKeys()) {    configurationStorage.put(        configKey = localConfigKey,         keyValue = remoteConfig[localConfigKey.key]      )  }}//....interface ConfigKeyStorage {   fun put(configKey: ConfigKey, keyValue: String?)   fun get(configKey: ConfigKey): String   fun getOrNull(configKey: ConfigKey): String?}internal class InMemoryConfigKeyStorage : ConfigKeyStorage {private val storageMap: MutableMap<ConfigKey, String?> = mutableMapOf()  override fun put(configKey: ConfigKey, keyValue: String?) {    storageMap[configKey] = keyValue}  override fun get(configKey: ConfigKey): String =       requireNotNull(storageMap[configKey])override fun getOrNull(configKey: ConfigKey): String? =       storageMap[configKey]}

Если помните, в изначальной постановке задачи не стояло уточнение о типе хранилища данных. Подготавливаем систему к изменениям, где имплементация может быть различной и никак не влияет на детали алгоритма сбора данных. Даже если в изначальных требованиях и были бы уточнения по типу хранилища - это повод для того, чтобы усомниться и перестраховаться. Вместо того, чтобы влезать в N компонентов для модификации типа хранилища, можно добиться этого с помощью замены источника данных через DI/IoC и быть уверенным, что все работает исправно. Так же, такой код проще тестировать.

Описывайте состояния явно

Программисты ленивые люди. Я нахожу в этом свои плюсы, что такая профессиональная деформация позволяет делать расслабленные решения которые будут понятны каждому(в любом состоянии). Часто, самое простое и быстрое решение задачи - правильное решение. Но вот воспринимать это слишком буквально не стоит. Не смотреть в завтрашний день чревато тем, что через неделю ты будешь стахановцем без привычного смузи.

В очередной раз возьмем пример технического задания:

Подготовить репозиторий для вывода имени пользователя на экран

Создадим репозиторий, который будет возвращать имя пользователя. Выведемnullв случае, если не смогли получить имя. Так как в первоначальном задании не шло речи о том, откуда нам нужно брать данные - оставим дело за абстракцией и заодно создадим наивное решение для получения из remote.

interface UsernameRepository {    suspend fun getUsername(): String?}class RemoteUsernameRepository(    private val remoteAPI: RemoteAPI) : UsernameRepository {    override suspend fun getUsername(): String? = try {        remoteAPI.getUsername()    } catch (throwable: Throwable) {        null    }}

Мы создали контракт получения имени пользователя, где в качестве успeшного результата приходит состояниеString?и в случае провала полученияString?. При чтении кода, нет ничего подозрительного. Мы можем определить состояние ошибки простым условиемgetUsername() == nullи все будут счастливы. По факту, мы не имеем состояния провала. По контрактуSuccessState === FailState.

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

После уточнения тех задания, мы должны сделать развилку. Распишем развилку абстрактно и подсветим проблемное место:

interface UsernameRepository {    suspend fun getUsername(): String?}class CommonUsernameRepository(  private val remoteRepository: UsernameRepository,  private val localRepository: UsernameRepository) : UsernameRepository {    suspend fun getUsername(): String? {        return remoteRepository.getUsername() ?: localRepository.getUsername()    }}

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

  • верно ли утверждать, что результатnull- имя пользователя? Обязательных условий мы не имеем. Все легально.

  • верно ли утверждать, что результатnull- состояние из кэша?

  • верно ли утверждать, что результатnull- состояние ошибки удаленного узла при пустом кэше?

Однозначного ответа нет, так как наш контракт не подразумевает этого. Да и по требованиям от нас этого не требуют. А зачем нужно явно декларировать состояния для системы, если важен результат? Смежные состояния при одинаковых результатах - русло неустойчивости узла к изменениям. Избегайте неоднозначности. Новые требования, которые затронут хотя бы один смежный статус, потребуют рефакторинга корневой модели. Изменения в начальной абстракции - это очень страшно. Это чревато тем, что вам придется переписать все известные имплементации.

В случае получения ошибки - изменить цвет имени на экране.

Используйтеenum/sealed classes/interfaces/abstract classes. Техника выведения абстракций зависит от изначальных условий проекта. Если вам важна строгость в контрактах и вы хотите закрыть возможность произвольного расширения -enum/sealed classes. В противном случае -interface/abstract classes.

sealed class UsernameState {data class Success(val username: CharSequence?) : UsernameState()  object Failed : UsernameState()}

When может не хватить

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

enum class NavigationFlow {  PIN_CODE,  MAIN_SCREEN,  ONBOARDING,  CHOOSE_LANGUAGE}fun detectNavigationFlow(): NavigationFlow {    return when {        authRepo.isAuthorized() -> NavigationFlow.PIN_CODE        languageRepo.defaultLanguage != null -> NavigationFlow.CHOOSE_LANGUAGE        onboardingStorage.isCompleted() -> NavigationFlow.MAIN_SCREEN        else -> NavigationFlow.ONBOARDING    }}

Мы определили возможные состояния навигации. Так проще будет реализовать навигатор. Но вотdetectNavigationFlowстал слишком много знать. Разломать функцию могут следующие события: добавить новое состояние, изменить приоритет или когда используемый репозиторий станет устаревшим Постараемся создать такую функцию, в которой основной участок будет неизменен, а шаги можно будет легко заменить.

enum class NavigationFlow {    PIN_CODE,    MAIN_SCREEN,    ONBOARDING,    CHOOSE_LANGUAGE}// Описываем возможные состояния явноsealed class State {    data class Found(val flow: NavigationFlow) : State()    object NotFound : State()}interface NavigationFlowProvider {    // Возвращаем не null NavigationFlow чтобы гарантировать проход на следующий экран    fun getNavigation(): NavigationFlow}// Абстракция для поиска подходящего флоу для навигацииinterface NavigationFlowResolver {    fun resolveNavigation(): State}internal class SplashScreenNavigationFlowProvider(    // Sequence - для того чтобы прервать итерации при нахождении первого подходящего условия.    // Обратите внимание на очередность экземляров класса в последовательности.    private val resolvers: Sequence<NavigationFlowResolver>) : NavigationFlowProvider {    override fun getNavigation(): NavigationFlow = resolvers        .map(NavigationFlowResolver::resolveNavigation)        .filterIsInstance<State.Found>()        .firstOrNull()?.flow        // Если ничего не нашли - проход в состояние неизвестности        ?: NavigationFlow.MAIN_SCREEN}

Заменяем N-условныйwhenнаChainOfResponsibililty. На первый взгляд выглядит сложным: кода стало больше и алгоритм чуть сложнее. Перечислим плюсы подхода:

  1. Знакомый паттерн из ООП

  2. Соответствует правилам SOLID

  3. Прост в масштабировании

  4. Прост в тестировании

  5. Компоненты резолвера независимы, что никак не повлияет на структуру разработки

Главное в таком подходе - масштабируемость и независимость компонентов. Нам позволено сколь угодно наращивать систему не боясь за общую обработку. Каждый элемент резолвера заменим в любой момент. Новый компонент легко вставить в последовательность, нужно только следить за очередностью вызова. Независимые компоненты упрощают жизнь в динамике. С помощьюDIPкаждому компоненту системы доступно быть сколь угодно сложным, что никак не скажется на общем алгоритме.

Наследование или композиция

Вопрос по этой теме поднимался ни один миллионраз. Я не буду останавливаться на подробностях, детали о проблемах можете почитать на просторах google. Хочу затронуть тему платформы, когда причина избыточного использования наследования - "платформа". Разберем на примерах компонентов Android.

BaseActivity. Заглядывая в старые прокты, с ужасом наблюдаю, какую же ошибку мы допускали. Под маской повторного использования смело добавляли частные случаи в базовую активити. Шли недели, активити обрастали общими прогрессбарами, обработчиками и пр. Проходят месяцы, поступают требования - на экране N прогрессбар должен отличаться от того, что на всех других От общей активити отказаться уже не можем, слишком много она знает и выполняет. Добавить новый прогрессбар как частный случай - выход, но в базовом будет оставаться рудимент и это будет нечестное наследование. Добавить вариацию вBaseActivity- обидеть других наследников и Через время вы получаете монстра в > 1000 строк, цена внесения изменений в который слишком велика. Да и не по SOLID это все.

Агаок, но мне нужно использовать компоненту, которая точно будет на всех экранах кроме 2х. Что делать?

Не проблема, Android SDK еще с 14 версиипредоставили такую возможность.Application.ActivityLifecycleCallbacksоткрывает нам простор на то, чтобы переопределять элементы жизненного цикла любойActivity. Теперь общие случаи можно вынести в обработчик и разгрузить базовый класс.

class App : Application(), KoinComponent {    override fun onCreate() {        super.onCreate()        // ...         registerActivityLifecycleCallbacks(SetupKoinFragmentFactoryCallbacks())    }    // Подключаем Koin FragmentFactory для инициализации фрагментов с помощью Koin    private class SetupKoinFragmentFactoryCallbacks : EmptyActivityLifecycleCallbacks {        override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {            if (activity is FragmentActivity) {                activity.setupKoinFragmentFactory()            }        }    }}

К сожалению, не всегда возможно отказаться от базовой активити. Но можно сделать ее простой и лаконичной:

abstract class BaseActivity(@LayoutRes contentLayoutId: Int = 0) : AppCompatActivity(contentLayoutId) {    // attachBaseContext по умолчанию protected    override fun attachBaseContext(newBase: Context) {        // добавляем extension для изменения языка на лету        super.attachBaseContext(newBase.applySelectedAppLanguage())    }}

BaseFragment. С фрагментами все тоже самое. ИзучаемFragmentManager, добавляемregisterFragmentLifecycleCallbacks- профит. Чтобы проброситьFragmentLifecycleCallbacksдля каждого фрагмента - используйте наработки из предыдущих примеров сActivty. Пример на базе Koin -здесь.

Композиция и фрагменты. Для передачи объектов можем использовать инъекции DIP фреймворков - Dagger, Koin, свое и т.д. А можем отвязаться от фрейморков и передать их в конструктор. ЧТОООО? Типичный вопрос с собеседования - Почему нельзя передавать аргументы в конструктор фрагмента? До5 ноября 2018 года было именно так, теперь же естьFragmentFactoryи это стало легально.

BaseApplication. Здесь чуть сложнее. Для разныхFlavorsиBuildTypeнеобходимо использовать базовыйApplicationдля возможности переопределения компонентов для других сборок. Как правило,Applicationстановится большим, потому что на старте приложения, необходимо проинициализировать большое количество 3rd party библиотек. Добавим к этому и список своих инициализаций и вот мы на пороге того момента, когда нам нужно разгрузить стартовую точку.

interface Bootstrapper {    // KoinComponent - entry point DIP для возможности вызвать инъекции зависимостей в метод     fun init(component: KoinComponent)}interface BootstrapperProvider {    fun provide(): Set<Bootstrapper>}class BootstrapperLauncher(val provider: BootstrapperProvider) {    fun launch(component: KoinComponent) {        provider.provide().onEach { it.init(component) }    }}class App : Application() {  override fun onCreate() {        super.onCreate()        // Вызываем бутстраппер после инициализации Koin        this.get<BootstrapperLauncher>().launch(component = this)    }}

Разгружаем килотонны методов в разныеBootstrapperинстансы и делаем наш код чище. Либо можем воспользоваться нативным решением отзеленого робота.

Уменьшение области видимости

Инкапсуляция - один из немаловажных моментов в ООП парадигме. Современные языки программирования не просто так содержат в себе модификаторы доступа, которые ограничивают скоуп видимости сигнатур. Уменьшение простора на использование строк кода поддержано на уровне компилятора. Это отличная защита от (дурака)того, что код изначально написан так, чтобы его нельзя было модифицировать. В противном случае, он не скомпилируется. На практике встречаются случаи, когда ограничения касаются только внутренних состояний и поведений объекта - приватные функции, а что насчет самого объекта?

Выделим отдельный модуль, который будет содержать в себе объекты валидации входящих состояний.

interface Validator {    fun validate(contact: CharSequence): ValidationResult}sealed class ValidationResult {    object Valid : ValidationResult()    data class Invalid(@StringRes val errorRes: Int) : ValidationResult()}class PhoneNumberValidator : Validator {    override fun validate(contact: CharSequence): ValidationResult =        if (REGEX.matches(contact)) ValidationResult.Valid         else ValidationResult.Invalid(R.string.error)    companion object {        private val REGEX = "[0-9]{16}".toRegex()    }}

А разве плохо иметь публичный класс, который будет доступен всем? Но избыточное использование публичных сущностей по умолчанию означает, что объект данного класса может использоваться каждым. Возникает желание внести изменения для личных нужд не задумываясь о последствиях. Если вы не обезопасились методами, которые не пропустят "сломанный" код в рабочую среду, ждите бага.

Пришло обновление задачи, когда на экране N вместоMSISDNнеобходимо использоватьE.164:

class PhoneNumberValidator : Validator {    override fun validate(contact: CharSequence): ValidationResult =        if (REGEX.matches(contact)) ValidationResult.Valid         else ValidationResult.Invalid(R.string.error)    companion object {        private val REGEX = "+[0-9]{16}".toRegex()    }}

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

С одной стороны, проблема надуманная и обойти ее можно было:

  • создать новый валидатор

  • создать валидатор с регексом по умолчанию и передать аргумент для частного случая

  • наследование и переопределение

  • другой подход

А теперь, давайте посмотрим на код, если бы мы изначально забетонировали MSISDN валидатор и вынесли бы его в бинарь.

interface Validator {    fun validate(contact: CharSequence): ValidationResult}sealed class ValidationResult {    object Valid : ValidationResult()    data class Invalid(@StringRes val errorRes: Int) : ValidationResult()}internal class MSISDNNumberValidator : Validator {//... код выше}internal class E164NumberValidator : Validator {//... код выше}

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

interface ValidatorFactory {    fun create(type: ValidatorType): Validator?    interface ValidatorType    companion object {        fun create() : ValidatorFactory {            return DefaultValidatorFactory()        }    }}object MSISDN : ValidatorFactory.ValidatorTypeobject E164 : ValidatorFactory.ValidatorTypeprivate class DefaultValidatorFactory : ValidatorFactory {    override fun create(type: ValidatorFactory.ValidatorType): Validator? = when(type) {        is MSISDN -> MSISDNValidator()        is E164 -> E164Validator()        else -> null    }}

Собираем кубик, пакуем в бинарь и отдаем в использование. По мере необходимости код открыт для расширения, но закрыт для модификации. Если вы захотите добавить свою валидацию - смело можете создатьValidatorFactoryс фолбэком наDefaultValidatorFactory. Или выпустить новый патч.

Заключение

В общем случае, при проектировании систем, я руководствуюсь правилам SOLID. Про эти принципы говорят не первый десяток лет из каждого утюга, но они все еще актуальны. Местами система выглядит избыточной. Стоит ли заморачиваться насчет сложности и стабильности дизайна кода? Решать вам. Однозначного ответа нет. Определиться вы можете в любой момент. Желательно - на зачаточном этапе. Если вам стало понятно, что ваш проект может жить более чем полгода и он будет расти - пишите гибкий код. Не обманывайте себя, что это все оверинженерия. Мобильных приложений с 2-3 экранами уже давно нет. Разработка под мобильные устройства уже давно вошла в разряд enterprise. Быть маневренным - золотой навык. Ваш бизнес не забуксует на месте и поток запланнированных задач реже станет оставать от графика.

Подробнее..

Бесплатные онлайн-мероприятия по разработке (20 октября 29 октября)

18.10.2020 14:09:04 | Автор: admin

Нажимайте на интересующую вас тему и откроется подробная информация о мероприятии.

20 октября, Вторник

Flutter vs технология, на которой пишите вы: за чем будущее?
Flutter vs технология, на которой пишете вы: за чем будущее?Flutter vs технология, на которой пишете вы: за чем будущее?

Flutter vs технология, на которой пишете вы: за чем будущее?

20 октября, начало в 18:00, Вторник

  • Что такое Flutter

  • Какие у Flutter перспективы

  • Какие проекты можно делать на Flutter

  • С чего начать, чтобы освоить эту технологию

  • Как сменить привычный стек и перейти во Flutter

  • Как начать писать на Flutter, если ты никогда не был разработчиком, но очень хочешь им стать

  • Где и как Flutter-разработчики находят высокооплачиваемую работу

Страница мероприятия

QA Meeting Point 2020
QA Meeting Point 2020QA Meeting Point 2020

QA Meeting Point 2020

20 октября, 12:0017:00, Суббота

  1. Топ-10 вредных советов и к чему они могут привести - Ирина Ушакова, Senior QA Engineer, EPAM. Как на хороших конференциях по тестированию могут звучать спорные советы. Как делать точно не надо, и почему.

  2. Отлавливаем баги в коде и рабочем процессе - Елена Гурова, Team Lead QA, Usetech.

  3. Качество релизов ответственность команды - Людмила Малеева, QA engineer, Miro. С помощью каких инструментов построить процессы, чтобы релизить с большим количеством команд и монорепозиторием.

  4. Автоматизация тестирования мобильного приложения Яндекс.Деньги - Александр Наташкин, руководитель мобильного тестирования, Яндекс.Деньги. Какие подходы и инструменты сработали, а какие не очень, и как менялись технические потребности с ростом команды. Об автотестах, разработке фреймворков и настройке CI.

  5. Круглый стол

Страница мероприятия

DATA SCIENCE
DATA SCIENCEDATA SCIENCE

DATA SCIENCE

20 октября, 11:00-19:00, Вторник

  1. Фрактальность функции потерь, эффект двойного спуска и степенные законы в глубинном обучении - фрагменты одной мозаики - Дмитрий Ветров, профессор-исследователь

  2. Инфраструктура извлечения факторов в проде - Михаил Трофимов, ML Engineer

  3. Питонись на отличненько - Михаил Свешников, ML Architect

  4. Новинки CATBOOST: поддержка эмбеддингов, обучение на spark и это еще не всё! - Станислав Кириллов, руководитель группы ML систем

  5. Инструменты визуалзиации в NLP: от графов знаний до BERT - Валентин Малых, Senior Research Scientist

  6. "Классическое" машинное обучение на табличных данных - Александр Фонарев, data scientist

  7. Эмбеддинги графов без учителя - Антон Цицулин, студент-исследователь в Google

  8. Что такое "быстрый код"? - Николай Марков, Principal Architect

  9. Семантический поиск в индексе с миллионами документов на основе BERT - Павел Гончаров, Андрей Хобня

Страница мероприятия

22 октября, Четверг

HOT MOBILE: iOS, Android, Flutter
HOT Mobile: iOS, Android, FlutterHOT Mobile: iOS, Android, Flutter

HOT Mobile: iOS, Android, Flutter

22 октября, начало в 18:00 (МСК), Четверг

  1. Flutter for web - Андрей, Flutter-разработчик. Для чего использовать Flutter в вебе и как это работает. Что нужно знать о dart null safety.

  2. MotionLayout & ConstraintLayout 2.0 - Артур, ведущий iOS- и Android-разработчик. Ключевые идеи MotionLayout. Функции ConstraintLayout 2.0. Сценарии использования, инструменты, возможные проблемы с анимацией.

  3. Объективная оценка - Вячеслав, iOS-разработчик. Как оценивать. Из чего состоит идеальная оценка. Сбор требований и подготовка.

  4. Как строить карьеру и ни о чем не жалеть - Дмитрий, руководитель направления мобильной разработки. Культура разработки. Командное взаимодействие, которое недооценивают. Как быстро нарастить свои компетенции.

Страница мероприятия

Webinar for juniors Izhevsk #2 (Kotlin)
Webinar for juniors Izhevsk #2Webinar for juniors Izhevsk #2

Webinar for juniors Izhevsk #2

22 октября, начало в 17:00 (МСК), Четверг

  1. Kotlin - Java, которую мы заслужили - Илья Исахин, Senior Software Engineer, EPAM. Попробуем посмотреть на Kotlin поближе, сравнить его с Java, познакомиться с историей языка, понять, почему этот язык стал таким популярным и поиграться с его фичами!

  2. Q&A session

Страница мероприятия

24 октября, Суббота

Hot Frontend
Hot FrontendHot Frontend

Hot Frontend

  1. Как не оставаться джуном 7 лет: история нескольких факапов - Владислав, frontend-разработчик. Что мешает быть продуктивным. Как войти в поток. Как не растратить все ресурсы во время рабочего дня.

  2. Анимация с помощью JS - Елена, frontend-разработчик. Какой подход выбрать: CSS vs JS. Библиотека GreenSock. Работа с React. Разбор примеров.

  3. Локализация приложения в 2020 - Алексей, frontend-разработчик, техлид. Как можно локализовать приложение. Как это сделать более правильно. О чем не стоит забывать при локализации.

  4. Интерактивная схема зала - Дмитрий, frontend-разработчик. Особенности разработки гибкой, кроссбраузерной и удобной для встраивания схемы. Реализация алгоритма плавного zoom и scrolling при большом объеме данных.

Страница мероприятия

Online QA Meetup
Online QA MeetupOnline QA Meetup

Online QA Meetup

24 октября, 12:0017:00 (МСК), Суббота

12:00 Автоматизация тестирования. Библиотека PLAT-ON - Дмитрии Хащинин, Специалист филиала, NeoFlex

12:45 Как манипулировать разработчиком с помощью баг-репортов - Карина Волынская, Manual QA engineer Evrone

13:30 У тебя есть тест кейсы? Лучше! У меня есть карта: декомпозиция проекта и чек листы, как альтернатива тест кейсам - Анатолий Жукович, QA-специалист, Neoflex

14:15 Как эффективно тестировать ETL системы вручную? - Наталья Яхина, Senior QA Engineer at DataArt

15:00 Что обычно оценивают QA? - Татьяна Суходолова, QA Lead Evrone

Страница мероприятия

29 октября, Четверг

Hot Java
Hot JavaHot Java

Hot Java

  1. Project Reactor. Реактивное программирование - Александр, Java TechLead. Асинхронное программирование, зачем оно нужно и когда его правильно использовать. Java Reactive Stream Initiative. Project Reactor (+Webflux) как реализация Java Reactive Stream Initiative.

  2. Шедулеры в микросервисной среде - Валерия, Senior Java-разработчик. Есть ли жизнь (и шедулеры) после нескольких инстансов. Как запустить шедулер и не привлекать к этому весь департамент. Как не оплатить один заказ несколько раз и не заспамить шефа.

  3. Облачный Telegram-бот без проблем на примере Amazon Lambda - Алексей, архитектор, ведущий Java-разработчик. Как развернуть телеграм-бота и автоматически масштабировать на любую нагрузку. Как настроить автоматическую сборку проекта в облаке. Что такое Amazon free tier и как не потерять деньги.

Страница мероприятия

Подробнее..

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

16.03.2021 18:09:12 | Автор: admin

Меня зовут Дмитрий Макаренко, я Mobile QA Engineer в Badoo и Bumble: занимаюсь тестированием новой функциональности в наших приложениях вручную и покрытием её автотестами.

За последние два года подход к автоматизации тестирования в нашей компании сильно изменился. Количество людей, активно вовлечённых в разработку тестов, увеличилось с десяти до 40 человек. А любая новая функциональность в приложениях теперь обязательно должна быть покрыта тестами до релиза.

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

В подготовке текста мне помогал мой коллега Виктор Короневич: с этой темой мы вместе выступали на конференции Heisenbug.

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

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

Спойлер

В конце статьи будет ссылка на тестовый проект со всеми практиками.

Практика 4. Верификация изменения состояния элементов

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

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

Таким образом, прежде чем начать проверять элементы, нам необходимо дождаться их появления на экране. Естественно, эта проблема не нова и существуют стандартные решения. Например, в Selenium это различные типы методов wait, а в Calabash метод wait_for.

Зачем нам свой велосипед, или Почему мы отказались от стандартного решения

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

После того как мы добавили логи и проанализировали их, оказалось, что эти зависания были связаны с реализацией метода wait_for, входящего в состав фреймворка Calabash. wait_for использует метод timeout модуля Ruby Timeout, который реализован на глобальном потоке. А тесты зависали, когда этот метод timeout использовался вложено в других методах: наших и фреймворка Calabash.

Например, рассмотрим прокрутку страницы профиля до кнопки блокировки пользователя.

def scroll_to_block_button  wait_for(timeout: 30) do    ui.scroll_down    ui.wait_until_no_animation    ui.element_displayed?(BLOCK_BUTTON)  endend

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

Рассмотрим реализацию метода wait_until_no_animation.

def wait_until_no_animation  wait_for(timeout: 10) do    !ui.any_element_animating?  endend

Метод wait_until_no_animation реализован так же с wait_for. Он ждёт, когда на экране закончится анимация. Получается, что wait_for, вызванный внутри wait_for, вызывает другие методы. Представьте себе, что вызовы wait_for также есть внутри методов Calabash. С увеличением цепочки wait_for внутри wait_for внутри wait_for риск зависания увеличивается. Поэтому мы решили отказаться от использования этого метода и придумать своё решение.рый бы повторял проверку до тех пор, пока не выполнится заданное условие либо пока не истечёт отведённое время. Если проверка не проходит успешно за отведённое время, наш метод должен выбрасывать ошибку.

Сначала мы создали модуль Poll с одним методом for, который повторял стандартный метод wait_for. Со временем собственная реализация позволила нам расширять функциональность модуля по мере того, как у нас появлялась такая необходимость. Мы добавили методы, ожидающие конкретные значения заданных условий. Например, Poll.for_true и Poll.for_false явно ожидают, что исполняемый код вернёт true либо false. В примерах ниже я покажу использование разных методов из модуля Poll.

Также мы добавили разные параметры методов. Рассмотрим подробнее параметр return_on_timeout. Его суть в том, что при использовании этого параметра наш метод Poll.for перестаёт выбрасывать ошибку, даже если заданное условие не выполняется, а просто возвращает результат выполнения проверки.

Предвижу вопросы Как это работает? и Зачем это нужно?. Начнём с первого. Если в методе Poll.for мы будем ждать, пока 2 станет больше, чем 3, то мы всегда будем получать ошибку по тайм-ауту.

Poll.for { 2 > 3 }> WaitError

Но если мы добавим наш параметр return_on_timeout и всё так же будем ждать, пока 2 станет больше, чем 3, то после окончания тайм-аута, 2 всё ещё не станет больше, чем 3, но наш тест не упадёт, а метод Poll.for вернёт результат этой проверки.

Poll.for(return_on_timeout: true) { 2 > 3 }> false

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

Варианты изменения состояния элементов

А теперь перейдём к самому интересному поговорим о том, как проверять различные изменения состояния и какие изменения состояния вообще существуют. Познакомьтесь с нашим объектом тестирования чёрным квадратом:

Он умеет всего две вещи: появляться на экране и пропадать с экрана.

Первый вариант изменения состояния называется Должен появиться. Он происходит в том случае, когда состояние 1 на экране нет нашего объекта тестирования, а состояние 2 он должен появиться.

Должен появитьсяДолжен появиться

Если он появляется, то проверка проходит успешно.

Второй вариант изменения состояния называется Должен пропасть. Происходит он тогда, когда в состоянии 1 отображается наш объект тестирования, а в состоянии 2 его быть не должно.

 Должен пропасть Должен пропасть

Третий вариант не такой очевидный, как первые два, потому что в нём, по сути, мы проверяем неизменность состояния. Называется он Не должен появиться. Это происходит, когда в состоянии 1 наш объект тестирования не отображается на экране и спустя какое-то время в состоянии 2 он всё ещё не должен появиться.

 Не должен появиться Не должен появиться

Вы, наверное, догадались, какой вариант четвёртый. Он называется Не должен пропасть. Происходит это, когда в состоянии 1 объект отображается на экране, и спустя какое-то время в состоянии 2 он всё ещё находится там.

Не должен пропастьНе должен пропасть

Реализация проверок разных вариантов

Мы зафиксировали все возможные варианты изменения состояния элементов. Как же их проверить? Разобьём реализацию на проверки первых двух вариантов и проверки третьего и четвёртого.

В случае с первыми двумя вариантами всё довольно просто. Для проверки первого нам просто нужно подождать, пока элемент появится, используя наш метод Poll:

# вариант "Должен появиться"Poll.for_true { ui.elements_displayed?(locator) }

Для проверки второго подождать, пока элемент пропадёт:

# вариант "Должен пропасть"Poll.for_false { ui.elements_displayed?(locator) }

Но в случае с третьим и четвёртым вариантами всё не так просто.

Рассмотрим вариант Не должен появиться:

# вариант "Не должен появиться"ui.wait_for_elements_not_displayed(locator)actual_state = Poll.for(return_on_timeout: true) { ui.elements_displayed?(locator) }Assertions.assert_false(actual_state, "Element #{locator} should not appear")

Здесь мы, во-первых, фиксируем состояние отсутствия элемента на экране.

Далее, используя Poll.for с параметром return_on_timeout, мы ждём появления элемента. При этом метод Poll.for не выбросит ошибку, а вернёт false, если элемент не появится. Значение, полученное из Poll.for, сохраняется в переменной actual_state.

После этого происходит проверка неизменности состояния элемента с использованием метода assert.

Для проверки варианта Не должен пропасть мы используем похожую логику, ожидая пропажи элемента с экрана вместо его появления:

# вариант "Не должен пропасть"ui.wait_for_elements_displayed(locator)actual_state = Poll.for(return_on_timeout: true) { !ui.elements_displayed?(locator) }Assertions.assert_false(actual_state, "Element #{locator} should not disappear")

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

def verify_dynamic_state(state:, timeout: 10, error_message:)  options = {    return_on_timeout: true,    timeout:           timeout,  }  case state    when 'should appear'      actual_state = Poll.for(options) { yield }      Assertions.assert_true(actual_state, error_message)    when 'should disappear'      actual_state = Poll.for(options) { !yield }      Assertions.assert_true(actual_state, error_message)    when 'should not appear'      actual_state = Poll.for(options) { yield }      Assertions.assert_false(actual_state, error_message)    when 'should not disappear'      actual_state = Poll.for(options) { !yield }      Assertions.assert_false(actual_state, error_message)    else      raise("Undefined state: #{state}")  endend

yield это код блока, переданного в данный метод. На примерах выше это был метод elements_displayed?. Но это может быть любой другой метод, результат выполнения которого отражает состояние необходимого нам элемента. Документация Ruby.

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

Выводы:

  • важно не забывать про все четыре варианта изменения состояния при проверках UI-элементов;

  • полезно вынести эти проверки в общий метод.

Мы рекомендуем использовать полную систему проверок всех вариантов изменения состояния. Что мы имеем в виду? Представьте, что когда элемент есть это состояние true, а когда его нет false.

Состояние 1

Состояние 2

Должен появиться

FALSE

TRUE

Должен пропасть

TRUE

FALSE

Не должен появиться

FALSE

FALSE

Не должен пропасть

TRUE

TRUE

Мы строим матрицу всех комбинаций. При появлении нового состояния таблицу можно расширить и получить новые комбинации.

Практика 5. Надёжная настройка предусловий тестов

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

Рассмотрим два примера. Первый отключение сервиса локации на iOS в настройках. Второй создание истории чата.

В первом примере реализация метода отключения сервиса локации на iOS выглядит следующим образом:

def switch_off_location_service  ui.wait_for_elements_displayed(SWITCH)  if ui.element_value(SWITCH) == ON    ui.tap_element(SWITCH)    ui.tap_element(TURN_OFF)  endend

Мы ждём, пока переключатель (элемент switch) появится на экране. Потом проверяем его состояние. Если оно не соответствует ожидаемому, мы его изменяем.

После этого мы закрываем настройки и запускаем приложение. И иногда внезапно сталкиваемся с проблемой: почему-то сервис локации остаётся включённым. Как это получается? Мы же сделали всё, чтобы его отключить. Кажется, что это проблема работы системных настроек в iOS системе. При быстром выходе из настроек (а тест делает это моментально после нажатия на переключатель) их новое состояние не сохраняется. Но проблемы могут возникнуть и при настройке предусловий в нашем приложении.

Давайте обратимся ко второму примеру созданию истории чата перед началом выполнения теста. Реализация метода выглядит следующим образом:

def send_message(from:, to:, message:, count:)  count.times do    QaApi.chat_send_message(user_id: from, contact_user_id: to, message: message)  endend

Мы используем QAAPI для отправки сообщений по user_id. В цикле мы отправляем необходимое количество сообщений.

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

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

Как же решить эту проблему? Мы можем добавить гарантию выполнения действия в методы установки предусловий.

Тогда наш метод отключения сервиса локации будет выглядеть следующим образом:

def ensure_location_services_switch_in_state_off  ui.wait_for_elements_displayed(SWITCH)  if ui.element_value(SWITCH) == ON    ui.tap_element(SWITCH)    ui.tap_element(TURN_OFF)    Poll.for(timeout_message: 'Location Services should be disabled') do      ui.element_value(SWITCH) == OFF    end  endend

Используя метод Poll.for, мы убеждаемся, что состояние переключателя изменилось, прежде чем переходить к следующим действиям теста. Это позволяет избежать проблем, вызванных тем, что сервис локации время от времени был включён.

Во втором примере нам снова помогут наши методы QAAPI.

def send_message(from:, to:, message:, count:)  actual_messages_count = QaApi.received_messages_count(to, from)  expected_messages_count = actual_messages_count + count  count.times do    QaApi.chat_send_message(user_id: from, contact_user_id: to, message: message)  end  QaApi.wait_for_user_received_messages(from, to, expected_messages_count)end

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

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

В качестве общей рекомендации советуем добавлять в ваши тесты проверку выставления любых предустановок, когда выполняются асинхронные действия.

Более подробно о проблемах, описанных в этом разделе, можно прочитать в статье Мартина Фаулера.

Практика 6. Простые и сложные действия, или Независимость шагов в тестах

Простые действия

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

Начнём с теста поиска и отправки GIF-сообщений.

Сначала нам нужно открыть чат с пользователем, которому мы хотим отправить сообщение:

When  primary_user opens Chat with chat_user

Потом открыть поле ввода GIF-сообщений:

And   primary_user switches to GIF input source

Далее нам нужно ввести поисковый запрос для GIF-изображений, убедиться, что список обновился, отправить понравившееся изображение из списка и убедиться, что оно было отправлено.

And   primary_user searches for "bee" GIFsAnd   primary_user sends 7th GIF in the listThen  primary_user verifies that the selected GIF has been sent

Целиком сценарий выглядит так:

Scenario: Searching and sending GIF in Chat  Given users with following parameters    | role         | name |    | primary_user | Dima |    | chat_user    | Lera |  And   primary_user logs in  When  primary_user opens Chat with chat_user  And   primary_user switches to GIF input source  And   primary_user searches for "bee" GIFs  And   primary_user sends 7th GIF in the list  Then  primary_user verifies that the selected GIF has been sent

Обратим внимание на шаг, который отвечает за поиск гифки:

And(/^primary_user searches for "(.+)" GIFs$/) do |keyword|  chat_page = Pages::ChatPage.new.await  TestData.gif_list = chat_page.gif_list  chat_page.search_for_gifs(keyword)  Poll.for_true(timeout_message: 'Gif list is not updated') do    (TestData.gif_list & chat_page.gif_list).empty?  endend

Здесь, как и почти во всех остальных шагах, мы делаем следующее:

  1. сначала ожидаем открытия нужной страницы (ChatPage);

  2. потом сохраняем список всех доступных GIF-изображений;

  3. далее вводим ключевое слово для поиска;

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

Кажется, что всё реализовано правильно. После завершения поиска мы убеждаемся, что список изображений обновился, и только после этого отправляем одно из них. Но у нас появится проблема, если мы, например, захотим написать тест, проверяющий, что после ввода идентичного поискового запроса список изображений не обновится. В этом случае нам придётся создавать отдельный шаг для ввода поискового запроса для GIF-изображений, который во многом будет дублировать уже имеющийся.

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

Как же нам этого избежать? Как вы, возможно, заметили, наш шаг поиска GIF-изображений на самом деле включал в себя три действия:

  1. сохранение текущего списка;

  2. поиск;

  3. проверку обновления списка.

Решением проблемы переиспользования будет разделение этого шага на три простых и независимых.

Первый шаг сохраняет текущий список изображений:

And(/^primary_user stores the current list of GIFs$/) do  TestData.gif_list = Pages::ChatPage.new.await.gif_listend

Второй шаг поиск гифки позволяет напечатать ключевое слово для поиска:

And(/^primary_user searches for "(.+)" GIFs$/) do |keyword|  Pages::ChatPage.new.await.search_for_gifs(keyword)end

На третьем шаге мы ждём обновления списка:

And(/^primary_user verifies that list of GIFs is updated$/) do  chat_page = Pages::ChatPage.new.await  Poll.for_true(timeout_message: 'Gif list is not updated') do    (TestData.gif_list & chat_page.gif_list).empty?  endend

В итоге наш первоначальный сценарий выглядит следующим образом:

Scenario: Searching and sending GIF in Chat  Given users with following parameters    | role         | name |    | primary_user | Dima |    | chat_user    | Lera |  And   primary_user logs in  When  primary_user opens Chat with chat_user  And   primary_user switches to GIF input source  And   primary_user stores the current list of GIFs  And   primary_user searches for "bee" GIFs  Then  primary_user verifies that list of GIFs is updated  When  primary_user sends 7th GIF in the list  Then  primary_user verifies that the selected GIF has been sent

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

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

Сложные действия

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

Мини-игра это экран, на котором пользователю предлагаются профили других людей, которые ему написали. Можно либо отвечать на сообщения, либо пропускать этих пользователей. Действие пропуска назовём Голосовать нет.

Тестовый пользовательТестовый пользователь

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

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

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

When(/^primary_user votes No in Messenger mini game (\d+) times$/) do |count|  page = Pages::MessengerMiniGamePage.new.await  count.to_i.times do    page.vote_no  endend

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

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

When(/^primary_user votes No in Messenger mini game (\d+) times$/) do |count|  page = Pages::MessengerMiniGamePage.new.await  count.to_i.times do    progress_before = page.progress    page.vote_no    Poll.for_true do      page.progress > progress_before       end  endend

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

Подведём итоги. Мы создаём независимые шаги для простых действий. Это позволяет легко их переиспользовать и создавать различные сценарии за более короткое время, чем если бы нам нужно было переписывать похожие друг на друга шаги. В шаги для сложных действий мы добавляем проверки того, что действие выполнено, перед переходом к следующим действиям.

Обобщая эти рекомендации, советуем выделять независимые методы для простых действий в тестах и добавлять проверку предустановок в сложные действия.

Практика 7. Верификация необязательных элементов

Под необязательными элементами мы понимаем такие элементы, которые могут либо отображаться, либо не отображаться на одном и том же экране в зависимости от каких-либо условий. Здесь мы рассмотрим пример диалогов о подтверждении действий пользователя, или алёртов (alerts).

Примеры диалоговых оконПримеры диалоговых окон

Наверняка вы сталкивались с подобными диалогами в различных мобильных приложениях. В наших двух приложениях, например, их больше 70. Они появляются в разных местах в ответ на разные действия пользователей. Что же представляют собой необязательные элементы на них?

Проанализируем скриншоты выше.

  • Скриншот 1: заголовок, описание и две кнопки.

  • Скриншот 2: заголовок, описание и одна кнопка.

  • Скриншот 3: описание и две кнопки.

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

Начнём с того, как выглядит вызов метода для верификации каждого из диалогов:

class ClearAccountAlert < AppAlertAndroid  def verify_alert_lexemes    verify_alert(title:        ClearAccount::TITLE,                 description:  ClearAccount::MESSAGE,                 first_button: ClearAccount::OK_BUTTON,                 last_button:  ClearAccount::CANCEL_BUTTON)  endend
class WaitForReplyAlert < AppAlertAndroid  def verify_alert_lexemes    verify_alert(title:        WaitForReply::TITLE,                 description:  WaitForReply::MESSAGE,                 first_button: WaitForReply::CLOSE_BUTTON)  endend
class SpecialOffersAlert < AppAlertAndroid  def verify_alert_lexemes    verify_alert(description:  SpecialOffers::MESSAGE,                 first_button: SpecialOffers::SURE_BUTTON,                 last_button:  SpecialOffers::NO_THANKS_BUTTON)  endend

Во всех примерах мы вызываем метод verify_alert, передавая ему лексемы для проверки необходимых элементов. При этом, как вы можете заметить, WaitForReplyAlert мы не передаём лексему для второй кнопки, так как её не должно быть, а SpecialOffersAlert лексему для заголовка.

Рассмотрим реализацию метода verify_alert:

def verify_alert(title: nil, description:, first_button:, last_button: nil)  ui.wait_for_elements_displayed([MESSAGE, FIRST_ALERT_BUTTON])  ui.wait_for_element_text(expected_lexeme: title, locator: ALERT_TITLE) if title  ui.wait_for_element_text(expected_lexeme: description, locator: MESSAGE)  ui.wait_for_element_text(expected_lexeme: first_button, locator: FIRST_ALERT_BUTTON) ui.wait_for_element_text(expected_lexeme: last_button, locator: LAST_ALERT_BUTTON) if last_buttonend

Сначала мы ожидаем появления на экране обязательных элементов. После этого убеждаемся, что их текст совпадает с переданными в метод лексемами. В случае же с необязательными элементами мы проверяем текст, если лексема была передана в метод, а если нет, то не делаем ничего.

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

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

Для этого в тестах мы меняем проверку

ui.wait_for_element_text(expected_lexeme: title, locator: ALERT_TITLE) if title

на

if title.nil?  Assertions.assert_false(ui.elements_displayed?(ALERT_TITLE), "Alert title should not be displayed")else  ui.wait_for_element_text(expected_lexeme: title, locator: ALERT_TITLE)end

Мы изменили условие if и добавили проверку второго состояния. Если мы не передаём лексему для необязательного элемента, значит, этого элемента не должно быть на экране, что мы и проверяем. Если же в title есть какой-то текст, мы понимаем, что элемент с этим текстом должен быть, и проверяем его. Мы решили выделить эту логику в общий метод, который назвали wait_for_optional_element_text. Этот метод мы можем применять не только для диалогов из этого примера, но и для любых других экранов приложения, на которых есть необязательные элементы. Видим, что if-условие из примера выше полностью находится внутри нового метода:

def wait_for_optional_element_text(expected_lexeme:, locator:)  GuardChecks.not_nil(locator, 'Locator should be specified')  if expected_lexeme.nil?    Assertions.assert_false(elements_displayed?(locator), "Element with locator #{locator} should not be displayed")  else    wait_for_element_text(expected_lexeme: expected_lexeme, locator: locator)  endend

Реализация метода verify_alert тоже изменилась:

def verify_alert(title: nil, description:, first_button:, last_button: nil)  ui.wait_for_elements_displayed([MESSAGE, FIRST_ALERT_BUTTON])  ui.wait_for_optional_element_text(expected_lexeme: title, locator: ALERT_TITLE)  ui.wait_for_element_text(expected_lexeme: description, locator: MESSAGE)  ui.wait_for_element_text(expected_lexeme: first_button, locator: FIRST_ALERT_BUTTON)  ui.wait_for_optional_element_text(expected_lexeme: last_button, locator: LAST_ALERT_BUTTON)end

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

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

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

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

Общие рекомендации

Выделим основные рекомендации по автоматизации тестирования мобильных приложений из семи практик, которые мы описали:

  • так как проверки это то, ради чего мы пишем тесты, всегда используйте полную систему проверок;

  • не забывайте добавлять проверку предустановки для асинхронных действий;

  • выделяйте общие методы для переиспользования однотипного кода как в шагах, так и в методах на страницах;

  • делайте объект тестирования простым;

  • выделяйте независимые методы для простых действий в тестах.

Возможно, эти советы кому-то покажутся очевидными. Но мы хотим обратить ваше внимание на то, что применять их можно (и нужно) в разных ситуациях. Если вы хотите дополнить список другими полезными рекомендациями, добро пожаловать в комментарии!

Бонус

Мы подготовили тестовый проект, в котором отразили все практики, описанные в статье. Переходите по ссылке, изучайте и применяйте:

Mobile Automation Sample Project

Подробнее..

Model-Widget-WidgetModel, или какой архитектурой пользуется Flutter-команда в Surf

09.07.2020 18:19:56 | Автор: admin

Привет, меня зовут Артём. Я руководитель Flutter-разработки в Surf и со-ведущий FlutterDev подкаста.


Flutter-отделу в Surf уже больше года. За это время мы сделали несколько проектов: от маленьких служебных, до полноценных е-коммерс и банкинга. Как минимум, многие из вас уже могли видеть приложение аптеки Ригла. В статье я расскажу про недавно вышедший пакет mwwm архитектуру, на которой построены все наши проекты.



Что такое MWWM?


MWWM это архитектура и реализация паттерна Model-View-ViewModel, которую мы в Surf переложили на Flutter. Мы заменили слово View на Widget, потому что View не очень часто используется во Flutter и так будет нагляднее для разработчиков. Главное, что она позволяет делать разделять вёрстку и логику, как бизнесовую, так и презентационного слоя.


Немного истории


Почему именно MWWM мы используем в Surf? Давайте вернёмся к началу 2019 года, когда у нас зародился Flutter отдел. Что было на тот момент?


Flutter зарелизился буквально месяц назад: хайп начал подниматься, активных игроков на рынке и в опенсорсе пока нет. Отличное время чтобы залететь, занять неплохое место в индустрии и завоевать внимание, не правда ли? Фреймворк молод, коммьюнити развивается и это открывает все дороги.


В это время от Android-отдела Surf отделяется Flutter направление. И сразу же стоит задача сначала заложить основу для наших будущих приложений, а потом уже кодить. Конечно же, одним из основных моментов здесь являлся выбор архитектуры.


В начале 2019 года нет явно выделяющегося мнения в комьюнити какую архитектуру использовать (хотя и сейчас ведутся активные холивары). Да, есть основные концепции: BLoC, Redux, Vanilla, MobX и тд. Самыми массовыми являются BLoC и Redux. Замечу, что речь ведётся не о пакетах, а о концепции.
Итак, встал вопрос BLoC или Redux нам взять? Или же придумать нечто своё?


Почему мы не BLoC?


Business Logic Component отличная концепция. Блоки кода, по факту, чёрные ящики с некоторым входным воздействием и выходным потоком, внутри которых крутится бизнес-логика на чистом Dart это просто потрясающе. Чистый Dart для кросс-платформенного переиспользования с вебом (да, тогда было далеко до Flutter for Web и сайты писали на Angular Dart, при необходимости). Удобство использования и достаточная изоляция самих блоков. Круто одним словом. Но есть одно но: где писать логику презентационного слоя? Где писать навигацию? Как работать с чисто UI событиями?


Чуть позже вышел в релиз всем известный Bloc от Felix Angelov. А также flutter_bloc. Многие стали рассматривать блоки как логику презентационного слоя, но это уже не вязалось с аббревиатурой. Сама же библиотека не давала указаний, где писать подобную логику, например, валидировать поля ввода. Это было неприемлемо для выбора хорошей архитектуры.


Redux?


Как я написал выше мы выходцы из Android разработки. В то время в мире царили Clean Architecture, подходил MVVM. Веб-технологии были чем-то чуждым и непонятным. Redux мы быстро отмели: на него надо было переучиваться довольно долго и отчасти менять мышление, к которому мы привыкли во время Android-разработки с Rx и CleanArchitecture.


Отбросив основные концепции и принимая во внимание реалии Surf, мы поставили задачу создать архитектуру, которая позволит Android-разработчикам из Surf при необходимости быстро конвертироваться во Flutter-разработчиков. И чтобы при необходимости Flutter-разработчики могли быстро перейти на Android, потому что архитектура понятна, знакома, а язык выучить несложно. Так и появился Model Widget WidgetModel.


Model-Widget-WidgetModel


Выглядит он примерно вот так.



Эту картинку можно видеть на странице в GitHub. Здесь есть несколько основных частей.


  • Widget-UI та самая вёрстка.
  • WidgetModel логика этого самого UI и его состояния.
  • Model контракт с сервисным слоем. На данный момент это экспериментальная часть. Исторически у нас использовались Interactorы напрямую в WM.

Пройдёмся по каждой из этих частей поподробнее.


Widget в нашем контексте полностью пассивная вёрстка. Максимум что допускается в ней наличие условного оператора, когда мы пишем там if (условие), покажи мне это, иначе покажи мне loader. А всё остальное: вычисления этих условий, обработка нажатий и так далее уходят в WidgetModel. Причём виджетом может быть как целый экран, так и конкретный маленький элемент на экране со своим состоянием.


Сразу замечу, кроме MwwmWidget (буду называть их так, чтобы не путаться) мы также используем обычные Flutter-виджеты. Потому что куда без них? Есть места, где нет необходимости усложнять простенький переключатель ради архитектуры.


WidgetModel это, по сути, состояние виджета и описание логики его работы. Что входит в логику? Это обработка тех или иных действий пользовательского интерфейса. Это обращение к любым другим слоям, которые стоят выше. Это вычисление тех или иных значений, обработка, mapping данных, необходимых для вёрстки. В общем-то, все то, чем должна заниматься стандартная ViewModel.


Рассмотрим на небольшом примере.



Допустим, у нас есть некоторый экран и у него есть некоторое состояние и кнопка, которая входит в сеть. Widget экрана будет содержать только вёрстку. При этом состояние его и реакция на взаимодействия в WidgetModel. Замечу, что WM описывает также некоторые микросостояния на экране. Этими микро-состояниями могут являться Streamы.


class SomeWidgetModel{    final _itemsController = StreamController<List<Items>>.broadcast();    get items => itemsController.stream;}

Внутри команды мы как раз используем стримы (а точнее обёртку над ними) внутри виджет-модели. Поэтому по форме она очень сильно похожа на конценпцию BLoCа. Это коробка с input/output. Каждый input это действие пользователя или событие, output данные, влияющие на UI. Как я уже сказал, каждый стрим микросостояние внутри виджет-модели. Такими состояниями могут быть маленькие элементы: кнопка, которая дизейблится при каких-то условиях; поток текста с экрана и т.д.


Вспомним о кнопке на экране. Её состояние может быть одним из таких стримов и тогда она просто будет обернута в StreamBuilder на экране. Но надо понимать, что в этом случае логика кнопки должна быть довольно проста.


Stream<bool> get isBtnDisabled => btnController.stream;

А теперь представим, что такая кнопка встречается ещё в 5 местах по всему приложению. И кроме некоторого изменения состояния на, к примеру, дизейбл, она ещё и триггерит запрос в Сеть. Причём запрос один и тот же, у него лишь разные аргументы. В этом случае каждый раз копипастить логику из экрана в экран, прописывать запросы и вообще загрязнять виджет-модель не очень хорошо. Гораздо лучше просто выделить эту кнопку в Widget+WidgetModel и целиком и полностью переиспользовать из экрана в экран, передавая те или иные параметры на вход.
Ещё одно важное замечание, что виджет-модель единственный способ привести виджет или часть виджета в некоторое состояние. Что под этим надо понимать? В Flutter, например, мы можем передавать некоторые аргументы виджет в конструктор. Это полезно, если вы используете stateless-виджеты.


Но что у нас получается при использовании WM? Самим состоянием виджета должна управлять именно виджет-модель. Это главный источник. Если передаём какие-то данные в виджет, то мы должны передать их в WM, и только из неё их взять. Напрямую эти данные использовать в виджете нельзя, потому что иначе получается два пути приведения виджета в состояние. И это вас в конечном счёте может запутать или привести к очень неочевидным багам, которых лучше не допускать.


Поток данных W-WM



Какой поток данных происходит между виджетом и виджет-моделью? Из виджета идут некоторые действия события пользовательского интерфейса. Из виджет-модели идут некоторые состояния в виджет. Состояния могут быть в виде потоков (так делаем мы в Surf), могут быть просто переменными. На эти состояния мы подписываем части виджета с помощью StreamBuilderов.


//child: StreamBuilder<Item>(  stream: wm.item,  builder: (ctx, snapshot) => //...),

Важно заметить, что связь между Widget и WidgetModel явно не прописана внутри пакета. Мы не стали сужать возможности фреймворка и дали свободу пользователям пакета самим определять связь. При этом, тот подход, что работает в нашей компании является отдельным пакетом на pub.dev.


Relation


Мы рекомендуем использовать наш пакет MWWM вместе с модулем Relation. Relation это связь, которую мы используем между виджетом виджет-моделью. Это просто семантическая обёртка над потоками. У нас есть некоторые потоковые состояния в виде StreamedState и действия под названием Action. С Relation довольно просто работать.


  final toggleAction = Action<int>();  final contentState = StreamedState<int>(0);//subscribe(toggleAction.stream, (data) => contentState.accept(data));

Обработка ошибок



В больших проектах очень важно правильно обрабатывать ошибки. В рамках MWWM предусмотрен специальный интерфейс ErrorHandler, который обязательно поставляется в WM. WidgetModel перехватывает ошибки, которые приходят из сервисного слоя (или происходят внутри презентационного), и передаёт их обработчику. Автоматическая обработка происходит при использовании методов WM с постфиксом ...HandleError().


subscribeHandleError(someAction, (data) => doOnData());doFutureHandleError(someFuture, (data) => doOnData());

Реализацию ErrorHandler можно посмотреть в примере проекта.


Model


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



Рассмотрим подробнее.


Model контракт и унифицированное АПИ для взаимодействие с сервисным слоем. По факту это конкретная сущность с двумя методами: perform и listen. При этом в модель передаётся некоторый список Performerов, но обо всём по порядку.


Суть взаимодействия основана на том, что WM говорит: Model, хочу внести Изменение (совершить действие) в сервисный слой. Сделай это и ждёт результата. Такая конструкция позволяет полностью абстрагироваться друг от друга при написании кода, просто условившись на контракте.


Change


Это первая часть контракта и это класс, который описывает намерение что-либо изменить, получить и тип получаемого результата. Он может содержать данные, но не может содержать никакой логики. Просто data-класс в терминах того же Kotlin.


class Authenticate extends FutureChange<Result> {  final String name;  Authenticate(this.name);}

Performer


Вторая часть контракта Performer. Performer это реализация логики. Самый близкий аналог перформера UseCase. Это такая функциональная часть контракта. Если Change это параметры, название метода, то Performer тело метода и, по сути, код, который исполняется по этому Change. В перформер могут поставляться любые сервисы, бизнес-логика, интеракторы и так далее. Такая конструкция полностью отвязывает виджет-модель от реализации этой бизнес-логики.
Идеальный перформер это только одно действие. То есть это такой очень атомарный кусок кода, который просто выполняет что-то одно и отдаёт результат. Performer однозначно связан с типом Change.


class AuthPerformer extends FuturePerformer<Result, Authenticate> {  final AuthService authService;  AuthPerformer(    // сущности, которые нужны перформеру для работы    this.authService,  );  Future<Result> perform(Authenticate change) {    return authService.login(change.name);  }}

Был один метод, стало два класса


Зачем это надо было разделять? Потому что если у интерактора был один метод, то здесь у нас получается два класса вместо одного метода. Но таким образом из виджет-модели исчезает полностью информация об интеракторах, о реализации сервисного слоя. Всё что нужно знать, что вы хотите сделать. То есть знать Change. И предоставить Model с необходимым набором перформеров.


Неприятный минус: если вы не предоставили Performer, то узнаете об этом только в рантайме.


Бизнес-логика


Тут полная свобода действий. MWWM не декларирует, как реализовать бизнес-логику вашего приложения. Рекомендуется использовать тот подход, что принят в вашей команде. В Surf мы используем CleanArchitecture, я в своём pe-проекте работаю с сервисами, которые поставляются в перформеры и там работают. Тут может быть действительно всё, что угодно. Вся суть MWWM в том, что он довольно гибок в использовании, и его можно адаптировать под свою команду.


Стек Surf


Наш стек в Surf это вот такой набор пакетов для архитектуры.



По факту для нас это один пакет под названием surf_mwwm. Если интересно посмотреть подробнее, то можно найти на нашем GitHub.


На диаграмме:


  • injector пакет для реализации DI. Основан на InheritedWidget. Маленький и простой.
  • relation связь между слоями.
  • mwwm герой этой статьи
  • surf_mwwm всё вместе с небольшой добавочкой, специфичной для нашей команды.

Вместо заключения


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


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


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


Если вкратце, что мы имеем по MWWM:


Плюсы:


  • Разделяет и изолирует все слои (UI, Presentation Logic, Business Logic).
  • Не имеет лишних зависимостей.
  • Гибко настраивается под нужды команды.
  • Позволяет описать контракт и работать параллельно.

Минусы:


  • Слишком сложно, если пишешь в одного.
  • Многословное описание контракта.

MWWM это часть нашего большего репозитория. У неё есть свой отдельный репозиторий SurfGear. Это набор наших стандартов и библиотек, которые мы используем в Surf.


Часть этих библиотек уже в релизе на pub.dev:



И команда Surf не собирается останавливаться на этом.

Подробнее..

Не мешай ему взрослеть как оптимизировать растущее приложение, чтобы оно оставалось удобным для пользователя

27.02.2021 14:17:14 | Автор: admin
Количество установок приложения IntellectoKids Classroom & Learning games.Количество установок приложения IntellectoKids Classroom & Learning games.

Привет, Хабр! Меня зовут Андрей Романенков, я работаю ведущим программистом в IntellectoKids. Мы создаем образовательные приложения для дошкольников.

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

Но есть одно но.

В определенный момент ваше простенькое приложение может превратиться в перспективный проект с десятками мини-игр внутри и еженедельными обновлениями. Собственно, это произошло с нами. И тут начинается самое интересное. Представьте, что геймдизайнеры постоянно добавляют в игры новые уровни, художники новые текстуры, а локализаторы локализацию на волапюк. Репозиторий растет, приложение раздувается. Как результат увеличивается время скачивания. А это уже может повлиять на популярность продукта и на его продвижение.

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

Саб-модульность, многорепозиторность, подход к общему коду

Всего у IntellectoKids 4 приложения. Поскольку сервисы у них идентичны (например, логика работы с сервером, аналитика, покупки) и много одинакового кода, мы выделили общий функционал в отдельный репозиторий, который каждый конкретный проект подключает через git submodules. Выскажу довольно очевидную мысль, что когда ваши проекты с общим кодом множатся, код нужно скорее выделить в единую библиотеку. Вроде бы все это понимают, но часто откладывают на потом, а чем дальше вы откладываете, тем тяжелее будет процесс слияния.Помимо выделения репозитория для общих сервисов, мы создали также другой общий репозиторий для более базовой библиотеки утилит и вспомогательных классов.Второй вариант подключения дополнительного функционала появился у нас с добавлением Package Manager в Unity. Так можно делать, когда ваша библиотека устоялась и если вам необходимо подключить какую-то стороннюю библиотеку с репозитория как package.Когда проект длится давно, а количество контента увеличивается с каждым днем, то из-за раздувшейся истории и обилия больших файлов рано или поздно вы столкнетесь с проблемой размера репозитория. У нас текущий репозиторий перевалил за 10Гб (сейчас 14 гб), с чем справляются не все хостинги (большая часть из них ограничивает размеры хранилища).В борьбе за производительность нам помогают чистка истории и использование git lfs, а также внимательное отношение к размеру и формату импортируемых в проект ассетов. Например, импортирование mp3 и ogg файлов вместо wav; и отсекание слишком больших текстур.

Локализация, в том числе RTL-языки

Наши приложения локализованы более чем на 40 языков, включая RTL языки (предполагающие чтение справа налево). Система локализации самописная, но в целом она похожа на типовые решения из Asset Store (такие, как I2 Localization). В Google-таблицах хранятся ключи и значения. Есть базовая таблица для всех игр, и дополнительные таблицы для каждой конкретной игры. Каждая таблица в Google-документах скриптами собирается из других вспомогательных таблиц, которые редактируют локализаторы.

Данные из вспомогательных таблиц(цветные закладки внизу) попадают в финальные таблицы локализаций.Данные из вспомогательных таблиц(цветные закладки внизу) попадают в финальные таблицы локализаций.

На клиенте наши скрипты MonoBehaviour выцепляют нужные значения и выставляют их в TextMesh Pro компоненты. Клиент может обновлять таблицы как в режиме редактора, так и рантайма. У клиента таблицы хранятся в csv формате, и в память грузится только нужный язык, так как количество ключей для каждого языка превышает уже пять сотен!

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

Пример стандартной вёрстки на английском.Пример стандартной вёрстки на английском.То же окно, но на иврите.То же окно, но на иврите.

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

Когда волна больше определённого значения у Animator кролика включается параметр IsTalking.Когда волна больше определённого значения у Animator кролика включается параметр IsTalking.

Бандловость: 2 подхода. Эволюция в работе с бандлами

По мере развития проекта (добавления новых уровней и другого контента, увеличения количества локализаций) постоянно рос общий размер содержимого. С самого начала нам было понятно, что необходимо использовать бандлы, иначе размер клиента был бы огромен и сейчас составлял бы 3 ГБ. Да, не Modern Warfare, но всё же для еженедельной скачки это неприемлемо.

В какой-то момент мы, правда, провели эксперимент с выпуском таких больших релизов (тогда размер был примерно под два гигабайта), но это сразу заметно отразилось на общей статистике приложения. Сейчас у нас зашиты в билд только небольшие бандлы, необходимые для ускорения старта. В нашем основном приложении IntellectoKids Classroom&Learning Games больше тысячи бандлов общим размером 2.5 гигабайта. Может показаться, что это слишком много, но если умножить количество встроенных игр на количество языков, и добавить к этому, что у каждой игры есть множество уровней с насыщенным контентом, то всё сразу станет понятно.Из-за особенности геймплея каждая игра имеет свои нюансы объединения ресурсов в бандлы. Где-то можно поместить все локализованные фразы в один бандл, так как их общий размер мал, а где-то необходимо разделить и поместить каждый язык в отдельный бандл. В каких-то играх несколько уровней объединены в один бандл, а в других должен быть бандл у каждого уровня. При формировании бандлов мы создаём manifest файл, описывающий имена и хэши бандлов.

Загрузчик при обновлении версии качает этот manifest и проверяет, какие бандлы нужно закачать заново, а какие удалить. Изначально игра поддерживала фоновую загрузку бандлов во время игры, но позже мы отказались от такого подхода, так как он таил в себе много скрытых проблем (сценарии обновления бандла во время использования его игрой, баги Unity в выгрузке бандлов и т.п.) Сейчас мы перешли на более классический вариант, когда игроку в нужные моменты показывается экран загрузки бандлов.Самый маленький большой нюанс нашего проекта, это, безусловно, релиз и деплой более чем тысячи бандлов. На первоначальном этапе, когда бандлов было меньше, мы размещали их в Google репозитории и даже использовали веб-интерфейс Chrome для их загрузки, но довольно быстро перешли на собственный билдер-загрузчик, выполненный в виде инструментария в Unity. Он позволяет загрузить нужные бандлы, задать версионность билда и настроить другую рутину.

Помимо загрузки бандлов инструментарий позволяет поменять все необходимые параметры для очередного билда.Помимо загрузки бандлов инструментарий позволяет поменять все необходимые параметры для очередного билда.

Первоначально бандлы лежали в Google Cloud Storage, затем мы перешли на Amazon Web Services. Основная статья затрат у бандлов это скачивание. С переходом на AWS и CloudFront нам удалось оптимизировать издержки. Хоть это, а также переход на новый API с доработкой инструментов деплоя занял некоторое время, но оно того стоило.

Переход на новые версии Unity

За прошедшие четыре года разработки мы многократно обновляли версии Unity. В целом мы стремимся делать это часто, но ведем разработку только в LTS версиях из-за их большей стабильности.

Если вы растите приложение, будьте готовы, что требования к нему будут довольно часто меняться. Нам много раз приходилось перерабатывать фреймворки аналитики (один Facebook включался и выключался больше пяти раз).

(Чтобы подчеркнуть всю глубину глубин этой ситуации я хотел вставить картинку описывающюю определённую эмоцию, но не нашёл подходящей картинки человека схватившегося за голову или попу.)

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

Что можно добавить к сказанному в статье?

Пожалуй, стоит понимать, что ваш проект всегда будет далек от того идеала, который вы себе намечтали. Но это не значит, что нужно откладывать очевидные улучшения архитектуры. Особенно в условиях, когда требования к функционалу приложения постоянно меняются. Ведь неделька на рефакторинг может сэкономить вам месяцы мучительной разработки, но помните, что "Real artists ship" и если ваш рефакторинг слишком большой, а бизнес не стоит на месте, то и рефакторинг может подождать. Тут дам совет, особенно скромным программистам: более четко доносить до менеджмента такие критичные вещи, чтобы ваш технический долг не рос.

Dixi

Подробнее..

Как увеличить срок хранения мобильного приложения? 6 проверенных способов

28.02.2021 02:15:32 | Автор: admin

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

1. Специальные акции для пользователей

Акции являются одним из способов увеличения удержания. Например акции с ограниченым временем на покупку или ограниченое колличество товаров.

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

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

Акцияможет быть дополненаперекрестнымипродажами(например, Купите два продукта из данной категории и получить третий бесплатно) и, таким образом, можно увеличить объем покупки.

2. Интеллектуальные push-уведомления

Данные о поведении и геолокации, часто получаемые в режиме реального времени, позволяют точно связаться с людьми с сообщением в нужном месте и времени (например, Здравствуйте! Вы чувствуете себя сонным? Сегодня со скидкой [здесь стоимость акции]). Это дает им преимущество перед, например, ремаркетингом на основе файлов cookie. Придавая правильную ценность пользователю через персонализированное сообщение, мы увеличиваем вероятность совершения покупки.

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

Тщательно разработанное push-уведомление должно быть коротким, разборчивым и, самое главное, четко информировать пользователя о преимуществах возврата в приложение. Возможность пробудить интерес пользователя также заключается в использовании расширенных push-уведомлений, которые вместо текста могут иметь значок и изображение в содержимом.

Подробно про это я уже писал в другой своей статье. Если будет интересно - выложу на хабре.

3. Индексация приложений, то есть индексация приложений в поисковой системе.

В 2015 году, после трех лет тестирования, Google, наконец, решил ввести функцию индексации приложений в поисковой системе общего пользования (избранные разработчики получили доступ к ней двумя годами ранее; именно их приложения проложили путь для преемников). В настоящее время Google индексирует приложения для мобильных устройств с iOS и Android, что означает, прежде всего, гораздо больше возможностей для охвата получателей. Индексируя приложение, потенциальные пользователи могут найти его в результатах поиска Google. Я говорю здесь - конечно - о людях, просматривающих интернет с телефонов и планшетов. Если у них есть конкретное приложение, нажимающее на ссылку (например, размещенную на веб-сайте), они автоматически переносятся на один из его экранов (например, ответы на конкретный вопрос или товар). Если нет - они могут установить его, нажав кнопку на странице результатов (или в мобильной версии сайта). В этой ситуации они переносятся прямо в App Store или платформу Google Play.

Следует сразу отметить, что Google индексирует только те приложения, которые соответствуют условиям. Программная документация о так называемой индексация приложений доступна на веб-сайтеFirebase App Indexing. Внедрение функций индексации (например, глубоких ссылок с мобильной страницы на экраны приложений) должно осуществляться на этапе разработки программы и являться частью более широкого плана присутствия в Интернете. Сам процесс реализации индексации приложений варьируется в зависимости от операционной системы (мы делаем по-разному в случае iOS, по-разному - Android) и подробно описанздесь (iOS)издесь (Android).

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

4. Ссылки, открывающие приложение, размещенные в рассылках, социальных сетях и т. Д. (так называемая глубокая связь)

Принцип роботы "глубоких ссылок" или deep link

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

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

5. Google UAC - универсальные кампании по продвижению приложений

Кампании попродвижению универсальных приложений(Universal App Campaigns, корочеUAC) - хороший вариант для людей, которые хотят автоматизировать рекламные процессы; После загрузки всех необходимых материалов в AdWords система распространения рекламы будет решать, кто, когда, что и как отображать, чтобы достичь нашей цели.

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

Вы можете узнать больше о кампаниях Universal App наофициальной странице справки Google. Там мы также найдем информацию о различных способах установления целей, ориентированных на удержание.

6. Увеличение удержания мобильного приложения за пределы цифрового маркетинга: ATL и BTL

Продвижение мобильного приложения не должно ограничиваться интернет-маркетингом. Продвижение ATL (выше линии), например, рекламные щиты, газеты, листовки и BTL (ниже линии), например, городские игры в среде дополненной реальности и POSy, при правильном использовании они также могут хорошо работать.

Резюмируя

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

Подробнее..

Перевод 6 объединяющих операторов Swift Combine, которые вам следует знать

12.08.2020 12:22:47 | Автор: admin
Перевод статьи подготовлен в преддверии старта продвинутого курса iOS-Разработчик.



В этой статье мы рассмотрим шесть полезных операторов объединения в Combine. Мы сделаем это на примерах, экспериментируя с каждым из них в Xcode Playground.

Исходный код доступен в конце статьи.

Ну что ж, без лишних разглагольствований, давайте приступим.

1. prepend


Эта группа операторов позволяет нам добавлять (prepend дословно добавить в начало) к нашему исходному паблишеру события, значения или других паблишеров:

import Foundationimport Combinevar subscriptions = Set<AnyCancellable>()func prependOutputExample() {    let stringPublisher = ["World!"].publisher        stringPublisher        .prepend("Hello")        .sink(receiveValue: { print($0) })        .store(in: &subscriptions)}

Результат: Hello и World! выводятся в последовательном порядке:



Теперь давайте добавим другого издателя того же типа:

func prependPublisherExample() {    let subject = PassthroughSubject<String, Never>()    let stringPublisher = ["Break things!"].publisher        stringPublisher        .prepend(subject)        .sink(receiveValue: { print($0) })        .store(in: &subscriptions)        subject.send("Run code")    subject.send(completion: .finished)}

Результат аналогичен предыдущему (обратите внимание, что нам нужно отправить событие .finished в subject, чтобы оператор .prepend работал):



2. append


Оператор .append (дословно добавить в конец) работает аналогично .prepend, но в этом случае мы добавляем значения к исходному паблишеру:

func appendOutputExample() {    let stringPublisher = ["Hello"].publisher        stringPublisher        .append("World!")        .sink(receiveValue: { print($0) })        .store(in: &subscriptions)}

В результате мы видим Hello и World! выведенные на консоли:



Аналогично тому, как ранее мы использовали .prepend для добавления другого Publisherа, у нас также есть такая возможность и для оператора .append:



3. switchToLatest


Более сложный оператор .switchToLatest позволяет нам объединить серию паблишеров в один поток событий:

func switchToLatestExample() {    let stringSubject1 = PassthroughSubject<String, Never>()    let stringSubject2 = PassthroughSubject<String, Never>()    let stringSubject3 = PassthroughSubject<String, Never>()        let subjects = PassthroughSubject<PassthroughSubject<String, Never>, Never>()        subjects        .switchToLatest()        .sink(receiveValue: { print($0) })        .store(in: &subscriptions)        subjects.send(stringSubject1)        stringSubject1.send("A")        subjects.send(stringSubject2)        stringSubject1.send("B") // отброшено        stringSubject2.send("C")    stringSubject2.send("D")        subjects.send(stringSubject3)        stringSubject2.send("E") // отброшено    stringSubject2.send("F") // отброшено        stringSubject3.send("G")        stringSubject3.send(completion: .finished)}

Вот что происходит в коде:

  • Мы создаем три объекта PassthroughSubject, которым мы будем отправлять значения.
  • Мы создаем главный объект PassthroughSubject, который отправляет другие объекты PassthroughSubject.
  • Мы отправляем stringSubject1 на основной subject.
  • stringSubject1 получает значение A.
  • Мы отправляем stringSubject2 на основной subject, автоматически отбрасывая события stringSubject1.
  • Точно так же мы отправляем значения в stringSubject2, подключаемся к stringSubject3 и отправляем ему событие завершения.

В результате мы видим вывод A, C, D и G:



Для простоты, функция isAvailable возвращает случайное значение Bool после некоторой задержки.

func switchToLatestExample2() {    func isAvailable(query: String) -> Future<Bool, Never> {        return Future { promise in            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {                promise(.success(Bool.random()))            }        }    }        let searchSubject = PassthroughSubject<String, Never>()        searchSubject        .print("subject")        .map { isAvailable(query: $0) }        .print("search")        .switchToLatest()        .sink(receiveValue: { print($0) })        .store(in: &subscriptions)        searchSubject.send("Query 1")    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {        searchSubject.send( "Query 2")    }}

Благодаря оператору .switchToLatest мы достигаем того, чего хотим. Только одно значение Bool будет выведено на экран:



4. merge(with:)


Мы используем .merge(with:) для объединения двух Publishersов, как если бы мы получали значения только от одного:

func mergeWithExample() {    let stringSubject1 = PassthroughSubject<String, Never>()    let stringSubject2 = PassthroughSubject<String, Never>()        stringSubject1        .merge(with: stringSubject2)        .sink(receiveValue: { print($0) })        .store(in: &subscriptions)        stringSubject1.send("A")        stringSubject2.send("B")        stringSubject2.send("C")        stringSubject1.send("D")}

Результатом является чередующаяся последовательность элементов:



5. combineLatest


Оператор .combineLatest паблишит кортеж, содержащий последнее значение каждого издателя.

Чтобы проиллюстрировать это, рассмотрим следующий реальный пример: у нас есть имя пользователя, пароль UITextFields и кнопка продолжения. Мы хотим держать кнопку отключенной до тех пор, пока имя пользователя не будет содержать не менее пяти символов, а пароль не менее восьми. Мы можем легко добиться этого, используя оператор .combineLatest:

func combineLatestExample() {    let usernameTextField = CurrentValueSubject<String, Never>("")    let passwordTextField = CurrentValueSubject<String, Never>("")        let isButtonEnabled = CurrentValueSubject<Bool, Never>(false)        usernameTextField        .combineLatest(passwordTextField)        .handleEvents(receiveOutput: { (username, password) in            print("Username: \(username), password: \(password)")            let isSatisfied = username.count >= 5 && password.count >= 8            isButtonEnabled.send(isSatisfied)        })        .sink(receiveValue: { _ in })        .store(in: &subscriptions)        isButtonEnabled        .sink { print("isButtonEnabled: \($0)") }        .store(in: &subscriptions)        usernameTextField.send("user")    usernameTextField.send("user12")        passwordTextField.send("12")    passwordTextField.send("12345678")}

После того, как usernameTextField и passwordTextField получат user12 и 12345678 соответственно, условие удовлетворяется, и кнопка активируется:



6. zip


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

func zipExample() {    let intSubject1 = PassthroughSubject<Int, Never>()    let intSubject2 = PassthroughSubject<Int, Never>()        let foundIdenticalPairSubject = PassthroughSubject<Bool, Never>()        intSubject1        .zip(intSubject2)        .handleEvents(receiveOutput: { (value1, value2) in            print("value1: \(value1), value2: \(value2)")            let isIdentical = value1 == value2            foundIdenticalPairSubject.send(isIdentical)        })        .sink(receiveValue: { _ in })        .store(in: &subscriptions)        foundIdenticalPairSubject        .sink(receiveValue: { print("is identical: \($0)") })        .store(in: &subscriptions)        intSubject1.send(0)    intSubject1.send(1)        intSubject2.send(4)        intSubject1.send(6)    intSubject2.send(1)    intSubject2.send(7)        intSubject2.send(9) // Не отображено, потому что его пара еще не отправлена}

У нас есть следующие соответствующие значения из intSubject1 и intSubject2:

  • 0 и 4
  • 1 и 1
  • 6 и 7

Последние значение 9 не выводится, поскольку intSubject1 еще не опубликовал соответствующее значение:



Ресурсы


Исходный код доступен на Gist.

Заключение


Вас интересуют другие типы операторов Combine? Не стесняйтесь посещать мои другие статьи:

Подробнее..

SVGator.com на практике

20.05.2021 18:13:57 | Автор: admin

Привет, мы дизайнеры экосистемы Своё для фермеров и банковских сервисов Россельхозбанка. Рассказываем, зачем нам понадобился SVGator.

Как мы пришли к SVGator.com

Digital-рынок сегодня насыщен хорошими решениями, тренды не стоят на месте, одним
из доминантных признаков качества становится анимация интерфейса. В рамках разработки группы продуктов Своё и банковских сервисов Россельхозбанка мы с командой рассматривали
различные решения.

Наш путь начался с lottie, как с более популярного сервиса. Ключевой недостаток: встроенная api. Нам хотелось найти более прагматичное решение без интеграции, поэтому выбор пал на SVGator. В наши цели попадало закрытие микроанимаций элементов интерфейса, оформительские детали и т.п.

Что за зверь и какие задачи решает

SVGator.com это web-платформа для создания svg-анимаций, то есть svg-файлов со встроенными анимациями, которые без каких-либо проблем интегрируются в html. Можно задать последовательную обработку таких анимаций. Возможен экспорт как js, так и чистым CSS. Возможности js немного шире, но разница не существенная.

В рамках продуктов группы Своё мы создавали различные анимации для лоадеров, микроэлементов (таких как стрелка дропдауна), логотипов и элементов оформления. Мы ещё находимся на стадии экспериментов с SVGator, но уже чётко понимаем, в каких моментах он имеет преимущества и что можно создать с его помощью:

Небольшие функциональные элементы интерфейса. Например, интерактивные состояния компонентов и вытекающая микроанимация

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

Элементы дизайна. Например, иллюстрации

Интерфейс и функционал

На данный момент SVGator располагает исключительно веб-платформой, но весь необходимый функционал присутствует. Интерфейс напоминает классическое решение канувшего в Лету Flash и взявшего бразды правления After Effects. Слева слои файла. Сверху панель инструментов. Снизу таймлайн, а по центру сама рабочая область. Если есть навыки работы с АЕ, совершенно точно SVGator покажется очень простым. Но в этом и его преимущество порог входа значительно ниже.

Инструмент включает в себя следующие функции: работа с размером, положением, поворотом, наклоном, цветом, прозрачностью, а также богатый функционал для работы с обводкой. Помимо этого, поддерживается работа с масками и векторами движения. Этого достаточно для работы с классическими решениями анимации. Ещё один важный момент работа с easing, которые также встраиваются в svg-файл. Это упрощает работу на этапе front-end, и дизайнеру не приходится тревожить разработчика скоростью анимации.

Механика работы несколько отличается от привычных решений After Effects, вы выбираете необходимый элемент в меню слева и добавляете на него нужный эффект. После этого слой появляется в области таймлайна и можно менять значения, добиваясь нужной анимации. Сами значения находятся на панели справа. Easing могут быть привязаны как ко всему таймлайну, так и к конкретному отрезку между keyframes.

Чем svg отличается от sequence, gif, video? А также основные конкуренты:
Lottie и Keyshape

Ключевые преимущества svg вес, отсутствие api и возможность давать плавную анимацию. Разберём каждую из альтернатив.

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

Gif. Решение потеряло свою актуальность также из-за веса. Однако есть и другой минус отсутствие плавности анимации. Часто в погоне за оптимизацией размера дизайнерам приходится поскупиться количеством кадров в секунду, а также сама постобработка оставляет желать лучшего.

Video. Безусловно занимало и занимает свою нишу, обладая весьма большими перспективами. Одна из ключевых проблем данного решения отсутствие альфа-канала на выходе, что не позволяет сделать элементы прозрачными. Но сейчас очень популярны решения,
в которых видео является частью слоя, а управляется, например, через скрол. У данного решения много поддержки в комьюнити.

Lottie. Это один из ключевых конкурентов SVGator. Компания раньше вышла на рынок и имеет значительно больше почитателей. Плюс возможности анимации значительно больше, поскольку базовая анимация собирается в Аfter Еffects, а lottie является своего рода оболочкой для такой анимации. Но для работы с данным решением необходимо предустановить api на платформу, что не всегда хорошо для продакшена больших нагруженных систем.

Keyshape. Очень близкая по возможностям платформа, различия лишь в деталях. Текущий функционал Keyshape несколько выше и позволяет делать экспорт чистым svg + css. К недостаткам можно отнести её стационарность и возможность работать исключительно на macOS.

Вот поэтому и стоит обратить внимание на SVGator. Плавные анимации, реализованные кодом, кроссплатформенные, без каких-либо api и с нормальным весом.

SVGator в экосистеме Своё

На наших продуктах Своё Фермерство, Своё Жильё, Своё Родное, Монеты, Подбор персонала, Своё Село и т.д. уже присутствуют наработки в SVGator: лоадер, интерактивные состояния компонентов, логотипы, а также иллюстрации для оформления разделов. Мы планируем увеличивать их долю и продолжать экспериментировать.

В настоящее время работаем над возможностью покадровой анимации на примере движения коровы. Если наш эксперимент закончится успехом, вероятно, мы ещё увеличим долю подобных решений.

Перспективы

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

Объёмные решения склонны к излишней нагрузке системы и могут стать камнем преткновения. Поэтому в наших планах использовать этот инструмент поступательно, небольшими шагами, анимируя поначалу небольшие элементы.

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

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru