Flutter SDK - 딥 링크 지원

문서

딥 링크 지원 추가

딥 링크는 사용자를 앱 내 특정 콘텐츠로 안내합니다. 사용자가 앱이 설치된 기기에서 딥 링크를 탭하면 앱이 제품 페이지나 특정 경험과 같은 의도된 콘텐츠로 바로 열립니다.

Singular 추적 링크는 설치된 앱용 표준 딥 링크와 신규 설치용 지연 딥 링크를 모두 지원합니다. 자세한 내용은 딥 링크 FAQSingular 링크 FAQ를 참조하세요.


요구 사항

필수 조건

앱에 딥 링크를 활성화하려면 Singular 링크 필수 조건을 완료하세요.

참고:

  • 본 문서는 귀사가 Singular 링크 (Singular Links) - Singular의 추적 링크 기술을 사용 중이라고 가정합니다. 기존 고객은 레거시 추적 링크를 사용할 수 있습니다.
  • 앱의 딥 링크 대상은 Singular의 '앱' 페이지에서 구성해야 합니다( 앱 속성 추적 구성 참조).

사용 가능한 매개변수

SingularLink 핸들러는 앱이 실행될 때 Singular 추적 링크의 딥 링크, 지연 딥 링크 및 패스스루 매개변수에 대한 액세스를 제공합니다.

  • 딥 링크(_dl): 링크를 클릭한 사용자를 위한 앱 내 목적지 URL
  • 지연 딥 링크(_ddl): 링크 클릭 후 앱을 설치하는 사용자의 대상 URL
  • 패스스루(_p): 추가 컨텍스트를 위해 추적 링크를 통해 전달되는 사용자 지정 데이터

Flutter 3.35는 딥 링크 처리 방식에 중대한 변경 사항을 도입했습니다. 내부 함수 ` didPushRouteInformation() `가 이제 타사 SDK가 처리하기 전에 딥 링크를 가로채고 디코딩합니다. 이로 인해 Singular의 딥 링크 핸들러와 충돌이 발생하여 Singular 링크가 알 수 없는 경로로 처리되고 사용자가 의도된 목적지가 아닌 홈 화면으로 이동하게 됩니다.

이는 Android와 iOS 모두에 영향을 미칩니다. 앱이 Flutter 3.35 이상을 대상으로 하는 경우, 표준 딥 링크 구현을 진행하기 전에 아래의 플랫폼별 추가 설정을 완료하십시오.

참고: 이는 Singular SDK 버그가 아닌 Flutter 프레임워크 수준의 변경 사항입니다. SDK 업데이트는 필요하지 않습니다. 아래 구성은 모든 앱 상태(전경, 배경, 콜드 스타트)에서 올바른 딥 링크 라우팅 동작을 복원합니다.

Android에서는 두 가지 변경이 필요합니다: Flutter의 내장 딥링크 핸들러 비활성화와 Flutter 엔진 및 Singular SDK 양쪽에 대한 인텐트 수동 전달입니다.

AndroidManifest.xml 파일의 <activity> 태그 내부에 다음 항목을 추가하세요: <meta-data>

AndroidManifest.xml
<meta-data
    android:name="flutter_deeplinking_enabled"
    android:value="false" />

MainActivity 를 업데이트하여 인텐트를 Flutter 엔진과 Singular SDK 모두로 전달하도록 합니다.

MainActivity.kt MainActivity.java
// Add as part of the imports at the top of the class
import android.content.Intent
import com.singular.flutter_sdk.SingularBridge

// Add to the MainActivity class
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    setIntent(intent)

    // Forward to Flutter engine
    flutterEngine?.activityControlSurface?.onNewIntent(intent)

    // Forward to Singular SDK
    if (intent.data != null) {
        SingularBridge.onNewIntent(intent)
    }
}
// Add as part of the imports at the top of the class
import android.content.Intent;
import com.singular.flutter_sdk.SingularBridge;

// Add to the MainActivity class
@Override
public void onNewIntent(Intent intent) {
    super.onNewIntent(intent);
    setIntent(intent);

    // Forward to Flutter engine
    if (getFlutterEngine() != null) {
        getFlutterEngine().getActivityControlSurface().onNewIntent(intent);
    }

    // Forward to Singular SDK
    if (intent.getData() != null) {
        SingularBridge.onNewIntent(intent);
    }
}

참고: 이는 아래 Android 플랫폼 구성 섹션에 표시된 Flutter 3.35+ 프로젝트용 표준 onNewIntent 구현을 대체합니다. Flutter 3.10.x 이하 버전을 사용하는 경우 대신 표준 구현을 사용하십시오.

앱의 ` Info.plist`에서 Flutter의 네이티브 딥 링크 가로채기를 비활성화하려면 ` FlutterDeepLinkingEnabled ` 플래그를 ` false `로 설정하세요:

Info.plist
<key>FlutterDeepLinkingEnabled</key>
<false/>

참고: 이 플래그 외에 AppDelegate 에 추가 변경이 필요하지 않습니다. 아래 iOS 플랫폼 구성 섹션에 표시된 표준 iOS AppDelegate 구성을 계속 진행하십시오.


플랫폼 구성

iOS 플랫폼 구성

iOS AppDelegate 업데이트

Singular SDK가 실행 관련 데이터를 처리하고 딥 링크를 처리할 수 있도록 AppDelegate 파일에서 Singular SDK에 launchOptionsuserActivity 객체를 전달하세요.

이 객체들은 앱이 어떻게, 왜 실행되었는지에 대한 중요한 정보를 포함하며, Singular는 이를 어트리뷰션 추적 및 딥 링크 탐색에 사용합니다.

Objective-C 구현

AppDelegate.m
// Top of AppDelegate.m
#import "SingularAppDelegate.h"

- (BOOL)application:(UIApplication *)application 
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [GeneratedPluginRegistrant registerWithRegistry:self];
    [SingularAppDelegate shared].launchOptions = launchOptions;

    return [super application:application 
        didFinishLaunchingWithOptions:launchOptions];
}

- (BOOL)application:(UIApplication *)application 
    continueUserActivity:(NSUserActivity *)userActivity 
    restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> 
        *restorableObjects))restorationHandler {
    [[SingularAppDelegate shared] continueUserActivity:userActivity 
        restorationHandler:restorationHandler];

    return [super application:application 
        continueUserActivity:userActivity 
        restorationHandler:restorationHandler];
}

- (BOOL)application:(UIApplication *)app 
    openURL:(NSURL *)url 
    options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
    [[SingularAppDelegate shared] handleOpenUrl:url options:options];

    return [super application:app openURL:url options:options];
}

Swift 구현

AppDelegate.swift
import singular_flutter_sdk

override func application(_ application: UIApplication, 
    didFinishLaunchingWithOptions 
    launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    GeneratedPluginRegistrant.register(with: self)

    if let singularAppDelegate = SingularAppDelegate.shared() {
        singularAppDelegate.launchOptions = launchOptions
    }

    return super.application(application, 
        didFinishLaunchingWithOptions:launchOptions)
}

override func application(_ application: UIApplication, 
    continue userActivity: NSUserActivity, 
    restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    if let singularAppDelegate = SingularAppDelegate.shared() {
        singularAppDelegate.continueUserActivity(userActivity, 
            restorationHandler: nil)
    }

    return super.application(application, 
        continue: userActivity, 
        restorationHandler: restorationHandler)
}

override func application(_ app: UIApplication, 
    open url: URL, 
    options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
    if let singularAppDelegate = SingularAppDelegate.shared() {
        singularAppDelegate.handleOpen(url, options: options)
    }

    return super.application(app, open: url, options: options)
}

Android 플랫폼 구성

Android MainActivity 업데이트

MainActivity 파일을 수정하여 launch-related 데이터를 처리하고 딥 링크를 관리하도록 Singular SDK를 활성화합니다. 이 과정에서 Intent 객체를 Singular SDK로 전달합니다.

Intent 객체에는 앱이 실행된 방식과 이유에 대한 정보가 포함되어 있으며, Singular는 이를 어트리뷰션 추적 및 딥 링크 탐색에 사용합니다.

Java 구현

MainActivity.java
// Add as part of the imports at the top of the class
import android.content.Intent;
import com.singular.flutter_sdk.SingularBridge;

// Add to the MainActivity class
@Override
public void onNewIntent(Intent intent) {
    if(intent.getData() != null) {
        setIntent(intent);
        super.onNewIntent(intent);
        SingularBridge.onNewIntent(intent);
    }
}

Kotlin 구현

MainActivity.kt
// Add as part of the imports at the top of the class
import android.content.Intent
import com.singular.flutter_sdk.SingularBridge

// Add to the MainActivity class
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    if (intent.data != null) {
        setIntent(intent)
        SingularBridge.onNewIntent(intent)
    }
}

SDK 구성

Singular 링크 핸들러 구현

SDK 초기화 시 singularLinksHandler 콜백을 구성하여 수신되는 딥 링크 및 지연 딥 링크 데이터를 처리합니다.

Dart
import 'package:flutter/material.dart';
import 'package:singular_flutter_sdk/singular.dart';
import 'package:singular_flutter_sdk/singular_config.dart';
import 'package:singular_flutter_sdk/singular_link_params.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    initializeSDK();
  }

  void initializeSDK() {
    // Create SDK configuration
    SingularConfig config = SingularConfig(
      'YOUR_SDK_KEY',
      'YOUR_SDK_SECRET'
    );

    // Configure deep link handler
    config.singularLinksHandler = (SingularLinkParams params) {
      print('=== Singular Link Resolved ===');
      print('Deep link: ${params.deeplink}');
      print('Passthrough: ${params.passthrough}');
      print('Is deferred: ${params.isDeferred}');

      // Handle deep link navigation
      if (params.deeplink != null) {
        handleDeepLink(params.deeplink!, params.isDeferred);
      }

      // Handle passthrough data
      if (params.passthrough != null) {
        handlePassthroughData(params.passthrough!);
      }
    };

    // Initialize SDK
    Singular.start(config);
  }

  void handleDeepLink(String url, bool isDeferred) {
    print('Routing to: $url (Deferred: $isDeferred)');

    // Parse URL and navigate to appropriate screen
    // Example: myapp://product/123
    if (url.contains('product')) {
      final productId = url.split('/').last;
      navigateToProduct(productId);
    } else if (url.contains('promo')) {
      navigateToPromo(url);
    }
  }

  void handlePassthroughData(String passthrough) {
    print('Processing passthrough data: $passthrough');
    // Parse and use passthrough data as needed
  }

  void navigateToProduct(String productId) {
    // Your navigation logic
    print('Navigating to product: $productId');
  }

  void navigateToPromo(String url) {
    // Your navigation logic
    print('Navigating to promo: $url');
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

구성 속성:

void Function(SingularLinkParams)? singularLinksHandler

참고: singularLinksHandler 콜백은 앱이 Singular 링크를 통해 열릴 때만 트리거됩니다. 자세한 내용은 Singular 링크 FAQ를 참조하십시오.

전체 문서는 singularLinksHandler 참조를 참조하십시오.


핸들러 동작

링크 해결 방식 이해

singularLinksHandler 는 앱이 새로 설치되었는지 이미 설치되었는지에 따라 다르게 동작합니다.

신규 설치(지연 딥 링크)

새로 설치된 경우, 앱 실행 시 Open URL이 존재하지 않습니다. Singular는 추적 링크에 딥 링크 또는 지연 딥 링크 값이 포함되었는지 확인하기 위해 어트리뷰션을 완료합니다.

지연형 딥 링크 흐름:

  1. 사용자가 딥 링크 값으로 구성된 Singular 추적 링크를 클릭합니다
  2. 사용자가 앱을 설치하고 처음 실행합니다
  3. Singular SDK가 첫 세션을 Singular 서버로 전송합니다
  4. 애트리뷰션 완료 시 추적 링크에서 딥 링크 식별
  5. singularLinksHandler 딥 링크 값이 isDeferred = true매개변수와 함께 deeplink 콜백으로 반환됨

지연형 딥 링크 테스트:

  1. 테스트 기기에서 앱을 삭제합니다(현재 설치된 경우)
  2. iOS: IDFA 재설정. Android: Google 광고 ID(GAID) 재설정
  3. 기기에서 Singular 추적 링크 클릭(딥링크 값이 설정되었는지 확인)
  4. 앱 설치 후 실행

어트리뷰션이 성공적으로 완료되어야 하며, 지연 딥 링크 값이 singularLinksHandler 핸들러로 전달됩니다.

프로 팁: 다른 패키지 이름(예: com.example.prod 대신 com.example.dev )을 사용하는 개발 빌드로 딥 링크를 테스트할 때는 개발 앱의 패키지 이름에 맞게 추적 링크를 구성하세요. 테스트 링크를 클릭한 후, 앱 스토어에서 프로덕션 앱을 다운로드하지 말고 개발 빌드를 기기에 직접 설치하세요(Android Studio 또는 Xcode 사용).


이미 설치된 경우 (즉시 딥 링크)

앱이 이미 설치된 상태에서 Singular 링크를 클릭하면 유니버설 링크(iOS) 또는 안드로이드 앱 링크 기술을 통해 앱이 즉시 실행됩니다.

즉각적 딥 링크 흐름:

  1. 사용자가 Singular 추적 링크 클릭
  2. 운영 체제가 전체 Singular 추적 링크를 포함한 'URL 열기'를 제공합니다
  3. SDK 초기화 과정에서 Singular가 URL을 파싱합니다
  4. Singular가 deeplinkpassthrough 값 추출
  5. 값은 isDeferred = false와 함께 singularLinksHandler 핸들러를 통해 반환됨

고급 기능

패스스루 매개변수

추적 링크 클릭 시 추가 데이터를 패스스루 매개변수를 통해 캡처합니다. 추적 링크에 xml-ph-0000@deepl.internal 매개변수가 포함된 경우,

추적 링크에 passthrough (_p) 매개변수가 포함된 경우, singularLinksHandler 핸들러의 passthrough 매개변수에 해당 데이터가 포함됩니다. 캠페인 메타데이터, 사용자 세분화 데이터 또는 앱에서 필요한 모든 사용자 지정 정보를 캡처하는 데 사용하십시오.

Dart
import 'package:singular_flutter_sdk/singular_config.dart';
import 'package:singular_flutter_sdk/singular_link_params.dart';
import 'dart:convert';

SingularConfig config = SingularConfig('API_KEY', 'SECRET');

config.singularLinksHandler = (SingularLinkParams params) {
  // Extract passthrough data
  final passthroughData = params.passthrough;

  if (passthroughData != null) {
    try {
      // Parse JSON passthrough data
      final jsonData = jsonDecode(passthroughData);

      print('Campaign ID: ${jsonData['campaign_id']}');
      print('User Segment: ${jsonData['segment']}');
      print('Promo Code: ${jsonData['promo_code']}');

      // Apply campaign-specific settings
      applyCampaignSettings(jsonData);
    } catch (error) {
      print('Error parsing passthrough data: $error');
    }
  }
};

void applyCampaignSettings(Map<String, dynamic> data) {
  // Your campaign logic here
  print('Applying campaign settings: $data');
}

모든 쿼리 매개변수 전달

추적 링크 URL의 모든 쿼리 매개변수를 캡처하려면 추적 링크에 _forward_params=2 매개변수를 추가하세요.

추적 링크에 _forward_params=2 을 추가하면 모든 쿼리 매개변수가 singularLinksHandler 핸들러의 deeplink 매개변수에 포함되어 모든 매개변수가 포함된 전체 URL에 접근할 수 있습니다.

추적 링크 예시:
https://yourapp.sng.link/A1b2c/abc123?_dl=myapp://product/123&_forward_params=2&utm_source=facebook&promo=SALE2024

singularLinksHandler 핸들러가 수신하는 내용:
deeplink = "myapp://product/123?utm_source=facebook&promo=SALE2024"


완전한 구현 예시

Flutter 앱을 위한 네비게이션, 패스스루 처리, 매개변수 전달을 포함한 포괄적인 딥 링크 구현.

Dart
import 'package:flutter/material.dart';
import 'package:singular_flutter_sdk/singular.dart';
import 'package:singular_flutter_sdk/singular_config.dart';
import 'package:singular_flutter_sdk/singular_link_params.dart';
import 'dart:convert';

void main() {
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  @override
  void initState() {
    super.initState();
    initializeSDK();
  }

  void initializeSDK() {
    SingularConfig config = SingularConfig(
      'YOUR_SDK_KEY',
      'YOUR_SDK_SECRET'
    );

    config.singularLinksHandler = (SingularLinkParams params) {
      print('=== Singular Link Resolved ===');
      print('Deep link: ${params.deeplink}');
      print('Passthrough: ${params.passthrough}');
      print('Is deferred: ${params.isDeferred}');

      // Handle passthrough data
      if (params.passthrough != null) {
        handlePassthroughData(params.passthrough!);
      }

      // Handle deep link navigation
      if (params.deeplink != null) {
        handleDeepLinkNavigation(params.deeplink!, params.isDeferred);
      }
    };

    Singular.start(config);
  }

  void handlePassthroughData(String passthroughString) {
    try {
      final data = jsonDecode(passthroughString);

      // Apply promo code if present
      if (data.containsKey('promo_code')) {
        applyPromoCode(data['promo_code']);
      }

      // Set user segment
      if (data.containsKey('segment')) {
        setUserSegment(data['segment']);
      }

      // Track campaign
      if (data.containsKey('campaign_id')) {
        trackCampaign(data['campaign_id']);
      }
    } catch (error) {
      print('Error parsing passthrough: $error');
    }
  }

  void handleDeepLinkNavigation(String url, bool isDeferred) {
    // Parse URL to extract route and parameters
    final urlObj = parseDeepLink(url);

    print('Navigating to: ${urlObj['route']}');
    print('Parameters: ${urlObj['params']}');
    print('Deferred install: $isDeferred');

    // Route based on deep link structure
    switch (urlObj['route']) {
      case 'product':
        navigateToProduct(urlObj['params']['id']);
        break;
      case 'promo':
        navigateToPromo(urlObj['params']['code']);
        break;
      case 'category':
        navigateToCategory(urlObj['params']['name']);
        break;
      default:
        navigateToHome();
    }
  }

  Map<String, dynamic> parseDeepLink(String url) {
    // Parse myapp://product/123?variant=blue
    final parts = url.split('?');
    final path = parts[0];
    final queryString = parts.length > 1 ? parts[1] : null;

    // Extract path components
    final pathParts = path.replaceFirst(RegExp(r'^[^:]+://'), '').split('/');
    final route = pathParts[0];

    // Parse parameters
    final params = <String, String>{};

    // Add path parameters
    if (pathParts.length > 1) {
      params['id'] = pathParts[1];
    }

    // Add query parameters
    if (queryString != null) {
      queryString.split('&').forEach((pair) {
        final keyValue = pair.split('=');
        if (keyValue.length == 2) {
          params[keyValue[0]] = Uri.decodeComponent(keyValue[1]);
        }
      });
    }

    return {
      'route': route,
      'params': params
    };
  }

  // Navigation functions
  void navigateToProduct(String? productId) {
    if (productId != null) {
      print('Navigating to product: $productId');
      // Use your navigation framework (Navigator, GoRouter, etc.)
      navigatorKey.currentState?.pushNamed('/product/$productId');
    }
  }

  void navigateToPromo(String? promoCode) {
    if (promoCode != null) {
      print('Navigating to promo: $promoCode');
      navigatorKey.currentState?.pushNamed('/promo/$promoCode');
    }
  }

  void navigateToCategory(String? categoryName) {
    if (categoryName != null) {
      print('Navigating to category: $categoryName');
      navigatorKey.currentState?.pushNamed('/category/$categoryName');
    }
  }

  void navigateToHome() {
    print('Navigating to home');
    navigatorKey.currentState?.pushNamed('/');
  }

  // Utility functions
  void applyPromoCode(String code) {
    print('Applying promo code: $code');
    // Your promo code logic
  }

  void setUserSegment(String segment) {
    print('Setting user segment: $segment');
    // Your user segmentation logic
  }

  void trackCampaign(String campaignId) {
    print('Tracking campaign: $campaignId');
    // Your campaign tracking logic
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      navigatorKey: navigatorKey,
      initialRoute: '/',
      routes: {
        '/': (context) => HomeScreen(),
        '/product': (context) => ProductScreen(),
        '/promo': (context) => PromoScreen(),
        '/category': (context) => CategoryScreen(),
      },
    );
  }
}

// Placeholder screen classes
class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Home')),
      body: Center(child: Text('Home Screen')),
    );
  }
}

class ProductScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Product')),
      body: Center(child: Text('Product Screen')),
    );
  }
}

class PromoScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Promo')),
      body: Center(child: Text('Promo Screen')),
    );
  }
}

class CategoryScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Category')),
      body: Center(child: Text('Category Screen')),
    );
  }
}

모범 사례:

  • URL 안전하게 파싱: 보안 취약점 방지를 위해 항상 네비게이션 전 딥링크 URL을 검증 및 정제하세요
  • 네비게이션 상태 처리: MaterialApp이 완전히 초기화되기 전에 네비게이션을 활성화하려면 NavigatorState용 GlobalKey 사용
  • 두 시나리오 모두 테스트: 개발 중 즉시 딥링크(앱 설치 완료)와 지연 딥링크(신규 설치) 모두 테스트
  • 디버깅을 위한 로깅: 개발 중 심층 링크 해결 및 네비게이션 흐름 추적을 위해 포괄적인 로깅 활성화
  • 오류 처리: JSON 파싱 및 네비게이션 작업에 try-catch 블록을 구현하여 잘못된 형식의 데이터를 우아하게 처리하세요