Если вы используете React Native в производственной среде, это просто факт жизни, что в конечном итоге что-то заставит JS генерировать неперехваченное исключение. По умолчанию React Native в производственной среде фактически полностью выдает сбой вашего приложения, возвращая это исключение обратно до Objective-C. Это не идеально: пользователя просто бесцеремонно выкидывают обратно на домашний экран iOS, а вы, бедный разработчик приложения, возможно, даже не узнаете об этом. Если вы используете собственный инструмент для отчетов о сбоях, например New Relic или Google Analytics, все, что вы увидите, - это кучу бесполезных функций JavaScriptCore, возможно, с горсткой функций React: не так уж и полезно.
Разве не было бы неплохо, если бы мы могли регистрировать следы, которые мы обычно видим на Красном экране смерти ™, в какой-нибудь системе и улучшать работу пользователей, которые вызывают сбой?
Подход, основанный на использовании всего JavaScript
В React Native есть модуль ErrorUtils
, который позволяет вам переопределить глобальный обработчик ошибок. Вы можете использовать это, чтобы предоставить свой собственный обработчик, который отправляет исключение выбранному вами инструменту.
В вашем index.js
используйте это, чтобы изменить глобальный обработчик ошибок:
const ErrorUtils = require('ErrorUtils'); ErrorUtils.setGlobalHandler((e, isFatal) => { if (__DEV__) { // In DEV, pass the error to the standard ExceptionsManager // This way you'll still get nice RSODs in DEV require('ExceptionsManager').handleException(e, isFatal); } else { // Not in DEV. Send the exception 'e' to your logger of choice sendErrorToCrashReporter(e.message); } });
Однако что произойдет, если внутри этого блока кода что-то пойдет не так? Что, если исключение действительно плохо влияет на состояние вашей среды JavaScript, а ваш sendErrorToCrashReporter
вызов не работает? Вы также теряете все удобное форматирование и синтаксический анализ, которые дает вам ExceptionsManager
React Native. Также сложнее справиться с тем, что происходит после сбоя, если вы не согласны с тем, чтобы вернуть пользователя на главный экран iOS.
Как правило, в React Native вы всегда должны пытаться заставить что-то работать на чистом JavaScript везде, где это возможно. Однако в данном случае мы думали, что сможем улучшить взаимодействие как разработчика, так и пользователя, если внедрим что-то изначально.
Более надежный подход Objective-C
Это будет решение для iOS. Приносим свои извинения, если вам здесь нужна поддержка Android, ExceptionsManagerModule
в React Native Android, похоже, не так полон функций, как RCTExceptionsManager
, и, поскольку мы не используем React Native в Android, мы не приложили никаких усилий для обходного пути.
В React Native есть класс RCTExceptionsManager
, в котором регистрируются исключения JS. У него есть необязательное свойство, в котором вы можете указать делегата. В идеале вам нужно установить этот делегат до выполнения любого кода JavaScript, чтобы вы могли быть уверены, что получите все ошибки, в том числе при запуске.
Для этого вам нужно будет более непосредственно управлять созданием вашего RCTBridge
. Вы заметите, что RCTBridge
имеет инициализатор, который также принимает делегат, убедитесь, что вы используете этот инициализатор, а затем в передаваемом делегате реализуйте следующие методы таким образом.
Если вы никогда не реализовывали RCTBridgeDelegate
, первое, что вам нужно, это реализовать требуемый метод -sourceURLForBridge:
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { #if DEBUG return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil]; #else return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; #endif }
Теперь, когда это решено, вот полезный: -extraModulesForBridge:
. Этот метод позволяет вам предварительно инициализировать некоторые собственные модули, чтобы вы могли вручную управлять временем жизни и семантикой инициализации этих модулей, вместо того, чтобы полагаться на React Native, чтобы лениво создавать их экземпляры через RCT_EXPORT_MODULE
как обычно. Здесь мы инициализируем RCTExceptionsManager
, но тот, который мы предоставляем, будет иметь настраиваемого делегата.
- (NSArray<id<RCTBridgeModule>> *)extraModulesForBridge:(RCTBridge *)bridge { // Initialize delegate object to listen hard and soft exceptions MyCustomExceptionsManager *myExceptionsManager = [MyCustomExceptionsManager new]; // Initialize RCTExceptionManager and assign myExceptionsManager as delegate RCTExceptionsManager *rctExceptionsManager = [[RCTExceptionsManager alloc] initWithDelegate: myExceptionsManager]; // All three modules will be re-created upon reload. return @[rctExceptionsManager, myExceptionsManager]; }
Убедитесь, что ваш MyCustomExceptionsManager
также является обычным RCTBridgeModule
, чтобы вы могли получить доступ к мосту. Теперь, когда вы это сделали, ваш MyCustomExceptionsManager
класс будет получать все фатальные и нефатальные исключения JS! Теперь вы можете делать с этим все, что вам заблагорассудится:
- (void)handleFatalJSExceptionWithMessage:(NSString *)message stack:(NSArray *)stack exceptionId:(NSNumber *)exceptionId { [self sendFatalExceptionToBackendWithMessage: message stack:stack exceptionId:exceptionId]; } - (void)handleSoftJSExceptionWithMessage:(NSString *)message stack:(NSArray *)stack exceptionId:(NSNumber *)exceptionId { [self sendSoftExceptionToBackendWithMessage: message stack:stack exceptionId:exceptionId]; }
Кроме того, мы представим UIAlertController
пользователю вопрос, хочет ли он «перезапустить» приложение из-за непредвиденной ошибки.
Вы можете реализовать эту функцию «перезапуска», просто вызвав [self.bridge reload]
в своем пользовательском классе диспетчера исключений!
Следы стека
Теперь, когда вы получаете первые трассировки стека, вы можете заметить, что они не так уж и полезны:
[ { "lineNumber": 1, "file": "123.js", "methodName":"crashAllTheThings", "column": 1234 }, ... ]
И сообщения тоже не такие уж хорошие:
Не бойтесь, эти следы можно вернуть в здравомыслие. React Native использует упаковщик JavaScript под названием metro для компиляции множества файлов JavaScript в два файла: пакет JS и исходную карту. Этот пакет JS отправляется вашим клиентам, а исходная карта используется для сопоставления мест в окончательном пакете JS с исходным источником.
Первое, что вам нужно сделать, это убедиться, что у вас есть исходная карта, доступная для сборки, которая произвела эту трассировку стека, при условии, что вы не сгенерировали ее во время первоначальной сборки. Сначала убедитесь, что вы git checkout
в точности выполняли фиксацию, когда была произведена трассировка стека. Вы можете сгенерировать исходную карту, используя одну из двух команд:
Если вы не используете пакеты ОЗУ (если не уверены, то, вероятно, нет):
node node_modules/react-native/local-cli/cli.js bundle --platform ios --entry-file index.js --dev false --reset-cache --bundle-output /dev/null --assets-dest /dev/null --sourcemap-output bundle.ios.map --sourcemap-sources-root "$(pwd)"
Если вы используете пакеты RAM:
node node_modules/react-native/local-cli/cli.js ram-bundle --platform ios --entry-file index.js --dev false --reset-cache --bundle-output /dev/null --assets-dest /dev/null --sourcemap-output bundle.ios.map --sourcemap-sources-root "$(pwd)"
Я настоятельно рекомендую вашей системе CI / CD генерировать эти исходные карты с вашими производственными сборками и хранить их в безопасном месте. Убедитесь, что вы действительно доверяете системе, в которой храните ее, поскольку исходная карта содержит весь исходный код!
Metro создает карту источников, несовместимую со стандартными картами веб-источников, поэтому, скорее всего, ваш любимый инструмент сопоставления источников не будет работать с ними. Metro предоставляет собственный инструмент metro-symbolicate
, который, к сожалению, не считывает эти трассировки стека на основе JSON: он ожидает, что они будут в текстовом формате JavaScriptCore. Однако, немного смазав локоть, его можно использовать как библиотеку для вашего собственного инструмента командной строки JavaScript ...
yarn add metro-symbolicate
Затем вы можете действительно наивно сгенерировать трассировку стека из JSON, вот фрагмент, который поможет вам начать работу.
const fs = require('fs'); const { SourceMapConsumer } = require('source-map'); const Symbolication = require('./node_modules/metro-symbolicate/src/Symbolication'); const sourceMap = fs.readFileSync(sourceMapPath, 'utf8'); const context = Symbolication.createContext(SourceMapConsumer, sourceMap, { nameSource: 'function_names', inputLineStart: 1, inputColumnStart: 0, outputLineStart: 1, outputColumnStart: 0 } ); const jsonStr = fs.readFileSync(stackJsonPath, 'utf8'); const json = JSON.parse(jsonStr); function formatMappedPosition(pos) { if (!pos.source) { return " at <unknown>"; } else { const source = pos.source; if (pos.name) { return ` at ${pos.name} (${source}:${pos.line}:${pos.column})`; } else { return ` at ${source}:${pos.line}:${pos.column}`; } } } const stackStr = json .map(element => { return Symbolication.getOriginalPositionFor( element.lineNumber, element.column, Symbolication.parseFileName(element.file), context ); ) .map(formatMappedPosition) .join("\n"); console.log(`Stack: ${stackStr}`);
Пожалуйста, выполняйте аварийное завершение работы ответственно!