# มาทำ Dependency Injection ใน Flutter กันเถอะ
#Dev#Flutter#Package#Dependecy injectionDependency Injection (หลังจากนี้จะเรียก DI) แปลแบบ Erotic ได้ว่าการสอดใส่ตามความต้องการ 🤣
Class หรือ Object ของเราต้องการอะไรเราก็ส่งต่อสิ่งนั้นเข้าไป (ไปดูของคนอื่นที่เขียนดีกว่านี้ ที่นี่ (opens new window) 😂)
ในส่วนของโปรแกรมผมก็ลอกๆปรับๆมาจาก Injectable - Reso Coder (opens new window)
# เบื้องต้น
โดยปกติถ้าเราไม่เขียนโดยใช้ DI นั้น เราจะเขียนโค้ดประมาณนี้
class Human {
final leftArm = NormalArm();
final rightArm = CyborgArm();
}
class NormalArm {}
class CyborgArm {}
void main() {
final human = Human();
final human2 = Human();
}
การเขียนแบบนี้จะทำให้เกิดปัญหาขึ้นอย่างหนึ่งคือ Coupling (ในทางการเขียนโปรแกรมจะกล่าวถึงความสัมพันธ์ที่แนบแน่นเกิน) อย่างเช่นในตัวอย่างเราสร้าง Human ขึ้นมาสองตัว แต่จะพบว่าทุกอย่างมันจะเหมือนกันหมด เพราะเราไปกำหนดเลยว่า Human จะต้องมีส่วนประกอบยังไง
ถ้าเราอยากได้คนที่แตกต่างล่ะ? เราก็อาจจะเพิ่ม class SuperHuman แล้วก็ใส่แขนที่ต้องการลงไป
แล้วถ้ามีแบบที่อยากได้อีกล่ะ? ก็เขียนเพิ่มกันไป...
DI จึงคิดมาไว้เพื่อแก้ปัญหานี้
class AnotherHuman {
final IArm leftArm;
final IArm rightArm;
AnotherHuman(this.leftArm, this.rightArm);
}
abstract class IArm {} // abstract class ใช้เหมือนๆ interface ในภาษาอื่่น
class NormalArm implements IArm {}
class CyborgArm implements IArm {}
void main() {
final justAnotherMan = AnotherHuman(NormalArm(), NormalArm());
final thisIsCyborg = AnotherHuman(CyborgArm(), CyborgArm());
}
จะเห็นว่าแทนที่เราจะกำหนด leftArm, rightArm แต่แรก
เราจะมาใส่ leftArm และ rightArm ในภายหลังตอนที่เรากำลังจะเรียกใช้ AnotherHuman
แน่นอนว่าเราจะใช้ DI มาแก้ปัญหาอะไรทำนองนี้ในโค้ดของเรากันนี่ล่ะ
# เริ่มกันเหมือนเดิม
flutter create dependency_injection
ไฟล์เริ่มต้นเหมือนเดิม lib/main.dart
ก็ตามนี้ครับ
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
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;
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
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.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
# ไปอย่างรวดเร็วกับการติดตั้ง BLoC
pubspec.yaml
...
dependencies:
flutter:
sdk: flutter
provider: ^4.0.3
...
lib/bloc/counter_bloc.dart
import 'dart:async';
class CounterBloc {
var _counter = 0;
var _counterController = StreamController<int>();
get counterStream => _counterController.stream;
get currentCounter => _counter;
increaseCounter() {
_counter += 1;
_counterController.sink.add(_counter);
}
dispose() {
_counterController.close();
}
}
lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'bloc/counter_bloc.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return Provider<CounterBloc>(
create: (context) => CounterBloc(),
dispose: (context, bloc) => bloc.dispose(),
child: 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;
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
var counterBloc = Provider.of<CounterBloc>(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:',
),
StreamBuilder<int>(
stream: counterBloc.counterStream,
initialData: counterBloc.currentCounter,
builder: (context, snapshot) {
return Text(
'${snapshot.data}',
style: Theme.of(context).textTheme.headline4,
);
},
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: counterBloc.increaseCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
สามารถดูเพิ่มเติมเกี่ยวกับ BLoC ได้ที่ Link
ตอนนี้ทุกอย่างเหมือนเดิมเพิ่มเติมคือเขียนแยก Bussiness Logic ออกจาก View แล้ว
# มาเริ่มใช้ DI กัน
ติดตั้ง package เพิ่มตามนี้
pubspec.yaml
...
dependencies:
flutter:
sdk: flutter
provider: ^4.0.3
get_it: ^3.1.0 # get_it Package ที่ช่วยให้ชีวิต DI ขึ้น
injectable: ^0.2.0 # Injectable ตัวช่วยสร้างโค้ด ใช้ร่วมกับ get_it
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^1.7.4 # เป็น Tool เอาไว้สร้างโค้ดอัตโนมัติ
injectable_generator: ^0.2.0 # Injectable ตัวช่วยสร้างโค้ด ใช้ร่วมกับ get_it
mockito: ^4.1.1 # เอาไว้ทำ Mock Data เวลา Testing
...
โค้ดเดิมๆของเราในส่วนของ bloc นั้นจะเป็น
import 'dart:async';
class CounterBloc {
var _counter = 0;
var _counterController = StreamController<int>();
get counterStream => _counterController.stream;
get currentCounter => _counter;
increaseCounter() {
_counter += 1;
_counterController.sink.add(_counter);
}
dispose() {
_counterController.close();
}
}
เนื่องจากตัวอย่างโค้ดตอนนี้มันเป็นอะไรง่ายๆอย่างแค่เพิ่มเลขไปเรื่อยๆ การใช้ DI ก็อาจจะไม่ได้จำเป็นอะไรมากนัก
เว้นเสียแต่ว่าในบางครั้งจะมีเหตุการณ์แบบว่าในตอนพัฒนาโปรแกรมต้องทำอย่างหนึ่ง พอไปลง Play Store หรือ App Store ต้องเปลี่ยนไปใช้อีกอย่างหนึ่ง การทำ DI ก็จะเห็นประโยชน์
ในที่นี้เราจะทำการจำลองกันครับ โดยการสร้าง
lib/repository/i_counter_repository.dart
abstract class ICounterRepository {
getIncrement();
}
lib/repository/i_counter_repository.dart
import 'i_counter_repository.dart';
class DevCounterRepository implements ICounterRepository {
getIncrement() => 100;
}
lib/repository/i_counter_repository.dart
import 'i_counter_repository.dart';
class ProdCounterRepository implements ICounterRepository {
getIncrement() => 1;
}
ทีนี้แทนที่เราจะใส่ค่าไปตรงๆ เราจะไปกำหนดตอนสร้าง object CounterBloc แทน
lib/bloc/counter_bloc.dart
import 'dart:async';
import '../repository/i_counter_repository.dart';
class CounterBloc {
CounterBloc(this._counterRepository) {}
final ICounterRepository _counterRepository;
var _counter = 0;
var _counterController = StreamController<int>();
get counterStream => _counterController.stream;
get currentCounter => _counter;
increaseCounter() {
_counter += _counterRepository.getIncrement();
_counterController.sink.add(_counter);
}
dispose() {
_counterController.close();
}
}
lib/main.dart
...
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return Provider<CounterBloc>(
create: (context) => CounterBloc(DevCounterRepository()), // หรือ CounterBloc(ProdCounterRepository())
dispose: (context, bloc) => bloc.dispose(),
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
),
);
}
}
...
ซึ่งจะพบว่าตอนนี้แอปของเราจะเปลี่ยนเป็นเพิ่มค่าทีละ 100 แล้วครับ และถ้าเราอยากจะให้เพิ่มทีละ 1 เหมือนเดิม ก็แค่เปลี่ยน DevCounterRepository เป็น ProdCounterRepository เท่านั้นเอง
ปัญหาก็คือถ้ามันมีโค้ดหลายๆจุดที่เราต้องเปลี่ยนล่ะ จะไปไล่เปลี่ยนด้วยมือหมดเลยมันก็คงจะไม่สะดวก เราจึงจะใช้ get_it และ injectable ในการช่วยให้ชีวิตเราง่ายขึ้นกัน
# เริ่มจากกำหนด injectable
lib/inject.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'inject.iconfig.dart';
var getIt = GetIt.instance;
// กำหนดตัวสำหรับตั้งค่า get_it
// ฟังก์ชันที่ชื่อ $initGetIt จะถูกสร้างเมื่อเราสั่ง build_runner
void configureInjection(String environment) =>
$initGetIt(getIt, environment: environment);
// กำหนดว่าอยากให้มี Environment อะไรบ้างตามแต่ใจของเราเลย
abstract class Env {
static const dev = 'dev';
static const prod = 'prod';
}
lib/repository/dev_counter_repository.dart
import 'package:injectable/injectable.dart';
import 'i_counter_repository.dart';
import '../inject.dart';
(ICounterRepository, env: Env.dev)
class DevCounterRepository implements ICounterRepository {
getIncrement() => 100;
}
lib/repository/prod_counter_repository.dart
import 'package:injectable/injectable.dart';
import 'i_counter_repository.dart';
import '../inject.dart';
(ICounterRepository, env: Env.prod)
class ProdCounterRepository implements ICounterRepository {
getIncrement() => 1;
}
และเมื่อสั่ง
flutter pub run build_runner watch
ก็จะได้ไฟล์ inject.iconfig.dart
มาครับ
// GENERATED CODE - DO NOT MODIFY BY HAND
// **************************************************************************
// InjectableConfigGenerator
// **************************************************************************
import 'package:dependency_injection/repository/dev_counter_repository.dart';
import 'package:dependency_injection/repository/i_counter_repository.dart';
import 'package:dependency_injection/repository/prod_counter_repository.dart';
import 'package:get_it/get_it.dart';
void $initGetIt(GetIt getIt, {String environment}) {
if (environment == 'dev') {
_registerDevDependencies(getIt);
}
if (environment == 'prod') {
_registerProdDependencies(getIt);
}
}
void _registerDevDependencies(GetIt getIt) {
getIt..registerFactory<ICounterRepository>(() => DevCounterRepository());
}
void _registerProdDependencies(GetIt getIt) {
getIt..registerFactory<ICounterRepository>(() => ProdCounterRepository());
}
สุดท้ายเราก็แค่ไปกำหนดการใช้งานใน lib/main.dart
...
import 'inject.dart';
void main() {
configureInjection(Env.dev);
runApp(MyApp());
}
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return Provider<CounterBloc>(
create: (context) => CounterBloc(getIt<ICounterRepository>()),
dispose: (context, bloc) => bloc.dispose(),
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
),
);
}
}
...
ถ้าเราต้องการเปลี่ยน Environment เราก็แค่เปลี่ยน
...
void main() {
configureInjection(Env.prod);
runApp(MyApp());
}
...
ในส่วนของ CounterBloc
นั้นที่ต้องการ ICounterRepository
ตัว getIt
จะคืนค่าออกมาตาม Environment ที่เราเป็นคนกำหนด
CounterBloc(getIt<ICounterRepository>())
# เอามาช่วยในการทำ Unittest
เพิ่ม Mock Environment ขึ้นมา lib/inject.dart
abstract class Env {
static const mock = 'mock';
static const dev = 'dev';
static const prod = 'prod';
}
แล้วสร้าง lib/repository/mock_counter_repository.dart
import 'package:injectable/injectable.dart';
import 'package:mockito/mockito.dart';
import 'i_counter_repository.dart';
import '../inject.dart';
(ICounterRepository, env: Env.mock)
class MockCounterRepository extends Mock implements ICounterRepository {}
และ test/counter_test.dart
import 'package:dependency_injection/bloc/counter_bloc.dart';
import 'package:dependency_injection/inject.dart';
import 'package:dependency_injection/repository/i_counter_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
void main() {
setUpAll(() {
configureInjection(Env.mock);
});
test('Counter increase should work', () {
var mockCounterRepository = getIt<ICounterRepository>();
// กำหนดให้เมื่อ .getIncrement() ถูกเรียกจะคืนค่าออกมาเป็น 123
when(mockCounterRepository.getIncrement()).thenReturn(123);
var counterBloc = CounterBloc(mockCounterRepository);
var startCounter = counterBloc.currentCounter;
counterBloc.increaseCounter();
// ค่าเริ่มต้นต้องเป็น 0
expect(startCounter, equals(0));
// .getIncrement() ต้องถูกเรียกแค่ครั้งเดียว
verify(mockCounterRepository.getIncrement()).called(1);
// เมื่อสั่ง counterBloc.increaseCounter() แล้ว counter จะต้องเป็น 123
expect(counterBloc.currentCounter, equals(123));
});
}
Source code สามารถดูได้ ที่นี่ (opens new window)