Pull to refresh

Как написать отличную ленту новостей ВКонтакте за 20 часов

Development for iOS *Development of mobile applications *Swift *Hackathon
Всем привет! Недавно прошёл конкурс от ВКонтакте 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-и самых острых и критичных проблем:

  1. Pull-to-refresh (добавление в начало списка)
  2. Плавность загрузки истории (добавление в конец списка)
  3. Fast scrolling (попеременно пальцами разгоняем ленту настолько, насколько позволит сила трения)
  4. Scroll to top (накапливаем большую историю и нажимаем на статус-бар)
  5. Есть ли динамическое раскрытие (увеличение) поста и какая при этом анимация
  6. Как работает лента в режиме почти полной потери пакетов (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 – всё-таки конкурс был крутым и отлично организованным. А всем читателям рекомендую принимать участие в челленджах и хакатонах – это отличный способ бросить вызов самому себе и посоревноваться с топовыми разработчиками по всему миру (ну или хотя бы русскоговорящему комьюнити).
Tags:
Hubs:
Total votes 23: ↑21 and ↓2 +19
Views 12K
Comments Comments 25