Conversation
The weather backdrop is the only full-screen layer that keeps redrawing while its tab is hidden (60 fps ticker, 1792 rain particles, full-screen shaders) and it burns low-end GPUs. Five equivalent batches: - Recompute the ephemeris and keyframe ring on a daily/minute cadence; the LUT bake now runs once a minute instead of once a second - TickerMode mutes every ticker under the sheet while Home is hidden - Quantise the full-screen blur sigmas into 6 steps during drags - Tier by RAM on Android (< 4 GB): render scale 0.75->0.6, rain pool 1792->1024, snow 900->640 (native reports totalMemoryMb) - Hoist loop invariants out of the particle and cloud loops
Scrolling rebuilds _ScrollBlurredWeather every tick while the sky is visually frozen under it: - ImageFilter has no value equality, so a fresh blur() every tick made the full-screen blur layer recomposite constantly; the quantised sigma ladder now reuses one instance between steps - WeatherSkyBackground reuses its painter while the ticker is stopped, so the CustomPaint skips repaint on the rebuilds above Adds a widget test pinning the stopped sky to its painter across rebuilds and a fresh one when it restarts.
The shell's IndexedStack keeps every tab mounted, so both MapLibre platform views (home backdrop + map tab) kept rendering behind other tabs. BaseMap now subscribes to VisibleTabScope and calls setRenderPaused on the controller, so a hidden map stops burning the GPU. Adds the forked maplibre_gl setRenderPaused API (git-pinned platform interface and web packages) and the cupertino_icons dep.
iOS Settings reports the whole sandbox, which is far larger than the 150 MB ETag body budget: the SQLite file carries page/free-space overhead, the system NSURLCache keeps its own copy of responses, and ambient MapLibre data can linger. A native channel scans the sandbox (cache/support/document/tmp, top 30 files); the Developer page shows total usage, a categorized pie breakdown, and per-slice percentages. Growth is bounded: startup configures NSURLCache to 64 MB, and Clear cache now also compacts the SQLite file (VACUUM) and empties the system HTTP cache.
The trail buffer rasterized at full screen resolution every frame (toImageSync, a synchronous GPU round-trip on the UI thread), the stamp path allocated up to 6400 Offsets per frame, and each particle paid a log+tan projection. The buffer now renders at half resolution (or a third on low-end devices), stamping goes through preallocated Float32Lists with drawRawPoints, and the mercator projection is a LUT. The ticker also stops while the map tab is hidden, so the overlay no longer animates behind other tabs.
|
🔍 OpenCodeReview found 10 issue(s) in this PR.
|
| /** Total physical RAM in MiB — the cheap proxy for the low-end tier. */ | ||
| private fun totalMemoryMb(): Long { | ||
| val mem = ActivityManager.MemoryInfo() | ||
| (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) | ||
| .getMemoryInfo(mem) | ||
| return mem.totalMem / 1024 / 1024 | ||
| } |
There was a problem hiding this comment.
[maintainability · low]
使用強制轉型 (as) 可能在系統服務回傳 null 時導致應用程式崩潰。此外,可以利用 Kotlin 的特性將其改寫得更簡潔且符合慣用法(Idiomatic Kotlin)。建議改用更安全的 API 或安全轉型 (as?),並配合單一表達式函式 (single-expression function) 來提高程式碼的可讀性與安全性。
Suggestion:
| /** Total physical RAM in MiB — the cheap proxy for the low-end tier. */ | |
| private fun totalMemoryMb(): Long { | |
| val mem = ActivityManager.MemoryInfo() | |
| (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) | |
| .getMemoryInfo(mem) | |
| return mem.totalMem / 1024 / 1024 | |
| } | |
| /** Total physical RAM in MiB — the cheap proxy for the low-end tier. */ | |
| private fun totalMemoryMb(): Long = | |
| ActivityManager.MemoryInfo().apply { | |
| (context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.getMemoryInfo(this) | |
| }.totalMem / 1024 / 1024 |
| var visited = 0 | ||
| for case let url as URL in enumerator { | ||
| visited += 1 | ||
| if visited > StorageScanPlugin.visitCap { break } | ||
| guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) else { | ||
| continue | ||
| } | ||
| if values.isDirectory == true { continue } | ||
| let fileBytes = Int64(values.fileSize ?? 0) | ||
| guard fileBytes > 0 else { continue } | ||
| bytes += fileBytes | ||
| if fileBytes >= StorageScanPlugin.topFileFloor { | ||
| top.append((url.path, fileBytes)) | ||
| } | ||
| } | ||
| return (bytes, top) |
There was a problem hiding this comment.
[bug · high]
當文件遍歷數量達到 visitCap (100,000) 時,scan 方法會中斷遍歷並返回已累加的 bytes。這會導致 totalBytes 僅代表部分文件的總和,而非目錄的真實總大小,從而導致掃描結果在大型文件系統中顯著不準確,誤導用戶對存儲空間佔用的認知。建議在達到限制時,明確標記結果為「部分掃描」或調整邏輯以確保 totalBytes 的正確性(例如先獲取目錄大小,再進行詳細遍歷)。
| String? dirOf(String path) { | ||
| for (final dir in scan.dirs) { | ||
| if (path.startsWith(dir.path)) return dir.path; | ||
| } | ||
| return null; | ||
| } |
There was a problem hiding this comment.
[other · medium]
storageBreakdown 函數存在效能與邏輯風險。首先,它對每一種已知分類都會完整遍歷一次 scan.files,若檔案數量極多,效能會下降。其次,dirOf 函數使用 path.startsWith(dir.path) 來匹配目錄,若存在巢狀目錄(例如 /a 與 /a/b),匹配結果會受 scan.dirs 列表順序影響,可能導致檔案被歸類到錯誤的目錄或導致 dirBytes 計算錯誤(甚至出現負值)。建議將目錄路徑按長度從長到短排序,以確保優先匹配最精確的目錄。
Suggestion:
| String? dirOf(String path) { | |
| for (final dir in scan.dirs) { | |
| if (path.startsWith(dir.path)) return dir.path; | |
| } | |
| return null; | |
| } | |
| // 建議先對 dirs 按路徑長度降序排列,確保優先匹配最深層的目錄 | |
| final sortedDirs = [...scan.dirs]..sort((a, b) => b.path.length.compareTo(a.path.length)); | |
| String? dirOf(String path) { | |
| for (final dir in sortedDirs) { | |
| if (path.startsWith(dir.path)) return dir.path; | |
| } | |
| return null; | |
| } |
| List<StorageEntry> entries(String key) => [ | ||
| for (final row in (raw[key] as List? ?? const [])) | ||
| StorageEntry( | ||
| path: (row as Map)['path'] as String, | ||
| bytes: (row['bytes'] as num).toInt(), | ||
| ), | ||
| ]; | ||
| return StorageScan( | ||
| totalBytes: (raw['totalBytes'] as num?)?.toInt() ?? 0, | ||
| dirs: entries('dirs'), | ||
| files: entries('files'), | ||
| ); |
There was a problem hiding this comment.
[other · high]
StorageScanner.scan 方法對原生端傳回的資料結構高度依賴。雖然目前 Android (StorageScanChannel.kt) 與 iOS (StorageScanPlugin.swift) 的實作看起來是符合預期的(包含 totalBytes (num), dirs (List), files (List),以及子項目的 path (String) 與 bytes (num)),但若未來原生端協議變動,這段 Dart 程式碼會因型別轉換錯誤(例如 as Map 或 as List)而拋出異常,目前只會被 catch 並回傳空的掃描結果,這會讓除錯變得困難。建議在轉換前加入更明確的型別檢查或提供更詳細的錯誤資訊。
Suggestion:
| List<StorageEntry> entries(String key) => [ | |
| for (final row in (raw[key] as List? ?? const [])) | |
| StorageEntry( | |
| path: (row as Map)['path'] as String, | |
| bytes: (row['bytes'] as num).toInt(), | |
| ), | |
| ]; | |
| return StorageScan( | |
| totalBytes: (raw['totalBytes'] as num?)?.toInt() ?? 0, | |
| dirs: entries('dirs'), | |
| files: entries('files'), | |
| ); | |
| List<StorageEntry> entries(String key) { | |
| final list = raw[key]; | |
| if (list is! List) return []; | |
| return [ | |
| for (final row in list) | |
| if (row is Map && row['path'] is String && row['bytes'] is num) | |
| StorageEntry( | |
| path: row['path'] as String, | |
| bytes: (row['bytes'] as num).toInt(), | |
| ) | |
| else | |
| // 可以考慮拋出更具體的錯誤或記錄警告 | |
| continue, | |
| ]; | |
| } | |
| // ... 其餘部分也應進行類似的安全性檢查 |
| @override | ||
| void didChangeDependencies() { | ||
| super.didChangeDependencies(); | ||
| final visibleTab = VisibleTabScope.of(context); | ||
| if (identical(visibleTab, _visibleTab)) return; | ||
| _visibleTab?.removeListener(_onTabChanged); | ||
| _visibleTab = visibleTab; | ||
| visibleTab?.addListener(_onTabChanged); | ||
| _syncRender(); | ||
| } |
There was a problem hiding this comment.
[bug · medium]
在 didChangeDependencies 中使用 identical(visibleTab, _visibleTab) 进行提前返回可能会导致在 VisibleTab 实例不变但其 value 变化时,无法触发 _syncRender。此外,缺少 didUpdateWidget 来处理 widget.tabIndex 的变化,这会导致当父组件传入新的 tabIndex 时,地图的渲染暂停状态无法即时更新。
Suggestion:
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| final visibleTab = VisibleTabScope.of(context); | |
| if (identical(visibleTab, _visibleTab)) return; | |
| _visibleTab?.removeListener(_onTabChanged); | |
| _visibleTab = visibleTab; | |
| visibleTab?.addListener(_onTabChanged); | |
| _syncRender(); | |
| } | |
| @override | |
| void didUpdateWidget(BaseMap oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (oldWidget.tabIndex != widget.tabIndex) { | |
| _syncRender(); | |
| } | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| final visibleTab = VisibleTabScope.of(context); | |
| if (identical(visibleTab, _visibleTab)) { | |
| _syncRender(); | |
| return; | |
| } | |
| _visibleTab?.removeListener(_onTabChanged); | |
| _visibleTab = visibleTab; | |
| visibleTab?.addListener(_onTabChanged); | |
| _syncRender(); | |
| } |
| /// current frame until it is near this cap, then stops — the mirror trims | ||
| /// LRU beyond it, dropping the frames a scrub swept past. | ||
| static const int defaultMemoryBytes = 24 * 1024 * 1024; | ||
| static const int defaultMemoryBytes = 48 * 1024 * 1024; |
There was a problem hiding this comment.
[other · low]
預設記憶體容量 defaultMemoryBytes 從 24MB 增加到了 48MB。雖然這能提升地圖滑動時的圖塊命中率,但在記憶體受限的低階裝置上,可能會增加 OOM (Out of Memory) 的風險。建議確認專案是否已具備根據裝置等級(如新增的 render_tier)動態調整此值的機制。
| Future<int> _injectFill(List<MapLibreTile> tiles, double fillUntil) async { | ||
| final cap = (_memoryLimit * fillUntil).floor(); | ||
| if (cap <= 0) return 0; | ||
| var used = 0; // No pre-inject usage query — start at the optimistic 0. |
There was a problem hiding this comment.
[performance · medium]
在 _injectFill 方法中,used 變數的初始值被設為 0(這被註釋為「樂觀估算」)。如果快取在調用 warm 方法時已經存在大量資料,第一個 chunk 的注入可能會顯著超過 cap 限制,進而觸發原生層的 LRU 剔除,這可能導致剛注入的圖塊被立即刪除,造成效能抖動。
| if (used + chunkBytes > cap) { | ||
| // Split the chunk at the goal — send only the tiles that fit. | ||
| final fits = <MapLibreTile>[]; | ||
| var size = 0; | ||
| for (var j = i; j < end; j++) { | ||
| if (used + size + tiles[j].data.length > cap) break; | ||
| fits.add(tiles[j]); | ||
| size += tiles[j].data.length; | ||
| } | ||
| if (fits.isEmpty) break; | ||
| final usage = await injectMapLibreTiles(fits); | ||
| used = usage?.used ?? used + size; | ||
| injected += fits.length; | ||
| break; | ||
| } |
There was a problem hiding this comment.
[maintainability · medium]
_injectFill 方法引入了複雜的分塊(chunk splitting)邏輯,包含嵌套迴圈與多重邊界條件判斷(例如 used + size + tiles[j].data.length > cap)。這種複雜的邏輯增加了維護難度,且若邊界條件計算不精確或與原生層的記憶體計算方式不一致,可能會導致無法達到預期的填充目標或造成錯誤的注入行為。
| test('a low-RAM Android phone is downgraded', () { | ||
| expect( | ||
| renderTierFor(device(totalMemoryMb: 3072), isAndroid: true), | ||
| RenderTier.low, | ||
| reason: '2–4 GB Android devices are the low-end GPU class', | ||
| ); | ||
| expect( | ||
| renderTierFor(device(totalMemoryMb: 4095), isAndroid: true), | ||
| RenderTier.low, | ||
| ); | ||
| }); | ||
|
|
||
| test('a mid/high-RAM Android phone keeps full quality', () { | ||
| expect( | ||
| renderTierFor(device(totalMemoryMb: 4096), isAndroid: true), | ||
| RenderTier.high, | ||
| ); | ||
| expect( | ||
| renderTierFor(device(totalMemoryMb: 12288), isAndroid: true), | ||
| RenderTier.high, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
[test · low]
測試案例中的邊界值判斷與實際邏輯一致。在 lib/core/platform/render_tier.dart 中,判定邏輯為 totalMb < 4096 ? RenderTier.low : RenderTier.high。測試中使用了 4095 MB 作為低階 Android 的上限,以及 4096 MB 作為高階 Android 的下限,這與實作邏輯完全吻合。
| test('known big files are pulled out of their directory', () { | ||
| final s = scan( | ||
| totalBytes: 300 * 1024 * 1024, | ||
| dirs: const [ | ||
| StorageEntry(path: '/caches', bytes: 200 * 1024 * 1024), | ||
| StorageEntry(path: '/support', bytes: 100 * 1024 * 1024), | ||
| ], | ||
| files: const [ | ||
| StorageEntry( | ||
| path: '/caches/http_etag_cache.db', | ||
| bytes: 180 * 1024 * 1024, | ||
| ), | ||
| StorageEntry( | ||
| path: '/support/MapLibre/cache.db', | ||
| bytes: 60 * 1024 * 1024, | ||
| ), | ||
| ], | ||
| ); | ||
| final slices = storageBreakdown(s); | ||
| expect( | ||
| slices, | ||
| contains( | ||
| predicate<StorageSlice>((s) => s.label == 'ETag cache (SQLite)'), | ||
| ), | ||
| ); | ||
| expect( | ||
| slices.firstWhere((s) => s.label == 'ETag cache (SQLite)').bytes, | ||
| 180 * 1024 * 1024, | ||
| ); | ||
| expect( | ||
| slices.firstWhere((s) => s.label == 'MapLibre').bytes, | ||
| 60 * 1024 * 1024, | ||
| ); | ||
| // The cache directory keeps the leftover after the DB is subtracted. | ||
| expect( | ||
| slices.firstWhere((s) => s.label == 'caches').bytes, | ||
| 20 * 1024 * 1024, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
[bug · medium]
storageBreakdown 邏輯在處理數據不一致時(例如:大檔案的大小超過了其父目錄報告的大小)可能會導致計算出的總量 accounted 超過 scan.totalBytes。這會導致 UI 圓餅圖的百分比總和超過 100%。建議在計算 accounted 時進行截斷,或者確保 known 匹配過程中,扣除的容量不會使目錄大小變成負數。
Suggestion:
| test('known big files are pulled out of their directory', () { | |
| final s = scan( | |
| totalBytes: 300 * 1024 * 1024, | |
| dirs: const [ | |
| StorageEntry(path: '/caches', bytes: 200 * 1024 * 1024), | |
| StorageEntry(path: '/support', bytes: 100 * 1024 * 1024), | |
| ], | |
| files: const [ | |
| StorageEntry( | |
| path: '/caches/http_etag_cache.db', | |
| bytes: 180 * 1024 * 1024, | |
| ), | |
| StorageEntry( | |
| path: '/support/MapLibre/cache.db', | |
| bytes: 60 * 1024 * 1024, | |
| ), | |
| ], | |
| ); | |
| final slices = storageBreakdown(s); | |
| expect( | |
| slices, | |
| contains( | |
| predicate<StorageSlice>((s) => s.label == 'ETag cache (SQLite)'), | |
| ), | |
| ); | |
| expect( | |
| slices.firstWhere((s) => s.label == 'ETag cache (SQLite)').bytes, | |
| 180 * 1024 * 1024, | |
| ); | |
| expect( | |
| slices.firstWhere((s) => s.label == 'MapLibre').bytes, | |
| 60 * 1024 * 1024, | |
| ); | |
| // The cache directory keeps the leftover after the DB is subtracted. | |
| expect( | |
| slices.firstWhere((s) => s.label == 'caches').bytes, | |
| 20 * 1024 * 1024, | |
| ); | |
| }); | |
| if (sum > 0) { | |
| slices[label] = (slices[label] ?? 0) + sum; | |
| } | |
| } | |
| for (final dir in scan.dirs) { | |
| final bytes = dirBytes[dir.path] ?? 0; | |
| if (bytes <= 0) { | |
| continue; | |
| } | |
| slices[dir.name] = (slices[dir.name] ?? 0) + bytes; | |
| } | |
| var accounted = slices.values.fold(0, (a, b) => a + b); | |
| // 確保 accounted 不會超過 totalBytes | |
| if (accounted > scan.totalBytes) { | |
| accounted = scan.totalBytes; | |
| } |
VisibleTabScope handed every page the same notifier instance, so its InheritedWidget never notified on a value change and the home sheet's TickerMode plus the wind overlay's ticker gate froze at their first value — both kept animating behind hidden tabs. Subscribe to the notifier itself (as BaseMap and RefreshOnAppear already did) and pin the contract with tests.
Switching the typhoon weather underlay to satellite swaps the county frame to the bare bright-yellow line the standalone B13 layer uses — the shared cased stroke reads as black over opaque IR. Removal is unconditional on either side so toggling or switching never leaves a stale frame behind.
adminBaseLayerId anchored frames below the bottommost admin stroke, which is the global casing once 國界 is on — so a scrubbed frame still covered the county and town lines. Anchor below the topmost admin line instead, and apply the same anchoring to radar and QPESUMS (their later frames stacked over their own borders and scan-range outline). 國界 now ships on for every raster layer (radar, wind, QPESUMS, satellite); the menus' "not the defaults" dot and their tests follow.
SQLite cache entries no longer expire by age — only the byte budget trims, and only once the store is actually over 350 MB, dropping least-recently-used rows until it is back under. Debug kernel snapshots (*.dill) count as engine in the storage pie and the largest files now show their directory, so a tmp pile-up is attributable at a glance.
MapLibre's native downloads already persist through the Dart tile bridge into the app's own ETag SQLite, so NSURLCache's disk copy was pure overhead — a second, un-metered copy of the same bytes that only the system could evict. configure() now sets diskCapacity to 0 (memory-only 16 MB stays, so a SQLite miss can still skip the network), drops any residue left by older builds, and the storage breakdown marks the System HTTP cache slice as residue-only.
flutter run leaves main.dart.dill / .swap.dill (~87 MB each) in tmp on every debug launch and iOS keeps tmp across app updates, so a dev device that runs release picks up hundreds of MB of JIT kernels it cannot use. Release startup clears tmp once — release has nothing of its own there, and Android's handler is a no-op by design.
The perf rewrite counted each bucket's points in a Uint8List, and a whole 6400-particle population can land in one bucket under strong wind — the count then wraps at 255, dropping the bucket (or most of it) so new particles vanish and stale trails outlive a rotation. Count in 16 bits, and make the streak tests actually see the particles: the sampled boundary was Scaffold's white one (blank overlays passed), and the z7 viewport held too few particles to trip the wrap. A zoomed-in Taiwan field now puts thousands of points in one bucket, pinning the count at 300+ bright pixels — the buggy build measures ~60.
The AIFFs sat loose in ios/Runner and the OGGs beside the Android resources, sized 5.0 MB and 287 KB between them with no common spec — several were already clipping at 0 dBFS while others sat 3 dB quieter, and the OGGs were Vorbis stereo. Move the iOS sounds into Runner/Sounds (pbxproj paths updated) and re-encode everything: 44.1 kHz mono, peak normalised to -1 dBFS, Android as 128 kbps MP3 and iOS as IMA4 AIFF (notification sounds must stay in an Apple container, so MP3 is not an option there). iOS drops from 5.0 MB to 640 KB.
Flutter 3.44.8 -> 3.47.0 (Dart 3.13) via mise; SDK floor to ^3.13.0. Dart 3.13 reserves `final` on parameters for primary constructors, so the freezed 3.x codegen no longer compiles — freezed 4.0.0-dev.3 + build_runner 2.16 regenerate all 23 models (output otherwise unchanged). Firebase stays pinned 4.11.0/16.4.1 (exact, not ^, so pub upgrade can't drift it). Dependency bumps: dio 5.11, go_router 17.5, package_info_plus 10.2.1, talker 5.1.20, json_serializable 6.14.1. All 38 touched files are the Dart 3.13 formatter's reflow plus one lint fix (unawaited_return_in_try_block in MapTileCache.warm).
The first `_refresh()` only seeds `_status` — its "previous" is the optimistic initial value, not a confirmed usable state. If a fix published the township while that refresh was in flight (slow geolocator channel), the GPS-lost branch then overrode it with null. The lost branch now requires `_seeded`, so a seed refresh can never clobber a fix that already landed.
The 19 bundled marker PNGs (intensity-1…9, dark variants, cross) are now painted locally into the same badge geometry — rounded-square shell + the discrete intensity colour from IntensityColors (single source of truth, can't drift from the legend) + level digit — and cached PNG bytes feed MapLibre exactly as the assets did. Removes ~28KB of assets and the pubspec declarations; structural tests pin the geometry.
Flutter ≥3.35 auto-unions its 3-ABI abiFilters with the app's, dragging the map SDK's libmaplibre.so (10MB/ABI) in for architectures the engine doesn't ship. Clearing and pinning arm64-v8a (minSdk 26, emulators run debug) cuts the release APK 53MB → 37.6MB; CI's redundant --target-platform flag goes away with it. android/build + android/app/build join the ignore list.
The breakdown subtracts known big files (the SQLite DB etc.) from the directory that contains them, but on iOS the dirs and files came out of different APIs, so path styles could differ (/private/var vs /var) and the subtraction silently missed — the same 123MB appeared as both "Caches" and "ETag cache (SQLite)", summing past 100%. Standardize both path sets to the resolved spelling, tolerate the /var spelling in the Dart matcher, and label a directory that gave up a known file "(other)" so the pie reads ETag as part of Caches, not a sibling.
Port the reference CWA travel-time grid (depth × dist, P + S–P) into the domain and pre-interpolate one depth into two 1-D curves per event, so each distance↔time query is a single bisect + linear interp instead of a linear scan. The replay map caches one source per alert across ticks; wave-radius goldens are unchanged (depth 0).
Gzip box.json (7 KB → 0.8 KB) and drop the redundant uncompressed travel_time.json; re-encode the two purely-visual sky textures lossy (starmap 96 → 64 KB, sun_rays 34 → 4 KB) with the generator tool updated to match; re-gzip location.json at level 9.
No description provided.