記事

XcodeでSwiftを活用|Run Scriptで多言語と画像資産の欠損を自動検出

Xcodeユーザー向けにSwiftでRun Scriptを作成し、多言語LocalizationとImage Assetsの欠損を自動チェック。手間削減と品質向上を実現する具体的手法を紹介します。

XcodeでSwiftを活用|Run Scriptで多言語と画像資産の欠損を自動検出

本記事は AI による翻訳をもとに作成されています。表現が不自然な箇所がありましたら、ぜひコメントでお知らせください。

記事一覧


Xcodeで直接Swiftを使ってShell Scriptを書く!

Localization 多言語および Image Assets の不足チェック導入、Swift で作る Shell Script スクリプト

Photo by [Glenn Carstens-Peters](https://unsplash.com/@glenncarstenspeters?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Glenn Carstens-Peters

背景

自分の不注意で、多言語ファイルを編集する際に「;」をよく抜かしてしまい、そのためにアプリのビルド後の言語表示が誤ることがよくあります。さらに開発が進むにつれて言語ファイルがどんどん大きくなり、重複したり使われなくなった文言が混在して非常に混乱しています(画像アセットも同様の状況です)。

ずっとこの分野の問題を助けるツールを探していました。以前は iOSLocalizationEditor という Mac アプリを使っていましたが、これは主にローカライズファイルの編集ツールで、ファイルの内容を読み取り編集するもので、自動チェック機能はありませんでした。

期待される機能

build 時に多言語のエラー、欠落、重複、Image Assets の欠落を自動でチェックできます。

解決策

期待する機能を実現するには、Build Phases に Run Script チェックスクリプトを追加する必要があります。

しかし、チェックスクリプトは shell script で書く必要があり、自分の shell script の習熟度があまり高くなかったため、既存のスクリプトをネットで探してみても完全に期待通りの機能を持つものは見つかりませんでした。諦めかけたときにふと思いつきました:

Shell Script は Swift で書けるんです

shell script に比べてより馴染みがあり、習熟度も高いです!この方向で探したところ、既存のツールスクリプトを2つ見つけました!

freshOS チームが作成した2つのチェックツール:

完全に私たちの期待する機能要件を満たしています!しかも Swift で書かれているので、カスタマイズや改造も簡単です。

Localize 🏁 多言語ファイル検査ツール

機能:

  • ビルド時に自動でチェック

  • ローカライズファイルの自動整形と整理

  • 多言語および主要言語の欠落や余分な部分のチェック

  • 多言語の重複文のチェック

  • 多言語の未翻訳文のチェック

  • 多言語で未使用の文言のチェック

インストール方法:

  1. ツールの Swift スクリプトファイルをダウンロード

  2. プロジェクトディレクトリに配置します 例: ${SRCROOT}/Localize.swift

  3. プロジェクト設定を開く → iOSターゲット → Build Phases → 左上の「+」 → New Run Script Phase → スクリプト内容にパスを貼り付け EX: ${SRCROOT}/Localize.swift

  1. Xcodeで Localize.swift ファイルを開いて設定を編集します。ファイルの上部に変更可能な設定項目が表示されます:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// チェックスクリプトを有効にする
let enabled = true

// ローカライズファイルのディレクトリ
let relativeLocalizableFolders = "/Resources/Languages"

// プロジェクトディレクトリ(コード内でキーが使われているかを検索するため)
let relativeSourceFolder = "/Sources"

// コード内の NSLocalized ローカライズファイルの正規表現パターン
// 追加可能、変更不要
let patterns = [
    "NSLocalized(Format)?String\\(\\s*@?\"([\\w\\.]+)\"", // Swift と Objc ネイティブ
    "Localizations\\.((?:[A-Z]{1}[a-z]*[A-z]*)*(?:\\.[A-Z]{1}[a-z]*[A-z]*)*)", // Laurine 呼び出し
    "L10n.tr\\(key: \"(\\w+)\"", // SwiftGen 生成
    "ypLocalized\\(\"(.*)\"\\)",
    "\"(.*)\".localized" // "key".localized パターン
]

// 「未使用のキー警告」を無視するキー
let ignoredFromUnusedKeys: [String] = []
/* 例
let ignoredFromUnusedKeys = [
    "NotificationNoOne",
    "NotificationCommentPhoto",
    "NotificationCommentHisPhoto",
    "NotificationCommentHerPhoto"
]
*/

// メイン言語
let masterLanguage = "en"

// ファイルを a-z ソート・整理を有効にする
let sanitizeFiles = false

// プロジェクトが単一言語か多言語か
let singleLanguage = false

// 未翻訳キーのチェックを有効にする
let checkForUntranslated = true
  1. ビルド!成功!

チェック結果のメッセージタイプ:

  • Build Error
    • [Duplication] ローカライズファイル内で項目が重複しています
    • [Unused Key] ローカライズファイルに定義されている項目がコード内で使用されていません
    • [Missing] ローカライズファイルに定義されていない項目がコード内で使用されています
    • [Redundant] このローカライズファイルの項目はメインのローカライズファイルに比べて余分です
    • [Missing Translation] 項目はメインのローカライズファイルにありますが、このローカライズファイルには欠けています
  • Build Warning ⚠️
    • [Potentially Untranslated] この項目は翻訳されていません(メイン言語ファイルの内容と同じです)

まだ完了していません。現在は自動チェックの通知が表示されますが、さらに自分でカスタマイズする必要があります。

カスタムマッチ正規表現:

Localize.swift の先頭設定ブロック patterns 部分の最初の項目を振り返ってみましょう:

"NSLocalized(Format)?String\\(\\s*@?\"([\\w\\.]+)\""

Swift/ObjCの NSLocalizedString() メソッドにマッチします。この正規表現は "Home.Title" のような形式の文だけにマッチします。完全な文章やフォーマットパラメータ付きの場合は、誤って[Unused Key]と判断されます。

EX: "Hi, %@ welcome to my app"、"Hello World!" ← これらの文はマッチしません

patterns の設定を新たに追加するか、既存の patterns を次のように変更できます:

"NSLocalized(Format)?String\\(\\s*@?\"([^(\")]+)\""

主に NSLocalizedString メソッドの後のマッチング文を調整し、任意の文字列を " が出現するまで取得するようにしました。ご自身のニーズに合わせて、こちらからカスタマイズも可能です。

多言語ファイルのフォーマットチェック機能を追加:

このスクリプトは言語ファイルの内容対応チェックのみを行い、ファイル形式の正確さ(「;」を忘れていないかなど)はチェックしません。必要な場合はご自身で追加してください!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//....
let formatResult = shell("plutil -lint \(location)")
guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == "OK" else {
  let str = "\(path)/\(name).lproj"
            + "/Localizable.strings:1: "
            + "error: [File Invaild] "
            + "この Localizable.strings ファイルのフォーマットが無効です。"
  print(str)
  numberOfErrors += 1
  return
}
//....

func shell(_ command: String) -> String {
    let task = Process()
    let pipe = Pipe()

    task.standardOutput = pipe
    task.arguments = ["-c", command]
    task.launchPath = "/bin/bash"
    task.launch()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)!

    return output
}

shell() を追加してシェルスクリプトを実行し、plutil -lint を使って plist のローカライズファイルのフォーマット正確性をチェックします。エラーや「;」の不足がある場合はエラーを返し、問題なければ OK を返して判定します!

チェック箇所は LocalizationFiles->process() -> let location = singleLanguage… の後、約135行目あたりに追加できます。または、最後に提供する完全改造版を参照してください。

その他のカスタマイズ:

私たちは自分のニーズに合わせてカスタマイズできます。例えば、error を warning に変更したり、特定のチェック機能(例:Potentially Untranslated、Unused Key)を無効にしたりできます。スクリプトは Swift なので、慣れている私たちには安心して変更できます!間違って壊す心配もありません!

ビルド時にエラー❌を出すには:

1
print("Projectファイル.lproj" + "/ファイル:行: " + "error: エラーメッセージ")

build 時に Warning ⚠️ を表示させるには:

1
print("Projectファイル.lproj" + "/ファイル:行: " + "warning: 警告メッセージ")

最終魔改版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
#!/usr/bin/env xcrun --sdk macosx swift

import Foundation

// WHAT
// 1. 他のローカライズファイルで欠落しているキーを検出する
// 2. 未翻訳の可能性があるキーを検出する
// 3. 重複しているキーを検出する
// 4. 未使用のキーを検出し、一括削除用のスクリプトを生成する

// MARK: Start Of Configurable Section

/*
 スクリプトを有効または無効にできます
 */
let enabled = true

/*
 パスをここに記入してください。例 ->  Resources/Localizations/Languages
 */
let relativeLocalizableFolders = "/streetvoice/SupportingFiles"

/*
 プロジェクト内で実際に使用しているローカライズキーを検索するためのソースフォルダのパスです
 */
let relativeSourceFolder = "/streetvoice"

/*
 ローカライズを認識するための正規表現パターンです。
 */
let patterns = [
    "NSLocalized(Format)?String\\(\\s*@?\"([^(\")]+)\"", // Swift と Objc ネイティブ
    "Localizations\\.((?:[A-Z]{1}[a-z]*[A-z]*)*(?:\\.[A-Z]{1}[a-z]*[A-z]*)*)", // Laurine 呼び出し
    "L10n.tr\\(key: \"(\\w+)\"", // SwiftGen 生成
    "ypLocalized\\(\"(.*)\"\\)",
    "\"(.*)\".localized" // "key".localized パターン
]

/*
 "未使用" と認識したくないキーです
 例えば、連結して使うキーは解析で検出されないため、
 誤検知を防ぐためにここに追加してください :)
 */
let ignoredFromUnusedKeys: [String] = []
/* 例
let ignoredFromUnusedKeys = [
    "NotificationNoOne",
    "NotificationCommentPhoto",
    "NotificationCommentHisPhoto",
    "NotificationCommentHerPhoto"
]
*/

let masterLanguage = "base"

/*
 ファイルのサニタイズはコメントや空行を削除し、
 キーをアルファベット順に並べ替えます。
 */
let sanitizeFiles = false

/*
 複数言語があるかどうかを判定します。
 */
let singleLanguage = false

/*
 アプリ内に存在するがマスター翻訳にないキーがある場合にエラーを表示するかどうか
 */
let checkForUntranslated = false

// MARK: End Of Configurable Section

if enabled == false {
    print("Localization check cancelled")
    exit(000)
}

// 対応言語のリストを自動検出
func listSupportedLanguages() -> [String] {
    var sl: [String] = []
    let path = FileManager.default.currentDirectoryPath + relativeLocalizableFolders
    if !FileManager.default.fileExists(atPath: path) {
        print("Invalid configuration: \(path) does not exist.")
        exit(1)
    }
    let enumerator = FileManager.default.enumerator(atPath: path)
    let extensionName = "lproj"
    print("Found these languages:")
    while let element = enumerator?.nextObject() as? String {
        if element.hasSuffix(extensionName) {
            print(element)
            let name = element.replacingOccurrences(of: ".\(extensionName)", with: "")
            sl.append(name)
        }
    }
    return sl
}

let supportedLanguages = listSupportedLanguages()
var ignoredFromSameTranslation: [String: [String]] = [:]
let path = FileManager.default.currentDirectoryPath + relativeLocalizableFolders
var numberOfWarnings = 0
var numberOfErrors = 0

struct LocalizationFiles {
    var name = ""
    var keyValue: [String: String] = [:]
    var linesNumbers: [String: Int] = [:]

    init(name: String) {
        self.name = name
        process()
    }

    mutating func process() {
        if sanitizeFiles {
            removeCommentsFromFile()
            removeEmptyLinesFromFile()
            sortLinesAlphabetically()
        }
        let location = singleLanguage ? "\(path)/Localizable.strings" : "\(path)/\(name).lproj/Localizable.strings"
        
        let formatResult = shell("plutil -lint \(location)")
        guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == "OK" else {
            let str = "\(path)/\(name).lproj"
                + "/Localizable.strings:1: "
                + "error: [File Invaild] "
                + "この Localizable.strings ファイルの形式が無効です。"
            print(str)
            numberOfErrors += 1
            return
        }
        
        guard let string = try? String(contentsOfFile: location, encoding: .utf8) else {
            return
        }

        let lines = string.components(separatedBy: .newlines)
        keyValue = [:]

        let pattern = "\"(.*)\" = \"(.+)\";"
        let regex = try? NSRegularExpression(pattern: pattern, options: [])
        var ignoredTranslation: [String] = []

        for (lineNumber, line) in lines.enumerated() {
            let range = NSRange(location: 0, length: (line as NSString).length)

            // 無視するパターン
            let ignoredPattern = "\"(.*)\" = \"(.+)\"; *\\/\\/ *ignore-same-translation-warning"
            let ignoredRegex = try? NSRegularExpression(pattern: ignoredPattern, options: [])
            if let ignoredMatch = ignoredRegex?.firstMatch(in: line,
                                                           options: [],
                                                           range: range) {
                let key = (line as NSString).substring(with: ignoredMatch.range(at: 1))
                ignoredTranslation.append(key)
            }

            if let firstMatch = regex?.firstMatch(in: line, options: [], range: range) {
                let key = (line as NSString).substring(with: firstMatch.range(at: 1))
                let value = (line as NSString).substring(with: firstMatch.range(at: 2))

                if keyValue[key] != nil {
                    let str = "\(path)/\(name).lproj"
                        + "/Localizable.strings:\(linesNumbers[key]!): "
                        + "error: [Duplication] \"\(key)\" "
                        + "\(name.uppercased()) ファイルで重複しています"
                    print(str)
                    numberOfErrors += 1
                } else {
                    keyValue[key] = value
                    linesNumbers[key] = lineNumber + 1
                }
            }
        }
        print(ignoredFromSameTranslation)
        ignoredFromSameTranslation[name] = ignoredTranslation
    }

    func rebuildFileString(from lines: [String]) -> String {
        return lines.reduce("") { (r: String, s: String) -> String in
            (r == "") ? (r + s) : (r + "\n" + s)
        }
    }

    func removeEmptyLinesFromFile() {
        let location = "\(path)/\(name).lproj/Localizable.strings"
        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
            var lines = string.components(separatedBy: .newlines)
            lines = lines.filter { $0.trimmingCharacters(in: .whitespaces) != "" }
            let s = rebuildFileString(from: lines)
            try? s.write(toFile: location, atomically: false, encoding: .utf8)
        }
    }

    func removeCommentsFromFile() {
        let location = "\(path)/\(name).lproj/Localizable.strings"
        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
            var lines = string.components(separatedBy: .newlines)
            lines = lines.filter { !$0.hasPrefix("//") }
            let s = rebuildFileString(from: lines)
            try? s.write(toFile: location, atomically: false, encoding: .utf8)
        }
    }

    func sortLinesAlphabetically() {
        let location = "\(path)/\(name).lproj/Localizable.strings"
        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
            let lines = string.components(separatedBy: .newlines)

            var s = ""
            for (i, l) in sortAlphabetically(lines).enumerated() {
                s += l
                if i != lines.count - 1 {
                    s += "\n"
                }
            }
            try? s.write(toFile: location, atomically: false, encoding: .utf8)
        }
    }

    func removeEmptyLinesFromLines(_ lines: [String]) -> [String] {
        return lines.filter { $0.trimmingCharacters(in: .whitespaces) != "" }
    }

    func sortAlphabetically(_ lines: [String]) -> [String] {
        return lines.sorted()
    }
}

// MARK: - メモリ内にローカライズファイルを読み込む

let masterLocalizationFile = LocalizationFiles(name: masterLanguage)
let localizationFiles = supportedLanguages
    .filter { $0 != masterLanguage }
    .map { LocalizationFiles(name: $0) }

// MARK: - 未使用キーを検出

let sourcesPath = FileManager.default.currentDirectoryPath + relativeSourceFolder
let fileManager = FileManager.default
let enumerator = fileManager.enumerator(atPath: sourcesPath)
var localizedStrings: [String] = []
while let swiftFileLocation = enumerator?.nextObject() as? String {
    // 拡張子をチェック
    if swiftFileLocation.hasSuffix(".swift") \\|\\| swiftFileLocation.hasSuffix(".m") \\|\\| swiftFileLocation.hasSuffix(".mm") {
        let location = "\(sourcesPath)/\(swiftFileLocation)"
        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
            for p in patterns {
                let regex = try? NSRegularExpression(pattern: p, options: [])
                let range = NSRange(location: 0, length: (string as NSString).length) // Obj c 用
                regex?.enumerateMatches(in: string,
                                        options: [],
                                        range: range,
                                        using: { result, _, _ in
                                            if let r = result {
                                                let value = (string as NSString).substring(with: r.range(at: r.numberOfRanges - 1))
                                                localizedStrings.append(value)
                                            }
                })
            }
        }
    }
}

var masterKeys = Set(masterLocalizationFile.keyValue.keys)
let usedKeys = Set(localizedStrings)
let ignored = Set(ignoredFromUnusedKeys)
let unused = masterKeys.subtracting(usedKeys).subtracting(ignored)
let untranslated = usedKeys.subtracting(masterKeys)

// ここで未使用キーを一括削除するためのXcode正規表現の置換スクリプトを生成!
var replaceCommand = "\"("
var counter = 0
for v in unused {
    var str = "\(path)/\(masterLocalizationFile.name).lproj/Localizable.strings:\(masterLocalizationFile.linesNumbers[v]!): "
    str += "error: [Unused Key] \"\(v)\" は一度も使用されていません"
    print(str)
    numberOfErrors += 1
    if counter != 0 {
        replaceCommand += "\\|"
    }
    replaceCommand += v
    if counter == unused.count - 1 {
        replaceCommand += ")\" = \".*\";"
    }
    counter += 1
}

print(replaceCommand)

// MARK: - 各翻訳ファイルをマスター(en)と比較

for file in localizationFiles {
    for k in masterLocalizationFile.keyValue.keys {
        if file.keyValue[k] == nil {
            var str = "\(path)/\(file.name).lproj/Localizable.strings:\(masterLocalizationFile.linesNumbers[k]!): "
            str += "error: [Missing] \"\(k)\"\(file.name.uppercased()) ファイルにありません"
            print(str)
            numberOfErrors += 1
        }
    }

    let redundantKeys = file.keyValue.keys.filter { !masterLocalizationFile.keyValue.keys.contains($0) }

    for k in redundantKeys {
        let str = "\(path)/\(file.name).lproj/Localizable.strings:\(file.linesNumbers[k]!): "
            + "error: [Redundant key] \"\(k)\"\(file.name.uppercased()) ファイルで冗長です"

        print(str)
    }
}

if checkForUntranslated {
    for key in untranslated {
        var str = "\(path)/\(masterLocalizationFile.name).lproj/Localizable.strings:1: "
        str += "error: [Missing Translation] \(key) は翻訳されていません"

        print(str)
        numberOfErrors += 1
    }
}

print("警告の数 : \(numberOfWarnings)")
print("エラーの数 : \(numberOfErrors)")

if numberOfErrors > 0 {
    exit(1)
}

func shell(_ command: String) -> String {
    let task = Process()
    let pipe = Pipe()

    task.standardOutput = pipe
    task.arguments = ["-c", command]
    task.launchPath = "/bin/bash"
    task.launch()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)!

    return output
}

最後最後、まだ終わっていません!

私たちの Swift チェックツールスクリプトの調整が完了したら、コンパイルして実行ファイルにすることでビルド時間を短縮しましょう。そうしないと、毎回ビルド時に再コンパイルが発生し(約90%の時間を削減可能)、時間がかかります。

ターミナルを開き、プロジェクト内のチェックツールスクリプトがあるディレクトリに移動して実行してください:

1
swiftc -o Localize Localize.swift

そして Build Phases に戻り、Script の内容のパスを実行ファイルに変更します。

EX: ${SRCROOT}/Localize

完了!

ツール 2. Asset Checker 👮 画像リソースチェックツール

機能:

  • build 時に自動でチェック

  • 画像の欠落チェック:名前は呼び出されているが、画像リソースディレクトリに存在しないもの

  • 画像の余分:名前は未使用だが、画像リソースディレクトリに存在するもの

インストール方法:

  1. ツールの Swift スクリプトファイルをダウンロード

  2. プロジェクトディレクトリに配置します 例: ${SRCROOT}/AssetChecker.swift

  3. プロジェクト設定を開く → iOSターゲット → Build Phases → 左上の「+」 → New Run Script Phase → スクリプト内容にパスを貼り付ける

1
2
${SRCROOT}/AssetChecker.swift ${SRCROOT}/專案目錄 ${SRCROOT}/Resources/Images.xcassets
//${SRCROOT}/Resources/Images.xcassets = あなたの .xcassets の場所

パラメータを直接パスに渡すことができます。パラメータ1:プロジェクトディレクトリの場所、パラメータ2:画像リソースディレクトリの場所;または多言語チェックツールと同様に AssetChecker.swift の先頭のパラメータ設定ブロックを編集してください:

1
2
3
4
5
6
7
8
9
10
// 設定してください \o/

// プロジェクトディレクトリの場所(画像がコード内で使用されているか検索するため)
var sourcePathOption:String? = nil

// .xcassets ディレクトリの場所
var assetCatalogPathOption:String? = nil

// 未使用の警告を無視する項目
let ignoredUnusedNames = [String]()
  1. ビルド!成功!

チェック結果のメッセージタイプ:

  • Build Error
    • [Asset Missing] プログラム内で呼び出されているが、画像リソースフォルダに存在しない項目
  • Build Warning ⚠️
    • [Asset Unused] 項目はコード内で使用されていませんが、画像リソースフォルダには存在します p.s 画像が動的な変数で提供されている場合、チェックツールは認識できません。その場合は ignoredUnusedNames に追加して例外に設定してください。

他の操作は多言語チェックツールと同様なのでここでは省略します;最も重要なのは、調整後に必ずコンパイルして実行ファイルにし、run script の内容を実行ファイルに変更することを忘れないでください!

自分のツールを開発しよう!

画像リソースチェックツールのスクリプトを参考にできます:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#!/usr/bin/env xcrun --sdk macosx swift

import Foundation

// 設定してください \o/
var sourcePathOption:String? = nil
var assetCatalogPathOption:String? = nil
let ignoredUnusedNames = [String]()

for (index, arg) in CommandLine.arguments.enumerated() {
    switch index {
    case 1:
        sourcePathOption = arg
    case 2:
        assetCatalogPathOption = arg
    default:
        break
    }
}

guard let sourcePath = sourcePathOption else {
    print("AssetChecker:: error: ソースパスが指定されていません!")
    exit(0)
}

guard let assetCatalogAbsolutePath = assetCatalogPathOption else {
    print("AssetChecker:: error: アセットカタログのパスが指定されていません!")
    exit(0)
}

print("\(sourcePath) 内のソースから \(assetCatalogAbsolutePath) のアセットを検索中")

/* ここに誤検知するアセットを追加してください
 例えば、実行時にアセット名を生成する場合など
let ignoredUnusedNames = [
    "IconArticle",
    "IconMedia",
    "voteEN",
    "voteES",
    "voteFR"
] 
*/


// MARK : - 設定終了
func elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] {
    var elements = [String]()
    while let e = enumerator?.nextObject() as? String {
        elements.append(e)
    }
    return elements
}


// MARK: - アセット一覧取得
func listAssets() -> [String] {
    let extensionName = "imageset"
    let enumerator = FileManager.default.enumerator(atPath: assetCatalogAbsolutePath)
    return elementsInEnumerator(enumerator)
        .filter { $0.hasSuffix(extensionName) }                             // アセットかどうか
        .map { $0.replacingOccurrences(of: ".\(extensionName)", with: "") } // 拡張子を削除
        .map { $0.components(separatedBy: "/").last ?? $0 }                 // フォルダパスを削除
}


// MARK: - コード内で使用されているアセット一覧取得
func localizedStrings(inStringFile: String) -> [String] {
    var localizedStrings = [String]()
    let namePattern = "([\\w-]+)"
    let patterns = [
        "#imageLiteral\\(resourceName: \"\(namePattern)\"\\)", // Image Literal
        "UIImage\\(named:\\s*\"\(namePattern)\"\\)", // SwiftのUIImage呼び出し
        "UIImage imageNamed:\\s*\\@\"\(namePattern)\"", // UIImageの呼び出し
        "\\<image name=\"\(namePattern)\".*", // Storyboardのリソース
        "R.image.\(namePattern)\\(\\)" // R.swift対応
    ]
    for p in patterns {
        let regex = try? NSRegularExpression(pattern: p, options: [])
        let range = NSRange(location:0, length:(inStringFile as NSString).length)
        regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in
            if let r = result {
                let value = (inStringFile as NSString).substring(with:r.range(at: 1))
                localizedStrings.append(value)
            }
        }
    }
    return localizedStrings
}

func listUsedAssetLiterals() -> [String] {
    let enumerator = FileManager.default.enumerator(atPath:sourcePath)
    print(sourcePath)
    
    #if swift(>=4.1)
        return elementsInEnumerator(enumerator)
            .filter { $0.hasSuffix(".m") \\|\\| $0.hasSuffix(".swift") \\|\\| $0.hasSuffix(".xib") \\|\\| $0.hasSuffix(".storyboard") }    // SwiftとObj-Cファイルのみ
            .map { "\(sourcePath)/\($0)" }                              // ファイルパスを生成
            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // ファイル内容を取得
            .compactMap{$0}
            .compactMap{$0}                                             // nilを除去
            .map(localizedStrings)                                      // localizedStringsを検索
            .flatMap{$0}                                                // 平坦化
    #else
        return elementsInEnumerator(enumerator)
            .filter { $0.hasSuffix(".m") \\|\\| $0.hasSuffix(".swift") \\|\\| $0.hasSuffix(".xib") \\|\\| $0.hasSuffix(".storyboard") }    // SwiftとObj-Cファイルのみ
            .map { "\(sourcePath)/\($0)" }                              // ファイルパスを生成
            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // ファイル内容を取得
            .flatMap{$0}
            .flatMap{$0}                                                // nilを除去
            .map(localizedStrings)                                      // localizedStringsを検索
            .flatMap{$0}                                                // 平坦化
    #endif
}


// MARK: - スクリプト開始
let assets = Set(listAssets())
let used = Set(listUsedAssetLiterals() + ignoredUnusedNames)


// 未使用アセットの警告を生成
let unused = assets.subtracting(used)
unused.forEach { print("\(assetCatalogAbsolutePath):: warning: [Asset Unused] \($0)") }


// 壊れた(存在しない)アセットのエラーを生成
let broken = used.subtracting(assets)
broken.forEach { print("\(assetCatalogAbsolutePath):: error: [Asset Missing] \($0)") }

if broken.count > 0 {
    exit(1)
}

言語チェックスクリプトと比べて、このスクリプトはシンプルで重要な機能がすべて揃っており、とても参考になります!

P.S コードに localizedStrings() という名前が出てくるのは、多言語チェックツールのロジックから流用してメソッド名を変更し忘れたと思われます XD

例えば:

1
2
3
4
5
6
7
8
9
10
for (index, arg) in CommandLine.arguments.enumerated() {
    switch index {
    case 1:
        // 引数1
    case 2:
        // 引数2
    default:
        break
    }
}

^外部パラメータを受け取る方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
func elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] {
    var elements = [String]()
    while let e = enumerator?.nextObject() as? String {
        elements.append(e)
    }
    return elements
}

func localizedStrings(inStringFile: String) -> [String] {
    var localizedStrings = [String]()
    let namePattern = "([\\w-]+)"
    let patterns = [
        "#imageLiteral\\(resourceName: \"\(namePattern)\"\\)", // 画像リテラル
        "UIImage\\(named:\\s*\"\(namePattern)\"\\)", // デフォルトの UIImage 呼び出し (Swift)
        "UIImage imageNamed:\\s*\\@\"\(namePattern)\"", // デフォルトの UIImage 呼び出し
        "\\<image name=\"\(namePattern)\".*", // ストーリーボードのリソース
        "R.image.\(namePattern)\\(\\)" // R.swift サポート
    ]
    for p in patterns {
        let regex = try? NSRegularExpression(pattern: p, options: [])
        let range = NSRange(location:0, length:(inStringFile as NSString).length)
        regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in
            if let r = result {
                let value = (inStringFile as NSString).substring(with:r.range(at: 1))
                localizedStrings.append(value)
            }
        }
    }
    return localizedStrings
}

func listUsedAssetLiterals() -> [String] {
    let enumerator = FileManager.default.enumerator(atPath:sourcePath)
    print(sourcePath)
    
    #if swift(>=4.1)
        return elementsInEnumerator(enumerator)
            .filter { $0.hasSuffix(".m") \\|\\| $0.hasSuffix(".swift") \\|\\| $0.hasSuffix(".xib") \\|\\| $0.hasSuffix(".storyboard") }    // Swift と Obj-C ファイルのみ
            .map { "\(sourcePath)/\($0)" }                              // ファイルパスを作成
            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // ファイル内容を取得
            .compactMap{$0}
            .compactMap{$0}                                             // nil を除去
            .map(localizedStrings)                                      // localizedStrings の出現を検索
            .flatMap{$0}                                                // フラット化
    #else
        return elementsInEnumerator(enumerator)
            .filter { $0.hasSuffix(".m") \\|\\| $0.hasSuffix(".swift") \\|\\| $0.hasSuffix(".xib") \\|\\| $0.hasSuffix(".storyboard") }    // Swift と Obj-C ファイルのみ
            .map { "\(sourcePath)/\($0)" }                              // ファイルパスを作成
            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // ファイル内容を取得
            .flatMap{$0}
            .flatMap{$0}                                                // nil を除去
            .map(localizedStrings)                                      // localizedStrings の出現を検索
            .flatMap{$0}                                                // フラット化
    #endif
}

^すべてのプロジェクトファイルを走査して正規表現でマッチングする方法

1
2
3
4
// ビルド時にエラーを表示するには ❌:
print("Project檔案.lproj" + "/檔案:行: " + "error: エラーメッセージ")
// ビルド時に警告を表示するには ⚠️:
print("Project檔案.lproj" + "/檔案:行: " + "warning: 警告メッセージ")

^エラーまたは警告を出力する

以上のプログラム方法を総合的に参考にして、自分の欲しいツールを作成できます。

まとめ

これらのチェックツールを導入することで、開発がより安心で効率的になり、冗長さも減らせます。また、今回の経験で視野が広がったため、今後新しいビルドランスクリプトの要件があっても、最も慣れ親しんだ Swift 言語で直接作成できます!

ご質問やご意見がありましたら、こちらからご連絡ください

Post は Medium から ZMediumToMarkdown によって変換されました。


🍺 Buy me a beer on PayPal

👉👉👉 Follow Me On Medium! (1,053+ Followers) 👈👈👈

本記事は Medium にて初公開されました(こちらからオリジナル版を確認)。ZMediumToMarkdown による自動変換・同期技術を使用しています。

Improve this page on Github.

本記事は著者により CC BY 4.0 に基づき公開されています。

© ZhgChgLi. All rights reserved.
閲覧数: 802,415+, 最終更新日時: 2026-01-15 11:14:58 +08:00

本サイトは Chirpy テーマを使用し、Jekyll 上で構築されています。
Medium の記事は ZMediumToMarkdown により変換されています。