Taking Control of Exceptions in Flutter: A Remote Configuration Approach
Ever thought what's one of the best ways to handle exceptions in a Flutter app? In this article, David O. , one of our Principal Software Engineers will elaborate on the implementation of a?centralized exception handler in Flutter with remotely configurable messages using Firebase Remote Config. Below is the architectural design of what we aim to achieve. We will use a default Flutter project to exemplify the implementations. No worries, if you don’t use Firebase in your project, this architecture will work equally since the service you use to analyze user behavior and crashes can be abstracted or changed.
We will use some Flutter packages for code generation and Dio for HTTP requests. You can check all the packages used in our example repository https://github.com/DavidGrunheidt/flutter-centralized-exception-handler-example.
Implementation of Centralized Exception Handling in Flutter with Firebase Remote Config
With no more delays, let's start:
1 - Create an enum for all report types your error report service contains. In case of Crashlytics, there’s fatal or nonFatal. I also added a third one called dontReport which will be used in case I don't want to report the exception.
enum CrashlyticsErrorStatusEnum {
fatal,
nonFatal,
dontReport,
}
extension CrahslyticsErrorStatusEnumExtension on CrashlyticsErrorStatusEnum {
bool get isFatal => this == CrashlyticsErrorStatusEnum.fatal;
bool get shouldNotReport => this == CrashlyticsErrorStatusEnum.dontReport;
}
2 - Create a class called AppExceptionCode that implements Exception and can be thrown anywhere in code. It will have only a "code" field which will be used later to parse the exception into a UI message. This message can then be changed anytime without the need for new releases.
class AppExceptionCode implements Exception {
const AppExceptionCode({
required this.code,
});
final String code;
}
3 - Add a file called ui_error_alerts.json under assets/json and list this folder into the assets on pubspec.yaml. We will use this file to set up the default values for each error message. You will also need to add each key-value of this JSON in your remote config service. You will need to tell your backend team to return the errors in a way you can parse it into a message, so your error code may vary depending on that. In our case, the error code will come in this form: [ERROR_CODE]. Here's an example of that file:
{
"DIO_TIMEOUT" : "The request took too much time. Please try again later",
"GENERIC_FAIL_MESSAGE": "Oh no! Something went really wrong."
}
4 - Create a class to handle remote config integration logic. In our case, this class is called RemoteConfigRepository. Firebase Remote Config allows real-time updates. This class configures a listener for real-time updates and also defines a minimum fetch interval. On init method it fetches all remote config values and activates them.
A thing to notice is to always set the default configs on code and create one or multiple JSONs containing mostly all values you have saved on your remote config service. This way, if somehow the first fetch fails, it will have the default values and the app will still be usable. Bool values can be ignored here since their default values are false unless you want the default value as true.
You can find this class on our example repository here https://github.com/DavidGrunheidt/flutter-centralized-exception-handler-example/blob/master/lib/repositories/remote_config_repository.dart
5 - Add a runZonedGuarded in your main function. The first positional parameter is the body , which will be "protected" by an error zone. The second parameter, onError will handle both async and synchronous errors thrown inside the body. Don't throw any errors inside onError or it will be thrown outside of runZonedGuarded, making it escape our handler.
void main() {
return runZonedGuarded(() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
setupRepositoryLocator();
FlutterError.onError = (details) async {
FlutterError.presentError(details);
reportErrorDetails(details);
final errorStatus = getCrashlyticsErrorStatus(details.exception);
if (kIsWeb || errorStatus.shouldNotReport) return;
return FirebaseCrashlytics.instance.recordFlutterError(details, fatal: errorStatus.isFatal);
};
if (!kIsWeb) {
Isolate.current.addErrorListener(RawReceivePort((pair) async {
final List<dynamic> errorAndStacktrace = pair;
final stackTrace = StackTrace.fromString(errorAndStacktrace.last.toString());
return FirebaseCrashlytics.instance.recordError(errorAndStacktrace.first, stackTrace);
}).sendPort);
}
runApp(const MyApp());
}, (error, stackTrace) async {
if (kDebugMode) debugPrint('Unhandled Error: $error StackTrace: $stackTrace');
reportErrorToUI(error, stackTrace);
});
}
Here we are catching errors in three different places: FlutterError.onError, Isolate.current.addErrorListener, and onError positional parameter of runZonedGuarded. This is done so we can maximize the error handler reach and to report the maximum of errors we can to our error reporting tool.
6 - Implement getCrashlyticsErrorStatus , reportErrorDetails , reportErrorToUI the way you like it. These will vary depending on how you want to report errors to your reporting tool and to the user, but here's an example of how we did it:
6.1 getCrashlyticsErrorStatus: Basically this method will tell you if your exception should be reported to your crashlytics as fatal, nonFatal, or shouldn't be reported at all.
CrashlyticsErrorStatusEnum getCrashlyticsErrorStatus(Object error) {
if (error is AppExceptionCode) return CrashlyticsErrorStatusEnum.dontReport;
if (error is DioException) {
final nonFatalTypes = [DioExceptionType.connectionTimeout, DioExceptionType.connectionError];
final isNonFatal = nonFatalTypes.contains(error.type);
return isNonFatal ? CrashlyticsErrorStatusEnum.nonFatal : CrashlyticsErrorStatusEnum.dontReport;
}
return CrashlyticsErrorStatusEnum.fatal;
}
6.2 reportErrorDetails: This function is used when a FlutterError is captured and basically checks if it was a silent exception or an exception thrown by another library. In the end it will call reportErrorToUI
领英推è
void reportErrorDetails(FlutterErrorDetails flutterErrorDetails) {
const errors = <String>['rendering library', 'widgets library'];
final isSilentOnRelease = kReleaseMode && flutterErrorDetails.silent;
final isLibraryOnDebug = !kReleaseMode && errors.contains(flutterErrorDetails.library);
if (isSilentOnRelease || isLibraryOnDebug) {
log(
flutterErrorDetails.exceptionAsString(),
name: 'ReportErrorDetails',
stackTrace: flutterErrorDetails.stack,
error: flutterErrorDetails.exception,
);
}
return reportErrorToUI(flutterErrorDetails.exception, flutterErrorDetails.stack);
}
6.3 reportErrorToUI : Responsible for parsing the exception into an AppExceptionCode which will later be translated into a UI-friendly message. We used three other auxiliary functions, handleDioException, handleAppExceptionCode, and showErrorMessage to parse the Exception accordingly with its type. As you can see, if the error is not well known, we will parse it into a generic AppExceptionCode.
void reportErrorToUI(Object error, StackTrace? stackTrace) {
if (error is DioException) return handleDioException(error);
if (error is AppExceptionCode) return handleAppExceptionCode(code: error.code);
return handleAppExceptionCode(code: kGenericErrorKey);
}
6.3.1 handleDioException: This handles HTTP errors. For timeouts or unknown error,s we will parse the error into a predefined AppExceptionCode, which of course can be configurable remotely. For other error,s we will check if there's an error code inside the HTTP response. If so, we will use that code to call handleAppExceptionCode. If we have any exception during this flow, we will use the response message if there's one, or use a generic message if not.
void handleDioException(DioException error) {
try {
switch (error.type) {
case DioExceptionType.receiveTimeout:
case DioExceptionType.connectionTimeout:
return handleAppExceptionCode(code: kDioTimeoutErrorKey);
case DioExceptionType.unknown:
return handleAppExceptionCode(code: kCheckInternetConnectionErrorKey);
default:
final errorMsg = error.errorMessageDetail;
final codeRaw = kBracketsContentRegex.allMatches(errorMsg).first.group(0)!;
final code = codeRaw.substring(1, codeRaw.length - 1);
return handleAppExceptionCode(code: code, fallbackMsg: error.errorMessageDetail);
}
} catch (_) {
return showErrorMessage(error.errorMessageDetail);
}
}
errorMessageDetail getter and containsErrorCode are implemented in a custom extension for DioException. This is also highly customizable and will change according to how the API you are working with returns errors.
Why use brackets as delimiters? With the brackets, we can limit the search. For example: "UNMAPPED_CODE".contains("MAPPED_CODE")would return true and parse the error into a wrong message. "[UNMAPPED_CODE]".contains("[MAPPED_CODE]") returns false, thus solving the error code search problem.
import 'package:dio/dio.dart';
import 'app_constants.dart';
extension DioExceptionUtils on DioException {
bool containsErrorCode(String errorCode) {
try {
final data = response?.data;
return data != null && (data['detail']?['error'] as String).contains(errorCode);
} catch (_) {
return false;
}
}
String get errorMessageDetail {
try {
return response?.data['detail']['error'];
} catch (_) {
return kGenericExceptionMessage;
}
}
}
6.3.2 handleAppExceptionCode: maps a code to its corresponding error message. It also has a fallback message in case the message retrieved from remote config comes as an empty string.
void handleAppExceptionCode({
required String code,
String fallbackMsg = kGenericExceptionMessage,
}) {
try {
final message = repositoryLocator<RemoteConfigRepository>().getString(code);
return showErrorMessage(message.isEmpty ? fallbackMsg : message);
} catch (_) {
return showErrorMessage(fallbackMsg);
}
}
6.3.3 showErrorMessage: Show the error message on UI.
void showErrorMessage(String message) {
final context = getErrorHandlerContext();
return showSnackbar(context: context, content: message);
}
And how do we test it?
On our example repository, there's a screen with several buttons, each one throwing a different exception. This test aims to check that all exceptions are being caught and parsed into a UI-friendly message by our handler.
Benefits and Considerations of Centralized Error Handling
Say you have a new request error, yet non-parsed and previously unknown showing to the users, for example, [CODE_123] obj.xyz is null. We can use the initial part, [CODE_123] to parse this error into a friendly message by adding a new value to our ui_error_alert remote JSON stored on Firebase Remote Config, and the new message will automatically be shown to all users experiencing this error, without any need for a new release. This method allows local handling mixed with centralized handling of errors by using AppExceptionCode as a middleman.
What is the downside of using this method? I would say medium to big apps should opt for local and decentralized handling of errors, especially because these types of apps will tend to have a completely modular architecture with decentralized logic following SOLID principles and lots of people working on it.
That's basically it for configuring a centralized remotely configurable error handler. Let us know if you have any doubts and feel free to contact us for better explanations. Thanks for reading and hope you have awesome projects ahead.
Feel free to check the full article on https://medium.com/tarmac/centralized-remotely-configurable-exception-handler-in-flutter-8448374ddc7a