Обновление строк на лету в мобильных приложениях: часть 2 +27




Привет, Хабр!

В недавней статье наш коллега Дмитрий Марущенко 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-разработчик




К сожалению, не доступен сервер mySQL