The Value is the Boundary

Managing view controller complexity in tvOS and iOS projects.

In his Boundaries talk, Gary Bernhardt describes the use of “simple values (as opposed to complex objects) not just for holding data, but also as the boundaries between components and subsystems.”

In this article, we’ll examine how Black Pixel was able to use Bernhardt’s concept as a guiding principle when managing view controller complexity on a recent project. In particular, we’ll show how we substituted implicit behaviors with explicit behavior modeling through value types in order to guarantee correctness and reduce the surface area for bugs and regressions.

A Bit of Background

One of our clients challenged us with a difficult problem. Their cross-platform app (iOS and tvOS) required a native video player with several novel and highly-interactive features, but they wanted the player to adhere closely to the default experience offered by AVPlayerViewController on both iOS and tvOS. The novel features were simply not possible to implement using AVKit (the framework which includes AVPlayerViewController), which meant the only way we could satisfy our client’s needs was to implement a pixel-perfect, cross-platform AVPlayerViewController clone from scratch, and extend it to support the new features.

Goals

When we started planning our implementation, our goals were:

  • Pixel-perfect replication of AVPlayerViewController’s user interface on both iOS and tvOS, with all of its subtleties of layout and animation (lots of overlapping, user-cancellable, timed animations triggered by both user interaction and other events).
  • Emphasis on compile-time guarantees of completeness and correctness.
  • Ease of development when modifying existing behaviors or addressing unanticipated edge cases.
  • Resilience to bugs and regressions.

The last two goals in particular are often at odds with one another in real-world app development. UIKit’s reliance on object-oriented programming can lead time-pressed developers into situations where the expedient solution (hot-fixing bugs by inserting more conditional statements into imperative code) is diametrically opposed to the resilient solution (refactoring for composition and testability). Over time, as existing code is extended far beyond its initial implementation, incremental changes to implicit behaviors begin to pile up until every modification seems to introduce new regressions. We anticipated that the custom video player for this project would end up accommodating a very large number of special cases. The ideal architecture would take this awkwardness into account, allowing for quick fixes without sacrificing resilience.

What Are Implicit Behaviors?

Previously, we used the term implicit behaviors. That deserves some elaboration. We consider a behavior to be implicit when it’s not explicitly modeled but is implied by a series of statements. Let’s look at a simplified example of implicit behaviors. Consider the following view controller method:

This behavior is implicit because it is only comprehended by reading the series of statements and inferring their collective intent from their context. If we were to write this behavior as a sentence, it would read:

When the background is tapped, show the toolbar, bottom gradient, and status bar, and reschedule the toolbar disappearance timer using a four-second delay.

One of the drawbacks of implicit behaviors is there are no compiler warnings if the developer neglects to update a particular view, or accidentally removes a statement during refactoring. The behaviors are also not exposed to unit tests. They’re bound to runtime events, hidden inside imperative code. This is a contrived example; in real world usage, desired behaviors are more nuanced. Perhaps we might not want to show an activity indicator every time the player status changes to .buffering, but only when certain other criteria are also met. Dealing with overlapping special cases will bloat the implementation with conditionals, increasing the number of implicit behaviors and obscuring the intent of the code.

Why Xcode UI Tests Would Not Have Sufficed

One way we could have tested for correct behaviors would have been to use Xcode’s UI testing feature, but there were several reasons why we chose not to. Primarily, Xcode UI tests would not have allowed us to test for everything we wanted to test for, such as correct animation parameters (duration, curve, etc). Recall that our first goal was pixel-perfect replication of AVPlayerViewController, and that includes subtle differences in animation speeds triggered by arbitrary events. UI testing is tightly-coupled to particular views, making cross-platform testing more tedious. Time was a scarce resource on this project. It takes more time to set up and maintain UI tests than it takes for unit tests that use developer-friendly value types and pure functions. Video player state is also highly sensitive to AVPlayer conditions. Those conditions are not trivially reproduced.

The most critical reason why UI testing wasn’t the right choice for us is that it doesn’t make it easier to avoid breaking implicit behaviors in the first place. Over time, conditional statements pile up until it isn’t obvious from the code itself what the expected behaviors are. If a developer needs to make a change, it can be difficult to know where to begin and how to avoid introducing regressions. On our project, we sought an approach that substituted implicit behaviors with explicit behavior modeling — value types that can be passed between components and verified for correctness at compile time and in unit tests.

Converting Implicit Behaviors into Explicit Value Types

In Bernhardt’s talk, he recommends using value types as the boundaries between objects to make it easier to isolate and test them. In the context of our project, that meant identifying implicit behaviors and decomposing them into explicit value types. Recall the implicit behavior we identified in the code sample above:

When the background is tapped, show the toolbar, bottom gradient, and status bar, and reschedule the toolbar disappearance timer using a four-second delay.

We can decompose this behavior into three distinct value types:

  • when the background is tapped: an event that occurred
  • show the toolbar, bottom gradient, and status bar: a set of updates to be applied to the user interface
  • reschedule the toolbar disappearance timer using a four-second delay: an action to be performed

We can model these types explicitly using Swift enums:

By converting the implicit behaviors into explicit value types, we can now isolate the expected behaviors inside pure functions, which are easily unit tested:

This reduces the view controller implementation to a very thin layer that dutifully updates its views with simple imperative statements:

Now we have completely eliminated implicit behaviors from our view controller code.

Putting It All Together

The above code examples are oversimplified to more easily illustrate the basic principles. They also aren’t an accurate picture of the pattern we applied to organize the relationships between all the participating objects. Here is a diagram of the whole system:

A diagram of the system.
A diagram of the system.
  • Core: This is a class with a name like VideoPlayerCore, but we’ll refer to it here as just “the core.” As a reference type, the core is the arbiter of truth; it owns a reference to the current model. Events are passed to the core from the view controller, or from a variety of other objects with focused responsibilities (AVPlayer boilerplate, NSTimer, etc.) The core forwards events and the current model to the evaluator (see below).
  • View Controller: The view controller manages some complex layout and gestures, but conceptually it is still very thin. It has two essential responsibilities: 1) send user-driven events to the core, and 2) apply user interface updates received from the core.
  • Model: Every unit of data that could have bearing on expected behaviors is modeled as a value type property on a comprehensive model struct. This includes both AVPlayer state info, as well as global conditions like UIApplicationState. The current model is owned by the core, and is a private property.
  • Evaluator: This is essentially a glorified namespace of free functions. Every time the core receives an event, the core passes the event and the current model to the evaluator. The evaluator returns three things: 1) an updated model, 2) a set of UI updates, and 3) a set of actions. The core replaces its current model with the updated model and performs all the actions in the actions set (if any). Lastly it forwards the UI update instructions to the view controller via a delegate protocol.

The majority of the elements in the diagram above are reference types implemented as thin layers of imperative code. The evaluator contains the bulk of the implementation.

Testing

Because all the decision-making occurs inside the evaluator, and because the evaluator is a purely functional creature operating only on its inputs and always returning a value, we were able to unit test the evaluator for every event under all relevant conditions. Every property on the model struct — which is a comprehensive representation of the status of the entire system — is a value type, so it was trivial to establish special conditions for each test. As we discovered edge cases and added new features, we were able to extend the evaluator’s implementation and know immediately whether we had introduced any regressions.

The design of the video player’s custom features was iterated upon constantly throughout the project. Whenever we needed a new event, action, or update, we added a case to the affected enums and the Swift compiler guided us to the gaps in our implementation through switch exhaustiveness checks. Where appropriate, we used a test-driven development process, writing unit tests for a given event before implementing the evaluator function that would process it. Other times it was more expedient to roughly implement some behaviors and refine them according to feedback from the client, writing tests once the requirements were fully understood. There was little benefit for us to create unit tests for the other elements in the system besides the evaluator, nor was there much benefit for writing UI or integration tests for the system as a whole, so we opted not to write any. This approach gave us the optimal blend of resilience and ease of development that we were looking for.

Developer Experience

Overall, the experience of the developers working on this code was positive. The custom video player was a complex tentpole feature with a demanding set of functional requirements. It was likely unavoidable that we would be writing a lot of code, regardless of the pattern we chose. The initial development period was spent replicating AVPlayerViewController behaviors, which was a tedious process, but our chosen approach enabled many developers to work in parallel. Once the value types were declared, user interface code could proceed using stubbed values while evaluator implementation and testing was still in development. Merge conflicts were surprisingly rare, despite the number of developers working simultaneously. Bug fixing was a refreshingly straightforward experience. Whenever a QA bug ticket was filed, it was possible to quickly identify the root cause and modify only the affected code paths.

Because of how resilient this system was, we were able to make significant changes to the code right up until the end of the project, confident that essential behaviors would continue to function as expected.

Final Thoughts

There is more than one way to reduce view controller complexity. Modeling implicit behaviors through explicit value types is just one of many. Our approach was well suited to this project because we had a laundry list of well-defined behaviors that needed to be implemented with precision. If the behavior expectations had been too ambiguous, or if the design changes were significantly more volatile over the life of the project, our approach might have been more of a burden than an asset. However, we were pleasantly surprised by how well our approach scaled across design revisions and changing expectations. We can also imagine how explicit behavior modeling can be applied to problems other than view controller complexity. The next time you face a knotty mass of complex implicit behaviors in your code, consider decomposing them into explicit value types to make them easier to define and implement.


For more insights on design and development, subscribe to BPXL Craft and follow Black Pixel on Twitter.

Black Pixel is a creative digital products agency. Learn more at blackpixel.com.