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;
}
}
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:
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();
}
}
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
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.delayedaqui é 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
)
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
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
DeepLinkServicecomo ponto único de entrada para deep links no Flutter. - Integração com
MethodChannel(cold start) eEventChannel(app em execução). - Roteamento automático para a
SignupPagecom 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)