Adetunji Dahunsi

Building the right Android View Abstraction

What even are declarative UIs?

TJ Dahunsi

Oct 18 2020 · 9 min read

Categories:
android

UPDATE JAN 2023:

This post is heavily inspired by Jesse Wilson’s post of the exact same title which I’ve shamelessly lifted. Please give it a read here.

This post isn’t a rebuttal, but rather a series of juxtapositions; there is going to be a problem, and two solutions to the same problem will be compared on the abstractions they use to solve said problem, culminating in one comparing View systems on Android.

Problem 1; Networking in Android: Volley and Retrofit:

RESTful APIs aren’t the easiest resource to consume on Android out of the box. Using the standard HTTPUrlConnection leads to a lot of repetition for common basic tasks like GET requests, and since the results need to end up in the UI in one way or the other, there needs to be some thread jumping from calling in a background thread, and receiving on the UI thread. Let’s take a look at two solutions that deal with the abstractions needed differently.

Volley

The following code is lifted from the Android developer’s website:

1 2val textView = findViewById<TextView>(R.id.text) 3// ... 4 5// Instantiate the RequestQueue. 6val queue = Volley.newRequestQueue(this) 7val url = "https://www.google.com" 8 9// Request a string response from the provided URL. 10val stringRequest = StringRequest(Request.Method.GET, url, 11 Response.Listener<String> { response -> 12 // Display the first 500 characters of the response string. 13 textView.text = "Response is: ${response.substring(0, 500)}" 14 }, 15 Response.ErrorListener { textView.text = "That didn't work!" }) 16 17// Add the request to the RequestQueue. 18queue.add(stringRequest)

Volley’s API, notice that the HTTP method and URL needs to be passed in every time

This implementation does indeed solve the problem, but there are issues:

  1. Specifying the request method and url is going to get really old, really fast

  2. The use of callbacks to deliver responses makes callback hell an eventuality

  3. The library is not readily configurable, chaining requests and choosing the thread the results should be delivered on is a chore. What if you want to deliver results to a database, not the UI?

  4. Worst of the bunch, if Volley does not support the type you want, you need to write a custom converter for it.

Retrofit

The following snippet is lifted from Retrofit’s page:

1public interface GitHubService { 2 @GET("users/{user}/repos") 3 Call<List<Repo>> listRepos(@Path("user") String user); 4}

A Retrofit API declaration

Retrofit models the API as an interface, and the method arguments do not contain anything about the kind of the request, headers or anything of the sort. Those are specified in annotations instead so the caller doesn’t need to be verbose when using it. Furthermore, since the API being queried is RESTful it is already typed and well defined. The logic of deserialization can be passed into the Retrofit builder, and not the call site, how you choose to do that (Moshi, Jackson, Gson) is an implementation detail up to you. Retrofit lets you describe your API exactly as it is, and consume it as such; fundamentally it does not require you to define the semantics and types of another domain (HTTP method, params, headers) directly in code.

Problem 2; databases and ORMs: Room and SQLDelight

Similar to RESTful APIs above, typed data exists in some location; likewise serialized, and we would like to retrieve it quickly and conveniently.

Room

The following are lifted from the Android developer site:

1 2@Entity 3data class User( 4 @PrimaryKey val uid: Int, 5 @ColumnInfo(name = "first_name") val firstName: String?, 6 @ColumnInfo(name = "last_name") val lastName: String? 7) 8@Dao 9interface UserDao { 10 @Query("SELECT * FROM user") 11 fun getAll(): List<User> 12 13 @Query("SELECT * FROM user WHERE uid IN (:userIds)") 14 fun loadAllByIds(userIds: IntArray): List<User> 15 16 @Query("SELECT * FROM user WHERE first_name LIKE :first AND " + 17 "last_name LIKE :last LIMIT 1") 18 fun findByName(first: String, last: String): User 19 20 @Insert 21 fun insertAll(vararg users: User) 22 23 @Delete 24 fun delete(user: User) 25}

Room’s Retrofit like API, the result of the query is coerced into the User data class

This is a promising approach, in fact, it is very Retrofit like, it does not require a SQL query builder, nor does it require you to pass the query in code to some object to parse it for you, instead it represents queries with annotations.

To actually parse the query results into data classes or POJOs however, it defines the model first, and then coerces the query to match it. In fact at compile time, lint will warn the the query can be more efficient by selecting the columns that actually be used to populate the model, rather that defaulting to all columns with*. It however doesn’t have the concept of a type adapter like Retrofit, where you can swap the implementation of how you want your queries deserialized. This isn’t necessarily a bad thing, json deserialization is non straightforward, and more importantly unlike a SQL query, an HTTP request does not have complete type information of the response implicitly in its definition, so Room can take the liberty of query coercion for models.

SQLDelight

The following are lifted from SQLDelight’s GitHub page:

CREATE TABLE hockey_player (
  id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  number INTEGER NOT NULL
);

SQLDelight on the other hand, realizes the query already has the type of the model in it, and will generate the model from the query. A lot less roundabout, and no lint warnings about query performance. Better yet, if the query changes, the model does as well; a better and more elegant abstraction for a similar but different enough problem.

Problem 3; views and their data: XML and Jetpack Compose

You may think you know where this is going, but there might be a twist yet.

The common theme you may have noticed from the previous examples is there is usually a boundary to be bridged:

  • A RESTful API to a POJO or data class

  • A SQL query to a POJO or data class

Typed information generally exists in one domain, and it needs to be pulled out to be used in another one with typing preserved. On Android the view layer has traditionally been represented in XML, however manipulating said views has needed to be done in Kotlin or Java code. Various abstractions have been built to deal with this:

  • Manual view looks ups with findViewById

  • ButterKnife

  • Kotlin Synthetics

  • Anko

  • ViewBinding

  • Jetpack Compose

findViewById is the least ergonomic of the bunch, analogous to inlining raw SQL or making a HTTPURLConnection at the site of use. In the case of ButterKnife and Kotlin Synthetics, the former has been deprecated, and the latter soon will be in favor of ViewBinding.

Anko while now deprecated, has a philosophy sort of similar to Jetpack Compose; since the business logic is going to be in Kotlin, why have the View written in a separate domain? While Anko had endemic issues (view state restoration without view ids for example), Jetpack Compose solves most of them, so the really the question comes down to approach:

Should the View domain be written in code?

I personally think the answer to this is no, for the following reasons:

  1. Abstractions: In both the cases of networking and databases, there isn’t much clamor for writing networking code or SQL queries in Kotlin. Volley does this for Android, and I’ve said why I think that is a bad idea, and on the SQL side, there’s a reason no one really talks about JOOQ for Android; RESTful APIs and SQL queries are best represented in their natural forms. For manipulation and transformations downstream, the best approach has been to let types be generated for the structured data that already exists, and not to represent a unique domain with the semantics of another. Retrofit, SQLDelight and ViewBinding all do this with aplomb. I think Jetpack Compose is a lot similar to JOOQ where it provides APIs to represent Views and their styles with Kotlin. To do this it introduces constructs for memoization such as remember, which duplicates the functionality that already exists in LiveData or StateFlow in the ViewModel.

  2. Symptomatic Treatment: One of the bigger issues with the current View system in Android is state dichotomy; the ViewModel and Views each have their own state, leading to different sources of truth. The fix for this really is for androidx to have a stateless widget package, where Views there are in fact just that; stateless. A StatelessEditText and StatelessCheckbox would go a long way to solve the annoying issues that come up when trying to observe and bind state while simultaneously emitting mutations of said state by their contemporary versions today. To discard the existing View system and create a humongous amount of tech debt overnight is as courageous as removing the headphone jack, or refusing to adopt USB C in favor of expensive hockey puck magnets. Its emblematic of treating the symptom, and not the cause.

  3. Ergonomics: I find the ergonomics of writing and styling views in XML to be second to none, however I realize this varies by individual. The current implementation of using a ConstraintLayout in Jetpack Compose leaves a lot to be desired. Also the layout editor preview integration with XML is currently unrivaled by the same for Compose. Also consider the following:

An XML view declaration An XML view declaration

Kotlin extension for binding a View to a a data object Kotlin extension for binding a View to a a data object

With an extension method, bridging between XML and Kotlin, and possibly even java becomes trivial. The type being bound could even be an interface with multiple implementations, allowing for portability. The same bind method can be used in different contexts independently, and different XML layouts to be defined for landscape or portrait as they’ve always been, and none of that having to be represented in code.

XML Was Never the Issue

Going further, there’s really no reason why the above could not be used to define a Composable. A composableFrom extension could be written that looked up an XML definition and generated a Composable from it, without having to drop XML entirely. The big thing Jetpack Compose does right, is that Composables are stateless; the Composable function needs to be passed everything it needs to render the widget. There is nothing about this that XML is antithetical to.

HTML and CSS have championed this for a while, the separation of the display and styling of views, and the binding of them to the data that populates them. Jetpack Compose is currently a lot like JSX in React, and comes with all the advantages and disadvantages of it. I think it is fine for people who want to be able to write their layouts in code to be able to do so directly, but Jetpack Compose does not need to exclude XML; in fact I think it presents a perfect opportunity to complement and enhance it, while not generating a lot of tech debt overnight.

JetBrains just announced the milestone release for Jetpack Compose on the desktop. You know what would be amazing? If the same view declarations on Android, worked just as well on Desktop. You know what could enable this? A platform independent markup language that was somehow extensible…. oh, XML. The Android specific XML tags can be changed for platform independent ones, LinearLayout to VerticalRow and HorizontalRow, ConstraintLayout to ConstraintBlock or something similar; the list goes on. This becomes even more apparent when you consider Jetpack Compose still uses an Android View instance under the hood, albeit a custom one.

Jetpack Compose is amazing, however is not mutually exclusive with XML, in fact, creating Composables from from XML a la ViewBinding might be the bridge we need to bridge Android’s past, to a multiplatform future seamlessly; HTML & CSS for the web, XML for native platforms.

2