Gonçalo Palma
January 16, 2020

The main function in Flutter

Every time we start a new Flutter project, there’s a single line of code that most of the times we don’t need to change. This line ensures that our Flutter app is shown to the user.

void main() => runApp(MyApp());

We can read this line of code as a simple phrase: our main function for this project will run an app called MyApp. As with many other programming languages, the main function is the entry point of our application, with it Dart knows where it should start running our code.

And though this works perfectly for some apps, we may encounter some issues as our projects grows in scale and complexity, namely:

In this article we’ll explore these questions so that we can get a better understanding of what we can do and expect from the main function in Flutter.

Managing different App versions

There are many reasons why we may need different versions of our app, from having different backend environments to having different branding for our app. Whatever the reason, we will need to have a way to specify to Flutter what we want to display/change in our app in each run or build without having to create separate projects for each app.

Since we can build Flutter apps in the command line via the $ flutter run command, we might explore this approach to see if we can pass more arguments to this command. If we take a look into the Dart documentation we see that we can change our main function so that it accepts arguments:

// Run the app like this: dart args.dart 1 test
void main(List<String> arguments) {
  print(arguments);

  assert(arguments.length == 2);
  assert(int.parse(arguments[0]) == 1);
  assert(arguments[1] == 'test');
}

We would then be required to use the args library, define each parameter that we would want to define, such as flavor and branding and in the end we could in theory use the following command to run our project:

$ dart lib/main.dart --flavor dev --branding portugal

However, when running a Flutter app, we will need to use a command such as:

$ flutter run

And if we try to use the command

$ flutter run --flavor dev --branding portugal

We get the following error message:

Could not find an option named "branding".


Run 'flutter -h' (or 'flutter <command> -h') for available flutter commands and options.

Let’s then see how the flutter run command works. If we inspect the executable.dart file located in Flutter/packages/flutter_tools/lib, we can verify that this is already a Dart program that takes a list of arguments List<String> args and then manipulates them. In order for us to use additional arguments, we would have to change the way Flutter uses the run command and find a way to pass it down to our project. Is there an easier solution?

Fortunately, one of the arguments of the flutter run command is -t, or entry-point of the app. Basically, instead of running the main.dart function located in the lib folder, we can tell Flutter to use another file to run our app. How is this useful? Instead of passing down the arguments directly in the command line, we can create one file per each flavour or branding of the app with the configurations that we need. This means that if we created the main_portugal.dart and the main_emirates.dart files, we could run a Portugal and UAE version of our app.

But how can our MyApp widget know that we are running one or the other version? The simplest way is to create a configuration for each file with the help of a Config data class that will be passed down to it.

class Config {
	String name;
	Color appColor;

	Config(this.name, this.appColor);
}

Now we can create our main_portugal.dart file and specify the configuration:

// main_portugal.dart
// This will be the main_x file to be created for each version
void main() {
	var config = Config("Portugal", Color(0xfffc0303));
	runApp(
		MyApp(config)
	);
}

Instead of having to copy and paste the runApp command for each main_x.dart file, we can create a helper class, main_common.dart which will be responsible for initialising our application:

// main_common.dart
// This will hold all the common logic to be used in each application
void mainCommon(Config config) {
	runApp(
		MyApp(config)
	);
}

And finally, our main_x.dart sole responsibility will be configuring the Config file.

// main_portugal.dart
// This will be the main_x file to be created for each version
void main() {
	var config = Config("Portugal", Color(0xfffc0303));
	// run the main_common function
	mainCommon(config);
}

In MyApp we can change our app’s primaryColor and the app’s title with the information from the Config object:

class MyApp extends StatelessWidget {
  Config _config;
  
  MyApp(this._config);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _config.name,
      theme: ThemeData(
        primarySwatch: Colors.blue,
        primaryColor: _config.appColor
      ),
      home: MyHomePage(),
    );
  }
}

These changes could be more than just changing the ThemeData of the app: we can change the home Widget, setup different baseUrl to use in our http client, hide or show different pages or widgets and show different content for each version of the app.

Now that we have each version of the app configured on the Flutter side, there’s one additional problem to solve: if we want to publish different version of the app, each app will need its own unique identifier, which requires us to create different flavors in our Android project and different schemas in our iOS project. Thankfully, the official documentation for Flutter already provides us with multiple articles to help us with it..

Setting up our app before running it

Our apps might have some user preferences saved - be her name, a value that tells if the user is logged in or the color theme that the user picked for the app - and we might need to get to this information from the shared_preferences before displaying our first screen or make an API call to our server to verify if the user is still logged in. Depending on our specific problem, we may have different solutions for it, so let’s take two common scenarios and break them down.

Scenario 1 - The user chooses a theme that is going to change the colour of the app

In this case, we need to style our whole app depending on the color that the user chose. This means that before our MaterialApp or CupertinoApp widget is built, we need to have accessed the shared_preferences and retrieve the color or theme the user chose for the app.

However, we are using one of Flutter’s capabilities - the ability to contact the native platforms via Platform Channels in the plugins - without knowing if the framework is already initialised or not. How can we make sure that we can use shared_preferences without causing an exception? By using the WidgetsFlutterBinding.ensureInitialized() method.

You only need to call this method if you need the binding to be initialized before calling [runApp].

After we wait for this method to return, we can access the plugin and retrieve the information that we need:

// main_common.dart
// This will hold all the common logic to be used in each application
void mainCommon(Config config) async {
	// Ensure that the Flutter Framework is available to access
	await WidgetsFlutterBinding.ensureInitialized();

	SharedPreferences prefs = await SharedPreferences.getInstance();

	final r = prefs.getInt('r');
  final g = prefs.getInt('g');
  final b = prefs.getInt('b');

	final color = Color.fromRGBO(r, g, b, 1.0);

	runApp(
		MyApp(config, color)
	);
}

One thing that we notice from this use case is that the more intensive the computation is before the runApp command is called, the longer our app will take to actually show the first screen, showing a blank screen to the user.

The reason for this is that while the Flutter engine is being warmed up and before we actually call the runApp method, Flutter is showing us the native splash screen for iOS and Android, which by default is a blank screen. We can change these screens to show our app logo or a custom background by changing the iOS and Android projects as seen in the official documentation for Flutter.

Scenario 2 - Checking if the user is still logged in to the server

To verify if the user is logged in or if his token has expired, we may need to make an API call and wait for the response from the server. In this case, it makes sense to give some feedback to the user to show him that our app is loading before showing the Login or Home page.

We could use the same approach as we used before, but as we have seen we will be able to just show a static screen to the user, with no indication of what is happening. What if we take too long to communicate to the server? Or what if we want to show an animation to the user? The first thought that we can have is that we run our app normally and use as the first widget to be shown a Splash Screen widget in which we include all this logic. But if we take a look into the runApp documentation, we can figure out another way to do it:

Calling runApp again will detach the previous root widget from the screen and attach the given widget in its place.

This means that we can call runApp multiple times for different use cases:

To help us to communicate to our main function the results of the API call, we can use a Completer:

void mainCommon(Config config) async {
  // Create completerto receive the result
  final completer = Completer<bool>();
  // Run first widget to fetch data from the network
  runApp(
		SplashPage(completer: completer,)
	);
	// This will wait for the SplashPage to finish the request. 
	// It will be `true` if we receive a result from the network and `false` otherwise
  bool isLoggedIn = await completer.future;
  
	// Runs the app with the Config and login status information
  runApp(
      MyApp(config, isLoggedIn)
  );
}

Our SplashPage will show a loading animation, make the API call and complete our Completer with the result:

class SplashPage extends StatelessWidget {
  final Completer<bool> completer;

  SplashPage({this.completer, Key key}) : super(key : key);

  @override
  Widget build(BuildContext context) {
    // Fetch some information from the internet.
    // When finished, complete the Completer with the `true` value
    // If there is an error, then
    http.get('https://jsonplaceholder.typicode.com/todos/1').then((_) =>
        completer.complete(true)).catchError((_) =>
        completer.complete(false));

    return Material(
      child: Container(
        color: Colors.red,
        child: Center(
          child: CircularProgressIndicator(),
        ),
      ),
    );
  }
}

As we can see from both situations, we can access resources and make network requests before our app is run, showing either a static or a dynamic screen depending on our app’s design. This approach is also valid to initialise our app: creating a Dio instance and its interceptors, managers and helper classes and BLoC. These classes could then be accessed by an InheritedWidget, which you can read more about in the article ““Dependency Injection” in Flutter with InheritedWidget”

Personalized Crash Experience

When our app crashes due an error, we will want one of two things: either we want the user to know that something has happened or we want to have some sort of report with the error and the stack trace. To help us with the later, we can use packages such as firebase_crashlytics that will send all the errors to an online platform. For it to work, it uses both the FlutterError class and the runZoned function:

void main() {
  FlutterError.onError = Crashlytics.instance.recordFlutterError;
  
  runZoned<Future<void>>(() async {
    runApp(new MyApp());
  }, onError: Crashlytics.instance.recordError);
}

But what exactly is happening?

FlutterError.onError documentation is self explanatory:

Called whenever the Flutter framework catches an error. The default behavior is to call [dumpErrorToConsole]. You can set this to your own function to override this default behavior. For example, you could report all errors to your server.

So, by default this will call the dumpErrorToConsole function that will consume the current error and log all the details to the console.

On the other hand, the runZoned will run our application and provide us with an onError callback that will let is deal with any errors that will occur outside of the Flutter Framework. But what if we want to deal with all of the errors here instead of having two places? Since this onError clause will deal with unhandled errors, we can change our FlutterError.onError function to re-throw our error:

void main() {
  FlutterError.onError = (error) {
		// dumps errors to console
  	FlutterError.dumpErrorToConsole(error);
		// re-throws error so that `runZoned` handles it
  	throw error;
	};

  runZoned<Future<void>>(() async {
    runApp(new MyApp());
  }, onError: (error, stack) => print("Handling an error $error"));
}

This will let us handle all the errors in one place and, for example, send in the error report. However, if our app is distributed to beta-testers, we may want to have a different behaviour: to show them an error screen with some context and a button to allow them to send a report back to the developer.

Looking at our previous use-cases for the main function, we might be tempted to use the runApp command inside the onError callback, but this will just discard all of our current widget tree and present a new one, meaning that the user will not be able to navigate back to the app. Thus, we must navigate inside the app to show new content, but how can we do it without a BuildContext? In Dane Mackier’s article “Navigate without context in Flutter with a Navigation Service” we learn that we can use a GlobalKey holding the NavigatorState to access the Navigator widget in any place, with or without a BuildContext, so let’s use that in our app.

First, we need to declare a new global variable for the navigatorKey and assign it in the navigatorKey parameter of the MaterialApp.

/// Navigator key to navigate without a BuildContext
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      // Add the navigator key to our app
      navigatorKey: navigatorKey,
      //...
    );
  }
}

We can proceed to create a simple widget that shows a message and a button to close this screen:

class ErrorWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text("Oh no! An error has occurred with the app! Please send the details over to our team"),
            MaterialButton(child: Text("Send Report"), onPressed: () => navigatorKey.currentState?.pop(), color: Colors.red,)
          ],
        ),
      ),
    );
  }
}

Then, in our main_common function we will navigate to a new screen using the navigatorKey.navigatorState.

void main_common() async {
  //...
  runZoned<Future<Null>>(() async {
    runApp(CrashyApp());
  }, onError: (error, stackTrace) async {
	  // Since the state can be null, we use `?.` to verify if it is null. If it is null, we don't do anything, if it is NOT null, we call the `push` function on it
    navigatorKey.currentState?.push(
      MaterialPageRoute(builder: (_) => ErrorWidget(),
    ));
  });
}

If we try to throw an error in our application, by calling the following function in the build method of our home page for example:

void throwError() {
  Future.delayed(Duration(seconds: 1)).then((_) => throw Exception());
}

What happens is that after 1 second our app shows the following screen:

[SCREENSHOT]

To quickly show a more elegant Text style we can quickly wrap our widget tree in the ErrorWidget with a Material widget so that it can inherit the default Theme of Material:

class ErrorWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Material(
      color: Colors.grey,
      child: //...
    );
  }
}

This way, with no additional styling of the text, the final widget shows default Material-styled text and a grey background:

[SCREENSHOT]

So, in summary, we can make use of both FlutterError and runZoned to take care of the errors that occur in our app and either show the user a new screen with some context or report it back to our server or external service.

Conclusion

The main function is essential for each and every Flutter app that we create, so it was important to know what we could do with it. The past three examples showed us that we can use it to gather information before our app starts or to change how our app handles errors ⛄.

There is much more to be said about the main function and some more uses that were not explored in the article. However, we can use what we’ve learned here and expand the use cases:

I’m interested in knowing your opinion: do you usually change your main function? Have you come up with other clever solutions to these problems? Please share them on Twitter!

Follow me!

I often share some small insights on Flutter 💙