This article was originally published on my website.
Watch the Video Tutorial on YouTube.
In this tutorial I'll show you how to create Dart packages for your Flutter apps, so that you can improve and reuse your code.
Why is this important?
With large applications, it is challenging to keep folders organized, and minimise inter-dependencies between files and different parts of the app.
Dart packages solve this problem by making apps more modular and dependencies more explicit.
So if you have a single large application, or multiple apps that need to share some functionality, extracting reusable code into packages is the way forward.
How this tutorial is organized
We will start with a step-by-step guide and convert a sample BMI calculator application to use internal packages within the same project.
Then, we will talk about:
- Dealing with existing, large apps
- Reusing packages across multiple apps
- Local vs remove (git) packages
- Versioning packages and the humble changelog
Let's get started!
Example: BMI calculator
To follow along each step, you can download the starter project here.
Suppose we have a single page BMI calculator app, composed of these four files:
lib/
bmi_calculation_page.dart
bmi_calculator.dart
bmi_formatter.dart
main.dart
The most interesting functionality is in bmi_calculator.dart
and bmi_formatter.dart
:
// bmi_calculator.dart
double calculateBMI(double weight, double height) {
return weight / (height * height);
}
// bmi_formatter.dart
import 'package:intl/intl.dart';
String formattedBMI(double bmi) {
final formatter = NumberFormat('###.#');
return formatter.format(bmi);
}
The UI is built with a single BMICalculationPage
widget class. This shows two input text fields for the weight and height, and one output text field for the BMI (full source here):
This app is simple enough that we can keep all files inside lib
. But how can we reuse the BMI calculation and formatting logic across other projects?
We could copy-paste bmi_calculator.dart
and bmi_formatter.dart
on each new project.
But copy pasting is rarely a good thing. If we want to change the number of decimal places in the formatter code, we have to do it in each project. Not very DRY. 🌵
Creating a new package
A better approach is to create a new package for all the shared code.
In doing this, we should consider the following:
- By convention, all packages should go inside a
packages
folder. - When starting from a single application, it's simpler to add the new package(s) inside the same git repo.
- If we need to share packages across multiple projects, we can move them to a new git repo (more on this below).
- We can keep multiple packages inside a single repository. The FlutterFire monorepo is a good example of this, and I recommend we do the same for simplicity.
For this example, we'll add a new package and keep it inside the same git repo.
From the root of your project, we can run this:
mkdir packages
cd packages
flutter create --template=package bmi
This will create a new Flutter package in packages/bmi
, but the main.dart
file with the usual runApp(MyApp())
code is missing. Instead, we have a bmi.dart
file with some default boilerplate:
library bmi;
/// A Calculator.
class Calculator {
/// Returns [value] plus 1.
int addOne(int value) => value + 1;
}
As we don't need the Calculator
class, we can replace it with the BMI calculation and formatting code from the main app.
The simplest way to do this is to add all the code from lib/bmi_calculator.dart
and lib/bmi_formatter.dart
to packages/bmi/lib/bmi.dart
(we will see later on how to have multiple files inside a package):
// bmi.dart
library bmi;
import 'package:intl/intl.dart';
double calculateBMI(double weight, double height) {
return weight / (height * height);
}
String formattedBMI(double bmi) {
final formatter = NumberFormat('###.#');
return formatter.format(bmi);
}
Note that this code depends on intl
, so we need to add this to the pubspec.yaml
file of our package:
dependencies:
flutter:
sdk: flutter
intl: ^0.16.1
Using the new package
Now that we have a bmi
package, we need to add it as a dependency to our app's pubspec.yaml
file:
dependencies:
flutter:
sdk: flutter
# Used by bmi_calculation_page.dart
flutter_hooks: ^0.9.0
# we no longer need to import intl explicitly, as bmi already depends on it
bmi:
path: packages/bmi
Here we use a path
argument to tell Flutter where to find our new package. This works as long as the package lives in the same repo.
After running flutter pub get
(from the root of the project), the package will be installed and we can use it, just like we would do with any other Dart package.
So we can update the imports in our bmi_calculator_page.dart
from this:
import 'package:bmi_calculator_app_flutter/bmi_calculator.dart';
import 'package:bmi_calculator_app_flutter/bmi_formatter.dart';
to this:
import 'package:bmi/bmi.dart';
And viola! Our code works and we can now remove bmi_calculator.dart
and bmi_formatter.dart
from the main app project. 🏁
In summary, to extract existing code to a separate package we have to:
- create a new package and move our code inside it.
- add any dependencies to the
pubspec.yaml
file for the package. - add the new package as a dependency to
pubspec.yaml
for our application. - replace the old imports with the new package where needed.
- delete all the old files.
Bonus: Adding multiple files to a package with part
and part of
This example is simple enough that we can keep the BMI calculation and formatting code in bmi.dart
.
But as our package grows, we should split the code into multiple files.
So rather than keeping everything in one file like this:
// bmi.dart
library bmi;
import 'package:intl/intl.dart';
double calculateBMI(double weight, double height) {
return weight / (height * height);
}
String formattedBMI(double bmi) {
final formatter = NumberFormat('###.#');
return formatter.format(bmi);
}
We can move the calculateBMI
and formattedBMI
methods in separate files, just like we had them at the beginning:
// bmi_calculator.dart
part of bmi;
double calculateBMI(double weight, double height) {
return weight / (height * height);
}
// bmi_formatter.dart
part of bmi;
String formattedBMI(double bmi) {
final formatter = NumberFormat('###.#');
return formatter.format(bmi);
}
Then, we can update bmi.dart
to specify its part
s:
// bmi.dart
library bmi;
import 'package:intl/intl.dart';
part 'bmi_calculator.dart';
part 'bmi_formatter.dart';
A few notes:
- Files declared with
part of
should not contain anyimport
s, or we'll get compile errors. - Instead, all
import
s should remain in the main file that specifies all thepart
s.
In essence, we're saying that bmi_calculator.dart
and bmi_formatter.dart
are part of
bmi.dart
.
When we import bmi.dart
in the main app, all public symbols defined in all its parts will be visible.
In other words, our main app just needs to import 'package:bmi/bmi.dart';
, and have access to all the methods declared in all its parts.
Job done! You can find the finished project for this tutorial here.
Note: Using
part
andpart of
works well if you have just a few files in the same folder. One library that uses this extensively is flutter_bloc, where it's common to define state, event and bloc classes together.For more complex apps it's advisable (and recommended by the Dart team) to export library files instead. See this official guide on creating packages for more details.
Top tip: moving code into packages is a great opportunity to move existing tests, or write new ones.
Creating a package for a simple app is easy enough, but how do we do this when we have complex apps?
Dealing with existing, large apps
For more complex apps, we can incrementally move code into self-contained packages.
This forces us to think harder about the dependencies between packages.
But if there are already a lot of inter-dependencies, how do we get started?
A bottom-up approach is most effective. Consider this example:
// lib/a.dart
import 'b.dart';
import 'c.dart';
// lib/b.dart
import 'c.dart';
// lib/d.dart
import 'a.dart';
import 'c.dart';
// lib/c.dart
// no imports
As we can see, c.dart
doesn't depend on any files.
So moving c.dart
into a c
package would be a good first step.
Then, we could move b.dart
into a separate b
package, making sure to add c
as a dependency:
# packages/b/pubspec.yaml
dependencies:
flutter:
sdk: flutter
c:
path: packages/c
And we can repeat this process until all dependencies are taken care of.
It's still our job to decide how many packages to create, and what goes in each one.
Reusing packages across multiple apps
Up until now we've seen how to create new packages within the same project.
If we want to reuse packages across multiple projects, we can move them to a common shared repository.
Using the BMI calculator example, we could update the pubspec.yaml
file to point to the new repository:
dependencies:
...
bmi:
git:
url: https://github.com/your-username/bmi
path: packages/bmi
I followed this approach when refactoring my starter architecture project, and ended up with 6 new packages that I now reuse in other projects.
To learn more about to the package dependencies syntax, read this page on the Dart documentation.
Local vs remove (git) packages
Dart packages make our code cleaner and increase code reuse, but do they slow us down?
As long as we specify dependencies with path
in our pubspec.yaml
file, we can edit, commit and push all our code (main app and packages) just as we did before.
But if we move our packages to a separate repo, getting the latest code for our packages becomes more difficult:
dependencies:
...
bmi:
git:
url: https://github.com/your-username/bmi
path: packages/bmi
That's because pub
will cache packages when we use git
as a source. Running flutter pub get
will keep the old cached version, even if we have pushed changes to the package.
As a workaround we can:
- comment out our package
- run
flutter pub get
(to delete it from cache) - uncomment the package
- run
flutter pub get
again (to get the latest changes)
Repeating these steps every time we make a change is not fun. 🤔
Bottom line: we get the fastest turnaround time by keeping all our code (apps and packages) in the same git repo. That way we can specify package dependencies with
path
and always use the latest code.
Versioning packages
When we import packages from pub.dev
, we normally specify which version we want to use.
Each version corresponds to a release, and we can preview the changelog to see what changes across releases (example changelog from Provider).
This makes it a lot easier to see what changed and when, and can help pinpoint bugs/regressions to a specific release.
As you develop your own packages, I encourage you to also have a changelog. And don't forget to update the package version, which lives at the top of the pubspec.yaml
file:
name: bmi
description: Utility functions to calculate the BMI.
version: 0.0.1
author:
homepage:
This is a good and useful habit if other devs will use your code, or you plan to publish your package on pub.dev
.
Challenge: Refactor your apps
Time to put things in practice with a challenge.
Try extracting some code from one of your Flutter projects, and move it to one of more packages.
And use this as an opportunity to think about dependencies in your own projects.
Conclusion
Dart packages are a good way to scale up projects, whether you work for yourself or as part of a big organization.
They make code more modular and reusable, and with clearly defined dependencies.
They also make it easier to split code ownership with other collaborators.
You should consider using them, whether you're working on a single large app, or many separate apps.
After all, there's a reason we have an entire package ecosystem on pub.dev. 😎
Happy coding!
Top comments (0)