Привет, Хабр!
В недавней статье наш коллега Дмитрий Марущенко yojick рассказал о том, как доставлять динамические обновления переводов с сервера на мобильные устройства. В продолжение темы сегодня поговорим о том, как мы используем эти обновлённые переводы в наших приложениях.
Исторически сложилось, что все основные мобильные платформы «из коробки» обладают превосходной поддержкой локализации сообщений. В iOS, Android и Windows Phone приложение можно локализовать без всяких трудностей. Все средства для этого уже встроены в IDE: просто укажите нужный язык в списке поддерживаемых локализаций, введите текст на этом языке — и всё остальное за вас сделает IDE. Работает как часы. Но у этого подхода всё же есть недостатки.
Нашли в тексте ошибку? Хотите что-то перефразировать? Вам нравится экспериментировать с разными обращениями к разным целевым группам? Во всех случаях ответ один: придётся пересобирать приложение, снова выкладывать его в магазин, проходить проверку, получать одобрение, публиковать новую версию со всеми изменениями и ждать, чтобы пользователи обновили приложение на своих устройствах. Даже если все процедуры пройдут без заминок, это займёт дни или недели. А если пользователи не захотят обновляться? Или того хуже — не смогут этого сделать по техническим причинам вроде неподдерживаемой ОС? Тогда нежелательный текст в вашем приложении проживет гораздо дольше, чем хотелось бы.
Это довольно неудобно. К счастью, нам удалось решить эту проблему на разных платформах (с учётом особенностей каждой из них). И мы рады поделиться своим решением.
Всё просто. Мы воспользуемся имеющимися платформенными средствами локализации, но добавим к ним динамические обновления по требованию. Для этого внедрим систему версионирования локализации. Когда разработчик, технический писатель или кто-либо ещё изменит базу данных локализации, мы увеличим номер версии локализации. При сборке мобильного приложения мы получим самую свежую версию локализации и положим её в бандл вместе с номером версии.
Если клиент получает от сервера сигнал о появлении новой версии локализации, он запрашивает обновление, предоставляя серверу текущую версию локализации. Имея две разные версии, сервер создаёт дифф и передаёт его мобильному клиенту. Получив дифф, клиент применяет его к текущей версии, сохраняет последнюю версию и с этого момента использует в приложении только обновлённый пакет.
Процесс можно повторять раз за разом. Клиент всегда будет получать от сервера самую свежую версию локализации. Подход, о котором пойдёт речь далее, мы используем на клиентской стороне во всех мобильных приложениях Badoo. Учитывая все реализованные серверные средства и предполагая, что клиентский код уже имеет доступ к обновлённым локализациям, нам нужно лишь предоставлять компонентам пользовательского интерфейса правильные сообщения. За работу!
iOS
Естественный способ локализации сообщения в iOS заключается в использовании одного из методов семейства
NSLocalizedString
. Мы создали набор аналогичных методов BPFLocalizedString
(префикс BPF означает Badoo Platform Foundation) и используем их по всему приложению. «Под капотом» BPFLocalizedString
использует сервис локализации, который содержит все данные и реализует основной функционал. Мы сохраняем все поступающие от сервера обновления в отдельном бандле. Когда клиентский код запрашивает локализованную строку, мы ищем в этом бандле нужное сообщение и при необходимости возвращаемся к дефолтному бандлу локализации.public func localizedStringForKey(_ key: String) - > String {
let str = self.localizationsBundle.localizedString(forKey: key)
return str == key ? Bundle.main.localizedString(forKey: key,
value: nil, table: nil) : str
}
Этот подход сильно упрощает клиентский код. При этом можно использовать всю тяжёлую локализационную iOS-машинерию, включая поддержку языков с правосторонним начертанием и множественные (plural) локализации. Для этого нам нужно лишь поддерживать внутри дополнительного пакета валидные данные.
Верхнеуровневый API для
BPFLocalizedString
выглядит так:NSString * __nonnull BPFLocalizedString(NSString * __nonnull key, NSString * __nullable comment);
public func BPFLocalizedString(_ key: String) - > String {
return
BPFGlobals.shared().localizedStringsService.localizedStringForKey(
key)
}
Его легко использовать как из Objective-C-, так и из Swift-кода.
Есть одна тонкость. Обновления локализации могут приходить в любой момент жизненного цикла клиентского приложения. И при этом мы ещё можем показывать пользователям какие-то «старые» локализованные сообщения. Чтобы сохранять согласованность данных, лучше не смешивать «старые» локализации с «новыми». Эту задачу мы решаем, применяя обновления только при следующем запуске приложения. Такое решение всё упрощает и позволяет клиентскому коду не «думать» о неожиданных случаях.
Какие здесь ограничения? Нужно быть уверенными, что мы везде заменили
NSLocalizedString
на соответствующие BPFLocalizedString
. К счастью, эту задачу можно легко решить с помощью автоматических скриптов. Другим ограничением является невозможность применения BPFLocalizedString
напрямую к статично упакованным элементам пользовательского интерфейса (XIB и Storyboard). Это вполне естественное ограничение, поскольку мы заменяем статичную локализацию на динамическую.Android
В Android локализации упакованы в APK-файл, и в ходе выполнения менять его невозможно. В этой ОС стандартным решением по локализации сообщения является использование Resources. Доступ к
Resources
является частью интерфейса Context
. Resources
предоставляет конфигурацию текущего устройства (локейшн, размер экрана, ориентацию и так далее). Одним из решений является замена всех Resources.getString()
на нашу собственную кастомную реализацию, как в iOS. Но мы выбрали более элегантный способ.Что, если можно было бы внедрять свою реализацию
Resources
вместо системной? К счастью, это возможно! Возьмём класс Activity
, напишем его наследника и везде применим:public abstract class BaseActivity extends Activity {
private Resources mResources;
public Resources getResources() {
if (mResources == null) {
Resources r = super.getResources();
mResources = new ResourceWrapper(this, r);
}
return mResources;
}
}
И сделаем обёртку вокруг стандартного
Resources
для извлечения обновлённых значений лексем:public class ResourceWrapper extends Resources {
private final Resources mResources;
private final LexemeProvider mLexemeProvider;
public ResourceWrapper(Context context, Resources r) {
super(r.getAssets(), r.getDisplayMetrics(), r.getConfiguration());
mResources = resources;
mLexemeProvider = new LexemeProvider(...);
}
@Override
public String getString(@StringRes int id) throws
NotFoundException{
String hotString = mLexemeProvider.getString(id);
if (hotString == null) {
return mResources.getString(id);
} else {
return hotString;
}
// Override each method and return corresponding value from
mResources
@Override
public boolean getBoolean(int id) throws NotFoundException {
return mResources.getBoolean(id);
}
}
Очевидно, что нужно перехватывать все методы, относящиеся к тексту (getString, getText, getQuantityString, getQuantityText), и возвращать значения, полученные от нашего собственного поставщика локализаций.
Обычно система использует не непосредственно класс Resources, который есть в исходниках Android, а некоего его наследника, поэтому надо пробрасывать все вызовы к
ResourceWrapper
в эту конкретную реализацию (mResources
в нашем случае). Пока что мы имели дело с явными получателями лексем. А что насчёт представлений, «надуваемых» из XML-макетов? Когда вы объявляете атрибут android:text, после своего «надувания»
TextView
вызывает context.getTheme().obtainStyledAttributes(…).getText(…), чтобы получить соответствующие значения, и в этом случае наша замена для Resources
уже не работает.Нужно и сюда тоже внедрить наш
LocalizationProvider
.public class DynamicLexemeInflater {
private static void applyDynamicLexems(View view, String name,
Context context, AttributeSet attrs) {
if (view instanceof TextView) {
TextView textView = (TextView) view;
TypedArray typedArray = context.obtainStyledAttributes(attrs,
new int[] {
android.R.attr.text, android.R.attr.hint
});
int textResourceId = typedArray.getResourceId(0, -1);
if (textResourceId != -1) {
String dispatchedString = context.getString(textResourceId);
textView.setText(dispatchedString);
}
int hintResourceId = typedArray.getResourceId(1, -1);
if (hintResourceId != -1) {
String dispatchedString = context.getString(hintResourceId);
textView.setHint(dispatchedString);
}
typedArray.recycle();
}
}
Здесь мы настроили свою фабрику
InflaterFactory
для добавления «постобработки» «надуваемых» представлений, в которых задаются текстовые значения.Windows Phone
Как и в iOS с Android, в Windows Phone ресурсы локализации помещены в пакет приложения. Его тоже нельзя изменить в ходе выполнения программы. Наш подход к горячему обновлению локализаций основан на API Windows Phone Silverlight 8.1.
В WP-приложениях мы обычно обращаемся к локализованным сообщениям через генерируемый инструментарием класс
AppResources
(или можно дать ему любое другое имя), который содержит статические геттеры для всех строк, используемых в приложении. Вот что находится внутри этих геттеров:public static string ApplicationTitle {
get {
return ResourceManager.GetString("ApplicationTitle",
resourceCulture);
}
}
Обратите внимание, что здесь используется свойство
ResourceManager
, определённое какpublic static global::System.Resources.ResourceManager
ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PhoneApp.Resources.AppResources",
typeof (AppResources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
System.Resources.ResourceManager
— это центральный компонент API системы локализации, на него ложится весь нелёгкий труд по загрузке актуальных строковых значений. К счастью, в перегруженном методе (overloaded method) у него есть точка расширения: public virtual string GetString(string name, CultureInfo culture)
. Именно это нам и нужно для внедрения своей машинерии и для дополнения предоставляемых системой средств. Нужно лишь унаследоваться от этого класса и перегрузить метод GetString
:public class UpdateableResourceManager: ResourceManager {
public override string GetString(string name, CultureInfo culture)
{
var lexemesHandler = _localizationService.GetLexemesHandler(culture);
return lexemesHandler?.GetLexeme(name)?.Value?.Text ?? base.GetString(name, culture);
}
}
Теперь нужно использовать наш
UpdateableResourceManager
вместо того, что по умолчанию используется в классе AppResources
. Но поскольку этот класс генерируется автоматически, нужно также получить контроль над генерированием, чтобы добавлять в получающийся файл свои данные. Обычно это делается при каждом открывании файла AppResources
в Visual Studio, но можно сделать это и вручную (или автоматически в скрипте) с помощью инструмента RESGen, как в этом примере с PowerShell:$resgenPath = “C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.6 Tools\ResGen.exe” & $resgenPath AppResources.resx to_delete.txt “/str:cs,Badoo.Is.Ponies.Namespace,AppResources,AppResources.Designer.cs” / publicclass
Remove — Item “to_delete.txt”
Также нужно заменить строку
System.Resources.ResourceManager
в нашем Badoo.Next.Big.Thing.UpdateableResourceManager
. Остальное обрабатывается в отдельном LocalizationService, отвечающем за всю работу с сетью, сохранение и поиск данных.Заключение
Это краткий обзор нашего подхода к реализации процесса динамического обновления локализаций для собственных мобильных приложений на всех основных платформах. У всех трёх случаев много общего. Мы создали мощный и гибкий в использовании инструмент, позволяющий в любое время применять обновления локализаций. При этом мы постарались как можно более органично интегрировать его с нашей базой кода. Это позволило нам внедрить локализационную инфраструктуру в мобильные приложения с минимальными усилиями со стороны клиентских разработчиков. Мы используем средства локализации в повседневном рабочем процессе. Это проверенный временем, надёжный и полезный инструмент.
Пётр Колпащиков, iOS-разработчик
Виктор Патрушев, Android-разработчик
Стас Шуша, Windows Phone-разработчик
dmitry_dvm
Недостижимый код же. Или там парсер "?." сожрал?
geegaset Автор
dmitry_dvm все верно, форматирование слетело. Спасибо, поправил.