Flutter: ลองสร้าง Todo App โดยใช้ BLoC Pattern กัน

Suppachai Thanrukprasert
7 min readSep 11, 2019

--

🔔 Updated — สำหรับคนพึ่งมาใหม่สามารถข้ามไปอ่านเนื้อหาได้เลย

31/8/20 — อัพเดท flutter_bloc เป็น ^6.0.2 ใน BlocBuilder เปลี่ยนจาก bloc เป็น cubit

14/7/20 — เปลี่ยนมาใช้ api สำหรับ flutter_bloc ตั้งแต่ 1.0.0 ขึ้นไป และ equatable ตั้งแต่ 0.6.0 ขึ้นไป

หลังจากบล็อกที่แล้ว เราลองทำความเข้าใจ bloc pattern แบบเบื้องต้น กับ increment app เพื่อให้เข้าใจพื้นฐาน และโครงสร้างของสไตล์การเขียนแบบนี้แล้ว หากใครยังไม่ได้อ่าน สามารถลองไปอ่านได้ที่ลิงค์ข้างล่างเลย

ถ้าเริ่มพอจะเข้าใจแล้ว เรามาลองสร้าง Todo App กันต่อ แนวคิดของแอปนี้คือ เราต้องการสร้างแอปที่ใช้จดสิ่งที่ต้องทำ สามารถจดรายละเอียดเพิ่มเติมได้ และหลังจากทำเสร็จแล้ว สามารถติ๊กว่าเสร็จสิ้นได้ ลองดูตัวอย่างข้างล่างได้

ดูเป็นแอปที่ง่ายๆ แต่ใช้พื้นฐานความเข้าใจไม่น้อยเลย ในแอปนี้เราจะยังไม่ลงลึกเรื่องการดีไซน์ เอาเพียงให้ใช้การได้ เพราะเราจะเน้นไปที่ logic ของแอปให้ครบถ้วนก่อน ไว้โอกาสหน้าอาจจะมาลองทำดีไซน์แปลกๆกัน

เรามาอธิบายโครงสร้างแอปแบบคร่าวๆกันก่อน

หมายเหตุ “todo” หมายถึง ข้อมูล todo เพียงอันเดียว ส่วน “todos” จะหมายถึง list/ชุด ข้อมูลของ todo หลายๆอันรวมกัน

จากในตัวอย่างด้านบน ประกอบไปด้วย

โครงสร้างของไฟล์โค๊ด
  • Model ข้อมูล todo แต่ละอันจะประกอบไปด้วย title detail กับ complete และเพิ่มเติมที่มองไม่เห็นแต่สำคัญมากคือ uuid (คำอธิบายจะมีเพิ่มด้านล่าง)
  • Screen แอปเราจะประกอบไปด้วย 2 หน้า 1. หน้าที่แสดงชุดข้อมูลของ todo หรือหน้าหลัก 2. หน้าที่แสดงรายละเอียด/แก้ไข/เพิ่ม todo
  • Widget จะเป็น custom widget ที่นำมาใช้แสดงใน Screen โดยอาจจะนำมาแยกใส่ลง widget เพราะ มีการถูกใช้ซ้ำๆ หรือเพื่อจัดระเบียบให้โค๊ดดูสวยงาม เป็นระเบียบขึ้น
  • Provider แอปต้องมีส่วนของตัวให้บริการ 2 อย่าง คือ ตัวสร้าง uuid และตัวจัดเก็บข้อมูล local storage
  • Bloc โค๊ดที่ทำหน้าที่จัดการ ประสานงาน ของข้อมูลที่ถูกเรียกไปมาในแอป แล้วถูกนำไปใช้แสดงผลในหน้า screen หรือ UI ของเรา

พื้นฐานเพิ่มเติมที่ต้องใช้

นอกเหนือจากการใช้ flutter_bloc แล้ว

  • การสร้าง uuid หรือ Unique Id หรือ รหัสไอดีเฉพาะ ที่ทุกครั้งที่สร้างจะไม่ซ้ำกัน เราจะนำมันไปใช้ระบุให้ todo แต่ละอันของเรา เนื่องจากเรามี todo ในลักษณะเป็นชุดข้อมูล ถ้าเราอยากสร้าง หรือแก้ไข todo ที่ถูกตัว โดยเฉพาะเมื่อเรามี todo หลายอันที่เหมือนกัน เราก็จะไม่สับสน เพราะ todo แต่ละอันมี uuid ของตัวเอง
  • JSON Serialization เราจะอิมพอร์ท json_annotation เข้ามาใช้ช่วยแปลงข้อมูล todo ที่เป็น class อยู่ให้กลายเป็น json เพื่อให้ง่ายต่อการจัดเก็บข้อมูลลงในเครื่อง
  • Path Provider ทำหน้าที่ดึงข้อมูลที่อยู่ ที่สามรถใช้จัดเก็บข้อมูลในเครื่อง โดยเราจะเอาที่อยู่นี้ใช้บันทึกข้อมูล todo ลงไปเป็น text file โดย text นี้ก็จะเป็น json

มาเริ่มส่วนโค๊ดกัน

ต่อไปนี้เราจะแปะโค๊ดเต็มลงมาก่อน แล้วค่อยๆอธิบายทีละส่วนตามมา

แก้ pubspec.yaml กันก่อน

ใน dependencies เราจะเพิ่ม

meta: ">=1.1.0 <2.0.0"equatable: ^1.2.2flutter_bloc: ^6.0.2path_provider: ^1.6.11json_annotation: ^3.0.1

และใน dev_dependencies เราเพิ่ม

build_runner: ^1.10.0json_serializable: ^3.0.1

คำอธิบายแต่ละตัว สามารถไปอ่านได้ในโค๊ดเลย

!! หลังจากนี้ไปจะพบว่าสุดท้าย เราจะยังมี logic บางส่วนอยู่ในโค๊ดส่วน UI ซึ่งจะดูขัดๆ กับสิ่งที่เคยกล่าวไป ในกรณีนี้ เราจะจะแยกไปอยู่กับ bloc เฉพาะข้อมูลหลักๆในแอปที่ถูกเรียกใช้ไปมาระหว่างหน้า แต่ถ้าเป็นการแปลงข้อมูลเล็กน้อย หรืออาจจะเป็นข้อมูลที่ใช้เฉพาะหน้านั้นๆ เราก็อาจจะเขียนลงในส่วน UI สำหรับกรณีเหล่านี้ก็ลองบาลานซ์ความเหมาะสมกันดู ว่าควรจะจัดการอะไรยังไง โดยเฉพาะอย่างยิ่ง ไม่ทำให้โค๊ดเกิดความซับซ้อนเกินความจำเป็น

ก่อนอื่นเลย มาสร้างโฟลเดอร์ไว้เพื่อรองรับโค๊ดของเรากันก่อนเลย

สร้างโฟลเดอร์ blocs models providers screens widgets มารองรับโค๊ดของเรากันก่อน

ที่โฟลเดอร์ provider สร้างไฟล์ uuid.dart ขึ้นมา

แล้วก็อปตามนี้ลงไปใน providers/uuid.dart

class นี้จะทำหน้าที่สร้าง unique ID ขี้นมา เป็นโครงสร้าง แบบนี้ xxx-xxxxxxx ถ้าจะใช้งาน ก็ให้อิมพอร์ทเข้ามาแล้วเรียก Uuid().generateV4(); เพื่อใช้งานได้เลย

ต่อมาสร้างไฟล์ providers/providers.dart ขึ้นมา ทำหน้าที่ export คลาสที่เราสร้างมาในโฟลเดอร์ providers

ที่โฟลเดอร์ models มาทำโครงสร้างสำหรับข้อมูล todo

อย่างที่กล่าวไป todo แต่ละอันจะประกอบไปด้วย complete title detail uuid โดยที่มีเพียง title ที่บังคับว่าห้ามเป็นช่องว่าง ส่วน uuid จะถูกสร้าวขึ้นเมื่อ todo นั้นๆ ถูกสร้างครั้งแรก complete จะเป็นได้แค่ boolean ค่าตั้งต้นจะเป็น false และ detail ที่จะใส่หรือไม่ใส่ก็ได้

ถ้าพร้อมแล้ว ก็เอาโค๊ดไปใส่ใน models/todo.dart

มาลงรายละเอียดกัน

import 'package:json_annotation/json_annotation.dart';

เราอิมพอร์ท JSON Serialization มา ทำหน้าที่แปลงข้อมูล todos ที่เป็น class อยู่ ให้กลายเป็น json จะได้เก็บนำข้อมูลไปจัดเก็บแบบ text file ได้

import 'package:simple_todos_bloc/providers/providers.dart';

อิมพอร์ท providers.dart มา จะได้นำ uuid มาใส่ได้เลย

part ‘todo.g.dart’;

ในตอนแรก มันจะขึ้น error อยู่ ไม่ต้องไปตกใจ เพราะเดี๋ยวเราจะรันสคริปที่จะ สร้างไฟล์ตัวนี้ขึ้นมาให้ ไฟล์นี้จะเกี่ยวข้องกับ JSON Serialization ทำให้เราไม่ต้องมานั่งเขียนสคริปแบบมือในส่วนนี้ แต่เราจะรันหลังจากที่อธิบายโค๊ดไฟล์นี้ให้เสร็จก่อน

กระโดดมาดู class TodoModel กันก่อน

@immutable      // <-- เพื่อให้มันใจว่าตัวแปรทั้งหมดถูกประกาศเป็น final@JsonSerializable()     // <-- เพื่อบอกให้ สคริป JSON Serialization เราทราบว่า คลาสนี้จะถูกนำไปแปลงเป็น json ได้class TodoModel extends Equatable {final bool complete;final String id;final String title;final String detail;TodoModel({
this.complete = false, // <-- ค่าแรกเริ่มเป็น false
String id,
this.title,
this.detail = ''}): // <-- ค่าแรกเริ่มเป็น สตริงว่างๆ ถ้าไม่ถูกระบุมา
this.id = id ?? Uuid().generateV4(), // <-- ค่าแรกเริ่มถูกสร้างโดย Uuid().generateV4()
super();

// เนื่องจากภาษา dart ไม่มีวิธีการ copy by value สำหรับ class ดังนั้นเราจะต้องสร้างขึ้นมาเอง
// วิธีเรียก TodoModel newTodo = oldTodo.copyWith();
// ถ้าไปแก้ค่าใน newTodo ก็จะไม่ไปกระทบกับ oldTodo
TodoModel copyWith({bool complete, String id, String title, String detail}) {return TodoModel(complete: complete ?? this.complete,id: id ?? this.id,title: title ?? this.title,detail: detail ?? this.detail);}// เพื่อให้ดีบัคได้ง่าย เราก็จะแก้ toString() ตอนสั่ง print ออกมาจะได้เห็นค่าข้างใน class แทนที่เดิมไม่ได้แก้ จะเห็นเป็นที่ของ memory@override
String toString() {
return 'todo { title: $title, complete: $complete, detail: $detail, id: $id}';}// ฟังก์ชั่นสำหรับแปลง class เป็น json (JSON Serialization)factory TodoModel.fromJson(Map<String, dynamic> json) => _$TodoModelFromJson(json);// ฟังก์ชั่นสำหรับแปลง json เป็น class (JSON Serialization)
Map<String, dynamic> toJson() => _$TodoModelToJson(this);
@override
List<Object> get props => [complete,id,title,detail];
}

เราสร้าง todoModel สำหรับเป็นโครงสร้างของ todo แต่ละอันแล้ว เราก็ต้องมี list ที่เก็บ todo หลายๆอัน โดยปกติแล้ว เราประกาศตัวแปรแบบนี้ได้เลย List<TodoModel> todos; ซึ่งในแอปของเราส่วนใหญ่ก็จะใช้แบบนี้เลย แต่เพื่อความสะดวกในการแปลง List<TodoModel> ให้เป็น json เราเลยสร้าง class TodoList สำหรับการนี้โดยเฉพาะ

@immutable@JsonSerializable()class TodoList extends Equatable {
final List<TodoModel> todos;TodoList(this.todos); //<-- รับ List<TodoModel> เข้ามาได้เลย// เพื่อให้ดีบัคได้ง่ายเหมือนเดิม@overrideString toString() {return 'todoList { todos: $todos }';}// (JSON Serialization)factory TodoList.fromJson(Map<String, dynamic> json) =>_$TodoListFromJson(json);// (JSON Serialization)Map<String, dynamic> toJson() => _$TodoListToJson(this);@override
List<Object> get props => [todos];
}

เมื่อทุกอย่างเรียบร้อยแล้ว เรามาทำให้ error ตอนต้นหายไปโดยรันสคริปดังนี้

flutter pub run build_runner build

เป็นสคริปที่จะช่วยสร้างโค๊ดในส่วนของการแปลง Class <— — > JSON ให้เราเสร็จสรรพ โดยสคริปก็จะสร้างให้เฉพาะ class ที่เราใส่@JsonSerializable() ไว้ด้านหน้า แล้ว error ทั้งหมดในตอนนี้ก็จะหายไป

เผื่ออนาคตแอปเราจะมี model หลายอัน เราเลยจะสร้างไฟล์ไว้รวมเพื่อ export เป็นไฟล์เดียว ชื่อ models/models.dart

models

เพียงเท่านี้โครงสร้างของข้อมูลเราก็พร้อมนำไปใช้งานแล้ว

กลับมาที่ Providers อีกอันที่เราต้องสร้าง storage

สร้างไฟล์ providers/storage.dart แล้วก็อปโค๊ดตามนี้โลด

import 'package:simple_todos_bloc/models/models.dart';

เอาโครงสร้าง todo ของเราเข้ามา

import 'package:path_provider/path_provider.dart';

นำมาเพื่อใช้ดึงที่อยู่ local ของแอปเรา จะได้ใช้ที่อยู่นี้จัดเก็บข้อมูลแบบถาวรลงในเครื่องได้

ข้ามมาส่วน Future<Directory> getDirectory() async ก่อน

Future<Directory> getDirectory() async {Directory path = await getApplicationDocumentsDirectory();if (path == null) {return null;}return path;}

เมื่อเรียก getDirectory() เราก็จะได้ path กลับมา

final String tag = '__todos__';Future<File> _getLocalFile() async {
final dir = await getDirectory();return File('${dir.path}/ArchSampleStorage__$tag.json');
}

เรานำฟังก์ชั่นเมื่อกี้ มาใช้ใน Future<File> _getLocalFile() async ต่อ ทำหน้าที่เชื่อมที่อยู่ กับชื่อไฟล์ json ที่เราจะได้ไว้่ เข้าด้วยกัน และส่งข้อมูลประเภท File ไป

ต่อมาจะเป็นฟังก์ชั่นหลักๆในแอปของเรา loadTodos() กับ saveTodos() ทำหน้าที่ โหลด กับ บันทึก ดังนั้นถ้าเราเรียก loadTodos() ก็จะได้รับ todos กับมา ถ้าเรียกsaveTodos() เราก็ต้องส่ง todos เข้าไป และเนื่องจากเป็น I/O ก็จะต้องทำให้เป็น async/await ด้วย

Future<List<TodoModel>> loadTodos() async {try {final file = await _getLocalFile();   // --> ดึงไฟล์ todos มาfinal string = await file.readAsString();  // --> อ่านไฟล์แบบ stringfinal json = await jsonDecode(string);  // --> แปลง string เป็น json ที่โปรแกรมเข้าใจfinal todos = TodoList.fromJson(json);  // --> แปลง json เป็น class แล้วนำไปใช้งานได้ตามปกติเลยreturn todos.todos;} catch (e) {     // --> กรณีที่เราเปิดแอปเป็นครั้งแรกprint('not init');return TodoList([TodoModel(title: 'Welcome to Simple Todo', )]).todos;}}Future<File> saveTodos(List<TodoModel> todos) async {final file = await _getLocalFile();   // --> ดึงไฟล์ todos มาTodoList temp = TodoList(todos);    // --> แปลง List<TodoModel> เป็น TodoListreturn file.writeAsString(jsonEncode(temp));   // --> แปลง TodoList เป็น json แล้วบันทีกลงไฟล์แบบ string}

เสร็จเรียบร้อยแล้ว เราก็ไปอัพเดท เพิ่ม

export ‘./storage.dart’;
providers

ไปที่ providers/providers.dart

รวมกันได้ดังนี้

ตอนนี้เราก็ปิดงานกับ providers แล้ว

มาทำในส่วนของ bloc กันต่อ

เริ่มที่ event เช่นเคย สร้างไฟล์ blocs/todos/todos_event.dart

รอบนี้จะไม่ขอลงลึกกับ bloc มาอธิบายแบบคร่าวๆพอ

event เราจะมีทั้งหมด 4 อัน

  • LoadTodos — เรียกเมื่อต้องการให้ bloc ไปดึงข้อมูล todos จาก local storage มาใช้
  • AddTodos — เรียกเมื่อต้องการเพิ่ม todo อันใหม่เข้าไปใน todos โดยตอนเรียกจะต้องส่ง todo ไปด้วย
  • UpdateTodos — เรียกเมื่อต้องการอัพเดท todo เดิม ตอนเรียกก็จะต้องส่ง todo ที่ถูกแก้ไขไปด้วย
  • DeleteTodos — เรียกเมื่อต้องการลบ todo เดิม ออกไปจาก todos ตอนเรียกก็จะต้องส่ง todo ที่ต้องการลบด้วย

จบ event ไป state กันต่อ

สร้างไฟล์ blocs/todos/todos_state.dart

State ของเราที่จะทำหน้าที่ส่งไป UI ก็จะมีเพียง 3 อันเท่านั้น

  • TodosLoading — แจ้ง UI ว่าตอนนี้ ข้อมูลยังไม่พร้อมใช้งาน ให้โชว์ว่ากำลังโหลดอยู่
  • TodosLoaded — แจ้ง UI ว่า ข้อมูล todos พร้อมแล้ว และนำ todos มาใช้แสดงผลได้
  • TodosNotLoaded — แจ้ง UI ว่าเกิดปัญหาขึ้น ให้โชว์ error

ไป bloc กันต่อออ

สร้างไฟล์ blocs/todos/todos_bloc.dart

รอบนี้ _map****ToState() เริ่มมีความหลากหลายมากขึ้น มันจะมีจำนวนตาม event ที่มีเลย คือ 4 อัน

  • _mapLoadTodosToState() — เมื่อรับ event LoadTodos มา เราก็จะรัน FileStorage().loadTodos(); เพื่อไปดึง todos มาจาก local storage แล้วส่งต่อไป state TodosLoaded(todos); แต่ ถ้าเกิดปัญหาผิดพลาดอะไรขึ้นมา TodosNotLoaded(); ถูกเรียกแทน
  • _mapAddTodosToState(AddTodos event) — เมื่อรับ event AddTodos มา
final List<TodoModel> updatedTodos = List.from((state as TodosLoaded).todos);  // --> สร้าง list ใหม่ที่ลอกข้อมูลมาจาก list เก่า เราไม่อยากให้ list เก่าเกิดการ mutate หรือกลายพันธุ์ เพราะ flutter_bloc จะใช้ list เก่า กับ list ใหม่ เทียบกัน แล้วนำไปตัดสินว่า UI ส่วนไหนต้องถูกสร้างใหม่บ้างupdatedTodos.add(event.todo);   // --> เพิ่ม todo ไปที่ list ใหม่_saveTodos(updatedTodos);   // --> บันทึก list ใหม่ ไปยัง local storageyield TodosLoaded(updatedTodos);   // --> เรียก state TodosLoaded
  • _mapUpdateTodosToState(UpdateTodos event) — เมื่อรับ event UpdateTodos มา
// สร้าง list ใหม่โดย list ใหม่ จะถูก map เปรียบเทียบตามเงื่อนไข ถ้าเป็น id ของ todo ที่เราต้องการแก้ เราถึงจะเอา todo อันใหม่ไปแทนที่อันเก่า สุดท้ายจะได้ list ที่อัพเดทเรียบร้อย
final List<TodoModel> updatedTodos = (state as TodosLoaded).todos.map((todo) { return todo.id == event.updateTodo.id ? event.updateTodo : todo; }).toList();
yield TodosLoaded(updatedTodos); // --> เรียก state TodosLoaded_saveTodos(updatedTodos); // --> บันทึก list ใหม่ ไปยัง local storage
  • _mapDeleteTodosToState(DeleteTodos event)
// สร้าง list ใหม่โดย list ใหม่ จะถูก where หาตามเงื่อนไข ถ้าไม่ใช่ id ของ todo ที่เราต้องการลบ ก็จะถูกนำไปใส่ใน list อันใหม่ list ใหม่ก็จะลบ todo ที่ไม่ต้องการออกไปแล้ว
final List<TodoModel> updatedTodos = (stateas TodosLoaded).todos.where((todo) => todo.id != event.deleteTodo.id).toList();
yield TodosLoaded(updatedTodos); // --> เรียก state TodosLoaded_saveTodos(updatedTodos); // --> บันทึก list ใหม่ ไปยัง local storage

เมื่อเรียบร้อยหมดแล้ว เราก็รวบไฟล์ export ในชื่อเดียว blocs/todos/todos.dart

blocs

แล้วเผื่อในอนาคตเราจะมี bloc หลายตัว ก็มาสร้างตัวรวม export เผื่อไว้ก่อน ในชื่อ blocs/blocs.dart

ในที่สุดก็เสร็จส่วนของ logic แล้ว

มาทำส่วนของการแสดงผลกันต่อ

ก่อนไปส่วน Screens เรามาทำ widgets กันก่อน

widget loading

เราจะสร้าง widget LoadingIndicator ไว้โชว์สถานะกำลังโหลด เมื่อได้รับ state TodosLoading มา

** แต่เมื่อลองรันจริงอาจแทบไม่เห็น state นี้เลย เพราะเราดึงข้อมูลจาก local storage ซึ่งมีความเร็ว และการตอบสนองที่รวดเร็วมาก

สร้างไฟล์ widgets/loading_indicator.dart ขึ้นมา

widget นี้ไม่มีไรซับซ้อน มีเพียง widget CircularProgressIndicator() ที่มากับ flutter อยู่แล้ว และจับใส่ widget center เพื่อให้อยู่ตรงกลางหน้า

widgets

สำหรับแอปนี้ก็จะมี widgets อันเดียว แต่เผื่ออนาคต เช่นเดิม เราก็จะสร้าง widgets/widgets.dart ไว้รวบ export ทั้งหมด จะได้ import เพียงครั้งเดียว

มาเข้าส่วน Screens กัน

ในโฟลเดอร์ screen เราจะมีเพียง 2 หน้า

  1. todos.dart แสดงชุดข้อมูลของ todo หรือหน้าหลัก
  2. detail-add.dart แสดงรายละเอียด/แก้ไข/เพิ่ม todo

มาทำหน้า todos ก่อน สร้างไฟล์ todos.dart

รายละเอียดอธิบายไว้ในโค๊ดแล้ว

เราไปที่ detail-add.dart กันต่อ

สุดท้ายก็รวบทั้งสองไฟล์ไว้ที่ screens.dart

สุดท้าย

ตอนนี้เราเตรียมการทุกอย่างพร้อมแล้ว เหลือแค่นำมาแสดงจริงในแอปเรา

มาแก้ไฟล์ main.dart

เพียงเท่านี้ แอป Todo ของเราก็จะใช้งานได้แล้ว 🎆🎆🎆

จบแล้วววว~~~

สำหรับ Todo App แล้ว นอกเหนือจาก BLoC Pattern ที่เรานำมาใช้ เราก็ได้ลองใช้หลายๆฟีเจอร์ของ flutter เลย เราได้ฝึกใช้ local storage การโยนข้อมูลไปมาระหว่างหน้า การใช้ widget หลายๆตัว ตัวอย่างนี้อาจจะมีความยากระดับนึง และใช้ความรู้อื่นๆควบด้วย ทำให้ต้องใช้เวลาทำความเข้าใจระดับนึง ผู้เขียนก็หวังว่าตัวอย่างนี้ จะทำให้ผู้อ่านมีความคุ้นชินกับ BLoC Pattern มากยิ่งขึ้น แล้วสามารถนำไปประยุคต์ใช้จริงกับแอปที่อยากจะสร้างได้ในอนาคต

ถ้าใครอยากดูโค๊ดเต็มๆทั้งหมด ก็ดูได้จากลิงค์ข้างล่างเลย

--

--