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)],
);
}
}
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),
);
// ...
}