Home Xcode 直接使用 Swift 撰寫 Run Script!
Post
Cancel

Xcode 直接使用 Swift 撰寫 Run Script!

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

緣由

因為自己手殘,時常在編輯多語系檔案時遺漏「;」導致 app build 出來語言顯示出錯再加上隨著開發的推移語系檔案越來越龐大,重複的、已沒用到的語句都夾雜再一起,非常混亂(Image Assets 同樣狀況)。

一直以來都想找工具協助處理這方面的問題,之前是用 iOSLocalizationEditor 這個 Mac APP,但它比較像是語系檔案編輯器,讀取語系檔案內容&編輯,沒有自動檢查的功能。

期望功能

build 專案時能自動檢查多語系有無錯誤、缺露、重複、Image Assets 有無缺漏。

解決方案

要達到我們的期望功能就要在 Build Phases 加入 Run Script 檢查腳本。

但檢查腳本需要使用 shell script 撰寫,因自己對 shell script 的掌握度並不太高,想說站在巨人的肩膀上從網路搜尋現有腳本也找不太到完全符合期望功能的 script,再快要放棄的時候突然想到:

Shell Script 可以用 Swift 來寫啊

相對 shell script 來說更熟悉、掌握度更高!依照這個方向果然讓我找到兩個現有的工具腳本!

freshOS 這個團隊撰寫的兩個檢查工具:

完全符合我們的期望功能需求! ! 並且他們使用 swift 撰寫,要客製化魔改都很容易。

Localize 🏁 多語系檔檢查工具

功能:

  • build 時自動檢查
  • 語系檔自動排版、整理
  • 檢查多語系與主要語系之缺漏、多餘
  • 檢查多語系重複語句
  • 檢查多語系未經翻譯語句
  • 檢查多語系未使用的語句

安裝方法:

  1. 下載工具的 Swift Script 檔案
  2. 放到專案目錄下 EX: ${SRCROOT}/Localize.swift
  3. 打開專案設定 → iOS Target → Build Phases →左上角「+」 → New Run Script Phases → 在 Script 內容貼上路徑 EX: ${SRCROOT}/Localize.swift

4. 使用 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 and Objc Native
    "Localizations\\.((?:[A-Z]{1}[a-z]*[A-z]*)*(?:\\.[A-Z]{1}[a-z]*[A-z]*)*)", // Laurine Calls
    "L10n.tr\\(key: \"(\\w+)\"", // SwiftGen generation
    "ypLocalized\\(\"(.*)\"\\)",
    "\"(.*)\".localized" // "key".localized pattern
]

//要忽略「語句未使用警告」的語句
let ignoredFromUnusedKeys: [String] = []
/* example
let ignoredFromUnusedKeys = [
    "NotificationNoOne",
    "NotificationCommentPhoto",
    "NotificationCommentHisPhoto",
    "NotificationCommentHerPhoto"
]
*/

//主要語系
let masterLanguage = "en"

//開啟與係檔案a-z排序、整理功能
let sanitizeFiles = false

//專案是單一or多語系
let singleLanguage = false

//啟用檢查未翻譯語句功能
let checkForUntranslated = true

5. Build!成功!

檢查結果提示類型:

  • 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" 這種格式的語句;假設我們是完整句子或有帶 Format 參數,則會被當誤當成 [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] "
            + "This Localizable.strings file format is invalid."
  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() 執行 shell script,使用 plutil -lint 檢查 plist 語系檔案格式正確性,有錯、少「;」會回傳錯誤,沒錯會回傳 OK 以此作為判斷!

檢查的地方可加在 LocalizationFiles->process( ) -> let location = singleLanguage… 後,約 135 行的地方或參考我最後提供的完整魔改版。

其他客製化:

我們可以依照自己的需求進行客製,例如把 error 換成 warning 或是拔掉某個檢查功能 (EX: Potentially Untranslated、Unused Key);腳本就是 swift 我們都很熟悉!不怕改壞改錯!

要讓 build 時出現 Error ❌:

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
345
#!/usr/bin/env xcrun --sdk macosx swift

import Foundation

// WHAT
// 1. Find Missing keys in other Localisation files
// 2. Find potentially untranslated keys
// 3. Find Duplicate keys
// 4. Find Unused keys and generate script to delete them all at once

// MARK: Start Of Configurable Section

/*
 You can enable or disable the script whenever you want
 */
let enabled = true

/*
 Put your path here, example ->  Resources/Localizations/Languages
 */
let relativeLocalizableFolders = "/streetvoice/SupportingFiles"

/*
 This is the path of your source folder which will be used in searching
 for the localization keys you actually use in your project
 */
let relativeSourceFolder = "/streetvoice"

/*
 Those are the regex patterns to recognize localizations.
 */
let patterns = [
    "NSLocalized(Format)?String\\(\\s*@?\"([^(\")]+)\"", // Swift and Objc Native
    "Localizations\\.((?:[A-Z]{1}[a-z]*[A-z]*)*(?:\\.[A-Z]{1}[a-z]*[A-z]*)*)", // Laurine Calls
    "L10n.tr\\(key: \"(\\w+)\"", // SwiftGen generation
    "ypLocalized\\(\"(.*)\"\\)",
    "\"(.*)\".localized" // "key".localized pattern
]

/*
 Those are the keys you don't want to be recognized as "unused"
 For instance, Keys that you concatenate will not be detected by the parsing
 so you want to add them here in order not to create false positives :)
 */
let ignoredFromUnusedKeys: [String] = []
/* example
let ignoredFromUnusedKeys = [
    "NotificationNoOne",
    "NotificationCommentPhoto",
    "NotificationCommentHisPhoto",
    "NotificationCommentHerPhoto"
]
*/

let masterLanguage = "base"

/*
 Sanitizing files will remove comments, empty lines and order your keys alphabetically.
 */
let sanitizeFiles = false

/*
 Determines if there are multiple localizations or not.
 */
let singleLanguage = false

/*
 Determines if we should show errors if there's a key within the app
 that does not appear in master translations.
*/
let checkForUntranslated = false

// MARK: End Of Configurable Section

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

// Detect list of supported languages automatically
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] "
                + "This Localizable.strings file format is invalid."
            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)

            // Ignored pattern
            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)\" "
                        + "is duplicated in \(name.uppercased()) file"
                    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: - Load Localisation Files in memory

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

// MARK: - Detect Unused Keys

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 {
    // checks the extension
    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 wa
                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)

// Here generate Xcode regex Find and replace script to remove dead keys all at once!
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)\" is never used"
    print(str)
    numberOfErrors += 1
    if counter != 0 {
        replaceCommand += "|"
    }
    replaceCommand += v
    if counter == unused.count - 1 {
        replaceCommand += ")\" = \".*\";"
    }
    counter += 1
}

print(replaceCommand)

// MARK: - Compare each translation file against master (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)\" missing from \(file.name.uppercased()) file"
            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)\" redundant in \(file.name.uppercased()) file"

        print(str)
    }
}

if checkForUntranslated {
    for key in untranslated {
        var str = "\(path)/\(masterLocalizationFile.name).lproj/Localizable.strings:1: "
        str += "error: [Missing Translation] \(key) is not translated"

        print(str)
        numberOfErrors += 1
    }
}

print("Number of warnings : \(numberOfWarnings)")
print("Number of errors : \(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 檢查工具腳本都調試完成之後,要將其 compile 成執行檔減少 build 花費時間 ,否則每次 build 都要重新 compile 一次(約能減少 90% 的時間)。

打開 terminal ,前往專案中檢查工具腳本所在目錄下執行:

1
swiftc -o Localize Localize.swift

然後再回頭到 Build Phases 更改 Script 內容路徑成執行檔

EX: ${SRCROOT}/Localize

完工!

工具 2. Asset Checker 👮 圖片資源檢查工具

功能:

  • build 時自動檢查
  • 檢查圖片缺漏:名稱有呼叫,但圖片資源目錄內沒有出現
  • 檢查圖片多餘:名稱未使用,但圖片資源目錄存在的

安裝方法:

  1. 下載工具的 Swift Script 檔案
  2. 放到專案目錄下 EX: ${SRCROOT}/AssetChecker.swift
  3. 打開專案設定 → iOS Target → Build Phases →左上角「+」 → New Run Script Phases → 在 Script 內容貼上路徑
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
// Configure me \o/

// 專案目錄位置(用來搜索圖片有沒有在程式碼中使用到)
var sourcePathOption:String? = nil

// .xcassets 目錄位置
var assetCatalogPathOption:String? = nil

// Unused 警告忽略項目
let ignoredUnusedNames = [String]()

4. Build! 成功!

檢查結果提示類型:

  • Build Error - [Asset Missing] 項目在程式內有呼叫使用,但圖片資源目錄內沒有出現
  • Build Warning ⚠️ - [Asset Unused] 項目在程式內未使用,但圖片資源目錄內有出現 p.s 假設圖片是動態變數提供,檢查工具將無法識別,可將其加入 ignoredUnusedNames 中設為例外。

其他操作同語系檢查工具,這邊就不做贅述;最重要的事是也要 記得調適完後要 compile 成執行檔,並更改 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

// Configure me \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: Source path was missing!")
    exit(0)
}

guard let assetCatalogAbsolutePath = assetCatalogPathOption else {
    print("AssetChecker:: error: Asset Catalog path was missing!")
    exit(0)
}

print("Searching sources in \(sourcePath) for assets in \(assetCatalogAbsolutePath)")

/* Put here the asset generating false positives, 
 For instance whne you build asset names at runtime
let ignoredUnusedNames = [
    "IconArticle",
    "IconMedia",
    "voteEN",
    "voteES",
    "voteFR"
] 
*/


// MARK : - End Of Configurable Section
func elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] {
    var elements = [String]()
    while let e = enumerator?.nextObject() as? String {
        elements.append(e)
    }
    return elements
}


// MARK: - List Assets
func listAssets() -> [String] {
    let extensionName = "imageset"
    let enumerator = FileManager.default.enumerator(atPath: assetCatalogAbsolutePath)
    return elementsInEnumerator(enumerator)
        .filter { $0.hasSuffix(extensionName) }                             // Is Asset
        .map { $0.replacingOccurrences(of: ".\(extensionName)", with: "") } // Remove extension
        .map { $0.components(separatedBy: "/").last ?? $0 }                 // Remove folder path
}


// MARK: - List Used Assets in the codebase
func localizedStrings(inStringFile: String) -> [String] {
    var localizedStrings = [String]()
    let namePattern = "([\\w-]+)"
    let patterns = [
        "#imageLiteral\\(resourceName: \"\(namePattern)\"\\)", // Image Literal
        "UIImage\\(named:\\s*\"\(namePattern)\"\\)", // Default UIImage call (Swift)
        "UIImage imageNamed:\\s*\\@\"\(namePattern)\"", // Default UIImage call 
        "\\<image name=\"\(namePattern)\".*", // Storyboard resources
        "R.image.\(namePattern)\\(\\)" //R.swift support
    ]
    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") }    // Only Swift and Obj-C files
            .map { "\(sourcePath)/\($0)" }                              // Build file paths
            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // Get file contents
            .compactMap{$0}
            .compactMap{$0}                                             // Remove nil entries
            .map(localizedStrings)                                      // Find localizedStrings ocurrences
            .flatMap{$0}                                                // Flatten
    #else
        return elementsInEnumerator(enumerator)
            .filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") }    // Only Swift and Obj-C files
            .map { "\(sourcePath)/\($0)" }                              // Build file paths
            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // Get file contents
            .flatMap{$0}
            .flatMap{$0}                                                // Remove nil entries
            .map(localizedStrings)                                      // Find localizedStrings ocurrences
            .flatMap{$0}                                                // Flatten
    #endif
}


// MARK: - Begining of script
let assets = Set(listAssets())
let used = Set(listUsedAssetLiterals() + ignoredUnusedNames)


// Generate Warnings for Unused Assets
let unused = assets.subtracting(used)
unused.forEach { print("\(assetCatalogAbsolutePath):: warning: [Asset Unused] \($0)") }


// Generate Error for broken Assets
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)\"\\)", // Image Literal
        "UIImage\\(named:\\s*\"\(namePattern)\"\\)", // Default UIImage call (Swift)
        "UIImage imageNamed:\\s*\\@\"\(namePattern)\"", // Default UIImage call 
        "\\<image name=\"\(namePattern)\".*", // Storyboard resources
        "R.image.\(namePattern)\\(\\)" //R.swift support
    ]
    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") }    // Only Swift and Obj-C files
            .map { "\(sourcePath)/\($0)" }                              // Build file paths
            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // Get file contents
            .compactMap{$0}
            .compactMap{$0}                                             // Remove nil entries
            .map(localizedStrings)                                      // Find localizedStrings ocurrences
            .flatMap{$0}                                                // Flatten
    #else
        return elementsInEnumerator(enumerator)
            .filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") }    // Only Swift and Obj-C files
            .map { "\(sourcePath)/\($0)" }                              // Build file paths
            .map { try? String(contentsOfFile: $0, encoding: .utf8)}    // Get file contents
            .flatMap{$0}
            .flatMap{$0}                                                // Remove nil entries
            .map(localizedStrings)                                      // Find localizedStrings ocurrences
            .flatMap{$0}                                                // Flatten
    #endif
}

^遍歷所有專案檔案並進行正則匹配的方法

1
2
3
4
//要讓 build 時出現 Error ❌:
print("Project檔案.lproj" + "/檔案:行: " + "error: 錯誤訊息")
//要讓 build 時出現 Warning ⚠️:
print("Project檔案.lproj" + "/檔案:行: " + "warning: 警告訊息")

^print error or warning

可以綜合參考以上的程式方法,自己打造想要的工具。

總結

這兩個檢查工具導入之後,我們在開發上就能更安心、更有效率並且減少冗餘;也因為這次經驗大開眼界,日後如果有什麼新的 build run script 需求都能直接使用最熟悉的語言 swift 來進行製作!

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難

Apple Watch Series 6 開箱 & 兩年使用體驗