Skip to content

Perf/all - #525

Open
whes1015 wants to merge 13 commits into
mainfrom
perf/all
Open

Perf/all#525
whes1015 wants to merge 13 commits into
mainfrom
perf/all

Conversation

@whes1015

Copy link
Copy Markdown
Member

No description provided.

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.
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

🔍 OpenCodeReview found 10 issue(s) in this PR.

  • ✅ Successfully posted inline: 10 comment(s)

Comment on lines +39 to +45
/** 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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
使用強制轉型 (as) 可能在系統服務回傳 null 時導致應用程式崩潰。此外,可以利用 Kotlin 的特性將其改寫得更簡潔且符合慣用法(Idiomatic Kotlin)。建議改用更安全的 API 或安全轉型 (as?),並配合單一表達式函式 (single-expression function) 來提高程式碼的可讀性與安全性。

Suggestion:

Suggested change
/** 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

Comment on lines +98 to +113
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
當文件遍歷數量達到 visitCap (100,000) 時,scan 方法會中斷遍歷並返回已累加的 bytes。這會導致 totalBytes 僅代表部分文件的總和,而非目錄的真實總大小,從而導致掃描結果在大型文件系統中顯著不準確,誤導用戶對存儲空間佔用的認知。建議在達到限制時,明確標記結果為「部分掃描」或調整邏輯以確保 totalBytes 的正確性(例如先獲取目錄大小,再進行詳細遍歷)。

Comment on lines +78 to +83
String? dirOf(String path) {
for (final dir in scan.dirs) {
if (path.startsWith(dir.path)) return dir.path;
}
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · medium]
storageBreakdown 函數存在效能與邏輯風險。首先,它對每一種已知分類都會完整遍歷一次 scan.files,若檔案數量極多,效能會下降。其次,dirOf 函數使用 path.startsWith(dir.path) 來匹配目錄,若存在巢狀目錄(例如 /a/a/b),匹配結果會受 scan.dirs 列表順序影響,可能導致檔案被歸類到錯誤的目錄或導致 dirBytes 計算錯誤(甚至出現負值)。建議將目錄路徑按長度從長到短排序,以確保優先匹配最精確的目錄。

Suggestion:

Suggested change
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;
}

Comment on lines +146 to +157
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'),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · high]
StorageScanner.scan 方法對原生端傳回的資料結構高度依賴。雖然目前 Android (StorageScanChannel.kt) 與 iOS (StorageScanPlugin.swift) 的實作看起來是符合預期的(包含 totalBytes (num), dirs (List), files (List),以及子項目的 path (String) 與 bytes (num)),但若未來原生端協議變動,這段 Dart 程式碼會因型別轉換錯誤(例如 as Mapas List)而拋出異常,目前只會被 catch 並回傳空的掃描結果,這會讓除錯變得困難。建議在轉換前加入更明確的型別檢查或提供更詳細的錯誤資訊。

Suggestion:

Suggested change
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,
];
}
// ... 其餘部分也應進行類似的安全性檢查

Comment on lines +175 to +184
@override
void didChangeDependencies() {
super.didChangeDependencies();
final visibleTab = VisibleTabScope.of(context);
if (identical(visibleTab, _visibleTab)) return;
_visibleTab?.removeListener(_onTabChanged);
_visibleTab = visibleTab;
visibleTab?.addListener(_onTabChanged);
_syncRender();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
didChangeDependencies 中使用 identical(visibleTab, _visibleTab) 进行提前返回可能会导致在 VisibleTab 实例不变但其 value 变化时,无法触发 _syncRender。此外,缺少 didUpdateWidget 来处理 widget.tabIndex 的变化,这会导致当父组件传入新的 tabIndex 时,地图的渲染暂停状态无法即时更新。

Suggestion:

Suggested change
@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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · medium]
_injectFill 方法中,used 變數的初始值被設為 0(這被註釋為「樂觀估算」)。如果快取在調用 warm 方法時已經存在大量資料,第一個 chunk 的注入可能會顯著超過 cap 限制,進而觸發原生層的 LRU 剔除,這可能導致剛注入的圖塊被立即刪除,造成效能抖動。

Comment on lines +216 to +230
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · medium]
_injectFill 方法引入了複雜的分塊(chunk splitting)邏輯,包含嵌套迴圈與多重邊界條件判斷(例如 used + size + tiles[j].data.length > cap)。這種複雜的邏輯增加了維護難度,且若邊界條件計算不精確或與原生層的記憶體計算方式不一致,可能會導致無法達到預期的填充目標或造成錯誤的注入行為。

Comment on lines +15 to +36
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,
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · low]
測試案例中的邊界值判斷與實際邏輯一致。在 lib/core/platform/render_tier.dart 中,判定邏輯為 totalMb < 4096 ? RenderTier.low : RenderTier.high。測試中使用了 4095 MB 作為低階 Android 的上限,以及 4096 MB 作為高階 Android 的下限,這與實作邏輯完全吻合。

Comment on lines +12 to +50
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,
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
storageBreakdown 邏輯在處理數據不一致時(例如:大檔案的大小超過了其父目錄報告的大小)可能會導致計算出的總量 accounted 超過 scan.totalBytes。這會導致 UI 圓餅圖的百分比總和超過 100%。建議在計算 accounted 時進行截斷,或者確保 known 匹配過程中,扣除的容量不會使目錄大小變成負數。

Suggestion:

Suggested change
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant