Как написать отличную ленту новостей ВКонтакте за 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-и самых острых и критичных проблем:

    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 – всё-таки конкурс был крутым и отлично организованным. А всем читателям рекомендую принимать участие в челленджах и хакатонах – это отличный способ бросить вызов самому себе и посоревноваться с топовыми разработчиками по всему миру (ну или хотя бы русскоговорящему комьюнити).
    Поделиться публикацией

    Комментарии 25

      +1
      Поздравляю с отличным результатам! Очень мотивирующая статья. В коде увидел несколько приятных мест: склонение слова (можно сделать через регулярку), прокидывание сервиса в UITableViewCell с поясняющим комментарием, и круглое изображение через UIGraphicsContext.
        0
        Спасибо! :-)
        0
        Поздравляю! Мне показалось, что на ios задание было посложнее, чем на android.
          0
          Спасибо! К своему стыду даже не знал, что задания различались :-)
          Единственное знаю, что работ по iOS было сдано больше чем по Android.
          0
          Вот как в вк появляется новый дизайн и новые фичи)
            0
            круто! я по android участвовал, понравились задачи 1 и 2 этапа, но не понравился подход к фидбеку по сданным работам.
            Hourly, кстати, прикольно выглядит, на Android не планируете выпустить?
              +1
              Спасибо. В ближайших планах нет, но если есть желающие Android-разработчики – то можем обсудить :-)
              0
              Поздравляю вас!
              Пару моментов хочу прояснить по реализации.
              Имхо для плавной анимации раскрытия ячейки можно просто поменять соответствующий state в модели на «раскрыть» и вызвать метод tableView.reloadRowsAtIndexPathWithAnimation(название метода по памяти пишу). Вроде должно прокатить и намного меньше кода.
              И если для таблицы важна скорость работы, то лучше значения модели в ячейку сетить в willDisplayCell, а не cellForRow.
              Еще интересно было ли ограничение на использование сторонних библиотек?
              А вы показ видео не реализовывали? Интересно как разномастные источники (ютуб, рутуб, вк) лучше всего обрабатывать.
                0
                Поздравляю вас!
                Спасибо.
                Имхо для плавной анимации раскрытия ячейки можно просто поменять соответствующий state в модели на «раскрыть» и вызвать метод tableView.reloadRowsAtIndexPathWithAnimation(название метода по памяти пишу). Вроде должно прокатить и намного меньше кода.
                Не пользовался, но выглядит подходящим. Попробую, спасибо за совет.
                И если для таблицы важна скорость работы, то лучше значения модели в ячейку сетить в willDisplayCell, а не cellForRow.
                И да, и нет. Можете попробовать – в моём случае прироста производительности заметного не наступит. Я пытался сконцентрировать 20% усилий на решение 80% проблем производительности. Но вы верно заметили, что правильнее сеттить данные в willDisplayCell.
                Еще интересно было ли ограничение на использование сторонних библиотек?
                Нельзя было использовать сторонние библиотеки кроме VKSDK исключительно для авторизации.
                А вы показ видео не реализовывали? Интересно как разномастные источники (ютуб, рутуб, вк) лучше всего обрабатывать.
                Неа, по заданию нужно было только текст, раскрытие текста, изображение и карусельки изображений.
                  0
                  И если для таблицы важна скорость работы, то лучше значения модели в ячейку сетить в willDisplayCell, а не cellForRow.

                  Популярное заблуждение
                  +1
                  Почему для парсинга джейсонов не использовался Codable?
                    0
                    Исключительно без причины. Нервная атмосфера, на скорость – местами идёшь в лоб. И спасибо за замечание :-)
                      0
                      Спросил исключительно из-за того, что, с Codable, кажется, было бы как раз быстрее :)

                      И да, забыл поздравить с достижением цели и отличной статьёй!
                        0
                        Спасибо!
                    0
                    А можно где-нибудь узнать обоснование фикса от лага подгрузки снизу?
                      +1
                      После обновления таблицы через reloadData у неё устанавливается contentOffset.y c учётом estimatedRowHeight, а не реальной высоты ячеек из heightForRow. estimatedRowHeight по умолчанию имеет константное и не нулевое значение automaticDimension. А ячейки с динамическим контентом имеют разную высоты. Вот и получается прыжок.

                      При установке estimatedRowHeight в ноль вообще отключаются estimated height и всё считается через heightForRow.

                      Из документации
                      Set the value to 0 to disable estimated heights.
                        0
                        Спасибо
                      +1
                      Очень круто, спасибо за пост!

                      По поводу UIRefreshControl. Пофиксить его некоторые баги нельзя, особенно если хочется юзать спокойно с любыми наследниками UIScrollView без особенных UIViewController. Я навидался кривого поведения с ним (даже эпловых аппов), немного посмотрел как он устроен внутри через hopper и выкинул его нафиг. Так что совет всегда юзать его не самый лучший. Я бы сказал так, если пофиг на баги и хочется быстро поддержать рефреш — юзать, если хочется хорошо, то не стоит)
                        0
                        Спасибо! А не видел где-нибудь хороших собственных реализаций посмотреть?
                          0
                          Я свой велосипед сделал. Руки никак не дойдут опубликовать)
                            0
                            Я буду ждать :-) Расскажи потом на CocoaHeads
                        0

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

                          0
                          Привет! Можешь чуть подробнее раскрыть мысль?
                          Это просто добавить свои типы ячеек для карусельки и одиночной картинки, либо какой-то «кеш» отдельный UIView / CALayer?
                            0

                            я вижу 3 варианта (если не юзать всякое сторонее)
                            1 — сделать для каждого типа свою ячейку (чисто текст, текст с картинкой, картинка), и сразу зарегистрировать их в таблице
                            2 — сделать базовую ячейку которая подстраивается под контент(в принцыпе так как в твоем коде но большее унифицировано, чтоб не запутатся в куче переменных), как вариант — каждый тип контента на своей вьюхе, которая добавляется на ячейку если такой тип контента есть в посте. из таблицы в cellForRowAtIndexPath получать пытаться получить ячеqку под тип контента dequeueReusableCellWithIdentifier, и если такой нет — создать такую.
                            как результат, действий с addSubview/removeFromsuperview будет минимум, только при новом создании ячейки
                            ну и еще пул uiimageview для карусели картинок можно
                            3 — для самых изощренных — рендерить пост как картинку, и отображать чисто картинку) с каруселями нужно будет включать смекалку

                              0
                              Тут главное не упороться и не пойти в over-инжиниринг :-)
                              Насколько я знаю, CALayer умеют кешироваться. Т.е. и на уровне UIKit и на уровне GPU в каком-то виде.
                              Обычно проседают либо расчёт размеров, либо «тяжёлые» allocations (например, UIImage, NSAttributedString, NSCalendar и др.).

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое