Making Everything Faster by Caching to Local Storage

Our app works, but it is very slow because it downloads everything every time, and that wastes the user’s time and data.

To make our app more efficient, we’ll have to save the comics to a local cache and retrieve them from there before trying to get them from the Internet. If the needed comic isn’t already saved, we’ll download it from the Internet.

An Introduction to the path_provider Package

Mobile apps can store data in two directories: a temporary cache directory (which is what we’ll use for this example) or a permanent Data/Documents directory (which is meant to be used for permanent storage of user data). The difference between those two directories is that the cache directory may be wiped at any time by the operating system or the user; wiping the data directory should be done only after warning the user that they may lose important data.

The path_provider package allows you to get the path where these folders are located in all of the operating systems supported by Flutter, whereas the I/O operations themselves are handled by the built-in dart:io plugin. Let’s start by adding the following to the dependencies in pubspec.yaml:

 path_provider:

And import it in our Dart code using this at the top:

 import​ ​'package:path_provider/path_provider.dart'​;

Since they perform I/O operations (which take a considerable amount of time to complete), both path_provider and dart:io functions return Futures.

Get Down to It: Implement Caching

Since we also need to use Dart’s built-in dart:io plugin to actually perform the I/O operations, we need to add the following at the top of the Dart file:

 import​ ​'dart:io'​;

The code we need to modify is the code that fetches the comics: we need the comic fetching functions to check if the comic is already saved, get it from local storage if it is, and download it and save it if it isn’t already saved.

We’ll create a file to store the number of the latest comic (which is going to be updated whenever the app is started with a network connection available) and each comic is going to be saved in a separate file.

Let’s start with the most significant change: the change to the fetchComic methods.

Specifically, the HomeScreen’s fetchComic method, which, at the moment, looks like this:

 Future<Map<​String​, ​dynamic​>> _fetchComic(​int​ n) async =>
  json.decode(
  await http.read(
 "https://xkcd.com/​​${latestComic-n}​​/info.0.json"
  )
  );

Fat arrow syntax won’t work for what we need now: before returning a value we need to get the temporary directory path, check if the comic has already been downloaded and fetch it if it hasn’t.

We’ll start editing it by reverting to the regular syntax, with braces and return:

 Future<Map<​String​, ​dynamic​>> _fetchComic(​int​ n) async {
 return​ json.decode(
  await http.read(
 "https://xkcd.com/​​${latestComic-n}​​/info.0.json"
  )
  );
 }

At the start of the function, we’ll declare three variables: the directory that will contain the files, the comic number (since we’ll use it more than once it’s easier to just calculate it once and use it every time) and the File object itself.

The function that returns the directory is getTemporaryDirectory, it is asynchronous and returns a Directory object, the path string itself is the Directory.path member.

Files are managed through the File class, which allows us to create files and perform read/write operations on them.

The File constructor is very simple: it takes one positional argument, which is the path to the file.

We can perform operations on files using two methods for each operation: we can choose between a synchronous method (like File.readAsStringSync) which returns whatever is requested directly (no Futures involved), and an asynchronous method (like File.readAsString) which is asynchronous, returns a Future and requires await or Future.then to retrieve the requested data. The advantage of the synchronous method is that we don’t need to convert everything to asynchronous methods and we don’t need to use await all the time, while the advantage of the asynchronous method is that we can just run it and let the rest of the main thread run unaffected if we don’t need the result, as would happen when we create files, for example.

The First Step: Saving the JSON Response

These three things can be done with the following three lines of code:

 final​ dir = await getTemporaryDirectory();
 int​ comicNumber = latestComic-n;
 var​ comicFile = File(​"​​${dir.path}​​/​​$comicNumber​​.json"​);

Now we need to check if the file exists and isn’t empty and return its decoded content:

 if​(await comicFile.exists() && comicFile.readAsStringSync() != ​""​)
 return​ json.decode(comicFile.readAsStringSync());

If it isn’t, we need to create the file and write the JSON string to it:

 else​ {
 final​ comic =
  await http.read(​'https://xkcd.com/​​${latestComic - n}​​/info.0.json'​);

The entire function, when put together, ends up being:

 Future<Map<​String​, ​dynamic​>> _fetchComic(​int​ n) async {
 final​ dir = await getTemporaryDirectory();
 int​ comicNumber = latestComic-n;
 var​ comicFile = File(​"​​${dir.path}​​/​​$comicNumber​​.json"​);
 if​(await comicFile.exists() && comicFile.readAsStringSync() != ​""​)
 return​ json.decode(comicFile.readAsStringSync());
 else​ {
 final​ comic =
  await http.read(​'https://xkcd.com/​​${latestComic - n}​​/info.0.json'​);
 /* no need to use sync methods as we
  don't have to wait for it to finish caching */
  comicFile.writeAsString(comic);
 return​ json.decode(comic);
  }
 }

Since the SelectionPage should also take advantage of cached comics, we just need to modify this function slightly to take the comic number as a string argument:

 Future<Map<​String​, ​dynamic​>> _fetchComic(​int​ n) async {
 final​ dir = await getTemporaryDirectory();
 var​ comicFile = File(​"​​${dir.path}​​/​​$n​​.json"​);
 
 if​(await comicFile.exists() && comicFile.readAsStringSync() != ​""​)
 return​ json.decode(comicFile.readAsStringSync());
 else​ {
 final​ comic =
  await http.read(​'https://xkcd.com/​​$n​​/info.0.json'​);
  comicFile.writeAsString(comic);
 return​ json.decode(comic);
  }
 }

Saving the Latest Comic Number

In its current state, our app still depends on the latest comic number being fetched at startup to work, but we can save that in a file when we get it and, if at some point we can’t fetch it at startup, we will try to get it from the file instead.

We obviously want to fetch the remote one whenever we can, since we want that number to change when new comics come out, unlike what we did in _fetchComic, where we fetched from files whenever possible and only resorted to fetching from the Internet if that wasn’t possible.

To do this in our app we need to change the getLatestComicNumber top-level function, and to achieve what we want there is a feature that will be familiar to most programmers is catching exception using try and catch.

If the app attempts a network connection and fails (which happens when the site can’t be reached by the device for any reason), it will throw an exception.

We can catch that exception and grab the comic number from local storage instead.

We’ll start editing the function declaring the directory and file variables, along with a variable to store the latest comic number that will be returned:

 final​ dir = await getTemporaryDirectory();
 var​ file = File(​'​​${dir.path}​​/latestComicNumber.txt'​);
 int​ n = 1;

Catching Exceptions

This section is an anticipation of Throwing and Catching Exceptions, since we will deal with try-catch block to catch exceptions.

Whenever, in any programming language, a piece of code fails to perform the action that is requested to perform, the programming language allows it to throw an exception. This will result in a crash of the piece of software in question, but any exception can be handled by catching it and performing an action in response to the exception.

For example, if we try to get the comic number and an exception is thrown (meaning that the latest comic number couldn’t be fetched, probably because no network connection is available) we can catch that exception and check if we have fetched it in a previous occasion, so that we can retrieve it from there instead.

To do that, we’ll start by wrapping whatever could fail in a try block, like this:

 try​ {
 // CODE THAT COULD FAIL
 } ​catch​(exceptionDetails) {
  print(​"Something failed"​);
  print(exceptionDetails);
 }

So let’s fetch the latest comic number, but in a try block:

 try​ {
  n = json.decode(
  await http.read(​'https://xkcd.com/info.0.json'​)
  )[​"num"​];
 }

Since saving the number in a file doesn’t affect the rest of the function’s execution, we can check if the file exist, create it if it doesn’t and write the number to the file in a separate thread, without making the function wait for these operations to be completed (all of this will go inside the try block and will only be executed if the previous instruction doesn’t fail):

 file.exists().then(
  (exists) {
 if​(!exists) file.createSync();
  file.writeAsString(​'​​$n​​'​);
  }
 );

The .then method creates a new thread, waiting for the function to return a value.

When file.exists returns a value, it is passed to an anonymous function that does everything we need it to do without affecting the rest of the function.

If this fails and there is a file from which we can get the number we will get the number from that file, using the catch keyword:

 catch​(e) {
 if​(
  file.existsSync() &&
  file.readAsStringSync() != ​""
  )
  n = ​int​.parse(file.readAsStringSync());
 }

And, regardless of how we get here, we return the number:

 return​ n;

Saving the Images

Now, if you try to open the app with no network connection, you will notice that everything works, but there are no images.

This shouldn’t be surprising: we are only saving their URL, and we obviously can’t access the images without a network connection.

This means we need to download the images to make our app 100% usable offline and, to be able to do this, we will, once again, have to make changes to fetchComic.

The code that runs when the file already exists doesn’t need to be changed: it just reads the file and saves it, we just need to ensure that we clear the cache before running the app since we’ll be making changes to how the comic’s JSON string is being saved and we will be making changes to the functions that use the values in the cached comics.

In the else statement’s curly braces in the HomeScreen’s fetchComic method we need to create the file, get the comic’s JSON body, save the image to local storage, and replace the URL in the img parameter with the saved image’s path.

These are all operations that have already been described by this book and, when put together, produce the following _fetchComic:

 Future<Map<​String​, ​dynamic​>> _fetchComic(​int​ n) async {
 
 final​ dir = await getTemporaryDirectory();
 int​ comicNumber = latestComic-n;
 var​ comicFile = File(​"​​${dir.path}​​/​​$comicNumber​​.json"​);
 if​(
  await comicFile.exists() &&
  comicFile.readAsStringSync() != ​""
  )
 return​ json.decode(comicFile.readAsStringSync());
 else​ {
  comicFile.createSync();
 final​ comic = json.decode(
  await http.read(
 'https://xkcd.com/​​$comicNumber​​/info.0.json'
  )
  );
 
  File(​'​​${dir.path}​​/​​$comicNumber​​.png'​)
  .writeAsBytesSync(await http.readBytes(comic[​"img"​]));
  comic[​"img"​] = ​'​​${dir.path}​​/​​$comicNumber​​.png'​;
  comicFile.writeAsString(json.encode(comic));
 
 return​ comic;
 
  }
 }

We are just using an asynchronous function without an await expression to write the comic file, since it isn’t going to be used by the app until it is restarted, so we don’t need to pause the execution to write it like we do, for example, for the image file that is going to be used by the ComicTile not long after the method returns.

The same exact changes apply to the SelectionPage’s _fetchComic method, given that this part of the function doesn’t change between the two:

 Future<Map<​String​, ​dynamic​>> _fetchComic(​String​ n) async {
 final​ dir = await getTemporaryDirectory();
 var​ comicFile = File(​"​​${dir.path}​​/​​$n​​.json"​);
 
 if​(
  await comicFile.exists() &&
  comicFile.readAsStringSync() != ​""
  )
 return​ json.decode(comicFile.readAsStringSync());
 else​ {
  comicFile.createSync();
 final​ comic = json.decode(
  await http.read(​'https://xkcd.com/​​$n​​/info.0.json'​)
  );
 
  File(​'​​${dir.path}​​/​​$n​​.png'​)
  .writeAsBytesSync(await http.readBytes(comic[​"img"​]));
  comic[​"img"​] = ​'​​${dir.path}​​/​​$n​​.png'​;
  comicFile.writeAsString(json.encode(comic));
 
 return​ comic;
  }
 }

Since the comic["img"] value is now the path to the file that contains the comic image, we need to change (in the build methods for the ComicPage and for the ComicTile) the following line:

 Image.network(comic[​"img"​])

to:

 Image.file(File(comic[​"img"​]))

At this point opening the app with an active Internet connection, closing it and opening it again without an Internet connection will reveal that the app is now completely capable of browsing comics that have already been fetched while not being able to perform requests to the XKCD website.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset