🐉

rxdartを使用して郵便番号APIをサーチする

2025/01/20に公開

RxDart を使用した郵便番号検索アプリ

仕事でrxdartというライブラリを使うことがありました。RxSwiftみたいなものですね。Streamで状態管理をするライブラリです。今回はこちらを使用して、setState()メソッドを使用せずに状態管理をしてみようと思います。

rxdartですが半年前からメンテナンスされていないようで今から学ぶ必要はないかもしれません。仕事で使ってたらやった方がいいかも(^_^;)

動作はこんな感じです

https://youtu.be/rc78UJfnRjY

こちらが完成品

RxSwiftについては以前学んでみて記事にしてみました。RxJSもあるらしい。
https://zenn.dev/joo_hashi/articles/75f60678bc3ddc

概要

このプロジェクトは、RxDartを使用してCQRSパターンを実装した郵便番号検索アプリケーションです。setStateを使用せずに、Streamベースの状態管理を実現しています。

ファイルは2個用意するだけでOK

lib
├── main.dart
└── viewmodels
    └── postal_code_viewmodel.dart

状態管理するViewModelとして使用するクラス。モデルも同じファイルに書いてしまいましたが、今回は使い方を体験するものなのでレイヤー分けるとか意識してません。

import 'package:rxdart/rxdart.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

// Command: 郵便番号検索のコマンド
class SearchPostalCodeCommand {
  final String postalCode;
  SearchPostalCodeCommand(this.postalCode);
}

// Query Result: 住所検索の結果
class AddressQueryResult {
  final String address;
  final bool isError;

  AddressQueryResult(this.address, {this.isError = false});

  factory AddressQueryResult.error(String message) {
    return AddressQueryResult(message, isError: true);
  }
}

class PostalCodeViewModel {
  /// [Command Stream]
  /// [PublishSubject]
  /// 特徴:
  /// 購読開始後に発生したイベントのみを受け取ります
  /// 初期値を持ちません
  /// Hot Observable(購読開始のタイミングが重要)
  /// 使用場面:
  /// リアルタイムのイベント通知(ボタンタップなど)
  /// ライブデータの配信(株価の更新など)
  /// 過去のデータが不要な場合
  final _commandController = PublishSubject<SearchPostalCodeCommand>();

  /// [Query Stream]
  /// [BehaviorSubject]
  ///特徴:
  /// 必ず初期値を持ちます
  ///購読開始時に最新値を受け取れます
  ///状態を保持します
  ///使用場面:
  ///UIの状態管理(テキストフィールドの現在値など)
  ///設定値の管理
  ///常に値を持っている必要があるデータ
  final _queryResultController = BehaviorSubject<AddressQueryResult>();

  // Public Stream for UI
  Stream<AddressQueryResult> get addressStream => _queryResultController.stream;

  // Command Sink
  Sink<SearchPostalCodeCommand> get searchCommand => _commandController.sink;

  PostalCodeViewModel() {
    // コマンドの処理をセットアップ
    _commandController
        .debounceTime(const Duration(milliseconds: 500))
        .where((command) => command.postalCode.length == 7)
        .distinct()
        .listen(_handleSearchCommand);
  }

  // 郵便番号検索コマンドの処理
  Future<void> _handleSearchCommand(SearchPostalCodeCommand command) async {
    try {
      final response = await http.get(
        Uri.parse(
            'https://zipcloud.ibsnet.co.jp/api/search?zipcode=${command.postalCode}'),
      );

      if (response.statusCode == 200) {
        final data = json.decode(response.body);
        if (data['results'] != null && data['results'].isNotEmpty) {
          final address = data['results'][0];
          final addressText =
              '${address['address1']}${address['address2']}${address['address3']}';
          _queryResultController.add(AddressQueryResult(addressText));
        } else {
          _queryResultController.add(AddressQueryResult.error('住所が見つかりませんでした'));
        }
      } else {
        _queryResultController.add(AddressQueryResult.error('APIエラーが発生しました'));
      }
    } catch (e) {
      _queryResultController
          .add(AddressQueryResult.error('エラーが発生しました: ${e.toString()}'));
    }
  }

  // Dispose 関数
  void dispose() {
    _commandController.close();
    _queryResultController.close();
  }
}

main.dartにViewのコードを書いています。ViewModelというと、RiverpodのNotifierと思ってしまうが、あっちはAndroidよりのものらしい。本来のViewModelとはMicrosoftが考えたものらしい。コマンドの話とか出てきますが、voidのメソッドと思ってください。データ追加する外部に変更を加えるものが副作用というらしく、return戻り値を返す値を参照するメソッドが状態を変更しないので、主作用というとか。

https://learn.microsoft.com/ja-jp/dotnet/architecture/maui/mvvm

import 'package:flutter/material.dart';
import 'viewmodels/postal_code_viewmodel.dart';

void main() {
  runApp(const MyApp());
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '郵便番号検索',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const PostalCodeSearchPage(),
    );
  }
}

class PostalCodeSearchPage extends StatefulWidget {
  const PostalCodeSearchPage({super.key});

  
  State<PostalCodeSearchPage> createState() => _PostalCodeSearchPageState();
}

class _PostalCodeSearchPageState extends State<PostalCodeSearchPage> {
  // ここでViewModelをインスタンス化
  late final PostalCodeViewModel _viewModel;

  
  void initState() {
    super.initState();
    // ページが呼ばれたらViewModelを初期化
    _viewModel = PostalCodeViewModel();
  }

  
  void dispose() {
    // ViewModelを破棄
    _viewModel.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('郵便番号検索'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextField(
              decoration: const InputDecoration(
                labelText: '郵便番号(ハイフンなし7桁)',
                hintText: '1234567',
                border: OutlineInputBorder(),
              ),
              keyboardType: TextInputType.number,
              onChanged: (value) => _viewModel.searchCommand.add(
                SearchPostalCodeCommand(value),
              ),
            ),
            const SizedBox(height: 20),
            StreamBuilder<AddressQueryResult>(
              stream: _viewModel.addressStream,
              builder: (context, snapshot) {
                if (!snapshot.hasData) {
                  return const Text(
                    '郵便番号を入力してください',
                    textAlign: TextAlign.center,
                    style: TextStyle(fontSize: 16),
                  );
                }

                final result = snapshot.data!;
                return Card(
                  child: Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: Text(
                      result.address,
                      style: TextStyle(
                        fontSize: 18,
                        color: result.isError ? Colors.red : Colors.black,
                      ),
                      textAlign: TextAlign.center,
                    ),
                  ),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

技術スタック

  • Flutter3.27.2
  • RxDart0.28.0
  • HTTP Package (郵便番号API通信用)

アーキテクチャ

このアプリケーションは以下のアーキテクチャパターンを採用しています:

  • CQRS (Command Query Responsibility Segregation)
  • ViewModel Pattern
  • Reactive Programming

主要なコンポーネント

Command Stream

final _commandController = PublishSubject<SearchPostalCodeCommand>();

PublishSubjectの特徴:

  • 購読開始後に発生したイベントのみを受け取る
  • 初期値を持たない
  • Hot Observable(購読開始のタイミングが重要)

使用場面:

  • リアルタイムのイベント通知(ボタンタップなど)
  • ライブデータの配信
  • 過去のデータが不要な場合

Query Stream

final _queryResultController = BehaviorSubject<AddressQueryResult>();

BehaviorSubjectの特徴:

  • 必ず初期値を持つ
  • 購読開始時に最新値を受け取れる
  • 状態を保持する

使用場面:

  • UIの状態管理(テキストフィールドの現在値など)
  • 設定値の管理
  • 常に値を持っている必要があるデータ

実装のポイント

1. コマンドの処理

_commandController
    .debounceTime(const Duration(milliseconds: 500))
    .where((command) => command.postalCode.length == 7)
    .distinct()
    .listen(_handleSearchCommand);
  • debounceTime: 連続入力時の API コール抑制
  • where: 7桁の郵便番号のみを処理
  • distinct: 重複した検索を防止

2. エラーハンドリング

class AddressQueryResult {
  final String address;
  final bool isError;

  AddressQueryResult(this.address, {this.isError = false});

  factory AddressQueryResult.error(String message) {
    return AddressQueryResult(message, isError: true);
  }
}
  • エラー状態を含むクエリ結果の型定義
  • ファクトリメソッドによるエラーインスタンスの生成

3. リソース管理

void dispose() {
  _commandController.close();
  _queryResultController.close();
}
  • Streamのリソース解放
  • メモリリークの防止

メリット

  1. 状態管理の簡素化

    • setStateを使用せずにUIの更新が可能
    • 状態の変更が予測可能
  2. テスト容易性

    • CommandとQueryの分離により、ユニットテストが容易
    • Streamのテストが可能
  3. 保守性

    • 責務の明確な分離
    • コードの再利用性が高い

使用方法

  1. テキストフィールドに7桁の郵便番号を入力
  2. 自動的に住所検索が実行される
  3. 結果が表示される(エラーの場合は赤文字で表示)

注意点

  • StreamのDisposeを忘れないこと
  • Hot ObservableとCold Observableの違いを理解すること
  • エラーハンドリングを適切に行うこと

Discussion