Как часто вы используете 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

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


  1. Gargo
    21.11.2019 11:58

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


    1. VadikBeglov Автор
      21.11.2019 13:38

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

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

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

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

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


      1. Gargo
        21.11.2019 15:59

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

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