Control over resources. Customize SwiftGen
- Tutorial
Probably, in every large iOS project - a long-liver, you can stumble upon icons that are not used anywhere, or access to localization keys that have not existed for a long time. Most often, such situations arise because of inattention, and automation is the best cure for inattention.
In the HeadHunter iOS team, we pay great attention to automating routine tasks that a developer may face. With this article we want to start a cycle of stories about those tools and approaches that simplify our daily work.
Some time ago we managed to take control of application resources using the SwiftGen utility. How to set it up, how to live with it and how this utility helps to shift the compiler’s checks on the relevance of resources, and will be discussed under the cut.
SwiftGen is a utility that allows you to generate Swift code to access various Xcode project resources, among them:
- fonts;
- colors;
- storyboards;
- localization strings;
- assets.
Such code for initializing images or localization strings could be written by everyone:
logoImageView.image = UIImage(named: "Swift")
nameLabel.text = String(
format: NSLocalizedString("languages.swift.name", comment: ""),
locale: Locale.current
)
To indicate the name of an image or a localization key, we use string literals. What is written between double quotes is not validated by the compiler or development environment (Xcode). This is the following set of problems:
- You can make a typo;
- You may forget to update the usage in the code after editing or deleting the key / image.
Let's see how we can improve this code with SwiftGen.
For our team, generation was only relevant for strings and assets, which will be discussed in the article. Generation for other types of resources is similar and, if desired, is easily mastered independently.
Introduction to the project
First you need to install SwiftGen. We chose to install it via CocoaPods as a convenient way to distribute the utility among all team members. But this can be done in other ways, which are described in detail in the documentation . In our case, all that needs to be done is to add to the Podfile pod 'SwiftGen'
, then add a new build phase ( Build Phase
), which will run SwiftGen before starting the build project.
"$PODS_ROOT"/SwiftGen/bin/swiftgen
It is important to run SwiftGen before starting the phase Compile Sources
to avoid errors when compiling the project.
Now you can begin to adapt SwiftGen for our project.
SwiftGen Setup
First of all, you need to configure the templates by which the code for accessing resources will be generated. The utility already contains a set of templates for generating code, all of them can be viewed on the githaba and, in principle, they are ready for use. The templates are written in the Stencil language , perhaps you are familiar with it if you used Sourcery or played with Kitura . If desired, each of the templates can be adapted to your guides.
For example, take a template that generates enum
to access localization strings. It seemed to us that in the standard too much is superfluous and it can be simplified. A simplified example with explanatory comments is under the spoiler.
{# Обработка одного из входных параметров #}
{% 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
)
}
}
The template file itself is conveniently stored in the project root, for example, in a folder SwiftGen/Templates
so that this template is available to everyone who works on the project.
The utility supports customization through a YAML file swiftgen.yml
, in which you can specify paths to source files, templates, and advanced parameters. Create it in the project root in the folder Swiftgen
, later group the other files associated with the script into the same folder.
For our project, this file might look like this:
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
In fact, there are indicated the paths to the files and templates, as well as additional parameters that are passed to the template context.
Since the file is not in the root of the project, we need to specify the path to it when you start Swiftgen. Let's change our startup script:
"$PODS_ROOT"/SwiftGen/bin/swiftgen config run --config SwiftGen/swiftgen.yml
Now our project can be assembled. After assembling in the project folder swiftgen.yml
, two files should appear in the paths Localization.swift
and Image.swift
. They need to be added to the Xcode project. In our case, the generated files contain the following:
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 {}
Now it is possible to replace all uses of the localization and initialization lines of the view images UIImage(named: "")
with what we generated. This will make it easier for us to track changes in keys of localization strings or remove them. In any of these cases, the project simply fails until all errors associated with the changes are corrected.
After the changes, our code looks like this:
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()
}
}
Setting up a project in Xcode
There is one problem with the generated files: they can be changed manually by mistake, and since they are overwritten from scratch at each compilation, these changes may be lost. To avoid this, you can block files on the record after the execution of the script SwiftGen
.
This can be achieved with the command chmod
. We rewrite ours Build Phase
with the launch of SwiftGen as follows:
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
The script is pretty simple. Before starting the generation, if the files exist, we grant write permissions for them. After executing the script, we block the ability to change files.
For ease of editing and checking the script for review, it is convenient to put it in a separate file runswiftgen.sh
. The final version of the script with minor modifications can be found here. Now our Build Phase
will look like this: at the entrance to the script, we pass the path to the root folder of the project and the path to the Pods folder:
"$SRCROOT"/SwiftGen/runswiftgen.sh "$SRCROOT" "$PODS_ROOT"
Rebuilding the project, and now when trying to manually modify the generated file, a warning will appear:
So, the Swiftgen folder now contains a configuration file, a script to block files and launch, Swiftgen
and a folder with customized templates. It is convenient to add it to the project for further editing if necessary.
And as files Localization.swift
and Image.swift
are generated automatically, you can add them in .gitignore, to once again not to resolve conflicts in their after git merge
.
Results
SwiftGen is a good tool to protect against our carelessness when working with project resources. With it, we managed to automatically generate code to access application resources and shift some of the work of checking the relevance of resources on the shoulders of the compiler, which means we can simplify our work a bit. In addition, we set up the Xcode project so that further work with the tool was more convenient.
Pros:
- Easier to control project resources.
- The probability of typos decreases, it becomes possible to use auto substitution.
- Errors are checked at compile time.
Minuses:
- No support for Localizable.stringsdict.
- Resources that are not used are not counted.
A full example can be seen on the githaba.