【Flutter】FirebaseDynamicLinksで招待機能を実装する



# Firebase Dynamic Linksとは
Firebaseが提供するディープリンクの機能です。ディープリンクとは1つのURLでAndroid/iOS/WEBでの遷移先を振り分けたり、そのあとアプリにパラメータを渡すことも出来ます。
[Flutter大学](https://kboyflutteruniv.com/)の共同開発で作成したCOUPLE TODOはこのDynamic Linksを使って、夫婦同士の紐づけを行っていますので、そのやり方を紹介します。

https://couple-todo-product.web.app/

:::note warn
このコードの例は多少古い書き方も含んでいますのでご承知おきください。(RaisedButtonなど)
:::

# Flutterでの実装
Flutterあるあるですが、公式のドキュメントには実装例が載っていないです。

https://firebase.google.com/docs/dynamic-links/

そしてFlutterFireというFlutterでFirebase使う時の実装例などが書いてあるドキュメントにもDynamic Linksの記載はないです。(ページだけは存在していた。そのうち書かれるかも？)

https://firebase.flutter.dev/docs/dynamic-links/usage/

pub.devの方には一応実装例があるので、そちらを参考にするのも良いかと思います。

https://pub.dev/packages/firebase_dynamic_links

## 利用パッケージ

今回の実装で必要なパッケージは以下です。結構前のプロジェクトから引用してきているので、最新のものをお使いください。

```yaml:pubspec.yaml
dependencies:
  flutter:
    sdk: flutter

  firebase_dynamic_links: ^0.5.3
  share: ^0.6.5+4
```


## リンクの動的生成

ダイナミックリンクにアプリの中でユーザーのペアを紐づける`coupleId`というものを共有させるために、動的にリンクを生成しています。
生成処理はリポジトリで行っています。
抽象クラスはこちら。

```dart:lib/domain/repository/dynamic_links_repository.dart
abstract class DynamicLinksRepository {
  Future<Uri> createInviteDynamicLink({@required String coupleId});
  Future<String> dynamicLinksHandlerNonInstall();
  Future<String> setOnLink();
}
```

実装はこちら

```dart:lib/infrastructure/dynamic_links_repository_impl.dart
class DynamicLinksRepositoryImpl implements DynamicLinksRepository {
  DynamicLinksRepositoryImpl({@required FirebaseAuthRepository authRepository})
      : _authRepository = authRepository;

  final FirebaseAuthRepository _authRepository;

  /// 招待用のdynamic linksを生成する
  @override
  Future<Uri> createInviteDynamicLink({@required String coupleId}) async {
    final DynamicLinkParameters parameters = DynamicLinkParameters(//ここで各OSのパラメータを設定
      uriPrefix: uriPrefix, // URL接頭辞
      link: Uri.parse(getDefaultDeepLink(coupleId: coupleId)), //デフォルトのDeep Link
      // Android用の設定
      androidParameters: AndroidParameters(
        packageName: androidPackageName,
        minimumVersion: 1,
        fallbackUrl: Uri.parse(androidFallbackUrl), //アプリ未インストール時の遷移先
      ),
      // iOS用の設定
      iosParameters: IosParameters(
        bundleId: iosBundleId,
        minimumVersion: '1',
        fallbackUrl: Uri.parse(iosFallbackUrl),
      ),
    );
    final ShortDynamicLink shortDynamicLink = await parameters.buildShortLink(); // ショートリンクとして作成
    final Uri shortUrl = shortDynamicLink.shortUrl;
    return shortUrl;
  }
```

そしてViewModelで`DynamicLinksRepository.createInviteDynamicLink`を呼び出し、URLを生成しています。

```dart:lib/presentation/invite/invite_model.dart
class InviteModel extends ChangeNotifier {
  InviteModel({
    @required UserRepository userRepository,
    @required FirebaseAuthRepository authRepository,
    @required DynamicLinksRepository dynamicLinksRepository,
  })  : _userRepository = userRepository,
        _authRepository = authRepository,
        _dynamicLinksRepository = dynamicLinksRepository;
  final UserRepository _userRepository;
  final FirebaseAuthRepository _authRepository;
  final DynamicLinksRepository _dynamicLinksRepository;

  String _inviteLink = ''; // URL格納用変数
  String get inviteLink => _inviteLink; # getter
  String _coupleId;
  bool isLoading = true;

  Future<void> init() async {
    await _fetchInviteLink();
    isLoading = false;
    notifyListeners();
  }

  Future<void> _fetchInviteLink() async {
    final String uid = _authRepository.getUid();
    _coupleId = await _userRepository.getCoupleIdByUid(uid);
    final Uri dynamicUrl = await _dynamicLinksRepository
        .createInviteDynamicLink(coupleId: _coupleId);
    _inviteLink = dynamicUrl.toString();
  }
}
```

UI側では画面描画時に先ほどのモデルの`init()`を呼び出し、URLを生成します。

```dart:lib/presentation/invite/invite_page.dart
class InvitePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<InviteModel>(
      create: (_) => InviteModel(
        userRepository: context.read<UserRepository>(),
        authRepository: context.read<FirebaseAuthRepository>(),
        dynamicLinksRepository: context.read<DynamicLinksRepository>(),
      )..init(),
      child: Consumer<InviteModel>(
          builder: (BuildContext context, InviteModel model, Widget child) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('パートナーを招待する'),
          ),
          body: Stack(
            children: [
              Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    const Text(
                      'パートナーと一緒にタスクを管理しよう！',
                      style: TextStyle(
                        fontWeight: FontWeight.bold,
                        fontSize: 20,
                      ),
                    ),
                    const SizedBox(
                      height: 32,
                    ),
                    Image.asset(
                      'assets/icon.jpg',
                      width: 200,
                    ),
                    const SizedBox(
                      height: 32,
                    ),
                    RaisedButton(
                      onPressed: () async {
                        await Share.share(model.inviteLink); // ここでボタンが押されたらURLをShareできるようになる
                      },
                      child: const Text('招待する'),
                    ),
                  ],
                ),
              ),
              if (model.isLoading)
                Container(
                  color: Colors.black.withOpacity(0.3),
                  child: const Center(
                    child: CircularProgressIndicator(),
                  ),
                )
              else
                const SizedBox()
            ],
          ),
        );
      }),
    );
  }
}
```

これで`https://coupletodopro.page.link/yg27Jf4f6ac5pL11K`のようなURLを以下の写真のように共有できるようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/224b4c96-a528-4587-44e4-dc0ac30036de.png" width="300">

## dynamic linksの検知

共有したら、次はdynamic links受け取った時の挙動です。
repositoryの中で未インストールだった場合とインストール済みで未連携の場合のメソッドを定義しておきます。

```dart:lib/infrastructure/dynamic_links_repository_impl.dart
  /// 未インストールの場合
  @override
  Future<String> dynamicLinksHandlerNonInstall() async {
    // ここで取得
    final PendingDynamicLinkData data =
        await FirebaseDynamicLinks.instance.getInitialLink(); 
    final Uri deepLink = data?.link;
    if (deepLink != null) {
      final String coupleId = deepLink.queryParameters['coupleId'];
      return coupleId;
    } else {
      return null;
    }
  }

  // インストール済みの場合でdynamic_linkを受け取った際の挙動
  // dynamic linksを取得するかどうかをlistenしておく
  @override
  Future<String> setOnLink() async {
    String coupleId;

    FirebaseDynamicLinks.instance.onLink(onSuccess: (
      PendingDynamicLinkData dynamicLink,
    ) async {
      final Uri deepLink = dynamicLink?.link;
      if (deepLink != null) {
        coupleId = deepLink.queryParameters['coupleId'];
        await _authRepository.logout();
        _authRepository.interruptUserState(UserState.invited);
      }
    }, onError: (OnLinkErrorException e) async {
      print(e.message);
    });
    return coupleId;
  }
}
```

今回のアーキテクチャ上、dynamic linksの検知や認証しているかどうかなど、アプリに常駐させたい動きは`MainModel`にて行っています。

```dart:lib/main_model.dart
class MainModel {
  ///長いので省略

  Future<void> init() async {
    await _authRepository.setAuthStateChangeListener();

    // 初めに呼び出し、未インストールの時だけcoupleIdが返ってくる。
    final String coupleIdWhenNonInstall =
        await _dynamicLinksRepository.dynamicLinksHandlerNonInstall();
    // coupleIdを受け取ることが出来たら、ローカルのストレージに保存して、ユーザーの状態を`invited`に変える(独自実装)
    if (coupleIdWhenNonInstall != null) {
      _coupleId = coupleIdWhenNonInstall;
      await _storageRepository.savePersistenceStorage(key_couple_id, _coupleId); 
      _authRepository.interruptUserState(UserState.invited);
    }

    // ログイン済みの状態だったらcoupleIdを取得して、prefに保存する
    final String uid = _authRepository.getUid();
    if (uid != null) {
      _uid = uid;
      _coupleId = await _userRepository.getCoupleIdByUid(_uid);
     await _storageRepository.savePersistenceStorage(key_couple_id, _coupleId);
    }

    // インストール済みの場合でdynamic_linkを受け取った際の挙動
    final String coupleId = await _dynamicLinksRepository.setOnLink();
    if (coupleId != null) {
      _coupleId = coupleId;
      await _authRepository.logout();
      _authRepository.interruptUserState(UserState.invited);
    }
  }
}
```

このようにして検知しています。

# デバッグ方法
生成されたURLの末尾に`?d=1`をつけることで以下のような画面が表示され、デバッグできるようになります。
![cook_217JZKygLccJ59upletodpz6_d=1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/fb2a3e73-a7b9-8699-4350-7f8cab6235d6.png)

この画面のフローチャートが想定しているものとあっているかを確認し、エラーが出ていれば[公式のページ](https://firebase.google.com/docs/dynamic-links/debug?hl=ja)で各エラーの対処法を見てみると良いと思います。

# まとめ
ざっくりとDynamic Linksを使った実装を紹介しました。
今回は紹介しませんでしたが、Firebaseのコンソールから静的なDynamic Linksを生成することができ、一つのURLでAndroid/iOSでそれぞれGoogle Play/Apple Storeと遷移先を変えることも出来ます。

題材にしたアプリのLPページにはこの静的なDynamic Linksが埋め込んであり、上記のような振り分けを行っています。

https://couple-todo-product.web.app/

### 参考
- [FlutterでDynamic Linksを使ってみる - Qiita](https://qiita.com/mona/items/99622e91e49caae8a626)
- [Dynamic Links をデバッグする  |  Firebase Documentation](https://firebase.google.com/docs/dynamic-links/debug?hl=ja)
- [アプリにユーザーを招待する  |  Firebase Documentation](https://firebase.google.com/docs/dynamic-links/use-cases/user-to-user?hl=ja)
- [firebase_dynamic_links | Flutter Package](https://pub.dev/packages/firebase_dynamic_links)
- [Flutterでのshare機能の実装について - Qiita](https://qiita.com/shimopata/items/142b39bab6176b6a5da9)
- [Flutter大学 – Flutterで作るスマホアプリ開発コミュニティ](https://kboyflutteruniv.com/)