Quản lý state bằng Provider trong Flutter

I. Lời nói đầu

Có rất nhiều cách quản lý state trong Flutter. Sử dụng provider là 1 trong những cách đó. Chúng ta sẽ tìm hiểu thử xem sử dụng nó như thế nào.

II. Case study

Giả sử ta có 1 ứng dụng đơn giản. Ứng dụng này gồm 2 thành phần chính là 1 thư viện dùng để hiện thị danh sách các sản phẩm và 1 giỏ mua hàng để hiển thị danh sách những sản phẩm đã được chọn.

Từ 2 thành phần này, ta sẽ thiết kế 1 app bằng flutter có cấu trúc như hình bên dưới.

Vì vậy, chúng ta có ít nhất 5 lớp con của Widget. Trong cấu trúc này, ta thấy rằng có rất nhiều thành phần có nhu cầu truy cập vào state của thành phần khác. Ví dụ như mỗi MyListItem có nhu cầu truy cập vào state của MyCart để add chính nó vào MyCart.

Câu hỏi đặt ra là chúng ta sẽ đặt state ở đâu?

III. Đặt state ở phía trên

Ta nên đặt state ở trên Widget mà sử dụng state đó. Tại sao?

Trong declarative frameworks như Flutter, nếu bạn muốn update UI, bạn phải build phần UI đó lại. Sẽ không dễ dàng nếu bạn sử dụng giải pháp đại loại như là  MyCart.updateWith (somethingNew). Nói cách khác,  Và ngay cả khi bạn có thể làm cho điều này thành công, bạn đang chống lại frameworks thay vì để nó giúp bạn.

// BAD: DO NOT DO THIS
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget();
  cartWidget.updateWith(item);
}

Ngay cả khi code phía trên hoạt động, bạn sẽ phải xử lý thêm phần phía bên dưới nữa:

// BAD: DO NOT DO THIS
Widget build(BuildContext context) {
  return SomeWidget(
    // The initial state of the cart.
  );
}

void updateWith(Item item) {
  // Somehow you need to change the UI from here.
}

Bạn sẽ cần phải xem xét trạng thái hiện tại của UI và áp dụng dữ liệu mới cho nó. Thật khó để tránh lỗi theo cách này.

Trong Flutter, bạn tạo một widget mới mỗi khi nội dung của nó thay đổi. Thay vì sử dụng việc gọi phương thức thông thường như MyCart.updateWith (somethingNew), thì bạn nên sử dụng 1 constructor như MyCart (contents).

Vì bạn chỉ có thể khởi tạo các widget con bên trong phương thức build của parent Widget, nên nếu bạn muốn thay đổi contents, bạn cần đặt state ở MyCart’s parent hoặc cao hơn.

// GOOD
void myTapHandler(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  cartModel.add(item);
}

Trong ví dụ này, contents cần đặt ở MyApp. Bất cứ khi nào có sự thay đổi, MyCart được phía trên build lại. Do đó, MyCart không cần phải lo lắng về vòng đời — nó chỉ khai báo những gì sẽ hiển thị cho bất kỳ contents nhất định nào. Khi contents thay đổi, MyCart cũ sẽ biến mất và được thay thế hoàn toàn bằng MyCart mới.

IV. Truy cập state

Khi người dùng nhấp vào một trong các mặt hàng trong danh mục, mặt hàng đó sẽ được thêm vào giỏ hàng. Nhưng vì giỏ hàng nằm trên MyListItem, làm cách nào để làm điều đó?

Một giải pháp đơn giản là dùng callbackMyListItem có thể gọi khi nó được nhấp vào.

@override
Widget build(BuildContext context) {
  return SomeWidget(
    // Construct the widget, passing it a reference to the method above.
    MyListItem(myTapCallback),
  );
}

void myTapCallback(Item item) {
  print('user tapped on $item');
}

Các xử lý này hoạt động đúng, nhưng có 1 vấn đề là khi làm việc với state thì sẽ có rất nhiều nơi khác nhau muốn thay đổi state đó, điều này sẽ có nghĩa là ta sẽ cần rất nhiều callback tương ứng với việc có bao nhiêu chỗ muốn thay đổi state đó.

May mắn thay, trong Flutter ta có thể sử dụng Provider
Để sử dụng nó, đầu tiên ta cần thêm config ở file pubspec.yaml

name: my_name
description: Blah blah blah.

# ...

dependencies:
  flutter:
    sdk: flutter

  provider: ^4.0.0

dev_dependencies:
  # ...

Khi sử dụng Provider ta không cần phải lo lắng về callback nữa. Để hiểu được về Provider ta cần nắm 3 khái niệm quan trọng đó là ChangeNotifier, ChangeNotifierProvider và Consumer

V. ChangeNotifier

ChangeNotifier cấp cấp thông báo về sự thay đổi cho những listeners của nó. Nói cách khác, nếu ta có 1 ChangeNotifier thì ta có thể subscribe những sự thay đổi mà nó thông báo.

Trong provider, ChangeNotifier là 1 cách để encapsulate application state. Ta cần 1 hay nhiều ChangeNotifier tùy vào độ phức tạp của ứng dụng.

class CartModel extends ChangeNotifier {
  /// Internal, private state of the cart.
  final List<Item> _items = [];

  /// An unmodifiable view of the items in the cart.
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// The current total price of all items (assuming all items cost $42).
  int get totalPrice => _items.length * 42;

  /// Adds [item] to cart. This and [removeAll] are the only ways to modify the
  /// cart from the outside.
  void add(Item item) {
    _items.add(item);
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }

  /// Removes all items from the cart.
  void removeAll() {
    _items.clear();
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }
}

Ta cần gọi phương thức notifyListeners ở phía model trong những thân hàm gây ra những sự thay đổi mà ta muốn phía UI update những sự thay đổi đó.

VI. ChangeNotifierProvider

ChangeNotifierProvider chẳng qua là một widget dùng để tạo ra instance của ChangeNotifier

Như bạn đã biết, ta cần đặt ChangeNotifierProvider  phía trên những widget mà nó muốn truy cập. Vì vậy ta cần ở vị trí trên cả MyCartMyCatalog. Bạn sẽ muốn đặt nó ở vị trí qua hơn quá mức cần thiết, làm như vậy sẽ làm cho scope không được clean

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: MyApp(),
    ),
  );
}

Trong trường hợp có nhiều ChangeNotifierProvider, ta sử dụng dụng cú pháp như bên dưới

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: MyApp(),
    ),
  );
}

VII. Consumer

Giờ ta chỉ cần sử dụng những thứ đã chuẩn bị phía trên bằng Consumer

return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text("Total price: ${cart.totalPrice}");
  },
);

Lưu ý là cần viết rõ kiểu dữ liệu cho Consumer thì Provider mới hoạt động được.

return Consumer<CartModel>(
  builder: (context, cart, child) => Stack(
        children: [
          // Use SomeExpensiveWidget here, without rebuilding every time.
          child,
          Text("Total price: ${cart.totalPrice}"),
        ],
      ),
  // Build the expensive widget here.
  child: SomeExpensiveWidget(),
);

Consumer có 1 phương thức duy nhất là builder.
Builder nhận vào 3 tham số:

  1. context: cũng là thứ cần thiết cho mọi phương thức build
  2. instance của ChangeNotifier
  3. child: dùng để tối ưu trong trường hợp phía dưới Consumer bạn có 1 widget rất lớn nhưng nó lại không cần thiết phải bị thay đổi khi mà phía model thông báo, thì ta có thể construct nó chỉ lần thôi và ở hàm builder thì chỉ việc dùng lại

Tốt nhất là nên đặt Consumer ở phía thấp nhất có thể.

// DON'T DO THIS
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);

// DO THIS
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: Consumer<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);

VIII. Provider.of

Đôi lúc ta không muốn data trong model làm thay đổi UI. Ví dụ như ta có nút ClearCart sẽ xóa toàn bộ data của giỏ hàng nhưng lại không cần phải hiển thị giỏ hàng đó lên. Để tránh lãng phí, thay vì sử dụng Consumer<CartModel> (làm cho rebuild widget mà không cần thiết phải rebuild). Thì ta có thể sử dụng code như bên dưới:

Provider.of<CartModel>(context, listen: false).removeAll();

IX. Tài liệu tham khảo

  1. https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple
  2. https://github.com/flutter/samples/tree/master/provider_shopper