company_banner

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



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

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

    Badoo

    216,48

    Big Dating

    Поделиться публикацией

    Похожие публикации

    Комментарии 2
      0
      return lexemesHandler ? .GetLexeme(name) ? .Value ? .Text
       base.GetString(name, culture);

      Недостижимый код же. Или там парсер "?." сожрал?
        0
        dmitry_dvm все верно, форматирование слетело. Спасибо, поправил.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое