Как стать автором
Обновить
79.99
hh.ru
HR Digital

Обзор решений для навигации в iOS

Время на прочтение15 мин
Количество просмотров5K

Всем привет! Меня зовут Тимур, я – iOS разработчик в hh.ru. В этой статье поговорим о фреймворкинге навигации в iOS. Я расскажу кулстори о популярных и не очень решениях и их преимуществах, а еще о том, как мы искали фреймворк мечты среди этой смертной любви. Поехали!

Налево пойдешь – в прод попадешь

Навигация в iOS – это сложно. И на то есть причины. Во-первых, чаще всего нам нужно сделать некоторую цепочку переходов, а не просто показать один экран. А чтобы производить какую-то навигацию, нам необходимо проверить, открыт ли вообще текущий экран. Далее следует понять, как нам передать данные между экранами. И кроме того, существует масса других краевых случаев, вроде одинаковой навигации на нескольких экранах, которую нужно как-то переиспользовать. 

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

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

Направо пойдешь – фреймворки найдешь

Фреймворков навигации в iOS достаточно много. Но при этом мы можем не использовать сторонние вовсе, а пользоваться только стандартными – через UIKit. Но мы рассматривали еще несколько решений, о которых я сейчас и расскажу.

Есть такая штука – Marshroute от Avito. Мы в hh использовали его уже несколько лет, но он устарел и официальная библиотека перестала поддерживаться. К тому же, он окончательно перестал соответствовать нашим требованиям. Далее мы стали изучать решение от Badoo. У ребят есть статья о том, как они делали свой роутинг. Статья нас вдохновила и нам в целом понравилось предложенное решение. Потом решили присмотреться к RouteComposer. Это открытая библиотека на GitHub с исходным кодом, которую может использовать любой желающий. 

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

Методы и критерии оценки фреймворков

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

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

Пример

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

Пример

Существует набор тестовых кейсов:

  • Работа со стандартными контроллерами из UIKit. Мы будем тестировать, как фреймворки справляются с показом UIAlertController, UIImagePickerController и обработкой ошибок;

  • Пуш уведомление на чат – осуществляем переход на нужный экран чата из любой точки приложения с проверкой авторизации;

  • Пуш уведомление на текущий открытый чат – мы должны будем обновить текущий экран без совершения навигации;

  • Пуш уведомление на другой чат из текущей открытой комнаты – закрываем текущий открытый чат и открываем новый;

  • Пуш уведомление на чат из другой комнаты – закрываем текущую и и открываем новую с новым чатом;

С демо-приложением всё более-менее понятно, теперь разберемся с критериями оценки для фреймворков. Ведь прежде чем выбирать фреймворк, надо понять – как именно его выбрать. Мы придумали аж три критерия:

  • Первый – удобство. С фреймворком должно быть удобно работать.

  • Второй – построение графа навигации. Мы должны суметь построить разный граф в зависимости от ситуации. Например, пользователь отказался авторизовываться, тогда мы можем пойти по другому flow-навигации.

  • Третий – масштабируемость. Фреймворк не должен нас сковывать и не позволять нам масштабировать наш проект.

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

Удобство работы

Удобство работы с фреймворком складывается из множества факторов. Стоит рассмотреть их по отдельности.


Разберем первый подкритерий под кодовым названием «локальная навигация». Она должна быть легкой, удобной и без громоздких конструкций. Чтобы положить экран в стек (сделать обычный push), мы не должны писать космолет. А еще должно быть удобно работать с контроллерами из UIKit.

Давайте посмотрим, как с этим справляется Badoo. У нас есть метод показа экрана чата: здесь мы просто навигируемся в экран и передаем контекст.

private func showChat(id: Int) {
    router.navigateToScreen(
        .chat, 
        with: ChatContextInfo(roomID: roomID, chatID: id), 
        animated: true
    )
}

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

В RouteComposer вся навигация разбивается на шаги, и чтобы этот шаг собрать, у нас есть класс StepAssembly. Соберем свой шаг, чтобы запушить экран. Здесь передаются такие параметры, как finder – это просто класс, который помогает искать текущий открытый экран. Так как его точно нет, передавать мы ничего не будем и отдаем в NilFinder.

let chatScreenStep = StepAssembly(finder: NilFinder(), factory: ChatScreen())
    .using(UINavigationController.push())
    .from(GeneralStep.custom(using: InstanceFinder(instance: self)).expectingContainer())
    .assemble()

Factory – он отдает наш собранный экран. Далее, используя команду push, мы кладем его в стек из текущего инстанса. Шаг собран, нам остается только отдать его роутеру и совершить непосредственно навигацию.

try? router.navigate(
    to: chatScreenStep,
    with: ChatContext(roomID: roomID, chatID: id),
    animated: true,
    completion: nil
)

Получается слишком громоздко, сложновато и требует большого количества кода для локальной навигации. И здесь тоже неудобно. 


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

В Badoo нельзя сделать цепочку, но можно запилить отдельные навигации.

func showChat(roomID: Int, chatID: Int) {
    // Switch to room list tab
    router.navigateToScreen(
        .roomList, 
        animated: true
    )

    // Push to chat screen
    router.navigateToScreen(
        .chat, 
        with: ChatContextInfo(roomID: roomID, chatID: chatID), 
        animated: true
    )
}

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

Возможно, удивит RouteComposer.

static func roomListScreen(router: Router) -> DestinationStep<RoomListViewController, Any?> {
    StepAssembly(finder: ClassFinder<RoomListViewController, Any?>(), factory: NilFactory())
        .from(homeScreen(router: router))
        .assemble()
}

У нас есть список комнат, мы можем показывать его из HomeScreen. HomeScreen – это наш UITabBarController. В данном случае при навигации к RoomListScreen у нас будет переключена вкладка, что довольно удобно.

И мы можем продолжать цепочку, ведь у нас есть ChatListScreen, который показывается как раз из RoomListScreen.

static func chatListScreen(router: Router) -> DestinationStep<ChatListViewController, ChatContext> {
    StepAssembly(
        finder: ClassWithContextFinder<ChatListViewController, ChatContext>(),
        factory: ChatListScreen(router: router)
    )
        // ...
        .from(roomListScreen(router: router).unsafelyRewrapped())
        .assemble()
}

Удобно – ставим плюсик.


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

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

extension ChatViewController: ViewControllerContextHolder {
    var currentContext: ViewControllerContext? {
        ViewControllerContext(screenType: .chat, info: contextInfo)
    }
}

Однако в Badoo нет взаимодействия с обновлением экрана – сам экран мы найдем, но не обновим данные в нём. Поэтому здесь создается неоднозначная ситуация, которая требует доработок.

В RouteComposer есть такая сущность, как Finder. Мы передаем в finder класс ClassWithContextFinder. Он найдет наш экран по классу и по контексту. Вроде, то что нужно.

static func chatListScreen(router: Router) -> DestinationStep<ChatListViewController, ChatContext> {
    StepAssembly(
        finder: ClassWithContextFinder<ChatListViewController, ChatContext>(),
        factory: ...
    )
    ...
}

Но, увы, в RouteComposer нет встроенного инструмента для обновления экрана, поэтому нам пришлось дописать свой Finder, который его обновит.

static func chatScreen(router: Router) -> DestinationStep<ChatViewController, ChatContext> {
    StepAssembly(
        finder: ClassWithContextRefreshableFinder<ChatViewController, ChatContext>(options: .currentAllStack),
        factory: ...
    )
    ...
}

Здесь, как и в решении Badoo, тоже не всё однозначно.


Дальше разберем подкритерий удобный DSL. Под “удобным DSL” подразумевается, что описание навигации не должно вызывать никаких трудностей. Разработчики должны беспретятственно описать любую  сложную цепочку навигаций. От фреймворка мы ожидаем некоторое автодополнение, оно должно помогать разработчику прописывать маршрут до экрана. 

У Badoo синтаксис супер простой. Мы просто пишем: "снавигируй нас к экрану чата", и получаем всю навигацию. Кажется, удобно.

router.navigateToScreen(
    .chat, 
    with: ChatContextInfo(roomID: roomID, chatID: chatID), 
    animated: true
)

А с RouteComposer чуть сложнее – мы описываем шаг и обращаем внимание на строки 5-8:

static func loginScreen(
    authorizationCompletion: @escaping AuthorizationCompletion
) -> DestinationStep<AuthorizationPhoneNumberViewController, Void> {
    StepAssembly(...)
        .using(UINavigationController.push())
        .from(NavigationControllerStep<UINavigationController, Void>())
        .using(GeneralAction.presentModally(presentationStyle: .formSheet))
        .from(GeneralStep.current())
        .assemble()
}

Мы используем такие функции, как using(_:) и from(_:), отдаем какие-то действия и откуда-то их выполняем. Делаем пуш, создаем NavigationControllerStep и показываем его модально из текущего экрана. Но всё это требует заучиваний, поскольку шаги расположены в статических функциях. Например, push(), который находится статичной функцией у UINavigationController-а – здесь нет никакого автодополнения. Это решается, когда вы немного поиграете с библиотекой, только тогда всё становится более-менее понятно. Но нам-то хочется собирать цепочку навигации автокомплитом.


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

В Badoo у нас делается навигация, но контекст не строго типизирован и нет ассоциации контекста с экраном.

router.navigateToScreen(
    .chat, 
    with: ProfileContextInfo(firstName: "John", lastName: "Appleseed"), 
    animated: true
)

Поэтому мы можем передать в экран чата какой-нибудь ProfileContextInfo, тем самым допустив ошибку. Навигация просто не будет совершена. Неудобно. 

В случае с RouteComposer, мы совершаем навигацию к экрану чата и передаем ему ChatContext. Здесь я могу точно сказать, что мы не можем передать неверный контекст – он строго типизирован и ассоциирован с экраном.

try? router.navigate(
    to: Screens.chatScreen(router: router),
    with: ChatContext(roomID: roomID, chatID: id),
    animated: true,
    completion: nil
)

Однако если мы строим цепочку, то контекст необходимо передавать между шагами. В таком случае если мы показываем ChatListScreen с ChatContext, то и RoomList должен быть с ChatContext. А в этом случае мы не сможем использовать RoomListScreen отдельно, поскольку он ничего не знает о ChatContext и о том, как он должен с ним собраться.

Решение есть – мы можем просто стереть тип контекста у RoomListScreen до Any?. Тогда в ChatListScreen нам остается только добавить вызов метода unsafelyRewrapped(). Название может немного пугать, но на самом деле ничего не упадет в runtime. Если каст не удастся, то навигация просто не будет осуществлена. По итогу ChatContext мы просто приведем к типу Any?

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


Следующий подкритерий – кастомная анимация. Сюда входит:

  • Смена корневого экрана. То есть мы можем со своей анимацией сменить наш rootViewController у UIWindow. 

  • Изменение стека. Должна быть возможность изменить стек с собственной анимацией. 

  • Модальный показ. Передача своего transitioningDelegate, тот же показ BottomSheet и прочее.

Как с этим справляется Badoo?

router.navigateToScreen(.roomList, animated: true)

Да никак. Там нет никакой возможности передать свои анимации, настроить переходы, там есть только поле animated, true или false. Нам этого маловато, придется пилить свое.

В RouteComposer для смены rootViewController можно задать animationOptions, это позволит нам сделать стандартные анимации: переворот, разворот etc. Однако здесь нет возможности передать свой transition.

static func homeScreen(router: Router) -> DestinationStep<HomeTabBarController, Any?> {
    StepAssembly(...)
        .using(GeneralAction.replaceRoot(animationOptions: .transitionFlipFromLeft))
        .from(GeneralStep.root())
        .assemble()
}

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

static func chatScreen(router: Router) -> DestinationStep<ChatViewController, ChatContext> {
    StepAssembly(...)
        .using(UINavigationController.push())
        .from(chatListScreen(router: router).expectingContainer())
        .assemble()
}

Если мы хотим показать экран модально, то здесь мы можем передать transitioningDelegate. Но нужно учесть, что внутри он захватывается слабо и снаружи мы должны где-то хранить его сильно.

static func chatScreen(router: Router) -> DestinationStep<ChatViewController, ChatContext> {
    StepAssembly(...)
        .using(GeneralAction.presentModally(transitioningDelegate: transitionController))
        .from(chatListScreen(router: router))
        .assemble()
}

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

Итог по удобству использования такой: в Badoo много минусов, а RouteComposer не смог закрыть все наши хотелки.

Граф навигации

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

В Badoo мы можем только снавигироваться на экран, но результат навигации получить не получится. 

private func navigateToChat(roomID: Int, chatID: Int) {
    router.navigateToScreen(
        .roomList, 
        animated: true
    )
  
    router.navigateToScreen(
        .chat, 
        with: ChatContextInfo(roomID: roomID, chatID: chatID), 
        animated: true
    )
}

Это не очень удобно, в случае ошибки другую цепочку построить не получается. 

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

do {
    try router.navigate(
        to: Screens.homeScreen(router: router), 
        animated: false, 
        completion: nil
    )
} catch {
    // handle error
}

Стоит упомянуть, что, метод navigate(to:,animated:,completion:) выбрасывает ошибку, поэтому мы всегда пишем его вызов с try. Если вы не хотите ловить ошибку, то можно просто написать опциональный try?. Это удобно. 

Плюс, в RouteComposer можно реализовать различные ветки навигации: если мы захотим запушить экран, мы можем сделать это по-разному.

static let productScreen = StepAssembly(...)
    .using(UINavigationController.push())
    .from(SwitchAssembly<UINavigationController, ProductContext>()
        .addCase(when: { $0.productURL != nil },
                 from: ChainAssembly.from(NavigationControllerStep<UINavigationController, ProductContext>())
                     .using(GeneralAction.presentModally())
                     .from(GeneralStep.current())
                     .assemble())
        .addCase(from: ClassFinder<UINavigationController, ProductContext>(options: .currentVisibleOnly))
        .assemble(default: ConfigurationHolder.configuration.circleScreen.expectingContainer()))
    .assemble()

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


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

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

func showChat(roomID: Int, chatID: Int) {
    if authorizationProvider.isAuthorized {
        navigateToChat(roomID: roomID, chatID: chatID)
    } else {
        router.navigateToScreen(
            .authorization,
            with: AuthorizationPhoneNumberContextInfo(
                authorizationCompletion: { [unowned self] result in
                    if result.isAuthorized {
                        self.navigateToChat(
                          	roomID: roomID, 
                          	chatID: chatID
                        )
                    }
            		}
            ),
            animated: true
        )
    }
}

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

В RouteComposer нам достаточно выделить отдельный класс, подписать его под протокол RoutingInterceptor, и когда мы собираем наш шаг навигации, через метод adding(_:) добавить его.

static func chatListScreen(router: Router) -> DestinationStep<ChatListViewController, ChatContext> {
    StepAssembly(...)
        .adding(
            AuthorizationInterceptor(authorizationProvider: DefaultAuthorizationProvider.shared, router: router)
        )
        ...
}

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

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

private func navigateToChat(roomID: Int, chatID: Int) {
    router.navigateToScreen(
      	.roomList, 
      	animated: true
    )
  
    router.navigateToScreen(
      	.chat, 
      	with: ChatContextInfo(roomID: roomID, chatID: chatID), 
      	animated: true
    )
}

В RouteComposer наш экран уже автоматически поддерживает диплинковку, если мы описали шаги, которые могут зависеть от других шагов. Поэтому нам остается только сказать: “router, снавигируй нас к этому экрану”, и RouteComposer сделает всю магию сам. Звучит классно и достаточно гибко.

func showChat(roomID: Int, chatID: Int) {
    try? router.navigate(
        to: Screens.chatScreen(router: router),
        with: ChatContext(roomID: roomID, chatID: chatID),
        animated: true,
        completion: nil
    )
}

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

Масштабируемость

Последний важный критерий – масштабируемость. У него мы тоже выделили несколько подкритериев. Первый – многомодульность. В нашем проекте используется много фичемодулей, и навигация должна легко осуществляться между ними. Также не должно быть проблем с шарингом шагов навигации и экранов через DI.

В Badoo есть протокол, который собирает нам экраны по контексту. Мы можем разбить эту фабрику на подфабрики. Ну или каждый фичемодуль может иметь свою фабрику, и всё это будет собираться в хосте. Достаточно удобно, а еще мы можем шарить фабрики между фичемодулями.

final class ScreenFactory: ViewControllersByContextFactory {
    func viewController(for context: ViewControllerContext) -> UIViewController? {
        guard let screenType = ScreenType(rawValue: context.screenType), 
      				let router = router else {
            return nil
        }

        switch screenType {
        case .authorization:
            guard let contextInfo = context.info as? AuthorizationPhoneNumberContextInfo else {
                return nil
            }

            return AuthorizationPhoneNumberScreen(
              	authorizationCompletion: contextInfo.authorizationCompletion
            ).build()

        case .chat:
            guard let info = context.info as? ChatContextInfo else {
                return nil
            }

            return ChatScreen(contextInfo: info).build()

        case .chatList:
        ...
    }
}

Что касается RouteComposer – здесь есть DestinationStep-ы. Они тоже позволяют нам шарить их между фичемодулями, но нужно стереть тип до UIViewController-а. А делается это просто: в конце сборки нужно  вызвать unsafelyRewrapped(), и тогда нам не придется делать класс контроллера публичным.

static func roomListScreen(router: Router) -> DestinationStep<UIViewController, Any?> {
    StepAssembly(finder: ClassFinder<RoomListViewController, Any?>(), factory: NilFactory())
        .from(homeScreen(router: router))
        .assemble()
        .unsafelyRewrapped()
}

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

В Badoo мы можем продолжать использовать свои фабрики экранов, нам главное отдать текущий viewController по контексту. То есть нам не потребуется переписывать приложение целиком.

А RouteComposer заставляет нас подписать наши фабрики под протокол Factory, и у него появляется два ассоциированных типа – ViewController и Context.

struct ChatScreen: Factory {
    func build(with context: ChatContext) throws -> ChatViewController {
        guard let chatID = context.chatID else {
            throw RoutingError.generic(
              	RoutingError.Context("chatID can't be nil")
            )
        }

        return ChatViewController(
            chatID: chatID,
            roomID: context.roomID
        )
    }
}

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

Подведем итоги по масштабируемости: Badoo нас полностью удовлетворил, а RouteComposer не дал нам постепенной миграции.

Финал?

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

Всё пропало? Нет!

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

Будем на связи!


Репо с примерами
Наш фреймворк для навигации Nivelir
Наш канал в телеге
Наш чат в телеге
Наш блог на Хабре
Наш Open Source

Теги:
Хабы:
Всего голосов 12: ↑10 и ↓2+8
Комментарии2

Публикации

Информация

Сайт
hh.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия