mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Restoration Framework (#60375)
* state restoration * added example * typos and analyzer * whitespace * more typos * remove unnecessary import * whitespace * fix sample code * tests for restorationmanager and restorationid * ++ * typo * tests for bucket, part1 * rename tests * more tests * finished tests for service layer * remove wrong todo * ++ * review comments * tests for Unmanaged and regular scope * RootRestorationScope tests * typo * whitespace * testing framework * tests for properties * last set of tests * analyzer * typo * dan review * whitespace * ++ * refactor finalizers * ++ * ++ * dispose guard * ++ * ++ * dan review * add manager assert * ++ * analyzer * greg review * fix typo * Ian & John review * ian review * RestorationID -> String * revert comment * Make primitives non-nullable in prep for NNBD
This commit is contained in:
parent
1fff10500f
commit
175e5c9aca
@ -33,6 +33,7 @@ export 'src/services/raw_keyboard_linux.dart';
|
||||
export 'src/services/raw_keyboard_macos.dart';
|
||||
export 'src/services/raw_keyboard_web.dart';
|
||||
export 'src/services/raw_keyboard_windows.dart';
|
||||
export 'src/services/restoration.dart';
|
||||
export 'src/services/system_channels.dart';
|
||||
export 'src/services/system_chrome.dart';
|
||||
export 'src/services/system_navigator.dart';
|
||||
|
||||
@ -13,6 +13,7 @@ import 'package:flutter/scheduler.dart';
|
||||
|
||||
import 'asset_bundle.dart';
|
||||
import 'binary_messenger.dart';
|
||||
import 'restoration.dart';
|
||||
import 'system_channels.dart';
|
||||
|
||||
/// Listens for platform messages and directs them to the [defaultBinaryMessenger].
|
||||
@ -27,6 +28,7 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
|
||||
super.initInstances();
|
||||
_instance = this;
|
||||
_defaultBinaryMessenger = createBinaryMessenger();
|
||||
_restorationManager = createRestorationManager();
|
||||
window.onPlatformMessage = defaultBinaryMessenger.handlePlatformMessage;
|
||||
initLicenses();
|
||||
SystemChannels.system.setMessageHandler(handleSystemMessage);
|
||||
@ -204,6 +206,27 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The [RestorationManager] synchronizes the restoration data between
|
||||
/// engine and framework.
|
||||
///
|
||||
/// See the docs for [RestorationManager] for a discussion of restoration
|
||||
/// state and how it is organized in Flutter.
|
||||
///
|
||||
/// To use a different [RestorationManager] subclasses can override
|
||||
/// [createRestorationManager], which is called to create the instance
|
||||
/// returned by this getter.
|
||||
RestorationManager get restorationManager => _restorationManager;
|
||||
RestorationManager _restorationManager;
|
||||
|
||||
/// Creates the [RestorationManager] instance available via
|
||||
/// [restorationManager].
|
||||
///
|
||||
/// Can be overriden in subclasses to create a different [RestorationManager].
|
||||
@protected
|
||||
RestorationManager createRestorationManager() {
|
||||
return RestorationManager();
|
||||
}
|
||||
}
|
||||
|
||||
/// The default implementation of [BinaryMessenger].
|
||||
|
||||
963
packages/flutter/lib/src/services/restoration.dart
Normal file
963
packages/flutter/lib/src/services/restoration.dart
Normal file
@ -0,0 +1,963 @@
|
||||
// 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.
|
||||
|
||||
// @dart = 2.8
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import 'message_codec.dart';
|
||||
import 'message_codecs.dart';
|
||||
import 'system_channels.dart';
|
||||
|
||||
typedef _BucketVisitor = void Function(RestorationBucket bucket);
|
||||
|
||||
/// Manages the restoration data in the framework and synchronizes it with the
|
||||
/// engine.
|
||||
///
|
||||
/// Restoration data can be serialized out and - at a later point in time - be
|
||||
/// used to restore the application to the previous state described by the
|
||||
/// serialized data. Mobile operating systems use the concept of state
|
||||
/// restoration to provide the illusion that apps continue to run in the
|
||||
/// background forever: after an app has been backgrounded, the user can always
|
||||
/// return to it and find it in the same state. In practice, the operating
|
||||
/// system may, however, terminate the app to free resources for other apps
|
||||
/// running in the foreground. Before that happens, the app gets a chance to
|
||||
/// serialize out its restoration data. When the user navigates back to the
|
||||
/// backgrounded app, it is restarted and the serialized restoration data is
|
||||
/// provided to it again. Ideally, the app will use that data to restore itself
|
||||
/// to the same state it was in when the user backgrounded the app.
|
||||
///
|
||||
/// In Flutter, restoration data is organized in a tree of [RestorationBucket]s
|
||||
/// which is rooted in the [rootBucket]. All information that the application
|
||||
/// needs to restore its current state must be stored in a bucket in this
|
||||
/// hierarchy. To store data in the hierarchy, entities (e.g. [Widget]s) must
|
||||
/// claim ownership of a child bucket from a parent bucket (which may be the
|
||||
/// [rootBucket] provided by this [RestorationManager]). The owner of a bucket
|
||||
/// may store arbitrary values in the bucket as long as they can be serialized
|
||||
/// with the [StandardMessageCodec]. The values are stored in the bucket under a
|
||||
/// given restoration ID as key. A restoration ID is a [Sting] that must be
|
||||
/// unique within a given bucket. To access the stored value again during state
|
||||
/// restoration, the same restoration ID must be provided again. The owner of
|
||||
/// the bucket may also make the bucket available to other entities so that they
|
||||
/// can claim child buckets from it for their own restoration needs. Within a
|
||||
/// bucket, child buckets are also identified by unique restoration IDs. The
|
||||
/// restoration ID must be provided when claiming a child bucket.
|
||||
///
|
||||
/// When restoration data is provided to the [RestorationManager] (e.g. after
|
||||
/// the application relaunched when foregrounded again), the bucket hierarchy
|
||||
/// with all the data stored in it is restored. Entities can retrieve the data
|
||||
/// again by using the same restoration IDs that they originally used to store
|
||||
/// the data.
|
||||
///
|
||||
/// In addition to providing restoration data when the app is launched,
|
||||
/// restoration data may also be provided to a running app to restore it to a
|
||||
/// previous state (e.g. when the user hits the back/forward button in the web
|
||||
/// browser). When this happens, the current bucket hierarchy is decommissioned
|
||||
/// and replaced with the hierarchy deserialized from the newly provided
|
||||
/// restoration data. Buckets in the old hierarchy notify their listeners when
|
||||
/// they get decommissioned. In response to the notification, listeners must
|
||||
/// stop using the old buckets. Owners of those buckets must dispose of them and
|
||||
/// claim a new child as a replacement from a parent in the new bucket hierarchy
|
||||
/// (that parent may be the updated [rootBucket]).
|
||||
///
|
||||
/// Same platforms restrict the size of the restoration data. Therefore, the
|
||||
/// data stored in the buckets should be as small as possible while still
|
||||
/// allowing the app to restore its current state from it. Data that can be
|
||||
/// retrieved from other services (e.g. a database or a web server) should not
|
||||
/// be included in the restoration data. Instead, a small identifier (e.g. a
|
||||
/// UUID, database record number, or resource locator) should be stored that can
|
||||
/// be used to retrieve the data again from its original source during state
|
||||
/// restoration.
|
||||
///
|
||||
/// The [RestorationManager] sends a serialized version of the bucket hierarchy
|
||||
/// over to the engine at the end of a frame in which the data in the hierarchy
|
||||
/// or its shape has changed. The engine caches the data until the operating
|
||||
/// system needs it. The application is responsible for keeping the data in the
|
||||
/// bucket always up-to-date to reflect its current state.
|
||||
///
|
||||
/// ## Discussion
|
||||
///
|
||||
/// Due to Flutter's threading model and restrictions in the APIs of the
|
||||
/// platforms Flutter runs on, restoration data must be stored in the buckets
|
||||
/// proactively as described above. When the operating system asks for the
|
||||
/// restoration data, it will do so on the platform thread expecting a
|
||||
/// synchronous response. To avoid the risk of deadlocks, the platform thread
|
||||
/// cannot block and call into the UI thread (where the dart code is running) to
|
||||
/// retrieve the restoration data. For this reason, the [RestorationManager]
|
||||
/// always sends the latest copy of the restoration data from the UI thread over
|
||||
/// to the platform thread whenever it changes. That way, the restoration data
|
||||
/// is always ready to go on the platform thread when the operating system needs
|
||||
/// it.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [ServicesBinding.restorationManager], which holds the singleton instance
|
||||
/// of the [RestorationManager] for the currently running application.
|
||||
/// * [RestorationBucket], which make up the restoration data hierarchy.
|
||||
/// * [RestorationMixin], which uses [RestorationBucket]s behind the scenes
|
||||
/// to make [State] objects of [StatefulWidget]s restorable.
|
||||
class RestorationManager extends ChangeNotifier {
|
||||
/// The root of the [RestorationBucket] hierarchy containing the restoration
|
||||
/// data.
|
||||
///
|
||||
/// Child buckets can be claimed from this bucket via
|
||||
/// [RestorationBucket.claimChild]. If the [RestorationManager] has been asked
|
||||
/// to restore the application to a previous state, these buckets will contain
|
||||
/// the previously stored data. Otherwise the root bucket (and all children
|
||||
/// claimed from it) will be empty.
|
||||
///
|
||||
/// The [RestorationManager] informs its listeners (added via [addListener])
|
||||
/// when the value returned by this getter changes. This happens when new
|
||||
/// restoration data has been provided to the [RestorationManager] to restore
|
||||
/// the application to a different state. In response to the notification,
|
||||
/// listeners must stop using the old root bucket and obtain the new one via
|
||||
/// this getter ([rootBucket] will have been updated to return the new bucket
|
||||
/// just before the listeners are notified).
|
||||
///
|
||||
/// The restoration data describing the current bucket hierarchy is retrieved
|
||||
/// asynchronously from the engine the first time the root bucket is accessed
|
||||
/// via this getter. After the data has been copied over from the engine, this
|
||||
/// getter will return a [SynchronousFuture], that immediately resolves to the
|
||||
/// root [RestorationBucket].
|
||||
///
|
||||
/// The returned [Future] may resolve to null if state restoration is
|
||||
/// currently turned off.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [RootRestorationScope], which makes the root bucket available in the
|
||||
/// [Widget] tree.
|
||||
Future<RestorationBucket> get rootBucket {
|
||||
if (!SystemChannels.restoration.checkMethodCallHandler(_methodHandler)) {
|
||||
SystemChannels.restoration.setMethodCallHandler(_methodHandler);
|
||||
}
|
||||
if (_rootBucketIsValid) {
|
||||
return SynchronousFuture<RestorationBucket>(_rootBucket);
|
||||
}
|
||||
if (_pendingRootBucket == null) {
|
||||
_pendingRootBucket = Completer<RestorationBucket>();
|
||||
_getRootBucketFromEngine();
|
||||
}
|
||||
return _pendingRootBucket.future;
|
||||
}
|
||||
RestorationBucket _rootBucket; // May be null to indicate that restoration is turned off.
|
||||
Completer<RestorationBucket> _pendingRootBucket;
|
||||
bool _rootBucketIsValid = false;
|
||||
|
||||
Future<void> _getRootBucketFromEngine() async {
|
||||
final Map<dynamic, dynamic> config = await SystemChannels.restoration.invokeMethod<Map<dynamic, dynamic>>('get');
|
||||
if (_pendingRootBucket == null) {
|
||||
// The restoration data was obtained via other means (e.g. by calling
|
||||
// [handleRestorationDataUpdate] while the request to the engine was
|
||||
// outstanding. Ignore the engine's response.
|
||||
return;
|
||||
}
|
||||
assert(_rootBucket == null);
|
||||
_parseAndHandleRestorationUpdateFromEngine(config);
|
||||
}
|
||||
|
||||
void _parseAndHandleRestorationUpdateFromEngine(Map<dynamic, dynamic> update) {
|
||||
handleRestorationUpdateFromEngine(
|
||||
enabled: update != null && update['enabled'] as bool,
|
||||
data: update == null ? null : update['data'] as Uint8List,
|
||||
);
|
||||
}
|
||||
|
||||
/// Called by the [RestorationManager] on itself to parse the restoration
|
||||
/// information obtained from the engine.
|
||||
///
|
||||
/// The `enabled` parameter indicates whether the engine wants to receive
|
||||
/// restoration data. When `enabled` is false, state restoration is turned
|
||||
/// off and the [rootBucket] is set to null. When `enabled` is true, the
|
||||
/// provided restoration `data` will be parsed into the [rootBucket]. If
|
||||
/// `data` is null, an empty [rootBucket] will be instantiated.
|
||||
///
|
||||
/// When this method is called, the old [rootBucket] is decommissioned.
|
||||
///
|
||||
/// Subclasses in test frameworks may call this method at any time to inject
|
||||
/// restoration data (obtained e.g. by overriding [sendToEngine]) into the
|
||||
/// [RestorationManager]. When the method is called before the [rootBucket] is
|
||||
/// accessed, [rootBucket] will complete synchronously the next time it is
|
||||
/// called.
|
||||
@protected
|
||||
void handleRestorationUpdateFromEngine({@required bool enabled, @required Uint8List data}) {
|
||||
assert(enabled != null);
|
||||
|
||||
final RestorationBucket oldRoot = _rootBucket;
|
||||
|
||||
_rootBucket = enabled
|
||||
? RestorationBucket.root(manager: this, rawData: _decodeRestorationData(data))
|
||||
: null;
|
||||
_rootBucketIsValid = true;
|
||||
assert(_pendingRootBucket == null || !_pendingRootBucket.isCompleted);
|
||||
_pendingRootBucket?.complete(_rootBucket);
|
||||
_pendingRootBucket = null;
|
||||
|
||||
if (_rootBucket != oldRoot) {
|
||||
notifyListeners();
|
||||
}
|
||||
if (oldRoot != null) {
|
||||
oldRoot
|
||||
..decommission()
|
||||
..dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the [RestorationManager] on itself to send the provided
|
||||
/// encoded restoration data to the engine.
|
||||
///
|
||||
/// The `encodedData` describes the entire bucket hierarchy that makes up the
|
||||
/// current restoration data.
|
||||
///
|
||||
/// Subclasses in test frameworks may override this method to capture the
|
||||
/// restoration data that would have been send to the engine. The captured
|
||||
/// data can be re-injected into the [RestorationManager] via the
|
||||
/// [handleRestorationUpdateFromEngine] method to restore the state described
|
||||
/// by the data.
|
||||
@protected
|
||||
Future<void> sendToEngine(Uint8List encodedData) {
|
||||
assert(encodedData != null);
|
||||
return SystemChannels.restoration.invokeMethod<void>(
|
||||
'put',
|
||||
encodedData,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _methodHandler(MethodCall call) {
|
||||
switch (call.method) {
|
||||
case 'push':
|
||||
_parseAndHandleRestorationUpdateFromEngine(call.arguments as Map<dynamic, dynamic>);
|
||||
break;
|
||||
default:
|
||||
throw UnimplementedError("${call.method} was invoked but isn't implemented by $runtimeType");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic> _decodeRestorationData(Uint8List data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
final ByteData encoded = data.buffer.asByteData(data.offsetInBytes, data.lengthInBytes);
|
||||
return const StandardMessageCodec().decodeMessage(encoded) as Map<dynamic, dynamic>;
|
||||
}
|
||||
|
||||
Uint8List _encodeRestorationData(Map<dynamic, dynamic> data) {
|
||||
final ByteData encoded = const StandardMessageCodec().encodeMessage(data);
|
||||
return encoded.buffer.asUint8List(encoded.offsetInBytes, encoded.lengthInBytes);
|
||||
}
|
||||
|
||||
bool _debugDoingUpdate = false;
|
||||
bool _postFrameScheduled = false;
|
||||
|
||||
final Set<RestorationBucket> _bucketsNeedingSerialization = <RestorationBucket>{};
|
||||
|
||||
/// Called by a [RestorationBucket] to request serialization for that bucket.
|
||||
///
|
||||
/// This method is called by a bucket in the hierarchy whenever the data
|
||||
/// in it or the shape of the hierarchy has changed.
|
||||
///
|
||||
/// Calling this is a no-op when the bucket is already scheduled for
|
||||
/// serialization.
|
||||
///
|
||||
/// It is exposed to allow testing of [RestorationBucket]s in isolation.
|
||||
@protected
|
||||
@visibleForTesting
|
||||
void scheduleSerializationFor(RestorationBucket bucket) {
|
||||
assert(bucket != null);
|
||||
assert(bucket._manager == this);
|
||||
assert(!_debugDoingUpdate);
|
||||
_bucketsNeedingSerialization.add(bucket);
|
||||
if (!_postFrameScheduled) {
|
||||
_postFrameScheduled = true;
|
||||
SchedulerBinding.instance.addPostFrameCallback((Duration _) => _doSerialization());
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by a [RestorationBucket] to unschedule a request for serialization.
|
||||
///
|
||||
/// This method is called by a bucket in the hierarchy whenever it no longer
|
||||
/// needs to be serialized (e.g. because the bucket got disposed).
|
||||
///
|
||||
/// It is safe to call this even when the bucket wasn't scheduled for
|
||||
/// serialization before.
|
||||
///
|
||||
/// It is exposed to allow testing of [RestorationBucket]s in isolation.
|
||||
@protected
|
||||
@visibleForTesting
|
||||
void unscheduleSerializationFor(RestorationBucket bucket) {
|
||||
assert(bucket != null);
|
||||
assert(bucket._manager == this);
|
||||
assert(!_debugDoingUpdate);
|
||||
_bucketsNeedingSerialization.remove(bucket);
|
||||
}
|
||||
|
||||
void _doSerialization() {
|
||||
assert(() {
|
||||
_debugDoingUpdate = true;
|
||||
return true;
|
||||
}());
|
||||
_postFrameScheduled = false;
|
||||
|
||||
for (final RestorationBucket bucket in _bucketsNeedingSerialization) {
|
||||
bucket.finalize();
|
||||
}
|
||||
_bucketsNeedingSerialization.clear();
|
||||
sendToEngine(_encodeRestorationData(_rootBucket._rawData));
|
||||
|
||||
assert(() {
|
||||
_debugDoingUpdate = false;
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
||||
/// A [RestorationBucket] holds pieces of the restoration data that a part of
|
||||
/// the application needs to restore its state.
|
||||
///
|
||||
/// For a general overview of how state restoration works in Flutter, see the
|
||||
/// [RestorationManager].
|
||||
///
|
||||
/// [RestorationBucket]s are organized in a tree that is rooted in
|
||||
/// [RestorationManager.rootBucket] and managed by a [RestorationManager]. The
|
||||
/// tree is serializable and must contain all the data an application needs to
|
||||
/// restore its current state at a later point in time.
|
||||
///
|
||||
/// A [RestorationBucket] stores restoration data as key-value pairs. The key is
|
||||
/// a [String] representing a restoration ID that identifies a piece of data
|
||||
/// uniquely within a bucket. The value can be anything that is serializable via
|
||||
/// the [StandardMessageCodec]. Furthermore, a [RestorationBucket] may have
|
||||
/// child buckets, which are identified within their parent via a unique
|
||||
/// restoration ID as well.
|
||||
///
|
||||
/// During state restoration, the data previously stored in the
|
||||
/// [RestorationBucket] hierarchy will be made available again to the
|
||||
/// application to restore it to the state it had when the data was collected.
|
||||
/// State restoration to a previous state may happen when the app is launched
|
||||
/// (e.g. after it has been terminated gracefully while running in the
|
||||
/// background) or after the app has already been running for a while.
|
||||
///
|
||||
/// ## Lifecycle
|
||||
///
|
||||
/// A [RestorationBucket] is rarely instantiated directly via its constructors.
|
||||
/// Instead, when an entity wants to store data in or retrieve data from a
|
||||
/// restoration bucket, it typically obtains a child bucket from a parent by
|
||||
/// calling [claimChild]. If no parent is available,
|
||||
/// [RestorationManager.rootBucket] may be used as a parent. When claiming a
|
||||
/// child, the claimer must provide the restoration ID of the child it would
|
||||
/// like to own. A child bucket with a given restoration ID can at most have
|
||||
/// one owner. If another owner tries to claim a bucket with the same ID from
|
||||
/// the same parent, an exception is thrown (see discussion in [claimChild]).
|
||||
/// The restoration IDs that a given owner uses to claim a child (and to store
|
||||
/// data in that child, see below) must be stable across app launches to ensure
|
||||
/// that after the app restarts the owner can retrieve the same data again that
|
||||
/// it stored during a previous run.
|
||||
///
|
||||
/// Per convention, the owner of the bucket has exclusive access to the values
|
||||
/// stored in the bucket. It can read, add, modify, and remove values via the
|
||||
/// [read], [write], and [remove] methods. In general, the owner should store
|
||||
/// all the data in the bucket that it needs to restore its current state. If
|
||||
/// its current state changes, the data in the bucket must be updated. At the
|
||||
/// same time, the data in the bucket should be kept to a minimum. For example,
|
||||
/// for data that can be retrieved from other sources (like a database or
|
||||
/// webservice) only enough information (e.g. an ID or resource locator) to
|
||||
/// re-obtain that data should be stored in the bucket. In addition to managing
|
||||
/// the data in a bucket, an owner may also make the bucket available to other
|
||||
/// entities so they can claim child buckets from it via [claimChild] for their
|
||||
/// own restoration needs.
|
||||
///
|
||||
/// The bucket returned by [claimChild] may either contain state information
|
||||
/// that the owner had previously (e.g. during a previous run of the
|
||||
/// application) stored in it or it may be empty. If the bucket contains data,
|
||||
/// the owner is expected to restore its state with the information previously
|
||||
/// stored in the bucket. If the bucket is empty, it may initialize itself to
|
||||
/// default values.
|
||||
///
|
||||
/// During the lifetime of a bucket, it may notify its listeners that the bucket
|
||||
/// has been [decommission]ed. This happens when new restoration data has been
|
||||
/// provided to, for example, the [RestorationManager] to restore the
|
||||
/// application to a different state (e.g. when the user hits the back/forward
|
||||
/// button in the web browser). In response to the notification, owners must
|
||||
/// dispose their current bucket and replace it with a new bucket claimed from a
|
||||
/// new parent (which will have been initialized with the new restoration data).
|
||||
/// For example, if the owner previously claimed its bucket from
|
||||
/// [RestorationManager.rootBucket], it must claim its new bucket from there
|
||||
/// again. The root bucket will have been replaced with the new root bucket just
|
||||
/// before the bucket listeners are informed about the decommission. Once the
|
||||
/// new bucket is obtained, owners should restore their internal state according
|
||||
/// to the information in the new bucket.
|
||||
///
|
||||
/// When the data stored in a bucket is no longer needed to restore the
|
||||
/// application to its current state (e.g. because the owner of the bucket is no
|
||||
/// longer shown on screen), the bucket must be [dispose]d. This will remove all
|
||||
/// information stored in the bucket from the app's restoration data and that
|
||||
/// information will not be available again when the application is restored to
|
||||
/// this state in the future.
|
||||
class RestorationBucket extends ChangeNotifier {
|
||||
/// Creates an empty [RestorationBucket] to be provided to [adoptChild] to add
|
||||
/// it to the bucket hierarchy.
|
||||
///
|
||||
/// {@template flutter.services.restoration.bucketcreation}
|
||||
/// Instantiating a bucket directly is rare, most buckets are created by
|
||||
/// claiming a child from a parent via [claimChild]. If no parent bucket is
|
||||
/// available, [RestorationManager.rootBucket] may be used as a parent.
|
||||
/// {@endtemplate}
|
||||
///
|
||||
/// The `restorationId` must not be null.
|
||||
RestorationBucket.empty({
|
||||
@required String restorationId,
|
||||
@required Object debugOwner,
|
||||
}) : assert(restorationId != null),
|
||||
_restorationId = restorationId,
|
||||
_rawData = <String, dynamic>{} {
|
||||
assert(() {
|
||||
_debugOwner = debugOwner;
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
|
||||
/// Creates the root [RestorationBucket] for the provided restoration
|
||||
/// `manager`.
|
||||
///
|
||||
/// The `rawData` must either be null (in which case an empty bucket will be
|
||||
/// instantiated) or it must be a nested map describing the entire bucket
|
||||
/// hierarchy in the following format:
|
||||
///
|
||||
/// ```javascript
|
||||
/// {
|
||||
/// 'v': { // key-value pairs
|
||||
/// // * key is a string representation a restoration ID
|
||||
/// // * value is any primitive that can be encoded with [StandardMessageCodec]
|
||||
/// '<restoration-id>: <Object>,
|
||||
/// },
|
||||
/// 'c': { // child buckets
|
||||
/// 'restoration-id': <nested map representing a child bucket>
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// {@macro flutter.services.restoration.bucketcreation}
|
||||
///
|
||||
/// The `manager` argument must not be null.
|
||||
RestorationBucket.root({
|
||||
@required RestorationManager manager,
|
||||
@required Map<dynamic, dynamic> rawData,
|
||||
}) : assert(manager != null),
|
||||
_manager = manager,
|
||||
_rawData = rawData ?? <dynamic, dynamic>{},
|
||||
_restorationId = 'root' {
|
||||
assert(() {
|
||||
_debugOwner = manager;
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
|
||||
/// Creates a child bucket initialized with the data that the provided
|
||||
/// `parent` has stored under the provided [id].
|
||||
///
|
||||
/// This constructor cannot be used if the `parent` does not have any child
|
||||
/// data stored under the given ID. In that case, create an empty bucket (via
|
||||
/// [RestorationBucket.empty] and have the parent adopt it via [adoptChild].
|
||||
///
|
||||
/// {@macro flutter.services.restoration.bucketcreation}
|
||||
///
|
||||
/// The `restorationId` and `parent` argument must not be null.
|
||||
RestorationBucket.child({
|
||||
@required String restorationId,
|
||||
@required RestorationBucket parent,
|
||||
@required Object debugOwner,
|
||||
}) : assert(restorationId != null),
|
||||
assert(parent != null),
|
||||
assert(parent._rawChildren[restorationId] != null),
|
||||
_manager = parent._manager,
|
||||
_parent = parent,
|
||||
_rawData = parent._rawChildren[restorationId] as Map<dynamic, dynamic>,
|
||||
_restorationId = restorationId {
|
||||
assert(() {
|
||||
_debugOwner = debugOwner;
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
|
||||
static const String _childrenMapKey = 'c';
|
||||
static const String _valuesMapKey = 'v';
|
||||
|
||||
final Map<dynamic, dynamic> _rawData;
|
||||
|
||||
/// The owner of the bucket that was provided when the bucket was claimed via
|
||||
/// [claimChild].
|
||||
///
|
||||
/// The value is used in error messages. Accessing the value is only valid
|
||||
/// in debug mode, otherwise it will return null.
|
||||
Object get debugOwner {
|
||||
assert(_debugAssertNotDisposed());
|
||||
return _debugOwner;
|
||||
}
|
||||
Object _debugOwner;
|
||||
|
||||
RestorationManager _manager;
|
||||
RestorationBucket _parent;
|
||||
|
||||
/// The restoration ID under which the bucket is currently stored in the
|
||||
/// parent of this bucket (or wants to be stored if it is currently
|
||||
/// parent-less).
|
||||
///
|
||||
/// This value is never null.
|
||||
String get restorationId {
|
||||
assert(_debugAssertNotDisposed());
|
||||
return _restorationId;
|
||||
}
|
||||
String _restorationId;
|
||||
|
||||
// Maps a restoration ID to the raw map representation of a child bucket.
|
||||
Map<dynamic, dynamic> get _rawChildren => _rawData.putIfAbsent(_childrenMapKey, () => <dynamic, dynamic>{}) as Map<dynamic, dynamic>;
|
||||
// Maps a restoration ID to a value that is stored in this bucket.
|
||||
Map<dynamic, dynamic> get _rawValues => _rawData.putIfAbsent(_valuesMapKey, () => <dynamic, dynamic>{}) as Map<dynamic, dynamic>;
|
||||
|
||||
/// Called to signal that this bucket and all its descendants are no longer
|
||||
/// part of the current restoration data and must not be used anymore.
|
||||
///
|
||||
/// Calling this method will drop this bucket from its parent and notify all
|
||||
/// its listeners as well as all listeners of its descendants. Once a bucket
|
||||
/// has notified its listeners, it must not be used anymore. During the next
|
||||
/// frame following the notification, the bucket must be disposed and replaced
|
||||
/// with a new bucket.
|
||||
///
|
||||
/// As an example, the [RestorationManager] calls this method on its root
|
||||
/// bucket when it has been asked to restore a running application to a
|
||||
/// different state. At that point, the data stored in the current bucket
|
||||
/// hierarchy is invalid and will be replaced with a new hierarchy generated
|
||||
/// from the restoration data describing the new state. To replace the current
|
||||
/// bucket hierarchy, [decommission] is called on the root bucket to signal to
|
||||
/// all owners of buckets in the hierarchy that their bucket has become
|
||||
/// invalid. In response to the notification, bucket owners must [dispose]
|
||||
/// their buckets and claim a new bucket from the newly created hierarchy. For
|
||||
/// example, the owner of a bucket that was originally claimed from the
|
||||
/// [RestorationManager.rootBucket] must dispose that bucket and claim a new
|
||||
/// bucket from the new [RestorationManager.rootBucket]. Once the new bucket
|
||||
/// is claimed, owners should restore their state according to the data stored
|
||||
/// in the new bucket.
|
||||
void decommission() {
|
||||
assert(_debugAssertNotDisposed());
|
||||
if (_parent != null) {
|
||||
_parent._dropChild(this);
|
||||
_parent = null;
|
||||
}
|
||||
_performDecommission();
|
||||
}
|
||||
|
||||
bool _decommissioned = false;
|
||||
|
||||
void _performDecommission() {
|
||||
_decommissioned = true;
|
||||
_updateManager(null);
|
||||
notifyListeners();
|
||||
_visitChildren((RestorationBucket bucket) {
|
||||
bucket._performDecommission();
|
||||
});
|
||||
}
|
||||
|
||||
// Get and store values.
|
||||
|
||||
/// Returns the value of type `P` that is currently stored in the bucket under
|
||||
/// the provided `restorationId`.
|
||||
///
|
||||
/// Returns null if nothing is stored under that id. Throws, if the value
|
||||
/// stored under the ID is not of type `P`.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [write], which stores a value in the bucket.
|
||||
/// * [remove], which removes a value from the bucket.
|
||||
/// * [contains], which checks whether any value is stored under a given
|
||||
/// restoration ID.
|
||||
P read<P>(String restorationId) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
assert(restorationId != null);
|
||||
return _rawValues[restorationId] as P;
|
||||
}
|
||||
|
||||
/// Stores the provided `value` of type `P` under the provided `restorationId`
|
||||
/// in the bucket.
|
||||
///
|
||||
/// Any value that has previously been stored under that ID is overwritten
|
||||
/// with the new value. The provided `value` must be serializable with the
|
||||
/// [StandardMessageCodec].
|
||||
///
|
||||
/// Null values will be stored in the bucket as-is. To remove a value, use
|
||||
/// [remove].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [read], which retrieves a stored value from the bucket.
|
||||
/// * [remove], which removes a value from the bucket.
|
||||
/// * [contains], which checks whether any value is stored under a given
|
||||
/// restoration ID.
|
||||
void write<P>(String restorationId, P value) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
assert(restorationId != null);
|
||||
assert(debugIsSerializableForRestoration(value));
|
||||
if (_rawValues[restorationId] != value || !_rawValues.containsKey(restorationId)) {
|
||||
_rawValues[restorationId] = value;
|
||||
_markNeedsSerialization();
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the value currently stored under the provided `restorationId` from
|
||||
/// the bucket.
|
||||
///
|
||||
/// The value removed from the bucket is casted to `P` and returned. If no
|
||||
/// value was stored under that id, null is returned.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [read], which retrieves a stored value from the bucket.
|
||||
/// * [write], which stores a value in the bucket.
|
||||
/// * [contains], which checks whether any value is stored under a given
|
||||
/// restoration ID.
|
||||
P remove<P>(String restorationId) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
assert(restorationId != null);
|
||||
final bool needsUpdate = _rawValues.containsKey(restorationId);
|
||||
final P result = _rawValues.remove(restorationId) as P;
|
||||
if (_rawValues.isEmpty) {
|
||||
_rawData.remove(_valuesMapKey);
|
||||
}
|
||||
if (needsUpdate) {
|
||||
_markNeedsSerialization();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Checks whether a value stored in the bucket under the provided
|
||||
/// `restorationId`.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [read], which retrieves a stored value from the bucket.
|
||||
/// * [write], which stores a value in the bucket.
|
||||
/// * [remove], which removes a value from the bucket.
|
||||
bool contains(String restorationId) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
assert(restorationId != null);
|
||||
return _rawValues.containsKey(restorationId);
|
||||
}
|
||||
|
||||
// Child management.
|
||||
|
||||
// The restoration IDs and associated buckets of children that have been
|
||||
// claimed via [claimChild].
|
||||
final Map<String, RestorationBucket> _claimedChildren = <String, RestorationBucket>{};
|
||||
// Newly created child buckets whose restoration ID is still in use, see
|
||||
// comment in [claimChild] for details.
|
||||
final Map<String, List<RestorationBucket>> _childrenToAdd = <String, List<RestorationBucket>>{};
|
||||
|
||||
/// Claims ownership of the child with the provided `restorationId` from this
|
||||
/// bucket.
|
||||
///
|
||||
/// If the application is getting restored to a previous state, the bucket
|
||||
/// will contain all the data that was previously stored in the bucket.
|
||||
/// Otherwise, an empty bucket is returned.
|
||||
///
|
||||
/// The claimer of the bucket is expected to use the data stored in the bucket
|
||||
/// to restore itself to its previous state described by the data in the
|
||||
/// bucket. If the bucket is empty, it should initialize itself to default
|
||||
/// values. Whenever the information that the claimer needs to restore its
|
||||
/// state changes, the data in the bucket should be updated to reflect that.
|
||||
///
|
||||
/// A child bucket with a given `restorationId` can only have one owner. If
|
||||
/// another owner claims a child bucket with the same `restorationId` an
|
||||
/// exception will be thrown at the end of the current frame unless the
|
||||
/// previous owner has either deleted its bucket by calling [dispose] or has
|
||||
/// moved it to a new parent via [adoptChild].
|
||||
///
|
||||
/// When the returned bucket is no longer needed, it must be [dispose]d to
|
||||
/// delete the information stored in it from the app's restoration data.
|
||||
RestorationBucket claimChild(String restorationId, {@required Object debugOwner}) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
assert(restorationId != null);
|
||||
// There are three cases to consider:
|
||||
// 1. Claiming an ID that has already been claimed.
|
||||
// 2. Claiming an ID that doesn't yet exist in [_rawChildren].
|
||||
// 3. Claiming an ID that does exist in [_rawChildren] and hasn't been
|
||||
// claimed yet.
|
||||
// If an ID has already been claimed (case 1) the current owner may give up
|
||||
// that ID later this frame and it can be re-used. In anticipation of the
|
||||
// previous owner's surrender of the id, we return an empty bucket for this
|
||||
// new claim and check in [_debugAssertIntegrity] that at the end of the
|
||||
// frame the old owner actually did surrendered the id.
|
||||
// Case 2 also requires the creation of a new empty bucket.
|
||||
// In Case 3 we create a new bucket wrapping the existing data in
|
||||
// [_rawChildren].
|
||||
|
||||
// Case 1+2: Adopt and return an empty bucket.
|
||||
if (_claimedChildren.containsKey(restorationId) || !_rawChildren.containsKey(restorationId)) {
|
||||
final RestorationBucket child = RestorationBucket.empty(
|
||||
debugOwner: debugOwner,
|
||||
restorationId: restorationId,
|
||||
);
|
||||
adoptChild(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
// Case 3: Return bucket wrapping the existing data.
|
||||
assert(_rawChildren[restorationId] != null);
|
||||
final RestorationBucket child = RestorationBucket.child(
|
||||
restorationId: restorationId,
|
||||
parent: this,
|
||||
debugOwner: debugOwner,
|
||||
);
|
||||
_claimedChildren[restorationId] = child;
|
||||
return child;
|
||||
}
|
||||
|
||||
/// Adopts the provided `child` bucket.
|
||||
///
|
||||
/// The `child` will be dropped from its old parent, if it had one.
|
||||
///
|
||||
/// The `child` is stored under its [id] in this bucket. If this bucket
|
||||
/// already contains a child bucket under the same id, the owner of that
|
||||
/// existing bucket must give it up (e.g. by moving the child bucket to a
|
||||
/// different parent or by disposing it) before the end of the current frame.
|
||||
/// Otherwise an exception indicating the illegal use of duplicated
|
||||
/// restoration IDs will trigger in debug mode.
|
||||
///
|
||||
/// No-op if the provided bucket is already a child of this bucket.
|
||||
void adoptChild(RestorationBucket child) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
assert(child != null);
|
||||
if (child._parent != this) {
|
||||
child._parent?._removeChildData(child);
|
||||
child._parent = this;
|
||||
_addChildData(child);
|
||||
if (child._manager != _manager) {
|
||||
_recursivelyUpdateManager(child);
|
||||
}
|
||||
}
|
||||
assert(child._parent == this);
|
||||
assert(child._manager == _manager);
|
||||
}
|
||||
|
||||
void _dropChild(RestorationBucket child) {
|
||||
assert(child != null);
|
||||
assert(child._parent == this);
|
||||
_removeChildData(child);
|
||||
child._parent = null;
|
||||
if (child._manager != null) {
|
||||
child._updateManager(null);
|
||||
child._visitChildren(_recursivelyUpdateManager);
|
||||
}
|
||||
}
|
||||
|
||||
bool _needsSerialization = false;
|
||||
void _markNeedsSerialization() {
|
||||
assert(_manager != null || _decommissioned);
|
||||
if (!_needsSerialization) {
|
||||
_needsSerialization = true;
|
||||
_manager?.scheduleSerializationFor(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the [RestorationManager] just before the data of the bucket
|
||||
/// is serialized and send to the engine.
|
||||
///
|
||||
/// It is exposed to allow testing of [RestorationBucket]s in isolation.
|
||||
@visibleForTesting
|
||||
void finalize() {
|
||||
assert(_debugAssertNotDisposed());
|
||||
assert(_needsSerialization);
|
||||
_needsSerialization = false;
|
||||
assert(_debugAssertIntegrity());
|
||||
}
|
||||
|
||||
void _recursivelyUpdateManager(RestorationBucket bucket) {
|
||||
bucket._updateManager(_manager);
|
||||
bucket._visitChildren(_recursivelyUpdateManager);
|
||||
}
|
||||
|
||||
void _updateManager(RestorationManager newManager) {
|
||||
if (_manager == newManager) {
|
||||
return;
|
||||
}
|
||||
if (_needsSerialization) {
|
||||
_manager?.unscheduleSerializationFor(this);
|
||||
}
|
||||
_manager = newManager;
|
||||
if (_needsSerialization && _manager != null) {
|
||||
_needsSerialization = false;
|
||||
_markNeedsSerialization();
|
||||
}
|
||||
}
|
||||
|
||||
bool _debugAssertIntegrity() {
|
||||
assert(() {
|
||||
if (_childrenToAdd.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
final List<DiagnosticsNode> error = <DiagnosticsNode>[
|
||||
ErrorSummary('Multiple owners claimed child RestorationBuckets with the same IDs.'),
|
||||
ErrorDescription('The following IDs were claimed multiple times from the parent $this:')
|
||||
];
|
||||
for (final MapEntry<String, List<RestorationBucket>> child in _childrenToAdd.entries) {
|
||||
final String id = child.key;
|
||||
final List<RestorationBucket> buckets = child.value;
|
||||
assert(buckets.isNotEmpty);
|
||||
assert(_claimedChildren.containsKey(id));
|
||||
error.addAll(<DiagnosticsNode>[
|
||||
ErrorDescription(' * "$id" was claimed by:'),
|
||||
...buckets.map((RestorationBucket bucket) => ErrorDescription(' * ${bucket.debugOwner}')),
|
||||
ErrorDescription(' * ${_claimedChildren[id].debugOwner} (current owner)'),
|
||||
]);
|
||||
}
|
||||
throw FlutterError.fromParts(error);
|
||||
}());
|
||||
return true;
|
||||
}
|
||||
|
||||
void _removeChildData(RestorationBucket child) {
|
||||
assert(child != null);
|
||||
assert(child._parent == this);
|
||||
if (_claimedChildren.remove(child.restorationId) == child) {
|
||||
_rawChildren.remove(child.restorationId);
|
||||
final List<RestorationBucket> pendingChildren = _childrenToAdd[child.restorationId];
|
||||
if (pendingChildren != null) {
|
||||
final RestorationBucket toAdd = pendingChildren.removeLast();
|
||||
_finalizeAddChildData(toAdd);
|
||||
if (pendingChildren.isEmpty) {
|
||||
_childrenToAdd.remove(child.restorationId);
|
||||
}
|
||||
}
|
||||
if (_rawChildren.isEmpty) {
|
||||
_rawData.remove(_childrenMapKey);
|
||||
}
|
||||
_markNeedsSerialization();
|
||||
return;
|
||||
}
|
||||
_childrenToAdd[child.restorationId]?.remove(child);
|
||||
if (_childrenToAdd[child.restorationId]?.isEmpty == true) {
|
||||
_childrenToAdd.remove(child.restorationId);
|
||||
}
|
||||
}
|
||||
|
||||
void _addChildData(RestorationBucket child) {
|
||||
assert(child != null);
|
||||
assert(child._parent == this);
|
||||
if (_claimedChildren.containsKey(child.restorationId)) {
|
||||
// Delay addition until the end of the frame in the hopes that the current
|
||||
// owner of the child with the same ID will have given up that child by
|
||||
// then.
|
||||
_childrenToAdd.putIfAbsent(child.restorationId, () => <RestorationBucket>[]).add(child);
|
||||
_markNeedsSerialization();
|
||||
return;
|
||||
}
|
||||
_finalizeAddChildData(child);
|
||||
_markNeedsSerialization();
|
||||
}
|
||||
|
||||
void _finalizeAddChildData(RestorationBucket child) {
|
||||
assert(_claimedChildren[child.restorationId] == null);
|
||||
assert(_rawChildren[child.restorationId] == null);
|
||||
_claimedChildren[child.restorationId] = child;
|
||||
_rawChildren[child.restorationId] = child._rawData;
|
||||
}
|
||||
|
||||
void _visitChildren(_BucketVisitor visitor, {bool concurrentModification = false}) {
|
||||
Iterable<RestorationBucket> children = _claimedChildren.values
|
||||
.followedBy(_childrenToAdd.values.expand((List<RestorationBucket> buckets) => buckets));
|
||||
if (concurrentModification) {
|
||||
children = children.toList(growable: false);
|
||||
}
|
||||
children.forEach(visitor);
|
||||
}
|
||||
|
||||
// Bucket management
|
||||
|
||||
/// Changes the restoration ID under which the bucket is stored in its parent
|
||||
/// to `newRestorationId`.
|
||||
///
|
||||
/// No-op if the bucket is already stored under the provided id.
|
||||
///
|
||||
/// If another owner has already claimed a bucket with the provided `newId` an
|
||||
/// exception will be thrown at the end of the current frame unless the other
|
||||
/// owner has deleted its bucket by calling [dispose], [rename]ed it using
|
||||
/// another ID, or has moved it to a new parent via [adoptChild].
|
||||
void rename(String newRestorationId) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
assert(newRestorationId != null);
|
||||
assert(_parent != null);
|
||||
if (newRestorationId == restorationId) {
|
||||
return;
|
||||
}
|
||||
_parent._removeChildData(this);
|
||||
_restorationId = newRestorationId;
|
||||
_parent._addChildData(this);
|
||||
}
|
||||
|
||||
/// Deletes the bucket and all the data stored in it from the bucket
|
||||
/// hierarchy.
|
||||
///
|
||||
/// After [dispose] has been called, the data stored in this bucket and its
|
||||
/// children are no longer part of the app's restoration data. The data
|
||||
/// originally stored in the bucket will not be available again when the
|
||||
/// application is restored to this state in the future. It is up to the
|
||||
/// owners of the children to either move them (via [adoptChild]) to a new
|
||||
/// parent that is still part of the bucket hierarchy or to [dispose] of them
|
||||
/// as well.
|
||||
///
|
||||
/// This method must only be called by the object's owner.
|
||||
@override
|
||||
void dispose() {
|
||||
assert(_debugAssertNotDisposed());
|
||||
_visitChildren(_dropChild, concurrentModification: true);
|
||||
_claimedChildren.clear();
|
||||
_childrenToAdd.clear();
|
||||
_parent?._removeChildData(this);
|
||||
_parent = null;
|
||||
_updateManager(null);
|
||||
super.dispose();
|
||||
_debugDisposed = true;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '${objectRuntimeType(this, 'RestorationBucket')}(restorationId: $restorationId, owner: $debugOwner)';
|
||||
|
||||
bool _debugDisposed = false;
|
||||
bool _debugAssertNotDisposed() {
|
||||
assert(() {
|
||||
if (_debugDisposed) {
|
||||
throw FlutterError(
|
||||
'A $runtimeType was used after being disposed.\n'
|
||||
'Once you have called dispose() on a $runtimeType, it can no longer be used.'
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when the provided `object` is serializable for state
|
||||
/// restoration.
|
||||
///
|
||||
/// Should only be called from within asserts. Always returns false outside
|
||||
/// of debug builds.
|
||||
bool debugIsSerializableForRestoration(Object object) {
|
||||
bool result = false;
|
||||
|
||||
assert(() {
|
||||
try {
|
||||
const StandardMessageCodec().encodeMessage(object);
|
||||
result = true;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -286,4 +286,35 @@ class SystemChannels {
|
||||
'flutter/mousecursor',
|
||||
StandardMethodCodec(),
|
||||
);
|
||||
|
||||
/// A [MethodChannel] for synchronizing restoration data with the engine.
|
||||
///
|
||||
/// The following outgoing methods are defined for this channel (invoked using
|
||||
/// [OptionalMethodChannel.invokeMethod]):
|
||||
///
|
||||
/// * `get`: Retrieves the current restoration information (e.g. provided by
|
||||
/// the operating system) from the engine. The method returns a map
|
||||
/// containing an `enabled` boolean to indicate whether collecting
|
||||
/// restoration data is supported by the embedder. If `enabled` is true,
|
||||
/// the map may also contain restoration data stored under the `data` key
|
||||
/// from which the state of the framework may be restored. The restoration
|
||||
/// data is encoded as [Uint8List].
|
||||
/// * `put`: Sends the current restoration data to the engine. Takes the
|
||||
/// restoration data encoded as [Uint8List] as argument.
|
||||
///
|
||||
/// The following incoming methods are defined for this channel (registered
|
||||
/// using [MethodChannel.setMethodCallHandler]).
|
||||
///
|
||||
/// * `push`: Called by the engine to send newly provided restoration
|
||||
/// information to the framework. The argument given to this method has
|
||||
/// the same format as the object that the `get` method returns.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [RestorationManager], which uses this channel and also describes how
|
||||
/// restoration data is used in Flutter.
|
||||
static const MethodChannel restoration = OptionalMethodChannel(
|
||||
'flutter/restoration',
|
||||
StandardMethodCodec(),
|
||||
);
|
||||
}
|
||||
|
||||
1053
packages/flutter/lib/src/widgets/restoration.dart
Normal file
1053
packages/flutter/lib/src/widgets/restoration.dart
Normal file
File diff suppressed because it is too large
Load Diff
267
packages/flutter/lib/src/widgets/restoration_properties.dart
Normal file
267
packages/flutter/lib/src/widgets/restoration_properties.dart
Normal file
@ -0,0 +1,267 @@
|
||||
// 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.
|
||||
|
||||
// @dart = 2.8
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'editable_text.dart';
|
||||
import 'restoration.dart';
|
||||
|
||||
/// A [RestorableProperty] that makes the wrapped value accessible to the owning
|
||||
/// [State] object via the [value] getter and setter.
|
||||
///
|
||||
/// Whenever a new [value] is set, [didUpdateValue] is called. Subclasses should
|
||||
/// call [notifyListeners] from this method if the new value changes what
|
||||
/// [toPrimitives] returns.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [RestorableProperty], which is the super class of this class.
|
||||
/// * [RestorationMixin], to which a [RestorableValue] needs to be registered
|
||||
/// in order to work.
|
||||
/// * [RestorationManager], which provides an overview of how state restoration
|
||||
/// works in Flutter.
|
||||
abstract class RestorableValue<T> extends RestorableProperty<T> {
|
||||
/// The current value stored in this property.
|
||||
///
|
||||
/// A representation of the current value is stored in the restoration data.
|
||||
/// During state restoration, the property will restore the value to what it
|
||||
/// was when the restoration data it is getting restored from was collected.
|
||||
///
|
||||
/// The [value] can only be accessed after the property has been registered
|
||||
/// with a [RestorationMixin] by calling
|
||||
/// [RestorationMixin.registerForRestoration].
|
||||
T get value {
|
||||
assert(isRegistered);
|
||||
return _value;
|
||||
}
|
||||
T _value;
|
||||
set value(T newValue) {
|
||||
assert(isRegistered);
|
||||
if (newValue != _value) {
|
||||
final T oldValue = _value;
|
||||
_value = newValue;
|
||||
didUpdateValue(oldValue);
|
||||
}
|
||||
}
|
||||
|
||||
@mustCallSuper
|
||||
@override
|
||||
void initWithValue(T value) {
|
||||
_value = value;
|
||||
}
|
||||
|
||||
/// Called whenever a new value is assigned to [value].
|
||||
///
|
||||
/// The new value can be accessed via the regular [value] getter and the
|
||||
/// previous value is provided as `oldValue`.
|
||||
///
|
||||
/// Subclasses should call [notifyListeners] from this method, if the new
|
||||
/// value changes what [toPrimitives] returns.
|
||||
@protected
|
||||
void didUpdateValue(T oldValue);
|
||||
}
|
||||
|
||||
// _RestorablePrimitiveValue and its subclasses do not allow null values in
|
||||
// anticipation of NNBD (non-nullability by default).
|
||||
//
|
||||
// If necessary, we can in the future define a new subclass hierarchy that
|
||||
// does allow null values for primitive types. Borrowing from lisp where
|
||||
// functions that returned a bool ended in 'p', a suggested naming scheme for
|
||||
// these new subclasses could be to add 'N' (for nullable) to the end of a
|
||||
// class name (e.g. RestorableIntN, RestorableStringN, etc.) to distinguish them
|
||||
// from their non-nullable friends.
|
||||
class _RestorablePrimitiveValue<T> extends RestorableValue<T> {
|
||||
_RestorablePrimitiveValue(this._defaultValue)
|
||||
: assert(_defaultValue != null),
|
||||
assert(debugIsSerializableForRestoration(_defaultValue)),
|
||||
super();
|
||||
|
||||
final T _defaultValue;
|
||||
|
||||
@override
|
||||
T createDefaultValue() => _defaultValue;
|
||||
|
||||
@override
|
||||
set value(T value) {
|
||||
assert(value != null);
|
||||
super.value = value;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateValue(T oldValue) {
|
||||
assert(debugIsSerializableForRestoration(value));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
T fromPrimitives(Object serialized) {
|
||||
assert(serialized != null);
|
||||
return serialized as T;
|
||||
}
|
||||
|
||||
@override
|
||||
Object toPrimitives() {
|
||||
assert(value != null);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/// A [RestorableProperty] that knows how to store and restore a [num].
|
||||
///
|
||||
/// {@template flutter.widgets.restoration.primitivevalue}
|
||||
/// The current [value] of this property is stored in the restoration data.
|
||||
/// During state restoration the property is restored to the value it had when
|
||||
/// the restoration data it is getting restored from was collected.
|
||||
///
|
||||
/// If no restoration data is available, [value] is initialized to the
|
||||
/// `defaultValue` given in the constructor.
|
||||
/// {@endtemplate}
|
||||
///
|
||||
/// Instead of using the more generic [RestorableNum] directly, consider using
|
||||
/// one of the more specific subclasses (e.g. [RestorableDouble] to store a
|
||||
/// [double] and [RestorableInt] to store an [int]).
|
||||
class RestorableNum<T extends num> extends _RestorablePrimitiveValue<T> {
|
||||
/// Creates a [RestorableNum].
|
||||
///
|
||||
/// {@template flutter.widgets.restoration.primitivevalue.constructor}
|
||||
/// If no restoration data is available to restore the value in this property
|
||||
/// from, the property will be initialized with the provided `defaultValue`.
|
||||
/// {@endtemplate}
|
||||
RestorableNum(T defaultValue) : assert(defaultValue != null), super(defaultValue);
|
||||
}
|
||||
|
||||
/// A [RestorableProperty] that knows how to store and restore a [double].
|
||||
///
|
||||
/// {@macro flutter.widgets.restoration.primitivevalue}
|
||||
class RestorableDouble extends RestorableNum<double> {
|
||||
/// Creates a [RestorableDouble].
|
||||
///
|
||||
/// {@macro flutter.widgets.restoration.primitivevalue.constructor}
|
||||
RestorableDouble(double defaultValue) : assert(defaultValue != null), super(defaultValue);
|
||||
}
|
||||
|
||||
/// A [RestorableProperty] that knows how to store and restore an [int].
|
||||
///
|
||||
/// {@macro flutter.widgets.restoration.primitivevalue}
|
||||
class RestorableInt extends RestorableNum<int> {
|
||||
/// Creates a [RestorableInt].
|
||||
///
|
||||
/// {@macro flutter.widgets.restoration.primitivevalue.constructor}
|
||||
RestorableInt(int defaultValue) : assert(defaultValue != null), super(defaultValue);
|
||||
}
|
||||
|
||||
/// A [RestorableProperty] that knows how to store and restore a [String].
|
||||
///
|
||||
/// {@macro flutter.widgets.restoration.primitivevalue}
|
||||
class RestorableString extends _RestorablePrimitiveValue<String> {
|
||||
/// Creates a [RestorableString].
|
||||
///
|
||||
/// {@macro flutter.widgets.restoration.primitivevalue.constructor}
|
||||
RestorableString(String defaultValue) : assert(defaultValue != null), super(defaultValue);
|
||||
}
|
||||
|
||||
/// A [RestorableProperty] that knows how to store and restore a [bool].
|
||||
///
|
||||
/// {@macro flutter.widgets.restoration.primitivevalue}
|
||||
class RestorableBool extends _RestorablePrimitiveValue<bool> {
|
||||
/// Creates a [RestorableBool].
|
||||
///
|
||||
/// {@macro flutter.widgets.restoration.primitivevalue.constructor}
|
||||
RestorableBool(bool defaultValue) : assert(defaultValue != null), super(defaultValue);
|
||||
}
|
||||
|
||||
/// A base class for creating a [RestorableProperty] that stores and restores a
|
||||
/// [Listenable].
|
||||
///
|
||||
/// This class may be used to implement a [RestorableProperty] for a
|
||||
/// [Listenable], whose information it needs to store in the restoration data
|
||||
/// change whenever the [Listenable] notifies its listeners.
|
||||
///
|
||||
/// The [RestorationMixin] this property is registered with will call
|
||||
/// [toPrimitives] whenever the wrapped [Listenable] notifies its listeners to
|
||||
/// update the information that this property has stored in the restoration
|
||||
/// data.
|
||||
abstract class RestorableListenable<T extends Listenable> extends RestorableProperty<T> {
|
||||
/// The [Listenable] stored in this property.
|
||||
///
|
||||
/// A representation of the current value of the [Listenable] is stored in the
|
||||
/// restoration data. During state restoration, the [Listenable] returned by
|
||||
/// this getter will be restored to the state it had when the restoration data
|
||||
/// the property is getting restored from was collected.
|
||||
///
|
||||
/// The [value] can only be accessed after the property has been registered
|
||||
/// with a [RestorationMixin] by calling
|
||||
/// [RestorationMixin.registerForRestoration].
|
||||
T get value {
|
||||
assert(isRegistered);
|
||||
return _value;
|
||||
}
|
||||
T _value;
|
||||
|
||||
@override
|
||||
void initWithValue(T value) {
|
||||
assert(value != null);
|
||||
_value?.removeListener(notifyListeners);
|
||||
_value = value;
|
||||
_value.addListener(notifyListeners);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_value?.removeListener(notifyListeners);
|
||||
}
|
||||
}
|
||||
|
||||
/// A [RestorableProperty] that knows how to store and restore a
|
||||
/// [TextEditingController].
|
||||
///
|
||||
/// The [TextEditingController] is accessible via the [value] getter. During
|
||||
/// state restoration, the property will restore [TextEditingController.text] to
|
||||
/// the value it had when the restoration data it is getting restored from was
|
||||
/// collected.
|
||||
class RestorableTextEditingController extends RestorableListenable<TextEditingController> {
|
||||
/// Creates a [RestorableTextEditingController].
|
||||
///
|
||||
/// This constructor treats a null `text` argument as if it were the empty
|
||||
/// string.
|
||||
factory RestorableTextEditingController({String text}) => RestorableTextEditingController.fromValue(
|
||||
text == null ? TextEditingValue.empty : TextEditingValue(text: text),
|
||||
);
|
||||
|
||||
/// Creates a [RestorableTextEditingController] from an initial
|
||||
/// [TextEditingValue].
|
||||
///
|
||||
/// This constructor treats a null `value` argument as if it were
|
||||
/// [TextEditingValue.empty].
|
||||
RestorableTextEditingController.fromValue(TextEditingValue value) : _initialValue = value;
|
||||
|
||||
final TextEditingValue _initialValue;
|
||||
|
||||
@override
|
||||
TextEditingController createDefaultValue() {
|
||||
return TextEditingController.fromValue(_initialValue);
|
||||
}
|
||||
|
||||
@override
|
||||
TextEditingController fromPrimitives(Object data) {
|
||||
return TextEditingController(text: data as String);
|
||||
}
|
||||
|
||||
@override
|
||||
Object toPrimitives() {
|
||||
return value.text;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (isRegistered) {
|
||||
value.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@ -82,6 +82,8 @@ export 'src/widgets/platform_view.dart';
|
||||
export 'src/widgets/preferred_size.dart';
|
||||
export 'src/widgets/primary_scroll_controller.dart';
|
||||
export 'src/widgets/raw_keyboard_listener.dart';
|
||||
export 'src/widgets/restoration.dart';
|
||||
export 'src/widgets/restoration_properties.dart';
|
||||
export 'src/widgets/routes.dart';
|
||||
export 'src/widgets/safe_area.dart';
|
||||
export 'src/widgets/scroll_activity.dart';
|
||||
|
||||
67
packages/flutter/test/services/restoration.dart
Normal file
67
packages/flutter/test/services/restoration.dart
Normal file
@ -0,0 +1,67 @@
|
||||
// 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.
|
||||
|
||||
// @dart = 2.8
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class MockRestorationManager extends TestRestorationManager {
|
||||
bool get updateScheduled => _updateScheduled;
|
||||
bool _updateScheduled = false;
|
||||
|
||||
final List<RestorationBucket> _buckets = <RestorationBucket>[];
|
||||
|
||||
@override
|
||||
void scheduleSerializationFor(RestorationBucket bucket) {
|
||||
_updateScheduled = true;
|
||||
_buckets.add(bucket);
|
||||
}
|
||||
|
||||
@override
|
||||
bool unscheduleSerializationFor(RestorationBucket bucket) {
|
||||
_updateScheduled = true;
|
||||
return _buckets.remove(bucket);
|
||||
}
|
||||
|
||||
void doSerialization() {
|
||||
_updateScheduled = false;
|
||||
for (final RestorationBucket bucket in _buckets) {
|
||||
bucket.finalize();
|
||||
}
|
||||
_buckets.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void restoreFrom(TestRestorationData data) {
|
||||
// Ignore in mock.
|
||||
}
|
||||
|
||||
int rootBucketAccessed = 0;
|
||||
|
||||
@override
|
||||
Future<RestorationBucket> get rootBucket {
|
||||
rootBucketAccessed++;
|
||||
return _rootBucket;
|
||||
}
|
||||
Future<RestorationBucket> _rootBucket;
|
||||
set rootBucket(Future<RestorationBucket> value) {
|
||||
_rootBucket = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Future<void> sendToEngine(Uint8List encodedData) {
|
||||
throw UnimplementedError('unimplemented in mock');
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'MockManager';
|
||||
}
|
||||
|
||||
const String childrenMapKey = 'c';
|
||||
const String valuesMapKey = 'v';
|
||||
629
packages/flutter/test/services/restoration_bucket_test.dart
Normal file
629
packages/flutter/test/services/restoration_bucket_test.dart
Normal file
@ -0,0 +1,629 @@
|
||||
// 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.
|
||||
|
||||
// @dart = 2.8
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'restoration.dart';
|
||||
|
||||
void main() {
|
||||
test('root bucket values', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
expect(bucket.restorationId, 'root');
|
||||
expect(bucket.debugOwner, manager);
|
||||
|
||||
// Bucket contains expected values from rawData.
|
||||
expect(bucket.read<int>('value1'), 10);
|
||||
expect(bucket.read<String>('value2'), 'Hello');
|
||||
expect(bucket.read<String>('value3'), isNull); // Does not exist.
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
// Can overwrite existing value.
|
||||
bucket.write<int>('value1', 22);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(bucket.read<int>('value1'), 22);
|
||||
manager.doSerialization();
|
||||
expect(rawData[valuesMapKey]['value1'], 22);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
// Can add a new value.
|
||||
bucket.write<bool>('value3', true);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(bucket.read<bool>('value3'), true);
|
||||
manager.doSerialization();
|
||||
expect(rawData[valuesMapKey]['value3'], true);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
// Can remove existing value.
|
||||
expect(bucket.remove<int>('value1'), 22);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(bucket.read<int>('value1'), isNull); // Does not exist anymore.
|
||||
manager.doSerialization();
|
||||
expect(rawData[valuesMapKey].containsKey('value1'), isFalse);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
// Removing non-existing value is no-op.
|
||||
expect(bucket.remove<Object>('value4'), isNull);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
// Can store null.
|
||||
bucket.write<bool>('value4', null);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(bucket.read<int>('value4'), null);
|
||||
manager.doSerialization();
|
||||
expect(rawData[valuesMapKey].containsKey('value4'), isTrue);
|
||||
expect(rawData[valuesMapKey]['value4'], null);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
});
|
||||
|
||||
test('child bucket values', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rootRawData = _createRawDataSet();
|
||||
final Object debugOwner = Object();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rootRawData);
|
||||
final RestorationBucket child = RestorationBucket.child(
|
||||
restorationId: 'child1',
|
||||
parent: root,
|
||||
debugOwner: debugOwner,
|
||||
);
|
||||
|
||||
expect(child.restorationId, 'child1');
|
||||
expect(child.debugOwner, debugOwner);
|
||||
|
||||
// Bucket contains expected values from rawData.
|
||||
expect(child.read<int>('foo'), 22);
|
||||
expect(child.read<String>('bar'), isNull); // Does not exist.
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
// Can overwrite existing value.
|
||||
child.write<int>('foo', 44);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(child.read<int>('foo'), 44);
|
||||
manager.doSerialization();
|
||||
expect(rootRawData[childrenMapKey]['child1'][valuesMapKey]['foo'], 44);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
// Can add a new value.
|
||||
child.write<bool>('value3', true);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(child.read<bool>('value3'), true);
|
||||
manager.doSerialization();
|
||||
expect(rootRawData[childrenMapKey]['child1'][valuesMapKey]['value3'], true);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
// Can remove existing value.
|
||||
expect(child.remove<int>('foo'), 44);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(child.read<int>('foo'), isNull); // Does not exist anymore.
|
||||
manager.doSerialization();
|
||||
expect(rootRawData[childrenMapKey]['child1'].containsKey('foo'), isFalse);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
// Removing non-existing value is no-op.
|
||||
expect(child.remove<Object>('value4'), isNull);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
// Can store null.
|
||||
child.write<bool>('value4', null);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(child.read<int>('value4'), null);
|
||||
manager.doSerialization();
|
||||
expect(rootRawData[childrenMapKey]['child1'][valuesMapKey].containsKey('value4'), isTrue);
|
||||
expect(rootRawData[childrenMapKey]['child1'][valuesMapKey]['value4'], null);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
});
|
||||
|
||||
test('claim child with exisiting data', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final Object debugOwner = Object();
|
||||
final RestorationBucket child = bucket.claimChild('child1', debugOwner: debugOwner);
|
||||
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
expect(child.restorationId, 'child1');
|
||||
expect(child.debugOwner, debugOwner);
|
||||
|
||||
expect(child.read<int>('foo'), 22);
|
||||
child.write('bar', 44);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(rawData[childrenMapKey]['child1'][valuesMapKey]['bar'], 44);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
});
|
||||
|
||||
test('claim child with no exisiting data', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('child2'), isFalse);
|
||||
|
||||
final Object debugOwner = Object();
|
||||
final RestorationBucket child = bucket.claimChild('child2', debugOwner: debugOwner);
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(child.restorationId, 'child2');
|
||||
expect(child.debugOwner, debugOwner);
|
||||
|
||||
child.write('foo', 55);
|
||||
expect(child.read<int>('foo'), 55);
|
||||
manager.doSerialization();
|
||||
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
expect(rawData[childrenMapKey].containsKey('child2'), isTrue);
|
||||
expect(rawData[childrenMapKey]['child2'][valuesMapKey]['foo'], 55);
|
||||
});
|
||||
|
||||
test('claim child that is already claimed throws if not given up', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child1 = bucket.claimChild('child1', debugOwner: 'FirstClaim');
|
||||
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
expect(child1.restorationId, 'child1');
|
||||
expect(child1.read<int>('foo'), 22);
|
||||
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
final RestorationBucket child2 = bucket.claimChild('child1', debugOwner: 'SecondClaim');
|
||||
expect(child2.restorationId, 'child1');
|
||||
expect(child2.read<int>('foo'), isNull); // Value does not exist in this child.
|
||||
|
||||
// child1 is not given up before running finalizers.
|
||||
try {
|
||||
manager.doSerialization();
|
||||
fail('expected error');
|
||||
} on FlutterError catch (e) {
|
||||
expect(
|
||||
e.message,
|
||||
'Multiple owners claimed child RestorationBuckets with the same IDs.\n'
|
||||
'The following IDs were claimed multiple times from the parent RestorationBucket(restorationId: root, owner: MockManager):\n'
|
||||
' * "child1" was claimed by:\n'
|
||||
' * SecondClaim\n'
|
||||
' * FirstClaim (current owner)'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('claim child that is already claimed does not throw if given up', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child1 = bucket.claimChild('child1', debugOwner: 'FirstClaim');
|
||||
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
expect(child1.restorationId, 'child1');
|
||||
expect(child1.read<int>('foo'), 22);
|
||||
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
final RestorationBucket child2 = bucket.claimChild('child1', debugOwner: 'SecondClaim');
|
||||
expect(child2.restorationId, 'child1');
|
||||
expect(child2.read<int>('foo'), isNull); // Value does not exist in this child.
|
||||
child2.write<int>('bar', 55);
|
||||
|
||||
// give up child1.
|
||||
child1.dispose();
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
expect(rawData[childrenMapKey]['child1'][valuesMapKey].containsKey('foo'), isFalse);
|
||||
expect(rawData[childrenMapKey]['child1'][valuesMapKey]['bar'], 55);
|
||||
});
|
||||
|
||||
test('claiming a claimed child twice and only giving it up once throws', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child1 = bucket.claimChild('child1', debugOwner: 'FirstClaim');
|
||||
expect(child1.restorationId, 'child1');
|
||||
final RestorationBucket child2 = bucket.claimChild('child1', debugOwner: 'SecondClaim');
|
||||
expect(child2.restorationId, 'child1');
|
||||
child1.dispose();
|
||||
final RestorationBucket child3 = bucket.claimChild('child1', debugOwner: 'ThirdClaim');
|
||||
expect(child3.restorationId, 'child1');
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(() => manager.doSerialization(), throwsFlutterError);
|
||||
});
|
||||
|
||||
test('unclaiming and then claiming same id gives fresh bucket', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child1 = bucket.claimChild('child1', debugOwner: 'FirstClaim');
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
expect(child1.read<int>('foo'), 22);
|
||||
child1.dispose();
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
final RestorationBucket child2 = bucket.claimChild('child1', debugOwner: 'SecondClaim');
|
||||
expect(child2.read<int>('foo'), isNull);
|
||||
});
|
||||
|
||||
test('cleans up raw data if last value/child is dropped', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
expect(rawData.containsKey(childrenMapKey), isTrue);
|
||||
final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner');
|
||||
child.dispose();
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(rawData.containsKey(childrenMapKey), isFalse);
|
||||
|
||||
expect(rawData.containsKey(valuesMapKey), isTrue);
|
||||
expect(root.remove<int>('value1'), 10);
|
||||
expect(root.remove<String>('value2'), 'Hello');
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(rawData.containsKey(valuesMapKey), isFalse);
|
||||
});
|
||||
|
||||
test('dispose deletes data', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1');
|
||||
child1.claimChild('child1OfChild1', debugOwner: 'owner1.1');
|
||||
child1.claimChild('child2OfChild1', debugOwner: 'owner1.2');
|
||||
final RestorationBucket child2 = root.claimChild('child2', debugOwner: 'owner2');
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isTrue);
|
||||
expect(rawData[childrenMapKey].containsKey('child2'), isTrue);
|
||||
|
||||
child1.dispose();
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isFalse);
|
||||
|
||||
child2.dispose();
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
expect(rawData.containsKey(childrenMapKey), isFalse);
|
||||
});
|
||||
|
||||
test('rename is no-op if same id', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner1');
|
||||
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
expect(child.restorationId, 'child1');
|
||||
child.rename('child1');
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
expect(child.restorationId, 'child1');
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isTrue);
|
||||
});
|
||||
|
||||
test('rename to unused id', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner1');
|
||||
final Object rawChildData = rawData[childrenMapKey]['child1'];
|
||||
expect(rawChildData, isNotNull);
|
||||
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
expect(child.restorationId, 'child1');
|
||||
child.rename('new-name');
|
||||
expect(child.restorationId, 'new-name');
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isFalse);
|
||||
expect(rawData[childrenMapKey]['new-name'], rawChildData);
|
||||
});
|
||||
|
||||
test('rename to used id throws if id is not given up', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1');
|
||||
final RestorationBucket child2 = root.claimChild('child2', debugOwner: 'owner1');
|
||||
manager.doSerialization();
|
||||
|
||||
expect(child1.restorationId, 'child1');
|
||||
expect(child2.restorationId, 'child2');
|
||||
child2.rename('child1');
|
||||
expect(child2.restorationId, 'child1');
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(() => manager.doSerialization(), throwsFlutterError);
|
||||
});
|
||||
|
||||
test('rename to used id does not throw if id is given up', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1');
|
||||
final RestorationBucket child2 = root.claimChild('child2', debugOwner: 'owner1');
|
||||
manager.doSerialization();
|
||||
|
||||
final Object rawChild1Data = rawData[childrenMapKey]['child1'];
|
||||
expect(rawChild1Data, isNotNull);
|
||||
final Object rawChild2Data = rawData[childrenMapKey]['child2'];
|
||||
expect(rawChild2Data, isNotNull);
|
||||
|
||||
expect(child1.restorationId, 'child1');
|
||||
expect(child2.restorationId, 'child2');
|
||||
child2.rename('child1');
|
||||
expect(child2.restorationId, 'child1');
|
||||
expect(child1.restorationId, 'child1');
|
||||
|
||||
child1.dispose();
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
expect(rawData[childrenMapKey]['child1'], rawChild2Data);
|
||||
expect(rawData[childrenMapKey].containsKey('child2'), isFalse);
|
||||
});
|
||||
|
||||
test('renaming a to be added child', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final Object rawChild1Data = rawData[childrenMapKey]['child1'];
|
||||
expect(rawChild1Data, isNotNull);
|
||||
|
||||
final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1');
|
||||
final RestorationBucket child2 = root.claimChild('child1', debugOwner: 'owner1');
|
||||
|
||||
child2.rename('foo');
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
expect(child1.restorationId, 'child1');
|
||||
expect(child2.restorationId, 'foo');
|
||||
|
||||
expect(rawData[childrenMapKey]['child1'], rawChild1Data);
|
||||
expect(rawData[childrenMapKey]['foo'], isEmpty); // new bucket
|
||||
});
|
||||
|
||||
test('adopt is no-op if same parent', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1');
|
||||
|
||||
root.adoptChild(child1);
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isTrue);
|
||||
});
|
||||
|
||||
test('adopt fresh child', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child = RestorationBucket.empty(restorationId: 'fresh-child', debugOwner: 'owner1');
|
||||
|
||||
root.adoptChild(child);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
|
||||
child.write('value', 22);
|
||||
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('fresh-child'), isTrue);
|
||||
expect(rawData[childrenMapKey]['fresh-child'][valuesMapKey]['value'], 22);
|
||||
|
||||
child.write('bar', 'blabla');
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
});
|
||||
|
||||
test('adopt child that already had a parent', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner1');
|
||||
final RestorationBucket childOfChild = child.claimChild('childOfChild', debugOwner: 'owner2');
|
||||
childOfChild.write<String>('foo', 'bar');
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
final Object childOfChildData = rawData[childrenMapKey]['child1'][childrenMapKey]['childOfChild'];
|
||||
expect(childOfChildData, isNotEmpty);
|
||||
|
||||
root.adoptChild(childOfChild);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
expect(rawData[childrenMapKey]['child1'].containsKey(childrenMapKey), isFalse); // child1 has no children anymore.
|
||||
expect(rawData[childrenMapKey]['childOfChild'], childOfChildData);
|
||||
});
|
||||
|
||||
test('adopting child throws if id is already in use and not given up', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner1');
|
||||
final RestorationBucket childOfChild = child.claimChild('child1', debugOwner: 'owner2');
|
||||
childOfChild.write<String>('foo', 'bar');
|
||||
|
||||
root.adoptChild(childOfChild);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
expect(() => manager.doSerialization(), throwsFlutterError);
|
||||
});
|
||||
|
||||
test('adopting child does not throw if id is already in use and given up', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner1');
|
||||
final RestorationBucket childOfChild = child.claimChild('child1', debugOwner: 'owner2');
|
||||
childOfChild.write<String>('foo', 'bar');
|
||||
|
||||
final Object childOfChildData = rawData[childrenMapKey]['child1'][childrenMapKey]['child1'];
|
||||
expect(childOfChildData, isNotEmpty);
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
root.adoptChild(childOfChild);
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
child.dispose();
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
expect(rawData[childrenMapKey]['child1'], childOfChildData);
|
||||
});
|
||||
|
||||
test('adopting a to-be-added child under an already in use id', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1');
|
||||
final RestorationBucket child2 = root.claimChild('child2', debugOwner: 'owner1');
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
final RestorationBucket child1OfChild1 = child1.claimChild('child2', debugOwner: 'owner2');
|
||||
child1OfChild1.write<String>('hello', 'world');
|
||||
final RestorationBucket child2OfChild1 = child1.claimChild('child2', debugOwner: 'owner2');
|
||||
child2OfChild1.write<String>('foo', 'bar');
|
||||
|
||||
root.adoptChild(child2OfChild1);
|
||||
child2.dispose();
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
expect(rawData[childrenMapKey]['child2'][valuesMapKey]['foo'], 'bar');
|
||||
expect(rawData[childrenMapKey]['child1'][childrenMapKey]['child2'][valuesMapKey]['hello'], 'world');
|
||||
});
|
||||
|
||||
test('decommission drops itself from parent and notifies all listeners', () {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1');
|
||||
final RestorationBucket child2 = root.claimChild('child2', debugOwner: 'owner1');
|
||||
final RestorationBucket childOfChild1 = child1.claimChild('child1.1', debugOwner: 'owner1');
|
||||
final RestorationBucket childOfChildOfChild1 = childOfChild1.claimChild('child1.1.1', debugOwner: 'owner1');
|
||||
|
||||
expect(manager.updateScheduled, isTrue);
|
||||
manager.doSerialization();
|
||||
expect(manager.updateScheduled, isFalse);
|
||||
|
||||
bool rootDecommissioned = false;
|
||||
root.addListener(() {
|
||||
rootDecommissioned = true;
|
||||
});
|
||||
bool child1Decommissioned = false;
|
||||
child1.addListener(() {
|
||||
child1Decommissioned = true;
|
||||
});
|
||||
bool child2Decommissioned = false;
|
||||
child2.addListener(() {
|
||||
child2Decommissioned = true;
|
||||
});
|
||||
bool childOfChild1Decommissioned = false;
|
||||
childOfChild1.addListener(() {
|
||||
childOfChild1Decommissioned = true;
|
||||
});
|
||||
bool childOfChildOfChild1Decommissioned = false;
|
||||
childOfChildOfChild1.addListener(() {
|
||||
childOfChildOfChild1Decommissioned = true;
|
||||
});
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isTrue);
|
||||
|
||||
child1.decommission();
|
||||
expect(rootDecommissioned, isFalse);
|
||||
expect(child2Decommissioned, isFalse);
|
||||
expect(child1Decommissioned, isTrue);
|
||||
expect(childOfChild1Decommissioned, isTrue);
|
||||
expect(childOfChildOfChild1Decommissioned, isTrue);
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isFalse);
|
||||
});
|
||||
|
||||
test('throws when used after dispose', () {
|
||||
final RestorationBucket bucket = RestorationBucket.empty(restorationId: 'foo', debugOwner: null);
|
||||
bucket.dispose();
|
||||
|
||||
expect(() => bucket.debugOwner, throwsFlutterError);
|
||||
expect(() => bucket.restorationId, throwsFlutterError);
|
||||
expect(() => bucket.decommission(), throwsFlutterError);
|
||||
expect(() => bucket.read<int>('foo'), throwsFlutterError);
|
||||
expect(() => bucket.write('foo', 10), throwsFlutterError);
|
||||
expect(() => bucket.remove<int>('foo'), throwsFlutterError);
|
||||
expect(() => bucket.contains('foo'), throwsFlutterError);
|
||||
expect(() => bucket.claimChild('child', debugOwner: null), throwsFlutterError);
|
||||
final RestorationBucket child = RestorationBucket.empty(restorationId: 'child', debugOwner: null);
|
||||
expect(() => bucket.adoptChild(child), throwsFlutterError);
|
||||
expect(() => bucket.rename('bar'), throwsFlutterError);
|
||||
expect(() => bucket.dispose(), throwsFlutterError);
|
||||
});
|
||||
|
||||
test('cannot serialize without manager', () {
|
||||
final RestorationBucket bucket = RestorationBucket.empty(restorationId: 'foo', debugOwner: null);
|
||||
expect(() => bucket.write('foo', 10), throwsAssertionError);
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _createRawDataSet() {
|
||||
return <String, dynamic>{
|
||||
valuesMapKey: <String, dynamic>{
|
||||
'value1' : 10,
|
||||
'value2' : 'Hello',
|
||||
},
|
||||
childrenMapKey: <String, dynamic>{
|
||||
'child1' : <String, dynamic>{
|
||||
valuesMapKey : <String, dynamic>{
|
||||
'foo': 22,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
270
packages/flutter/test/services/restoration_test.dart
Normal file
270
packages/flutter/test/services/restoration_test.dart
Normal file
@ -0,0 +1,270 @@
|
||||
// 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.
|
||||
|
||||
// @dart = 2.8
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'restoration.dart';
|
||||
|
||||
void main() {
|
||||
group('RestorationManager', () {
|
||||
testWidgets('root bucket retrieval', (WidgetTester tester) async {
|
||||
final List<MethodCall> callsToEngine = <MethodCall>[];
|
||||
final Completer<Map<dynamic, dynamic>> result = Completer<Map<dynamic, dynamic>>();
|
||||
SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) {
|
||||
callsToEngine.add(call);
|
||||
return result.future;
|
||||
});
|
||||
|
||||
final RestorationManager manager = RestorationManager();
|
||||
final Future<RestorationBucket> rootBucketFuture = manager.rootBucket;
|
||||
RestorationBucket rootBucket;
|
||||
rootBucketFuture.then((RestorationBucket bucket) {
|
||||
rootBucket = bucket;
|
||||
});
|
||||
expect(rootBucketFuture, isNotNull);
|
||||
expect(rootBucket, isNull);
|
||||
|
||||
// Accessing rootBucket again gives same future.
|
||||
expect(manager.rootBucket, same(rootBucketFuture));
|
||||
|
||||
// Engine has only been contacted once.
|
||||
expect(callsToEngine, hasLength(1));
|
||||
expect(callsToEngine.single.method, 'get');
|
||||
|
||||
// Complete the engine request.
|
||||
result.complete(_createEncodedRestorationData1());
|
||||
await tester.pump();
|
||||
|
||||
// Root bucket future completed.
|
||||
expect(rootBucket, isNotNull);
|
||||
|
||||
// Root bucket contains the expected data.
|
||||
expect(rootBucket.read<int>('value1'), 10);
|
||||
expect(rootBucket.read<String>('value2'), 'Hello');
|
||||
final RestorationBucket child = rootBucket.claimChild('child1', debugOwner: null);
|
||||
expect(child.read<int>('another value'), 22);
|
||||
|
||||
// Accessing the root bucket again completes synchronously with same bucket.
|
||||
RestorationBucket synchronousBucket;
|
||||
manager.rootBucket.then((RestorationBucket bucket) {
|
||||
synchronousBucket = bucket;
|
||||
});
|
||||
expect(synchronousBucket, isNotNull);
|
||||
expect(synchronousBucket, same(rootBucket));
|
||||
});
|
||||
|
||||
testWidgets('root bucket received from engine before retrieval', (WidgetTester tester) async {
|
||||
SystemChannels.restoration.setMethodCallHandler(null);
|
||||
final List<MethodCall> callsToEngine = <MethodCall>[];
|
||||
SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) {
|
||||
callsToEngine.add(call);
|
||||
return null;
|
||||
});
|
||||
final RestorationManager manager = RestorationManager();
|
||||
|
||||
await _pushDataFromEngine(_createEncodedRestorationData1());
|
||||
|
||||
RestorationBucket rootBucket;
|
||||
manager.rootBucket.then((RestorationBucket bucket) => rootBucket = bucket);
|
||||
// Root bucket is available synchronously.
|
||||
expect(rootBucket, isNotNull);
|
||||
// Engine was never asked.
|
||||
expect(callsToEngine, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('root bucket received while engine retrieval is pending', (WidgetTester tester) async {
|
||||
SystemChannels.restoration.setMethodCallHandler(null);
|
||||
final List<MethodCall> callsToEngine = <MethodCall>[];
|
||||
final Completer<Map<dynamic, dynamic>> result = Completer<Map<dynamic, dynamic>>();
|
||||
SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) {
|
||||
callsToEngine.add(call);
|
||||
return result.future;
|
||||
});
|
||||
final RestorationManager manager = RestorationManager();
|
||||
|
||||
RestorationBucket rootBucket;
|
||||
manager.rootBucket.then((RestorationBucket bucket) => rootBucket = bucket);
|
||||
expect(rootBucket, isNull);
|
||||
expect(callsToEngine.single.method, 'get');
|
||||
|
||||
await _pushDataFromEngine(_createEncodedRestorationData1());
|
||||
expect(rootBucket, isNotNull);
|
||||
expect(rootBucket.read<int>('value1'), 10);
|
||||
|
||||
result.complete(_createEncodedRestorationData2());
|
||||
await tester.pump();
|
||||
|
||||
RestorationBucket rootBucket2;
|
||||
manager.rootBucket.then((RestorationBucket bucket) => rootBucket2 = bucket);
|
||||
expect(rootBucket2, isNotNull);
|
||||
expect(rootBucket2, same(rootBucket));
|
||||
expect(rootBucket2.read<int>('value1'), 10);
|
||||
expect(rootBucket2.contains('foo'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('root bucket is properly replaced when new data is available', (WidgetTester tester) async {
|
||||
SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) async {
|
||||
return _createEncodedRestorationData1();
|
||||
});
|
||||
final RestorationManager manager = RestorationManager();
|
||||
RestorationBucket rootBucket;
|
||||
manager.rootBucket.then((RestorationBucket bucket) {
|
||||
rootBucket = bucket;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(rootBucket, isNotNull);
|
||||
expect(rootBucket.read<int>('value1'), 10);
|
||||
final RestorationBucket child = rootBucket.claimChild('child1', debugOwner: null);
|
||||
expect(child.read<int>('another value'), 22);
|
||||
|
||||
bool rootDecommissioned = false;
|
||||
bool childDecommissioned = false;
|
||||
RestorationBucket newRoot;
|
||||
rootBucket.addListener(() {
|
||||
rootDecommissioned = true;
|
||||
manager.rootBucket.then((RestorationBucket bucket) {
|
||||
newRoot = bucket;
|
||||
});
|
||||
// The new bucket is available synchronously.
|
||||
expect(newRoot, isNotNull);
|
||||
});
|
||||
child.addListener(() {
|
||||
childDecommissioned = true;
|
||||
});
|
||||
|
||||
// Send new Data.
|
||||
await _pushDataFromEngine(_createEncodedRestorationData2());
|
||||
|
||||
expect(rootDecommissioned, isTrue);
|
||||
expect(childDecommissioned, isTrue);
|
||||
expect(newRoot, isNot(same(rootBucket)));
|
||||
|
||||
child.dispose();
|
||||
|
||||
expect(newRoot.read<int>('foo'), 33);
|
||||
expect(newRoot.read<int>('value1'), null);
|
||||
final RestorationBucket newChild = newRoot.claimChild('childFoo', debugOwner: null);
|
||||
expect(newChild.read<String>('bar'), 'Hello');
|
||||
});
|
||||
|
||||
testWidgets('returns null as root bucket when restoration is disabled', (WidgetTester tester) async {
|
||||
final List<MethodCall> callsToEngine = <MethodCall>[];
|
||||
final Completer<Map<dynamic, dynamic>> result = Completer<Map<dynamic, dynamic>>();
|
||||
SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) {
|
||||
callsToEngine.add(call);
|
||||
return result.future;
|
||||
});
|
||||
int listenerCount = 0;
|
||||
final RestorationManager manager = RestorationManager()..addListener(() {
|
||||
listenerCount++;
|
||||
});
|
||||
RestorationBucket rootBucket;
|
||||
bool rootBucketResolved = false;
|
||||
manager.rootBucket.then((RestorationBucket bucket) {
|
||||
rootBucketResolved = true;
|
||||
rootBucket = bucket;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(rootBucketResolved, isFalse);
|
||||
expect(listenerCount, 0);
|
||||
|
||||
result.complete(_packageRestorationData(enabled: false));
|
||||
await tester.pump();
|
||||
expect(rootBucketResolved, isTrue);
|
||||
expect(rootBucket, isNull);
|
||||
|
||||
// Switch to non-null.
|
||||
await _pushDataFromEngine(_createEncodedRestorationData1());
|
||||
expect(listenerCount, 1);
|
||||
manager.rootBucket.then((RestorationBucket bucket) {
|
||||
rootBucket = bucket;
|
||||
});
|
||||
expect(rootBucket, isNotNull);
|
||||
|
||||
// Switch to null again.
|
||||
await _pushDataFromEngine(_packageRestorationData(enabled: false));
|
||||
expect(listenerCount, 2);
|
||||
manager.rootBucket.then((RestorationBucket bucket) {
|
||||
rootBucket = bucket;
|
||||
});
|
||||
expect(rootBucket, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('debugIsSerializableForRestoration', () {
|
||||
expect(debugIsSerializableForRestoration(Object()), isFalse);
|
||||
expect(debugIsSerializableForRestoration(Container()), isFalse);
|
||||
|
||||
expect(debugIsSerializableForRestoration(null), isTrue);
|
||||
expect(debugIsSerializableForRestoration(147823), isTrue);
|
||||
expect(debugIsSerializableForRestoration(12.43), isTrue);
|
||||
expect(debugIsSerializableForRestoration('Hello World'), isTrue);
|
||||
expect(debugIsSerializableForRestoration(<int>[12, 13, 14]), isTrue);
|
||||
expect(debugIsSerializableForRestoration(<String, int>{'v1' : 10, 'v2' : 23}), isTrue);
|
||||
expect(debugIsSerializableForRestoration(<String, dynamic>{
|
||||
'hello': <int>[12, 12, 12],
|
||||
'world': <int, bool>{
|
||||
1: true,
|
||||
2: false,
|
||||
4: true,
|
||||
},
|
||||
}), isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pushDataFromEngine(Map<dynamic, dynamic> data) async {
|
||||
await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
|
||||
'flutter/restoration',
|
||||
const StandardMethodCodec().encodeMethodCall(MethodCall('push', data)),
|
||||
(_) { },
|
||||
);
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic> _createEncodedRestorationData1() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{
|
||||
valuesMapKey: <String, dynamic>{
|
||||
'value1' : 10,
|
||||
'value2' : 'Hello',
|
||||
},
|
||||
childrenMapKey: <String, dynamic>{
|
||||
'child1' : <String, dynamic>{
|
||||
valuesMapKey : <String, dynamic>{
|
||||
'another value': 22,
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
return _packageRestorationData(data: data);
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic> _createEncodedRestorationData2() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{
|
||||
valuesMapKey: <String, dynamic>{
|
||||
'foo' : 33,
|
||||
},
|
||||
childrenMapKey: <String, dynamic>{
|
||||
'childFoo' : <String, dynamic>{
|
||||
valuesMapKey : <String, dynamic>{
|
||||
'bar': 'Hello',
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
return _packageRestorationData(data: data);
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic> _packageRestorationData({bool enabled = true, Map<dynamic, dynamic> data}) {
|
||||
final ByteData encoded = const StandardMessageCodec().encodeMessage(data);
|
||||
return <dynamic, dynamic>{
|
||||
'enabled': enabled,
|
||||
'data': encoded == null ? null : encoded.buffer.asUint8List(encoded.offsetInBytes, encoded.lengthInBytes)
|
||||
};
|
||||
}
|
||||
377
packages/flutter/test/widgets/restorable_property_test.dart
Normal file
377
packages/flutter/test/widgets/restorable_property_test.dart
Normal file
@ -0,0 +1,377 @@
|
||||
// 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.
|
||||
|
||||
// @dart = 2.8
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('value is not accessible when not registered', (WidgetTester tester) async {
|
||||
expect(() => RestorableNum<num>(0).value, throwsAssertionError);
|
||||
expect(() => RestorableDouble(1.0).value, throwsAssertionError);
|
||||
expect(() => RestorableInt(1).value, throwsAssertionError);
|
||||
expect(() => RestorableString('hello').value, throwsAssertionError);
|
||||
expect(() => RestorableBool(true).value, throwsAssertionError);
|
||||
expect(() => RestorableTextEditingController().value, throwsAssertionError);
|
||||
expect(() => _TestRestorableValue().value, throwsAssertionError);
|
||||
});
|
||||
|
||||
testWidgets('cannot initialize with null', (WidgetTester tester) async {
|
||||
expect(() => RestorableNum<num>(null), throwsAssertionError);
|
||||
expect(() => RestorableDouble(null), throwsAssertionError);
|
||||
expect(() => RestorableInt(null), throwsAssertionError);
|
||||
expect(() => RestorableString(null).value, throwsAssertionError);
|
||||
expect(() => RestorableBool(null).value, throwsAssertionError);
|
||||
});
|
||||
|
||||
testWidgets('work when not in restoration scope', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const _RestorableWidget());
|
||||
|
||||
expect(find.text('hello world'), findsOneWidget);
|
||||
final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget));
|
||||
|
||||
// Initialized to default values.
|
||||
expect(state.numValue.value, 99);
|
||||
expect(state.doubleValue.value, 123.2);
|
||||
expect(state.intValue.value, 42);
|
||||
expect(state.stringValue.value, 'hello world');
|
||||
expect(state.boolValue.value, false);
|
||||
expect(state.controllerValue.value.text, 'FooBar');
|
||||
expect(state.objectValue.value, 55);
|
||||
|
||||
// Modify values.
|
||||
state.setProperties(() {
|
||||
state.numValue.value = 42.2;
|
||||
state.doubleValue.value = 441.3;
|
||||
state.intValue.value = 10;
|
||||
state.stringValue.value = 'guten tag';
|
||||
state.boolValue.value = true;
|
||||
state.controllerValue.value.text = 'blabla';
|
||||
state.objectValue.value = 53;
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
expect(state.numValue.value, 42.2);
|
||||
expect(state.doubleValue.value, 441.3);
|
||||
expect(state.intValue.value, 10);
|
||||
expect(state.stringValue.value, 'guten tag');
|
||||
expect(state.boolValue.value, true);
|
||||
expect(state.controllerValue.value.text, 'blabla');
|
||||
expect(state.objectValue.value, 53);
|
||||
expect(find.text('guten tag'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('restart and restore', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _RestorableWidget(),
|
||||
));
|
||||
|
||||
expect(find.text('hello world'), findsOneWidget);
|
||||
_RestorableWidgetState state = tester.state(find.byType(_RestorableWidget));
|
||||
|
||||
// Initialized to default values.
|
||||
expect(state.numValue.value, 99);
|
||||
expect(state.doubleValue.value, 123.2);
|
||||
expect(state.intValue.value, 42);
|
||||
expect(state.stringValue.value, 'hello world');
|
||||
expect(state.boolValue.value, false);
|
||||
expect(state.controllerValue.value.text, 'FooBar');
|
||||
expect(state.objectValue.value, 55);
|
||||
|
||||
// Modify values.
|
||||
state.setProperties(() {
|
||||
state.numValue.value = 42.2;
|
||||
state.doubleValue.value = 441.3;
|
||||
state.intValue.value = 10;
|
||||
state.stringValue.value = 'guten tag';
|
||||
state.boolValue.value = true;
|
||||
state.controllerValue.value.text = 'blabla';
|
||||
state.objectValue.value = 53;
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
expect(state.numValue.value, 42.2);
|
||||
expect(state.doubleValue.value, 441.3);
|
||||
expect(state.intValue.value, 10);
|
||||
expect(state.stringValue.value, 'guten tag');
|
||||
expect(state.boolValue.value, true);
|
||||
expect(state.controllerValue.value.text, 'blabla');
|
||||
expect(state.objectValue.value, 53);
|
||||
expect(find.text('guten tag'), findsOneWidget);
|
||||
|
||||
// Restores to previous values.
|
||||
await tester.restartAndRestore();
|
||||
final _RestorableWidgetState oldState = state;
|
||||
state = tester.state(find.byType(_RestorableWidget));
|
||||
expect(state, isNot(same(oldState)));
|
||||
|
||||
expect(state.numValue.value, 42.2);
|
||||
expect(state.doubleValue.value, 441.3);
|
||||
expect(state.intValue.value, 10);
|
||||
expect(state.stringValue.value, 'guten tag');
|
||||
expect(state.boolValue.value, true);
|
||||
expect(state.controllerValue.value.text, 'blabla');
|
||||
expect(state.objectValue.value, 53);
|
||||
expect(find.text('guten tag'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('cannot set to null', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _RestorableWidget(),
|
||||
));
|
||||
|
||||
expect(find.text('hello world'), findsOneWidget);
|
||||
final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget));
|
||||
|
||||
expect(() => state.numValue.value = null, throwsAssertionError);
|
||||
expect(() => state.doubleValue.value = null, throwsAssertionError);
|
||||
expect(() => state.intValue.value = null, throwsAssertionError);
|
||||
expect(() => state.stringValue.value = null, throwsAssertionError);
|
||||
expect(() => state.boolValue.value = null, throwsAssertionError);
|
||||
});
|
||||
|
||||
testWidgets('restore to older state', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _RestorableWidget(),
|
||||
));
|
||||
|
||||
expect(find.text('hello world'), findsOneWidget);
|
||||
final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget));
|
||||
|
||||
// Modify values.
|
||||
state.setProperties(() {
|
||||
state.numValue.value = 42.2;
|
||||
state.doubleValue.value = 441.3;
|
||||
state.intValue.value = 10;
|
||||
state.stringValue.value = 'guten tag';
|
||||
state.boolValue.value = true;
|
||||
state.controllerValue.value.text = 'blabla';
|
||||
state.objectValue.value = 53;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(find.text('guten tag'), findsOneWidget);
|
||||
|
||||
final TestRestorationData restorationData = await tester.getRestorationData();
|
||||
|
||||
// Modify values.
|
||||
state.setProperties(() {
|
||||
state.numValue.value = 20;
|
||||
state.doubleValue.value = 20.0;
|
||||
state.intValue.value = 20;
|
||||
state.stringValue.value = 'ciao';
|
||||
state.boolValue.value = false;
|
||||
state.controllerValue.value.text = 'blub';
|
||||
state.objectValue.value = 20;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(find.text('ciao'), findsOneWidget);
|
||||
final TextEditingController controller = state.controllerValue.value;
|
||||
|
||||
// Restore to previous.
|
||||
await tester.restoreFrom(restorationData);
|
||||
expect(state.numValue.value, 42.2);
|
||||
expect(state.doubleValue.value, 441.3);
|
||||
expect(state.intValue.value, 10);
|
||||
expect(state.stringValue.value, 'guten tag');
|
||||
expect(state.boolValue.value, true);
|
||||
expect(state.controllerValue.value.text, 'blabla');
|
||||
expect(state.objectValue.value, 53);
|
||||
expect(find.text('guten tag'), findsOneWidget);
|
||||
expect(state.controllerValue.value, isNot(same(controller)));
|
||||
|
||||
// Restore to empty data will re-initialize to default values.
|
||||
await tester.restoreFrom(TestRestorationData.empty);
|
||||
expect(state.numValue.value, 99);
|
||||
expect(state.doubleValue.value, 123.2);
|
||||
expect(state.intValue.value, 42);
|
||||
expect(state.stringValue.value, 'hello world');
|
||||
expect(state.boolValue.value, false);
|
||||
expect(state.controllerValue.value.text, 'FooBar');
|
||||
expect(state.objectValue.value, 55);
|
||||
expect(find.text('hello world'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('call notifiers when value changes', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _RestorableWidget(),
|
||||
));
|
||||
|
||||
expect(find.text('hello world'), findsOneWidget);
|
||||
final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget));
|
||||
|
||||
final List<String> notifyLog = <String>[];
|
||||
state.numValue.addListener(() {
|
||||
notifyLog.add('num');
|
||||
});
|
||||
state.doubleValue.addListener(() {
|
||||
notifyLog.add('double');
|
||||
});
|
||||
state.intValue.addListener(() {
|
||||
notifyLog.add('int');
|
||||
});
|
||||
state.stringValue.addListener(() {
|
||||
notifyLog.add('string');
|
||||
});
|
||||
state.boolValue.addListener(() {
|
||||
notifyLog.add('bool');
|
||||
});
|
||||
state.controllerValue.addListener(() {
|
||||
notifyLog.add('controller');
|
||||
});
|
||||
state.objectValue.addListener(() {
|
||||
notifyLog.add('object');
|
||||
});
|
||||
|
||||
state.setProperties(() {
|
||||
state.numValue.value = 42.2;
|
||||
});
|
||||
expect(notifyLog.single, 'num');
|
||||
notifyLog.clear();
|
||||
|
||||
state.setProperties(() {
|
||||
state.doubleValue.value = 42.2;
|
||||
});
|
||||
expect(notifyLog.single, 'double');
|
||||
notifyLog.clear();
|
||||
|
||||
state.setProperties(() {
|
||||
state.intValue.value = 45;
|
||||
});
|
||||
expect(notifyLog.single, 'int');
|
||||
notifyLog.clear();
|
||||
|
||||
state.setProperties(() {
|
||||
state.stringValue.value = 'bar';
|
||||
});
|
||||
expect(notifyLog.single, 'string');
|
||||
notifyLog.clear();
|
||||
|
||||
state.setProperties(() {
|
||||
state.boolValue.value = true;
|
||||
});
|
||||
expect(notifyLog.single, 'bool');
|
||||
notifyLog.clear();
|
||||
|
||||
state.setProperties(() {
|
||||
state.controllerValue.value.text = 'foo';
|
||||
});
|
||||
expect(notifyLog.single, 'controller');
|
||||
notifyLog.clear();
|
||||
|
||||
state.setProperties(() {
|
||||
state.objectValue.value = 42;
|
||||
});
|
||||
expect(notifyLog.single, 'object');
|
||||
notifyLog.clear();
|
||||
|
||||
await tester.pump();
|
||||
expect(find.text('bar'), findsOneWidget);
|
||||
|
||||
// Does not notify when set to same value.
|
||||
state.setProperties(() {
|
||||
state.numValue.value = 42.2;
|
||||
state.doubleValue.value = 42.2;
|
||||
state.intValue.value = 45;
|
||||
state.stringValue.value = 'bar';
|
||||
state.boolValue.value = true;
|
||||
state.controllerValue.value.text = 'foo';
|
||||
});
|
||||
expect(notifyLog, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('RestorableValue calls didUpdateValue', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _RestorableWidget(),
|
||||
));
|
||||
|
||||
expect(find.text('hello world'), findsOneWidget);
|
||||
final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget));
|
||||
|
||||
expect(state.objectValue.didUpdateValueCallCount, 0);
|
||||
|
||||
state.setProperties(() {
|
||||
state.objectValue.value = 44;
|
||||
});
|
||||
expect(state.objectValue.didUpdateValueCallCount, 1);
|
||||
|
||||
await tester.pump();
|
||||
|
||||
state.setProperties(() {
|
||||
state.objectValue.value = 44;
|
||||
});
|
||||
expect(state.objectValue.didUpdateValueCallCount, 1);
|
||||
});
|
||||
}
|
||||
|
||||
class _TestRestorableValue extends RestorableValue<Object> {
|
||||
@override
|
||||
Object createDefaultValue() {
|
||||
return 55;
|
||||
}
|
||||
|
||||
int didUpdateValueCallCount = 0;
|
||||
|
||||
@override
|
||||
void didUpdateValue(Object oldValue) {
|
||||
didUpdateValueCallCount++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Object fromPrimitives(Object data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
@override
|
||||
Object toPrimitives() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
class _RestorableWidget extends StatefulWidget {
|
||||
const _RestorableWidget({Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_RestorableWidget> createState() => _RestorableWidgetState();
|
||||
}
|
||||
|
||||
class _RestorableWidgetState extends State<_RestorableWidget> with RestorationMixin {
|
||||
final RestorableNum<num> numValue = RestorableNum<num>(99);
|
||||
final RestorableDouble doubleValue = RestorableDouble(123.2);
|
||||
final RestorableInt intValue = RestorableInt(42);
|
||||
final RestorableString stringValue = RestorableString('hello world');
|
||||
final RestorableBool boolValue = RestorableBool(false);
|
||||
final RestorableTextEditingController controllerValue = RestorableTextEditingController(text: 'FooBar');
|
||||
final _TestRestorableValue objectValue = _TestRestorableValue();
|
||||
|
||||
@override
|
||||
void restoreState(RestorationBucket oldBucket) {
|
||||
registerForRestoration(numValue, 'num');
|
||||
registerForRestoration(doubleValue, 'double');
|
||||
registerForRestoration(intValue, 'int');
|
||||
registerForRestoration(stringValue, 'string');
|
||||
registerForRestoration(boolValue,'bool');
|
||||
registerForRestoration(controllerValue, 'controller');
|
||||
registerForRestoration(objectValue, 'object');
|
||||
}
|
||||
|
||||
void setProperties(VoidCallback callback) {
|
||||
setState(callback);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(stringValue.value ?? 'null', textDirection: TextDirection.ltr,);
|
||||
}
|
||||
|
||||
@override
|
||||
String get restorationId => 'widget';
|
||||
}
|
||||
34
packages/flutter/test/widgets/restoration.dart
Normal file
34
packages/flutter/test/widgets/restoration.dart
Normal file
@ -0,0 +1,34 @@
|
||||
// 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.
|
||||
|
||||
// @dart = 2.8
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
export '../services/restoration.dart';
|
||||
|
||||
class BucketSpy extends StatefulWidget {
|
||||
const BucketSpy({Key key, this.child}) : super(key: key);
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<BucketSpy> createState() => BucketSpyState();
|
||||
}
|
||||
|
||||
class BucketSpyState extends State<BucketSpy> {
|
||||
RestorationBucket bucket;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
bucket = RestorationScope.of(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child ?? Container();
|
||||
}
|
||||
}
|
||||
833
packages/flutter/test/widgets/restoration_mixin_test.dart
Normal file
833
packages/flutter/test/widgets/restoration_mixin_test.dart
Normal file
@ -0,0 +1,833 @@
|
||||
// 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.
|
||||
|
||||
// @dart = 2.8
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'restoration.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('claims bucket', (WidgetTester tester) async {
|
||||
const String id = 'hello world 1234';
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = <String, dynamic>{};
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
expect(rawData, isEmpty);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: id,
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.bucket.restorationId, id);
|
||||
expect(rawData[childrenMapKey].containsKey(id), isTrue);
|
||||
expect(state.property.value, 10);
|
||||
expect(rawData[childrenMapKey][id][valuesMapKey]['foo'], 10);
|
||||
expect(state.property.log, <String>['createDefaultValue', 'initWithValue', 'toPrimitives']);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
expect(state.restoreStateLog.single, isNull);
|
||||
});
|
||||
|
||||
testWidgets('claimed bucket with data', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet());
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: 'child1',
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.property.value, 22);
|
||||
expect(state.property.log, <String>['fromPrimitives', 'initWithValue']);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
expect(state.restoreStateLog.single, isNull);
|
||||
});
|
||||
|
||||
testWidgets('renames existing bucket when new ID is provided via widget', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet());
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: 'child1',
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
// Claimed existing bucket with data.
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.bucket.read<int>('foo'), 22);
|
||||
final RestorationBucket bucket = state.bucket;
|
||||
|
||||
state.property.log.clear();
|
||||
state.restoreStateLog.clear();
|
||||
|
||||
// Rename the existing bucket.
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: 'something else',
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
expect(state.bucket.restorationId, 'something else');
|
||||
expect(state.bucket.read<int>('foo'), 22);
|
||||
expect(state.bucket, same(bucket));
|
||||
expect(state.property.log, isEmpty);
|
||||
expect(state.restoreStateLog, isEmpty);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('renames existing bucket when didUpdateRestorationId is called', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet());
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: 'child1',
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
// Claimed existing bucket with data.
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.bucket.read<int>('foo'), 22);
|
||||
final RestorationBucket bucket = state.bucket;
|
||||
|
||||
state.property.log.clear();
|
||||
state.restoreStateLog.clear();
|
||||
|
||||
// Rename the existing bucket.
|
||||
state.injectId('newnewnew');
|
||||
manager.doSerialization();
|
||||
|
||||
expect(state.bucket.restorationId, 'newnewnew');
|
||||
expect(state.bucket.read<int>('foo'), 22);
|
||||
expect(state.bucket, same(bucket));
|
||||
expect(state.property.log, isEmpty);
|
||||
expect(state.restoreStateLog, isEmpty);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('Disposing widget removes its data', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isTrue);
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: 'child1',
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isTrue);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: Container(),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('toggling id between null and non-null', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.bucket, isNull);
|
||||
expect(state.property.value, 10); // Initialized to default.
|
||||
expect(rawData[childrenMapKey]['child1'][valuesMapKey]['foo'], 22);
|
||||
expect(state.property.log, <String>['createDefaultValue', 'initWithValue']);
|
||||
state.property.log.clear();
|
||||
expect(state.restoreStateLog.single, isNull);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
state.restoreStateLog.clear();
|
||||
state.toogleBucketLog.clear();
|
||||
|
||||
// Change id to non-null.
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: 'child1',
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(state.bucket, isNotNull);
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.property.value, 10);
|
||||
expect(rawData[childrenMapKey]['child1'][valuesMapKey]['foo'], 10);
|
||||
expect(state.property.log, <String>['toPrimitives']);
|
||||
state.property.log.clear();
|
||||
expect(state.restoreStateLog, isEmpty);
|
||||
expect(state.toogleBucketLog.single, isNull);
|
||||
state.restoreStateLog.clear();
|
||||
state.toogleBucketLog.clear();
|
||||
|
||||
final RestorationBucket bucket = state.bucket;
|
||||
|
||||
// Change id back to null.
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(state.bucket, isNull);
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isFalse);
|
||||
expect(state.property.log, isEmpty);
|
||||
expect(state.restoreStateLog, isEmpty);
|
||||
expect(state.toogleBucketLog.single, same(bucket));
|
||||
});
|
||||
|
||||
testWidgets('move in and out of scope', (WidgetTester tester) async {
|
||||
final Key key = GlobalKey();
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_TestRestorableWidget(
|
||||
key: key,
|
||||
restorationId: 'child1',
|
||||
),
|
||||
);
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.bucket, isNull);
|
||||
expect(state.property.value, 10); // Initialized to default.
|
||||
expect(rawData[childrenMapKey]['child1'][valuesMapKey]['foo'], 22);
|
||||
expect(state.property.log, <String>['createDefaultValue', 'initWithValue']);
|
||||
state.property.log.clear();
|
||||
expect(state.restoreStateLog.single, isNull);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
state.restoreStateLog.clear();
|
||||
state.toogleBucketLog.clear();
|
||||
|
||||
// Move it under a valid scope.
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: _TestRestorableWidget(
|
||||
key: key,
|
||||
restorationId: 'child1',
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(state.bucket, isNotNull);
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.property.value, 10);
|
||||
expect(rawData[childrenMapKey]['child1'][valuesMapKey]['foo'], 10);
|
||||
expect(state.property.log, <String>['toPrimitives']);
|
||||
state.property.log.clear();
|
||||
expect(state.restoreStateLog, isEmpty);
|
||||
expect(state.toogleBucketLog.single, isNull);
|
||||
state.restoreStateLog.clear();
|
||||
state.toogleBucketLog.clear();
|
||||
|
||||
final RestorationBucket bucket = state.bucket;
|
||||
|
||||
// Move out of scope again.
|
||||
await tester.pumpWidget(
|
||||
_TestRestorableWidget(
|
||||
key: key,
|
||||
restorationId: 'child1',
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(state.bucket, isNull);
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isFalse);
|
||||
expect(state.property.log, isEmpty);
|
||||
expect(state.restoreStateLog, isEmpty);
|
||||
expect(state.toogleBucketLog.single, same(bucket));
|
||||
});
|
||||
|
||||
testWidgets('moving scope moves its data', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = <String, dynamic>{};
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
final Key key = GlobalKey();
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: Row(
|
||||
textDirection: TextDirection.ltr,
|
||||
children: <Widget>[
|
||||
RestorationScope(
|
||||
restorationId: 'fixed',
|
||||
child: _TestRestorableWidget(
|
||||
key: key,
|
||||
restorationId: 'moving-child',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.bucket.restorationId, 'moving-child');
|
||||
expect(rawData[childrenMapKey]['fixed'][childrenMapKey].containsKey('moving-child'), isTrue);
|
||||
final RestorationBucket bucket = state.bucket;
|
||||
state.property.log.clear();
|
||||
state.restoreStateLog.clear();
|
||||
|
||||
state.bucket.write('value', 11);
|
||||
manager.doSerialization();
|
||||
|
||||
// Move widget.
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: Row(
|
||||
textDirection: TextDirection.ltr,
|
||||
children: <Widget>[
|
||||
RestorationScope(
|
||||
restorationId: 'fixed',
|
||||
child: Container(),
|
||||
),
|
||||
_TestRestorableWidget(
|
||||
key: key,
|
||||
restorationId: 'moving-child',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(state.bucket.restorationId, 'moving-child');
|
||||
expect(state.bucket, same(bucket));
|
||||
expect(state.bucket.read<int>('value'), 11);
|
||||
expect(state.property.log, isEmpty);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
expect(state.restoreStateLog, isEmpty);
|
||||
|
||||
expect(rawData[childrenMapKey]['fixed'], isEmpty);
|
||||
expect(rawData[childrenMapKey].containsKey('moving-child'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('decommission claims new bucket with data', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
RestorationBucket root = RestorationBucket.root(manager: manager, rawData: <String, dynamic>{});
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: 'child1',
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.property.value, 10); // Initialized to default.
|
||||
expect(state.bucket.read<int>('foo'), 10);
|
||||
final RestorationBucket bucket = state.bucket;
|
||||
state.property.log.clear();
|
||||
state.restoreStateLog.clear();
|
||||
|
||||
// Replace root bucket.
|
||||
root..decommission()..dispose();
|
||||
root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet());
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const _TestRestorableWidget(
|
||||
restorationId: 'child1',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Bucket has been replaced.
|
||||
expect(state.bucket, isNot(same(bucket)));
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.property.value, 22); // Restored value.
|
||||
expect(state.bucket.read<int>('foo'), 22);
|
||||
expect(state.restoreStateLog.single, bucket);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
expect(state.property.log, <String>['fromPrimitives', 'initWithValue']);
|
||||
});
|
||||
|
||||
testWidgets('restartAndRestore', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _TestRestorableWidget(
|
||||
restorationId: 'widget',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
_TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.bucket, isNotNull);
|
||||
expect(state.property.value, 10); // default
|
||||
expect(state.property.log, <String>['createDefaultValue', 'initWithValue', 'toPrimitives']);
|
||||
expect(state.restoreStateLog.single, isNull);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
_clearLogs(state);
|
||||
|
||||
state.setProperties(() {
|
||||
state.property.value = 20;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.property.value, 20);
|
||||
expect(state.property.log, <String>['toPrimitives']);
|
||||
expect(state.restoreStateLog, isEmpty);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
_clearLogs(state);
|
||||
|
||||
final _TestRestorableWidgetState oldState = state;
|
||||
await tester.restartAndRestore();
|
||||
state = tester.state(find.byType(_TestRestorableWidget));
|
||||
|
||||
expect(state, isNot(same(oldState)));
|
||||
expect(state.property.value, 20);
|
||||
expect(state.property.log, <String>['fromPrimitives', 'initWithValue']);
|
||||
expect(state.restoreStateLog.single, isNull);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('restore while running', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _TestRestorableWidget(
|
||||
restorationId: 'widget',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
_TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
|
||||
state.setProperties(() {
|
||||
state.property.value = 20;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.property.value, 20);
|
||||
|
||||
final TestRestorationData data = await tester.getRestorationData();
|
||||
|
||||
state.setProperties(() {
|
||||
state.property.value = 30;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.property.value, 30);
|
||||
_clearLogs(state);
|
||||
|
||||
final _TestRestorableWidgetState oldState = state;
|
||||
final RestorationBucket oldBucket = oldState.bucket;
|
||||
await tester.restoreFrom(data);
|
||||
state = tester.state(find.byType(_TestRestorableWidget));
|
||||
|
||||
expect(state, same(oldState));
|
||||
expect(state.property.value, 20);
|
||||
expect(state.property.log, <String>['fromPrimitives', 'initWithValue']);
|
||||
expect(state.restoreStateLog.single, oldBucket);
|
||||
expect(state.toogleBucketLog, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('can register additional property outside of restoreState', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _TestRestorableWidget(
|
||||
restorationId: 'widget',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
state.registerAdditionalProperty();
|
||||
expect(state.additionalProperty.value, 11);
|
||||
expect(state.additionalProperty.log, <String>['createDefaultValue', 'initWithValue', 'toPrimitives']);
|
||||
|
||||
state.setProperties(() {
|
||||
state.additionalProperty.value = 33;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.additionalProperty.value, 33);
|
||||
|
||||
final TestRestorationData data = await tester.getRestorationData();
|
||||
|
||||
state.setProperties(() {
|
||||
state.additionalProperty.value = 44;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.additionalProperty.value, 44);
|
||||
_clearLogs(state);
|
||||
|
||||
await tester.restoreFrom(data);
|
||||
|
||||
expect(state, same(tester.state(find.byType(_TestRestorableWidget))));
|
||||
expect(state.additionalProperty.value, 33);
|
||||
expect(state.property.log, <String>['fromPrimitives', 'initWithValue']);
|
||||
});
|
||||
|
||||
testWidgets('cannot register same property twice', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _TestRestorableWidget(
|
||||
restorationId: 'widget',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
state.registerAdditionalProperty();
|
||||
await tester.pump();
|
||||
expect(() => state.registerAdditionalProperty(), throwsAssertionError);
|
||||
});
|
||||
|
||||
testWidgets('cannot register under ID that is already in use', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _TestRestorableWidget(
|
||||
restorationId: 'widget',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(() => state.registerPropertyUnderSameId(), throwsAssertionError);
|
||||
});
|
||||
|
||||
testWidgets('data of disabled property is not stored', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _TestRestorableWidget(
|
||||
restorationId: 'widget',
|
||||
),
|
||||
),
|
||||
);
|
||||
_TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
|
||||
state.setProperties(() {
|
||||
state.property.value = 30;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.property.value, 30);
|
||||
expect(state.bucket.read<int>('foo'), 30);
|
||||
_clearLogs(state);
|
||||
|
||||
state.setProperties(() {
|
||||
state.property.enabled = false;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.property.value, 30);
|
||||
expect(state.bucket.contains('foo'), isFalse);
|
||||
expect(state.property.log, isEmpty);
|
||||
|
||||
state.setProperties(() {
|
||||
state.property.value = 40;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.bucket.contains('foo'), isFalse);
|
||||
expect(state.property.log, isEmpty);
|
||||
|
||||
await tester.restartAndRestore();
|
||||
state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.property.log, <String>['createDefaultValue', 'initWithValue', 'toPrimitives']);
|
||||
expect(state.property.value, 10); // Initialized to default value.
|
||||
});
|
||||
|
||||
testWidgets('Enabling property stores its data again', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _TestRestorableWidget(
|
||||
restorationId: 'widget',
|
||||
),
|
||||
),
|
||||
);
|
||||
_TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
_clearLogs(state);
|
||||
|
||||
state.setProperties(() {
|
||||
state.property.enabled = false;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.bucket.contains('foo'), isFalse);
|
||||
state.setProperties(() {
|
||||
state.property.value = 40;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.property.value, 40);
|
||||
expect(state.bucket.contains('foo'), isFalse);
|
||||
expect(state.property.log, isEmpty);
|
||||
|
||||
state.setProperties(() {
|
||||
state.property.enabled = true;
|
||||
});
|
||||
await tester.pump();
|
||||
expect(state.bucket.read<int>('foo'), 40);
|
||||
expect(state.property.log, <String>['toPrimitives']);
|
||||
|
||||
await tester.restartAndRestore();
|
||||
state = tester.state(find.byType(_TestRestorableWidget));
|
||||
expect(state.property.log, <String>['fromPrimitives', 'initWithValue']);
|
||||
expect(state.property.value, 40);
|
||||
});
|
||||
|
||||
testWidgets('Unregistering a property removes its data', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _TestRestorableWidget(
|
||||
restorationId: 'widget',
|
||||
),
|
||||
),
|
||||
);
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
state.registerAdditionalProperty();
|
||||
await tester.pump();
|
||||
expect(state.additionalProperty.value, 11);
|
||||
expect(state.bucket.read<int>('additional'), 11);
|
||||
state.unregisterAdditionalProperty();
|
||||
await tester.pump();
|
||||
expect(state.bucket.contains('additional'), isFalse);
|
||||
expect(() => state.additionalProperty.value, throwsAssertionError); // No longer registered.
|
||||
|
||||
// Can register the same property again.
|
||||
state.registerAdditionalProperty();
|
||||
await tester.pump();
|
||||
expect(state.additionalProperty.value, 11);
|
||||
expect(state.bucket.read<int>('additional'), 11);
|
||||
});
|
||||
|
||||
testWidgets('Disposing a property unregisters it, but keeps data', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _TestRestorableWidget(
|
||||
restorationId: 'widget',
|
||||
),
|
||||
),
|
||||
);
|
||||
final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget));
|
||||
state.registerAdditionalProperty();
|
||||
await tester.pump();
|
||||
expect(state.additionalProperty.value, 11);
|
||||
expect(state.bucket.read<int>('additional'), 11);
|
||||
|
||||
state.additionalProperty.dispose();
|
||||
await tester.pump();
|
||||
expect(state.bucket.read<int>('additional'), 11);
|
||||
|
||||
// Can register property under same id again.
|
||||
state.additionalProperty = _TestRestorableProperty(22);
|
||||
state.registerAdditionalProperty();
|
||||
await tester.pump();
|
||||
|
||||
expect(state.additionalProperty.value, 11); // Old value restored.
|
||||
expect(state.bucket.read<int>('additional'), 11);
|
||||
});
|
||||
|
||||
test('RestorableProperty throws after disposed', () {
|
||||
final RestorableProperty<Object> property = _TestRestorableProperty(10);
|
||||
property.dispose();
|
||||
expect(() => property.dispose(), throwsFlutterError);
|
||||
});
|
||||
}
|
||||
|
||||
void _clearLogs(_TestRestorableWidgetState state) {
|
||||
state.property.log.clear();
|
||||
state.additionalProperty?.log?.clear();
|
||||
state.restoreStateLog.clear();
|
||||
state.toogleBucketLog.clear();
|
||||
}
|
||||
|
||||
class _TestRestorableWidget extends StatefulWidget {
|
||||
|
||||
const _TestRestorableWidget({Key key, this.restorationId}) : super(key: key);
|
||||
|
||||
final String restorationId;
|
||||
|
||||
@override
|
||||
State<_TestRestorableWidget> createState() => _TestRestorableWidgetState();
|
||||
}
|
||||
|
||||
class _TestRestorableWidgetState extends State<_TestRestorableWidget> with RestorationMixin {
|
||||
final _TestRestorableProperty property = _TestRestorableProperty(10);
|
||||
_TestRestorableProperty additionalProperty;
|
||||
bool _rerigisterAdditionalProperty = false;
|
||||
|
||||
final List<RestorationBucket> restoreStateLog = <RestorationBucket>[];
|
||||
final List<RestorationBucket> toogleBucketLog = <RestorationBucket>[];
|
||||
|
||||
|
||||
@override
|
||||
void restoreState(RestorationBucket oldBucket) {
|
||||
restoreStateLog.add(oldBucket);
|
||||
registerForRestoration(property, 'foo');
|
||||
if (_rerigisterAdditionalProperty && additionalProperty != null) {
|
||||
registerForRestoration(additionalProperty, 'additional');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didToggleBucket(RestorationBucket oldBucket) {
|
||||
toogleBucketLog.add(oldBucket);
|
||||
super.didToggleBucket(oldBucket);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
property.dispose();
|
||||
additionalProperty?.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
void setProperties(VoidCallback fn) => setState(fn);
|
||||
|
||||
String _injectedId;
|
||||
void injectId(String id) {
|
||||
_injectedId = id;
|
||||
didUpdateRestorationId();
|
||||
}
|
||||
|
||||
void registerAdditionalProperty({bool reregister = true}) {
|
||||
additionalProperty ??= _TestRestorableProperty(11);
|
||||
registerForRestoration(additionalProperty, 'additional');
|
||||
_rerigisterAdditionalProperty = reregister;
|
||||
}
|
||||
|
||||
void unregisterAdditionalProperty() {
|
||||
unregisterFromRestoration(additionalProperty);
|
||||
}
|
||||
|
||||
void registerPropertyUnderSameId() {
|
||||
registerForRestoration(_TestRestorableProperty(11), 'foo');
|
||||
}
|
||||
|
||||
@override
|
||||
String get restorationId => _injectedId ?? widget.restorationId;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _createRawDataSet() {
|
||||
return <String, dynamic>{
|
||||
valuesMapKey: <String, dynamic>{
|
||||
'value1' : 10,
|
||||
'value2' : 'Hello',
|
||||
},
|
||||
childrenMapKey: <String, dynamic>{
|
||||
'child1' : <String, dynamic>{
|
||||
valuesMapKey : <String, dynamic>{
|
||||
'foo': 22,
|
||||
}
|
||||
},
|
||||
'child2' : <String, dynamic>{
|
||||
valuesMapKey : <String, dynamic>{
|
||||
'bar': 33,
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class _TestRestorableProperty extends RestorableProperty<Object> {
|
||||
_TestRestorableProperty(this._value);
|
||||
|
||||
List<String> log = <String>[];
|
||||
|
||||
@override
|
||||
bool get enabled => _enabled;
|
||||
bool _enabled = true;
|
||||
set enabled(bool value) {
|
||||
_enabled = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Object createDefaultValue() {
|
||||
log.add('createDefaultValue');
|
||||
return _value;
|
||||
}
|
||||
|
||||
@override
|
||||
Object fromPrimitives(Object data) {
|
||||
log.add('fromPrimitives');
|
||||
return data;
|
||||
}
|
||||
|
||||
Object get value {
|
||||
assert(isRegistered);
|
||||
return _value;
|
||||
}
|
||||
Object _value;
|
||||
set value(Object value) {
|
||||
_value = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void initWithValue(Object v) {
|
||||
log.add('initWithValue');
|
||||
_value = v;
|
||||
}
|
||||
|
||||
@override
|
||||
Object toPrimitives() {
|
||||
log.add('toPrimitives');
|
||||
return _value;
|
||||
}
|
||||
}
|
||||
387
packages/flutter/test/widgets/restoration_scope_test.dart
Normal file
387
packages/flutter/test/widgets/restoration_scope_test.dart
Normal file
@ -0,0 +1,387 @@
|
||||
// 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.
|
||||
|
||||
// @dart = 2.8
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'restoration.dart';
|
||||
|
||||
void main() {
|
||||
group('UnmanagedRestorationScope', () {
|
||||
testWidgets('makes bucket available to descendants', (WidgetTester tester) async {
|
||||
final RestorationBucket bucket1 = RestorationBucket.empty(
|
||||
restorationId: 'foo',
|
||||
debugOwner: 'owner',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: bucket1,
|
||||
child: const BucketSpy(),
|
||||
),
|
||||
);
|
||||
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket, bucket1);
|
||||
|
||||
// Notifies when bucket changes.
|
||||
final RestorationBucket bucket2 = RestorationBucket.empty(
|
||||
restorationId: 'foo2',
|
||||
debugOwner: 'owner',
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: bucket2,
|
||||
child: const BucketSpy(),
|
||||
),
|
||||
);
|
||||
expect(state.bucket, bucket2);
|
||||
});
|
||||
|
||||
testWidgets('null bucket disables restoration', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const UnmanagedRestorationScope(
|
||||
bucket: null,
|
||||
child: BucketSpy(),
|
||||
),
|
||||
);
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('RestorationScope', () {
|
||||
testWidgets('makes bucket available to descendants', (WidgetTester tester) async {
|
||||
const String id = 'hello world 1234';
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = <String, dynamic>{};
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
expect(rawData, isEmpty);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RestorationScope(
|
||||
restorationId: id,
|
||||
child: BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket.restorationId, id);
|
||||
expect(rawData[childrenMapKey].containsKey(id), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('bucket for descendants contains data claimed from parent', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet());
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RestorationScope(
|
||||
restorationId: 'child1',
|
||||
child: BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.bucket.read<int>('foo'), 22);
|
||||
});
|
||||
|
||||
testWidgets('renames existing bucket when new ID is provided', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet());
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RestorationScope(
|
||||
restorationId: 'child1',
|
||||
child: BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
// Claimed existing bucket with data.
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.bucket.read<int>('foo'), 22);
|
||||
final RestorationBucket bucket = state.bucket;
|
||||
|
||||
// Rename the existing bucket.
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RestorationScope(
|
||||
restorationId: 'something else',
|
||||
child: BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
expect(state.bucket.restorationId, 'something else');
|
||||
expect(state.bucket.read<int>('foo'), 22);
|
||||
expect(state.bucket, same(bucket));
|
||||
});
|
||||
|
||||
testWidgets('Disposing a scope removes its data', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = _createRawDataSet();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isTrue);
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RestorationScope(
|
||||
restorationId: 'child1',
|
||||
child: BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isTrue);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: Container(),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
expect(rawData[childrenMapKey].containsKey('child1'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('no bucket for descendants when id is null', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: <String, dynamic>{});
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RestorationScope(
|
||||
restorationId: null,
|
||||
child: BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket, isNull);
|
||||
|
||||
// Change id to non-null.
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RestorationScope(
|
||||
restorationId: 'foo',
|
||||
child: BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(state.bucket, isNotNull);
|
||||
expect(state.bucket.restorationId, 'foo');
|
||||
|
||||
// Change id back to null.
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RestorationScope(
|
||||
restorationId: null,
|
||||
child: BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(state.bucket, isNull);
|
||||
});
|
||||
|
||||
testWidgets('no bucket for descendants when scope is null', (WidgetTester tester) async {
|
||||
final Key scopeKey = GlobalKey();
|
||||
|
||||
await tester.pumpWidget(
|
||||
RestorationScope(
|
||||
key: scopeKey,
|
||||
restorationId: 'foo',
|
||||
child: const BucketSpy(),
|
||||
),
|
||||
);
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket, isNull);
|
||||
|
||||
// Move it under a valid scope.
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: <String, dynamic>{});
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: RestorationScope(
|
||||
key: scopeKey,
|
||||
restorationId: 'foo',
|
||||
child: const BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(state.bucket, isNotNull);
|
||||
expect(state.bucket.restorationId, 'foo');
|
||||
|
||||
// Move out of scope again.
|
||||
await tester.pumpWidget(
|
||||
RestorationScope(
|
||||
key: scopeKey,
|
||||
restorationId: 'foo',
|
||||
child: const BucketSpy(),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(state.bucket, isNull);
|
||||
});
|
||||
|
||||
testWidgets('no bucket for descendants when scope and id are null', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RestorationScope(
|
||||
restorationId: null,
|
||||
child: BucketSpy(),
|
||||
),
|
||||
);
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket, isNull);
|
||||
});
|
||||
|
||||
testWidgets('moving scope moves its data', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = <String, dynamic>{};
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
final Key scopeKey = GlobalKey();
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: Row(
|
||||
textDirection: TextDirection.ltr,
|
||||
children: <Widget>[
|
||||
RestorationScope(
|
||||
restorationId: 'fixed',
|
||||
child: RestorationScope(
|
||||
key: scopeKey,
|
||||
restorationId: 'moving-child',
|
||||
child: const BucketSpy(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket.restorationId, 'moving-child');
|
||||
expect(rawData[childrenMapKey]['fixed'][childrenMapKey].containsKey('moving-child'), isTrue);
|
||||
final RestorationBucket bucket = state.bucket;
|
||||
|
||||
state.bucket.write('value', 11);
|
||||
manager.doSerialization();
|
||||
|
||||
// Move scope.
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: Row(
|
||||
textDirection: TextDirection.ltr,
|
||||
children: <Widget>[
|
||||
RestorationScope(
|
||||
restorationId: 'fixed',
|
||||
child: Container(),
|
||||
),
|
||||
RestorationScope(
|
||||
key: scopeKey,
|
||||
restorationId: 'moving-child',
|
||||
child: const BucketSpy(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
expect(state.bucket.restorationId, 'moving-child');
|
||||
expect(state.bucket, same(bucket));
|
||||
expect(state.bucket.read<int>('value'), 11);
|
||||
|
||||
expect(rawData[childrenMapKey]['fixed'], isEmpty);
|
||||
expect(rawData[childrenMapKey].containsKey('moving-child'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('decommission claims new bucket with data', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
RestorationBucket root = RestorationBucket.root(manager: manager, rawData: <String, dynamic>{});
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RestorationScope(
|
||||
restorationId: 'child1',
|
||||
child: BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.bucket.read<int>('foo'), isNull); // Does not exist.
|
||||
final RestorationBucket bucket = state.bucket;
|
||||
|
||||
// Replace root bucket.
|
||||
root..decommission()..dispose();
|
||||
root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet());
|
||||
|
||||
await tester.pumpWidget(
|
||||
UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RestorationScope(
|
||||
restorationId: 'child1',
|
||||
child: BucketSpy(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Bucket has been replaced.
|
||||
expect(state.bucket, isNot(same(bucket)));
|
||||
expect(state.bucket.restorationId, 'child1');
|
||||
expect(state.bucket.read<int>('foo'), 22);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _createRawDataSet() {
|
||||
return <String, dynamic>{
|
||||
valuesMapKey: <String, dynamic>{
|
||||
'value1' : 10,
|
||||
'value2' : 'Hello',
|
||||
},
|
||||
childrenMapKey: <String, dynamic>{
|
||||
'child1' : <String, dynamic>{
|
||||
valuesMapKey : <String, dynamic>{
|
||||
'foo': 22,
|
||||
}
|
||||
},
|
||||
'child2' : <String, dynamic>{
|
||||
valuesMapKey : <String, dynamic>{
|
||||
'bar': 33,
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
400
packages/flutter/test/widgets/root_restoration_scope_test.dart
Normal file
400
packages/flutter/test/widgets/root_restoration_scope_test.dart
Normal file
@ -0,0 +1,400 @@
|
||||
// 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.
|
||||
|
||||
// @dart = 2.8
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'restoration.dart';
|
||||
|
||||
void main() {
|
||||
final TestAutomatedTestWidgetsFlutterBinding binding = TestAutomatedTestWidgetsFlutterBinding();
|
||||
|
||||
setUp(() {
|
||||
binding._restorationManager = MockRestorationManager();
|
||||
});
|
||||
|
||||
testWidgets('does not inject root bucket if inside scope', (WidgetTester tester) async {
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> rawData = <String, dynamic>{};
|
||||
final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData);
|
||||
expect(rawData, isEmpty);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: UnmanagedRestorationScope(
|
||||
bucket: root,
|
||||
child: const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
manager.doSerialization();
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 0);
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket.restorationId, 'root-child');
|
||||
expect(rawData[childrenMapKey].containsKey('root-child'), isTrue);
|
||||
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('waits for root bucket', (WidgetTester tester) async {
|
||||
final Completer<RestorationBucket> bucketCompleter = Completer<RestorationBucket>();
|
||||
binding.restorationManager.rootBucket = bucketCompleter.future;
|
||||
|
||||
await tester.pumpWidget(
|
||||
const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
// Child rendering is delayed until root bucket is available.
|
||||
expect(find.text('Hello'), findsNothing);
|
||||
expect(binding.firstFrameIsDeferred, isTrue);
|
||||
|
||||
// Complete the future.
|
||||
final Map<String, dynamic> rawData = <String, dynamic>{};
|
||||
final RestorationBucket root = RestorationBucket.root(manager: binding.restorationManager, rawData: rawData);
|
||||
bucketCompleter.complete(root);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(binding.firstFrameIsDeferred, isFalse);
|
||||
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket.restorationId, 'root-child');
|
||||
expect(rawData[childrenMapKey].containsKey('root-child'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('no delay when root is available synchronously', (WidgetTester tester) async {
|
||||
final Map<String, dynamic> rawData = <String, dynamic>{};
|
||||
final RestorationBucket root = RestorationBucket.root(manager: binding.restorationManager, rawData: rawData);
|
||||
binding.restorationManager.rootBucket = SynchronousFuture<RestorationBucket>(root);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(binding.firstFrameIsDeferred, isFalse);
|
||||
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket.restorationId, 'root-child');
|
||||
expect(rawData[childrenMapKey].containsKey('root-child'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('does not insert root when restoration id is null', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: RootRestorationScope(
|
||||
restorationId: null,
|
||||
child: BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 0);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(binding.firstFrameIsDeferred, isFalse);
|
||||
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket, isNull);
|
||||
|
||||
// Change restoration id to non-null.
|
||||
final Completer<RestorationBucket> bucketCompleter = Completer<RestorationBucket>();
|
||||
binding.restorationManager.rootBucket = bucketCompleter.future;
|
||||
await tester.pumpWidget(
|
||||
const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(state.bucket, isNull); // root bucket future has not completed yet.
|
||||
|
||||
// Complete the future.
|
||||
final RestorationBucket root = RestorationBucket.root(manager: binding.restorationManager, rawData: <String, dynamic>{});
|
||||
bucketCompleter.complete(root);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(state.bucket.restorationId, 'root-child');
|
||||
|
||||
// Change ID back to null.
|
||||
await tester.pumpWidget(
|
||||
const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: RootRestorationScope(
|
||||
restorationId: null,
|
||||
child: BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(state.bucket, isNull);
|
||||
});
|
||||
|
||||
testWidgets('injects root bucket when moved out of scope', (WidgetTester tester) async {
|
||||
final Key rootScopeKey = GlobalKey();
|
||||
final MockRestorationManager manager = MockRestorationManager();
|
||||
final Map<String, dynamic> inScopeRawData = <String, dynamic>{};
|
||||
final RestorationBucket inScopeRootBucket = RestorationBucket.root(manager: manager, rawData: inScopeRawData);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: UnmanagedRestorationScope(
|
||||
bucket: inScopeRootBucket,
|
||||
child: RootRestorationScope(
|
||||
key: rootScopeKey,
|
||||
restorationId: 'root-child',
|
||||
child: const BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 0);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket.restorationId, 'root-child');
|
||||
expect(inScopeRawData[childrenMapKey].containsKey('root-child'), isTrue);
|
||||
|
||||
// Move out of scope.
|
||||
final Completer<RestorationBucket> bucketCompleter = Completer<RestorationBucket>();
|
||||
binding.restorationManager.rootBucket = bucketCompleter.future;
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: RootRestorationScope(
|
||||
key: rootScopeKey,
|
||||
restorationId: 'root-child',
|
||||
child: const BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
|
||||
final Map<String, dynamic> outOfScopeRawData = <String, dynamic>{};
|
||||
final RestorationBucket outOfScopeRootBucket = RestorationBucket.root(manager: binding.restorationManager, rawData: outOfScopeRawData);
|
||||
bucketCompleter.complete(outOfScopeRootBucket);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(state.bucket.restorationId, 'root-child');
|
||||
expect(outOfScopeRawData[childrenMapKey].containsKey('root-child'), isTrue);
|
||||
expect(inScopeRawData, isEmpty);
|
||||
|
||||
// Move into scope.
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: UnmanagedRestorationScope(
|
||||
bucket: inScopeRootBucket,
|
||||
child: RootRestorationScope(
|
||||
key: rootScopeKey,
|
||||
restorationId: 'root-child',
|
||||
child: const BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(state.bucket.restorationId, 'root-child');
|
||||
expect(outOfScopeRawData, isEmpty);
|
||||
expect(inScopeRawData[childrenMapKey].containsKey('root-child'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('injects new root when old one is decommissioned', (WidgetTester tester) async {
|
||||
final Map<String, dynamic> firstRawData = <String, dynamic>{};
|
||||
final RestorationBucket firstRoot = RestorationBucket.root(manager: binding.restorationManager, rawData: firstRawData);
|
||||
binding.restorationManager.rootBucket = SynchronousFuture<RestorationBucket>(firstRoot);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
state.bucket.write('foo', 42);
|
||||
expect(firstRawData[childrenMapKey]['root-child'][valuesMapKey]['foo'], 42);
|
||||
final RestorationBucket firstBucket = state.bucket;
|
||||
|
||||
// Replace with new root.
|
||||
final Map<String, dynamic> secondRawData = <String, dynamic>{
|
||||
childrenMapKey: <String, dynamic>{
|
||||
'root-child': <String, dynamic>{
|
||||
valuesMapKey: <String, dynamic>{
|
||||
'foo': 22,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
final RestorationBucket secondRoot = RestorationBucket.root(manager: binding.restorationManager, rawData: secondRawData);
|
||||
binding.restorationManager.rootBucket = SynchronousFuture<RestorationBucket>(secondRoot);
|
||||
firstRoot..decommission()..dispose();
|
||||
await tester.pump();
|
||||
|
||||
expect(state.bucket, isNot(same(firstBucket)));
|
||||
expect(state.bucket.read<int>('foo'), 22);
|
||||
});
|
||||
|
||||
testWidgets('injects null when rootBucket is null', (WidgetTester tester) async {
|
||||
final Completer<RestorationBucket> completer = Completer<RestorationBucket>();
|
||||
binding.restorationManager.rootBucket = completer.future;
|
||||
|
||||
await tester.pumpWidget(
|
||||
const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsNothing);
|
||||
|
||||
completer.complete(null);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket, isNull);
|
||||
|
||||
final RestorationBucket root = RestorationBucket.root(manager: binding.restorationManager, rawData: null);
|
||||
binding.restorationManager.rootBucket = SynchronousFuture<RestorationBucket>(root);
|
||||
await tester.pump();
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 2);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(state.bucket, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('can switch to null', (WidgetTester tester) async {
|
||||
final RestorationBucket root = RestorationBucket.root(manager: binding.restorationManager, rawData: null);
|
||||
binding.restorationManager.rootBucket = SynchronousFuture<RestorationBucket>(root);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: BucketSpy(
|
||||
child: Text('Hello'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 1);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
final BucketSpyState state = tester.state(find.byType(BucketSpy));
|
||||
expect(state.bucket, isNotNull);
|
||||
|
||||
binding.restorationManager.rootBucket = SynchronousFuture<RestorationBucket>(null);
|
||||
root..decommission()..dispose();
|
||||
await tester.pump();
|
||||
|
||||
expect(binding.restorationManager.rootBucketAccessed, 2);
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(state.bucket, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
class TestAutomatedTestWidgetsFlutterBinding extends AutomatedTestWidgetsFlutterBinding {
|
||||
MockRestorationManager _restorationManager;
|
||||
|
||||
@override
|
||||
MockRestorationManager get restorationManager => _restorationManager;
|
||||
|
||||
@override
|
||||
TestRestorationManager createRestorationManager() {
|
||||
return null;
|
||||
}
|
||||
|
||||
int _deferred = 0;
|
||||
|
||||
bool get firstFrameIsDeferred => _deferred > 0;
|
||||
|
||||
@override
|
||||
void deferFirstFrame() {
|
||||
_deferred++;
|
||||
super.deferFirstFrame();
|
||||
}
|
||||
|
||||
@override
|
||||
void allowFirstFrame() {
|
||||
_deferred--;
|
||||
super.allowFirstFrame();
|
||||
}
|
||||
}
|
||||
@ -58,6 +58,7 @@ export 'src/goldens.dart';
|
||||
export 'src/matchers.dart';
|
||||
export 'src/nonconst.dart';
|
||||
export 'src/platform.dart';
|
||||
export 'src/restoration.dart';
|
||||
export 'src/stack_manipulation.dart';
|
||||
export 'src/test_async_utils.dart';
|
||||
export 'src/test_compat.dart';
|
||||
|
||||
@ -23,6 +23,7 @@ import 'package:vector_math/vector_math_64.dart';
|
||||
import '_binding_io.dart' if (dart.library.html) '_binding_web.dart' as binding;
|
||||
import 'goldens.dart';
|
||||
import 'platform.dart';
|
||||
import 'restoration.dart';
|
||||
import 'stack_manipulation.dart';
|
||||
import 'test_async_utils.dart';
|
||||
import 'test_exception_reporter.dart';
|
||||
@ -187,6 +188,21 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
|
||||
TestWindow get window => _window;
|
||||
final TestWindow _window;
|
||||
|
||||
@override
|
||||
TestRestorationManager get restorationManager => _restorationManager;
|
||||
TestRestorationManager _restorationManager;
|
||||
|
||||
/// Called by the test framework at the beginning of a widget test to
|
||||
/// prepare the binding for the next test.
|
||||
void reset() {
|
||||
_restorationManager = createRestorationManager();
|
||||
}
|
||||
|
||||
@override
|
||||
TestRestorationManager createRestorationManager() {
|
||||
return TestRestorationManager();
|
||||
}
|
||||
|
||||
/// The value to set [debugPrint] to while tests are running.
|
||||
///
|
||||
/// This can be used to redirect console output from the framework, or to
|
||||
|
||||
85
packages/flutter_test/lib/src/restoration.dart
Normal file
85
packages/flutter_test/lib/src/restoration.dart
Normal file
@ -0,0 +1,85 @@
|
||||
// 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:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// The [RestorationManager] used for tests.
|
||||
///
|
||||
/// Unlike the real [RestorationManager], this one just keeps the restoration
|
||||
/// data in memory and does not make it available to the engine.
|
||||
class TestRestorationManager extends RestorationManager {
|
||||
/// Creates a [TestRestorationManager].
|
||||
TestRestorationManager() {
|
||||
// Ensures that [rootBucket] always returns a synchronous future to avoid
|
||||
// extra pumps in tests.
|
||||
restoreFrom(TestRestorationData.empty);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RestorationBucket> get rootBucket {
|
||||
_debugRootBucketAccessed = true;
|
||||
return super.rootBucket;
|
||||
}
|
||||
|
||||
/// The current restoration data from which the current state can be restored.
|
||||
///
|
||||
/// To restore the state to the one described by this data, pass the
|
||||
/// [TestRestorationData] obtained from this getter back to [restoreFrom].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [WidgetTester.getRestorationData], which makes this data available
|
||||
/// in a widget test.
|
||||
TestRestorationData get restorationData => _restorationData;
|
||||
TestRestorationData _restorationData;
|
||||
|
||||
/// Whether the [rootBucket] has been obtained.
|
||||
bool get debugRootBucketAccessed => _debugRootBucketAccessed;
|
||||
bool _debugRootBucketAccessed = false;
|
||||
|
||||
/// Restores the state from the provided [TestRestorationData].
|
||||
///
|
||||
/// The restoration data obtained form [restorationData] can be passed into
|
||||
/// this method to restore the state to what it was when the restoration data
|
||||
/// was originally retrieved.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [WidgetTester.restoreFrom], which exposes this method to a widget test.
|
||||
void restoreFrom(TestRestorationData data) {
|
||||
_restorationData = data;
|
||||
handleRestorationUpdateFromEngine(enabled: true, data: data.binary);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> sendToEngine(Uint8List encodedData) async {
|
||||
_restorationData = TestRestorationData._(encodedData);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restoration data that can be used to restore the state to the one described
|
||||
/// by this data.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [WidgetTester.getRestorationData], which retrieves the restoration data
|
||||
/// from the widget under test.
|
||||
/// * [WidgetTester.restoreFrom], which takes a [TestRestorationData] to
|
||||
/// restore the state of the widget under test from the provided data.
|
||||
class TestRestorationData {
|
||||
const TestRestorationData._(this.binary);
|
||||
|
||||
/// Empty restoration data indicating that no data is available to restore
|
||||
/// state from.
|
||||
static const TestRestorationData empty = TestRestorationData._(null);
|
||||
|
||||
/// The serialized representation of the restoration data.
|
||||
///
|
||||
/// Should only be accessed by the test framework.
|
||||
@protected
|
||||
final Uint8List binary;
|
||||
}
|
||||
@ -23,6 +23,7 @@ import 'controller.dart';
|
||||
import 'event_simulation.dart';
|
||||
import 'finders.dart';
|
||||
import 'matchers.dart';
|
||||
import 'restoration.dart';
|
||||
import 'test_async_utils.dart';
|
||||
import 'test_compat.dart';
|
||||
import 'test_pointer.dart';
|
||||
@ -137,6 +138,7 @@ void testWidgets(
|
||||
test_package.addTearDown(binding.postTest);
|
||||
return binding.runTest(
|
||||
() async {
|
||||
binding.reset();
|
||||
debugResetSemanticsIdCounter();
|
||||
tester.resetTestTextInput();
|
||||
Object memento;
|
||||
@ -678,6 +680,53 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
|
||||
});
|
||||
}
|
||||
|
||||
/// Simulates restoring the state of the widget tree after the application
|
||||
/// is restarted.
|
||||
///
|
||||
/// The method grabs the current serialized restoration data from the
|
||||
/// [RestorationManager], takes down the widget tree to destroy all in-memory
|
||||
/// state, and then restores the widget tree from the serialized restoration
|
||||
/// data.
|
||||
Future<void> restartAndRestore() async {
|
||||
assert(
|
||||
binding.restorationManager.debugRootBucketAccessed,
|
||||
'The current widget tree did not inject the root bucket of the RestorationManager and '
|
||||
'therefore no restoration data has been collected to restore from. Did you forget to wrap '
|
||||
'your widget tree in a RootRestorationScope?',
|
||||
);
|
||||
final Widget widget = (binding.renderViewElement as RenderObjectToWidgetElement<RenderObject>).widget.child;
|
||||
final TestRestorationData restorationData = binding.restorationManager.restorationData;
|
||||
runApp(Container(key: UniqueKey()));
|
||||
await pump();
|
||||
binding.restorationManager.restoreFrom(restorationData);
|
||||
return pumpWidget(widget);
|
||||
}
|
||||
|
||||
/// Retrieves the current restoration data from the [RestorationManager].
|
||||
///
|
||||
/// The returned [TestRestorationData] describes the current state of the
|
||||
/// widget tree under test and can be provided to [restoreFrom] to restore
|
||||
/// the widget tree to the state described by this data.
|
||||
Future<TestRestorationData> getRestorationData() async {
|
||||
assert(
|
||||
binding.restorationManager.debugRootBucketAccessed,
|
||||
'The current widget tree did not inject the root bucket of the RestorationManager and '
|
||||
'therefore no restoration data has been collected. Did you forget to wrap your widget tree '
|
||||
'in a RootRestorationScope?',
|
||||
);
|
||||
return binding.restorationManager.restorationData;
|
||||
}
|
||||
|
||||
/// Restores the widget tree under test to the state described by the
|
||||
/// provided [TestRestorationData].
|
||||
///
|
||||
/// The data provided to this method is usually obtained from
|
||||
/// [getRestorationData].
|
||||
Future<void> restoreFrom(TestRestorationData data) {
|
||||
binding.restorationManager.restoreFrom(data);
|
||||
return pump();
|
||||
}
|
||||
|
||||
/// Runs a [callback] that performs real asynchronous work.
|
||||
///
|
||||
/// This is intended for callers who need to call asynchronous methods where
|
||||
|
||||
111
packages/flutter_test/test/restoration_test.dart
Normal file
111
packages/flutter_test/test/restoration_test.dart
Normal file
@ -0,0 +1,111 @@
|
||||
// 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 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('restartAndRestore', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _RestorableWidget(
|
||||
restorationId: 'restorable-widget',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget));
|
||||
expect(find.text('Hello World 100'), findsOneWidget);
|
||||
expect(state.doubleValue, 1.0);
|
||||
|
||||
state.setValues('Guten Morgen', 200, 33.4);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Guten Morgen 200'), findsOneWidget);
|
||||
expect(state.doubleValue, 33.4);
|
||||
|
||||
await tester.restartAndRestore();
|
||||
|
||||
expect(find.text('Guten Morgen 200'), findsOneWidget);
|
||||
expect(find.text('Hello World 100'), findsNothing);
|
||||
final _RestorableWidgetState restoredState = tester.state(find.byType(_RestorableWidget));
|
||||
expect(restoredState, isNot(same(state)));
|
||||
expect(restoredState.doubleValue, 1.0);
|
||||
});
|
||||
|
||||
testWidgets('restore from previous restoration data', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const RootRestorationScope(
|
||||
restorationId: 'root-child',
|
||||
child: _RestorableWidget(
|
||||
restorationId: 'restorable-widget',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget));
|
||||
expect(find.text('Hello World 100'), findsOneWidget);
|
||||
expect(state.doubleValue, 1.0);
|
||||
|
||||
state.setValues('Guten Morgen', 200, 33.4);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Guten Morgen 200'), findsOneWidget);
|
||||
expect(state.doubleValue, 33.4);
|
||||
|
||||
final TestRestorationData data = await tester.getRestorationData();
|
||||
|
||||
state.setValues('See you later!', 400, 123.5);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('See you later! 400'), findsOneWidget);
|
||||
expect(state.doubleValue, 123.5);
|
||||
|
||||
await tester.restoreFrom(data);
|
||||
|
||||
expect(tester.state(find.byType(_RestorableWidget)), same(state));
|
||||
expect(find.text('Guten Morgen 200'), findsOneWidget);
|
||||
expect(state.doubleValue, 123.5);
|
||||
});
|
||||
}
|
||||
|
||||
class _RestorableWidget extends StatefulWidget {
|
||||
const _RestorableWidget({Key key, this.restorationId}) : super(key: key);
|
||||
|
||||
final String restorationId;
|
||||
|
||||
@override
|
||||
State<_RestorableWidget> createState() => _RestorableWidgetState();
|
||||
}
|
||||
|
||||
class _RestorableWidgetState extends State<_RestorableWidget> with RestorationMixin {
|
||||
final RestorableString stringValue = RestorableString('Hello World');
|
||||
final RestorableInt intValue = RestorableInt(100);
|
||||
|
||||
double doubleValue = 1.0; // Not restorable.
|
||||
|
||||
@override
|
||||
void restoreState(RestorationBucket oldBucket) {
|
||||
registerForRestoration(stringValue, 'string');
|
||||
registerForRestoration(intValue, 'int');
|
||||
}
|
||||
|
||||
void setValues(String s, int i, double d) {
|
||||
setState(() {
|
||||
stringValue.value = s;
|
||||
intValue.value = i;
|
||||
doubleValue = d;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text('${stringValue.value} ${intValue.value}', textDirection: TextDirection.ltr);
|
||||
}
|
||||
|
||||
@override
|
||||
String get restorationId => widget.restorationId;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user