# มาทำ Dependency Injection ใน Flutter กันเถอะ

#Dev#Flutter#Package#Dependecy injection

Dependency Injection (หลังจากนี้จะเรียก DI) แปลแบบ Erotic ได้ว่าการสอดใส่ตามความต้องการ 🤣
Class หรือ Object ของเราต้องการอะไรเราก็ส่งต่อสิ่งนั้นเข้าไป (ไปดูของคนอื่นที่เขียนดีกว่านี้ ที่นี่ (opens new window) 😂)

ในส่วนของโปรแกรมผมก็ลอกๆปรับๆมาจาก Injectable - Reso Coder (opens new window)

Header

# เบื้องต้น

โดยปกติถ้าเราไม่เขียนโดยใช้ 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)

อัปเดตเมื่อ: 1 ปีที่แล้ว