mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00

This change enables Flutter to generate multiple Scenes to be rendered into separate FlutterViews from a single widget tree. Each Scene is described by a separate render tree, which are all associated with the single widget tree. This PR implements the framework-side mechanisms to describe the content to be rendered into multiple views. Separate engine-side changes are necessary to provide these views to the framework and to draw the framework-generated Scene into them. ## Summary of changes The details of this change are described in [flutter.dev/go/multiple-views](https://flutter.dev/go/multiple-views). Below is a high-level summary organized by layers. ### Rendering layer changes * The `RendererBinding` no longer owns a single `renderView`. In fact, it doesn't OWN any `RenderView`s at all anymore. Instead, it offers an API (`addRenderView`/`removeRenderView`) to add and remove `RenderView`s that then will be MANAGED by the binding. The `RenderView` itself is now owned by a higher-level abstraction (e.g. the `RawView` Element of the widgets layer, see below), who is also in charge of adding it to the binding. When added, the binding will interact with the `RenderView` to produce a frame (e.g. by calling `compositeFrame` on it) and to perform hit tests for incoming pointer events. Multiple `RenderView`s can be added to the binding (typically one per `FlutterView`) to produce multiple Scenes. * Instead of owning a single `pipelineOwner`, the `RendererBinding` now owns the root of the `PipelineOwner` tree (exposed as `rootPipelineOwner` on the binding). Each `PipelineOwner` in that tree (except for the root) typically manages its own render tree typically rooted in one of the `RenderView`s mentioned in the previous bullet. During frame production, the binding will instruct each `PipelineOwner` of that tree to flush layout, paint, semantics etc. A higher-level abstraction (e.g. the widgets layer, see below) is in charge of adding `PipelineOwner`s to this tree. * Backwards compatibility: The old `renderView` and `pipelineOwner` properties of the `RendererBinding` are retained, but marked as deprecated. Care has been taken to keep their original behavior for the deprecation period, i.e. if you just call `runApp`, the render tree bootstrapped by this call is rooted in the deprecated `RendererBinding.renderView` and managed by the deprecated `RendererBinding.pipelineOwner`. ### Widgets layer changes * The `WidgetsBinding` no longer attaches the widget tree to an existing render tree. Instead, it bootstraps a stand-alone widget tree that is not backed by a render tree. For this, `RenderObjectToWidgetAdapter` has been replaced by `RootWidget`. * Multiple render trees can be bootstrapped and attached to the widget tree with the help of the `View` widget, which internally is backed by a `RawView` widget. Configured with a `FlutterView` to render into, the `RawView` creates a new `PipelineOwner` and a new `RenderView` for the new render tree. It adds the new `RenderView` to the `RendererBinding` and its `PipelineOwner` to the pipeline owner tree. * The `View` widget can only appear in certain well-defined locations in the widget tree since it bootstraps a new render tree and does not insert a `RenderObject` into an ancestor. However, almost all Elements expect that their children insert `RenderObject`s, otherwise they will not function properly. To produce a good error message when the `View` widget is used in an illegal location, the `debugMustInsertRenderObjectIntoSlot` method has been added to Element, where a child can ask whether a given slot must insert a RenderObject into its ancestor or not. In practice, the `View` widget can be used as a child of the `RootWidget`, inside the `view` slot of the `ViewAnchor` (see below) and inside a `ViewCollection` (see below). In those locations, the `View` widget may be wrapped in other non-RenderObjectWidgets (e.g. InheritedWidgets). * The new `ViewAnchor` can be used to create a side-view inside a parent `View`. The `child` of the `ViewAnchor` widget renders into the parent `View` as usual, but the `view` slot can take on another `View` widget, which has access to all inherited widgets above the `ViewAnchor`. Metaphorically speaking, the view is anchored to the location of the `ViewAnchor` in the widget tree. * The new `ViewCollection` widget allows for multiple sibling views as it takes a list of `View`s as children. It can be used in all the places that accept a `View` widget. ## Google3 As of July 5, 2023 this change passed a TAP global presubmit (TGP) in google3: tap/OCL:544707016:BASE:545809771:1688597935864:e43dd651 ## Note to reviewers This change is big (sorry). I suggest focusing the initial review on the changes inside of `packages/flutter` first. The majority of the changes describe above are implemented in (listed in suggested review order): * `rendering/binding.dart` * `widgets/binding.dart` * `widgets/view.dart` * `widgets/framework.dart` All other changes included in the PR are basically the fallout of what's implemented in those files. Also note that a lot of the lines added in this PR are documentation and tests. I am also very happy to walk reviewers through the code in person or via video call, if that is helpful. I appreciate any feedback. ## Feedback to address before submitting ("TODO")
496 lines
18 KiB
Dart
496 lines
18 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
import 'dart:async';
|
|
import 'dart:developer' as developer;
|
|
import 'dart:io' show HttpClient, SocketException, WebSocket;
|
|
import 'dart:ui';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:vm_service/vm_service.dart' as vm;
|
|
|
|
import '_callback_io.dart' if (dart.library.html) '_callback_web.dart' as driver_actions;
|
|
import '_extension_io.dart' if (dart.library.html) '_extension_web.dart';
|
|
import 'common.dart';
|
|
import 'src/channel.dart';
|
|
|
|
const String _success = 'success';
|
|
|
|
/// Whether results should be reported to the native side over the method
|
|
/// channel.
|
|
///
|
|
/// This is enabled by default for use by native test frameworks like Android
|
|
/// instrumentation or XCTest. When running with the Flutter Tool through
|
|
/// `flutter test integration_test` though, it will be disabled as the Flutter
|
|
/// tool will be responsible for collection of test results.
|
|
const bool _shouldReportResultsToNative = bool.fromEnvironment(
|
|
'INTEGRATION_TEST_SHOULD_REPORT_RESULTS_TO_NATIVE',
|
|
defaultValue: true,
|
|
);
|
|
|
|
/// A subclass of [LiveTestWidgetsFlutterBinding] that reports tests results
|
|
/// on a channel to adapt them to native instrumentation test format.
|
|
class IntegrationTestWidgetsFlutterBinding extends LiveTestWidgetsFlutterBinding implements IntegrationTestResults {
|
|
/// Sets up a listener to report that the tests are finished when everything is
|
|
/// torn down.
|
|
IntegrationTestWidgetsFlutterBinding() {
|
|
tearDownAll(() async {
|
|
if (!_allTestsPassed.isCompleted) {
|
|
_allTestsPassed.complete(failureMethodsDetails.isEmpty);
|
|
}
|
|
callbackManager.cleanup();
|
|
|
|
// TODO(jiahaog): Print the message directing users to run with
|
|
// `flutter test` when Web is supported.
|
|
if (!_shouldReportResultsToNative || kIsWeb) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await integrationTestChannel.invokeMethod<void>(
|
|
'allTestsFinished',
|
|
<String, dynamic>{
|
|
'results': results.map<String, dynamic>((String name, Object result) {
|
|
if (result is Failure) {
|
|
return MapEntry<String, dynamic>(name, result.details);
|
|
}
|
|
return MapEntry<String, Object>(name, result);
|
|
}),
|
|
},
|
|
);
|
|
} on MissingPluginException {
|
|
debugPrint(r'''
|
|
Warning: integration_test plugin was not detected.
|
|
|
|
If you're running the tests with `flutter drive`, please make sure your tests
|
|
are in the `integration_test/` directory of your package and use
|
|
`flutter test $path_to_test` to run it instead.
|
|
|
|
If you're running the tests with Android instrumentation or XCTest, this means
|
|
that you are not capturing test results properly! See the following link for
|
|
how to set up the integration_test plugin:
|
|
|
|
https://flutter.dev/docs/testing/integration-tests#testing-on-firebase-test-lab
|
|
''');
|
|
}
|
|
});
|
|
|
|
final TestExceptionReporter oldTestExceptionReporter = reportTestException;
|
|
reportTestException =
|
|
(FlutterErrorDetails details, String testDescription) {
|
|
results[testDescription] = Failure(testDescription, details.toString());
|
|
oldTestExceptionReporter(details, testDescription);
|
|
};
|
|
}
|
|
|
|
@override
|
|
bool get overrideHttpClient => false;
|
|
|
|
@override
|
|
bool get registerTestTextInput => false;
|
|
|
|
Size? _surfaceSize;
|
|
|
|
// This flag is used to print warning messages when tracking performance
|
|
// under debug mode.
|
|
static bool _firstRun = false;
|
|
|
|
@override
|
|
Future<void> setSurfaceSize(Size? size) {
|
|
return TestAsyncUtils.guard<void>(() async {
|
|
assert(inTest);
|
|
if (_surfaceSize == size) {
|
|
return;
|
|
}
|
|
_surfaceSize = size;
|
|
handleMetricsChanged();
|
|
});
|
|
}
|
|
|
|
@override
|
|
ViewConfiguration createViewConfigurationFor(RenderView renderView) {
|
|
final FlutterView view = renderView.flutterView;
|
|
final Size? surfaceSize = view == platformDispatcher.implicitView ? _surfaceSize : null;
|
|
return TestViewConfiguration.fromView(
|
|
size: surfaceSize ?? view.physicalSize / view.devicePixelRatio,
|
|
view: view,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Completer<bool> get allTestsPassed => _allTestsPassed;
|
|
final Completer<bool> _allTestsPassed = Completer<bool>();
|
|
|
|
@override
|
|
List<Failure> get failureMethodsDetails => results.values.whereType<Failure>().toList();
|
|
|
|
@override
|
|
void initInstances() {
|
|
super.initInstances();
|
|
_instance = this;
|
|
}
|
|
|
|
/// The singleton instance of this object.
|
|
///
|
|
/// Provides access to the features exposed by this class. The binding must
|
|
/// be initialized before using this getter; this is typically done by calling
|
|
/// [IntegrationTestWidgetsFlutterBinding.ensureInitialized].
|
|
static IntegrationTestWidgetsFlutterBinding get instance => BindingBase.checkInstance(_instance);
|
|
static IntegrationTestWidgetsFlutterBinding? _instance;
|
|
|
|
/// Returns an instance of the [IntegrationTestWidgetsFlutterBinding], creating and
|
|
/// initializing it if necessary.
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [WidgetsFlutterBinding.ensureInitialized], the equivalent in the widgets framework.
|
|
static IntegrationTestWidgetsFlutterBinding ensureInitialized() {
|
|
if (_instance == null) {
|
|
IntegrationTestWidgetsFlutterBinding();
|
|
}
|
|
return _instance!;
|
|
}
|
|
|
|
/// Test results that will be populated after the tests have completed.
|
|
///
|
|
/// Keys are the test descriptions, and values are either [_success] or
|
|
/// a [Failure].
|
|
@visibleForTesting
|
|
Map<String, Object> results = <String, Object>{};
|
|
|
|
/// The extra data for the reported result.
|
|
///
|
|
/// The values in `reportData` must be json-serializable objects or `null`.
|
|
/// If it's `null`, no extra data is attached to the result.
|
|
///
|
|
/// The default value is `null`.
|
|
@override
|
|
Map<String, dynamic>? reportData;
|
|
|
|
/// Manages callbacks received from driver side and commands send to driver
|
|
/// side.
|
|
final CallbackManager callbackManager = driver_actions.callbackManager;
|
|
|
|
/// Takes a screenshot.
|
|
///
|
|
/// On Android, you need to call `convertFlutterSurfaceToImage()`, and
|
|
/// pump a frame before taking a screenshot.
|
|
Future<List<int>> takeScreenshot(String screenshotName, [Map<String, Object?>? args]) async {
|
|
reportData ??= <String, dynamic>{};
|
|
reportData!['screenshots'] ??= <dynamic>[];
|
|
final Map<String, dynamic> data = await callbackManager.takeScreenshot(screenshotName, args);
|
|
assert(data.containsKey('bytes'));
|
|
|
|
(reportData!['screenshots']! as List<dynamic>).add(data);
|
|
return data['bytes']! as List<int>;
|
|
}
|
|
|
|
/// Android only. Converts the Flutter surface to an image view.
|
|
/// Be aware that if you are conducting a perf test, you may not want to call
|
|
/// this method since the this is an expensive operation that affects the
|
|
/// rendering of a Flutter app.
|
|
///
|
|
/// Once the screenshot is taken, call `revertFlutterImage()` to restore
|
|
/// the original Flutter surface.
|
|
Future<void> convertFlutterSurfaceToImage() async {
|
|
await callbackManager.convertFlutterSurfaceToImage();
|
|
}
|
|
|
|
/// The callback function to response the driver side input.
|
|
@visibleForTesting
|
|
Future<Map<String, dynamic>> callback(Map<String, String> params) async {
|
|
return callbackManager.callback(
|
|
params, this /* as IntegrationTestResults */);
|
|
}
|
|
|
|
// Emulates the Flutter driver extension, returning 'pass' or 'fail'.
|
|
@override
|
|
void initServiceExtensions() {
|
|
super.initServiceExtensions();
|
|
|
|
if (kIsWeb) {
|
|
registerWebServiceExtension(callback);
|
|
}
|
|
|
|
registerServiceExtension(name: 'driver', callback: callback);
|
|
}
|
|
|
|
@override
|
|
Future<void> runTest(
|
|
Future<void> Function() testBody,
|
|
VoidCallback invariantTester, {
|
|
String description = '',
|
|
@Deprecated(
|
|
'This parameter has no effect. Use the `timeout` parameter on `testWidgets` instead. '
|
|
'This feature was deprecated after v2.6.0-1.0.pre.'
|
|
)
|
|
Duration? timeout,
|
|
}) async {
|
|
await super.runTest(
|
|
testBody,
|
|
invariantTester,
|
|
description: description,
|
|
);
|
|
results[description] ??= _success;
|
|
}
|
|
|
|
vm.VmService? _vmService;
|
|
|
|
/// Initialize the [vm.VmService] settings for the timeline.
|
|
@visibleForTesting
|
|
Future<void> enableTimeline({
|
|
List<String> streams = const <String>['all'],
|
|
@visibleForTesting vm.VmService? vmService,
|
|
@visibleForTesting HttpClient? httpClient,
|
|
}) async {
|
|
assert(streams.isNotEmpty);
|
|
if (vmService != null) {
|
|
_vmService = vmService;
|
|
}
|
|
if (_vmService == null) {
|
|
final developer.ServiceProtocolInfo info = await developer.Service.getInfo();
|
|
assert(info.serverUri != null);
|
|
final String address = 'ws://localhost:${info.serverUri!.port}${info.serverUri!.path}ws';
|
|
try {
|
|
_vmService = await _vmServiceConnectUri(address, httpClient: httpClient);
|
|
} on SocketException catch (e, s) {
|
|
throw StateError(
|
|
'Failed to connect to VM Service at $address.\n'
|
|
'This may happen if DDS is enabled. If this test was launched via '
|
|
'`flutter drive`, try adding `--no-dds`.\n'
|
|
'The original exception was:\n'
|
|
'$e\n$s',
|
|
);
|
|
}
|
|
}
|
|
await _vmService!.setVMTimelineFlags(streams);
|
|
}
|
|
|
|
/// Runs [action] and returns a [vm.Timeline] trace for it.
|
|
///
|
|
/// Waits for the `Future` returned by [action] to complete prior to stopping
|
|
/// the trace.
|
|
///
|
|
/// The `streams` parameter limits the recorded timeline event streams to only
|
|
/// the ones listed. By default, all streams are recorded.
|
|
/// See `timeline_streams` in
|
|
/// [Dart-SDK/runtime/vm/timeline.cc](https://github.com/dart-lang/sdk/blob/main/runtime/vm/timeline.cc)
|
|
///
|
|
/// If [retainPriorEvents] is true, retains events recorded prior to calling
|
|
/// [action]. Otherwise, prior events are cleared before calling [action]. By
|
|
/// default, prior events are cleared.
|
|
Future<vm.Timeline> traceTimeline(
|
|
Future<dynamic> Function() action, {
|
|
List<String> streams = const <String>['all'],
|
|
bool retainPriorEvents = false,
|
|
}) async {
|
|
await enableTimeline(streams: streams);
|
|
if (retainPriorEvents) {
|
|
await action();
|
|
return _vmService!.getVMTimeline();
|
|
}
|
|
|
|
await _vmService!.clearVMTimeline();
|
|
final vm.Timestamp startTime = await _vmService!.getVMTimelineMicros();
|
|
await action();
|
|
final vm.Timestamp endTime = await _vmService!.getVMTimelineMicros();
|
|
return _vmService!.getVMTimeline(
|
|
timeOriginMicros: startTime.timestamp,
|
|
timeExtentMicros: endTime.timestamp,
|
|
);
|
|
}
|
|
|
|
/// This is a convenience method that calls [traceTimeline] and sends the
|
|
/// result back to the host for the [flutter_driver] style tests.
|
|
///
|
|
/// This records the timeline during `action` and adds the result to
|
|
/// [reportData] with `reportKey`. The [reportData] contains extra information
|
|
/// from the test other than test success/fail. It will be passed back to the
|
|
/// host and be processed by the [ResponseDataCallback] defined in
|
|
/// [integration_test_driver.integrationDriver]. By default it will be written
|
|
/// to `build/integration_response_data.json` with the key `timeline`.
|
|
///
|
|
/// For tests with multiple calls of this method, `reportKey` needs to be a
|
|
/// unique key, otherwise the later result will override earlier one. Tests
|
|
/// that call this multiple times must also provide a custom
|
|
/// [ResponseDataCallback] to decide where and how to write the output
|
|
/// timelines. For example,
|
|
///
|
|
/// ```dart
|
|
/// import 'package:integration_test/integration_test_driver.dart';
|
|
///
|
|
/// Future<void> main() {
|
|
/// return integrationDriver(
|
|
/// responseDataCallback: (data) async {
|
|
/// if (data != null) {
|
|
/// for (var entry in data.entries) {
|
|
/// print('Writing ${entry.key} to the disk.');
|
|
/// await writeResponseData(
|
|
/// entry.value as Map<String, dynamic>,
|
|
/// testOutputFilename: entry.key,
|
|
/// );
|
|
/// }
|
|
/// }
|
|
/// },
|
|
/// );
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// The `streams` and `retainPriorEvents` parameters are passed as-is to
|
|
/// [traceTimeline].
|
|
Future<void> traceAction(
|
|
Future<dynamic> Function() action, {
|
|
List<String> streams = const <String>['all'],
|
|
bool retainPriorEvents = false,
|
|
String reportKey = 'timeline',
|
|
}) async {
|
|
final vm.Timeline timeline = await traceTimeline(
|
|
action,
|
|
streams: streams,
|
|
retainPriorEvents: retainPriorEvents,
|
|
);
|
|
reportData ??= <String, dynamic>{};
|
|
reportData![reportKey] = timeline.toJson();
|
|
}
|
|
|
|
Future<_GarbageCollectionInfo> _runAndGetGCInfo(Future<void> Function() action) async {
|
|
if (kIsWeb) {
|
|
await action();
|
|
return const _GarbageCollectionInfo();
|
|
}
|
|
|
|
final vm.Timeline timeline = await traceTimeline(
|
|
action,
|
|
streams: <String>['GC'],
|
|
);
|
|
|
|
final int oldGenGCCount = timeline.traceEvents!.where((vm.TimelineEvent event) {
|
|
return event.json!['cat'] == 'GC' && event.json!['name'] == 'CollectOldGeneration';
|
|
}).length;
|
|
final int newGenGCCount = timeline.traceEvents!.where((vm.TimelineEvent event) {
|
|
return event.json!['cat'] == 'GC' && event.json!['name'] == 'CollectNewGeneration';
|
|
}).length;
|
|
return _GarbageCollectionInfo(
|
|
oldCount: oldGenGCCount,
|
|
newCount: newGenGCCount,
|
|
);
|
|
}
|
|
|
|
/// Watches the [FrameTiming] during `action` and report it to the binding
|
|
/// with key `reportKey`.
|
|
///
|
|
/// This can be used to implement performance tests previously using
|
|
/// [traceAction] and [TimelineSummary] from [flutter_driver]
|
|
Future<void> watchPerformance(
|
|
Future<void> Function() action, {
|
|
String reportKey = 'performance',
|
|
}) async {
|
|
assert(() {
|
|
if (_firstRun) {
|
|
debugPrint(kDebugWarning);
|
|
_firstRun = false;
|
|
}
|
|
return true;
|
|
}());
|
|
|
|
// The engine could batch FrameTimings and send them only once per second.
|
|
// Delay for a sufficient time so either old FrameTimings are flushed and not
|
|
// interfering our measurements here, or new FrameTimings are all reported.
|
|
// TODO(CareF): remove this when flush FrameTiming is readily in engine.
|
|
// See https://github.com/flutter/flutter/issues/64808
|
|
// and https://github.com/flutter/flutter/issues/67593
|
|
final List<FrameTiming> frameTimings = <FrameTiming>[];
|
|
Future<void> delayForFrameTimings() async {
|
|
int count = 0;
|
|
while (frameTimings.isEmpty) {
|
|
count++;
|
|
await Future<void>.delayed(const Duration(seconds: 2));
|
|
if (count > 20) {
|
|
debugPrint('delayForFrameTimings is taking longer than expected...');
|
|
}
|
|
}
|
|
}
|
|
|
|
await Future<void>.delayed(const Duration(seconds: 2)); // flush old FrameTimings
|
|
final TimingsCallback watcher = frameTimings.addAll;
|
|
addTimingsCallback(watcher);
|
|
final _GarbageCollectionInfo gcInfo = await _runAndGetGCInfo(action);
|
|
|
|
await delayForFrameTimings(); // make sure all FrameTimings are reported
|
|
removeTimingsCallback(watcher);
|
|
|
|
final FrameTimingSummarizer frameTimes = FrameTimingSummarizer(
|
|
frameTimings,
|
|
newGenGCCount: gcInfo.newCount,
|
|
oldGenGCCount: gcInfo.oldCount,
|
|
);
|
|
reportData ??= <String, dynamic>{};
|
|
reportData![reportKey] = frameTimes.summary;
|
|
}
|
|
|
|
@override
|
|
Timeout defaultTestTimeout = Timeout.none;
|
|
|
|
@override
|
|
Widget wrapWithDefaultView(Widget rootWidget) {
|
|
// This is a workaround where screenshots of root widgets have incorrect
|
|
// bounds.
|
|
// TODO(jiahaog): Remove when https://github.com/flutter/flutter/issues/66006 is fixed.
|
|
return super.wrapWithDefaultView(RepaintBoundary(child: rootWidget));
|
|
}
|
|
|
|
@override
|
|
void reportExceptionNoticed(FlutterErrorDetails exception) {
|
|
// This method is called to log errors as they happen, and they will also
|
|
// be eventually logged again at the end of the tests. The superclass
|
|
// behavior is specific to the "live" execution semantics of
|
|
// [LiveTestWidgetsFlutterBinding] so users don't have to wait until tests
|
|
// finish to see the stack traces.
|
|
//
|
|
// Disable this because Integration Tests follow the semantics of
|
|
// [AutomatedTestWidgetsFlutterBinding] that does not log the stack traces
|
|
// live, and avoids the doubly logged stack trace.
|
|
// TODO(jiahaog): Integration test binding should not inherit from
|
|
// `LiveTestWidgetsFlutterBinding` https://github.com/flutter/flutter/issues/81534
|
|
}
|
|
}
|
|
|
|
@immutable
|
|
class _GarbageCollectionInfo {
|
|
const _GarbageCollectionInfo({this.oldCount = -1, this.newCount = -1});
|
|
|
|
final int oldCount;
|
|
final int newCount;
|
|
}
|
|
|
|
// Connect to the given uri and return a new [VmService] instance.
|
|
//
|
|
// Copied from vm_service_io so that we can pass a custom [HttpClient] for
|
|
// testing. Currently, the WebSocket API reuses an HttpClient that
|
|
// is created before the test can change the HttpOverrides.
|
|
Future<vm.VmService> _vmServiceConnectUri(
|
|
String wsUri, {
|
|
HttpClient? httpClient,
|
|
}) async {
|
|
final WebSocket socket = await WebSocket.connect(wsUri, customClient: httpClient);
|
|
final StreamController<dynamic> controller = StreamController<dynamic>();
|
|
final Completer<void> streamClosedCompleter = Completer<void>();
|
|
|
|
socket.listen(
|
|
(dynamic data) => controller.add(data),
|
|
onDone: () => streamClosedCompleter.complete(),
|
|
);
|
|
|
|
return vm.VmService(
|
|
controller.stream,
|
|
(String message) => socket.add(message),
|
|
disposeHandler: () => socket.close(),
|
|
streamClosed: streamClosedCompleter.future,
|
|
);
|
|
}
|