Skip to content

Commit 51c6bfd

Browse files
authored
[Property Editor] Debounce calls to the Analysis Server for editableArguments (#9017)
1 parent 8e405f3 commit 51c6bfd

6 files changed

Lines changed: 151 additions & 41 deletions

File tree

packages/devtools_app/lib/src/framework/observer/memory_observer.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class MemoryObserver extends DisposableController {
4747

4848
static const _memoryPressureLimitGb = 3.0;
4949

50-
DebounceTimer? _timer;
50+
PeriodicDebouncer? _timer;
5151

5252
/// Tracks the most recent memory usage measurement.
5353
///
@@ -58,7 +58,7 @@ class MemoryObserver extends DisposableController {
5858
@override
5959
void init() {
6060
super.init();
61-
_timer = DebounceTimer.periodic(_pollingDuration, _pollForMemoryUsage);
61+
_timer = PeriodicDebouncer.run(_pollingDuration, _pollForMemoryUsage);
6262
}
6363

6464
@override

packages/devtools_app/lib/src/screens/memory/panes/chart/controller/chart_connection.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class ChartVmConnection extends DisposableController
3636

3737
bool initialized = false;
3838

39-
DebounceTimer? _polling;
39+
PeriodicDebouncer? _polling;
4040

4141
late final bool isDeviceAndroid;
4242

@@ -79,7 +79,7 @@ class ChartVmConnection extends DisposableController
7979
),
8080
);
8181

82-
_polling = DebounceTimer.periodic(chartUpdateDelay, ({
82+
_polling = PeriodicDebouncer.run(chartUpdateDelay, ({
8383
DebounceCancelledCallback? cancelledCallback,
8484
}) async {
8585
if (!_isConnected) {

packages/devtools_app/lib/src/screens/network/network_controller.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ class NetworkController extends DevToolsScreenController
165165
/// This timestamp is on the monotonic clock used by the timeline.
166166
int lastSocketDataRefreshMicros = 0;
167167

168-
DebounceTimer? _pollingTimer;
168+
PeriodicDebouncer? _pollingTimer;
169169

170170
@visibleForTesting
171171
bool get isPolling => _pollingTimer != null;
@@ -277,7 +277,7 @@ class NetworkController extends DevToolsScreenController
277277

278278
void _updatePollingState(bool recording) {
279279
if (recording) {
280-
_pollingTimer ??= DebounceTimer.periodic(
280+
_pollingTimer ??= PeriodicDebouncer.run(
281281
// TODO(kenz): look into improving performance by caching more data.
282282
// Polling less frequently helps performance.
283283
_pollingDuration,

packages/devtools_app/lib/src/shared/utils/utils.dart

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,17 +185,43 @@ extension IsKeyType on KeyEvent {
185185
bool get isKeyDownOrRepeat => this is KeyDownEvent || this is KeyRepeatEvent;
186186
}
187187

188+
/// A helper class for [Timer] functionality, where the callbacks are debounced.
189+
class Debouncer extends Disposable {
190+
Debouncer({required this.duration});
191+
192+
final Duration duration;
193+
Timer? _activeTimer;
194+
195+
/// Invokes the [callback] once after the specified [duration] has elapsed.
196+
///
197+
/// If multiple invokations are called while the timer is running, only the
198+
/// last one will be called once no more invokations happen within the given
199+
/// [duration].
200+
void run(void Function() callback) {
201+
if (disposed) return;
202+
_activeTimer?.cancel();
203+
_activeTimer = Timer(duration, callback);
204+
}
205+
206+
@override
207+
void dispose() {
208+
_activeTimer?.cancel();
209+
_activeTimer = null;
210+
super.dispose();
211+
}
212+
}
213+
188214
typedef DebounceCancelledCallback = bool Function();
189215

190-
/// A helper class for [Timer] functionality, where the callbacks are debounced.
191-
class DebounceTimer {
192-
/// A periodic timer that ensures [callback] is only called at most once
193-
/// per [duration].
216+
/// A periodic debouncer that calls the given [callback] at most once per
217+
/// [duration].
218+
class PeriodicDebouncer {
219+
/// Start running the periodic debouncer.
194220
///
195221
/// [callback] is triggered once immediately, and then every [duration] the
196222
/// timer checks to see if the previous [callback] call has finished running.
197223
/// If it has finished, then then next call to [callback] will begin.
198-
DebounceTimer.periodic(
224+
PeriodicDebouncer.run(
199225
Duration duration,
200226
Future<void> Function({DebounceCancelledCallback? cancelledCallback})
201227
callback,

packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import '../../../shared/analytics/analytics.dart' as ga;
99
import '../../../shared/analytics/constants.dart' as gac;
1010
import '../../../shared/editor/api_classes.dart';
1111
import '../../../shared/editor/editor_client.dart';
12+
import '../../../shared/utils/utils.dart';
1213

1314
typedef EditableWidgetData =
1415
({List<EditableArgument> args, String? name, String? documentation});
@@ -22,7 +23,7 @@ typedef EditArgumentFunction =
2223
class PropertyEditorController extends DisposableController
2324
with AutoDisposeControllerMixin {
2425
PropertyEditorController(this.editorClient) {
25-
_init();
26+
init();
2627
}
2728

2829
final EditorClient editorClient;
@@ -36,7 +37,15 @@ class PropertyEditorController extends DisposableController
3637
_editableWidgetData;
3738
final _editableWidgetData = ValueNotifier<EditableWidgetData?>(null);
3839

39-
void _init() {
40+
late final Debouncer _editableArgsDebouncer;
41+
42+
static const _editableArgsDebounceDuration = Duration(milliseconds: 600);
43+
44+
@override
45+
void init() {
46+
super.init();
47+
_editableArgsDebouncer = Debouncer(duration: _editableArgsDebounceDuration);
48+
4049
autoDisposeStreamSubscription(
4150
editorClient.activeLocationChangedStream.listen((event) async {
4251
final textDocument = event.textDocument;
@@ -45,34 +54,33 @@ class PropertyEditorController extends DisposableController
4554
if (textDocument == null) {
4655
return;
4756
}
48-
// Don't do anything if the event corresponds to the current position.
49-
if (textDocument == _currentDocument &&
57+
// Don't do anything if the event corresponds to the current position
58+
// and document version.
59+
//
60+
// Note: This is only checked if the document version is not null. For
61+
// IntelliJ, the document verison is always null, so identical events
62+
// indicating a valid change are possible.
63+
if (textDocument.version != null &&
64+
textDocument == _currentDocument &&
5065
cursorPosition == _currentCursorPosition) {
5166
return;
5267
}
53-
_currentDocument = textDocument;
54-
_currentCursorPosition = cursorPosition;
55-
// Get the editable arguments for the current position.
56-
final result = await editorClient.getEditableArguments(
57-
textDocument: textDocument,
58-
position: cursorPosition,
59-
);
60-
final args = result?.args ?? <EditableArgument>[];
61-
final name = result?.name;
62-
_editableWidgetData.value = (
63-
args: args,
64-
name: name,
65-
documentation: result?.documentation,
66-
);
67-
// Register impression.
68-
ga.impression(
69-
gaId,
70-
gac.PropertyEditorSidebar.widgetPropertiesUpdate(name: name),
68+
_editableArgsDebouncer.run(
69+
() => _updateWithEditableArgs(
70+
textDocument: textDocument,
71+
cursorPosition: cursorPosition,
72+
),
7173
);
7274
}),
7375
);
7476
}
7577

78+
@override
79+
void dispose() {
80+
_editableArgsDebouncer.dispose();
81+
super.dispose();
82+
}
83+
7684
Future<EditArgumentResponse?> editArgument<T>({
7785
required String name,
7886
required T value,
@@ -88,6 +96,31 @@ class PropertyEditorController extends DisposableController
8896
);
8997
}
9098

99+
Future<void> _updateWithEditableArgs({
100+
required TextDocument textDocument,
101+
required CursorPosition cursorPosition,
102+
}) async {
103+
_currentDocument = textDocument;
104+
_currentCursorPosition = cursorPosition;
105+
// Get the editable arguments for the current position.
106+
final result = await editorClient.getEditableArguments(
107+
textDocument: textDocument,
108+
position: cursorPosition,
109+
);
110+
final args = result?.args ?? <EditableArgument>[];
111+
final name = result?.name;
112+
_editableWidgetData.value = (
113+
args: args,
114+
name: name,
115+
documentation: result?.documentation,
116+
);
117+
// Register impression.
118+
ga.impression(
119+
gaId,
120+
gac.PropertyEditorSidebar.widgetPropertiesUpdate(name: name),
121+
);
122+
}
123+
91124
@visibleForTesting
92125
void initForTestsOnly({
93126
EditableArgumentsResult? editableArgsResult,

packages/devtools_app/test/shared/utils/utils_test.dart

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import 'package:fake_async/fake_async.dart';
1212
import 'package:flutter_test/flutter_test.dart';
1313

1414
void main() {
15-
group('DebounceTimer', () {
15+
group('PeriodicDebouncer', () {
1616
test('the callback happens immediately', () {
1717
fakeAsync((async) {
1818
int callbackCounter = 0;
19-
DebounceTimer.periodic(const Duration(seconds: 1), ({
19+
PeriodicDebouncer.run(const Duration(seconds: 1), ({
2020
DebounceCancelledCallback? cancelledCallback,
2121
}) async {
2222
callbackCounter++;
@@ -30,7 +30,7 @@ void main() {
3030
test('only triggers another callback after the first is done', () {
3131
fakeAsync((async) {
3232
int callbackCounter = 0;
33-
DebounceTimer.periodic(const Duration(milliseconds: 500), ({
33+
PeriodicDebouncer.run(const Duration(milliseconds: 500), ({
3434
DebounceCancelledCallback? cancelledCallback,
3535
}) async {
3636
callbackCounter++;
@@ -44,7 +44,7 @@ void main() {
4444
test('calls the callback at the beginning and then once per period', () {
4545
fakeAsync((async) {
4646
int callbackCounter = 0;
47-
DebounceTimer.periodic(const Duration(seconds: 1), ({
47+
PeriodicDebouncer.run(const Duration(seconds: 1), ({
4848
DebounceCancelledCallback? cancelledCallback,
4949
}) async {
5050
callbackCounter++;
@@ -60,7 +60,7 @@ void main() {
6060
() {
6161
fakeAsync((async) {
6262
int callbackCounter = 0;
63-
final timer = DebounceTimer.periodic(const Duration(seconds: 1), ({
63+
final timer = PeriodicDebouncer.run(const Duration(seconds: 1), ({
6464
DebounceCancelledCallback? cancelledCallback,
6565
}) async {
6666
callbackCounter++;
@@ -82,7 +82,7 @@ void main() {
8282
() {
8383
fakeAsync((async) {
8484
int callbackCounter = 0;
85-
final timer = DebounceTimer.periodic(const Duration(seconds: 1), ({
85+
final timer = PeriodicDebouncer.run(const Duration(seconds: 1), ({
8686
DebounceCancelledCallback? cancelledCallback,
8787
}) async {
8888
callbackCounter++;
@@ -102,7 +102,7 @@ void main() {
102102
test('cancels the callback when cancelled during the first callback', () {
103103
fakeAsync((async) {
104104
int callbackCounter = 0;
105-
final timer = DebounceTimer.periodic(const Duration(seconds: 1), ({
105+
final timer = PeriodicDebouncer.run(const Duration(seconds: 1), ({
106106
DebounceCancelledCallback? cancelledCallback,
107107
}) async {
108108
callbackCounter++;
@@ -123,7 +123,7 @@ void main() {
123123
test('cancels the callback when cancelled during the Nth callback', () {
124124
fakeAsync((async) {
125125
int callbackCounter = 0;
126-
final timer = DebounceTimer.periodic(const Duration(seconds: 1), ({
126+
final timer = PeriodicDebouncer.run(const Duration(seconds: 1), ({
127127
DebounceCancelledCallback? cancelledCallback,
128128
}) async {
129129
callbackCounter++;
@@ -142,6 +142,57 @@ void main() {
142142
});
143143
});
144144

145+
group('Debouncer', () {
146+
late Debouncer debouncer;
147+
late int callbackCount;
148+
149+
setUp(() {
150+
callbackCount = 0;
151+
debouncer = Debouncer(duration: const Duration(milliseconds: 100));
152+
});
153+
154+
tearDown(() {
155+
debouncer.dispose();
156+
});
157+
158+
test('calls callback after duration', () async {
159+
debouncer.run(() => callbackCount++);
160+
expect(callbackCount, 0);
161+
await Future.delayed(const Duration(milliseconds: 150));
162+
expect(callbackCount, 1);
163+
});
164+
165+
test('debounces multiple calls', () async {
166+
debouncer.run(() => callbackCount++);
167+
debouncer.run(() => callbackCount++);
168+
debouncer.run(() => callbackCount++);
169+
expect(callbackCount, 0);
170+
await Future.delayed(const Duration(milliseconds: 150));
171+
expect(callbackCount, 1);
172+
});
173+
174+
test('calls callback after multiple calls with delay', () async {
175+
debouncer.run(() => callbackCount++);
176+
await Future.delayed(const Duration(milliseconds: 50));
177+
debouncer.run(() => callbackCount++);
178+
await Future.delayed(const Duration(milliseconds: 50));
179+
debouncer.run(() => callbackCount++);
180+
expect(callbackCount, 0);
181+
await Future.delayed(const Duration(milliseconds: 150));
182+
expect(callbackCount, 1);
183+
});
184+
185+
test('dispose cancels timer', () async {
186+
debouncer.run(() => callbackCount++);
187+
debouncer.dispose();
188+
await Future.delayed(const Duration(milliseconds: 150));
189+
expect(callbackCount, 0);
190+
debouncer.run(() => callbackCount++);
191+
await Future.delayed(const Duration(milliseconds: 150));
192+
expect(callbackCount, 0);
193+
});
194+
});
195+
145196
group('InterruptableChunkWorker', () {
146197
late InterruptableChunkWorker worker;
147198
late List<int> indexes;

0 commit comments

Comments
 (0)