Аналог R.string в android приложении

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Всем привет! Меня зовут Владимир, я Android-разработчик в компании Альфа-Капитал. Наверняка любое мобильное приложение в процессе развития нуждается в гибкой настройке текстовой информации за счет серверной части. В этой статье я поделюсь мыслями и решениями нашей команды. Также я покажу пример генерации кода с помощью gradle скрипта, сильно упростивший жизнь android команде.

С чего всё начиналось

В нашем приложении довольно много текстовой информации для пользователей, начиная от подсказок и описаний и заканчивая дисклеймерами и большими информационными экранами. Мы работаем в сфере финансовых технологий, и порой — чтобы соответствовать требованиям Центробанка — необходимо быстрое обновление информации. Это иногда нужно делать и по запросам наших юристов. Кроме того, порой требуется экран, где на первой неделе будет один текст, а на второй неделе — другой текст.

Сразу оговорюсь: в текущем виде (и в ближайшем будущем) бизнес ориентирован на Россию, поэтому в проекте нет необходимости поддерживать несколько языков в приложении.

Столкнувшись с проблемой обновления информации, мы стали задумываться о хранении текстов (которые в перспективе придется корректировать) не в приложении, а на сервере. Часть (например, большой список дисклеймеров для юристов) удалось вытащить. Но оставалось еще множество строк, которые сложно как-то объединить и для которых не хотелось каждый раз делать сетевой запрос и экран загрузки. 

Сначала мы попробовали держать тексты на Firebase. По функциональности такое  решение вполне подходило, к тому же оно добавляло версионирование и возможность создания a/b тестов. Вскоре стало ясно, что это все-таки не то, что нам нужно. Тогда мы сформулировали свои требования:

  1. Удобный и единый источник текстов для всех мобильных платформ (android/ios);

  2. Обновление текстов в рантайме при старте приложения (для обновления важных мест без выпуска фиксов/релизов);

  3. В приложении мы не должны страдать от необходимости выполнения сетевого запроса или показа лоадинга ради загрузки лексем;

  4. Обновление текстов должно быть доступно без вмешательства разработчиков (т.е. чтобы условный аналитик / тестировщик смог спокойно обновить тексты при необходимости);

  5. Максимально простое создание дефолтных значений, которые будут лежать в приложении.

Firebase Remote Config не подошел — слишком хороший функционал для простых текстов. У нас быстро получился большой список необходимых лексем, а их добавление / редактирование становилось слишком сложным. Нелегкой задачей была и установка дефолтных значений в приложении. Нам хотелось чего-то попроще.

Мы решили, что самым оптимальным  будет объединение необходимых текстов в JSON файл. Почему именно JSON, а не XML, который кажется более нативным для Android? Так показалось удобней для обеих команд (Android и iOS). JSON — понятный формат данных, его легко разберет любая платформа. Этот файл можно легко скачать, положить в проект и получить дефолтные данные. Схема работает и в обратную сторону. Пришла задача с новым текстом? Нужно добавить новые строки в проект, закинуть этот же JSON c ключами на сервер.

Пример json файла:

{
 "screen1_text1": "Text 1",
 "screen1_text2": "Text 2 \nnext line",
 "screen1_text3": "Text 3",
 "screen1_text4": "Text 4"
}

Первая реализация 

В итоге мы получили JSON файл с текстами на сервере, этот же файл храним в проекте в папке assets. Сначала мы создали объект Lexemator, у которого можно по ключу запросить какой-то текст. При старте приложение подкачивает тексты с сервера в Lexemator, а если что-то пошло не так, берет дефолтные текста из папки assets.

object Lexemator {
	fun getString(key: String): String
}

Использование в коде выглядит следующим образом: 

class MainActivity : Activity() {

   override fun onCreate(savedInstanceState: Bundle?) {
			 ...
       val textView = findViewById<TextView>(R.id.text1)
       textView.text = Lexemator.getString("screen1_text1")
   }
}

По сравнению с Firebase стало лучше, но был один существенный недостаток: достаточно одной ошибки в ключе, и мы получаем не тот текст. Нам хотелось получить статическую поддержку, похожую на R.string, где Android Studio подсказывала бы константы и проект не компилировался бы при ошибке.

Это была предыстория, теперь переходим к коду.

Gradle - наше всё

Раз в проекте имеется JSON файл с текстами, значит, уже на этапе сборки мы понимаем, какие есть ключи для разных текстов. Если каких-то ключей нет, то они либо не нужны, либо их всё равно надо добавить для дефолтных значений. Выходит, на этапе сборки можно сгенерировать код, который будет содержать ключи для текстов. Мы решили сделать это с помощью gradle task.

Ниже представлен получившийся скрипт

import groovy.json.JsonSlurper

/**
* Таска ищет файл с текстами с названием strings.json и создает объект LL.
* Для каждого текста из strings.json создает переменную LL.key внутри объекта
*
* Если файла strings.json не существует - скрипт кинет Exception.
*
* Чтобы сгенерить текста заново, достаточно перебилдить проект, или изменить файл strings.json
*/

def classFileName = "LL"
def stringsFileName = "strings.json"
def filePath = project.rootProject.getProjectDir().path + "/app/src/main/assets/json"
def outputPath = project.rootProject.getProjectDir().path + "/app/build/generated/strings"
def inputFile = new File(filePath + "/${stringsFileName}")
def outputFile = new File(outputPath + "/${classFileName}.kt")

task createStrings {

   /**
    * Если что-то изменится в inputFile, то при следующей сборке будет заново сгенерирован
    * outputFile.
    * Если ничего не изменилось, и outputFile уже есть, таска будет помечена "UP-TO-DATE" и
    * не будет выполняться лишний раз.
    */
   inputs.file(inputFile)
   outputs.file(outputFile)

   doLast {
       if (!inputFile.exists()) {
           throw RuntimeException("файл ${inputFile} не найден")
       }

       println("Начало создания файла ${outputFile.path}")
       outputFile.delete()
       outputFile.createNewFile()

       /**
        * Тройные кавычки нужны для того, чтобы перевод строки (\n) в strings.json
        * не ломал строки в созданном LL.kt файле.
        */
       def s1 = """package com.obolonnyy.lexemator

//<!--Этот файл создан автоматически gradle скриптом из create_strings.gradle -->

object ${classFileName} {
"""

       def s2 =
               """   
   fun addLexems(map: Map<String, String>) {
       map.forEach { k, v -> addLexem(k, v) }
   }

   fun addLexem(key: String, value: String) {
       when(key) {
"""

       def json = new JsonSlurper().parse(inputFile)
       assert json instanceof Map

       json.each { entry ->
           s1 += "    var ${entry.key} = \"\"\"${entry.value}\"\"\"\n        private set\n"
           s2 += "            \"${entry.key}\" -> ${entry.key} = value\n"
       }

       def result = s1 + "\n\n" + s2 + """        }
   }
}"""

       outputFile.write(result)
       println("файл ${outputFile.path} успешно создан.")
   }
}

/**
* Показываем, что созданный файл теперь тоже является частью проекта.
* Без этого мы не сможем использовать созданный LL.kt класс в своих классах.
*/
android {
   sourceSets {
       main {
           java {
               srcDirs += outputPath
           }
       }
   }
}

Скрипт создает object LL, у которого есть список ключей (поля типа String, с приватным сеттером) с дефолтными значениями, и две функции для обновления значения ключей. При старте приложения мы запрашиваем с сервера текста и обновляем значения через функцию addLexems().

Комментарий про название объекта LL: сначала мы думали назвать L (от слова Lexemator), чтобы было привычно как с R, но мешала константа android.icu.lang.UCharacter.GraphemeClusterBreak.L. Поэтому, мы не придумали ничего лучше, чем назвать класс LL. 

Сгенерированный объект LL выглядит следующим образом: 

//<!--Этот файл создан автоматически gradle скриптом из create_strings.gradle -->

object LL {
   var screen1_text1 = """Text 1"""
       private set
   var screen1_text2 = """Text 2
next line"""
       private set
   var screen1_text3 = """Text 3"""
       private set
   var screen1_text4 = """Text 4"""
       private set


  
   fun addLexems(map: Map<String, String>) {
       map.forEach { k, v -> addLexem(k, v) }
   }

   fun addLexem(key: String, value: String) {
       when(key) {
           "screen1_text1" -> screen1_text1 = value
           "screen1_text2" -> screen1_text2 = value
           "screen1_text3" -> screen1_text3 = value
           "screen1_text4" -> screen1_text4 = value
       }
   }
}

Пример использования объекта LL в коде выглядит следующим образом:  

class MainActivity : Activity() {

   override fun onCreate(savedInstanceState: Bundle?) {
			...
       val textView = findViewById<TextView>(R.id.text1)
       textView.text = LL.screen1_text1
   }
}

Получилось довольно просто и привычно. 

Итоги

Мы сделали механизм управления текстами в приложении — без необходимости перевыпуска релиза. Тексты хранятся на сервере, обновляются через git репозиторий. Для бизнеса планируется создать админку для управления текстами. Для Android команды мы сделали удобный механизм работы с этими текстами и статическую поддержку текстов в коде. Сейчас наш JSON файл насчитывает 180 различных строк, и найденное решение всех устраивает.

Рабочий пример можно найти по ссылке.

Источник: https://habr.com/ru/post/563672/


Интересные статьи

Интересные статьи

Android — постоянно совершенствующаяся ОС, которая идеально подходит для работы со смартфонами и планшетами. Несколько лет разные разработчики из разных компаний пытаются превратить эту...
Полнотекстовый поиск необходим в приложениях для того, чтобы быстро находить совпадения в большом объеме данных. Такая возможность удобна, например, для поиска товаров, фильмов, рецептов,...
Здравствуй, мой любознательный друг! Наверняка тебя посещали мысли о том, как хакать все вокруг, не привлекая лишнего внимания санитаров службы безопасности и окружающих, быть похожим на героев ф...
Слышали ли вы когда-нибудь про Вампуса? Независимо от ответа — добро пожаловать в его владения! В этой статье я хочу поведать вам свою историю создания игры под Android. В зависимости от к...
На носу уже 2020 год и сегодня мы имеем уже версию Android 9.0 Pie, где компания Google бьет себе в грудь и говорит что их продукт защищен. Но злодеи не дремлют и создают свои вредоносы для A...