+152

Học Flutter từ cơ bản đến nâng cao. Phần 4: Lột trần InheritedWidget

Lời mở đầu

Trong đoạn kết của phần 2, chúng ta đã đối mặt với 1 bài toán: Làm thế nào để truyền data từ một widget cha nào đó xuống thẳng widget chắt mà không phải sử dụng constructor để truyền xuống từ từ từng widget một. Và trong phần 3 thì cách giải bài toán này đã được hé lộ. Đó là sử dụng BuildContext kết hợp với InheritedWidget. BuildContext đã được giải thích trong phần 3. Bây giờ chúng ta sẽ tìm hiểu InheritedWidget

1. Đặt vấn đề

Quay trở lại ví dụ app Counter ở phần 2: https://dartpad.dev/?id=157b19bb180a0d7ea4d738946c4e9bf1

Chúng ta sẽ thấy để truyền được data từ MyHomePage xuống child widget của nó là CounterWidget thì trong class CounterWidget, ta phải tạo 1 constructor có vẻ giống hệt constructor của MyHomePage

image.png

Kỹ thuật truyền data từ widget cha xuống Widget con thông qua constructor như vậy được gọi là "Passing state down".

image.png

Giả sử, trong đoạn code trên, nếu chúng ta extract widget Center ra một Widget đặt tên là MyCenterWidget thì ta cũng phải tạo một constructor với 2 property là counterisLoading để nó có thể nhận data từ MyHomePage truyền xuống cho CounterWidget.

image.png

image.png

Nếu cứ làm theo cách như vậy, từ một widget ông muốn truyền data xuống widget cháu, ta phải truyền sang tay từng người một, từ ông → ba → con → cháu. Chúng ta đã thấy vấn đề nghiêm trọng ở đây chưa?

Có 2 vấn đề xảy ra ở đây là:

  1. Nếu khoảng cách giữa MyHomePageCounterWidget trên Widget tree càng xa thì chúng ta sẽ càng cực khổ để truyền được data từ MyHomePage xuống CounterWidget qua constructor của nhiều Widget trung gian.
  2. Khi MyHomePage gọi hàm setState, nó sẽ gọi lại hàm build khiến cho cả MyCenterWidgetCounterWidget đều được khởi tạo lại và gọi hàm build. Đây là 1 sự lãng phí vì ta chỉ cần widget CounterWidget được rebuild mà thôi.

Vậy làm thế nào để khắc phục được 2 nhược điểm trên. Có một loại Widget có thể giúp ta làm điều đó là InheritedWidget

2. InheritedWidget

InheritedWidget là một nơi lưu trữ data và cung cấp data cho widget con trong widget tree. Tất cả widget con của InheritedWidget đều có thể truy cập vào InheritedWidget để lấy data. Tức là từ vị trí InheritedWidget, bạn không cần thiết phải truyền data xuống từng 1 widget con một nữa mà Widget con ở bất kỳ vị trí nào trên widget tree muốn lấy data từ InheritedWidget, sẽ giơ cao cánh tay chộp lấy data mà nó muốn từ InheritedWidget luôn.

Bây giờ, chúng ta sẽ tạo một InheritedWidget bằng cách tạo ra 1 class extends nó. Vì nó là Data provider nên tất nhiên nó sẽ chứa các data counterisLoading để truyền đến widget con của nó. Tất nhiên, chúng ta cũng cần phải tạo constructor cho nó.

class MyInheritedWidget extends InheritedWidget {
  final int counter;
  final bool isLoading;
  
  MyInheritedWidget({
    required this.isLoading,
    required this.counter,
  });
}

Nó bắt chúng ta phải override lại hàm updateShouldNotify trả về kiểu bool. Well, tạm thời chúng ta chưa hiểu nó để làm gì nên mình sẽ tạm return false.

class MyInheritedWidget extends InheritedWidget {
...
  
  bool updateShouldNotify(MyInheritedWidget oldWidget) {
    return false;
  }
}

Nó lại bắt chúng ta phải add super constructor super(...) invocation mà super constructor này lại yêu cầu phải có parameter child. Như vậy, chúng ta buộc phải tạo thêm 1 property là Widget child nữa để truyền vào super constructor.

class MyInheritedWidget extends InheritedWidget {
  final int counter;
  final bool isLoading;
  final Widget child;
  
  MyInheritedWidget({
    required this.isLoading,
    required this.counter,
    required this.child,
  }) : super(child: child);
  
  
  bool updateShouldNotify(MyInheritedWidget oldWidget) {
    return false;
  }
}

Đến đây vẫn chưa có cách nào để các widget con có thể tìm thấy MyInheritedWidget để truy cập vào lấy data. Khoan, idea "tìm thấy Widget" này rất quen. Chúng ta đã gặp nó ở trong phần 3, cụ thể là hàm Scaffold.of(context). Như vậy, chúng ta cũng sẽ tạo 1 hàm static tên là of cho truyền context vào. Các Widget con sẽ cần gọi MyInheritedWidget.of(context) để dựa vào context đó (tức vị trí của widget con), hàm of sẽ đi tìm thằng widget cha có type là MyInheritedWidget và trả về MyInheritedWidget đó.

class MyInheritedWidget extends InheritedWidget {
  ...  
  
  static MyInheritedWidget? of(BuildContext context) {  }
}

Để tìm kiếm chúng ta sử dụng hàm context.dependOnInheritedWidgetOfExactType<MyInheritedWidget>. Nó sẽ giúp ta get được Widget cha gần vị trí context nhất có type là MyInheritedWidget:

static MyInheritedWidget? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<MyInheritedWidget>();
}

Sau tất cả thì chúng ta cũng đã tạo xong một custom InheritedWidget:

class MyInheritedWidget extends InheritedWidget {
  final int counter;
  final bool isLoading;
  final Widget child;
  
  MyInheritedWidget({
    required this.isLoading,
    required this.counter,
    required this.child,
  }) : super(child: child);  
  
  
  bool updateShouldNotify(MyInheritedWidget oldWidget) {
    return false;
  }
  
  static MyInheritedWidget? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<MyInheritedWidget>();
  }
}

Bây giờ, chúng ta sẽ cần đặt widget MyInheritedWidget ở vị trí cha của widget CounterWidgetMyCenterWidget. Khi đó ta được quyền xóa luôn constructor và data của kẻ trung gian MyCenterWidget. Thậm chí xoá luôn constructor của CounterWidget, chúng ta không cần thiết phải nhận data qua constructor nữa mà sẽ nhận data bằng hàm MyInheritedWidget.of(context) 😄

...
class MyHomePageState extends State<MyHomePage> {
  ...  
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: MyInheritedWidget( // NEW
        isLoading: _isLoading,
        counter: _counter,
        child: MyCenterWidget(),
      ),
      ...
    );
  }
  ...
}
class CounterWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final myInheritedWidget = MyInheritedWidget.of(context); // NEW
    
    if (myInheritedWidget == null) {
      return Text('MyInheritedWidget was not found');
    }
    
    return myInheritedWidget.isLoading
        ? CircularProgressIndicator()
        : Text('${myInheritedWidget.counter}');
  }
}
...

Full source code: https://dartpad.dev/?id=e1d67137e8815a7b877b7aa119887a89

Thử run app lên lại xem. It works like a charm 😄

Đoạn code trên, mình nợ các bạn một lời giải thích về hàm updateShouldNotify đúng ko nào. Nhưng trước khi mình giải thích hàm này, cho mình xin thú tội với các bạn.

3. Lời thú tội ngọt ngào

Những gì chúng ta vừa code ở trên chỉ giải quyết được một vấn đề đầu tiên trong 2 vấn đề đã nêu ra ở đầu bài. Bạn thử đặt lệnh print vào hàm build của MyCenterWidgetCounterWidget sẽ thấy nó được gọi lại mỗi khi ta click button.

Button clicked!. Call setState method
rebuild MyHomePage
rebuild MyCenterWidget
rebuild CounterWidget

Bởi vì khi bạn click button, hàm setState sẽ khiến cho hàm build của MyHomePage sẽ được gọi và nó khiến cho cả MyCenterWidgetCounterWidget bị khởi tạo mới. Oh no, như vậy là lãng phí, chúng ta ko muốn MyCenterWidget bị khởi tạo lại.

Thay cho lời xin lỗi, mình sẽ chỉ ra cách fix bằng cách:

Trong MyHomePage tạo ra 1 property là Widget child và mình sẽ truyền MyCenterWidget vào paramenter child này:

void main() {
  runApp(
    MaterialApp(
      home: MyHomePage(
        isLoading: false,
        counter: 0,
        child: MyCenterWidget(), // code thêm dòng này
      ),
    ),
  );
}

class MyHomePage extends StatefulWidget {
  final bool isLoading;
  final int counter;
  final Widget child;  // code thêm dòng này
  
  const MyHomePage({
    required this.isLoading,
    required this.counter,
    required this.child,  // code thêm dòng này
  });
...
}

Vì chúng ta đã tạo ra MyCenterWidget và truyền nó vào MyHomePage nên child của MyInheritedWidget không cần phải tạo mới MyCenterWidget nữa mà chúng ta sẽ sử dụng chính instance được truyền vào MyHomePage thông qua biến widget

class MyHomePageState extends State<MyHomePage> {
...
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: MyInheritedWidget(
        isLoading: _isLoading,
        counter: _counter,
        child: widget.child,          // code thêm dòng này
      ),
      ...
    );
  }
...
}

Full source code: https://dartpad.dev/?id=0a54da9b69a47e341a16873a62fc6bcb

Bây giờ thử chạy và click button lại xem:

Tuyệt vời, hàm build của cả MyCenterWidgetCounterWidget đều đã không được gọi lại. Khoan đã, có gì đó sai sai. Chúng ta chỉ muốn thằng MyCenterWidget không bị rebuild thôi mà. Nếu thằng CounterWidget cũng không được rebuild thì có nghĩa là UI của CounterWidget cũng sẽ không được update??? Sinh ra bug khủng rồi =)). Á cái thằng này, mài bảo ta rằng sẽ chuộc lỗi bằng cách chỉ ta cách fix, ai dè fix bug nhỏ sinh ra bug khủng lun hả. Lôi đầu nó ra chém!

Khoooang, tại hạ xin khai sự thật ạ: "Bệ hạ nghĩ kĩ xem, Flutter đâu có ngu như vậy, nó cung cấp hàm updateShouldNotify để chúng ta sử dụng MyInheritedWidget điều khiển child của nó". Đây chính là hàm mà thần nợ bệ hạ 1 lời giải thích từ đầu, là hàm mà khi chúng ta kế thừa InheritedWidget nó sẽ bắt override ấy. Trong đống child của MyInheritedWidget, chúng ta muốn thằng nào update, thằng đó sẽ update, chúng ta muốn thằng nào ko được update, nó sẽ ko được update: "Phụ sử tử build, tử bất build bắt bug". Thế có toẹt vời không bệ hạ =))

4. Giải thích hàm updateShouldNotify

Hàm updateShouldNotify được gọi ngay sau khi InheritedWidget bị rebuild. Nếu hàm updateShouldNotify return true thì một khi InheritedWidget rebuild, nó cũng bắt các widget con đang phụ thuộc vào nó phải rebuild. Ngược lại, nếu hàm updateShouldNotify return false thì nó sẽ không rebuild mấy thằng con phụ thuộc nó. Như thế nào được gọi là phụ thuộc?. Các widget con của MyInheritedWidget, nếu sử dụng hàm MyInheritedWidget.of(context) thì ta sẽ nói Widget đó sẽ phụ thuộc vào MyInheritedWidget.

Ah, ta hiểu rồi. Bởi vì hàm updateShouldNotify của chúng ta đang trả về false nên CounterWidget sẽ không thể gọi lại hàm build. Bây giờ chỉ cần đổi lại trả về true thì vấn đề sẽ được giải quyết. Nhưng để giảm bớt những lần rebuild CounterWidget không cần thiết, ta nên check xem data (isLoading and counter) của InheritedWidget có thay đổi hay ko bằng cách:


bool updateShouldNotify(MyInheritedWidget oldWidget) {
   return isLoading != oldWidget.isLoading || counter != oldWidget.counter;
}

Full source code: https://dartpad.dev/?id=82db2fd77528fa02b3bc7a4799ba1ff4

Run app lên và kiểm chứng thôi nào. Khi click vào button, ta nhận được log:

Button clicked!. Call setState method
rebuild MyHomePage
rebuild CounterWidget

Tuyệt vời, hàm build của MyCenterWidget không được gọi lại còn hàm build của CounterWidget đã được gọi lại. Như vậy chúng ta đã giải quyết xong vấn đề thứ 2 được nêu ra từ đầu bài viết, đó là chỉ rebuild những widget cần được rebuild.

Kết luận

Tại sao cần phải biết InheritedWidget và cách nó hoạt động. Vì đây là nền tảng, là gốc gác, là một phương pháp quản lý state low-level. Nhờ đó mà chúng ta có thể học lên các phương pháp quản lý state level cao hơn như provider, bloc

Click follow để nhận thông báo khi có bài viết mới nhé 500 anh em cây khế 😄

Đọc tiếp phần 5: Cô nàng Flutter hoạt động như thế nào?


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí