เนื้อหาในบทนี้ เป็นคลาสเฉพาะที่มากับ 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.'),
);
}
}
เมธอดหลักที่ทำหน้าที่ในการกำหนดส่วนแสดงผลหรือ UI คือเมธอด build()
แน่นอน เวลาเราเขียนแอพจริงๆ โครงสร้างมันจะเป็นการเอา Widget มาซ้อนกันหลายๆๆๆ ชั้นมากๆ เช่น
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 ออกเป็นชิ้นๆ ด้วย
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
มีวิธีการสร้างแบบนี้
FutureBuilder(
future: _future,
builder: (BuildContext context, AsyncSnapshot snapshot) {
return ...
},
)
StreamBuilder(
stream: _stream,
builder: (BuildContext context, AsyncSnapshot snapshot) {
return ...
},
)
ส่วนวิธีการใช้งานนั้นง่ายมาก คือวิดเจ็ตตัวไหนที่สามารถเรนเดอร์ใหม่ได้เฉพาะส่วน ให้เอา 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}');
},
);
}
}
ในกรณีนี้วิดเจ็ตทั้งหมดจะมีการเรนเดอร์แค่ครั้งแรกครั้งเดียว หลังจากนั้นถ้ามีการกดปุ่ม วิดเจ็ตตัวอื่นที่ไม่เกี่ยวข้องด้วย จะไม่มีการเรนเดอร์ใหม่ เรนเดอร์เฉพาะตัวของ 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 ส่งมาให้ตัว เจ้าตัวนี้เป็นเหมือนกับตัวที่บอกว่าตอนนี้ข้อมูลของเรามีสถานะเป็นยังไงบ้างแล้ว
// สถานะของ 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
: เมื่อสั่งให้ streamclose
(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 ก็ขอจบลงแค่ตอนนี้ก่อนล่ะ