The original address: tech.ebayinc.com/engineering…
The original author: tech.ebayinc.com/authors/lar…
Release date: April 1, 2021
Learn how we avoided state management arguments when building the eBay Motors application.
When we discuss eBay Motors applications, the most common question we get is, “What state management solution does eBay Motors use?” The short answer is that we primarily manage state in StatefulWidgets, using providers or InheritedWidgets to expose this state under the Widget tree.
However, we thought a more interesting question should be asked.” How did eBay Motors design their codebase so that the choice of status management tool doesn’t matter?”
We believe that choosing the right tools, or applying a single design pattern, is far less important than establishing clear contracts and boundaries between unique functions and components in your application. Without boundaries, it’s all too easy to write unmaintainable code that doesn’t scale as your application grows in complexity.
“Unmaintainable code” can be defined in several ways.
- Changes in one area can create errors in seemingly unrelated areas.
- Code that is hard to reason with.
- It is difficult to write code for automated tests; or
- Code has unexpected behavior.
Any of these problems in your code will slow you down and make it harder to provide value to your users.
Establishing clear boundaries between different areas of code can reduce these problems. This approach encourages you to break down large, complex problems into smaller, more manageable pieces. It encourages different domains to communicate through abstraction and allows private implementation details to be encapsulated. It also reduces unexpected coupling and side effects, ultimately leading to a more flexible design that is easier to change. Most importantly, creating these contracts forces engineers to really understand the problem space and the functionality they are building, which always leads to better results.
Most of us on the team have been working together on our inherited code base for several years, and we know from experience that it is important to add clear domain boundaries from the outset.
As a team, we agreed that the best way to start codifying our boundaries was to create separate Flutter packages for each of the main screens in our application. This is a mandatory feature that serves multiple purposes. First, it allowed our team members to work independently on different screens without stepping on each other’s toes. We want to provide experimentation space for engineers to discover which models work best for us in the new technology stack. Second, it supports our team’s goal of ensuring that all behavior is covered by tests. In order to pass continuous integration (CI) checks, each package needs to be fully covered by automated tests. Our boundaries force each package to be independently testable, which increases our confidence in development.
The apis for these packages are usually simple and straightforward. Each package exposes a widget representing the entire screen that defines the dependencies needed to achieve its purpose. Everything else in the package is private. Thus, the look and feel of the screen, user interaction, and state management are implementation details that can evolve freely without affecting the rest of the application.
When we started coding, we typed out several packages that represented our first set of features. This includes making a skeleton of a home screen, a search screen, and a vehicle details screen for viewing more listing information. Our top-level application packages focus on stitching these packages together properly to create a functional user flow. With this, we can work together and easily parallel.
At this point, our team members continue to test and learn to determine what is best for us when using Flutter. We started using BLOC mode almost exclusively in each package and explored adding other design patterns that we were used to from traditional local development. Throughout this early phase, the only constants were our package boundaries and focus on achieving 100% test coverage through the common API for each package.
A few weeks later, as our understanding of the Widget tree of Flutter grew, we began to realize that the pattern we had applied to state management was not serving us well. They force us to create extra layers of abstraction, unnecessary templates, and overcomplicate the code base. In many cases, we learned that we could solve the same problem with a simple StatefulWidget and much less code. This is where the value of our testing strategy becomes apparent. Because we were testing through the package’s public API, our testing was not tied to implementation details, but focused on asserting the behavior of the package. This allows us to ruthlessly refactor and swap layers of code, often without changing a single line of test code.
As the size and complexity of the application grew, so did the number of packages we created. Today, after two years of development, our MonorePO consists of approximately 240,000 lines of Dart code and 5,500 tests spread over 80 packages. Over the last 24 months, we’ve seen some patterns emerge in state management.
During application initialization, a considerable number of states are created that need to continue throughout the life of the application. Our Application Package initializes and holds references to these states, usually with a StatefulWidget at the top of the Widget Tree. It then injects these class or behavioral dependencies into the Widget tree via a Provider or InheritedWidgets.
Within each domain’s package, there are often states that are limited to a particular screen. We are deliberately not using a consistent pattern here. Each package has evolved to use whichever state management scheme is appropriate for work. We have applied many successful (and unsuccessful!) models. , including BLOC, InheritedModel, and exposing Streams and ValueListenables through InheritedWidgets. In many cases, we have replaced state management tools, and in more cases, we plan to do so. Our approach is to listen to the code and choose the best tool for the needs of that particular domain. This is not so much a key architectural decision as a matter of style.
To better understand our approach to package structure, let’s look at an example.
One of the key features in our purchasing process is the ability to search for a vehicle on our search screen and navigate to the vehicle details screen to learn more about the vehicle and purchase it.
If we break down these screens into their simplest requirements, they look something like this.
Search screen
- Integration with the search API
- Provides infinitely scrolling lists
- Provides a mechanism for filtering and sorting results
- When you click on a list, you need to navigate to another screen.
Car beauty screen
- Integrate with the list details API
- Provides rich content about specific lists
- You need to navigate to other screens to make transactions on the list (chat, buy, bid, etc.).
The two screens feel very different and have very different reasons for change. They are ideal candidates and need to be separated by clear boundaries.
Let’s first model the contract for the search screen. In order for this screen to be tested independently, two dependencies should be injected. In this example, we have chosen to inject these dependencies into an InheritedWidget that is closer to the root of the widget tree than our Search Screen widget.
class SearchDependencies extends InheritedWidget {
const SearchDependencies({
@required this.searchApi,
@required this.onSearchResultTapped,
@required Widget child,
}) : super(child: child);
final 可迭代<SearchResult> Function(SearchParameters, int offset, int limit) searchApi;
final void Function(BuildContext context, String listingId) onSearchResultTapped;
static SearchDependencies of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<SearchDependencies>();
@override
bool updateShouldNotify(covariantSearchDependencies oldWidget) => oldWidget.searchApi ! = searchApi || oldWidget.onSearchResultTapped ! = onSearchResultTapped; }Copy the code
Note: In this example, we only injected some dependencies. In our application, we inject many dependencies into our domain package, such as analysis report apis, feature flags, platform apis, and so on.
This allows any widget in the Search package to access these dependencies using BuildContext. SearchDependencies. Of (context). This is conceptually no different from accessing Theme. Of (context) or any other built-in InheritedWidgets.
class SearchResultCard extends StatelessWidget {
const SearchResultCard({@required this.listingId});
final String listingId;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: () => SearchDependencies.of(context).onSearchResultTapped(context, listingId),
child: Column(
children: [
// some UI here],),),); }}Copy the code
From a testing perspective, we can simply inject any dummy implementation needed for a given test case, and we can fully test the behavior of the Search Screen package.
return SearchDependencies(
searchApi: (searchParams, offset, pageSize) => [ /* Stub data here */ ],
onSearchResultTapped: (context, listingId) => { /* Stubbed implementation here */},
child: SearchResultsScreen(),
);
Copy the code
While we sometimes use this strategy to unit test individual widgets, we typically test the top-level public widgets of a package. This helps ensure that our tests validate the overall behavior rather than coupling to implementation details. We even used this strategy to provide mock data to perform full-screen screenshot tests. You can read more about this in our previous post. Screenshots were taken with Flutter.
This dependency management approach has other benefits as well. We used the same approach with the vehicle detail screen. While the primary use case is rendering a live eBay listing, we have a feature in our sales process that allows sellers to preview their listing before Posting. This preview capability can be easily implemented by wrapping Vehicle Detail widgets with different dependencies for the use case.
Now that we have a public API for searching screen packages, let’s look at how to integrate it in our application.
class _AppState extends State<App> {
ApiClient apiClient = EbayApiClient();
AppNavigator navigator = AppNavigator();
@override
Widget build(BuildContext context) {
returndependencies( [ (app) => SearchDependencies( searchApi: apiClient.search, onSearchResultTapped: navigator.goToVehicleDetails, child: app, ), (app) => VehicleDetailDependencies( vehicleApi: apiClient.getVehicleDetails, child: app, ), ], app: MaterialApp( localizationsDelegates: [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, SearchResultsLocalizations.delegate, VehicleDetailsLocalizations.delegate, ], home: SearchResultsScreen(), ), ); }}Copy the code
In this simple example, a stateful widget exists in our Application package and constructs the concrete implementation of our API client. This is one of the root parts of our application, responsible for constructing our MaterialApp. Notice that we are configuring the dependencies and placing them above the MaterialApp. This is crucial because the MaterialApp provides our root navigator. This means that when we navigate to the new route, these same dependencies are still available from the context because they are in the trunk of the Widget tree.
We will then add a simple integration test to our application package to verify that we have connected our package properly.
testWidgets('Should navigate from Search Results to Vehicle Details when I click on a result', harness((given, when, then) async {
final app = AppPageObject();
// Pump the App and assert we are on the Search Screen
await given.appIsPumped();
// Assert the Search Results is on screen
then.findsOneWidget(app.searchResults);
// Assert no Vehicle Details is on screen
then.findsNothing(app.vehicleDetails);
// Tap on the first search result to see its details
await when.userTaps(app.searchResults.resultAt(0));
// Assert no Search Results is on screen
then.findsNothing(app.searchResults);
// Assert the Vehicle Details is on screen
then.findsOneWidget(app.vehicleDetails);
}));
Copy the code
You may also notice that each package exposes its own localization delegate. In order for each package to be tested independently, the package needs to fully own all its resources: images, fonts, and localized strings.
Obviously, this example has been grossly simplified. On the surface, this package structure might seem excessive. In our code base, however, our Search Screen package has grown to 17,000 lines of code and over 500 tests — it’s big enough that we’re actively working to break it down into smaller, more manageable pieces. In practice, the boundaries of this package allow developers working on other functions to completely ignore all of this complexity. Similarly, when someone does need to search, they can just work in the search screen package and ignore the rest of the entire application.
This approach provides a basis for extending the code base in a manageable way. We can easily develop multiple large-scale features simultaneously with minimal friction. Working in a smaller package allows for greater focus and improved developer turnaround times. If developers make changes, they only need to re-run tests in the affected package, and occasionally run tests in the application package if their changes affect the public API.
We also completely avoid monad and global state and always manage our state through the widget tree. Because of this, the stateful thermal overload of Flutter works consistently throughout the application. It enabled us to add options in the in-app developer menu to switch to our QA environment — forcing all apI-dependent states to be discarded, and enabling the entire application to seamlessly switch environments without recompiling. This allows us to avoid adding context-specific build flavors. Our only compile-time change is an optional build parameter to include the developer menu.
Having a decoupled package also brings a huge benefit to our CI pipeline. If we were to build, analyze, and test all the code in the repository, it would take more than 20 minutes. However, since each package is independently testable, we have optimized our CI pipeline to build and test packages in parallel on our build server. Let’s go one step further, and for our pull request (PR), we only build and test packages that are affected by the affected files. We do this by evaluating which packages are dependent on the changed packages. This means that for most pull requests, our CI turnaround time is usually under 5 minutes. However, this is not free, it requires constant iteration and optimization. If you plan to have a single unit with multiple packages, you should plan to invest some time in developer tools and CI automation.
Getting the correct package boundaries is not always easy. The examples we’ve walked through are cut-in, but with complex functionality that spans multiple packages, the problem becomes more subtle. Consider a feature that allows users to “like” or “favorites” a car on the search results screen and vehicle details screen. Sometimes we don’t discover the correct package boundaries until late in the feature development. Redesigning these boundaries takes time and effort, and it’s easy to kick things to the side and postpone cleaning up. It’s also tempting to shove reusable code into common, shared, or utility packages. However, the “easy” approach almost always leads to the accumulation of technical debt. For a long time, we resisted creating single-purpose packages because we mistakenly thought adding more packages was bad. We went beyond that assumption and couldn’t have been happier since we had almost finished breaking down the last garbage dump bag.
For our team, breaking down the domain modeling and applying it to our application was far more important than choosing the right state management tool. State management fads come and go, but modeling is forever if your application is to survive.
Translation via www.DeepL.com/Translator (free version)