Muitos aplicativos possuem documentos indexados por locais físicos. Por exemplo, seu aplicativo pode permitir que os usuários naveguem em lojas próximas à sua localização atual.
O Cloud Firestore permite apenas uma cláusula de intervalo por consulta composta , o que significa que não podemos realizar consultas geográficas simplesmente armazenando latitude e longitude como campos separados e consultando uma caixa delimitadora.
Solução: Geohashes
Geohash é um sistema para codificar um par (latitude, longitude)
em uma única string Base32. No sistema Geohash o mundo é dividido em uma grade retangular. Cada caractere de uma string Geohash especifica uma das 32 subdivisões do hash do prefixo. Por exemplo, o Geohash abcd
é um dos 32 hashes de quatro caracteres totalmente contidos no Geohash abc
maior.
Quanto maior o prefixo compartilhado entre dois hashes, mais próximos eles estarão um do outro. Por exemplo abcdef
está mais próximo de abcdeg
do que abcdff
. No entanto, o inverso não é verdade! Duas áreas podem estar muito próximas uma da outra e terem Geohashes muito diferentes:
Podemos usar Geohashes para armazenar e consultar documentos por posição no Cloud Firestore com eficiência razoável, exigindo apenas um único campo indexado.
Instalar biblioteca auxiliar
Criar e analisar Geohashes envolve matemática complicada, por isso criamos bibliotecas auxiliares para abstrair as partes mais difíceis no Android, Apple e Web:
// 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
// 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
// Add this to your app/build.gradle
implementation 'com.firebase:geofire-android-common:3.2.0'
// Add this to your app/build.gradle
implementation 'com.firebase:geofire-android-common:3.1.0'
Armazenar Geohashes
Para cada documento que você deseja indexar por localização, você precisará armazenar um campo Geohash:
import { doc, updateDoc } from 'firebase/firestore';
// 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 = doc(db, 'cities', 'LON');
await updateDoc(londonRef, {
geohash: hash,
lat: lat,
lng: lng
});
// 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(() => {
// ...
});
// 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
// ...
}
// Compute the GeoHash for a lat/lng point
val lat = 51.5074
val lng = 0.1278
val hash = GeoFireUtils.getGeoHashForLocation(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.
val updates: MutableMap<String, Any> = mutableMapOf(
"geohash" to hash,
"lat" to lat,
"lng" to lng,
)
val londonRef = db.collection("cities").document("LON")
londonRef.update(updates)
.addOnCompleteListener {
// ...
}
// 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) {
// ...
}
});
Consultar Geohashes
Geohashes nos permitem aproximar consultas de área juntando um conjunto de consultas no campo Geohash e depois filtrando alguns falsos positivos:
import { collection, query, orderBy, startAt, endAt, getDocs } from 'firebase/firestore';
// 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 = query(
collection(db, 'cities'),
orderBy('geohash'),
startAt(b[0]),
endAt(b[1]));
promises.push(getDocs(q));
}
// Collect all the query results together into a single list
const snapshots = await Promise.all(promises);
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);
}
}
}
// 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
// ...
});
// 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])
}
@Sendable func fetchMatchingDocs(from query: Query,
center: CLLocationCoordinate2D,
radiusInMeters: Double) async throws -> [QueryDocumentSnapshot] {
let snapshot = try await query.getDocuments()
// Collect all the query results together into a single list
return snapshot.documents.filter { document in
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)
return distance <= radiusInM
}
}
// After all callbacks have executed, matchingDocs contains the result. Note that this code
// executes all queries serially, which may not be optimal for performance.
do {
let matchingDocs = try await withThrowingTaskGroup(of: [QueryDocumentSnapshot].self) { group -> [QueryDocumentSnapshot] in
for query in queries {
group.addTask {
try await fetchMatchingDocs(from: query, center: center, radiusInMeters: radiusInM)
}
}
var matchingDocs = [QueryDocumentSnapshot]()
for try await documents in group {
matchingDocs.append(contentsOf: documents)
}
return matchingDocs
}
print("Docs matching geoquery: \(matchingDocs)")
} catch {
print("Unable to fetch snapshot data. \(error)")
}
// Find cities within 50km of London
val center = GeoLocation(51.5074, 0.1278)
val radiusInM = 50.0 * 1000.0
// 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.
val bounds = GeoFireUtils.getGeoHashQueryBounds(center, radiusInM)
val tasks: MutableList<Task<QuerySnapshot>> = ArrayList()
for (b in bounds) {
val 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 {
val matchingDocs: MutableList<DocumentSnapshot> = ArrayList()
for (task in tasks) {
val snap = task.result
for (doc in snap!!.documents) {
val lat = doc.getDouble("lat")!!
val lng = doc.getDouble("lng")!!
// We have to filter out a few false positives due to GeoHash
// accuracy, but most will match
val docLocation = GeoLocation(lat, lng)
val distanceInM = GeoFireUtils.getDistanceBetween(docLocation, center)
if (distanceInM <= radiusInM) {
matchingDocs.add(doc)
}
}
}
// matchingDocs contains the results
// ...
}
// 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
// ...
}
});
Limitações
Usar Geohashes para consultar locais nos dá novos recursos, mas vem com seu próprio conjunto de limitações:
- Falsos Positivos - a consulta por Geohash não é exata e você deve filtrar resultados falsos positivos no lado do cliente. Essas leituras extras adicionam custo e latência ao seu aplicativo.
- Casos extremos - este método de consulta depende da estimativa da distância entre linhas de longitude/latitude. A precisão desta estimativa diminui à medida que os pontos se aproximam do Pólo Norte ou Sul, o que significa que as consultas Geohash têm mais falsos positivos em latitudes extremas.