简体   繁体   中英

Flutter app architecture using "modules" with provider package

I've been coding an app in Flutter since few weeks now and started wondering what the best architecture could be a few days ago.

A little bit of context first:

  • It's a messaging app using Firebase as a backend;
  • It's heavily relying on the wonderful Provider package to handle the state throughout the whole app
  • The plan is to have multiple features, that can interact with each other.
  • I'm fairly new to Flutter (React/ReactNative background mostly), it can explain the strange approach I've below.

I've been experiencing different architecture approaches and managed to get one working that finally seems to suit me.

As I'll have multiple features, reusable at different places in the app, I want to split the code by features(or Modules) that can then be used independently in different screens. The folder architecture would be like this:

FlutterApp
|
|--> ios/
|--> android/
|--> lib/
      |
      |--> main.dart
      |--> screens/
      |       |
      |       |--> logged/
      |       |      |
      |       |      |--> profile.dart
      |       |      |--> settings.dart
      |       |      |--> ...
      |       |
      |       |--> notLogged/
      |       |      |
      |       |      |--> home.dart
      |       |      |--> loading.dart
      |       |      |--> ...
      |       
      |--> features/
              |
              |--> featureA/
              |       |
              |       |--> ui/
              |       |     |--> simpleUI.dart
              |       |     |--> complexUI.dart
              |       |--> provider/
              |       |     |-->featureAProvider.dart
              |       |--> models/
              |             |--> featureAModel1.dart
              |             |--> featureAModel2.dart
              |             |--> ...
              |
              |
              |--> featureB/
              |       |
              |       |--> ui/
              |       |     |--> simpleUI.dart
              |       |     |--> complexUI.dart
              |       |--> provider/
              |       |     |--> featureBProvider.dart
              |       |--> models/
              |             |--> featureBModel1.dart
              |             |--> featureBModel2.dart
              |             |--> ...
              |
             ...

Ideally each feature would follow these guidelines:

  • Each feature has a logic part (often using Provider Package);
  • Each feature logic part can request variables (ChangeNotifier class members) from another Feature
  • Each feature has a (dumb) UI part that can directly interact with the "logic" part (thus maybe not so dumb);
  • Each feature can have its UI part being replaced by a custom UI but then, the custom UI must implement the interaction(s) with the logic part on its own;
  • Each feature, can have models that are the representation of the feature ressources if I need to store them in Firebase later

I've tried this approach with one feature (or 2 depends how you see it) of my app, which is the ability to record / listen to voice notes. I found it interesting because you can record at one place but listen to the recording at many places: just after the recording for instance or when a recording is sent to you as well.

Here is what I came up with:

  • folder structure of the test There is no models/ folder in this case because it's just a file that I'm handling elsewhere
  • The voiceRecorderNotifier handles a file (add/remove) and a recording (start/end)
  • The voicePlayerNotifier requires a file to be instanciated (named constructor) and then handles the audio file playing (play, pause, stop).

In the code, it's a bit verbose but works as expected, for instance, in the screens, I can request the voiceRecorder feature like this:

class Screen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => VoiceRecorderNotifier(),
      child: Column(
        children: [
          AnUIWidget(),
          AnotherUIWidget(),
          ...,
          // The "dumb" feature UI widget from 'features/voiceRecorder/ui/simpleButton.dart' that can be overrided if you follow use the VoiceRecorderNotifier
          VoiceRecorderButtonSimple(), 
          ...,
        ]
      )
    );
  }
}

I can have the two features (voiceRecorder / voicePlayer) working together as well, like this:

class Screen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => VoiceRecorderNotifier(),
      child: Column(
        children: [
          AnUIWidget(),
          AnotherUIWidget(),
          ...,
          VoiceRecorderButtonSimple(),
          ...,
          // adding the dependent voicePlayer feature (using the VoiceRecorderNotifier data);
          Consumer<VoiceRecorderNotifier>(
            builder: (_, _voiceRecorderNotifier, __) {
              if (_voiceRecorderNotifier.audioFile != null) {
                // We do have audio file, so we put the VoicePlayer feature
                return ChangeNotifierProvider<VoicePlayerNotifier>(
                  create: (_) => VoicePlayerNotifier.withFile(_voiceRecorderNotifier.audioFile),
                  child: VoicePlayerButtonSimple(),
                );
              } else {
                // We don't have audio file, so no voicePlayer needed
                return AnotherUIWidget(); 
              }
            }
          ),
          ...,
          AnotherUIWidget(),
        ]
      )
    );
  }
}

It's a fresh test so I assume that there are drawbacks that I can't see now but I feel like there is few good points:

  • Cleaner folders structure;
  • One place to handle the "high level" logic related to the feature, easy to update;
  • It's easy to add, move, remove the feature everywhere is the app;
  • A basic UI is provided for the feature to work as expected, such as a simple
Text('hi')

but I can still "override" the UI for specific display of the feature;

  • I can focus on UI and usage of features rather than creating a lot of Stateful components to replicate the same logic of a feature a different places;

The drawbacks I see:

  • Feature logic is "hidden", I'll need to go through the Notifier each time I want to do something specific with the feature to remember how the feature behaves;
  • Implementing the notifiers at the good place can become a mess, if a UI widgets can have multiple features usage then i'll require multiple FeatureNotifier (even if Multiprovider is useful in this case);

Finally, here are the questions:

  • Do you think this approach is scalable/recommended thus if I can continue creating features this way without having troubles later?
  • Do you see any other drawbacks?

Provider is a great tool that can help you access data all throughout the app. I don't seen any issues on how it's currently implemented on your app.

On the points that you're looking for like handling logic and updating UI, you may want to look into BloC pattern. With this, you can handle UI updates via Stream and the UI can be updated on StreamBuilder.

This sample demonstrates updating the UI on the Flutter app using BloC pattern. Here's the part where all logic can be handled. Timer is used to mock the waiting time for an HTTP response.

class Bloc {
  /// UI updates can be handled in Bloc
  final _repository = Repository();
  final _progressIndicator = StreamController<bool>.broadcast();
  final _updatedNumber = StreamController<String>.broadcast();

  /// StreamBuilder listens to [showProgress] to update UI to show/hide the LinearProgressBar
  Stream<bool> get showProgress => _progressIndicator.stream;

  /// StreamBuilder listens to [updatedNumber] to update UI 
  Stream<String> get updatedNumber => _updatedNumber.stream;

  updateShowProgress(bool showProgress) {
    _progressIndicator.sink.add(showProgress);
  }

  /// Updates the List<UserThreads> Stream
  fetchUpdatedNumber(String number) async {
    bloc.updateShowProgress(true); // Show ProgressBar

    /// Timer mocks an instance where we're waiting for
    /// a response from the HTTP request
    Timer(Duration(seconds: 2), () async {
      // delay for 4 seconds to display LinearProgressBar
      var updatedNumber = await _repository.fetchUpdatedNumber(number);
      _updatedNumber.sink.add(updatedNumber); // Update Stream
      bloc.updateShowProgress(false); // Hide ProgressBar
    });
  }

  dispose() {
    _updatedNumber.close();
  }

  disposeProgressIndicator() {
    _progressIndicator.close();
  }
}

/// this enables Bloc to be globally accessible
final bloc = Bloc();

/// Class where we can keep Repositories that can be accessed in Bloc class
class Repository {
  final provider = Provider();

  Future<String> fetchUpdatedNumber(String number) =>
      provider.updateNumber(number);
}

/// Class where all backend tasks can be handled
class Provider {
  Future<String> updateNumber(String number) async {
    /// HTTP requests can be done here
    return number;
  }
}

Here's our main app. Notice how we don't need to call setState() to refresh the UI anymore. UI updates are dependent on the StreamBuilder set on them. With each update on Stream with StreamController.broadcast.sink.add(Object) , StreamBuilder gets rebuilt again to update the UI. StreamBuilder was also used to show/hide the LinearProgressBar.

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'BloC Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    bloc.fetchUpdatedNumber('${++_counter}');
    // setState(() {
    //   _counter++;
    // });
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<bool>(
        stream: bloc.showProgress,
        builder: (BuildContext context, AsyncSnapshot<bool> progressBarData) {
          /// To display/hide LinearProgressBar
          /// call bloc.updateShowProgress(bool)
          var showProgress = false;
          if (progressBarData.hasData && progressBarData.data != null)
            showProgress = progressBarData.data!;
          return Scaffold(
            appBar: AppBar(
              title: Text(widget.title),
              bottom: showProgress
                  ? PreferredSize(
                      preferredSize: Size(double.infinity, 4.0),
                      child: LinearProgressIndicator())
                  : null,
            ),
            body: StreamBuilder<String>(
                stream: bloc.updatedNumber,
                builder: (BuildContext context,
                    AsyncSnapshot<String> numberSnapshot) {
                  var number = '0';
                  if (numberSnapshot.hasData && numberSnapshot.data != null)
                    number = numberSnapshot.data!;
                  return Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: <Widget>[
                        Text(
                          'You have pushed the button this many times:',
                        ),
                        Text(
                          '$number',
                          style: Theme.of(context).textTheme.headline4,
                        ),
                      ],
                    ),
                  );
                }),
            floatingActionButton: FloatingActionButton(
              onPressed: _incrementCounter,
              tooltip: 'Increment',
              child: Icon(Icons.add),
            ),
          );
        });
  }
}

Demo

演示

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM