跳转至

12 Supporting the Development Lifecycle With Firebase

You’ve gotten through the first 11 chapters, and you’ve finished your app — well done! You’ve set up the data layer, written the app’s business logic, spiced up the UI, and created custom components and packages. Just distribute it among the people, and your WonderWords app will be a hit.

But not so fast!

App development is an ongoing process that never stops. Once you finish the first version of your app, you must monitor its performance. Besides adding new features, you’ll have to release new versions of the app to improve users’ experience. This might be some UI and UX changes, adding a new feature or removing confusing ones, or just resolutions of the bugs that your QA team missed when testing the app.

Here, you might ask yourself how you can know what changes are required to make your app even better. Well, you have to monitor users’ engagement with the specific features of the app as well as analyze their interaction with the app. You might want to track the app’s crashes when users discover some side case you hadn’t thought about. Or, maybe you’ll have to run a few tests in your user group without necessarily releasing a new version of the app.

When dealing with these types of issues, Firebase can come in very handy. You’ve probably already heard a lot about Firebase. In this chapter, you’ll look at a few tools you might not be very familiar with, but are essential in almost any real-world app. Those tools are Firebase Analytics and Crashlytics.

Firebase Analytics lets you understand information about your app’s users, including:

  • Which features of your app they use the most or least.
  • How much time they spend on your app.
  • Where they come from.
  • Which devices they use.

By adding Firebase Crashlytics to your project, you may discover hidden issues in the app that you need to resolve immediately. Crashlytics does this by providing you with the record and stacktrace of an error or crash.

In this chapter, you’ll learn how to:

  • Add analytics on-screen view events.
  • Record crashes and non-fatal errors.

Throughout this chapter, you’ll work on the starter project from this chapter’s assetsfolder.

Firebase Analytics

Getting back to Chapter 1, “Setting up Your Environment”, you might remember adding Firebase to the WonderWords app. When you finally added all the necessary files to the project, you might’ve taken a sneak peek into the Firebase Analytics console. If so, you’ll remember that it offers a bunch of cool information about your audience. But in this section, you’ll focus primarily on capturing a screen_view event when users visit a specific screen in the app.

screen_view is one of the predefined events in Firebase Analytics, although it enables you to define custom events as well. A screen_view event occurs when the user visits a screen in your app.

But before continuing, you’ll look at some useful information that Firebase Analytics tracks for you automatically when you add it to your project. To check your behavior in the app, run WonderWords.

Note

If you’re having trouble running the app, you might have forgotten to propagate the configurations you did in the first chapter’s starter project to the following chapters’ materials. If that’s the case, please revisit Chapter 1, “Setting up Your Environment”.

Go to Firebase console and navigate to Analytics ▸ Dashboard from the left-side menu. The Dashboard shows various eye-catching graphs and analyses recorded automatically by Firebase Analytics when users run your app. Here are a few that will be the most relevant for you:

img

Going through the selected information panels in the previous image, you can see:

  1. The first information panel, at the top-left, shows event counts for all users in descending order of their occurrence. The most interesting information for you in this section will be the screen_view event. By drilling down further into it, you can see which screens are most used by your users — but more about that later.
  2. The second graph represents the recent average engagement time.
  3. The third representation is a demographical view of user base distribution across countries.
  4. The fourth shows a count of users who’ve installed the app on a specific device model.

Besides the useful information highlighted here, the Dashboard also has a lot more, such as user activity over time, users by app versions, user retention, revenue statistics, etc.

Note

As soon as you add Firebase Analytics dependency in the project, it starts recording all this data automatically. This won’t be the case for a few types of information, such as the screen_view event, which you have to implement separately. Note that the data in your console will be different from what you may see in the image above.

Next, click screen_view on the Firebase Analytics page to see the user engagement per screen in the app. Scroll farther down and locate the User engagement card.

img

Compare the names listed in the TITLE column with the ones defined in the lib/routing_table.dart file. You can see that they match. All the titles are the names of MaterialPages in the Routes class. From the % TOTAL column, you can see that users use the quotes-list screen as much as they use all the other screens combined. You can also see the average time they spend on every single one of the pages. This small but meaningful information can help you determine which feature or screen your users spend the most time on. Using this analysis to enhance highly used features can be a great business strategy, especially when thinking about monetizing the app.

As you have a better overview of what Firebase Analytics offers, it’s time to jump into its implementation in the app.

Adding Firebase Analytics

Open lib/routing_table.dart at the root of the project:

img

Refer to the screen names in the buildRoutingTable function:

MaterialPage(
  name: 'quotes-list'
  ...
)

MaterialPage(
  name: 'profile-menu'
  ...
)

Once again, notice the name attribute for MaterialPage. In the buildRoutingTable function, you’ll notice definitions for all MaterialPages, which, in other words, are all screens in the app. You’ll use these names as unique identifiers for screens when capturing the screen_view event.

Modifying ScreenViewObserver

WonderWords uses the Routemaster package as a navigation solution. The package offers RoutemasterDelegate with the observer’s attribute, which takes the list of RoutemasterObservers.

The RoutemasterObserver observes all screen in and out events, like when a new screen enters or when a screen exits.

Look at the implementation of the RoutemasterObserver class in the screen_view_observer.dart file located in the root-level lib folder. Locate // TODO: add _sendScreenView() helper method, and replace it with the following code snippet:

void _sendScreenView(PageRoute<dynamic> route) {
  // 1
  final String? screenName = route.settings.name;
  // 2
  if (screenName != null) {
    analyticsService.setCurrentScreen(screenName);
  }
}

The code above:

  1. Extracts the name of the screen from route settings.
  2. Once verified that the screen name is non-null, you record the screen view event by invoking the predefined setCurrentScreen method.

Notice that here you’re invoking the setCurrentScreen method on the FirebaseAnalytics instance. Since this is in use in multiple places, you declared its instance at the file level in analytics_services.dart under packages/monitoring/lib/src.

When you open this file, you’ll see an instance of Firebase Analytics declared as well as two methods — setCurrentScreen() and logEvent(). The first one takes screenName for a parameter and logs it to the Firebase Analytics service. You use it in the code snippet above to log screen views. The second one takes a custom event and logs it to the Firebase Analytics service.

Next, replace // TODO: override didPush and didPop method with the following code snippet:

@override
void didPush(Route route, Route? previousRoute) {
  super.didPush(route, previousRoute);
  if (route is PageRoute) {
    _sendScreenView(route);
  }
}

@override
void didPop(Route route, Route? previousRoute) {
  super.didPop(route, previousRoute);
  if (previousRoute is PageRoute && route is PageRoute) {
    _sendScreenView(previousRoute);
  }
}

When navigating to a new screen, the didPush method passes its route to your _sendScreenView method. When navigating back to the previous screen, the current screen disappears, and the previous screen reappears. That’s when the didPopmethod passes the previous route to the _sendScreenView method instead of the current route. This will be important later to understand on which screen a specific error happened when Firebase Crashlytics reports it.

Lastly, assign ScreenViewObserver to RoutemasterDelegate. Open the root main.dart file and replace // TODO: add observers to RoutemasterDelegate with the following:

ScreenViewObserver(
    analyticsService: _analyticsService,
  ),

With that, you’ve added an observer to RoutemasterDelegate, which tracks navigation from one screen to another. You can see that the observers attribute is a type of List, which means that you could add multiple observers to observe users navigating from screen to screen.

To make sure that the analytics will record the screen views, you have to reinstall the app. After reinstalling the app, you can test what you’ve done so far. There’s no difference in the appearance of your app, but you may see the result of your efforts in the Firebase Analytics console. The recorded events might take up to one day to reflect in the Firebase Analytics console’s Dashboard. Instead, visit the Analytics ▸ Realtime screen to see events in real time.

img

So far, you’ve learned that Firebase Analytics helps you record events that may occur in a known user journey, such as visiting a quotes list screen or a quote detail screen. But what if your app crashes while loading the quotes list or navigating to details, or it misbehaves for any reason? Then, your role as an app developer would be to find the root cause of that crash. It’s very difficult to get this information directly from the user. Don’t worry — Firebase Crashlytics can help you with that!

Firebase Crashlytics

So far, you probably have a good understanding of why, in addition to users’ engagement, you also have to track your app’s crashes. So, you’ll start by getting straight to the point.

There are two major groups of app crashes that you need to be able to distinguish between:

  • Fatal: The app stops processing and terminates as soon as an error occurs.
  • Non-Fatal: The app still runs after the error or warning was thrown.

You’ll dig deeper into tracking both of these in just a moment. For now, it’s worth noting that Firebase Crashlytics supports both of them, although, by default, Flutter tracks only non-fatal crashes. You can override that by changing the fatalparameter to true when calling the recordFlutter() method. But you’ll get back to that later.

First, you’ll look at how to add Firebase Crashlytics to your project.

Enabling Firebase Crashlytics

Look at the Crashlytics tab in your Firebase console. Navigate to Release & Monitor ▸ Crashlytics in the menu on the left:

img

When you navigate to the Crashlytics screen, you’ll see the following screen:

img

Before proceeding to the Crashlytics console, you have to add a few things to your WonderWords project.

Setting up Firebase Crashlytics

Just like Firebase Analytics, Firebase Crashlytics is also a one-time setup. Add the required package in the monitoring package pubspec.yaml file located in packages/monitoring by replacing # TODO: Add crashlytics packages with the following code snippet:

firebase_crashlytics: ^2.8.4

With that, you’ve added both required packages.

Note

When editing pubspec.yaml — or yaml files in general — be sure to use the correct indentation.

Before continuing with the next steps, run the make get command in the terminal at the root of the app.

With that, you enabled Dart-only Firebase error reporting. This means you can only track Dart exceptions. As you’ll also want to report native Android and iOS exceptions, a few additional steps are required.

Android-Specific Crashlytics Integration

Open android/build.gradle and add the following classpath under the dependenciesgroup by replacing // TODO: add Firebase Crashlytics classpath with the following code:

classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'

Next, open android/app/build.gradle and replace // TODO: apply Firebase Crashlytics plugin with the following line:

apply plugin: 'com.google.firebase.crashlytics'

You’ve successfully added all the necessary things for using Firebase Crashlytics for reporting native Android exceptions. Now, look at how you can achieve the same for iOS exceptions.

iOS-Specific Crashlytics Integration

Use Xcode to open Runner.xcworkspace located in the root-level ios folder. Select Runner in the TARGETS section. Go to the Build Phases tab and add New Run Script Phase, as shown in the image below:

img

When you add a New Run Script Phase, it should appear at the end of the list with all scripts. Expand its list tile and focus on the text box underneath the Shell property:

img

Add the following script in the text box:

$PODS_ROOT/FirebaseCrashlytics/upload-symbols --build-phase --validate -ai <googleAppId>
$PODS_ROOT/FirebaseCrashlytics/upload-symbols --build-phase -ai <googleAppId>

Lastly, in the script you just pasted, replace <googleAppId> with your Google App ID. Find it by navigating to Project settings, scrolling down, and selecting iOS app:

img

Note

iOS App ID is specific to every app; therefore, it’s blacked out here.

Initializing a Flutter App With Firebase Crashlytics

Before accessing the Firebase Crashlytics instance in the app, you need to initialize Firebase core services in the Flutter app. You do this by invoking Firebase.initializeApp() before the runApp statement. Open lib/main.dart, and look at the current implementation of the main() function:

void main() async {
  // 1
  WidgetsFlutterBinding.ensureInitialized();
  // 2
  await initializeMonitoringPackage();

  // TODO: Perform explicit crash

  // TODO: Add Error reporting

  // the following line of code will be relevant for next chapter
  final remoteValueService = RemoteValueService();
  await remoteValueService.load();
  runApp(
    WonderWords(
      remoteValueService: remoteValueService,
    ),
  );
}

What the code above does is:

  1. Ensures WidgetsFlutterBinding initialization. When initializing a Firebase app, the app interacts with its native layers through asynchronous operation. This happens via platform channels.
  2. Initializes the Firebase core services, which are defined in monitoring.dart by calling Future<void> initializeMonitoringPackage() => Firebase.initializeApp();.

You can finally run the app again.

Finalizing Firebase Crashlytics Installation

Now, as you have that settled, go back to your Firebase console and navigate to the Crashlytics tab. You may notice that something has changed. The button Add SDK has changed to a loading indicator saying that an app has been detected, as shown in the following image:

img

To proceed, you have to invoke an app crash. Hmm, how to crash an app on demand… Not a trivial task, right? Fortunately, the flutter_crashlytics package has your back.

Navigate to explicit_crash.dart located in monitoring/lib/src/ and replace // TODO: add implementation of explicit crash with the following code:

import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';

class ExplicitCrash {
  ExplicitCrash({
    @visibleForTesting FirebaseCrashlytics? crashlytics,
  }) : _crashlytics = crashlytics ?? FirebaseCrashlytics.instance;

  // 1
  final FirebaseCrashlytics _crashlytics;

  // 2
  crashTheApp() {
    _crashlytics.crash();
  }
}

With the code above, you:

  1. Define the instance of Firebase Crashlytics.
  2. Add an implementation of an explicit crash.

In main.dart, replace // TODO: Perform explicit crash with the following code snippet:

final explicitCrash = ExplicitCrash();
explicitCrash.crashTheApp();

With the code above, you’ll explicitly crash the app. Now, restart the app, and the app should crash.

Note

Don’t forget to remove the code above from your project when you’re finished testing this feature. You won’t need it anymore in the future, so you may delete the whole explicit_crash.dart file and its export in monitoring.dart.

Go back to the Firebase console, and notice that the UI of the Crashlytics tab has slightly changed again. Now, the button that says “Go to Crashlytics dashboard” has appeared, as you see in the following image:

img

After pressing it, you’ll navigate to the Crashlytics dashboard:

img

In the image above, you can see the overview of crashes in your app. The first panel shows the number of users without crashes over days represented in percentage. The second panel shows the number of crashes by type over time.

Note

Your Crashlytics Dashboard should look very similar to the image above, except for the data displayed. In this case, a few crashes were performed over a few days, which is why the numbers on your console look a bit different.

Analyzing Crashes

By navigating lower, you may see the list of issues recorded by Firebase Crashlytics:

img

Firebase has successfully recorded the explicit crash you invoked with code from the previous section.

Diving deeper into this can uncover a lot of valuable information about how to reproduce — and eventually fix — the error that occurred:

img

You can see the details of your first crash saying, “This is a test crash caused by calling .crash() in Dart”. Now, you’ll see how to record crash logs when the app crashes in real-time scenarios.

Isolating Error-Catching Logic in a Single File

There are a few different types of errors. You’ll dive deeper into the specifics of different error types in just a second, but before that, you have to prepare a few things.

Create a new file called error_reporting_service.dart in packages/monitoring/lib/src. Similar to what you did before, here you’ll define an instance of Crashlytics Service and prepare a few functions that’ll come in handy in the future.

Paste the following code snippet in the newly created file:

import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';

/// Wrapper around [FirebaseCrashlytics].
class ErrorReportingService {
  ErrorReportingService({
    @visibleForTesting FirebaseCrashlytics? crashlytics,
  }) : _crashlytics = crashlytics ?? FirebaseCrashlytics.instance;

  // 1
  final FirebaseCrashlytics _crashlytics;

  // 2
  Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails) {
    return _crashlytics.recordFlutterError(flutterErrorDetails);
  }

  // 3
  Future<void> recordError(
    dynamic exception,
    StackTrace? stack, {
    bool fatal = false,
  }) {
    return _crashlytics.recordError(
      exception,
      stack,
      fatal: fatal,
    );
  }
}

Here’s what the code above does:

  1. Declares an instance of Firebase Crashlytics.
  2. Defines the method used for recording Flutter framework errors, a type of error you’ll learn about in the next section.
  3. Defines the method for recording other errors.

Don’t forget to export the newly created file by replacing // TODO: export error_reporting_service.dart file in packages/monitoring/lib/monitoring.dart:

export 'src/error_reporting_service.dart';

Handling Errors in a Flutter App

As already mentioned, there are a few different types of errors. Most of the time, when resolving the different types, you won’t bother to distinguish between them. Nevertheless, for the sake of general knowledge, here are three types of errors that you may want to record:

  • Flutter framework errors happen inside the Flutter framework. The most well-known example of this is RenderFlex overflowed.
  • Zoned errors occur when running asynchronous code. An example of this error is one that happens during the execution of the onPressed method inside FlatButton.
  • Errors outside Flutter framework are all the errors that happen outside of Flutter context.

The easiest way to handle them all is by replacing // TODO: replace the implementation of main() function with the following code snippet:

void main() async {
  // 1
  // Has to be late so it doesn't instantiate before the
  // `initializeMonitoringPackage()` call.
  late final errorReportingService = ErrorReportingService();
  // 2
  runZonedGuarded<Future<void>>(
    () async {
      // 3
      WidgetsFlutterBinding.ensureInitialized();
      await initializeMonitoringPackage();

      final remoteValueService = RemoteValueService();
      await remoteValueService.load();
      // 4
      FlutterError.onError = errorReportingService.recordFlutterError;
      // 5
      Isolate.current.addErrorListener(
        RawReceivePort((pair) async {
          final List<dynamic> errorAndStacktrace = pair;
          await errorReportingService.recordError(
            errorAndStacktrace.first,
            errorAndStacktrace.last,
          );
        }).sendPort,
      );

      runApp(
        WonderWords(
          remoteValueService: remoteValueService,
        ),
      );
    },
    // 6
    (error, stack) => errorReportingService.recordError(
      error,
      stack,
      fatal: true,
    ),
  );
}

Here’s what the code above does:

  1. Initializes an instance of ErrorReportingService, which you defined in the previous section.
  2. The whole content of the main() function is wrapped with the runZonedGuarded() function, which enables you to report zoned errors.
  3. Similar to before, you have to ensure the binding of the widgets with the native layers and initialize Firebase Core services. To refresh your memory, jump back to the Initializing a Flutter App With Firebase Crashlytics section of this chapter.
  4. This is a lambda expression that invokes the recordFlutterError method with the FlutterErrorDetails that holds the stack trace, exception details, etc. It records the Flutter framework errors.
  5. This handles the errors outside of Flutter context.
  6. This catches and reports the errors that happen asynchronously — zoned errors.

With that, you’ve covered the whole palette of errors that might occur in your project.

Now, you’ll look at how to resolve the errors when they’re recorded by Firebase Crashlytics. You’ll learn about it with the example of the famous RenderFlex overflowed, which is a type of Flutter framework error. Dealing with other types of errors is very similar.

Handling a Flutter Framework Error

First, you have to intentionally produce the RenderFlex overflowed error. To achieve that, open packages/component_library/lib/src/count_indicator_icon_button.dart and scroll to the end of file. Locate // TODO: change the font size for invoking an errorand replace small with xxLarge so that it matches the following code:

// TODO: change font size back to FontSize.small
fontSize: FontSize.xxLarge,

This change increases the font size and invokes the RenderFlex overflowed error. Build and run the app, and when you navigate to the quote details screen, you’ll notice the following changes in the UI:

img

In the image above, you can see a bottom overflow error for the two count indicators that have counts of 1 and 0.

Next, kill the app and re-run it so it can upload the crash details to Firebase. As soon as the app starts, refresh the Firebase Crashlytics console page. You’ll find a new non-fatal error:

img

Click the crash to see more details:

img

The image above resembles a typical error detail page in Firebase Crashlytics with the following details:

  1. The number of times the event occurred.
  2. A summary of the error that reveals app version, operating system version, device model and time of occurrence.
  3. The exception message that you also see in the mobile app. Remember, when you run the app in Debug mode, the framework prints out the exception message in the app. But, when you run the app in Release mode, the framework hides that message and instead shows a gray box in place of the widget in question.
  4. To fix the error, you need more details. The Logs tab will help you reproduce the error and pinpoint the exact line that caused it.

So, move to the Logs tab, and you’ll see the entries below:

img

Try to get used to reading logs from the bottom to the top to reproduce the exact issue. So, as per the image above, the user navigated from the quote list screen to the quote detail screen and got the exception. This information helps, doesn’t it? You can now easily reach the exact screen and fix the issue.

Note

Hover on the icons under the Source header in the image above, and you’ll notice that screen_view logs came from Analytics that you recorded initially in this chapter, and the top two logs are from Crashlytics. This allows you to get a clearer image of the crash.

With that, you’ve learned how to record and handle errors in real-life situations in Flutter. In the same way as above, you can solve all the error types that Firebase Crashlytics records for you.

Note

Don’t forget to remove the error you’ve caused above. Locate // TODO: change font size back to FontSize.small and replace the fontSize parameter with FontSize.small.

Key Points

  • Using Firebase services such as Analytics and Crashlytics is a must when supporting an app’s lifecycle.
  • Firebase Analytics offers valuable data on the structure of your audience as well as screen time, number of screen visits and custom event tracking.
  • With the help of Firebase Crashlytics, you can record all the errors you might miss during the development process.

Where to Go From Here

With this chapter, you’ve learned a very important lesson on how to track vital aspects of your app after finishing its initial development.

As only a limited amount of knowledge can fit in a book, there’s plenty more to learn online at the respective doc sites for [Firebase Analytics](https://firebase.google.com/docs/analytics] as well as Firebase Crashlytics.