Контроль над ресурсами. Настраиваем 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, чтобы дальнейшая работа с инструментом была более удобной.
Плюсы:
- Легче контролировать ресурсы проекта.
- Уменьшается вероятность опечаток, появляется возможность пользоваться автоподстановкой.
- Ошибки проверяются на этапе компиляции.
Минусы:
- Нет поддержки Localizable.stringsdict.
- Не учитываются ресурсы, которые не используются.
Полностью пример можно посмотреть на гитхабе