Swift: протокол UIStoryboard

Потому что строковые литералы такие противные.

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

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

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

Строковые литералы

Тихий убийца, которого ты впустил в свой дом

let name = "News"
let storyboard = UIStoryboard(name: name, bundle: nil)
let identifier = "ArticleViewController"
let viewController =
    storyboard.instantiateViewController(withIdentifier: identifier) 
        as! ArticleViewController

В приведенном выше коде мы создаем экземпляр UIStoryboard с именем “News”, который будет искать файл «News.storyboard» в ресурсах проекта. Но что, если бы у раскадровки было более сложное имя, такое как «Звукоподражание», это слово было бы настолько странным и необычным, что мне пришлось искать, как его написать, только для этого поста. Представьте, если бы я неоднократно пытался угадать, как записать это в производственном коде, это глупая идея, но люди действительно делают такие безумные вещи.

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

Так как же сделать UIStoryboard более безопасным?

Глобальные константные строковые литералы

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

Но тогда у вас будет на одно имя переменной меньше, и вы будете удивлены, как часто вам может понадобиться повторно использовать имя переменной. Вы когда-нибудь пробовали назвать переменную description в классе, наследуемом от NSObject? Тогда вы понимаете, о чем я. Если существует несколько постоянных идентификаторов строковых литералов для раскадровки, единообразие можно легко потерять, или они могут быть определены в отдельных частях проекта, что затрудняет их поиск или объединение.

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

Родственные имена раскадровки

Как правило, ваши раскадровки должны быть названы в честь разделов, которые они охватывают. Например, если у вас есть раскадровка, в которой находятся контроллеры представлений, относящиеся к Новостям, назовите файл этой раскадровки «News.storyboard».

Единые идентификаторы раскадровки

Если вы собираетесь использовать UIStoryboard идентификаторы раскадровки в контроллерах представления, рекомендуется использовать имя класса в качестве идентификатора. Например, «ArticleViewController» будет идентификатором для ArticleViewController. Это уменьшит нагрузку на вас и ваших коллег, связанную с необходимостью думать об уникальном идентификаторе или соглашениях об именах, а также запоминать либо или.

Перечисления

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

extension UIStoryboard {
    enum Storyboard: String {
        case main
        case news
        case gallery
        var filename: String {
            return rawValue.capitalized
        }
    }
}

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

Мы создали вычисляемую переменную filename, потому что в наших файлах будут заглавные буквы. Итак, мы просто получаем rawValue и делаем первую букву заглавной.

let storyboard = UIStoryboard(
    name: UIStoryboard.Storyboard.News.filename, 
    bundle: nil)

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

convenience init(storyboard: Storyboard, bundle: Bundle? = nil) {
    self.init(name: storyboard.filename, bundle: bundle)
}
...
let storyboard = UIStoryboard(storyboard: .news)

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

Причина в том, что если вы предоставите nil в аргумент пакета, класс UIStoryboard будет искать ресурс внутри основного пакета, что делает nil тем же, что и Bundle.main в аргумент bundle. , как указано в документации Apple:

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

- Справочник классов UIStoryboard

Альтернативой удобным инициализаторам является создание функций класса для UIStoryboard, которые возвращают экземпляр UIStoryboard.

class func storyboard(storyboard: Storyboard, bundle: Bundle? = nil) -> UIStoryboard {
    return UIStoryboard(name: storyboard.filename, bundle: bundle)
}
...
let storyboard = UIStoryboard.storyboard(.news)

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

Хорошо, давайте увеличим это число до 11, добавив вещи, которыми я изначально приманил вас к этому посту.

Расширения и обобщения протокола

Обычно в проектах не бывает такого большого количества файлов раскадровки, даже если у нас есть 20 файлов раскадровки, их все равно легко поддерживать с помощью решений, представленных выше. С другой стороны, контроллеры просмотра - это совсем другая история. Проведя быстрый поиск по проекту Xcode моей работы, я обнаружил, что в настоящее время мы используем более 100 различных подклассов UIViewController. Это проблема.

let storyboard = UIStoryboard.storyboard(.news)
let identifier = "ArticleViewController"
let viewController = 
    storyboard.instantiateViewController(withIdentifier: identifier)   
    as! ArticleViewController

Теперь нам нужно иметь дело с управлением не только идентификаторами раскадровки в коде и Interface Builder, но теперь в смесь добавляется приведение типов, потому что функция возвращает только UIViewController:

func instantiateViewController(withIdentifier identifier: String) -> UIViewController

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

РаскадровкаИдентифицируемый протокол

protocol StoryboardIdentifiable {
    static var storyboardIdentifier: String { get }
}

Мы создали protocol, который дает любому соответствующему классу статическую переменную storyboardIdentifier . Это сократит объем работы, которую мы должны выполнять при управлении идентификаторами для контроллеров представления.

РаскадровкаИдентифицируемое расширение протокола

extension StoryboardIdentifiable where Self: UIViewController {
    static var storyboardIdentifier: String {
        return String(describing: self)
    }
}

В нашем объявлении протокола extension есть предложение where, которое применяет его только к классам, которые являются UIViewController или его подклассами. Это предотвратит получение другими классами, такими как NSDate, переменной протокола storyboardIdentifier .

Внутри расширения протокола мы предоставляем метод для динамического получения строки storyboardIdentifier из класса во время выполнения.

Раскадровка: глобальное соответствие

extension UIViewController: StoryboardIdentifiable { }

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

class ArticleViewController: UIViewController { }
...
print(ArticleViewController.storyboardIdentifier)
// prints: ArticleViewController

Расширение UIStoryboard с дженериками

func instantiateViewController<T: UIViewController>() -> T  
    where T: StoryboardIdentifiable

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

Мы используем здесь универсальные шаблоны, которые позволяют нам передавать только классы, которые являются UIViewController или подклассами, а также есть оператор where, включенный в generics объявление, ограничивающее совместимые аргументы теми классами, которые соответствуют протоколу StoryboardIdentifiable.

Если мы попытаемся передать NSObject, Xcode не скомпилируется. Или, если мы попытаемся передать UIViewController, который не соответствует протоколу StoryboardIdentifiable, это тоже остановит компиляцию Xcode. Уже это намного безопаснее. # выигрыш.

<T: UIViewController>() -> T 
    where T: StoryboardIdentifiable

Эй! Что случилось со всем этим странным синтаксисом?

По общепринятому соглашению универсальные шаблоны обычно имеют имя параметра T, однако вы можете заменить его на все, что захотите, когда впервые объявите его внутри угловых скобок. Если бы мы захотели, мы могли бы переименовать T во что-нибудь более читаемое, например, VC или ViewController:

<VC: UIViewController>() -> VC 
    where VC: StoryboardIdentifiable

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

Примечание. Чтобы узнать больше о дженериках Swift, перейдите в документацию.

Вернемся к разбивке:

let optionalViewController =  
   instantiateViewController(withIdentifier: T.storyboardIdentifier)

Мы вызываем исходный UIStoryboard instantiateViewController API и передаем ему storyboardIdentifier переменную, которая возвращает необязательный UIViewController

guard let viewController = optionalViewController as? T else {
    fatalError(“Couldn’t instantiate view controller with identifier \(T.storyboardIdentifier)“)
}
return viewController

Мы пытаемся развернуть необязательный UIViewController и преобразовать его как тот же класс, который мы передали. Если по какой-либо причине контроллер представления не существует в вызывающем его экземпляре раскадровки, произойдет fatalError, и консоль уведомит вас во время отладки, поэтому такого рода ошибки не проскользнут в производственные релизы.

Наконец, мы возвращаем развернутую viewController типа T вызывающей стороне.

На практике

class ArticleViewController: UIViewController
{ 
    func printHeadline() { }
}
...
let storyboard = UIStoryboard.storyboard(.news)
let viewController: ArticleViewController = storyboard.instantiateViewController()
viewController.printHeadline()
presentViewController(viewController, animated: true, completion: nil)

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

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

Обновления

Благодаря отзывам Раифура Андрея и Кайла Дэвиса я обновил статью и примеры кода, чтобы сократить синтаксис и улучшить читаемость. Также были обновлены Github и Gists. Наслаждаться.

Образец кода этого сообщения можно найти на GitHub.

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