Всем привет! Меня зовут Анатолий Варивончик. Я работаю в Badoo уже больше года, а мой общий стаж Android-разработки — более пяти лет.
В своей практике я и мои коллеги часто сталкиваемся с необходимостью тестировать идеи максимально быстро и просто. Мы не хотим тратить много сил на реализацию, поскольку знаем, что если эксперимент неуспешен, то код мы выкинем.
В этой статье я на реальных примерах покажу, как мы действуем в таких ситуациях и какие принципы помогают нам сделать выбор в пользу того или иного решения задачи. Разбор примеров должен помочь понять наш паттерн
План такой:
- Принципы подхода к разработке в Badoo.
- Примеры из практики.
- Дизайн-система.
- Когда стоит применять описанные принципы.
Эта статья — текстовый вариант моего доклада на 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-масками. Кроме того, придётся ещё поиграться с ними, посмотреть краевые случаи, протестировать. Я думаю, что в целом на это потребуется примерно четыре часа.
Вариант С. Мы просто берём несколько фиксированных типов иконок и в зависимости от прогресса показываем одну из них. К примеру, если прогресс пользователя меньше 0,5, то будет отображаться незаполненная иконка. Очевидно, что это решение не удовлетворяет на 100% требованиям. Но для его реализации нужно написать всего пять строчек кода и получить от дизайнера три иконки.
fun getBackground(goal: GoalInProgress) =
when (goal.progress) {
in 0.0..0.5 -> R.drawable.ic_not_filled
in 0.5..0.99 -> R.drawable.ic_half_filled
else -> R.drawable.ic_full_filled
}
К тому же это решение оптимально в условиях жёсткой нехватки времени (как было у нас, когда мы релизили функционал лайвстримов). Оно не отнимает много времени — вы просто выкатываете его и в следующем релизе заменяете на корректное красивое решение. Собственно, как мы в своё время и сделали.
Пример 2. Строка ввода номера телефона
Следующий пример со вводом номера телефона. Отличительные особенности:
- префикс страны находится чуть левее;
- префикс невозможно удалить;
- есть отступ;
- префикс некликабельный.
Давайте думать, как это можно реализовать.
Вариант A: написать кастомный TextWatcher, который реализует нужную логику. Он будет держать этот префикс, держать пробел, управлять позицией курсора.
Вариант B: разделить этот компонент на два независимых поля. С точки зрения UI это будет один и тот же компонент.
Вариант 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 и EditText. Программно на TextView мы наложили бэкграунд таким образом, чтобы получать именно тот дизайн, который ожидают продакты.
Единственное, о чём стоит подумать, — о том, что в 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 тут помочь не сможет. В этом случае нужно писать дополнительную логику, а также просить сервер, чтобы он возвращал все возможные локальные алиасы для данной страны. Чтобы решить эту проблему, придётся вносить изменения в протокол коммуникации с сервером и после этого реализовывать этот функционал на сервере. Это займёт больше времени, чем что-то сделать на клиенте. Кажется, что есть решение проще (и мы к нему придём).
Вариант С. Убираем лимит на количество символов — и дальше сервер валидирует. Это отличная опция. Ничего страшного, что префикс отображается два раза. Если пользователь переходит на следующий шаг и номер телефона валидно определяется, то, в принципе, никаких проблем.
Но всё-таки есть одна загвоздка. Представьте, что пользователь не использует автозаполнение, а просто вводит свой номер телефона. В таком случае, если есть лимит на количество символов, ему будет гораздо сложнее случайно продублировать цифру — в конце он увидит, что последняя цифра не напечаталась. Поэтому мы решили не использовать этот способ.
Вариант D. Использование N символов с конца показалось нам подходящим решением.
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 для обеих сторон.
Ссылки для связи со мной:
- GitHub: https://github.com/Nublo;
- Telegram: https://t.me/anatolv;
- блог про переезд в Лондон (на правах рекламы :)): https://vk.com/london_relocate.
P. S. Несомненно, у этих задач есть и другие решения. Возможно, вы знаете те, которые лучше предложенных. Цель статьи — показать, как мы в реальной жизни принимаем решения и какой логикой руководствуемся. Может быть вы знаете более удачные решения для этих задач? Поделитесь примерами в комментариях.
Автор: Анатолий Варивончик