This is the third day of my participation in the August More text Challenge. For details, see: August More Text Challenge

The main content of this article is translated from the official Document of the Flutter: Simple App State Management. There will be many articles in the series, but start with the official sample document to better understand the concept of state management.

preface

The main feature of declarative UI programs is that the actual drawing of the UI interface is separated from the code that declares the interface. When I first came into contact with the Flutter, I was not comfortable with it. In iOS, when you write a text control, you can directly modify the text property of the UIText when you modify the text content. However, with the Flutter, the text component cannot be modified directly after the content is initialized. Instead, you need to use state management to change the data and then trigger the corresponding method to rebuild the UI (typically by calling the setState method to trigger the build). This is also a feature of modern responsive frameworks like React, Vue, SwiftUI.

The separation of data and interface makes the business logic of the code clearer and easier to encapsulate and share. State management becomes a core business and is therefore important.

Shopping cart Example

To demonstrate state management, let’s take a simple shopping cart as an example. Our application has two separate pages: GoodsList and MyCart. The business logic is simple:

  • Items are added to the cart when you click the Add button from the item list
  • Items that have been added can be seen from the shopping cart page.
  • Items on the list are checked if they have already been added to the cart. Repeat additions are not allowed.

In order to simplify the business logic, the item modification quantity and the removal of items from the cart are not implemented. The following figure shows the component structure of the application.

So here we have a question, where do we manage the status of the shopping cart? Is it in MyCart or somewhere else?

Status management upgrade

In Flutter, it makes more sense to place state management on top of the components that use the state. This is because a declarative framework like Flutter has to rebuild components if the UI is to be changed. We cannot update the interface with myCart.updatewith (somethingNew). In other words, we cannot invoke a method of the component externally to visibly change the component. Even if you want to do that, you have to work around the limitations of the framework rather than taking advantage of the framework.

// Bad example
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget();
  cartWidget.updateWith(item);
}
Copy the code

Even if the above code works, then we need to implement the corresponding updateWith method.

// Bad example
Widget build(BuildContext context) {
  return SomeWidget(
    // The initial state of the cart
  );
}

void updateWith(Item item) {
  // Update the interface code
}
Copy the code

This code takes into account the current state of the UI and then applies new data to the interface. It’s hard to avoid bugs. In Flutter, a new component is built each time the interface content changes. We should replace the myCart.updatewith (somethingNew) method call with MyCart(contents). This is because we can only build a new component in its parent’s build method, which requires that state be managed at MyCart’s parent or higher level.

// Good example
void myTapHandler(BuildContext context) {
  car cartModel = somehowGetMyCartModel(context);
  cartModel.add(item);
}
Copy the code

Now, there will only be one entry in the shopping cart to build the UI.

// Good example
Widget build(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  return SomeWidget(
    // You only need to build the UI once using the current state
  );
}
Copy the code

In this example, contents should be managed in MyApp, and once it has changed, the application will rebuild the MyCart component from the previous layer. The advantage of this is that MyCart does not require lifecycle management, it simply declares how the interface is presented in terms of contents (MyCart becomes a stateless component, and the interface and business logic are separate). When the state changes, the old MyCart components disappear and are replaced with new ones.

This also shows why widgets are immutable, they don’t change, they get replaced. Now that we know where to manage state, let’s look at how to access it.

Access to the state

When the user clicks on an element of the item list, it is added to the shopping cart. But what if our shopping cart is one level above the item element? An easy way to do this is to give each element a callback method that is called when clicked. In Dart, functions are first-class objects, so you can pass them as arguments. Therefore, in the list of items we can implement this in code:

@override
Widget build(BuildContext context) {
  return SomeWidget(myTapCallback);
}

void myTapCallback(Item item) {
  // Handle the item click event
}
Copy the code

This works fine, but if we use the item list component in many places in our application, then our item click processing method will be scattered among the components and will be difficult to maintain (when the same code is used more than twice, you need to consider whether your design is faulty). Fortunately, Flutter provides a mechanism for components to provide data to sub-components, including sub-components, and sub-components of sub-components. Like the idea that Everything is a component in Flutter, data transfer is a special kind of component: InheritedWidget, InheritedNotifier, InheritedModel, etc. This article will not cover the contents of these components for the time being because they are implemented at a much deeper level.

Here we need plug-in providers, which hide deep data transfer components for us to simplify state management. For details about how to use the Provider, see the pub document: State Management Plug-in Provider. We will explore the use of Provider plug-ins in more detail.

The Provider of ChangeNotifier

ChangeNotifer is a simple class built into the Flutter SDK to provide information about changes to listeners. In other words, if the object is a ChangeNotifier object (inheritance or mixin), then we can subscribe to its changes (just like the observer pattern). In Provider, ChangeNotifer is a way to encapsulate application state. For simple applications, you can use a single ChangeNotifer. For complex applications, there are multiple models and therefore multiple Changenotifers. You can use ChangeNotifer without using a Provider, but with a Provider, it’s much easier. In our shopping cart example, we can manage the state of the shopping cart in a ChangeNotifer, so we create a shopping cart model class that inherits from ChangeNotifier.

class CartModel extends ChangeNotifier {
  final List<Item> _items = [];
  
  UnmofiableListView<Item> get items => UnmodiableListView(_items);
  
  int get totalPrice => _items.length * 42;
  
  void add(Item item) {
		_items.add(item);
    notifyListeners();
  }
  
  void removeAll() {
    _items.clear();
    notifyListeners();
  }
Copy the code

The only special aspect of ChangeNotifer is that it calls notifyListeners. Calling this method at any time when the model changes might refresh the UI. The rest of the code in the CartModel is its own business logic. ChangeNotifier is part of the Flutter: Foundation and does not depend on any of the more advanced classes. As a result, testing is simple (you don’t even need to use components to test). For example, the following is a simple unit test for the CartModel:

text('adding item increass total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
  });
  cart.add(Item('Dash'));
Copy the code

The Provider of ChangeNotiferProvider

ChangeNotiferProvider is a component that provides instances of ChangeNotifer to child nodes. This is defined in the Provider package. We talked earlier about defining the state on top of the directional state component, which is the ChangeNotifierProvider in this case. In the case of CartModel, this means the top of the list of items and the cart — that’s our App layer.

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const App(),
      ),
    );
}
Copy the code

Note that we define a constructor to return an instance object of the CartModel. ChangeNotifierProvider does not rebuild the CartModel if it is not necessary. Furthermore, Dispose is called to destroy the object when the instance is no longer needed. If we need to provide multiple state sample objects, we can use MultiProvider:

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: const App(),
      ),
    );
}
Copy the code

The Provider of the Consumer

Now that the CartModel is available to the component via the ChangeNotifierProvider defined at the application level, we can use it in the component.

return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text('Total price is ${cart.totalPrice}'); });Copy the code

In the Consumer we must specify the class of the model we want to access. In this example, we need the CartModel, so we are using the Consumer. If we don’t specify that class in the generic, the Provider package won’t help us. A Provider provides state information based on a type, and does not know what information a component needs if it does not specify a type. The Consumer only needs one required parameter, which is the Builder. Builder is the function that is called when the ChangeNotifer object changes. When the notifyListeners method of the state model is called, the Builder methods of all consumers that respond to the state model are called. The Builder method takes three arguments. The first is the same context as the component’s build method. The second is the ChangeNotifier instance object that triggers the build function call, from which we can get the data we need for the UI. The third parameter is child, which is used for optimization. If we have a large tree of sub-components under our Consumer that do not need to be changed when the model changes, we can build the sub-component only once:

return Consumer<CartModel>(
  builder: (context, cart, child) => Stack (
    children: [
    	if(child ! =null) child,
    	Text('Total price is ${cart.totalPrice}'),
    ],
  ),
  child: const SomeExpensiveWidget(),
);
Copy the code

By placing the Consumer component as deep in the component tree as possible, I can improve performance without having to build a lot of UI when certain details in other parts change.

// Bad example
return Consumre<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      child: AnotherMonstrousWidget(
        / /...
        child: Text('Total price is ${cart.totalPrice}'),),); });Copy the code

The right thing to do is:

return HumongousWidget(
  child: AnotherMonstrousWidget(
    / /...
    child: Consumre<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price is ${cart.totalPrice}'); },)));Copy the code

The Provider of method

In some cases, we don’t need to change the interface based on the state information, but access the state object to do something else. For example, if we have a cart empty button, we need to call the removeAll method on the CartModel when we click the button. In this case, we can write:

onPressed: () {
  Provider.of<CartModel>(context, listen: false).removeAll();
}
Copy the code

Note that the LISTEN parameter set to false means that the component does not need to be notified to rebuild when the state changes.

conclusion

The code has been uploaded to Gitee: Simple State Management Example. It works like this (with a few changes to the original example to look like a real shopping cart). As you can see, using state management has several benefits:

  1. Data is synchronized between pages.
  2. Even after you exit the page, the state remains the same, which is one of the reasons why state management should be placed at a higher level.
  3. The business code is separated from the interface, which is only responsible for rendering and interacting with the page, while the specific business logic is implemented in state management. The code is easier to maintain.
  4. Most pages can be set to stateless components and local refresh through the Provider to improve performance.


I’m an island user with the same name on wechat. This is a column on introduction and practice of Flutter.

👍🏻 : feel a harvest please point to encourage!

🌟 : Collect articles, convenient to read back!

💬 : Comment exchange, mutual progress!