Tłumaczenie aplikacji mobilnych – XAMARIN

Tłumaczenie aplikacji mobilnych – XAMARIN

Wstęp

To jest kolejny artykuł z serii o globalizacji i lokalizacji. Jeśli nie czytałeś poprzednich, koniecznie to nadrób. W tym artykule opisuję TYLKO jak ładować tłumaczenia w aplikacjach mobilnych tworzonych w XAMARIN. Poprzedni artykuł – Tłumaczenie aplikacji cz. 3 – jak to ogarnąć? – daje całą podstawę.

Piszemy MarkupExtension

W XAMARINie, jak i w WPF, też mamy do czynienia z językiem XAML. Wersja dla Xamarina może i nie jest tak rozbudowana, jednak pozwala na dużo. I tutaj też posłużymy się MarkupExtension. Jeśli nie wiesz co to, w skrócie to jest interfejs (w WPF to jest klasa), który pozwala Ci na tworzenie własnych tagów XAML. Wystarczy go zaimplementować:

[ContentProperty("ResId")]
public class LocalizeExtension : IMarkupExtension
{
	public string ResId { get; set; }
	static CultureInfo ci = null;

	public LocalizeExtension()
	{
		if (ci == null && (Device.RuntimePlatform == Device.iOS || Device.RuntimePlatform == Device.Android))
		{
			ci = DependencyService.Get<ILocaleService>().GetCurrentCultureInfo();
		}
	}

	public object ProvideValue(IServiceProvider serviceProvider)
	{
		if (string.IsNullOrWhiteSpace(ResId))
			return "<???>";

		string result = LangRes.ResourceManager.GetString(ResId, ci);
		if (string.IsNullOrEmpty(result))
			return $"<? {ResId} ?>";
		else
			return result;
	}
}

Klasa trzyma CultureInfo, które jest pobrane z jakiegoś serwisu. Jest to potrzebne tylko na Androidzie i iOS. Kto bardziej spostrzegawczy, to zorientuje się, że moja metoda ProvideValue ma mały błąd. Tak naprawdę powinienem też w niej sprawdzić platformę i albo wykorzystać metodę GetString z przeciążeniem CultureInfo (dla Androida i iOS), albo wersję bez CultureInfo. Jednak w Xamarinie robię tylko pod Androida, więc darowałem sobie to sprawdzenie.

Atrybut ContentProperty ustawiony na klasie wskazuje na domyślną właściwość. Dzięki czemu w XAML nie musimy już jej podawać:

<Label Text="{app:Localize ResId=Receipt}"/>

wystarczy:

<Label Text="{app:Localize Receipt}"/>

No dobrze, ale co z tym serwisem ILocaleService?

Pobranie lokalizacji z Androida i iOS

Xamarin wymaga jednego małego myku. Każdy z tych systemów musi sam zwrócić odpowiednią lokalizację. Zatem idealnym wydaje się utworzenie interfejsu i jego implementacja w konkretnym projekcie (nie ogólnym Xamarin, tylko konkretnie w aplikacji Android i iOS). Interfejs jest prosty:

Tworzenie ILocaleService

Zdefiniuj ten interfejs gdzieś w projekcie Xamarina lub w projekcie współdzielonym przez projekty Xamarina.

public interface ILocaleService
{
	CultureInfo GetCurrentCultureInfo();
	void SetLocale(CultureInfo ci);
}

Tworzenie PlatformCulture

Teraz musimy utworzyć małą klasę pomocniczą też w projekcie Xamarin (lub współdzielonym). Powinna ona wyglądać tak:

public class PlatformCulture
 {
	public PlatformCulture(string platformCultureString)
	{
		if (String.IsNullOrEmpty(platformCultureString))
			throw new ArgumentException("Expected culture identifier", 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;
	}
}

To jest kod wzięty z oficjalnej dokumentacji Microsoftu. Zadanie tej klasy stanie się za chwilę bardziej jasne. Generalnie jej celem jest właściwie zwrócenie kodu kraju, jeśli dostaniemy z urządzenia kod, którego nie ma w .NET, np. „en-ES”.

Implementacja ILocaleService na Androidzie

Po tych wszystkich znojach, musimy teraz utworzyć klasę w projekcie Androida, która będzie implementowała interfejs ILocaleService:

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

[assembly: Dependency(typeof(Xamarin.Droid.Services.LocaleService))]
namespace Xamarin.Droid.Services
{
    public class LocaleService : ILocaleService
	{
		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
			CultureInfo ci = null;
			try
			{
				ci = new CultureInfo(netLanguage);
			}
			catch (CultureNotFoundException)
			{
				// 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 CultureInfo(fallback);
				}
				catch (CultureNotFoundException)
				{
					// iOS language not valid .NET culture, falling back to English
					ci = new CultureInfo("en");
				}
			}
			return ci;
		}

		public void SetLocale(CultureInfo ci)
		{
			Thread.CurrentThread.CurrentCulture = ci;
			Thread.CurrentThread.CurrentUICulture = 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;
		}
	}
}

Z urządzenia dostaniemy kod kraju w postaci en_US – z podkreślnikiem zamiast myślnika. Dlatego też w pierwszej kolejności trzeba to zmienić.

Następnie trzeba spróbować utworzyć CultureInfo z przekazanym kodem. Niestety, może się okazać że z urządzenia otrzymamy kod, którego nie ma w .NET (jak wyżej wspomniany en-ES). I tu wchodzi do roboty klasa PlatformCulture, która po prostu sparsuje odpowiednio kod kraju i zwróci tylko identyfikator języka (np. „en”).

Ten kod również pochodzi z oficjalnej dokumentacji Microsoftu. Przyjrzyj się jeszcze linijce nr 5:

[assembly: Dependency(typeof(Xamarin.Droid.Services.LocaleService))]

To po prostu mechanizm DependencyInjection, którym posłużymy się jeszcze za chwilę. I którym posługujemy się w konstruktorze Xamarinowego LocalizeExtension. Ten atrybut automagicznie rejestruje klasę.

Implementacja ILocaleService na iOS

Nie musisz tego robić, jeśli nie piszesz aplikacji pod iOS. Jeśli jednak ma działać na jabłuszku, jest to konieczne. Poniższy kod jest podobny do tego z Androida i też pochodzi z oficjalnej dokumentacji Microsoftu:

assembly: Xamarin.Forms.Dependency(typeof(Xamarin.iOS.Services.LocaleService))]
namespace Xamarin.iOS.Services
{
	//https://docs.microsoft.com/en-us/xamarin/xamarin-forms/app-fundamentals/localization/text?tabs=windows

	public class LocaleService : ILocaleService
	{
		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
			CultureInfo ci = null;
			try
			{
				ci = new CultureInfo(netLanguage);
			}
			catch (CultureNotFoundException )
			{
				// 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 CultureInfo(fallback);
				}
				catch (CultureNotFoundException )
				{
					// iOS language not valid .NET culture, falling back to English
					ci = new CultureInfo("en");
				}
			}
			return ci;
		}

		public void SetLocale(CultureInfo ci)
		{
			Thread.CurrentThread.CurrentCulture = ci;
			Thread.CurrentThread.CurrentUICulture = ci;
		}

		string iOSToDotnetLanguage(string iOSLanguage)
		{
			// .NET cultures don't support underscores
			string netLanguage = iOSLanguage.Replace("_", "-");

			//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;
		}
	}
}

Ustawienie kultury

Teraz w pliku App.xaml.cs w projekcie Xamarin powinieneś dodać gdzieś podczas inicjalizacji taki fragment kodu:

if(Device.RuntimePlatform == Device.iOS || Device.RuntimePlatform == Device.Android)
{
	var ci = DependencyService.Get<ILocaleService>().GetCurrentCultureInfo();
	LangRes.Culture = ci;
	DependencyService.Get<ILocaleService>().SetLocale(ci);
}

Gdzie LangRes to Twoja klasa z zasobami utworzona przez VisualStudio. Wszystko to ma na celu zapewnienie poprawnego działania lokalizacji na urządzeniach z Androidem i iOS.

Jak tłumaczyć w XAML?

Tutaj sprawa wygląda już dokładnie tak samo jak przy WPF. Wystarczy, że zadeklarujesz alias na swój namespace w pliku xaml:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:app="clr-namespace:MojaAplikacja"

gdzie MojaAplikacja to namespace do klasy LocalizeExtension; następnie w kodzie:

<Label Text="{app:Localize Receipt}"/>

gdzie Receipt to po prostu klucz z zasobów językowych.


To na tyle jeśli chodzi o tłumaczenia aplikacji na urządzeniach mobilnych. Jeśli używasz jakiegoś innego sposobu lub znalazłeś błąd w artykule, koniecznie podziel się w komentarzu 🙂

Podziel się artykułem na:
Tłumaczenie aplikacji desktopowych (WinForms i WPF)

Tłumaczenie aplikacji desktopowych (WinForms i WPF)

Wstęp

To jest kolejny artykuł z serii o globalizacji i lokalizacji. Jeśli nie czytałeś poprzednich, koniecznie to nadrób. W tym artykule opisuję TYLKO jak ładować tłumaczenia w aplikacjach WinForms i WPF. Poprzedni artykuł – Tłumaczenie aplikacji cz. 3 – jak to ogarnąć? – daje całą podstawę.

Tłumaczenia w WinForms

W WinForms nie ma żadnego zmyślnego sposobu na ładowanie tłumaczeń. Po prostu każdemu przyciskowi, labelowi itd musisz zmienić TEXT w runtime. Chyba, że wymyślisz swój własny sposób, który zadziała automatycznie. Ale chyba więcej z tym nerwów niż pożytku.

Generalnie robisz to dokładnie tak samo, jak robiłbyś to w konsoli – dokładnie tak jak opisane w artykule podstawowym.

Pamiętaj, żeby domyślnie nadawać teksty w języku angielskim – wtedy jeśli czegoś nie przetłumaczysz, użytkownicy zobaczą teksty w tym właśnie języku.

Tłumaczenia w WPF

W WPF skorzystamy z ustrojstwa, co się zowie MarkupExtension. Jeśli nie wiesz co to, to odsyłam do netu: „WPF markup extension”, być może kiedyś opiszę ten mechanizm.

W skrócie – to coś, dzięki czemu możesz tworzyć własne tagi XAML. Coś jak {Binding...}

Teraz popatrz na ten fragment kodu:

<GroupBox Header="{app:Localize Receipt}" />

Tutaj widzisz mój markup extension – Localize. Efektem tego kodu będzie pobranie zasobu o kluczu Receipt i wartość tego zasobu będzie widoczna w nagłówku GroupBoxa – w Polsce: „Paragon”, wszędzie indziej: „Receipt”. Super? Ja się jaram 🙂

A teraz zobaczmy, jak coś takiego osiągnąć. Generalnie łatwo, prosto i przyjemnie…

MarkupExtension do tłumaczeń

Stwórz taką klasę najlepiej gdzieś w projekcie WPF:

[ContentProperty("ResId")]
class LocalizeExtension : MarkupExtension
{
    public string ResId { get; set; }
    public LocalizeExtension()
    {

    }

    public LocalizeExtension(string ResId)
    {
        this.ResId = ResId;
    }


    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        if (string.IsNullOrWhiteSpace(ResId))
            return "<???>";

        string result = LangRes.ResourceManager.GetString(ResId);
        if (string.IsNullOrEmpty(result))
            return $"<? {ResId} ?>";
        else
            return result;
    }
}

Wystarczy napisać klasę, która dziedziczy po MarkupExtension.

W linijce 1 podajesz domyślną właściwość… Tzn. gdybym tego nie zrobił, musiałbym kod w XAML napisać tak:

<GroupBox Header="{app:Localize ResId=Receipt}"/> //zwróć uwagę na obecność ResId

Klasa ma jedną metodę – ProvideValue i to w niej dzieje się cała magia. Po prostu pobieram stringa z zasobów na podstawie przekazanego klucza.

Ja tu sobie zrobiłem taki myk, że w razie jakbym nie dodał jakiegoś tłumaczenia, wtedy zamiast konkretnego stringa (którego nie ma w zasobach) zobaczę nazwę tego klucza w nawiasach ostrych. Dzięki temu wiem, że danego tłumaczenia nie ma w zasobach. Pozwalam sobie na taką nonszalancję, bo sprawdzam każde okienko, które robię.

I to właściwie tyle. Po utworzeniu takiej klasy, możesz skompilować projekt i używać swojego markup extension.

Być może nie wiesz skąd się bierze to app w kodzie:

<GroupBox Header="{app:Localize Receipt}"/>

To po prostu alias na namespace, w którym masz swoją klasę LocalizeExtension. Musisz go oczywiście zadeklarować na początku pliku XAML analogicznie do innych, np:

<Window
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:app="clr-namespace:MojaAplikacja">

gdzie MojaAplikacja to namespace, w którym znajduje się Twoja klasa. Oczywiście to nie musi być app. To może być cokolwiek, ale mam nadzieję, że to wiesz.


To wszystko jeśli chodzi o tłumaczenie aplikacji desktopowych. Jeśli masz jakieś inne pomysły, podziel się w komentarzu.

Obrazek komputera w logo tego artykułu dzięki upklyak / Freepik

Podziel się artykułem na: