Розробка

Композиція UIViewController-ів і навігація між ними (і не тільки)

 

У цій статті я хочу поділитися досвідом, який ми успішно використовуємо вже кілька років у наших iOS додатках, 3 з яких в даний момент знаходяться в Appstore. Даний підхід добре зарекомендував себе і нещодавно ми сегрегировали його від решти коду і оформили в окрему бібліотеку RouteComposer про яку власне і піде мова.

 

https://github.com/saksdirect/route-composer

 

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

 

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

 

Можна використовувати альтернативні архітектурні патерни такі як MVVM, VIP, VIPER, але і в них UIViewController буде задіяний так чи інакше, а, значить, ця бібліотека може використовуватися разом з ними. Сутність 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)

 

Чому ми вирішили створити бібліотеку для композиції

 

Як видно з прикладів вище, немає єдиного способу інтеграції звичайних в’ю контролерів у контейнери, як і немає єдиного способу побудови стека в’ю контролерів. І, варто вам захотіти злегка змінити лайаут вашого додатка або спосіб навігації в ньому, як вам будуть потрібні значні зміни в код програми, так само вам будуть потрібні посилання на об’єкти контейнерів, що б ви могли вставити в них ваші в’ю контролери і т. д. тобто, сам стандартний спосіб передбачає досить великий обсяг роботи, а так само наявність посилань на в’ю контролери для породження дій та презентації інших контролерів.

 

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

 

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

 

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

 

 

З ними можна взаємодіяти і тестувати індивідуально, а потім зібрати в кінцеве додаток для продакшену. Такий підхід сильно полегшує розробку, але, як ви бачили вище прикладів, починається пекло композиції, коли потрібно тримати в коді кілька способів інтеграції в’ю контролера в стек.

 

Залишається додати, що це все помножиться на N як тільки ваша маркетингова команда виявить бажання провести A/B тестування на живих користувачів і перевірити який спосіб навігації працює краще, наприклад, таб бар або гамбургер меню?

 

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

 

Я спробую розповісти вам як ми підійшли до вирішення цієї проблеми і, зрештою, виділили його в бібліотеку RouteComposer.

 

Сусанін Route Composer

 

Проаналізувавши всі сценарії композиції і переходів ми спробували абстрагувати наведений у прикладах вище код та виділили 3 основних сутності з яких складається і якими оперує бібліотека RouteComposer — Factory, Finder, Action. Крім того, в бібліотеці присутні 3 допоміжні сутності які відповідають за невеликий тюнінг який може знадобитися в процесі навігації — RoutingInterceptor, ContextTask, PostRoutingTask. Всі ці сутності повинні бути налаштовані в ланцюг залежностей і передані Routerу — об’єкту, який і буде будувати ваш стек в’ю контролерів.

 

Але, про кожну з них по порядку:

 

Factory

 

Як і випливає з назви Factory відповідає за створення в’ю контролера.

 

public protocol Factory {

 associatedtype ViewController: UIViewController

 associatedtype Context

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

}

 

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

 

Наприклад:

 

class ProductViewControllerFactory: Factory {

 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())

 // Допоміжні сутності:

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

 // Ланцюжок залежностей:
.using(PushToNavigationAction())
 .from(NavigationControllerStep()) 
 // NavigationControllerStep -> StepAssembly(finder: NilFinder(), factory: NavigationControllerFactory())
.using(GeneralAction.presentModally())
.from(GeneralStep.current())
 .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())
.add(LoginInterceptor())
.add(ProductViewControllerContextTask())
 .add(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance))
.using(PushToNavigationAction())
.from(NavigationControllerStep())
.using(GenericActions.PresentModally())
.from(CurrentControllerStep())
.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 і т. д.)

 

Бібліотека знаходиться в активній розробці, тим не менш, як я вже писав вище, використовується в продакшне. Я б рекомендував випробувати даний підхід і поділитися враженнями. У нашому випадку це дозволило нам позбутися від великої кількості головного болю.

Related Articles

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Close