Разработка
Как в Dropbox сделали загрузку с камеры в Android быстрее и надежнее
В этой статье рассказывается о некоторых решениях по дизайну, проверке и релизу, которые мы приняли при создании новой функции загрузки камеры для Android, которую мы выпустили для всех пользователей летом 2021 года.
Загрузка с камеры — это функция в наших приложениях для Android и iOS, которая автоматически копирует фотографии и видео пользователя с мобильного устройства в Dropbox. Эта функция была впервые представлена в 2012 году и ежедневно загружает миллионы фотографий и видео для сотен тысяч пользователей. Люди, использующие загрузку с камеры, являются одними из наших самых преданных и вовлеченных пользователей. Они очень заботятся о своих библиотеках фотографий и ожидают, что их резервные копии всегда будут быстрыми и надежными. Важно, что мы предлагаем услугу, которой они могут доверять.
До недавнего времени загрузка с камеры была основана на библиотеке C++, совместно используемой приложениями Dropbox для Android и iOS. Эта библиотека хорошо служила нам долгое время, загрузив миллиарды изображений за многие годы. Однако у нее было множество проблем. Общий код стал загрязнен сложными хаками для конкретных платформ, которые сделали его трудным для понимания и рискованным для изменений. Этот риск усугублялся отсутствием инструментальной поддержки и нехваткой собственных специалистов по C++. Кроме того, после более чем пяти лет работы реализация C++ начала показывать свой возраст. Она не знала о специфичных для платформы ограничениях на фоновые процессы, имела ошибки, которые могли задерживать загрузку на длительные периоды времени, и делала восстановление после отключения трудным и трудоемким.
В 2019 году мы решили, что переписать эту функцию — лучший способ обеспечить надежный и заслуживающий доверия пользовательский опыт на долгие годы. На этот раз реализации для Android и iOS будут отдельными и будут использовать собственные языки платформы (Kotlin и Swift соответственно) и собственные библиотеки (такие как WorkManager и Room для Android). После этого реализации можно было бы оптимизировать для каждой платформы и развивать независимо, не ограничиваясь проектными решениями другой платформы.
В этой статье рассказывается о некоторых решениях по дизайну, проверке и релизу, которые мы приняли при создании новой функции загрузки камеры для Android, которую мы выпустили для всех пользователей летом 2021 года. Проект был успешно запущен, без сбоев или серьезных проблем; частота ошибок снизилась, а производительность загрузки значительно улучшилась. Если вы еще не включили загрузку с камеры, попробуйте сами.
1. Создано для надежной работы в фоновом режиме
Основное преимущество загрузки с камеры заключается в том, что она работает тихо в фоновом режиме. Для пользователей, которые не открывают приложение в течение нескольких недель или даже месяцев, новые фотографии все равно должны быстро загружаться.
Как это работает? Когда кто-то делает новую фотографию или изменяет существующую фотографию, ОС уведомляет об этом мобильное приложение Dropbox. Фоновый воркер, которого мы называем сканером, тщательно идентифицирует все фотографии (или видео), которые еще не были загружены в Dropbox, и ставит их в очередь для загрузки. Затем другой фоновый рабочий, загрузчик, загружает все фотографии в очереди.
Загрузка — это двухэтапный процесс. Во-первых, как и во многих системах Dropbox, мы разбиваем файл на блоки по 4 Мб, вычисляем хэш каждого блока и загружаем каждый блок на сервер. Как только все блоки файла загружены, мы делаем окончательный запрос на фиксацию на сервер со списком всех хэшей блоков в файле. Это создает новый файл, состоящий из этих блоков, в пользовательской папке Camera Uploads. Фотографии и видео, загруженные в эту папку, могут быть доступны с любого связанного устройства.
Одна из наших самых больших проблем заключается в том, что Android накладывает серьезные ограничения на то, как часто приложения могут работать в фоновом режиме и какими возможностями они обладают. Например, App Standby ограничивает наш фоновый доступ к сети, если приложение Dropbox недавно не было приоритетным. Это означает, что нам может быть разрешен доступ к сети только на 10-минутный интервал один раз в 24 часа. Эти ограничения стали более строгими в последних версиях Android, и кросс-платформенная версия загрузки камеры на C++ не была хорошо приспособлена для работы с ними. Иногда она пыталась выполнить загрузку, которая была обречена на сбой из-за отсутствия доступа к сети, или не могла перезапустить загрузку в течение предоставленного системой окна, когда доступ к сети стал доступен.
Наше переписывание не избегает этих фоновых ограничений; они по-прежнему применяются, если пользователь не решит отключить их в системных настройках Android. Однако мы максимально сокращаем задержки, максимально используя доступ к сети, который мы получаем. Мы используем WorkManager для обработки этих фоновых ограничений, гарантируя, что попытки загрузки будут предприняты тогда и только тогда, когда доступ к сети станет доступным. В отличие от нашей реализации на C++, мы также выполняем как можно больше работы в автономном режиме — например, выполняя элементарные проверки новых фотографий на наличие дубликатов — прежде чем попросить WorkManager запланировать для нас доступ к сети.
Чтобы еще больше оптимизировать использование нашего ограниченного доступа к сети, мы также усовершенствовали обработку неудачных загрузок. Загрузка с камеры C++ агрессивно повторяла неудачные попытки загрузки неограниченное количество раз. В новой версии мы добавили интервалы между повторными попытками, а также настроили наше поведение при повторных попытках для различных категорий ошибок. Если ошибка временная, мы повторяем попытку несколько раз. Если она может быть постоянной, мы вообще не пытаемся повторить попытку. В результате мы делаем меньше повторных попыток, что ограничивает использование сети и аккумулятора, а пользователи видят меньше ошибок.
2. Создано для производительности
Наши пользователи не просто ожидают, что загрузка с камеры будет работать надежно. Они также ожидают, что их фотографии будут загружаться быстро и без траты системных ресурсов. Здесь мы смогли сделать несколько больших улучшений. Например, первая загрузка больших фотобиблиотек теперь завершается в четыре раза быстрее. Наша новая реализация достигает этого несколькими способами.
Параллельные загрузки
Во-первых, мы существенно повысили производительность, добавив поддержку параллельных загрузок. Версия C++ загружала только один файл за раз. В начале проекта переделки мы поработали с нашими коллегами по iOS и серверной инфраструктуре, чтобы разработать обновленный commit endpoint с поддержкой параллельных загрузок.
Как только ограничение сервера исчезло, корутины Kotlin упростили одновременную загрузку. Хотя Flow в Kotlin обычно обрабатываются последовательно, доступные операторы достаточно гибки, чтобы служить строительными блоками для мощных пользовательских операторов, поддерживающих параллельную обработку. Эти операторы могут быть объединены в цепочку декларативно для создания кода, который намного проще и имеет меньше накладных расходов, чем ручное управление потоками, которое было бы необходимо в C++.
val uploadResults = mediaUploadStore .getPendingUploads() .unorderedConcurrentMap(concurrentUploadCount) { mediaUploader.upload(it) } .takeUntil { it != UploadTaskResult.SUCCESS } .toList()
Простой пример параллельного конвейера загрузки. unorderedConcurrentMap — это настраиваемый оператор, который сочетает в себе встроенные операторы flatMapMerge и transform.
Оптимизация использования памяти
После добавления поддержки параллельных загрузок мы заметили значительный всплеск сбоев из-за нехватки памяти по сравнению с нашими ранними тестировщиками. Потребовался ряд улучшений, чтобы сделать параллельные загрузки достаточно стабильными для продакшена.
Во-первых, мы модифицировали наш загрузчик, чтобы динамически изменять количество одновременных загрузок в зависимости от объема доступной системной памяти. Таким образом, устройства с большим объемом памяти смогут загружать файлы максимально быстро, а старые устройства не будут перегружены. Однако мы по-прежнему наблюдали гораздо более высокий уровень использования памяти, чем ожидали, поэтому мы использовали профилировщик памяти, чтобы присмотреться работе с ней.
Первое, что мы заметили, это то, что потребление памяти не возвращалось к начальному уровню после завершения всех загрузок. Оказалось, что это произошло из-за неудачного поведения Java NIO API. Он создавал кеш в памяти в каждом потоке, где мы читали файл, и однажды созданный кеш никогда не мог быть уничтожен. Поскольку мы читаем файлы с помощью диспетчера ввода-вывода, работающего с пулом потоков, мы обычно получаем многие такие кэши, по одному на каждый диспетчеризируемый поток. Мы решили эту проблему, переключившись на прямые байтовые буферы, которые не выделяют этот кеш.
Следующее, что мы заметили, это большие всплески использования памяти при загрузке, особенно с большими файлами. Во время каждой загрузки мы читаем файл блоками, копируя каждый блок в ByteArray для дальнейшей обработки. Мы никогда не создавали новый байтовый массив до тех пор, пока предыдущий не выйдет за пределы области видимости, поэтому мы ожидали, что в каждый момент времени в памяти будет находиться только один массив байтов. Однако оказалось, что когда мы выделяли большое количество байтовых массивов за короткое время, сборщик мусора не мог достаточно быстро их освободить, вызывая временный всплеск памяти. Мы решили эту проблему, повторно используя один и тот же буфер для чтения всех блоков.
Параллельное сканирование и загрузка
В реализации загрузки с камеры на C++ загрузка не могла начаться до тех пор, пока мы не закончим сканирование библиотеки фотографий пользователя на наличие изменений. Чтобы избежать задержек с загрузкой, при каждом сканировании рассматривались только те изменения, которые были новее тех, что были замечены при предыдущем сканировании.
У этого подхода были минусы. Были некоторые крайние случаи, когда фотографии с вводящими в заблуждение временными метками можно было полностью пропустить. Если мы когда-либо пропускали фотографии из-за ошибки или изменения ОС, исправления было недостаточно для восстановления. Нам приходилось очищать сохраненные временные метки сканирования у затронутых пользователей, чтобы выполнить полное повторное сканирование. Кроме того, когда загрузка с камеры впервые включалась, нам все еще приходилось проверять все, прежде чем что-либо загружать. Это не было хорошим первым впечатлением для новых пользователей.
В переписывании мы обеспечили корректность повторным сканированием всей библиотеки после каждого изменения. Мы также распараллелили загрузку и сканирование, поэтому новые фотографии могут загружаться, пока мы сканируем старые. Это означает, что хотя повторное сканирование может занять больше времени, сами загрузки по-прежнему начинаются и заканчиваются быстро.
3. Валидация
Переписывать в таких масштабах рискованно. Возникают опасные режимы и сбои, которые могут проявляться только в масштабе, например, повреждение одной загрузки из миллиона. Кроме того, как и в большинстве случаев переписывания, мы не могли избежать внесения новых ошибок, потому что мы не понимали — или даже не знали — всех крайних случаев, которые обрабатывала старая система. Нам напомнили об этом в начале проекта, когда мы попытались удалить какой-то древний код загрузки камеры, который, как мы думали, был мертв, и вместо этого в конечном итоге подвергли DDOS-атаке службу отчетов о сбоях Dropbox.
Проверка хэша в продакшене
На ранних этапах разработки мы проверили многие низкоуровневые компоненты, запустив их в рабочей среде вместе с их аналогами на C++, а затем сравнив результаты. Это позволило нам убедиться, что новые компоненты работают правильно, прежде чем мы начали полагаться на их результаты.
Одним из таких компонентов была реализация на Kotlin алгоритмов хэширования, которые мы используем для идентификации фотографий. Поскольку эти хэши используются для дедупликации, могут произойти неожиданные вещи, если хэши изменятся даже для небольшого процента фотографий. Например, мы можем повторно загрузить старые фотографии, считая их новыми. Когда мы запускали наш код Kotlin вместе с реализацией C++, обе реализации почти всегда возвращали совпадающие хэши, но они различались примерно в 0,005% случаев. Какая реализация была неправильной?
Чтобы ответить на этот вопрос, мы добавили дополнительное логирование. В тех случаях, когда Kotlin и C++ расходились во мнениях, мы проверяли, отклонил ли впоследствии сервер загрузку из-за несовпадения хэшей, и если да, то какой хэш он ожидал. Мы увидели, что сервер ожидал хэши Kotlin, что дало нам высокую уверенность в том, что хэши C++ были неверными. Это была отличная новость, так как это означало, что мы исправили редкую ошибку, о которой мы даже не знали.
Проверка переходов состояний
Загрузка с камеры использует базу данных для отслеживания состояния загрузки каждой фотографии. Как правило, сканер добавляет фотографии в состоянии NEW, а затем переводит их в состояние PENDING (или DONE, если их не нужно загружать). Загрузчик пытается загрузить PENDING фотографии, а затем переводит их в состояние DONE или ERROR.
Поскольку мы распараллеливаем так много работы, для нескольких частей системы нормально читать и записывать эту базу данных состояния одновременно. Индивидуальные операции чтения и записи гарантированно выполняются последовательно, но мы по-прежнему уязвимы для незаметных ошибок, когда несколько рабочих процессов пытаются изменить состояние избыточными или противоречивыми способами. Поскольку модульные тесты охватывают только отдельные компоненты в отдельности, они не будут обнаруживать эти ошибки. Даже интеграционный тест может пропустить редкие условия гонки.
В переписанной версии загрузки фотографий камеры мы защищаемся от этого, проверяя каждое обновление состояния на соответствие набору разрешенных переходов состояний. Например, мы оговариваем, что фотография никогда не может перейти из состояния ERROR в состояние DONE, не пройдя через состояние PENDING. Неожиданные переходы состояний могут указывать на серьезную ошибку, поэтому, если мы ее видим, мы прекращаем загрузку и сообщаем об исключении.
Эти проверки помогли нам обнаружить неприятную ошибку на раннем этапе развертывания. Мы начали замечать большое количество исключений в наших логах, которые были вызваны попытками загрузки с камеры перевести фотографии из состояния DONE в DONE. Это заставило нас понять, что мы загружали некоторые фотографии несколько раз! Основная причина заключалась в неожиданном поведении WorkManager, когда уникальные рабочие процессы могут перезапуститься до того, как предыдущий экземпляр будет полностью отменен. Дубликаты файлов не создавались, потому что сервер их отклонял, но избыточные загрузки тратили впустую пропускную способность и время. Как только мы исправили проблему, скорость загрузки значительно улучшилась.
4. Раскатывание
Даже после всей этой проверки нам все еще приходилось быть осторожными во время развертывания. Полностью интегрированная система была более сложной, чем ее части, и нам также пришлось бы иметь дело с длинным хвостом редких типов устройств, которые не представлены в нашем внутреннем пуле тестирования. Нам также нужно было продолжать оправдывать или превосходить высокие ожидания всех наших пользователей, которые полагаются на загрузку с камеры.
Чтобы заранее снизить этот риск, мы позаботились о поддержке откатов с новой версии на версию на C++. Например, мы позаботились о том, чтобы все изменения пользовательских настроек, сделанные в новой версии, применялись и к старой версии. В конце концов, нам так и не пришлось откатывать назад, но все равно это стоило затраченных усилий, чтобы иметь доступную опцию в случае аварии.
Мы начали развертывание с пула пользователей бета-версии (ранний доступ в Play Store), которые еженедельно получают новую версию приложения Dropbox для Android. Этот пул пользователей был достаточно большим, чтобы выявлять редкие ошибки и собирать ключевые показатели производительности, такие как процент успешных загрузок. Мы отслеживали эти ключевые показатели в популяции в течение нескольких месяцев, чтобы убедиться, что новая версия готова к широкому распространению. За это время мы обнаружили много проблем, но быстрый темп выпуска бета-версий позволил нам быстро проводить итерации и исправлять их.
Мы также отслеживали множество метрик, которые могли указывать на будущие проблемы. Чтобы убедиться, что наш загрузчик не отстает со временем, мы следили за признаками постоянно растущего количества фотографий, ожидающих загрузки. Мы отслеживали количество успешных повторных попыток по типам ошибок и использовали это для точной настройки нашего алгоритма повторных попыток. И последнее, но не менее важное. Мы также уделяли пристальное внимание отзывам и обращениям в службу поддержки, которые мы получали от пользователей, что помогло выявить ошибки, которые не учитывались нашими метриками.
Когда мы, наконец, выпустили новую версию загрузки камеры для всех пользователей, стало ясно, что месяцы, проведенные в бета-версии, окупились. Наши показатели оставались стабильными на протяжении всего развертывания, и у нас не было никаких серьезных сюрпризов, мы получили повышенную надежность и низкий уровень ошибок сразу после запуска. На самом деле, мы закончили развертывание досрочно. Поскольку мы заранее выполнили так много работы по улучшению качества в период бета-тестирования (с его еженедельными выпусками), у нас не было многонедельных задержек в ожидании исправления критических ошибок в стабильных выпусках.
5. Итак, оно того стоило?
Переписать большую устаревшую функцию — не всегда правильное решение. Переписывание занимает чрезвычайно много времени — только над версией для Android два человека работали два полных года — и может легко привести к серьезным регрессам или сбоям. Чтобы быть стоящим, переписывание должно приносить ощутимую пользу, улучшая взаимодействие с пользователем, экономя время и усилия разработчиков в долгосрочной перспективе, или и то, и другое.
Какой совет мы можем дать тем, кто начинает подобный проект?
- Определите свои цели и то, как вы будете их измерять. В начале важно убедиться, что польза оправдает затраченные усилия. В конце концов, это поможет вам определить, получили ли вы желаемые результаты. Некоторые цели (например, будущая устойчивость к изменениям ОС) могут не поддаваться количественной оценке — и это нормально, — но хорошо указать, какие из них являются, а какие — нет.
- Избавьтесь от риска. Определите компоненты (или общесистемные взаимодействия), которые вызовут самые большие проблемы в случае их сбоя, и с самого начала защитите себя от этих сбоев. Сначала создайте критические компоненты и попробуйте протестировать их в производственной среде, не дожидаясь завершения обновления всей системы. Также стоит проделать дополнительную работу заранее, чтобы иметь возможность откатиться, если что-то пойдет не так.
- Не спешите. Отправка переписанной версии, возможно, более рискованна, чем отправка новой фичи, поскольку ваша аудитория уже рассчитывает на то, что вещи будут работать должным образом. Начните с выпуска для аудитории, которая достаточно велика, чтобы предоставить вам данные, необходимые для оценки успеха. Затем наблюдайте и ждите (и исправляйте ошибки), пока ваши данные не придадут вам уверенности в продолжении. Решение проблем, когда пользовательская база невелика, в долгосрочной перспективе происходит намного быстрее и менее напряжно.
- Ограничьте свое поле деятельности. При переписывании заманчиво одновременно заняться запросами новых фич, очисткой пользовательского интерфейса и другими незавершенными работами. Подумайте, будет ли это на самом деле быстрее или проще сначала поставить переписанный код, а потом уже заняться остальным. Во время этого переписывания мы решили проблемы, связанные с базовой архитектурой (например, сбои, присущие базовой модели данных), и отложили все другие улучшения. Если вы измените функцию слишком сильно, не только потребуется больше времени на ее реализацию, но и труднее заметить регрессию или откат.
В этом случае мы правильно отнеслись к решению переписать все с нуля. Нам удалось сразу же повысить надежность, и, что более важно, мы настроились на то, чтобы оставаться надежными в будущем. Поскольку операционные системы iOS и Android продолжают развиваться в разных направлениях, это был лишь вопрос времени, когда библиотека C++ выйдет из строя настолько сильно, что потребуются фундаментальные системные изменения. Теперь, когда переписывание завершено, мы можем создавать и итерировать загрузку с камеры намного быстрее, а также предлагать лучший опыт для наших пользователей.
-
Видео и подкасты для разработчиков1 месяц назад
Lua – идеальный встраиваемый язык
-
Новости1 месяц назад
Poolside, занимающийся ИИ-программированием, привлек $500 млн
-
Новости1 месяц назад
Видео и подкасты о мобильной разработке 2024.40
-
Новости1 месяц назад
Видео и подкасты о мобильной разработке 2024.41