ZhgChg.Li

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

Photo by Glenn Carstens-Peters

背景

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

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

期待する機能

ビルド時に多言語のエラー、欠落、重複、Image Assets の欠落を自動でチェック可能。

解決策

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

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

Shell Script は Swift で書けるんです

シェルスクリプトよりも慣れていて、理解度も高い!この方向で探した結果、既存のツールスクリプトを2つ見つけました!

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

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

Localize 🏁 多言語ファイルチェックツール

機能:

  • ビルド時の自動チェック

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

  • 多言語と主要言語の不足・過剰をチェック

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

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

  • 多言語で使用されていない文をチェック

インストール方法:

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

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

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

  1. Xcodeで Localize.swift ファイルを開いて編集します。ファイルの上部に変更可能な設定項目が表示されます:
// チェックスクリプトを有効にする
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. ビルド!成功!

チェック結果の種類:

  • ビルドエラー

  • [重複] 項目がローカライズファイル内で重複しています

  • [未使用キー] 項目はローカライズファイルに定義されていますが、実際のコードで使用されていません

  • [欠落] 項目はローカライズファイルに定義されていませんが、実際のコードで使用されています

  • [冗長] この言語ファイル内の項目は、主要な言語ファイルに比べて余分です

  • [翻訳欠落] 項目はメインのローカライズファイルにありますが、このローカライズファイルには欠落しています

  • ビルド警告 ⚠️

  • [未翻訳の可能性] この項目は翻訳されていません(メインのローカライズファイルの内容と同じです)

まだ終わっていません。現在は自動チェックの警告が表示されますが、さらに自分でカスタマイズする必要があります。

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

回顧すると、チェックスクリプト 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 メソッドの後のマッチング文を調整し、任意の文字列を " が現れるまで取得するようにしました。ご自身のニーズに合わせて こちらをクリック してカスタマイズも可能です。

ローカライズファイルのフォーマットチェック機能を追加:

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

//....
let formatResult = shell("plutil -lint \(location)")
guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == "OK" else {
  let str = "\(path)/\(name).lproj"
            + "/Localizable.strings:1: "
            + "error: [ファイル無効] "
            + "この 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() を追加して shell スクリプトを実行し、plutil -lint で plist ローカライズファイルのフォーマット正確性をチェックします。エラーやセミコロン不足がある場合はエラーを返し、問題なければ OK を返して判定に使います!

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

その他のカスタマイズ:

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

build 時に Error ❌ を表示させるには:

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

ビルド時に Warning ⚠️ を表示させるには:

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

最終魔改版:

#!/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%の時間を削減可能)、時間がかかってしまいます。

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

swiftc -o Localize Localize.swift

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

例: ${SRCROOT}/Localize

完了!

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

機能:

  • ビルド時の自動チェック

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

  • 画像の余分なもののチェック:名前が使用されていないが、画像リソースディレクトリに存在するもの

インストール方法:

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

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

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

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

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

// 設定してください \o/

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

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

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

チェック結果の種類:

  • Build Error

  • [Asset Missing] 項目はコード内で呼び出されていますが、画像リソースフォルダに存在しません。

  • ビルド警告 ⚠️

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

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

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

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

#!/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)\"", // Objective-Cの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

例えば:

for (index, arg) in CommandLine.arguments.enumerated() {
    switch index {
    case 1:
        // 引数1
    case 2:
        // 引数2
    default:
        break
    }
}

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

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
}

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

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

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

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

まとめ

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

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

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

コメント