Advanced GetX Patterns について
Advanced GetX Patternsは、GetXでリアクティブなDartアプリを構築する開発者向けガイドです。状態変化への副作用、依存関係とライフサイクルの管理、HTTP通信、バックグラウンドキュー、テストを扱います。
主な機能
- 01リアクティブなWorkerと副作用
- 02常駐サービスとライフサイクル制御
- 03GetConnectによるHTTPクライアントのパターン
- 04依存関係の複合バインディング
- 05Rx変換とバックグラウンドキュー
- 06ControllerとWidgetのテスト
活用シーン
- 01検索入力の変更を遅延処理
- 02アプリ全体の認証サービス管理
- 03ルートごとの依存関係の整理
- 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>();
});
ベストプラクティス
-
Worker:
- 検索入力には
debounceを使用します(遅延時間は 800ms)。 - 副作用(分析、ナビゲーション)には
everを使用します - 1 回限りのアクション(オンボーディング)には
onceを使用します - 定期的な更新(30 秒以上)には
intervalを使用します onClose()で必ず Worker を破棄します
- 検索入力には
-
GetxService:
- アプリ全体で使用するサービス(認証、テーマ、ローカライズ)に使用します
permanent: trueを指定して登録します- 非同期処理は
init()メソッドで初期化します - 可能な限り、サービスはステートレスまたはイミュータブルにします
-
SmartManagement:
- ほとんどのアプリでは、デフォルトの
SmartManagement.fullを使用します - メモリ管理に特別な要件がある場合にのみ変更します
- デフォルト以外の管理モードを使用する理由を記録します
- ほとんどのアプリでは、デフォルトの
-
GetConnect:
- API ごとに
GetConnectインスタンスを 1 つ作成します - 認証、ロギング、エラー処理にはインターセプターを使用します
addAuthenticatorにリトライロジックを実装します- リポジトリー内で一貫した方法でエラーを処理します
- API ごとに
-
Binding:
- 機能/ルートごとに Binding を 1 つ用意します
- 依存関係には
lazyPutを使用します(初回使用時に読み込まれます) - すぐに必要なシングルトンには
putを使用します - 複雑な機能では Binding を合成します
-
テスト:
tearDown()で必ずコントローラーを破棄します- テスト間ですべての依存関係をクリアするには
Get.reset()を使用します - リポジトリーではなくユースケースをモックします
pumpAndSettle()を使ってリアクティブな状態変化をテストします
コメント
Markdown 対応