Deeplink в iOS — это ссылка, которая открывает конкретный экран внутри приложения, а не просто запускает его. Нажал на баннер — попал сразу на акционный товар. Перешёл по письму — оказался в нужном разделе профиля. Звучит просто, но под капотом — несколько механизмов, у каждого свои плюсы и ограничения.

Два способа сделать deeplink

В iOS исторически сложилось два подхода: URL-схемы и универсальные ссылки. Они работают по-разному, и выбор между ними зависит от задачи.

URL-схемы: быстро, но с оговорками

URL-схема — это кастомный протокол вида myapp://product/123. Вы регистрируете схему в Info.plist, и iOS знает: если кто-то открывает ссылку с таким префиксом — запускать ваше приложение.

Настроить просто. В Info.plist добавляете:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

После этого ссылка myapp://product/123 будет открывать приложение. Обрабатывать переход нужно в SceneDelegate:

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let url = URLContexts.first?.url else { return }
    handleDeeplink(url: url)
}

Или в AppDelegate, если scene lifecycle не используется:

func application(_ app: UIApplication, open url: URL,
                 options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    handleDeeplink(url: url)
    return true
}

Проблема URL-схем в том, что их никто не контролирует. Любое другое приложение может зарегистрировать ту же схему. Если два приложения претендуют на myapp://, iOS выберет одно из них — и не факт, что ваше. Это создаёт риски: в теории злоумышленник может перехватить переход и получить данные из ссылки.

Ещё один минус: если приложение не установлено, ссылка просто не откроется. Никакого фолбэка на сайт — пользователь увидит ошибку или ничего не произойдёт.

Универсальные ссылки: надёжно и безопасно

Universal Links появились в iOS 9. Идея в том, что deeplink — это обычный HTTPS-адрес вашего сайта. Например, https://example.com/product/123. iOS проверяет, установлено ли приложение, которое заявило права на этот домен. Если да — открывает приложение. Если нет — открывает сайт в браузере.

Это решает сразу две проблемы: нет конфликтов схем и есть фолбэк для пользователей без приложения.

Как настроить Universal Links

Настройка состоит из двух частей: серверной и клиентской.

На сервере

Нужно разместить файл apple-app-site-association по адресу:

https://example.com/.well-known/apple-app-site-association

Файл без расширения, в формате JSON:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.example.myapp"],
        "components": [
          { "/": "/product/*" },
          { "/": "/profile/*" },
          { "/": "/order/*" }
        ]
      }
    ]
  }
}

appIDs — это Team ID из Apple Developer Portal плюс Bundle Identifier. components — маски URL, которые будут открывать приложение. Формат с components появился в iOS 14 и даёт более гибкую настройку, чем старый paths.

Важно: файл должен отдаваться с Content-Type: application/json, без редиректов, по HTTPS. Apple проверяет его при установке приложения и периодически в фоне.

В приложении

В Xcode в настройках таргета нужно включить Associated Domains и добавить:

applinks:example.com

Обработка перехода — в SceneDelegate:

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return }
    handleDeeplink(url: url)
}

Или в AppDelegate:

func application(_ application: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard let url = userActivity.webpageURL else { return false }
    handleDeeplink(url: url)
    return true
}

Обработка переходов: как не превратить это в лапшу

Разобрать URL несложно, но с ростом приложения количество маршрутов растёт, и логика быстро превращается в месиво из if-else.

Простой разбор URL

Для небольшого приложения достаточно парсить компоненты вручную:

func handleDeeplink(url: URL) {
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }

    let path = components.path
    let queryItems = components.queryItems ?? []

    switch path {
    case _ where path.hasPrefix("/product/"):
        let productId = String(path.dropFirst("/product/".count))
        navigateToProduct(id: productId)

    case "/profile":
        let tab = queryItems.first(where: { $0.name == "tab" })?.value
        navigateToProfile(tab: tab)

    default:
        break
    }
}

Роутер для больших приложений

Когда маршрутов становится много, имеет смысл вынести логику в отдельный роутер:

enum DeeplinkRoute {
    case product(id: String)
    case profile(tab: String?)
    case order(id: String)
    case unknown
}

struct DeeplinkParser {
    func parse(url: URL) -> DeeplinkRoute {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return .unknown
        }

        let path = components.path
        let query = components.queryItems ?? []

        if path.hasPrefix("/product/") {
            return .product(id: String(path.dropFirst("/product/".count)))
        }
        if path == "/profile" {
            return .profile(tab: query.first(where: { $0.name == "tab" })?.value)
        }
        if path.hasPrefix("/order/") {
            return .order(id: String(path.dropFirst("/order/".count)))
        }
        return .unknown
    }
}

class DeeplinkRouter {
    private let parser = DeeplinkParser()

    func handle(url: URL) {
        switch parser.parse(url: url) {
        case .product(let id):
            navigateToProduct(id: id)
        case .profile(let tab):
            navigateToProfile(tab: tab)
        case .order(let id):
            navigateToOrder(id: id)
        case .unknown:
            break
        }
    }
}

Такой подход держит логику в одном месте и упрощает тестирование — парсер можно покрыть юнит-тестами независимо от UI.

Deeplink при холодном старте

Частая ошибка — не учитывать, что приложение могут открыть через deeplink, когда оно не было запущено. В этом случае нужно дождаться, пока приложение инициализируется, и только потом выполнить навигацию.

Если попытаться сразу показать экран товара, пока ещё не загрузился стек навигации — получите краш или пустой экран.

Простое решение: хранить ожидающий deeplink и обрабатывать его после завершения инициализации:

class DeeplinkRouter {
    private var pendingURL: URL?
    private var appIsReady = false

    func handle(url: URL) {
        if appIsReady {
            navigate(to: url)
        } else {
            pendingURL = url
        }
    }

    func appDidBecomeReady() {
        appIsReady = true
        if let url = pendingURL {
            navigate(to: url)
            pendingURL = nil
        }
    }
}

URL-схемы vs Universal Links — что выбрать

Если кратко:

URL-схемы подходят для межприложного взаимодействия, когда оба приложения ваши. Например, корпоративный набор приложений общается между собой. Настраивается за несколько минут, не требует сервера.

Universal Links — стандарт для любого публичного deeplink. Надёжнее, безопаснее, есть фолбэк на сайт. Требует настройки сервера, но это разовая работа.

На практике часто делают и то, и другое: Universal Links — для внешних переходов из писем, мессенджеров и браузера; URL-схемы — для переходов внутри экосистемы своих приложений.

Частые грабли

AASA не обновляется сразу. Apple кэширует файл. При изменении на сервере обновление доходит не сразу — иногда нужно подождать несколько часов или переустановить приложение.

Переход не срабатывает из WKWebView. Внутри WKWebView Universal Links по умолчанию не работают — Apple сделала это намеренно, чтобы не ломать веб-навигацию. Нужно перехватывать навигацию через WKNavigationDelegate и обрабатывать вручную.

Домен с www и без. Если сайт доступен на example.com и www.example.com — нужно настроить Associated Domains для обоих: applinks:example.com и applinks:www.example.com.

Параметры теряются. При обработке ссылки легко забыть про query-параметры или неправильно их декодировать. URLComponents справляется с этим лучше, чем ручная обработка строк.

Нет тестирования deeplinks. Проверить deeplink в симуляторе можно через Terminal:

xcrun simctl openurl booted "https://example.com/product/123"

Добавьте проверку deeplinks в регрессионное тестирование — иначе что-то обязательно сломается в самый неподходящий момент.

Deeplink в push-уведомлениях

Deeplinks часто используют в паре с push-уведомлениями. В пейлоаде уведомления передают URL или идентификатор маршрута:

{
  "aps": {
    "alert": "Ваш заказ готов"
  },
  "deeplink": "https://example.com/order/456"
}

При нажатии на уведомление обрабатываете пейлоад в UNUserNotificationCenterDelegate и передаёте URL в роутер. Логика та же, что и для обычных переходов.


Deeplinks — одна из тех вещей, которые кажутся простыми, пока не начинаешь разбираться в деталях. Правильно настроенные, они заметно улучшают пользовательский опыт: меньше шагов до нужного контента, плавные переходы из рекламы и писем, удобное межприложное взаимодействие.

Если разрабатываете мобильное приложение в связке с сайтом — имеет смысл сразу закладывать поддержку Universal Links. В REEXY помогаем настроить и серверную часть, и клиентскую логику: от AASA-файла до роутера в приложении. Подробности и цены на разработку — на сайте r3xy.ru.