Хабрахабр

Контроль над ресурсами. Настраиваем 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.

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

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

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

"$PODS_ROOT"/SwiftGen/bin/swiftgen

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

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

Настройка SwiftGen

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

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


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

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. Не учитываются ресурсы, которые не используются.

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

Показать больше

Похожие публикации

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

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

Кнопка «Наверх»