Check out what’s new from Firebase at Google I/O 2022. Learn more

Requêtes géographiques

De nombreuses applications ont des documents qui sont indexés par emplacements physiques. Par exemple, votre application peut permettre aux utilisateurs de parcourir les magasins à proximité de leur emplacement actuel.

Cloud Firestore n'autorise qu'une seule clause de plage par requête composée , ce qui signifie que nous ne pouvons pas effectuer de requêtes géographiques en stockant simplement la latitude et la longitude dans des champs séparés et en interrogeant une zone de délimitation.

Solution : les géohachages

Geohash est un système permettant d'encoder une paire (latitude, longitude) en une seule chaîne Base32. Dans le système Geohash, le monde est divisé en une grille rectangulaire. Chaque caractère d'une chaîne Geohash spécifie l'une des 32 subdivisions du préfixe de hachage. Par exemple, le Geohash abcd est l'un des 32 hachages à quatre caractères entièrement contenus dans le plus grand Geohash abc .

Plus le préfixe partagé entre deux hachages est long, plus ils sont proches l'un de l'autre. Par exemple abcdef est plus proche de abcdeg que abcdff . Cependant l'inverse n'est pas vrai ! Deux zones peuvent être très proches l'une de l'autre tout en ayant des Geohashes très différents :

Geohashes éloignés

Nous pouvons utiliser Geohashes pour stocker et interroger des documents par position dans Cloud Firestore avec une efficacité raisonnable tout en ne nécessitant qu'un seul champ indexé.

Installer la bibliothèque d'aide

La création et l'analyse de Geohashes impliquent des calculs délicats. Nous avons donc créé des bibliothèques d'assistance pour résumer les parties les plus difficiles sur Android, Apple et Web :

la toile

// Install from NPM. If you prefer to use a static .js file visit
// https://github.com/firebase/geofire-js/releases and download
// geofire-common.min.js from the latest version
npm install --save geofire-common

Rapide

Remarque : ce produit n'est pas disponible sur les cibles watchOS et App Clip.
// Ajoutez ceci à votre pod Podfile 'GeoFire/Utils'

Java

// Add this to your app/build.gradle
implementation 'com.firebase:geofire-android-common:3.1.0'

Stocker les Geohashes

Pour chaque document que vous souhaitez indexer par emplacement, vous devrez stocker un champ Geohash :

la toile

// Compute the GeoHash for a lat/lng point
const lat = 51.5074;
const lng = 0.1278;
const hash = geofire.geohashForLocation([lat, lng]);

// Add the hash and the lat/lng to the document. We will use the hash
// for queries and the lat/lng for distance comparisons.
const londonRef = db.collection('cities').doc('LON');
londonRef.update({
  geohash: hash,
  lat: lat,
  lng: lng
}).then(() => {
  // ...
});

Rapide

Remarque : ce produit n'est pas disponible sur les cibles watchOS et App Clip.
// Compute the GeoHash for a lat/lng point
let latitude = 51.5074
let longitude = 0.12780
let location = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)

let hash = GFUtils.geoHash(forLocation: location)

// Add the hash and the lat/lng to the document. We will use the hash
// for queries and the lat/lng for distance comparisons.
let documentData: [String: Any] = [
    "geohash": hash,
    "lat": latitude,
    "lng": longitude
]

let londonRef = db.collection("cities").document("LON")
londonRef.updateData(documentData) { error in
    // ...
}

Java

// Compute the GeoHash for a lat/lng point
double lat = 51.5074;
double lng = 0.1278;
String hash = GeoFireUtils.getGeoHashForLocation(new GeoLocation(lat, lng));

// Add the hash and the lat/lng to the document. We will use the hash
// for queries and the lat/lng for distance comparisons.
Map<String, Object> updates = new HashMap<>();
updates.put("geohash", hash);
updates.put("lat", lat);
updates.put("lng", lng);

DocumentReference londonRef = db.collection("cities").document("LON");
londonRef.update(updates)
        .addOnCompleteListener(new OnCompleteListener<Void>() {
            @Override
            public void onComplete(@NonNull Task<Void> task) {
                // ...
            }
        });

Interroger les géohachages

Les géohachages nous permettent d'approximer les requêtes de zone en joignant un ensemble de requêtes sur le champ Geohash, puis en filtrant certains faux positifs :

la toile

// Find cities within 50km of London
const center = [51.5074, 0.1278];
const radiusInM = 50 * 1000;

// Each item in 'bounds' represents a startAt/endAt pair. We have to issue
// a separate query for each pair. There can be up to 9 pairs of bounds
// depending on overlap, but in most cases there are 4.
const bounds = geofire.geohashQueryBounds(center, radiusInM);
const promises = [];
for (const b of bounds) {
  const q = db.collection('cities')
    .orderBy('geohash')
    .startAt(b[0])
    .endAt(b[1]);

  promises.push(q.get());
}

// Collect all the query results together into a single list
Promise.all(promises).then((snapshots) => {
  const matchingDocs = [];

  for (const snap of snapshots) {
    for (const doc of snap.docs) {
      const lat = doc.get('lat');
      const lng = doc.get('lng');

      // We have to filter out a few false positives due to GeoHash
      // accuracy, but most will match
      const distanceInKm = geofire.distanceBetween([lat, lng], center);
      const distanceInM = distanceInKm * 1000;
      if (distanceInM <= radiusInM) {
        matchingDocs.push(doc);
      }
    }
  }

  return matchingDocs;
}).then((matchingDocs) => {
  // Process the matching documents
  // ...
});

Rapide

Remarque : ce produit n'est pas disponible sur les cibles watchOS et App Clip.
// Find cities within 50km of London
let center = CLLocationCoordinate2D(latitude: 51.5074, longitude: 0.1278)
let radiusInM: Double = 50 * 1000

// Each item in 'bounds' represents a startAt/endAt pair. We have to issue
// a separate query for each pair. There can be up to 9 pairs of bounds
// depending on overlap, but in most cases there are 4.
let queryBounds = GFUtils.queryBounds(forLocation: center,
                                      withRadius: radiusInM)
let queries = queryBounds.map { bound -> Query in
    return db.collection("cities")
        .order(by: "geohash")
        .start(at: [bound.startValue])
        .end(at: [bound.endValue])
}

var matchingDocs = [QueryDocumentSnapshot]()
// Collect all the query results together into a single list
func getDocumentsCompletion(snapshot: QuerySnapshot?, error: Error?) -> () {
    guard let documents = snapshot?.documents else {
        print("Unable to fetch snapshot data. \(String(describing: error))")
        return
    }

    for document in documents {
        let lat = document.data()["lat"] as? Double ?? 0
        let lng = document.data()["lng"] as? Double ?? 0
        let coordinates = CLLocation(latitude: lat, longitude: lng)
        let centerPoint = CLLocation(latitude: center.latitude, longitude: center.longitude)

        // We have to filter out a few false positives due to GeoHash accuracy, but
        // most will match
        let distance = GFUtils.distance(from: centerPoint, to: coordinates)
        if distance <= radiusInM {
            matchingDocs.append(document)
        }
    }
}

// After all callbacks have executed, matchingDocs contains the result. Note that this
// sample does not demonstrate how to wait on all callbacks to complete.
for query in queries {
    query.getDocuments(completion: getDocumentsCompletion)
}

Java

// Find cities within 50km of London
final GeoLocation center = new GeoLocation(51.5074, 0.1278);
final double radiusInM = 50 * 1000;

// Each item in 'bounds' represents a startAt/endAt pair. We have to issue
// a separate query for each pair. There can be up to 9 pairs of bounds
// depending on overlap, but in most cases there are 4.
List<GeoQueryBounds> bounds = GeoFireUtils.getGeoHashQueryBounds(center, radiusInM);
final List<Task<QuerySnapshot>> tasks = new ArrayList<>();
for (GeoQueryBounds b : bounds) {
    Query q = db.collection("cities")
            .orderBy("geohash")
            .startAt(b.startHash)
            .endAt(b.endHash);

    tasks.add(q.get());
}

// Collect all the query results together into a single list
Tasks.whenAllComplete(tasks)
        .addOnCompleteListener(new OnCompleteListener<List<Task<?>>>() {
            @Override
            public void onComplete(@NonNull Task<List<Task<?>>> t) {
                List<DocumentSnapshot> matchingDocs = new ArrayList<>();

                for (Task<QuerySnapshot> task : tasks) {
                    QuerySnapshot snap = task.getResult();
                    for (DocumentSnapshot doc : snap.getDocuments()) {
                        double lat = doc.getDouble("lat");
                        double lng = doc.getDouble("lng");

                        // We have to filter out a few false positives due to GeoHash
                        // accuracy, but most will match
                        GeoLocation docLocation = new GeoLocation(lat, lng);
                        double distanceInM = GeoFireUtils.getDistanceBetween(docLocation, center);
                        if (distanceInM <= radiusInM) {
                            matchingDocs.add(doc);
                        }
                    }
                }

                // matchingDocs contains the results
                // ...
            }
        });

Limites

L'utilisation de Geohashes pour interroger des emplacements nous offre de nouvelles fonctionnalités, mais s'accompagne de son propre ensemble de limitations :

  • Faux positifs - l'interrogation par Geohash n'est pas exacte et vous devez filtrer les résultats faux positifs côté client. Ces lectures supplémentaires ajoutent du coût et de la latence à votre application.
  • Cas extrêmes - cette méthode de requête repose sur l'estimation de la distance entre les lignes de longitude/latitude. La précision de cette estimation diminue à mesure que les points se rapprochent du pôle Nord ou Sud, ce qui signifie que les requêtes Geohash ont plus de faux positifs aux latitudes extrêmes.