diff --git a/CHANGELOG.md b/CHANGELOG.md index 301ca3b..836dd24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## Unreleased +- Route HTTP(S) network images through a single Flutter codec provider by default. +- Keep an explicit native network backend for Drawable/Texture/Surface rendering. +- Remove placeholder codec metadata, nested image providers, URL-suffix format routing, + and completed encoded-handoff requests retained by Android. +- Split Flutter-codec and native Drawable/Surface animated WebP benchmarks. +- Add an injectable encoded-byte disk-cache boundary before direct codec decode, + without introducing a file-backed or nested image provider. +- Add a lightweight file implementation with an in-memory index, byte-capacity + LRU, same-key single-flight, atomic writes and background cleanup. +- Add direct-network headers, cache keys, per-attempt timeouts, transient retries + and cancellation. +- Coalesce equal raw-byte network misses and prioritize visible transfer/decode + work over queued prefetches. +- Batch raw-cache writes and LRU touches after the first displayed frame. +- Keep ordinary PNG, GIF and WebP on Flutter's codec by default; reserve the + native backend for explicit Drawable and private-decoder integrations. +- Submit normal Surface frames asynchronously and wait for presentation only at + teardown; scope release serialization to a TextureRegistry generation. +- Remove the runtime dependency on global `PowerImageBinding`/`ImageCacheExt`; + native requests now follow the stream completer's last listener. +- Add cold-load, raw-byte disk-hit, Flutter ImageCache-hit and 100-Surface + release/rebuild macrobenchmarks. + ## 0.1.0-pre.2 - pre publish in github and flutter pub diff --git a/README.md b/README.md index 1edf5e4..f8ca3f7 100644 --- a/README.md +++ b/README.md @@ -56,37 +56,101 @@ dependency_overrides: ## Setup ### Flutter -#### 1. Replace `ImageCache` with `ImageCacheExt`. +#### 1. Use Flutter's standard binding and image cache + +PowerImage no longer requires a global `PowerImageBinding` or +`ImageCacheExt`. Native texture resources are owned by their stream completer +and released when its last listener is removed. Use Flutter's standard binding: ```dart -/// call before runApp() -PowerImageBinding(); -``` -or -```dart -/// return ImageCacheExt in createImageCache(), -/// if you have extends with WidgetsFlutterBinding -class XXX extends WidgetsFlutterBinding { - @override - ImageCache createImageCache() { - return ImageCacheExt(); - } -} +WidgetsFlutterBinding.ensureInitialized(); ``` - - #### 2. Setup PowerImageLoader Initialize and set the global default rendering mode, renderingTypeTexture is texture mode, renderingTypeExternal is ffi mode In addition, there are exception reports in PowerImageSetupOptions, and the sampling rate of exception reports can be set. ```dart PowerImageLoader.instance.setup(PowerImageSetupOptions(renderingTypeTexture, + debugLogging: true, errorCallbackSamplingRate: 1.0, errorCallback: (PowerImageLoadException exception) { })); ``` +On Android, `debugLogging: true` enables structured request, Surface lifecycle, +animated-frame coalescing and render-time logs. It is disabled by default. Read +the logs with `adb logcat -s PowerImage` while diagnosing performance. + +### Android animated image adapters + +Animated GIF and custom animated `Drawable` adapters draw directly into a +Flutter `SurfaceProducer`; frames are coalesced at VSync and stale frames are +dropped. Android HTTP(S) images now default to one Flutter `ImageProvider`: encoded +bytes go directly to Flutter's codec, whose content/magic detection does not +depend on a URL suffix. This avoids a native request, placeholder `ImageInfo` +and nested `Image.file` cache entry. + +Use `networkBackend: PowerImageNetworkBackend.native` to opt into the separate +Glide/Drawable/Surface path. A custom native loader may still hand encoded data +or a cache-file path to Flutter; that compatibility path now publishes real +codec frames on the original stream and removes its Android request immediately +after successful delivery. + +The direct codec path can use the built-in encoded-byte disk cache or another +implementation of the lightweight `PowerImageRawBytesCache` interface. +The cache returns `Uint8List`; a hit is passed straight to +`ImmutableBuffer`/`ui.Codec`, so no temporary `Image.file` or nested provider is +created. Equal concurrent misses share one byte download. Visible requests are +scheduled before queued prefetch work, with independent limits for transfer and +decode. Cache writes and LRU touches are batched after the first displayed frame, +so disk maintenance is not on the first-frame critical path. + +```dart +final temporaryDirectory = await getTemporaryDirectory(); +final rawBytesCache = PowerImageFileRawBytesCache( + // Give each cache instance its own directory. + directory: Directory( + '${temporaryDirectory.path}${Platform.pathSeparator}power_image_raw_bytes', + ), + maxSizeBytes: 200 * 1024 * 1024, +); +await rawBytesCache.warmUp(); + +PowerImageLoader.instance.setup(PowerImageSetupOptions( + renderingTypeTexture, + rawBytesCache: rawBytesCache, +)); + +final cancelToken = PowerImageCancellationToken(); + +PowerImage.network( + imageUrl, + headers: const {'Authorization': 'Bearer token'}, + cacheKey: 'user-42-avatar-v3', + timeout: const Duration(seconds: 5), // applied to every attempt + retryCount: 2, // two retries after the first attempt + retryDelay: const Duration(milliseconds: 100), + cancellationToken: cancelToken, +); + +// For example, when the owning screen is disposed: +cancelToken.cancel(); +``` + +The built-in cache keeps only metadata in memory. It uses capacity-bounded LRU, +same-key read/write single-flight, same-directory atomic replacement and +background cleanup. Its file modification times preserve an approximate LRU +order after a restart. Use one instance per dedicated directory. + +`cacheKey` defaults to the URL. This cache deliberately has no TTL or HTTP +revalidation: use it only for immutable CDN URLs or explicitly versioned cache +keys, including when headers can change the bytes returned by the same URL. +Retries are limited to I/O/timeouts and HTTP 408, 429, and 5xx responses; +cancellation is never retried. Cache read/write failures fall back to the +network or the already decoded image. Set `cacheRawBytes: false` to bypass the +injected cache for one request. + ### iOS @@ -766,6 +830,7 @@ network image: String src, { Key? key, String? renderingType, + PowerImageNetworkBackend networkBackend = PowerImageNetworkBackend.auto, double? imageWidth, double? imageHeight, this.width, diff --git a/README_CN.md b/README_CN.md index 793d74d..09d473c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -57,28 +57,16 @@ dependency_overrides: ### Flutter -#### 1. 用 `ImageCacheExt`替换 `ImageCache` . +#### 1. 使用 Flutter 标准 Binding 和图片缓存 -```dart -/// call before runApp() -PowerImageBinding(); -``` - -or +PowerImage 不再依赖全局 `PowerImageBinding` 或 `ImageCacheExt`。原生纹理资源由 +对应的 stream completer 管理,并在最后一个监听者移除时释放。应用只需使用 +Flutter 标准 Binding: ```dart -/// return ImageCacheExt in createImageCache(), -/// if you have extends with WidgetsFlutterBinding -class XXX extends WidgetsFlutterBinding { - @override - ImageCache createImageCache() { - return ImageCacheExt(); - } -} +WidgetsFlutterBinding.ensureInitialized(); ``` - - #### 2. 初始化 PowerImageLoader 初始化并设置全局的默认的渲染方式,renderingTypeTexture为texture方式,renderingTypeExternal为ffi方式 diff --git a/android/build.gradle b/android/build.gradle index c780f94..1eeb54c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -4,28 +4,34 @@ version '1.0' buildscript { repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:8.11.1' } } rootProject.allprojects { repositories { google() - jcenter() + mavenCentral() } } apply plugin: 'com.android.library' android { - compileSdkVersion 29 + namespace 'com.taobao.power_image' + compileSdkVersion 36 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } defaultConfig { - minSdkVersion 16 + minSdkVersion 21 // externalNativeBuild { // cmake { // cppFlags '-std=c++14' @@ -42,3 +48,8 @@ android { // } // } } + +dependencies { + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.12.0' +} diff --git a/android/src/main/java/com/taobao/power_image/PowerImageDiagnostics.java b/android/src/main/java/com/taobao/power_image/PowerImageDiagnostics.java new file mode 100644 index 0000000..cabd087 --- /dev/null +++ b/android/src/main/java/com/taobao/power_image/PowerImageDiagnostics.java @@ -0,0 +1,90 @@ +package com.taobao.power_image; + +import android.os.Debug; +import android.os.SystemClock; +import android.util.Log; + +/** Structured, opt-in diagnostics for request and animated-frame performance. */ +public final class PowerImageDiagnostics { + public static final String TAG = "PowerImage"; + + private static volatile boolean enabled; + + private PowerImageDiagnostics() { + } + + /** Enables verbose diagnostics at runtime. Disabled by default. */ + public static void setEnabled(boolean value) { + if (enabled == value) { + return; + } + enabled = value; + Log.i(TAG, "event=diagnostics enabled=" + value); + } + + public static boolean isDebugEnabled() { + return enabled || Log.isLoggable(TAG, Log.DEBUG); + } + + public static boolean isVerboseEnabled() { + return enabled || Log.isLoggable(TAG, Log.VERBOSE); + } + + public static void debug(String event, String requestId, String details) { + if (!isDebugEnabled()) { + return; + } + Log.d(TAG, format(event, requestId, details)); + } + + public static void verbose(String event, String requestId, String details) { + if (!isVerboseEnabled()) { + return; + } + Log.v(TAG, format(event, requestId, details)); + } + + public static void error( + String event, String requestId, String details, Throwable throwable) { + String message = format(event, requestId, details); + if (throwable == null) { + Log.e(TAG, message); + } else { + Log.e(TAG, message, throwable); + } + } + + /** Avoids putting URLs and file paths from the request key into logcat. */ + public static String requestToken(String requestId) { + return valueToken(requestId); + } + + /** Returns a correlation token without exposing the original text. */ + public static String valueToken(String value) { + return value == null ? "none" : Integer.toHexString(value.hashCode()); + } + + public static long elapsedMillis(long startedAtNanos) { + return (SystemClock.elapsedRealtimeNanos() - startedAtNanos) / 1_000_000L; + } + + /** Lightweight process memory counters suitable for lifecycle logs. */ + public static String memorySummary() { + Runtime runtime = Runtime.getRuntime(); + long javaUsedBytes = runtime.totalMemory() - runtime.freeMemory(); + return "javaUsedBytes=" + Math.max(0L, javaUsedBytes) + + " javaCommittedBytes=" + runtime.totalMemory() + + " nativeHeapBytes=" + Debug.getNativeHeapAllocatedSize(); + } + + private static String format(String event, String requestId, String details) { + StringBuilder message = new StringBuilder(96) + .append("event=").append(event) + .append(" request=").append(requestToken(requestId)) + .append(" thread=").append(Thread.currentThread().getName()); + if (details != null && !details.isEmpty()) { + message.append(' ').append(details.replace('\n', ' ')); + } + return message.toString(); + } +} diff --git a/android/src/main/java/com/taobao/power_image/PowerImageEngineContext.java b/android/src/main/java/com/taobao/power_image/PowerImageEngineContext.java index ae9ecd1..b99bdb3 100644 --- a/android/src/main/java/com/taobao/power_image/PowerImageEngineContext.java +++ b/android/src/main/java/com/taobao/power_image/PowerImageEngineContext.java @@ -3,6 +3,7 @@ import androidx.annotation.NonNull; import com.taobao.power_image.request.PowerImageRequestManager; +import com.taobao.power_image.request.PowerImageBaseRequest; import java.util.List; import java.util.Map; @@ -66,6 +67,10 @@ public void sendImageStateEvent(Map event, boolean success) { } } + public void releaseCompletedRequest(PowerImageBaseRequest request) { + powerImageRequestManager.releaseCompletedRequest(request); + } + @Override public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { if ("startImageRequests".equals(call.method)) { @@ -85,12 +90,42 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result } else { throw new IllegalArgumentException("stopImageRequests require List arguments"); } + } else if ("setImageAnimationActive".equals(call.method)) { + if (call.arguments instanceof Map) { + Map arguments = (Map) call.arguments; + Object uniqueKeyValue = arguments.get("uniqueKey"); + String uniqueKey = uniqueKeyValue instanceof String + ? (String) uniqueKeyValue : null; + Object activeValue = arguments.get("active"); + if (uniqueKey != null && activeValue instanceof Boolean) { + powerImageRequestManager.setAnimationActive( + uniqueKey, (Boolean) activeValue); + result.success(true); + } else { + result.error("invalid_arguments", + "setImageAnimationActive requires uniqueKey and active", null); + } + } else { + result.error("invalid_arguments", + "setImageAnimationActive requires Map arguments", null); + } + } else if ("setPowerImageDebugLogging".equals(call.method)) { + if (call.arguments instanceof Boolean) { + PowerImageDiagnostics.setEnabled((Boolean) call.arguments); + result.success(true); + } else { + result.error("invalid_arguments", + "setPowerImageDebugLogging requires a boolean", null); + } } else { result.notImplemented(); } } public void onDetached() { + // Flutter calls plugin detach before FlutterJNI detach. Surface textures + // must therefore be unregistered synchronously in this callback. + powerImageRequestManager.releaseAllRequestsForEngineDetach(); if (methodChannel != null) { methodChannel.setMethodCallHandler(null); } diff --git a/android/src/main/java/com/taobao/power_image/dispatcher/PowerImageDispatcher.java b/android/src/main/java/com/taobao/power_image/dispatcher/PowerImageDispatcher.java index e7f5fdb..1ec221f 100644 --- a/android/src/main/java/com/taobao/power_image/dispatcher/PowerImageDispatcher.java +++ b/android/src/main/java/com/taobao/power_image/dispatcher/PowerImageDispatcher.java @@ -1,6 +1,7 @@ package com.taobao.power_image.dispatcher; import android.os.Handler; +import android.os.HandlerThread; import android.os.Looper; /** @@ -14,7 +15,7 @@ public class PowerImageDispatcher { private static volatile PowerImageDispatcher sInstance; - private boolean isPrepared = false; + private volatile boolean isPrepared = false; private Handler workHandler; private Handler mainHandler; private Looper workLooper; @@ -30,23 +31,17 @@ public static PowerImageDispatcher getInstance() { return sInstance; } - public void prepare() { + public synchronized void prepare() { if (isPrepared) { return; } - isPrepared = true; mainHandler = new Handler(Looper.getMainLooper()); - Thread workThread = new Thread(new Runnable() { - @Override - public void run() { - Looper.prepare(); - workLooper = Looper.myLooper(); - workHandler = new Handler(); - Looper.loop(); - } - }); - workThread.setName("com.taobao.power_image.work"); + HandlerThread workThread = + new HandlerThread("com.taobao.power_image.work"); workThread.start(); + workLooper = workThread.getLooper(); + workHandler = new Handler(workLooper); + isPrepared = true; } public void runOnWorkThread(Runnable runnable) { diff --git a/android/src/main/java/com/taobao/power_image/loader/FencedSurfaceRenderer.java b/android/src/main/java/com/taobao/power_image/loader/FencedSurfaceRenderer.java new file mode 100644 index 0000000..d876d48 --- /dev/null +++ b/android/src/main/java/com/taobao/power_image/loader/FencedSurfaceRenderer.java @@ -0,0 +1,77 @@ +package com.taobao.power_image.loader; + +import android.graphics.Canvas; +import android.graphics.HardwareRenderer; +import android.graphics.RecordingCanvas; +import android.graphics.RenderNode; +import android.view.Surface; + +import androidx.annotation.RequiresApi; + +/** API 29 renderer with asynchronous frames and one present fence at teardown. */ +@RequiresApi(29) +final class FencedSurfaceRenderer { + private final RenderNode renderNode = new RenderNode("PowerImageSurface"); + private final HardwareRenderer renderer = new HardwareRenderer(); + private RecordingCanvas canvas; + private Surface surface; + private boolean submittedFrame; + + FencedSurfaceRenderer() { + renderer.setContentRoot(renderNode); + renderer.setOpaque(false); + } + + Canvas lock(Surface target, int width, int height) { + if (surface != target) { + renderer.setSurface(target); + surface = target; + } + renderNode.setPosition(0, 0, width, height); + canvas = renderNode.beginRecording(width, height); + return canvas; + } + + boolean owns(Canvas candidate) { + return canvas == candidate; + } + + void unlockAndPost() { + renderNode.endRecording(); + canvas = null; + int result = renderer.createRenderRequest() + .setVsyncTime(System.nanoTime()) + .setWaitForPresent(waitForPresent(false)) + .syncAndDraw(); + submittedFrame = true; + if ((result & HardwareRenderer.SYNC_LOST_SURFACE_REWARD_IF_FOUND) != 0) { + throw new IllegalStateException("Surface was lost while rendering"); + } + } + + void release() { + try { + if (submittedFrame && surface != null && surface.isValid()) { + // The normal frame path only waits for command submission. This + // final request is the teardown fence that prevents HWUI from + // retaining the ImageReader surface after producer release. + renderer.createRenderRequest() + .setVsyncTime(System.nanoTime()) + .setWaitForPresent(waitForPresent(true)) + .syncAndDraw(); + } + } catch (RuntimeException ignored) { + // A surface may be reported invalid concurrently with teardown. + } finally { + canvas = null; + surface = null; + submittedFrame = false; + renderer.setSurface(null); + renderer.destroy(); + } + } + + static boolean waitForPresent(boolean tearingDown) { + return tearingDown; + } +} diff --git a/android/src/main/java/com/taobao/power_image/loader/FlutterEncodedImage.java b/android/src/main/java/com/taobao/power_image/loader/FlutterEncodedImage.java new file mode 100644 index 0000000..b7b3367 --- /dev/null +++ b/android/src/main/java/com/taobao/power_image/loader/FlutterEncodedImage.java @@ -0,0 +1,88 @@ +package com.taobao.power_image.loader; + +import android.graphics.Rect; +import android.view.Surface; + +/** Compressed image data intended for Flutter's codec instead of a native texture. */ +public final class FlutterEncodedImage extends FlutterImage { + private volatile byte[] encodedData; + private volatile String encodedFilePath; + private final int width; + private final int height; + private final int frameCount; + + public FlutterEncodedImage(byte[] encodedData, int width, int height, int frameCount) { + super(); + if (encodedData == null || encodedData.length == 0) { + throw new IllegalArgumentException("Empty encoded image data"); + } + validateDimensions(width, height, frameCount); + this.encodedData = encodedData; + this.width = width; + this.height = height; + this.frameCount = frameCount; + } + + public FlutterEncodedImage(String encodedFilePath, int width, int height, int frameCount) { + super(); + if (encodedFilePath == null || encodedFilePath.isEmpty()) { + throw new IllegalArgumentException("Empty encoded image file path"); + } + validateDimensions(width, height, frameCount); + this.encodedFilePath = encodedFilePath; + this.width = width; + this.height = height; + this.frameCount = frameCount; + } + + private static void validateDimensions(int width, int height, int frameCount) { + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("Invalid encoded image dimensions"); + } + if (frameCount <= 0) { + throw new IllegalArgumentException("Invalid frame count"); + } + } + + @Override + public int getFrameCount() { + return frameCount; + } + + @Override + public byte[] getEncodedData() { + return encodedData; + } + + @Override + public String getEncodedFilePath() { + return encodedFilePath; + } + + @Override + public int getWidth() { + return width; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public boolean isValid() { + return encodedData != null || encodedFilePath != null; + } + + @Override + public void release() { + encodedData = null; + encodedFilePath = null; + } + + @Override + public void draw(Surface surface, Rect destRect) { + throw new UnsupportedOperationException( + "Encoded-only images require the Flutter codec backend"); + } +} diff --git a/android/src/main/java/com/taobao/power_image/loader/FlutterImage.java b/android/src/main/java/com/taobao/power_image/loader/FlutterImage.java index 0882fe7..c6a6e5b 100644 --- a/android/src/main/java/com/taobao/power_image/loader/FlutterImage.java +++ b/android/src/main/java/com/taobao/power_image/loader/FlutterImage.java @@ -1,19 +1,42 @@ package com.taobao.power_image.loader; +import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Drawable; +import android.os.Build; import android.view.Surface; +import com.taobao.power_image.PowerImageDiagnostics; + +import java.nio.ByteBuffer; + /** */ public abstract class FlutterImage { + private static final int CANVAS_MODE_UNKNOWN = 0; + private static final int CANVAS_MODE_HARDWARE = 1; + private static final int CANVAS_MODE_SOFTWARE = 2; + protected Drawable drawable; protected boolean needRecycle; + private volatile int canvasMode = CANVAS_MODE_UNKNOWN; + private volatile String diagnosticRequestId; + private FencedSurfaceRenderer fencedRenderer; + + public interface SurfaceProvider { + Surface getSurface(); + } public FlutterImage(Drawable drawable) { this(drawable, false); } + /** Constructor for encoded-only images that never allocate a Drawable. */ + protected FlutterImage() { + drawable = null; + needRecycle = false; + } + /** * * @param drawable actual drawable to render @@ -40,6 +63,18 @@ public FlutterImage(Drawable drawable, boolean needRecycle) { */ public abstract void release(); + /** + * Releases the image and runs {@code onReleased} after no renderer can use + * the backing drawable anymore. Single-frame images release synchronously; + * animated images may wait for an in-flight surface draw to finish. + */ + public void release(Runnable onReleased) { + release(); + if (onReleased != null) { + onReleased.run(); + } + } + /** * draw the actual bitmap to the surface with specific destRect * @param surface @@ -47,6 +82,123 @@ public FlutterImage(Drawable drawable, boolean needRecycle) { */ public abstract void draw(Surface surface, Rect destRect); + /** + * Draw using a Flutter-managed surface that may change during its lifetime. + */ + public void draw(SurfaceProvider surfaceProvider, Rect destRect) { + if (surfaceProvider == null) { + return; + } + draw(surfaceProvider.getSurface(), destRect); + } + + /** + * Called when Flutter temporarily destroys the backing surface. + */ + public void onSurfaceCleanup() { + releaseSurfaceRenderer(); + resetCanvasMode(); + } + + /** + * Pauses or resumes animated images. Single-frame images ignore this state. + */ + public void setAnimationActive(boolean active) { + } + + /** Returns the original compressed image when it is cheaply available. */ + public byte[] getEncodedData() { + return null; + } + + /** Returns a readable compressed image file when one is already cached. */ + public String getEncodedFilePath() { + return null; + } + + /** Copies a decoder-owned buffer without changing its position. */ + protected static byte[] copyEncodedBuffer(ByteBuffer source) { + if (source == null) { + return null; + } + ByteBuffer copy = source.asReadOnlyBuffer(); + copy.rewind(); + if (!copy.hasRemaining()) { + return null; + } + byte[] bytes = new byte[copy.remaining()]; + copy.get(bytes); + return bytes; + } + + public final void setDiagnosticRequestId(String requestId) { + diagnosticRequestId = requestId; + } + + protected final String diagnosticRequestId() { + return diagnosticRequestId; + } + + protected final Canvas lockSurfaceCanvas(Surface surface, int width, int height) { + if (surface == null || !surface.isValid()) { + throw new IllegalStateException("Surface is unavailable"); + } + if (needsFencedHwuiRenderer()) { + if (fencedRenderer == null) { + fencedRenderer = new FencedSurfaceRenderer(); + } + canvasMode = CANVAS_MODE_HARDWARE; + return fencedRenderer.lock(surface, width, height); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && canvasMode != CANVAS_MODE_SOFTWARE) { + try { + Canvas canvas = surface.lockHardwareCanvas(); + canvasMode = CANVAS_MODE_HARDWARE; + return canvas; + } catch (RuntimeException ignored) { + // Some Surface implementations do not support hardware canvases. + canvasMode = CANVAS_MODE_SOFTWARE; + PowerImageDiagnostics.debug( + "canvas_fallback", + diagnosticRequestId, + "reason=" + ignored.getClass().getSimpleName()); + } + } + Canvas canvas = surface.lockCanvas(null); + canvasMode = CANVAS_MODE_SOFTWARE; + return canvas; + } + + protected final void unlockSurfaceCanvasAndPost(Surface surface, Canvas canvas) { + if (fencedRenderer != null && fencedRenderer.owns(canvas)) { + fencedRenderer.unlockAndPost(); + return; + } + surface.unlockCanvasAndPost(canvas); + } + + protected final void releaseSurfaceRenderer() { + FencedSurfaceRenderer renderer = fencedRenderer; + if (renderer == null) { + return; + } + fencedRenderer = null; + renderer.release(); + } + + static boolean needsFencedHwuiRenderer(int sdkInt) { + return sdkInt >= Build.VERSION_CODES.Q; + } + + private static boolean needsFencedHwuiRenderer() { + return needsFencedHwuiRenderer(Build.VERSION.SDK_INT); + } + + protected final void resetCanvasMode() { + canvasMode = CANVAS_MODE_UNKNOWN; + } + /** * Returns the drawable's intrinsic width. * @return diff --git a/android/src/main/java/com/taobao/power_image/loader/FlutterMultiFrameImage.java b/android/src/main/java/com/taobao/power_image/loader/FlutterMultiFrameImage.java index 47afd2a..af62656 100644 --- a/android/src/main/java/com/taobao/power_image/loader/FlutterMultiFrameImage.java +++ b/android/src/main/java/com/taobao/power_image/loader/FlutterMultiFrameImage.java @@ -3,48 +3,72 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PorterDuffXfermode; +import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Handler; import android.os.HandlerThread; import android.os.SystemClock; +import android.os.Trace; import android.view.Surface; +import com.taobao.power_image.PowerImageDiagnostics; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; -import java.lang.ref.WeakReference; /** * created by wayne.xie on 2021/7/22 - * A MultiFrame Object。 such as format: gif、apng + * A MultiFrame Object。 such as format: gif、webp、apng */ public abstract class FlutterMultiFrameImage extends FlutterImage implements Drawable.Callback { private static final String TAG = "FlutterMultiFrameImage"; // schedule thread for multi-frame private static final Handler gAnimateScheduler; + private static final ExecutorService gRenderExecutor; + private static final MultiFrameRenderCoordinator gRenderCoordinator; static { final HandlerThread schedulerThead = new HandlerThread("multi-frame-image-scheduler"); schedulerThead.start(); gAnimateScheduler = new Handler(schedulerThead.getLooper()); + final int renderThreadCount = Math.min( + 4, Math.max(2, Runtime.getRuntime().availableProcessors() / 2)); + final AtomicInteger renderThreadId = new AtomicInteger(); + gRenderExecutor = Executors.newFixedThreadPool( + renderThreadCount, + new ThreadFactory() { + @Override + public Thread newThread(Runnable runnable) { + return new Thread( + runnable, + "power-image-render-" + renderThreadId.incrementAndGet()); + } + }); + gRenderCoordinator = new MultiFrameRenderCoordinator( + gRenderExecutor, renderThreadCount); + PowerImageDiagnostics.debug( + "render_pool_created", null, "threads=" + renderThreadCount); } - - private WeakReference curBitmapRef = new WeakReference<>(null); - - private volatile Surface surface; + private volatile Surface fixedSurface; + private volatile SurfaceProvider surfaceProvider; private volatile Rect destRect; - private volatile boolean released = false; + private boolean started = false; - private final Paint painter = new Paint(); - - private final Rect srcRect; - private boolean needRecycle=false; + private final MultiFrameRenderState renderState = new MultiFrameRenderState(); + private final Object surfaceLock = new Object(); + private final List releaseCallbacks = new ArrayList<>(); + private long lastLoggedFrameCount; public FlutterMultiFrameImage(Drawable drawable) { this(drawable, false); @@ -53,63 +77,47 @@ public FlutterMultiFrameImage(Drawable drawable) { public FlutterMultiFrameImage(Drawable drawable, boolean needRecycle) { super(drawable, needRecycle); drawable.setCallback(this); - - srcRect = new Rect(0, 0, getWidth(), getHeight()); } @Override public final void invalidateDrawable(final Drawable who) { - if (released) { + if (!renderState.markDirty()) { return; } + gRenderCoordinator.requestRender(this); + } - runOnScheduler(new Runnable() { - @Override - public void run() { - try { - final Bitmap newly = getCurrentFrame(who); - if (newly == null) { - return; - } - - if (newly != curBitmapRef.get()) { - curBitmapRef = new WeakReference<>(newly); - - - if (!surface.isValid()) { - return; - } - - final Canvas canvas = surface.lockCanvas(null); - if (canvas == null) { - return; - } - - painter.setXfermode(new PorterDuffXfermode(android.graphics.PorterDuff.Mode.CLEAR)); - canvas.drawPaint(painter); - painter.setXfermode(new PorterDuffXfermode(android.graphics.PorterDuff.Mode.DST_OVER)); - - canvas.drawBitmap(newly, srcRect, destRect, painter); - surface.unlockCanvasAndPost(canvas); + /** + * Returns a one-off snapshot for the external rendering path. + * Texture animations draw the Drawable directly and never retain this Bitmap. + */ + @Deprecated + public Bitmap getCurrentFrame(Drawable who) { + final int width = getWidth(); + final int height = getHeight(); + if (who == null || width <= 0 || height <= 0) { + return null; + } - } - } catch (Throwable t) { - t.printStackTrace(); - } - } - }, true); + final Rect previousBounds = who.copyBounds(); + final Bitmap frame = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(frame); + try { + who.setBounds(0, 0, width, height); + who.draw(canvas); + } finally { + who.setBounds(previousBounds); + } + return frame; } - public abstract Bitmap getCurrentFrame(Drawable who); - @Override public final void scheduleDrawable(Drawable who, Runnable what, long when) { - if (released) { + if (renderState.isReleased()) { return; } - final long delay = when - SystemClock.uptimeMillis(); - gAnimateScheduler.postDelayed(what, delay); + gAnimateScheduler.postAtTime(what, this, when); } @Override @@ -119,66 +127,394 @@ public final void unscheduleDrawable(Drawable who, Runnable what) { @Override public final void draw(Surface surface, Rect destRect) { - if (released) { + if (renderState.isReleased()) { return; } + synchronized (surfaceLock) { + this.surfaceProvider = null; + this.fixedSurface = surface; + } + attachSurface(destRect); + } + @Override + public final void draw(SurfaceProvider surfaceProvider, Rect destRect) { + if (renderState.isReleased()) { + return; + } + synchronized (surfaceLock) { + this.fixedSurface = null; + this.surfaceProvider = surfaceProvider; + } + attachSurface(destRect); + } - this.destRect = destRect; - this.surface = surface; - - - final Canvas canvas = surface.lockCanvas(null); - canvas.drawColor(Color.argb(1, 255, 255, 255)); - surface.unlockCanvasAndPost(canvas); - + private void attachSurface(Rect destRect) { + this.destRect = new Rect(destRect); + if (renderState.markDirty()) { + gRenderCoordinator.requestRender(this); + } runOnScheduler(new Runnable() { @Override public void run() { - if (drawable != null) { - onStart(drawable); + if (renderState.isActive() && drawable != null && !started) { + startDrawable(); } } }, false); } + private boolean renderCurrentFrame() { + final long frameStartedAt = SystemClock.elapsedRealtimeNanos(); + synchronized (surfaceLock) { + final Drawable currentDrawable = drawable; + final Rect currentDestRect = destRect; + final SurfaceProvider currentProvider = surfaceProvider; + final Surface surface = currentProvider != null + ? currentProvider.getSurface() + : fixedSurface; + if (renderState.isReleased() || currentDrawable == null + || currentDestRect == null || surface == null || !surface.isValid()) { + return false; + } + + final long surfaceReadyAt = SystemClock.elapsedRealtimeNanos(); + final Canvas canvas = lockSurfaceCanvas( + surface, currentDestRect.width(), currentDestRect.height()); + final long canvasLockedAt = SystemClock.elapsedRealtimeNanos(); + long drawableDrawnAt; + try { + if (!coversOpaqueSurface(currentDrawable, currentDestRect, canvas)) { + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); + } + currentDrawable.setBounds(currentDestRect); + currentDrawable.draw(canvas); + } finally { + drawableDrawnAt = SystemClock.elapsedRealtimeNanos(); + unlockSurfaceCanvasAndPost(surface, canvas); + } + final long framePostedAt = SystemClock.elapsedRealtimeNanos(); + if (PowerImageDiagnostics.isVerboseEnabled() + && framePostedAt - frameStartedAt > 32_000_000L) { + PowerImageDiagnostics.verbose( + "frame_slow", + diagnosticRequestId(), + "surfaceUs=" + ((surfaceReadyAt - frameStartedAt) / 1_000L) + + " lockUs=" + ((canvasLockedAt - surfaceReadyAt) / 1_000L) + + " drawUs=" + ((drawableDrawnAt - canvasLockedAt) / 1_000L) + + " postUs=" + ((framePostedAt - drawableDrawnAt) / 1_000L) + + " hardware=" + canvas.isHardwareAccelerated()); + } + return true; + } + } + + final boolean dispatchPendingFrame(Executor executor, final Runnable onTaskFinished) { + if (!renderState.tryStartRender()) { + return false; + } + + final long queuedAtNanos = SystemClock.elapsedRealtimeNanos(); + try { + executor.execute(new Runnable() { + @Override + public void run() { + Trace.beginSection("PowerImage#renderAnimatedFrames"); + final long startedAt = SystemClock.elapsedRealtimeNanos(); + boolean rendered = false; + boolean failed = false; + try { + rendered = renderCurrentFrame(); + } catch (Throwable error) { + failed = true; + PowerImageDiagnostics.error( + "frame_render_failed", + diagnosticRequestId(), + null, + error); + } finally { + try { + Trace.endSection(); + MultiFrameRenderState.Completion completion = + renderState.finishRender( + SystemClock.elapsedRealtimeNanos() - startedAt, + rendered, + failed); + long queueWaitNanos = startedAt - queuedAtNanos; + if (queueWaitNanos > 16_666_667L) { + PowerImageDiagnostics.verbose( + "frame_queue_wait", + diagnosticRequestId(), + "waitUs=" + (queueWaitNanos / 1_000L)); + } + logPeriodicFrameStats(); + handleCompletion(completion); + } finally { + onTaskFinished.run(); + } + } + } + }); + } catch (RuntimeException error) { + handleCompletion(renderState.abortRender()); + onTaskFinished.run(); + PowerImageDiagnostics.error( + "frame_dispatch_failed", + diagnosticRequestId(), + null, + error); + } + return true; + } + + private void handleCompletion(MultiFrameRenderState.Completion completion) { + if (completion == MultiFrameRenderState.Completion.CLEANUP) { + scheduleReleaseCleanup(); + } else if (completion == MultiFrameRenderState.Completion.RENDER_AGAIN) { + gRenderCoordinator.requestRender(this); + } + } + + private void logPeriodicFrameStats() { + if (!PowerImageDiagnostics.isVerboseEnabled()) { + return; + } + MultiFrameRenderState.Snapshot stats = renderState.snapshot(); + if (stats.renderedFrames >= lastLoggedFrameCount + 120) { + lastLoggedFrameCount = stats.renderedFrames; + logFrameStats("frame_stats"); + } + } + + private void logFrameStats(String event) { + MultiFrameRenderState.Snapshot stats = renderState.snapshot(); + PowerImageDiagnostics.debug( + event, + diagnosticRequestId(), + "invalidations=" + stats.invalidations + + " rendered=" + stats.renderedFrames + + " coalesced=" + stats.coalescedFrames + + " skipped=" + stats.skippedFrames + + " failed=" + stats.failedFrames + + " avgUs=" + (stats.averageRenderNanos() / 1_000L) + + " maxUs=" + (stats.maxRenderNanos / 1_000L)); + } + + private boolean coversOpaqueSurface( + Drawable currentDrawable, Rect currentDestRect, Canvas canvas) { + return isCurrentFrameOpaqueAndComplete(currentDrawable) + && currentDestRect.left <= 0 + && currentDestRect.top <= 0 + && currentDestRect.right >= canvas.getWidth() + && currentDestRect.bottom >= canvas.getHeight(); + } + + /** + * Subclasses may opt into skipping the surface clear only when every + * rendered frame is known to be both opaque and a complete image. + * Drawable#getOpacity alone is not sufficient for partial GIF/WebP frames. + */ + protected boolean isCurrentFrameOpaqueAndComplete(Drawable who) { + return false; + } + /** * we should play the GifDrawable * @param who */ protected abstract void onStart(Drawable who); + protected void onStop(Drawable who) { + } + + @Override + public final void setAnimationActive(final boolean active) { + if (!renderState.setActive(active)) { + return; + } + if (active) { + gRenderCoordinator.requestRender(this); + } else { + gRenderCoordinator.remove(this); + } + runOnScheduler(new Runnable() { + @Override + public void run() { + if (drawable == null) { + return; + } + if (active && hasSurface() && !started) { + startDrawable(); + } else if (!active && started) { + stopDrawable(); + } + } + }, false); + } + + @Override + public final void onSurfaceCleanup() { + if (renderState.isReleased()) { + return; + } + synchronized (surfaceLock) { + surfaceProvider = null; + releaseSurfaceRenderer(); + resetCanvasMode(); + } + renderState.clearDirty(); + gRenderCoordinator.remove(this); + runOnScheduler(new Runnable() { + @Override + public void run() { + if (!hasSurface() && drawable != null && started) { + stopDrawable(); + } + } + }, false); + } + /** * we should stop the gifDrawable and do some gc work */ @Override public final void release() { - released = true; + release(null); + } + + @Override + public final void release(Runnable onReleased) { + boolean runCallbackNow = false; + boolean firstRelease = false; + synchronized (renderState) { + if (onReleased != null) { + if (renderState.isCleanupFinished()) { + runCallbackNow = true; + } else { + releaseCallbacks.add(onReleased); + } + } + firstRelease = !renderState.isReleased(); + renderState.release(); + } + if (runCallbackNow) { + onReleased.run(); + return; + } + + if (firstRelease) { + gRenderCoordinator.remove(this); + gAnimateScheduler.removeCallbacksAndMessages(this); + } + scheduleReleaseCleanup(); + } + + protected abstract void onRelease(Drawable who); + + private void startDrawable() { + try { + onStart(drawable); + started = true; + PowerImageDiagnostics.debug( + "animation_started", diagnosticRequestId(), null); + } catch (Throwable error) { + PowerImageDiagnostics.error( + "animation_start_failed", diagnosticRequestId(), null, error); + } + } + + private void stopDrawable() { + try { + onStop(drawable); + } catch (Throwable error) { + PowerImageDiagnostics.error( + "animation_stop_failed", diagnosticRequestId(), null, error); + } finally { + started = false; + PowerImageDiagnostics.debug( + "animation_stopped", diagnosticRequestId(), null); + } + } + + private boolean hasSurface() { + synchronized (surfaceLock) { + return surfaceProvider != null || fixedSurface != null; + } + } + + private void scheduleReleaseCleanup() { + if (!renderState.tryScheduleCleanup()) { + return; + } runOnScheduler(new Runnable() { @Override public void run() { - if (drawable != null) { - onRelease(drawable); - drawable = null; - } + try { + synchronized (surfaceLock) { + if (drawable != null) { + Drawable releasedDrawable = drawable; + drawable = null; + releasedDrawable.setCallback(null); + try { + onRelease(releasedDrawable); + } catch (Throwable error) { + PowerImageDiagnostics.error( + "drawable_release_failed", + diagnosticRequestId(), + null, + error); + } + } - if (surface != null) { - surface.release(); - surface = null; + if (fixedSurface != null) { + Surface releasedSurface = fixedSurface; + fixedSurface = null; + try { + releasedSurface.release(); + } catch (Throwable error) { + PowerImageDiagnostics.error( + "surface_release_failed", + diagnosticRequestId(), + null, + error); + } + } + surfaceProvider = null; + releaseSurfaceRenderer(); + } + } finally { + started = false; + logFrameStats("frame_stats_final"); + + final List callbacks; + synchronized (renderState) { + renderState.markCleanupFinished(); + callbacks = new ArrayList<>(releaseCallbacks); + releaseCallbacks.clear(); + } + for (Runnable callback : callbacks) { + try { + callback.run(); + } catch (Throwable error) { + PowerImageDiagnostics.error( + "release_callback_failed", + diagnosticRequestId(), + null, + error); + } + } } } }, false); } - protected abstract void onRelease(Drawable who); - private void runOnScheduler(Runnable task, boolean forceNextLoop) { if (Thread.currentThread() == gAnimateScheduler.getLooper().getThread() && !forceNextLoop) { task.run(); } else { - gAnimateScheduler.post(task); + gAnimateScheduler.postAtTime(task, this, SystemClock.uptimeMillis()); } } } diff --git a/android/src/main/java/com/taobao/power_image/loader/FlutterSingleFrameImage.java b/android/src/main/java/com/taobao/power_image/loader/FlutterSingleFrameImage.java index 0284e63..f9de3be 100644 --- a/android/src/main/java/com/taobao/power_image/loader/FlutterSingleFrameImage.java +++ b/android/src/main/java/com/taobao/power_image/loader/FlutterSingleFrameImage.java @@ -34,6 +34,7 @@ public int getFrameCount() { @Override public void release() { + releaseSurfaceRenderer(); if (needRecycle && bitmap != null) { bitmap.recycle(); bitmap = null; @@ -52,9 +53,13 @@ public int getHeight() { @Override public void draw(Surface surface, Rect destRect) { - final Canvas canvas = surface.lockCanvas(null); - canvas.drawBitmap(bitmap, srcRect, destRect, null); - surface.unlockCanvasAndPost(canvas); + final Canvas canvas = lockSurfaceCanvas( + surface, destRect.width(), destRect.height()); + try { + canvas.drawBitmap(bitmap, srcRect, destRect, null); + } finally { + unlockSurfaceCanvasAndPost(surface, canvas); + } } @Override diff --git a/android/src/main/java/com/taobao/power_image/loader/MultiFrameRenderCoordinator.java b/android/src/main/java/com/taobao/power_image/loader/MultiFrameRenderCoordinator.java new file mode 100644 index 0000000..f3664c4 --- /dev/null +++ b/android/src/main/java/com/taobao/power_image/loader/MultiFrameRenderCoordinator.java @@ -0,0 +1,125 @@ +package com.taobao.power_image.loader; + +import android.os.Handler; +import android.os.Looper; +import android.view.Choreographer; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; + +/** + * Coalesces invalidations to VSync and keeps executor work bounded. + * + *

At most {@code maxConcurrentRenders} images are submitted at once. Other + * dirty images remain in insertion order, so a slow surface cannot create an + * unbounded executor queue or permanently starve later images. + */ +final class MultiFrameRenderCoordinator { + private final Object lock = new Object(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final Executor renderExecutor; + private final int maxConcurrentRenders; + private final Set dirtyImages = new LinkedHashSet<>(); + + private boolean frameCallbackPending; + private int rendersInFlight; + + private final Runnable renderFinished = new Runnable() { + @Override + public void run() { + onRenderFinished(); + } + }; + + private final Runnable postFrameCallback = new Runnable() { + @Override + public void run() { + Choreographer.getInstance().postFrameCallback(frameCallback); + } + }; + + private final Choreographer.FrameCallback frameCallback = + new Choreographer.FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { + final List batch; + synchronized (lock) { + frameCallbackPending = false; + final int availableSlots = + maxConcurrentRenders - rendersInFlight; + if (dirtyImages.isEmpty() || availableSlots <= 0) { + return; + } + batch = new ArrayList<>( + Math.min(availableSlots, dirtyImages.size())); + java.util.Iterator iterator = + dirtyImages.iterator(); + while (iterator.hasNext() + && batch.size() < availableSlots) { + batch.add(iterator.next()); + iterator.remove(); + } + rendersInFlight += batch.size(); + } + for (FlutterMultiFrameImage image : batch) { + if (!image.dispatchPendingFrame( + renderExecutor, renderFinished)) { + onRenderFinished(); + } + } + } + }; + + MultiFrameRenderCoordinator( + Executor renderExecutor, int maxConcurrentRenders) { + this.renderExecutor = renderExecutor; + if (maxConcurrentRenders <= 0) { + throw new IllegalArgumentException( + "maxConcurrentRenders must be positive"); + } + this.maxConcurrentRenders = maxConcurrentRenders; + } + + void requestRender(FlutterMultiFrameImage image) { + boolean scheduleFrame = false; + synchronized (lock) { + dirtyImages.add(image); + if (!frameCallbackPending + && rendersInFlight < maxConcurrentRenders) { + frameCallbackPending = true; + scheduleFrame = true; + } + } + if (scheduleFrame) { + mainHandler.post(postFrameCallback); + } + } + + void remove(FlutterMultiFrameImage image) { + synchronized (lock) { + dirtyImages.remove(image); + } + } + + private void onRenderFinished() { + boolean scheduleFrame = false; + synchronized (lock) { + if (rendersInFlight <= 0) { + throw new IllegalStateException("No render is in flight"); + } + rendersInFlight--; + if (!dirtyImages.isEmpty() + && !frameCallbackPending + && rendersInFlight < maxConcurrentRenders) { + frameCallbackPending = true; + scheduleFrame = true; + } + } + if (scheduleFrame) { + mainHandler.post(postFrameCallback); + } + } +} diff --git a/android/src/main/java/com/taobao/power_image/loader/MultiFrameRenderState.java b/android/src/main/java/com/taobao/power_image/loader/MultiFrameRenderState.java new file mode 100644 index 0000000..865380c --- /dev/null +++ b/android/src/main/java/com/taobao/power_image/loader/MultiFrameRenderState.java @@ -0,0 +1,177 @@ +package com.taobao.power_image.loader; + +/** + * Thread-safe state for one animated image renderer. + * + *

The state keeps at most one dirty frame and one in-flight render. New + * invalidations replace an older dirty frame instead of creating queue work. + */ +final class MultiFrameRenderState { + enum Completion { + NONE, + RENDER_AGAIN, + CLEANUP + } + + static final class Snapshot { + final long invalidations; + final long coalescedFrames; + final long renderedFrames; + final long skippedFrames; + final long failedFrames; + final long totalRenderNanos; + final long maxRenderNanos; + + Snapshot( + long invalidations, + long coalescedFrames, + long renderedFrames, + long skippedFrames, + long failedFrames, + long totalRenderNanos, + long maxRenderNanos) { + this.invalidations = invalidations; + this.coalescedFrames = coalescedFrames; + this.renderedFrames = renderedFrames; + this.skippedFrames = skippedFrames; + this.failedFrames = failedFrames; + this.totalRenderNanos = totalRenderNanos; + this.maxRenderNanos = maxRenderNanos; + } + + long averageRenderNanos() { + return renderedFrames == 0 ? 0 : totalRenderNanos / renderedFrames; + } + } + + private boolean dirty; + private boolean renderInFlight; + private boolean released; + private boolean active = true; + private boolean cleanupScheduled; + private boolean cleanupFinished; + + private long invalidations; + private long coalescedFrames; + private long renderedFrames; + private long skippedFrames; + private long failedFrames; + private long totalRenderNanos; + private long maxRenderNanos; + + synchronized boolean markDirty() { + if (released || !active) { + return false; + } + invalidations++; + final boolean newlyDirty = !dirty; + if (!newlyDirty) { + coalescedFrames++; + } + dirty = true; + return newlyDirty; + } + + synchronized boolean tryStartRender() { + if (released || !active || !dirty || renderInFlight) { + return false; + } + dirty = false; + renderInFlight = true; + return true; + } + + synchronized Completion finishRender( + long renderNanos, boolean rendered, boolean failed) { + if (!renderInFlight) { + throw new IllegalStateException("No render is in flight"); + } + renderInFlight = false; + if (rendered) { + renderedFrames++; + totalRenderNanos += Math.max(0, renderNanos); + maxRenderNanos = Math.max(maxRenderNanos, Math.max(0, renderNanos)); + } else { + skippedFrames++; + } + if (failed) { + failedFrames++; + } + return nextAction(); + } + + synchronized Completion abortRender() { + if (!renderInFlight) { + return nextAction(); + } + renderInFlight = false; + if (!released && active) { + dirty = true; + } + return nextAction(); + } + + synchronized boolean setActive(boolean active) { + if (released || this.active == active) { + return false; + } + this.active = active; + dirty = active; + return true; + } + + synchronized void clearDirty() { + dirty = false; + } + + synchronized boolean release() { + released = true; + dirty = false; + return !renderInFlight; + } + + synchronized boolean tryScheduleCleanup() { + if (!released || renderInFlight || cleanupScheduled || cleanupFinished) { + return false; + } + cleanupScheduled = true; + return true; + } + + synchronized void markCleanupFinished() { + cleanupFinished = true; + } + + synchronized boolean isReleased() { + return released; + } + + synchronized boolean isActive() { + return active; + } + + synchronized boolean isCleanupFinished() { + return cleanupFinished; + } + + synchronized Snapshot snapshot() { + return new Snapshot( + invalidations, + coalescedFrames, + renderedFrames, + skippedFrames, + failedFrames, + totalRenderNanos, + maxRenderNanos); + } + + private Completion nextAction() { + if (released) { + return Completion.CLEANUP; + } + if (active && dirty) { + return Completion.RENDER_AGAIN; + } + return Completion.NONE; + } +} diff --git a/android/src/main/java/com/taobao/power_image/loader/PowerImageLoaderProtocol.java b/android/src/main/java/com/taobao/power_image/loader/PowerImageLoaderProtocol.java index a51d00e..d77f935 100644 --- a/android/src/main/java/com/taobao/power_image/loader/PowerImageLoaderProtocol.java +++ b/android/src/main/java/com/taobao/power_image/loader/PowerImageLoaderProtocol.java @@ -1,7 +1,5 @@ package com.taobao.power_image.loader; -import android.graphics.Bitmap; - import com.taobao.power_image.request.PowerImageRequestConfig; /** @@ -9,8 +7,15 @@ */ public interface PowerImageLoaderProtocol { + interface PowerImageRequestHandle { + void cancel(); + } + interface PowerImageResponse { void onResult(PowerImageResult result); + + default void onRequestHandle(PowerImageRequestHandle handle) { + } } void handleRequest(PowerImageRequestConfig request, PowerImageResponse response); diff --git a/android/src/main/java/com/taobao/power_image/request/PowerImageBaseRequest.java b/android/src/main/java/com/taobao/power_image/request/PowerImageBaseRequest.java index 277f4f1..6b73cb7 100644 --- a/android/src/main/java/com/taobao/power_image/request/PowerImageBaseRequest.java +++ b/android/src/main/java/com/taobao/power_image/request/PowerImageBaseRequest.java @@ -1,7 +1,11 @@ package com.taobao.power_image.request; +import android.os.SystemClock; + import com.taobao.power_image.PowerImageEngineContext; +import com.taobao.power_image.PowerImageDiagnostics; import com.taobao.power_image.dispatcher.PowerImageDispatcher; +import com.taobao.power_image.loader.FlutterEncodedImage; import com.taobao.power_image.loader.FlutterMultiFrameImage; import com.taobao.power_image.loader.PowerImageLoader; import com.taobao.power_image.loader.PowerImageLoaderProtocol; @@ -25,21 +29,37 @@ public abstract class PowerImageBaseRequest { public static final String RENDER_TYPE_TEXTURE = "texture"; private final PowerImageEngineContext engineContext; + private final Object loadHandleLock = new Object(); + private final long createdAtNanos = SystemClock.elapsedRealtimeNanos(); private PowerImageRequestConfig imageRequestConfig; + private PowerImageLoaderProtocol.PowerImageRequestHandle loadHandle; + private boolean loadHandleReleased; + private volatile boolean requestReleased; String requestId; protected String imageTaskState; protected PowerImageResult realResult; public PowerImageBaseRequest(PowerImageEngineContext context, Map arguments) { engineContext = context; - requestId = (String) arguments.get("uniqueKey"); + Object requestIdValue = arguments.get("uniqueKey"); + requestId = requestIdValue instanceof String + ? (String) requestIdValue : null; imageRequestConfig = PowerImageRequestConfig.requestConfigWithArguments(arguments); + PowerImageDiagnostics.debug( + "request_created", + requestId, + "type=" + imageRequestConfig.imageType + + " render=" + imageRequestConfig.renderingType + + " target=" + imageRequestConfig.width + "x" + + imageRequestConfig.height); } public boolean configTask() { boolean inited = imageRequestConfig != null; imageTaskState = inited ? REQUEST_STATE_INITIALIZE_SUCCEED : REQUEST_STATE_INITIALIZE_FAILED; + PowerImageDiagnostics.debug( + "request_configured", requestId, "success=" + inited); return inited; } @@ -52,20 +72,63 @@ public boolean startLoading() { if (imageRequestConfig == null) { return false; } + PowerImageDiagnostics.debug("load_started", requestId, null); performLoadImage(); return true; } private void performLoadImage() { - PowerImageLoader.getInstance().handleRequest( - imageRequestConfig, - new PowerImageLoaderProtocol.PowerImageResponse() { - @Override - public void onResult(PowerImageResult result) { - PowerImageBaseRequest.this.onLoadResult(result); + try { + PowerImageLoader.getInstance().handleRequest( + imageRequestConfig, + new PowerImageLoaderProtocol.PowerImageResponse() { + @Override + public void onResult(PowerImageResult result) { + PowerImageBaseRequest.this.onLoadResult(result); + } + + @Override + public void onRequestHandle( + PowerImageLoaderProtocol.PowerImageRequestHandle handle) { + PowerImageBaseRequest.this.setLoadHandle(handle); + } } - } - ); + ); + } catch (RuntimeException error) { + PowerImageDiagnostics.error( + "load_dispatch_failed", requestId, null, error); + onLoadFailed(error.getMessage()); + } + } + + private void setLoadHandle(PowerImageLoaderProtocol.PowerImageRequestHandle handle) { + boolean cancelImmediately; + synchronized (loadHandleLock) { + cancelImmediately = loadHandleReleased; + if (!cancelImmediately) { + loadHandle = handle; + } + } + if (cancelImmediately && handle != null) { + handle.cancel(); + } + PowerImageDiagnostics.verbose( + "load_handle", + requestId, + "cancelImmediately=" + cancelImmediately + " present=" + (handle != null)); + } + + protected final void releaseLoadHandle() { + PowerImageLoaderProtocol.PowerImageRequestHandle handle; + synchronized (loadHandleLock) { + loadHandleReleased = true; + handle = loadHandle; + loadHandle = null; + } + if (handle != null) { + handle.cancel(); + } + PowerImageDiagnostics.debug("load_handle_released", requestId, null); } void onLoadResult(PowerImageResult result) { @@ -76,8 +139,18 @@ public void onLoadSuccess() { PowerImageDispatcher.getInstance().runOnMainThread(new Runnable() { @Override public void run() { + if (requestReleased) { + return; + } PowerImageBaseRequest.this.imageTaskState = REQUEST_STATE_LOAD_SUCCEED; + PowerImageDiagnostics.debug( + "load_succeeded", + requestId, + "elapsedMs=" + PowerImageDiagnostics.elapsedMillis(createdAtNanos)); engineContext.sendImageStateEvent(PowerImageBaseRequest.this.encode(), true); + if (releaseAfterSuccessfulDelivery()) { + engineContext.releaseCompletedRequest(PowerImageBaseRequest.this); + } } }); } @@ -86,9 +159,21 @@ public void onLoadFailed(final String errMsg) { PowerImageDispatcher.getInstance().runOnMainThread(new Runnable() { @Override public void run() { + if (requestReleased) { + return; + } PowerImageBaseRequest.this.imageTaskState = REQUEST_STATE_LOAD_FAILED; Map event = PowerImageBaseRequest.this.encode(); event.put("errMsg", errMsg != null ? errMsg : "failed!"); + PowerImageDiagnostics.error( + "load_failed", + requestId, + "elapsedMs=" + PowerImageDiagnostics.elapsedMillis(createdAtNanos) + + " reasonToken=" + + PowerImageDiagnostics.valueToken(errMsg) + + " reasonLength=" + + (errMsg != null ? errMsg.length() : 0), + null); engineContext.sendImageStateEvent(event, false); } }); @@ -98,11 +183,49 @@ public boolean stopTask() { return false; } + protected boolean releaseAfterSuccessfulDelivery() { + return false; + } + + /** + * Releases resources while the Flutter engine is still attached. + * Texture requests override this to unregister textures synchronously. + */ + public boolean stopTaskForEngineDetach() { + return stopTask(); + } + + protected final synchronized boolean markRequestReleased() { + if (requestReleased) { + return false; + } + requestReleased = true; + PowerImageDiagnostics.debug( + "request_releasing", + requestId, + "elapsedMs=" + PowerImageDiagnostics.elapsedMillis(createdAtNanos)); + return true; + } + + protected final boolean isRequestReleased() { + return requestReleased; + } + + protected final PowerImageRequestConfig getImageRequestConfig() { + return imageRequestConfig; + } + + public void setAnimationActive(boolean active) { + } + public Map encode() { Map encodedTask = new HashMap<>(); encodedTask.put("uniqueKey", requestId); encodedTask.put("state", imageTaskState); - if (realResult != null && realResult.success && realResult.image instanceof FlutterMultiFrameImage) { + if (realResult != null + && realResult.success + && (realResult.image instanceof FlutterMultiFrameImage + || realResult.image instanceof FlutterEncodedImage)) { encodedTask.put("_multiFrame", true); } return encodedTask; diff --git a/android/src/main/java/com/taobao/power_image/request/PowerImageExternalRequest.java b/android/src/main/java/com/taobao/power_image/request/PowerImageExternalRequest.java index 9374fea..26b439d 100644 --- a/android/src/main/java/com/taobao/power_image/request/PowerImageExternalRequest.java +++ b/android/src/main/java/com/taobao/power_image/request/PowerImageExternalRequest.java @@ -35,6 +35,12 @@ public PowerImageExternalRequest(PowerImageEngineContext context, Map src; + public String requestId; public String imageType; public String renderingType; public int width; @@ -20,33 +21,41 @@ public class PowerImageRequestConfig { public int originWidth; public int originHeight; + @SuppressWarnings("unchecked") public static PowerImageRequestConfig requestConfigWithArguments(Map arguments) { - Map src = (Map) arguments.get("src"); - String imageType = (String) arguments.get("imageType"); - String renderingType = (String) arguments.get("renderingType"); - double width = .0; - if (arguments.get("width") instanceof Double) { - width = (double) arguments.get("width"); - } - double height = .0; - if (arguments.get("height") instanceof Double) { - height = (double) arguments.get("height"); - } + Object srcValue = arguments.get("src"); + Map src = srcValue instanceof Map + ? (Map) srcValue : null; + String imageType = stringValue(arguments.get("imageType")); + String renderingType = stringValue(arguments.get("renderingType")); float scale = Resources.getSystem().getDisplayMetrics().density; PowerImageRequestConfig config = new PowerImageRequestConfig(); config.src = src; + config.requestId = stringValue(arguments.get("uniqueKey")); config.imageType = imageType; config.renderingType = renderingType; - config.width = (int) (width * scale); - config.height = (int) (height * scale); - config.originWidth = (int) width; - config.originHeight = (int) height; + config.width = dimensionInPixels(arguments.get("width"), scale); + config.height = dimensionInPixels(arguments.get("height"), scale); + config.originWidth = dimensionInPixels(arguments.get("width"), 1f); + config.originHeight = dimensionInPixels(arguments.get("height"), 1f); return config; } + static int dimensionInPixels(Object value, float density) { + if (!(value instanceof Number) || density <= 0 || !Float.isFinite(density)) { + return 0; + } + double points = ((Number) value).doubleValue(); + if (!Double.isFinite(points) || points <= 0) { + return 0; + } + double pixels = points * density; + return pixels >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) pixels; + } + public String srcString() { - return src != null ? (String) src.get("src") : null; + return src != null ? stringValue(src.get("src")) : null; } public boolean isExternal() { @@ -57,4 +66,8 @@ public boolean isTexture() { return TextUtils.equals(renderingType, RENDERING_TYPE_TEXTURE); } + private static String stringValue(Object value) { + return value instanceof String ? (String) value : null; + } + } diff --git a/android/src/main/java/com/taobao/power_image/request/PowerImageRequestManager.java b/android/src/main/java/com/taobao/power_image/request/PowerImageRequestManager.java index ed7c717..044e160 100644 --- a/android/src/main/java/com/taobao/power_image/request/PowerImageRequestManager.java +++ b/android/src/main/java/com/taobao/power_image/request/PowerImageRequestManager.java @@ -4,6 +4,7 @@ import static com.taobao.power_image.request.PowerImageBaseRequest.RENDER_TYPE_TEXTURE; import com.taobao.power_image.PowerImageEngineContext; +import com.taobao.power_image.PowerImageDiagnostics; import java.lang.ref.WeakReference; import java.util.ArrayList; @@ -21,14 +22,25 @@ public class PowerImageRequestManager { private final PowerImageEngineContext engineContext; private Map requests; + private final Map animationStates; private WeakReference textureRegistryWrf; + private SurfaceProducerReleaseGate surfaceReleaseGate; public PowerImageRequestManager(PowerImageEngineContext context) { engineContext = context; requests = new HashMap<>(); + animationStates = new HashMap<>(); + surfaceReleaseGate = new SurfaceProducerReleaseGate(); } public void configWithTextureRegistry(TextureRegistry textureRegistry) { + TextureRegistry previous = textureRegistryWrf != null + ? textureRegistryWrf.get() : null; + if (previous != textureRegistry) { + // One gate belongs to one engine/TextureRegistry generation. An old + // engine release must never stall surfaces created by a new engine. + surfaceReleaseGate = new SurfaceProducerReleaseGate(); + } this.textureRegistryWrf = new WeakReference<>(textureRegistry); } @@ -38,17 +50,48 @@ public List> configRequestsWithArguments(List arguments = list.get(i); - String renderType = (String) arguments.get("renderingType"); + Object item = list.get(i); + if (!(item instanceof Map)) { + PowerImageDiagnostics.error( + "request_config_ignored", null, "reason=null_arguments", null); + continue; + } + @SuppressWarnings("unchecked") + Map arguments = (Map) item; + Object renderTypeValue = arguments.get("renderingType"); + String renderType = renderTypeValue instanceof String + ? (String) renderTypeValue : null; PowerImageBaseRequest request; if (RENDER_TYPE_EXTERNAL.equals(renderType)) { request = new PowerImageExternalRequest(engineContext, arguments); } else if (RENDER_TYPE_TEXTURE.equals(renderType)) { - request = new PowerImageTextureRequest(engineContext, arguments, textureRegistryWrf.get()); + TextureRegistry textureRegistry = textureRegistryWrf != null + ? textureRegistryWrf.get() : null; + request = new PowerImageTextureRequest( + engineContext, + arguments, + textureRegistry, + surfaceReleaseGate); } else { + Object requestIdValue = arguments.get("uniqueKey"); + PowerImageDiagnostics.error( + "request_config_ignored", + requestIdValue instanceof String + ? (String) requestIdValue : null, + "reason=invalid_render_type", + null); continue; } - requests.put(request.requestId, request); + PowerImageBaseRequest previous = requests.put(request.requestId, request); + if (previous != null && previous != request) { + previous.stopTask(); + PowerImageDiagnostics.debug( + "request_replaced", request.requestId, null); + } + Boolean animationActive = animationStates.get(request.requestId); + if (animationActive != null) { + request.setAnimationActive(animationActive); + } boolean success = request.configTask(); Map requestInfo = request.encode(); requestInfo.put("success", success); @@ -62,10 +105,22 @@ public void startLoadingWithArguments(List arguments) { return; } for (int i = 0; i < arguments.size(); i++) { - Map arg = (Map) arguments.get(i); - String requestId = (String) arg.get("uniqueKey"); + Object item = arguments.get(i); + if (!(item instanceof Map)) { + continue; + } + Object requestIdValue = ((Map) item).get("uniqueKey"); + if (!(requestIdValue instanceof String)) { + continue; + } + String requestId = (String) requestIdValue; PowerImageBaseRequest request = requests.get(requestId); - request.startLoading(); + if (request != null) { + request.startLoading(); + } else { + PowerImageDiagnostics.debug( + "request_start_ignored", requestId, "reason=not_configured"); + } } } @@ -75,11 +130,19 @@ public List> releaseRequestsWithArguments(List arguments) { return results; } for (int i = 0; i < arguments.size(); i++) { - Map arg = (Map) arguments.get(i); - String requestId = (String) arg.get("uniqueKey"); + Object item = arguments.get(i); + if (!(item instanceof Map)) { + continue; + } + Object requestIdValue = ((Map) item).get("uniqueKey"); + if (!(requestIdValue instanceof String)) { + continue; + } + String requestId = (String) requestIdValue; PowerImageBaseRequest request = requests.get(requestId); if (request != null) { requests.remove(requestId); + animationStates.remove(requestId); boolean success = request.stopTask(); Map requestInfo = request.encode(); requestInfo.put("success", success); @@ -88,4 +151,50 @@ public List> releaseRequestsWithArguments(List arguments) { } return results; } -} \ No newline at end of file + + public void setAnimationActive(String requestId, boolean active) { + if (requestId == null) { + return; + } + animationStates.put(requestId, active); + PowerImageBaseRequest request = requests.get(requestId); + if (request != null) { + request.setAnimationActive(active); + } + } + + /** Removes an encoded-file handoff immediately after its success event. */ + public void releaseCompletedRequest(PowerImageBaseRequest completedRequest) { + if (completedRequest == null) { + return; + } + String requestId = completedRequest.requestId; + PowerImageBaseRequest current = requests.get(requestId); + if (current != completedRequest) { + return; + } + requests.remove(requestId); + animationStates.remove(requestId); + completedRequest.stopTask(); + } + + public void releaseAllRequests() { + for (PowerImageBaseRequest request : new ArrayList<>(requests.values())) { + request.stopTask(); + } + requests.clear(); + animationStates.clear(); + } + + public void releaseAllRequestsForEngineDetach() { + for (PowerImageBaseRequest request : new ArrayList<>(requests.values())) { + request.stopTaskForEngineDetach(); + } + requests.clear(); + animationStates.clear(); + } + + SurfaceProducerReleaseGate surfaceReleaseGateForTesting() { + return surfaceReleaseGate; + } +} diff --git a/android/src/main/java/com/taobao/power_image/request/PowerImageTextureRequest.java b/android/src/main/java/com/taobao/power_image/request/PowerImageTextureRequest.java index a32e09f..de7971b 100644 --- a/android/src/main/java/com/taobao/power_image/request/PowerImageTextureRequest.java +++ b/android/src/main/java/com/taobao/power_image/request/PowerImageTextureRequest.java @@ -4,41 +4,74 @@ import android.view.Surface; import com.taobao.power_image.PowerImageEngineContext; +import com.taobao.power_image.PowerImageDiagnostics; import com.taobao.power_image.dispatcher.PowerImageDispatcher; +import com.taobao.power_image.loader.FlutterEncodedImage; import com.taobao.power_image.loader.FlutterImage; import com.taobao.power_image.loader.PowerImageResult; import java.lang.ref.WeakReference; +import java.lang.reflect.Method; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import io.flutter.view.TextureRegistry; /** * created by Muke on 2021/7/27 */ -public class PowerImageTextureRequest extends PowerImageBaseRequest { +public class PowerImageTextureRequest extends PowerImageBaseRequest + implements TextureRegistry.SurfaceProducer.Callback { private static final String TAG = "PowerImageTextureRequest"; public static final int MAX_RESIZE_HEIGHT = 1920; public static final int MAX_RESIZE_WIDTH = 1920; - + private static final int ESTIMATED_SURFACE_BUFFER_COUNT = 3; + private static final AtomicInteger ACTIVE_SURFACE_PRODUCERS = new AtomicInteger(); + private static final AtomicLong ESTIMATED_SURFACE_BYTES = new AtomicLong(); private final WeakReference textureRegistryWrf; + private final SurfaceProducerReleaseGate surfaceReleaseGate; + private final AtomicBoolean loadSuccessSent = new AtomicBoolean(false); + private final AtomicBoolean surfaceProducerTracked = new AtomicBoolean(false); + private final int requestedWidth; + private final int requestedHeight; private volatile boolean stopped; - private volatile TextureRegistry.SurfaceTextureEntry textureEntry; - private volatile Surface surface; + private volatile boolean surfaceAvailable; + private volatile boolean animationActive = true; + private volatile TextureRegistry.SurfaceProducer textureEntry; + private volatile byte[] flutterCodecData; + private volatile String flutterCodecFilePath; + private volatile boolean flutterCodecBackend; private volatile int imageTextureWidth; private volatile int imageTextureHeight; private int bitmapWidth; private int bitmapHeight; + private long estimatedProducerBytes; - public PowerImageTextureRequest(PowerImageEngineContext context, Map arguments, TextureRegistry textureRegistry) { + public PowerImageTextureRequest( + PowerImageEngineContext context, + Map arguments, + TextureRegistry textureRegistry, + SurfaceProducerReleaseGate surfaceReleaseGate) { super(context, arguments); textureRegistryWrf = new WeakReference<>(textureRegistry); + this.surfaceReleaseGate = surfaceReleaseGate; + PowerImageRequestConfig config = getImageRequestConfig(); + requestedWidth = config != null ? config.width : 0; + requestedHeight = config != null ? config.height : 0; stopped = false; } @Override void onLoadResult(final PowerImageResult result) { + if (stopped || isRequestReleased()) { + if (result != null && result.image != null) { + result.image.release(); + } + return; + } super.onLoadResult(result); if (result == null) { onLoadFailed(TAG + ":onLoadResult(PowerImageResult result) result is null"); @@ -49,35 +82,50 @@ void onLoadResult(final PowerImageResult result) { return; } if (stopped) { - onLoadFailed(TAG + ":onLoadResult isStopped"); + if (result.image != null) { + result.image.release(); + } return; } if (result.image == null || !result.image.isValid()) { onLoadFailed(TAG + ":onLoadResult FlutterImage/bitmap is null or bitmap has recycled"); return; } + result.image.setDiagnosticRequestId(requestId); realResult = result; bitmapWidth = result.image.getWidth(); bitmapHeight = result.image.getHeight(); + checkImageTextureSize(result.image); + + if (useFlutterCodec(result.image)) { + return; + } PowerImageDispatcher.getInstance().runOnMainThread(new Runnable() { @Override public void run() { - TextureRegistry textureRegistry = textureRegistryWrf.get(); - if (textureEntry == null && textureRegistry != null) { - // 纹理创建,需要运行在有Looper的线程 - textureEntry = textureRegistry.createSurfaceTexture(); - } - if (textureEntry == null) { - onLoadFailed(TAG + ":onLoadResult SurfaceTextureEntry create failed"); - return; - } - if (stopped) { - onLoadFailed(TAG + ":onLoadResult isStopped 2"); - return; - } - // 切到子线程进行图片加载和纹理绘制 - performDraw(result.image); + surfaceReleaseGate.runWhenIdle(new Runnable() { + @Override + public void run() { + if (stopped || isRequestReleased()) { + return; + } + TextureRegistry textureRegistry = textureRegistryWrf.get(); + if (textureEntry == null && textureRegistry != null) { + // 纹理创建,需要运行在有Looper的线程 + textureEntry = createSurfaceProducer(textureRegistry); + surfaceAvailable = true; + textureEntry.setCallback(PowerImageTextureRequest.this); + trackSurfaceProducer(result.image.getFrameCount()); + } + if (textureEntry == null) { + onLoadFailed(TAG + ":onLoadResult SurfaceTextureEntry create failed"); + return; + } + // 切到子线程进行图片加载和纹理绘制 + performDraw(result.image); + } + }); } }); @@ -85,97 +133,314 @@ public void run() { @Override public boolean stopTask() { + return stopTask(false); + } + + @Override + protected boolean releaseAfterSuccessfulDelivery() { + return flutterCodecBackend; + } + + @Override + public boolean stopTaskForEngineDetach() { + return stopTask(true); + } + + private boolean stopTask(final boolean engineDetaching) { stopped = true; + surfaceAvailable = false; + flutterCodecData = null; + flutterCodecFilePath = null; + if (!markRequestReleased()) { + return true; + } imageTaskState = REQUEST_STATE_RELEASE_SUCCEED; textureRegistryWrf.clear(); + final SurfaceProducerReleaseGate.Release producerRelease = + textureEntry != null && !engineDetaching + ? surfaceReleaseGate.beginRelease() : null; - // 延迟2S 释放 纹理资源 Runnable runnable = new Runnable() { @Override public void run() { - if (textureEntry != null) { - synchronized (textureEntry) { + final TextureRegistry.SurfaceProducer entry = textureEntry; + if (entry != null) { + synchronized (entry) { try { - if (textureEntry != null) { - textureEntry.release(); + if (textureEntry == entry) { textureEntry = null; - if(realResult.image != null){ - realResult.image.release(); - } - } - if (surface != null) { - surface.release(); - surface = null; + entry.setCallback(null); } - } catch (Exception e) { + } catch (Exception error) { + PowerImageDiagnostics.error( + "surface_callback_clear_failed", + requestId, + null, + error); } } } + final Runnable finishRelease = new Runnable() { + @Override + public void run() { + PowerImageDispatcher.getInstance().runOnMainThread(new Runnable() { + @Override + public void run() { + if (!engineDetaching) { + releaseSurfaceProducer(entry, producerRelease); + } + releaseLoadHandle(); + PowerImageDiagnostics.debug( + "texture_request_released", requestId, null); + } + }); + } + }; + if (engineDetaching) { + // onSurfaceCleanup waits for an in-flight multi-frame draw's + // surface lock and prevents future draws from using entry. + if (realResult != null && realResult.image != null) { + realResult.image.onSurfaceCleanup(); + } + releaseSurfaceProducer(entry, producerRelease); + } + if (realResult != null && realResult.image != null) { + realResult.image.release(finishRelease); + } else { + finishRelease.run(); + } } }; - PowerImageDispatcher.getInstance().runOnMainThreadDelayed(runnable, 2000); + PowerImageDispatcher.getInstance().runOnMainThread(runnable); return true; } + private void releaseSurfaceProducer( + TextureRegistry.SurfaceProducer entry, + SurfaceProducerReleaseGate.Release producerRelease) { + if (entry == null) { + if (producerRelease != null) { + producerRelease.complete(); + } + return; + } + try { + entry.release(); + } catch (Exception error) { + PowerImageDiagnostics.error( + "surface_producer_release_failed", requestId, null, error); + } finally { + untrackSurfaceProducer(); + if (producerRelease != null) { + producerRelease.complete(); + } + } + } + @Override public Map encode() { Map encodedRequest = super.encode(); encodedRequest.put("width", bitmapWidth); encodedRequest.put("height", bitmapHeight); - if (textureEntry != null) { - encodedRequest.put("textureId", textureEntry.id()); + TextureRegistry.SurfaceProducer entry = textureEntry; + if (entry != null) { + encodedRequest.put("textureId", entry.id()); + } + byte[] encodedData = flutterCodecData; + String encodedFilePath = flutterCodecFilePath; + if (flutterCodecBackend && (encodedData != null || encodedFilePath != null)) { + encodedRequest.put("renderingBackend", "flutterCodec"); + if (encodedData != null) { + encodedRequest.put("encodedData", encodedData); + } + if (encodedFilePath != null) { + encodedRequest.put("encodedFilePath", encodedFilePath); + } + encodedRequest.put("targetWidth", imageTextureWidth); + encodedRequest.put("targetHeight", imageTextureHeight); } return encodedRequest; } + private boolean useFlutterCodec(final FlutterImage image) { + if (!(image instanceof FlutterEncodedImage) || image.getFrameCount() <= 1) { + return false; + } + + final byte[] encodedData; + final String encodedFilePath; + try { + encodedData = image.getEncodedData(); + encodedFilePath = image.getEncodedFilePath(); + } catch (RuntimeException error) { + PowerImageDiagnostics.error( + "flutter_codec_data_failed", requestId, null, error); + return false; + } + boolean hasData = encodedData != null && encodedData.length > 0; + boolean hasFile = encodedFilePath != null && !encodedFilePath.isEmpty(); + if (!hasData && !hasFile) { + onLoadFailed(TAG + ": encoded image has no data"); + return true; + } + + flutterCodecData = hasData ? encodedData : null; + flutterCodecFilePath = hasFile ? encodedFilePath : null; + flutterCodecBackend = true; + loadSuccessSent.set(true); + PowerImageDiagnostics.debug( + "flutter_codec_backend", + requestId, + "bytes=" + (hasData ? encodedData.length : 0) + + " file=" + hasFile + + " source=" + bitmapWidth + "x" + bitmapHeight + + " target=" + imageTextureWidth + "x" + imageTextureHeight); + image.release(new Runnable() { + @Override + public void run() { + PowerImageDispatcher.getInstance().runOnMainThread(new Runnable() { + @Override + public void run() { + try { + releaseLoadHandle(); + if (!stopped && !isRequestReleased()) { + onLoadSuccess(); + } + } finally { + flutterCodecData = null; + flutterCodecFilePath = null; + realResult = null; + } + } + }); + } + }); + return true; + } + + @Override + public void onSurfaceCreated() { + onSurfaceAvailable(); + } + + // No @Override: this callback was added after Flutter 3.24. + public void onSurfaceAvailable() { + if (stopped) { + return; + } + surfaceAvailable = true; + PowerImageDiagnostics.debug("surface_available", requestId, null); + PowerImageResult result = realResult; + if (!stopped && result != null && result.image != null && result.image.isValid()) { + result.image.setAnimationActive(animationActive); + performDraw(result.image); + } + } + + @Override + public void onSurfaceDestroyed() { + onSurfaceCleanup(); + } + + // No @Override: this callback was added after Flutter 3.24. + public void onSurfaceCleanup() { + surfaceAvailable = false; + PowerImageDiagnostics.debug("surface_cleanup", requestId, null); + TextureRegistry.SurfaceProducer entry = textureEntry; + if (entry != null) { + synchronized (entry) { + cleanupImageSurface(); + } + } else { + cleanupImageSurface(); + } + } + + private void cleanupImageSurface() { + PowerImageResult result = realResult; + if (result != null && result.image != null) { + result.image.onSurfaceCleanup(); + } + } + + @Override + public void setAnimationActive(boolean active) { + animationActive = active; + PowerImageDiagnostics.verbose( + "animation_visibility", requestId, "active=" + active); + PowerImageResult result = realResult; + if (result != null && result.image != null) { + result.image.setAnimationActive(active && surfaceAvailable); + } + } + // 独立线程中完成纹理绘制工作 void performDraw(final FlutterImage image) { PowerImageDispatcher.getInstance().runOnWorkThread(new Runnable() { @Override public void run() { - if (textureEntry == null || stopped || image == null) { - onLoadFailed(TAG + ":performDraw " - + (textureEntry == null ? "textureEntry:null " : "") - + (stopped ? "stopped:true " : "") - + (image == null ? "image:null " : "")); + final TextureRegistry.SurfaceProducer entry = textureEntry; + if (entry == null || stopped || !surfaceAvailable || image == null) { return; } - synchronized (textureEntry) { - if (textureEntry == null || stopped) { - onLoadFailed(TAG + ":performDraw synchronized" - + (textureEntry == null ? "textureEntry:null " : "") - + (stopped ? "stopped:true " : "")); + synchronized (entry) { + if (textureEntry != entry || stopped || !surfaceAvailable) { return; } - // 显示纹理 - checkImageTextureSize(image); - - if (surface == null) { - surface = new Surface(textureEntry.surfaceTexture()); - } - textureEntry.surfaceTexture().setDefaultBufferSize(imageTextureWidth, - imageTextureHeight); - if (surface != null && surface.isValid()) { - try { + try { + // 显示纹理 + checkImageTextureSize(image); + if (imageTextureWidth <= 0 || imageTextureHeight <= 0) { + throw new IllegalArgumentException( + "Drawable has invalid intrinsic dimensions"); + } + entry.setSize(imageTextureWidth, imageTextureHeight); + image.setAnimationActive(animationActive); + Surface surface = entry.getSurface(); + if (surface != null && surface.isValid()) { Rect destRect = new Rect(0, 0, imageTextureWidth, imageTextureHeight); - image.draw(surface, destRect); - onLoadSuccess(); - } catch (Exception e) { - e.printStackTrace(); + image.draw(new FlutterImage.SurfaceProvider() { + @Override + public Surface getSurface() { + if (textureEntry != entry || stopped || !surfaceAvailable) { + return null; + } + return entry.getSurface(); + } + }, destRect); + if (loadSuccessSent.compareAndSet(false, true)) { + onLoadSuccess(); + } + } + } catch (Exception e) { + PowerImageDiagnostics.error( + "texture_draw_failed", requestId, null, e); + if (!loadSuccessSent.get()) { onLoadFailed(TAG + ":performDraw drawBitmap " + e.getMessage()); } - } else { - onLoadFailed(TAG + ":performDraw drawBitmap " - + (surface == null ? "surface:null " : "") - + (surface != null && !surface.isValid() ? "surface invalid" : "")); } } } }); } + private static TextureRegistry.SurfaceProducer createSurfaceProducer( + TextureRegistry textureRegistry) { + try { + Class lifecycleClass = + Class.forName("io.flutter.view.TextureRegistry$SurfaceLifecycle"); + Object lifecycle = lifecycleClass.getField("resetInBackground").get(null); + Method method = TextureRegistry.class.getMethod( + "createSurfaceProducer", lifecycleClass); + return (TextureRegistry.SurfaceProducer) method.invoke(textureRegistry, lifecycle); + } catch (ReflectiveOperationException | LinkageError | SecurityException ignored) { + // Flutter 3.24 exposes SurfaceProducer through the no-argument API. + return textureRegistry.createSurfaceProducer(); + } + } + // 确保图片的大小不超过系统的纹理大小的限制 void checkImageTextureSize(FlutterImage imageBitmap) { if (imageBitmap == null) { @@ -185,17 +450,84 @@ void checkImageTextureSize(FlutterImage imageBitmap) { int originHeight = imageBitmap.getHeight(); - double widthRatio = originWidth / (double) MAX_RESIZE_WIDTH; - double heightRatio = originHeight / (double) MAX_RESIZE_HEIGHT; + int[] size = fitTextureSize( + originWidth, + originHeight, + requestedWidth, + requestedHeight); + imageTextureWidth = size[0]; + imageTextureHeight = size[1]; + } + + static int[] fitTextureSize( + int originWidth, + int originHeight, + int requestedWidth, + int requestedHeight) { + if (originWidth <= 0 || originHeight <= 0) { + return new int[]{0, 0}; + } - if (widthRatio <= 1 && heightRatio <= 1) { - imageTextureWidth = originWidth; - imageTextureHeight = originHeight; + double scale = Math.min( + 1d, + Math.min( + MAX_RESIZE_WIDTH / (double) originWidth, + MAX_RESIZE_HEIGHT / (double) originHeight)); + if (requestedWidth > 0) { + scale = Math.min(scale, requestedWidth / (double) originWidth); + } + if (requestedHeight > 0) { + scale = Math.min(scale, requestedHeight / (double) originHeight); + } + + return new int[]{ + Math.max(1, (int) Math.round(originWidth * scale)), + Math.max(1, (int) Math.round(originHeight * scale))}; + } + + static long estimateSurfaceBufferBytes(int width, int height) { + if (width <= 0 || height <= 0) { + return 0L; + } + return (long) width * height * 4L * ESTIMATED_SURFACE_BUFFER_COUNT; + } + + private void trackSurfaceProducer(int frameCount) { + if (!surfaceProducerTracked.compareAndSet(false, true)) { return; } + estimatedProducerBytes = estimateSurfaceBufferBytes( + imageTextureWidth, imageTextureHeight); + int active = ACTIVE_SURFACE_PRODUCERS.incrementAndGet(); + long estimatedTotal = ESTIMATED_SURFACE_BYTES.addAndGet(estimatedProducerBytes); + if (PowerImageDiagnostics.isDebugEnabled()) { + PowerImageDiagnostics.debug( + "surface_producer_created", + requestId, + "source=" + bitmapWidth + "x" + bitmapHeight + + " target=" + imageTextureWidth + "x" + imageTextureHeight + + " frames=" + frameCount + + " active=" + active + + " estimatedTripleBufferBytes=" + estimatedProducerBytes + + " estimatedTotalBufferBytes=" + estimatedTotal + + " " + PowerImageDiagnostics.memorySummary()); + } + } - double ratio = Math.max(widthRatio, heightRatio); - imageTextureWidth = (int) (originWidth / ratio); - imageTextureHeight = (int) (originHeight / ratio); + private void untrackSurfaceProducer() { + if (!surfaceProducerTracked.compareAndSet(true, false)) { + return; + } + int active = ACTIVE_SURFACE_PRODUCERS.decrementAndGet(); + long estimatedTotal = ESTIMATED_SURFACE_BYTES.addAndGet(-estimatedProducerBytes); + if (PowerImageDiagnostics.isDebugEnabled()) { + PowerImageDiagnostics.debug( + "surface_producer_released", + requestId, + "active=" + active + + " estimatedTotalBufferBytes=" + estimatedTotal + + " " + PowerImageDiagnostics.memorySummary()); + } + estimatedProducerBytes = 0L; } } diff --git a/android/src/main/java/com/taobao/power_image/request/SurfaceProducerReleaseGate.java b/android/src/main/java/com/taobao/power_image/request/SurfaceProducerReleaseGate.java new file mode 100644 index 0000000..f64fea0 --- /dev/null +++ b/android/src/main/java/com/taobao/power_image/request/SurfaceProducerReleaseGate.java @@ -0,0 +1,59 @@ +package com.taobao.power_image.request; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Serializes producer teardown within one engine/TextureRegistry generation. */ +final class SurfaceProducerReleaseGate { + interface Release { + void complete(); + } + + private int pendingReleases; + private final List waiters = new ArrayList<>(); + + Release beginRelease() { + synchronized (this) { + pendingReleases++; + } + final AtomicBoolean completed = new AtomicBoolean(false); + return new Release() { + @Override + public void complete() { + if (!completed.compareAndSet(false, true)) { + return; + } + finishRelease(); + } + }; + } + + void runWhenIdle(Runnable runnable) { + boolean runNow; + synchronized (this) { + runNow = pendingReleases == 0; + if (!runNow) { + waiters.add(runnable); + } + } + if (runNow) { + runnable.run(); + } + } + + private void finishRelease() { + final List ready; + synchronized (this) { + pendingReleases--; + if (pendingReleases != 0) { + return; + } + ready = new ArrayList<>(waiters); + waiters.clear(); + } + for (Runnable runnable : ready) { + runnable.run(); + } + } +} diff --git a/android/src/test/java/com/taobao/power_image/PowerImageDiagnosticsTest.java b/android/src/test/java/com/taobao/power_image/PowerImageDiagnosticsTest.java new file mode 100644 index 0000000..5f15356 --- /dev/null +++ b/android/src/test/java/com/taobao/power_image/PowerImageDiagnosticsTest.java @@ -0,0 +1,18 @@ +package com.taobao.power_image; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +import org.junit.Test; + +public class PowerImageDiagnosticsTest { + @Test + public void valueTokenDoesNotExposeSourceText() { + String source = "https://example.com/private/image.webp"; + + assertEquals(Integer.toHexString(source.hashCode()), + PowerImageDiagnostics.valueToken(source)); + assertNotEquals(source, PowerImageDiagnostics.valueToken(source)); + assertEquals("none", PowerImageDiagnostics.valueToken(null)); + } +} diff --git a/android/src/test/java/com/taobao/power_image/loader/FlutterEncodedImageTest.java b/android/src/test/java/com/taobao/power_image/loader/FlutterEncodedImageTest.java new file mode 100644 index 0000000..18a94ae --- /dev/null +++ b/android/src/test/java/com/taobao/power_image/loader/FlutterEncodedImageTest.java @@ -0,0 +1,32 @@ +package com.taobao.power_image.loader; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class FlutterEncodedImageTest { + @Test + public void exposesAndReleasesEncodedBytes() { + byte[] bytes = new byte[]{1, 2, 3}; + FlutterEncodedImage image = new FlutterEncodedImage(bytes, 20, 10, 2); + + assertArrayEquals(bytes, image.getEncodedData()); + assertEquals(20, image.getWidth()); + assertEquals(10, image.getHeight()); + assertEquals(2, image.getFrameCount()); + assertTrue(image.isValid()); + + image.release(); + assertNull(image.getEncodedData()); + assertFalse(image.isValid()); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsInvalidDimensions() { + new FlutterEncodedImage("/cache/image.webp", 0, 10, 2); + } +} diff --git a/android/src/test/java/com/taobao/power_image/loader/FlutterImageTest.java b/android/src/test/java/com/taobao/power_image/loader/FlutterImageTest.java new file mode 100644 index 0000000..73233c5 --- /dev/null +++ b/android/src/test/java/com/taobao/power_image/loader/FlutterImageTest.java @@ -0,0 +1,21 @@ +package com.taobao.power_image.loader; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class FlutterImageTest { + @Test + public void fencesHwuiOnEverySupportedAndroidVersion() { + assertFalse(FlutterImage.needsFencedHwuiRenderer(28)); + assertTrue(FlutterImage.needsFencedHwuiRenderer(29)); + assertTrue(FlutterImage.needsFencedHwuiRenderer(34)); + } + + @Test + public void waitsForPresentOnlyForTheTeardownFence() { + assertFalse(FencedSurfaceRenderer.waitForPresent(false)); + assertTrue(FencedSurfaceRenderer.waitForPresent(true)); + } +} diff --git a/android/src/test/java/com/taobao/power_image/loader/MultiFrameRenderStateTest.java b/android/src/test/java/com/taobao/power_image/loader/MultiFrameRenderStateTest.java new file mode 100644 index 0000000..045224d --- /dev/null +++ b/android/src/test/java/com/taobao/power_image/loader/MultiFrameRenderStateTest.java @@ -0,0 +1,93 @@ +package com.taobao.power_image.loader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class MultiFrameRenderStateTest { + @Test + public void coalescesInvalidationsAndKeepsOnlyLatestPendingFrame() { + MultiFrameRenderState state = new MultiFrameRenderState(); + + assertTrue(state.markDirty()); + assertFalse(state.markDirty()); + assertTrue(state.tryStartRender()); + assertTrue(state.markDirty()); + assertFalse(state.markDirty()); + + assertEquals( + MultiFrameRenderState.Completion.RENDER_AGAIN, + state.finishRender(2_000_000, true, false)); + assertTrue(state.tryStartRender()); + assertEquals( + MultiFrameRenderState.Completion.NONE, + state.finishRender(1_000_000, true, false)); + + MultiFrameRenderState.Snapshot stats = state.snapshot(); + assertEquals(4, stats.invalidations); + assertEquals(2, stats.coalescedFrames); + assertEquals(2, stats.renderedFrames); + assertEquals(1_500_000, stats.averageRenderNanos()); + assertEquals(2_000_000, stats.maxRenderNanos); + } + + @Test + public void releaseWaitsForInFlightRenderBeforeCleanup() { + MultiFrameRenderState state = new MultiFrameRenderState(); + state.markDirty(); + assertTrue(state.tryStartRender()); + + assertFalse(state.release()); + assertFalse(state.tryScheduleCleanup()); + assertEquals( + MultiFrameRenderState.Completion.CLEANUP, + state.finishRender(500_000, true, false)); + assertTrue(state.tryScheduleCleanup()); + assertFalse(state.tryScheduleCleanup()); + + state.markCleanupFinished(); + assertTrue(state.isCleanupFinished()); + } + + @Test + public void pauseDropsPendingFrameAndResumeRequestsFreshFrame() { + MultiFrameRenderState state = new MultiFrameRenderState(); + state.markDirty(); + + assertTrue(state.setActive(false)); + assertFalse(state.tryStartRender()); + assertFalse(state.markDirty()); + assertTrue(state.setActive(true)); + assertTrue(state.tryStartRender()); + assertEquals( + MultiFrameRenderState.Completion.NONE, + state.finishRender(100, true, false)); + } + + @Test + public void rejectedExecutionRestoresDirtyFrame() { + MultiFrameRenderState state = new MultiFrameRenderState(); + state.markDirty(); + assertTrue(state.tryStartRender()); + + assertEquals( + MultiFrameRenderState.Completion.RENDER_AGAIN, + state.abortRender()); + assertTrue(state.tryStartRender()); + } + + @Test + public void recordsSkippedAndFailedRenders() { + MultiFrameRenderState state = new MultiFrameRenderState(); + state.markDirty(); + state.tryStartRender(); + state.finishRender(0, false, true); + + MultiFrameRenderState.Snapshot stats = state.snapshot(); + assertEquals(0, stats.renderedFrames); + assertEquals(1, stats.skippedFrames); + assertEquals(1, stats.failedFrames); + } +} diff --git a/android/src/test/java/com/taobao/power_image/request/PowerImageRequestConfigTest.java b/android/src/test/java/com/taobao/power_image/request/PowerImageRequestConfigTest.java new file mode 100644 index 0000000..50f4651 --- /dev/null +++ b/android/src/test/java/com/taobao/power_image/request/PowerImageRequestConfigTest.java @@ -0,0 +1,39 @@ +package com.taobao.power_image.request; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class PowerImageRequestConfigTest { + @Test + public void acceptsIntegerAndDoubleDimensions() { + assertEquals(30, PowerImageRequestConfig.dimensionInPixels(10, 3f)); + assertEquals(31, PowerImageRequestConfig.dimensionInPixels(10.5, 3f)); + } + + @Test + public void rejectsInvalidDimensions() { + assertEquals(0, PowerImageRequestConfig.dimensionInPixels(null, 3f)); + assertEquals(0, PowerImageRequestConfig.dimensionInPixels("10", 3f)); + assertEquals(0, PowerImageRequestConfig.dimensionInPixels(-1, 3f)); + assertEquals(0, PowerImageRequestConfig.dimensionInPixels(Double.NaN, 3f)); + assertEquals(0, PowerImageRequestConfig.dimensionInPixels(10, 0f)); + } + + @Test + public void clampsOverflow() { + assertEquals( + Integer.MAX_VALUE, + PowerImageRequestConfig.dimensionInPixels(Double.MAX_VALUE, 3f)); + } + + @Test + public void srcStringIgnoresNonStringValues() { + PowerImageRequestConfig config = new PowerImageRequestConfig(); + config.src = new java.util.HashMap<>(); + config.src.put("src", 42); + + assertNull(config.srcString()); + } +} diff --git a/android/src/test/java/com/taobao/power_image/request/PowerImageRequestManagerTest.java b/android/src/test/java/com/taobao/power_image/request/PowerImageRequestManagerTest.java new file mode 100644 index 0000000..1081c76 --- /dev/null +++ b/android/src/test/java/com/taobao/power_image/request/PowerImageRequestManagerTest.java @@ -0,0 +1,55 @@ +package com.taobao.power_image.request; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.taobao.power_image.PowerImageEngineContext; + +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Map; + +import io.flutter.view.TextureRegistry; + +public class PowerImageRequestManagerTest { + @Test + public void completedEncodedRequestIsRemovedAndStoppedImmediately() throws Exception { + PowerImageRequestManager manager = + new PowerImageRequestManager(mock(PowerImageEngineContext.class)); + PowerImageBaseRequest request = mock(PowerImageBaseRequest.class); + request.requestId = "encoded-request"; + + Field requestsField = PowerImageRequestManager.class.getDeclaredField("requests"); + requestsField.setAccessible(true); + @SuppressWarnings("unchecked") + Map requests = + (Map) requestsField.get(manager); + requests.put(request.requestId, request); + manager.setAnimationActive(request.requestId, true); + + manager.releaseCompletedRequest(request); + + assertFalse(requests.containsKey(request.requestId)); + verify(request).stopTask(); + } + + @Test + public void releaseGateIsScopedToOneTextureRegistryGeneration() { + PowerImageRequestManager manager = + new PowerImageRequestManager(mock(PowerImageEngineContext.class)); + TextureRegistry firstRegistry = mock(TextureRegistry.class); + TextureRegistry secondRegistry = mock(TextureRegistry.class); + + manager.configWithTextureRegistry(firstRegistry); + SurfaceProducerReleaseGate firstGate = manager.surfaceReleaseGateForTesting(); + manager.configWithTextureRegistry(firstRegistry); + assertSame(firstGate, manager.surfaceReleaseGateForTesting()); + + manager.configWithTextureRegistry(secondRegistry); + assertNotSame(firstGate, manager.surfaceReleaseGateForTesting()); + } +} diff --git a/android/src/test/java/com/taobao/power_image/request/PowerImageTextureRequestTest.java b/android/src/test/java/com/taobao/power_image/request/PowerImageTextureRequestTest.java new file mode 100644 index 0000000..296fcb6 --- /dev/null +++ b/android/src/test/java/com/taobao/power_image/request/PowerImageTextureRequestTest.java @@ -0,0 +1,47 @@ +package com.taobao.power_image.request; + +import static org.junit.Assert.assertArrayEquals; + +import org.junit.Test; + +public class PowerImageTextureRequestTest { + @Test + public void fitsInsideRequestedPhysicalPixels() { + assertArrayEquals( + new int[]{420, 420}, + PowerImageTextureRequest.fitTextureSize(512, 512, 420, 420)); + assertArrayEquals( + new int[]{400, 200}, + PowerImageTextureRequest.fitTextureSize(800, 400, 400, 0)); + } + + @Test + public void preservesAspectRatioAndDoesNotUpscale() { + assertArrayEquals( + new int[]{300, 150}, + PowerImageTextureRequest.fitTextureSize(300, 150, 900, 900)); + assertArrayEquals( + new int[]{300, 150}, + PowerImageTextureRequest.fitTextureSize(800, 400, 300, 300)); + } + + @Test + public void enforcesTextureLimitAndRejectsInvalidSources() { + assertArrayEquals( + new int[]{1920, 960}, + PowerImageTextureRequest.fitTextureSize(4000, 2000, 0, 0)); + assertArrayEquals( + new int[]{0, 0}, + PowerImageTextureRequest.fitTextureSize(0, 100, 50, 50)); + } + + @Test + public void estimatesThreeRgbaSurfaceBuffersWithoutIntegerOverflow() { + assertArrayEquals( + new long[]{2_764_800L, 0L, 49_152_000_000L}, + new long[]{ + PowerImageTextureRequest.estimateSurfaceBufferBytes(480, 480), + PowerImageTextureRequest.estimateSurfaceBufferBytes(0, 480), + PowerImageTextureRequest.estimateSurfaceBufferBytes(64_000, 64_000)}); + } +} diff --git a/android/src/test/java/com/taobao/power_image/request/SurfaceProducerReleaseGateTest.java b/android/src/test/java/com/taobao/power_image/request/SurfaceProducerReleaseGateTest.java new file mode 100644 index 0000000..c58f8ba --- /dev/null +++ b/android/src/test/java/com/taobao/power_image/request/SurfaceProducerReleaseGateTest.java @@ -0,0 +1,39 @@ +package com.taobao.power_image.request; + +import static org.junit.Assert.assertEquals; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; + +public class SurfaceProducerReleaseGateTest { + @Test + public void waitsForEveryPendingReleaseBeforeStartingNewSurfaceWork() { + SurfaceProducerReleaseGate gate = new SurfaceProducerReleaseGate(); + SurfaceProducerReleaseGate.Release first = gate.beginRelease(); + SurfaceProducerReleaseGate.Release second = gate.beginRelease(); + List events = new ArrayList<>(); + + gate.runWhenIdle(() -> events.add(3)); + first.complete(); + assertEquals(0, events.size()); + + second.complete(); + assertEquals(Arrays.asList(3), events); + } + + @Test + public void completionIsIdempotentAndIdleWorkRunsImmediately() { + SurfaceProducerReleaseGate gate = new SurfaceProducerReleaseGate(); + SurfaceProducerReleaseGate.Release release = gate.beginRelease(); + List events = new ArrayList<>(); + + release.complete(); + release.complete(); + gate.runWhenIdle(() -> events.add(1)); + + assertEquals(Arrays.asList(1), events); + } +} diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index b41e20d..cb15357 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,43 +1,29 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' +plugins { + id 'com.android.application' + id 'dev.flutter.flutter-gradle-plugin' } -apply plugin: 'com.android.application' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { - compileSdkVersion 29 + namespace 'com.taobao.power_image_example' + compileSdk flutter.compileSdkVersion + ndkVersion "28.2.13676358" lintOptions { disable 'InvalidPackage' } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.taobao.power_image_example" - minSdkVersion 16 - targetSdkVersion 29 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName + minSdk flutter.minSdkVersion + targetSdk flutter.targetSdkVersion + versionCode flutter.versionCode + versionName flutter.versionName } buildTypes { @@ -46,11 +32,20 @@ android { // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } + benchmark { + initWith release + signingConfig signingConfigs.debug + matchingFallbacks = ['release'] + debuggable false + minifyEnabled false + shrinkResources false + } } dependencies { - implementation 'com.github.bumptech.glide:glide:4.12.0' - annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' + implementation 'com.github.bumptech.glide:glide:4.16.0' + implementation 'com.github.zjupure:webpdecoder:2.7.4.16.0' + testImplementation 'junit:junit:4.13.2' } } diff --git a/example/android/app/src/benchmark/AndroidManifest.xml b/example/android/app/src/benchmark/AndroidManifest.xml new file mode 100644 index 0000000..6043806 --- /dev/null +++ b/example/android/app/src/benchmark/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 6839e17..1cad432 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ +