Closed15

Flutter × AppsFlyerでdeep linkを実装する

ohtsukiohtsuki

起動時の開き方がなんか3つ書いてある。新規PJなのでバージョンの問題はクリア。
何をどう指定するのが良いのか悩む。

  • Universal Links
    • iOS9以降で動く
  • App Links
    • Android v6以降で動く
  • URI スキーム
    • 全てのバージョンで動く

求めてた質問がそのままトラブルシューティングのよくある質問にあった

ユニバーサルリンク、アプリリンク、URI スキームなど、アプリの起動にどの方法を使用すればよいですか?

https://support.appsflyer.com/hc/ja/articles/360014821438#which-method-should-i-use-for-opening-apps—universal-links-app-links-or-uri-schemes

ユニバーサルリンク:iOSユーザーの98%以上で必要な方法です

App Links:バージョン6.0以上のAndroidユーザー向けです
      注意:Samsung OSでは、Android App Linksによるアプリ起動はできません。

へー。

URIスキーム: Samsung端末でアプリを起動するための主要な方法です。それ以外の場合は、アプリを開くための従来のフォールバック方法です。
次の場合に使用できます。

バージョン6.0以前を使用しているAndroidユーザー(Androidユーザーの15%以下)の場合。
Universal LinksとApp Linksが機能しない、または設定されていないためにアプリが開かない場合のフォールバックとして使用
注:ユニバーサルリンクのフォールバックとしてURIスキームを使用することは、iOSベースの制限の対象となります。詳細はこちらを参照してください。

ディープリンク
ユニバーサルリンク、アプリリンク、およびURIスキームは、アプリを起動するための安全な方法です。ユーザーをアプリ内の特定のアクティビティまたはページにディープリンクしてリダイレクトするためには、OneLinkディープリンクの手順に従ってください。

へー。

ohtsukiohtsuki

これらでdeep linkを開きましょう

  • Universal Links
  • App Links

↑サポート対象外、なんらか失敗して開けなかった時にこれ↓も用意しておきましょう

  • URI スキーム

ということね。
つまり全部必要がジャスティス。

ohtsukiohtsuki

iOSでユニバーサルリンクでアプリが開かない問題に詰まる。
凡ミス。dictの階層がずれてた。なんでズレたのかは謎。

Runner.entitlements
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
- <dict>
-	<key>com.apple.developer.associated-domains</key>
-	<array>
-		<string>applinks:$(deepLinkDomain)</string>
-	</array>
- </dict>
+	<dict>
+		<key>com.apple.developer.associated-domains</key>
+		<array>
+			<string>applinks:$(deepLinkDomain)</string>
+		</array>
+	</dict>
 </plist>
ohtsukiohtsuki

Note: The code implementation for onDeepLinking must be made prior to the initialization code of the SDK.

onDeepLinkingは初期化処理の前に呼んでねとのこと。
もっと目立つように書いても良いと思う。まじで。
qiitaの記事には分かりやすく書いてあった。ありがたい。

大事なことはしれっと書いてある

ohtsukiohtsuki

https://codewithandrea.com/articles/flutter-deep-links/

go_router使ってネイティブのdeep link enabledをenabledにすると、go_routerでアプリ開けるよって話。
軽く動かしてみると、設計がシンプルになって良さげ。

けどDeferred deep link実装する時どうなんかな、となって今回は見送り。

ohtsukiohtsuki

deep linkを受け取るクラスを設計。

DIはriverpod / routerはgo_routerですん。

  • data/
    • appsflyer_manager.dart
      • appsflyer_sdkの処理置き場
  • repository/
    • deep_link_repository.dart
      • 抽象化します、app_deep_link(model)に変換する。
  • model/
    • app_deep_link.dart
  • service/
    • deep_link_service.dart
      • stream controllerでdeep link来たらよしなにイベント通知する。
  • view/navigation/
    • deep_link_navigation_widget.dart
      • deep_link_serviceからのイベントを検知したら、条件に応じてナビゲーションする。
  • app.dart(初期化処理で使う)
    • main.dartでdeep link やら appsflyer周り初期化してたんだけど、リンクが動かないのでapp.dartのinitState内に移動した。
ohtsukiohtsuki

repositoryとserviceとviewのコードだけメモっておく。
エラーの時はTODO

repository/deep_link_repositroy.dart
class AppsflyerDeepLinkRepository
    implements DeepLinkRepository {
  AppsflyerDeepLinkRepository({
    required this.appsflyerManager,
  });

  final AppsflyerManager appsflyerManager;

  
  Future<void> onListenDeepLink({
    required void Function(AppDeepLink) onOpenedLink,
    required void Function(AppDeepLink) onError,
  }) async {
    try {
      appsflyerManager.onDeepLinkLinking()((dp) {
        final appDeepLink = _convertToAppDeepLink(dp);
        switch (appDeepLink.status) {
          case AppDeepLinkStatus.found:
            onOpenedLink(appDeepLink);
          case AppDeepLinkStatus.notFound:
            onError(appDeepLink);
          case AppDeepLinkStatus.error:
            onError(appDeepLink);
          case AppDeepLinkStatus.listen:
          case null:
        }
      });
    } on Exception {
      rethrow;
    }
  }

  // DeepLinkResult(appsflyerのモデル)を、アプリ用のmodelに変換しとく。
  // queryパラメータとかアプリで使いやすいようにmodelに持たせたいよね
  AppDeepLink _convertToAppDeepLink(DeepLinkResult dp) {
    switch (dp.status) {
      case Status.FOUND:
        return AppDeepLink(
          deepLinkValue: dp.deepLink?.deepLinkValue ?? '',
          status: AppDeepLinkStatus.found,
        );
      case Status.NOT_FOUND:
        return AppDeepLink(
          deepLinkValue: dp.deepLink?.deepLinkValue ?? '',
          status: AppDeepLinkStatus.notFound,
        );
      case Status.ERROR:
      case Status.PARSE_ERROR:
        return AppDeepLink(
          errorMessage: dp.error?.name ?? '',
          status: AppDeepLinkStatus.error,
        );
    }
  }
}


DeepLinkRepository deepLinkRepository(DeepLinkRepositoryRef ref) {
  final appsflyerManager = ref.read(appsflyerManagerProvider);
  return AppsflyerDeepLinkRepository(
    appsflyerManager: appsflyerManager,
  );
}
service/deep_link_service.dart
class DeepLinkService {
  DeepLinkService(this._deepLinkRepository);

  final DeepLinkRepository _deepLinkRepository;
  final deepLinkStream = StreamController<AppDeepLink>.broadcast();

  void Function(AppDeepLink) get _deepLinkDispatch => deepLinkStream.add;
  Stream<AppDeepLink> get deepLinkSubscribe => deepLinkStream.stream;

  Future<void> onInitListenDeepLink() async {
    try {
      await _deepLinkRepository.onListenDeepLink(
        onOpenedLink: (appDeepLink) {
          _deepLinkDispatch(appDeepLink);
          // リンクを開いて成功したら、イベントを受け取ってaddする。
          // 中身はリンクに含まれる情報とかをmodelにしたもの。
        },
        onError: deepLinkStream.addError,
      );
    } on Exception {
      rethrow;
    }
  }

  void dispose() {
    deepLinkStream.close();
  }
}


DeepLinkService deepLinkService(
  DeepLinkServiceRef ref,
) {
  final deepLinkRepository = ref.read(deepLinkRepositoryProvider);
  return DeepLinkService(deepLinkRepository);
}

// こいつをviewでlistenして、イベントをサブスクする

Stream<AppDeepLink> appDeepLinkServiceListen(
  AppDeepLinkServiceListenRef ref,
) async* {
  final service = ref.read(deepLinkServiceProvider);
  ref.onDispose(() {
    ref.invalidateSelf();
    service.dispose();
  });
  yield* service.deepLinkSubscribe;
}
view/navigation/deep_link_navigation_widget.dart

class DeepLinkNavigationWidget extends ConsumerWidget {
  const DeepLinkNavigationWidget({
    super.key,
    required this.child,
  });

  final Widget child;

  
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen(appDeepLinkServiceListenProvider, (previous, next) async {
      if (next.hasValue && next.value is AppDeepLink) {
        final appDeepLink = next.value!;
        final linkType =
            AppDeepLinkPath.fromDeepLinkValue(appDeepLink.deepLinkValue);

        // widgetのcontextではなく、go_routerのnavigatorKey.currentContextを使う
        final bc =
            ref.watch(routerProvider).configuration.navigatorKey.currentContext;

        if (bc is BuildContext) {
          switch (linkType) {
            case AppDeepLinkPath.start:
              const HomePageRoute().go(bc);
            case AppDeepLinkPath.app:
            case null:
            // 指定がなければアプリを立ち上げるだけ
          }
        }
      }
    });

    // 上位でwrapする(routerの初期化よりは後ろの方で)
    return child;
  }
}

ohtsukiohtsuki

それぞれUT書いてfinish。
Deferred deep linkは近いうちに実装すると思うので、その時に。

ohtsukiohtsuki

おまけ
go_routerのmockどうするのか彷徨ってた時、go_routerのパッケージテスト眺めてたらcreateRouterなるもので実現してそう。

test/go_router_test.dart
    testWidgets('match home route', (WidgetTester tester) async {
      final List<GoRoute> routes = <GoRoute>[
        GoRoute(
            path: '/',
            builder: (BuildContext context, GoRouterState state) =>
                const HomeScreen()),
      ];

      final GoRouter router = await createRouter(routes, tester);
      final RouteMatchList matches = router.routerDelegate.currentConfiguration;
      expect(matches.matches, hasLength(1));
      expect(matches.uri.toString(), '/');
      expect(find.byType(HomeScreen), findsOneWidget);
    });

https://github.com/flutter/packages/blob/main/packages/go_router/test/go_router_test.dart

ohtsukiohtsuki

これ使ってた。ありがたく拝借しよう。

test/test_helpers.dart
Future<GoRouter> createRouter(
  List<RouteBase> routes,
  WidgetTester tester, {
  GoRouterRedirect? redirect,
  String initialLocation = '/',
  Object? initialExtra,
  int redirectLimit = 5,
  GlobalKey<NavigatorState>? navigatorKey,
  GoRouterWidgetBuilder? errorBuilder,
  String? restorationScopeId,
  Codec<Object?, Object?>? extraCodec,
  GoExceptionHandler? onException,
  bool requestFocus = true,
  bool overridePlatformDefaultLocation = false,
}) async {
  final GoRouter goRouter = GoRouter(
    routes: routes,
    redirect: redirect,
    extraCodec: extraCodec,
    initialLocation: initialLocation,
    onException: onException,
    initialExtra: initialExtra,
    redirectLimit: redirectLimit,
    errorBuilder: errorBuilder,
    navigatorKey: navigatorKey,
    restorationScopeId: restorationScopeId,
    requestFocus: requestFocus,
    overridePlatformDefaultLocation: overridePlatformDefaultLocation,
  );
  await tester.pumpWidget(
    MaterialApp.router(
      restorationScopeId:
          restorationScopeId != null ? '$restorationScopeId-root' : null,
      routerConfig: goRouter,
    ),
  );
  return goRouter;
}

https://github.com/flutter/packages/blob/main/packages/go_router/test/test_helpers.dart

ohtsukiohtsuki

viewのテストを一部抜粋、ログイン中かどうかのフラグをヘルパー関数の引数に持たせて、mockルートでも認証状態でリダイレクトする処理を追加。

Future<GoRouter> createRouter(
  List<RouteBase> routes,
  WidgetTester tester, {
  GoRouterRedirect? redirect,
  String initialLocation = '/',
  Object? initialExtra,
  int redirectLimit = 5,
  GlobalKey<NavigatorState>? navigatorKey,
  GoRouterWidgetBuilder? errorBuilder,
  String? restorationScopeId,
  Codec<Object?, Object?>? extraCodec,
  GoExceptionHandler? onException,
  bool requestFocus = true,
  bool overridePlatformDefaultLocation = false,
  bool isLoggedIn = true,
}) async {
  final goRouter = GoRouter(
    observers: [],
    routes: routes,
    redirect: (context, state) {
      final loggedIn = isLoggedIn;
      final isPublicRoute = publicRoutes.contains(state.matchedLocation);
      final goingToLogin =
          state.matchedLocation == const SignUpLoginPageRoute().location;

      if (!loggedIn && !isPublicRoute) {
        if (!goingToLogin) {
          return const SignUpLoginPageRoute().location;
        }
      }

      if (loggedIn && goingToLogin) {
        return const HomePageRoute().location;
      }

      return null;
    },
    extraCodec: extraCodec,
    initialLocation: initialLocation,
    onException: onException,
    initialExtra: initialExtra,
    redirectLimit: redirectLimit,
    errorBuilder: errorBuilder,
    navigatorKey: navigatorKey,
    restorationScopeId: restorationScopeId,
    requestFocus: requestFocus,
    overridePlatformDefaultLocation: overridePlatformDefaultLocation,
  );
  await tester.pumpWidget(
    MaterialApp.router(
      restorationScopeId:
          restorationScopeId != null ? '$restorationScopeId-root' : null,
      routerConfig: goRouter,
    ),
  );
  return goRouter;
}

// ページのmock
final homePageRoute = GoRoute(
  path: RouterPath.home,
  pageBuilder: (context, state) => const MaterialPage(
    child: SizedBox(
      child: Text('HOME'),
    ),
  ),
);

final signUpLoginPageRoute = GoRoute(
  path: RouterPath.signUpLogin,
  pageBuilder: (context, state) => const MaterialPage(
    child: SizedBox(
      child: Text('LOGIN'),
    ),
  ),
);

viewのテストを一部抜粋

test/view/navigation/deep_link_navigation_widget_test.dart
class _TestWidget extends StatelessWidget {
  const _TestWidget({
    required this.container,
    required this.testDeepLink,
    required this.goRouter,
  });

  final ProviderContainer container;
  final Stream<AppDeepLink> testDeepLink;
  final GoRouter goRouter;

  
  Widget build(BuildContext context) {
    return UncontrolledProviderScope(
      container: container,
      child: MaterialApp.router(
        routerConfig: goRouter,
        builder: (context, child) => DeepLinkNavigationWidget(
          child: child!,
        ),
      ),
    );
  }
}

void main() {
  late Stream<AppDeepLink> testDeepLink;

  group('DeepLinkNavigationWidget /start', () {
    setUp(() {
      testDeepLink = Stream.value(
        AppDeepLink(
          deepLinkValue: AppDeepLinkPath.start.deepLinkValue,
          status: AppDeepLinkStatus.found,
        ),
      );
    });

    testWidgets('ログイン中の場合HomePageRouteにナビゲートする', (
      WidgetTester tester,
    ) async {
      // ルートの定義
      final routes = <RouteBase>[
        homePageRoute,
      ];

      // GoRouter の設定
      final goRouter = await createRouter(routes, tester);
      final container = ProviderContainer(
        overrides: [
          appDeepLinkServiceListenProvider.overrideWith(
            (_) => testDeepLink,
          ),
          routerProvider.overrideWithValue(goRouter),
        ],
      );

      await tester.pumpWidget(
        _TestWidget(
          container: container,
          testDeepLink: testDeepLink,
          goRouter: goRouter,
        ),
      );

      // ディープリンクイベントをシミュレート
      await tester.pumpAndSettle();
      expect(find.text('HOME'), findsOneWidget);
      container.dispose();
    });
  });
}
このスクラップは2023/12/30にクローズされました