Кто крешит приложение на старте?

Привет! Меня зовут Александр Денисов, я из команды мобильного Яндекс.Браузера в Санкт-Петербурге. В этом посте расскажу вам, как мы справляемся с циклическими крешами на старте.

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

В качестве примера будет выступать приложение Яндекс.Браузер для iOS: более 100 тысяч исходных файлов, тысячи коммитов в год и около тысячи модулей без учёта ядра (Swift + Objective-C). Кстати, не так давно мы рассказывали, как помогли команде Swift ускорить отладчик.

Циклический креш на старте

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

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

Как бороться с циклическими крешами

Проблему циклических крешей Яндекс.Браузера мы решали в несколько подходов. Сначала просто показывали UIAlertController с предложением сбросить вкладки. Это было особенно актуально во времена UIWebView, когда приложение выступало веб-процессом, и баг в веб-подсистеме мог вызвать креш всего приложения. Легко было попасть в обидную ситуацию: открытие сайта, приводящего к падению UIWebView, не позволяло пользоваться приложением из-за постоянного креша на старте.

Затем мы реализовали более сложную подсистему, которая запускает минимальный набор компонент и позволяет сбросить кеши, настройки, вкладки и другие параметры по выбору пользователя. Мы назвали её Safe mode (или режим восстановления), и выглядит это примерно как на скриншоте в заголовке этой статьи.

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

После AB-тестирования и доводки мы внедрили новую систему в Браузер, серьёзно повысив его стабильность. Однако даже она оказалась бессильна перед проблемой, поразившей многие iOS-приложения по всему миру, включая наиболее популярные: речь идет о крешах в Facebook SDK. Напомню, в мае и июле 2020 года многие самые популярные приложения (и не только они) перестали запускаться из-за ошибки на старте, когда код Facebook SDK пытался обработать серверный ответ. И, конечно, наше приложение не было исключением. Почему же Safe mode не помог нам в этом случае?

Что делать с Facebook SDK

Проблема в том, что Facebook SDK начинал исполнять код ещё до вызова какого-либо метода своего публичного API. Objective-C без проблем позволяет делать такие (и другие) вещи с помощью метода +(void)load. Соответственно, у интегрирующего такую «невежливую» библиотеку приложения нет возможности защититься от выполнения кода, который никто явно не вызывал. Или есть?

Идея решения возникла, когда мы размышляли над постановкой задачи с технической стороны. Если мы не можем защититься от ошибок, происходящих сразу после загрузки кода в память процесса, стоит ли вообще загружать этот код? Проблемную часть кода можно не включать в основной исполняемый файл приложения, а положить рядом и подгружать (и выгружать, если бы такая функциональность была доступна на iOS) по мере необходимости. Этот механизм называется динамическим связыванием (линковкой), а подгружаемая часть кода — динамически подключаемой библиотекой. В формате Mach-O (исполняемые файлы на платформах Apple) динамические библиотеки обычно имеют расширение dylib.

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

Итак, для применения описанного подхода нам нужно:

  1. Уметь загружать код Facebook SDK по требованию — в этом нам как раз поможет dylib.
  2. Централизованно управлять отключением функциональности у всех пользователей. Если в вашем приложении ещё нет такой возможности — самое время её добавить!
  3. Предусмотреть защитный механизм, позволяющий получить новую настройку, даже если приложение падает на старте. В Браузере мы на сутки блокируем Facebook SDK, если сталкиваемся с циклическим крешем, чтобы приложение выжило и скачало обновлённый конфиг.

Рассмотрим последний пункт подробнее.

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

Если же крешлоггера нет, можно воспользоваться эвристикой: при нормальном уходе в фон или завершении работы с делегатным вызовом applicationWillTerminate() (для SwiftUI используется UIApplicationDelegateAdaptor) записываем на диск признак, которого там не окажется при некорректном завершении работы (то есть креше).

Отмечу, что история с Facebook SDK не уникальна: подобного рода проблемы могут возникнуть с любым third-party кодом. При этом рассмотренный защитный механизм является универсальным и может быть применён во всех случаях, где на первый план выходит надёжность.

Динамическая библиотека

Теперь вернёмся к самому интересному — динамической библиотеке. В этой статье мы будем работать с сэмпл-проектами с использованием Swift Package Manager, так как в нашем проекте используется кастомная система сборки, основанная на GN и Ninja: если мы поделимся примерами кода «как есть», то применить их сможет весьма ограниченная аудитория разработчиков.

Сразу оговорюсь, что Swift Package Manager используется здесь для простоты: такой код легко собрать и запустить. Однако в SPM нет поддержки iOS. То есть добавить зависимость на Facebook SDK для iOS мы сможем, а вот собрать такой код — уже нет. Поэтому для использования в iOS-приложении всё же потребуется интеграция кода из примера в Xcode-проект: например, добавление дополнительного шага сборки, копирующего библиотеку в подкаталог Frameworks внутри бандла приложения. Если оставить Facebook SDK и особенности платформы iOS за скобками, то получим следующие примеры.

Пример 1. Простой интерфейс

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

Структура проекта предельно проста: имеем независимые динамическую библиотеку и приложение, которое её загружает.

import PackageDescription let package = Package( name: "DylibExample", products: [ .library(name: "Dylib", type: .dynamic, targets: ["Dylib"]), ], targets: [ .target(name: "Dylib", dependencies: []), .target(name: "HostApp", dependencies: []), ]
)

Для начала разберём код на стороне библиотеки:

import Foundation @_cdecl("performSampleTask")
public func performSampleTask( _ name: String, _ options: [String: Any]?
) -> Bool { print("Performing task with name: \(name), options: \(options ?? [:])") return true
}

Обратите внимание на атрибут @_cdecl(), позволяющий экспортировать символы в C-нотации.

Далее — уже на стороне основного приложения — функциональность для поддержки dylib:

import Darwin public final class DynamicLinkLibrary { public let handle: UnsafeMutableRawPointer public init?(path: String, mode: Int32 = RTLD_LAZY) { guard let handle = dlopen(path, mode) else { return nil } self.handle = handle } deinit { dlclose(handle) } public func load<T>(symbol: String) -> T? { dlsym(handle, symbol).map { unsafeBitCast($0, to: T.self) } }
}

Здесь мы инкапсулируем логику по загрузке и выгрузке (однако такая возможность не поддерживается на платформе iOS) библиотеки и разрешению символов (в нашем случае — функций). RTLD_LAZY используется для «ленивого» связывания символов по мере использования и является режимом по умолчанию. Альтернативой может быть RTLD_NOW, когда связывание для всех символов происходит прямо во время вызова dlopen.

На базе этого класса делаем обвязку под свою задачу:

import Foundation final class DylibImpl { private let performSampleTaskSymName = "performSampleTask" private let dylib: DynamicLinkLibrary typealias PerformSampleTaskFunc = @convention(c) ( _ name: String, _ options: [String: Any]? ) -> (Bool) private(set) lazy var performSampleTask: PerformSampleTaskFunc = self.dylib.load(symbol: performSampleTaskSymName)! init(path: String) { self.dylib = DynamicLinkLibrary(path: path)! }
}

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

Ну и наконец самое приятное — собираем всё воедино и запускаем:

let dylib = DylibImpl(path: "libDylib.dylib")
let result = dylib.performSampleTask("ExampleTask", ["exampleKey": "exampleValue"])
print("Result: \(result)")
./HostApp
Performing task with name: ExampleTask, options: ["exampleKey": "exampleValue"]
Result: true

Пример 2. Публичный интерфейс, вынесенный отдельно

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

Для начала рассмотрим пакет публичного API:

import PackageDescription let package = Package( name: "DylibInterface", products: [ .library(name: "DylibInterface", type: .dynamic, targets: ["DylibInterface"]), ], targets: [ .target(name: "DylibInterface", dependencies: []), ]
)
public struct DylibDTO { public let str: String public let int: Int public init(str: String, int: Int) { self.str = str self.int = int }
} public protocol DylibInterface { func fetch() -> DylibDTO
} // We use abstract class since `Unmanaged` only supports class type.
open class DylibInterfaceProvider { public init() {} open func provide() -> DylibInterface { preconditionFailure("Not implemented") }
}

Наличие общего интерфейса позволяет использовать существенно более богатый API, если сравнивать с предыдущим примером (я постарался проиллюстрировать это с помощью типа DylibDTO), при этом для доступа к типам не требуется ручной маппинг с использованием символов.

Теперь рассмотрим код на стороне библиотеки и основного приложения.

import PackageDescription let package2 = Package( name: "DylibExample", products: [ .library(name: "Dylib", type: .dynamic, targets: ["Dylib"]), ], dependencies: [ .package(name: "DylibInterface", path: "../example_interface"), ], targets: [ .target(name: "Dylib", dependencies: ["DylibInterface"]), .target(name: "HostApp", dependencies: ["DylibInterface"]), ]
)

Библиотека:

import DylibInterface struct DylibImpl: DylibInterface { func fetch() -> DylibDTO { return DylibDTO(str: "Llorem Ipsum", int: 42) }
} @_cdecl("getDylibProvider")
public func getDylibProvider() -> UnsafeMutableRawPointer { return Unmanaged.passRetained(DylibProviderImpl()).toOpaque()
} final class DylibProviderImpl: DylibInterfaceProvider { override func provide() -> DylibInterface { DylibImpl() }
}

Описание библиотеки очень похоже на предыдущий пример и использует ту же реализацию DynamicLinkLibrary.swift (поэтому не будем приводить её повторно):

import DylibInterface
import Foundation final class DylibImpl { private let getDylibProviderSymName = "getDylibProvider" private let dylib: DynamicLinkLibrary typealias getDylibProviderFunc = @convention(c) () -> UnsafeMutableRawPointer private(set) lazy var getDylibProvider: getDylibProviderFunc = self.dylib.load(symbol: getDylibProviderSymName)! func getInterface() -> DylibInterface { let provider = Unmanaged<DylibInterfaceProvider>.fromOpaque(getDylibProvider()).takeRetainedValue() return provider.provide() } init(path: String) { self.dylib = DynamicLinkLibrary(path: path)! }
}

Обратите внимание на работу с созданным объектом через Unmanaged: здесь нам потребуется использовать ручное управление памятью.

И теперь снова собираем всё воедино и запускаем:

let dylib = DylibImpl(path: "libDylib.dylib")
let result = dylib.getInterface().fetch()
print("Result: \(result)")
./HostApp
Result: DylibDTO(str: "Llorem Ipsum", int: 42)

Заключение

Итак, мы рассмотрели несколько подходов, которые помогают делать приложение надёжнее:

  1. Обнаружение циклических крешей.
  2. Режим восстановления, позволяющий отправить диагностическую информацию и экстренно очистить данные, которые могли привести к сбою.
  3. Вынесение потенциально опасного кода в динамически подключаемую библиотеку, с тем чтобы его можно было отключить при неблагоприятных условиях.

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

Читайте так же:

  • Для 15-летней PlayStation 3 вышло неожиданное обновлениеДля 15-летней PlayStation 3 вышло неожиданное обновление Игровая консоль PlayStation 3, которая в этом году отметит 15 лет с момента запуска, неожиданно получила обновление программного обеспечения 4.88, удивив огромное количество пользователей. Предыдущее обновление было выпущено в декабре прошлого года. Оно улучшило стабильность работы […]
  • Как рассылки Умного голосования попали в спам mail.ru?Как рассылки Умного голосования попали в спам mail.ru? Коротко о том, что произошло:Mail.ru отправлял в спам письма, связанные с Умным голосованием. Письма с кандидатами и письма об итогах УмГ. В дни выборов и не во время выборов. На всех основных доменах команды Навального: rus.vote, navalny.com и fbk.info. Есть небольшие шансы на то, что […]
  • Число сделок с элитными домами в Подмосковье выросло в несколько разЧисло сделок с элитными домами в Подмосковье выросло в несколько раз В 2021 году спрос на элитные загородные дома под Москвой растет быстрее. Чем в прошлом году Фото: ID1974\shutterstock В первом квартале 2021 года объем сделок с первичным элитным загородным жильем в Подмосковье в полтора раза превысил показатель 2020 года. На вторичном рынке […]
  • Как будут выглядеть процессоры после 2025 годаКак будут выглядеть процессоры после 2025 года Сколько хоронили закон Мура, а он продолжает работать. Даже сейчас, на фоне острого дефицита микросхем.Планы Intel, AMD, Apple и производителей ARM следующего поколения говорят, что мы на пороге небольшой технологической революции. Транзисторы с круговым затвором, техпроцесс 2 нм, […]