Modernising A Legacy Android App Architecture, Part Two: MVVM-ish
Hello. I’m Rob and I work on the Android version of BBC Sport. This article is the second in a sequence, talking about a refactor of our app.
Obligatory disclaimer: views expressed are my own, not the BBC’s or the team’s.
The first is here and it introduced the phase of ‘rewrite’ that we’ve been doing, and then went on to look at moving to a single activity model made up of multiple fragments.
Now we’re going to look at a level of finer granularity and see how we want our fragments to actually be structured.
What’s In A Fragment?
You may be familiar with or at least have heard of a few different UI paradigms:
- MVC — model, view, controller
- MVVM — model, view, viewmodel
- MVI — model, view, interactor
- MVP — model, view, presenter
Confused? Me too. You can research the full detail of each but they’re all means by which to try and separate the responsibilities of presentation and data and/or business logic in some form. Various different paradigms have become popular and faded away again over time, and that’s exhibited in our codebase, with a few examples of each (and sometimes hybrids!) introduced over different eras of development.
A Brief History of UI Design Patterns on Android Since 2008
In the previous section, I wrote about how the single activity design is (now) the preferred model for designing an app on Android. So what does good look like in terms of presentation pattern according to Android?
I’ve worked with Android since the very beginning, and for a very long time, Android didn’t offer any help in this respect, proudly calling itself ‘unopinionated’. Developers basically did anything they liked, mostly putting all their code in the platform components like Activities and later Fragments, because that's how we all started out building our first apps. The whole platform was the wild west for a long time, and then from about 2015, Google started tightening up restrictions on what you were allowed to do in terms of permissions and so on, but still didn’t specify what your architecture should look like.
In about 2017, almost a decade into the platform’s life, this changed too, and Google gave us a family of stuff it called ‘architecture components’. A key one of these things is
Personally, some of these latecomer frameworks and ideas rouse a fair bit of caution in me, because not everything that has arrived in this manner to great fanfare has necessarily turned out to be entirely good or even a permanent feature on the landscape.
Nonetheless ViewModel is one of the much better introductions; it blends helpful lifecycle management and scope with a light-touch object that doesn’t bring huge obligations or strong ties to platform components along with it. For example, from a test perspective, ViewModel is just an object we can mock in a plain old JUnit test.
Viewmodels and the surrounding pattern of MVVM have therefore become, like single activity, a fairly clear best practice for modern development on Android.
What is MVVM again?
Reminder: model, view, viewmodel. You can do a lot of reading on the subject across software engineering as a whole, not just Android. What follows is a highly simplified summary.
The view is the presentation responsibility, the Android
View , and this is supposed to be deeply stupid. It should know how to show stuff when it’s told to show stuff, and it might send back some user-driven events too, but in a fairly raw form.
The model is the underlying data. Maybe for example that’s a service that makes REST API calls and provides data objects to consumers.
The viewmodel is probably best described as presentation-oriented data. Maybe, for example, your 'model' data from the service has numeric epoch timestamps. Unless your audience are actual robots, a timestamp of 1607092041 probably isn’t ideal, so you want to format it into 14:27. The viewmodel can do this. That’s a form of business logic, albeit a simple one, and no, it shouldn’t live in the pure presentation layer.
You might also have a button in your view that is supposed to make something happen, and that something might be to change the data. To support this, the viewmodel exposes a method to call, and it is responsible for handling and executing that request.
There’s much more you can do with a viewmodel — for example, use it to hold interim state for the duration of the UI — but you probably get the idea.
Why do we do all this? It’s all about separation of responsibility. That’s separation of each responsibility: data, logic, presentation. That’s important enough in itself but within it, it’s also separation from the platform we run on. At the very least we want to be able to test our stuff without a real device, and we become unable to do that once we’re inextricably coupled to the implementation detail of Android.
View/ViewModel communication — LiveData? RxJava?
MVVM is the preferred design pattern of the platform, you’ve got components to support it, you understand it, so that’s what we’re doing, right?
What I haven’t discussed yet is how these layers communicate. The viewmodel and model bit is easy enough: we use a viewmodel factory that has the services injected into it, and you can read more about that here.
How the view talks to the viewmodel is more complicated. Modern Android gives you various options here. One of the most talked about is LiveData and, going further, a use of LiveData called Data Binding. With LiveData, you can expose streams of events from the viewmodel to the view, coupling them together automatically without any boilerplate between. Data Binding allows you to couple them in the layout itself.
RxJava and its ‘observables’ are another way of doing much the same thing, just not in the layout.
This stuff is where it all gets a bit more contentious and opinionated.
In our legacy codebase, we had some LiveData, lots of RxJava, and sometimes it was connected together in a truly bewildering way.
We have talked about this a lot within the team and concluded a few key principles:
- We strongly value simplicity and comprehension over elegance and ‘cleverness’, and are willing to accept verbosity in pursuit of that
- We are not currently an app that has a strong inherent need for ‘live data’ as a concept, i.e. we are not currently serving up a live stream of events; instead we're mostly reactive to user interaction
- While conscious of both ‘reinventing the wheel' and ‘not invented here’, we tend to find that fully owning our core problems ourselves is a positive; our problems with Koin illustrated the consequences of abrogating architectural responsibility, but we also get bitten by this in more subtle ways that don’t end in us throwing out whole bits of tech
- The most value we can add is the ‘legacy’ in legacy code. Value is not so much how we attempt to apply architectural structure and oversight today; it’s establishing an environment whereby future devs look at the codebase and go, ‘this new responsibility obviously belongs here’.
On that basis, given the possible implementation choices:
- RxJava is a powerful tool that we could potentially use to our benefit if we wanted to, and some BBC teams do make this choice
- RxJava’s API has a huge surface area, and quickly obliges all developers to subscribe to its newsletter, drink all its Kool-Aid and become an expert in its detail, even just to maintain existing code
- On balance, RxJava doesn’t suit where we are as a team. We will avoid writing new code in it, and slowly migrate away from it. We’ll use coroutines to replace the asynchronous element it provides.
- LiveData is much simpler than RxJava, and mostly fulfils that same responsibility
- We’re not confident enough in LiveData yet to commit to it, partly for fear of ‘marrying the framework’, something we really want to avoid in pursuit of hexagonal architecture — decoupling our own code from its environment by pushing external dependencies (including the platform) to the edge
- LiveData may have a future place in a carefully demarcated layer of the app, but this requires further thought and leaking outside of those boundaries is a risk
We’re temporary custodians of this codebase and we have to acknowledge that if you have a view binding that lets you put all your business logic in the XML layout, or if you’re empowered to write the single most showboaty, brain-meltingly complex piece of RxJava in the entire world, someone eventually will do that, and the best we can do to stop it is build something understandable whose opinionated design is really, ‘please just keep it simple’.
That’s a lot of words on what we’re not going to do, but the view and viewmodel still need connecting. So what is our answer?
An Introduction To Traditionalism
We’re going to use something very old fashioned and unpopular: the humble callback listener. As if that wasn’t uncool enough, we’re also going to resurrect something else old: the view controller, from MVC.
What next? J2ME?
Here’s what we’re doing. Lobbing the controller into the mix with MVVM, we’ve called this ‘MVVM+C’, which is a thoroughly inadequate acronym, but who really cares?
The fragment gets instantiated, and we get a call to
onViewCreated with an Android view.
We throw that platform view into a wrapper class.
We summon a viewmodel from the viewmodel factory. That’s got all the services it needs as dependencies.
We create a view controller and we give it both the viewmodel and the view wrapper.
Now all our fragment-originated interactions go through the view controller. The fragment has no reference to anything else.
Let’s see what it looks like. Here’s the fragment, as described:
Here’s the view wrapper.
This view (and its wrapper) is stupid, as promised. It just does the exact things it was told to do. It’s also got a listener applied to a button, but all that does is invoke a callback to whoever set it.
Why a view wrapper rather than the Android view itself? It gives us two things. One is a layer of separation from the platform — it’s easier to mock this wrapper to test the controller than it is to mock a real view. The other is that layer gives us the opportunity to add semantic value, having meaningfully named functions that may not have a 1:1 mapping to Android view operations.
Who interacts with the view and sets that listener? The view controller.
This prepares the view, using information from the viewmodel. That listener we talked about causes the viewmodel to perform a function, and it’s set on the view.
(We also have a messaging mechanism here to tell the parent fragment to do something — this is beyond the scope of this article series, but I might describe it in future)
The viewmodel in this case has quite a lot of responsibility, so I’m not going to share it in its entirety, but here’s a snippet for completeness of illustration.
You can see our services here, which are injected by the viewmodel factory. In terms of MVVM, these are really the ‘model’.
Why Bother With The Controller?
- it’s dead simple
- the controller has an explicit responsibility — it’s the binding, the glue layer, the go-between — and this means there is a signposted place for things to live
- we can follow, debug and diagnose this code more easily than we can the framework-driven versions of the same
- we have somewhere concrete & transparent for platform lifecycle interactions to live. If this view controller subscribed to the viewmodel, we could add an ‘unsubscribe’ method and call it when the fragment was destroyed — we could even bind the controller to lifecycle callbacks if we wanted
- we can separate the scope of controller and viewmodel. A viewmodel could be activity-scoped and shared amongst different fragments (it is here, in fact) whilst our controller belongs to the fragment
- it doesn’t prohibit or preclude a future where we replace part or all of the controller with e.g. LiveData if we want to
- when complexity grows, we have somewhere good to contain it
- we can test the controller in unit tests, with a mock view wrapper, and usually not have to bother testing the view/view wrapper at all. What we should and would do here when coupling the two directly is less straightforward.
On the last point, this is a snippet from another related view controller:
This manages UI transitions in the same ‘view’ between success and failure. This still isn’t very complex but is more involved than ‘the text changed, show the update’ and it’s good to have somewhere that owns this, rather than it being encapsulated into either view or viewmodel.
You’re probably thinking verbosity and boilerplate that could have been eliminated, but we dealt with that already and decided the benefits of simplicity outweigh that as a drawback.
There are a few other reasons this isn’t always beautiful.
One is chains of events. Suppose we have a service that broadcasts some event. Then we have a viewmodel that takes those events. Then we have a controller that takes those events. We do all this with listeners.
The boilerplate isn’t going to win any awards for elegance but the bigger headache is that the viewmodel is doing two very similar things: subscribing its own listener to the service, but also allowing subscription from its subscribers. They’re separate links in the chain of events, but in terms of naming, they’re the same concept, so we have to be careful to make clear what’s what. It can be a little confusing to look at the viewmodel and make sense of there being two listeners, effectively one ‘up’ and one ‘down’. We could use straight pass-throughs but then we may erase the boundaries between what should be two distinct scopes.
The other is a relatively minor detail and that’s the consequence on tests. We require different approaches for all the possibilities under discussion, be it listeners or RxJava or whatever else. RxJava and LiveData offer test support in various forms — this is a mixed bag that makes testing easier in some respects, but it’s yet another thing to digest and understand. With listeners, we don’t have any frameworks to help here. We have to use argument captors to grab hold of subscribed listeners and invoke them. This itself, at least with Mockito, is a little bit fiddly, a bit undignified and something of an acquired practice.
I have to admit that it’s also possible that we’re unnecessarily conservative here. Maybe we’re the equivalent of those soldiers in the Pacific that thought they were still fighting WWII in the 1970s and 1980s, or that engineer you know that doggedly insists that Emacs is better than an IDE. Perhaps we could find ways to integrate something like LiveData or binding in a way that was compatible with our values and goals, and perhaps that would give us a productivity boost. I can’t say this keeps me awake at night though, because if experience eventually points to that being the right call, we can still migrate easily enough from plain-and-simple to whatever that thing is. The inverse, not so much.
Does This Pattern Actually Pay Off?
The theory of this is all well and good, but it’s freshly implemented on Sport, and like all software engineering claims, it’s important to see how well it stands up to reality in the long term. We’ve not got that experience on the project yet, so how can we say it’s good?
In the previous article, I talked about the nature of our rewrite, and how we are applying an accelerated version of what we previously did on Sounds. This was one of those things and so we already have a lot of experience applying it.
The benefits talked about here really did prove valuable over time. When we onboarded new developers, especially graduates, we got a lot of value from being able to talk in terms of very strong, almost ‘cookie cutter’ patterns — ‘here are the three components that combine to make a feature or UI component, you can start by duplicating them to make your thing’.
It allowed us to articulate and discuss software engineering best practices — decoupling, single responsibility, hexagonal architecture — in a tangible, look-at-this way that was consistent and omnipresent across all feature implementations. Often it takes a long time and a lot of experience on both sides to be able to have these conversations, and their learnings often evaporate as soon as they’re faced with a problem based around an implementation that looks and feels different, even if it’s the same underlying principles.
The opinionated nature of our work also allowed us as senior developers to discourage dilution of and divergence from those standards by being able to point to our existing precedent, again with that level of clarity, and make clear what we think good looks like. It's much harder to assert this when our product is out there and ostensibly running OK powered by an assortment of bad examples.
Consider how multiple different frameworks and solutions first arrive in a codebase, the horrible origin story of the head-scratching ‘why did they do this?’ mess. It’s usually either a positive idea (e.g. ‘I could eliminate all this code if I introduced X’) or borne out of adversity (‘this problem is difficult, it might be solved by bringing in X’). Keeping our implementation simple and making clear that the simplicity is there for a reason helps suppress the first one. The simplicity itself hopefully reduces instances of the second.
On a similar note it was also really helpful to able to remove complex frameworks from the learning curve; new starters may have to learn Android, Kotlin, our architecture and our product, as well as potentially everything else about our organisation and domain, and it really doesn’t help to have something like RxJava added to that pile.
In the next article, we’ll look at the full extent of what we actually did with this approach when applied to the inherited legacy state of the Sport app, and whether anything interesting happened on the way.