DEV Community

Cover image for Conectando Tudo: Integração Flutter com Deep Links Nativos (Parte 4)
Cristian Dornelles
Cristian Dornelles

Posted on

Conectando Tudo: Integração Flutter com Deep Links Nativos (Parte 4)

Se você já tentou integrar deep links no Flutter usando código nativo, provavelmente passou por isso:

o Android recebe o link…

o iOS também…

mas o Flutter simplesmente não reage.

Ou pior:
funciona no cold start… mas não funciona com o app aberto.

É exatamente esse problema que vamos resolver agora.

Dando continuidade à série sobre Deep Links no Flutter, com novos artigos saindo semanalmente, ou quase. Se você ainda não viu os posts anteriores: Post 1 — Guia para Iniciantes | Post 2 — Android com Kotlin | Post 3 — iOS com Swift.


Neste artigo, você vai aprender:

  • Como implementar DeepLinkService com MethodChannel e EventChannel no Flutter.
  • Por que usar StreamController.broadcast() para eventos de deep link.
  • Como pré-preencher a UI automaticamente a partir de um link.

DeepLinkService no Flutter

A solução é ter um ponto único de entrada para todos os deep links no app — o DeepLinkService. Ele fala com o código nativo em dois momentos distintos:

  • Via MethodChannel: quando o app abre e precisamos saber se havia um link pendente (app estava fechado).
  • Via EventChannel: stream contínuo para links recebidos enquanto o app já está em execução.
// lib/services/deep_link_service.dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';

class DeepLinkService {
  static final DeepLinkService _instance = DeepLinkService._internal();
  factory DeepLinkService() => _instance;
  DeepLinkService._internal();

  final MethodChannel _methodChannel =
      const MethodChannel('com.fitconnect.app/deeplink');

  final EventChannel _eventChannel =
      const EventChannel('com.fitconnect.app/deeplink_stream');

  final StreamController<DeepLinkData> _controller =
      StreamController.broadcast();

  bool _initialized = false;

  Stream<DeepLinkData> get deepLinkStream => _controller.stream;

  Future<void> initialize() async {
    if (_initialized) return;
    _initialized = true;

    // Busca deep link inicial (app estava fechado)
    final String? initialUrl =
        await _methodChannel.invokeMethod('getInitialLink');

    if (initialUrl != null) {
      final data = _parseDeepLink(initialUrl);
      if (data != null) _controller.add(data);
    }

    // Stream para links quando app está aberto
    _eventChannel
      .receiveBroadcastStream()
      .listen(
        (url) {
          if (url is String) {
            final data = _parseDeepLink(url);
            if (data != null) _controller.add(data);
          }
        },
        onError: (error) {
          // Log ou tratamento
        },
        cancelOnError: false,
      );
  }

  DeepLinkData? _parseDeepLink(String url) {
    try {
      final uri = Uri.parse(url);

      return DeepLinkData(
        url: url,
        type: _determineType(uri),
        scheme: uri.scheme,
        host: uri.host,
        path: uri.path,
        queryParameters: uri.queryParameters,
        referralCode: uri.queryParameters['referralCode'],
        receivedAt: DateTime.now(),
      );
    } catch (e) {
      return null;
    }
  }

  DeepLinkType _determineType(Uri uri) {
    if (uri.scheme == 'fitconnect') return DeepLinkType.customScheme;
    if (uri.scheme == 'https' && uri.host == 'fitconnect.app') {
      return Platform.isIOS ? DeepLinkType.universalLink : DeepLinkType.appLink;
    }
    return DeepLinkType.unknown;
  }
}
Enter fullscreen mode Exit fullscreen mode

Como esse controller vive durante todo o ciclo de vida do app, não fazemos o close manual — mas em serviços descartáveis isso seria obrigatório.

Esse padrão também facilita testes e evolução futura (ex: analytics, tracking, feature flags baseadas em deep link).

Mas mais importante do que isso -

na prática, o DeepLinkService funciona como uma camada de tradução:

Camada de tradução

Ele isola completamente o restante da aplicação da complexidade de cada plataforma.

Por que StreamController.broadcast()?

Um StreamController padrão só permite um listener. Se o app tiver múltiplas telas ouvindo deep links ao mesmo tempo — ou se o listener for registrado após o evento ser emitido — o evento se perde. O .broadcast() resolve os dois problemas: aceita múltiplos listeners e não lança exceção se nenhum estiver ativo no momento do evento.

A responsabilidade de cada canal

O _methodChannel faz uma chamada pontual — getInitialLink — e retorna uma vez. É o suficiente para o cenário de cold start. Já o _eventChannel permanece aberto como um stream; qualquer link recebido com o app em execução chega por ele. Juntos, cobrem todos os cenários sem sobreposição.

O método _parseDeepLink

Uri.parse decompõe a URL em partes (scheme, host, path, queryParameters) sem nenhuma dependência externa. A partir daí, montamos o DeepLinkData com os campos que o app vai precisar — incluindo o referralCode extraído diretamente dos query parameters. Se a URL for inválida, o método retorna null e o link é ignorado silenciosamente.

Evitando processamento duplicado no Flutter

Dependendo da plataforma, o mesmo deep link pode chegar mais de uma vez (especialmente em cold start).

Uma estratégia simples é manter o último link processado e ignorar duplicados —
principalmente em cenários onde o mesmo evento pode chegar via MethodChannel e EventChannel.


Integrando no ciclo de vida do app

Com o DeepLinkService pronto, a integração acontece no widget raiz do app.

É aqui que conectamos o serviço ao ciclo de vida da aplicação.

// lib/app_widget.dart

class _AppState extends State<App> {
  StreamSubscription? _deepLinkSub;

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

  Future<void> _initDeepLinks() async {
    final service = DeepLinkService();

    // Listener registrado antes de initialize() para não perder
    // o evento de cold start emitido durante a inicialização
    _deepLinkSub = service.deepLinkStream.listen((data) {
      if (data.path == '/signup' && data.referralCode != null) {
        _handleSignupLink(data);
      }

    await service.initialize();
    });
  }

  void _handleSignupLink(DeepLinkData data) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      Navigator.pushNamed(context, '/signup');
    });

    Future.delayed(const Duration(milliseconds: 500), () {
      final controller = context.read<SignupController>();
      controller.setReferralCode(data.referralCode!);
    });
  }

  @override
  void dispose() {
    _deepLinkSub?.cancel();
    super.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

Atenção: a ordem importa

Repare que o listener é registrado antes de chamar initialize(). Isso não é acidente.

O StreamController.broadcast() não bufferiza eventos — se nenhum listener estiver ativo no momento em que o evento é emitido, ele se perde silenciosamente. E o initialize() pode emitir o link inicial (cold start) ainda durante sua execução.

Se você inverter a ordem:

// ❌ errado — o evento de cold start é perdido
await service.initialize(); // evento emitido aqui...
_deepLinkSub = service.deepLinkStream.listen(...); // ...mas o listener só existe aqui
Enter fullscreen mode Exit fullscreen mode

O app abre, o link chega no Android/iOS, mas o Flutter nunca reage. Nenhum erro, nenhum log — o deep link simplesmente some.

O uso de Future.delayed aqui é uma simplificação didática. Em produção, o ideal é desacoplar navegação e estado — via state management (MobX, Bloc, etc.) ou via evento disparado após o build da tela. Isso elimina dependência de timing arbitrário e condições de corrida entre navegação e UI.

O StreamSubscription precisa ser cancelado no dispose para evitar memory leaks. Como o widget raiz raramente é removido da árvore, na prática isso raramente é acionado — mas é uma boa prática que vale manter.


UI da SignupPage

A última peça é a tela de cadastro. O campo de referral code precisa de uma decisão de UX clara: mostrar ao usuário que o dado veio de um link, mas permitir que ele edite se quiser.

// lib/features/signup/signup_page.dart

TextField(
  controller: controller.referralCodeController,
  decoration: InputDecoration(
    labelText: controller.cameFromDeepLink
        ? 'Código de Indicação (via link)'
        : 'Código de Indicação (opcional)',
    suffixIcon: controller.cameFromDeepLink
        ? const Icon(Icons.link, color: Colors.green)
        : null,
  ),
  maxLength: 20,
  enabled: true, // Sempre editável
)
Enter fullscreen mode Exit fullscreen mode

O campo é sempre editável — mesmo quando pré-preenchido por deep link. O label e o ícone mudam para dar contexto ao usuário, mas nunca bloqueamos a edição. Travar o campo criaria fricção desnecessária: e se o usuário cometeu um erro no código de indicação ou quer usar outro?


Fluxo completo

Fluxo completo


A partir daqui, o deep link deixa de ser um detalhe técnico.

Ele passa a ser parte da experiência do usuário.

O app não apenas abre.

Ele entende o contexto.


No próximo post, vamos sair do ambiente de desenvolvimento e ir para produção:

  • App Links funcionando de verdade no Android
  • Universal Links validados no iOS
  • Verificação bidirecional com domínio

É aqui que começam os problemas reais de produção:

  • domínio não verificado corretamente
  • link abrindo no navegador em vez do app
  • comportamento inconsistente entre Android e iOS

E é exatamente isso que vamos resolver.


O que construímos até aqui

Ao final desta etapa, você já tem:

  • O DeepLinkService como ponto único de entrada para deep links no Flutter.
  • Integração com MethodChannel (cold start) e EventChannel (app em execução).
  • Roteamento automático para a SignupPage com o referral code pré-preenchido.
  • A decisão de UX de manter o campo sempre editável, com indicador visual de origem.

Este é o quarto de 9 posts da série. Com Android, iOS e Flutter integrados, o fluxo ponta a ponta já funciona — mas ainda em ambiente de desenvolvimento. No próximo post, vamos colocar isso em produção: configurar App Links no Android e Universal Links no iOS com verificação bidirecional de domínio. Se você já teve dor de cabeça com essa etapa, conta nos comentários — vou usar isso no Post 5.


Código completo disponível no repositório: FitConnect no GitHub


Se esse conteúdo te ajudou, deixa um ❤️ ou um 🔖 aqui no DEV.to — isso ajuda o post a alcançar mais devs.

E você, já implementou deep links no seu app?

Qual foi o maior desafio que encontrou?

Quero usar esses casos reais nos próximos posts da série 👇


Tags: Flutter, Dart, MethodChannel, Deep Links, Mobile Development

Top comments (0)