Локализация приложений в iOS
Часть 1. Что мы имеем?
Руководство по работе с локализированными строковыми ресурсами
Введение
Несколько лет назад я окунулся в волшебный мир iOS разработки, который всей своей сутью сулил мне счастливое будущее в сфере IT. Однако углубляясь в особенности платформы и среды разработки, я столкнулся со многими сложностями и неудобствами в решении, казалось бы, весьма тривиальных задач: «инновационный консерватизм» Apple порой заставляет разработчиков сильно изощряться, дабы удовлетворить необузданное «ХОЧУ» заказчика.
Одной из таких проблем является вопрос локализации строковых ресурсов приложения. Именно этой проблеме мне хотелось бы посвятить несколько своих первых публикаций на просторах Хабра.
Изначально я рассчитывал уместить свои мысли в одной статье, но объем той информации, которую хотелось бы изложить, оказался достаточно большим. В этой статье я попытаюсь раскрыть суть стандартных механизмов работы с локализованными ресурсами с акцентом на некоторые аспекты, которыми пренебрегают большинство гайдов и туториалов. Материал ориентирован прежде всего на начинающих разработчиков (или тех, кто с такими задачами не сталкивался). Для опытных девелоперов данная информация может не нести особо ценности. А вот о неудобствах и недостатках, с которыми можно столкнуться на практике, я расскажу в дальнейшем...
Из-под коробки. Как организовано хранение строковых ресурсов в iOS-приложениях
Для начала отметим, что наличие в платформе механизмов локализации уже является огромным плюсом, т.к. избавляет программиста от дополнительной разработки и задает единый формат работы с данными. Да и зачастую, базовых механизмов хватает для реализации относительно небольших проектов.
И так, какие же возможности предоставляет нам Xcode "из-под коробки"? Для начала давайте разберемся со стандартом хранения строковых ресурсов в проекте.
В проектах со статическим контентом, строковые данные вполне можно хранить непосредственно в интерфейсе (файлах разметки .storyboard
и .xib
, которые в свою очередь являются XML-файлами визуализируемыми по средствам Interface Builder) или в коде. Первый подход позволяет упростить и ускорить процесс разметки экранов и отдельных отображений, т.к. разработчик может наблюдать большинство изменении без сборки приложения. Однако в этом случае не сложно нарваться на избыточности данных (если один и тот же текст используется несколькими элементами, отображениями). Второй подход как раз избавляет от проблемы избыточности данных, но приводит к необходимости заполнять экраны вручную (путем установления дополнительных IBOutlet
-ов и присвоения им соответственных текстовых значений), что в свою очередь приводит к избыточности кода (разумеется, кроме тех случаев, когда текст должен устанавливаться непосредственно кодом приложения).
Помимо этого Apple предоставляет стандарт файлов с расширением .strings
. Данный стандарт регламентирует формат хранения строковых данных в виде ассоциативного массива ("ключ-значение"
):
"key" = "value";
Ключ чувствителен к регистру символов, допускает использование пробелов, подчеркиваний, знаков пунктуации и специальных символов.
Важно заметить, что несмотря на незамысловатый синтаксис, Strings-файлы являются регулярным источников ошибок на этапе компиляции, сборки или эксплуатации приложения. Причин тому несколько.
Во-первых, ошибки в синтаксисе. Пропущенные точка с запятой, знак равенства, лишние или незаэкранированные кавычки неизбежно приведут к ошибке компилятора. Причем Xcode укажет на файл с ошибкой, но не подсветит строку, в которой что-то не так. Поиск такой опечатки может занять значительное время, особенно если файл содержит значительный объем данных.
Во-вторых, дублирование ключей. Приложение из-за него, конечно, не упадет, но пользователю могут быть отображены некорректные данные. Все дело в том, что при обращении к строке по ключу, подтягивается значение соответствующее последнему вхождению ключа в файле.
В итоге, простая конструкция требует от программиста значительной скрупулезности и внимательности при наполнении файлов данными.
Знающие разработчики могут сразу воскликнуть: "А как же JSON и PLIST? Чем они не угодили?" Ну во-первых, JSON
и PLIST
(фактически, обыкновенный XML
) являются универсальными стандартами, позволяющими хранить как строки, так и численные, логические (BOOL
), бинарные данные, время и дату, а также коллекции — индексированные (Array
) и ассоциативные (Dictionary
) массивы. Соответственно, синтаксис этих стандартов более насыщен, а значит и накосячить в них проще. Во-вторых, скорость обработки таких файлов несколько ниже, чем Strings-файлов, опять же по причине более сложного синтаксиса. Это не говоря еще о том, что для работы с ними нужно провести целый ряд манипуляций в коде.
Локализовали, локализовали, да не вылокализовали. Локализация пользовательского интерфейса
И так, со стандартами разобрались, разберемся теперь как это все использовать.
Пойдем по порядку. Для начала создадим простой Single View Application и в Main.storyboard на ViewController добавим несколько текстовых компонентов.
Контент в данном случае хранится непосредственно в интерфейсе. Чтобы локализовать его необходимо сделать следующее:
1) Перейти в настройки проекта
2) Затем — из Target в Project
3) Открыть вкладку Info
В пункте Localizations сразу видим, что у нас есть уже запись "English — Development language". Это значит, что английский язык выставлен как язык разработки ( или по умолчанию).
Давайте теперь добавим еще один язык. Для этого нажимаем "+" и выбираем необходимый язык (я для примера выбрал русский). Заботливый Xcode сразу предлагает нам выбрать, какие файлы необходимо локализовать для добавленного языка.
Нажимаем Finish, смотрим, что получилось. В навигаторе по проекту возле выбранных файлов появились кнопки отображения вложенностей. Кликнув на них видим, что выбранные ранее файлы содержат в себе созданные файлы локализации.
К примеру, Main.storyboard (Base)
— это созданный по умолчанию файл разметки интерфейса на базовом языке разработки, а при формировании локализации к нему в пару был создан ассоциированный Main.strings (Russian)
— файл строк для русской локализации. Открыв его можно увидеть следующее:
/* Class = "UILabel"; text = "Label"; ObjectID = "tQe-tG-eeo"; */
"tQe-tG-eeo.text" = "Label";
/* Class = "UITextField"; placeholder = "TextField"; ObjectID = "cpp-y2-Z0N"; */
"cpp-y2-Z0N.placeholder" = "TextField";
/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "EKl-Rz-Dc2"; */
"EKl-Rz-Dc2.normalTitle" = "Button";
Здесь, в целом, все просто, но все же для пущей ясности рассмотрим подробнее, обратив внимание на комментарии, сгенерированные заботливым Xcode-ом:
/* Class = "UILabel"; text = "Label"; ObjectID = "tQe-tG-eeo"; */
"tQe-tG-eeo.text" = "Label";
Вот экземпляр класса UILabel
со значением "Label"
для параметра text
. ObjectID
— идентификатор объекта в файле разметки,- это уникальная строка, присваиваемая любому компоненту в момент его помещение на Storyboard/Xib
. Именно из ObjectID
и имени параметра объекта (в данном случае text
) формируется ключ, а саму запись можно формально трактовать так:
Параметру "text" объекта "tQe-tG-eeo" присвоить значение "Label".
В данной записи изменению подлежит только "значение". Заменим "Label" на "Надпись". Аналогично поступим и с другими объектами.
/* Class = "UILabel"; text = "Label"; ObjectID = "tQe-tG-eeo"; */
"tQe-tG-eeo.text" = "Надпись";
/* Class = "UITextField"; placeholder = "TextField"; ObjectID = "cpp-y2-Z0N"; */
"cpp-y2-Z0N.placeholder" = "Текстовое поле";
/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "EKl-Rz-Dc2"; */
"EKl-Rz-Dc2.normalTitle" = "Кнопка";
Запускаем наше приложение.
Но что же мы видим? Приложение использует базовую локализацию. Как же проверить правильно ли мы произвели перевод?
Тут стоит сделать небольшое отступление и копнуть немного в сторону особенностей iOS платформы и структуры приложения.
Для начала рассмотрим изменение структуры проекта в процессе добавления локализации. Вот так выглядит каталог проекта до добавления русской локализации:
А вот так после:
Как мы видим, Xcode создал новый каталог ru.lproj
, в которую поместил созданные локализованные строки.
Причем тут структура Xcode проекта к готовому iOS приложению? А при том, что это помогает лучше понять особенности платформы, а также принципы распределения и хранения ресурсов непосредственно в готовом приложении. Суть в том, что при сборке Xcode проекта, помимо формирования исполнительного файла, среда переносит ресурсы (файлы разметки интерфейса Storyboard/Xib, изображения, файлы строк и прочее) в готовое приложение сохраняя иерархию заданную на этапе разработки.
Для работы с этой иерархией Apple предоставляет класс Bundle(NSBundle)
(вольный перевод):
Apple используетBundle
для предоставления доступа к приложениям, фреймворкам, плагинам и многим другим типам контента.Bundle
-ы организуют ресурсы в четко определенные подкаталоги, а структуры bundle-ов различаются в зависимости от платформы и типа. Используяbundle
, вы можете получить доступ к ресурсам пакета, не зная его структуры.Bundle
представляет собой единый интерфейс для поиска элементов, с учетом структуры пакета, потребностей пользователя, доступных локализаций и других соответствующих факторов.
Поиск и открытие ресурса
Прежде чем начать работу с ресурсом, необходимо указать егоbundle
. КлассBundle
имеет множество конструкторов, но чаще всего используется main.Bundle.main
предоставляет путь к каталогам, содержащим текущий исполняемый код. Таким образом,Bundle.main
предоставляет доступ к ресурсам, используемым текущим приложением.
Рассмотрим структуру Bundle.main
используя класс FileManager
:
Исходя из вышесказанного можем сделать вывод: при загрузке приложения формируется его Bundle.main
, анализируется текущая локализация устройства (язык системы), локализации приложения и локализованные ресурсы. Затем приложение выбирает из всех доступных локализаций ту, которая соответствует текущему языку системы и подтягивает соответствующие локализованные ресурсы. Если же совпадений нет — используются ресурсы из каталога по умолчанию (в нашем случае английскую локализацию, т.к. английский язык был определен как язык разработки, и необходимостью дополнительной локализации ресурсов можно пренебречь). Если сменить язык устройства на русский и перезапустить приложение, то и интерфейс уже будет уже соответствовать русской локализации.
Но прежде чем закрыть тему локализации пользовательского интерфейса через Interface Builder, стоит отметить еще один примечательный способ. При создании файлов локализации (путем добавления нового языка в проект или в инспекторе локализованного файла) нетрудно заметить, что Xcode предоставляет возможность выбрать тип создаваемого файла:
Вместо файла строк можно запросто создать локализованный Storyboard/Xib
, который будет сохранять всю разметку базового файла. Огромным плюсом данного подхода является то, что разработчик может сразу увидеть как будет отображаться контент на том или ином языке и сразу подкорректировать разметку экрана, особенно если объем текста разниться, или используется другое направление текста (например, в арабском языке, иврите) и так далее. Но в то же время создание дополнительных Storyboard/Xib файлов значительно увеличивает размер самого приложения (все таки строковые файлы занимают гораздо меньше места).
По этому, выбирая тот или иной метод локализации интерфейса, стоит учитывать, какой подход будет более целесообразен и практичен в конкретной ситуации.
Do It Yourself. Работа с локализованными строковыми ресурсами в коде
Надеюсь, со статическим контентом все более-менее понятно. Но как быть с текстом, который задается непосредственно в коде?
Разработчики операционной системы iOS и об этом позаботились.
Для работы с локализованными текстовыми ресурсами фреймворк Foundation предоставляет семейство методов NSLocalizedStrings
в Swift
NSLocalizedString(_ key: String, comment: String)
NSLocalizedString(_ key: String, tableName: String?, bundle: Bundle, value: String, comment: String)
и макросов в Objective-C
NSLocalizedString(key, comment)
NSLocalizedStringFromTable(key, tbl, comment)
NSLocalizedStringFromTableInBundle(key, tbl, bundle, comment)
NSLocalizedStringWithDefaultValue(key, tbl, bundle, val, comment)
Начнем с очевидного. Параметр key
— ключ строки в Strings-файле; val
(default value) — значение по умолчанию, которое используется в случае отсутствия указанного ключа в файле; comment
— (менее очевидный) краткое описание локализируемой строки (по сути не несет в себе полезного функционала и предназначен для пояснения цели использования конкретной строки).
Что касается параметров tableName
(tbl
) и bunble
, то на их рассмотреть стоит более подробно.
tableName
(tbl
) — это имя String-файла (честно говоря, не знаю почему Apple называют его таблицей), в котором находиться необходимая нам строка по указанному ключу; при его передаче расширение .string
не указывается. Возможность навигации между таблицами позволяет не хранить строковые ресурсы в одном файле, а распределять их по собственному усмотрению. Это позволяет избавиться от перегруженности файлов, упрощает их редактирование, минимизирует шанс появления ошибок.
Параметр bundle
расширяет возможности навигации по ресурсам еще больше. Как говорилось ранее, bundle — это механизм доступа к ресурсам приложения, то есть мы можем самостоятельно определять источник ресурсов.
Немного подробнее. Перейдем непосредственно в Foundation и рассмотрим объявление методов (макросов) для более ясной картины, т.к. преимущественное большинство туториалов попросту игнорирует этот момент. Фреймворк на Swift не особо информативен:
/// Returns a localized string, using the main bundle if one is not specified.
public func NSLocalizedString(_ key: String, tableName: String? = default, bundle: Bundle = default, value: String = default, comment: String) -> String
"Главный bundle возвращает локализированную строку" — все что мы имеем. В случае с Objective-C дело обстоит уже немного иначе
#define NSLocalizedString(key, comment) \
[NSBundle.mainBundle localizedStringForKey:(key) value:@"" table:nil]
#define NSLocalizedStringFromTable(key, tbl, comment) \
[NSBundle.mainBundle localizedStringForKey:(key) value:@"" table:(tbl)]
#define NSLocalizedStringFromTableInBundle(key, tbl, bundle, comment) \
[bundle localizedStringForKey:(key) value:@"" table:(tbl)]
#define NSLocalizedStringWithDefaultValue(key, tbl, bundle, val, comment) \
[bundle localizedStringForKey:(key) value:(val) table:(tbl)]
Здесь уже наглядно видно, что с файлами строковых ресурсов работает никто иной, как bundle
(в первых двух случаях mainBundle
) — так же как и в случае с локализацией интерфейса. Конечно, я мог бы сразу об этом сказать, рассматривая класс Bundle
(NSBundle
) в предыдущем пункте, но на тот момент эта информация не несла особой практической ценности. А вот в контексте работы со строками в коде об этом нельзя не сказать. По сути, глобальные функции, предоставленные Foundation, всего навсего обертки над стандартными методами bundle, основная задача которых сделать код более лаконичным и безопасным. Никто не запрещает инициализировать bundle
вручную и непосредственно от его имени обращаться к ресурсам, но таким образом появляется (пускай и очень-очень маленькая) вероятность образования циклических ссылок и утечек памяти.
Далее в примерах будет описана работа именно с глобальными функциями и макросами.
Рассмотрим как это все работает.
Для начала создадим String-файл, который будет содержать наши строковые ресурсы. Назовем его Localizable.strings* и добавим в него
"testKey" = "testValue";
( Локализация String-файлов осуществляется абсолютно точно так же, как и Storyboard/Xib, поэтому описывать этот процесс я не буду. Заменим в файле русской локализации "testValue" на "тестовое значение*".)
Важно! В iOS файл с этим именем является файлом строковых ресурсов по умолчанию, т.е. если не указывать имя таблицы tableName
(tbl
), приложение автоматически будет стучаться в Localizable.strings
.
Добавим в наш проект следующий код
//Swift
print("String for 'testKey': " + NSLocalizedString("testKey", comment: ""))
//Objective-C
NSLog(@"String for 'testKey': %@", NSLocalizedString(@"testKey", @""));
и запустим проект. После выполнения кода, в консоли появится строка
String for 'testKey': testValue
Все работает верно!
Аналогично с примером локализации интерфейса, сменим локализацию и запустим приложение. Результатом выполнения кода будет
String for 'testKey': тестовое значение
Теперь попробуем получить значение по ключу, которого в файле Localizable.strings
нет:
//Swift
print("String for 'unknownKey': " + NSLocalizedString("unknownKey", comment: ""))
//Objective-C
NSLog(@"String for 'unknownKey': %@", NSLocalizedString(@"unknownKey", @""));
Результатом выполнения такого кода будет
String for 'unknownKey': unknownKey
Поскольку ключа в файле нет, метод возвращает в качестве результата сам ключ. Если такой результат является неприемлемым, то лучше воспользоваться методом
//Swift
print("String for 'testKey': " + NSLocalizedString("unknownKey", tableName: nil, bundle: Bundle.main, value: "noValue", comment: ""))
//Objective-C
NSLog(@"String for 'testKey': %@", NSLocalizedStringWithDefaultValue(@"unknownKey", nil, NSBundle.mainBundle, @"noValue", @""));
где есть параметр value
(значение по умолчанию). Но в данном случае обязательно нужно указать источник ресурсов — bundle
.
Локализированные строки поддерживают механизм интерполяции, аналогично стандартным строкам iOS. Для этого в файл строк необходимо добавить запись с использованием строчных литералов (%@
, %li
, %f
и т.д.), например:
"stringWithArgs" = "String with %@: %li, %f";
Для вывода такой строки необходимо добавить код вида
//Swift
print(String(format: NSLocalizedString("stringWithArgs", comment: ""), "some", 123, 123.098 ))
//Objective-C
NSLog(@"%@", [NSString stringWithFormat: NSLocalizedString(@"stringWithArgs", @""), @"some", 123, 123.098]);
Но при использовании таких конструкций нужно быть очень внимательным! Дело в том, что iOS строго отслеживает количество, порядок аргументов, соответствие их типов указанным литералам. Так, например, если в качестве второго аргумента вместо целочисленного значения подставить строку
//Swift
print(String(format: NSLocalizedString("stringWithArgs", comment: ""), "some", "123", 123.098 ))
//Objective-C
NSLog(@"%@", [NSString stringWithFormat: NSLocalizedString(@"stringWithArgs", @""), @"some", @"123", 123.098]);
то приложение подставит в месте несоответствия целочисленный код строки "123"
"String with some: 4307341664, 123.089000"
Если пропустить его, то получим
"String with some: 0, 123.089000"
А вот если пропустить в перечне аргументов объект соответствующий %@
//Swift
print(String(format: NSLocalizedString("stringWithArgs", comment: ""), "123", 123.098 ))
//Objective-C
NSLog(@"%@", [NSString stringWithFormat: NSLocalizedString(@"stringWithArgs", @""), @"123", 123.098]);
то приложение просто напросто упадет в момент выполнения кода.
Push me, baby! Локализация уведомлений
Еще одной важной задачей в вопросе работы с локализованными строковыми ресурсами, о которой мне бы хотелось коротко рассказать, является задача локализации уведомлений. Суть в том, что большинство туториалов (как по Push Notifications
, так и по Localizable Strings
) зачастую пренебрегают данной проблемой, а подобные задачи не такая уж и редкость. Поэтому, столкнувшись с подобным впервые, у разработчика может возникнуть резонный вопрос: возможно ли это в принципе? Механизм работы Apple Push Notification Service
здесь я рассматривать не буду, тем более, что начиная с iOS 10.0 Push-ы и локальные уведомления реализуются через один и тот же фреймворк — UserNotifications
.
С подобной задачей приходиться сталкиваться при разработке мультиязычных клиент-серверных приложений. Когда такая задача впервые встала передо мной, первое, что пришло пришло мне в голову, это скинуть проблему локализации сообщений на сторону сервера. Идея была предельно проста: приложение при запуске отправляет на backend текущую локализацию, а сервер при отправке push-а подбирает соответсвующее сообщение. Но сразу назрела проблема: если локализация устройства поменялась, а приложение не было перезапущено (не обновило данные в базе), то сервер отправлял текст соответствующий последней "зарегестрированной" локализации. А если приложение установлено сразу на нескольких устройствах с разными системными языками, то вся реализация работала бы как черт знает что. Поскольку такое решение мне сразу показалось дичайшим костылем, я тотчас же начал искать адекватные решения (забавно, но на многих форумах "разработчики" советовали локализировать пуши именно на backend-е).
Правильное решение оказалось до ужаса простым, хотя и не совсем очевидным. Вместо стандартного JSON отправляемого сервером на APNS
"aps" : {
"alert" : {
"body" : "some message";
};
};
необходимо отправлять JSON вида
"aps" : {
"alert" : {
"loc-key" : "message localized key";
};
};
где по ключу loc-key
передается ключ локализированной строки из файла Localizable.strings
. Соответственно сообщение push-а отображается в соотвествии с текущей локализацией устройства.
Аналогичным образом работает и механизм интерполяции локализированных строк в Push-уведомлениях:
"aps" : {
"alert" : {
"loc-key" : "message localized key";
"loc-args" : [ "First argument", "Second argument" ];
};
};
По ключу loc-args
передается массив аргументов, которые необходимо внедрить в локализированный текст уведомления.
Подытожим...
И так, что же имеем в итоге:
- стандарт хранения строковых данных в специализированных файлах
.string
с простым и доступным синтаксисом; - возможность локализации интерфейса без дополнительных манипуляций в коде;
- быстрый доступ к локализованным ресурсам из кода;
- автоматическая генерация файлов локализации и структурирование ресурсов директории проекта (приложения) средствами Xcode;
- возможность локализации текста уведомлений.
В общем, Xcode предоставляет разработчикам достаточно простой и гибкий механизм локализации строковых ресурсов приложения, достаточный для реализации простых задач локализации в относительно небольших проектах.
О подводных камнях описанного механизма и методах их обхода я постараюсь рассказать в следующей статье.