Skip to content
23 April 2020

Project Q – evaluating Flutter by building internal resourcing software

It’s hard to keep track of the skills of our colleagues in the ever-changing tech industry, especially skills they’re not using in their current project. Qvik no longer has that problem.

When a developer needs support for the tech they’re working with, who’s the best person to ask? When our talent acquisition team needs experts to evaluate the skills of potential hires, who do they bring in? What about when our sales team needs to determine the best candidate for a client project, where is the required information?

Enter Project Q, our internal resourcing software in which each Qvikie has an editable profile that reflects their expertise in key areas, such as agile methodologies, design and software development.

Many reasons to choose Flutter

With our staff using both Android and iOS devices as work phones, using a hybrid technology that allows us to build an app for both platforms with a single codebase makes a lot of sense. This is especially true when you consider the limited number of developers available to build this project, as most are tied up with client work at any given time.

React Native, being the de facto standard for high-quality hybrid applications, has been a staple in our toolbox for some time now. However, Flutter has been rapidly gaining in popularity since its release, and Project Q seemed like the perfect opportunity for us to take the time to evaluate Flutter as a technology.

The project is thus useful both as an internal tool and as a developer reference project for future Flutter endeavors.

Creating your Flutter blueprint

There are a lot of factors that come into play when determining whether a technology is ready to be used in actual client work. Architecture is a central factor contributing to an app’s long-term performance and maintainability. A well-structured codebase ensures, among other things, that the app is easy to understand and develop further.

A commonly used architectural approach is to separate the code into layers, each with its own responsibilities. The application framework is often associated with a certain architecture model, which helps developers figure out how to structure their applications.

Flutter is very architecture-agnostic, however, allowing developers more freedom with their designs. Looking into the multitude of options, we found that a tried-and-true MVVM (Model-View-ViewModel) approach suited Flutter as well as any, with the added benefit of being familiar to both iOS and Android developers.

As the name suggests, our chosen architecture model’s core layers are View, ViewModel, and Model. We have additionally separated things like API communication and persistence into their own ‘Service’ layer, all in an effort to ensure that the application’s source code remains as neatly encapsulated and modular as possible.

At the core of our Model layer is a class we’ve named AppState, which is responsible for initializing services. It then initializes Models and gives our ViewModels access to those Models. The Models describe our application’s state and contain the logic to update it, for which they can leverage services injected at initialization.

Each View in our application gets its own ViewModel, which provides relevant state to the View in a displayable format and contains state change callbacks to react to UI actions. Finally, the View simply displays the data and triggers state change commands in reaction to user interaction. In order to build this with a reactive approach, we chose to use the Provider package to inject our state into the UI. Using ChangeNotifierProviders in conjunction with Models that extend ChangeNotifier allows state to be injected into the UI reactively, so that changes in the relevant data are automatically displayed on the screen.

class Users extends ChangeNotifier {
  static final Users _singleton = Users._internal();
  ProjectQApi _projectQApi;
  List _users = [];
  bool _isFetching = false;

  List get users => _users;
  bool get isFetching => _isFetching;

  factory Users(ProjectQApi api) {
    _singleton._projectQApi = api;
    return _singleton;
  }

  factory Users.getInstance() {
    return _singleton;
  }

  Users._internal() {
    // Internal setup
  }

  Future loadUsers() async {
    startFetching();
    _users = await _projectQApi.loadUsers();
    _isFetching = false;
    notifyListeners();
  }

  void startFetching() {
    _isFetching = true;
    notifyListeners();
  }
}

Above you have the implementation of our Users Model. It contains logic for updating its state and extends ChangeNotifier, which allows us to register listeners and notify them of changes to the state.

class AppState {
  static final AppState _singleton = AppState._internal();
  static final PreferencesService _preferencesService = PreferencesService();
  static final ProjectQApi _projectQApi = ProjectQApi(_preferencesService);

  final Session session = Session();
  final Subcategories subcategories = Subcategories(_projectQApi);
  final Users users = Users(_projectQApi);

  factory AppState() {
    return _singleton;
  }

  AppState._internal() {
    // Here we can e.g. rehydrate state
  }
}

The AppState class serves as the owner of our Models and Services classes and is the only access point from which our ViewModels retrieve state.

class PeopleViewModel extends ChangeNotifier {
  static final AppState _appState = AppState();
  final Users _users = _appState.users;
  final Subcategories _subcategories = _appState.subcategories;
  final Session _session = _appState.session;
  final PreferencesService _preferencesService = PreferencesService();
  final FirebaseService _firebaseService = FirebaseService();

  List get users => _users.users;
  bool get isFetching => _users.isFetching;

  PeopleViewModel() {
    _users.addListener(notifyListeners);
    _subcategories.addListener(notifyListeners);
    _users.loadUsers();
    _subcategories.loadSubcategories();
  }

  Future signOut() async {
    await _preferencesService.clearAccessToken();
    _firebaseService.signOutGoogle();
    _session.isSignedIn = false;
  }

  void navigateToPerson(BuildContext context, User user) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => PeopleDetailPage(user: user),
      ),
    );
  }

  @override
  void dispose() {
    _users.removeListener(notifyListeners);
    _subcategories.removeListener(notifyListeners);
    super.dispose();
  }
}

PeopleViewModel provides state and callbacks for UI actions to PeopleView. Like our Models, the ViewModel also extends ChangeNotifier. However, instead of directly notifying its listeners, it registers as a listener to our Models and injects notifyListeners() as its notification callback, propagating state changes to the UI.

class PeopleView extends StatelessWidget {
  static const routeName = "people";

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => PeopleViewModel(),
      child: Selector<PeopleViewModel, Function>(
        selector: (_, viewModel) => viewModel.signOut,
        builder: (_, signOut, __) => Scaffold(
          appBar: AppBar(
            title: Text(AppLocalizations.of(context).translate('app_name')),
            actions: [
              IconButton(
                icon: Icon(Icons.exit_to_app),
                onPressed: () => showDialog(
                    context: context,
                    barrierDismissible: false,
                    // user must tap button!
                    builder: (_) => SignOutDialog(signOut: signOut)),
              ),
            ],
          ),
          body: Selector<PeopleViewModel, Tuple3<List, bool, Function>>(
            selector: (_, viewModel) => Tuple3(viewModel.users,
                viewModel.isFetching, viewModel.navigateToPerson),
            builder: (_, params, __) {
              return params.item2
                  ? _spinner
                  : PeopleList(
                      users: params.item1,
                      onSelect: params.item3,
                    );
            },
          ),
        ),
      ),
    );
  }

  Widget get _spinner => Center(child: CircularProgressIndicator());
}

The ViewModel is injected into the widget tree via ChangeNotifierProvider. We use Selector to inject state and callbacks into child widgets, as it allows us to control which state changes trigger UI redraws.

Flutter in real-world use

To determine whether a framework is ready for real-world applications, it must be put to real-world tasks. That means implementing features that are found in most of our client work to determine ease-of-use and scope out any possible shortcomings of the framework. Flutter fares well in use, allowing you to easily extend the framework’s capabilities with packages available on pub.dev.

Some packages come with the Flutter open source project and some are created by third-party developers. Naturally, the variety of authors means that you need to be careful about selecting the right package for your use case. However, there is already a trend of certain packages being adopted widely enough to be considered de facto standards, making the choice easier for developers.

To give your UIs a unified professional look, Flutter allows you to define Themes and inject them into your widget tree to be applied to all descendant widgets. When both light and dark themes are provided, the application will automatically conform to the device’s system setting.

For added convenience, you can use MaterialApp or CupertinoApp as your base widget. This will provide you with pre-defined implementations of Google’s or Apple’s design languages. Any part of your chosen theme can be overridden, so you can quickly create your app’s custom, yet platform-compliant, set of styles by simply changing a few values. TextStyles can also be added to enable easy overriding of the Theme’s typography, should you need to have an alternate text theme for some section of your app.

Know your market and audience

As we predominantly operate on the bi-lingual Finnish market, virtually all of the apps we develop require localization. Luckily Flutter makes localization simple with the flutter_localizations package. Setup and use is a breeze, with JSON objects as localization files and a singular injection into the widget tree, after which the correct localized strings can be retrieved from virtually anywhere in the app.

Good accessibility support is also paramount to provide quality applications to everyone without discrimination. To help us streamline the UX, Flutter has built-in Semantics widgets that can be used to add context to widgets or prevent them from being read by screen readers.

Most of the time, we are also interested in seeing how an app we’ve built performs when in use by the public. This creates the need for analytics solutions such as the offerings from Google and Adobe. Using tools like these helps us analyze how our app is being used, along with the strengths and weaknesses to focus on in further development. Firebase is well supported with Flutter plugins belonging to the Firebase open source project. There’s even a third-party package for Adobe Analytics, though we didn’t have the chance to evaluate how well it works.

With any hybrid framework, you should keep in mind that the framework does not always have out-of-the-box support for all planned features. In such cases it might be necessary to create a custom solution on native iOS or Android to be triggered by the hybrid program. Flutter offers something called the Method Channel for these situations. It allows a connection between the native and Flutter layers, where the Flutter layer can call native functions exposed to the channel.

My experience

As with any new technology, there is a bit of a learning curve when getting started with Flutter. To be honest, adjusting to Flutter was initially a little difficult, even though I had previous native and hybrid development experience.

Settling on an architecture model and mapping out the resulting app structure made a massive difference. Combining the MVVM design pattern with a declarative, reactive way of building the UI similar to the React ecosystem really brought things together for me and made further work on the app efficient and enjoyable.

Dart allows nicely concise and descriptive code, combining a lot of powerful features found in languages like Swift or Kotlin. Flutter feels steadily performant and I haven’t encountered any issues with something working worse on iOS or Android so far. This holds promise for building apps for both with a single codebase. It’s especially impressive since a lot of developers report writing less code in Flutter compared to just one of the native alternatives, excluding SwiftUI.

The tooling is powerful, with a handy CLI and IDE integration for VS Code and IntelliJ allowing for easy debugging and speedy development with features like breakpoints and hot reload. All in all, I really enjoyed working with Flutter and hope to get the opportunity to build a lot more with it.

Flutter or something else?

Whether Flutter, React Native or native is the best option depends on your project’s specific needs, however. Each option has its strengths and weaknesses. In addition to being enjoyable to work with and having good tooling and maintainability, Flutter may be your first choice if you want to create an app for both Android and iOS that looks identical on both platforms. Since it’s not using any UI elements provided by the platform but draws directly on a canvas, it can circumvent any differences in UI functionality between the two.

React Native has the upper hand due to its similarities with its web counterpart if you have a team of web developers transitioning to mobile. Because the framework leverages the native UI toolkits, it also has the edge in terms of building platform-specific UIs, including platform-level accessibility support. It nevertheless lags slightly behind in performance compared to Flutter, especially with complex apps.

If resource limitations are not a factor in your choice of technology, native will still come out on top with unparalleled platform integration allowing for the app to be polished and optimized beyond what cross-platform frameworks are capable of. But the rift between hybrid and native is shrinking constantly.

Written by

Milos Berka

Besides being a software engineer at Qvik, Milos builds his own guitars and has a mic in his hand before you can say "karaoke."