perf(network): prepare large SMB listings off the UI thread

This commit is contained in:
zarzet committed 2026-10-08 04:47:53 +07:00
1 parent 0af197b82a
commit 105f1b0946
4 files changed
+246 -17

No files matched your search

@@ -0,0 +1,54 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
import '../test/support/library_collections_benchmark.dart';
import '../test/support/smb_listing_benchmark.dart';
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final report = <String, dynamic>{
'platform': Platform.operatingSystem,
'note':
'SMB result adapter/sort only, no live server or network throughput/FPS claim.',
};
var completed = 0;
tearDownAll(() {
report['completed_cases'] = completed;
binding.reportData ??= {};
binding.reportData!['smb_listing'] = report;
});
for (final count in [32, 256, 1024, 4096, 10000, 50000]) {
testWidgets('prepare $count SMB entries', (tester) async {
final input = await compute(smbListingFixture, count);
final expected = smbListingRows(legacySmbListing(input));
final samples = <String, List<Map<String, int>>>{
for (final kind in ['legacy', 'current']) kind: [],
};
legacySmbListing(input);
await NetworkStorageService.prepareSmbEntries(input.files, input.path);
for (var pair = 0; pair < 5; pair++) {
for (final kind
in pair.isEven ? samples.keys : samples.keys.toList().reversed) {
final result = await measureCollectionOperation<List<NetworkEntry>>(
() => switch (kind) {
'legacy' => legacySmbListing(input),
_ => NetworkStorageService.prepareSmbEntries(
input.files,
input.path,
),
},
);
expect(smbListingRows(result.value), expected);
expect(input.files.length, count);
samples[kind]!.add(result.timing);
}
}
report['entries_$count'] = samples;
completed++;
});
}
}
+58 -17
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:dart_smb2/dart_smb2.dart' show Smb2DirEntry;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -16,6 +17,23 @@ import 'package:xml/xml.dart';
part 'network_storage_upload.dart';
typedef _SmbListingInput = ({
List<({String name, bool directory, int size})> files,
String path,
});
List<NetworkEntry> _prepareSmbListingInBackground(_SmbListingInput input) =>
NetworkStorageService._sorted([
for (final file in input.files)
if (file.name != '.' && file.name != '..')
NetworkEntry(
'${input.path}${file.name}${file.directory ? '/' : ''}',
file.name,
directory: file.directory,
size: file.size,
),
]);
List<NetworkEntry> _parseNetworkListingInBackground(
Map<String, dynamic> request,
) {
@@ -447,24 +465,16 @@ class NetworkStorageService {
]);
}
final smb = await _smb(c, target);
final List<Smb2DirEntry> files;
try {
final files = await smb.list().timeout(timeout);
return _sorted([
for (final file in files)
if (file.name != '.' && file.name != '..')
NetworkEntry(
'$path${file.name}${file.stat.isDirectory ? '/' : ''}',
file.name,
directory: file.stat.isDirectory,
size: file.stat.size,
),
]);
files = await smb.list().timeout(timeout);
} finally {
// A failed disconnect must not hide the original share/login error.
try {
await smb.close();
} catch (_) {}
}
return prepareSmbEntries(files, path);
}
final client = _client();
try {
@@ -506,12 +516,43 @@ class NetworkStorageService {
}
}
static List<NetworkEntry> _sorted(List<NetworkEntry> entries) =>
entries..sort(
(a, b) => a.directory != b.directory
? (a.directory ? -1 : 1)
: a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
/// Large SMB listings arrive from native I/O as plain stat objects. Transfer
/// only fields used by the browser, avoiding copies of unused timestamps.
static Future<List<NetworkEntry>> prepareSmbEntries(
List<Smb2DirEntry> files,
String path,
) async {
final input = (
path: path,
files: [
for (final file in files)
(
name: file.name,
directory: file.stat.isDirectory,
size: file.stat.size,
),
],
);
// Small lists are cheaper inline; large sorts must leave the UI isolate.
if (files.length < 4096) return _prepareSmbListingInBackground(input);
return compute(_prepareSmbListingInBackground, input);
}
static List<NetworkEntry> _sorted(List<NetworkEntry> entries) {
final keyed =
[
for (final entry in entries)
(entry: entry, key: entry.name.toLowerCase()),
]..sort(
(a, b) => a.entry.directory != b.entry.directory
? (a.entry.directory ? -1 : 1)
: a.key.compareTo(b.key),
);
for (var index = 0; index < entries.length; index++) {
entries[index] = keyed[index].entry;
}
return entries;
}
static List<NetworkEntry> parseListing(
NetworkConnection c,
+69
View File
@@ -0,0 +1,69 @@
import 'package:dart_smb2/dart_smb2.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
import 'support/smb_listing_benchmark.dart';
void main() {
for (final count in [0, 1, 32, 256, 4095, 4096, 10000]) {
test(
'SMB adapter retains original order and values for $count entries',
() async {
final input = smbListingFixture(count);
final original = [...input.files];
final expected = smbListingRows(legacySmbListing(input));
final result = await NetworkStorageService.prepareSmbEntries(
input.files,
input.path,
);
expect(smbListingRows(result), expected);
expect(input.files, original);
expect(
result.any((entry) => entry.name == '.' || entry.name == '..'),
isFalse,
);
},
);
}
test(
'case ties, links, literal paths and large sizes retain their semantics',
() async {
final date = DateTime.utc(2026);
final input = (
path: '100% + #1/日本語/',
files: [
for (final (name, type, size) in [
('Same', Smb2FileType.file, 9007199254740991),
('SAME', Smb2FileType.directory, 1),
('same', Smb2FileType.file, 2),
('same', Smb2FileType.link, 3),
('İ.dot', Smb2FileType.file, 4),
('i.dot', Smb2FileType.file, 5),
('literal%20 #+.mp3', Smb2FileType.file, 6),
('..', Smb2FileType.directory, 0),
('.', Smb2FileType.directory, 0),
])
Smb2DirEntry(
name: name,
stat: Smb2Stat(
type: type,
size: size,
modified: date,
created: date,
),
),
],
);
final result = await NetworkStorageService.prepareSmbEntries(
input.files,
input.path,
);
expect(smbListingRows(result), smbListingRows(legacySmbListing(input)));
expect(result.first.path, '100% + #1/日本語/SAME/');
expect(result.where((entry) => entry.directory), hasLength(1));
expect(result.singleWhere((entry) => entry.size == 3).directory, isFalse);
expect(result.any((entry) => entry.size == 9007199254740991), isTrue);
},
);
}
+65
View File
@@ -0,0 +1,65 @@
import 'dart:math';
import 'package:dart_smb2/dart_smb2.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
typedef SmbListingInput = ({List<Smb2DirEntry> files, String path});
// Frozen adapter and comparator from NetworkStorageService.list/_sorted.
List<NetworkEntry> legacySmbListing(SmbListingInput input) =>
[
for (final file in input.files)
if (file.name != '.' && file.name != '..')
NetworkEntry(
'${input.path}${file.name}${file.stat.isDirectory ? '/' : ''}',
file.name,
directory: file.stat.isDirectory,
size: file.stat.size,
),
]..sort(
(a, b) => a.directory != b.directory
? (a.directory ? -1 : 1)
: a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
SmbListingInput smbListingFixture(int count) {
const names = [
'Track',
'track',
'Ångström',
'İstanbul',
'音楽 🎵',
'Κόσμος',
'ß',
];
final random = Random(8127);
final files = List.generate(count, (index) {
final directory = index % 7 == 0;
return Smb2DirEntry(
name: switch (index) {
0 => '.',
1 => '..',
_ =>
'${names[index % names.length]} — ${index % 400 == 3 ? 'SAME' : index.toString()} Album with spaces #1${directory ? '' : '.flac'}',
},
stat: Smb2Stat(
type: directory ? Smb2FileType.directory : Smb2FileType.file,
size: 1000000000 + index,
modified: DateTime.fromMillisecondsSinceEpoch(
1700000000000 + index,
isUtc: true,
),
created: DateTime.fromMillisecondsSinceEpoch(
1600000000000 + index,
isUtc: true,
),
),
);
})..shuffle(random);
return (files: files, path: 'Folder #1/');
}
List<Object?> smbListingRows(List<NetworkEntry> entries) => [
for (final entry in entries)
[entry.path, entry.name, entry.directory, entry.size],
];