Flutter app architecture 101: Vanilla, Scoped Model, BLoC +5



(originally published on Medium)


Flutter provides a modern react-style framework, rich widget collection and tooling, but there’s nothing similar to Android’s guide to app architecture.


Indeed, there’s no ultimate architecture that would meet all the possible requirements, yet let’s face the fact that most of the mobile apps we are working on have at least some of the following functionality:


  1. Request/upload data from/to the network.
  2. Map, transform, prepare data and present it to the user.
  3. Put/get data to/from the database.

Taking this into account I have created a sample app that is solving exactly the same problem using three different approaches to the architecture.


User is presented with a button “Load user data” in the center of the screen. When user clicks the button asynchronous data loading is triggered and the button is replaced with a loading indicator. After data is loaded loading indicator is replaced with the data.


Let’s get started.



Data


For the purpose of simplicity I have created Repository class that contains getUser() method that emulates an asynchronous network call and returns Future<User> object with hardcoded values.


If you are not familiar with Futures and asynchronous programming in Dart you can learn more about it by following this tutorial and reading a doc.


class Repository {
  Future<User> getUser() async {
    await Future.delayed(Duration(seconds: 2));
    return User(name: 'John', surname: 'Smith');
  }
}

class User {
  User({
    @required this.name,
    @required this.surname,
  });

  final String name;
  final String surname;
}

Vanilla


Let’s build the app in the way most developers would do after reading official Flutter documentation.


Navigating to VanillaScreen screen using Navigator


Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => VanillaScreen(_repository),
  ),
);

As the state of the widget could change several times during the lifetime of the widget we should extend StatefulWidget. Implementing a stateful widget also requires to have a State class. Fields bool _isLoading and User _user in _VanillaScreenState class represent the state of the widget. Both fields are initialised before the build(BuildContext context) method is called.


class VanillaScreen extends StatefulWidget {
  VanillaScreen(this._repository);
  final Repository _repository;

  @override
  State<StatefulWidget> createState() => _VanillaScreenState();
}

class _VanillaScreenState extends State<VanillaScreen> {
  bool _isLoading = false;
  User _user;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Vanilla'),
      ),
      body: SafeArea(
        child: _isLoading ? _buildLoading() : _buildBody(),
      ),
    );
  }

  Widget _buildBody() {
    if (_user != null) {
      return _buildContent();
    } else {
      return _buildInit();
    }
  }

  Widget _buildInit() {
    return Center(
      child: RaisedButton(
        child: const Text('Load user data'),
        onPressed: () {
          setState(() {
            _isLoading = true;
          });
          widget._repository.getUser().then((user) {
            setState(() {
              _user = user;
              _isLoading = false;
            });
          });
        },
      ),
    );
  }

  Widget _buildContent() {
    return Center(
      child: Text('Hello ${_user.name} ${_user.surname}'),
    );
  }

  Widget _buildLoading() {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
}

When the widget state object is created build(BuildContext context) method is called to build the UI. All the decisions about the widgets that should be built to represent the current state are made in the UI declaration code.


body: SafeArea(
  child: _isLoading ? _buildLoading() : _buildBody(),
)

In order do display progress indicator when user click “Load user details” button we do following.


setState(() {
  _isLoading = true;
});

Calling setState() notifies the framework that the internal state of this object has changed in a way that might impact the user interface in this subtree, which causes the framework to schedule a build for this State object.

That means that after calling setState() method build(BuildContext context) method is called by the framework again and the whole widget tree is rebuilt. As _isLoading is now set to true method _buildLoading() is called instead of _buildBody() and loading indicator is displayed on the screen. Exactly the same happens when we handle callback from getUser() and call setState() to reassign _isLoading and _user fields.


widget._repository.getUser().then((user) {
  setState(() {
    _user = user;
    _isLoading = false;
  });
});

Pros


  1. Easy to learn and understand.
  2. No third-party libraries are required.

Cons


  1. The whole widget tree is rebuilt every time widget state changes.
  2. It’s breaking the single responsibility principle. Widget is not only responsible for building the UI, it’s also responsible for data loading, business logic and state management.
  3. Decisions about how the current state should be represented are made in the UI declaration code. If we would have a bit more complex state code readability would decrease.

Scoped Model


Scoped Model is a third-party package that is not included into Flutter framework. This is how the developers of Scoped Model describe it:


A set of utilities that allow you to easily pass a data Model from a parent Widget down to its descendants. In addition, it also rebuilds all of the children that use the model when the model is updated. This library was originally extracted from the Fuchsia codebase.

Let’s build the same screen using Scoped Model. First, we need to install Scoped Model package by adding scoped_model dependency to pubspec.yaml under dependencies section.


scoped_model: ^1.0.1

As we want to make our model available to all the widget’s descendants we should wrap it with generic ScopedModel and provide a widget and a model.


Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => ScopedModel<UserModel>(
          model: UserModel(_repository),
          child: UserModelScreen(),
        ),
  ),
);

Let’s take a look at UserModelScreen widget and compare it with the previous example that was built without using Scoped Model.


class UserModelScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Scoped model'),
      ),
      body: SafeArea(
        child: ScopedModelDescendant<UserModel>(
          builder: (context, child, model) {
            if (model.isLoading) {
              return _buildLoading();
            } else {
              if (model.user != null) {
                return _buildContent(model);
              } else {
                return _buildInit(model);
              }
            }
          },
        ),
      ),
    );
  }

  Widget _buildInit(UserModel userModel) {
    return Center(
      child: RaisedButton(
        child: const Text('Load user data'),
        onPressed: () {
          userModel.loadUserData();
        },
      ),
    );
  }

  Widget _buildContent(UserModel userModel) {
    return Center(
      child: Text('Hello ${userModel.user.name} ${userModel.user.surname}'),
    );
  }

  Widget _buildLoading() {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
}

The most noticeable change is that UserModelScreen extends StatelessWidget instead of StatefulWidget. In the previous example when we used StatefulWidget the whole widget tree was rebuilt when widget’s state changed. But do we actually require to rebuild the whole screen? For example AppBar shouldn’t change at all so there’s no point in rebuilding it. Ideally, we should rebuild only those widgets that are updated. Scoped Model can help us to solve that.


ScopedModelDescendant<UserModel> widget is used to find UserModel in the Widget tree. It will be automatically rebuilt whenever the UserModel notifies that change has taken place.


Another improvement is that UserModelScreen is not anymore responsible for the state management and business logic.


Let’s take a look at UserModel code.


class UserModel extends Model {
  UserModel(this._repository);
  final Repository _repository;

  bool _isLoading = false;
  User _user;

  User get user => _user;
  bool get isLoading => _isLoading;

  void loadUserData() {
    _isLoading = true;
    notifyListeners();
    _repository.getUser().then((user) {
      _user = user;
      _isLoading = false;
      notifyListeners();
    });
  }

  static UserModel of(BuildContext context) =>
      ScopedModel.of<UserModel>(context);
}

Now UserModel holds and manages the state. In order to notify listeners (and rebuild descendants) that the change took place notifyListeners() method should be called.


Pros


  1. Business logic, state management and UI code separation.
  2. Easy to learn.

    Cons

  3. Requires third-party library.
  4. As model gets more and more complex it’s hard to keep track when you should call notifyListeners().

BLoC


BLoC (Business Logic Components) is a pattern recommended by Google developers. It leverages streams functionality in order to manage and propagate state changes.


No third-party libraries are required to implement BLoC pattern, nevertheless we require BlocProvider helper class.


Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => BlocProvider(
          bloc: UserBloc(_repository),
          child: UserBlocScreen(),
        ),
  ),
);

For Android developers: You can think of Bloc object as a ViewModel and of StreamController as a LiveData. This will make the following code very straightforward as you’re already familiar with the concepts.


class UserBloc extends BlocBase {
  UserBloc(this._repository);

  final Repository _repository;

  final _userStreamController = StreamController<UserState>();

  Stream<UserState> get user => _userStreamController.stream;

  void loadUserData() {
    _userStreamController.sink.add(UserState._userLoading());
    _repository.getUser().then((user) {
      _userStreamController.sink.add(UserState._userData(user));
    });
  }

  @override
  void dispose() {
    _userStreamController.close();
  }
}

class UserState {
  UserState();
  factory UserState._userData(User user) = UserDataState;
  factory UserState._userLoading() = UserLoadingState;
}

class UserInitState extends UserState {}

class UserLoadingState extends UserState {}

class UserDataState extends UserState {
  UserDataState(this.user);
  final User user;
}

No additional method calls are required to notify subscribers when the state changes.


I have created 3 classes to represent possible states of the screen:


UserInitState for the state, when user opens a screen with a button in the center.


UserLoadingState for the state, when loading indicator is displayed while data is being loaded.


UserDataState for the state, when data is loaded and displayed on the screen.


Propagating state changes in this way allows us to get rid of all the logic in the UI declaration code. In example with Scoped Model we still were checking if _isLoading is true in the UI declaration code to decide which widget we should render. In case with BLoC we are propagating the state of the screen and the only responsibility of UserBlocScreen widget is to render the UI for this state.


class UserBlocScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final UserBloc userBloc = BlocProvider.of(context);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Bloc'),
      ),
      body: SafeArea(
        child: StreamBuilder<UserState>(
          stream: userBloc.user,
          initialData: UserInitState(),
          builder: (context, snapshot) {
            if (snapshot.data is UserInitState) {
              return _buildInit(userBloc);
            }
            if (snapshot.data is UserDataState) {
              UserDataState state = snapshot.data;
              return _buildContent(state.user);
            }
            if (snapshot.data is UserLoadingState) {
              return _buildLoading();
            }
          },
        ),
      ),
    );
  }

  Widget _buildInit(UserBloc userBloc) {
    return Center(
      child: RaisedButton(
        child: const Text('Load user data'),
        onPressed: () {
          userBloc.loadUserData();
        },
      ),
    );
  }

  Widget _buildContent(User user) {
    return Center(
      child: Text('Hello ${user.name} ${user.surname}'),
    );
  }

  Widget _buildLoading() {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
}

UserBlocScreen code got even simpler in comparison to the previous examples. To listen to the state changes changes we are using StreamBuilder. StreamBuilder is a StatefulWidget that builds itself based on the latest snapshot of interaction with a Stream.


Pros


No third-party libraries needed.
Business logic, state management and UI logic separation.
It’s reactive. No additional calls are needed like in case with Scoped Model’s notifyListeners().


Cons


Experience working with streams or rxdart is required.



You can checkout the source code of the examples above form this github repo.


Originally article is published on Medium




К сожалению, не доступен сервер mySQL