Skip to content

feat: add built-in caching #2082

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

Open
wants to merge 24 commits into
base: master
Choose a base branch
from
Open

feat: add built-in caching #2082

wants to merge 24 commits into from

Conversation

JaffaKetchup
Copy link
Member

@JaffaKetchup JaffaKetchup commented May 3, 2025

See https://docs.fleaflet.dev/layers/tile-layer/built-in-caching.

Adds 'simple' built-in caching to NetworkTileProvider for native platforms. On the web (or where the caching mechanism cannot be setup for whatever reason), the NetworkTileProvider falls back to its existing implementation.

Unlike existing caching methods, this requires absolutely no additional code to use, although an additional one liner can be useful. It's easily extensible.

The first tile load waits for the caching mechanism to be set up (let's say 300ms) - this means a grey tile until the mechanism is ready. Other tiles loaded simultaneously must also wait. Once the mechanism has loaded, all other tiles do not have to wait. This does not have to be the case for all 'caching providers'.

Caching uses a JSON registry stored on the filesystem, which is loaded into memory as a Map twice (in two different isolates) on startup. Tiles are stored directly as files using a UUID. A size monitor containing 8 bytes holding the size of the cached tiles in bytes is also kept in sync.

No guarantees are made as to the safety of the cached data. It's cached in an OS-controlled caching directory by default, and a corrupted registry will cause the entire cache to be reset.

The cache cannot be cleared through the API. The cache can optionally be size-limited (defaults to 800 MB) - although this is applied at startup and can be relatively slow.

Through testing, it seems to work reasonably well. The OSM tile server does seem to spit out some weird HTTP headers which seems to ask for unnecessary requests. I have opened a thread on the OSM Slack to try to figure out what's going on.

Fixed bugs in persistent registry writer
Added cache size limiter
Added `MapCachingOptions` to configure caching externally
Minor other renaming & refactoring
Added `MapCachingOptions.overrideFreshAge`
Improved performance by moving tile file writing into long-lived isolate worker to reduce overheads
@JaffaKetchup JaffaKetchup marked this pull request as ready for review May 4, 2025 17:11
@JaffaKetchup JaffaKetchup requested a review from a team May 4, 2025 17:11
Renamed `CachedTileInformation` to `CachedMapTileMetadata`
Improved documentation
@androidseb
Copy link
Contributor

Hey @JaffaKetchup, since you asked for feedback about this, I'll try and add some thoughts here. Disclaimer: I read through the code at a high level, not in fine details, so apologies if I missed some things there.

I'm not sure why there is a concept of "loading" for the caching mechanism, is it because we need to read an index JSON file first? I find that option to not be great because the JSON will grow larger and larger as the user has more and more tiles, and as you pointed out, might get corrupted, and I'm hoping we don't need that, and I'm hoping we can build this in a way that doesn't have an initialization delay by using the file system directly.

Do we need to store anything more than those pieces of information for a given cached tile (I'm hoping not)?

  • x
  • y
  • z
  • timestamp

I've had to tackle a similar problem in my app before because I implemented offline maps which are expensive to process (it means generating a PNG image from vector data), so I cache offline built tiles so I don't have to reprocess them later. I have implemented a simple approach where I cache tiles based on file named x_y_z.png. I understand the use case is a little more complex here, but that approach might still work with minor adjustments. I saw you wanted to cache tiles based on their URL, but I'm hoping we don't need that and can just focus on x / y / z.

Here are some ideas I hope would help this work better:

  • Android has a dedicated "cache" folder, meaning if you locate your cache files there, it can be deleted by the user without the need of implementing this in the app's code, so I would default the storage folder to there
  • Get rid of the JSON index file completely, and use the file system as your cache
    • MapTileCachingManager would take in a cache folder path and be instantly initialized and available
    • When requested for a tile, check if the file named x_y_z.png exists, and
      • if the file is recent enough, return its content
      • if the file is too outdated (passed the cache expiry setting), return a cache miss response
    • In the background, run an indexing process to list out all the cached files inventory - this will delay the moment we can start enforcing cache max size, but not the moment we can start serving cached tiles
    • Once the cache index is built, we can run an outdated tiles + max size enforcement cleanup
    • When requested to store a tile
      • Store write the tile using the file named x_y_z.png.tmp
      • Then after the file write has fully completed, rename the file to x_y_z.png (this avoid having half-written files)
      • If the index is already built, delete oldest files if exceeding storage quota
  • Can you get rid of isolates? Don't async file read / write methods do the same thing? It would make the code simpler if no negative impact on performance. Have we confirmed using isolates is actually positively impacting performance?

I might not have the full context here, so this might be a naive approach that doesn't work for some reason, but I hope this helps :-)

@JaffaKetchup
Copy link
Member Author

Hey @androidseb, thanks for your thoughts :)

Do we need to store anything more than those pieces of information for a given cached tile

We need to store:

  • Some way to refer to the unique tile
  • The HTTP caching headers (or calculated result) associated with the tile (of which there are at least 2)
  • (Ideally) the time at which the tile metadata (above) was last updated in the cache

So unfortunately some form of additional storage is required for the caching headers

I saw you wanted to cache tiles based on their URL, but I'm hoping we don't need that and can just focus on x / y / z.

This might not work well. If a user is using two different tile servers with the same coords, we need to differentiate that. We could create a parent directory for each URL, but I'm not sure I can see the value.

Android has a dedicated "cache" folder, meaning if you locate your cache files there, it can be deleted by the user without the need of implementing this in the app's code, so I would default the storage folder to there

I'm using whatever directory is returned by path_provider's getApplicationCacheDirectory. I'm assuming it would be cleared when the user requests to clear the app cache.

if the file is too outdated (passed the cache expiry setting), return a cache miss response

I did consider this kind of storage mechanism, but how would you store the expiry time? In another file, one per tile? I want this to cache based off HTTP headers primarily, to be compliant with the OSM requirements for example.

In the background, run an indexing process to list out all the cached files inventory - this will delay the moment we can start enforcing cache max size, but not the moment we can start serving cached tiles

Would this be in an isolate? I've tried to minimise I/O on main thread (see below). But yeah, the method I've written for enforcing the max cache size would be much better if it didn't delay the caching - this is difficult to do performantly however with the current setup. Maybe a change in setup would better allow this.

Can you get rid of isolates? Don't async file read / write methods do the same thing? It would make the code simpler if no negative impact on performance. Have we confirmed using isolates is actually positively impacting performance?

The idea is to eliminate some of the overheads. Although you've suggested removing the registry file, in its current form, opening, writing and closing the file for every single update would be quite costly. The async methods do unblock the main thread, but come with their own overheads. Using long-lived isolates avoids some of this. I'm not sure whether the tile file writer isolate makes a noticeable difference, but it seemed to have some good effect (not properly profiled), and theoretically it should avoid some overheads.

I'm definitely on board with looking into ways to remove the registry, but I just don't see a great way around it to store all of the required metadata - except maybe using two files per tile, but that introduces additional overheads as a file needs to be read before the initial caching decision at fetch time can be made (rather than just reading from memory). Let me know what you think!

@androidseb
Copy link
Contributor

Thanks, I think I understand this a little better now.

About storage

We need to store:

  • Some way to refer to the unique tile
  • The HTTP caching headers (or calculated result) associated with the tile (of which there are at least 2)
  • (Ideally) the time at which the tile metadata (above) was last updated in the cache
  • Some way to refer to the unique tile: the file name should suffice hopefully?
  • The HTTP caching headers (or calculated result) associated with the tile (of which there are at least 2): I guess you could split this into two files: tile_x_y_z.png and tile_x_y_z.png.metadata
  • (Ideally) the time at which the tile metadata (above) was last updated in the cache: that would be the file creation / last modified time which we have with the file system entity, but could also be stored in the metadata file...

About using files directly

This might not work well. If a user is using two different tile servers with the same coords, we need to differentiate that. We could create a parent directory for each URL, but I'm not sure I can see the value.

I agree splitting URLs per directory will make things difficult, but we could maybe figure out a unique file naming function for that, even if it means including a file-name-friendly version of the URL in the file name, I don't think it would necessarily be an issue.

About the android cache folder

Android has a dedicated "cache" folder, meaning if you locate your cache files there, it can be deleted by the user without the need of implementing this in the app's code, so I would default the storage folder to there

I'm using whatever directory is returned by path_provider's getApplicationCacheDirectory. I'm assuming it would be cleared when the user requests to clear the app cache.

Oh OK, good, I had missed that, my comment is not relevant then.

About cache expiry based on headers

if the file is too outdated (passed the cache expiry setting), return a cache miss response

I did consider this kind of storage mechanism, but how would you store the expiry time? In another file, one per tile? I want this to cache based off HTTP headers primarily, to be compliant with the OSM requirements for example.

Yes, having a .metadata file associated with the main .png file would work for this hopefully.

About I/O

Although you've suggested removing the registry file, in its current form, opening, writing and closing the file for every single update would be quite costly.

What do you mean by costly? More costly than fetching the data from the web with an HTTP request over the network? The tiles are fairly small and light in volume (~50kb for a 256x256 tile) - I can't imagine fetching cached tile for the entire screen of a device would exceed 10MB of reading on the disk, and that would almost always be faster than re-fetching them from the network anyways, right?

The async methods do unblock the main thread, but come with their own overheads. Using long-lived isolates avoids some of this. I'm not sure whether the tile file writer isolate makes a noticeable difference, but it seemed to have some good effect (not properly profiled), and theoretically it should avoid some overheads.

What kind of overhead are you thinking about? I'm not super knowledgeable about these things so I'd be curious to have insights about this. I have a hunch that isolates might have noticeable value in intensive I/O operations, but given the size and number of the tiles we're dealing with here, I feel like it will not make a difference for the user, especially if you compare it with network-based requests. It might be worth keeping them for later if needed, instead of preemptively making them part of the architecture, but it's just a thought.

I'm definitely on board with looking into ways to remove the registry, but I just don't see a great way around it to store all of the required metadata - except maybe using two files per tile, but that introduces additional overheads as a file needs to be read before the initial caching decision at fetch time can be made (rather than just reading from memory).

I think using two files could work. I don't see this introducing overhead if the in-memory cache is building itself in the background from the files. What I mean by that is that you could implement your cache controller entity so that:

  • It's available for use instantly
  • It starts scanning files in the background at its own pace, processes them and indexes them in memory (i.e. "low priority cache build queue")
  • When requested to fetch a cached tile:
    • If memory indexing is complete: check memory directly, don't use files
    • If memory indexing is still in progress:
      • check the file system
      • if a pair of files is found, add the info to the memory cache currently building itself (i.e. "high priority cache build queue")
  • Eventually, the background files scan completes and the cache is entirely stored in memory

With this approach, you might fetch cached files information from the disk when needing them, but that disk read was going to happen at some point anyways during the indexing.

If you have a cache of 10k files, it might mean that on cache initialization, you read the cache info from 10k files, which is really not ideal...

An alternative approach to this could be to lazy-access the cached files so you only read / discard them when attempting to fetch the cache entry. To keep track of the cache size without having to scan all files, you could have a unique file storing the total size value, and that would only be a few bytes.

A few more thoughts about this

  • HTTP headers returned by OSM dictate how long we want to cache something, is that it?
  • I'm assuming we'll want to reduce the frequency at which we write the registry (I saw some de-bounce code in there, not sure if it was for that), if you have 10k cached tiles, are you going to write the entire 10k cache information to the disk every time you save your registry?

@JaffaKetchup
Copy link
Member Author

Just to be clear, you're suggesting removing the persistent registry? I'm happy with this in theory, but in practise, I think it could be a little more difficult.
The advantage is that storing metadata separately for each tile would allow instant starting of the caching mechanism, however, the suggested in-memory registry would be built much slower (have to read through many files).

I think having the single registry is better in this respect - reading 10k files (which would only cover 5k tiles) doesn't seem like the best option. If we reduce the duration of the size limiter (see below), we can bring the initialisation time down significantly.

We can also provide an optional 'preload' method to be called in main (for example).

An alternative approach to this could be to lazy-access the cached files so you only read / discard them when attempting to fetch the cache entry.

I'm not quite sure what you mean by this exactly, could you explain a little more? If you mean we just check the metadata file for every tile load, that is an option, but it would make tile loading much slower.

To keep track of the cache size without having to scan all files, you could have a unique file storing the total size value, and that would only be a few bytes.

This sounds like a good idea. The size limiter is by far the largest slow down in initialisation. But if we are over the limit, ordering the tiles by size and last modified is also really slow. Is there a better way to do this?

Is there any way we can get the best of both worlds? We don't need to be too careful with RAM particularly IMO. Is there a possibility of somehow using both a centralised registry and decentralised metadata file structure - or is that asking for too much (I feel like it might be)?


In terms of file names, why is a hash (or any other generation function) not good? It means no worrying about particular URLs or coordinates. I would propose the file names <hash> (we don't store an extension for ease of use) and <hash>.metadata.

What do you mean by costly? More costly than fetching the data from the web with an HTTP request over the network? The tiles are fairly small and light in volume (~50kb for a 256x256 tile) - I can't imagine fetching cached tile for the entire screen of a device would exceed 10MB of reading on the disk, and that would almost always be faster than re-fetching them from the network anyways, right?

I was referring only to writing to the persistent registry. Opening it for each update would be unnecessary.

What kind of overhead are you thinking about? I'm not super knowledgeable about these things so I'd be curious to have insights about this. I have a hunch that isolates might have noticeable value in intensive I/O operations, but given the size and number of the tiles we're dealing with here, I feel like it will not make a difference for the user, especially if you compare it with network-based requests. It might be worth keeping them for later if needed, instead of preemptively making them part of the architecture, but it's just a thought.

From my (admittedly non-profiled) experience when writing FMTC, putting I/O in a separate thread definitely reduced jank.

Also https://dart.dev/tools/linter-rules/avoid_slow_async_io exists - although I will admit I thought it applied to all synchronous I/O methods, not just the listed ones. See also dart-lang/sdk#19156.

One thing that's more difficult when dealing with this kind of traffic is that the tile updates are relatively small each (but not totally insignificant), but there's a lot of them and they come in very quick bursts. If you've got an open HTTP connection, making a bunch of tile requests to fill the screen will result in a burst of responses.

HTTP headers returned by OSM dictate how long we want to cache something, is that it?

Yes, or by any other server. The OSM ones in particular seem a little strange (serving tiles repeatedly that have already expired), but that's an issue at the source.

I'm assuming we'll want to reduce the frequency at which we write the registry (I saw some de-bounce code in there, not sure if it was for that), if you have 10k cached tiles, are you going to write the entire 10k cache information to the disk every time you save your registry?

If we keep using a registry, then yeah, we don't want to update it for each individual tile (especially if we're using JSON as we need to write and flush the whole file each time). My initial idea was to write it and queue up any updates that occur whilst writing, then write again, but I couldn't get this working well, so I just resorted to a 50ms debounce.

@androidseb
Copy link
Contributor

Just to be clear, you're suggesting removing the persistent registry?

Yes, or at least I'm hoping we can achieve this somehow. But maybe there is no good way around it...


An alternative approach to this could be to lazy-access the cached files so you only read / discard them when attempting to fetch the cache entry.

I'm not quite sure what you mean by this exactly, could you explain a little more? If you mean we just check the metadata file for every tile load, that is an option, but it would make tile loading much slower.

Yes that's what I mean. I understand this is "much slower" than direct RAM access, but we're still talking about sub-16-milliseconds frame time to even be noticeable (even in the bursts you mentioned?) by the user hopefully? I/O being "slow" is not a problem if the user can't notice. It isn't "slow" as in "consuming more CPU + battery" slow, it's slow as in "extra I/O idle delays" slow.


To keep track of the cache size without having to scan all files, you could have a unique file storing the total size value, and that would only be a few bytes.

This sounds like a good idea. The size limiter is by far the largest slow down in initialisation. But if we are over the limit, ordering the tiles by size and last modified is also really slow. Is there a better way to do this?

Is there any way we can get the best of both worlds? We don't need to be too careful with RAM particularly IMO. Is there a possibility of somehow using both a centralised registry and decentralised metadata file structure - or is that asking for too much (I feel like it might be)?

Yeah getting the best of both worlds would be good 😄. I think the top priority here should be avoid sending extra HTTP requests when we have the cache. The long initialization time is an incentive to skip the cache at the beginning, but that's unfortunately the moment where the user gets the most tiles (the entire screen), and at scale, this implementation would mean we're effectively not minimizing (only reducing) the out of the box impact of flutter_map on the OSM servers.

To be clear, my suggestion of the direct file system approach was primarily driven by the intent to get around the initialization time, because I feel like we should not skip the cache, and I would like to find a solution where the user doesn't have to wait several seconds before seeing tiles.

At the very least, the hybrid approach could have the long initialization time, but during that time, instead of using direct network requests as a backup, we could use direct file system requests as a backup, because we know where the tile cached file will be located?


In terms of file names, why is a hash (or any other generation function) not good? It means no worrying about particular URLs or coordinates. I would propose the file names (we don't store an extension for ease of use) and .metadata.

That's fine, I think hashes for file names would work too. I thought having a file name structure where you can trace back which tile a specific file is linked to just from its name could be useful, but I can't come up with any concrete use case, so it's probably not useful at the moment.


From my (admittedly non-profiled) experience when writing FMTC, putting I/O in a separate thread definitely reduced jank.

Interesting, I wonder if this is related to the debugging setup specifically.


Also https://dart.dev/tools/linter-rules/avoid_slow_async_io exists - although I will admit I thought it applied to all synchronous I/O methods, not just the listed ones. See also dart-lang/sdk#19156.

I think it depends what you mean by "slow". I may be mistaken, but the way I understand it, there are two kinds of "slow" we're looking in this context:

  1. CPU load slowness: deriving a cryptographic key many times is slow because it requires a lot of calculations - running long sync operations using CPU on the main thread will cause jank
  2. I/O wait slowness: reading from a file is slow, because you need to wait for I/O - running these operations with await / async will not cause jank, because the operations in question are already being sent to another thread under the hood (which is why I'm saying we may not need isolates, because those are meant for CPU heavy operations)

For example, the link you shared mentions "Reading files asynchronously is 40% slower", but that's the total round-trip time for a single operation, because that operation is waiting a lot for things to come back between threads. If you were running 10 of those operations at the same time, they would probably not take 10x the time to execute, probably closer to the same time as a single operation. Because most of the time needed to perform the operation is waiting between threads.

In our specific setup with cache, the file operations I'm talking about are all affected by "I/O wait slowness", so fetching a single tile's data might take 150ms, but completing a quick burst of 100 of these operations running in parallel might not take much longer than 200ms to complete... And I'm using 150ms as an example, but it's probably less, and most certainly faster than a network-based request.

Don't take my word for it, we should look into it, but I'm just saying it could be worth considering.


If we keep using a registry, then yeah, we don't want to update it for each individual tile (especially if we're using JSON as we need to write and flush the whole file each time). My initial idea was to write it and queue up any updates that occur whilst writing, then write again, but I couldn't get this working well, so I just resorted to a 50ms debounce.

For the registry serialization, there might be good libraries to handle updates more efficiently, I've used sqflite in my project, but I feel like adding an SQL dependency to flutter_map for this is overkill. Maybe hive could be an interesting choice, even if it's to look at the code to find out how it's implemented.


One more note: it looks like you're abstracting the cache implementation so we can write our own implementation of it, I think that's good, because it will make contributions to alternate cache-focused plugins (those that need extra dependencies) easier down the line.

@JaffaKetchup JaffaKetchup marked this pull request as draft May 9, 2025 11:05
@JaffaKetchup
Copy link
Member Author

JaffaKetchup commented May 9, 2025

@androidseb So just because I'm not certain what we're suggesting exactly, we're suggesting using both a registry and an individual metadata file for each tile? I know that's perhaps what I was suggesting, but I've had a think about it, and that sounds really difficult to manage.

This would be my new suggestion:

  • Revert to waiting for the cache to be ready before tiles can be loaded
  • Recommend to users to call a method in main to preload the cache if performance matters
  • Remove the waiting for the size limiter - handle this in the background somehow (this is likely to be quite complex, but I think I can make it work reasonably well?)
  • Store the total cache size in another file
  • Potentially reduce the isolate work - I would want to keep the isolates for the persistent registry writer and total cache size writer at least

How does that sound to you (and @fleaflet/maintainers)? Basically, if the user doesn't call to preload, the delay is just the time taken to parse the registry.


In terms of the registry format, I wanted to keep things as JSON because it's so simple. But maybe using an actual database is a good idea. I absolutely do not want to introduce a non-SQL database into FM - I've had far too many bad experiences with that:

  • I believe Hive is abandoned to focus on Isar
  • Isar is abandoned to a large extent and has multiple stability issues (although tbf I was trying to do some pretty non standard stuff)
  • Drift is an option, but that's suddenly a lot of bloat
  • I think the only viable option would be to use sqflite

Also to note is that we never close the persistent registry, and we flush on every write. This is because we don't know when to close it, so we hope the platform does it for us when the program & isolate are terminated. That's another reason for using an isolate there: performing a hot restart does not seem to close the file handle if open on the main thread, which throws errors when we try to open it again - it does seem to terminate isolates though, which closes the file handle.

I'm not sure about whether to use JSON or sqflite here. And if we are using sqflite, would it make sense to store tiles as blobs in the database?+


For example, the link you shared mentions "Reading files asynchronously is 40% slower", but that's the total round-trip time for a single operation, because that operation is waiting a lot for things to come back between threads. If you were running 10 of those operations at the same time, they would probably not take 10x the time to execute, probably closer to the same time as a single operation. Because most of the time needed to perform the operation is waiting between threads.

In our specific setup with cache, the file operations I'm talking about are all affected by "I/O wait slowness", so fetching a single tile's data might take 150ms, but completing a quick burst of 100 of these operations running in parallel might not take much longer than 200ms to complete... And I'm using 150ms as an example, but it's probably less, and most certainly faster than a network-based request.

I agree with the first paragraph. But the only thing we regularly read is tiles, which I do just use await tileFile.readAsBytes() from the main thread for.

It's different for writing I think.

Only one handle can be used to write at the same time. writeAsBytes* seems to perform some internal sequencing somehow, because if you try to write at the same time with an open RandomAccessFile (which is what writeAsBytes* does internally), it throws.

Here's the implementation of writeAsBytes*:

https://github.com/dart-lang/sdk/blob/b09f6ab9cb3c9da47c6e076006e8a0ece8d2f99d/sdk/lib/io/file_impl.dart#L727-L755

They both need to open the RandomAccessFile through open. The async version then does everything asynchronously, whilst the sync version does everything synchronously.

The sync version is implemented externally immediately. The async version uses _IOService, which implements an external _dispatch method.

_dispatch/_IOService seems to be implemented here:

https://github.com/dart-lang/sdk/blob/b09f6ab9cb3c9da47c6e076006e8a0ece8d2f99d/sdk/lib/_internal/vm/bin/io_service_patch.dart#L24

I can't find the implementation of _open, but it leads pretty quickly to C I think:

https://github.com/dart-lang/sdk/blob/b09f6ab9cb3c9da47c6e076006e8a0ece8d2f99d/runtime/bin/dartutils.cc#L261

I'm not sure how _IOService is working. It seems to be requesting a SendPort from C code, and defining a RawRequestPort, then communicating as if there were an isolate? So I can only assume the C code returns a SendPort for a potentially long living thread?

If this is the case, then potentially the delay waiting around for threads/isolates to start/stop doesn't exist. Therefore, potentially the only delay is waiting for files to open and close for every individual write, which would be the same for sync and async methods (as we basically do the isolate work for the sync methods)? Therefore, assuming we use an open RandomAccessFile, it might actually be the same doing sync in isolates and async in main?

However, this does go back to this from above:

That's another reason for using an isolate there: performing a hot restart does not seem to close the file handle if open on the main thread, which throws errors when we try to open it again - it does seem to terminate isolates though, which closes the file handle.

Making a non-isolate approach work in debug mode seems to be difficult (when devs are frequently hot restarting), because we either need to close the file before restarting, which is impossible to do reliably or maintain access to its handle so we can use it again without opening, which I don't know how I would do.

@androidseb
Copy link
Contributor

we're suggesting using both a registry and an individual metadata file for each tile? I know that's perhaps what I was suggesting, but I've had a think about it, and that sounds really difficult to manage

Sounds likely indeed.


This would be my new suggestion: [...]

Yes, this sounds good to me - after thinking about this more, I realized you only really need to ensure the out of the box version of flutter_map provides good mitigation for the OSM servers, a little startup delay is probably no big deal, especially if alternative cache plugins can easily be built on the side (and those can be more feature-rich and use much more bloat).

As a library consumer, I'm very interested in the cool map features you and the team are building, so keeping the cache implementation simple means it will have a lower maintenance overhead and allow you to focus more on map features more than "boring" problems that others can solve very deeply with a plugin.


In terms of the registry format, I wanted to keep things as JSON because it's so simple. But maybe using an actual database is a good idea [...] I'm not sure about whether to use JSON or sqflite here. And if we are using sqflite, would it make sense to store tiles as blobs in the database?

That sounds appealing having everything in a single file. Be aware that Android devices are plagued with the infamous "Row too big to fit into CursorWindow" error: if your database row exceeds 2MB in size, you'll risk having issues storing / reading database rows - I would personally stay away from storing data blobs into a DB, but it's more of a gut feeling, I can't provide a strong rationale around this, especially since tiles usually weight around 50KB, and even a raw 4-Bytes-Per-Pixel 512x512 tile is about 1MB. I would give the opinionated recommendation to use files to store the data outside the DB, but it's obviously a little more complex to have truly "atomic" operations, so maybe just a bad bias I have towards DB data blobs.


It's different for writing I think [...]

Yeah, I think most OSes don't allow you to open a file in write mode multiple tiles, there can be only one "write" file descriptor at a time.


assuming we use an open RandomAccessFile, it might actually be the same doing sync in isolates and async in main?

Yeah it might be, the reason I was reluctant to use isolates is the added (admittedly reasonably small) complexity related to the messaging between the isolate thread and main, but the outcome for the machine and performances is probably the same.


Making a non-isolate approach work in debug mode seems to be difficult (when devs are frequently hot restarting), because we either need to close the file before restarting, which is impossible to do reliably or maintain access to its handle so we can use it again without opening, which I don't know how I would do.

I know sqlite doesn't have that "locked file" problem in debug mode when hot-restarting, but it's probably using isolates under the hood...

@atototenten
Copy link

atototenten commented May 14, 2025

why not use lmdb(3x faster than sqlite ,and 10x faster than file-systems) instead of file-system ?

lmdb is generally :

  • compared to sqlite :

  • 3-5 times faster

  • 2-3 times more space-efficent

  • compared to file-system :

  • 10-15 times faster

  • 5 times more space-efficient

thanks

@JaffaKetchup
Copy link
Member Author

Yeah I have some experience with lmdb through Isar. For what I was trying to do with it, I had too many issues to put it in the core - maybe it would be more stable for this kind of use case.

But there's also the consideration to be made to keep the package lightweight - chances are, a production app might already use a library which depends on sqflite.

@atototenten
Copy link

isar is very bad .

checkout http://pub.dev/packages/dart_lmdb2

this pkg. needs just 150 k.b. ,while being super-fast cmp.ed to FS and SQLite

@JaffaKetchup
Copy link
Member Author

That does look like an encouraging package, but I would want more existing usage for it before adding it as a transitive dependency to all our users.

@atototenten
Copy link

why not internalize/fork it ?

or maybe port it to dart ,using google-gemini then review ,because c is a lot like dart ,so it should be smooth ,hence it could become 10 - 15 kB ,compared to sqlite's 4 MB

Allowed `NetworkTileProvider` to accept any `MapCachingProvider` implementation, defaulting to `BuiltInMapCachingProvider`
Used size monitor to check cache size before applying limit if necessary
@JaffaKetchup
Copy link
Member Author

why not internalize/fork it ?

That is infeasible. Our maintenance resources are stretched as it is, without having to manage an entirely new database project with no real low-level database experience.

or maybe port it to dart ,using google-gemini then review ,because c is a lot like dart ,so it should be smooth ,hence it could become 10 - 15 kB ,compared to sqlite's 4 MB

We cannot just use a generative AI agent to perform code review. I personally stay away from generative AI for development, but even besides that, we cannot entrust the stability of such a large and complex project to AI.


@androidseb Looking at sqflite, we would need to use the layer to add compatibility for more platforms which uses sqlite3. So we may as well use sqlite3. Looking at that, I'm very unsure about adding sqlite3_flutter_libs, as this will put the package size way up (includes its own sqlite binaries), and making it work using the OS provided sqlite seems like a good way to introduce difficult to debug issues.

For the time being, I'm going to keep pursuing JSON. The size monitor has made a very large reduction to startup times, so that's good. I'm going to see if I can then make the size reducer run in the background without delaying the startup.

@JaffaKetchup
Copy link
Member Author

I've also changed the structure quite a bit in the latest commits. Now users can provide their own caching providers to NetworkTileProvider if they want. It should also make it easier to integrate into the cancellable TP.

Refactored workers into independent source files
Added size monitor auto-repair
Added read-only option
@JaffaKetchup
Copy link
Member Author

Here's some very rough performance stats:

  • To drop 350MB/31k tiles down to 1MB: ~2.5s size limiter, with a total init time ~3s
  • To load 11.2k tiles (registry 2MB) without limiting: ~470ms
  • To load 9k tiles (registry 1.6MB) without limiting, but having to regenerate the size limiter: ~450ms

If we want to get the startup times down, I think the only option is to use a database at this point.

@androidseb @fleaflet/maintainers I think it's a good time for fresh feedback! I can try implementing a DB - but I'm a bit cautious about making it work seamlessly everywhere and keeping the weight down. What do you think about the new implementation style? Are there any other ways to get some more performance out? Thanks :)

@JaffaKetchup JaffaKetchup marked this pull request as ready for review May 15, 2025 22:09
@atototenten
Copy link

LMDB is just 10k lines-of-code in c ,should be roughly 5-7 k l.o.c. in dart

maybe 10 kB in final binary

note that LMDB is more of storage-engine than a d.b. ,hence more suited to the project

flutter seems to be the future ,so i think this would also help the community

thanks

@atototenten
Copy link

atototenten commented May 16, 2025

may i get some details ,regarding the requirements ?

will the data be add-only ,and never/rarely removed ,or simply re-creating the data-file after a specific threshold is reached (of wastage ,due to removed data) (approach used by both realm and sqlite(auto-vacum)) ?
then we can remove the paging and garbage-collection(of pages containing obsolete data) ,hence its at most 1-3 l.o.c. .i had impl.ed a data-storage framework like this (2 years ago ,hence can be refined a bit) .its as space/access-time efficient as possible (2 of the 3 priorities) ,due to minimum mgmt. portion ,and only the essentials .

@androidseb
Copy link
Contributor

[...]
I'm very unsure about adding sqlite3_flutter_libs, as this will put the package size way up
[...]
For the time being, I'm going to keep pursuing JSON
[...]
Now users can provide their own caching providers to NetworkTileProvider if they want.

Great, I think this makes perfect sense. Keeping the base package light while allowing for flexibility to implement more advanced and heavier caching packages makes perfect sense to me. As long as we can mitigate the impact on OSM servers while getting decent UX results that will not turn down library consumers, and have the straightforward and low-cost-to-maintain JSON approach... As I wrote earlier, I'd rather see you spend your time on valuable mapping features, we plugin developers can tackle these separate complicated advanced caching problems in separate plugin packages :-)


[...] Here's some very rough performance stats [...] load 11.2k tiles (registry 2MB) without limiting: ~470ms

Are you saying there is about half a second delay on the startup time? This seems very acceptable if you compare this to fetching tiles from the network... Just wondering, did you test this in a production release on a real physical device? From my experience, the Android emulator can be deceivingly fast.


I can try implementing a DB - but I'm a bit cautious about making it work seamlessly everywhere and keeping the weight down. What do you think about the new implementation style?

I agree, keeping it lightweight no DB is the way to go if we can have an acceptable tradeoff. A 470ms startup time seems very acceptable to me.


Apologies I don't have time to test things out (things are fairly busy for me at the moment), I've quickly glanced through the code, but at a high level the approach you're proposing makes sense to me. I hope these inputs help.

@JaffaKetchup
Copy link
Member Author

@atototenten

LMDB is just 10k lines-of-code in c ,should be roughly 5-7 k l.o.c. in dart

That is kinda surprising that it's that small, I would've expected it to be larger! I would be interested in a Dart port, but it's unfortunately not something that I would be comfortable doing outside of 'for fun' at the moment. Now that this functionality allows swapping of the caching provider, it would be easy to change in future.

may i get some details ,regarding the requirements ?

Sure. This is a non-critical cache which stores lots of 10-50KB blobs very frequently, plus some metadata. So the requirements are to have very fast creates (or at least handle a burst well) and updates. Deletion is not important - it is currently implemented, but should never be used (it's used when the registry somehow gets out of sync, which shouldn't happen).
A cap on the size is useful, but other than that, considering what happens when data is removed is not necessary. If data is removed, all the data is removed. Reliability/robustness is not very important, so long as the cache can be reset.

I think LMDB could be something to explore in future, but it's not feasible at the moment. But maybe I have some free time at some point :D Thanks for the suggestions though, it's certainly useful to consider.


@androidseb

Are you saying there is about half a second delay on the startup time?

I'm testing this on a Windows build. I think it's fair to say the longest time is the parsing of the JSON, so I think it would be fairly stable across devices. Also see dart-lang/sdk#51596 & dart-lang/sdk#51779.

This is also the delay before we make the first network requests. So it looks longer to the user. But I would be comfortable saying it would go unnoticed in all feasible registry sizes when awaited in the app startup, and maybe noticed but usually less than 1 sec if startup is performed just before the first tile load. Acceptable hopefully - maybe some stronger hints in documentation.

There is also a balance between the max size we impose (by default) and time spent 'size limiting'. We won't need to size limit (which is the expensive operation) for longer with a higher max size, but parse times will keep growing. It's current at 1GB - wdyt about changing this?

Thanks for all your feedback :)

@androidseb
Copy link
Contributor

Again, thanks for doing this work, this looks good and I'll look into leveraging these changes in a future app update.

I think it would be good to test worst case scenarios to get an idea, so what I have in mind for testing (when I get to it, you might do this way before me 😄) is something like this:

  • An app for which the map view is the main view, so the map is displayed on startup (e.g. I'll have this problem with my app) - this negates the effects of "trigger the parse ahead", because the app starts with the map view visible
  • Running on an old, slow-ish Android device (a Windows PC is probably much faster)
  • With the minimum reasonable cache size we'd like to see (e.g. 1GB)
  • With the cache full (i.e. max parse time)

And then if I see the cache is fast enough, all good, otherwise I'll have to figure out ways to adjust the implementation, so I'll probably contribute back either core library improvements to your existing cache implementation, or if heavier dependencies are needed, improvements to an existing cache plugin, or the creation of a new one, but hopefully that won't even be needed.

Other performance improvements to initialisation
@JaffaKetchup
Copy link
Member Author

@androidseb I've changed the registry format from JSON to a FlatBuffer. I've also moved some stuff within the worker isolates around, and added a shortcut to check the size monitor size on initialisation without starting an isolate if it's not necessary to run the limiter.

Here's running in profile mode on my Windows again (which I note puts the getApplicationCacheDirectory method from 50ms to 0ms interestingly):

  • 30.2k tiles in 539MB, stored with a 3.6MB registry: total initialisation time ~300ms
  • 50.5k tiles in 812MB, stored with a 6MB registry: total initialisation time: ~500ms
    • Compared to approx the same time to load 11K tiles with a 2MB registry before
  • Initialisation time grows much slower than before
  • A good rule of thumb seems to be 100ms/10k tiles (at least for me)
  • A little more difficult to get a good benchmark on mobile - I still haven't done this

That's much better IMO. Even without moving the initialisation delay, I would say that it's barely noticeable until it reaches ~500ms. Maybe we drop our default limit down to 800MB?

@androidseb
Copy link
Contributor

Awesome progress, I had not heard about FlatBuffer, learned something there. The new performance looks even better. Some thoughts:

Even without moving the initialisation delay, I would say that it's barely noticeable until it reaches ~500ms.

Yes I would tend to agree.

Maybe we drop our default limit down to 800MB?

It would be good to know the performance on a real mobile device, PCs are generally faster... If the performance is very different between platforms, we might want to make that default limit value platform-dependent? Mobile devices are generally more constrained in computing and storage resources.

Integrate basic stats and loading into example app
@JaffaKetchup
Copy link
Member Author

I've exposed the tile count at initialisation from the API. In the example app I've added this count to the menu header, alongside the time it took to initialise.

If you have free time, it should now be relatively easy to test! Just download the APK from the actions output for the latest commit.

@JaffaKetchup
Copy link
Member Author

@androidseb Testing on my Android (https://www.gsmarena.com/xiaomi_redmi_note_13_pro-12581.php).

  • Overall the init times are much more varied - I restarted the app a few times on each and took the extremes but without any real outliers.
  • 15.5k tiles in 130ms - 220ms
  • 17k in 150ms - 260ms
  • 21k in ~230ms
  • 24.6k in 220ms - 320ms

I haven't gone higher, but I feel comfortable saying that it's still relatively performant. Not quite 100ms:10k, but still close.


One thing I have found out is that FlatBuffers can be made to work with a binary search. So you wouldn't require a seperate in-memory registry - until you consider that you have to write in order every time, which does require a full sort of every single tile for every single update, or an in-memory registry. So it may be possible to implement it so that reads can happen using binary search whilst a registry is built in the background.

I had a start at implementing that, but it makes things much much more complex (especially since there is no way to validate the FlatBuffer, so any read at any time might not work as expected if there's a real corruption) and for probably an overall small gain in the end. Maybe I can look into it once I have more time if there's a real need.

I'm going to drop the cache limit down to 800 MB.

Use `DisabledMapCachingProvider` as a mixin (for built-in caching provider on web)
Removed `WidgetsFlutterBinding.ensureInitialized()` from internals
Improved documentation
@JaffaKetchup
Copy link
Member Author

I'm just running some more tests comparing against JSON. I realised it may have not been a fair comparison before - I was not running in profile/release mode I don't think.

As it turns out, I've just loaded 50k tiles with a JSON registry consistently in 300ms on Windows. Yes, the registry size is definitely larger, but with more efficient encoding, I think we can get that down, and maybe the time too.

I may have made a mistake switching to flatbufs 😭 - more testing on Android to come tomorrow before I make a decision whether to move back or not. But it's strange that for both methods, the difference between debug, profile, and release is so large. Like easily 3-5x slower.

Removed `CachedMapTileMetadata.lastModifiedLocally`
Store `CachedMapTileMetadata.staleAt` & `.lastModified` as milliseconds since epoch to improve performance/efficiency
Compress/obfuscate `CachedMapTileMetadata` JSON representation
@JaffaKetchup
Copy link
Member Author

Back to JSON, getting a ratio of 40k:100ms on Windows and 10k:100ms on Android (with a slightly noticeably worse ratio at lower numbers (where the constant time of the isolate spawning has more of an overall effect). Debug mode is ~10x slower.

The size limiter on Windows does 680 MB/40.5k tiles down to 1 MB in a total time of a little under 4 seconds. Not really an easy way to make this faster, it's just a lot of I/O.

I also removed one thing we store which wasn't actually very useful very often, which has also brought the JSON size ratio down to a slightly better than 10k:1 MB.

I think I'm going to leave it there for now. I can't think of any way to get that down any more, given it's now clearly very device dependent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants