Flutter × AppsFlyerでdeep linkを実装する
FDLが使えなくなるので、AppsFlyerでdeep link(OneLink)実装した時のメモ書きなど
同じ理由でAppsFlyerでdeep link実装している人の記事を見つける。
起動時の開き方がなんか3つ書いてある。新規PJなのでバージョンの問題はクリア。
何をどう指定するのが良いのか悩む。
- Universal Links
- iOS9以降で動く
- App Links
- Android v6以降で動く
- URI スキーム
- 全てのバージョンで動く
求めてた質問がそのままトラブルシューティングのよくある質問にあった
ユニバーサルリンク、アプリリンク、URI スキームなど、アプリの起動にどの方法を使用すればよいですか?
ユニバーサルリンク: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ディープリンクの手順に従ってください。
へー。
これらでdeep linkを開きましょう
- Universal Links
- App Links
↑サポート対象外、なんらか失敗して開けなかった時にこれ↓も用意しておきましょう
- URI スキーム
ということね。
つまり全部必要がジャスティス。
あとは最初に貼ったqiitaの記事を手を合わせて拝見しながら設定、ゴリゴリ実装する。
公式の設定が分かりやすい。こっちにも貼っておく。
使用したsdkバージョンは6.12.2
onDeepLinkingを使います。シンプル。
iOSでフォールバック時にカスタムURIスキーマでアプリが開かないときがある
リンクのお尻にこれを追加すると開いた
?af_force_deeplink=true
iOSでユニバーサルリンクでアプリが開かない問題に詰まる。
凡ミス。dictの階層がずれてた。なんでズレたのかは謎。
<?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>
Note: The code implementation for onDeepLinking must be made prior to the initialization code of the SDK.
onDeepLinkingは初期化処理の前に呼んでねとのこと。
もっと目立つように書いても良いと思う。まじで。
qiitaの記事には分かりやすく書いてあった。ありがたい。
go_router使ってネイティブのdeep link enabledをenabledにすると、go_routerでアプリ開けるよって話。
軽く動かしてみると、設計がシンプルになって良さげ。
けどDeferred deep link実装する時どうなんかな、となって今回は見送り。
deep linkを受け取るクラスを設計。
DIはriverpod / routerはgo_routerですん。
- data/
- appsflyer_manager.dart
- appsflyer_sdkの処理置き場
- appsflyer_manager.dart
- repository/
- deep_link_repository.dart
- 抽象化します、app_deep_link(model)に変換する。
- deep_link_repository.dart
- model/
- app_deep_link.dart
- service/
- deep_link_service.dart
- stream controllerでdeep link来たらよしなにイベント通知する。
- deep_link_service.dart
- view/navigation/
- deep_link_navigation_widget.dart
- deep_link_serviceからのイベントを検知したら、条件に応じてナビゲーションする。
- deep_link_navigation_widget.dart
- app.dart(初期化処理で使う)
- main.dartでdeep link やら appsflyer周り初期化してたんだけど、リンクが動かないのでapp.dartのinitState内に移動した。
repositoryとserviceとviewのコードだけメモっておく。
エラーの時はTODO
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,
);
}
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;
}
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;
}
}
それぞれUT書いてfinish。
Deferred deep linkは近いうちに実装すると思うので、その時に。
おまけ
go_routerのmockどうするのか彷徨ってた時、go_routerのパッケージテスト眺めてたらcreateRouter
なるもので実現してそう。
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);
});
これ使ってた。ありがたく拝借しよう。
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;
}
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のテストを一部抜粋
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();
});
});
}