มาทำความรู้จักกับ Singletons และ Dependency Injection ใน Flutter ให้มากขึ้นกว่าเดิมกันเถอะ

Phongharit Pichaiwong
20Scoops CNX
Published in
5 min readJul 3, 2023

สวัสดีผู้อ่านทุกท่าน วันนี้มีหัวข้อน่าสนใจอยากนำมาแชร์เกี่ยวกับสิ่งที่เรียกว่า Singletons และ Dependency Injection ซึ่งถือเป็นอีกพื้นฐานความรู้ที่สำคัญในการเขียนโปรแกรมเชิงวัตถุหรือ Object Oriented Programming (OOP) โดยมีรายละเอียดดังที่จะกล่าวต่อไปนี้

ลองนึกภาพตามดูนะครับ สมมุติถ้าเราต้องการสร้างโรงภาพยนต์ (แบบคร่าวๆ) เพื่อฉายหนังสัก 1 โรง

https://shorturl.at/mrvUZ

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

กลับกันหากเปลี่ยนเป็น ก่อนที่ฉายภาพยนต์จะต้องสร้างจอภาพยนต์อันใหม่ทุกครั้ง ทั้งๆที่ของเดิมก็มีอยู่แล้วหล่ะก็…คงมีจอภาพยนต์มากมายเต็มโรง แถมยังเป็นการสิ้นเปลืองอย่างมากอีกด้วย

“อะไรที่ไม่มี หากต้องใช้ก็สร้างขึ้นมา แต่ถ้าหากมีอยู่แล้วก็ใช้ของเดิมนั้นไป ไม่จำเป็นต้องสร้างขึ้นมาใหม่ซ้ำๆโดยไม่จำเป็น”

Singletons คืออะไร?

อ้างอิงจาก wikipedia

The singleton pattern is a software design pattern that restricts the instantiation of a class to one “single” instance.

“Singletons” หรือ “Singleton Pattern” เป็น 1 ใน creational design pattern ที่ถูกนำมาใช้ในการเขียนโปรแกรมเชิงวัตถุ โดยจำกัดจำนวนการสร้าง Instance หรือ Object ของ Class นั้นๆ เพื่อลดความซ้ำซ้อนในการสร้าง Instance เดิมซ้ำๆในขณะที่โปรแกรมทำงานและทำ global point of acces ไปยัง Instance นั้นๆ เพื่อให้มั่นใจว่า Object ใดๆก็ตามที่ใช้งาน Instance ของ Class นี้อยู่ จะใช้งาน Instance ตัวเดียวกัน

Singletons ในภาษา Dart

ทีนี้เรามาลองดูตัวอย่างการ implement เจ้า Singletons ในรูปแบบของภาษา Dart กันดูนะครับ

จากรูปตัวอย่างข้างบน ผมได้สร้าง Class ขึ้นมาชื่อว่า ClassA ภายในนั้น จะมี default constructor ซึ่งจะถูกสร้างอัตโนมัติโดย compiler และมี method ที่ชื่อว่า getGreetingText เพื่อใช้สำหรับ return ข้อความเป็น String กลับออกมาว่า “Hello World!”

จากนั้นผมก็สร้าง Class ขึ้นมาอีกอันชื่อว่า ClassB เพื่อจะใช้เรียก method getGreetingText ผ่าน Instance ของ ClassA แบบนี้

ClassA instance = ClassA();

ทีนี้จะเห็นได้ว่าพอเราไม่ได้ตั้งค่า private ให้กับ default constructor ของ ClassA เราก็สามารถสร้าง Instance เพื่อเข้าถึงตัวแปรและ method ต่างๆของ ClassA ได้ผ่านทาง default constructor ของ ClassA นั่นเอง

แถมยังสามารถสร้าง Instance ขึ้นมาใหม่มาได้เรื่อยๆอีกด้วย

ClassA instance1 = ClassA();
ClassA instance2 = ClassA();
ClassA instance3 = ClassA();

ดังนั้นเพื่อควบคุมการสร้างและการใช้งาน Instance ที่มีประสิทธิภาพยิ่งขึ้น เราจึงได้นำ Singleton Pattern เข้ามาช่วยแก้ปัญหาตรงจุดนี้

คราวนี้จะเห็นได้ว่าเราไม่สามารถสร้าง Instance ใหม่ของ ClassA ได้แบบก่อนหน้านี้แล้ว เพราะ default constructor ของ ClassA ถูกตั้งค่าให้เป็น private แทนของเดิมที่เป็น public เพื่อจำกัดการใช้งานและเข้าถึง และจะสามารถใช้งานได้ผ่านทาง Instance ที่เราเตรียมไว้ให้แล้วแทนทางเดียวเท่านั้น (single source of truth)

static final instance = ClassA._();

ตรงนี้ผมได้กำหนดชนิดของตัวแปรให้เป็น static variable ซึ่งถือเป็น top-level variable (ตัวแปรระดับ class) ที่เหมาะกับการกำหนดค่าให้กับตัวแปรที่มีค่าคงที่

โดยตัวแปรแบบ static variable จะมีการเรียกใช้งานหน่วยความจำฌฉพาะแค่ครั้งแรกของการเรียกใช้งานและจะใช้ค่าเดิมจากหน่วยความจำในการเรียกใช้งานในครั้งต่อๆไปไม่ถูกทำลายทิ้ง

แล้วถ้าลองสร้างตัวแปรมาหลายๆตัวแปรดูบ้างหล่ะ

final instance1 = ClassA.instance
final instance2 = ClassA.instance
final instance3 = ClassA.instance

จากตัวอย่าง code ข้างบนจะเห็นได้ว่า ถึงแม้เราจะสร้างตัวแปรรับค่าขึ้นมาอีกหลายตัว ทั้ง instance1, instance2, instance3 แต่ทุกๆตัวนั้นจะอ้างอิงไปยัง Instance ตัวเดียวกัน

วิธีนี้จะช่วยแก้ปัญหาและลดความซ้ำซ้อนในการสร้าง Instance เดิมซ้ำๆตามที่ต่างๆภายใน code ของเราได้ และยังทำให้มั่นใจได้อีกว่า Instance จาก Class ที่เราเรียกใช้งานนั้นจะเป็นตัวเดียวกัน

ประโยชน์ของ Singletons

  • ควบคุมการสร้างและจำกัดจำนวน Instance ของ Class นั้นๆ
  • เพื่อให้แน่ใจว่า Class นั้นๆจะมีแค่เพียง Instance เดียวเท่านั้น
  • เข้าถึง Instance ของ Class นั้นๆได้ ผ่าน global variable
  • เพื่อการบริหารและจัดการการใช้หน่อยความจำอย่างมีประสิทธิภาพ (memory management)

ข้อเสียของ Singletons? 🤔

เอ..ถ้าเขียนอวยมาซะขนาดนี้ เราก็ควรหันมาให้ Singletons กันให้หมดเลยสินะครับ จะได้จบบทความไว้ตรงนี้ไม่ต้องพูดถึงเรื่อง Dependency Injection ต่อละ (ประหยัดเวลาเขียนบทความอีกด้วย 🤣)

ไม่ได้สิครับ เพราะเราตั้งหัวข้อเอาไว้แบบนั้นแล้ว ก็ต้องมาพูดถีงข้อเสียของการใช้ Singletons กันด้วยหล่ะครับ

ข้อเสียหรือข้อพึงควรระวังเกี่ยวกับการใช้ Singletons มีอะไรบ้างมาดูกัน

  • Memory Management ด้วยความที่ตัว Instance จาก Singletons ที่เราสร้างขึ้นมาแล้วนั้นมันจะไม่หายไปเอง แม้ว่า process ที่ใช้งานตัวมันจะทำงานจบลงไปแล้วก็ตาม หากเราปล่อยเอาไว้แบบนี้นั่นหมายถึงการบริโภคหน่วยความจำอย่างสิ้นเปลืองโดยไม่จำเป็นเป็น ดังนั้นจึงควรทำการ clear Instacne ที่ไม่ต้องการหลังจากการใช้งานแล้วด้วย ตัวอย่างเช่น
@override
void dispose() {
// เพิ่มคำสั่ง clear ตัว Instance ที่เราไม่ต้องการใช้งานตรงนี้
super.dispose();
}
  • Costly in terms of memory and time เนื่องจาก Singleton patterns ส่วนใหญ่มักเป็น “lazy initialization” หรือก็คือจะทำการ initialization ก็ต่อเมื่อใช้งานจริงเท่านั้นโดยไม่มีการโหลดเตรียมไว้ล่วงหน้า ตรงนี้อาจทำให้เกิดปัญหาได้ในบางกรณี เช่น
class AuthService{
AuthService._();

static final instance = AuthService._();
}

void main(){
final AuthService authService = AuthService.instance;
}

จากตัวอย่างข้างต้นจะเห็นได้ว่า มีเรียกใช้งาน Instance ของ AuthService ภายใน main() นั่นหมายความว่าขณะที่ทำการ load ตัว application นั้น การ initial แบบนี้ อาจส่งผลกระทบต่อโดยตรง เช่น ต้องรอเวลาเตรียมการตรงส่วนนี้เพิ่มขึ้น

ซึ่งเราสามารถแก้ไขได้โดยใส่ตัวแปร “late” ไว้ข้างหน้า เพื่อทำการ “Lazy Initialization” หรือค่อยทำการ initial ให้มันทีหลัง ตอนที่มีการใช้งานจริงๆแล้วนั่นเอง เพื่อลดการต้องมารอเวลา load ที่ไม่จำเป็น

class AuthService{
AuthService._();

static final instance = AuthService._();
}

void main(){
late final AuthService authService = AuthService.instance;
}
  • Difficult to unit test code พูดง่ายๆก็คือ “มัน Test ยากยังไงหล่ะ!”

ยกตัวอย่างจาก code ที่มีการเรียกใช้ Instance จาก FirebaseAuth เพื่อทำการ SignIn/SignOut แบบคร่าวๆดูดังนี้

แล้วลองมาเขียน Unit Test กันดูคร่าวๆนะครับ

ทีนี้เราก็เจอกับปัญหาเข้าแล้วหล่ะครับ

FirebaseAuth.instance.signInAnonymously();

ตรงที่เราไม่สามารถ mock เจ้าตัว ​Instacne ของ FireabseAuth ภายใน FirebaseAuthenticationRepository ได้น่ะสิ แถมก็ไม่สามารถดัดแปลงหรือแก้ไขมันจากภายนอกได้อีกด้วย

แต่ในเมื่อเราต้องทำให้ได้ ดังนั้นเราจะมาพูดถึงอีกหัวข้อที่เหลือกันเลยนั่นก็คือ “Dependency Injection” ซึ่งถือได้ว่าเป็นทั้ง solution และอีกทางเลือกแทนการใช้ Singletons แบบเดิมๆพร้อมกันในตัว

Dependency Injection (DI) คืออะไร?

อ้างอิงจาก wikipedia

Dependency injection is a programming technique in which an object or function receives other objects or functions that it depends on. Dependency injection aims to separate the concerns of constructing objects and using them, leading to loosely coupled programs.

โดยคอนเซปของ Dependency Injection (DI) คือการจัดการกับความสัมพันธ์ระหว่าง Class หรือ Components ต่างๆ โดยการลดความเกี่ยวข้องหรือพึ่งพากัน (Coupling) ระหว่าง Class หรือ Components ลงให้มากที่สุด ซึ่งการออกแบบที่ดีควรออกแบบให้ Class หรือ Components มีความเป็นอิสระจากกันให้มากที่สุดเท่าที่เป็นไปได้

เพราะหาก Class หรือ Components มีความเกี่ยวข้องหรือผูกติดระหว่างกันมากจนเกินไปอาจส่งผลกระทบตามมาภายหลังได้ เช่น เวลาที่เราต้องการจะ maintain เปลี่ยนแปลงหรือแก้ไขแค่เพียงบางส่วนก็อาจทำได้ยากยิ่งขึ้น โดยเฉพาะ Class หรือ Components ที่มีความเกี่ยวข้องหรือพึ่งพากันสูง (High Coupling) เพราะอาจส่งผลกระทบต่อส่วนอื่นๆของระบบได้โดยไม่ตั้งใจ

ดังนั้นเพื่อที่จะเลี่ยงปัญหาเหล่านี้ แทนที่เราจะสร้าง Instance ต่างๆใส่ไว้ข้างใน Class นั้นๆมากจนเกินไป ก็เปลี่ยนมาเป็นส่งผ่าน (inject) มันจากข้างนอกเข้าไปแทนซะเลยสิ! 💉

Dependency Injection คือการส่ง (inject) ตัว dependency ที่ Class นั้นๆต้องการจากภายนอก เข้าไปทาง constructor แทนการสร้าง dependency เหล่านั้นขึ้นมาใหม่ไว้ภายในตัว Class นั้นเอง

ทีนี้เราลองมาปรับ code เดิมให้สอดคล้องกับเทคนิด Dependency Injection กันดูนะครับ

จะเห็นได้ว่าคราวนี้เราไม่ได้สร้างตัว Instance ของ FirebaseAuth ไว้เองภายในตัว class อีกแล้ว แต่เป็นสร้างตัวแปรมารับ Instance (Shared Instance) ที่ส่งเข้ามาจากข้างนอกผ่านทาง constructor นั่งเอง

แล้วลองมาดูในฝั่งของ Unit Test กันบ้าง ดูว่าคราวนี้จะช่วยแก้ปัญหาเดิมที่มีจาก Singletons ได้จริงไหม โดยผมจะทำการสร้าง mock dependency เพื่อ test ในครั้งผ่านตัว package ที่ชื่อว่า mocktail นะครับ

จะเห็นได้ว่าพอเราสามารถจัดการกับ dependency ที่ติดปัญหา (FirebaseAuth) ได้แล้ว ด้วยวิธีการ Dependency Injection เพื่อสร้าง mock dependency ที่ต้องการจากภายนอก แล้วส่งค่าเข้าไป (inject) ผ่านทาง constactor ของคลาสที่ต้องการ ทำให้เราสามารถเขียน Unit Test ต่อจนจบได้นั่นเอง

จะเห็นได้ว่าการนำเอาวิธีการ Dependency Injection เข้ามาประยุกต์ใช้งานจริงนั้น มีส่วนช่วยทำให้ code มีความยืดหยุ่นมากขึ้น แก้ไขได้ง่ายขึ้น และสามารถเขียน Unit Test เพื่อทำการทดสอบได้ง่ายมากขึ้นกว่าเดิม 🎉

ทางเลือกแบบอื่นๆ

นอกเหนือจากการใช้ Singletons และ Dependency Injection แล้ว สำหรับ Flutter developer แบบเราๆนี้ยังสามารถเลือกใช้ package อื่นๆ เพื่อช่วยอำนวยความสะดวกมากยิ่งขึ้นกว่าเดิมแทนการต้องมาคอยสร้าง Singletons แต่ละคลาสเองอีกด้วย (ไม่แนะนำ) โดยเจ้าของ blog จะขอยกตัวอย่าง package ที่น่าสนใจมาสัก 2 ตัวตามนี้นะครับ

1. get_it

อธิบายคร่าวๆก็คือ get_it สามารถทำให้เรา register พวก service คลาสต่างๆที่ต้องการใช้งานทั้งหมดไว้ภายใน Service Locator และยังสามารถระบุชนิดของการ register แบบต่างๆได้เองตามที่ต้องการอีกด้วย เช่น registerSingleton หรือแบบ registerLazySingleton และยังมีรูปแบบการ register แบบอื่นๆให้เลือกได้เองตามต้องการ เพิ่มเติม

ส่วนการเรียกใช้ Instance ก็ทำได้ง่ายๆโดย

final firebaseService = getIt.get<FirebaseService>();

2. Riverpod Providers

Riverpod คือ State Management ที่พัฒนาขึ้นมาจากตัว Provider โดยใช้วิธีการ Dependency Injection (DI) มาใช้เพื่อการจัดการกับ State ต่างๆ โดยมีจุดเด่นที่เหนือกว่าตัว Providers เดิมนั่นก็คือ “สามารถ auto dispose ตัวเองได้” หากไม่ต้องการใช้งานแล้ว (ฉลาดและสะดวกดีจริงๆ)

ตัวอย่างการใช้งานก็สามารถทำได้โดยการสร้าง providers ที่ต้องการเป็น global variables เพื่อเรียกใช้

final authStateChangesProvider = StreamProvider.autoDispose<User?>((ref) {
// get FirebaseAuth from the provider below
final firebaseAuth = ref.watch(firebaseAuthProvider);
// call a method that returns a Stream<User?>
return firebaseAuth.authStateChanges();
});
// provider to access the FirebaseAuth instance
final firebaseAuthProvider = Provider<FirebaseAuth>((ref) {
return FirebaseAuth.instance;
});

แล้วเรียกใช้งานผ่าน Widget ได้โดย

Widget build(BuildContext context, WidgetRef ref) {
// watch the StreamProvider and get an AsyncValue<User?>
final authStateAsync = ref.watch(authStateChangesProvider);

// use pattern matching to map the state to the UI
return authStateAsync.when(
data: (user) => user != null ? HomePage() : SignInPage(),
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
);
}

ส่งท้าย

จากที่ร่ายยาวมาข้างต้น น่าจะพอทำให้ผู้ที่หลงเข้ามาอ่านหลายๆท่าน พอมองเห็นภาพกันมากขึ้น กับสิ่งเล็กๆที่เรียกว่ารัก (ไม่ใช่!) ที่เรียกว่า Singletons และ Dependency Injection ว่าคืออะไร แตกต่างกันอย่างไร และสามารถใช้งานได้อย่างไรบ้าง หวังว่าจะพอเป็นประโยชน์กับผู้ที่หลงเข้ามาอ่านกันทุกๆท่านนะครับ 😊

Happy Eating! (Coding มาทั้งวันละ หิว!!)

Reference:

--

--

Phongharit Pichaiwong
20Scoops CNX

Team Lead — Senior Mobile (Native Android and React Native), Blockchain and Full Stack Developer.