Consultas geográficas

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:

Geohashes distantes

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

Observação: este produto não está disponível em destinos watchOS e App Clip.
// Adicione isto ao seu pod Podfile 'GeoFire/Utils'

// 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(() => {
 
// ...
});

Observação: este produto não está disponível em destinos watchOS e 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
 
// ...
}

// 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
 
// ...
});

Observação: este produto não está disponível em destinos watchOS e 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])
}

@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.