Как найти подходящую абстракцию для работы со строками в Android

Автор оригинала: Hannes Dorfmann
  • Перевод

В своих проектах мы стараемся по мере необходимости покрывать код тестами и придерживаться принципов SOLID и чистой архитектуры. Хотим поделиться с читателями Хабра переводом статьи Hannes Dorfmann – автора серии публикаций об Android-разработке. В этой статье описан способ, который помогает абстрагировать работу со строками, чтобы скрыть детали взаимодействия с разными типами строковых ресурсов и облегчить написание юнит-тестов. 

Если вы работаете над большим Android-приложением и предполагаете, что в коде может появиться путаница в работе с ресурсами из разных источников, или если хотите упростить написание тестов в отношении строк, то эта статья может быть вам полезна. Переводим с разрешения автора.

Фото: Unsplash
Фото: Unsplash

Подбирать подходящую абстракцию нелегко. В этой статье я хотел бы поделиться техникой, которая хорошо работает у меня и моих товарищей по команде Android при операциях со строковыми ресурсами на Android.

Уровень абстракции для строк?

Нужна ли вообще абстракция для работы со строками на Android? Возможно, не нужна, если ваше приложение достаточно простое. Но чем более гибким должно быть приложение при отображении текстовых данных, тем скорее вы поймете, что существуют разные типы строковых ресурсов, и чтобы изящно оперировать всеми этими типами в своем коде, вам, возможно, понадобится еще один уровень абстракции. Давайте я объясню, что я имею в виду под разными типами строковых ресурсов: 

  • Простой строковый ресурс вроде R.string.some_text, отображаемый на экране с помощью resources.getString(R.string.some_text)

  • Отформатированная строка, которая форматируется во время выполнения, т.е. context.getString(R.string.some_text, «arg1», 123) с 

<string name=”some_formatted_text”>Some formatted Text with args %s %i</string>
  • Более сложные строковые ресурсы, такие как Plurals, которые перегружены, например resources.getQuantityString(R.plurals.number_of_items, 2)

<plurals name="number_of_items">
  <item quantity="one">%d item</item>
  <item quantity="other">%d items</item>
</plurals>
  • Простой текст, который не загружается из ресурсов Android в XML-файле вроде strings.xml, а уже загружен в переменную типа String и не требует дальнейшего преобразования (в отличие от R.string.some_text). Например, фрагмент текста, извлеченный из json ответа с сервера.

Вы обратили внимание, что для загрузки этих видов строк нужно вызывать разные методы с разными параметрами, чтобы фактически получить строковое значение? Для элегантной работы с ними следует подумать о введении слоя абстракции для строк. Для этого надо учитывать следующие моменты:

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

 2. Нам нужно сделать текст объектом первого класса (если это возможно) слоя бизнес-логики, а не слоя пользовательского интерфейса, чтобы слой представления мог легко его визуализировать. 

Давайте шаг за шагом рассмотрим эти моменты на конкретном примере: предположим, мы хотим загружать строку с сервера по http, и если это не удается, мы отображаем аварийную fallback-строку из strings.xml. Например, так:

class MyViewModel(
  private val backend : Backend,
  private val resources : Resources // ресурсы Android из context.getResources()
) : ViewModel() {
  val textToDisplay : MutableLiveData<String>  // MutableLiveData используется для удобства чтения
 
  fun loadText(){
    try {
      val text : String = backend.getText() 
      textToDisplay.value = text
    } catch (t : Throwable) {
      textToDisplay.value = resources.getString(R.string.fallback_text)
    }
  }
}

Детали реализации просочились в нашу MyViewModel, что в целом усложняет ее тестирование. Действительно, чтобы написать тест для loadText(), нам надо либо замокать Resources, либо ввести интерфейс наподобие StringRepository (по шаблону "репозиторий"), чтобы при тестировании мы могли заменить его другой реализацией: 

interface StringRepository{
  fun getString(@StringRes id : Int) : String
}
 
class AndroidStringRepository(
  private val resources : Resources // ресурсы Android из context.getResources()
) : StringRepository {
  override fun getString(@StringRes id : Int) : String = resources.getString(id)
}
 
class TestDoubleStringRepository{
    override fun getString(@StringRes id : Int) : String = "some string"
}

Затем вью-модель получит StringRepository вместо непосредственно ресурсов, и в этом случае все будет в порядке, не так ли?

class MyViewModel(
  private val backend : Backend,
  private val stringRepo : StringRepository // детали реализации скрываются за интерфейсом
) : ViewModel() {
  val textToDisplay : MutableLiveData<String>  
 
  fun loadText(){
    try {
      val text : String = backend.getText() 
      textToDisplay.value = text
    } catch (t : Throwable) {
      textToDisplay.value = stringRepo.getString(R.string.fallback_text)
    }
  }
}

На эту вью-модель можно написать такой юнит-тест:

@Test
fun when_backend_fails_fallback_string_is_displayed(){
  val stringRepo = TestDoubleStringRepository()
  val backend = TestDoubleBackend()
  backend.failWhenLoadingText = true // заставит backend.getText() выкинуть исключение
  val viewModel = MyViewModel(backend, stringRepo)
  viewModel.loadText()
 
  Assert.equals("some string", viewModel.textToDisplay.value)
}

С введением interface StringRepository мы добавили уровень абстракции и решили задачу, верно? Нет. Мы добавили уровень абстракции, но реальная проблема все еще перед нами: 

  • StringRepository не учитывает тот факт, что существуют другие типы текста (см. перечисление в начале этой статьи). Это проявляется в том, что код нашей вью-модели все еще трудно поддерживать, потому что она явно знает, как преобразовывать разные типы текста в String. Вот где нам на самом деле нужна хорошая абстракция.

  • Кроме того, если рассматривать  реализацию TestDoubleStringRepository и тест, который мы написали, насколько он является значимым? TestDoubleStringRepository всегда возвращает одну и ту же строку. Мы могли бы совершенно испортить код вью-модели, передавая R.string.foo вместо R.string.fallback_text в StringRepository.getString(), и наш тест все равно бы был пройден. Конечно, можно улучшить TestDoubleStringRepository, чтобы он не просто всегда возвращал одну и ту же строку:

class TestDoubleStringRepository{
    override fun getString(@StringRes id : Int) : String = when(id){
      R.string.fallback_test -> "some string"
      R.string.foo -> "foo"
      else -> UnsupportedStringResourceException()
    }
}

Но насколько это поддерживаемо? Вы хотели бы так делать для всех строк в вашем приложении (если их у вас сотни)?

Есть более удачный вариант, который позволяет решить обе эти проблемы с помощью одной абстракции.

Нам поможет TextResource 

Придуманная нами абстракция называется TextResource. Это модель для представления текста, которая относится к слою domain. Таким образом, это объект первого класса в нашей бизнес-логике. И выглядит это следующим образом:

sealed class TextResource {
  companion object { // Используется для статических фабричных методов, чтобы файл с конкретной реализацией оставался приватным
    fun fromText(text : String) : TextResource = SimpleTextResource(text)
    fun fromStringId(@StringRes id : Int) : TextResource = IdTextResource(id)
    fun fromPlural(@PluralRes id: Int, pluralValue : Int) : TextResource = PluralTextResource(id, pluralValue)
  }
}
 
private data class SimpleTextResource( // Можно будет также использовать inline классы
  val text : String
) : TextResource()
 
private data class IdTextResource(
  @StringRes id : Int
) : TextResource()
 
private data class PluralTextResource(
    @PluralsRes val pluralId: Int,
    val quantity: Int
) : TextResource()
 
// можно будет добавить и другие виды текста
...

 Так выглядит вью-модель с TextResource:

class MyViewModel(
  private val backend : Backend // Обратите, пожалуйста, внимание, что не надо передавать ни какие-то ресурсы, ни StringRepository.
) : ViewModel() {
  val textToDisplay : MutableLiveData<TextResource> // Тип уже не String  
 
  fun loadText(){
    try {
      val text : String = backend.getText() 
      textToDisplay.value = TextResource.fromText(text)
    } catch (t : Throwable) {
      textToDisplay.value = TextResource.fromStringId(R.string.fallback_text)
    }
  }
}

Основные отличия:

1) textToDisplay поменялся c LiveData<String> на LiveData<TextResource>, поэтому теперь вью-модели не нужно знать, как переводить разные типы текста в String. Она должна уметь переводить их  в TextResource. Однако, это нормально, как будет видно далее, TextResource – это абстракция, которая решит наши проблемы. 

2) Посмотрите на конструктор вью-модели. Нам удалось удалить «неправильную абстракцию» StringRepository (при этом нам не нужны Resources). Вас, возможно, интересует, как теперь писать тесты? Так же просто, как напрямую протестировать TextResource. Дело в том, что эта абстракция также абстрагирует зависимости Android, такие как ресурсы или контекст (R.string.fallback_text – это просто Int). И вот как выглядит наш юнит-тест: 

@Test
fun when_backend_fails_fallback_string_is_displayed(){
  val backend = TestDoubleBackend()
  backend.failWhenLoadingText = true // заставит backend.getText() выкинуть исключение
  val viewModel = MyViewModel(backend)
  viewModel.loadText()
 
  val expectedText = TextResource.fromStringId(R.string.fallback_text)
  Assert.equals(expectedText, viewModel.textToDisplay.value)
  // для data class-ов генерируются методы equals, поэтому мы легко можем их сравнивать
}

Пока все хорошо, но не хватает одной детали: как нам преобразовать TextResource в String, чтобы можно было отобразить его, например, в TextView? Что ж, это касается исключительно отрисовки в Android, и мы можем создать функцию расширения и заключить ее в слое UI. 

// Можно получить ресурсы с помощью context.getResources()
fun TextResource.asString(resources : Resources) : String = when (this) { 
  is SimpleTextResource -> this.text // smart cast
  is IdTextResource -> resources.getString(this.id) // smart cast
  is PluralTextResource -> resources.getQuantityString(this.pluralId, this.quantity) // smart cast
}

А поскольку преобразование TextResource в String происходит в UI (на уровне представления) архитектуры нашего приложения, TextResource будет «переводиться» при изменении конфигурации (т.е. при изменении системного языка на смартфоне), что обеспечит правильную локализацию строки для любых ресурсов R.string.* вашего приложения. 

Бонус: вы можете легко написать юнит-тест для TextResource.asString(), создавая моки для ресурсов. При этом не следует создавать мок для каждого отдельного строкового ресурса в приложении, потому что на самом деле нужно протестировать всего лишь работу конструкции when. Поэтому здесь будет корректно всегда возвращать одну и ту же строку из замоканного resources.getString(). Кроме того, TextResource можно многократно использовать в коде, и он соответствует принципу «открытости/закрытости». Так, его можно расширить для будущих вариантов использования, добавив всего несколько строк кода: новый класс данных, который расширяет TextResource, и новую ветку в конструкцию when в TextResource.asString()

Поправка: как правильно подметили в комментариях, TextResource не следует принципу открытости/закрытости. Можно было бы поддержать принцип открытости/закрытости для TextResource, если бы у sealed class TextResouce была abstract fun asString(r: Resources), которую реализуют все подклассы. Я лично считаю, что можно пожертвовать принципом открытости/закрытости в пользу упрощения структур данных и работать с расширенной функцией asString(r: Resources), которая находится за пределами иерархии наследования (именно этот способ описан в статье и является достаточно расширяемым, хотя и не настолько, как с принципом открытости/закрытости). Почему? Я считаю, что добавление функции с параметром Resources к публичному API TextResource проблематично, потому что только часть подклассов нуждается в этом параметре (например, SimpleTextResource такого вообще не требует). Кроме того, если такая реализация станет частью общедоступного API, это может привести к увеличению накладных расходов на поддержку кода, а также к появлению дополнительных сложностей (особенно при тестировании). 

Выводы

Описанный в статье способ можно применять для работы со строками и другими ресурсами: dimens, изображениями, цветами. При этом в каждом случае важно анализировать, насколько уместна работа с абстракциями. Хотя злоупотреблять ими нежелательно, в некоторых случаях абстракции могут быть полезны – в том числе, как мы уже упоминали, для более простого написания тестов. Ждем ваших отзывов об этом методе и его практическом применении!

SimbirSoft
Лидер в разработке современных ИТ-решений на заказ

Комментарии 3

    0
    А если юзер поменял локаль на девайсе? Надо бы все перелоадить…
    А вообще это избыточно. Как по мне, то лучше строки из ресурсов держать в lru-кеше.
      +1

      С локалью все хорошо будет. Строковое значение резолвится на уровне UI через вызов asString, при смене конфигурации UI пересоздастся и вызовет опять asString.

        0
        Для строк из xml-ресурсов TextResource хранит только id строки. А вот SimpleTextResource придется переводить, но это необходимо делать и в том случае, если мы не вводим дополнительную абстракцию. То есть повторно вызывать MyViewModel.loadText() надо только в том случае, если с сервера приходят данные на разных языках, в зависимости от параметра запроса.
        Также следует отметить, что описанный в статье TextResource предназначен не для кеширования, а для повышения чистоты и гибкости кода.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое