Несколько месяцев назад мы приняли решение отказаться от SwiftUI и Swift Concurrency в нашем приложении и перевести ядро приложения на UIKit и Dispatch. Недавно мы перевели все управление экранами и навигацией в нашем приложении на UIKit, и это позволило нам удалить сразу несколько обходных хаков и решить кучу проблем с производительностью и ошибок, которые у нас были.
Мы по-прежнему будем использовать SwiftUI для простых экранов, таких как экраны регистрации или настроек, но сложные вещи, такие как представления коллекций или что-то еще, где нам не нужны ошибки, мы будем использовать UIKit.
Люди, которые создают типичные клиент-серверные приложения с SwiftUI и Swift Concurrency, имеют все шансы не столкнуться с нашими проблемами из-за отсутствия потоков и нагрузки на ресурсы. Им не нужно постоянно поддерживать целостное локальное состояние на устройстве, поскольку они могут положиться на бэкенд, который будет правильно поддерживать состояние самостоятельно. Такого рода приложениям, вероятно, будет гораздо проще работать со SwiftUI.
Многие люди интересовались подробностями того, почему мы приняли такое решение, поэтому эта статья для вас.
Наше приключение
Поначалу мы были очень рады работать над новым приложением со всеми новейшими библиотеками Swift, но, как только начинаешь делать что-то существенное, обнаруживается, что эта система неполноценна.
Когда я пришел в компанию, мы переживали кризис стабильности, причина которого была загадочной, и мы не были в состоянии выпустить приложение в App Store. Нам потребовалось от 2 до 3 месяцев, чтобы частично исправить проблемы со стабильностью и перевести приложение в состояние, пригодное для релиза.
В целом нам пришлось потратить в сумме более года инженерных часов, чтобы исправить проблемы со стабильностью, выяснить их причины и устранить в большинстве случаев. Поскольку причиной проблем со стабильностью были трудно воспроизводимые взаимные блокировки и бесконечные спиннеры, которые также блокировали отчеты и наблюдаемость этих проблем, было гораздо сложнее выяснить, почему они происходят, и получить стеки вызовов, которые вызывают эти состояния. Наше приложение также не зависело от бэкенда, поэтому мы не могли просмотреть логи на сервере, чтобы понять, какие сетевые вызовы ведут себя «плохо». Все, что у нас было, — это удаленное логирование с помощью Datadog и Sentry.
После выпуска в магазин приложений, нескольких месяцев использования в продакшене и глубокого изучения книг, документов и видео WWDC мы наконец-то выяснили, какая комбинация плохих вещей вызывала наши проблемы. Чтобы понять, что мы сделали не так, нужно понять, какие проблемы мы обнаружили в SwiftUI и Swift Concurrency.
Наши проблемы
В SwiftUI слишком много магии и утечек абстракций, AttributeGraph недетерминирован с точки зрения стороннего наблюдателя
SwiftUI:
- Наполнен недостающими фичами, о которые легко споткнуться.
- Не хватает функций, которые существуют в UIKit со времен iOS 2.0.
- Имеет проблемы с отладкой, наблюдаемостью и возможностью рассуждать о том, что происходит в нашем приложении.
- Трудно контролировать жизненный цикл объектов состояния, колбеки и так далее.
Мы обнаружили, что в SwiftUI много «магии», которая затрудняет понимание. В SwiftUI есть множество неявных скрытых предположений, которые, если их не соблюдать, могут привести к неожиданным болезненным результатам. Кроме того, SwiftUI многократно вызывает body функцию представления с частотой, о которой сложно рассуждать или предсказывать. Производительность представлений больших коллекций изображений, которые, по сути, являются ядром нашего приложения, также остается проблемой и сегодня.
Бывает практически невозможно определить, как определенный биндинг обновится именно в этот раз, а происходит это, например, только в 5% случаев. Чтобы устранить такие проблемы, вам приходится, как Шерлоку, вычислять, что происходит, тестируя гипотетические входные данные и наблюдая за выходными. Сам SwiftUI — это черный ящик относительно его поведения.
SwiftUI — это обманчивая система, в которой легко начать работать, но трудно заставить ее работать правильно, не зная неявных предположений, если только вы не делаете что-то довольно простое. Особенно это проявляется, когда все дерево представлений и навигация приложения управляются SwiftUI.
В некотором смысле это напоминает мне компромисс, который вы получаете с React Native из-за утечки абстракций из-за необходимости бесшовной интеграции с UIKit. Необходимо стать экспертом в нескольких платформах одновременно, чтобы правильно отладить все проблемы в стеке, по сравнению с работой с одной нативной, кроме того, декларативная система явно проигрывает императивной, которая использует алгоритм диффиринга для создания изменений представления.
Я боюсь, что SwiftUI страдает от эффекта второй системы, как и его родитель Swift.
Эффект второй системы или синдром второй системы — это тенденция к тому, что на смену небольшим, элегантным и успешным системам приходят перепроектированные и раздутые системы, что вызвано завышенными ожиданиями и излишней самоуверенностью.
Эта фраза была впервые использована Фредом Бруксом в его книге «Мифический человеко-месяц«, впервые опубликованной в 1975 году.
Swift Concurrency слишком легко заходит в блокировки, а заметить это очень трудно
Swift Concurrency, как мы выяснили, довольно хрупок. Его слишком легко застать врасплох из-за того, что внутренние библиотеки Apple, такие как PhotoKit, используют стандартные вызовы dispatch_sync по непредсказуемому расписанию.
Благодаря тому, что все находится в одном пуле потоков, любой последующий вызов этого пула потоков наследует особые свойства пула потоков Swift Concurrency, а это делает эти состояния блокировок ненаблюдаемыми.
Таким образом, если у вас есть async-функция, вызывающая другой модуль, который использует независимую очередь диспетчеризации для управления многопоточностью, Swift Concurrency «заразит» эту очередь диспетчеризации особыми свойствами основной очереди, и эта очередь начнет вести себя как очередь диспетчеризации Swift Concurrency.
Таким образом, если вы вызовете функцию логирования в вашей async-функции, эта система протоколирования также начнет тупить, потому что она окажется в контексте вызова Swift Concurrency, даже если эта система протоколирования не использует никаких примитивов Swift Concurrency, соблюдает контракт на выполнение и так далее. Если поведение в другом месте заблокирует вашу очередь Swift Concurrency, это приведет к блокировке вашей независимой очереди логирования!
Если вы полагаетесь на систему логирования для отладки или обнаружения таких тупиков, вам нужно быть крайне осторожным, чтобы Swift Concurrency никогда не касался этих очередей. Это может быть очень сложно сделать и при этом сохранить полезность, так как часто данные, собранные системой логов как раз и нужны для диагностики того, что происходит!
Вдобавок ко всему, MainActor/главный поток приложения получает особые привилегии с точки зрения Swift Concurrency. Взаимодействия Swift Concurrency, выполняемые в главном потоке, не выполняются в общей очереди диспетчеризации, в которой выполняется остальная часть Swift Concurrency, и «вирус поведения очереди Swift Concurrency» не заражает главный поток!
В результате, когда Swift Concurrency блокируется, он заводит в тупик все, к чему прикасается, кроме главного потока. Это означает, что ваш пользовательский интерфейс все равно будет меняться в ответ на нажатия, просто все, что вызывает асинхронный вызов await, никогда не ничего не вернет, как только вы окажетесь в таком состоянии.
На самом деле это еще хуже, потому что ваши старые независимые системы логирования, такие как Datadog и Sentry, использующие простую диспетчеризацию, не смогут из другого независимого пула потоков отобразить тот факт, что вы находитесь в блокировке. Ваше приложение также никогда не упадет при сбое системы, потому что UX/основной поток остаются отзывчивыми, что делает ситуацию совершенно необнаруживаемой за пределами сообщений пользователей и внутреннего тестирования или некоторых действительно тяжелых и хрупких инженерных хаков.
Вдобавок ко всему, вы никогда не сможете выяснить распространенность происходящего с тем, как настроены приложения iOS в настоящее время. Я бы решил эту проблему, например, созданием процесса-наблюдателя, который порождал бы реальный процесс приложения в качестве дочернего, но в iOS, в отличие от macOS, вы не можете этого сделать.
Задачи, вызовы await, акторы и т.д. в Swift Concurrency выполняются правильно большую часть времени, но иногда не выполняются, и неочевидно, как заставить его выполнять их в определенном порядке
Это самая сложная для понимания часть нашей статьи, но после того, как мы провели 6 часов с десятком iOS-инженеров в приватном Slack с сотнями сообщений, обсуждая это, вставляя много кода и проверяя множество крайних случаев, которые в итоге не срабатывали, мы так и не смогли понять, почему это происходит. Только то, что это происходит.
Это вполне может быть ошибкой в Swift Concurrency, или мы неправильно использовали Swift Concurrency в каком-то действительно пограничном случае, но в конечном итоге это не имеет значения. То, что это происходит, и то, что многим людям очень трудно понять, почему это происходит, и есть главная проблема. Слишком легко все испортить. Просто знайте, что приведенный ниже код — это упрощение для иллюстрации нашей точки зрения, в реальном коде гораздо больше проверок для предотвращения двойного выполнения и тому подобного.
class MyViewModel: ObservedObject { @Published var showLoadingOverlay = false /// Significantly simplified from the original code func refreshPhotos() async { await photoManager.refresh() // 1 (long running, has await calls & Tasks to other systems inside of it) await MainActor.run { showLoadingOverlay = false // 2 } } }
В 99% случаев, когда вы запускаете такой код, он будет выполняться по порядку. 1 закончит выполнение, а затем будет выполняться 2. Когда вы пишете этот код, это то, что вы предполагаете исходя из всех ваших лет программирования. Но иногда, особенно когда вы нагружаете систему потоков большим количеством работы в других местах, 1 начнет выполняться, а до того, как завершится 1, будет выполняться 2, ИНОГДА вызывая всевозможные ошибки состояния гонки!
Аналогично, акторы также не гарантируют последовательного выполнения, но в большинстве случаев действуют последовательно!
Если вы столкнулись с подобной ошибкой и пытаетесь исправить ее, разумно принудив к последовательному выполнению, то не совсем очевидно, как это сделать с помощью Swift Concurrency. Если вы попытаетесь принудительно поместить эти два await вызова в их собственные очереди диспетчеризации, вы получите целую кучу предупреждений и ошибок, а swift-concurrency будет бесконечно жаловаться на это.
Поищите информацию об этом, и вы увидите непонятные вещи в ветках форума swift и reddit об AsyncStream и SerialExecutors, и также не очевидно, как заставить две задачи выполняться последовательно друг за другом, читая эти ветки форума. С другой стороны, API диспетчеризации сделал это очевидным прямо в своей документации, в то время как для того, чтобы сделать что-то подобное из async/await, вам нужно сделать 50-строчную обертку, чтобы сделать это так же просто, как это сделано в Dispatch. Большинство инженеров не смогут легко разобраться в этом, да и вообще не ожидают, что это произойдет.
Взаимодействие всех трех вышеперечисленных проблем вместе привело к нашему кризису стабильности.
Как мы ошиблись в SwiftUI и Swift Concurrency
- Мы использовали MVVM, который мы перевели в @StateObject бизнес-логику тяжелых ViewModels, что было нормально для UIKit, но это в корне плохая идея с SwiftUI из-за непредсказуемого поведения инициализации.
- Большинство учебников и документации по SwiftUI не делают это очевидным, если только вы не прочитаете несколько ключевых абзацев в документации или не погрузитесь в видеоролики WWDC, такие как «demystifying swift ui» и «swift ui performance«, которые частично объясняют внутреннюю суть работы SwiftUI и неявные предположения. Большинство не узнает об этих проблемах хрупкости при использовании SwiftUI.
- Если бы я делал модель данных заново, я бы избегал использования управления состояниями в SwiftUI и вводил бы все как наблюдаемые сверху. Это позволит вам управлять жизненным циклом всех данных, бизнес-логикой и многопоточным кодом полностью независимо от SwiftUI.
- Внутренняя библиотека SwiftUI C++ AttributeGraph, определяющая жизненный цикл объектов состояния, прикрепленных к представлениям, сложна для понимания, особенно в том виде, в котором мы ее использовали. Множественные экземпляры, возникающие в наших вью-моделях StateObject, когда мы ожидали только один, вызвали множество дополнительных проблем при взаимодействии со Swift Concurrency и Combine, поскольку заставляли нас нагружать систему потоков повторяющимися действиями больше, чем нужно,.
- Мы вызываем множество тяжеловесных библиотек Apple, таких как Vision, CoreML, PhotoKit и т.д. Мы также используем сложную библиотеку Google Photos как опциональную фичу. Эти кодовые базы внутри не на 100% соответствуют контракту Swift Concurrency, что иногда приводило к проблемам со взаимными блокировками в моменты нагрузки на потоки, которые становились еще более случаными из-за недетерминированного поведения жизненного цикла объекта AttributeGraph в SwiftUI.
- Combine может неожиданно породить 100 потоков, если использовать его неправильно. Dispatch останавливается сам по себе после определенного момента.
Как мы решили некоторые из вышеперечисленных проблем
- Мы отказались от Combine. Это началось еще до моего прихода, но некоторые пережитки остались и приводили к ошибкам.
- Мы перенесли тяжелые операции в явные очереди диспетчеризации, чтобы не нагружать общие пулы потоков swift concurrency ‘cooperative’.
- Мы сделали некоторые ViewModels ObservedObject’ами, которые родительские StateObject’ы передавали в представления, но в некоторых местах это было нелегко из-за нашей зависимости от StateObject’а. Мы все еще используем StateObject неправильно во многих местах, но улучшили его использование, так как начали лучше понимать его неявные требования.
Как мы будем решать остальные проблемы: UIKit и Dispatch
Чтобы решить остальные проблемы, мы столкнулись с необходимостью значительного рефакторинга наших моделей представлений и бизнес-логики, чтобы сделать их более дружественными к SwiftUI, чтобы мы могли рассуждать о жизненном цикле наших моделей данных и отказаться от использования StateObject. Нам также предстоит перевести много async/await кода в dispatch, чтобы избежать ненаблюдаемых дедлоков и предсказуемо распределять потоковые ресурсы для наших различных подсистем. В целом это огромный рефакторинг нашей кодовой базы с и без того непростой многопоточной системой.
Поговорив немного об этом, мы поняли, что перенос представлений из SwiftUI в UIKit на самом деле будет меньшим злом. Мы освободимся от неопределенного управления состоянием SwiftUI, наши жизненные циклы ViewModel будут работать предсказуемо и хорошо с UIKit, а такие сложные вещи, как навигация в SwiftUI, станут проще. Кроме того, в будущем нам не придется тратить недели на реализацию вещей, которые в UIKit являются 5 строчками кода, а в SwiftUI — многодневными проектами.
После того как мы приняли это решение, один из наших инженеров за несколько часов сделал прототип UIKitCollectionView для основного экрана, который работал буквально в 10 раз быстрее нашей версии на SwiftUI с гораздо меньшим количеством головной боли, и он сделал это от энтузиазма, никто его даже не просил.
Через несколько месяцев после принятия этого решения мы закончили перенос всего управления навигацией, приложением и т.д. в UIKit. У нас больше нет структуры App верхнего уровня, вместо нее мы используем делегат Application & Scene. Каждый экран SwiftUI обернут хостингом UIViewController, и мы используем UINavigation из UIKit для управления экранами, модальными отображениями и т.д. Теперь все работает гораздо лучше. Мы частично предполагаем, что это работает лучше потому, что каждое дерево графа атрибутов SwiftUI не является частью одного мега-дерева App, а вместо этого представляет собой множество разделенных деревьев меньшего размера, с которыми AttributeGraph, вероятно, легче справляться.