[iOS]SwiftでLocalizable.stringsを動的に差し替える

# Swift で Localizable.strings を動的に差し替える
* 特に多言語対応しているiOSアプリでは、`Localizable.strings` を使ってアプリ内の文字列を管理している場合が多いと思います
* 私が作っているアプリでは、SwiftPM で作ったリソース用のモジュールがあり、`Bundle.module.localizedString` を使ってバンドルした文字列を取り出しています
* この方法では、翻訳データを更新するためには、アプリを再度ビルドする必要があります
* そこで、**動的に `Localizable.strings` を差し替える**仕組みを作ってみました

# うまくいったやり方
* 実際に作成したコードはこちらです
* 文字列データは、`DynamicLocalize.localizedString` を使って取り出します

```swift
public class DynamicLocalize {
    internal static func localizedString(_ key: String, _ table: String) -> String {
        return bundle.localizedString(forKey: key, value: nil, table: table + ".nocache")
    }

    private static let bundleName = "DynamicLocalize.bundle"
    private static let bundleURL: URL = {
        let doc = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        return doc.first!
            .appendingPathComponent(bundleName, isDirectory: true)
    }()

    private static let bundle: Bundle = {
        let dir = bundleURL.appendingPathComponent("en.lproj")
        if !FileManager.default.fileExists(atPath: dir.path) {
            try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
        }
        let url = dir.appendingPathComponent("Localizable.nocache.strings")
        try! "\"Key001\" = \"First Value\";".write(to: url, atomically: true, encoding: .utf8)
        return Bundle(url: bundleURL)!
    }()

    public static func update() {
        let url = bundleURL
            .appendingPathComponent("en.lproj")
            .appendingPathComponent("Localizable.nocache.strings")
        try! "\"Key001\" = \"\(UUID().uuidString)\";".write(to: url, atomically: true, encoding: .utf8)
    }
}
```

## 実装上のポイント
* ドキュメントディレクトリ下に bundle のディレクトリを作成する
    * Bundle.module は編集する事ができません
    * そこで、ドキュメントディレクトリ下に作る必要があります
* 文字列のファイル名に、`.nocache` をつける
    * これで、キャッシュされなくなります
    * https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html
        * Table 2-1 あたり
    * 文字列を差し替えた時に、`.nocache` を付けておくことで、最新のデータが取得されます

# SwiftGen で DynamicLocalize を利用する
* 文字列を使う場所で毎回、localizedString すると大変なので、[SwiftGen](https://github.com/SwiftGen/SwiftGen) を利用して文字列データを管理したいです
* 設定ファイル(swiftgen.yml) はこちらです

```yaml
strings:
  inputs:
    - Modules/Sources/Assets/ResourceFiles/Locales/en.lproj
  filter: .+\.strings$
  outputs:
    - templateName: structured-swift5
      output: Modules/Sources/Assets/Generated/Strings+Generated.swift
      params:
        publicAccess: true
        enumName: AppLocales
        lookupFunction: DynamicLocalize.localizedString
```

* 実際に生成されるコードがこちらです

```swift
public enum AppLocales {
  /// Value001
  public static var key001: String { return AppLocales.tr("Localizable", "Key001") }
}
extension AppLocales {
  private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String {
    let format = DynamicLocalize.localizedString(key, table)
    return String(format: format, locale: Locale.current, arguments: args)
  }
}
```

* lookupFunction を指定したので、`tr` 内で、DynamicLocalize.localizedString が呼ばれています
* また、AppLocales.key001 が Stored property から Computed property に変わります
    * おかげで stencil をいじらなくて済みました
* これで、AppLocales.key001 が呼ばれる度に、DynamicLocalize に問い合わせられるようになります

# 動的に差し替えられる文字列データを利用する
* AppLocales.key001 を利用できます
* DynamicLocalize.update() してから objectWillChange などで画面を再描画させると、文字列が更新されていることを確認できます

```swift
import SwiftUI
import Assets

public struct ContentView: View {
    @ObservedObject var viewModel = ContentViewModel()
    public init() { }
    public var body: some View {
        VStack {
            Text(AppLocales.key001)
            Button("Update") {
                DynamicLocalize.update()
                viewModel.update()
            }
        }
    }
}
class ContentViewModel: ObservableObject {
    func update() {
        self.objectWillChange.send()
    }
}
```

# まとめ
* 最終的にかなりシンプルな仕組みになりましたが、`.nocache` など辿り着くまで大変でした
* あと、SwiftGen は神