白黒羊

[Unity/iOS]PlistInfo.strings作成自動化でローカライズ

環境

UnityEditor 2020.3.6f1
XCode Version 12.5
iOS 14.5

手順

  1. 各言語ごとにPlistInfo.stringsファイルを作成
  2. .pbxprojファイルを書き換えてknownRegionsに言語コードを追加
  3. KnownRegionsが指定しているLocalizationの項目とResource(追加したPlistInfo.strings)を関連付け
  4. PlistInfo.stringsを含めてビルドするようにBuild Phases>Copy Bundle ResourcesにPlistInfo.stringsを追加

各言語ごとにPlistInfo.stringsファイルを作成

ローカライズしたい言語とPlist.infoで使用するキーの表をCSVで作成してUnityプロジェクト内のResourceフォルダに保存します。

Lang,CFBundleDisplayName,CFBundleDevelopmentRegion,NSUserTrackingUsageDescription
en,RainbowSea,en_US,This identifier will be used to deliver personalized ads to you.
ja,虹の降る海,ja_JP,興味に合わせた広告の表示に使用されます。

そのCSVを使って、このようにXCodeプロジェクト内にInfoPlist.stringsファイルを作る処理を書きます。
カンマのエスケープなどは考えていないので、値にカンマを使用する場合は他の記号で書いておいて後で置き換えたり、CSVではなくScriptableObjectを作るなどして対応してください。

private static void AddLocalizationStringFiles(string pathToBuiltProject)
{
    var infoPlistCsv = Resources.Load<TextAsset>(InfoPlistStringsCsvPath).ToString();   // CSV読み込み
    var textLines = infoPlistCsv.Split('\r', '\n'); // CSVデータを行ごとに分割
            
    var map = new Dictionary<string, Dictionary<string, string>>();
    var keys = textLines[0].Split(Delimiter);   // ローカライズ用のキーを持った配列
    for (var i = 1; i < textLines.Length; i++)
    {
        var row = textLines[i].Split(Delimiter);   // ローカライズされた値を持った配列
        var lang = row[0];  // 言語コード
        map[lang] = new Dictionary<string, string>();
        for (var j = 1; j < row.Length; j++)    
            map[lang][keys[j]] = row[j];   // languageCode(en):lang x key(CFBundleDisplayName):keys[j] = value(RainbowSea)row[j]
    }
            
    var dirPath = Path.Combine(pathToBuiltProject, PBXProject.GetUnityTestTargetName());
    // XCode Project内での Unity-iPhone Tests までのパス(Unity-iPhone Tests内に各ファイルを作成する)
    foreach (var languageCode in LanguageCodes) // Unity-iPhone Tests/en.lproj/InfoPlist.strings
    {
        var lProjDirPath = Path.Combine(dirPath, $"{languageCode}.lproj");
        if (!Directory.Exists(lProjDirPath)) Directory.CreateDirectory(lProjDirPath);
        var filePath = Path.Combine(dirPath, $"{languageCode}.lproj", "InfoPlist.strings");
        var content = string.Join("\n",
            map[languageCode].Select(pair => $"{pair.Key} = \"{pair.Value}\";"));
        File.WriteAllText(filePath, content);
    }
}

.pbxprojファイルを書き換えてknownRegionsに言語コードを追加

ローカライズ対応はknownRegionsに含まれる言語コードに適用されるようです。
Unityビルドでいくつかの言語名が自動的に追加されるのですが、全体的にDeprecatedなのでそれを上書きしていきます。
これをスマートに書き換えられるAPIはないので(おそらく)文字列を正規表現で解析してゴリゴリ書き換えます。

private static void RewritePbxProjectFile(PBXProject pbx)
{
    var pbxContent = pbx.WriteToString();   // pbx内のデータを文字列としてpbxContentに格納
            
    var updated = Regex.Replace(pbxContent, @"developmentRegion = English;",
        $"developmentRegion = {DefaultLanguageCode};"); // デフォルトの開発用言語をEnglish(deprecated)からenに変更

    updated = Regex.Replace(updated,    // 古いknownRegionsを消して対応する言語を追加
        @"knownRegions = \(\s*(\w+,[\r\n]\s*)+\);",
        $"knownRegions = (\n{string.Join($",\n", LanguageCodes)},\n);");

KnownRegionsが指定しているLocalizationの項目とResource(追加したPlistInfo.strings)を関連付け

PlistInfo.stringsを含めてビルドするようにBuild Phases>Copy Bundle ResourcesにPlistInfo.stringsを追加

この二つに関しては、Unity側でビルドして作った Unity-iPhone.xcodeproj と同じ階層で git init してからXCodeで手動で作業してdiffを確認しました。

git init
git add -A; git commit -m "before";
# XCodeでInfoPlist.stringsのLocalizationにチェックを入れてUseを選択
git diff
git add -A; git commit -m "file association";
# XCodeでTARGETS:Unity-iPhone>Build Phase>Copy Bundle Resources にInfoPlist.stringsを追加
git diff

これによって得られた変更点を反映するように、knownRegionsの作業同様に無理矢理.pbxprojファイルを書き換えました。

以下のコード内のコメントにも書きましたが、GUIDにどのような値を設定すれば良いのかわからず、適当な固定値を使っており、良くないような気がします……。
以下のコードでXXX…としているGUIDをどうすれば良いのかアイデアがある方がいれば教えてください。

コード全体

美しくないですが、コメントを大量につけて今回使ったコードの全文を載せておきます。
15、16行目を書き換えればあとはコピペでも動くはずです。

using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;
using UnityEngine;

namespace Project.Editor.CloudBuild.Editor
{
    public class PostXCodeBuild
    {
#if UNITY_IOS
        private const string DefaultLanguageCode = "en";    // デフォルトの言語コード
        private static readonly string[] LanguageCodes = {DefaultLanguageCode, "ja"};   // 言語を増やすときはここに言語コードを追加
        private const string InfoPlistStringsCsvPath = "InfoPlistStrings";  // 実際のローカライズデータを含むCSVのファイル名
        private const char Delimiter = ','; // CSV解析用

        [PostProcessBuild]  // このAttributeをつけるとローカルビルドでもUnity Cloud Buildでもビルド終了後に呼んでくれる
        public static void SetXcodePlist(BuildTarget buildTarget, string pathToBuiltProject)
        {
            if (buildTarget != BuildTarget.iOS) return;

            AddLocalizationStringFiles(pathToBuiltProject); // PlistInfo.stringsファイルを作成

            var pbxPath = PBXProject.GetPBXProjectPath(pathToBuiltProject);
            var pbx = new PBXProject(); // PBXProjectファイル。Mac: Unity-iPhone.xcodeproj を右クリック>「パッケージの中身を表示」
            pbx.ReadFromFile(pbxPath);  // 既存のデータをpbx内に読み込み
            
            RewritePbxProjectFile(pbx); // .pbxproj ファイルを無理矢理書き換える
            pbx.WriteToFile(pbxPath);

            var plistPath = pathToBuiltProject + "/Info.plist"; // Info.plistファイルのパス
            var plist = new PlistDocument();
            plist.ReadFromFile(plistPath);

            var rootDict = plist.root;
            var array = rootDict.CreateArray("CFBundleLocalizations");  // 言語コードを追加
            foreach (var languageCode in LanguageCodes) array.AddString(languageCode);

            plist.WriteToFile(plistPath);
        }

        private static void AddLocalizationStringFiles(string pathToBuiltProject)
        {
            var infoPlistCsv = Resources.Load<TextAsset>(InfoPlistStringsCsvPath).ToString();   // CSV読み込み
            var textLines = infoPlistCsv.Split('\r', '\n'); // CSVデータを行ごとに分割
            
            var map = new Dictionary<string, Dictionary<string, string>>();
            var keys = textLines[0].Split(Delimiter);   // ローカライズ用のキーを持った配列
            for (var i = 1; i < textLines.Length; i++)
            {
                var row = textLines[i].Split(Delimiter);   // ローカライズされた値を持った配列
                var lang = row[0];  // 言語コード
                map[lang] = new Dictionary<string, string>();
                for (var j = 1; j < row.Length; j++)    
                    map[lang][keys[j]] = row[j];   // languageCode(en):lang x key(CFBundleDisplayName):keys[j] = value(RainbowSea)row[j]
            }
            
            var dirPath = Path.Combine(pathToBuiltProject, PBXProject.GetUnityTestTargetName());
            // XCode Project内での Unity-iPhone Tests までのパス(Unity-iPhone Tests内に各ファイルを作成する)
            foreach (var languageCode in LanguageCodes) // Unity-iPhone Tests/en.lproj/InfoPlist.strings
            {
                var lProjDirPath = Path.Combine(dirPath, $"{languageCode}.lproj");
                if (!Directory.Exists(lProjDirPath)) Directory.CreateDirectory(lProjDirPath);
                var filePath = Path.Combine(dirPath, $"{languageCode}.lproj", "InfoPlist.strings");
                var content = string.Join("\n",
                    map[languageCode].Select(pair => $"{pair.Key} = \"{pair.Value}\";"));
                File.WriteAllText(filePath, content);
            }
        }

        private static void RewritePbxProjectFile(PBXProject pbx)
        {
            var pbxContent = pbx.WriteToString();   // pbx内のデータを文字列としてpbxContentに格納
            
            var updated = Regex.Replace(pbxContent, @"developmentRegion = English;",
                $"developmentRegion = {DefaultLanguageCode};"); // デフォルトの開発用言語をEnglish(deprecated)からenに変更

            updated = Regex.Replace(updated,    // 古いknownRegionsを消して対応する言語を追加
                @"knownRegions = \(\s*(\w+,[\r\n]\s*)+\);",
                $"knownRegions = (\n{string.Join($",\n", LanguageCodes)},\n);");

            foreach (var languageCode in LanguageCodes)
            {
                if (languageCode == DefaultLanguageCode) continue;  // デフォルトの言語は自動的に設定されるので何もしなくて良い

                var guid = $"XXXXXXXXXXXXXXXXXXXXXX{languageCode.ToUpper()}";
                // 24文字で他のものと重ならなければ動くが、衝突しない保証はない。正しいGUIDの取り方があるかもしれない
                var shortLine = $"{guid} /* {languageCode} */"; // XXXXXXXXXXXXXXXXXXXXXXJA /* ja */
                var longLine = $"{shortLine} = {{isa = PBXFileReference; lastKnownFileType = text.plist.strings; " +
                               $"name = {languageCode}; path = {languageCode}.lproj/InfoPlist.strings; " +
                               "sourceTree = \"<group>\"; };";
                // XXXXXXXXXXXXXXXXXXXXXXJA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja}.lproj/InfoPlist.strings; sourceTree = "<group>"; };
                // デフォルトのenをjaに、GUIDを新しいものに書き換えているだけなので、こんなにベタ書きしなくてももっとスマートに書けそうな気もする。
                updated = Regex.Replace(updated,
                    @"(?<original>.+ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = " +
                    DefaultLanguageCode + "; path = " + DefaultLanguageCode + ".lproj/InfoPlist.strings; sourceTree = .+; };)",
                    "${original}\n" + $"{longLine}");
                // 正規表現を使って、デフォルトの言語の宣言が書かれた行があったらそこにlongLineを追加する
                
                updated = Regex.Replace(updated,
                    @"(?<original>isa = PBXVariantGroup;[\r\n]\s*children = \([\r\n]\s*(.+,[\r\n]\s*)*)\);",
                    "${original}" + $"{shortLine},\n);");
                // 正規表現を使って、PBXVariantGroupのchildrenにshortLineを追加する
            }

            // Build Phase>Copy Bundle ResourcesにInfoPlist.stringsファイルを追加する設定
            var regex = new Regex(@"(?<guid>\w{24} /\* InfoPlist.strings \*/),");
            var infoPlistGuid = regex.Match(updated).Groups["guid"].Value;
            const string newGuid = "XXXXXXXXXXXXXXXXXXXXXXXX";
            
            updated = Regex.Replace(updated,
                @"(?<original>.+ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = " +
                DefaultLanguageCode + "; path = " + DefaultLanguageCode + ".lproj/InfoPlist.strings; sourceTree = .+; };)",
                newGuid + " /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = " +
                infoPlistGuid + "; };\n${original}");
            
            updated = Regex.Replace(updated,
                @"(?<original>\w+ /\* Data in Resources \*/,)",
                newGuid + ",\n${original}");

            pbx.ReadFromString(updated);
        }
#endif
    }
}

文字列ベタ書きが多くて怖いですね。XCodeのバージョンアップとかですぐに対応できなくなって死んじゃいそうなのでUnity公式が対応してくれれば一番嬉しいのですが、参考のところにもリンク貼ったForum見るとなかなか難しそうな雰囲気。

実際には他にも、広告の設定のために SKAdNetworkIdentifier の値を設定したり、 AppTrackingTransparency.framework を読み込んだりすると思いますがごちゃごちゃするので省略してあります。

参考

【Unity】[iOS]Xcode projectの Localization 自動化 _ hirokuma.blog (特にPBXProjectファイルの書き換えについて非常に参考になりました)

How to add InfoPlist.strings into xcode project in script_ – Unity Forum