Skip to content

Instantly share code, notes, and snippets.

@Vanethos

Vanethos/blog.md Secret

Created January 13, 2020 08:19
Show Gist options
  • Save Vanethos/fc4e7c6bf0c7701a8b4c89abdf51eeda to your computer and use it in GitHub Desktop.
Save Vanethos/fc4e7c6bf0c7701a8b4c89abdf51eeda to your computer and use it in GitHub Desktop.

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.

https://gist.github.com/ef4ef471fc9a831d485eff9f650b65ab

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:

https://gist.github.com/c838c9115c106186b2bd647204ab90b8

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:

https://gist.github.com/a40d695e79bd5e6a6c2e2a383f8fdf0f

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.

https://gist.github.com/a86447aa25765f80782ccd3b4bd8a8d8

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

https://gist.github.com/71bd066c8e152441205be3eec768b95a

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:

https://gist.github.com/37b301f5ed3b72e5d09df797803e0e21

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

https://gist.github.com/bcb7c8f3ec74fa380007290106e4ad31

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

https://gist.github.com/73506707ee8bf9bb5c91a9b907782918

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:

https://gist.github.com/863bd0d9ca9d13c3d79315de1cce7583

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 a Widget 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:

https://gist.github.com/25cda626fa829c925b1ea8d00d3fb4f3

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

https://gist.github.com/87d1629f7d1edde555661e866d635276

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:

https://gist.github.com/2464b775bfa8e281eb89a7434699a654

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:

https://gist.github.com/fe6af5e88c2a57cac7d4a86bef8327ce

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.

https://gist.github.com/7a517a5f3186889d77a4a69109fae79f

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

https://gist.github.com/3aa4db2f7546d8d65fc602fdc2293482

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

https://gist.github.com/4d14255cf65ada3792295b388fa0f05e

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:

https://gist.github.com/aa3a7330234efb069af50e77eb144f20

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:

https://gist.github.com/e9eaf49fe0a45a38bfa2e74f8328d3b7

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 in the comments!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment