mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 08:10:55 +00:00
fix(macos): release closed child window engines (#1639)
This commit is contained in:
3 files changed
+120
-1
No files matched your search
@@ -52,6 +52,7 @@ class AppWindowCapabilityHost {
|
||||
CapabilityRegistry? _registry;
|
||||
AppWindowAppearance _appearance = const AppWindowAppearance.defaults();
|
||||
final Map<String, WindowController> _subscribers = {};
|
||||
StreamSubscription<void>? _windowsChanged;
|
||||
|
||||
bool get started => _registry != null;
|
||||
|
||||
@@ -59,14 +60,33 @@ class AppWindowCapabilityHost {
|
||||
if (_registry != null) return;
|
||||
_registry = registry;
|
||||
await _hostChannel.setMethodCallHandler(_handleCall);
|
||||
_windowsChanged = onWindowsChanged.listen((_) => unawaited(_removeClosedSubscribers()));
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_registry = null;
|
||||
await _windowsChanged?.cancel();
|
||||
_windowsChanged = null;
|
||||
_subscribers.clear();
|
||||
await _hostChannel.setMethodCallHandler(null);
|
||||
}
|
||||
|
||||
Future<void> _removeClosedSubscribers() async {
|
||||
// Engine shutdown does not run Widget.dispose, so a closed child cannot
|
||||
// always send window.unsubscribe itself.
|
||||
final registry = _registry;
|
||||
final candidates = _subscribers.keys.toSet();
|
||||
if (registry == null || candidates.isEmpty) return;
|
||||
try {
|
||||
final windows = await WindowController.getAll();
|
||||
if (!identical(registry, _registry)) return;
|
||||
final openIds = windows.map((window) => window.windowId).toSet();
|
||||
_subscribers.removeWhere((id, _) => candidates.contains(id) && !openIds.contains(id));
|
||||
} catch (_) {
|
||||
// The native window registry can already be gone during app shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
void updateAppearance(AppWindowAppearance appearance) {
|
||||
if (_appearance == appearance) return;
|
||||
_appearance = appearance;
|
||||
|
||||
@@ -4,6 +4,8 @@ import desktop_multi_window
|
||||
import window_manager
|
||||
|
||||
class MainFlutterWindow: NSWindow {
|
||||
private var childWindowCloseObservers: [ObjectIdentifier: NSObjectProtocol] = [:]
|
||||
|
||||
override func awakeFromNib() {
|
||||
let flutterViewController = FlutterViewController()
|
||||
let windowFrame = self.frame
|
||||
@@ -11,13 +13,45 @@ class MainFlutterWindow: NSWindow {
|
||||
self.setFrame(windowFrame, display: true)
|
||||
|
||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||
FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in
|
||||
FlutterMultiWindowPlugin.setOnWindowCreatedCallback { [weak self] controller in
|
||||
RegisterGeneratedPlugins(registry: controller)
|
||||
self?.releaseChildEngineOnClose(controller)
|
||||
}
|
||||
|
||||
super.awakeFromNib()
|
||||
}
|
||||
|
||||
// desktop_multi_window removes closed windows from its registry, but plugin
|
||||
// channels can still retain the window/controller/engine cycle. NSWindow.close
|
||||
// alone therefore leaves each child Dart isolate and its heap running.
|
||||
private func releaseChildEngineOnClose(_ controller: FlutterViewController) {
|
||||
guard let window = controller.view.window else { return }
|
||||
let key = ObjectIdentifier(window)
|
||||
childWindowCloseObservers[key] = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.willCloseNotification, object: window, queue: .main
|
||||
) { [weak self, weak controller, weak window] _ in
|
||||
if let observer = self?.childWindowCloseObservers.removeValue(forKey: key) {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
// Let close notifications and the plugin's registry removal finish before
|
||||
// tearing down the engine that delivered the close request.
|
||||
DispatchQueue.main.async { [weak controller, weak window] in
|
||||
// window_manager's onEvent closure retains its plugin, which retains
|
||||
// the manager and NSWindow even after the engine has stopped.
|
||||
(window?.delegate as? WindowManager)?.onEvent = nil
|
||||
window?.delegate = nil
|
||||
controller?.engine.shutDownEngine()
|
||||
window?.contentViewController = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
for observer in childWindowCloseObservers.values {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) {
|
||||
super.order(place, relativeTo: otherWin)
|
||||
hiddenWindowAtLaunch()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/window/app_window_appearance.dart';
|
||||
import 'package:gopeed/core/window/window_capability_transport.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const windows = MethodChannel('mixin.one/desktop_multi_window');
|
||||
const channels = MethodChannel('mixin.one/desktop_multi_window/channels');
|
||||
const codec = StandardMethodCodec();
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
tearDown(() async {
|
||||
await AppWindowCapabilityHost.instance.stop();
|
||||
messenger.setMockMethodCallHandler(windows, null);
|
||||
messenger.setMockMethodCallHandler(channels, null);
|
||||
});
|
||||
|
||||
testWidgets('closed engines lose appearance subscriptions without a Dart unsubscribe', (tester) async {
|
||||
final deliveries = <String>[];
|
||||
messenger.setMockMethodCallHandler(channels, (call) async {
|
||||
if (call.method == 'invokeMethod') {
|
||||
final args = Map<String, dynamic>.from(call.arguments as Map);
|
||||
if (args['method'] == AppWindowRpcProtocol.event) deliveries.add(args['channel'] as String);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
messenger.setMockMethodCallHandler(windows, (call) async {
|
||||
if (call.method == 'getAllWindows') {
|
||||
return [
|
||||
{'windowId': 'live-child', 'windowArgument': ''},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
final host = AppWindowCapabilityHost.instance;
|
||||
await host.start(CapabilityRegistry(createAppCapabilityCodecs()));
|
||||
for (final id in ['closed-child', 'live-child']) {
|
||||
await messenger.handlePlatformMessage(
|
||||
channels.name,
|
||||
codec.encodeMethodCall(
|
||||
MethodCall('methodCall', {
|
||||
'channel': AppWindowRpcProtocol.channelName,
|
||||
'method': AppWindowRpcProtocol.subscribe,
|
||||
'arguments': {'windowId': id},
|
||||
}),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
}
|
||||
await tester.pump();
|
||||
deliveries.clear();
|
||||
await messenger.handlePlatformMessage(
|
||||
windows.name,
|
||||
codec.encodeMethodCall(const MethodCall('onWindowsChanged')),
|
||||
(_) {},
|
||||
);
|
||||
await tester.pump();
|
||||
host.updateAppearance(const AppWindowAppearance(themeMode: 'dark', themeColor: 'green', locale: 'zh'));
|
||||
await tester.pump();
|
||||
expect(deliveries, ['mixin.one/window_controller/live-child']);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user