I18n
Xamarin
多言語
Xamarin.Forms
More than 1 year has passed since last update.

多言語化専門フリーランスエンジニアの@chooyan_engです。

今回はXamarin.Formsを使ったアプリの多言語化に挑戦しようと思ったところ、テキストの言語切り替えだけでも意外と手順が多い&ドキュメント通りだと少し分かりづらい部分がある、という経験をしましたので、"New Solution"から多言語化が動作するところまでを振り返りつつ、Xamarin.Formsアプリの多言語化の方法についてまとめたいと思います。

前提

  • @chooyan_engはXamarin歴、C#歴ともに1週間足らずです。
  • Javaが多めです
  • Androidアプリも結構やってます

というわけで、XamarinやC#についての記述には誤りが含まれることが予想されます。
記述内容に気になる点がありましたら是非コメントか修正リクエストをいただければと思います!

環境

  • macOS Sierra (10.12.4)
  • Visual Studio for Mac Community版 (7.0.1)

参考

基本的には以下の公式ドキュメントに従って進めます。
Localizing Xamarin.Forms Apps with RESX Resource Files - Xamarin

追加で、上記ドキュメントで紹介されている以下のリポジトリを適宜参考にします。
xamarin-forms-samples/UsingResxLocalization/ - GitHub

また、この記事で説明した内容の結果は以下に上げています。
https://github.com/chooyan-eng/LocalizationSample

本文

ソリューションの作成

まずはソリューションを作成します。
のですが、ここでいきなり注意事項です。作成するテンプレートの種類は、必ず"Blank Forms App"を選んでください。"Forms App"ではありません。
また、次の画面の"Shared Code"の欄で"Use Portable Class Library"を指定する必要があります。

なぜかというと、"Blank Forms App"の方は上記の指定をすることで共通ソース部分が"Portable Class Library(PCL) として作成される一方、"Forms App"は常に"Shared Project"として作成されます。

僕もちゃんと理解できているわけではないのですが、"Shared Project"だと、後述する AppResources.resx ファイルを生成したときに、セットで必要になる AppResources.Designer.cs ファイルの自動生成がされません。

Shared Project - Xamarin 3 の新しいコード共有テクニック : XLsoft の、以下の記述あたりがその理由かな、と思います。(推測)

Shared Project 自体はコンパイルされません。Shared Project は、単純に、他のプロジェクトに含めることができるソース コード ファイルの一つとして存在します。 他のプロジェクトで参照する場合、プロジェクトの 一部 としてコードを効果的にコンパイルします。Shared Projects は、他のプロジェクト タイプを参照しません (他の Shared Projects を含む)。

いずれにしても、ドキュメント通りに多言語化を進める場合は、共通部分はPCLとして作成する必要がありそうです。
(Shared Projectで作成した場合に多言語化できるかどうかは別途検証してみようと思います。)

あとは項目に従ってソリューション作成を完了させます。
完了したら、一度Android、iOSそれぞれで起動してみて正常に動作するかを確認しておきましょう。

AppResources.resxファイルの作成

ソリューションが作成できたら、次はリソースファイルの作成です。
公式ドキュメント的にはAdding Resourcesのあたりです。

まず、PCLプロジェクトの直下にResxというディレクトリを作成します。ドキュメントには書いていませんが、参考にしているUsingResxLocalizationソリューションの方ではそうしていたのでそれに倣います。ここに、リソースファイルをまとめていきます。

次に、以下の手順でデフォルト言語となるAppResources.resxファイルを作成します。

  1. Resxディレクトリを右クリック
  2. 「追加」 > 「新しいファイル」
  3. 「Misc」 > 「リソースファイル」 をクリック
  4. ファイル名に「AppResources」を入力(拡張子は不要です)
  5. 「新規(N)」をクリック

こうすることで、AppResources.resxファイルと、AppResources.Designer.csファイルが自動生成されるはずです。
※ 前述の通り、"Shared Project"で作成されているとここでAppResources.Designer.csファイルが自動生成されません。

AppResources.resxに文字列の定義を追加

AppResources.resxファイルは、自動生成された状態では翻訳対象となるテキストが定義されていないため、</root>の前に以下を追加します。

AppResources.resx
<data name="HelloLocalization" xml:space="preserve">
    <value>Hello! Localization!</value>
    <comment>Sample text for localization</comment>
</data>

上記の HelloLocalization はこのテキストを呼び出す際のタグになります。また、valueは実際に表示する文字列になります。commentはコメントです。

翻訳ファイル AppResources.ja.resx の追加

公式ドキュメントのLanguage-Specific Resourcesのあたりです。

次に、言語設定ごとの翻訳ファイルを以下の手順で作成します。(AppResources.resxの作成方法とは違うので注意してください)

  1. Resxディレクトリを右クリック
  2. 「追加」 > 「新しいファイル」
  3. 「XML」 > 「空のXMLファイル」 をクリック
  4. ファイル名に「AppResources.ja.resx」を入力(拡張子も入力します)
  5. 「新規(N)」をクリック

AppResources.ja.resxファイルが生成されるので、先ほど作成したAppResources.resxの内容を全てコピーして上書きペーストします。

その上で、上記の HelloLocalization<value> 内を日本語に翻訳します。

AppResources.ja.resx
<data name="HelloLocalization" xml:space="preserve">
    <value>多言語化するぜ!</value>
    <comment>Sample text for localization</comment>
</data>

翻訳ファイルのビルドアクションをEmbeddedResourceに設定する

ここで、以下の設定を忘れずにやる必要があります。(僕はこれを見落としていてだいぶハマりました)

These language-specific resources files do not require a .designer.cs partial class so they can be added as regular XML files, with the Build Action: EmbeddedResource set.

手順は以下の通りです。

  1. AppResources.ja.resxファイルを右クリック
  2. 「ビルドアクション」 > 「EmbeddedResource」 をクリック

他の言語ファイルについても同様です。(デフォルトのAppResources.resxファイルについては不要です)

ここまでできれば、 AppResources.HelloLocalization と書けばプログラム中で言語ごとの文字列が取得できるようです。ただし、今回は .xaml ファイル内での多言語化をゴールとしているため、もう一手間あります。(というかここからが面倒臭いです)

端末の言語設定を反映させる(共通プロジェクト部分)

公式ドキュメントのDisplaying the Correct Languageのあたりです。

まず、共通プロジェクトの直下に ILocale.cs' インターフェースを追加します。
これを実装した
Locale.cs` クラスをAndroid、iOS各プラットフォームのプロジェクトに定義することで、ロケールの記述方法など、プラットフォーム固有の事情を吸収します。

また、実装クラスで PlatformCulture という、"ja-JP"などのロケール文字列を言語を表す"ja"と国を表す"JP"に分割して保持するクラスを共通で利用するため、このクラスについても ILocale.cs ファイルに一緒に記述してしまいます。

コードは以下のようになります。

ILocale.cs
using System;
using System.Globalization;

namespace LocalizationSample
{
    public interface ILocalize
    {
        CultureInfo GetCurrentCultureInfo();
        void SetLocale(CultureInfo cl);
    }

    public class PlatformCulture
    {
        public PlatformCulture(string platformCultureString)
        {
            if (String.IsNullOrEmpty(platformCultureString))
            {
                throw new ArgumentException("Expected culture identifier", "platformCultureString"); // in C# 6 use nameof(platformCultureString)
            }
            PlatformString = platformCultureString.Replace("_", "-"); // .NET expects dash, not underscore
            var dashIndex = PlatformString.IndexOf("-", StringComparison.Ordinal);
            if (dashIndex > 0)
            {
                var parts = PlatformString.Split('-');
                LanguageCode = parts[0];
                LocaleCode = parts[1];
            }
            else
            {
                LanguageCode = PlatformString;
                LocaleCode = "";
            }
        }
        public string PlatformString { get; private set; }
        public string LanguageCode { get; private set; }
        public string LocaleCode { get; private set; }
        public override string ToString()
        {
            return PlatformString;
        }
    }
}

※ 4行目の namespace 部分はソリューション名に合わせて修正してください。

次に、このクラスを使って実際に言語設定をセットする以下のコードを、共通プロジェクトの App.xaml.cs クラスのコンストラクタに追記します。Appクラスに記述することによって、アプリ起動時に必ず現在の言語設定が反映されるようになります。

App.xaml.cs
using Xamarin.Forms;

namespace LocalizationSample
{
    public partial class App : Application
    {
        public App()
        {
            InitializeComponent();
            if (Device.OS == TargetPlatform.iOS || Device.OS == TargetPlatform.Android)
            {
                var ci = DependencyService.Get<ILocalize>().GetCurrentCultureInfo();
                Resx.AppResources.Culture = ci; // set the RESX for resource localization
                DependencyService.Get<ILocalize>().SetLocale(ci); // set the Thread for locale-aware methods
            }
            MainPage = new LocalizationSamplePage();
        }

        protected override void OnStart()
        {
            // Handle when your app starts
        }

... 省略 ...

    }
}

※ 3行目の namespace 部分はソリューション名に合わせて修正してください。

端末の言語設定を反映させる(プラットフォーム毎のプロジェクト部分)

公式ドキュメントのPlatform-Specific Codeのあたりです。

先ほど作成した ILocalize.cs インターフェースを実装した Localize.cs クラスを各プラットフォームで定義します。

まず、iOSは以下の通りです。.iOSプロジェクトの直下に作成してください。

Localize.cs
using System;
using System.Globalization;
using System.Threading;
using Foundation;

[assembly: Xamarin.Forms.Dependency(typeof(LocalizationSample.iOS.Localize))]

namespace LocalizationSample.iOS
{
    public class Localize : LocalizationSample.ILocalize
    {
        public void SetLocale(CultureInfo ci)
        {
            Thread.CurrentThread.CurrentCulture = ci;
            Thread.CurrentThread.CurrentUICulture = ci;
        }
        public CultureInfo GetCurrentCultureInfo()
        {
            var netLanguage = "en";
            if (NSLocale.PreferredLanguages.Length > 0)
            {
                var pref = NSLocale.PreferredLanguages[0];
                netLanguage = iOSToDotnetLanguage(pref);
            }
            // this gets called a lot - try/catch can be expensive so consider caching or something
            System.Globalization.CultureInfo ci = null;
            try
            {
                ci = new System.Globalization.CultureInfo(netLanguage);
            }
            catch (CultureNotFoundException e1)
            {
                // iOS locale not valid .NET culture (eg. "en-ES" : English in Spain)
                // fallback to first characters, in this case "en"
                try
                {
                    var fallback = ToDotnetFallbackLanguage(new PlatformCulture(netLanguage));
                    ci = new System.Globalization.CultureInfo(fallback);
                }
                catch (CultureNotFoundException e2)
                {
                    // iOS language not valid .NET culture, falling back to English
                    ci = new System.Globalization.CultureInfo("en");
                }
            }
            return ci;
        }
        string iOSToDotnetLanguage(string iOSLanguage)
        {
            var netLanguage = iOSLanguage;
            //certain languages need to be converted to CultureInfo equivalent
            switch (iOSLanguage)
            {
                case "ms-MY":   // "Malaysian (Malaysia)" not supported .NET culture
                case "ms-SG":   // "Malaysian (Singapore)" not supported .NET culture
                    netLanguage = "ms"; // closest supported
                    break;
                case "gsw-CH":  // "Schwiizertüütsch (Swiss German)" not supported .NET culture
                    netLanguage = "de-CH"; // closest supported
                    break;
                    // add more application-specific cases here (if required)
                    // ONLY use cultures that have been tested and known to work
            }
            return netLanguage;
        }
        string ToDotnetFallbackLanguage(PlatformCulture platCulture)
        {
            var netLanguage = platCulture.LanguageCode; // use the first part of the identifier (two chars, usually);
            switch (platCulture.LanguageCode)
            {
                case "pt":
                    netLanguage = "pt-PT"; // fallback to Portuguese (Portugal)
                    break;
                case "gsw":
                    netLanguage = "de-CH"; // equivalent to German (Switzerland) for this app
                    break;
                    // add more application-specific cases here (if required)
                    // ONLY use cultures that have been tested and known to work
            }
            return netLanguage;
        }
    }
}

※ 6, 8, 10行目の namespace 部分などはソリューション名に合わせて修正してください。

次に、Androidです。 .Droidプロジェクトの直下に作成してください。

Localize.cs
using System;
using Xamarin.Forms;
using System.Threading;
using System.Globalization;

[assembly:Xamarin.Forms.Dependency(typeof(LocalizationSample.Droid.Localize))]

namespace LocalizationSample.Droid
{
    public class Localize : LocalizationSample.ILocalize
    {
        public void SetLocale(CultureInfo ci)
        {
            Thread.CurrentThread.CurrentCulture = ci;
            Thread.CurrentThread.CurrentUICulture = ci;
        }
        public CultureInfo GetCurrentCultureInfo()
        {
            var netLanguage = "en";
            var androidLocale = Java.Util.Locale.Default;
            netLanguage = AndroidToDotnetLanguage(androidLocale.ToString().Replace("_", "-"));
            // this gets called a lot - try/catch can be expensive so consider caching or something
            System.Globalization.CultureInfo ci = null;
            try
            {
                ci = new System.Globalization.CultureInfo(netLanguage);
            }
            catch (CultureNotFoundException e1)
            {
                // iOS locale not valid .NET culture (eg. "en-ES" : English in Spain)
                // fallback to first characters, in this case "en"
                try
                {
                    var fallback = ToDotnetFallbackLanguage(new PlatformCulture(netLanguage));
                    ci = new System.Globalization.CultureInfo(fallback);
                }
                catch (CultureNotFoundException e2)
                {
                    // iOS language not valid .NET culture, falling back to English
                    ci = new System.Globalization.CultureInfo("en");
                }
            }
            return ci;
        }
        string AndroidToDotnetLanguage(string androidLanguage)
        {
            var netLanguage = androidLanguage;
            //certain languages need to be converted to CultureInfo equivalent
            switch (androidLanguage)
            {
                case "ms-BN":   // "Malaysian (Brunei)" not supported .NET culture
                case "ms-MY":   // "Malaysian (Malaysia)" not supported .NET culture
                case "ms-SG":   // "Malaysian (Singapore)" not supported .NET culture
                    netLanguage = "ms"; // closest supported
                    break;
                case "in-ID":  // "Indonesian (Indonesia)" has different code in  .NET
                    netLanguage = "id-ID"; // correct code for .NET
                    break;
                case "gsw-CH":  // "Schwiizertüütsch (Swiss German)" not supported .NET culture
                    netLanguage = "de-CH"; // closest supported
                    break;
                    // add more application-specific cases here (if required)
                    // ONLY use cultures that have been tested and known to work
            }
            return netLanguage;
        }
        string ToDotnetFallbackLanguage(PlatformCulture platCulture)
        {
            var netLanguage = platCulture.LanguageCode; // use the first part of the identifier (two chars, usually);
            switch (platCulture.LanguageCode)
            {
                case "gsw":
                    netLanguage = "de-CH"; // equivalent to German (Switzerland) for this app
                    break;
                    // add more application-specific cases here (if required)
                    // ONLY use cultures that have been tested and known to work
            }
            return netLanguage;
        }
    }
}

※ 6, 8, 10行目の namespace 部分などはソリューション名に合わせて修正してください。

Android, iOSいずれのコードも、ネイティブのクラスからロケール情報を取得し、.NETがサポートしていないロケールを丸めたり、区切り文字として使われる -_ の違いを修正したりしているみたいですね。

これで、プラットフォームごとのロケールの扱いの違いを吸収できるようになりました。

XAMLファイル内で多言語化できるようにする

最後の工程です。
公式ドキュメントのLocalizing XAMLの部分です。

まず、XAMLファイルから多言語化のために呼び出す処理を以下の TranslateExtensin.cs ファイルに記述します。ファイルはXAMLファイルと同じ階層に置いてください。(つまり今回は共通プロジェクトの直下)

TranslateExtensin.cs
using System;
using System;
using System.Globalization;
using System.Reflection;
using System.Resources;
using Xamarin.Forms;
using Xamarin.Forms.Xaml;

namespace LocalizationSample
{
    // You exclude the 'Extension' suffix when using in Xaml markup
    [ContentProperty("Text")]
    public class TranslateExtension : IMarkupExtension
    {
        readonly CultureInfo ci;
        const string ResourceId = "LocalizationSample.Resx.AppResources";

        public TranslateExtension()
        {
            if (Device.OS == TargetPlatform.iOS || Device.OS == TargetPlatform.Android)
            {
                ci = DependencyService.Get<ILocalize>().GetCurrentCultureInfo();
            }
        }

        public string Text { get; set; }

        public object ProvideValue(IServiceProvider serviceProvider)
        {
            if (Text == null)
                return "";

            ResourceManager resmgr = new ResourceManager(ResourceId
                                , typeof(TranslateExtension).GetTypeInfo().Assembly);

            var translation = resmgr.GetString(Text, ci);

            if (translation == null)
            {
#if DEBUG
                throw new ArgumentException(
                    String.Format("Key '{0}' was not found in resources '{1}' for culture '{2}'.", Text, ResourceId, ci.Name),
                    "Text");
#else
                translation = Text; // HACK: returns the key, which GETS DISPLAYED TO THE USER
#endif
            }
            return translation;
        }
    }
}

※ 9, 16行目の namespace 部分などはソリューション名に合わせて修正してください。

最後に、多言語化したいページのXAMLファイルを修正します。
以下の記述をXAMLファイルの<ContentPage>に追記してください。

xmlns:i18n="clr-namespace:LocalizationSample;assembly=LocalizationSample"

※ LocalizationSampleの部分2箇所はソリューション名に合わせて修正してください

結果はこんな感じになります。

LocalizationSamplePage.xaml
<ContentPage 
    xmlns="http://xamarin.com/schemas/2014/forms" 
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
    xmlns:local="clr-namespace:LocalizationSample" 
    x:Class="LocalizationSample.LocalizationSamplePage"
    xmlns:i18n="clr-namespace:LocalizationSample;assembly=LocalizationSample">

最後に、表示したいテキスト部分に {i18n:Translate HelloLocalization} で記述します。

LocalizationSamplePage.xaml
<Label 
    Text="{i18n:Translate HelloLocalization}" 
    VerticalOptions="Center" 
    HorizontalOptions="Center" />

これで対応は完了です。
アプリを起動し、端末の言語設定を日本語や英語、その他の言語に切り替えながら動作確認してみてください。

まとめ

ドキュメントには他にも、iOSが自動で翻訳してくれちゃうコンポーネントの制御方法や、Windowsアプリでの多言語化のやり方、画像ファイルの切り替え方などが記載されています。
この記事では分量の関係でAndorid, iOSアプリでテキストを多言語化する部分にしか触れていませんが、必要に応じて公式ドキュメントを参照していただければと思います。

全体を通して試行錯誤してみて、Android Javaでは言語別のリソースフォルダ(values-jaなど)を定義して、その各フォルダにstrings.xmlを配置すれば勝手に多言語化してくれたのと同じノリでいけるのかと思って手をだしてみたら、だいぶ痛い目を見てしまいました。。。

おそらく各プラットフォームの差分をちゃんと埋めて共通コードで多言語化しようとすると、いろいろとやらなければならないことが増えるということなのだと思います

まだ記載したクラスの処理はあまり追えていませんので、それぞれなぜあんな長い処理を入れなければならないのかを、C#の勉強も兼ねて調べていければと思います。