mcpField
A
Advanced GetX Patterns

Advanced GetX Patterns

github.com/kaakati開発1054 回インストール39 週間前に更新
リポジトリ

Advanced GetX Patterns について

Advanced GetX Patternsは、GetXでリアクティブなDartアプリを構築する開発者向けガイドです。状態変化への副作用、依存関係とライフサイクルの管理、HTTP通信、バックグラウンドキュー、テストを扱います。

主な機能

  1. 01リアクティブなWorkerと副作用
  2. 02常駐サービスとライフサイクル制御
  3. 03GetConnectによるHTTPクライアントのパターン
  4. 04依存関係の複合バインディング
  5. 05Rx変換とバックグラウンドキュー
  6. 06ControllerとWidgetのテスト

活用シーン

  1. 01検索入力の変更を遅延処理
  2. 02アプリ全体の認証サービス管理
  3. 03ルートごとの依存関係の整理
  4. 04リアクティブなControllerの動作テスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/kaakati/rails-enterprise-dev --skill advanced-getx-patterns

インストール後、Claude Code で/advanced-getx-patternsを入力して読み込まれたか確認します。

詳細

高度な GetX パターン

適切な状態管理、依存性注入、ネットワーク通信を用いて高度なリアクティブアプリケーションを構築するための、GetX の高度なパターンです。

Worker - リアクティブな副作用

Worker を使うと、監視対象の値が変化したときにコールバックを実行できます。

ever - 変更のたびに実行
class UserController extends GetxController {
  final user = Rx<User?>(null);
  final isAuthenticated = false.obs;

  @override
  void onInit() {
    super.onInit();

    // Execute callback every time user changes
    ever(user, (User? userData) {
      if (userData != null) {
        print('User logged in: ${userData.name}');
        isAuthenticated.value = true;
      } else {
        print('User logged out');
        isAuthenticated.value = false;
      }
    });
  }
}
once - 1 回だけ実行
class OnboardingController extends GetxController {
  final hasCompletedOnboarding = false.obs;

  @override
  void onInit() {
    super.onInit();

    // Execute only the first time value becomes true
    once(hasCompletedOnboarding, (_) {
      Get.offAllNamed(AppRoutes.home);
      // This won't run again even if value changes back to false and then true
    });
  }
}
debounce - 実行を遅延
class SearchController extends GetxController {
  final searchQuery = ''.obs;
  final searchResults = <Product>[].obs;
  final isSearching = false.obs;

  @override
  void onInit() {
    super.onInit();

    // Wait 800ms after user stops typing before searching
    debounce(
      searchQuery,
      (_) => performSearch(),
      time: const Duration(milliseconds: 800),
    );
  }

  Future<void> performSearch() async {
    if (searchQuery.value.isEmpty) {
      searchResults.clear();
      return;
    }

    isSearching.value = true;
    final result = await repository.search(searchQuery.value);
    result.fold(
      (failure) => searchResults.clear(),
      (products) => searchResults.value = products,
    );
    isSearching.value = false;
  }
}
interval - 定期的に実行
class DashboardController extends GetxController {
  final stats = Rx<DashboardStats?>(null);

  @override
  void onInit() {
    super.onInit();

    // Refresh stats every 30 seconds while value changes
    interval(
      stats,
      (_) => refreshStats(),
      time: const Duration(seconds: 30),
    );

    // Initial load
    refreshStats();
  }

  Future<void> refreshStats() async {
    final result = await repository.getStats();
    result.fold(
      (failure) => {},
      (data) => stats.value = data,
    );
  }
}
Worker のベストプラクティス
class MyController extends GetxController {
  final count = 0.obs;
  Worker? _countWorker;

  @override
  void onInit() {
    super.onInit();

    // Store worker reference for manual disposal
    _countWorker = ever(count, (value) {
      print('Count changed to: $value');
    });
  }

  @override
  void onClose() {
    // Dispose worker manually if needed
    _countWorker?.dispose();
    super.onClose();
  }
}

GetxService - 永続サービス

GetxService のインスタンスは破棄されないため、アプリ全体で利用するサービスに最適です。

サービスの作成
import 'package:get/get.dart';

class AuthService extends GetxService {
  final _isAuthenticated = false.obs;
  bool get isAuthenticated => _isAuthenticated.value;

  final _currentUser = Rx<User?>(null);
  User? get currentUser => _currentUser.value;

  // Called when service is first created
  @override
  Future<void> onInit() async {
    super.onInit();
    await _loadSavedAuth();
  }

  Future<void> _loadSavedAuth() async {
    final storage = Get.find<GetStorage>();
    final token = storage.read<String>('auth_token');
    if (token != null) {
      await validateToken(token);
    }
  }

  Future<void> login(String email, String password) async {
    final result = await repository.login(email, password);
    result.fold(
      (failure) => throw Exception(failure.message),
      (user) {
        _currentUser.value = user;
        _isAuthenticated.value = true;
        Get.find<GetStorage>().write('auth_token', user.token);
      },
    );
  }

  void logout() {
    _currentUser.value = null;
    _isAuthenticated.value = false;
    Get.find<GetStorage>().remove('auth_token');
  }

  @override
  void onClose() {
    // GetxService onClose is never called
    // Service persists throughout app lifecycle
    super.onClose();
  }
}
サービスの登録
// In main.dart or dependency injection setup
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await GetStorage.init();

  // Register permanent services
  Get.putAsync(() => AuthService().init(), permanent: true);
  Get.put(ThemeService(), permanent: true);
  Get.put(LocalizationService(), permanent: true);

  runApp(MyApp());
}

// With async initialization
class AuthService extends GetxService {
  Future<AuthService> init() async {
    await _loadSavedAuth();
    return this;
  }
}

SmartManagement - ライフサイクル制御

GetX によるコントローラーのライフサイクル管理方法を制御します。

SmartManagement のモード
void main() {
  runApp(
    GetMaterialApp(
      smartManagement: SmartManagement.full, // Default
      home: HomePage(),
    ),
  );
}

SmartManagement.full(デフォルト):

  • ルートを閉じたときにコントローラーを破棄します
  • 最もメモリ効率に優れています
  • ほとんどのアプリに推奨されます

SmartManagement.onlyBuilder:

  • GetBuilder で作成されたコントローラーのみを破棄します
  • Get.find() のインスタンスは保持されます
  • 手動で制御する必要がある場合に使用します

SmartManagement.keepFactory:

  • ファクトリーのインスタンスを保持します
  • 同じ依存関係を使ってコントローラーを再作成できます
  • 複雑な依存関係グラフに使用します
コントローラーの手動破棄
class ManualController extends GetxController {
  // This controller won't auto-dispose
}

// Register without auto-dispose
Get.put(ManualController(), permanent: true);

// Manually dispose when needed
Get.delete<ManualController>();

// Or with tag
Get.put(ManualController(), tag: 'unique-tag');
Get.delete<ManualController>(tag: 'unique-tag');

GetConnect - HTTP クライアント

GetConnect は、インターセプターとベース URL の設定に対応した強力な HTTP クライアントを提供します。

基本設定
class ApiProvider extends GetConnect {
  @override
  void onInit() {
    // Base URL
    httpClient.baseUrl = 'https://api.example.com';

    // Default timeout
    httpClient.timeout = const Duration(seconds: 30);

    // Request interceptor
    httpClient.addRequestModifier<dynamic>((request) {
      // Add auth token to all requests
      final token = Get.find<AuthService>().token;
      if (token != null) {
        request.headers['Authorization'] = 'Bearer $token';
      }
      request.headers['Content-Type'] = 'application/json';
      return request;
    });

    // Response interceptor
    httpClient.addResponseModifier((request, response) {
      // Log responses in debug mode
      if (kDebugMode) {
        print('Response: ${response.statusCode} ${response.bodyString}');
      }
      return response;
    });

    // Auth interceptor
    httpClient.addAuthenticator<dynamic>((request) async {
      // Refresh token if 401
      final token = await refreshToken();
      request.headers['Authorization'] = 'Bearer $token';
      return request;
    });
  }

  // GET request
  Future<Response<List<Product>>> getProducts() {
    return get<List<Product>>(
      '/products',
      decoder: (data) => (data as List)
          .map((item) => Product.fromJson(item))
          .toList(),
    );
  }

  // POST request
  Future<Response<User>> createUser(User user) {
    return post<User>(
      '/users',
      user.toJson(),
      decoder: (data) => User.fromJson(data),
    );
  }

  // PUT request
  Future<Response<User>> updateUser(String id, User user) {
    return put<User>(
      '/users/$id',
      user.toJson(),
      decoder: (data) => User.fromJson(data),
    );
  }

  // DELETE request
  Future<Response> deleteUser(String id) {
    return delete('/users/$id');
  }
}
リポジトリーでの GetConnect の使用
class UserRepositoryImpl implements UserRepository {
  final ApiProvider apiProvider;

  UserRepositoryImpl({required this.apiProvider});

  @override
  Future<Either<Failure, List<User>>> getUsers() async {
    try {
      final response = await apiProvider.get<List<User>>(
        '/users',
        decoder: (data) => (data as List)
            .map((json) => User.fromJson(json))
            .toList(),
      );

      if (response.hasError) {
        return Left(ServerFailure(response.statusText ?? 'Unknown error'));
      }

      return Right(response.body!);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }
}

Binding の合成

複数の Binding を組み合わせて、複雑な機能を構成します。

Binding の作成
// Feature binding
class ProductBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut(() => ProductRemoteDataSource(api: Get.find()));
    Get.lazyPut(() => ProductLocalDataSource(storage: Get.find()));
    Get.lazyPut<ProductRepository>(
      () => ProductRepositoryImpl(
        remoteDataSource: Get.find(),
        localDataSource: Get.find(),
      ),
    );
    Get.lazyPut(() => GetProducts(repository: Get.find()));
    Get.lazyPut(() => ProductController(getProducts: Get.find()));
  }
}

// Another feature binding
class CartBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut(() => CartLocalDataSource(storage: Get.find()));
    Get.lazyPut<CartRepository>(
      () => CartRepositoryImpl(localDataSource: Get.find()),
    );
    Get.lazyPut(() => AddToCart(repository: Get.find()));
    Get.lazyPut(() => RemoveFromCart(repository: Get.find()));
    Get.lazyPut(() => CartController(
      addToCart: Get.find(),
      removeFromCart: Get.find(),
    ));
  }
}

// Combine bindings
class ProductDetailsBinding extends Bindings {
  @override
  void dependencies() {
    // Register product dependencies
    ProductBinding().dependencies();
    // Register cart dependencies
    CartBinding().dependencies();
    // Register page-specific controller
    Get.lazyPut(() => ProductDetailsController(
      product: Get.find(),
      cart: Get.find(),
    ));
  }
}
BindingsBuilder の使用
GetPage(
  name: '/checkout',
  page: () => CheckoutPage(),
  binding: BindingsBuilder(() {
    // Quick inline bindings
    Get.lazyPut(() => CheckoutController());
    Get.lazyPut(() => PaymentService());
    Get.lazyPut(() => ShippingService());
  }),
)

// Or with multiple bindings
GetPage(
  name: '/checkout',
  page: () => CheckoutPage(),
  bindings: [
    CartBinding(),
    PaymentBinding(),
    ShippingBinding(),
  ],
)

Rx の高度なパターン

カスタム Rx クラス
class RxUser extends Rx<User> {
  RxUser(User initial) : super(initial);

  String get fullName => value.firstName + ' ' + value.lastName;

  bool get isAdmin => value.role == 'admin';

  void updateEmail(String email) {
    value = value.copyWith(email: email);
  }
}

// Usage
final user = RxUser(User(firstName: 'John', lastName: 'Doe'));
user.updateEmail('[email protected]');
Rx の変換
class DataController extends GetxController {
  final rawData = <Item>[].obs;

  // Computed observable
  List<Item> get filteredData => rawData.where((item) => item.isActive).toList();

  // Or use Rx.map
  late final activeItems = rawData.map((data) =>
      data.where((item) => item.isActive).toList()
  ).obs;
}

GetQueue - バックグラウンドタスク

class UploadQueue extends GetxService {
  final _queue = <UploadTask>[].obs;

  void addTask(UploadTask task) {
    _queue.add(task);
    processQueue();
  }

  Future<void> processQueue() async {
    while (_queue.isNotEmpty) {
      final task = _queue.first;
      try {
        await _uploadFile(task);
        _queue.removeAt(0);
      } catch (e) {
        // Retry logic
        task.retryCount++;
        if (task.retryCount >= 3) {
          _queue.removeAt(0); // Give up after 3 retries
        } else {
          await Future.delayed(Duration(seconds: task.retryCount * 2));
        }
      }
    }
  }

  Future<void> _uploadFile(UploadTask task) async {
    // Upload logic
  }
}

GetX コントローラーのテスト

単体テスト
void main() {
  late ProductController controller;
  late MockGetProducts mockGetProducts;

  setUp(() {
    mockGetProducts = MockGetProducts();
    controller = ProductController(getProducts: mockGetProducts);
  });

  tearDown(() {
    controller.dispose();
  });

  test('loads products successfully', () async {
    // Arrange
    final products = [Product(id: '1', name: 'Product 1')];
    when(() => mockGetProducts())
        .thenAnswer((_) async => Right(products));

    // Act
    await controller.loadProducts();

    // Assert
    expect(controller.products, products);
    expect(controller.isLoading, false);
    verify(() => mockGetProducts()).called(1);
  });

  test('handles failure when loading products', () async {
    // Arrange
    when(() => mockGetProducts())
        .thenAnswer((_) async => Left(ServerFailure('Error')));

    // Act
    await controller.loadProducts();

    // Assert
    expect(controller.products, isEmpty);
    expect(controller.error, isNotNull);
    expect(controller.isLoading, false);
  });
}
GetX を使ったウィジェットテスト
testWidgets('displays products when loaded', (tester) async {
  // Mock controller
  final controller = ProductController(getProducts: mockGetProducts);
  Get.put(controller);

  // Pump widget
  await tester.pumpWidget(
    GetMaterialApp(
      home: ProductListPage(),
    ),
  );

  // Simulate loading
  controller.products.value = [Product(id: '1', name: 'Product 1')];
  await tester.pumpAndSettle();

  // Assert
  expect(find.text('Product 1'), findsOneWidget);

  // Cleanup
  Get.delete<ProductController>();
});

ベストプラクティス

  1. Worker:

    • 検索入力には debounce を使用します(遅延時間は 800ms)。
    • 副作用(分析、ナビゲーション)には ever を使用します
    • 1 回限りのアクション(オンボーディング)には once を使用します
    • 定期的な更新(30 秒以上)には interval を使用します
    • onClose() で必ず Worker を破棄します
  2. GetxService:

    • アプリ全体で使用するサービス(認証、テーマ、ローカライズ)に使用します
    • permanent: true を指定して登録します
    • 非同期処理は init() メソッドで初期化します
    • 可能な限り、サービスはステートレスまたはイミュータブルにします
  3. SmartManagement:

    • ほとんどのアプリでは、デフォルトの SmartManagement.full を使用します
    • メモリ管理に特別な要件がある場合にのみ変更します
    • デフォルト以外の管理モードを使用する理由を記録します
  4. GetConnect:

    • API ごとに GetConnect インスタンスを 1 つ作成します
    • 認証、ロギング、エラー処理にはインターセプターを使用します
    • addAuthenticator にリトライロジックを実装します
    • リポジトリー内で一貫した方法でエラーを処理します
  5. Binding:

    • 機能/ルートごとに Binding を 1 つ用意します
    • 依存関係には lazyPut を使用します(初回使用時に読み込まれます)
    • すぐに必要なシングルトンには put を使用します
    • 複雑な機能では Binding を合成します
  6. テスト:

    • tearDown() で必ずコントローラーを破棄します
    • テスト間ですべての依存関係をクリアするには Get.reset() を使用します
    • リポジトリーではなくユースケースをモックします
    • pumpAndSettle() を使ってリアクティブな状態変化をテストします

コメント