Molte app hanno documenti indicizzati in base a posizioni fisiche. Ad esempio, la tua app potrebbe consentire agli utenti di navigare nei negozi vicino alla loro posizione attuale.
Cloud Firestore consente solo una singola clausola di intervallo per query composta , il che significa che non possiamo eseguire query geografiche semplicemente archiviando latitudine e longitudine come campi separati e interrogando un riquadro di delimitazione.
Soluzione: Geohash
Geohash è un sistema per codificare una coppia (latitude, longitude)
in una singola stringa Base32. Nel sistema Geohash il mondo è diviso in una griglia rettangolare. Ogni carattere di una stringa Geohash specifica una delle 32 suddivisioni dell'hash del prefisso. Ad esempio, Geohash abcd
è uno dei 32 hash di quattro caratteri completamente contenuti all'interno del più grande Geohash abc
.
Più lungo è il prefisso condiviso tra due hash, più vicini sono l'uno all'altro. Ad esempio abcdef
è più vicino ad abcdeg
che abcdff
. Tuttavia non è vero il contrario! Due aree possono essere molto vicine tra loro pur avendo Geohash molto diversi:
Possiamo utilizzare Geohash per archiviare e interrogare i documenti per posizione in Cloud Firestore con ragionevole efficienza, richiedendo un solo campo indicizzato.
Installa la libreria di supporto
La creazione e l'analisi di Geohash comporta alcune complicate operazioni matematiche, quindi abbiamo creato librerie di supporto per astrarre le parti più difficili su Android, Apple e Web:
ragnatela
// 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
Veloce
Java
// Add this to your app/build.gradle
implementation 'com.firebase:geofire-android-common:3.1.0'
Memorizza Geohash
Per ogni documento che desideri indicizzare per posizione, dovrai memorizzare un campo Geohash:
ragnatela
// 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(() => { // ... });
Veloce
// 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) { // ... } });
Interroga gli hash geografici
I geohash ci consentono di approssimare le query di area unendo una serie di query sul campo Geohash e quindi filtrando alcuni falsi positivi:
ragnatela
// 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 // ... });
Veloce
// 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 // ... } });
Limitazioni
L'uso di Geohash per interrogare le posizioni ci offre nuove funzionalità, ma presenta una serie di limitazioni:
- Falsi positivi : la query di Geohash non è esatta e devi filtrare i risultati falsi positivi sul lato client. Queste letture extra aggiungono costi e latenza alla tua app.
- Casi limite : questo metodo di query si basa sulla stima della distanza tra le linee di longitudine/latitudine. L'accuratezza di questa stima diminuisce man mano che i punti si avvicinano al Polo Nord o Sud, il che significa che le query Geohash hanno più falsi positivi a latitudini estreme.