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:
- This command will only run the
MyApp
widget with no given arguments, but what if we want a different backend environment to be called or what if we have different versions of the app that we want to test and deploy? - What if we need to check the user preferences before the app runs? For example, how can we verify what color scheme our user picked before showing our first screen? Or how can we check if the user is logged in in order to choose what screen we should show first?
- What happens if there is an error while our app is running? Is there a way to log our app errors apart from displaying them in the console? Can we show an error screen to the user with the option for her to send a report?
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:
- The first call
runApp
will only have aWidget
that will show the user an animation and fetch some data from the network. - The second call to
runApp
will have as an argument the results fetched
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:
- Now that we have different versions of the app, we can create
bash
scripts to easily run and deploy our apps. If you want to easily deploy a development version to testers, checkout Fastlane. - Instead of showing an error message to the user, we can call a dedicated endpoint in our backend to report errors or just use Firebase Crashlytics or Sentry for our error reporting.
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!
Want to get the latest articles and news? Subscribe to the newsletter here 👇
And for other articles, check the rest of the blog! Blog - Gonçalo Palma