Decoupling logic from UI in Flutter application
By: Anton Klimenko
Tags: Flutter App Development, Clean Code, Software Development, Flutter

Photo by Kelly Sikkema on Unsplash

Introduction

Recently I worked on a Flutter project. I started from the UI definition. Then I extended the application with user interaction handlers and business logic rules. In no time the widget classes became polluted. Readability and reusability of the code were low. But the major problem was testing. I could not validate business rules without launching the whole application or without initialising the widgets.

After a while, I decoupled UI from the application logic. I refactored codebase to a set of widget classes, application state class, and the business rules class.

Figure 1. Architecture components

Such architecture allowed me to test every component in isolation. And I didn’t need to use widgets to verify application logic. Additionally, usage of the provider package allowed me to inject required application state into a widget instance and test UI behaviour in isolation. Before it, I did a sequence of UI interactions to bring the application to the needed state.

Figure 2. Application testing

In this article, I would like to share my experience and show how I detached UI from application logic. As an example, I am going to write a simple calculator application.

Calculus application

I am going to build an application that has the following features:

Calculate the result of mathematical operation with one or two operands (addition, division, square root)

Memorise result

Assign value in memory to operands

Reset memory

My first step is to define the UI scaffold.

Building UI

Based on the feature list I built the following elements:

Calculator input ( CalculatorInput )

) Memory management ( MemoryManagement )

) Memory information ( MemoryInfo )

) Decorative elements (Header, Divider)

Here is how the application user interface looks like:

Figure 3. Calculus application UI

The following snippet shows how UI elements wired together.

Snippet 1. UI scaffold

Note: snippets do not contain the full application source code. I am using them to highlight the important pieces of code. The full source code is available in the repository. There you can switch to different branches and see how the application evolved.

At this point, UI is a set of simple widgets, just a bit modified bootstrap Flutter project. Next, I am going to add functionality to the application.

Implementing application logic

There are two feature categories that the application should provide:

Memory management

Mathematical calculations

First, I am going to implement memory management. As you can see in Figure 3, three elements depend on memory. Therefore I am adding a memory state to the top-level widget. Finally, I am providing two callbacks to the MemoryManagement element. They handle user commands to memorise the current calculation result and reset the memory state.

Snippet 2. Adding memory state to the top-level widget

Next, I am going to provide a mechanism to update the value of calculatorResult . The calculation logic will be hosted in CalculatorInput widget class. Thus I am initializing it with onChanged callback.

Snippet 3. Providing memory information and the update callback to CalculatorInput

At this point, I am passing the memory state from the top-level element to the nested widgets. Using callbacks, nested elements notify their parent about changes. The following figure shows the control and data flows between parent and children elements. The left part of the diagram shows widgets hierarchy. While the right side represents widget classes scopes. Because of update callbacks and memory state are within the scope of CalculusScreen, they filled with the same colour.

Figure 4. Injecting state and callbacks from parent to children

Memory management feature finished. Next step is the implementation of calculations logic. I am starting with adding calculator value and format getters to CalculatorInput element.

Snippet 4. Calculator operations value and format getters

Finally, I am injecting onChanged callback to OperandInput element to get the latest value in CalculatorInput.

Snippet 5. CalculatorInput provides callbacks to OperandInput

Snippet 6. OperandInput

The following figure shows relations between CalculatorInput and OperandInput elements.

Figure 5. Calculator-Operand flow

The next figure shows a top-level view of control and data flow in the whole application.

Figure 6. Top-level view on control and data flows in the application

Now the application is fully functional. I can see the calculation result which changes when I switch between different operation types. Also, I can store the operation result and use it as an operand value. All requirements completed. But there is one problem here - calculation logic is embedded to widget classes. Therefore, to be able to test it I need to launch the application and test it manually. Or use Flutter test kit to initialize widgets and manipulate interface elements to provide user input to calculations.

The second option is better than manual testing and could be enough for small projects. But after adding more elements and conditions to business rules, testing becomes a mess. Thus, I would encourage you to separate the interface code from application logic code as soon as possible. In the end, the main aim of widget tests is validating interface completeness and responsiveness on user actions.

So, how can I improve the situation?

Decoupling application logic from UI

To solve the problem I am going to use principles of The Clean Architecture. These principles allow building robust and testable code. It is a layered architecture where every layer does only one thing - UI, database, logic, etc. The dependency injection rule is used to glue all layers together.

I am planning to refactor my application to make it cleaner and improve testability. First, I am going to take all calculations logic and move it to a separate class. I will call it CalculatorUseCase.

Figure 7. Moving application logic to a use-case

Following is the new CalculatorUseCase class.

Snippet 7. CalculatorUseCase

CalculatorUseCase is a dart class that has no external dependencies (Flutter SDK or other packages). It means I can validate the code in isolation without initializing any of widgets.

Snippet 8. CalculatorUseCase unit tests

Next, I am replacing application logic in widget classes, with the instance of CalculusUseCase. Note, I create only one instance of the use case class in the top-level element and pass it to its children. The following snippets show how the widget classes changed.

Snippet 9. Calculus screen injecting use case to its children elements

Snippet 10. CalculatorInput utilizes CalculatorUseCase

Snippet 11. OperatorInput uses CalculatorUseCase

The next figure shows how the control and data flows changed in the application.

Figure 8. “Cleaned” UI and use-case

Now it is better than the flow in Figure 6. The logic provided by CalculatorUseCase instance. Widget classes use it to make calculation and show results. All decoupled. But there is room for improvement. There are a few concerns I have:

Using different getters for value and format in CalculatorUseCase

Using switch in getters in CalculatorUseCase

in getters in Using operand update callback to notify CalculatorInput about changes in OperandInput

So, what is wrong with these points? First, using different getters for value and format it is possible to make changes in one method and forget about the other. To fix it I am going to create an Operation class. It encapsulates an operation calculation and formatting.

Snippet 12. Introducing operations

Second, having switch construction makes the method constantly growing and brittle to changes. Also, switch statement coupled with enum Operation . Having created Operation class and operation constants I don't need to use enum or switch anymore.

Snippet 13. CalculatorUseCase uses Operation class

Snippet 14. Using operation constants in CalculatorInput

The last concern is the usage of callbacks. I am using it to update the operation result text in CalculatorInput. But Flutter can automatically refresh widgets when the related state changed. And I would like to use this feature. To do it I need to create the application state.

Adding application state

Flutter comes with the ChangeNotifier class, which provides change notifications API. It can be extended or mixed in by the application classes. I could make CalculatorUseCase the extension of ChangeNotifier. But I want to keep use case classes independent of any platform, SDK or package. Therefore I am going to define a CalculatorState class to connect UI with the use case. The following figure shows the updated architecture.

Figure 9. Using the state to “glue” UI with the use-case

CalculatorState extends ChangeNotifier. As a result, I need no callbacks to refresh UI when the state changes. Flutter does everything automatically. But I need to add callbacks to the use case class to propagate updates to listeners.

Snippet 15. Adding callbacks to use case to notify about changes

Snippet 16. CalculatorState

CalculatorState uses ChangeNotifier to inform Flutter about state changes and initiate UI refresh. At the same time, information about operations and calculations logic are in CalculatorUseCase. CalculatorState is a decorator for the use case with additional functionality. Now I can replace CalculatorUseCase with CalculatorState in CalculusScreen. For this, I would need to update constructors of all widget classes that depend on the state. Not only that it creates noise in constructors, but also requires code maintenance when dependencies added or deleted. Instead of hardcoding the dependency into widget classes constructors, I am going to use provider package.

Using provider

The next snippets show how I changed the code and injected the state using the provider package. I also refactored CalculusScreen into two classes: one is responsible for the state initialization (CalculusScreen), other assembles the UI components (CalculusScreenContainer). I did it to improve widget testing experience (more about it in the next part - Widget testing).

Snippet 17. Updated CalculusScreen with ChangeNotifierProvider

Now CalculatorState available in all nested widgets. Here is how CalculatorInput changed.

Snippet 18. Updated CalculatorInput

Please check the repository to see the full code.

The following figure shows how the provider package used to inject dependencies to the application components. This is the final form of the application architecture.

Figure 10. Using a provider to inject state to widget classes

Widget testing

As mentioned in the previous part, the CalculusScreen split into two classes. Doing this I made it possible to test UI at any state of the application. All I need to do is to initiate an instance of CalculatorState in test setup and pass it to widget constructor. The rest is to assert that the interface elements rendered correct.

Snippet 19. Setting interface state in widget tests

Using state in widget tests allows validating not only interface elements (text, colour, selected option) but the state changes as well. State changes as important as UI changes and testing them together might prevent lots of bugs.

Conclusion

In this article, I tried to show/replicate the way I have gone to decouple the application user interface from logic. The main idea is to improve application architecture where: changes are easy to make, different components can be tested in isolation, multiple tests categories supported (unit tests, widget/UI tests).

I am new to Flutter and Dart and I struggled to find an example application with good testing. I hope this article could be helpful.