Контроль над ресурсами. Настраиваем SwiftGen

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

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

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

SwiftGen — это утилита, которая позволяет генерировать Swift-код для доступа к различным ресурсам Xcode-проекта, среди них:

  • шрифты;
  • цвета;
  • сториборды;
  • строки локализации;
  • ассеты.

Подобный код инициализации изображений или строк локализации мог писать каждый:

logoImageView.image = UIImage(named: "Swift") nameLabel.text = String(     format: NSLocalizedString("languages.swift.name", comment: ""),     locale: Locale.current )

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

  • можно сделать опечатку;
  • можно забыть обновить использование в коде после редактирования или удаления ключа/изображения.

Давайте посмотрим, как мы можем улучшить подобный код с помощью SwiftGen.

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

Внедрение в проект

Для начала нужно установить SwiftGen. Мы выбрали его установку через CocoaPods как удобный способ распространения утилиты между всеми участниками команды. Но это можно сделать и другими способами, которые детально описаны в документации. В нашем случае все, что нужно сделать, — это добавить в Podfile pod 'SwiftGen', после чего добавить новую фазу сборки (Build Phase), которая будет запускать SwiftGen перед началом сборки проекта.

"$PODS_ROOT"/SwiftGen/bin/swiftgen

Важно выполнять запуск SwiftGen перед запуском фазы Compile Sources, чтобы избежать ошибок при компиляции проекта.

Теперь можно приступить к адаптации SwiftGen под наш проект.

Настройка SwiftGen

Первым делом необходимо настроить шаблоны, по которым будет генерироваться код для доступа к ресурсам. Утилита уже содержит набор шаблонов для генерации кода, их все можно посмотреть на гитхабе и, в принципе, они готовы к использованию. Шаблоны пишутся на языке Stencil, возможно, вы с ним знакомы, если пользовались Sourcery или играли с Kitura. При желании каждый из шаблонов можно адаптировать под свои гайды.
Для примера возьмем шаблон, который генерирует enum для доступа к строкам локализации. Нам показалось, что в стандартном слишком много лишнего и его можно упростить. Упрощенный пример с поясняющими комментариями находится под спойлером.

Пример шаблона

{# Обработка одного из входных параметров #} {% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %} {# Объявление вспомогательных макросов #} {% macro parametersBlock types %}{% filter removeNewlines:"leading" %}   {% for type in types %}     _ p{{forloop.counter}}: {{type}}{% if not forloop.last %}, {% endif %}   {% endfor %} {% endfilter %}{% endmacro %} {% macro argumentsBlock types %}{% filter removeNewlines:"leading" %}   {% for type in types %}     p{{forloop.counter}}{% if not forloop.last %}, {% endif %}   {% endfor %} {% endfilter %}{% endmacro %} {# Объявление макроса который создает либо вложенный enum либо статичную константу для доступа к значению #} {% macro recursiveBlock table item sp %} {{sp}}{% for string in item.strings %} {{sp}}{% if not param.noComments %} {{sp}}/// {{string.translation}} {{sp}}{% endif %} {{sp}}{% if string.types %} {{sp}}{{accessModifier}} static func {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}({% call parametersBlock string.types %}) -> String { {{sp}}    return localize("{{string.key}}", {% call argumentsBlock string.types %}) {{sp}}} {{sp}}{% else %} {{sp}}{{accessModifier}} static let {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = localize("{{string.key}}") {{sp}}{% endif %} {{sp}}{% endfor %} {{sp}}{% for child in item.children %}  {{sp}}{{accessModifier}} enum {{child.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} { {{sp}}{% set sp2 %}{{sp}}    {% endset %} {{sp}}{% call recursiveBlock table child sp2 %} {{sp}}} {{sp}}{% endfor %} {% endmacro %} import Foundation  {# Объявлем корневой enum #} {% set enumName %}{{param.enumName|default:"L10n"}}{% endset %} {{accessModifier}} enum {{enumName}} {     {% if tables.count > 1 %}     {% for table in tables %}     {{accessModifier}} enum {{table.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} {         {% call recursiveBlock table.name table.levels "    " %}     }     {% endfor %}     {% else %}     {% call recursiveBlock tables.first.name tables.first.levels "    " %}     {% endif %} }  {# Расширяем enum Localization для удобной конвертации ключа в нужную строку локализации #} extension Localization {      fileprivate static func localize(_ key: String, _ args: CVarArg...) -> String {         return String(             format: NSLocalizedString(key, comment: ""),             locale: Locale.current,             arguments: args         )     } }

Сам файл шаблона удобно сохранить в корне проекта, например в папке SwiftGen/Templates, чтобы этот шаблон был доступен всем, кто работает над проектом.
Утилита поддерживает настройку через YAML-файл swiftgen.yml, в котором можно указать пути до исходных файлов, шаблонов и дополнительные параметры. Создадим его в корне проекта в папке Swiftgen, в эту же папку позже сгруппируем и другие файлы, связанные со скриптом.
Для нашего проекта этот файл может выглядеть так:

xcassets: - paths: ../SwiftGenExample/Assets.xcassets   templatePath: Templates/ImageAssets.stencil   output: ../SwiftGenExample/Image.swift   params:     enumName: Image     publicAccess: 1     noAllValues: 1 strings: - paths: ../SwiftGenExample/en.lproj/Localizable.strings   templatePath: Templates/LocalizableStrings.stencil   output: ../SwiftGenExample/Localization.swift   params:     enumName: Localization     publicAccess: 1     noComments: 0

По сути, там указаны пути до файлов и шаблонов, а также дополнительные параметры, которые передаются в контекст шаблона.
Так как файл лежит не в корне проекта, нам нужно указать путь до него при запуске Swiftgen. Изменим наш скрипт запуска:

"$PODS_ROOT"/SwiftGen/bin/swiftgen config run --config SwiftGen/swiftgen.yml

Теперь наш проект можно собрать. После сборки в папке проекта по указанным в swiftgen.yml путям должны появиться два файла Localization.swift и Image.swift. Их нужно добавить в Xcode-проект. В нашем случае сгенерированные файлы содержат следующее:

Для строк:

public enum Localization {      public enum Languages {          public enum ObjectiveC {             /// General-purpose, object-oriented programming language that adds Smalltalk-style messaging to the C programming language             public static let description = localize("languages.objective-c.description")             /// https://en.wikipedia.org/wiki/Objective-C             public static let link = localize("languages.objective-c.link")             /// Objective-C             public static let name = localize("languages.objective-c.name")         }          public enum Swift {             /// General-purpose, multi-paradigm, compiled programming language developed by Apple Inc. for iOS, macOS, watchOS, tvOS, and Linux             public static let description = localize("languages.swift.description")             /// https://en.wikipedia.org/wiki/Swift_(programming_language)             public static let link = localize("languages.swift.link")             /// Swift             public static let name = localize("languages.swift.name")         }     }      public enum MainScreen {         /// Language         public static let title = localize("main-screen.title")          public enum Button {             /// View in Wikipedia             public static let title = localize("main-screen.button.title")         }     } }  extension Localization {      fileprivate static func localize(_ key: String, _ args: CVarArg...) -> String {         return String(             format: NSLocalizedString(key, comment: ""),             locale: Locale.current,             arguments: args         )     } }

Для изображений:

public enum Image {      public enum Logos {         public static var objectiveC: UIImage {             return image(named: "ObjectiveC")         }         public static var swift: UIImage {             return image(named: "Swift")         }     }      private static func image(named name: String) -> UIImage {         let bundle = Bundle(for: BundleToken.self)         guard let image = UIImage(named: name, in: bundle, compatibleWith: nil) else {             fatalError("Unable to load image named \(name).")         }         return image     } }  private final class BundleToken {}

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

    let logos = Image.Logos.self     let localization = Localization.self      private func setupWithLanguage(_ language: ProgrammingLanguage) {         switch language {         case .Swift:             logoImageView.image = logos.swift             nameLabel.text = localization.Languages.Swift.name             descriptionLabel.text = localization.Languages.Swift.description             wikiUrl = localization.Languages.Swift.link.toURL()         case .ObjectiveC:             logoImageView.image = logos.objectiveC             nameLabel.text = localization.Languages.ObjectiveC.name             descriptionLabel.text = localization.Languages.ObjectiveC.description             wikiUrl = localization.Languages.ObjectiveC.link.toURL()         }     }

Настройка проекта в Xcode

Есть одна проблема с генерируемыми файлами: их можно изменить вручную по ошибке, а так как они перезаписываются с нуля при каждой компиляции, эти изменения могут быть утеряны. Чтобы этого избежать, можно блокировать файлы на запись после исполнения скрипта SwiftGen.
Этого можно добиться с помощью команды chmod. Перепишем нашу Build Phase с запуском SwiftGen следующим образом:

if [ -f "$SRCROOT"/SwiftGenExample/Image.swift ]; then     chmod +w "$SRCROOT"/SwiftGenExample/Image.swift fi if [ -f "$SRCROOT"/SwiftGenExample/Localization.swift ]; then     chmod +w "$SRCROOT"/SwiftGenExample/Localization.swift fi "$PODS_ROOT"/SwiftGen/bin/swiftgen config run --config SwiftGen/swiftgen.yml chmod -w "$SRCROOT"/SwiftGenExample/Image.swift chmod -w "$SRCROOT"/SwiftGenExample/Localization.swift

Скрипт получается довольно простой. Перед запуском генерации, если файлы существуют, мы выдаем для них права на запись. После выполнения скрипта мы блокируем возможность изменения файлов.
Для удобства редактирования и проверки скрипта на ревью удобно вынести его в отдельный файл runswiftgen.sh. Итоговый вариант скрипта с небольшими модификациями можно посмотреть здесь. Теперь наша Build Phase будет выглядеть следующим образом: на вход скрипту передаем путь до корневой папки проекта и путь до папки Pods:

"$SRCROOT"/SwiftGen/runswiftgen.sh "$SRCROOT" "$PODS_ROOT"

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

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

А так как файлы Localization.swift и Image.swift генерируются автоматически, их можно добавить в .gitignore, чтобы лишний раз не решать в них конфликты после git merge.

Итоги

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

Плюсы:

  1. Легче контролировать ресурсы проекта.
  2. Уменьшается вероятность опечаток, появляется возможность пользоваться автоподстановкой.
  3. Ошибки проверяются на этапе компиляции.

Минусы:

  1. Нет поддержки Localizable.stringsdict.
  2. Не учитываются ресурсы, которые не используются.

Полностью пример можно посмотреть на гитхабе


ссылка на оригинал статьи https://habr.com/post/423381/

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *