Отложенный deep linking после Firebase Dynamic Links: App Clips + Play Install Referrer

Пользователь нажимает на вашу ссылку — общий товар, приглашение, сброс пароля — а приложение ещё не установлено. Он попадает в App Store или Google Play, устанавливает приложение, открывает его и… оказывается на обычном главном экране. Контекст того, на что он нажал, потерян. Именно этот сломанный момент призван исправить отложенный deep linking (deferred deep linking), и долгие годы большинство команд решало это через Firebase Dynamic Links. Этого варианта больше нет.
В статье разбираем, как мы построили систему отложенного deep linking, которая переживает установку — и при необходимости умеет дождаться бизнес-события вроде логина, прежде чем выполнить переход, — используя iOS App Clips и Google Play Install Referrer API вместо стороннего сервиса.
Ключевые выводы
- Отложенный deep linking сохраняет назначение ссылки через установку приложения, чтобы первый запуск открывал нужный экран, а не главный.
- Firebase Dynamic Links объявлен устаревшим и отключён 25 августа 2025 года — самое распространённое решение исчезло, и существующие ссылки больше не работают.
- Популярные самодельные обходные пути — фингерпринтинг устройства и сопоставление через буфер обмена — ненадёжны, уязвимы для приватности и не одобряются Apple.
- На iOS лёгкий App Clip перехватывает ссылку ещё до того, как существует полное приложение, и передаёт её через общий контейнер App Group после установки.
- На Android официальный Play Install Referrer API детерминированно доставляет полезную нагрузку ссылки при первом запуске.
- Очередь отложенных ссылок позволяет отложить переход до тех пор, пока не завершится установка и не наступит опциональное бизнес-событие (например, успешный логин).
Что такое отложенный deep linking?
Deep link открывает конкретный экран внутри приложения — например myapp://product/42. Deep link работает только если приложение уже установлено. Отложенный deep linking снимает это условие: когда приложение не установлено, назначение запоминается на протяжении визита в стор и установки, и приложение переходит к нему при первом запуске.
Самое сложное — это разрыв. Между нажатием и первым запуском операционная система устанавливает свежее приложение, у которого нет памяти о том, на что нажал пользователь. Пронести полезную нагрузку через этот разрыв — надёжно и без вторжения в приватность — и есть вся задача.
Стандартного решения больше нет: Firebase Dynamic Links
Большую часть последнего десятилетия ответом по умолчанию был Firebase Dynamic Links. Он брал на себя редирект в стор, отложенную полезную нагрузку и разрешение при первом запуске для обеих платформ за одним SDK.
Google объявил Dynamic Links устаревшим и полностью отключил сервис 25 августа 2025 года. Ссылки перестали разрешаться, и готовой замены от Google нет. Платные платформы атрибуции вроде Branch и AppsFlyer закрывают эту нишу коммерчески, но многие команды хотят владеть процессом без счёта за каждое событие и без отправки кликов пользователей третьей стороне.
Firebase Dynamic Links больше не вариант
Если ваш отложенный deep linking всё ещё зависит от Firebase Dynamic Links, он перестал работать 25 августа 2025 года. Переход на Universal Links и Android App Links закрывает случай установленного приложения — но не отложенный случай (когда приложение ещё не установлено). Именно этот пробел и закрывает наш подход.
Распространённые самодельные обходные пути — и их подводные камни
Когда Dynamic Links отпадает, большинство руководств хватается за один из двух самодельных приёмов. Их стоит понимать именно потому, что они хрупкие.
Фингерпринтинг устройства. Веб-страница записывает «отпечаток» (IP-адрес, размер экрана, версия ОС, локаль) в момент нажатия. При первом запуске приложение отправляет собственный отпечаток на сервер, который пытается сопоставить эти два в коротком окне. Подводные камни серьёзны: общие/операторские IP вызывают несовпадения, окно сопоставления короткое, точность падает в нагруженных сетях, а вероятностное сопоставление пользователей — ровно тот паттерн, против которого направлены правила приватности Apple.
Буфер обмена. Веб-страница копирует полезную нагрузку в буфер обмена; приложение читает её при запуске. Начиная с iOS 14 это вызывает видимый баннер «вставлено из Safari», пользователь может очистить буфер, а любое копирование в промежутке уничтожает полезную нагрузку.
| Подход | Платформа | Надёжность | Приватность | Статус |
|---|---|---|---|---|
| Firebase Dynamic Links | iOS + Android | Высокая | Приемлемая | Отключён в авг. 2025 |
| Фингерпринтинг устройства | iOS + Android | Низкая–средняя | Плохая | Не рекомендуется |
| Буфер обмена | iOS | Средняя | Показывает баннер вставки | Хрупкий |
| App Clip + App Group (наш) | iOS | Высокая | Хорошая | Рекомендуется |
| Play Install Referrer API | Android | Высокая | Хорошая | Официальный |
Наш подход: App Clips на iOS, Install Referrer на Android
Вместо того чтобы угадывать, кто пользователь, уже после установки, мы перехватываем ссылку детерминированно на каждой платформе первородным механизмом, а затем сходимся к единому шагу разрешения внутри приложения.
iOS: перехват ссылки через App Clip
App Clip — это крошечная часть вашего приложения (до 15 МБ), которая почти мгновенно запускается по ссылке, App Clip Code или QR-коду — без полной установки. Это свойство нам и нужно: App Clip выполняется до того, как существует полное приложение, поэтому он видит исходную ссылку.
Поток:
- Ссылка открывает App Clip. iOS доставляет URL вызова через
NSUserActivity. - App Clip записывает этот URL (и метку времени) в общий контейнер App Group, который может читать и полное приложение.
- App Clip показывает оверлей App Store с предложением установить полное приложение.
- После установки полное приложение читает отложенную ссылку из того же контейнера App Group при первом запуске.
// App Clip — перехват URL вызова в общий App Group
func scene(_ scene: UIScene, continue activity: NSUserActivity) {
guard activity.activityType == NSUserActivityTypeBrowsingWeb,
let url = activity.webpageURL else { return }
let shared = UserDefaults(suiteName: "group.pro.nerdy.deeplink")
shared?.set(url.absoluteString, forKey: "pendingDeepLink")
shared?.set(Date(), forKey: "pendingDeepLinkAt")
}
// Полное приложение — читаем один раз, при первом запуске после установки
let shared = UserDefaults(suiteName: "group.pro.nerdy.deeplink")
if let link = shared?.string(forKey: "pendingDeepLink") {
DeepLinkQueue.shared.enqueue(link)
shared?.removeObject(forKey: "pendingDeepLink")
}
Поскольку App Clip и полное приложение используют общий App Group, полезная нагрузка передаётся точно — без сопоставления отпечатков, без баннера буфера обмена, без вероятностных догадок.
Android: Google Play Install Referrer API
У Android есть чистый официальный ответ: Play Install Referrer API. Прикрепите свою полезную нагрузку к URL Play Store как параметр referrer, и Google доставит эту строку приложению при первом запуске.
https://play.google.com/store/apps/details?id=pro.nerdy.app&referrer=deeplink%3D%2Fproduct%2F42
val client = InstallReferrerClient.newBuilder(context).build()
client.startConnection(object : InstallReferrerStateListener {
override fun onInstallReferrerSetupFinished(responseCode: Int) {
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
val referrer = client.installReferrer.installReferrer // "deeplink=/product/42"
DeepLinkQueue.enqueue(referrer)
client.endConnection()
}
}
override fun onInstallReferrerServiceDisconnected() {}
})
Это детерминированно — строка referrer переживает визит в стор и установку нетронутой, без серверного сопоставления.
Сходимся в одном месте — и ждём логина
Обе платформы теперь питают одну и ту же внутреннюю очередь отложенных ссылок. В нашей разработке приложений на Flutter мы делаем это небольшим platform channel, который выносит нативную полезную нагрузку в Dart, а затем единый резолвер решает, когда действовать.
«Когда» здесь важно. Deep link на аутентифицированный экран (/orders/42) упадёт при холодном старте, если пользователь ещё не залогинен. Поэтому резолвер не переходит сразу — он удерживает назначение до тех пор, пока приложение не будет готово и не наступит требуемое бизнес-событие.
class DeepLinkQueue {
Uri? _pending;
bool _authReady = false;
void enqueue(Uri link) { _pending = link; _tryResolve(); }
// Вызывается бизнес-слоем, например после успешного логина
void onEvent(AppEvent event) {
if (event == AppEvent.loggedIn) _authReady = true;
_tryResolve();
}
void _tryResolve() {
final link = _pending;
if (link == null || !_authReady) return; // ждём обоих
_pending = null;
router.go(link.path); // безопасно: приложение установлено И пользователь залогинен
}
}
Результат — отложенный deep link, устойчивый к двум сценариям отказа, которые ломают большинство реализаций: приложение не установлено и целевой экран не готов.
Когда этот подход подходит
Этот паттерн хорош, когда вы контролируете и источник ссылки, и приложение, хотите первородной надёжности и заботитесь о приватности или стоимости вендора. Если нужен только случай установленного приложения, достаточно обычных Universal Links и App Links. Если нужна кросс-канальная маркетинговая атрибуция с дашбордами, платная платформа вроде Branch может оправдать затраты. Но для продуктовых сценариев — приглашения, общий контент, онбординг, сброс пароля — App Clips плюс Install Referrer API дают детерминированный отложенный deep link, которым вы полностью владеете.
Если вы мигрируете с Firebase Dynamic Links или строите это с нуля, мы поможем вам это запустить.
Часто задаваемые вопросы
Отложенный deep linking сохраняет цель ссылки через установку приложения. Когда пользователь нажимает ссылку без установленного приложения, назначение запоминается, пользователь устанавливает приложение, и при первом запуске приложение переходит к исходной цели вместо обычного главного экрана.
