Разработка
Как iOS-приложение Tinder сократило размер локализаций на 95%
На практике это означает, что в любой момент времени мы можем поддерживать более 50 языков для любой функции, которую мы предоставляем нашим конечным пользователям. Со временем доставка всех поддерживаемых локалей нашим пользователям, особенно на рынках с более ограниченными сетевыми возможностями, стала сопряжена с существенными затратами.
iOS-приложение Tinder используется более чем в 190 странах мира. Для того чтобы работать в каждой из этих стран, нам необходимо обеспечить локализованный опыт. Важнейшим аспектом локализации является отображение правильного языка для локали текущего пользователя, какой бы она ни была. На практике это означает, что в любой момент времени мы можем поддерживать более 50 языков для любой функции, которую мы предоставляем нашим конечным пользователям. Со временем доставка всех поддерживаемых локалей нашим пользователям, особенно на рынках с более ограниченными сетевыми возможностями, стала сопряжена с существенными затратами.
Проблема
Локализация на платформах Apple использует общий шаблон пути к файлу каталога для обеспечения локализации строк. Каждая папка называется по поддерживаемой локали: например, en.lproj
соответствует английскому языку США, fr.lproj
— французскому, а затем каждый файл strings/stringdict
, находящийся в этой папке, будет использоваться для локализации на нужном языке. Ниже приведено визуальное представление этого отображения:
Localization
├── en.lproj
│ ├── Localized.strings
│ └── Localized.stringsdict
├── fr.lproj
│ ├── Localized.strings
│ └── Localized.stringsdict
Эта структура повторяется для каждого таргета, использующего локализацию в графе сборки, что означает, что на каждую цель будет приходиться не менее 50 файлов Localized.strings
и Localized.stringdicts
. Для приложений, использующих статическую линковку, это означает, что каждый из этих файлов должен быть отнесен по имени к цели, в которой он находится, поскольку мы не можем допустить коллизии путей файлов в конечном пакете iOS App Store Package (IPA). Например, если есть две цели TargetA и TargetB, то дерево локализованных файлов должно выглядеть примерно так, чтобы статическая линковка работала:
xxxxxxxxxx
TargetA
├── Localization
│ ├── BUILD
│ ├── en.lproj
│ │ ├── TargetA_Localized.strings
│ │ └── TargetA_Localized.stringsdict
│ ├── fr.lproj
│ │ ├── TargetA_Localized.strings
│ │ └── TargetA_Localized.stringsdict
TargetB
├── Localization
│ ├── BUILD
│ ├── en.lproj
│ │ ├── TargetB_Localized.strings
│ │ └── TargetB_Localized.stringsdict
│ ├── fr.lproj
│ │ ├── TargetB_Localized.strings
│ │ └── TargetB_Localized.stringsdict
То, что мы имеем на данный момент, будет работать, но есть проблема, связанная с процессом подписания кода Apple. Минимальный размер одного файла при подписании кода составляет 4 КБ, а это значит, что каждый локализованный файл, независимо от его содержания, будет занимать в конечном IPA не менее 4 КБ из-за подписи.
Чтобы добиться значительного сокращения размера, нам нужно было уменьшить количество файлов, поставляемых конечным пользователям. Кроме того, можно выиграть за счет уменьшения содержимого, хранящегося в файлах локализации, при сохранении правильности перевода. Изучив наши файлы локализации, можно заметить общую закономерность в строках:
xxxxxxxxxx
/* Title text to display in Account Settings for Email setting */
“account_settings.email” = “Email”;
/* Detail text to display in Account Settings when email needs to be verified */
"account_settings.emailVerifyNow" = "Verify Now";
Комментарий над каждым строковым файлом используется только переводчиками и не может быть использован клиентами. Кроме того, в этих файлах есть неиспользуемые пробельные символы. Удалив эти комментарии и пробельные символы, мы сможем освободить место без ущерба для конечных пользователей. Дополнительным преимуществом является удаление потенциально служебной информации из наших файлов локализации.
После удаления этого неиспользуемого содержимого мы получаем примерно следующее:
xxxxxxxxxx
“account_settings.email” = “Email”;
“account_settings.emailVerifyNow” = “Verify Now”;
На этом этапе все выглядит лучше, но теперь мы видим, что для каждого файла локализации мы повторяем ключ снова и снова для каждого поддерживаемого языка. Это можно оптимизировать с помощью инструмента SmallStrings от Emerge.
SmallStrings может сжимать ключи и значения для каждого языка в файлы LZFSE, которые можно динамически распаковывать во время выполнения программы для получения строк. К счастью для нас, мы уже используем генерацию кода для получения локализованных строк, поэтому мы смогли без проблем внедрить SmallStrings в нашу существующую сборку — с точки зрения разработчика, никаких заметных изменений в рабочих процессах не произошло.
В итоге мы получили файловую структуру, похожую на пример ниже, где ключи были сжаты в один файл, а значения для каждого языка — в отдельный файл.
xxxxxxxxxx
Tinder.app
├── keys.json.lzfse
├── en.values.json.lzfse
├── fr.values.json.lzfse
├── he.values.json.lzfse
… (full locale list omitted for brevity)
Решение
Хотя шаги, описанные в предыдущем разделе, относительно просты, есть важные соображения при решении проблемы в масштабе. В действительности существует две разные проблемы, которые необходимо решить для достижения максимального уровня минификации:
- Объединить все файлы локализации в один файл для каждого языка
- Минифицировать этот окончательный объединенный файл локализации с помощью SmallStrings от Emerge
Обе эти задачи должны быть выполнены вне критического пути, а работа должна быть кэширована, иначе мы рискуем получить значительное влияние на время сборки, приняв это решение. К счастью, использование Bazel гарантирует, что эти действия будут выполняться вне критического пути, а результаты будут кэшироваться до тех пор, пока содержимое базовых строк не изменится.
Объединить все файлы локализации в один файл для каждого языка
Для выполнения этой части работы мы реализовали новое правило под названием namespaced_strings
. Это правило собирает локализации для каждой цели в графе и возвращает провайдер, содержащий версию каждого файла локализации с расставленными именами.
Псевдокод Starlark можно посмотреть ниже:
xxxxxxxxxx
NamespacedStringsInfo = provider(
doc = "Provides lever configuration information.",
fields = {
"namespace": "The namespace used to namespace the localized keys.",
"namespaced_localizations": "The localized strings.",
"unmodified_localizations": "The unmodified localized strings.",
},
)
def _namespaced_strings_impl(ctx):
namespaced_localizations = []
unmodified_localizations = []
for src in ctx.files.srcs:
...
ctx.actions.run(
mnemonic = "NamespaceStrings",
inputs = [src],
outputs = [output_file],
executable = ctx.executable._strings_tool,
arguments = [args],
tools = [ctx.executable._namespace_stringsdict_tool],
)
namespaced_localizations.append(output_file)
return [
NamespacedStringsInfo(
namespace = ctx.attr.namespace,
namespaced_localizations = namespaced_localizations,
unmodified_localizations = unmodified_localizations,
),
]
namespaced_strings = rule(
implementation = _namespaced_strings_impl,
attrs = {
"srcs": attr.label_list(
doc = "List of localization files to consume.",
allow_empty = False,
allow_files = [
".strings",
".stringsdict",
],
),
"namespace": attr.string(
doc = "Used to namespace the localized keys.",
mandatory = True,
),
…
},
)
Затем, используя этот Provider, мы создали Aspect и еще одно правило для его запуска. Этот Aspect может обойти весь наш граф сборки, собрать провайдеров строк с именами, а затем объединить все файлы локализации в один файл для каждого языка.
xxxxxxxxxx
MergeStringsInfo = provider(
doc = "Provides lever configuration information.",
fields = {
"namespaced_localizations_json_infos": "A List of Files containing a JSON payload representing the namespaced strings.",
"namespaced_localizations": "The namespaced localizations.",
},
)
def _collect_namespaced_strings_info_impl(target, ctx):
namespaced_localizations_json_infos = []
namespaced_localizations = []
if hasattr(ctx.rule.attr, "data"):
for data in ctx.rule.attr.data:
if NamespacedStringsInfo in data:
namespaced_localizations.extend(data[NamespacedStringsInfo].namespaced_localizations)
localized_strings_by_locale = {}
localized_stringsdicts_by_locale = {}
for localization in data[NamespacedStringsInfo].namespaced_localizations:
locale = localization.basename.split("_")[0]
if localization.extension == "strings":
localized_strings_by_locale[locale] = localization.path
elif localization.extension == "stringsdict":
localized_stringsdicts_by_locale[locale] = localization.path
json_info = {
"namespace": data[NamespacedStringsInfo].namespace,
"localized_strings_by_locale": localized_strings_by_locale,
"localized_stringsdicts_by_locale": localized_stringsdicts_by_locale,
}
namespace_localizations_info = ctx.actions.declare_file(
data[NamespacedStringsInfo].namespace + "_namespace_localizations_info.json",
)
ctx.actions.write(
namespace_localizations_info,
json.encode(json_info),
)
namespaced_localizations_json_infos.append(namespace_localizations_info)
namespaced_localizations_json_infos_depset = depset(
direct = namespaced_localizations_json_infos,
transitive = [dep[MergeStringsInfo].namespaced_localizations_json_infos for dep in ctx.rule.attr.deps if MergeStringsInfo in dep] if hasattr(ctx.rule.attr, "deps") else [],
)
namespaced_localizations_depset = depset(
direct = namespaced_localizations,
transitive = [dep[MergeStringsInfo].namespaced_localizations for dep in ctx.rule.attr.deps if MergeStringsInfo in dep] if hasattr(ctx.rule.attr, "deps") else [],
)
return [
MergeStringsInfo(
namespaced_localizations_json_infos = namespaced_localizations_json_infos_depset,
namespaced_localizations = namespaced_localizations_depset,
),
OutputGroupInfo(
namespaced_localizations = namespaced_localizations_depset,
),
]
collect_namespaced_strings_info = aspect(
implementation = _collect_namespaced_strings_info_impl,
attr_aspects = ["deps"],
)
def _merge_strings_impl(ctx):
for dep in ctx.attr.deps:
if MergeStringsInfo in dep:
namespaced_localizations = sets.union(namespaced_localizations, sets.make(dep[MergeStringsInfo].namespaced_localizations.to_list()))
namespaced_localizations_json_infos = sets.union(namespaced_localizations_json_infos, sets.make(dep[MergeStringsInfo].namespaced_localizations_json_infos.to_list()))
...
ctx.actions.run(
mnemonic = "MergeStrings",
inputs = sets.to_list(namespaced_localizations) + sets.to_list(namespaced_localizations_json_infos),
outputs = [localized_strings_output_file, localized_stringsdict_output_file],
executable = ctx.executable._merge_strings_tool,
arguments = [args],
tools = [ctx.executable._namespace_stringsdict_tool],
)
output_files.append(localized_strings_output_file)
output_files.append(localized_stringsdict_output_file)
return [
SmallStringsInfo(merged_localizations = depset(output_files)),
OutputGroupInfo(merged_localizations = depset(output_files)),
]
merge_strings = rule(
implementation = _merge_strings_impl,
doc = "Transitively merges all strings assets into a single strings file and stringsdict file. This is done in an effort to reduce bundle size of the app.",
attrs = {
"deps": attr.label_list(
doc = "The dependencies to merge strings from.",
aspects = [
collect_namespaced_strings_info,
],
),
},
)
Благодаря нашему новому правилу merge_strings
у нас теперь есть способ транзитивно собрать все namespaced строки для всего приложения.
Чтобы этот подход работал, нам также нужно было перенести право собственности на файлы локализации с отдельных целей на исполняемый файл. Это означает, что приложение и наши тестовые цели владеют конечными файлами локализации — цели обращаются к этим пакетам, чтобы получить локализации. Эта миграция была простой, поскольку логика для ее выполнения уже была сгенерирована в коде, а значит, разработчики не знали о ней.
Минифицируем этот окончательный объединенный файл локализации с помощью SmallStrings от Emerge
Теперь, когда у нас есть единый файл локализации для каждого языка, у нас есть необходимые настройки для поддержки использования инструмента SmallStrings от Emerge. Первым делом нам нужно было перенести инструментарий, основанный на Ruby, в Swift, поскольку мы не хотим поддерживать цепочку инструментов Ruby в сборке. После того как мы перенесли логику фронтенда инструмента на Swift, мы создали цели cc_binary
и cc_library
для кода на C, используемого для выполнения сжатия LZFSE. После того как эти цели были созданы, мы могли создать новые правила, которые могли бы получить наш провайдер NamespacedStringsInfo
и выполнить необходимую минификацию:
xxxxxxxxxx
SmallStringsInfo = provider(
doc = "Provides small strings information.",
fields = {
"merged_localizations": "A List of Files containing all merged localizations files.",
},
)
def _small_strings_impl(ctx):
strings_files = []
lzfse_output_files = {}
output_files = []
for dep in ctx.attr.deps:
small_strings_info = dep[SmallStringsInfo]
localizations = small_strings_info.merged_localizations.to_list()
for localization in localizations:
if localization.extension == "stringsdict":
output_files.append(localization)
elif localization.extension == "strings":
locale = localization.dirname.split("/")[-1].split(".")[0]
strings_files.append(localization)
lzfse_output_files[locale] = ctx.actions.declare_file(
ctx.label.name + "/" + "{locale}.values.json.lzfse".format(locale = locale),
)
output_files.append(_create_placeholder_file(ctx, localization))
if strings_files:
keys_json_lzfse_file = ctx.actions.declare_file(
ctx.label.name + "/" + "keys.json.lzfse",
)
output_files.append(keys_json_lzfse_file)
sorted_keys_json_file = ctx.actions.declare_file(
ctx.label.name + "/" + "sorted_keys.json",
)
args = ctx.actions.args()
args.add_all([
"compress-strings-keys",
"--compression-tool-path",
ctx.executable._compression_tool,
])
for strings_file in strings_files:
args.add("--merged-localized-strings-filepaths")
args.add(strings_file.path)
args.add("--keys-json-lzfse-output-path", keys_json_lzfse_file.path)
args.add("--sorted-keys-json-output-path", sorted_keys_json_file.path)
ctx.actions.run(
outputs = [keys_json_lzfse_file, sorted_keys_json_file],
inputs = strings_files,
tools = [ctx.executable._compression_tool],
executable = ctx.executable._strings_tool,
arguments = [args],
mnemonic = "SmallStringsKeys",
)
for locale, lzfse_output_file in lzfse_output_files.items():
args = ctx.actions.args()
args.add_all([
"compress-strings-values",
"--compression-tool-path",
ctx.executable._compression_tool,
"--sorted-keys-json-path",
sorted_keys_json_file.path,
"--locale",
locale,
"--values-json-lzfse-output-path",
lzfse_output_file.path,
])
for strings_file in strings_files:
args.add("--merged-localized-strings-filepaths")
args.add(strings_file.path)
ctx.actions.run(
outputs = [lzfse_output_file],
inputs = strings_files + [sorted_keys_json_file],
tools = [ctx.executable._compression_tool],
executable = ctx.executable._strings_tool,
arguments = [args],
mnemonic = "SmallStringsValues",
)
output_files.append(lzfse_output_file)
return DefaultInfo(
files = depset(output_files),
)
def _create_placeholder_file(ctx, src):
output = ctx.actions.declare_file(src.dirname + "/" + src.basename)
ctx.actions.write(
output,
"\"placeholder\" = \"_\";\n",
)
return output
small_strings = rule(
implementation = _small_strings_impl,
attrs = {
"deps": attr.label_list(
providers = [
SmallStringsInfo,
],
),
},
)
После этого изменения нам снова потребовалось обновить генерацию кода, чтобы использовать новую функцию SSTStringForKey
для получения строк из бандла. Как и в случае с предыдущими изменениями, это изменение было включено в кодовую базу прозрачным для наших разработчиков способом. Несмотря на то что это действие требует вычислительного времени во время сборки, планировщик действий и кэширование Bazel минимизируют или устраняют это влияние в подавляющем большинстве сборок.
Влияние
Мы разделили выигрыши для каждой части этого решения, чтобы лучше понять, как каждая из них влияет на размер нашей сборки:
Свернуть все файлы локализации в один файл для каждого языка
- Изменение размера загрузки: -5,5 МБ
- Изменение размера установки: -30,1 МБ
Минификация этого окончательного объединенного файла локализации с помощью SmallStrings от Emerge
- Изменение размера загрузки: -5.2MB
- Изменение размера при установке: -21.2MB
В целом эти усилия привели к уменьшению размера загружаемого файла на 10,7 МБ и уменьшению размера устанавливаемого приложения на 51,3 МБ без какого-либо ущерба для разработчиков или конечных пользователей.
Выводы
Вот несколько выводов из этой работы:
- Генерация кода может быть невероятно мощным инструментом для абстрагирования от больших изменений, таких как эти.
- Упаковка множества файлов размером менее 4 КБ может значительно увеличить размер вашего приложения, поэтому очень важно уменьшить количество мелких файлов, упакованных в конечный IPA.
- Тем, кто заинтересован в таком подходе, следует разработать тесты для упакованных файлов локализации до и после этого изменения, чтобы убедиться, что ни одна строка по ошибке не потерялась. Эти тесты также являются хорошим способом доказать, что количество файлов локализации было уменьшено в результате изменения.
- Герметичная песочница Bazel может прозрачно предоставлять улучшения нашим разработчикам и пользователям. Вместо того, чтобы фиксировать результаты этих действий по минимизации, мы можем использовать песочницу для скрытия этих деталей реализации. Иначе было бы невозможно перестроить наш конвейер переводов, чтобы приспособить этот рабочий процесс к одной платформе.
- В результате этой работы мы получили еще больше преимуществ, таких как динамическое удаление неиспользуемых строк. Благодаря этому подходу мы видим способ добиться этого и получить еще больше улучшений.
Для тех, кто интересуется этим подходом, пожалуйста, обратитесь к репозиторию SmallStrings.
-
Программирование3 недели назад
Конец программирования в том виде, в котором мы его знаем
-
Видео и подкасты для разработчиков6 дней назад
Как устроена мобильная архитектура. Интервью с тех. лидером юнита «Mobile Architecture» из AvitoTech
-
Магазины приложений3 недели назад
Магазин игр Aptoide запустился на iOS в Европе
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.8