NSLocalizedString で溢れかえったプロジェクトに SwiftGen を導入した話

こんにちは、亀山です。近頃は SwiftGen を利用して Localizable.strings から生成したコードを利用するプロジェクトが多いように感じます。feather でも最近になって SwiftGen を導入しました。しかし非常に多くの箇所で NSLocalizedString を利用しており、新規プロジェクトにはない難しさがありました。どのようにして導入したかについて書きます。

概要

feather は日本語、英語、韓国語に対応しており、大部分は NSLocalizedString を使ってコード上でローカライズ対応をしています。 これまでは細かいルールなどは設けず、自由に Localizable.strings に追加してきました。そのため長い年月をかけてローカライズ文言が膨大になり、管理が難しくなりました。 また、NSLocalizedString はキー名を文字列で指定し、Localizable.strings に存在しなくてもエラー等が出ないためアプリを動かすまで気づくことができないという問題がありました。

存在しないキー名を指定する問題を回避するために、昨今は SwiftGen を利用して Localizable.strings から生成されたコードを利用するシーンが増えてきたように見えます。 そこで feather にも SwiftGen を導入することとしましたが、feather のコードベースは古く膨大で、NSLocalizedString を利用する箇所は多く手動での書き換えが難しい状況でした。

そこで NSLocalizedString を L10n に自動的に置き換えるスクリプトを書き、またキー名のルールを整備し Localizable.strings を健全に保てるようにしました。

SwiftGen の導入

ビルド時に SwiftGen を実行するため、XcodeGen の project.yml に下記の preBuildScripts を追加しました。

    preBuildScripts:
      - script: |
          mint run SwiftGen/SwiftGen config generate-xcfilelists --outputs "${SRCROOT}/feather/Supporting Files/xcfilelist/SwiftGenOutput.xcfilelist"
        name: "Generate SwiftGenOutput.xcfilelist"
        inputFiles:
          - $(SRCROOT)/feather/Supporting Files/xcfilelist/SwiftGenOutput.xcfilelist
      - script: |
          mint run SwiftGen/SwiftGen
        name: "SwiftGen"
        outputFileLists:
          - $(SRCROOT)/feather/Supporting Files/xcfilelist/SwiftGenOutput.xcfilelist

また、SwiftGen のファイル生成前に XcodeGen でプロジェクトを生成してもファイルがプロジェクトに登録された状態にするため、project.yml に下記を追加しました。Generated ディレクトリが作られた状態にするため .gitkeep ファイルを入れているので excludes での除去も行っています。

    sources:
      - path: feather
        excludes:
          - Generated/.gitkeep
      - path: feather/Generated/Assets.swift
        group: feather/Generated
        optional: true
        type: file
      - path: feather/Generated/LocalizableString.swift
        group: feather/Generated
        optional: true
        type: file
      - path: feather/Generated/Localizable.h
        group: feather/Generated
        optional: true
        type: file
      - path: feather/Generated/Localizable.m
        group: feather/Generated
        optional: true
        type: file

NSLocalizedString の置換

Node.js 用のスクリプトを書き、機械的に一括置換しました。

github.com

このスクリプトは指定したディレクトリ内の .swift ファイルと .m ファイルから正規表現でマッチした NSLocalizedString を L10n (ObjC は Localizable) に置き換えます。

また、極力そのままコンパイルできるよう、キー名は SwiftGen が生成するコードの仕様に合わせ、適宜 camel case に変換します。

引数を持つものもなるべく L10n の関数呼び出しへと変換を行います。

手動での NSLocalizedString の書き換え

スクリプトで8割方は対応できましたが、以下のような例外もありました。

  • NSLocalizedString の引数の間に改行が入っているもの
    • 手動で書き直しました
  • ローカライズ文言に % を含むもの
    • % と表示したいものでしたが、SwiftGen が引数を持つ文言として処理する問題がありました。%% に置換することで適切にエスケープされました
  • String(format:) と NSLocalizedString が分かれているもの
    • スクリプトは String(format: NSLocalizedString("foo.bar", comment: ""), value) をL10n.Foo.bar(value)` に置換できますが、format の引数が変数になっている場合に置換の対象になりません
  • ローカライズ文言に %@ を含むもの
    • String(format:) に変数として渡す処理を行っているため、%@ をそのまま使う必要がありました
    • L10n.Foo.bar("%@") のように引数に %@ を渡すことで対応しました

キー名の命名規則

SwiftGen はキー名にドットが入っていると、自動的に名前空間としてグルーピングを行います。適切にグルーピングが行われているとコード上でも見やすく、Localizable.strings の編集もしやすくなります。feather でもそれに合わせ、キー名の命名規則を定めることとしました。

命名者によらず迷わず命名ができつつ、厳しすぎないことを方針とし、以下の規則を定めました。

  • 画面や型名でグルーピングする (例: FooBarViewController の title なら FooBar.title)
  • 複数の画面で使われる文言は Common でグルーピングする

詳しくは下記のドキュメントにまとめています。feather の README から抜粋したものです。

gist.github.com

命名規則に合うように既存のキー名を修正する必要があり、単純なものは正規表現で置換しました。しかし多くはまだこの命名規則に則っておらず、今後手作業での書き換えが必要です。

Localizable.strings の書き方

各文言に付加されている /* No comment provided by engineer. */ のようなコメント文を削除し、次のように整理しました。

// Common
"Common.blockOrReport" = "Block or report spam";
"Common.translate" = "Translate";

// SupporterRequestFormViewController
"SupporterRequestForm.title" = "Send a Request";
"SupporterRequestForm.next" = "Next";
"SupporterRequestForm.cancel.confirm" = "Your message will be discarded. Are you sure?";
"SupporterRequestForm.text.header" = "Message body (%d characters or more)";
"SupporterRequestForm.text.footer" = "Your feedback will be used to improve our services. Please note that we may not be able to respond to all requests.";

// SupporterRequestSelectAccountViewController
"SupporterRequestSelectAccount.title" = "Select Account";
"SupporterRequestSelectAccount.confirm" = "Confirm";
"SupporterRequestSelectAccount.message" = "Select your Twitter account";
"SupporterRequestSelectAccount.empty" = "Not selected";
"SupporterRequestSelectAccount.footer" = "Please select your Twitter account if you are allowed to be contacted by us.";

SwiftLint の導入

置換後も NSLocalizedString が使用されないように、.swiftlint.yml に下記のルールを追加しました。

custom_rules:
  swiftgen_strings:
    name: "SwiftGen Strings"
    regex: 'NSLocalizedString'
    message: "Use L10n.key instead"
    severity: error

まとめ

スクリプトの導入により、全ての NSLocalizedString を L10n に置き換えることができました。またキー名のルールを整備し、Localizable.strings を整理しました。まだルールに則っていないキー名は多数ありますが、L10n に置き換えが済んでいるため、安全に書き換えを進めていくことができます。

置き換えスクリプトは npx で簡単に実行できるようにしてありますので、ぜひご利用下さい。

github.com