はじめに
SwiftGen
を使っていて、欲しい機能がなかったので、自分で拡張することにしました。
(情報もあんまりなく英語のものが多かったので、ニッチだけどまとめておこうかなと思ったり)
SwiftGen に関して
SwiftGen is a tool to automatically generate Swift code for resources of your projects (like images, localised strings, etc), to make them type-safe to use.
(引用:SwiftGen)
リソースファイル(画像、ローカライズなどのファイル)を扱う際に、String
がハードコードになってしまうので、それを自動で保管してくれる Swift コードを生成してくれる便利なツールです。
前提
コード生成される仕組みは以下のようになっています。
Stencil というテンプレート構文を書いて、吐き出したいコードにしていきます。
実際は StencilSwiftKit でラップされているので、こちらの書き方に準拠していくことになります。
実装
今回は、json
を読み込ませる想定で話を進めます。
1. 現状
SwiftGen
でjson
を扱う際、中の値にアクセスするのは容易になります。
例えば、以下のようなデータがあった場合は
{
"id": 123456789,
"name": "john"
}
このようなコードが生成されます。
internal enum JSONFiles {
internal enum Response {
private static let _document = JSONDocument(path: "response.json") // ※
internal static let id: Int = _document["id"]
internal static let name: String = _document["name"]
}
}
※ JSONDocument
はSwiftGen
が内部で作成したjson
を扱うためのもの
print(JSONFiles.Response.id) // 123456789
print(JSONFiles.Response.name) // john
上記の通り簡単に取り出すことができます。
2. 課題
チームではテストでjson
を使用しており読み込んでいるのですが、json
の読み込みは Stringのハードコード になっています。
Bundle(for: /* any class */ ).path(forResource: "response", ofType: "json")
これは現状のSwiftGen
ではファイル名の取得ができないため(内部的にはできているが、それが参照できない仕様になっているため)、拡張して対応することにしました。
3. SwiftGen のカスタムテンプレート作成
以下にデフォルトのテンプレートファイルがあるので、こちらを参考にして(コピーしてきて)書くと良いです。
先に生成される完成系を見てからの方が想像しやすいので載せておきます。
internal enum JSONFiles {
internal enum Response {
static let path = "response.json"
}
.
.
print(JSONFiles.Response.path) // "response.json"
これを生成するためのstencil
を書いていきます。
そのためには、StencilSwiftKit
の書き方であるmacro
を使っていきます。
macro
は値を動的に入れることができます。(→ 詳しくは公式リファレンスから)
{% macro documentBlock file document %}
static let path = "{% call transformPath file.path %}"
{% endmacro %}
{% macro transformPath path %}{% filter removeNewlines %}
{% if param.preservePath %}
{{path}}
{% else %}
{{path|basename}}
{% endif %}
{% endfilter %}{% endmacro %}
このコードからjson
のファイル名を取得できます。
(テンプレートファイルにあるものとほぼ同じです。)
さらに、json
の数だけ取得したいので、そのコードを書いていきます。
{{accessModifier}} enum {{param.enumName|default:"JSONFiles"}} {
{% if files.count > 1 or param.forceFileNameEnum %}
{% for file in files %}
{{accessModifier}} enum {{file.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} {
{% filter indent:2 %}{% call fileBlock file %}{% endfilter %}
}
{% endfor %}
{% else %}
{% call fileBlock files.first %}
{% endif %}
}
(こちらもテンプレートファイルにあるものとほぼ同じです。)
以下、軽く解説すると
-
accessModifier
:
デフォルトではinternal
がつくが、swiftgen.yml
から設定で変更できる。 -
{{param.enumName|default:"JSONFiles"}}
:
swiftgen.yml
からenum
名を設定でき、指定がないとJSONFiles
になる。 -
{{file.name|swiftIdentifier:"pretty"|escapeReservedKeywords}}
:
json
名が内部のenum
名に設定される。後ろにあるのはオプション。 -
{% filter indent:2 %}{% call fileBlock file %}{% endfilter %}
:
macro
で作成したpath
が生成される部分。
作成したstencil
ファイルはプロジェクトの任意の場所に設置しましょう。
4. swiftgen.yml の設定
テストでしか必要ないため、主に〇〇Tests
を見るようにしていますが、個々に設定したいようにしてください。
json:
inputs: 〇〇Tests/.../.../ # 読み込み先
filter: .+\.json$ # `json`のみをフィルター
outputs:
- templatePath: 〇〇Tests/.../.../custom-temp.stencil # 用意したカスタムテンプレート
output: 〇〇Tests/.../.../xxx.swift # 書き出し先
あとは、コマンドを実行してコードを生成するだけです。
このようにstencil
を書くことで、オリジナルの生成コードを作成することができ拡張できます。
おまけ
拡張は上記までの手順で問題ありません。
ここからはjson
の拡張をもう少し行ったので記載しておきます。
5. json を簡単に扱えるようにする
上記の実装の場合、名前を取得するとresponse.json
という形式のため、拡張子.json
がついてきます。
実際にデータを呼び出す場合は、
Bundle(for: /* any class */ ).path(forResource: "response", ofType: "json")
このように拡張子が不要なので、この部分はswiftで加工していきます。
また、生成された全てのenum
で使えるように、共通のprotocol
を作り、それをstencil
側で付与します。
共通のプロトコル
共通の処理ができるprotocol
は以下になります。
protocol JSONEntitable {
static var path: String { get }
static var fileName: String { get }
static var data: Any? { get }
}
extension JSONEntitable {
/// `.json`を取り除く
static var fileName: String {
path.components(separatedBy: ".json").joined()
}
/// jsonからデータを取り出す
static var data: Any? {
guard let path = Bundle(for: JSONBundle.self).path(forResource: fileName, ofType: "json") else {
return nil
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
return nil
}
return try? JSONSerialization.jsonObject(with: data, options: [.mutableContainers])
}
}
/// `Bundle(for: )`で使用するためだけのクラス
private final class JSONBundle {}
これをstencil
のmacro
を使って、動的に差し込めるようにします。
{% macro fileNameEnumBlock enumName %}{{enumName}}: JSONEntitable{% endmacro %}
そしてenum
に付与していきます。
{{accessModifier}} enum {{param.enumName|default:"JSONFiles"}} {
{% if files.count > 1 or param.forceFileNameEnum %}
{% for file in files %}
{{accessModifier}} enum {% call fileNameEnumBlock file.name|swiftIdentifier:"pretty"|escapeReservedKeywords %} {
{% filter indent:2 %}{% call fileBlock file %}{% endfilter %}
}
{% endfor %}
{% else %}
{% call fileBlock files.first %}
{% endif %}
}
こうすることで、生成されるコードには共通のプロトコル(JSONEntitable
)が付与されます。
internal enum JSONFiles {
internal enum Response: JSONEntitable {
static let path = "response.json"
}
.
.
実際に使用する際は
print(JSONFiles.Response.path)
// "response.json"
print(JSONFiles.Response.fileName)
// "response"
print(JSONFiles.Response.data!)
// {
// "id": 123456789,
// "name": "john"
// }
となり、かなり使いやすくなりました!
終わりに
コード全般を以下に掲載しておきます!
他でも言及されていましたが、テンプレートのカスタマイズは属人化すると管理が大変なので、要領用法を正しく守って行いましょう!