Refactor CanvasKit image ref counting; fix a minor memory leak (#22549)

* Refactor SkiaObjectBox ref counting
* make CkAnimatedImage a Codec
* disallow double dispose; better assertion messages
diff --git a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
index a3458ae..94b04f5 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
@@ -685,14 +685,11 @@
   external SkImage getCurrentFrame();
   external int width();
   external int height();
-  external Uint8List readPixels(SkImageInfo imageInfo, int srcX, int srcY);
-  external SkData encodeToData();
 
   /// Deletes the C++ object.
   ///
   /// This object is no longer usable after calling this method.
   external void delete();
-  external bool isAliasOf(SkAnimatedImage other);
   external bool isDeleted();
 }
 
@@ -1820,6 +1817,7 @@
   external int size();
   external bool isEmpty();
   external Uint8List bytes();
+  external void delete();
 }
 
 @JS()
diff --git a/lib/web_ui/lib/src/engine/canvaskit/image.dart b/lib/web_ui/lib/src/engine/canvaskit/image.dart
index 388af1f..5c558d5 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/image.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/image.dart
@@ -8,20 +8,17 @@
 /// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia.
 void skiaInstantiateImageCodec(Uint8List list, Callback<ui.Codec> callback,
     [int? width, int? height, int? format, int? rowBytes]) {
-  final SkAnimatedImage skAnimatedImage =
-      canvasKit.MakeAnimatedImageFromEncoded(list);
-  final CkAnimatedImage animatedImage = CkAnimatedImage(skAnimatedImage);
-  final CkAnimatedImageCodec codec = CkAnimatedImageCodec(animatedImage);
+  final CkAnimatedImage codec = CkAnimatedImage.decodeFromBytes(list);
   callback(codec);
 }
 
 /// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia after
 /// requesting from URI.
 Future<ui.Codec> skiaInstantiateWebImageCodec(
-    String src, WebOnlyImageCodecChunkCallback? chunkCallback) {
+    String uri, WebOnlyImageCodecChunkCallback? chunkCallback) {
   Completer<ui.Codec> completer = Completer<ui.Codec>();
   //TODO: Switch to using MakeImageFromCanvasImageSource when animated images are supported.
-  html.HttpRequest.request(src, responseType: "arraybuffer",
+  html.HttpRequest.request(uri, responseType: "arraybuffer",
       onProgress: (html.ProgressEvent event) {
     if (event.lengthComputable) {
       chunkCallback?.call(event.loaded!, event.total!);
@@ -33,10 +30,7 @@
     }
     final Uint8List list =
         new Uint8List.view((response.response as ByteBuffer));
-    final SkAnimatedImage skAnimatedImage =
-        canvasKit.MakeAnimatedImageFromEncoded(list);
-    final CkAnimatedImage animatedImage = CkAnimatedImage(skAnimatedImage);
-    final CkAnimatedImageCodec codec = CkAnimatedImageCodec(animatedImage);
+    final CkAnimatedImage codec = CkAnimatedImage.decodeFromBytes(list);
     completer.complete(codec);
   }, onError: (dynamic error) {
     completer.completeError(error);
@@ -44,123 +38,115 @@
   return completer.future;
 }
 
-/// A wrapper for `SkAnimatedImage`.
-class CkAnimatedImage implements ui.Image {
-  // Use a box because `SkImage` may be deleted either due to this object
-  // being garbage-collected, or by an explicit call to [delete].
-  late final SkiaObjectBox<SkAnimatedImage> box;
-
-  SkAnimatedImage get _skAnimatedImage => box.skObject;
-
-  CkAnimatedImage(SkAnimatedImage skAnimatedImage) {
-    box = SkiaObjectBox<SkAnimatedImage>(this, skAnimatedImage);
+/// The CanvasKit implementation of [ui.Codec].
+///
+/// Wraps `SkAnimatedImage`.
+class CkAnimatedImage implements ui.Codec, StackTraceDebugger {
+  /// Decodes an image from a list of encoded bytes.
+  CkAnimatedImage.decodeFromBytes(Uint8List bytes) {
+    if (assertionsEnabled) {
+      _debugStackTrace = StackTrace.current;
+    }
+    final SkAnimatedImage skAnimatedImage =
+        canvasKit.MakeAnimatedImageFromEncoded(bytes);
+    box = SkiaObjectBox<CkAnimatedImage, SkAnimatedImage>(this, skAnimatedImage);
   }
 
-  CkAnimatedImage.cloneOf(SkiaObjectBox<SkAnimatedImage> boxToClone) {
-    box = boxToClone.clone(this);
-  }
+  // Use a box because `CkAnimatedImage` may be deleted either due to this
+  // object being garbage-collected, or by an explicit call to [dispose].
+  late final SkiaObjectBox<CkAnimatedImage, SkAnimatedImage> box;
+
+  @override
+  StackTrace get debugStackTrace => _debugStackTrace!;
+  StackTrace? _debugStackTrace;
 
   bool _disposed = false;
+  bool get debugDisposed => _disposed;
+
+  bool _debugCheckIsNotDisposed() {
+    assert(!_disposed, 'This image has been disposed.');
+    return true;
+  }
+
   @override
   void dispose() {
-    box.delete();
+    assert(
+      !_disposed,
+      'Cannot dispose a codec that has already been disposed.',
+    );
     _disposed = true;
+
+    // This image is no longer usable. Bump the ref count.
+    box.unref(this);
   }
 
   @override
-  bool get debugDisposed {
-    if (assertionsEnabled) {
-      return _disposed;
-    }
-    throw StateError(
-        'Image.debugDisposed is only available when asserts are enabled.');
-  }
-
-  ui.Image clone() => CkAnimatedImage.cloneOf(box);
-
-  @override
-  bool isCloneOf(ui.Image other) {
-    return other is CkAnimatedImage &&
-        other._skAnimatedImage.isAliasOf(_skAnimatedImage);
+  int get frameCount {
+    assert(_debugCheckIsNotDisposed());
+    return box.skiaObject.getFrameCount();
   }
 
   @override
-  List<StackTrace>? debugGetOpenHandleStackTraces() =>
-      box.debugGetStackTraces();
-
-  int get frameCount => _skAnimatedImage.getFrameCount();
-
-  /// Decodes the next frame and returns the frame duration.
-  Duration decodeNextFrame() {
-    final int durationMillis = _skAnimatedImage.decodeNextFrame();
-    return Duration(milliseconds: durationMillis);
-  }
-
-  int get repetitionCount => _skAnimatedImage.getRepetitionCount();
-
-  CkImage get currentFrameAsImage {
-    return CkImage(_skAnimatedImage.getCurrentFrame());
+  int get repetitionCount {
+    assert(_debugCheckIsNotDisposed());
+    return box.skiaObject.getRepetitionCount();
   }
 
   @override
-  int get width => _skAnimatedImage.width();
-
-  @override
-  int get height => _skAnimatedImage.height();
-
-  @override
-  Future<ByteData> toByteData(
-      {ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba}) {
-    Uint8List bytes;
-
-    if (format == ui.ImageByteFormat.rawRgba) {
-      final SkImageInfo imageInfo = SkImageInfo(
-        alphaType: canvasKit.AlphaType.Premul,
-        colorType: canvasKit.ColorType.RGBA_8888,
-        colorSpace: SkColorSpaceSRGB,
-        width: width,
-        height: height,
-      );
-      bytes = _skAnimatedImage.readPixels(imageInfo, 0, 0);
-    } else {
-      // Defaults to PNG 100%.
-      final SkData skData = _skAnimatedImage.encodeToData();
-      // Make a copy that we can return.
-      bytes = Uint8List.fromList(canvasKit.getDataBytes(skData));
-    }
-
-    final ByteData data = bytes.buffer.asByteData(0, bytes.length);
-    return Future<ByteData>.value(data);
+  Future<ui.FrameInfo> getNextFrame() {
+    assert(_debugCheckIsNotDisposed());
+    final int durationMillis = box.skiaObject.decodeNextFrame();
+    final Duration duration = Duration(milliseconds: durationMillis);
+    final CkImage image = CkImage(box.skiaObject.getCurrentFrame());
+    return Future<ui.FrameInfo>.value(AnimatedImageFrameInfo(duration, image));
   }
-
-  @override
-  String toString() => '[$width\u00D7$height]';
 }
 
 /// A [ui.Image] backed by an `SkImage` from Skia.
-class CkImage implements ui.Image {
+class CkImage implements ui.Image, StackTraceDebugger {
+  CkImage(SkImage skImage) {
+    if (assertionsEnabled) {
+      _debugStackTrace = StackTrace.current;
+    }
+    box = SkiaObjectBox<CkImage, SkImage>(this, skImage);
+  }
+
+  CkImage.cloneOf(this.box) {
+    if (assertionsEnabled) {
+      _debugStackTrace = StackTrace.current;
+    }
+    box.ref(this);
+  }
+
+  @override
+  StackTrace get debugStackTrace => _debugStackTrace!;
+  StackTrace? _debugStackTrace;
+
   // Use a box because `SkImage` may be deleted either due to this object
   // being garbage-collected, or by an explicit call to [delete].
-  late final SkiaObjectBox<SkImage> box;
+  late final SkiaObjectBox<CkImage, SkImage> box;
 
-  SkImage get skImage => box.skObject;
-
-  CkImage(SkImage skImage) {
-    box = SkiaObjectBox<SkImage>(this, skImage);
-  }
-
-  CkImage.cloneOf(SkiaObjectBox<SkImage> boxToClone) {
-    box = boxToClone.clone(this);
-  }
+  /// The underlying Skia image object.
+  ///
+  /// Do not store the returned value. It is memory-managed by [SkiaObjectBox].
+  /// Storing it may result in use-after-free bugs.
+  SkImage get skImage => box.skiaObject;
 
   bool _disposed = false;
+
+  bool _debugCheckIsNotDisposed() {
+    assert(!_disposed, 'This image has been disposed.');
+    return true;
+  }
+
   @override
   void dispose() {
-    box.delete();
-    assert(() {
-      _disposed = true;
-      return true;
-    }());
+    assert(
+      !_disposed,
+      'Cannot dispose an image that has already been disposed.',
+    );
+    _disposed = true;
+    box.unref(this);
   }
 
   @override
@@ -173,10 +159,14 @@
   }
 
   @override
-  ui.Image clone() => CkImage.cloneOf(box);
+  ui.Image clone() {
+    assert(_debugCheckIsNotDisposed());
+    return CkImage.cloneOf(box);
+  }
 
   @override
   bool isCloneOf(ui.Image other) {
+    assert(_debugCheckIsNotDisposed());
     return other is CkImage && other.skImage.isAliasOf(skImage);
   }
 
@@ -185,14 +175,21 @@
       box.debugGetStackTraces();
 
   @override
-  int get width => skImage.width();
+  int get width {
+    assert(_debugCheckIsNotDisposed());
+    return skImage.width();
+  }
 
   @override
-  int get height => skImage.height();
+  int get height {
+    assert(_debugCheckIsNotDisposed());
+    return skImage.height();
+  }
 
   @override
   Future<ByteData> toByteData(
       {ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba}) {
+    assert(_debugCheckIsNotDisposed());
     Uint8List bytes;
 
     if (format == ui.ImageByteFormat.rawRgba) {
@@ -208,6 +205,7 @@
       final SkData skData = skImage.encodeToData(); //defaults to PNG 100%
       // make a copy that we can return
       bytes = Uint8List.fromList(canvasKit.getDataBytes(skData));
+      skData.delete();
     }
 
     final ByteData data = bytes.buffer.asByteData(0, bytes.length);
@@ -215,31 +213,9 @@
   }
 
   @override
-  String toString() => '[$width\u00D7$height]';
-}
-
-/// A [Codec] that wraps an `SkAnimatedImage`.
-class CkAnimatedImageCodec implements ui.Codec {
-  CkAnimatedImage animatedImage;
-
-  CkAnimatedImageCodec(this.animatedImage);
-
-  @override
-  void dispose() {
-    animatedImage.dispose();
-  }
-
-  @override
-  int get frameCount => animatedImage.frameCount;
-
-  @override
-  int get repetitionCount => animatedImage.repetitionCount;
-
-  @override
-  Future<ui.FrameInfo> getNextFrame() {
-    final Duration duration = animatedImage.decodeNextFrame();
-    final CkImage image = animatedImage.currentFrameAsImage;
-    return Future<ui.FrameInfo>.value(AnimatedImageFrameInfo(duration, image));
+  String toString() {
+    assert(_debugCheckIsNotDisposed());
+    return '[$width\u00D7$height]';
   }
 }
 
diff --git a/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart b/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart
index 3ce432d..17ef953 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart
@@ -252,6 +252,15 @@
   }
 }
 
+/// Interface that classes wrapping [SkiaObjectBox] must implement.
+///
+/// Used to collect stack traces in debug mode.
+abstract class StackTraceDebugger {
+  /// The stack trace pointing to code location that created or upreffed a
+  /// [SkiaObjectBox].
+  StackTrace get debugStackTrace;
+}
+
 /// Uses reference counting to manage the lifecycle of a Skia object owned by a
 /// wrapper object.
 ///
@@ -263,37 +272,47 @@
 ///
 /// The [delete] method may be called any number of times. The box
 /// will only delete the object once.
-class SkiaObjectBox<T> {
-  SkiaObjectBox(Object wrapper, T skObject)
-      : this._(wrapper, skObject, skObject as SkDeletable, <SkiaObjectBox>{});
-
-  SkiaObjectBox._(Object wrapper, this.skObject, this._skDeletable, this._refs) {
+class SkiaObjectBox<R extends StackTraceDebugger, T> {
+  SkiaObjectBox(R debugReferrer, this.skiaObject) : _skDeletable = skiaObject as SkDeletable {
     if (assertionsEnabled) {
-      _debugStackTrace = StackTrace.current;
+      debugReferrers.add(debugReferrer);
     }
-    _refs.add(this);
     if (browserSupportsFinalizationRegistry) {
-      boxRegistry.register(wrapper, this);
+      boxRegistry.register(this, _skDeletable);
     }
+    assert(refCount == debugReferrers.length);
   }
 
-  /// Reference handles to the same underlying [skObject].
-  final Set<SkiaObjectBox> _refs;
+  /// The number of objects sharing references to this box.
+  ///
+  /// When this count reaches zero, the underlying [skiaObject] is scheduled
+  /// for deletion.
+  int get refCount => _refCount;
+  int _refCount = 1;
 
-  late final StackTrace? _debugStackTrace;
+  /// When assertions are enabled, stores all objects that share this box.
+  ///
+  /// The length of this list is always identical to [refCount].
+  ///
+  /// This list can be used for debugging ref counting issues.
+  final Set<R> debugReferrers = <R>{};
+
   /// If asserts are enabled, the [StackTrace]s representing when a reference
   /// was created.
   List<StackTrace>? debugGetStackTraces() {
     if (assertionsEnabled) {
-      return _refs
-          .map<StackTrace>((SkiaObjectBox box) => box._debugStackTrace!)
+      return debugReferrers
+          .map<StackTrace>((R referrer) => referrer.debugStackTrace)
           .toList();
     }
     return null;
   }
 
   /// The Skia object whose lifecycle is being managed.
-  final T skObject;
+  ///
+  /// Do not store this value outside this box. It is memory-managed by
+  /// [SkiaObjectBox]. Storing it may result in use-after-free bugs.
+  final T skiaObject;
   final SkDeletable _skDeletable;
 
   /// Whether this object has been deleted.
@@ -302,17 +321,23 @@
 
   /// Deletes Skia objects when their wrappers are garbage collected.
   static final SkObjectFinalizationRegistry boxRegistry =
-      SkObjectFinalizationRegistry(js.allowInterop((SkiaObjectBox box) {
-    box.delete();
+      SkObjectFinalizationRegistry(js.allowInterop((SkDeletable deletable) {
+    deletable.delete();
   }));
 
-  /// Returns a clone of this object, which increases its reference count.
+  /// Increases the reference count of this box because a new object began
+  /// sharing ownership of the underlying [skiaObject].
   ///
   /// Clones must be [dispose]d when finished.
-  SkiaObjectBox<T> clone(Object wrapper) {
-    assert(!_isDeleted, 'Cannot clone from a deleted handle.');
-    assert(_refs.isNotEmpty);
-    return SkiaObjectBox<T>._(wrapper, skObject, _skDeletable, _refs);
+  void ref(R debugReferrer) {
+    assert(!_isDeleted, 'Cannot increment ref count on a deleted handle.');
+    assert(_refCount > 0);
+    assert(
+      debugReferrers.add(debugReferrer),
+      'Attempted to increment ref count by the same referrer more than once.',
+    );
+    _refCount += 1;
+    assert(refCount == debugReferrers.length);
   }
 
   /// Decrements the reference count for the [skObject].
@@ -321,15 +346,16 @@
   ///
   /// If this causes the reference count to drop to zero, deletes the
   /// [skObject].
-  void delete() {
-    if (_isDeleted) {
-      assert(!_refs.contains(this));
-      return;
-    }
-    final bool removed = _refs.remove(this);
-    assert(removed);
-    _isDeleted = true;
-    if (_refs.isEmpty) {
+  void unref(R debugReferrer) {
+    assert(!_isDeleted, 'Attempted to unref an already deleted Skia object.');
+    assert(
+      debugReferrers.remove(debugReferrer),
+      'Attempted to decrement ref count by the same referrer more than once.',
+    );
+    _refCount -= 1;
+    assert(refCount == debugReferrers.length);
+    if (_refCount == 0) {
+      _isDeleted = true;
       _scheduleSkObjectCollection(_skDeletable);
     }
   }
@@ -386,7 +412,7 @@
   ///
   /// Since it's expensive to resurrect, we shouldn't just delete it after every
   /// frame. Instead, add it to a cache and only delete it when the cache fills.
-  static void manageExpensive(ManagedSkiaObject object) {
+  static void manageExpensive(SkiaObject object) {
     registerCleanupCallback();
     expensiveCache.add(object);
   }
diff --git a/lib/web_ui/test/canvaskit/canvaskit_api_test.dart b/lib/web_ui/test/canvaskit/canvaskit_api_test.dart
index 452cffd..2498193 100644
--- a/lib/web_ui/test/canvaskit/canvaskit_api_test.dart
+++ b/lib/web_ui/test/canvaskit/canvaskit_api_test.dart
@@ -1187,10 +1187,10 @@
     final CkImage image = await picture.toImage(1, 1);
     final ByteData rawData =
         await image.toByteData(format: ui.ImageByteFormat.rawRgba);
-    expect(rawData, isNotNull);
+    expect(rawData.lengthInBytes, greaterThan(0));
     final ByteData pngData =
         await image.toByteData(format: ui.ImageByteFormat.png);
-    expect(pngData, isNotNull);
+    expect(pngData.lengthInBytes, greaterThan(0));
   });
 }
 
diff --git a/lib/web_ui/test/canvaskit/image_test.dart b/lib/web_ui/test/canvaskit/image_test.dart
index 2487efa..5141464 100644
--- a/lib/web_ui/test/canvaskit/image_test.dart
+++ b/lib/web_ui/test/canvaskit/image_test.dart
@@ -10,6 +10,7 @@
 import 'package:ui/src/engine.dart';
 import 'package:ui/ui.dart' as ui;
 
+import '../matchers.dart';
 import 'common.dart';
 import 'test_data.dart';
 
@@ -23,48 +24,30 @@
       await ui.webOnlyInitializePlatform();
     });
 
-    test('CkAnimatedImage toString', () {
-      final SkAnimatedImage skAnimatedImage =
-          canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage);
-      final CkAnimatedImage image = CkAnimatedImage(skAnimatedImage);
-      expect(image.toString(), '[1×1]');
-      image.dispose();
-    });
-
     test('CkAnimatedImage can be explicitly disposed of', () {
-      final SkAnimatedImage skAnimatedImage =
-          canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage);
-      final CkAnimatedImage image = CkAnimatedImage(skAnimatedImage);
+      final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kTransparentImage);
       expect(image.box.isDeleted, false);
       expect(image.debugDisposed, false);
       image.dispose();
       expect(image.box.isDeleted, true);
       expect(image.debugDisposed, true);
-      image.dispose();
-      expect(image.box.isDeleted, true);
-      expect(image.debugDisposed, true);
+
+      // Disallow double-dispose.
+      expect(() => image.dispose(), throwsAssertionError);
     });
 
     test('CkAnimatedImage can be cloned and explicitly disposed of', () async {
-      final SkAnimatedImage skAnimatedImage =
-          canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage);
-      final CkAnimatedImage image = CkAnimatedImage(skAnimatedImage);
-      final CkAnimatedImage imageClone = image.clone();
+      final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kTransparentImage);
+      final SkAnimatedImage skAnimatedImage = image.box.skiaObject;
+      final SkiaObjectBox<CkAnimatedImage, SkAnimatedImage> box = image.box;
+      expect(box.refCount, 1);
+      expect(box.debugGetStackTraces().length, 1);
 
-      expect(image.isCloneOf(imageClone), true);
-      expect(image.box.isDeleted, false);
-      await Future<void>.delayed(Duration.zero);
-      expect(skAnimatedImage.isDeleted(), false);
       image.dispose();
-      expect(image.box.isDeleted, true);
-      expect(imageClone.box.isDeleted, false);
-      await Future<void>.delayed(Duration.zero);
-      expect(skAnimatedImage.isDeleted(), false);
-      imageClone.dispose();
-      expect(image.box.isDeleted, true);
-      expect(imageClone.box.isDeleted, true);
+      expect(box.isDeleted, true);
       await Future<void>.delayed(Duration.zero);
       expect(skAnimatedImage.isDeleted(), true);
+      expect(box.debugGetStackTraces().length, 0);
     });
 
     test('CkImage toString', () {
@@ -86,9 +69,9 @@
       image.dispose();
       expect(image.debugDisposed, true);
       expect(image.box.isDeleted, true);
-      image.dispose();
-      expect(image.debugDisposed, true);
-      expect(image.box.isDeleted, true);
+
+      // Disallow double-dispose.
+      expect(() => image.dispose(), throwsAssertionError);
     });
 
     test('CkImage can be explicitly disposed of when cloned', () async {
@@ -96,22 +79,27 @@
           canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)
               .getCurrentFrame();
       final CkImage image = CkImage(skImage);
+      final SkiaObjectBox<CkImage, SkImage> box = image.box;
+      expect(box.refCount, 1);
+      expect(box.debugGetStackTraces().length, 1);
+
       final CkImage imageClone = image.clone();
+      expect(box.refCount, 2);
+      expect(box.debugGetStackTraces().length, 2);
 
       expect(image.isCloneOf(imageClone), true);
-      expect(image.box.isDeleted, false);
+      expect(box.isDeleted, false);
       await Future<void>.delayed(Duration.zero);
       expect(skImage.isDeleted(), false);
       image.dispose();
-      expect(image.box.isDeleted, true);
-      expect(imageClone.box.isDeleted, false);
+      expect(box.isDeleted, false);
       await Future<void>.delayed(Duration.zero);
       expect(skImage.isDeleted(), false);
       imageClone.dispose();
-      expect(image.box.isDeleted, true);
-      expect(imageClone.box.isDeleted, true);
+      expect(box.isDeleted, true);
       await Future<void>.delayed(Duration.zero);
       expect(skImage.isDeleted(), true);
+      expect(box.debugGetStackTraces().length, 0);
     });
 
     test('skiaInstantiateWebImageCodec throws exception if given invalid URL',
@@ -119,6 +107,15 @@
       expect(skiaInstantiateWebImageCodec('invalid-url', null),
           throwsA(isA<ProgressEvent>()));
     });
+
+    test('CkImage toByteData', () async {
+      final SkImage skImage =
+          canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)
+              .getCurrentFrame();
+      final CkImage image = CkImage(skImage);
+      expect((await image.toByteData()).lengthInBytes, greaterThan(0));
+      expect((await image.toByteData(format: ui.ImageByteFormat.png)).lengthInBytes, greaterThan(0));
+    });
     // TODO: https://github.com/flutter/flutter/issues/60040
   }, skip: isIosSafari);
 }
diff --git a/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart b/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart
index 7de655b..aa1b808 100644
--- a/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart
+++ b/lib/web_ui/test/canvaskit/skia_objects_cache_test.dart
@@ -11,8 +11,8 @@
 import 'package:ui/ui.dart' as ui;
 import 'package:ui/src/engine.dart';
 
-import 'common.dart';
 import '../matchers.dart';
+import 'common.dart';
 
 void main() {
   internalBootstrapBrowserTest(() => testMain);
@@ -158,42 +158,80 @@
   group(SkiaObjectBox, () {
     test('Records stack traces and respects refcounts', () async {
       TestSkDeletable.deleteCount = 0;
-      final Object wrapper = Object();
-      final SkiaObjectBox<TestSkDeletable> box = SkiaObjectBox<TestSkDeletable>(wrapper, TestSkDeletable());
+      final TestBoxWrapper original = TestBoxWrapper();
 
-      expect(box.debugGetStackTraces().length, 1);
+      expect(original.box.debugGetStackTraces().length, 1);
+      expect(original.box.refCount, 1);
+      expect(original.box.isDeleted, false);
 
-      final SkiaObjectBox clone = box.clone(wrapper);
-      expect(clone, isNot(same(box)));
-      expect(clone.debugGetStackTraces().length, 2);
-      expect(box.debugGetStackTraces().length, 2);
+      final TestBoxWrapper clone = original.clone();
+      expect(clone.box, same(original.box));
+      expect(clone.box.debugGetStackTraces().length, 2);
+      expect(clone.box.refCount, 2);
+      expect(original.box.debugGetStackTraces().length, 2);
+      expect(original.box.refCount, 2);
+      expect(original.box.isDeleted, false);
 
-      box.delete();
+      original.dispose();
 
-      expect(() => box.clone(wrapper), throwsAssertionError);
-
-      expect(box.isDeleted, true);
-
-      // Let any timers elapse.
+      // Let Skia object delete queue run.
       await Future<void>.delayed(Duration.zero);
       expect(TestSkDeletable.deleteCount, 0);
 
-      expect(clone.debugGetStackTraces().length, 1);
-      expect(box.debugGetStackTraces().length, 1);
+      expect(clone.box.debugGetStackTraces().length, 1);
+      expect(clone.box.refCount, 1);
+      expect(original.box.debugGetStackTraces().length, 1);
+      expect(original.box.refCount, 1);
 
-      clone.delete();
-      expect(() => clone.clone(wrapper), throwsAssertionError);
+      clone.dispose();
 
-      // Let any timers elapse.
+      // Let Skia object delete queue run.
       await Future<void>.delayed(Duration.zero);
       expect(TestSkDeletable.deleteCount, 1);
 
-      expect(clone.debugGetStackTraces().length, 0);
-      expect(box.debugGetStackTraces().length, 0);
+      expect(clone.box.debugGetStackTraces().length, 0);
+      expect(clone.box.refCount, 0);
+      expect(original.box.debugGetStackTraces().length, 0);
+      expect(original.box.refCount, 0);
+      expect(original.box.isDeleted, true);
+
+      expect(() => clone.box.unref(clone), throwsAssertionError);
     });
   });
 }
 
+/// A simple class that wraps a [SkiaObjectBox].
+///
+/// Can be [clone]d such that the clones share the same ref counted box.
+class TestBoxWrapper implements StackTraceDebugger {
+  TestBoxWrapper() {
+    if (assertionsEnabled) {
+      _debugStackTrace = StackTrace.current;
+    }
+    box = SkiaObjectBox<TestBoxWrapper, TestSkDeletable>(this, TestSkDeletable());
+  }
+
+  TestBoxWrapper.cloneOf(this.box) {
+    if (assertionsEnabled) {
+      _debugStackTrace = StackTrace.current;
+    }
+    box.ref(this);
+  }
+
+  @override
+  StackTrace get debugStackTrace => _debugStackTrace;
+  StackTrace _debugStackTrace;
+
+  SkiaObjectBox<TestBoxWrapper, TestSkDeletable> box;
+
+  void dispose() {
+    box.unref(this);
+  }
+
+  TestBoxWrapper clone() => TestBoxWrapper.cloneOf(box);
+}
+
+
 class TestSkDeletable implements SkDeletable {
   static int deleteCount = 0;