В этой статье я хочу поделиться опытом который мы успешно используем уже несколько лет в наших iOS приложениях, 3 из которых в данный момент находятся в Appstore. Данный подход хорошо зарекомендовал себя и недавно мы сегрегировали его от остального кода и оформили в отдельную библиотеку RouteComposer о которой собственно и пойдет речь.


Но, для начала, давайте попробуем разобраться что подразумевается под композицией вью контроллеров в iOS.


Прежде чем переходить собственно к пояснениям, я напомню, что в iOS чаще всего подразумевается под вью контролером или UIViewController. Это класс унаследованный от стандартного UIViewController, который является базовым контроллером паттерна MVC, который Apple рекомендует использовать для разработки iOS приложений.


Можно использовать альтернативные архитектурные паттерны такие как MVVM, VIP, VIPER, но и в них UIViewController будет задействован так или иначе, а, значит, данная библиотека может использоваться вместе с ними. Cущность UIViewController используется для контроля UIView, которая чаще всего представляет собой экран или значительную часть экрана, обработки событий от него и отображения в нем неких данных.



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


Стандартными контейнер вью контроллерами поставляемыми с Cocoa Touch можно считать: UINavigationConroller, UITabBarController, UISplitController, UIPageController и некоторые другие. Также, пользователь может создавать свои кастомные контейнер вью контролеры следуя правилам Cocoa Touch описаным в документации Apple.


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


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


Давайте рассмотрим для начала композицию некоторых стандартных контейнер вью контролеров для примера:


Примеры композиции в стандартных контейнерах


UINavigationController:



let tableViewController = UITableViewController(style: .plain)
// Вставка первого вью контролера в контролер навигации
let navigationController = UINavigationController(rootViewController: tableViewController)
// ...
// Вставка второго вью контролера в контролер навигации
let detailViewController = UIViewController(nibName: "DetailViewController", bundle: nil)
navigationController.pushViewController(detailViewController, animated: true)
// ...
// Вернуться к первому контроллеру
navigationController.popToRootViewController(animated: true)

UITabBarController



let firstViewController = UITableViewController(style: .plain)
let secondViewController = UIViewController()
// Создание контейнера
let tabBarController = UITabBarController()
// Вставка двух вью контролеров в таб бар контролер
tabBarController.viewControllers = [firstViewController, secondViewController]
// Один из программных способов переключения видимого контролера
tabBarController.selectedViewController = secondViewController

UISplitViewController



let firstViewController = UITableViewController(style: .plain)
let secondViewController = UIViewController()
// Создание контейнера
let splitViewController = UISplitViewController()
// Вставка первого вью контролера в сплит контролер
splitViewController.viewControllers = [firstViewController]
// Вставка второго вью контролера в сплит контролер
splitViewController.showDetailViewController(secondViewController, sender: nil)

Примеры интеграции (композиции) вью котроллеров в стек


Установка вью контролера рутом


let window: UIWindow = //...
window.rootViewController = viewController
window.makeKeyAndVisible()

Модальная презентация вью контролера


window.rootViewController.present(splitViewController, animated: animated, completion: nil)

Почему мы решили создать библиотеку для композиции


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


Всему этому добавляют головной боли различные способы дип-линкинга в приложение (например с использованием Universal links), так как вам придется ответить на вопрос: а что если контролер которой нужно показать пользователю так как он кликнул на ссылку в сафари уже показан, или вью контроллер который должен его показать еще не создан, вынуждая вас ходить по дереву вью контролеров и писать код от которого иной раз начинают кровоточить глаза и который любой iOS девелопер старается спрятать. Кроме того, в отличие от Android архитектуры где каждый экран строится отдельно, в iOS чтобы показать какую то часть приложения сразу после запуска может потребоваться построить достаточно большой стек вью контроллеров который будут скрыты под тем который вы показываете по запросу.


Было бы здорово просто вызывать методы вроде goToAccount(), goToMenu() или goToProduct(withId: "012345") по нажатии пользователем на некоторую кнопку или по получении приложением универсальной ссылки из другого приложения и не задумываться о интеграции данного вью контроллера в стек, зная что создатель этого вью контроллера уже предоставил эту реализацию.


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



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


Остается добавить, что это все умножится на N как только ваша маркетинговая команда изъявит желание провести A/B тестирование на живых пользователях и проверить какой способ навигации работает лучше, например, таб бар или гамбургер меню?


  • Давайте отрежем Сусанину ноги Давайте показывать 50% пользователей Таб Бар, а другим Гамбургер меню и через месяц мы вам скажем какие пользователи видят больше наших специальных предложений?

Я попробую рассказать вам как мы подошли к решению этой проблемы и в конце концов выделили его в библиотеку RouteComposer.


Сусанин Route Composer


Проанализировав все сценарии композиции и навигации мы попытались абстрагировать приведенный в примерах выше код и выделили 3 основных сущности из которых состоит и которыми оперирует библиотека RouteComposerFactory, Finder, Action. Кроме того, в библиотеке присутствуют 3 вспомогательные сущности которые отвечают за небольшой тюнинг который может потребоваться в процессе навигации — RoutingInterceptor, ContextTask, PostRoutingTask. Все эти сущности должны быть сконфигурированы в цепь зависимостей и переданы Routerу — объекту, который и будет строить ваш стек вью контролеров.


Но, о каждой из них по порядку:


Factory


Как и следует из названия Factory отвечает за создание вью контролера.


public protocol Factory {

    associatedtype ViewController: UIViewController

    associatedtype Context

    var action: Action { get }

    func prepare(with context: Context) throws

    func build(with context: Context) throws -> ViewController

}

Здесь же важно оговориться о понятии контекста. Контекстом в рамках библиотеки мы называем все что может понадобиться вью контролеру для того что бы быть созданным. Например, для того что бы показать вью контроллер отображающий детали продукта — необходимо в него передать некий productID например в виде String. Сущностью контекста может быть все что угодно: объект, структура, блок или тупл(tuple). Если же вашему контроллеру ничего не нужно для того что бы быть созданным — контекст можно указать как Any? и устанавливать в nil.


Например:


class ProductViewControllerFactory: Factory {

    let action: Action

    init(action: Action) {
        self.action = action
    }

    func build(with productID: UUID) throws -> ProductViewController {
        let productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil)

        productViewController.productID = productID // В целом, данное действие лучше переложить на `ContextAction`, но об этом далее

        return productViewController
    }

}

Из реализации выше становится понятно, что данная фабрика загрузит образ вью контроллера из XIB файла и установит в него переданный productID. Помимо стандартного протокола Factory, библиотека предоставляет несколько стандартных реализаций этого протокола для того что бы избавить вас от написания банального кода (в частности приведенного в примере выше).


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


Action


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


Самой банальной реализацией Action является модальная презентация контроллера:


class PresentModally: Action {

    func perform(viewController: UIViewController, on existingController: UIViewController, animated: Bool, completion: @escaping (_: ActionResult) -> Void) {
        guard existingController.presentedViewController == nil else {
            completion(.failure("\(existingController) is already presenting a view controller."))
            return
        }

        existingController.present(viewController, animated: animated, completion: {
            completion(.continueRouting)
        })
    }

}

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


Finder


Сущность Finder отвечает роутеру на вопрос — А создан ли уже такой вью контроллер и есть ли он уже в стеке? Возможно, ничего создавать не требуется и достаточно показать то что уже есть?.


public protocol Finder {

    associatedtype ViewController: UIViewController

    associatedtype Context

    func findViewController(with context: Context) -> ViewController?

}

Если вы храните ссылки на все созданные вами вью контроллеры — то в вашей реализации Finder вы можете просто возвращать ссылку на искомый вью контроллер. Но чаще всего это не так, ведь стек приложения, особенно особенно если оно большое, меняется довольно динамично. Кроме того, вы можете иметь несколько одинаковых вью контроллеров в стеке показывающих разные сущности (например несколько ProductViewController показывающие разные товары с разным productID), поэтому реализация Finder может потребовать кастомной имплементации и поиска соответствующего вью контролера в стеке. Библиотека облегчает эту задачу предоставляя StackIteratingFinder как расширение Finder — протокол с соответствующими настройками, позволяющий упростить эту задачу. В реализации StackIteratingFinder вам потребуется только ответить на вопрос — является ли данный вью контролер тем который роутер ищет по вашему запросу.


Пример такой реализации:


class ProductViewControllerFinder: StackIteratingFinder {

    let options: SearchOptions

    init(options: SearchOptions = .currentAndUp) {
        self.options = options
    }

    func isTarget(_ productViewController: ProductViewController, with productID: UUID) -> Bool {
        return productViewController.productID == productID
    }

}

Вспомогательные сущности


RoutingInterceptor


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


class LoginInterceptor: RoutingInterceptor {

    func execute(for destination: AppDestination, completion: @escaping (_: InterceptorResult) -> Void) {
        guard !LoginManager.sharedInstance.isUserLoggedIn else {
            // ...
            // Показать LoginViewController и по резульату действий пользователя вызвать completion(.success) или completion(.failure("User has not been logged in."))
            // ...
            return
        }
        completion(.success)
    }

}

Реализация такого RoutingInterceptor с комментариями содержится в примере поставляемом с библиотекой.


ContextTask


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


PostRoutingTask


Реализация PostRoutingTask будет вызвана роутером после успешного завершения интеграции запрошенного вью контроллера в стек. В его реализации удобно добавлять различную аналитику или дергать различные сервисы.


Более подробно с реализацией всех описанных сущностей можно ознакомиться в документации к библиотеке а также в прилагаемом примере.


PS: Количество вспомогательных сущностей которое может быть добавлено в конфигурацию не ограничено.


Конфигурация


Все описанные сущности хороши тем что разбивают процесс композиции на маленькие, взаимозаменяемые и хорошо трестируемые блоки.


Теперь перейдем к самому главному — к конфигурации, то есть соединению этих блоков между собой. Для того что бы собрать данные блоки между собой и объединить в цепочку шагов, библиотека предоставляет билдер класс StepAssembly (для контейнеров — ContainerStepAssembly). Его реализация позволяет нанизать блоки композиции в единый конфигурационный объект как бусины на ниточку, а также указать зависимости от конфигураций других вью контроллеров. Что делать с конфигурацией в дальнейшем — зависит от вас. Можете скормить ее роутеру с необходимыми параметрами и он построит для вас стек вью контроллеров, можете сохранить в словарь и использовать в последствии по ключу — зависит от вашей конкретной задачи.


Рассмотрим банальный пример: Допустим, по нажатии на ячейку в списке или получении приложением универсальной ссылки из сафари или почтового клиента, нам нужно показать модально вью контроллер продукта с неким productID. При этом вью контролер продукта должен быть построен внутри UINavigationController-а, что бы на его панели управления он мог показать свое название и кнопку закрыть. Кроме того, этот продукт можно показывать только пользователям которые вошли в систему, в противном случае предложить им войти в систему.


Если разобрать этот пример без использования библиотеки, то выглядеть он будет примерно вот так:


class ProductArrayViewController: UITableViewController {

    let products: [UUID]?

    let analyticsManager = AnalyticsManager.sharedInstance

    // Методы UITableViewControllerDelegate

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let productID = products[indexPath.row] else {
            return
        }

        // Уйдет в LoginInterceptor
        guard !LoginManager.sharedInstance.isUserLoggedIn else {
            // Много кода показывающего LoginViewController и обрабатывающего его результаты и в последствии вызывающего `showProduct(with: productID)`
            return
        }

        showProduct(with: productID)

    }

    func showProduct(with productID: String) {
        // Уйдет в ProductViewControllerFactory
        let productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil)

        // Уйдет в ProductViewControllerContextTask
        productViewController.productID = productID

        // Уйдет в NavigationControllerStep и PushToNavigationAction
        let navigationController = UINavigationController(rootViewController: productViewController)

        // Уйдет в GenericActions.PresentModally
        present(alertController, animated: navigationController) { [weak self]
            Уйдет в См. ProductViewControllerPostTask
            self?.analyticsManager.trackProductView(productID: productID)
        }
    }

}

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


Рассмотрим конфигурацию этого примера с использованием библиотеки:


let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory(action: PushToNavigationAction()))

        // Вспомогательные сущности:

        .add(LoginInterceptor())
        .add(ProductViewControllerContextTask())
        .add(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance))

        // Цепочка зависимостей:

        // NavigationControllerStep -> StepAssembly(finder: NilFinder(), factory: NavigationControllerFactory(action: GenericActions.PresentModally()))
        .from(NavigationControllerStep(action: GenericActions.PresentModally())) 

        .from(CurrentControllerStep())

        .assemble()

Если перевести это на человеческий язык:


  • Проверить что пользователь вошел в систему, и если нет предложить ему вход
  • Если пользователь успешно вошел в систему, продолжить
  • Искать продукт вью контроллер предоставленным Finder
  • Если был найден — сделать видимым и закончить
  • Если не был найден — создать UINavigationController, интегрировать в него вью контролер созданный ProductViewControllerFactory используя PushToNavigationAction()
  • Встроить полученый UINavigationController используя GenericActions.PresentModally() от текущего вью контролера

Конфигурирование требует некоторого изучения как и многие комплексные решения, например концепция AutoLayout, и, на первый взгляд, может показаться сложным и излишним. Однако, количество решаемых задач приведенным фрагментом кода охватывает все аспекты от авторизации до дип-линкига, а разбитие на последовательности действий дает возможность легко менять конфигурацию без необходимости внесения изменений в код. Кроме того, реализация StepAssembly поможет вам избежать проблем с незаконченой цепочкой шагов, а контроль типов — проблем с несовместимостью входных параметров у разных вью котроллеров.


Рассмотрим псевдокод полного приложения в котором в неком ProductArrayViewController выводится список продуктов и, если пользователь выбирает этот продукт, показывает его в зависимости от того залогинен пользователь или нет, или предлагает войти в систему и показывает после успешного входа:


Объекты конфигурации


// `RoutingDestination` протокол обертка для роутера. Можно добавить туда дополнительные параметры при желании для ваших обработчиков.
struct AppDestination: RoutingDestination {

    let finalStep: RoutingStep

    let context: Any?

}

struct Configuration {

    // Является статическим только для примера, вы можете создать протокол и подменять его реализации в зависимости от задачи
    static func productDestination(with productID: UUID) -> AppDestination {
        let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory(action: PushToNavigationAction()))
                .add(LoginInterceptor())
                .add(ProductViewControllerContextTask())
                .add(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance))
                .from(NavigationControllerStep(action: DefaultActions.PresentModally()))
                .from(CurrentViewControllerStep())
                .assemble()

        return AppDestination(finalStep: productScreen, context: productID)
    }

}

Реализация списка продуктов


class ProductArrayViewController: UITableViewController {

    let products: [UUID]?

    //...

    // DefaultRouter - реализация Router класса предоставляемая библиотекой, создается внутри UIViewController для примера
    let router = DefaultRouter()

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let productID = products[indexPath.row] else {
            return
        }
        router.navigate(to: Configuration.productDestination(with: productID))
    }

}

Реализация универсальных ссылок


@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    //...

    func application(_ application: UIApplication,
                     open url: URL,
                     sourceApplication: String?,
                     annotation: Any) -> Bool {
        guard let productID = UniversalLinksManager.parse(url: url) else {
            return false
        }

        return DefaultRouter().navigate(to: Configuration.productDestination(with: productID)) == .handled
    }
}

Вот в сущности и все что требовалось для реализации всех условий из поставленного примера.


Следует также упомянуть что конфигурация может быть гораздо сложнее и состоять из зависимостей. Например, если вам нужно показывать продукт не просто от текущего контроллера, но, в случае если пользователь пришел в него по универсальной ссылке — то под ним должен оказаться обязательно ProductArrayViewController, который должен находиться обязательно внутри UINavigationController после условного HomeViewController — то это все может быть указано в конфигурации StepAssembly используя from(). Если ваше приложение охвачено RouteComposer полностью, сделать это не составит труда (Смотрите приложение в примере к библиотеке). Кроме того, вы можете создать несколько реализаций Configuration и передавать их в один и тот же вью контроллер для реализации разных вариантов композиции. Или выбирать один из них, если в вашем приложении проводится A/B тестирование, в зависимости к какой фокус группе относится ваш пользователь.


Вместо заключения


На данный момент, описаный выше подход используется в 3х приложениях в продакшене и хорошо зарекомендовал себя. Разбиение задачи композиции на маленькие, легко читаемые, взаимозаменяемые блоки облегчает понимание и поиск багов. Дефолтная реализация Fabric, Finder и Action позволяет для большинства задач сразу начать с конфигурации без необходимости создавать свои. А самое главное, что дает данный подход — возможность автономного создания вью контроллеров без необходимости внесения в их код знания о том, как они будут построены, и как пользователь будет двигаться в дальнейшем. Вью контроллер должен лишь по действию пользователя вызвать нужную конфигурацию, что может быть так же абстрагировано.


Библиотека, как и предоставляемая ей реализация роутера, не использует никаких трюков с objective c рантаймом и полностью следует всем концепциям Cocoa Touch, лишь помогая разбить процесс композиции на шаги и выполняет их в заданной последовательности. Библиотека протестирована с версиями iOS с 9 по 12.


Данный подход вписывается во все архитектурный паттерны которые подразумевают работу с UIViewController стеком (MVC, MVVM, VIP, RIB, VIPER и т.д.)


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


Буду рад вашим комментариям и предложениям.

Комментарии (0)