许多应用程序都有按物理位置索引的文档。例如,您的应用可能允许用户浏览其当前位置附近的商店。
Cloud Firestore 仅允许每个复合查询使用单个范围子句,这意味着我们无法通过简单地将纬度和经度存储为单独的字段并查询边界框来执行地理查询。
解决方案:地理哈希
Geohash 是一个将(latitude, longitude)
对编码为单个 Base32 字符串的系统。在 Geohash 系统中,世界被划分为一个矩形网格。 Geohash 字符串的每个字符指定前缀哈希的 32 个细分之一。例如,Geohash abcd
是完全包含在较大的 Geohash abc
中的 32 个四字符哈希之一。
两个哈希之间的共享前缀越长,它们之间的距离就越近。例如abcdef
比abcdff
abcdeg
然而事实并非如此!两个区域可能非常接近,但具有非常不同的 Geohashes:
我们可以使用 Geohashes 以合理的效率在 Cloud Firestore 中按位置存储和查询文档,同时只需要一个索引字段。
安装帮助库
创建和解析 Geohashes 涉及一些棘手的数学问题,因此我们创建了辅助库来抽象 Android、Apple 和 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
迅速
Java
// Add this to your app/build.gradle
implementation 'com.firebase:geofire-android-common:3.1.0'
存储 Geohashes
对于要按位置索引的每个文档,您需要存储一个 Geohash 字段:
网络
// 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 // ... }
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) { // ... } });
查询地理哈希
Geohashes 允许我们通过加入 Geohash 字段上的一组查询,然后过滤掉一些误报来近似区域查询:
网络
// 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]) } 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 // ... } });
限制
使用 Geohashes 查询位置为我们提供了新功能,但也有其自身的限制:
- 误报- Geohash 查询不准确,您必须在客户端过滤掉误报结果。这些额外的读取会增加您的应用程序的成本和延迟。
- 边缘案例- 此查询方法依赖于估计经度/纬度线之间的距离。随着点越来越接近北极或南极,这个估计的准确性会降低,这意味着 Geohash 查询在极端纬度有更多的误报。