Как написать отличную ленту новостей ВКонтакте за 20 часов
Всем привет! Недавно прошёл конкурс от ВКонтакте Mobile Challenge, и моя работа заняла призовое место. По заданию второго этапа необходимо было разработать ленту новостей для мобильных устройств, а главными критериям оценки были плавность скроллинга и загрузки постов. Ещё когда участвовал решил, что вне зависимости от конечного результата, попробую написать статью о подходе к реализации ленты и о своих эмоциях и переживаниях во время конкурса. Что я и сделал. Под катом советы и рекомендации по разработке новостной ленты в режиме сторителлинга.
О конкурсе
В первую очередь стоит рассказать немного о конкурсе. Как мне известно, компания ВКонтакте ежегодно устраивает подобные мероприятия для мобильных разработчиков. Сам я уже участвовал в 2012 и 2013 годах. Задачами были соответсвенно разработка чата и фильтров для изображений. В 2013 мне удалось дойти до финального раунда и и выиграть 100 000 рублей, что показалось мне тогда очень неплохой суммой за 4 выходных интересной работы.
И, увидев рекламу в социальной сети, я решил, что нужно попробовать, ведь самое крутое — это доказать себе, что ты ещё способен писать качественный код в очень сжатые сроки).
В конкурсе было 2 этапа: в первом предлагалось пройти тест из 30 вопросов, решить 2 олимпиадые задачки и одну качественную (т.е. решение — это набор твоих мыслей, оформленных в виде текста). Во второй раунд из 1000+ человек прошли всего 112 (речь идёт только про iOS, по Android чуть меньшие значения) и начался основной этап — разработка приложения.
По заданию нужно было сделать ленту новостей, интеграцию с API ВК писать самостоятельно, лента должна была быть очень плавной на всех устройствах, были определённые требования по выводу контента (посты с изображениями и каруселькой, счётчики лайков, шэров, количество просмотров, а также возможность сворачивать и разворачивать пост при определённых условиях).
Макеты были выложены в Figma. Для конкурса это хороший выбор — потому что по количеству активных пользователей сразу чувствуется конкуренция (а в субботу даже выбило, т.к. одновременный лимит в 50 подключений был превышен).
Вообще организация конкурса была гладкой за исключением нескольких моментов: сразу после публикации задания ссылка на макеты оказалась битой, далее как я уже сказал выше — в субботу вход в Figma был заблокирован (эту проблему очень быстро починили), ну и после окончания мероприятия достаточно долго пришлось ожидать результатов. Но весь негатив сглаживает подобный пост:
План победы и стратегия
Очень важный и интересный блок, если вы участвуете в конкурсах, либо хотели бы попробовать. Прежде чем участвовать рекомендую каждому задуматься какую цель вы преследуете и какой результат хочется получить на выходе. Для себя я решил ещё перед тем как приступить к конкурсу, что хочу его выиграть (т.е. занять призовое место). И для того, чтобы это сделать нужна стратегия:
Ниже приведена формула выигрышной стратегии (она, мне кажется, универсальная и простая, но уверен, что ей не следует и большинство участников):
- Прочитайте задание очень внимательно. Через несколько часов прочитайте его ещё раз (я несколько раз попадал в ситуацию, когда не с первого раза воспринимал задачи). А за пару часов до сдачи ещё разок на всякий случай.
- Составьте вопросы и задайте их организаторам (обычно требования к заданию не учитывают множество ситуаций, либо выставляются очень поверхностно). Я так и сделал, получив ответы в стиле «на ваше усмотрение».
- Составьте план проекта. Да, участие в конкурсе или хакатоне — это выполнение проекта. У проекта есть цель, сроки, ресурсы и бюджет. Время строго ограничено, и двигать конец сдачи невозможно. Дополнительных людей приглашать нельзя — конкурс же не командный. Всё что у вас есть — это ваши собственные чел. / часы. Сразу определитесь сколько вы готовы потратить (подумайте хорошо сколько уйдёт на сон, еду, прокастинацию, перерывы, и да, ваша производительность будет падать ввиду усталости и нервов от ощущения беспомощности перед неумолимо приближающимися сроками).
- В плане должны быть задачи, их приоритеты и веса (я использовал оценки вида 1, 2,4, 8). Оценка включает в себе длительность, нежелание её выполнять и потенциальные риски (сложность, непонятные условия, нет опыта подобной разработки и т.д.). Далее вы составляете подробный план (или roadmap задач) — сперва самые важные и сложные (оставьте лёгкие и понятные на конец).
- Выберите несколько крупных интервалов или вех (отлично подходят дни — у нас было 3 дня). И при прохождении каждого из них смотрите на свой план работ, декомпозируйте задачки, сортируйте их, а главное — с наслаждением вычёркивайте те, которые уже сделали.
Теперь совет (я именно так и поступил): внимательно посмотрите как сформулировано задание, есть ли в нём слова «обязательно» и «дополнительно». И какие критерии оценки будут у пунктов из «дополнительно», т.е. будут ли они перекрывать своим плюсом отсутствие реализованных требований из секции «обязательно». Я сомневаюсь. Поэтому концентрируйтесь на секции «обязательно». Я для себя вообще осознанно вычеркнул секцию «дополнительно» и даже не собирался к ней приступать. - При реализации будьте внимательны к деталям. Например, если в задании много интерфейсных форм — то делайте их так, как они отображены на макетах. Обратите внимание на отступы, шрифты, тени, межстрочный интервал, и т.д. Это несомненно прибавит вам очков за аккуратность и соответствия. Кстати, в Figma в отличие от Zeplin есть очень широкий экспорт настроек, например, можно получить неплохую NSAttributedString.
Задание было открыто в ночь с четверга на пятницу. Пятница — рабочий день и заниматься конкурсом на работе возможности и желания не было. Поэтому после возвращения с работы в пятницу вечером я впервую очередь занялся планированием, оценкой и высчитыванием сколько реального времени я смогу потратить.
Первоначальный план был следующим:
День | Время | Задачи |
Пятница | 4 часа | Авторизация, получение данных для профиля и ленты. |
Суббота | 9 часов | Прототип ленты с отображением красных квадратиков разной высоты, подгрузкой их сверху по pull-to-refresh и бесконечной загрузкой в глубину истории, подготовка всех моделей и сервисов (работа с API, запросное кеширование, кеширование изображений). |
Воскресенье | 9 часов | Вывод реальных данных (текст, одиночные изображения и карусельки), счётчики лайков, просмотров и финальный тюнинг по макетам. |
Техника реализации ленты
Ура! Господа разработчики вы нашли нужный блок. Здесь пойдёт речь про реализацию основной части задания — новостную ленту.
Вообще, если абстрагироваться, то новостная лента — это лишь прикладная реализация, обобщённо речь пойдёт про создание списка разнородных сущностей (т.е. у каждой может быть своё отображение, требующее дополнительных расчётов и своя высота, которая может изменяться динамически даже после отображения поста), плюс список умеет обновляться через pull-to-refresh и бесконечно подгружать данные снизу.
Именно общую задачу я и решил решать в первую очередь. Первый шаг — анализ существующих решений. Ориентироваться нужно на сильнейших, поэтому я выбрал 3 приложения для сравнения: Vkontakte, Facebook, Instagram.
Итак я хотел провести исследование 6-и самых острых и критичных проблем:
- Pull-to-refresh (добавление в начало списка)
- Плавность загрузки истории (добавление в конец списка)
- Fast scrolling (попеременно пальцами разгоняем ленту настолько, насколько позволит сила трения)
- Scroll to top (накапливаем большую историю и нажимаем на статус-бар)
- Есть ли динамическое раскрытие (увеличение) поста и какая при этом анимация
- Как работает лента в режиме почти полной потери пакетов (Developer –> Network Link Conditioner –> Very Bad Network)
В целом все приложения ведут себя хорошо, но несколько проблем я всё-таки подметил.
Посмотрите, например, как ведёт себя pull-to-refresh в ВКонтакте, если при обновлении не отпускать палец и аккуратно тянуть ленту вверх (см. на gif слева).
Вы же тоже видите этот скачок?
У Instagram и Facebook такого поведения не обнаружено.
А ещё заметная разница есть при раскрытии поста. У Facebook и Instagram это происходит с плавной анимацией, а ВКонтакте просто обновляет размер по нажатию.
Итак наша задача — сделать плавный скролл, загрузку постов, а также раскрытие с анимацией.
Первый шаг — выбор концепции и создание прототипа на красных квадратиках (интересно, почему я всегда интуитивно выбираю красный цвет для прототипов. Это у всех так?).
Основная моя идея в повышении производительности заключалась в отказе от всех наворотов и изысков, которые Apple вводила многие годы и буквальный откат до разработки под iOS 3. А это значит:
- Отказ от AutoLayout (да, он медленный, не верите — могу доказать в комментариях)
- Отказ от автоматического расчёта высоты ячеек таблицы
В итоге была выбрана следующая концепция:
Давайте чуть подробнее.
В главном потоке мы обновляем интерфейс, обрабатываем действия пользователя (скролл вниз и pull-to-refresh, клик по посту для его увеличения) и триггерим сервис-модель для получения и подготовки данных.
Обращение к API. Здесь всё просто — NSURLSession с настроенным NSURLCache. При этом важно, что при загрузке истории вниз, мы пользуемся кешем, а при pull-to-refresh мы его инвалидируем. Ровно такое поведение я подсмотрел у VK и Facebook.
Парсинг и создание моделей. Здесь расположена логика по обработке конкретного запроса, выбрасыванию ошибок и возврат транспортных моделей с данными.
Расчёт моделей представления. Самый важный шаг для оптимизации производительности. Здесь транспортные модели преобразуются в сущности с постфиксом ViewModel. ViewModel хранит в себе полностью подготовленные данные для отображения — AttributedString, посчитанную высоту ячеек (для 2-х состояний: свёрнутого и развёрнутого), ФИО в виде строки, строку с датой (уже преобразованную из DateFormatter).
Только после этого происходит возврат данных в главный поток. Реализовать подобную логику на Swift — очень удобно и просто. Делаем ViewModel структурой. Структуры при передаче на новый поток копируются.
Отлично концепт готов, теперь давайте поговорим про сам механизм вывода.
Сперва нужно было выбрать на чём реализовывать ленту — UITableView или UICollectionView (на свою реализацию точно бы не хватило отведенного времени). Очевидно, что UITableView подходит для вывода списка, но меня очень беспокоило не будет ли проблем с увеличением списка сверху, снизу, а также увеличением ячейки содержимого. Поэтому принял решение идти от простого к сложному — т.е. если у UITableView проблем не будет найдено — её и оставляем.
Первым делом я решил определиться с pull-to-refresh. Для реализации данного паттерна существует UIRefreshControl. Когда-то лет 5–6 назад я писал свою реализацию, используя UIActivityIndicator и изменяя contentInset у таблицы. Так вот, пожалуйста, не делайте так сейчас! UIRefreshControl имеет удобный компактный интерфейс и забирает тучу костылей, которые непременно придётся сделать. Использовать его очень просто:
Однако при его использовании становится понятно, откуда растут проблемы, показанные выше для клиента VK. Похоже что они существуют в самом компоненте. Быстро попробовал поискать в чём может быть причина и какие есть решения.
Советы из интернета говорят (такой или такой):
- проверять при вызове endRefreshing обновляется ли сейчас контрол (isRefreshing)
- использовать именно UITableViewController (потому что в этом случае вызывается магический приватный метод)
Попробовал и то, и то — никакого положительного эффекта у меня не появилось. Я расстроился, но решил не тратить время и двигаться дальше. Кстати, если кто-то знает как побороть данную проблему — напишите, пожалуйста, в комментариях.
Следующий шаг — реализация подгрузки данных снизу.
Сперва у меня получилось не очень круто — при загрузке снизу был ужасный лаг (прыгал contentSize у таблицы видимо):
И это ад :-) С таким результатом точно не выиграть. Но быстрый поиск дал мне потрясающую подсказку, о которой я забыл:
И вуаля:
Осталось решить как анимировать высоту ячейки.
Первая идея, пришедшая в голову — бегать по visibleCells и в блоке анимации увеличивать высоту. Но ещё на стадии анализа эту идею стоит отбросить — проблема в том, что необходимо будет синхронизировать заданную в UITableViewDataSource высоту и что-то делать с contentSize (а это крайне не рекомендуется Apple).
Вторая мысль, пришедшая в голову, оказалось правильной — у UITableView есть методы insert / reload / delete, которые могут быть исполнены в блоке анимации:
Не забываем, что в heightForRowAt нам также нужно добавить новую высоту:
Кажется, что всё, но не совсем! В анимации разворачивания поста остался один тонкий момент. Посмотрите за текстом в Интаграмме или Facebook — он плавно появляется сразу при увеличении высоты. Что делать? Рендерить его построчно? Если дойти до уровня NSTextContainer, то возможно и появится подобная возможность. Но мне показалось, что не такая уж и плохая идея (за такое-то время) выводить весь текст сразу. Просто выставить clipToBounds у superView, в которой будет располагаться UILabel, выводящий наш текст. И подход сработал! Ох, данная анимация сильно меня воодушевило и открылось второе дыхание. Ведь такой анимации нет в нативном клиенте ВК. Значит шансов на победу она должна прибавлять :-)
Остальные детали при реализации ленты не так интересны. Но вы можете спросить их в комментариях. А ещё нет ничего страшного в выкладывании кода. Вот он — github.com/katleta3000/vkmobilechallenge. Простите, что, но не причёсан и там местами есть Magic Numbers (но это же конкурс на скорость, чем-то пришлось пожертвовать). Кстати, там можно попрыгать по коммитам (названия достаточно понятные).
Результат можно посмотреть здесь — www.youtube.com/watch? v=Md8YiJxSW1M&feature=youtu.be (качество конечно сильно потерялось, но лучше чем в gif для демонстрации именно smooth scroll)
Статистика, результаты, выводы
Всё время конкурса работал по таймеру. Вышло 20 с половиной часов чистого времени кодинга. Трекинг времени помог в 2-х вещах: как вы помните были же оценки в абстрактных единицах, так вот к середине последнего дня уже была неплохая статистика по трекингу и можно было куда точнее распланировать оставшиеся финальные задачи. А, во-вторых, удалось выявить и доказать закономерность «концентрации за один присест». Каждое измерение — это новая итерация, так вот получилось, что у меня было 68 итераций написания кода. Если усреднить, то получилось бы 18.5 минут. Но по факту, в первый день итерации были в среднем по 25 минут, а вот уже к концу второго дня по 7:-) Начинаешь сходить с ума, сильно нервничать, устаёшь и производительность падает. Подобные данные хорошо помогут в следующий раз.
Лично я использовал программу Hourly (можно скачать и попробовать) — просто и решает необходимую задачу (да ещё и сам её разрабатываю):
Из приятных бонусов — за каждую закрытую задачку тебе показывают какой-то очень приятный подбадривающий экранчик типа таких:
Забавно, что именно при завершении задачки «VK Mobile Challenge» мне показался следующий экран:
И да! Так оно и произошло :-) Переходим к результатам.
Не будем тянуть Персика за плюш. Персик, кто не знал, это имя котика — главного персонажа ВК. Очень крутой подарочный мерч:
Я занял 4-е место и получил 175к рублей. (Да и вы, наверняка, спалили уже картинку на Хабра-катом). И да, я конечно же доволен. Я достиг своей цели :-) А это несомненно безумно приятно.
Напоследок хотел бы сказать спасибо vkontakte — всё-таки конкурс был крутым и отлично организованным. А всем читателям рекомендую принимать участие в челленджах и хакатонах — это отличный способ бросить вызов самому себе и посоревноваться с топовыми разработчиками по всему миру (ну или хотя бы русскоговорящему комьюнити).