Async in Dart (5) รู้จักกับ FutureBuilder/StreamBuilder, ตัวช่วยสร้าง Async-Widget ใน Futter
Tags: beginners, programming, dart, flutter
Published: 2020-06-09 17:17:03

---
canonical_url: https://www.centrilliontech.co.th/blog/2819/async-in-dart-5-futurebuilder-streambuilder/
---

> เนื้อหาในบทนี้ เป็นคลาสเฉพาะที่มากับ Flutter Framework เท่านั้น .. ถ้าเขียน Dart ธรรมดาจะไม่มีให้ใช้นะ!!

เอาจริงๆ 99% ของคนที่ศึกษาภาษา Dart ก็เพื่อเอาไปเขียนแอพ (หรือเว็บ/เด็กส์ท็อป) แบบ cross-platform ด้วยเฟรมเวิร์ก **Flutter** นั่นแหละ

*ตามความคิดของเรา จริงๆ Flutter น่าจะเลือกภาษา Kotlin มาใช้แทนมากกว่า แต่ก็มีเหตุผลหลายๆ อย่างนั่นแหละที่ทำให้ทำไม่ได้*

สำหรับการเขียน Flutter นั้น ส่วนประกอบในแต่ละส่วนของแอพนั้นจะพูดออกแบบมาเป็น Component เอามาประกอบเข้าด้วยกันเป็นชั้นๆ เรียกว่า **"Widget"** 

มี Widget อยู่ 2 ประเภทหลักๆ คือ 
- `StatelessWidget`: เป็น Widget ที่ไม่สามารถเปลี่ยนแปลงได้หลังจาก render ไปแล้ว 
- `StatefulWidget`: เป็น Widget ที่สามารถ render หน้าตา UI ของแอพใหม่ได้ถ้ามีการเปลี่ยนแปลงข้อมูล

ในบทความนี้เราจะโฟกัสกันที่ `StatefulWidget` เป็นหลักนะ หน้าตาของ StatefulWidget ก็จะเป็นประมาณนี้ (StatefulWidget จะมาพร้อมกับคลาส State ของมันเสมอ)

```dart
class MyHomePage extends StatefulWidget {
  MyHomePage({Key key}) : super(key: key);

  @override
  MyHomePageState createState() => MyHomePageState();
}

class MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Text('This is my App.'),
    );
  }
}
```

เมธอดหลักที่ทำหน้าที่ในการกำหนดส่วนแสดงผลหรือ UI คือเมธอด `build()` 

แน่นอน เวลาเราเขียนแอพจริงๆ โครงสร้างมันจะเป็นการเอา Widget มาซ้อนกันหลายๆๆๆ ชั้นมากๆ เช่น

```dart
Widget build(BuildContext context) {
  return Scaffold(
    body: Center(
      child: Column(
        children: [
          Container(
            child: Row(
              children: [
                Text('This is my App.'),
              ]
            ),
          ),          
        ],
      ),
    ),
  );
}
```
ทีนี้ ปัญหามันก็อยู่ตรงนี้แหละ เพรายิ่งโครงสร้างซ้อนกันหลายชั้นมากๆ ถ้ามีการเปลี่ยนแปลงข้อมูล ก็ต้อง render หน้านี้ใหม่ (เรียกคำสั่ง `build()` ใหม่) แม้ว่าเราจะเปลี่ยนแปลงข้อมูลแค่ไม่กี่จุดเล็กๆ ก็ตาม

ลองมาดูตัวอย่างแอพมาตราฐานคือ Counter กัน (แอพตัวอย่างนี้เป็นแอพที่เวลาเราสั่ง new Flutter project มันจะสร้างให้เป็น default เลย)

แต่เราจะขอตัดมาแค่ส่วนที่จะใช้อธิบายนะ และขอแยก Widget ออกเป็นชิ้นๆ ด้วย

```dart
class MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    print('render - Scaffold');
    return Scaffold(
      body: columnWidget(),
    );
  }

  Widget columnWidget(){
    print('render - Column Widget');
    return Column(
      children: [
        incrementButtonWidget(),
        counterWidget(),
      ],
    );
  }

  Widget incrementButtonWidget(){
    print('render - Increment Button Widget');
    return RaisedButton(
      child: incrementButtonTextWidget(),
      onPressed: _incrementCounter,
    );
  }

  Widget incrementButtonTextWidget(){
    print('render - Increment Button Text Widget');
    return Text('Increment');
  }

  Widget counterWidget(){
    print('render - Counter Widget');
    return Text('count is $_counter');
  }
}
```
เมื่อเราเปิดหน้าแอพขึ้นมาครั้งแรก เราจะพบว่า `build()` เริ่มทำงาน โดยมันจะเรนเดอร์ Widget ทุกชิ้นขึ้นมาก่อน
```
render - Scaffold
render - Column Widget
render - Increment Button Widget
render - Increment Button Text Widget
render - Counter Widget
```
จากนั้น เมื่อเรากด increment จะทำให้เมธอด `_incrementCounter()` ทำงาน ไปทำการ `setState()` เพื่อเปลี่ยนค่า `_counter` 

ผลที่ได้คือเมื่อ state เปลี่ยนไป ทั้งหน้าจะต้องทำการเรนเดอร์ใหม่อีกครั้ง เมธอด build() ก็ต้องเริ่มทำงานใหม่ตั้งแต่ต้นอีกครั้ง ทำให้เราได้ผลแบบนี้ออกมาอีกที

```
render - Scaffold
render - Column Widget
render - Increment Button Widget
render - Increment Button Text Widget
render - Counter Widget
```
แต่จริงๆ แล้วส่วนที่มีการเปลี่ยนแปลงจริงๆ มีแค่วิดเจ็ต `Text('count is $_counter')` เท่านั้น

ทางทีม Flutter ของ Google เลยแนะนำมาว่าการเขียนแอพแบบนี้จะทำให้ประสิทธิภาพไม่ดี ถ้ามีข้อมูลแปลี่ยนเป็นบางตัว เราควรจะเรนเดอร์ใหม่เฉพาะวิดเจ็ตที่จำเป็นน่าจะดีกว่า

นั่นเลยเป็นที่มาของวิดเจ็ตพิเศษที่ชื่อว่า `FutureBuilder` และ `StreamBuilder` 

## FutureBuilder / StreamBuilder

มีวิธีการสร้างแบบนี้

```dart
FutureBuilder(
    future: _future,
    builder: (BuildContext context, AsyncSnapshot snapshot) {
        return ...
    },
)

StreamBuilder(
    stream: _stream,
    builder: (BuildContext context, AsyncSnapshot snapshot) {
        return ...
    },
)
```

ส่วนวิธีการใช้งานนั้นง่ายมาก คือวิดเจ็ตตัวไหนที่สามารถเรนเดอร์ใหม่ได้เฉพาะส่วน ให้เอา FutureBuilder ไม่ก็ StreamBuilder ครบมันลงไป แบบนี้

![](https://4.bp.blogspot.com/-nbD1G_yRK1o/XuCe0B-LaoI/AAAAAAAASb0/UQi4D43_xp8sRg5Axq5TPxoLb2TpfRTPwCNcBGAsYHQ/s1600/Screen%2BShot%2B2020-06-10%2Bat%2B15.50.05.png)

### แล้วเมื่อไหร่จะใช้ Future / Stream

ถ้าข้อมูลของเราเปลี่ยนแปลงได้แค่ครั้งเดียว เราจะใช้ FutureBuilder เช่นต้องการโหลดข้อมูลจาก API แค่ครั้งเดียว

แต่ถ้าข้อมูลของเราเปลี่ยนได้เรื่อยๆ มากกว่า 1 ครั้ง เราจะใช้ StreamBuilder เช่นการกดปุ่มที่กดได้มากกว่า 1 ครั้ง

*แน่นอนว่าเราสามารถใช้ StreamBuilder แทน FutureBuilder ได้แทบจะทุกกรณีเลย*

ทีนี้ลองเอา StreamBuilder มาใช้กับโค้ด Counter ดูบ้าง

ในเคสนี้ เราเปลี่ยน Text ตรงที่แสดงตัวนับให้กลายเป็น StreamBuilder จากนั้นสร้าง StreamController ขึ้นมาหนึ่งตัว ซึ่งจะอัพเดทค่า counter แทนการสั่ง `setState()`

```dart
class MyHomePageState extends State<MyHomePage> {
  
  int _counter = 0;
  StreamController<int> controller = new StreamController<int>();

  @override
  void initState(){
    super.initState();
    controller.add(_counter);
  }

  @override
  void dispose(){
    super.dispose();
    controller.close();
  }

  void _incrementCounter() {
    //แทนที่จะใช้ setState ก็เซ็ตค่าผ่าน StreamController แทน
    controller.add(++_counter);
  }

  @override
  Widget build(BuildContext context) {
    print('render - Scaffold');
    return Scaffold(
      body: columnWidget(),
    );
  }

  Widget columnWidget(){
    print('render - Column Widget');
    return Column(
      children: [
        incrementButtonWidget(),
        counterWidget(),
      ],
    );
  }

  Widget incrementButtonWidget(){
    print('render - Increment Button Widget');
    return RaisedButton(
      child: incrementButtonTextWidget(),
      onPressed: _incrementCounter,
    );
  }

  Widget incrementButtonTextWidget(){
    print('render - Increment Button Text Widget');
    return Text('Increment');
  }

  Widget counterWidget(){
    return StreamBuilder(
      stream: controller.stream,
      builder: (context, snapshot){
        print('render - Counter Widget');
        return Text('count is ${snapshot.data}');
      },
    );
  }
}
```
ในกรณีนี้วิดเจ็ตทั้งหมดจะมีการเรนเดอร์แค่ครั้งแรกครั้งเดียว หลังจากนั้นถ้ามีการกดปุ่ม วิดเจ็ตตัวอื่นที่ไม่เกี่ยวข้องด้วย จะไม่มีการเรนเดอร์ใหม่ เรนเดอร์เฉพาะตัวของ StreamBuilder เท่านั้น

```
render - Scaffold
render - Column Widget
render - Increment Button Widget
render - Increment Button Text Widget
render - Counter Widget
render - Counter Widget
render - Counter Widget
render - Counter Widget
```

จะเห็นว่าวิธีนี้ทำให้ประสิทธิภาพของแอพเพิ่มขึ้นเยอะมาก เพราะไม่จำเป็นต้องเรนเดอร์ทั้งหน้าใหม่ทุกครั้งที่มีข้อมูลเปลี่ยนแปลงเพียงเล็กน้อย

ซึ่งก็แลกมากับการที่เราต้องเขียนโค้ดเยอะขึ้นล่ะนะ (ฮา)

## AsyncSnapshot

ในการใช้ทั้ง FutureBuilder และ StreamBuilder จะมีสิ่งที่เรียกว่า **AsyncSnapshot** ส่งมาให้ตัว เจ้าตัวนี้เป็นเหมือนกับตัวที่บอกว่าตอนนี้ข้อมูลของเรามีสถานะเป็นยังไงบ้างแล้ว

```dart
// สถานะของ future/stream ในตอนนั้น
snapshort.connectionState

// มี error เกิดขึ้นไหม
snapshop.hasError
// error คืออะไร
snapshop.error

// ได้รับ data มาแล้วรึยัง
snapshop.hasData
// data คืออะไร
snapshop.data
```

### ConnectionState

**สำหรับ Future**
- `waiting`: ขณะกำลังรอข้อมูล
- `done`: เมื่อได้รับข้อมูลมาแล้ว
 
**สำหรับ Stream**
- `waiting`: ขณะกำลังรอข้อมูล
- `active`: เมื่อได้รับข้อมูลมาแล้ว แต่ stream ยังไม่ `close`
- `done`: เมื่อสั่งให้ stream `close`
  

```dart
(BuildContext context, AsyncSnapshot snapshot) {

  // เช็กก่อนว่ามี error มั้ย
  if(snapshot.hasError){
    return Text('เกิดข้อผิดพลาดในการโหลดข้อมูล ${snapshot.error}');
  }

  // ถ้าไม่มี ตอนนี้โหลดข้อมูลเป็นยังไงบ้างแล้ว
  switch(snapshort.connectionState){
      // ข้อมูลยังไม่มา กำลังโหลดอยู่
      case ConnectionState.waiting:
          return Text('กำลังโหลดข้อมูลอยู่');
      
      // ข้อมูลมาเรียบร้อยแล้ว แสดงผลได้
      case ConnectionState.done:
      case ConnectionState.active:
          return Text('ข้อมูลคือ ${snapshot.data}');
  }
}
```

## ทิ้งท้าย...

การใช้ FutureBuilder / StreamBuilder ใน Flutter เป็นสิ่งที่ช่วยเพิ่มประสิทธิภาพให้ตัวแอพไม่ต้องเรนเดอร์วิดเจ็ตที่ไม่ได้เปลี่ยนแปลงซ้ำๆ

ซึ่งคอนเซ็ปนี้จะถูกเอาไปใช้ต่อในแพทเทิร์นที่ทาง Google แนะนำมาให้ใช้กับการวางโครงสร้าง UI ใน Flutter ที่ชื่อว่า **BLoC** หรือ **Business Logic Component** ซึ่งเดี๋ยวเราจะเอามาสอนกันต่อในบทความซีรีส์ Flutter ต่อไป

ส่วนบทความซีรีส์ Async in Dart ก็ขอจบลงแค่ตอนนี้ก่อนล่ะ