1

Context

My app uses a similar functionality like Tinder:

  1. Get nearest users (geohash filter + some others)
  2. User can like/dislike users
  3. On subsequent launches, user needs to see NEW users, without those he or she already liked/disliked

Current structure looks like this:
enter image description here

This is my code so far:

UserRepository:

  // Geohashing with GeoFlutterFire2
  Stream<List<DocumentSnapshot>> getAllUsersInRadius(
      {required LocationData currentLocation, required double radius}) {
    String field = 'position';

    GeoFirePoint centerPoint = _geo.point(
        latitude: currentLocation.latitude ?? 0.0,
        longitude: currentLocation.longitude ?? 0.0);

    return _geo
        .collection(collectionRef: _buildUserQuery())
        .withinAsSingleStreamSubscription(
            center: centerPoint, radius: radius, field: field);
  }

  // The query to pre-filter BEFORE the geohash query
  Query<Map<String, dynamic>> _buildUserQuery() {
    // Filter by 1. location (above), 2. isActive, 3. game list, 4. user type
    var query = _firestoreDB
        .collection(userCollection)
        .where("isActive", isEqualTo: true)
        .where("gameList", arrayContainsAny: ["Example1","Example2"])
        .where(Filter.or(Filter("userType", isEqualTo: currentUser.lookingFor[0]),
            Filter("userType", isEqualTo: currentUser.lookingFor[1])));

    return query;
  }

  // UserRelations = list of liked/disliked other users
  Future<List<String>> getUserRelationIDs(String userID) async {
    List<String> result = [];

    await _firestoreDB
        .collection(userCollection)
        .doc(userID)
        .collection("user_relations")
        .get()
        .then((querySnapshot) {
      for (var doc in querySnapshot.docs) {
        final otherUserID = doc.data()["otherUserID"];
        result.add(otherUserID);
      }
    });

    return result;
  }

UserViewModel:

  Future<void> _getAllUsersInRadius() async {
    currentLocationValue = await _userRepo.getCurrentUserLocation();
    await _userRepo.getCurrentUser(_authRepo.user!.uid);

    if (currentLocationValue != null) {
      // Needed for later filtering
      final userRelationIDs =
          await _userRepo.getUserRelationIDs(_userRepo.currentUser!.uuid);

      var subcription = _userRepo
          .getAllUsersInRadius(
              currentLocation: currentLocationValue!, radius: 2000)
          .listen((documentList) {
        // First, filter out all users that were already liked/disliked by currentUser
        var filteredUserList = documentList.where((docSnapshot) {
          if (!docSnapshot.exists || docSnapshot.data() == null) return false;
          String uuid = docSnapshot.get("uuid");
          if (!userRelationIDs.contains(uuid)) return true;
          return false;
        });
        // Now turn all documents into user objects and publish the list
        allUsersList = filteredUserList
            .map((docSnapshot) => _createUserFromSnapshot(docSnapshot))
            .toList();
        _allUsersStreamcontroller.add(allUsersList);
      });

      subscriptions.add(subcription);
    }
  }

My Problem

I see the following points as problematic:

  • It feels very inefficient to load all the users first and then possibly discard most of them -> wasted reads/month
  • Due to using a geo-query (GeoFlutterFire2), I am very limited in the other filters I can apply (no ranges or sorting, since that is already used for the geohash) -> so I don't have a lot of options on the query side

So what I am wondering is: is there a more performant/efficient way to go about this?

Maybe by structuring the data differently?

Big_Chair
  • 2,781
  • 3
  • 31
  • 58
  • No good answer I am afraid. Firestore is bad at excluding data. One advice though is to change the way you do geo-query: you cant be wasting the range and sorting on this! In my app I implemented it myself using geohashes, no external libraries. no range, no sorting. Otherwise how can you perform such useful simple queries as "get the latest registered users around me"? – l1b3rty Apr 24 '23 at 10:22
  • @l1b3rty True, but don't you need the ranges for a bounding rectangle and the sorting to get the nearest users first? But I suppose there is another way too, having just read [this article](https://duncanacampbell.medium.com/demystifying-compound-location-queries-in-firebase-740e88a3fa9a) while typing. Did you use something similar? – Big_Chair Apr 24 '23 at 11:31
  • Yes I used geohashes (as mentioned in my first comment). Then you can query for instance: get me all users less than around 3km from me = querying on geohash5. Things get a bit more complex if you want to account for boundaries of geohahes and the fact that geohashes get narrower as you come nearer to the poles. DM me if you want more details – l1b3rty Apr 24 '23 at 11:57
  • @l1b3rty I don't think you can DM here? The GeoFlutterFire library also uses geohashes, but it uses this line: `temp.orderBy('$field.geohash').startAt([geoHash]).endAt([end])`. I think I'm okay for now, though, since I don't need more ordering or ranges necessarily (even if it would be nice). My bigger problem was the amount of unnecessary reads due to liked/disliked list filtering. But as you said, there doesn't seem to be much that I can do. – Big_Chair Apr 24 '23 at 13:30
  • If you have to do this filtering client-side, maybe Firestore is not a good choice for your app? It will lead to a bad user experience and huge bills when you have a lot of users – l1b3rty Apr 24 '23 at 13:57
  • @l1b3rty I'm pretty sure you're right, I would probably fare better with an SQL alternative. But Firebase is the best price-wise... 50k reads & 20k writes for free per month. Others, like [back4app](https://www.back4app.com/pricing/backend-as-a-service) offer only 25k requests per _month_. That's why I stayed with Firebase :/ – Big_Chair Apr 24 '23 at 14:47
  • Question: is the location of your users set once or can it change? – l1b3rty Apr 25 '23 at 09:48
  • @l1b3rty It would have to be checked on every app start. It is similar to Tinder in that regard, where users want to check for nearby users depending on where they are. But in theory I could force the users to make it more static, what did you have in mind? – Big_Chair Apr 25 '23 at 10:14
  • Just for fun, I have answered your question for a limited case where the user's position is static and your search radius as well. May not be of any help – l1b3rty Apr 25 '23 at 11:06

1 Answers1

1

I may have a solution in a limited case, it assumes:

  • that you users can only set their location once
  • you are using geohashes to encode the user's positions
  • that the radius on which a search is made is static. Say 3km corresponding to geohash5

The idea is to "include" users in your query rather then "excluding" them, which Firestore cannot do well:

  1. When a user sets its location, it is assigned a unique number per geohash5, a number starting at 1 for the first user and incremented for each new user

  2. The total number of users per geohash5 is also stored in a central document

  3. When your app picks a list of users to show, it will

    a. Select the geohash5 currentgeohash5 to use, the one of the user

    b. Pick the numbers [no1, ..., no10] of the users to show between 1 and the max for this geoash5 (step 2 above), but only among those that have been neither liked, nor disliked

    c. Query new users with

    .where("geohash5", "==", currentgeohash5)
    .where("no", "in", [no1, ..., no10])
    

    d. Also store locally any likes and dislikes so you dont have to load them from Firestore at step 3.b

Note: when using geohashes this way you also need to account for the frequent case of your user being close to the edge of its geohash, in which case you also want to include some of the ones around. But the algorithm works just the same way.

l1b3rty
  • 3,333
  • 14
  • 32
  • 2
    Hah, I see what you mean. It is not fully applicable to my use case in that form, but I have awarded you the bounty nevertheless for taking the time to think with me through my problem :) – Big_Chair Apr 25 '23 at 18:14