How Riverpod State Management Integrates with FlClash's CoreController
FlClash uses Riverpod as a reactive state layer that delegates platform-specific proxy operations to a singleton CoreController, creating a clean separation between UI state management and the underlying Clash Meta engine.
FlClash is a cross-platform proxy client built with Flutter that leverages Riverpod state management to maintain a responsive UI while offloading heavy lifting to a native Go core. The architecture centers on the CoreController, a singleton façade that abstracts platform-specific FFI calls and socket services. Understanding how these components interact is essential for contributors working with the chen08209/FlClash codebase.
Architecture Overview
The codebase follows a strict separation of concerns between the imperative native engine and the reactive Flutter UI. The CoreController handles low-level proxy operations, while Riverpod providers manage application state and widget rebuilds. This pattern ensures the Flutter layer remains testable, platform-agnostic, and responsive to changes in the underlying proxy engine.
CoreController as the Bridge to the Native Engine
Located in lib/core/controller.dart, the CoreController acts as a singleton façade that forwards all proxy-related calls to either the Android FFI library (coreLib) or the desktop socket service (coreService). It exposes async methods including init(), getConfig(), changeProxy(), and getTraffic().
The controller maintains an internal completion state via isCompleted, which indicates whether the Go core has finished its startup sequence. This flag is critical for lifecycle management, as providers check it before attempting to restart or reconfigure the engine.
Riverpod Providers for Reactive UI State
Riverpod providers annotated with @riverpod or @Riverpod(keepAlive: true) encapsulate UI-specific state such as traffic statistics, proxy groups, and selected profiles. These providers live in lib/providers/action.dart and lib/providers/state.dart.
Action notifiers serve as the coordination layer. Classes like CommonAction, CoreAction, and SetupAction invoke methods on the global coreController instance, await results, then update Riverpod state to trigger UI rebuilds. This keeps UI widgets free of business logic while maintaining reactive data flows.
The Bidirectional Interaction Pattern
The communication between Riverpod and CoreController follows a predictable cycle: notifiers trigger CoreController operations, process the returned data, then persist results back into Riverpod state. This creates a unidirectional data flow from user actions through the core engine and back to the interface.
Initializing the Core with CoreAction
The CoreAction notifier handles core initialization when the application starts. It first checks the CoreController's initialization status via isInit before invoking the expensive init() method.
class CoreAction extends _$CoreAction {
@override
void build() {}
Future<void> initCore() async {
final alreadyInit = await coreController.isInit;
final version = ref.read(versionProvider);
if (!alreadyInit) {
// Initialise the Go core
final result = await coreController.init(version);
commonPrint.log('init result: $result');
} else {
// Core already running – just refresh groups
await ref.read(proxiesActionProvider.notifier).updateGroups();
}
}
}
Fetching Traffic Data via CommonAction
CommonAction coordinates periodic traffic updates by reading user preferences from appSettingProvider, fetching statistics via coreController.getTraffic(), and storing results in trafficsProvider and totalTrafficProvider.
class CommonAction extends _$CommonAction {
@override
void build() {}
Future<void> updateTraffic() async {
final onlyStats = ref.read(
appSettingProvider.select((s) => s.onlyStatisticsProxy),
);
// Call the Core Controller
final traffic = await coreController.getTraffic(onlyStats);
// Store result in a Riverpod state notifier
ref.read(trafficsProvider.notifier).addTraffic(traffic);
// Also update total traffic state
ref.read(totalTrafficProvider.notifier).value =
await coreController.getTotalTraffic(onlyStats);
}
}
Managing Proxy Lifecycle with SetupAction
SetupAction orchestrates the start/stop sequence for the proxy service. The updateStatus() method checks coreController.isCompleted before attempting restarts, starts the Core's listener via _handleStart(), and manages the periodic timer that drives runtime UI updates.
class SetupAction extends _$SetupAction {
Future<void> updateStatus(bool start, {bool isInit = false}) async {
if (start) {
if (!isInit) {
final canStart = await ref
.read(coreActionProvider.notifier)
.tryStartCore(true);
if (canStart) return;
await _handleStart(); // starts listener & timer
applyProfileDebounce(force: true); // applies current profile
} else {
// start after app restart
await _handleStart();
await applyProfile(force: true);
}
} else {
await handleStop(); // stops listener, clears traffic
coreController.resetTraffic();
}
}
}
Deriving State Directly from the Core
Some providers read directly from the CoreController to derive fresh data without maintaining persistent state. The clashConfig provider in lib/providers/state.dart fetches raw configuration JSON via getConfig() and transforms it into typed ClashConfig objects.
@riverpod
Future<ClashConfig> clashConfig(Ref ref, int profileId) async {
// Direct Core Controller call
final configMap = await coreController.getConfig(profileId);
return ClashConfig.fromJson(configMap);
}
Key Files and Implementation Details
Understanding the file structure helps navigate the interaction between Riverpod and the CoreController:
lib/core/controller.dart– Contains the singletonCoreControllerclass that abstracts platform-specific implementations.lib/providers/action.dart– Houses Riverpod notifiers (CommonAction,SetupAction,CoreAction) that orchestrate core operations.lib/providers/state.dart– Defines reactive providers includingclashConfigthat fetch data directly fromcoreController.lib/providers/generated/*.g.dart– Contains Riverpod boilerplate generated by code generation (not hand-written).lib/main.dart– Application entry point that creates theProviderScopeand supplies the globalcoreControllerinstance.lib/common/common.dart– Utility helpers shared between Riverpod actions and the CoreController.
Summary
- The CoreController in
lib/core/controller.dartserves as a singleton façade over the platform-specific Clash Meta engine, exposing methods likeinit(),getTraffic(), andgetConfig(). - Riverpod providers act as reactive intermediaries, with notifiers in
lib/providers/action.dartinvoking CoreController methods and updating UI state. - The architecture enforces a unidirectional data flow: UI triggers actions → Actions call CoreController → Results update Riverpod state → Widgets rebuild automatically.
- Lifecycle safety is maintained by checking
coreController.isCompletedandisInitbefore performing destructive operations like restarting the core. - State derivation providers like
clashConfigread directly from the CoreController to ensure UI always reflects the actual native engine configuration.
Frequently Asked Questions
What is the CoreController's primary responsibility?
The CoreController acts as a singleton façade that forwards all proxy-related operations to the native Go core via FFI on Android or socket services on desktop. It exposes a Dart-friendly async API while hiding platform-specific implementation details, allowing Riverpod providers to remain platform-agnostic.
How does FlClash ensure UI updates when traffic changes?
The CommonAction notifier calls coreController.getTraffic() periodically, then updates trafficsProvider and totalTrafficProvider with the returned data. Because these are Riverpod state notifiers, any widget watching them automatically rebuilds when new traffic statistics arrive, creating a reactive pipeline from the native engine to the UI.
Why does the codebase use Riverpod instead of other state management solutions?
FlClash uses Riverpod for its compile-time safety, testability, and ability to handle complex asynchronous state. The @riverpod annotations generate type-safe providers that can be easily mocked for testing, while the keepAlive feature ensures critical services like the CoreController connection persist across widget rebuilds without manual lifecycle management.
What prevents the Core from being initialized multiple times?
The CoreAction.initCore() method checks coreController.isInit before calling init(). Additionally, CoreAction.tryStartCore() verifies coreController.isCompleted to ensure the Go core has finished its startup sequence before allowing restart operations. These guards prevent race conditions and resource leaks during the proxy lifecycle.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →