Бардак на старте: постмортем на скорость запуска iOS-приложения

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



    Запускается долго


    В приложении много кода, много классов и они как-то связаны. Чтобы управлять этими связями, мы использовали Dip (читай: Swinject или любой другой DI фреймворк без кодогенерации).

    Работает это так: на старте приложения все зависимости запихиваются в «контейнер», а потом из него извлекается нужный класс со всеми проставленными зависимостями. Это занимает время на старте, это занимает время при открытии любого экрана.

    Зато картинки красивые


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

    Вот наши сплеши. Красивые, но скрывающие проблему, которую мы даже не пытались решить.



    После очередного релиза у нас стало тормозить перелистывание карточек в пейдж контроллере. Тайм профайлер показал, что долго извлекаются зависимости при появлении нового экрана. Почему так? Понять невозможно. Dip очень сложно дебажить из-за одинаковых абстрактных вызовов. Мы попытались разбить один общий контейнер на много маленьких, но стало только хуже. В итоге мы отключили перелистывание карточек и продолжили новогодние обновления.

    Вот так выглядит Dip в профайлере. В конце списка Call stack limit reached. Сделать с этим что-то разумное невозможно.



    Вылетает на 4S? Позже поправим


    В это время в бэклоге валялся баг «не запускает релизную сборку на 4S», хотя дебажные запускались. Связи проблем никто не заметил, подняли минимальную версию iOS до 10 и тоже отложили изменения. На 4S мало пользователей, правда ведь?

    Выпиливаем Dip: не на старте, а при компиляции


    Тем не менее, стало ясно, что нужно выпиливать Dip. А на что менять? Очень своевременно нам попалась статья Dependency Injection in Swift.

    Работает просто: пишем пачку функций resolve() с разным типом (компилятор сам разберётся). Так связи перестанут просчитываться на старте, а компилятор даже сможет оптимизировать код. Это полезно и для разработки: если неправильно описал зависимости, то узнаешь об этом на запуске, а не при открытии экрана. Конечно, есть и проблемы: если функция resolve() не поняла тип, то выдаст вот такую бесполезную ошибку с сотней кандидатов:



    Мы сделали это ещё в ноябре. Изменений было ОЧЕНЬ МНОГО, а мы в этот момент запускали новый продукт «Комбо» и готовились к последним изменениям перед Новым годом. Новый код оказался в проекте перед Новым годом, но мы не выпускали его до января из-за предпраздничного кодфриза. За это три недели мы вдоволь попользовались программой в тестовом режиме, нашли проблемы и устранили их.

    Так выглядит код зависимостей сейчас. Грязно, но работает. 450 мест с регистрацией и 1400 мест с извлечением.



    Функционально код одинаковый, отличается только способ работы с ним. Разница в скорости видна на всех моделях. На ХS — в два раза быстрее, а на SE смотрите сами:


    Так мы ускорили не только старт, но и открытие экранов. До изменений у каждого экрана уходило по 0,3–1 секунды только на зависимости.

    Новый год без пиццы


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


    Гипотеза: это iOS дропает приложения, если они долго запускаются. Подтверждалась версия тем, что все отзывы были со старых девайсов: 5, 5S, 6. Количество таких отзывов сильно росло, ведь пицца — это праздник, люди чаще заказывают её на Новый год. Команда волнуется, продакт волнуется, но катим новую версию только в январе.

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



    Иногда сложно донести важность технической задачи до бизнеса: нет метрик, не ясна важность, не спрогнозировали опасность. Бизнес может решать проблему разными способами: так мы рисовали красивые картинки, а не ускоряли приложение. Но перформанс важен для каждого пользователя. Он сильно влияет на:

    • Ожидание. Если приложение долго запускается, то сколько они будут везти пиццу?
    • Удовольствие от использования. Почему тупит на каждом экране?
    • И даже на стабильность работы. «Приложение на запускается!!! Разработчики, вы там тестируете вообще?»(с).

    И обходите стороной Dip, Swinject и другие фреймворки, которые работают с контейнерами в реальном времени, если у вас крупный проект и много зависимостей.
    Чтобы не пропустить следующую статью, подписывайтесь на канал Dodo Pizza Mobile.
    Dodo Pizza Engineering
    235,87
    О том как IT доставляет пиццу
    Поделиться публикацией

    Похожие публикации

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

      0
      Тоже стояла либа для DI, приложение на реакте. Страницы загружались по 4 секунды, выпилили, вздохнули свободно. Эти паттерны похоже в научно исследовательских институтах придумывают
        0

        Подскажите, что это за либа?

        0
        «постмортем» в заголовке, это что? «постсмерть»? :)
          +3

          Вот в этой статье хорошо рассказали о Post Mortem: https://habr.com/ru/post/332762/


          После завершения (с любым итогом) проекта менеджер обязан написать т.н. «post mortem». Цель очевидна: сделать хорошее и плохое, что было в ходе проекта явным как для себя, так и для других. Если бы жанр таких post mortem стал более популярен у нас, уверен, процент успешных проектов от этого вырос бы, какими бы ни были критерии «успешности».
            0
            Спасибо, не знал. Думал, ошиблись.
              +1

              Почему не применить всем понятное слово "отчёт"? Отчёты бывают и о ходе разработки, и о результатах. В отчёте описываются возникшие, решённые и нерешённые проблемы.
              На одном из моих рабочих мест отчёт на 30-40 листов после каждого проекта был обязательным требованием, никаких "постмортем".

            +5

            Не мог пройти мимо. Я создатель библиотеки DITranquillity, может видели статьи. И работаю на проекте где Регистраций больше 1000. И на проекте встала проблема времени запуска на 5c уже более двух лет назад. Из них 3.5 секунды был DI. В силу чего в 3 версии своей Либы я делал упор на скорость, и время снизилось до 0.02 секунд на старте для DI на томже 5c.


            Так что выводы не верные — библиотеки с исполнением в реальном времени могут все делать быстро, просто никто их не оптимизирует. Я в своей либе этим обеспокоился и проблем видимых не стало 1000регистраций и 10к получения объекта работают быстрее чем создание вьюшки.


            А у swinject кстати график экспонициальный… То есть чем больше регистраций, тем больше время получения объекта, и это время где-то на 400 регистраций становиться уже не совместимыми с жизнью.


            Ну и ссылочка на либу. Там можно найти performance тесты и проект для сравнения скорости со swinject. https://github.com/ivlevAstef/DITranquillity

              +1

              Спасибо! Про библиотеку не слышал, почитаю и попробую. Вам лайк, репозиторию звезда.


              Похоже, именно вас я и пытался найти этой статьей. У меня несколько вопросов к фреймворкам, а вы разбираетесь в их работе:


              1. Что делать с отладкой и профилированием? Логи и стектрейс становятся очень длинными, прочитать и вычленить суть сложно.
              2. Какие хорошие практики по организации развесистой структуры есть? Что можно почитать по теме? Например, наша проблема: пользователь выбрал новую страну и это аффектит половину зависимостей.
              3. Какие ограничения на зависимости? В какой момент может случиться комбинаторный взрыв? Вы приводите пример на 1000 регистраций, но вы и фреймворк свой сделали, нет сомнений что у вас все вычищено до блеска. Как не тупить обычным разработчикам?
              4. Допустим, я подключил библиотеку и использую её 5 лет. А потом что-то сломалось и мне понадобилось починить/переписать. Что мне делать с такой кучей кода?
              5. Зачем вообще решать эти проблемы в рантайме? Что я приобретаю, в сравнении с ручным управлением? Что теряю?

              Много вопросов, вывалил как есть.


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


              container.register(YourViewController.self)
                .injection(\.presenter)
                +1

                Да вопросом много, и разных. Ответ будет большим, извиняюсь заранее. Для тех кому не хочется читать всё, советую прочитать пункт 5 — там поднята важная проблема описанного решения в статье.


                1. Эту проблему я решал в своей библиотеке самой первой, так как до этого имел дело с Typhoon. Яб даже сказал — это причина возникновения библиотеки :) Вначале были исключения, но потом перешел на логирование и внятные fatalError. То есть, если что-то пошло не так, то есть относительно внятное описание, и с 80% вероятностью в описании будет указано место куда смотреть. Конечно, для этого надо уметь читать мой ломанный английский :D
                  По другим либам – все грустно, иначе я бы не стал писать свою. В прочем сейчас уже появились CompileTime либы: Weaver, needle. Они частично решают проблему – частично, так как графы зависимостей бывают сложными и с циклами, а это уже надо проверять отдельными функциями.


                2. Почитать… ну "Чистая Архитектура" можно. Я еще люблю "ООАД" от Гради Буч. А так, каждое решение влияющее на большую часть приложения надо принимать командно, и желательно до того как начали его делать :)
                  В вашем случае не понятно, почему смена страны ломает зависимости. Возможно, у вас есть распространенная проблема, когда в зависимости случайно попадают и данные.
                  Из возможных проблем вижу лишь — у разных стран доступны разные экраны, и некоторые экраны выглядят настолько по разному, что у них много реализаций. Тут или умение работать с container-ами (кто мешает создать новый? :) ), или не подмешивать техническую бизнес логику в DI, и решать это в прикладном коде используя или аля фабрики, или обычные условия в router/coordinator/navigator.
                  Я бы склонял к последнему, так как, если подмешивать в DI бизнес логик, четвертая проблема, указанная вами, выльется в катастрофу.


                3. Слегка с конца — в команде 25 человек, и более того я уходил с проекта на 2 года. Так что использование DI в проекте далеко от идеала :) Например, даже когда я был на проекте, и мы переходили на третью версию библиотеки, которая начала искать проблемные циклы, то оказалось, что у нас есть циклы длиннее 15 объектов, и при создании одного простого экрана, приходится создавать более 50 зависимостей. К слову сказать два года назад Swinject просто ломался на подобных графах, и просто крашился, из-за внутренних ошибок.
                  По скорости
                  Там видно, сколько занимает сам процесс регистраций, и сколько занимает процесс получения 50000 раз объекта, при разном количестве регистраций. Можно увидеть, что сам процесс регистрации зависимостей не линеен, и где-то на 4000 регистраций время станет не приятным. А вот получение объектов линейно зависит от количества регистраций, и даже на 4000 тысячах ещё приемлемое. Но я не представляю себе проекты даже с 2000 регистраций на Swift в ближайшие лет 5, а там и мощности будут быстрее, и сам язык быстрее (по профайлу самое медленное место в библиотеки это ARC), и у меня еще есть идеи как оптимизировать. Ну, и на чистом 'C' всегда можно написать.
                  "Как не тупить обычным разработчикам?" — Если решили чем-то пользоваться, то научитесь этим пользоваться. Вы же когда пишете на языке понимаете что пишите? Ну, я надеюсь :D Тоже самое и с библиотеками — прежде чем начать на них писать, их надо как и язык изучить, и разобраться. DI контейнер автоматически предполагает, что вы понимаете, зачем вам нужно внедрение зависимостей на проекте, а не "так сказал тим лид".
                  По практике скажу — это не самая простая тема, я проводил лекции внутри компаний, и видел, как люди не понимают концепцию. И это даже не зависит от опыта.


                4. Все на ваш страх и риск. Предположим, вы писали на UIKit, а apple через 5 лет сказало — выпиливаем теперь только SwiftUI :D Вы можете уменьшить проблемы, если все будет отделено. Если у вас UIKit был только во View, а не оказывался случайно в базе данных (такое бывает), то проблема будет маленькой и решаемой.
                  Тоже самое и с библиотеками DI. Я выбрал концепцию "декларативный контейнер" и не долюбливаю концепции внедрения через атрибуты, аля Dagger2. В Swinject, DITranquillity при правильном использовании, код знающий о DI либе всегда отдельный, он никак не пересекается с другим. Соответственно подмена этого кода = подмене только этого кода, без необходимости искать по всему проекту. Но это в любом случае подмена, которая потребует времени.


                5. Начнем с главного. DI либы аля моей и Swinject не представляют из себя никакой магии — это просто хорошо обернутый Dictionary. Как работает Dip не помню, я его код 2.5 года назад смотрел, и уже не помню по каким причинам решил больше не возвращаться. Соответственно если отбросить то, что библиотеки пишут люди и допускают разные ошибки, то оверхед не такой уж и большой, по сравнению с ручными вызовами.
                  "Зачем вообще решать эти проблемы в рантайме?". Я задам встречный вопрос — вы перешли к варианту 450 функций resolve(). Но не описали: каким образом у вас решаются циклические зависимости? Что делать если объекты не надо пересоздавать каждый раз? А если надо создавать каждый раз, но у вас ромбовидные зависимости A->B A->C B->D C->D где объект D должен будет создаться единожды, но на каждое получение объекта A, он разный? Что делать если объект реализует с 10 протоколов, и вы хотите чтобы он отдавался по всем этим протоколам? А обратная ситуация — один протокол, но много реализаций?
                  А теперь добавим модульность, и последние вопросы становятся еще интересней :)



                Собственно говоря, я к чему. На проекте в 10-30к swift строчек кода, вполне можно обойтись концепцией DI без библиотек. Да где-то придется попотеть, вспомнить про Фабрику сервисов, а именно этот паттерн описан у вас, просто он использует возможности автовывода типов. Да это займет условных 1000 строчек, а с библиотекой бы заняло 100. Но это возможно, я так делал и все было хорошо. Кстати у вас точно не модульное приложение ;) Ну или вы не до конца описали GeneralAssembly — при модульности, его придется делать многоуровневым и с использованием протоколов.


                Но как только размерность проекта выходит за рамки человеческого понимания — в нашем проекте уже никто не знает, как работает ядро, или как работает самый верхний роутинг, многое написано 4-5 лет назад и людьми которые уже давно уволись, но с тех пор код там не менялся. И такого кода много. C зависимостями у нас также — они очень сложные, они межмодульные. Многие зависимости отключаемые/включаемые. То есть выкинул модуль, собрал проект, и все кто зависел от этого модуля продолжат работу, просто без части функционала (Ну это в идеальном мире правда… все мы люди, и ошибки есть).
                И вот тогда DI библиотеки начинают экономить уйму времени, и кода.


                P.S. По причинам сложных зависимостей, я и делаю сейчас возможности "нарисовать" граф зависимостей — то есть библиотека будет выдавать граф, который в другой программе можно будет посмотреть визуально.

                  0

                  Спасибо за такой развернутый ответ, есть над чем подумать.


                  Пока могу ответить только за 5-й пункт как реализовали корнер кейсы сейчас. Но, все решения пока принимались в режиме «надо срочно ускорить старт, сложные задачи решим попозже»:


                  1. Циклические зависимости. Я их вычищал при переписывании, поддерживать в любом случае неудобно. Парочка осталась на самых базовых классах, эти объекты поднимаются на старте и после создания ручками проставляются связи через проперти. Рядом лежит todo на доделывание.
                  2. Как не пересоздавать. Сделал lazy var проперти для таких объектов, и уже к их возвращаю через resolve(). Про ромбовидные зависимости либо я не понял, либо вот так и решились: объект A извлекается как обычный resolve(), а D это lazy var, который тоже возвращается из resolve().
                  3. У объекта много протоколов. Не проверял, но, resolve() -> A & B & C должен работать.
                  4. Один протокол, но много реализаций. Для этого у меня решения сходу нет.
                  5. Модульность. Модульность бывает разной, применительно к DI пока задачу не понял. Мы делим приложение двумя образами.
                    • Слоями: сеть, хранение данных, доменная логика, аналитика.
                    • Экранами: меню, корзина, история заказов. Деление по экранам позволяет собрать мини приложения на замоканых данных для быстрой работы со всеми видами тестов.
                    0
                    1. Простой пример цикла в популярных паттернах наших :) Presenter знает о View, но View должна как миниум держать presenter. В некоторых случая должна иметь delegate. Конечно подобный цикл можно сделать и ручками, без DI.


                    2. Предположим есть экран — это объект A. Каждый раз когда заходишь на экран, он должен создаваться, и все его зависимости (B,C,D) тоже должны создаваться каждый раз. Но на объект D ссылаются B и С. Если сделать его lazy var, то D не пересоздастся при создании нового A. Если его сделать всегда создаваемым, то у B и С будут разные экземпляры D.


                    3. Я про случай когда вот такие функции должны возвращать один и тот-же объект: resolve() -> A, resolve() -> B, resolve() -> C.


                    4. Я про вот эту модульность: "слабо связанные снаружи". Все остальное это "мы просто сделали куча фреймворков, и назвали модулями". Смысл модулей, в том чтобы максимально все скрыть, оставим минимум для взаимодействия (ну если это не куча вспомогательных функций), и не забывать при этом что в отличии от классов между ними связь в обе стороны не возможна на прямую.
                      Ну а теперь более конкретно, на примере экранных модулей. Есть модуль A и модуль B. С экрана из модуля A можно перейти на экран из модуля B. С экрана из модуля B можно перейти на экран из модуля A. Как делать будем? И как усложнение — реализаций каждого экрана может быть несколько, и только сам модуль знает какую реализацию когда использовать.



                    P.S. На все эти кейсы решение есть, но назвать эти решения удобными и понятными не всегда можно. Как говорится "плавали знаем" :)

                      0
                      1. Presenter знает о View, но View должна как миниум держать presenter. Я начал с того, что регистрировать каждый класс не нужно. У экрана все должно сводиться лишь к паре инфраструктурных зависимостей. Пример:

                      extension ApiDeadViewController: StoryboardInstantiatable {
                          func didInstantiateFromStoryboard(_ assembly: GeneralAssembly) {
                              self.presenter = ApiDeadModule.ApiDeadPresenter(
                                 ui: self,
                                 interactor: ApiDeadModule.ApiDeadInteractor(localeService: assembly.resolve()))
                          }
                      }

                      4 — Даже в постановке задачи это звучит как штука, над которой не очень много контроля и с ней не супер просто/приятно работать. Я бы выносил связь модулей выше и писал сценарий явно. Но я понимаю желание свести это на уровень DI.


                      Чуть вернусь к изначальному моему утверждению «не используйте рантайм библиотеки». Я рад, что узнал про вашу разработку и я понял, чего мне не хватало раньше: графика производительности в ридми каждого фреймворка. Для меня это было самым важным вопросом, но я не мог найти на него ответ за приемлемый срок. Теперь он у меня есть, спасибо вам :-)

              0
              А в US App Store уже выложили?
                0

                Нет, приложение пока не работает с пиццериями в США

                  0
                  Я имел ввиду прогу для российского рынка в американском App Store, как делают многие здесь для клиентов с американскими экаунтами
                    +1

                    Понимаю, но пока так не открываем. Запросов не было (или оч мало), а в странах, где есть Додо, но нет приложения это будет сильно смущать. Такой негатив видно в России, когда приложение минусуют за то, что в каком-то городе нет пиццерии.

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

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