SwiftUI: Реализация разделенного координатора совместно с DeepLink (Universal link)

e432505e00bd386c05531c904c0ea239.jpeg
Весь цикл
  1. SwiftUI (iOS 15). Есть ли жизнь без NavigationView или пару слов о координаторе

  2. SwiftUI (iOS 16+): Навигация по-новому

  3. Разделяемый координатор в SwiftUI

  4. SwiftUI. Навигация по строке в разделяемом координаторе.

  5. SwiftUI: Реализация разделенного координатора совместно с DeepLink (Universal link)

Навигация при помощи текстовой строки внутри приложения может и не быть самым удобным способом для разработчика, но когда строковое значение приходит из вне приложения — это едва ли не самый надежный вариант переместить пользователя внутрь вложенной системы иерархии экранов. Вместе с тем, довольно часты ситуации, когда вместе с путем навигации передаются еще и параметры, с которыми экран должен быть отображен. Если схема координатора задана через перечисление, то возникает неоднозначность — либо координатор не может быть развернут исходя из названий элементов перечисления, либо, конкретные координаторы будут унаследованы от типа String, но параметры не могут быть переданы как ассоциированные значения.

Использование концепции MVVM порождает еще один философский вопрос: может ли один и тот же экран с одной и той же viewmodel иметь различные типы входных параметров. Конечно, для идеологии чистого кода — ответ однозначен. Но ведь если нет нужды в создании нового вида или новой view model, то подавляющее количество разработчиков предпочтет переиспользовать один и тот же экран и для отображения десериализированного объекта, и для сериализированных параметров, передаваемых строкой в пути навигации.

Предыстория:

Мне казалось, что тема себя исчерпала, но к предыдущей статье получил следующий комментарий:  «вы используете enum НЕ по назначению. Если у вас проблемы уже с двумя уровнями вложенности enum, то что вы будете делать, когда для экрана потребуется передавать еще и входные параметры? Вам нужен какой-то другой тип вроде массива. И надеюсь один из следующих выпусков будет посвящен тому, как разрулить алерты в swiftui.»

Оппонент поднимает три вопроса:

  1. Назначение enum

  2. Передача параметров

  3. Отображение алертов.

Мне кажется, что сам по себе вопрос отображения алертов довольно тривиален, и никоим образом не отличается как для монолитного, так и для разделенного координатора. Быть может именно в связи с передачей / получением входящих параметров возникла озабоченность? Что же касается назначения enum — то, утверждение о том, что он используется не по назначению — неправомерно. Enum определяет константные значения, к которым допустимо прикрепить ассоциированные данные. Но нигде не определяется, как именно в бизнес-логике Enum должен использоваться. По отношению к координатору — enum определяет последовательность шагов пути навигации, но не тип передаваемых данных.

В самом деле, если данные приходят извне, то они всегда будут сериализированные, а это значит, что в приложение они могут попасть в нескольких форматах — строка, JSON объект, XML или в чем-то еще более экзотическом. Поскольку DeepLink и Universal Link соответствуют URI формату, то прикрепленные к линку данные всегда могут быть извлечены как массив URLQueryItem. Но означает ли что всякий раз, когда мы хотим передать данные на экран, который поддерживает перемещение по пути навигации мы должны использовать этот или аналогичный тип данных? Рассуждая на эту тему, рано или поздно приходишь к выводу –, а следует ли ограничиваться каким-то определенным типом?

В общем-то, если абстрагироваться от задачи, то вполне очевидно то, что путь навигации и используемые viewmodel данные — это сущности разных вселенных. И если для каждой из них использовать свой конвейер обработки, то, тем самым снимается ограничение для использования Enum в координаторе.

Поток приложения
Поток приложения

На схеме показано,  что после обработки URI в сервисе DeepLink данные и навигация расходятся разными путями: данные (параметры URI) размещаются в хранилище данных, а путь навигации передается на вход координатора, и там уже обрабатываться в соответствии с определенными в схеме правилами. ViewModel извлекает данные из хранилища, и использует для внутренней бизнес-логики. Таким образом навигация и данные (Model) встречаются в связке Model — View –ViewMdel. (MVVM-борцы могут сделать это где-то в другом месте без особых проблем).

Связь общего и частичного координатора рассмотрены подробно в предыдущей статье. В этой, сосредоточимся на DeepLinkService.

Название DeepLinkService — дань привычки. Для обработки Universal Link тоже используется этот же сервис. Собственно, между ними внутри приложения нет никакой разницы:  universal link, как следует из названия более универсальный, потому что при его активации происходит дополнительная магия (если приложение не установлено, то, вначале, произойдет переход в магазин приложений, а после установки, передача данных внутрь приложения), но этот тип ссылок требует наличие хост –сервера и конфигурирование его определенным образом — работа, которую никогда не выполняет iOS разработчик. Соответственно, все входящие линки будем считать диплинками, вне зависимости от их истинной природы.

Однако же, для использования диплинка нужно провести некоторые манипуляции в конфигурации приложения — добавить схему в раздел Info → URL Types. Это действие приведет к созданию Info.plist файла в дереве проектов. Если Вы предпочитаете хранить этот файл не в корне проекта, то, не забудьте поменять путь к файлу в разделе Build Settings (Секция Packaging). В примере приложения используется схема coordinator:// Это значит, что всякий линк, который начинается с этой схемы и активизируется в iOS с ранее установленным приложением, запустит это самое приложение, и передаст ссылку в качестве входного параметра. (Для статьи не актуально, но, может быть будет полезно знать, что в iOS 13.2 — 14.* наблюдались определенные проблемы при активизации / обработки диплинков, что давало основания полагать, что диплинки были задеприкейчены).

Шаги конфигурирования DeepLink
Шаги конфигурирования DeepLink

В качестве исходного проекта был использован проект из предыдудшей статьи.

С целью демонстрации работы механизма, в DeepLink Service были определены три команды в виде enum унаследованного от String (ну куда ж без него):

  1. Root — перемещает пользователя на главный экран, если приложение запущено.

  2. Product — отображение данных о продукте на розовом экране.

  3. остальное — отображение данных по пути.

Здесь же может располагаться команда, которая открывает специфический алерт в приложении.

Команды координатора и DeepLinkService совпадают частично (сделано намеренно, чтоб подчеркнуть разницу между ними).

Основной координатор был расширен секцией products. В секции определены две команды:

  1. product — розовое окно.

  2. merch — голубое окно.

Как не сложно догадаться, для отображение данных используется один и тот же экран. Цвет задается в зависимости от того, какой элемент выбран в координаторе — product. или merch в качестве конечного экрана пути навигации.

Для того чтоб активировать приложение, необходимо создать HTML страницу с ссылками, которые будут обрабатываться DeepLinkService.

Сами ссылки:

  1. coordinator:///

  2. coordinator://product? id=80674450-AAEC-4704–9D0F-63C7B2068FF1&count=5

  3. coordinator://auth/a/b/c/profile/b/c/products/merch? id=B121D5E7-EA65–49F8–8B33-B3C43FA93866&count=7

Параметры в ссылках определяют модель с двумя полями — id, заданным UUID идентификатором, и количеством — целым числом.

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

import Foundation

struct Product {
    let id: UUID
    let count: Int
    
    static func build(_ id: String, _ count: String) -> Self? {
        guard let id = UUID(uuidString: id),
              let count = Int(count) else {
            return nil
        }

        return Product(id: id, count: count)
    }
}

HTML страница должна быть доступна на устройстве. Если у Вас нет веб-сервера, то скорее всего, Вы захотите как-то протестировать приложение оффлайн. Для этого можно открыть встроенное в симулятор IOS приложение Files, и перетащить со своего Mac html файл прямо в него. 

Пример веб-страницы, открытой в приложении Files на iOS симуляторе.
Пример веб-страницы, открытой в приложении Files на iOS симуляторе.


  
  Deep links test


Select COMMAND for browsing

Copyright (C), DemonSoft Inc., 2025

В конечном итоге мы получим один из трех вариантов реакции при клике на ссылку в файле:

Открытие приложения на главной странице.

Открытие Root ссылки.
Открытие Root ссылки.

Выполнение команды product и открытие розовой страницы.

Открытие Product ссылки
Открытие Product ссылки

Навигация по пути, заданному в ссылке и открытие голубой страницы.

Открытие деталей продукта, с навигацией по пути.
Открытие деталей продукта, с навигацией по пути.

Заметьте, что никаким другим способом нет возможности открыть детали товара в приложении. Т.е. это дает возможность показывать по ссылке то, что при обычном запуске приложения недоступно.

Реализация.

XCode шаблон для реализации SwiftUI iOS приложения предполагает, что все содержимое Вашего UI расположено внутри ContentView (), вызов которого происходит из @main структуры. К ContentView ()добавляем модификатор, содержащий вызов  метода, в экземпляре класса DeepLinkService.

import SwiftUI

@main
struct DeepLinkCoordinatorApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    DeepLinkService().handle(url: url)
                }
        }
    }
}

Идея работы DeepLinkService сводится к тому, чтоб анализируя хост URL выбрать стратегию выполнения команды. Все команды DeepLinkService имеют единый интерфейс, объявленный в протоколе IDeepLinkRouter — команда принимает на вход URL и коллекцию элементов, из которых это URL состоит.

Получив значение хоста, мы выбираем объект команды, которому скармливаем полученные ранее значения. Если URL не удается разбить на компоненты, то мы извлекаем из него путь, и отдаем обработчику координатора, на случай, если координатор знает что с этим делать. Если координатор не сможет обработать путь — то, собственно, ничего не произойдет.

Другим граничным условием здесь будет ситуация, когда хост не может указать на определенную команду, и в этом случае сработает default условие — создастся роут (экземпляр класса PathDLRoute), в который передается парсинг входящей URL.

import Foundation

protocol IDeeplinkRouter  {
    func handle(_ components: NSURLComponents, _ url: URL)
}

class DeepLinkService {
    
    private enum Host : String {
        case root       = ""
        case product
    }

    func handle(url: URL) {
        let cmd = url.host() ?? ""
        let host = Host(rawValue: cmd)

        guard
            let components = NSURLComponents(url: url, resolvingAgainstBaseURL: true) else {
            Coordinator.routePath(url.path())
            return
        }
        
        var route: IDeeplinkRouter
        switch host {
            case .root        : route = RootDLRoute()
            case .product     : route = ProductDLRoute()
            default           : route = PathDLRoute()
        }

        route.handle(components, url)
    }
}

PathDLRoute — фактически, это то, ради чего была написана статья — здесь мы выполняем следующие действия:

  1. Получаем параметры URL

  2. Размещаем их в хранилище в виде стандартного словаря.

  3. Создаем полный путь навигации

  4. Передаем путь навигации координатору.

import Foundation

typealias ProductDict = [String: String]
struct PathDLRoute: IDeeplinkRouter {
    func handle(_ components: NSURLComponents,  _ url: URL) {
        guard let host = url.host() else { return }
        let query = components.queryItems ?? [URLQueryItem]()

        guard let id    = query.first(where: { $0.name == "id" })?.value,
              let count = query.first(where: { $0.name == "count" })?.value else {
            return
        }
        let dict: ProductDict = ["ID":id, "COUNT": count]        
        Storage.arrange(dict)
        
        // 1. всегда начинаем с корня.
        // 2. host - первый сегмент.
        let path = "/\(host)\(url.path())"
        Coordinator.routePath(path)
    }
}

Сравните реализацию с объектом ProductDLRoute. Здесь выполняются те же самые действия, но вместо того чтоб создавать словарь по параметрам, мы десериализируем объект Product, и именно его размещаем в хранилище.

import Foundation

struct ProductDLRoute: IDeeplinkRouter {
    func handle(_ components: NSURLComponents, _ url: URL) {
        guard let host = url.host() else { return }
        let query = components.queryItems ?? [URLQueryItem]()
        
        guard let id    = query.first(where: { $0.name == "id" })?.value,
              let count = query.first(where: { $0.name == "count" })?.value else {
            return
        }
        
        guard let product = Product.build(id, count) else { return }
        Storage.arrange(product)

        // Нам нужно указать какой координатор использовать.
        Coordinator.routePath("/products/\(host)")
    }
}

Т.е. важным момент для понимания идеи хранилища заключается в том, что мы можем в него передать буквально все что угодно, чтоб потом извлечь в нужном нам месте. Следовательно, нам не нужно заботится о том, какого типа должны быть данные в конечной точки путешествия по пути навигации — ViewModel конечного экрана знает, какой тип данных должен быть извлечен для того, чтоб отобразить эти данные на конечном экране.

Хранилище представлено классом StorageModel. В классе содержится обобщенный метод, который помещает что угодно в словарь, а так же обобщенный метод извлекающий что угодно из словаря. Что именно будет извлечено — определяется ключом, который представляет из себя строку с названием типа данных, который предполагается быть полученным на выходе. Если такого объекта в словаре нет, то вернется nil.

import Foundation

let Storage = StorageModel()

class StorageModel {
    
    private var models: [String: Any] = [:]
    
    func arrange(_ model:T) {
        let key = self.typeName(model.self)
        self.models[key] = model
    }
    
    func extract() -> T? {
        
        let key = self.typeName(T.self)
        return self.models[key]  as? T
    }

    func clear() {
        self.models.removeAll()
    }
    
    // MARK: - Private
    private func typeName(_ some: Any) -> String {
        return (some is Any.Type) ? "\(some)" : "\(type(of: some))"
    }

}

Если предположить, что каждый экран в пути навигации содержит объект уникального типа, то можно легко реализовать, чтоб при разворачивании всего стека навигации каждый экран обновлялся присущим ему содержимым. И все это содержимое может быть передано в URL строке, если, конечно, хватит длины. Напомню, что формально длина URL / URI ничем не ограничена, за исключением Interrnet Explorer, где URL может быть не больше 2048 символов. Но кто, в здравом уме, будет использовать Interrnet Explorer на iOS?

Для удобства обслуживания хранилища был так же добавлен метод. clear (), который сбрасывает содержимое словаря. Но он не является обязательным, хоть и используется в тестовом приложении.

В момент, когда создается объек отображаемого экрана, создается и связанная с ним ViewModel. ViewModel — не является обязательным условием, но весьма полезным. MVVM-борцам предстоит самим разобраться как обслуживать View. Мы же пойдем традиционным путем.

import Combine
import SwiftUI

class ProductVM : ObservableObject {
    
    // MARK: - Types
    
    // MARK: - Publishers
    @Published var id = UUID()
    @Published var count = 0
    
    // MARK: - Public properties
    
    // MARK: - Private properties
    
    // MARK: - Init
    init() {
        self.subscriptions()
    }
    
    deinit {
        Storage.clear()
    }
    
    // MARK: - Public methods

    // MARK: - Private methods
    private func subscriptions() {
        if let product: Product = Storage.extract() {
            self.assign(product: product)
            return
        }

        guard let dict: ProductDict = Storage.extract(),
              let id = dict["ID"],
              let count = dict["COUNT"] else { return }
        let product = Product.build(id, count)
        self.assign(product: product)
    }

    private func assign(product: Product?) {
        guard let product = product else { return }
        self.id    = product.id
        self.count = product.count
    }
    
}

Тестовое приложение исходит из того, что при нажатии на ссылку в iOS бразуере, приложение будет запущено «с нуля», а значит, никаких данных кроме передаваемых по ссылке в хранилище не содержится. Соответственно, ViewModel вначале пытается извлечь объект Product, а если это невозможно, то извлекает словарь.

Дисклеймер

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

Здесь же содержится деструктор, который очищает наше хранилище, после возврата с конечного экрана через «back» или любой другой способ (например, нажатие на кнопку «Root».

На данном этапе, мы полностью прошли южным потоком приложения. Северный поток (см. схему) представляет собой вызов координатора. Принципиально он не отличается ни чем, от того, который был описан в предыдущей статье, хотя содержит несколько нюансов.

import SwiftUI

enum CoordinatorProducts: String {
    case product
    case merch
    
    var view: some View {
        Group {
            switch self {
                case .product   : ProductView(.red)
                case .merch     : ProductView(.blue)
            }
        }
    }
}
  1. Был добавлен выделенный координатор, который работает с экранами продукта. В этом координаторе содержится два элемента, каждый из которых вызывает одно и то же вью, но с разными параметрам цвета. Теоретически, если при вызове ссылок с различными параметрами полученные объекты сохраняются в персестивном хранилище (CoreData / SwiftData или чем-то подобном), то сюда же можно добавить элемент, который отобразит экран с перечнем продуктов активированных по ссылке (продуктовую корзину). Масштабы ограничены только фантазиями разработчика (а у его коллег, особенно дизайнеров, фантазия еще больше).

  2. Корневой координатор был расширен с учетом использования нового типа выделенного координатора. Конечно, хотелось бы сделать данный класс неизменяемым, но, если в каждом выделенном координаторе может содержаться сотни экранов, то цена изменений данного класса совершенно незначительная.

  3. Метод setPath был приватизирован, а вместо него был введен интерфейсный метод. routePath с тем же самым строковым параметром. Причина в том, что объект роута использоваться и даже передаваться вне главного потока. Соответственно, необходимо было обезопасить код, от вызова процесса разворачивания стека навигации вне главного потока.

  4. Нюанс вытекающий из предыдущего. Так как отображение экрана при помощи метода next () происходило внутри своей асинхронной задачи, то с некоторой долей вероятности могла возникнуть ситуация, когда для длинного пути экраны будут созданы в случайной последовательности. Чтоб это избежать, внутрь класса была добавлена временная приватная переменная collector,   которая содержит путь навигации (точный аналог переменной path). Она накапливает в себе элементы пути, а затем одномоментно заменяет содержимое path. Это позволяет стеку навигации отработать однократно, вместо того, чтоб обновлять содержимое на каждом шаге. Решение указанной проблемы является хорошей причиной в пользу отказа от рекурсивной обработки, но рекурсия оставлена в примере как есть, чтоб структура обработки строкового пути была аналогичной.

import SwiftUI

let Coordinator = CoordinatorService()

class CoordinatorService: ObservableObject {
    

    enum Step: Hashable {
        enum Segment: String {
            case auth
            case profile
            case products // +
        }

        case auth(_ val: CoordinatorAuth)
        case profile(_ val: CoordinatorProfile)
        case products(_ val: CoordinatorProducts) // +

        var screen: (String, String) {
            switch self {
                case .auth(let screen): return (Segment.auth.rawValue, screen.rawValue)
                case .profile(let screen): return (Segment.profile.rawValue, screen.rawValue)
                case .products(let screen): return (Segment.products.rawValue, screen.rawValue) // +
            }
        }

        var view: some View {
            Group {
                switch self {
                    case .auth(let value): value.view
                    case .profile(let value): value.view
                    case .products(let value): value.view // +
                }
            }
        }
    }
    

    @Published var path: [Step] = []
    
    private var currentSegment: Step.Segment?
    @Published var collector: [Step] = []

    func next(_ step: Step) {
        Task {
            await MainActor.run {
                withAnimation {
                    self.path += [step]
                }
            }
        }
    }
    
    func root() {
        Task {
            await MainActor.run {
                withAnimation {
                    self.path = []
                }
            }
        }
    }
    
    func routePath(_ path: String) {
        Task { // Важный момент
            await MainActor.run {
                self.setPath(path)
            }
        }
    }

    func buildPath() -> String {
        var fullPath = ""
        var segment = ""
        
        for step in self.path {
            
            let (seg, screen) = step.screen
            
            if segment != seg {
                segment = seg
                fullPath += "/\(seg)"
            }

            fullPath += "/\(screen)"
        }
        
        return fullPath
    }

    // MARK: - Private
    private func setPath(_ path: String) {
        self.currentSegment = nil
        self.collector = []
        let array = path.components(separatedBy: "/")
        self.handlePath(array)
    }

    private func handlePath(_ array:[String]) {
        guard array.count > 0 else {
            self.flush() // +
            return
        }
        var array = array
        let step = array.removeFirst()
        self.handleStep(step)
        self.handlePath(array) // Recursive call
    }
    
    
    private func handleStep(_ step: String) {
        switch step {
            case "": self.root()                 // if path starts with '/'
            case Step.Segment.auth.rawValue,     // if path has coordinator
            Step.Segment.profile.rawValue,
            Step.Segment.products.rawValue:      // +
            self.currentSegment = Step.Segment(rawValue: step)

            default: self.handleCoordinator(step)
        }
    }

    private func handleCoordinator(_ step: String) {
        
        guard let segment = self.currentSegment else { return }

        switch segment {
            case .auth: 
                if let c = CoordinatorAuth(rawValue: step) {
                    self.collect(.auth(c)) // +
                }
            
            case .profile:
                if let c = CoordinatorProfile(rawValue: step) {
                    self.collect(.profile(c)) // +
                }
            
            case .products: // +
                if let c = CoordinatorProducts(rawValue: step) {
                    self.collect(.products(c)) // +
                }
            }
    }
    private func collect(_ step: Step) { // +
        self.collector += [step]
    }
    
    private func flush() { // +
        Task {
            await MainActor.run {
                withAnimation {
                    self.path = self.collector
                    self.collector = []
                }
            }
        }

    }
}

Заключение:  

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

Репозитарии на GitHub содержащие примеры остальных статей цикла были реорганизованы. Теперь все примеры доступны в одном репозитарии. Угощайтесь!

Как и прежде, обсудить подход можно в телеграм чате.

Habrahabr.ru прочитано 5941 раз