Flutter practice two: repository mode

1.repository

Almost all apps, from the simplest to the most complex, include state management and data sources in their architecture. Common state management methods include Bloc, Cubit, Provider, ViewModel, etc. Data sources are classes that directly interact with databases or network clients to obtain corresponding data and parse them into models.

Typically, the state manager communicates directly with the data source. When there is only one data source, things are simpler. But when there are multiple data sources, such as an APP that needs to cache data, things become complicated.

Caching means backing up the results of your API requests in a local database. This allows you to still obtain the data later when the network is abnormal. This will also help you respond faster and save bandwidth the next time you open this page.

When you cache data for a specific page, the state manager is responsible for interacting directly with the data source, coordinating database and network data sources.

In the repository mode, the repository class is located between the state manager and the data source, taking over the data coordination work originally performed by the state manager, which means that your state manager does not need to care about the source of the data.

The repository allows you to share data coordination logic between different state managers. The repository itself is simple, but has profound value to the code base.

2.class dependency

Class dependency means that one class depends on another class to achieve its work. For example, QuoteRepository relies on FavQsApi to obtain data, which makes FavQsApi a class dependency of QuoteRepository. There are two ways to obtain a class dependency instance of a class:

1. Instantiate yourself: You can instantiate dependent classes in constructors, property declarations, etc., for example:

The advantage is that you don’t have to expose internal dependencies to users of the class. The downside is that if other repositories also depend on the same class, you can’t share the same dependent class instance between different repositories, and you have to repeat this instantiation logic everywhere.

2. The constructor requires an instance to be passed in: for example:

The advantages and disadvantages of this method are exactly opposite to the previous one. Which one is better? This depends on the situation.

3. Processing class dependencies

In Practice 1, we created separate packages for each repository. Because a repository is often used by multiple functions. This makes it infeasible to put them in a feature package, because features cannot depend on each other. Therefore, you cannot use the same repository in multiple functions.

One option is to create a single package to house all repositories, making all functionality accessible. But packages are considered to be things that are often used together, and it is unlikely that a single function will need to use the entire repository. Then there is only one option left: create its own package for each repository.

Create a quote_repository package under the packages folder. in quote_repository.dart

Add the following code:

1.remoteApi is FavQsApi, used to send and request data to the remote API. FavQsApi comes from another fav_qs_api package:

2.QuoteLocalStorage is used to obtain and save quotes from device local storage. QuoteLocalStorage comes from the current package:

Because QuoteLocalStorage only deals with famous quotes, it has no use without the quote_repository package. While FavQsApi is more general as it handles both quotes and authentication calls. This makes it suitable for the user_repository package as well. As you know, when you need to share code between two packages, in this case two repositories – you have to create a third repository.

QuoteLocalStorage depends on KeyValueStorage, which comes from the separate key_value_storage package:

Think of KeyValueStorage as WonderWords’ local database. It is a wrapper for the popular Hive package. It had to become an internal package to have all Hive configuration in one place.

Going back to the QuoteRepository constructor, do you need to require class dependencies in the constructor, or do you instantiate the dependent classes internally?

Class dependencies whose files are in the same package you are working on should be instantiated in the constructor. This is the case with QuoteLocalStorage.

Class dependencies from other internal packages such as KeyValueStorage and FavQsApi must be received in the constructor.

Note that even though QuoteLocalStorage is instantiated in QuoteRepository’s constructor, you are still allowed to receive it in the constructor via an optional parameter. The intention behind this optional parameter is not to expose class dependencies to users of QuoteRepository. Instead, it exists only to allow you to provide mock instances in automated tests, which is why you annotate it with @visibleForTesting.

4. Create bucket file

The QuoteRepository code is in the src directory, so the state manager cannot import the QuoteRepository because they are considered private. The dart package layout convention recommends placing all code in the src directory and intentionally exposing the files you want to expose by exporting them from an “exporter” file placed directly under lib. This export file is also called a bucket file. Part of the convention is to give the barrel file the same name as the package.

Insert the following code into the bucket file quote_repository.dart:

export 'src/quote_repository.dart'

5. Pagination

Paging is to divide the results of an API into multiple batches, and each batch is called paging. This allows users to obtain data and interact with APPs without waiting too long, while also reducing cellular data consumption. Users can progressively load more paginated data on demand.

6.Stream

There are two types of asynchronous programming in Dart: Future and Stream. Future represents a value that you cannot get immediately. For example, getQuote() returns Future, not Quote. Because it takes some network request time to obtain the Quote. The getQuote function immediately returns a channel – Channel to the caller. Then when the request is successful, the real data is sent through this channel.

Stream is a complex form of Future. Future sends one data at a time, while Stream can send multiple data. getQuoteListPage() returns a Stream, not a Future. This is related to the data acquisition strategy below.

7. Data acquisition strategy

When you decide to cache the results of a network call, you need to consider what strategy you will use to deliver the data later.

Is cached data always returned? What if they expire?

Then do you need to get data from the server every time and just use the cached data as a fallback when the network request fails? If so, are frequent loading times unsettling to users? Assuming the data doesn’t change often, would making unnecessary network calls waste cellular traffic?

There are no clear answers to these questions. You have to consider every situation. How often does data expire? In this case, should you prioritize speed or accuracy?
So, it’s time to get more specific and decide what the best strategy is for your WonderWords home page.
When users open WonderWords, they probably expect to see new quotes every time. If they like a quote so much that they want to read it again, they can always bookmark it.
So far, for sure, the best strategy is to get quotes from the server every time and not worry about caching. But what if the network call fails? In this case, it’s better to display the cached data as a fallback.
Okay, you now have a policy. You will continue to get the quotes from the server each time, but then cache the quotes so that they can be used in the future if the network call fails.

Your new strategy is very reliable, but it still has one huge flaw: getting items from the API every time means frequent and lengthy load times for the user. When users open an app, they want to start interacting with it as quickly as possible.

You can’t make the server return data to you faster. But since you cache quotes whenever you want, there’s one major thing you can do: instead of showing a loading page every time the user opens the app, you can display the cached quotes while fetching new quotes in the background.

Note: Returning a Future from the repository is no longer sufficient when using this new strategy. When the state manager asks for the first page of data, you will first send the cached data (if available) and then the data from the API. When dealing with sending data multiple times, you need to use a Stream.

You now have a strategy tailored to your WonderWords home page. The bad news is that even with just the home screen in mind, this design strategy isn’t suitable for every situation.

8. Consider additional situations

Consider these edge cases:

What if the user wants to purposefully refresh the list via a dropdown? In this case you cannot return the “old” data first. Furthermore, users don’t mind seeing a loading page; after all, they know they just requested new data.
What if a user searches for a specific quote, but then clears the search box so they can return to the quote they saw earlier? In this case, it’s better to just show the cached data. You don’t need to show new data later because the user just wants to return to the previous state.

This means that depending on the complexity of the page, a single data fetching strategy may not be enough. In this case, all you can do is let the state manager decide the best strategy for each step of the user journey. That’s why getQuoteListPage() has a fetchPolicy parameter.

fetchPolicy is of type QuoteListPageFetchPolicy , which is the enumeration at the end of the file you’re processing. The following are the values of the enumeration:

cacheAndNetwork: If the HTTP call is successful, the cached quote (if any) is emitted first, then the quote is emitted from the server. Useful when the user first opens the app.

networkOnly: Do not use caching under any circumstances. If the server request fails, notify the user. Useful when the user intentionally refreshes the list.

networkPreferably: Prefer using the server. If the request fails, try using caching. If there is nothing in the cache, let the user know an error occurred. Useful when the user requests subsequent pages.

cachePreferably: Prefer using cache. If there is nothing in the cache, try using the server. Useful when the user clears a tab or search box.

Note: Only cacheAndNetwork can send data twice, and Future is sufficient for other policy return types.

9. Fill cache

Each of the four supported strategies may require data to be fetched from the server at some point in time; after all, there is no cacheOnly strategy. So the first step is to create a utility function that fetches data from the server and populates the cache with it. This way you can reuse the function in getQuoteListPage() for all strategies.

Open lib/quote_repository/src/quote_repository.dart and add code:

1. The return type is Future.

2. Get new pagination from the remote API.

3. Filtered results should not be cached.

4. Every time you get a new first page, all subsequent paginations previously stored must be removed from the cache. This forces future paginations to be fetched from the network, so you don’t risk a mix of updated and stale paginations. Not doing this can cause problems, for example, if a quote that was once on page two is moved to page one, the quote might be displayed twice if cached pages and new pages are mixed together.

10. Model separation

The object obtained from the API by calling remoteApi.getQuoteListPage() is of type QuoteListPageRM, and RM is represented as Remote Model.
The object obtained from the cache by calling _localStorage.upsertQuoteListPage() is of type QuoteListPageCM, and CM is represented as cache Model.

The two types are inconsistent. The repository’s getQuoteListPage() returns the QuoteListPage type.

Each layer of the application has its own specifications when it comes to its model. For example, your remote model copies the structure of JSON and is filled with JSON parsing annotations. Cached models, on the other hand, are filled with database content, depending on which database package you use. Not to mention that some attribute types may also be different; for example, some content is a string type in the API but an enumeration type in the database.

Finally, since the data for the repository sometimes comes from the database and sometimes from the network, you need a neutral and unbiased model to return to the users of the repository. This is called the domain model, in this case QuoteListPage.

In other words, domain models are models that are independent of their origin.

WonderWords defines the domain model in a separate domain_models package, and all repository packages depend on this model. Doing so allows different repositories to share the same domain model.

WonderWords also follows another good practice: in addition to the domain model, it also defines domain exceptions in the same package. Just like returning a neutral/domain model when everything is fine, you can also throw neutral/domain exceptions when something goes wrong.

You can see this happening in that catch block you just wrote. Whenever you catch an EmptySearchResultFavQsException from the fav_qs_api package, you replace it with an EmptySearchResultException from domain_models.

Having these realm exceptions may seem unnecessary, but it is the only way for the state manager to perform custom logic based on the exceptions that occur. For example, since the quote_list function does not depend on the fav_qs_api package, QuoteListBloc cannot check whether the exception is
EmptySearchResultFavQsException , simply because it doesn’t know the type. However, since the quote_list package does depend on domain_models, QuoteListBloc has no problem validating that the exception is an EmptySearchResultException and using it to display a custom message to the user.

11.Mappers

Now you understand why each data source requires a different model, and a neutral model to ultimately return from the repository. But how do you go from one model type to another? You may have guessed that you need some kind of converter. These converters are called Mappers.

Mapper is just a function that takes an object from one model and returns an object from another model. Any necessary conversion logic happens in the middle. For example:

All you have to do is instantiate a new Quote object using the values from the received QuoteCM object.

Then, to use this mapper function, you just do the following:

You can also use dart’s extension functions to implement mapper:

Now you no longer have to receive QuoteCM objects. Using Dart extension functions you can create a function that works just as if you declared it in QuoteCM. Note that you can access properties in QuoteCM simply by typing id or body .

Calling mapper becomes as follows:

12. Support different data strategies

Now you finally understand what is happening in _getQuoteListPageFromNetwork(). Come to getQuoteListPage() above and add the following implementation:

1. There are three situations in which you want to skip the cache lookup and return data directly from the network: if the user selects a tag, if they are searching, or if the caller of the function explicitly specifies the networkOnly policy.

2. The function created earlier

3. The easiest way to generate a Stream in a Dart function is to add async* to the head of the function and then use the yield keyword when you want to emit new items.

Now you’ve covered all the scenarios where cached lookups are not required, i.e. when the user has a filter or the policy is networkOnly. Now you’ll deal with scenarios that force cache lookups.

Replace “//TODO: Cover other fetch policies.” in the above code:

1. Your local storage saves the favorites list in a separate bucket, so you must specify whether to store a regular list or a favorites list.
2. Whether fetchPolicy is cacheAndNetwork or cachePreferably, cached paging must be sent. The difference between the two strategies is that with cacheAndNetwork you also send the server’s pagination later.
3. To return the cached page, namely QuoteListPageCM, the mapper function must be called to convert it into the domain model QuoteListPage.
4. If the policy is cachePreferably and you have successfully issued cached paging, no additional action is required. You can go back and close the Stream here.

The next step is to get the page from the API to complete the remaining three scenarios:

1. When the strategy is cacheAndNetwork. You’ve covered the caching part, but not the AndNetwork part yet.
2. When the policy is cachePreferably, you cannot get pagination from cache.
3. When the policy is networkPreferably.

1. If the policy is networkPreferably and you encounter an error while trying to get a page from the network, try to recover from the error by issuing a cached page (if any).

2. If the policy is cacheAndNetwork or cachePreferably , you have already issued cached paging before, so your only option now is to rethrow the error if the network call fails. This way the state manager can properly handle the error by displaying it to the user.

Use the app on your device and notice how it leverages different acquisition strategies. For example, when you refresh a list via a dropdown, the loading screen takes longer; this is the networkOnly strategy in use. When you add a tag and then remove it, the application quickly reverts to its previous state; this is due to the cachePreferably policy. When you close the app and reopen it, the data loads almost immediately, but then you can see how it’s swapped out after a few seconds; this is cacheAndNetwork in action.

refer to:

《Real-World Flutter by Tutorials》