Flutter : สรุปเรื่อง BuildContext , Widget , State , Key ใน Flutter
สวัสดีครับ บล็อกนี้ผมจะสรุปเรื่องพื้นฐานเกี่ยวกับ BuildContext , Widget , State , Key ใน Flutter เพราะว่าเรื่องพวกนี้เป็นเรื่องพื้นฐานมากใน Flutter อีกทั้งผมไปเจอบทความนึงที่เขียนดีมากๆเลยอยากแปล
และลองเขียนสรุปตามความเข้าใจครับ
ทุกอย่างเป็นเป็น Widget
ถ้าเราต้องการสร้างอะไรก็ตามที่มี layout ให้ผู้ใช้ เราต้องใช้สิ่งที่เรียกว่า Widget
ใน Flutter แทบทุกอย่างจัดว่าเป็น Widget หากเข้าไปดูคลาสต่างๆใน Flutter เช่น Text , Scaffold , Center , ล้วน extends มาจาก Widget
และ widget แต่ละตัวสามารถมี parent และ child และเมื่อนำ Widget มาต่อกันเป็น tree จะเรียกว่า Widget Tree
เช่น โปรเจคเริ่มต้นของ Flutter
import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Flutter Demo Home Page'), ); } } class MyHomePage extends StatefulWidget { MyHomePage({Key key, this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.display1, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: Icon(Icons.add), ), // This trailing comma makes auto-formatting nicer for build methods. ); } }
ที่ build() จะถูกสร้างเป็น Widget Tree ประมาณนี้
BuildContext
สังเกตที่ method build จะมี argument 1 ตัว ชื่อ BuildContext
BuildContext คืออะไร?
@override Widget build(BuildContext context) { ... }
BuildContext คือ object ที่เก็บ reference ของ Widget โดย 1 Widget จะมีแค่ 1 BuildContext เท่านั้น ดังนั้น BuildContext จึงเป็นตัวอธิบายความสัมพันธ์ของ Widget แต่ละตัวใน Widget Tree
ถ้า Widget หนึ่งเป็น child ของอีก Widget หนึ่ง BuildContext ของมันก็จะเป็นเชื่อมกับอีก BuildContext ในรูปแบบของ child ด้วย นี่จึงเป็นที่มาของ Widget tree ว่ามันเกิดขึ้นได้อย่างไร BuildContext เลยมีความสามารถของ pointer อยู่ในตัวมัน
Ancestor Widget
ใน Flutter เรียก Parent ว่า Ancestor Widget
เราสามารถใช้ BuildContext ให้วิ่งขึ้นไปหา parent ที่ต้องการที่ใกล้ที่สุดบน Tree ได้
เช่น หากอยู่ที่ Text ต้องการเรียกใช้ Scaffold สามารถใช้คำสั่ง ancestorWidgetOfExactType
วิธีคือ เรียกผ่าน BuildContext
context.ancestorWidgetOfExactType(Scaffold)
แต่ทว่ากลับไม่สามารถใช้ context วิ่งหา child ที่ต้องการได้ เดี๋ยวจะอธิบายต่อในหัวข้อถัดไป
ประเภทของ Widget
Widget แบ่งเป็น 2 ประเภท คือ Stateless Widget กับ Stateful Widget
ก่อนอื่นเข้าใจคำว่า State ก่อน State คือ สถานะของ object หนึ่งๆ หรือก็คือค่าของตัวแปรใน object ในเวลานึงๆ
Stateless Widget
มันคือ Widget ที่รับค่ามา แล้ว build layout แสดงผลอย่างเดียว ไม่มีเปลี่ยนค่า ไม่มีการ rebuild ด้วยตัวมันเอง เลยไม่จำเป็นต้องมี State
เช่น Text , Row, Column พวกนี้รับค่ามา ก็แค่แสดงผลเท่านั้น ไม่มี interaction ใดๆ
โครงสร้างก็จะง่ายๆ คือ extends StatelessWidget
แล้วก็ override method build() ซึ่งจะ return Widget กลับไป
class HelloWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Container(); } }
Stateful Widget
Stateful Widget ก็คือ StatelessWidget ที่อัพเกรดนั่นเอง มันสามารถ build layout แล้วยัง rebuild ได้ เมื่อมีค่าข้อมูลเปลี่ยนแปลง
เช่น TextField , CheckBox ดังนั้น Stateful Widget ก็คือ Widget ที่มี interaction เิกดขึ้น เช่น checkbox กดแล้วมีติ๊กถูก
โครงสร้างจะมีมากกว่า StatelessWidget นิดหน่อย
โดย Stateful Widget จะมี class State เฉพาะของตัวเองเพิ่มขึ้นมา โดย method createState จะทำการสร้าง object state ของมัน
method ที่ใช้บ่อยคือ initState ที่เอาไว้ กำหนดค่าเริ่มต้น
class HelloWidget extends StatefulWidget { @override _HelloWidgetState createState() => _HelloWidgetState(); } class _HelloWidgetState extends State<HelloWidget> { @override void initState() { // init something. super.initState(); } @override Widget build(BuildContext context) { return Container(); } }
การอ้างถึง ค่าตัวแปรจาก State ไปยังในคลาส StatefulWidget จะใช้ ตัวแปรที่ชื่อว่า widget
เช่น ผมรับค่า title เข้ามาใน StatefulWidget ใน State จะเรียกใช้ ให้ใช้ widget.title
class HelloWidget extends StatefulWidget { final String title; HelloWidget(this.title); @override _HelloWidgetState createState() => _HelloWidgetState(); } class _HelloWidgetState extends State<HelloWidget> { @override Widget build(BuildContext context) { return Text(widget.title); } }
และเมื่อต้องการอัพเดท widget ใช้คำสั่ง setState สิ่งที่มันทำคือ จะกำหนดค่าให้ตัวแปรใน state แล้วก็การ rebuild ใหม่เพื่ออัพเดท Widget
setState((){ title = "New title"; });
ความสัมพันธ์ระหว่าง State กับ BuildContext
ใน StatefulWidget คลาส State มีความสัมพันธ์กับ BuildContext โดยมันจะอยู่ด้วยกันแบบถาวร
แม้ว่า Widget จะถูกย้ายไปยังที่อื่นๆใน Widget Tree ทำให้ BuildContext อาจจะเปลี่ยนไป แต่จะยังเชื่อมกับ State อันเดิม
ทำให้ State จะไม่สามารถเข้าถึงด้วยการใช้ BuildContext ตัวอื่น ต้องใช้ BuildContext ที่มันสัมพันธ์กันเท่านั้น
Key ของ Widget
ใน Flutter แต่ละ Widget นั่นมี id เพื่อที่ระบุตัวของ Widget โดยใน Flutter เรียกว่า Key ซึ่งโดยปกติ key นี้ จะถูก Flutter สร้างขึ้นให้อัตโนมัติ
และเราสามารถที่จะ สร้าง Key ให้กับ Widget เองได้ เพราะที่จะระบุตัว Widget ที่เราต้องการ เช่นการ เข้าถึง State
ขอแนะนำให้ดูวิดีโอนี้ โดยวิดีโอจะอธิบายว่าเมื่อไหร่ถึงควรใช้ Key และใช้อย่างไร ใช้ Key ประเภทไหน
และเดี๋ยวผมจะสรุปให้อ่านข้างล่างอีกทีครับ
Key มีหลัก 2 แบบ คือ Local Key , Global Key
Local Key จะใช้สำหรับแยกแยะ Widget ต่างๆใน parent
ถ้า ตัวเลข หรือ string ก็ทำให้ unique ได้ ก็ใช้ ValueKey()
ถ้า ค่าทั่วไปไม่พอ ก็ใช้เป็น object
ถ้า object ยังมีโอกาสซ้ำกันอีก ก็ใช้ UniqueKey() ซึ่ง Flutter จะสร้าง key ให้แบบไม่มีทางซ้ำกัน
ส่วน Global Key จะใช้สำหรับทำให้เข้าถึง state ของ widget ได้จากภายนอก
ยกตัวอย่างการใช้ LocalKey เช่น กรณีที่ใช้ key เช่น มีลิสที่มี checkbox แล้วสามารถสลับลำดับกันได้ ปัญหาคือ update state ที่อยู่ใน parent เดียวกัน และเป็น type เดียวกัน widget จะไม่ถูกอัพเดท
เพราะว่า Flutter จะเช็คจากแค่ type ของ Widget เท่านั้น พอเราย้าย Widget ใน tree ปรากฏว่า type ทั้งคู่เป็นอันเดียวกัน Flutter เลยเข้าใจว่าไม่จำเป็นต้องอัพเดท
และเมื่อเรากำหนด key ให้กับ Widget
Flutter ก็จะเช็คจาก key แทน และเมื่อเปลี่ยนค่าใน State ทำให้ key ไม่ตรงกับ State ของ Widget
ทำให้เกิดการอัพเดท Widget
วิธีใช้ Key คือเพิ่มใน constructor อันที่ชื่อว่า key
MyWidget(key : UniqueKey())
ส่วนการใช้ GlobalKey ก็เพียงประกาศตัวแปรไว้ก่อน แล้วค่อยมาใส่ key ที่ Widget
GlobalKey key = new GlobalKey(); ... @override Widget build(BuildContext context){ return new MyWidget( key: key ); }
จากนั้นก็สามารถเข้าถึง state ได้ ผ่านตัวแปร GlobalKey
Data data = key.currentState.data;
การเข้าถึง State จากภายนอก
มีกรณีให้พิจารณา 3 กรณี คือ
1. parent ต้องการเข้าถึง state ของ child
2. child ต้องการเข้าถึง state ของ parent
3. A กับ B ไม่ได้เป็น parent-child แต่อยู่ใน tree เดียวกัน ต้องการเข้าถึง state กันและกัน
มาดูวิธีการ ในแต่ละกรณีครับ
Parent เข้าถึง state ของ Child
เมื่อ Parent Widget (ใน Flutter เรียกว่า Ancestor) ต้องการเข้าถึง State ของ child
อันนี้เราสามารถใช้ GlobalKey ได้เลย ซึ่งได้อธิบายไปด้านบนแล้ว
Child เข้าถึง State ของ Parent
จากตอนต้นของบทความนี้ เรารู้ว่า BuildContext สามารถวิ่งขึ้นไปบน tree เพื่อหา parent หรือ ancestor ที่ต้องการได้จาก type
ดังนั้นถ้าเราสามารถวิ่งไปหา Widget ของ parent ได้ เราก็สามารถ เข้าถึง state มันได้
ก่อนอื่นเพิ่มให้ Parent Widget เก็บค่าตัวแปร ตอนสร้าง state ไว้
class MyParentWidget extends StatefulWidget { MyParentWidgetState myState; // add here. @override MyParentWidgetState createState(){ myState = new MyParentWidgetState (); // add here. return myState; } }
เพิ่ม method สำหรับ get ค่าของ state ใน Parent Widget
class MyParentWidgetState extends State<MyParentWidget>{ int _data; int getData() => _data; ... }
จากนั้นที่ child widget ก็เรียกคำสั่ง context.ancestorWidgetOfExactType(T) สิ่งที่มัน return มาคือ parent widget ที่ใกล้ที่สุด
แล้วเราก็เอาค่าของ state ของมันมาใช้งานได้
class MyChildWidget extends StatelessWidget { @override Widget build(BuildContext context){ final MyParentWidget widget = context.ancestorWidgetOfExactType(MyParentWidget); // get parent final MyParentWidgetState state = widget?.myState; return Text( state == null ? "0" : state.getData().toString(), ); } }
แต่ทว่าข้อเสียของวิธีการนี้คือ MyChildWidget ไม่รู้ว่า เมื่อไหร่ที่ตัวเองต้อง rebuild
และสามารถเข้าถึงแค่ Widget ที่เป็น Parent เท่านั้น ทำให้มันไม่สะดวก
น่าจะมีวิธีที่ครอบคลุมทั้ง 3 กรณีเลยสิ นี่จึงเป็นีที่มาของ InheritedWidget
InheritedWidget
InheritedWidget เป็น Widget พิเศษ ความสามารถของมันคือ ตัวมันเก็บข้อมูลกลางเอาไว้ และเมื่อนำ sub-tree มาเป็น child ของมัน จะทำให้ทุก widget ใน sub-tree สามารถเข้าถึงข้อมูลใน InheritedWidget ได้
มันเลยเป็นเหมือนตัวกลางของ Widget ใน tree ทำให้ Widget ใดๆใน sub-tree สามารถติดต่อกันได้ และเมื่อมีการอัพเดทข้อมูลใน State ของ InheritedWidget เราไม่จำเป็นต้อง rebuild ทุก Widget ใน tree แต่จะ rebuild แค่เฉพาะ Widget ที่ต้องอัพเดทข้อมูลเท่านั้น เยี่ยมเลยใช่ไหมล่ะ
เช่นมี Widget 2 อัน อยู่คนละกิ่งของ tree ไม่ได้เป็น parent หรือ child กันและกัน แต่อยากให้กดปุ่มที่ Widget A แล้วอัพเดทข้อมูลที่ Widget B โดยที่ไม่จำเป็นต้อง rebuild ใหม่ทั้ง Widget Tree
วิธีการสร้าง class ขึ้นมาอันนึง กำหนดให้ extends InheritedWidget
สิ่งที่ class นี้ทำ ถือครอง Widget กับ State และส่งไปให้ super class
class MyInheritedWidget extends InheritedWidget { final Widget child; final MyInheritedWidgetDataState state; MyInheritedWidget({Key key, @required this.child, @required this.state}) : super(key: key, child: child); @override bool updateShouldNotify(MyInheritedWidget oldWidget) { return true; } }
โดยผมจะขอเรียก StatefulWidget class ที่ถูก InheritedWidget ถือครองอยู่ว่า InheritedWidgetData
โดย class InheritedWidgetData ก็เหมือน StatefulWidget ทั่วไป
เพียงแต่ว่ามี method ที่ เรียก context.inheritFromWidgetOfExactType เพื่อให้มันไปค้นหา class InheritedWidget ใน tree
ซึ่งในที่นี้คือ MyInheritedWidget ของเรา พอเจอแล้วก็ return state ที่มันถือครองอยู่ออกมา
class MyInheritedWidgetData extends StatefulWidget { final Widget child; MyInheritedWidgetData({@required this.child}); @override MyInheritedWidgetDataState createState() => MyInheritedWidgetDataState(); static MyInheritedWidgetDataState of(BuildContext context) { return (context.inheritFromWidgetOfExactType(MyInheritedWidget) as MyInheritedWidget) .state; } } class MyInheritedWidgetDataState extends State<MyInheritedWidgetData> { String data; @override void initState() { data = "First commit."; super.initState(); } setData(String newData) { setState(() { data = newData; }); } @override Widget build(BuildContext context) { return MyInheritedWidget(child: widget.child, state: this); } }
จากนั้นก็ใส่ MyInheritedWidgetData ที่ root ของ tree
class _HomePageState extends State<HomePage> { @override Widget build(BuildContext context) { return MyInheritedWidgetData( child: Scaffold( appBar: AppBar( title: Text('My App'), ), body: Column( children: <Widget>[ WidgetA(), Container( child: Column( children: <Widget>[ WidgetB(), WidgetC(), ], ...
สิ่งที่ผมจะลองทำคือ ให้ widget A มีปุ่มกด กดปุ่มแล้วให้ข้อความใน widget B เปลี่ยนแปลง โดยที่ widget C ไม่ถูก rebuild ใหม่
โดย child ไหนที่เราอยากให้เชื่อมกับ InheritedWidget ก็แค่เพิ่มคำสั่งนี้เพื่อ get state
MyInheritedWidgetDataState widget = MyInheritedWidgetData.of(context); String data = widget.state.data;
ดังนั้นต้องเพิ่มที่ widget A เพราะ ต้องอัพเดท state ตัวที่ InheritedWidget ถือครองอยู่
และต้องเพิ่มที่ widget B ด้วย เพราะ ต้องเอา state ใน InheritedWidget มาแสดง
ส่วน widget C ไม่เกี่ยว ไม่ต้องทำอะไร
class WidgetA extends StatelessWidget { @override Widget build(BuildContext context) { print("build Widget A"); final MyInheritedWidgetDataState state = MyInheritedWidgetData.of(context); return Container( child: FlatButton( child: Text('Commit'),color: Colors.orange,textColor: Colors.white, onPressed: () { state.setData("Second commit"); }, ), ); } } class WidgetB extends StatelessWidget { @override Widget build(BuildContext context) { print("build Widget B"); final MyInheritedWidgetDataState widget = MyInheritedWidgetData.of(context); return Text('${widget.data}'); } } class WidgetC extends StatelessWidget { @override Widget build(BuildContext context) { print("build Widget C"); return Text('I am robot.'); } }
ลองรันจะได้ดังนี้
จากนั้นสังเกตที่ console log เมื่อกดปุ่ม ตัวที่จะถูก rebuild จะมีแค่ widget A และ B เท่านั้น และไม่ได้ใช้ state ภายนอกเลย แต่เป็นการใช้ state ตัวกลางของ InheritedWidget
เมื่อเทียบกับ การทำงานแบบเดิมที่ เรา setState จากภายนอกแบบไม่ผ่าน InheritedWidget
ผมเพิ่มปุ่มสีแดงมาปุ่มนึง พอกดแล้วจะเรียก setState() เพื่ออัพเดทข้อมูล
Column( children: <Widget>[ FlatButton( child: Text('rebuild all'), color: Colors.red, textColor: Colors.white, onPressed: () { setState(() {}); }, ), WidgetA(), Container( child: Column( children: <Widget>[ WidgetB(), WidgetC(), ], ...
ผลคือ มันจะ rebuild ใหม่ทุก Widget ใน tree
ป้องกันการเข้าถึง Inherited Widget ขณะ rebuild
เนื่องจาก method เป็น static ทำให้มันมีโอกาสที่จะเกิดการถูกแทรกขณะมี Widget นึงกำลัง rebuild อยู่
วิธีการป้องกัน คือ rebuild เมื่อจำเป็นเท่านั้น ก็แค่เพิ่ม parameter boolean มา ตัวนึง ว่าต้องการ rebuild หรือแค่เข้าถึง state เฉยๆ
ถ้าต้องการ rebuild ก็เรียก inheritFromWidgetOfExactType แต่ถ้าไม่ต้องการก็เรียก ancestorWidgetOfExactType แทน
เพราะการเรียก ancestorWidgetOfExactType จะไม่มีการ rebuild เกิดขึ้น จะวิ่งไปค้นหา parent เท่านั้น
static MyInheritedWidgetDataState of([BuildContext context, bool rebuild = true]){ return (rebuild ? context.inheritFromWidgetOfExactType(MyInheritedWidget) as MyInheritedWidget : context.ancestorWidgetOfExactType(MyInheritedWidget) as MyInheritedWidget).state; }
ดังนั้นจากตัวอย่างด้านบน Widget A ไม่จำเป็นต้องถูก rebuild ใหม่ เพราะมันแค่ ดึงข้อมูลจาก state มาอัพเดท ไม่ได้เอามาแสดง ดังนั้น
เราก็เพิ่มเงื่อนไข ว่า rebuild = false ได้ ในตอนเรียก MyInheritedWidgetData
class WidgetA extends StatelessWidget { @override Widget build(BuildContext context) { print("build Widget A"); final MyInheritedWidgetDataState state = MyInheritedWidgetData.of(context, false); return Container( ...
ลองรัน ทีนี้ widget ที่ถูก rebuild ใหม่ มีแค่ Widget B แล้ว
ตัวอย่างอื่นๆ
ถ้าหากเราเขียน Flutter มาบ้างจะเห็นการใช้งาน ancestor , InheritedWidget อยู่เป็นประจำ
เช่นการแสดง SnackBar จะต้องใช้คำสั่งนี้
Scaffold.of(context).showSnackBar(...)
ซึ่งจริงๆแล้วมันก็คือ การใช้ ancestorWidgetOfExactType นั่นเอง โดยให้ buildContext วิ่งไปหา ancestor ที่สุดที่สุดบน tree แล้วเรียกคำสั่ง showSnackBarv
@override Widget build(BuildContext context) { // here, Scaffold.of(context) returns null return Scaffold( appBar: AppBar(title: Text('Demo')), body: Builder( builder: (BuildContext context) { return FlatButton( child: Text('BUTTON'), onPressed: () { // here, Scaffold.of(context) returns the locally created Scaffold Scaffold.of(context).showSnackBar(SnackBar( content: Text('Hello.') )); } ); } ) ); }
หรืออีกตัวอย่าง เวลาที่เราต้องการเปลี่ยนหน้าจอ เราจะใช้คำสั่งคือ Navigator.of(context)
ซึ่งมันก็คือ rootAncestorStateOfType และ ancestorWidgetOfExactType อีกเช่นกัน โดยคำสั่งนี้มันจะวิ่งไปหา Navigator ซึ่งปกติเมื่อเราสร้าง MaterialWidget ก็จะมี Navigator อยู่ที่นั่น
สรุป
บทความนี้เป็นสรุปรวมๆ ตามความเข้าใจของผมเกี่ยวกับเรื่องพื้นฐาน เช่น State , InheritedWidget ครับ
Widget เอามาสร้างเป็น Tree โดยมี BuildContext เป็นตัว reference แต่ละ widget ใน tree เราสามารถใช้ BuildContext วิ่งขึ้นไปบน tree เพื่อหา parent ได้
Widget มี 2 แบบคือ StatefulWidget , StatelessWidget โดย StatelessWidget มีไว้แค่แสดงผลเท่านั้น ไม่สามารถ rebuild ด้วยตัวเอง แต่ StatefulWidget ทั้งแสดงผลและมี interaction ด้วย
แต่ละ Widget สามารถระบุ Key ได้ เพื่อให้มันไม่ซ้ำกัน Key มี 2 ประเภทคือ LocalKey , GlobalKey
InheritedWidget ที่เป็นคลาสพิเสษที่ทำให้ Widget ใน sub-tree ติดต่อกันได้ เป็นเหมือนตัวกลางที่เก็บ state และช่วยให้ rebuild widget ที่จำเป็นเท่านั้น ไม่ต้อง rebuild ใหม่ทั้ง Widget tree
จบเกม ขอบคุณที่อ่านถึงตรงนี้ครับ (:
หากมีส่วนไหนผิด สามารถติแก้ไข หรือแนะนำได้เลยนะครับ
โค้ดตัวอย่างเรื่อง InheritedWidget ด้านบน
https://gist.github.com/benznest/d5debe790c5e65b7467f80debb4b8373
Credit
https://medium.com/flutter-community/widget-state-buildcontext-inheritedwidget-898d671b7956