Finding the right abstraction (when working with Strings on Android)

Finding the right abstraction is hard. In this blog post, I would like to share a technique that works well for us (my android teammates and me) when dealing with String resources on android.

An abstraction layer for Strings?

Why do we even need an abstraction to simply work with Strings on Android? Probably you don’t if your app is simple enough. But the more flexible your app needs to be regarding displaying text as content the sooner you realize that there are different kind of string resources and to deal with all of these kinds gracefully in your codebase you probably need another layer of abstraction. Let me explain what I mean with different kind of string resources:

Did you notice that to load these kinds of strings you have to invoke different methods with different parameters to actually get the string value? If we want to deal with all of them gracefully then we should consider introducting a layer of abstraction for strings. To do that we have to consider the following points:

  1. We don’t want to leak implementation details like which method to invoke to actually translate a resource into a string.
  2. We need to make text a first class citizen (if suitable) of our business logic layer instead of our UI layer so that the view layer can easily “render” it.

Let’s go step by step through this points by implementing a concrete example: Let’s say that we want to load a string from a backend via http and if that fails we fallback to display a fallback string that is loaded from strings.xml. Something like this:

class MyViewModel(
  private val backend : Backend,
  private val resources : Resources // Android resources from context.getResources()
) : ViewModel() {
  val textToDisplay : MutableLiveData<String>  // for the sake of readability I use MutableLiveData

  fun loadText(){
    try {
      val text : String = backend.getText() 
      textToDisplay.value = text
    } catch (t : Throwable) {
      textToDisplay.value = resources.getString(R.string.fallback_text)
    }
  }
}

We are leaking implementation details into MyViewModel making our ViewModel overall harder to test. Actually, to write a test for loadText() we would need to either mock Resources or to introduce an interface like StringRepository (repository pattern alike) so that we can swap it with another implementation for testing:

interface StringRepository{
  fun getString(@StringRes id : Int) : String
}

class AndroidStringRepository(
  private val resources : Resources // Android resources from context.getResources()
) : StringRepository {
  override fun getString(@StringRes id : Int) : String = resources.getString(id)
}

class TestDoubleStringRepository{
    override fun getString(@StringRes id : Int) : String = "some string"
}

Our ViewModel then gets a StringRepository instead of resources directly and we are good to go, right?

class MyViewModel(
  private val backend : Backend,
  private val stringRepo : StringRepository // hiding implementation details behind an interface
) : 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)
    }
  }
}

We can unit test it like this:

@Test
fun when_backend_fails_fallback_string_is_displayed(){
  val stringRepo = TestDoubleStringRepository()
  val backend = TestDoubleBackend()
  backend.failWhenLoadingText = true // makes backend.getText() throw an exception
  val viewModel = MyViewModel(backend, stringRepo)
  viewModel.loadText()

  Assert.equals("some string", viewModel.textToDisplay.value)
}

With the introduction of interface StringRepository we have introduced a layer of abstraction and our problem is solved, right? Wrong. We have introduced an abstraction layer but this does not solve the real problem:

A better abstraction helps to solve both issues with one single abstraction.

TextResource to the rescue

We call the abstraction that we came up with TextResource and is a domain specific model to represent text. Thus, it is a first class citizen of our business logic. It looks as following:

sealed class TextResource {
  companion object { // Just needed for static method factory so that we can keep concrete implementations file private
    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( // We could also use use inline classes in the future
  val text : String
) : TextResource()

private data class IdTextResource(
  @StringRes id : Int
) : TextResource()

private data class PluralTextResource(
    @PluralsRes val pluralId: Int,
    val quantity: Int
) : TextResource()

// you could add more kinds of text in the future
...

With TextResource our ViewModel looks as follows:

class MyViewModel(
  private val backend : Backend // Please note that we don't need to pass any Resources nor StringRepository.
) : ViewModel() {
  val textToDisplay : MutableLiveData<TextResource> // Not of type String anymore!  

  fun loadText(){
    try {
      val text : String = backend.getText() 
      textToDisplay.value = TextResource.fromText(text)
    } catch (t : Throwable) {
      textToDisplay.value = TextResource.fromStringId(R.string.fallback_text)
    }
  }
}

The major difference are the following:

So far so good, but one piece is missing: how do we translate TextResource to String so that we can display it in a TextView for example? Well, that is a pure “android rendering” thing and we can create an extension function and keep it to our UI layer only.

// Note: you can get resources with 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
}

Moreover, since “translating” TextResource to String is happening in the UI (or View layer) of our app’s architecture TextResource will be “retranslated” on config changes (i.e. changing system language on your smartphone) results in displaying the right localized string for any of your apps R.string.* resources.

Bonus: You can unit test TextResource.asString() easily (mocking Resources but you don’t need to mock it for every single string resource in your app as all you really want to unit test is that the when state works properly, so here it is fine to always return the same string from mocked resources.getString()).