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 started and shown to the user.
We can read this line of code as a simple sentence: 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, telling Dart 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
MyAppwidget 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:
We would then be required to use the
args library, define each parameter that we would want to define, such as
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:
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.
Now we can create our
main_portugal.dart file and specify the configuration:
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:
And finally, our
main_x.dart sole responsibility will be configuring the Config file.
MyApp we can change our app’s
primaryColor and the app’s title with the information from the
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
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
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:
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
runAppwill only have a
Widgetthat will show the user an animation and fetch some data from the network.
- The second call to
runAppwill 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:
SplashPage will show a loading animation, make the API call and complete our
Completer with the result:
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
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:
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.
We can proceed to create a simple widget that shows a message and a button to close this screen:
Then, in our
main_common function we will navigate to a new screen using the
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:
What happens is that after 1 second our app shows the following screen:
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:
This way, with no additional styling of the text, the final widget shows default Material-styled text and a grey background:
So, in summary, we can make use of both
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.
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
bashscripts 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 in the comments!