A Deeper Look into Clean Architecture: Flutter vs Kotlin by@nickzt

A Deeper Look into Clean Architecture: Flutter vs Kotlin

image
Nick Maletsky HackerNoon profile picture

Nick Maletsky

1st software startup in high school. To study the university earned in game dev and math modeling.

Somehow the idea arose to compare the construction of a Flutter application and a native Android application written in kotlin using a view model, live data, view binding and find analogs of the language tools familiar to kotlin.

The purpose of this article is to show my preferred way of making a Flutter project more intuitive and maintainable. The text can be useful both for those who are just starting to learn Flutter and for more advanced users, since here we will consider current approaches to development.

For this material, I decided to choose a slightly unusual "tutorial" format. Let's call it “codelife” - by analogy with screenlife. According to the rules of screenlife, the action will take place on the screen, the decorations are Github commits and the Story Reader page (codingstories.io), which makes them interactive. The focus of the story is on the development of the usual Hello World code for Flutter.

At the start, this is a counter, a "hello world" for the Flutter world, from the commits you can see how it changes and becomes different. Outwardly, nothing changes on the screen, only the inner essence of the code changes, and the result of execution and design remain unchanged. According to the precepts of Clean Code, state and logic are separated from the user interface, communication between them is established according to the rules of MVI.

Also on this celebration of metamodern a new approach to viewing and learning fits well - to do the kata code according to this article: Story Reader (codingstories.io). Links to the corresponding codingstory pages (kata code) can be found at the beginning of the relevant paragraphs.

Briefly about the Theory

Clean Code - what we mean

I am assuming you have already built apps using Flutter, Native or whatever.

When we need to get results quickly, we usually don't focus on how the data flow works, writing independent data layers, splitting code, actually using OOP, testing, scaling functions, etc. All we need is to complete development as soon as possible and release our code to production. A very typical for startups approach “very fast to production”, exacerbated by the use of the pattern “and so it will do”.

But what if it still took off, and we need to add something new, or we are creating a large project with many functions and the number of developers working on it is already more than two? etc. ..

If you have firsthand an idea of ​​the nuances described above, then you understand exactly what this quote means:

“The first 90 percent of the code takes 10 percent of the development time. The remaining 10 percent of the code takes the remaining 90 percent "Tom Cargill, Bell Labs

If you understand that it is expensive to make changes to the system, it is better to plan ahead for the possibility of changes. This is what is called “The Clean Architecture”.

The number of articles on this topic is incredibly large. You can fall back to the original source - in 2012, an article was published "The Clean Architecture"

image

In short: Clean architecture is the most powerful solution for building systems in which multiple teams can work on independent layers of code. Clean architecture has qualities such as scalability for adding / removing components; verifiable, easily replaceable components; simplicity of support at any stage of the application.

Now about MVVM, MVC, MVP, MVI, MVU and others

I tend to generalize that these patterns (MVVM, MVC, MVP, MVI, MVU, etc.) are generically referred to as data flow patterns. Which more or less accurately describes their purpose - to display data on the screen, be it a unidirectional or bi-directional flow. If you deduce the name from the concept of "an object that connects them", then it can be a Presenter, Controller, ViewModel entity, and a data flow entity - Cubit, StateNotifier, ChangeNotifier, BLOC, etc.

There are many patterns, and depending on where the developer came from, from the front-end, from mobile native, for each of them the concepts from another area can be difficult to understand.

For my opus magna MVVM, MVC, MVP, MVI see previous article. I draw analogies and comparisons from the world of native development for Android, which is well known to me.

Choosing an Example for the First Step

At first I wanted to implement an example from this Habr or this article, but I thought that it would not be entirely indicative. Still, the hello world flutter example with counter is not bad and shows a bi-directional flow of events - ie. from user to user.

So, let's implement a counter with a separate counter for even numbers, converting the code to MVI and following Clean Code.

The high-level diagram

image

Attached to this article is the coding for the kata - see Coding Stories. Further in the story, under each step, there are links to the Github repository and the corresponding codingstories pages.

For consistency and convenience of comparing the code (and because I can) I wrote it as a kata code on:

Consider the Nuances of Riverpod

Let's start

At the beginning of each paragraph there will be links to the commit on Github and the corresponding codingstory page (kata code): see. on github see how Codingstory (codingstories.io)

Let's create a new project.

image

Android studio will create a counter project by default. We will use it further. Let's add a dependency on Riverpod.

image

Let's separate the state and logic from the user interface: see. on github see how Codingstory (codingstories.io)

The purpose of this article is to show you how to make your Flutter project more maintainable by decoupling state and logic from the user interface. In the default project, the state of the widgets and logic is right there in the UI widgets.

Let's start changing. I will remove the state management from the widgets.

What happens if you launch the application? on github see how Codingstory (codingstories.io)

This is pretty much the same basic counter application as Flutter created by default, but with all states and logic removed.

MyHomePage is StatelessWidget instead of StatefulWidget. What happens if you launch the application?

image

Check it out for yourself - the application has stopped working, pressing the FAB does not lead to anything. It's no surprise if the onPressed of the FAB button is empty:

image

Thank you Captain Obvious!

see on github see how codingstory (codingstories.io)

To separate state and logic from a view, you need to store that view somewhere separately.

A model class represents the state of some part of your application. In our case, it will display the state of the counter. We will use this class to store and display the current counter value in the middle of the screen:

// class to represent the state

class CounterModel {
 const CounterModel (this.count);
 // state is immutable
 final int count;
}

This object is only a wrapper for an integer. In fact, we can just use the integer directly. However, in this case, we will go the other way. Note that count is final. This means that the state is unchanging. An alternative can be found in the Riverpod demo here.

IMHO, immutable state is good. I suggest you read this article to understand why. Every time the state changes, we create a completely new CounterModel object. This might seem like extra work, but it prevents some of the subtle bugs that can arise from changing the internal values ​​of mutable state.

There is also a const constructor which, in addition to enforcing immutability, also allows you to declare compile-time optimized constants. A good read on this topic here.

We finish with the lyrics, back to business.

We have created a Counter Model class to represent the state of a counter. Now we also need a class for managing the state, in particular for storing its current value and incrementing that value when the user presses the FAB (+) button:

class CounterNotifier extends StateNotifier <CounterModel> {

 CounterNotifier (): super (_initialValue);
 static const _initialValue = CounterModel (0);
  void increment () {
   state = CounterModel (state.count + 1);
 }
}

Let's go through CounterNotifier. It inherits from the StateNotifier state management class. StateNotifier is similar to Flutter's default ValueNotifier, or even Cubit from the Bloc package without underlying threads. This type of immutable state management is perfect for our task, and in the future I prefer to use it, plus immutable state, to prevent unpleasant surprises.

State Notifier under the hood has a single internal variable named; state, which contains the current state, in our case a CounterModel instance. We can change the value of state, but the CounterModel is unchanged. This means that in order to change CounterModel.count, we need to create a new object.

I need to initialize state, we will do this by passing the initial value of super to the constructor. Our code above initializes counter to 0.

The increment function can be called externally to replace state with a new CountModel, whose internal count is one greater than the last CountModel.

When the state changes, the StateNotifier notifies any objects that are listening to it:

Make it Run.

see on github see how codingstory (codingstories.io)

Now it's time to make our application work. For the Riverpod magic to work, you need to wrap the entire ProviderScope application with a widget:

runApp (

 const ProviderScope (child: MyApp ()),
);

We create a global provider.

I add the following top-level variable to main.dart:

final _counterProvider =

   StateNotifierProvider <CounterNotifier, CounterModel> ((ref) {
 return CounterNotifier ();
});

Since _counterProvider is a global constant, we can access it from anywhere (without the need for an assembly context like Provider did if you've tried it). You usually hear in programming that you shouldn't use global variables. One reason is that it's easy to get subtle errors if different parts of your code change a variable. However, this global variable is immutable, so there is no danger of changing it. Another reason to be careful with globals (and constants) is to create dependency problems.

Riverpod has many different providers. In this example, we are using the StateNotifierProvider because the state is in the StateNotifier class (CounterNotifier).

MyHomePage is currently a successor to StatelessWidget. Change the StatelessWidget to a ConsumerWidget to get a "ref" object. This object allows us to interact with providers, be it a widget or another provider.

Every time the counter value changes, it would be a good idea to display this in the user interface. To do this, we need to learn how to observe changes.

Add the following line inside the build method of MyHomePage (just before the return statement):

final counter = ref.watch (_counterProvider) .count;

.watch will listen for changes to _counterProvider.state, which is an instance of CounterModel. And accordingly, we can access the .count field, in which CounterModel stores the counter value.

To display the counter state, let's change the Text widget:

Text ('Count: $ counter'),
Counter update. When the user clicks the + button, we need to call the increment () method in our counter state management class (CounterNotifier).

Replace the onPressed callback in the FloatingActionButton with the following:

onPressed: () => ref.read (_counterProvider.notifier) ​​.increment (),

The ref object has a read method. Unlike watch, the read method gives you a reference to your state management class (CounterNotifier) ​​without tracking changes in state. The reason this is important is that the watch behind the CounterNotifier will cause the widget's build method to rerun when the state changes. If the widget, in this case FAB (FloatingActionButton), does not visually change in any way, it is somehow useless to redraw it. However, the current build method is already in use and will be called because there is a watch call inside the widget. This means that all widgets in this build method (including the FAB) will still be rebuilt. We'll optimize this later. Let's try to launch the application, and the counter starts responding to clicks.

Let's try to add a feature: see. on github see how Codingstory (codingstories.io)

The application may already be working, but there is no limit to perfection. Let's try to add a feature - let us have another counter that will show how many even numbers the user has seen. We will solve the problem head-on, as if we have a KPI for the number of lines. We will store the value and add it to a new counter, let's call it EvenCounter, every time Counter is divisible by two without a remainder.

And then we start EvenCounterNotifier and EvenCounterModel by analogy with Counter. As a provider, in full analogy with _counterProvider

final _evenCounterProviderAsSeparateState =
   StateNotifierProvider <EvenCounterNotifier, EvenCounterModel> ((ref) {
 return EvenCounterNotifier ();

});

We will also create a provider for the state property _isEvenProvider

final _isEvenProvider = Provider <bool> ((ref) {

 final counter = ref.watch (_counterProvider);
 return (counter.count% 2 == 0);
});

It will transmit on an even or odd number the change in the _counterProvider value.

We noticed earlier that it is not optimal to redraw the widget every time the provider changes. Since new providers pass values ​​twice less often than _counterProvider, we will create new widgets for new providers: CounterIsEven () and EvenCounter (). Their system can redraw only when the corresponding provider will transfer values.

Provider chains

See on github see like Codingstory (codingstories.io)

And now, on this holiday of the code, let's add a little more advanced and short code. To do this, let's deepen our ability to build provider chains:

// StateProvider and even numbers counter in a state provider

final _evenCounterProvider = Provider <int> ((ref) {
 ref.listen <bool> (_isEvenProvider, (previous, next) {
   if (next) {
     ref.state ++;
   }
 });
 return 0;
});

This provider does almost the same thing as _evenCounterProviderAsSeparateState, but without the extra code and completely automatically. It is easy to see that as soon as the value of the provider _isEvenProvider changes to true, then one is added to the value of our provider (_evenCounterProvider). It turns out that this is also possible.

Now let's cast the spell Clean Code (Clean Architecture) on this code

image

See on github see like Codingstory (codingstories.io)

Let's remove all unnecessary from the class files and place them in the appropriate packages (directories, package), naming them in connection with Clean Architecture:

  • lib / main.dart - remove unnecessary
  • lib / presentation / widgets / counter_widget.dart - group widgets into package / presentation / widgets /
  • lib / presentation / pages / home_page.dart - into the page package (analogous to fragment from Android), and the entire structure of the application will resemble the Single Activity approach
  • lib / presentation / manager / bindings / counter_view_binding.dart - select provider binding in a separate file in presentation / manager / bindings package
  • lib / data / models / counter_model.dart - transfer models to the data layer

Add a healthier look with MVI

Add View States

To fully represent MVI we need to add the view state, in Kotlin we add it to the sealed class.

Let's take another look at the grace of sealed Kotlin classes:

sealed class MainFragmentUiStatesModel {

   object Odd: MainFragmentUiStatesModel ()
   object Even: MainFragmentUiStatesModel ()
}

For a Kotlin analog look here: see. on github see how Codingstory (codingstories.io)

States are described in the MainFragmentUiStatesModel class. It is declared as a sealed class. This is because each of the sealed class implementations is itself a complete class. This means that each of them can have their own sets of properties independently of each other. When adding a new state object, there must be protection against accidental “no mapping” to the state of the user interface.

Let's go back to Flutter:

see on github see how codingstory (codingstories.io)

Nested sealed classes

@freezed
class ViewState with _ $ ViewState {
 /// Odd / default state
 const factory ViewState.odd () = _Odd;

 /// Data is loading state
 const factory ViewState.even () = _Even;
}

The presence of so simply described sealed classes is already enough to use freezed.

Minute for advertising: In the second part with a more serious example, we will look at how freezed helps in working with the database and and json_annotation for data serialization

Code generation

See on github see like Codingstory (codingstories.io)

Freezed works on code generation, it's okay - Kotlin does the same magic in the same way. There is one problem with freezed, because its code generation process is not automated. For us, this means that right after we wrote our code, nothing will work. We need to remember to manually start the process of rebuilding the code base. To do this, just write in the terminal:

'' 'flutter pub run build_runner build' ''

And all the necessary files will be generated and placed next to the description files.

Render contract

See on github see like Codingstory (codingstories.io)

The counterpart for Kotlin is here.

Let's continue converting all this to MVI and the names adopted in the Kotlin part. Add a contract for the View, which is responsible for the states and the logic of their display. Methods for switching View in different states are defined in the contract and described in the View, which implements ViewStatesRenderContract. (This is a mixin). The states themselves are in ViewsState.

Where is the problem and why did I make these changes

“Our providers are becoming bloated divine classes with the logic of state and business scattered all over the place.” Somewhere on Reddit

You need to understand that a provider is a very simple and straightforward state management tool, especially for beginners. But using such singletons is not very convenient in application development either, and I would like to be closer to a clean architecture.

In my opinion, one provider per page (or area) would be enough, for example [ViewModel] (lib / presentation / manager / counter_view_model.dart: 9). Then it really cannot become a large class. It should only contain the models of this page and some methods.

This way we can easily call [notifyListerns] (lib / presentation / manager / counter_view_model.dart: 47) whenever we do something with our models, and control callbacks and updates, optimizing widget redrawing. In addition to this, you may have classes to serve other logic that have nothing to do with the provider and only contain business logic.

And yes, you can see that the order of the widgets changes when the state changes.

At the same time, in order not to get up twice, we added the logic for switching states to our view model (lib / presentation / manager / counter_view_model.dart: 45)

if (isEven ()) {

 setEvenState ();
} else
 resetState ();

As a result, this code looks simple and straightforward Github, Story Reader (codingstories.io)

Polishing with Clean Code

See on github see like Codingstory (codingstories.io)

There is no limit to perfection. To be just like adults (and avoid additional explanations in the third part), let's move the data (counters) where they should be - to the data sources (Data source) in the corresponding Repository. Access to them will be provided through the corresponding Use Case interface.

What is “Use Case” or, more simply, “use case”? Uncle Bob in a video talk talks about the book “Object-Oriented Software Engineering: A Use Case Driven Approach”, which Ivar Jacobson wrote in 1992, and how he describes the Use Case.

“Use case” is a detail, a description of an action that a user of the system can perform.

Conclusions

I won't repeat the sugar pouring of Clean Architecture, MVI and MVVM. All this together, in my experience, helps to build systems, the code of which then, say, six months later, is not a shame to look at without a facepalm.

In the second part of this material, in the same format (MVI, Clean Code) I plan to consider an application that will use the API to access an external API. Something like how it was in one of my old examples of displaying the exchange rate - see the repository for more details, it was written a couple of years ago on a homemade BloC and self-written DI using RxDart.

I hope for someone my experience, laid on this text, will be useful. I would be glad to receive your recommendations, questions and, of course, reasonable criticism.

Nick Maletsky HackerNoon profile picture
by Nick Maletsky @nickzt.1st software startup in high school. To study the university earned in game dev and math modeling.
Read my stories

Comments

Signup or Login to Join the Discussion

Tags

Related Stories