見出し画像

UnityでXcodeのString Catalogに対応してみる

はじめに

PolySpatial を使っての Vision Pro アプリの開発過程でせっかく Xcode15 を使っているので Info.plist の多言語対応に String Catalog を使ってみました。

もしかしたら公式の最新の Localization パッケージを入れたら String Catalog に対応しているのかもしれませんが、あのパッケージだと Addressable の使用が前提となり、アプリの開発の終盤での導入には「重すぎ」ました。

ローカライズの対象は Info.plist だけのため、うまいこと「軽い」実装で String Catalog に対応できるかに挑戦してみました。

注意点

細かく書くと長くなるため、固定で英語と日本語に対応する内容になっており、意図的に詳細な説明は省いています。

以降は 2024/7 時点の記事内容になります。

開発環境

  • Xcode 15.4

  • Unity 2022.3 LTS

  • PolySpatial 1.2.3

  • visionOS 1.2

やったことの手順

  1. String Catalog の構造を知る

  2. Unity が出力する Xcode プロジェクトの現状を知る

  3. visionOS 独自のパーミッションに対応する

  4. Info.plist の書き換えと InfoPlist.xcstrings の生成

  5. コードをまとめて実行する

上の手順で実装を行いました。

1つずつ説明していきます。

1. String Catalog の構造

Xcode で新規に簡単なプロジェクトを作る。
Qiitaの記事 を参考に InfoPlist.xcstrings を作成する。

InfoPlist.xcstrings の中身を見てみると概ね JSON ファイルのフォーマットである。

{
  "sourceLanguage" : "en",
  "strings" : {
    "CFBundleDisplayName" : {
      "comment" : "Bundle display name",
      "extractionState" : "extracted_with_value",
      "localizations" : {
        "en" : {
          "stringUnit" : {
            "state" : "new",
            "value" : "it's hoge"
          }
        },
        "ja" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "それはホゲ"
          }
        }
      }
    },
    "CFBundleName" : {
      "comment" : "Bundle name",
      "extractionState" : "extracted_with_value",
      "localizations" : {
        "en" : {
          "stringUnit" : {
            "state" : "new",
            "value" : "Hoge"
          }
        },
        "ja" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "ホゲ"
          }
        }
      }
    }
  },
  "version" : "1.0"
}

Unity プロジェクト下に同じフォーマットのJSON ファイルを作る。
今回は Assets/Editor/Localization/Resources/StringCatalog.json とした。

StringCatalog.json には Info.plist の多言語化に必要な情報を書いておく。

そして、このファイルを読み込んでデシリアライズするクラスを用意した。

/// <summary>
/// XcStrings 
/// </summary>
public class XcStrings
{
    /// <summary>
    /// デフォルトの言語種類
    /// </summary>
    public string sourceLanguage { get; set; }
    
    /// <summary>
    /// 言語別の文言
    /// </summary>
    public Dictionary<string, StringEntry> strings { get; set; }
    
    /// <summary>
    /// フォーマットのバージョン
    /// </summary>
    public string version { get; set; }
    
    /// <summary>
    /// デシリアライズ
    /// </summary>
    /// <param name="json">JSONテキスト</param>
    /// <returns>XcStrings</returns>
    public static XcStrings Deserialize(string json)
    {
        return JsonConvert.DeserializeObject<XcStrings>(json);
    }
}

/// <summary>
/// 文言エントリ
/// </summary>
public class StringEntry
{
    public string comment { get; set; }
    public string extractionState { get; set; }
    public Dictionary<string, Localization> localizations { get; set; }
}

/// <summary>
/// ローカライズ
/// </summary>
public class Localization
{
    public StringUnit stringUnit { get; set; }
}

/// <summary>
/// 文言
/// </summary>
public class StringUnit
{
    public string state { get; set; }
    public string value { get; set; }
}

2. Unity が出力する Xcode プロジェクト

Unity で Xcode のプロジェクトを出力して開き、 PROJECT > Unity-VisionOS > Info > Localization をみると次のようになっている。

対応したい言語の English や Japanese の後ろに deprecated (廃止) が付いている。
また対応する意図のない French や German が入っている。

このままでは String Catalog に対応できない。

これをなんとか正常な状態にする。

public class MyAppLocalizePostBuild : IPostprocessBuildWithReport
{
    /// <summary>
    /// 実行順
    /// </summary>
    public int callbackOrder => MyAppPostBuild.BuildOrder + 1;

    /// <summary>
    /// ビルド後
    /// </summary>
    /// <param name="report"></param>
    public void OnPostprocessBuild(BuildReport report)
    {
        var target = report.summary.platform;
        var pathToBuiltProject = report.summary.outputPath;
        
        // プロジェクトの絶対パスの生成
        var projectPath = target == BuildTarget.iOS ? 
            PBXProject.GetPBXProjectPath(pathToBuiltProject) : 
            AppBuilder.GetPBXProjectPathForVisionOS(pathToBuiltProject);

        // プロジェクトファイルの読み込み
        var proj = new PBXProject();
        proj.ReadFromFile(projectPath);

        // 読み込み 
        var pbxstr = proj.WriteToString();
        
        // デフォルトの開発用言語をEnglish(deprecated)からenに変更
        pbxstr = Regex.Replace(pbxstr, "developmentRegion = English;",
            "developmentRegion = en;");

        // 古いknownRegionsを消して対応する言語を追加
        var languageCodes = new string[] {"en", "ja" };
        pbxstr = Regex.Replace(pbxstr,
            @"knownRegions = \(\s*(\w+,[\r\n]\s*)+\);",
            $"knownRegions = (\n{string.Join($",\n", languageCodes)},\n);");
        
        // いったん書き込み
        proj.ReadFromString(pbxstr);

        // プロジェクトファイルの書き込み
        proj.WriteToFile(projectPath);
    }
}

IPostprocessBuildWithReport を継承したクラスを作って Xcode プロジェクトを出力した直後に処理に介入する。
Localization の設定が English, deprecated となっている原因は Unity-VisionOS.xcodeproj/project.pbxproj の developmentRegion や knownRegions の言語の識別子が古い記述のままだったため。

古: "English", "Japanese"   
新: "en", "ja"

上のコードは、その部分をやや強引に書き換える処理をしている。
これを実行して出力した Xcode プロジェクトを開くと Localization から deprecated, French, German が消えてスッキリした内容になった。

3. visionOS 独自のパーミッション

visionOS 独自のパーミッションは Project Settings > Player ではなくて Project Settings > PolySpatial で管理されている。

Xcode プロジェクトの出力前に Project Settings > PolySpatial にアクセスして、StringCatalog.json で設定している内容に置き換えるようにした。

(次項の Info.plist の書き換えで済むため、この処理は敢えてする必要はないかも知れない。)

public class MyAppLocalizePostBuild : IPreprocessBuildWithReport
{
    private const string xcStringJson = "StringCatalog";
    private const string nSHandsTrackingUsageDescription = "NSHandsTrackingUsageDescription";
    private const string nSWorldSensingUsageDescription = "NSWorldSensingUsageDescription";

    /// <summary>
    /// 実行順
    /// </summary>
    public int callbackOrder => MyAppPostBuild.BuildOrder + 1;

    /// <summary>
    /// ビルド前
    /// </summary>
    /// <param name="report"></param>
    public void OnPreprocessBuild(BuildReport report)
    {
        // XcString の読み込み
        var (xcString, _) = xcStringsDeserialize();
        
        // visionOS設定の読み込み
        var visionOSSettings = VisionOSSettings.currentSettings;
        
        visionOSSettings.handsTrackingUsageDescription = 
            xcString.strings[nSHandsTrackingUsageDescription].localizations[xcString.sourceLanguage].stringUnit.value;
        
        visionOSSettings.worldSensingUsageDescription = 
            xcString.strings[nSWorldSensingUsageDescription].localizations[xcString.sourceLanguage].stringUnit.value;
        
        // visionOS設定の更新
        EditorUtility.SetDirty(visionOSSettings);
        AssetDatabase.SaveAssets();
    }

    /// <summary>
    /// XcString のデシリアライズ
    /// </summary>
    /// <returns>XcString, XcStringのJSON文字列</returns>
    private static (XcStrings xcStrings, string xcStringJsonText) xcStringsDeserialize()
    {
        // XcString の読み込み
        var xcStringJsonTextAsset = Resources.Load<TextAsset>(xcStringJson);
        var xcStringJsonText = xcStringJsonTextAsset.text;
        var xcStrings = XcStrings.Deserialize(xcStringJsonText);

        return (xcStrings, xcStringJsonText);
    }
}

4. Info.plist の書き換えと InfoPlist.xcstrings の生成

public class MyAppLocalizePostBuild : IPostprocessBuildWithReport
{
    private const string infoPlistXcStrings = "InfoPlist.xcstrings";

    private const string cFBundleDisplayName = "CFBundleDisplayName";
    private const string cFBundleName = "CFBundleName";
    private const string nSCameraUsageDescription = "NSCameraUsageDescription";
    private const string nSHandsTrackingUsageDescription = "NSHandsTrackingUsageDescription";
    private const string nSWorldSensingUsageDescription = "NSWorldSensingUsageDescription";

    /// <summary>
    /// Info.plist の多言語対応の必要がある項目
    /// </summary>
    private static readonly List<string> _plistParamKeys = new()
    {
        cFBundleDisplayName,
        cFBundleName,
        nSCameraUsageDescription,
        nSHandsTrackingUsageDescription,
        nSWorldSensingUsageDescription
    };

    /// <summary>
    /// 実行順
    /// </summary>
    public int callbackOrder => MyAppPostBuild.BuildOrder + 1;

    /// <summary>
    /// ビルド後
    /// </summary>
    /// <param name="report"></param>
    public void OnPostprocessBuild(BuildReport report)
    {
        var target = report.summary.platform;
        var pathToBuiltProject = report.summary.outputPath;
        
        // プロジェクトの絶対パスの生成
        var projectPath = target == BuildTarget.iOS ? 
            PBXProject.GetPBXProjectPath(pathToBuiltProject) : 
            AppBuilder.GetPBXProjectPathForVisionOS(pathToBuiltProject);

        // プロジェクトファイルの読み込み
        var proj = new PBXProject();
        proj.ReadFromFile(projectPath);

        // メインターゲットの GUID を取得
        var mainTargetGuid = proj.GetUnityMainTargetGuid();
        
        // XcString の読み込み
        var (xcString, xcStringJsonText) = xcStringsDeserialize();
        
        // デフォルト言語に合わせてInfoPlistを書き換え
        foreach (var paramKey in _plistParamKeys)
        {
            var word = xcString.strings[paramKey].localizations[xcString.sourceLanguage].stringUnit.value;
            proj.AddBuildProperty(mainTargetGuid, paramKey, word);
        }
        
        // Xcode プロジェクト下の InfoPlist.xcstrings を作る
        var toPath = Path.Combine(pathToBuiltProject, infoPlistXcStrings);
        var lGuid = proj.AddFile(toPath, infoPlistXcStrings);
        File.WriteAllText(toPath, xcStringJsonText, System.Text.Encoding.UTF8);
        proj.AddFileToBuild(mainTargetGuid, lGuid);

        // プロジェクトファイルの書き込み
        proj.WriteToFile(projectPath);
    }
}

上のコードは2つの処理を行っている。

1) Info.plist の書き換え

Xcode 上では Info.plist の該当項目が InfoPlist.xcstrings の方へ反映されるようになっているため、 InfoPlist.xcstrings のデフォルト言語の部分は書き換えることはできない。

なので Unity が Xcode プロジェクトを出力した直後に Info.plist の該当項目を StringCatalog.json で書き換えるようにした。

2) InfoPlist.xcstrings の生成

多言語JSONファイルの複製を InfoPlist.xcstrings と名前を変えて Xcode プロジェクト直下に保存している。
さらに PBXProject.AddFileToBuild でビルド対象のファイルとしてプロジェクトに登録している。

5. コードをまとめて実行

2.から4.までやったことをまとめたコードが次になる。

/// <summary>
/// ローカライズに関するビルド前後の処理
/// </summary>
public class MyAppLocalizePostBuild : IPreprocessBuildWithReport, IPostprocessBuildWithReport
{
    private const string langEn = "en";
    private const string langJa = "ja";

    private const string xcStringJson = "StringCatalog";
    private const string infoPlistXcStrings = "InfoPlist.xcstrings";
        
    private const string cFBundleDisplayName = "CFBundleDisplayName";
    private const string cFBundleName = "CFBundleName";
    private const string nSCameraUsageDescription = "NSCameraUsageDescription";
    private const string nSHandsTrackingUsageDescription = "NSHandsTrackingUsageDescription";
    private const string nSWorldSensingUsageDescription = "NSWorldSensingUsageDescription";
    
    /// <summary>
    /// Info.plist の多言語対応の必要がある項目
    /// </summary>
    private static readonly List<string> _plistParamKeys = new()
    {
        cFBundleDisplayName,
        cFBundleName,
        nSCameraUsageDescription,
        nSHandsTrackingUsageDescription,
        nSWorldSensingUsageDescription
    };

    /// <summary>
    /// 実行順
    /// </summary>
    public int callbackOrder => MyAppPostBuild.BuildOrder + 1;
    
    /// <summary>
    /// ビルド前
    /// </summary>
    /// <param name="report"></param>
    public void OnPreprocessBuild(BuildReport report)
    {
        // XcString の読み込み
        var (xcString, _) = xcStringsDeserialize();
        
        // visionOS設定の読み込み
        var visionOSSettings = VisionOSSettings.currentSettings;
        
        visionOSSettings.handsTrackingUsageDescription = 
            xcString.strings[nSHandsTrackingUsageDescription].localizations[xcString.sourceLanguage].stringUnit.value;
        
        visionOSSettings.worldSensingUsageDescription = 
            xcString.strings[nSWorldSensingUsageDescription].localizations[xcString.sourceLanguage].stringUnit.value;
        
        // visionOS設定の更新
        EditorUtility.SetDirty(visionOSSettings);
        AssetDatabase.SaveAssets();
    }

    /// <summary>
    /// ビルド後
    /// </summary>
    /// <param name="report"></param>
    public void OnPostprocessBuild(BuildReport report)
    {
        var target = report.summary.platform;
        var pathToBuiltProject = report.summary.outputPath;
        
        // プロジェクトの絶対パスの生成
        var projectPath = target == BuildTarget.iOS ? 
            PBXProject.GetPBXProjectPath(pathToBuiltProject) : 
            AppBuilder.GetPBXProjectPathForVisionOS(pathToBuiltProject);
        
        // プロジェクトファイルの読み込み
        var proj = new PBXProject();
        proj.ReadFromFile(projectPath);
        
        // プロジェクトファイルの改ざん
        modifyProject(proj);
        
        // InfoPlist の書き換え
        modifyPlist(proj, pathToBuiltProject);
        
        // プロジェクトファイルの書き込み
        proj.WriteToFile(projectPath);
    }

    /// <summary>
    /// プロジェクトファイルの改ざん
    /// </summary>
    /// <param name="proj">プロジェクト</param>
    private static void modifyProject(PBXProject proj)
    {
        // 読み込み 
        var pbxstr = proj.WriteToString();
        
        // デフォルトの開発用言語をEnglish(deprecated)からenに変更
        pbxstr = Regex.Replace(pbxstr, "developmentRegion = English;",
            "developmentRegion = en;");

        // 古いknownRegionsを消して対応する言語を追加
        var languageCodes = new string[] {"en", "ja" };
        pbxstr = Regex.Replace(pbxstr,
            @"knownRegions = \(\s*(\w+,[\r\n]\s*)+\);",
            $"knownRegions = (\n{string.Join($",\n", languageCodes)},\n);");
        
        // いったん書き込み
        proj.ReadFromString(pbxstr);
    }

    /// <summary>
    /// InfoPlist, InfoPlist.xcstrings の書き換え
    /// </summary>
    /// <param name="proj">プロジェクト</param>
    /// <param name="pathToBuiltProject">プロジェクトの絶対パス</param>
    private static void modifyPlist(PBXProject proj, string pathToBuiltProject)
    {            
        // メインターゲットの GUID を取得
        var mainTargetGuid = proj.GetUnityMainTargetGuid();
        
        // XcString の読み込み
        var (xcString, xcStringJsonText) = xcStringsDeserialize();
        
        // デフォルト言語(英語) に合わせてInfoPlistを書き換え
        foreach (var paramKey in _plistParamKeys)
        {
            var word = xcString.strings[paramKey].localizations[xcString.sourceLanguage].stringUnit.value;
            proj.AddBuildProperty(mainTargetGuid, paramKey, word);
        }
        
        // Xcode プロジェクト下の InfoPlist.xcstrings を作る
        var toPath = Path.Combine(pathToBuiltProject, infoPlistXcStrings);
        var lGuid = proj.AddFile(toPath, infoPlistXcStrings);
        File.WriteAllText(toPath, xcStringJsonText, System.Text.Encoding.UTF8);
        proj.AddFileToBuild(mainTargetGuid, lGuid);
    }
    
    /// <summary>
    /// XcString のデシリアライズ
    /// </summary>
    /// <returns>XcString, XcStringのJSON文字列</returns>
    private static (XcStrings xcStrings, string xcStringJsonText) xcStringsDeserialize()
    {
        // XcString の読み込み
        var xcStringJsonTextAsset = Resources.Load<TextAsset>(xcStringJson);
        var xcStringJsonText = xcStringJsonTextAsset.text;
        var xcStrings = XcStrings.Deserialize(xcStringJsonText);

        return (xcStrings, xcStringJsonText);
    }
}

この MyAppLocalizePostBuild がある Unity プロジェクトを visionOS プラットフォームに切り替えた上でビルドした。

ビルドして出力された Xcode プロジェクトを開いた内容が次になる。

InfoPlist.xcstrings はプロジェクトのビルド対象になっている。 (上図の左下)

プロジェクト上で InfoPlist.xcstrings を開くと StringCatalog.json で設定した通りの内容が確認できた。(上図の右半分)

動作確認

Vision Pro 実機でビルドしたアプリを動かしてみました。

アプリ名やパーミッションの警告ポップアップの文言が端末の言語設定に連動して、ちゃんと切り替わりました!

アプリ名

英語

日本語

パーミッション警告

英語

日本語

さいごに

String Catalog の知識がゼロのところから始めたので「もしかしたら面倒なことになるかな?」と思ったのですが、やってみたら意外とすんなり実装できました。

これまでも Xcode プロジェクトを出力するときにはIPreprocessBuildWithReport, IPostprocessBuildWithReport を使って独自の処理を介入させた実装を何度も行ってきたため、その経験が活きたのかもしれません。

また XcStrings クラスのコードは実は Claude AI に、一度簡単に出力した InfoPlist.xcstrings の内容を解析させて作らせました。
少しは手を入れましたが、ほぼそのままのコードが使えたので驚きました!

このような感じで、今後も新しいことにはチャレンジしていきたいと思います!!

この記事が気に入ったらサポートをしてみませんか?