How do I track Flutter screens in Firebase analytics?

Vince Varga picture Vince Varga · Apr 24, 2019 · Viewed 9.1k times · Source

I have a Flutter app and I'm testing Google Analytics for Firebase on Flutter.

I wanted to see the routes our users (well, me for now) are visiting. I followed the setup steps in firebase_analytics and I checked their example app, too. I enabled debugging for Analytics as described in the Debug View docs

Unfortunately, the only two kinds of screen views (firebase_screen_class) I receive in my Analytics Debug view are Flutter and MainActivity.

I'd expect to see /example-1, /example-2 and /welcome somewhere, but I don't.

This is the app I'm running in Flutter

class App extends StatelessWidget {
  final FirebaseAnalytics analytics = FirebaseAnalytics();
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      routes: <String, WidgetBuilder>{
        '/example-1': (_) => Example1(),
        '/example-2': (_) => Example2(),
        '/welcome': (_) => Welcome(),
      },
      home: Welcome(),
      navigatorObservers: [FirebaseAnalyticsObserver(analytics: analytics)],
    );
  }
}

Answer

Vince Varga picture Vince Varga · Apr 24, 2019

This exact use-case is in the documentation for Firebase Analytics under the Track Screenviews section.

Manually tracking screens is useful if your app does not use a separate UIViewController or Activity for each screen you may wish to track, such as in a game.

This is exactly the case with Flutter, as Flutter is taking care of the screen updates: most simple Flutter apps run one single FlutterActivity/FlutterAppDelegate and it takes care of rendering different screens on its own, so letting Firebase Analytics automatically track screens will not bring the desired effect.

As far as my past experience goes, the FirebaseAnalyticsObserver was not very helpful, however, I recommend you, too, check their docs again, they do imply that things should "just work". My best guess is that it didn't work well for me because I didn't use RouteSettings on any of my routes *.

In case FirebaseAnalyticsObserver won't work or apply for your app, the next approach worked quite well for me over the past months of development.

You can set the current screen with FirebaseAnalytics at any point, if you call the setCurrentScreen method with the screen name:

import 'package:firebase_analytics/firebase_analytics.dart';
// Somewhere in your widgets...
FirebaseAnalytics().setCurrentScreen(screenName: 'Example1');

As a first attempt I did this in the widget constructor, but that will not work well and miscount the events: if you pop or push routes, all widget constructors in the stack will be called, even though only the top route really qualifies as "the current screen".

To solve this, we need to use the RouteAware class and only set the current screen in case it's the top route: either our route is added to the stack or the previous top route was popped and we arrived onto the route.

RouteAware comes with boilerplate code and we don't want to repeat that boilerplate for all of our screens. Even for small apps, you have tens of different screens, so I created the RouteAwareAnalytics mixin:

import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/widgets.dart';

// A Navigator observer that notifies RouteAwares of changes to state of their Route
final routeObserver = RouteObserver<PageRoute>();

mixin RouteAwareAnalytics<T extends StatefulWidget> on State<T>
    implements RouteAware {
  AnalyticsRoute get route;

  @override
  void didChangeDependencies() {
    routeObserver.subscribe(this, ModalRoute.of(context));
    super.didChangeDependencies();
  }

  @override
  void dispose() {
    routeObserver.unsubscribe(this);
    super.dispose();
  }

  @override
  void didPop() {}

  @override
  void didPopNext() {
    // Called when the top route has been popped off,
    // and the current route shows up.
    _setCurrentScreen(route);
  }

  @override
  void didPush() {
    // Called when the current route has been pushed.
    _setCurrentScreen(route);
  }

  @override
  void didPushNext() {}

  Future<void> _setCurrentScreen(AnalyticsRoute analyticsRoute) {
    print('Setting current screen to $analyticsRoute');
    return FirebaseAnalytics().setCurrentScreen(
      screenName: screenName(analyticsRoute),
      screenClassOverride: screenClass(analyticsRoute),
    );
  }
}

I created an enum to track the screens (and functions to turn the enum to screen names). I used the enums to be able to easily track all routes, refactor route names. Using these enums and functions, I can unit test all possible values and enforce consistent naming: no accidental spaces or special characters, no inconsistent capitalization. There could be other, better ways to determine screen class values, but I went with this approach.

enum AnalyticsRoute { example }

String screenClass(AnalyticsRoute route) {
  switch (route) {
    case AnalyticsRoute.example:
      return 'ExampleRoute';
  }
  throw ArgumentError.notNull('route');
}

String screenName(AnalyticsRoute route) {
  switch (route) {
    case AnalyticsRoute.example:
      return '/example';
  }
  throw ArgumentError.notNull('route');
}

Next step in the inital setup is to register the routeObserver as a navigatorObserver of your MaterialApp:

MaterialApp(
  // ...
  navigatorObservers: [
    routeObserver,
    // FirebaseAnalyticsObserver(analytics: FirebaseAnalytics()),
  ],
);

Finally, we can add our first example route that's tracked. Add the with RouteAwareAnalytics to your states and override get route.

class ExampleRoute extends StatefulWidget {
  @override
  _ExampleRouteState createState() => _ExampleRouteState();
}

class _ExampleRouteState extends State<ExampleRoute> with RouteAwareAnalytics{
  @override
  Widget build(BuildContext context) => Text('Example');

  @override
  AnalyticsRoute get route => AnalyticsRoute.example;
}

Every time you add a new route, you can do so with little effort: first, add a new enum value, then the Dart compiler will guide you what to add next: add the screen name and class override values in their respective switch-case. Then, find your state that's building your route, add with RouteAwareAnalytics, and add the route getter.

* The reason why I didn't use RouteSettings is that I prefer Simon Lightfoot's approach with the typed arguments instead of the Object arguments the settings provide:

class ExampleRoute extends StatefulWidget {
  const ExampleRoute._({@required this.integer, Key key}) : super(key: key);
  // All types of members are supported, but I used int as example
  final int integer;
  static Route<void> route({@required int integer}) =>
      MaterialPageRoute(
        // I could add the settings here, though, it wouldn't enforce good types
        builder: (_) => ExampleRoute._(integer: integer),
      );
  // ...
}