DEV Community

Ta for Centrillion Technology

Posted on • Updated on • Originally published at centrilliontech.co.th

Async in Dart (5) รู้จักกับ FutureBuilder/StreamBuilder, ตัวช่วยสร้าง Async-Widget ใน Futter

เนื้อหาในบทนี้ เป็นคลาสเฉพาะที่มากับ 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 ของมันเสมอ)

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.'),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

Widget build(BuildContext context) {
  return Scaffold(
    body: Center(
      child: Column(
        children: [
          Container(
            child: Row(
              children: [
                Text('This is my App.'),
              ]
            ),
          ),          
        ],
      ),
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

ทีนี้ ปัญหามันก็อยู่ตรงนี้แหละ เพรายิ่งโครงสร้างซ้อนกันหลายชั้นมากๆ ถ้ามีการเปลี่ยนแปลงข้อมูล ก็ต้อง render หน้านี้ใหม่ (เรียกคำสั่ง build() ใหม่) แม้ว่าเราจะเปลี่ยนแปลงข้อมูลแค่ไม่กี่จุดเล็กๆ ก็ตาม

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

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

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

เมื่อเราเปิดหน้าแอพขึ้นมาครั้งแรก เราจะพบว่า build() เริ่มทำงาน โดยมันจะเรนเดอร์ Widget ทุกชิ้นขึ้นมาก่อน

render - Scaffold
render - Column Widget
render - Increment Button Widget
render - Increment Button Text Widget
render - Counter Widget
Enter fullscreen mode Exit fullscreen mode

จากนั้น เมื่อเรากด increment จะทำให้เมธอด _incrementCounter() ทำงาน ไปทำการ setState() เพื่อเปลี่ยนค่า _counter

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

render - Scaffold
render - Column Widget
render - Increment Button Widget
render - Increment Button Text Widget
render - Counter Widget
Enter fullscreen mode Exit fullscreen mode

แต่จริงๆ แล้วส่วนที่มีการเปลี่ยนแปลงจริงๆ มีแค่วิดเจ็ต Text('count is $_counter') เท่านั้น

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

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

FutureBuilder / StreamBuilder

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

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

StreamBuilder(
    stream: _stream,
    builder: (BuildContext context, AsyncSnapshot snapshot) {
        return ...
    },
)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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}');
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

ในกรณีนี้วิดเจ็ตทั้งหมดจะมีการเรนเดอร์แค่ครั้งแรกครั้งเดียว หลังจากนั้นถ้ามีการกดปุ่ม วิดเจ็ตตัวอื่นที่ไม่เกี่ยวข้องด้วย จะไม่มีการเรนเดอร์ใหม่ เรนเดอร์เฉพาะตัวของ 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
Enter fullscreen mode Exit fullscreen mode

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

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

AsyncSnapshot

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

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

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

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

ConnectionState

สำหรับ Future

  • waiting: ขณะกำลังรอข้อมูล
  • done: เมื่อได้รับข้อมูลมาแล้ว

สำหรับ Stream

  • waiting: ขณะกำลังรอข้อมูล
  • active: เมื่อได้รับข้อมูลมาแล้ว แต่ stream ยังไม่ close
  • done: เมื่อสั่งให้ stream close
(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}');
  }
}
Enter fullscreen mode Exit fullscreen mode

ทิ้งท้าย...

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

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

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

Discussion (0)