Null Safety helps us avoid an entire class of bugs in our Flutter apps by catching null errors during development rather than at runtime.
As Flutter 2 is here, we can migrate our projects to Null Safety and make the most of it.
To help us along, the Dart documentation comes with a handy migration guide. And we can use the dart migrate
command to automate part of the migration process.
This all sounds nice in theory, but the process can be lengthy for big apps what have a lot of dependencies.
So in this article I'll show you how to migrate a non-trivial project built with Flutter & Firebase.
Before we get to that, let's get one thing out of the way:
How to check if a project is using Null Safety
Open the pubspec.yaml
file and check the Dart SDK version inside the environment
section:
environment:
sdk: ">=2.10.0 <3.0.0"
- Do you see sdk: 2.10.0 or below? Then you're running without Null Safety
- Do you see sdk: 2.12.0 or above? Then you're running with Null Safety
Keep this in mind:
Migrating to Null Safety is optional. You can choose to upgrade your app and its dependencies to Flutter 2 without opting in to Null Safety by keeping the Dart SDK to version 2.10.0 or below in your
pubspec.yaml
.
Migrating a Flutter & Firebase app to Null Safety
As a reference we will migrate my Time Tracking app, which is the official project from my Flutter & Firebase course:
You can find this project on GitHub as well as the completed migration on this PR.
For reference, this app has roughly 2,500 lines of code:
45 text files.
45 unique files.
0 files ignored.
github.com/AlDanial/cloc v 1.84 T=0.03 s (1311.3 files/s, 82058.4 lines/s)
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Dart 45 265 13 2538
-------------------------------------------------------------------------------
SUM: 45 265 13 2538
-------------------------------------------------------------------------------
To make the process as painless as possible, we will go through the migration process one step at a time and ensure our app works after each step.
Ready? Let's go!
0. Create a new branch
As we're about to do a lot of changes, it's best to work on a new branch:
git checkout -b null-safety-migration
This way we can go back to a working state if we make any mistakes.
1. Check if all dependencies can be upgraded
As outlined in the official migration guide, it's best to update all the project dependencies first.
So let's check what needs to be updated by running this command:
dart pub outdated --mode=null-safety
This produces the following output:
Showing dependencies that are currently not opted in to null-safety.
[✗] indicates versions without null safety support.
[✓] indicates versions opting in to null safety.
Package Name Current Upgradable Resolvable Latest
direct dependencies:
cloud_firestore ✗0.14.3 ✗0.14.3 ✓1.0.0 ✓1.0.0
cupertino_icons ✗1.0.0 ✓1.0.2 ✓1.0.2 ✓1.0.2
firebase_auth ✗0.18.2 ✗0.18.2 ✓1.0.0 ✓1.0.0
firebase_core ✗0.5.2 ✗0.5.2 ✓1.0.0 ✓1.0.0
flutter_login_facebook ✗0.4.0+1 ✗0.4.0+1 ✓1.0.0-nullsafety.1 ✓1.0.0-nullsafety.1
google_sign_in ✗4.5.6 ✗4.5.6 ✓5.0.0 ✓5.0.0
intl ✗0.16.1 ✗0.16.1 ✓0.17.0 ✓0.17.0
provider ✗4.3.2+2 ✗4.3.2+2 ✓5.0.0 ✓5.0.0
rxdart ✗0.24.1 ✗0.24.1 ✓0.26.0 ✓0.26.0
dev_dependencies:
mockito ✗4.1.3 ✗4.1.3 ✓5.0.0 ✓5.0.0
1 upgradable dependency is locked (in pubspec.lock) to an older version.
To update it, use `dart pub upgrade`.
9 dependencies are constrained to versions that are older than a resolvable version.
To update these dependencies, edit pubspec.yaml, or run `dart pub upgrade --null-safety`.
2. Update to all the latest dependencies
The log above shows that all the dependencies can be upgraded. So let's run this:
dart pub upgrade --null-safety
After this process completes, the pubspec.yaml
is updated with new versions and we get this output:
cupertino_icons: ^1.0.0 -> ^1.0.2
firebase_core: 0.5.2 -> ^1.0.0
firebase_auth: 0.18.2 -> ^1.0.0
google_sign_in: 4.5.6 -> ^5.0.0
flutter_login_facebook: 0.4.0+1 -> ^1.0.0-nullsafety.1
provider: 4.3.2+2 -> ^5.0.0
cloud_firestore: 0.14.3 -> ^1.0.0
intl: 0.16.1 -> ^0.17.0
rxdart: 0.24.1 -> ^0.26.0
mockito: 4.1.3 -> ^5.0.0
3. Install the updated dependencies and dealing with breaking changes
Next, we want to install all the updated packages:
flutter pub get
The installation completes successfully and all our dependencies now use Null Safety.
But before we migrate our code to Null Safety, we need to check that the app still compiles and runs:
flutter analyze
As it turns out, we have a few errors due to breaking changes in some packages:
error • There isn’t a setter named 'value' in class 'ValueStreamExtensions' at lib/app/sign_in/email_sign_in_bloc.dart:60:19 • (assignment_to_final_no_setter)
error • Case expressions must be constant at lib/services/auth.dart:91:12 • (non_constant_case_expression)
error • There's no constant named 'Success' in 'FacebookLoginStatus' at lib/services/auth.dart:91:32 • (undefined_enum_constant)
error • Case expressions must be constant at lib/services/auth.dart:97:12 • (non_constant_case_expression)
error • There's no constant named 'Cancel' in 'FacebookLoginStatus' at lib/services/auth.dart:97:32 • (undefined_enum_constant)
error • Case expressions must be constant at lib/services/auth.dart:102:12 • (non_constant_case_expression)
error • There's no constant named 'Error' in 'FacebookLoginStatus' at lib/services/auth.dart:102:32 • (undefined_enum_constant)
Remember that we want our app to be runnable at each step. So we should fix those errors before moving forward. Here's a commit showing all the required changes at this stage.
This is a good time to commit any changes to our temporary branch in Git:
git add .
git commit -m "Upgrade dependencies + fixes for breaking changes"
4. Fixing the build on iOS
Next, we can try to run our app. The Android build seems to work fine, but on iOS we get a long error log. The actual error is:
[!] CocoaPods could not find compatible versions for pod "Firebase/Firestore":
In snapshot (Podfile.lock):
Firebase/Firestore (= 6.33.0, ~> 6.33.0)
In Podfile:
cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`) was resolved to 1.0.0, which depends on
Firebase/Firestore (= 7.3.0)
You have either:
* out-of-date source repos which you can update with `pod repo update` or with `pod install --repo-update`.
* changed the constraints of dependency `Firebase/Firestore` inside your development pod `cloud_firestore`.
You should run `pod update Firebase/Firestore` to apply changes you've made.
The error log suggests to run pod repo update
to resolve this:
Error: CocoaPods's specs repository is too out-of-date to satisfy dependencies.
To update the CocoaPods specs, run:
pod repo update
Error running pod install
Error launching application on iPhone 12 Pro Max.
But this is not the solution in this case. The real problem is that cloud_firestore: ^1.0.0
requires version 7.3.0
of the iOS Firestore SDK, but version 6.33.0
is currently installed.
Running pod repo update
is not enough in this case because our ios/Podfile.lock
file still points to version 6.33.0
. Let's fix this:
cd ios
rm Podfile.lock
pod repo update
Next, we need to set a minimum deployment target of iOS 10.0 in our Xcode project:
And we also need to set this at the top of the Podfile:
platform :ios, '10.0'
This is required by newer versions of the iOS Firestore SDK.
If we compile and run the iOS app now, everything works correctly.
Firestore is a big library with a lot of dependencies and it takes a long time to compile it from scratch. See this tip for how to speed up the build.
This completes our preparation work:
- We have migrated all our dependencies to Null Safe versions ✅
- We have fixed breaking changes in our code ✅
- Our app builds and runs correctly on iOS and Android ✅
We can commit our changes once again, and get started with the migration.
Make sure your app works correctly with all the latest packages before attempting to migrate to Null Safety. This will give you more confidence in your changes later on. You can always revert to a previous commit if something goes wrong.
5. Running the migration
Let's run this:
dart migrate
The output will look like this:
Migrating /Users/andrea/work/codewithandrea/github/flutter-course-apps/time_tracker_flutter_course
See https://dart.dev/go/null-safety-migration for a migration guide.
Analyzing project...
[--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------/\]
Because of all the preparation steps we did above, this command succeeds and the console prints a URL that we can open to start the migration process:
View the migration suggestions by visiting:
http://127.0.0.1:60278/Users/you/project/mypkg.console-simple?authToken=Xfz0jvpyeMI%3D
If we click on it, this page will open in our browser:
At this stage we can:
- review all the proposed changes
- (optionally) add hint markers
- apply the migration to all files (or a subset if we want to do this in batches)
This project is not huge so we can apply the migration to all files, and this will apply all the proposed changes. It will also update the minimum Dart SDK version to 2.12.0 in the pubspec.yaml
file:
environment:
sdk: ">=2.12.0 <3.0.0"
The migration tool will do a reasonable job at making the right changes, but it is not perfect.
Chances are that the project won't compile at this stage. So we have to go back to our code, review all changes, and tweak as needed. This ensures that we don't introduce tech debt at this stage and we have a good foundation going forward.
This can be a lenghty process and you may uncover some interesting gotchas. You can review this commit to get an idea of all the changes I made.
This document is quite useful at this stage:
The Flutter team has also shared this helpful video that explains all the steps in detail:
6. Doing a smoke test
Once we are satisfied with all the changes, we should run the app and make sure everything still works.
A quick smoke test is a good way to check that all the main features still work.
7. Migrating all the tests
In addition to the code in lib
, this project also has a number of unit and widget tests. These should also be updated as part of the migration.
Many of these tests rely on the mockito package.
Unfortunately mockito currently has some serious compatibility problems with Null Safety. As it stands, we can only create mock classes by introducing code generation steps to our build, which is not ideal.
I have followed the steps in this NULL_SAFETY_README to get my tests to work, but didn't manage to make them all green.
The mocktail package by Felix Angelov solves these problems while implementing a very similar API to mockito, though I'm waiting to see if the two package authors can agree on a solution that works for everyone.
So for the time being, I disabled the tests that I couldn't get to work (YOLO 😅).
Wrap Up
From beginning to end, the migration process took less than one day for this project.
Performing all the steps in the right order definitely helped, and I recommend doing the same in your own projects.
The biggest pain point was to update the tests dependent on mockito. I hope mockito will support Null Safety without code generation in the future as many projects depend on it. If not, I'll move over to mocktail.
To reiterate what I said at the beginning:
Migrating to Null Safety is optional. You can choose to upgrade your app and its dependencies to Flutter 2, without opting in to Null Safety.
When you get a chance, I'd still recommend migrating so that you can run your apps and all their dependencies with Sound Null Safety.
The Dart documentation covers everything you need to know, and you may also find my Null Safety guide useful.
The Complete Dart Developer Guide
If you want to learn Dart in-depth with a more structured approach, consider taking my complete Dart course. This covers all the most important language features, including exercises, projects and extra materials: