🦕

【Flutter】GetXで学ぶ、DIの基本〜実践的なアプリ作成までの3ステップ

2023/04/25に公開

こんにちは、Saeです。
「さえたエンジニア」へなるために、日々鍛錬に勤しんでます。

本記事では、「GetX」というPackageを使用したDI(依存性注入)の概要から、GetXでの依存性注入の方法、また依存性注入を利用したニュースアプリ作成までを解説していきます。

今回最後に紹介しているアプリのサンプルコードはGitHubで公開しておりますので、
参考になれば幸いです。
https://github.com/Sae-Eng/getx_news_app

0. 忙しい人のための「GetXでのDI」

忙しいエンジニアへ向けたシリーズ、「GetXでのDI」についての説明です。
ここだけ見れば、なんとなくわかります。

  • DI(依存性注入)とは、コンポーネント間の依存関係を外部から注入することで、疎結合なコードの柔軟性、再利用性、およびテストのしやすさを向上させるテクニック
  • Get.put(): インスタンス登録
  • Get.find(): 登録済みインスタンス取得
  • Get.put()Get.find()を使えば、アプリ全体で簡単にデータやサービス共有できる

import 'package:get/get.dart';

class MyService {
  void printMessage() {
    print('Hello, GetX!');
  }
}

void main() {
  Get.put(MyService()); // インスタンス登録
  var myService = Get.find<MyService>(); // インスタンス取得
  myService.printMessage(); // "Hello, GetX!" を出力
}

1. DI(依存性注入)について

そもそも「DI」とは?

依存性注入(Dependency Injection、DI)は、コンポーネント間の依存関係を外部から注入することで、疎結合なコードの柔軟性、再利用性、およびテストのしやすさを向上させるテクニックです。

DIを用いない場合、例えば、データベース接続やAPI呼び出しに対する具体的な実装に強く依存しているクラスを変更・拡張する際に、多くの箇所で修正が必要となり、保守性が低くなります。

具体例

しかし、DIを活用することで、プログラムの一部が他の部分に依存している場合でも、それらを別々に作成し、後からつなげることができるようになります。これにより、インターフェースや抽象クラスを使ってプログラムの部品間のつながりが柔軟になり、保守性やテストのしやすさが大幅に向上します。

依存性注入 コード例

以下に依存性注入のシンプルなコード例を記載いたします。

  • この例では、PersonクラスがGreetingServiceインターフェースに依存しており、具体的な挨拶の実装には依存していません。
  • Personクラスのコンストラクタに、具体的なGreetingServiceの実装を渡すことで、依存性を注入しています。

// GreetingService インターフェース
abstract class GreetingService {
  String getGreeting();
}

// EnglishGreetingService クラス
class EnglishGreetingService implements GreetingService {
  
  String getGreeting() {
    return 'Hello';
  }
}

// JapaneseGreetingService クラス
class JapaneseGreetingService implements GreetingService {
  
  String getGreeting() {
    return 'こんにちは';
  }
}

// Person クラス
class Person {
  final GreetingService _greetingService;

  // 依存性注入: コンストラクタに GreetingService を渡す
  Person(this._greetingService);

  void sayHello() {
    print(_greetingService.getGreeting());
  }
}

void main() {
  // EnglishGreetingService を使用する Person インスタンス
  var englishPerson = Person(EnglishGreetingService());
  englishPerson.sayHello(); // "Hello" と出力される

  // JapaneseGreetingService を使用する Person インスタンス
  var japanesePerson = Person(JapaneseGreetingService());
  japanesePerson.sayHello(); // "こんにちは" と出力される
}


このように、依存性注入を使用することで、コードの柔軟性とテストのしやすさが向上します。

「DI」のメリットは?

依存性注入には、主に以下のようなメリットがあります。

  1. 疎結合
    • 依存関係が外部から注入されるため、コンポーネント間の疎結合が実現されます。
    • これにより、クラスの再利用性が向上し、コードの変更に強くなります。
  2. 単体テストの容易性
    • DIを用いることで、依存するクラスをモック(Mock)やスタブ(Stub)といったテスト用のオブジェクトに置き換えることが容易になります。
    • これにより、単体テストを行いやすくなります。
  3. コードの再利用性
    • 依存関係が外部から注入されることで、コードの再利用性が向上します。
    • 同じインターフェースを実装した異なるクラスを、状況に応じて容易に切り替えることができます。
  4. コードの可読性
    • 依存性注入を使用することで、各クラスの役割や依存関係が明確になり、コードの可読性が向上します。

2. GetXでのDIについて

Get.put(): 依存性を登録

Get.put()は、依存性を登録するためのメソッドです。Get.put()を使用することで、クラスのインスタンスを作成し、そのインスタンスをGetのインスタンス管理に登録します。

例として、以下のサンプルコードを見てみましょう。

import 'package:get/get.dart';

class MyService {
  void printMessage() {
    print('Hello, GetX!');
  }
}

void main() {
  // MyServiceのインスタンスを作成し、Getのインスタンス管理に登録する
  Get.put(MyService());

  // 他の場所でMyServiceを使用する
  var myService = Get.find<MyService>();
  myService.printMessage(); // "Hello, GetX!" を出力
}

この例では、MyServiceクラスのインスタンスを作成し、Get.put()を使用してインスタンス管理に登録しています。また登録したインスタンス管理はGet.find()により、グローバルにアクセスすることが可能です。

Get.find(): 依存性を取得

Get.find()は、登録された依存性を取得するためのメソッドです。Get.find()を使用することで、Getのインスタンス管理から指定した型のインスタンスを取得できます。

先程の例で登録したMyServiceのインスタンスを取得するには、以下のようにGet.find()を使用します。

var myService = Get.find<MyService>();

これで、myService変数にMyServiceのインスタンスが格納されます。Get.find()を使用することで、アプリケーションのどこからでも登録された依存性にアクセスできるため、コンポーネント間で簡単にデータやサービスを共有できます。

実際の使用方法

ここまで説明してきたGet.putGet.findを組み合わせたシンプルなコードを記述します。

  1. Get.putを使ってMyServiceクラスのインスタンスを作成し、Getのインスタンス管理に登録しています。
  2. Get.findを使って登録されたMyServiceのインスタンスを取得し、printMessageメソッドを呼び出しています。
  3. コンソールにHello, GetX!と出力されます。
import 'package:get/get.dart';

class MyService {
  void printMessage() {
    print('Hello, GetX!');
  }
}

void main() {
  // MyServiceのインスタンスを作成し、Getのインスタンス管理に登録する
  Get.put(MyService());

  // 登録されたMyServiceのインスタンスを取得する
  var myService = Get.find<MyService>();

  // MyServiceのprintMessageメソッドを呼び出す
  myService.printMessage(); // "Hello, GetX!" を出力
}

3. GetXのDIを用いたアプリの作成

では実際にGetXのDIを使用して、ニュースアプリを作成していきましょう。
またDIのメリットを感じていただくため、最後にテストも作成しております。

なおコードの詳しい説明については、ソース内のコメントに詳しく記載しておりますので、そちらを参照ください。

作成するアプリケーションの概要

アプリケーションの基本的な機能は以下の通りです。

  • NewsAPIからニュースのトップヘッドラインを取得
  • トップヘッドラインをリストビューで表示
  • ニュースの詳細ページへのリンク

またアーキテクチャはMVVM + Repositoryパターンで実装しております。

0. 事前準備

  • 事前にニュースアプリ用のFlutterプロジェクトを作成
  • NewsAPIに登録し、APIKeyを取得(無料)

1. パッケージの登録と、設定ファイルの作成

まずはじめに、必要なパッケージをpubspec.yamlに追加します。


dependencies:
  flutter:
    sdk: flutter

  get: ^4.6.5 # GetX
  http: ^0.13.5 # API通信用
  flutter_config: ^2.0.0 # APIKey読み込み用
  url_launcher: ^6.1.10 # リンク表示用

dev_dependencies:
  flutter_test:
    sdk: flutter
  
  flutter_lints: ^2.0.0
  mockito: ^5.4.0 # テスト用

次にAPIKeyを読み込むために、プロジェクト直下に.env(設定)ファイルを作成しましょう。

API_KEY=[ここにNewsAPIのキーを記述]

2. APIクライアントを作成

lib/api/news_api_client.dartにAPIクライアントを作成します。
ここでは、News APIからデータを取得するためのリクエスト処理を実装します。


import 'dart:convert';

import 'package:flutter_config/flutter_config.dart';
import 'package:http/http.dart' as http;

/// News APIのクライアントを提供するクラス
///
/// このクラスは、News APIからデータを取得するためのhttpリクエストを処理します。
import 'dart:convert';

import 'package:flutter_config/flutter_config.dart';
import 'package:http/http.dart' as http;

/// News APIのクライアントを提供するクラス
///
/// このクラスは、News APIからデータを取得するためのhttpリクエストを処理します。
class NewsApiClient {
  // APIキーを取得
  final String _apiKey = FlutterConfig.get('API_KEY');

  /// トップニュースの一覧を取得するメソッド
  ///
  /// このメソッドは、News APIからトップニュースの一覧を取得し、JSON形式のデータを返します。
  /// 例外が発生した場合、エラーメッセージを投げます。
  Future<List<dynamic>> fetchTopHeadlines() async {
    // News APIのエンドポイントにGETリクエストを送信
    final response = await http.get(
      Uri.parse('https://newsapi.org/v2/top-headlines?country=jp&apiKey=$_apiKey'),
    );

    // ステータスコードが200(成功)の場合、JSON形式の記事リストを返す
    if (response.statusCode == 200) {
      return json.decode(response.body)['articles'];
    } else {
      // ステータスコードが200以外の場合、例外を投げる
      throw Exception('Failed to load news');
    }
  }
}

3. repositoryを作成

lib/repository/news_repository.dartにリポジトリを作成します。
またここではNewsApiClientを、コンストラクタでDI(依存性注入)をしております。


/// このクラスは、APIクライアントを通じてニュースデータを取得し、ビジネスロジックを適用します。
class NewsRepository {
  // News APIクライアントのインスタンス
  final NewsApiClient apiClient;

  /// NewsRepositoryクラスのコンストラクタ
  ///
  /// [apiClient] は、ニュースデータを取得するために使用されるNewsApiClientインスタンスです。
  NewsRepository({required this.apiClient});

  /// トップニュースの一覧を取得するメソッド
  ///
  /// このメソッドは、[apiClient]を使用してトップニュースの一覧を取得し、そのデータを返します。
  Future<List<dynamic>> getTopHeadlines() async {
    // NewsApiClientのfetchTopHeadlinesメソッドを呼び出し、ニュースデータを取得
    return await apiClient.fetchTopHeadlines();
  }
}


4. ViewModelを作成

lib/vm/news_view_model.dartにViewModelを作成します。


import 'package:get/get.dart';

import '../repository/news_repository.dart';

/// ニュース一覧 ViewObject
///
/// [title]と[url]のみを含むView用のデータクラス
class ArticleViewObject {
  final String title;
  final Uri url;

  /// ArticleViewObjectのコンストラクタ
  ///
  /// [title]はニュースのタイトル、[url]はニュースのURL
  ArticleViewObject({required this.title, required this.url});
}

/// ニュースのViewModelクラス
///
/// このクラスは、ニュースリポジトリを利用してデータを取得し、Viewで表示するために加工します。
class NewsViewModel extends GetxController {
  final NewsRepository repository;
  var articles = <ArticleViewObject>[].obs;
  var errorMessage = ''.obs;
  var hasError = false.obs;

  /// NewsViewModelクラスのコンストラクタ
  ///
  /// [repository]は、ニュースデータを取得するためのNewsRepositoryインスタンス
  NewsViewModel({required this.repository});

  /// ViewModelが初期化されるときに実行されるメソッド
  
  void onInit() {
    super.onInit();
    fetchTopHeadlines(); // 初期化時にトップニュースを取得
  }

  /// トップニュースを取得し、Viewで表示できる形に加工するメソッド
  void fetchTopHeadlines() async {
    try {
      // リポジトリから生の記事データを取得
      List<dynamic> rawArticles = await repository.getTopHeadlines();

      // 生の記事データをArticleViewObjectのリストに変換
      articles.value = rawArticles
          .map((article) => ArticleViewObject(
                title: article['title'],
                url: Uri.parse(article['url']),
              ))
          .toList();

      // エラー表示をリセット
      hasError.value = false;
      errorMessage.value = '';
    } catch (e) {
      // 記事取得に失敗した場合、エラー表示を更新
      hasError.value = true;
      errorMessage.value = '記事の取得に失敗しました。';
    }
  }
}


5. Viewを作成

最初にメインのビューを作成する前に、lib/view/error_state_widget.dartにエラーメッセージを表示するようのコンポーネントを作成します。


import 'package:flutter/material.dart';

/// エラー状態を表示するウィジェット
class ErrorStateWidget extends StatelessWidget {
  // 表示するエラーメッセージ
  final String errorMessage;
  // リトライボタンが押されたときのコールバック
  final VoidCallback retryCallback;

  // コンストラクタ
  const ErrorStateWidget({super.key, required this.errorMessage, required this.retryCallback});

  
  Widget build(BuildContext context) {
    // エラーメッセージとリトライボタンを含むウィジェットを返す
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(errorMessage),
          const SizedBox(height: 8),
          ElevatedButton(
            onPressed: retryCallback,
            child: const Text('リトライ'),
          ),
        ],
      ),
    );
  }
}


次にlib/view/news_view.dartにメインとなるViewを作成します。


import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher.dart';

import '../vm/news_view_model.dart';
import 'error_state_widget.dart';

/// ニュース記事一覧ページを表示するウィジェット
class NewsPage extends StatelessWidget {
  const NewsPage({super.key});

  // URLを開くヘルパーメソッド
  Future<void> _launchURL(Uri url) async {
    if (await canLaunchUrl(url)) {
      await launchUrl(url);
    } else {
      throw 'Could not launch $url';
    }
  }

  
  Widget build(BuildContext context) {
    final viewModel = Get.find<NewsViewModel>();

    return Scaffold(
      appBar: AppBar(title: const Text('GetX News')),
      body: Obx(() {
        // エラー表示
        if (viewModel.hasError.value) {
          return ErrorStateWidget(
            errorMessage: viewModel.errorMessage.value,
            retryCallback: viewModel.fetchTopHeadlines,
          );
        }
        // 読み込み中
        else if (viewModel.articles.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }
        // コンテンツを表示
        else {
          return ListView.separated(
            itemCount: viewModel.articles.length,
            itemBuilder: (context, index) {
              final article = viewModel.articles[index];
              return ListTile(
                title: Text(article.title),
                onTap: () => _launchURL(article.url),
              );
            },
            separatorBuilder: (BuildContext context, int index) {
              return const Divider(
                height: 20,
                color: Colors.grey,
              );
            },
          );
        }
      }),
      // リフレッシュボタン
      floatingActionButton: FloatingActionButton(
        onPressed: () => viewModel.fetchTopHeadlines(),
        child: const Icon(Icons.refresh),
      ),
    );
  }
}


6. main.dartを作成

lib/main.dartにエントリーポイントとなるmain.dartを作成します。


import 'package:flutter/material.dart';
import 'package:flutter_config/flutter_config.dart';
import 'package:get/get.dart';
import 'package:getx_news_app/view/news_page.dart';

import 'api/news_api_client.dart';
import 'repository/news_repository.dart';
import 'vm/news_view_model.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await FlutterConfig.loadEnvVariables();

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return GetMaterialApp(
      // 初期バインディングを設定
      initialBinding: BindingsBuilder(() {
        Get.put(NewsApiClient());
        Get.put(NewsRepository(apiClient: Get.find()));
        Get.put(NewsViewModel(repository: Get.find()));
      }),
      home: const NewsPage(),
    );
  }
}


7. テストを作成

最後にtest/news_repository_test.dartにテストを作成しましょう。

また今までDIで実装してきたことで、このテストコードではモックオブジェクト(MockNewsApiClient)を簡単に注入できるため、APIへ接続しなくてもダミーデータを返却できており、結果としてユニットテストが容易になっていることがわかります。


import 'package:flutter_test/flutter_test.dart';
import 'package:getx_news_app/api/news_api_client.dart';
import 'package:getx_news_app/repository/news_repository.dart';
import 'package:mockito/mockito.dart';

// NewsApiClientをモック化するクラス
class MockNewsApiClient extends Mock implements NewsApiClient {
  
  Future<List<dynamic>> fetchTopHeadlines() async {
    return [
      {
        "source": {"id": "google-news", "name": "Google News"},
        "author": "nhk.or.jp",
        "title": "G7農相会合始まる 食料安全保障の強化 一致できるか焦点 - nhk.or.jp",
        "description": null,
        "url":
            "https://news.google.com/rss/articles/CBMiPmh0dHBzOi8vd3d3My5uaGsub3IuanAvbmV3cy9odG1sLzIwMjMwNDIyL2sxMDAxNDA0NTk0MTAwMC5odG1s0gFCaHR0cHM6Ly93d3czLm5oay5vci5qcC9uZXdzL2h0bWwvMjAyMzA0MjIvYW1wL2sxMDAxNDA0NTk0MTAwMC5odG1s?oc=5",
        "urlToImage": null,
        "publishedAt": "2023-04-22T06:24:29Z",
        "content": null
      },
    ];
  }
}

void main() {
  late NewsRepository newsRepository;
  late NewsApiClient apiClient;

  setUp(() {
    apiClient = MockNewsApiClient();
    newsRepository = NewsRepository(apiClient: apiClient);
  });

  // fetchTopHeadlinesが記事のリストを返すことをテストします。
  test('fetchTopHeadlines returns a list of articles', () async {
    final result = await newsRepository.getTopHeadlines();
    expect(result, isNotNull);
    expect(result, isA<List<dynamic>>());
    expect(result.length, 1);
    expect(result[0]['title'], 'G7農相会合始まる 食料安全保障の強化 一致できるか焦点 - nhk.or.jp');
    expect(result[0]['description'], null);
  });
}



<番外編>

アプリ実装お疲れ様でした!
ここからは紹介しきれなかった依存性注入に関係する応用的な使い方を紹介していきます。

Binding: 依存関係をグループ化

Bindingは、GetXで依存性管理とステート管理を行うための機能です。

具体的にはアプリケーション内の特定のページや機能で必要なコントローラーやサービスをグループ化、それらのインスタンスをBinding経由で提供することで、リソースの効率的な管理が可能になります。

class MyBinding implements Bindings {
  
  void dependencies() {
    Get.put<Service>(Service());
    Get.put<Controller>(Controller());
  }
}

上記の例では、MyBindingクラスがBindingsを実装しており、dependencies()メソッド内でServiceとControllerのインスタンスが作成されています。

このMyBindingを使用することで、ページや機能で必要な依存関係を一元的に管理できます。

Get.lazyPut(): 依存性を必要になるまで遅らせて登録

Get.lazyPut()は、GetXで依存性を遅延生成するための方法です。

これは、インスタンスが実際に使用されるまでインスタンスの生成を遅らせることができるため、パフォーマンスの向上に役立ちます。特に、インスタンス生成に多くのリソースを消費する場合や、すぐに使用されない可能性があるインスタンスの場合に有用です。

例えば、ユーザー認証プロフィール編集などの機能が含まれたアプリを作成しているとします。これらの機能に関連するViewModelやRepositoryは、ユーザーが新規でユーザーを作成するまでは、それらの機能を利用するまでインスタンス化する必要はありません。

このような場合、Get.lazyPutを使って依存性注入を行うことで、アプリの起動時に不要なリソースを消費しないため、初回起動の速さが向上し、パフォーマンスの向上につながります。

Get.lazyPut<MyProfile>(() => MyProfile());

上記の例では、MyProfileのインスタンスは、最初にGet.find<MyProfile>()が呼び出されるまで生成されません。これにより、リソースの消費が抑制されます。

Get.create(): 依存性を生成するたびに新しいインスタンスを作成

Get.create()は、GetXで依存性を生成するたびに新しいインスタンスを作成する方法です。
これは、シングルトンではなく、複数のインスタンスが必要な場合や、独立した状態を持つインスタンスが必要な場合に役立ちます。

Get.create<MyService>(() => MyService());

上記の例では、MyServiceのインスタンスは、Get.find<MyService>()が呼び出されるたびに新しく生成されます。異なる状態を持つ複数のインスタンスを簡単に管理できます。

まとめ

いかがでしたでしょうか。
依存性注入とは、またGetXを使った依存性注入の方法が少しでも伝わりましたら幸いです。

みなさんもGetXを活用し、素敵なFlutterライフをお過ごしくださいませ。では〜。

Discussion