Разработка
Удаленная локализация в приложениях Jetpack Compose
Используя утилиту RemoteString, мы можем эффективно обновлять и управлять строками, не прибегая к выпуску нового приложения.
В мобильном приложении строки, видимые пользователю, можно разделить на два основных типа. К первому типу относятся динамические строки, получаемые из удаленного сервиса, которые могут быть обновлены или изменены. Например, названия товаров, цены и категории в приложении для электронной коммерции относятся к этой категории. Ко второму типу относятся статические строки, которые предопределены и остаются неизменными, например, заголовки или поля описания.
В приложениях Android эти статические строки обычно определяются в файле strings.xml и хранятся локально. При необходимости изменений они обновляются в коде и выпускаются в следующей версии приложения.
Можно сделать удаленное хранение строк, однако при этом возникают уникальные проблемы, такие как обеспечение эффективного обновления, поддержание согласованности данных и быстрое отображение обновленного текста для пользователей. В этой статье мы рассмотрим реальное решение по управлению удаленной локализацией в приложениях Jetpack Compose.
Зачем нужна удаленная локализация?
В большинстве приложений для управления текстовым содержимым обычно используются статические строки. Однако есть сценарии, в которых требования клиента или потребности проекта диктуют необходимость удаленного управления всеми строками.
При статическом подходе каждый раз, когда необходимо обновить строку, требуется новый релиз приложения. Для этого необходимо изменить код, подготовить новую версию и дождаться, пока обновления пройдут проверку и будут опубликованы в Play Store. Во многих случаях этот процесс может занимать много времени и быть нецелесообразным.
Удаленное управление строками позволяет вносить изменения мгновенно, без необходимости создания новой версии приложения. Это обеспечивает быстрое обновление, гибкость и беспрепятственное управление контентом в соответствии с меняющимися требованиями.
Реализация динамической системы управления строками
Для реализации удаленного управления строками в Android-приложении может быть очень эффективна утилита RemoteString. Ниже приведены основные шаги и функции, полученные из предоставленного кода.
1. Инициализация
RemoteString должен быть инициализирован классом R.string
приложения. Это позволит классу сопоставить идентификаторы ресурсов (resId
) с соответствующими ключами (resKey
).
RemoteString.init(R.string::class.java)
2. Динамическое получение строк
С помощью функции getString
можно динамически получать значения строк. Если удаленное значение существует, оно будет использовано; в противном случае будет возвращено значение по умолчанию из strings.xml.
xxxxxxxxxx
val dynamicString = RemoteString.getString(context, R.string.welcome_message)
3. Обновление строк из удаленного источника
Метод update
принимает на вход JSON-строку JSONObject
и обновляет значения строк, хранящиеся в SharedPreferences. Это позволяет выполнять обновления без необходимости выпуска нового приложения.
xxxxxxxxxx
val json = """{"welcome_message": "Welcome to our app!"}"""
RemoteSting.update(context, json)
4. Поддержка отката
Если удаленная строка недоступна, приложение изящно возвращается к значению по умолчанию, заданному в файле strings.xml.
5. Очистка данных
Утилита включает методы для очистки определенных или всех сохраненных значений, что обеспечивает гибкость в управлении ресурсами строк.
Полный код здесь:
/** | |
* RemoteString provides an elegant solution for dynamic string resources. It enables use of | |
* [StringRes] and [update] the StringRes dynamically without changing the xml values manually. | |
* | |
* The common use case is using RemoteString and updating values from the server for strings without manually | |
* shipping a new app release. You can also combine RemoteString to update values from Firebase Remote Config | |
* giving much more flexibility and power to do A/B testing on Strings without any major changes in code. | |
* | |
* Uses [SharedPreferences] to store and get the value for a string. Returns default value from [StringRes] | |
* if preference key not found. | |
* | |
* Some common vocab that you will see in code. | |
* 1. resId: [Int] The generated Id for a [StringRes] generated by [Resources] | |
* 2. resKey: [String] The name by which a string is saved in strings.xml. | |
* Eg. R.string.my_string then `my_string` is resKey | |
* 3. resValue: [String] The value for a resKey | |
*/ | |
object RemoteString { | |
/** | |
* TAG to [Timber] | |
*/ | |
private const val TAG = "RemoteString" | |
/** | |
* Preference file name where all the data will be stored. | |
*/ | |
private const val PREF_NAME = "remote_string_config" | |
/** | |
* The prefix key which will be used to save a resValue for a given resKey in the SharedPreference | |
*/ | |
private const val PREF_PREFIX_KEY = "remoteStr" | |
/** | |
* Lambda that returns [SharedPreferences] | |
*/ | |
private val getPref: (Context) -> SharedPreferences = { context -> | |
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) | |
} | |
/** | |
* The resource string class which will be used to get [StringRes] field using reflection. | |
*/ | |
private var resourceStringClass: Class<*>? = null | |
/** | |
* This field holds the [Map] of resId and resKey. For definition of resId and resKey see [RemoteString] class doc | |
*/ | |
private var resourceStringMap: Map<Int, String>? = null | |
/** | |
* Initializes the RemoteString to be ready. Must initialize before any getString call else will return empty string. | |
* @param stringResourceClass - java class must be [Resources].[String] | |
* | |
* In your Android Application | |
* ``` | |
* // Import the Resource String class (In the import section) | |
* import com.example.app.R.string as RString | |
* | |
* // In onCreate | |
* super.onCreate() | |
* RemoteString.init(RString::class.java) (Kotlin) | |
* ``` | |
*/ | |
fun init(stringResourceClass: Class<*>) { | |
resourceStringClass = stringResourceClass | |
} | |
/** | |
* Returns [String] from [resId]. Returns the value from the preference or default | |
* | |
* @param context [Context] | |
* @param resId [StringRes] | |
* | |
* @return [String] | |
*/ | |
fun getString(context: Context, @StringRes resId: Int): String { | |
val pref = getPref(context) | |
val key = makePrefKeyFromResId(context, resId) | |
return pref.getString(key, getDefaultStringFromResId(context, resId))!! | |
} | |
/** | |
* Returns [String] from [resId] with formatted args. | |
* Returns the value from the preference or default | |
* | |
* @param context [Context] | |
* @param resId [StringRes] | |
* @param args [List]<[Any]?> formatArgs to format String | |
* | |
* @return [String] | |
*/ | |
fun getString(context: Context, @StringRes resId: Int, args: List<Any?>): String { | |
val pref = getPref(context) | |
val key = makePrefKeyFromResId(context, resId) | |
val raw = pref.getString(key, "") | |
if (raw.isNullOrEmpty()) return context.getString(resId, *args.toTypedArray()) | |
return String.format(raw, *args.toTypedArray()) | |
} | |
/** | |
* Updates the [RemoteString]'s [SharedPreferences] from the [json]. The json must be parsable to [JSONObject] | |
* | |
* @param context [Context] | |
* @param json [String] that can be parsed as [JSONObject] | |
*/ | |
fun update(context: Context, json: String) { | |
if (json.isNotEmpty()) { | |
try { | |
val jsonObj = JSONObject(json) | |
setFromJSON(context, jsonObj) | |
} catch (e: Exception) { | |
Timber.tag(TAG).e(e, "Updating RemoteString failed.") | |
} | |
} | |
} | |
/** | |
* Updates the [RemoteString]'s [SharedPreferences] from the [jsonObj]. | |
* | |
* @param context [Context] | |
* @param jsonObj [JSONObject] | |
*/ | |
fun update(context: Context, jsonObj: JSONObject) { | |
if (jsonObj.length() > 0) { | |
try { | |
setFromJSON(context, jsonObj) | |
} catch (e: Exception) { | |
Timber.tag(TAG).e(e, "Updating RemoteString failed.") | |
} | |
} | |
} | |
/** | |
* Clear all values from the [RemoteString]'s [SharedPreferences] | |
* | |
* @param context [Context] | |
*/ | |
fun clearAll(context: Context) { | |
getPref(context).edit().clear().apply() | |
} | |
/** | |
* Clears the data for the keys stored in [RemoteString]'s [SharedPreferences] using [json]. | |
* The json must be parsable to [JSONArray] containing the keys to be removed. | |
* | |
* @param context [Context] | |
* @param json [String] Must be parsable to [JSONArray] | |
*/ | |
fun clear(context: Context, json: String) { | |
val prefEdit = getPref(context).edit() | |
if (json.isNotEmpty()) { | |
try { | |
val jsonArray = JSONArray(json) | |
for (i in 0 until jsonArray.length()) { | |
prefEdit.remove(makeFinalPrefKey(jsonArray[i].toString())) | |
} | |
} catch (e: Exception) { | |
Timber.tag(TAG).e(e, "Clearing RemoteString using json failed.") | |
} finally { | |
prefEdit.apply() | |
} | |
} | |
} | |
/** | |
* Returns a map of resId and resKey. Returns empty map if [resourceStringClass] and [resourceStringClass] | |
* both are null else populates the [resourceStringMap] from [resourceStringClass] using reflection and | |
* returns [resourceStringMap] | |
* | |
* @param context [Context] | |
* | |
* @return [Map]<[Int], [String]> Map of resId and resKey | |
*/ | |
private fun getResIdStringMap(context: Context): Map<Int, String> { | |
if (resourceStringMap == null && resourceStringClass == null) return mapOf() | |
if (resourceStringMap == null) { | |
resourceStringMap = mapOf( | |
*(resourceStringClass!!.fields.map { field: Field -> | |
Pair(getStringResIdFromKey(context, field.name), field.name) | |
}.toTypedArray()), | |
) | |
} | |
return resourceStringMap!! | |
} | |
/** | |
* Returns the default value for a [StringRes] from [Resources] using [Context.getString] else empty string. | |
* | |
* @param context [Context] | |
* @param resId [Int] For definition check [RemoteString] class doc | |
* | |
* @return [String] | |
*/ | |
private fun getDefaultStringFromResId(context: Context, resId: Int): String = try { | |
context.getString(resId) | |
} catch (e: Resources.NotFoundException) { | |
Timber.tag(TAG).w(e, "Unable to find resId $resId default value") | |
"" | |
} | |
/** | |
* Returns the preference key from a [resId] | |
* | |
* @param context [Context] | |
* @param resId [Int] For definition check [RemoteString] class doc | |
* | |
* @return [String] preference key for a resId | |
*/ | |
private fun makePrefKeyFromResId(context: Context, resId: Int): String { | |
val resKey = getKeyFromStringResId(context, resId) | |
return makeFinalPrefKey(resKey) | |
} | |
/** | |
* Makes the final preference key from the [resKey]. | |
* | |
* @param resKey For definition check [RemoteString] class doc | |
* | |
* @return [String] [resKey] with [PREF_PREFIX_KEY] | |
*/ | |
private fun makeFinalPrefKey(resKey: String): String = PREF_PREFIX_KEY + "_" + resKey | |
/** | |
* Returns the resId for a resKey | |
* | |
* @param context [Context] | |
* @param resKey For definition check [RemoteString] class doc | |
* | |
* @return [Int] resId | |
*/ | |
private fun getStringResIdFromKey(context: Context, resKey: String): Int = | |
context.resources.getIdentifier(resKey, "string", context.packageName) | |
/** | |
* Returns the resKey for a resId | |
* | |
* @param context [Context] | |
* @param resId For definition check [RemoteString] class doc | |
* | |
* @return [String] resKey | |
*/ | |
private fun getKeyFromStringResId(context: Context, resId: Int): String = | |
getResIdStringMap(context).getOrElse(resId, defaultValue = { "" }) | |
/** | |
* The helper function that takes a [JSONObject] and iterates over the keys and save | |
* in the [SharedPreferences] | |
* | |
* @param context [Context] | |
* @param [json] [JSONObject] with key value pair of resKey and corresponding [String value] | |
*/ | |
private fun setFromJSON(context: Context, json: JSONObject) { | |
val map = getResIdStringMap(context) | |
val prefEdit = getPref(context).edit() | |
for (jsonKey in json.keys()) { | |
map.values.find { it == jsonKey }?.let { resKey -> | |
try { | |
val value = json.getString(resKey) | |
val prefKey = makeFinalPrefKey(resKey) | |
prefEdit.putString(prefKey, value) | |
} catch (e: Exception) { | |
Timber.tag(TAG).e(e, "setFromJSON error while adding $resKey to preferences.") | |
} | |
} | |
} | |
prefEdit.apply() | |
} | |
} | |
/** | |
* Extension function to get RemoteString similar to getString | |
* @param resId [StringRes] | |
* | |
* @return [String] | |
*/ | |
fun Context.getRemoteString(@StringRes resId: Int): String { | |
return RemoteString.getString(this, resId) | |
} | |
/** | |
* Extension function to get RemoteString similar to getString | |
* @param resId [StringRes] | |
* | |
* @return [String] | |
*/ | |
fun Context.getRemoteString(resId: Int, args: List<Any?>): String { | |
return RemoteString.getString(this, resId, args) | |
} |
Настройка интеграции удаленных строк в вашем приложении
Шаг 1: Инициализация RemoteString
в классе приложения:
xxxxxxxxxx
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
RemoteString.init(RString::class.java)
}
}
RemoteString.init(RString::class.java)
инициализирует RemoteString
и ссылается на файл strings.xml, используемый в проекте (здесь RString). Это необходимо для динамического управления ресурсами строк.
Шаг 2: Обновление локальных строк из удаленных данных
Чтобы продемонстрировать, как обновлять удаленные строки с помощью RemoteString
, используются функции RemoteString.clearAll
и RemoteString.update
. Эти операции идеально подходят для получения входящих JSON-данных и локального обновления строк.
xxxxxxxxxx
RemoteString.clearAll(context)
RemoteString.update(context, JSONObject(data))
Шаг 3: Создание файла common_string.xml (название опционально)
xxxxxxxxxx
<resources>
<!-- Example for a static button label -->
<string name="btn_submit">Submit</string>
<!-- Example for a dynamic welcome message -->
<string name="welcome_message">Welcome, %1$s!</string>
<!-- Example for an error message -->
<string name="error_connection">Connection error. Please try again.</string>
<!-- Example for a price label -->
<string name="price_label">Price: %1$.2f USD</string>
</resources>
Эта настройка создает базовую инфраструктуру для управления динамическими строками и позволяет легко управлять как статическими, так и удаленно обновляемыми строками.
Можно использовать расширение контекста:
xxxxxxxxxx
fun getRemoteString( resId: Int, args: ImmutableList<Any?>? = null): String {
val context = LocalContext.current
return args?.let { context.getRemoteString(resId, it) } ?: context.getRemoteString(resId)
}
Шаг 4: Использование RemoteString
в Compose
xxxxxxxxxx
fun WelcomeScreen(context: Context, userName: String) {
// Screen Layout
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// Dynamic Welcome Message
Text(
text = getRemoteString(R.string.welcome_message, listOf(userName)),
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(bottom = 16.dp)
)
// Submit Button
Button(onClick = { /* Handle click */ }) {
Text(text = getRemoteString(R.string.btn_submit)
}
}
}
Заключение
В этой статье мы рассмотрели, как управлять динамическими строками в Android с помощью Jetpack Compose и удаленных источников данных. Используя утилиту RemoteString, мы можем эффективно обновлять и управлять строками, не прибегая к выпуску нового приложения. Такой подход обеспечивает большую гибкость, особенно в сценариях, где требуется обновление строк в реальном времени, например, при A/B-тестировании или динамическом изменении контента. Сочетание статических ресурсов с удаленной конфигурацией позволяет обеспечить бесшовное и масштабируемое решение для интернационализации и локализации в современных Android-приложениях.
-
Программирование4 недели назад
Конец программирования в том виде, в котором мы его знаем
-
Видео и подкасты для разработчиков1 неделя назад
Как устроена мобильная архитектура. Интервью с тех. лидером юнита «Mobile Architecture» из AvitoTech
-
Магазины приложений3 недели назад
Магазин игр Aptoide запустился на iOS в Европе
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.8