Рефакторинг большой раскадровки в несколько меньших

iOS

День настал

Я недавно работал над iOS приложением, которое уже находится на рынке. Оно было выпущено прежде, чем Apple запустили новый чудо фреймворк SwiftUI, и разрабатывалось с помощью одной раскадровки, реализующей весь UI.

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

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

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

Итак, настал тот самый день, когда моей задачей стало разделить сцены этого огромного объекта, содержащего весь UI, на множество раскадровок.

Целью было повысить управляемость, ускорить их открытие в Xcode, а также на случай увеличения числа разработчиков в проекте, уменьшить число проблем, связанных со слиянием и конфликтами версий.

Множество раскадровок

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

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

let storyboard = UIStoryboard(name: “SecondStoryboard”, bundle: nil)let secondVC = storyboard.instantiateViewController(identifier:”SecondViewController”)show(secondVC, sender: self)

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

  • Группировкой существующих сцен.
  • Разделением групп на отдельные раскадровки.
  • Проверкой работоспособности навигации между сценами из разных раскадровок.

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

Там я и отыскал возможности, о которых ранее не слышал, и взял на вооружение два подхода, каждый из которых оказался по-своему интересен.

Целостное построение интерфейса

Слышали ли вы когда-нибудь о связках раскадровок? Компания Apple ввела их в iOS 9 и macOS 10.11. Они-то как раз и решают мою задачу.

С их помощью можно разбить большую раскадровку на множество меньших. Такая связка объединяет множество раскадровок в одну комплексную. Меня поразило, насколько легко использовать эту возможность. Для этого нужно:

  • Открыть большую раскадровку.
  • Выбрать сцены для извлечения и помещения в отдельный файл.
  • Использовать опцию “рефакторинг в раскадровку…”, находящуюся в меню редактора Xcode.

Наша любимая IDE только спросит имя новой раскадровки, а мы в завершении просто выберем поместить новый файл в новую папку или в существующую. Сделано!

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

Ручной программный подход

Этот подход подразумевает самостоятельное разделение раскадровки на части.

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

Далее я присвоил соответствующие имена каждому дубликату (“Логин”, “Содержимое”, “Настройки” и пр.).

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

let storyboard = UIStoryboard(name: "SecondStoryboard", bundle: nil)let secondVC = storyboard.instantiateViewController(identifier: "SecondViewController")show(secondVC, sender: self)

Его легко писать и читать, поэтому его можно оперативно заменить на следующий:

self.performSegue(withIdentifier: "goThere", sender: self)

В завершении, если целевая сцена требует значения из вызывающего контроллера отображения, то код в подготовительной функции больше не нужен:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
   switch segue.identifier {
      case "goThere":
        let destination = segue.destination as! SecondViewController
        destination.data = someData
   default:
   break
   }
}

И может быть заменен на:

let storyboard = UIStoryboard(name: "SecondStoryboard", bundle: nil)
let secondVC = storyboard.instantiateViewController(identifier: "SecondViewControllerID")
secondVC.data = someData
show(secondVC, sender: self)

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

Компонент маршрутизации (Routable)

Этот компонент помогает легко отображать сцену из раскадровки, что хорошо видно по трем методам в следующем примере класса ViewController:

import UIKit

class MyViewController: ViewController {
   // ПОМЕТКА: - Публичные методы
   public func goToPrivacyVC() {
      self.show(storyboard: .settings, identifier:    
      "privacyViewControllerId")
   }
   public func goToLoginVC() {
      self.present(
         storyboard: .login,
         identifier: "loginNavigationController",
         modalPresentationStyle: .fullScreen)
   }
   public func goToFilesVC() {
      self.show(storyboard: .media_and_Files, identifier: "Files", 
         configure: { controller in
         // Передать данные в место назначения
         if let vc = controller  as? FilesViewController {
            vc.myData = "ciao"
         }
      })
   }
}
extension MyViewController: Routable {
   enum StoryboardId: String {
      case start_main = "Home_Main_Screens"
      case login = "Login"
      case settings = "Settings_Permissions_Agreement"
      case media_and_Files = "Media"
   }
}

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

Отправка информации из источника и целевых контроллеров производится весьма просто. В конце статьи приведен полный код этого компонента routable.

Последние обновления Xcode 11 и iOS 13

К моему удивлению. я обнаружил, что Xcode 11 и iOS 13 представили некоторые новые возможности раскадровок. Я имею в виду SegueActions и специальные инициализаторы.

SegueAction

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

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

Мы можем избежать реализации этой функции, использовав SegueAction — метод контроллера отображения, который UIKit вызывает о время перехода. Пример ниже демонстрирует простой вариант с SegueAction:

@IBSegueAction
private func showMyDestinationScene(coder: NSCoder, sender: Any?, segueIdentifier: String?)
-> MyDestinationSceneController? {
return MyDestinationSceneController(coder: coder, myDara: "Hello there!")
}

Мы можем выбрать более краткую форму, если нам не нужны все вводные параметры:

@IBSegueAction
private func showMyDestinationScene(coder: NSCoder)-> MySceneController? {
return MyDestinationSceneController(coder: coder, myDara: my"Hello there!"Data)
}

Целевой контроллер отображения должен быть реализован с пользовательским инициализатором, как показано в следующем примере:

class MyDestinationSceneController: UIViewController {
   @IBOutlet weak var myLabel: UILabel!
   let data: String
   init?(coder: NSCoder, myData: String) {
      self.data = myData
      super.init(coder: coder)
   }
   required init?(coder: NSCoder) {
      fatalError("Error. init(coder:) must be implemented")
   }
   override func viewDidLoad() {
      super.viewDidLoad()
      myLabel.text = data;
   }
}

Процедура формирования SegueAction очень проста. Как и всегда в IB, мы создаем переход. В моем примере я соединяю синюю кнопку с целевым контроллером отображения:

Мы выбираем вид перехода:

Затем создаем SegueAction, как если бы создавали IBAction, устанавливая связь Ctrl-перетаскиванием перехода в код viewController:

Именуем новую функцию:

Вот и создано действие SegueAction.

В моем примере я добавил в конце параметр myData для его передачи целевому ViewController:

Подготовительная функция больше не требуется.

Пользовательские инициализаторы

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

Например, мы можем создать обычный IBAction:

@IBAction private func goToMySceneViewController(_ sender: UIButton) {
   guard let destController = storyboard?.instantiateViewController(
      withIdentifier: "MySceneViewControllerID") as?  
      MyDestinationSceneController else {
      fatalError("rror while creating MyDestinationSceneController")
   }
   destController.myData = "Hi there!"
   show(destController, sender: self)
}

Версия instantiatateViewController, представленная в iOS 13, позволяет создавать блок, который вызывает пользовательский инициализатор:

@IBAction private func goToMySceneViewController(_ sender: UIButton) {
   guard let destController = storyboard?.instantiateViewController(
      identifier: "MySceneViewControllerID",
      creator: { coder in MyDestinationSceneController(coder: coder, 
          myData: "Hi there!")
   }) else {
      fatalError("Error while creating   
         MyDestinationSceneController")
   }
   
   show(destController, sender: self)
}

Заключение

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

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

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

Полный код для компонента маршрутизации (Routable)

Компонент маршрутизации и его расширение раскадровки были написаны Освальдом Пирелло, CEO и основателем OverApp. Выражаю ему свою благодарность.

Routable.swift

//
//  Routable.swift
//
//  Создан Osvaldo Pirrello on 11/12/2019.
//  Copyright © 2019. All rights reserved.
//
import UIKit
public protocol Routable {
associatedtype StoryboardId: RawRepresentable, Hashable
typealias RoutablePath = (StoryboardId, String?)
func present(
   storyboard: StoryboardId,
   identifier: String?,
   animated: Bool,
   modalPresentationStyle: UIModalPresentationStyle?,
   configure: ((UIViewController) -> Void)?,
   completion: ((UIViewController) -> Void)?)
func show(
   storyboard: StoryboardId,
   identifier: String?,
   configure: ((UIViewController) -> Void)?)
func showDetailViewController(
   storyboard: StoryboardId,
   identifier: String?,
   configure: ((UIViewController) -> Void)?)
func find(by storyboards: [StoryboardId]) -> [UIViewController]
func find(by paths: [RoutablePath]) -> [UIViewController]
func find(by storyboard: StoryboardId) -> UIViewController
func find(by path: RoutablePath) -> UIViewController
}
public extension Routable where Self: UIViewController, StoryboardId.RawValue == String {
   /**
   Представляет контроллер отображения определенной раскадровки
   - параметр storyboard: имя раскадровки.
   - параметр identifier: идентификатор контроллера отображения.
   - параметр animated: определяет, должно ли представление быть анимированным.
   - параметр modalPresentationStyle: Определяет стиль модальной презентации.
   - параметр configure: Конфигурирует контроллер представления перед его загрузкой.
   - параметр completion: Завершение контроллера представления после его загрузки.
   */
   func present(
      storyboard: StoryboardId,
      identifier: String? = nil,
      animated: Bool = true,
      modalPresentationStyle: UIModalPresentationStyle? = nil,
      configure: ((UIViewController) -> Void)? = nil,
      completion: ((UIViewController) -> Void)? = nil) {
      
      // Найти контроллер представления по раскадровке и идентификатору
      let controller = self.find(by: (storyboard, identifier))
      
      // Установить стиль модальной презентации(если определен)
      if let modalPresentationStyle = modalPresentationStyle {
         controller.modalPresentationStyle = modalPresentationStyle
      }
      
      // Сконфигурировать контроллер представления (если требуется)
      configure?(controller)
      
      // Представить контроллер отображения
      present(controller, animated: animated) {
         completion?(controller)
      }
   }
   /**
   Показывает контроллер отображения определенной раскадровки в основном контексте.
   - параметр storyboard: имя раскадровки.
   - параметр identifier: идентификатор контроллера отображения.
   - параметр configure: конфигурирует контроллер отображения перед его загрузкой.
   */
   func show(
      storyboard: StoryboardId,
      identifier: String? = nil,
      configure: ((UIViewController) -> Void)? = nil) {
      // Найти контроллер отображения по раскадровке и   идентификатору
      let controller = self.find(by: (storyboard, identifier))
      // Сконфигурировать контроллер отображения (если требуется)
      configure?(controller)
      //Показать контроллер отображения
     show(controller, sender: self)
   }
   /**
   Показывает контроллер отображения определенной раскадровки во второстепенном контексте.
   - параметр storyboard: имя раскадровки.
   - параметр identifier: идентификатор контроллера отображения.
   - параметр configure: конфигурирует контроллер отображения перед его загрузкой.
   */
   func showDetailViewController(
      storyboard: StoryboardId,
      identifier: String? = nil,
      configure: ((UIViewController) -> Void)? = nil) {
      // Найти контроллер отображения по раскадровке и идентификатору
      let controller = self.find(by: (storyboard, identifier))
      // Сконфигурировать контроллер отображения (если требуется)
      configure?(controller)
      // Предоставить детали контроллера отображения
      showDetailViewController(controller, sender: self)
   }
   /**
Извлекает массив контроллеров отображения, начиная с массива с именем раскадровки.
   - параметр storyboards: массив имен раскадровок.
   - возвращает: массив найденных контроллеров отображения.
   */
   func find(by storyboards: [StoryboardId]) -> [UIViewController] {
      // Просмотр контейнера контроллера
      var controllers: [UIViewController] = []
      // Для каждой раскадровки...
      storyboards.forEach { storyboard in
         // Найти контроллер отображения по раскадровке
         let controller = self.find(by: (storyboard, nil))
         // Добавить найденный контроллер отображения в контейнер
         controllers.append(controller)
      }
      // Вернуть найденные контроллеры отображения
      return controllers
   }
   /**
   Извлекает массив контроллеров отображения, начиная с массива путей, заданных именем раскадровки и идентификатором контроллера отображения.
   - параметр paths: массив кортежа с именем раскадровки и идентификатора контроллера отображения.
   - возвращает: массив найденных контроллеров отображения.
   */
   func find(by paths: [RoutablePath]) -> [UIViewController] {
      // Просмотреть контейнер контроллера
      var controllers: [UIViewController] = []
      // Для каждого кортежа раскадровки и идентификатора...
      paths.forEach { storyboard, identifier in
         // Найти контроллер отображения по раскадровке и идентификатору
         let controller = self.find(by: (storyboard, identifier))
         // Добавить найденный контроллер отображения в контейнер
         controllers.append(controller)
      }
      // Вернуть найденные контроллеры отображения
      return controllers
   }
   /**
   Извлекает изначальный контроллер отображения по имени содержащей его раскадровки.
   - параметр storyboard: имя раскадровки
   - возвращает: найденный контроллер отображения.
   */
   func find(by storyboard: StoryboardId) -> UIViewController {
      // Вернуть изначальный контроллер отображения раскадровки
      return self.find(by: (storyboard, nil))
   }
   /**
   Извлекает контроллер отображения, используя путь, состоящий из имени раскадровки и его идентификатора.
   - параметр paths: имя раскадровки + идентификатор контроллера отображения.
   - возвращает: найденный контроллер отображения.
   */
   func find(by path: RoutablePath) -> UIViewController {
      // Экземпляр раскадровки для именования
      let storyboard = UIStoryboard(name: path.0.rawValue)
      // Если идентификатор контроллера отображения существует...
      if let identifier = path.1 {
         // Вернуть определенный контроллер отображения
         return storyboard.instantiateViewController(withIdentifier:  
         identifier)
      }
      else if let initialViewController =   
         storyboard.instantiateInitialViewController() {
         
         // В противном случае это изначальный контроллер отображения
         return initialViewController
      }
      else {
         // Провал утверждения.
         assertionFailure("Invalid controller for storyboard 
           (storyboard).")
      }
      
      // Резервный вариант
      return UIViewController()
   }
}

Routable.swift

Extension+UIStoryboard.swift

//
//  UIStoryboard.swift
//
//  Создан Osvaldo Pirrello on 11/12/2019.
//  Copyright © 2019. All rights reserved.
//
import UIKit
public extension UIStoryboard {
   /**
   Создает и возвращает объект раскадровки для определенного ресурсного файла раскадровки в основном наборе текущего приложения
   - имя параметра: имя ресурсного файла раскадровки без его расширения.
   - возвращает: объект раскадровки для определенного файла. Если не существует подходящего имени ресурсного файла раскадровки, выбрасывается исключение.
   */
   convenience init(name: String) {
      self.init(name: name, bundle: nil)
   }
}

Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming