Хабрахабр

Деливерим фичи быстрее. Опыт Android-разработки в Badoo

Всем привет! Меня зовут Анатолий Варивончик. Я работаю в Badoo уже больше года, а мой общий стаж Android-разработки — более пяти лет.

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

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


План такой:

  1. Принципы подхода к разработке в Badoo.
  2. Примеры из практики.
  3. Дизайн-система.
  4. Когда стоит применять описанные принципы.

Эта статья — текстовый вариант моего доклада на AppsConf, видео можно посмотреть здесь.

Принципы подхода к разработке

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

На наш подход к разработке влияют несколько факторов.

Использование А/В-тестов

У нас на мобильных платформах сегодня активны десятки А/В-тестов, в то время как завершённых несколько сотен. Соответственно, если взять приложение Badoo на двух разных устройствах, то с высокой долей вероятности между ними будут какие-то отличия, возможно незаметные на первый взгляд.

Важно понимать: то, что считают необходимым продакт-менеджеры, и даже то, что кажется очевидным нам, не всегда оказывается полезным в действительности. Зачем нам А/В тесты? Иногда имеет смысл протестировать идею нового функционала, чтобы понять, подходит он или нет. Иногда нам приходится удалять код, который мы писали буквально месяц или два назад. И если функционал понравился пользователям, тогда мы уже можем инвестировать время в его развитие.

Уменьшение стоимости разработки

Мы, конечно, хотим, чтобы всё работало быстро и было красиво. Однако не всегда возможно добиться этого за короткое время. Иногда на это  приходится тратить много дней. Чтобы избежать этих проблем, мы стараемся помогать продакт-менеджерам, предварительно оценивая стоимость задач и обозначая, что нам сделать сложно, а что — легко.

Правило большинства пользователей

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

Как мы ускоряем разработку

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

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

Пример 1. Кнопка накопления прогресса

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

Какие есть варианты решения?

Нам не нужна эта иконка. Вариант A. Пускай там просто какая-нибудь текстовка отображается.
Вариант B. Надо попросить дизайнеров переделать функционал. При правильном смешении мы получим именно то, что нам нужно. Использовать bitmap-маски.

Вариант C: Просто возьмём несколько иконок, захардкодим их на клиенте и будем показывать одну из них.

Обсудим подробнее. В нашем случае мы пришли к решениям B и C.

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

Мы можем легко нарисовать прямоугольник со скруглёнными углами. Bitmap-маски (вариант В) являются идеальным решением этой задачи. Остаётся смешать их и выставить правильные настройки. Мы можем легко нарисовать обычный прямоугольник на нужный нам процент заполнения. После этого исчезнут оба угла слева.

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

data class GoalInProgress(val progress: Float) private val unchargedPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.MULTIPLY)
} private fun mixChargedAndUncharged(canvas: Canvas) { drawFullyCharged(canvas) drawUnchargedPart(canvas)
}

Подробнее про bitmap-маски можно прочитать в статье: https://habr.com/ru/company/badoo/blog/310618/. Я удалил большую часть кода. Также из неё вы узнаете, каким образом можно смешивать маски, каких эффектов достигать и как это работает по производительности.

Это решение на 100% удовлетворяет нашим требованиям, то есть даёт возможность показывать прогресс от 0 до 1.

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

Мы просто берём несколько фиксированных типов иконок и в зависимости от прогресса показываем одну из них. Вариант С. Очевидно, что это решение не удовлетворяет на 100% требованиям. К примеру, если прогресс пользователя меньше 0,5, то будет отображаться незаполненная иконка. Но для его реализации нужно написать всего пять строчек кода и получить от дизайнера три иконки.

fun getBackground(goal: GoalInProgress) = when (goal.progress)

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

Пример 2. Строка ввода номера телефона

Следующий пример со вводом номера телефона. Отличительные особенности:

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

Давайте думать, как это можно реализовать.

Он будет держать этот префикс, держать пробел, управлять позицией курсора. Вариант A: написать кастомный TextWatcher, который реализует нужную логику.

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

Вариант C: попросить другой дизайн, чтобы нам было проще.

Рассмотрим подробнее. Мы решили реализовать вариант В.

Однако продакты настаивали на первоначальной задумке. Попросить другой дизайн (вариант С) — это первое, что мы попробовали сделать. А если бизнес настаивает на каком-то функционале, то наша задача — его реализовать.

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

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

Кажется, что есть вариант проще. Сделать всё это, конечно, можно, но достаточно сложно.

И он действительно нашёлся:

<merge xmlns:android="http://schemas.android.com/apk/res/android" tools:parentTag="android.widget.LinearLayout"> <TextView android:id="@+id/country_code" /> <EditText android:id="@+id/phone_number" /> </merge>

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

Но мы легко подписываемся на изменение фокуса и меняем бэкграунд у префикса. Единственное, о чём стоит подумать, — о том, что в Android по умолчанию ширина нижней линии увеличивается, когда EditText в фокусе. Ничего сложного:

phoneNumber.setOnFocusChangeListener { _, hasFocus -> countryCode.setBackgroundResource(background(hasFocus))
} private fun background(hasFocus: Boolean) = when (hasFocus) { true -> R.drawable.phone_input_active false -> R.drawable.phone_input_inactive }

Это решение обладает рядом преимуществ:

  • не нужно хендлить клики на префикс;
  • не нужно работать с позицией курсора — он всегда в отдельном поле.
  • Гораздо меньше краевых случаев и проблем возникает при такой реализации.

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

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

Давайте подумаем, что можно с этим сделать.

Почему бы нам не поступить так же? Вариант А: кажется, что это редкий кейс, который никто не фиксит.

Вариант В: кастомный TextWatcher подойдёт гораздо лучше и решит все наши проблемы.

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

Вариант D: брать N символов с конца.

Мы остановились на варианте D.

Я посмотрел в нескольких крупных приложениях. Вариант А. Кажется, его действительно никто не фиксит.

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

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

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

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

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

Использование N символов с конца показалось нам подходящим решением. Вариант D.

class DigitsTrimStartFilter(private val max: Int) : InputFilter { override fun filter(...): CharSequence? { val s = source.subSequence(start, end).filter { it.isDigit() } val keep = max - (dest.length - (dend - dstart)) return when { keep <= 0 -> "" keep >= s.length -> null // keep original else -> s.subSequence(s.length - keep, s.length) } }
}

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

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

Пример 4. Компонент ввода даты

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

Давайте подумаем, как это можно реализовать.

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

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

Таким образом мы немного упростим требования и нам будет проще реализовать этот функционал. Вариант С: запретить управлять позицией курсора.

Вариант D: использовать стандартный компонент ввода даты, который есть на Android и который мы все видели.

Мы пришли к варианту С.

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

Начинаем тестировать: Берём это решение, добавляем в код, запускаем.

override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { if (edited) { edited = false return } var working = getEditText() working = manageDateDivider(working, 2, start, before) working = manageDateDivider(working, 5, start, before) edited = true input.setText(working) input.setSelection(input.text.length)
}

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

Хотелось бы этого избежать, потому что потом это работа тестировщиков, а потом — снова наша по фиксу багов и т. Нам это всё нужно дорабатывать. п.

Почему бы не использовать готовую библиотеку для масок decoro или input-mask-android? Вариант В. Если у вас в проекте есть библиотека для масок или вы готовы её добавить, то это отличное решение. В них протестированы все сценарии, можно просто всё переиспользовать и радоваться жизни.

И тащить её в проект ради одного маленького компонента, который больше нигде не используется, показалось лишним. У нас библиотеки не было.

Использовать стандартный компонент ввода даты. Вариант D.

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

Количество ошибок о невалидной дате сократилось в три раза. По этой причине мы в своё время отказались от стандартного диалога ввода даты на форме регистрации в Badoo.

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

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

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

class DateEditText : AppCompatEditText(context, attrs, defStyleAttr) { private var canChange: Boolean = false private var actualText: StringBuilder = StringBuilder() override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) if (!canChange) return canChange = false setSelection(actualText.length) canChange = true }
}

И мы подумали: «А зачем вообще давать возможность двигать этот курсор?» и просто запретили это делать. Недостатки варианта A начинали проявляться, когда пользователь менял позицию курсора.

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

Пример 5. Тултипы на экране видеостриминга

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

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

Мы попросили продактов о нескольких вещах. Всё это выглядело довольно сложным для реализации.

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

Раньше таймеры повторения тултипов были независимыми:

Мы же попросили поддерживать таймер только для самого приоритетного тултипа:

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

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

Пример 6. Реордеринг фотографий

Нам пришёл вот такой дизайн:

Мы пришли к выводу, что реализовать это достаточно сложно, на разработку придётся потратить три дня, и подумали: «Зачем нам это делать, если мы не знаем, нужно ли это пользователю?» Мы предложили для начала запустить упрощённую версию и оценить, насколько востребована эта фича.

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

Итого:

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

Пример 7. Компонент ввода ПИН-кода

Мы разрабатываем не только приложение Badoo — у нас есть и другие приложения с абсолютно разным дизайном. И во всех трёх приложениях мы используем один и тот же компонент ввода ПИН-кода:

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

Например, у нас чётко прописано, что у каждой кнопки должны быть определённые состояния и что она должна вести себя определённым образом. Дизайн-система — это набор UX-правил о том, как себя должны вести те или иные компоненты.

Больше про дизайн-систему можно узнать из доклада Рудого Артёма.

Чего бы мы хотели? А пока вернёмся к компоненту ввода ПИН-кода.

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

Какие у нас были варианты решений?

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

Вариант В: использовать один EditText, добавить немного творчества — и получить то, что нужно.

Мы выбрали вариант В.

С четырьмя отдельными EditText есть проблемы. Вариант А. Кроме того, нужно будет реализовать долгий тап назад, чтобы пользователь мог удалить весь ПИН-код. Android добавляет дополнительные отступы со всех сторон, которые нам надо будет корректно обрабатывать. Это кажется довольно сложным. Нам придётся вручную работать с фокусом и обрабатывать удаление символов.

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

private fun createActualInput(lengthCount: Int) = EditText(context) .apply { inputType = InputType.TYPE_CLASS_NUMBER isClickable = false maxHeight = 0 maxWidth = 0 alpha = 0F addOrUpdateFilter(InputFilter.LengthFilter(lengthCount)) } private fun createPinItems(count: Int) { actualText = createActualInput(count) actualText.textChanges() .subscribe { updatePins(it.toString()) pinChangesRelay.accept(it) } overlay.clicks().subscribe { focus() }
}

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

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

Итоги

Эти принципы не всегда применимы. Приведу условия, в которых они будут хорошо работать.

  • У разработчика есть возможность влиять на функционал. В противном случае ему остаётся просто выполнять поставленную задачу.
  • Разработчик работает в продуктовой компании, где фичи активно деливерятся и быстро выходят в релиз, а также быстро проверяются гипотезы касательно этих фич. В таких условиях эти принципы проявляются в полную силу, поскольку опять же мы изначально не можем быть на 100% уверены, какие обновления понравятся пользователям, а какие — нет.
  • У разработчика есть возможность декомпозировать задачи. Эти принципы являются логичным решением в ситуации, когда у продакт-менеджеров и разработчиков есть двусторонняя связь, что позволяет обеим сторонам находить то, что можно и нужно переделать.
  • Аутсорс. В редких случаях заказчику может быть интересно предложение, к примеру, сократить время исполнения задачи за счёт упрощения части функционала.

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

Вам нужно постараться декомпозировать свою задачу на несколько маленьких подзадач, а затем оценить их. У вас могут быть проблемы с UI/UX, как в большинстве примеров, а могут быть проблемы с бизнес-логикой, как в примере с тултипами.

Далее вы обсуждаете с коллегами, как их можно решить. После этого вы сможете точно узнать, где будут проблемы. Или, может, вы просто не знаете о каком-то простом решении, которое уже известно вашим коллегам. Может, что-то можно упростить. Если они довольны, то реализуете своё предложение. На следующем этапе согласовываете с продактами альтернативное решение.

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

Ссылки для связи со мной:

P. S. Несомненно, у этих задач есть и другие решения. Возможно, вы знаете те, которые лучше предложенных. Цель статьи — показать, как мы в реальной жизни принимаем решения и какой логикой руководствуемся. Может быть вы знаете более удачные решения для этих задач? Поделитесь примерами в комментариях.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть