Sign in

Flutter: Get the best ROI with Geolocation and Firebase (Firestore)

… By building a simple local cache

Photo by henry perks on Unsplash

As you probably know, Firebase pricing is based on the number of documents requested. It already provides a method of paging when it comes to lists, but there is none for geolocation.

The idea here is to take the concept of paging by Geohash, used by many Flutter modules using GeoFire like geoflutterfire or flutter_geofire.
A Geohash consists of representing a geographic region with a hash. Knowing this, we can correspond a hash to the screen bounds.

The main problem with the modules above comes when having a lot of data displayed on the map. Indeed, if we retrieve the data when the bounds change, all the markers already loaded are requested again. It will definitely increase the costs.

To decrease the number of documents requested, there are two methods:

  1. Implementing a clustering algorithm on the database side
  2. Setup a local cache to know which region of the map is already loaded

The current article talks about the second method.

The choice of the method above depends on your application. If the users edit the markers in real-time (e.g. user’s position tracking), the clustering method is better for your purpose. Indeed, the second one assumes that the data are rarely changed in Firebase.

The following modules are used to build our service:

Each document in our collection contains an attribute called position.

position: {
geohash : "gbsuv7ztq"
geopoint : [48.668983, -4.329021]
}

We called the service each time the user changes the displayed region. It is the role of the “onCameraIdle” listener to do so.

GoogleMap(
onCameraIdle: () async {
var region = await mapController.getVisibleRegion()
var zoom = await mapController.getZoomLevel()
var p1 = geo.LatLng(
region.southwest.latitude,
region.southwest.longitude
);
var p2 = geo.LatLng(
region.northeast.latitude,
region.northeast.longitude
);
var center = geo.Geodesy().midPointBetweenTwoGeoPoints(p1, p2);
MyService.getMarkers(
LatLng(center.latitude, center.longitude),
zoom,
(markers) {
// Update the markers
// ..
}
);
},
// ...
)

First, we define a function to convert the zoom level of Google Maps to the precision of the Geohash and another function to get the end code of a geohash. It will be used to get the geohashes until the next geohash (endcode)is reached.

static int _zoomToPrecision(double zoom) {
if (zoom < 5) return 1;
if (zoom < 7) return 2;
if (zoom < 10) return 3;
if (zoom < 12) return 4;
if (zoom < 15) return 5;
if (zoom < 17) return 6;
else return 7;
}
/// Return the endcode (last char) of the given word.
/// Used to get all documents related to a geohash.
static String _getEndcode(String word) {
int strLength = word.length;
var strFrontCode = word.substring(0, strLength-1);
String strEndCode = word.substring(strLength-1, word.length);

return strFrontCode + String.fromCharCode(
charCodeAt(strEndCode, 0) + 1);
}

Then, let’s define the cache structure.

static var zonesCache = {
1: Set(), 2: Set(), 3: Set(), 4: Set(), 5: Set(), 6: Set()
};

Finally, we will call Firestore to have the neighbours of the displayed region (figure 1) which are not in the cache.

Figure 1: In red, the neighbours of the region displayed. They are stored in the array “directions”

Here is the full code to get the markers from Firestore and update the cache.

static const MARKERS_COLLECTION = 'markers';
static const COL_POSITION = 'position';
static const COL_POSITION_HASH = COL_POSITION + '.geohash';
static Future<void> getMarkers(LatLng center, double zoom, Function(List<Marker>) function) async {
int precision = _zoomToPrecision(zoom);
String centerHash = hasher.encode(
center.longitude,
center.latitude,
precision: precision
);
List<String> directions = hasher.neighbors(centerHash)
.values.toList();
// Update cache and directions
_updateCache(precision, directions);
// Retrieve the markers from collection
for (var direction in directions) {
var snapshots = Firestore.instance
.collection(MARKERS_COLLECTION)
.where(
COL_POSITION_HASH,
isGreaterThanOrEqualTo: direction,
isLessThan: _getEndcode(direction)
)
.snapshots();
snapshots.listen((snapshot) {
var markers = _getMarkersFromSnapshot(snapshot);
function(markers)
});
}
}

/// Update the cache and update directions hashes given
/// in parameters
static void _updateCache(int precision, List<String> directions) {
int index = directions.length - 1;
bool stop = false;
for (; index >= 0; index--) {
var hash = directions[index];
for(int i = 1; i <= precision; i++) {
String zoneHash = hash.substring(0, i);
bool containsZoneHash =
zonesCache[i].contains(zoneHash);
if (zoneHash == hash) {
if (!containsZoneHash)
zonesCache[precision].add(hash);
else
directions.removeAt(index);
} else {
if (containsZoneHash) {
directions.clear();
stop = true;
break;
}
}
}
if (stop) break;
}
}
/// Format the snapshot elements to obtain a list of Marker
static List<Marker> _getMarkersFromSnapshot(snapshot) { ... }

Of course, as you can tell, my method is not the best looking at the precision we are caching. The modules based on GeoFire are more accurate by specifying a range around the center of the camera. Unfortunately, this method seems harder to cache than having a square defining a region.

Thank you for reading. If you have any suggestion or comments don’t hesitate I’ll be glad to read from you.

Hello :). I’m an engineer in Computer Science. Passionate about UX and UI design, I love to study how to make the life of users better.