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

Особенно это заметно сейчас, когда с отладкой нам начинают помогать ИИ-инструменты, и нам всё чаще приходится разбираться в коде, который выдала LLM, или, что ещё хуже, в ошибках, которые она сама же и породила.

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

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

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

Ошибка 1: Сбой

Одна из самых распространённых проблем, которая может потребовать от вас полного внимания, — это сбой (crash) приложения.

Недавно несколько пользователей нам (Helm) написали о том, что наша функция перевода вызывает сбой сразу после нажатия кнопки «Перевести всё»:

Однако воспроизвести проблему у себя нам так и не удалось, как бы мы ни старались. К счастью, у нас был доступ к журналам сбоев, так что мы смогли продолжить расследование и попытаться найти первопричину.

Если вам когда-либо приходилось открывать файл журнала сбоя, то вы знаете, что читать его непросто. К тому же он иногда указывает на какую-нибудь внутреннюю системную библиотеку, почти никак или вовсе никак не связанную с вашим реальным кодом. А если происходит именно это, понять, что вообще случилось, и тем более исправить сбой становится гораздо сложнее.

Вот фрагмент трассировки стека из отчёта о сбое:

Thread 0 Crashed::  Dispatch queue: com.apple.main-thread
0   libsystem_kernel.dylib        	       0x199001388 __pthread_kill + 8
1   libsystem_pthread.dylib       	       0x19903a88c pthread_kill + 296
2   libsystem_c.dylib             	       0x198f43c60 abort + 124
3   libsystem_c.dylib             	       0x198f42eec __assert_rtn + 284
4   AppKit                        	       0x19de49ff0 _nsis_frameInEngine + 1608
5   AppKit                        	       0x19de51acc -[NSView(NSConstraintBasedLayoutInternal) systemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority:] + 356
6   SwiftUI                       	       0x1ca5de204 PlatformViewHost.intrinsicContentSize.getter + 264
7   SwiftUI                       	       0x1ca5de0d4 @objc PlatformViewHost.fittingSize.getter + 40
8   AppKit                        	       0x19de51d70 -[NSView(NSConstraintBasedLayoutInternal) measureMin:max:ideal:stretchingPriority:] + 300
9   SwiftUI                       	       0x1ca5decbc PlatformViewHost.intrinsicLayoutTraits() + 384
10  SwiftUI                       	       0x1ca5dc804 PlatformViewHost.layoutTraits() + 608
11  SwiftUI                       	       0x1ca59c848 closure #1 in ViewLeafView.layoutTraits() + 224
12  SwiftUI                       	       0x1ca59c744 ViewLeafView.layoutTraits() + 52
13  SwiftUI                       	       0x1ca59c480 closure #1 in ViewLeafView.sizeThatFits(in:environment:context:) + 1420
14  SwiftUICore                   	       0x200429490 specialized static Update.syncMain(_:) + 84
15  SwiftUI                       	       0x1ca599b8c closure #1 in PlatformViewLayoutEngine.sizeThatFits(_:) + 112
16  SwiftUICore                   	       0x2004d32c4 ViewSizeCache.get(_:makeValue:) + 360
17  SwiftUI                       	       0x1ca599ad4 PlatformViewLayoutEngine.sizeThatFits(_:) + 264
18  SwiftUICore                   	       0x20073ead0 LayoutEngineBox.sizeThatFits(_:) + 144

Как видите, здесь есть только записи из фреймворков SwiftUI и AppKit, а это не слишком мне помогло. На этом этапе я обратился к ChatGPT, чтобы лучше понять журнал сбоя и выяснить, не подскажет ли он, что именно могло вызвать сбой внутри этих внутренних методов.

Вот какой ответ я получил:

И еще раз: хотя ChatGPT не указал точную причину проблемы, он направил меня в верную сторону благодаря следующей фразе: «_nsis_frameInEngine — это внутренний метод AppKit, который срабатывает с проверкой, когда состояние компоновки становится несогласованным». Хотя я не был до конца уверен, какой именно view вызывал проблему, я понял, что нужно сосредоточиться на коде интерфейса и попытаться выяснить, из-за чего макет рассчитывается некорректно.

Из сообщения пользователя я уже знал, что проблема возникала при нажатии кнопки «Перевести всё» на экране переводов, поэтому дальше я обратился к отдельному источнику информации, чтобы лучше понять контекст, в котором произошёл сбой: Diagnostics.

Если вы не знакомы с Diagnostics, это отличная библиотека с открытым исходным кодом, созданная Antoine van der Lee. Она собирает массу полезной информации из пользовательского сеанса в приложении: журналы, сетевые запросы и многое другое, а затем формирует понятный человеку HTML-отчёт, которым можно поделиться с разработчиками приложения.

В этой конкретной ситуации Diagnostics дал нам важную зацепку:

Как видно, отчёт показывает, что сетевой запрос на перевод всего содержимого завершился ошибкой прямо перед сбоем. Может ли это объяснить, почему нам не удавалось воспроизвести проблему у себя? Может ли быть, что сбой происходил только в том случае, когда сетевой запрос на перевод всего содержимого завершался неудачей?

Чтобы проверить эту гипотезу, я обратился к инструменту, которым чаще всего пользуюсь для отладки сетевых проблем, а иногда и просто для проверки корректности своей реализации: Proxyman. Это приложение для macOS, которое позволяет перехватывать и изменять сетевые запросы и ответы.

Я нашёл запрос на перевод, который отправляло приложение, а затем добавил его в «Block List» в Proxyman, чтобы принудительно вызвать ошибку в приложении:

И сразу после нажатия кнопки «Перевести всё» приложение крэшнулось. Когда мне удалось воспроизвести сбой, я довольно быстро понял, что он происходил в момент показа нашего пользовательского представления Toast Error, потому что оно встраивалось в HStack, где не хватало места, чтобы удовлетворить его ограничения.

Вместо этого я сделал его наложением (overlay), полностью исключив влияние на макет, и приложение смогло показать это представление уже без сбоя:

Ошибка 2: Регрессии производительности 

Недавно мы заметили, что одно из представлений в приложении стало загружаться дольше, чем в предыдущих версиях. Да, приложение неизбежно растёт и усложняется по мере добавления новых фич, но важно, чтобы влияние этих изменений на производительность оставалось минимальным.

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

Первым делом я воспользовался шаблоном Time Profiler в Instruments из Xcode, чтобы понять, куда уходит время:

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

На этом этапе я вернулся к кодовой базе, чтобы исправить проблему с помощью структурированной конкурентности. Я перешёл от такого кода, где каждая асинхронная задача выполняется последовательно:

let submission: Void = await SubmissionManager.shared.setup(for: app, platform: version.platform)
let versionInfo = try await app.getInformation(
    forVersion: version,
    otherPlatforms: app
        .availableVersions
        .reduce(into: [AvailableVersion](), { partialResult, iteratorVersion in
            guard version != iteratorVersion, iteratorVersion.platform != version.platform else { return }
            if !partialResult.contains(where: { $0.platform == iteratorVersion.platform }) {
                partialResult.append(iteratorVersion)
            }
        })
)
let updatedApp: AppStoreConnectApp? = try await {
    guard self.appID != app.id || forceRefresh else { return app }

    return try await store.getApp(byID: app.id, forceRefresh: true)
}()

Вот к такому коду, где всё выполняется параллельно:

async let submissionsRequest: Void = SubmissionManager.shared.setup(for: app, platform: version.platform)
async let appInformation = try app.getInformation(
    forVersion: version,
    otherPlatforms: app
        .availableVersions
        .reduce(into: [AvailableVersion](), { partialResult, iteratorVersion in
            guard version != iteratorVersion, iteratorVersion.platform != version.platform else { return }
            if !partialResult.contains(where: { $0.platform == iteratorVersion.platform }) {
                partialResult.append(iteratorVersion)
            }
        })
)
async let refreshedAppInfo: AppStoreConnectApp? = {
    guard self.appID != app.id || forceRefresh else { return app }

    return try await store.getApp(byID: app.id, forceRefresh: true)
}()

let (versionInfo, updatedApp, _) = try await (appInformation, refreshedAppInfo, submissionsRequest)

Как и в этом примере, чаще всего основное время уходит не на само исправление, а на поиск причины проблемы. Именно поэтому так важно уметь пользоваться инструментами, которые делают процесс поиска ошибки максимально эффективным.

Ошибка 3: Неожиданные системные запросы

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

Поэтому я начал разбираться, почему Apple вообще решила, что наше приложение пытается использовать функции, для которых требуется такое разрешение. И вот тут история стала особенно интересной:

  • Это предупреждение появлялось на устройстве только один раз, а в macOS этот параметр нельзя сбросить. Как можно догадаться, воспроизводить проблему было сущим кошмаром: мне приходилось снова и снова перезапускать компьютер, чтобы это окно появилось.

  • В консоли не было ни журналов, ни какой-либо конкретной информации, которая помогла бы понять, почему вообще показывается этот алерт.

  • Причиной могли быть сторонние библиотеки, а так как мы используем несколько таких библиотек, объём кода для проверки был немаленький.

Единственное, что мы знали наверняка: запрос показывался при запуске приложения. Значит, метод, который его вызывал, с высокой вероятностью срабатывал в инициализаторах структуры приложения. Эта информация послужила надёжной отправной точкой и в итоге оказалась ключевой для поиска первопричины.

Вот всё, что мы инициализируем при запуске приложения:

@main
struct HelmApp: App {
    // ...
    init() {
        do {
            try DiagnosticsLogger.setup()
        } catch {
            ErrorLogger.log("Failed to setup the Diagnostics Logger", for: .generic)
        }
        AIProxy.configure(
            logLevel: .debug,
            printRequestBodies: false,
            printResponseBodies: false,
            resolveDNSOverTLS: true,
            useStableID: true
        )
        HelmProManager.configureRevenueCat()
        let config = TelemetryDeck.Config(appID: Keys.telemetryDeckAppID)
        TelemetryDeck.initialize(config: config)
        
        NSWindow.allowsAutomaticWindowTabbing = false
        validator = AppStoreAPI.shared
        helmPro   = HelmProManager.shared
        let versionHistoryManager = VersionHistoryManager(storage: StorageManager.history)
        versionRefresher = VersionsRefresher(versionHistoryManager: versionHistoryManager)
        store = Store()
    }
}

Первым делом я закомментировал всё в методе init(), чтобы убедиться, что запрос действительно вызывает один из этих вызовов. После того как я закомментировал всё и снова запустил приложение, запрос больше не появлялся. Затем я начал по одному возвращать вызовы, пока не нашёл, какой именно метод обращается к локальной сети.

После того как я определил метод, вызывавший проблему, я стал разбираться, что именно внутри него приводит к появлению системного запроса. Поскольку файлы удалённой зависимости SPM нельзя редактировать прямо в исходниках, я клонировал репозиторий и заменил удалённый Swift Package на только что склонированный локальный вариант.

Этот приём не раз выручал меня раньше: с его помощью легко отлаживать и разбираться с проблемами в сторонних библиотеках. Получив локальную копию пакета, я повторил тот же процесс, что и раньше: закомментировал весь код библиотеки, а затем начал постепенно возвращать логику, пока запрос снова не появился. И в итоге мне удалось найти точный фрагмент кода, который вызывал появление этого системного запроса:

enum Device {
    static var systemName: String {
        #if os(macOS)
        // 😱 This is the culprit line!
        return ProcessInfo().hostName
        #else
        return UIDevice.current.systemName
        #endif
    }
}

Мы это пофиксили, создав форк библиотеки и заменив приведённый выше код другой реализацией, чтобы как можно скорее провести релиз. Также мы сообщили о проблеме сопровождающему библиотеки, чтобы её можно было исправить в одном из следующих релизов.

Хотя процесс поиска причины этого бага нельзя назвать особенно ��ффектным, некоторые баги требуют всего лишь старых добрых print, комментариев и изрядной доли терпения.

Подведем итоги

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

  • Сбои приложения часто требуют анализа трассировок стека, использования диагностических инструментов вроде Diagnostics и средств сетевой отладки вроде Proxyman.

  • Проблемы с производительностью можно эффективно выявлять с помощью Xcode Instruments, а исправлять их чаще всего помогает хорошее понимание возможностей Swift, таких как структурированная конкурентность.

  • Загадочное поведение системы бывает исправить сложнее всего: здесь нередко нужны методичное исключение возможных причин и терпение, чтобы докопаться до проблемы в сторонней библиотеке.

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

И помните: большая часть времени при отладке уходит не на исправление, а на поиск самой причины. Поэтому стоит всерьёз вкладываться в изучение своих инструментов отладки — этот навык окупается на протяжении всей карьеры разработчика.

Если в отладке вы чаще действуете на ощупь, чем системно, это рано или поздно начинает стоить времени и качества продукта. На курсе «IOS-разработчик. Продвинутый уровень» как раз разбирают такие ситуации на практике: работа с конкурентностью, устройством интерфейсов, инструментами анализа и реальными сценариями, где баги неочевидны.

Чтобы понять, готовы ли вы к серьезному обучению и повышению грейда, пройдите входное тестирование: 10–15 минут теста дадут понимание уровня и рекомендации по темам.

А для знакомства с форматом обучения и преподавателями приходите на бесплатные демо-уроки:

  • 23 марта в 20:00. «Навигация продвинутого уровня в SwiftUI: как строить масштабируемые iOS-приложения без хаоса в переходах». Записаться

  • 8 апреля в 20:00. «Эволюция асинхронности и конкурентности в iOS-разработке: архитектурный взгляд на современные инструменты Swift». Записаться

  • 20 апреля в 20:00. «Модульные (юнит) тесты для iOS приложений: работаем над качеством изнутри». Записаться