[Разработка мобильных приложений, Разработка под Android, Локализация продуктов] CompositionLocal в Jetpack Compose. Что это и как с его помощью реализовать реактивную локализацию приложения
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Власть в блоге Технократии переходит андроид-разработчикам. Владислав Титоврассказывает про то, как добиться непрерывающегося UI при смене локализации. В чем проблема Работа со строковыми ресурсами в Андроид устроена следующим образом:
- строки хранятся в виде xml разметки в разных папках values
- Чтобы получить строку в коде, нужно обратиться к объекту Context’а.
В объекте Context и заключается проблема. Чтобы сменить локализацию, нужно, во-первых, установить дефолтную Locale через Locale.setDefault(), во-вторых, приаттачить Context с новой Locale к активности в методе attachBaseContext(). Добиться вызова этого метода мы можем только при перезапуске активности, что приводит к прерывающемуся UI. Что же с этим можно сделать? Давайте разбираться.Как Jetpack Compose понимает, когда и как нужно (пере)рисовать виджеты Разберемся, как Compose обновляет состояние отображения — этот процесс называется рекомпозицией.Рассмотрим пример:
@Composable
fun Hello() {
Text(text = "Hello Compose!")
}
Определяем экран с текстовым виджетом. Никакого состояния здесь нет, так как под ним понимается значение, которое может меняться с течением времени. Значит, этот экран перерисовываться не будет. Добавим состояние:
@Composable
fun Hello() {
var name = "Compose"
Text(text = "Hello $name!")
TextField(
value = name,
onValueChanged = { name = it }
)
}
Добавили TextField и переменную name, в которую записываются изменения. Подразумевается, что эта переменная наполняется из текстового поля и реактивно отображается в текстовом виджете. К сожалению, этот пример не работает. Compose не понимает, что при изменении переменной name нужно перерисовать Text. Как же его научить?Для этих целей фреймворк предоставляет инструмент State — прослушиваемый контейнер данных. Compose понимает, когда изменяется состояние, и реагирует на это. Данная реакция называется рекомпозицией. Далее мы увидим, как она происходит.
@Composable
fun Hello() {
var name by mutableStateOf("Compose")
Text(text = "Hello ${name}!")
TextField(
value = name,
onValueChanged = { name = it }
)
}
mutableStateOf — это функция-builder изменяемого состояния для Composable. Она возвращает объект MutableState, наследника State, у которого определено property value. В него мы можем класть значение и забирать его оттуда. Чтобы сделать работу со State более удобной, можно использовать его как property delegate с помощью оператора by (как в примере выше). Тогда мы сможем обращаться с состоянием как с переменной и не писать state.value.Небольшое отступление В нашем примере мы используем изменяемое состояние (MutableState), так как изменяем его в том же скоупе. Если же в приложении используется архитектурный паттерн, то, вероятнее всего, состояние будет храниться и изменяться вне скоупа view. Так, если использовать ViewModel, нужно var name by mutableStateOf("Compose") заменить на val name by viewModel.nameLiveData.observeAsState(""), а onValueChanged = { name = it } на onValueChanged = { viewModel.setName(it) }. Заметьте, что при переходе на неизменяемое состояние var name превращается в val name. Такая организация работы с состоянием хорошо описана здесь.Вернемся к примеру. Когда значение внутри State будет изменено, Compose сам выполнит процесс рекомпозиции, то есть выполнит заново вызов данной Composable-функции. Как это работает изнутри, показано в этой статье. Кажется, это то, что нам нужно. Но, к сожалению, нет. Вот как отрабатывает наш пример:
Это происходит потому, что во время рекомпозиции Composable, которому принадлежит State, вызывается заново. При этом строка var name by mutableStateOf("Compose") выполняется снова и перезаписывает старое измененное состояние. Так мы оказываемся снова в отправной точке. Чтобы избежать такого поведения, нужно воспользоваться специальной функцией remember:
@Composable
fun Hello() {
val name by remember { mutableStateOf("Compose") }
Text(text = "Hello ${name}!")
TextField(
value = name,
onValueChanged = { name = it }
)
}
С помощью этой функции Composable “запоминает” значение, вычисленное при первоначальной композиции. В дальнейшем значение будет не вычисляться, а доставаться из памяти Composable-функции. Такая реализация рекомпозиции наводит на мысль, что Composable функция не должна иметь side-эффектов. Из-за неопределенных порядка и количества вызовов Composable функции side-эффекты могут привести к нежелаемому поведению. Отсюда вытекает требование, чтобы абсолютно все данные приходили в Composable через параметры. Но это не всегда удобно, и далее мы увидим, как обойти это требование. Подробнее о рекомпозиции можно почитать здесь.Теперь убеждаемся, что наш код работает как надо, и двигаемся дальше.
CompositionLocalКак было сказано выше, идеальный вариант, когда все данные Composable-функции поступают через параметры. Но это не всегда удобно. Посмотрите на пример ниже. Будем считать, что получить пользователя в другом месте невозможно. Видно, что у MyApp нет зависимости от пользователя, но его необходимо передать своим потомкам. Поэтому приходится в MyApp добавлять лишний параметр.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val viewModel: MainViewModel = viewModel()
MyApp(viewModel.user)
}
}
}
@Composable
fun MyApp(user: User) {
UserWidget(user)
}
@Composable
fun UserWidget(user: User) {
Text(text = user.name)
}
Согласитесь, что в большой иерархии со множеством вложенных хорошо декомпозированных Composable, такой способ доставки данных может стать головной болью. В данной ситуации на помощь приходит инструмент CompositionLocal. Как и State, по своей сути это прослушиваемый контейнер данных, который вы можете инстанцировать за пределами Composable скоупа и ссылаться на него статически. CompositionLocal используется для неявной передачи данных в Composable. У вас может возникнуть вопрос: “Не является ли в таком случае CompositionLocal side-эффектом?” Ответ - нет. А почему — узнаем ниже.
val ActiveUser = compositionLocalOf<User> { error("No active user found!") }
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val viewModel: MainViewModel = viewModel()
CompositionLocalProvider(ActiveUser provides viewModel.user) {
MyApp()
}
}
}
}
@Composable
fun MyApp() {
UserWidget()
}
@Composable
fun UserWidget() {
val user = ActiveUser.current
Text(text = user.name)
}
Итак, с помощью функции-билдера compositionLocalOf мы создаем объект класса CompositionLocal с типом User и записываем его в статическую переменную ActiveUser. В trailing лямбде можно вернуть дефолтный объект User’а, но если вам это не нужно, можно просто выкинуть исключение.Далее где-то в Composable контексте (скорее всего в корне, но это необязательно) мы должны поставить данные в этот CompositionLocal. Это делается с помощью конструкции CompositionLocalProvider(ActiveUser provides viewModel.user) { //contnt }. Таким образом мы устанавливаем в ActiveUser некоторое значение, которое будет доступно только в подиерархии, что будет установлена вместо комментария content. Такой конструкцией мы можем оборачивать множество подиерархий и устанавливать для каждой свое значение user. Это правило работает даже для вложенных Provider’ов. Вот поэтому CompositionLocal не является side-эффектом.Чтобы получить данные из CompositionLocal, нужно обратиться к Composable property CompositionLocal.current. Как и в случае со State, Compose понимает, что при изменении значения в конкретном CompositionLocal, нужно рекомпозировать зависимые от него Composable. Теперь данные из setContent идут напрямую в UserWidget, не касаясь при этом MyApp. Таким образом, мы достигли поставки данных в Composable в обход параметров, сохранив при этом возможность реагировать на изменение данных реактивно.Реактивная локализация: организация строковых ресурсов без Context Итак, настало время обсудить проблему, обозначенную в начале статьи. Нынешний вариант получения строковых ресурсов основан на LocalContext: через этот CompositionLocal мы получаем текущий Context и уже из него достаем строковые ресурсы всеми известным способом. То есть проблема, обозначенная в начале статьи, актуальна и для Jetpack Compose. Мы решили избавиться от этой досадной зависимости.
data class Localization(
val locale: Locale,
val strings: MutableMap<String, String> = mutableMapOf()
)
Для начала опишем главную структуру, в которой будут находиться следующие данные: локаль, для которой определен набор строк, и отображение идентификатора строки к самой строке. Идентификатор используется только для различения строк внутри реализации.Далее опишем структуры, в которых будет храниться сводная информация обо всех локализациях в приложении:
- localizationMap — это небольшая оптимизация, чтобы сложность поиска необходимой локализации по данной локали была O(1), а не O(n).
- supportedLocales отвечает за отсутствие дублирований регистрируемых локалей
- также устанавливаем дефолтную локализацию, в нашем случае английскую.
internal val defaultLocalization: Localization = Localization(Locale.ENGLISH)
private val supportedLocales: MutableSet<Locale> = mutableSetOf()
private val localizationMap = hashMapOf<Locale, Localization>()
fun registerSupportedLocales(vararg locales: Locale): Set<Locale> {
locales.filter { it != Locale.ENGLISH }
.forEach {
if (supportedLocales.add(it)) {
registerLocalizationForLocale(it)
}
}
return supportedLocales + Locale.ENGLISH
}
private fun registerLocalizationForLocale(locale: Locale) {
localizationMap[locale] = Localization(locale)
}
Для каждой локали, которую будем регистрировать как поддерживаемую, создаем пустой объект Локализации и сохраняем его в отображении к локали, при этом удаляя повторяющиеся локали. Далее нужно наполнить сами локализации. Для этого опишем две функции для переводимых и непереводимых ресурсов, при вызове которых будут наполняться локализации:
fun Translatable(name: String, defaultValue: String, localeToValue: () -> Map<Locale, String>): Localization.() -> String {
defaultLocalization.strings[name] = defaultValue
for ((locale, value) in localeToValue().entries) {
val localization = localizationMap[locale] ?: throw RuntimeException("There is no locale $locale")
localization.strings[name] = value
}
return fun Localization.(): String {
return this.strings[name] ?: defaultLocalization.strings[name] ?: throw RuntimeException("There is no string called $name in localization $this")
}
}
fun NonTranslatable(name: String, defaultValue: String): Localization.() -> String {
defaultLocalization.strings[name] = defaultValue
return fun Localization.(): String {
return defaultLocalization.strings[name] ?: throw RuntimeException("There is no string called $name in localization default")
}
}
Данные функции принимают на вход три параметра (два в случае непереводимых строк):name — идентификатор строки. Он должен быть уникальным. defaultValue — строка, которая будет связана с дефолтной локализацией closure localeToValue, который возвращает отображение локаль-строка.Далее все просто: устанавливаем дефолтное значение в дефолтную локаль. Для остальных локалей производим ту же операцию. В конце возвращаем extension-функцию, в которой описываем поиск строки в объекте Localization, а при его отсутствии — в дефолтной локализации.Оба строковых билдера возвращают extension-функции Локализации. Применяя их на различных локализациях, мы будем получать соответствующие переводы. Но как получить доступ к локализациям и как изменить текущую? Для организации метода доступа мы применим CompositionLocal:
val LocalLocalization = compositionLocalOf { defaultLocalization }
Чтобы придать лучший вид, обернем LocalLocalization в object (по аналогии с MaterialTheme):
object Vocabulary {
@Composable
@get:ReadOnlyComposable
val localization: Localization get() = LocalLocalization.current
}
Осталось доставить данные в CompositionLocal:
@Composable
fun Localization(locale: Locale, content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalLocalization provides (localizationMap[locale] ?: defaultLocalization),
children = content
)
}
Применение Шаг 1. Зарегистрировать поддерживаемые локали
val RUSSIAN = Locale("ru")
val TATAR = Locale("tt")
val supportedLocalesNow = registerSupportedLocales(RUSSIAN, TATAR)
Шаг 2. Описать необходимые строковые ресурсы
val hello = Translatable("hello", "Hello!") {
hashMapOf(
RUSSIAN to "Привет!",
TATAR to "Исәнме!"
)
}
val nonTrans = NonTranslatable("format", "%1\$d:%2\$02d")
Шаг 3. Применить при верстке
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Localization(locale = TATAR /*smth from supportedLocalesNow*/) {
val localization = Vocabulary.localization
Text(text = localization.hello())
Text(text = localization.nonTrans().format(20, 9))
}
}
}
}
Шаг 4. Наслаждаемся!
Теперь, подавая разные локали в функцию Localization, вы можете изменять локализацию экрана без пересоздания Activity. P.S. Данное решение мы упаковали в библиотеку, которую вы можете найти по ссылке.Сейчас ведется работа над plurals, возможностью установки собственной дефолтной локали, сохранением выбранной локали между перезапусками приложения и рефакторинг функций Translatable и NonTranslatable. Так же есть мысли по созданию type-safety форматирования. Сейчас мы предлагаем воспользоваться стандартными инструментами для форматирования. Это либо всем известный Formatter, либо другой инструмент — MessageFormat, с помощью которого также можно организовать функционал количественных строк. Мы считаем, что ваш опыт работы с нашими инструментами должен быть лучшим, поэтому вы можете участвовать в жизни проекта, улучшать его вместе с нами. Будем рады новым идеям и реализациям!Полезные ссылки Jetpack Compose Localization - https://github.com/TechnokratosDev/jetpack-compose-localizationОсновные концепции парадигмы Jetpack Compose - https://developer.android.com/jetpack/compose/mental-modelАнатомия Composable - https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cddCodelab по основам Compose - https://codelabs.developers.google.com/codelabs/jetpack-compose-basicsВидеоверсияМы предоставлыем видеоверсию статьи в виде доклада с открытого митапа ОЭЗ «Иннополис». Начало выступления с 4:28.Извините, данный ресурс не поддреживается. :( Подписывайтесь на наш Telegram-канал «Голос Технократии», где мы пишем о новостях из мира ИТ и высказываем свое мнение о важных событиях.
===========
Источник:
habr.com
===========
Похожие новости:
- [Разработка под iOS, Разработка мобильных приложений, Разработка игр, Unity] Запуск игры на Unity из приложения SwiftUI для iOS (перевод)
- [Совершенный код, Разработка мобильных приложений, Разработка под Android, Kotlin] Kotlin Best Practices
- [Разработка под iOS, Разработка мобильных приложений, Разработка под Android] Эволюция социального фида в iFunny — мобильном приложении с UGC-контентом
- [Программирование, Разработка под iOS, Разработка под Android, Тестирование мобильных приложений] Автоматизация тестирования мобильных приложений. Часть 1: проверки, модули и базовые действия
- [Разработка под iOS, Разработка мобильных приложений, Разработка под Android] Как выйти на китайский рынок с mini-app для WeChat, чтобы не прогореть
- [Программирование, Разработка мобильных приложений, Dart, Flutter] Flutter 2: что нового (перевод)
- [Разработка мобильных приложений] Тупые способы сэкономить на мобильной разработке
- [Локализация продуктов, Законодательство в IT, Продвижение игр, Игры и игровые приставки, IT-компании] Чтобы снять запрет игры в Индии, разработчик PUBG Mobile инвестировал $22,4 млн в местную компанию
- [Разработка под Android, Kotlin] Kotlin. Лямбда vs Ссылка на функцию
- [Хостинг, Разработка веб-сайтов, Разработка под Android, API, Транспорт] Как реализовать отслеживание местоположения андроид устройства на своем сайте
Теги для поиска: #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_razrabotka_pod_android (Разработка под Android), #_lokalizatsija_produktov (Локализация продуктов), #_android, #_jetpack_compose, #_lokalizatsija_interfejsa (локализация интерфейса), #_lokalizatsija (локализация), #_localization, #_reactive, #_string, #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
), #_razrabotka_pod_android (
Разработка под Android
), #_lokalizatsija_produktov (
Локализация продуктов
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 21:45
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Власть в блоге Технократии переходит андроид-разработчикам. Владислав Титоврассказывает про то, как добиться непрерывающегося UI при смене локализации. В чем проблема Работа со строковыми ресурсами в Андроид устроена следующим образом:
@Composable
fun Hello() { Text(text = "Hello Compose!") } @Composable
fun Hello() { var name = "Compose" Text(text = "Hello $name!") TextField( value = name, onValueChanged = { name = it } ) } @Composable
fun Hello() { var name by mutableStateOf("Compose") Text(text = "Hello ${name}!") TextField( value = name, onValueChanged = { name = it } ) } Это происходит потому, что во время рекомпозиции Composable, которому принадлежит State, вызывается заново. При этом строка var name by mutableStateOf("Compose") выполняется снова и перезаписывает старое измененное состояние. Так мы оказываемся снова в отправной точке. Чтобы избежать такого поведения, нужно воспользоваться специальной функцией remember: @Composable
fun Hello() { val name by remember { mutableStateOf("Compose") } Text(text = "Hello ${name}!") TextField( value = name, onValueChanged = { name = it } ) } CompositionLocalКак было сказано выше, идеальный вариант, когда все данные Composable-функции поступают через параметры. Но это не всегда удобно. Посмотрите на пример ниже. Будем считать, что получить пользователя в другом месте невозможно. Видно, что у MyApp нет зависимости от пользователя, но его необходимо передать своим потомкам. Поэтому приходится в MyApp добавлять лишний параметр. class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val viewModel: MainViewModel = viewModel() MyApp(viewModel.user) } } } @Composable fun MyApp(user: User) { UserWidget(user) } @Composable fun UserWidget(user: User) { Text(text = user.name) } val ActiveUser = compositionLocalOf<User> { error("No active user found!") }
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val viewModel: MainViewModel = viewModel() CompositionLocalProvider(ActiveUser provides viewModel.user) { MyApp() } } } } @Composable fun MyApp() { UserWidget() } @Composable fun UserWidget() { val user = ActiveUser.current Text(text = user.name) } data class Localization(
val locale: Locale, val strings: MutableMap<String, String> = mutableMapOf() )
internal val defaultLocalization: Localization = Localization(Locale.ENGLISH)
private val supportedLocales: MutableSet<Locale> = mutableSetOf() private val localizationMap = hashMapOf<Locale, Localization>() fun registerSupportedLocales(vararg locales: Locale): Set<Locale> { locales.filter { it != Locale.ENGLISH } .forEach { if (supportedLocales.add(it)) { registerLocalizationForLocale(it) } } return supportedLocales + Locale.ENGLISH } private fun registerLocalizationForLocale(locale: Locale) { localizationMap[locale] = Localization(locale) } fun Translatable(name: String, defaultValue: String, localeToValue: () -> Map<Locale, String>): Localization.() -> String {
defaultLocalization.strings[name] = defaultValue for ((locale, value) in localeToValue().entries) { val localization = localizationMap[locale] ?: throw RuntimeException("There is no locale $locale") localization.strings[name] = value } return fun Localization.(): String { return this.strings[name] ?: defaultLocalization.strings[name] ?: throw RuntimeException("There is no string called $name in localization $this") } } fun NonTranslatable(name: String, defaultValue: String): Localization.() -> String { defaultLocalization.strings[name] = defaultValue return fun Localization.(): String { return defaultLocalization.strings[name] ?: throw RuntimeException("There is no string called $name in localization default") } } val LocalLocalization = compositionLocalOf { defaultLocalization }
object Vocabulary {
@Composable @get:ReadOnlyComposable val localization: Localization get() = LocalLocalization.current } @Composable
fun Localization(locale: Locale, content: @Composable () -> Unit) { CompositionLocalProvider( LocalLocalization provides (localizationMap[locale] ?: defaultLocalization), children = content ) } val RUSSIAN = Locale("ru")
val TATAR = Locale("tt") val supportedLocalesNow = registerSupportedLocales(RUSSIAN, TATAR) val hello = Translatable("hello", "Hello!") {
hashMapOf( RUSSIAN to "Привет!", TATAR to "Исәнме!" ) } val nonTrans = NonTranslatable("format", "%1\$d:%2\$02d") class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Localization(locale = TATAR /*smth from supportedLocalesNow*/) { val localization = Vocabulary.localization Text(text = localization.hello()) Text(text = localization.nonTrans().format(20, 9)) } } } } Теперь, подавая разные локали в функцию Localization, вы можете изменять локализацию экрана без пересоздания Activity. P.S. Данное решение мы упаковали в библиотеку, которую вы можете найти по ссылке.Сейчас ведется работа над plurals, возможностью установки собственной дефолтной локали, сохранением выбранной локали между перезапусками приложения и рефакторинг функций Translatable и NonTranslatable. Так же есть мысли по созданию type-safety форматирования. Сейчас мы предлагаем воспользоваться стандартными инструментами для форматирования. Это либо всем известный Formatter, либо другой инструмент — MessageFormat, с помощью которого также можно организовать функционал количественных строк. Мы считаем, что ваш опыт работы с нашими инструментами должен быть лучшим, поэтому вы можете участвовать в жизни проекта, улучшать его вместе с нами. Будем рады новым идеям и реализациям!Полезные ссылки Jetpack Compose Localization - https://github.com/TechnokratosDev/jetpack-compose-localizationОсновные концепции парадигмы Jetpack Compose - https://developer.android.com/jetpack/compose/mental-modelАнатомия Composable - https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cddCodelab по основам Compose - https://codelabs.developers.google.com/codelabs/jetpack-compose-basicsВидеоверсияМы предоставлыем видеоверсию статьи в виде доклада с открытого митапа ОЭЗ «Иннополис». Начало выступления с 4:28.Извините, данный ресурс не поддреживается. :( Подписывайтесь на наш Telegram-канал «Голос Технократии», где мы пишем о новостях из мира ИТ и высказываем свое мнение о важных событиях. =========== Источник: habr.com =========== Похожие новости:
Разработка мобильных приложений ), #_razrabotka_pod_android ( Разработка под Android ), #_lokalizatsija_produktov ( Локализация продуктов ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 21:45
Часовой пояс: UTC + 5