-
-
Notifications
You must be signed in to change notification settings - Fork 863
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
migrate latlng to use record type (feedback requested) #1750
Conversation
This looks cool, can you help me understand two of your statements?
How is it easier? If both cases (class Vs record) you have to convert your type into whatever type the external library is using.
I tried to research the performance/memory layout of records, but didn't find anything useful. Surely a record is more expensive (in ram) than a object, since it has to carry around extra type information, where a class just carries a pointer to the class definition. I'm curious to learn your reasoning. Thanks |
@mootw I understand the reasoning that if all are using a record a base package like
@bramp I did some performance and memory testing and want to share the results:
Therefore I'd prefer to keep a immutable class for latitude/longitude. I used this to test memory (running in profile mode): Click here to viewimport 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final List<dynamic> data;
@override
void initState() {
const m = 100000000;
data = List.generate(m, (_) => const (23, 53));
// total: 768.6 MB, _Record 32 B
//data = List.generate(m, (_) => (23, 53));
// total: 3.7 MB, _Record 3.0 GB
//data = List.generate(m, (_) => const LatLng(23, 53));
// total: 768.1 MB, LatLng 32 B
//data = List.generate(m, (_) => LatLng(23, 53));
// total: 3.7 GB, LatLng 3.0 GB
//data = List.generate(m, (_) => const MyLatLng(23, 53));
// total: 768.6 GB, MyLatLng 32 B
//data = List.generate(m, (_) => MyLatLng(23, 53));
// total: 3.7 GB, MyLatLng 3.0 GB
super.initState();
}
@override
Widget build(BuildContext context) {
return const MaterialApp(home: Scaffold(body: SizedBox.shrink()));
}
}
@immutable
final class MyLatLng {
final double lat;
final double long;
const MyLatLng(this.lat, this.long);
@override
bool operator ==(Object other) =>
other is MyLatLng && lat == other.lat && long == other.long;
@override
int get hashCode => Object.hash(lat, long);
} And this to test performance (compile first to use the prevent JIT compilation): Click here to viewvoid main() async {
await Future.delayed(const Duration(seconds: 1));
final sw = Stopwatch();
sw.start();
const m = 300000000;
final name = 'MyLatLng';
List.generate(m, (i) => MyLatLng(i.toDouble(), (m - i).toDouble()));
// result: 82.256s, 81.344s
/*final name = 'LatLng';
List.generate(m, (i) => LatLng(i.toDouble(), (m - i).toDouble()));*/
// result: 71.43s, 81.228s
/*final name = 'record';
List.generate(m, (i) => (i.toDouble(), (m - i).toDouble()));*/
// result: 142.514s, 156.227s
sw.stop();
print('[$name] ${sw.elapsedMilliseconds / 1000}s');
}
final class MyLatLng {
final double lat;
final double long;
const MyLatLng(this.lat, this.long);
@override
bool operator ==(Object other) =>
other is MyLatLng && lat == other.lat && long == other.long;
@override
int get hashCode => Object.hash(lat, long);
} |
Thanks. It seems Classes and Record are very similar (if not identical) from a memory perspective. Thus I agree the benefits of a class outweigh a record in this case. @josxha you inspired me to learn more here. Firstly I tested named records However, This may be off topic now, but I dug deeper. Reading sdk/runtime/vm/object.h, runtime/vm/raw_object.cc and related files, and looking at ASM output ( All records and class instances are objects. In memory a object is a 64 bit tag, followed by the various fields. The tag contains information about the instance size, class (for this object), GC information, etc. The object is always padded up to the nearest 128 bits. So if a object has no fields, it still takes 64 bits for the tag, and 64 bits padding. A record (e.g Finally the List will take 64 bits per entry, for the pointer to the Instance/Record.
So I took your example, and tweaked it a little: @immutable
final class Three {
final double a;
final double b;
final double c;
const Three(this.a, this.b, this.c);
@override
bool operator ==(Object other) => other is Three && a == other.a && b == other.b && c == other.c;
@override
int get hashCode => Object.hash(a, b, c);
}
// Classes Zero, One, Two, Four hidden for brevity.
void main(List<String> arguments) {
const m = 100000000;
final data = List.generate(m, (n) => Three(1, 2, 3), growable: false);
} and ran tests like so
and
So to conclude this. A A final side note. If you define the classes, but never access the fields (e.g for example not having a |
This is a really fun thread, thanks everyone. I didn't know about the "shape" member. I totally believe the findings, it just defeated my intuition. I always considered records to be syntactic sugar around class definitions that implement the "Record" interface, as such I would expect them to have the same memory layout as any other subclass 🤷♀️
Yes, that's a general problem with tree-shaking. Since
There's a variety of arguments but with regards to performance, immutability is the enemy in garbage collected languages. If the coordinates were mutable they could be pooled and recycled putting less pressure on GC. Just my 2c, latlong2 is used in many geospatial dart libraries. While that's by itself not a good reason to use it, if we'd change the coordinate type now, it would be a very invasive migration and I would end up writing a lot of glue code for my application (which most certainly would drive up the memory consumption :) ). Personally, I like that LatLng2 validate the ranges. It has shown me quite a few bugs in my code. I don't understand why this would prevent us from wrapping maps. If that's the main reason to move away from latlong2, is there something else one could do? |
It gets difficult to handle the 'iterations'/'versions'(?) of maps if we restrict to the standard -180 - 180. We'd have to have some sort of extra indicator with every
Yes, I like this as well. If we stop validating ranges, we might be running into Issues with people asking why maths is breaking down or stuff isn't appearing correctly.
Another concern of mine. I would much prefer to inherit the library (into the org) as the maintainer appears to not be maintaining it anymore - but I think we've tried in the past to get in touch. Equally however, depending on abandoned libraries isn't a great plan for a growing library. I think migration and using existing APIs is the biggest issue here, not necessarily what the replacement could look like. As a side note, if we were to do this, an auto migration tool could be helpful. Search/replace would probably get most of the way, but not all of the way. |
Shouldn't the answer be: all? Just thinking out loud: if I have a point LatLng(42, 23) and I'm zoomed out so that we see N "versions" of that point, I would expect to see any marker, polyline, polygone associated with that point N times. I certainly wouldn't expect additional map-space coordinates like LatLng(42+180, 23+360) to spring into existence. What I would expect is that any transformation from map into screen coordinates would become a 1:N mapping, i.e.: Point<double> project(LatLng); would become List<Point<double>> project(LatLng);
Right, it would still leave you with the glue code to other dependencies.
That would be ideal. Arguably, latlong2 is relatively self-contained, so as long as we don't require any changes it sort-of-fine until we do :). Maybe, pub.dev has a process for adopting packages? |
Would that not be an infinite list? There are infinitely many versions of the map. |
Screen coordinates are very finite. It's just an API sketch but I would expect any such API to return a list of points on screen that correspond to the given map coordinate. |
Good point.
It doesn't look like it, and that's probably for the best. (I wonder what happens when a Google Account associated with an upload is deleted for inactivity 🤔 - but we can't exactly wait 2 years to find out :D.) |
I agree, I think the reasoning behind removing the -90/90 and -180/180 limits were so that e.g. the track of airplains can cross them. But I think some logic that checks if a line jumps to the other side and then drawing the line where the shorter distance is should solve this issue. Having those asserts in latlong2 is correct too since coordinates outside of these limits aren't valid latitude longitude coordinates.
In my opinion it should be fine to have LatLng in public APIs since most LatLng instances shouldn't chance every frame. Internally however we could remove the usage of Point and newly created LatLng instances all together. This should reduce a huge amount of load on the GC. |
How about if we add an abstraction widget between the top level FlutterMap widget and the map layers, e.g. Scope of the FlutterMap widget:
Scope of each FMWorld widget:
This is an very simple illustration. In case the map is zoomed in, there are only one (or at max two) FMWorld instances. This would need to be adjusted to the current FlutterMap class hirarchy. |
I appreciate that you're grounding us with a real use-case. I think the airplane example is a great one. (In your later post you have the other example where the world is shown 3 full times. That one is interesting as well but probably not as relevant in practice and has different requirements since markers, polylines, polygons, ... would actually be drawn 3 times. In the airplane example the map is wrapping but there's only ever one version of everything. That means we should/might be able to tackle this case w/o changing APIs and therefore API consumers like the PolylineLayer et al. Imagine Point project(LatLng) would simply return the screen coordinate for LatLng that is on screen rather than the one that is off screen (which it does today). All layers should automatically do the right thing (probably I'm missing some spherical corner cases).
I agree with you that this would be the ideal. I was mostly playing devils advocate. I don't think performance is everything and immutability has other nice properties. Even today, FlutterMap should not allocate too many new LatLngs internally. There are some use cases but often it's just a few points (e.g. when you translate a tap in screen coordinates to a marker placement location on the map). For cases that tend to have the most points, like large numbers of Poly(gon|line)s, FlutterMap will typically just take the user-provided/allocated points (that as you say don't necessarily change very often) and convert the point in many intermediate points and ultimately screen coordinates and that on every frame. If we wanted to minimize allocations the meat is in all the non-map-space-coordinates. |
From my point of view the problem with the current use of LatLng is that the -90/90 and -180/180 validation makes using the library for non-geographic maps more difficult. My organization uses the library for floor-plan maps with custom CRS, where coordinates are in millimeters. Migrating from Leaflet-based JS app was very straightforward otherwise, but I had to fork FlutterMap simply to get rid of the LatLng coordinates validation, which didn't exist in Leaflet implementation. |
I feel your pain @joosakle. On paper I would argue that I think the library I suspect that it wouldn't be a huge undertaking to make this switch. It's a slightly more abstract way of thinking, which some developers seem uncomfortable with for some reason, but it would also help reduce the occurrence of incorrect assumptions in the code base caused by the very same comfort that introduced the enforcement of For those that feel this train of thought is very foreign or even wrong, I think the picture would be a bit clearer after a briefer on basic projection mathematics. Coordinates are a pretty central component of the library after all, and by knowing the gist of how projections work, it will also be easier to see the implications of decisions regarding how the code should be structured. |
@joosakle, technically you don't have to fork the repo, but it's hairy (and I admittedly ended up forking it too). Here is a proposed ugly hack for your floor plan maps. First, pick a planar CRS, I will pick
The default unit for the coordinate system is meters ( The CRS is centered at the North Pole ( Next, we'll pick a reasonable latitude of true scale ( Relative to the size of the northern hemisphere, your building is probably small, so we will pick a latitude really close to the north pole, such as False easting/northing ( The Now you should be able to create the CRS: Proj4Crs.fromFactory(
code: 'Dummy',
proj4Projection: proj4.Projection.add(
'Dummy',
'+proj=stere +lat_0=90 +lat_ts=89.99 +lon_0=-45 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs +type=crs',
),
); Make Point p = Point(4, 8)
LatLng ll = crs.projection.unproject(p); Where When this is passed into the layer, the layer will then internally project this point back again: Point p = crs.projection.project(ll); // Point(4, 8) Going back to my original rant, my point is that if you have a planar projected coordinate system, especially a local coordinate system which don't need projections (or technically even a CRS), from a
All of this just to pass a coordinate to a layer. It incurs a cost in developer time/barrier for new developers, a performance cost (a lot of points projected/unprojected per frame adds up) and it just doesn't make much sense code-wise from a planar CRS point of view (especially in the case of simple local CRS like this). Additionally, even if you do use |
@JosefWN Thank you for the idea and great explanations in the other issue! It's very helpful for me and others who aren't experienced with mapping libraries. I really need to drive deeper into the topic. |
If part of the initial question is how to create Something like class MyLatLng extends LatLng {
MyLatLng(final double latitude, final double longitude)
: super.fromJson({
'coordinates': [longitude, latitude]
});
}
final LatLng test = MyLatLng(92, -182);
print('test: $test');
// test: LatLng(latitude:92.0, longitude:-182.0) |
Just as an update, @mootw pointed out the other day internally that jifalops/dart-latlong#11 has finally been merged! This opens the door to some of these changes without necessarily moving away from latlong2. |
The latlong_bounds class of this project still has the coordinate restrictions in place. I suppose they should be removed from it too, now that latlng2 package doesn't have them either? |
closing in favor of #1996 . records could be revisited in the future once they are more performant |
This is a potential proposal to removing dependency on latlong2. Mainly it restricts bounds to +-180, +-90 which makes wrapping maps impossible in practice. A record type makes it easy to work with lat lon points across libraries without having a central "lat lon class" dependency. There are potential performance gains to be had from converting lat, lon pairs to a record type instead of an object at scale, but they are likely minimal due to classes being efficient.
currently, this does not completely remove the latlong2 dependency, as it is still used internally in fm for distance calculations in 2 places but this is theoretically easy to fix in the future.
this is a proof of concept and i intend to create an issue outlining all of the reasons to migrate away from latlong2 and propose some options including a fm fork that fixes the issues with it, alternative base class, or using an existing "Point" class like math.Point or Vector2 from vector_math.
some of the issues that would be able to be resolved by moving away from latlong2:
removing bounds: jifalops/dart-latlong#11
#468
#1338
fixes #1452
#1582
maybe fixes #1717 (do not know if there are more issues with the gesture system past the latlong2 limitation)
I have created a PR on upstream jifalops/dart-latlong#13 to also see if an upstream fix is possible too