Swift.assert — жизнь после релиза

    Как часто вы используете Swift.assert() в вашем коде? Я, честно, использую довольно часто (Если это плохая практика, то, пожалуйста, напишите в комментариях — почему это плохо?). В моем коде часто можно встретить, например, такой вызов:

    Swift.assert(Thread.isMainThread)

    Не так давно я решил, что неплохо бы продолжить наблюдать результаты от этих вызовов не только в рамках запуска приложения в симуляторе / на девайсе, но и от действий реальных пользователей. Кстати, здесь речь может идти и про Swift.precondition(), Swift.fatalError() и т.п, хотя их я стараюсь избегать. Более подробно про Unrecoverable Errors in Swift я читал в этой публикации и она оказалось очень даже познавательной.

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

    С точки зрения пользователя вылетов происходить не должно (опять же мое мнение). В крайнем случае пользователь должен понять, что какой-то сценарий пошел не по плану и команда разработчиков уже работает над починкой. Подобные исключения всегда обрабатывались мной и для пользователя это могло сказаться в самой безобидной форме. Например, одна из сотен ячеек таблицы просто была невидима.

    С пользователем более-менее все ясно. Осталось разобраться с доставкой логов до разработчика. Во-первых, требовалось минимальными усилиями заменить в коде текущие вызовы на вызовы, отправляющие логи куда-то за пределы приложения. Во-вторых, требовалось точно локализовать место происшествия, иначе соотнести исключение с реальным кодом было бы практически невозможно. В-третьих, следовало учесть, что видоизмененные вызовы могут сработать при Unit-тестировании, где Thread.isMainThread уже должен игнорироваться т.к. я использую RxTest фреймворк для определенных видов тестрирования (здесь я тоже готов выслушать советы и критику). Главным пунктом осталось то, что локально все исключения должны срабатывать как и раньше, т.е. Loggin.assert() должен срабатывать тогда же, когда бы срабатывал Swift.assert()

    Отличный способ отправки событий предоставляет Fabric (Crashlytics). Выглядит это следующим образом:

    Crashlytics.sharedInstance().recordCustomExceptionName("", reason: ""...

    Осталось упаковать Crashlytics в какой-нибудь фреймворк, который можно подгружать в полноценном виде в приложение и в урезанном виде (без зависимости Crashlytics) в тестовые таргеты.

    «Упаковку» я решил сделать через CocoaPods:

    Pod::Spec.new do |s|
    
        s.name = 'Logging'
        ...
    
        s.subspec 'Base' do |ss|
            ss.source_files = 'Source/Logging+Base.swift'
            ss.dependency 'Crashlytics'
        end
    
        s.subspec 'Test' do |ss|
            ss.source_files = 'Source/Logging+Test.swift'
        end
    end

    Код для «боевого таргета» выглядит следующим образом:

    import Crashlytics
    
    public enum Logging {
        
        public static func send(_ reason: String? = nil, __file: String = #file, __line: Int = #line) {
            let file = __file.components(separatedBy: "/").last ?? __file
            let line = "\(__line)"
            let name = [line, file].joined(separator: "_")
            Crashlytics.sharedInstance().recordCustomExceptionName(name, reason: reason ?? "no reason", frameArray: [])
        }
        
        public static func assert(_ assertion: @escaping @autoclosure () -> Bool, reason: String? = nil, __file: String = #file, __line: Int = #line) {
            if assertion() == false {
                self.assertionFailure(reason, __file: __file, __line: __line)
            }
        }
        
        public static func assert(_ assertion: @escaping () -> Bool, reason: String? = nil, __file: String = #file, __line: Int = #line) {
            if assertion() == false {
                self.assertionFailure(reason, __file: __file, __line: __line)
            }
        }
        
        public static func assertionFailure(_ reason: String? = nil, __file: String = #file, __line: Int = #line) {
            Swift.assertionFailure(reason ?? "")
            self.send(reason, __file: __file, __line: __line)
        }
    }

    Для «тестового таргета» т.е. без зависимости Crashlytics:

    import Foundation
    
    public enum Logging {
        
        public static func send(_ reason: String? = nil, __file: String = #file, __line: Int = #line) {
            //
        }
        
        public static func assert(_ assertion: @escaping @autoclosure () -> Bool, reason: String? = nil, __file: String = #file, __line: Int = #line) {
            //
        }
        
        public static func assert(_ assertion: @escaping () -> Bool, reason: String? = nil, __file: String = #file, __line: Int = #line) {
            //
        }
        
        public static func assertionFailure(_ reason: String? = nil, __file: String = #file, __line: Int = #line) {
            //
        }
    }

    Результаты:


    Исключения действительно начали срабатывать. Большая часть уведомляла о некорректном формате получаемых данных: Decodable иногда получал данные с несоответствующим типом. Иногда срабатывали логи для Thread.isMainThread, которые очень оперативно исправлялись в следующих релизах. Самыми интересными ошибками стали чудом выловленные NSException.

    Спасибо за внимание.

    P.S. Если вдруг вы будете слишком часто слать подобные логи в Crashlytics, то сервис может распознать ваши действия как спам. И вы увидите следующее сообщение:
    Due to improper usage, non-fatal reporting has been disabled for multiple builds. Learn how to re-enable reporting in our documentation.
    Поэтому, стоит заранее продумать частоту отправки логов. Иначе все логи сборки могут оказаться под угрозой игнорирования сервисом Crashlytics
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

      0
      Никогда не использую assert, потому что приложение вместо того чтобы падать, если данные не верны, начинает вести себя непредсказуемо. Я, например, не знаю, что хуже — вылет приложения на assert с проверкой главного потока или зависание приложения (из-за чего его все равно нужно перезапускать) — и то, и другое может быть у конечного пользователя.
      + если бы у вас был отдел тестирования, то вы бы возможно поняли, что использовать 2 разных билда для отладки и тестирования усложняет исправление багов
      + начинаются костыли вроде отправки аналитики по вызовам assert в crashlytics
        0
        если бы у вас был отдел тестирования, то вы бы возможно поняли
        Что же меня выдало, как вы догадались, что его у меня нет?

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

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

        Если вы называете такое поведение «непредсказуемым», то для меня это обычная обработка ошибок. Ошибку от Decodable я отправлю в лог. Пользователю покажу уведомление о том, что что-то пошло не так. При таком сценарии пользователь сможет продолжить использовать приложение и сможет спокойной посмотреть другую карточку товара.

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

          Вы в статье рассматриваете один пример, а в комментариях — совершенно другой. Я писал о случаях, когда ошибка в коде именно клиента, а assert ее только прячет (от программиста в том числе). Если Decodable не справился с задачей, то этот код обернуть в try-catch либо реализовать пирсинг так, чтобы он не генерировал исключительных ситуаций вообще. Обошлось без assert, а добавить аналитику можно опционально.
          Еще API может меняться так быстро, что для поддержки Decodable нужно писать столько же кода, что и без него.

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

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