FlutterアプリにFirebase Analyticsでイベント送信する

以下の記事の続きです。

[Fltuterに初めてのFirebase導入(Firebase Analytics)](https://qiita.com/kasa_le/items/31a7b858b1969e75016c)

前回は接続確認だけしましたが、せっかくなので、イベントの送信もやってみます。

# 環境など

|ツールなど|バージョンなど|
|:--|:--|
| MacBook Air Early2015|macOS Mojave 10.14.5|
|Android Studio |3.6.1|
|Java |1.8.0_131|
|Flutter|1.12.13+hotfix.5|
|Dart |2.7.0|
|Xcode |10.2|

# 送信イベント

以下の2つを送ります。

- 画面名(setCurrentScreen)
- ボタンタップ(logEvent)

他にも課金イベントとかも用意されています。
詳しくは公式のサンプルなどをご覧下さい。

https://pub.dev/packages/firebase_analytics#-example-tab-

# 事前準備

DIのパッケージの1つである、get_itを使います。

https://pub.dev/packages/get_it

Providerパターンにしてもいいかなと思ったのですが、なんとなく、get_itも使い慣れておきたくて。
というかこちらのパッケージを駆使すれば、Providerパターンも正直不要かな？
`ChangeNotifierProvider`は要るでしょうけど、[BLoC+Providerのパターン](https://qiita.com/kasa_le/items/33e62b09905a7c635f16)は書き変えられそうですね。

# 画面名報告

通常Firebaseは、AndroidならActivity遷移すると自動でActivityクラス名を拾って`screen_view`イベントを送ってくれますが、Flutterの場合、Activityは1個なので、Flutter上でいくら遷移しても送信されません。
なので自前で送らなければならないのですが、かつては本当に色々やらないとダメだったような記事がある物の、最新版ではそんなに大変では無く入れられたので、まとめておきます。

なお、`pubspec.yaml`への依存関係の設定は済んでいるものとします。

## 1.DebugViewを有効にしておく
まず、FirebaseのDebugViewが有効になるよう、設定をしておきます。
こちらを参考にして下さい。

[Flutterに初めてのFirebase導入(Firebase Analytics)#接続が確認できないとき](https://qiita.com/kasa_le/items/31a7b858b1969e75016c#%E6%8E%A5%E7%B6%9A%E3%81%8C%E7%A2%BA%E8%AA%8D%E3%81%A7%E3%81%8D%E3%81%AA%E3%81%84%E3%81%A8%E3%81%8D)

## 2.AnalyticsServiceクラスを作成する

こんなクラスを作成しました。
多分、`FirebaseAnalytics`のインスタンスは何個作っても大丈夫なようにFirebaseのライブラリ側がうまいことやっているはずですが、一応シングルトンを意識します。でもちゃんとしたシングルトンではありません。


```dart
/// アナリティクスイベント
enum AnalyticsEvent {
  Button,
}

/// アナリティクス
class AnalyticsService {
  static final FirebaseAnalytics analytics = FirebaseAnalytics();
  static final FirebaseAnalyticsObserver observer =
      FirebaseAnalyticsObserver(analytics: analytics);

  /// ボタンタップイベント送信
  Future<void> sendButtonEvent({@required String buttonName}) async {
    sendEvent(
        event: AnalyticsEvent.Button,
        parameterMap: {'buttonName': "$buttonName"});
  }

  /// イベントを送信する
  /// [event] AnalyticsEvent
  /// [parameterMap] パラメータMap
  Future<void> sendEvent(
      {@required AnalyticsEvent event,
      Map<String, dynamic> parameterMap}) async {
    final eventName = event.toString().split('.')[1];
    analytics.logEvent(name: eventName, parameters: parameterMap);
  }
}
```

`sendButtonEvent`で、ボタン名をパラメータとして送ります。

他に送りたいイベントが出来たら、`AnalyticsEvent`に列挙値を追加していくイメージです。

`sendEvent`関数だけでも十分そうですが、送りたいイベントのパターンって結構決まってくるイメージなので、ラッパー関数も用意しちゃっています。


```dart
final eventName = event.toString().split('.')[1];
```

ここですが、Dartのenumを`toString`すると、`Enumクラス名.列挙ID`となるので、後ろの名前だけ取っています。

## 3.get_itのlocatorに登録する

```main.dart

void setupLocator() {
  locator.registerLazySingleton(() => NavigationService());
  locator.registerLazySingleton(() => AnalyticsService());
}
```

`NavigationService`というのは、こちらの記事で紹介したものです。

[Flutter/Navigator遷移にBuildContextを不要にする/遷移のWidgetTest](https://qiita.com/kasa_le/items/8c80ae565186c9ac275a)

`setupLocator`関数は、`main`関数で呼んでいます。

```main.dart
void main() async {
  setupLocator();
  ...
  runApp(...);
}
```

## 4.Navigatorにオブザーバーをセットする

```main.dart
class MyApp extends StatelessWidget {
  MyApp({Key key}) : super();

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      ...

      // 追加ここから----------------
      navigatorObservers: [
        AnalyticsService.observer,
      ],
      // 追加ここまで----------------

      onGenerateRoute: (settings) {
         ...
      },
      initialRoute: "/",
      // 画面
      title: 'タイトル',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
    );
  }
}
```

### 5.NavigatorのRouteSettingsに画面名を入れる

```main.dart
    return MaterialApp(
      ...
      onGenerateRoute: (settings) {
        switch (settings.name) {
          case '/':
            {
              return MaterialPageRoute(
                  settings: RouteSettings(name: 'Topページ'), // これを追加
                  builder: (context) => TopPage());
            }
          case '/input':
            final ProteinRecord record = settings.arguments;
            return MaterialPageRoute(
                settings: RouteSettings(name: 'Inputページ'), // これを追加
                builder: (context) => InputPage(record: record));
          default:
            return MaterialPageRoute(
                builder: (context) => Scaffold(
                      body: Center(
                        child: Text('No path for ${settings.name}'),
                      ),
                    ));
        }
        initialRoute: "/",
        ...
     );
```

これで実行して、画面遷移をしてみて下さい。
TopPageから、IndexPageに遷移して、TopPageに戻る、と操作すると、DebugViewに、`screen_view`のイベントが3回ほど届くはずです。

それぞれのイベントをクリックすると、パラメータ詳細が見られるので、`firebase_screen`というところを開いてみると、セットした画面名が見られます。

![fa_screenview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/7959bbd8-2b63-9008-2a19-5ef33e2f8fb4.png)

![fa_screen_view_params.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/e8ab4196-a1c6-6b8e-7add-bfd013f53c75.png)


他にもパラメータを見てみると、`firebase_previous_screen`とかあるのが分かりますね。前にいた画面も一緒に送られるので、遷移が一方通行でない複雑なアプリでもある程度のことが取れそうです。

# ボタンタップイベント

ボタンなどをタップしたのを送ります。
例えば、削除ボタンを押したとします。

```TopPage.dart
    FlatButton(
        child: const Text("削除"),
        onPressed: () => _onDeleteButton(context),
    ),
...

void _onDeleteButton(BuildContext context){
    // Analytics送信
    final analyticsService = locator<AnalyticsService>();
    analyticsService.sendButtonEvent(buttonName: '削除ボタン');

    // 削除処理
}
```

このイベントはDebugViewで次のように見えます。

![fa_events.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/88f8acae-7deb-1f80-683f-2c7a02f4dc15.png)

Buttonイベントをクリックしてパラメータを見ると、Map`{'buttonName': ボタン名}`で送ったカスタムパラメータ値がちゃんと届いているのが分かります。

![fa_button_param.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/3f34a6eb-9598-40f6-ae17-dd329cc3541a.png)

# Testの時送らないようにする

UnitTestやWidgetTestでは、送らないようにしましょう。

Mockitoを使えば簡単です。
https://pub.dev/packages/mockito

```pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter

  # mock
  mockito: ^3.0.0
```

## 1.Mockクラス化

`AnalyticsService`クラスのモッククラスを作ります。

```dart
/// MockAnalytics
class MockAnalyticsService extends Mock implements AnalyticsService {}
```

Flutterの言語であるDartには、**暗黙的インターフェース**という機能があり、`implements AnalyticsService`とすることで、すべてのメソッドが未実装状態にしてくれます。

`extends Mock`で、それらが何もしない関数ということにしてくれます(と、理解してます)。

## 2.テスト用のget_it初期化関数を作る

テスト用には、**get_it**パッケージがモック化したオブジェクトを返すようにします。

```dart
/// テスト用のget_it Locator初期化
void testSetupLocator({@required AnalyticsService analyticsService}) {
  locator.registerSingleton(NavigationService());
  locator.registerSingleton(analyticsService);
}
```

## 3.テストから使う

`AnalyticsService`が必要な場所で、モック化クラスのオブジェクトを作って先ほどの初期化関数を呼び出します。

テストでは、イベント送信メソッドが呼ばれたかどうかチェックするのも良いですね。

```dart
 group('画面遷移のテスト', () {
    final mockAnalytics = MockAnalyticsService();
    testSetupLocator(analyticsService: mockAnalytics);

    testWidgets('削除ボタンタップ', (WidgetTester tester) async {

      ...

      await tester.tap(find.byIcon(Icons.delete));
      await tester.pumpAndSettle();
   
      verify(mockAnalytics.sendButtonEvent(buttonName: '削除ボタン'));
  });
});
```


# Analyticsを上手く使うには

Analyticsの使いどころは、やはり一番は`UserProperty`等と組み合わせて`Audience`を作るときだと思います。有るイベントをたくさん送っているユーザーに対して、pushを送るとか、あるいは、特定のページを全然見てないユーザーにpushを送るとか、そういう戦略的なことが必要になってきたときに威力を発揮するかと思います。

とはいえ、じゃあ最初は全然取らなくて良いかというと、そうでもなくて。
以前調べたときは、`Audience`のリストが出来るのは24Hくらいかかっていましたが、その上、更に`Audience`作るのに必要な分析情報が送られてない！足さなきゃ！ってなると、アプリのリリースを踏まないと行けないため、かなり時間がかかってしまいます。

というわけで、最初から送れるものはある程度送っておいた方が都合が良いので、特に、お仕事で開発するアプリの場合は、この「アナリティクスに何をどう送っておくか？」も、結構重要な設計になってきます。

特に運営ユーザーが異なる場合には、この辺りもしっかり要件定義しておかないと、後からアナリティクスのイベント送信コードを入れればいいや〜と思っていると、とんでもない要件が上がってきて困ることになったりします。

FlutterならiOS/Android同じですが、別々に作ってたりすると、「パラメータが全然違う！合わない！」て事態にもなりがちです。

なので、ユーザーの動向を見て改良していこうというようなアプリの場合には、最初からアナリティクス送信イベントについてしっかり設計して積極的に収集しておくのが吉です。


# 参考サイトなど

【Dart/Flutter】Firebase AnalyticsのObserverとsetCurrentScreen()でページ遷移を監視する実装をまとめてみた
https://qiita.com/arthur_foreign/items/a019c490628323a96e4f

【Dart/Flutter】Firebase AnalyticsのlogEvent()を飛ばす実装についてまとめる
https://qiita.com/arthur_foreign/items/a6c2bc7ec661a847e5e6