[Async Updates] First MVP of Async Updates (#2276)

This is the first MVP of async updates. This project is being funded by Airbnb Eng and likely wouldn't happen without their sponsorship.

The docs for the AsyncUpdates enum includes details on what is going on here but at a high level, Lottie has two hot paths:
1. setProgress
2. draw

This allows the former to happen off of them main thread _ immediately after_ draw completes so it is ready before the next one starts.

In many of my tests, the two paths were each accountable for ~50% of the total main thread work so this could reduce the main thread activity by ~50% or more.

Here is an example of systrace before:
<img width="1258" alt="CleanShot 2023-04-09 at 15 24 34@2x" src="https://user-images.githubusercontent.com/1307745/230800026-d73bf779-a109-46ee-9396-a54753860be4.png">

You can see setProgress being called immediately before draw.

This is what it looks like with async updates enabled:
<img width="1258" alt="CleanShot 2023-04-09 at 15 23 35@2x" src="https://user-images.githubusercontent.com/1307745/230800035-c6a2e02a-f47e-4cac-9394-4239ae27c524.png">

You can see that draw happens first and then setProgress for the _next frame_ happens immediately after on a different thread.

This is experimental and defaults to AUTOMATIC but AUTOMATIC will default to false until this API is stabilized.
diff --git a/issue-repro/src/main/res/layout/issue_repro_activity.xml b/issue-repro/src/main/res/layout/issue_repro_activity.xml
index dc479bb..05ea4fb 100755
--- a/issue-repro/src/main/res/layout/issue_repro_activity.xml
+++ b/issue-repro/src/main/res/layout/issue_repro_activity.xml
@@ -11,5 +11,5 @@
         android:layout_gravity="center"
         app:lottie_rawRes="@raw/heart"
         app:lottie_autoPlay="true"
-        app:lottie_loop="true" />
+        app:lottie_loop="true"/>
 </FrameLayout>
\ No newline at end of file
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
index c7bc6e0..99b722f 100644
--- a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
@@ -20,6 +20,7 @@
 import androidx.compose.ui.layout.ScaleFactor
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.dp
+import com.airbnb.lottie.AsyncUpdates
 import com.airbnb.lottie.LottieComposition
 import com.airbnb.lottie.LottieDrawable
 import com.airbnb.lottie.RenderMode
@@ -67,6 +68,8 @@
  * @param contentScale Define how the animation should be scaled if it has a different size than this Composable.
  * @param clipToCompositionBounds Determines whether or not Lottie will clip the animation to the original animation composition bounds.
  * @param fontMap A map of keys to Typefaces. The key can be: "fName", "fFamily", or "fFamily-fStyle" as specified in your Lottie file.
+ * @param asyncUpdates When set to true, some parts of animation updates will be done off of the main thread.
+ *                     For more details, refer to the docs of [AsyncUpdates].
  */
 @Composable
 fun LottieAnimation(
@@ -83,6 +86,7 @@
     contentScale: ContentScale = ContentScale.Fit,
     clipToCompositionBounds: Boolean = true,
     fontMap: Map<String, Typeface>? = null,
+    asyncUpdates: AsyncUpdates = AsyncUpdates.AUTOMATIC,
 ) {
     val drawable = remember { LottieDrawable() }
     val matrix = remember { Matrix() }
@@ -107,6 +111,7 @@
 
             drawable.enableMergePathsForKitKatAndAbove(enableMergePaths)
             drawable.renderMode = renderMode
+            drawable.asyncUpdates = asyncUpdates
             drawable.composition = composition
             drawable.setFontMap(fontMap)
             if (dynamicProperties !== setDynamicProperties) {
@@ -145,20 +150,22 @@
     alignment: Alignment = Alignment.Center,
     contentScale: ContentScale = ContentScale.Fit,
     clipToCompositionBounds: Boolean = true,
+    asyncUpdates: AsyncUpdates = AsyncUpdates.AUTOMATIC,
 ) {
     LottieAnimation(
-        composition,
-        { progress },
-        modifier,
-        outlineMasksAndMattes,
-        applyOpacityToLayers,
-        enableMergePaths,
-        renderMode,
-        maintainOriginalImageBounds,
-        dynamicProperties,
-        alignment,
-        contentScale,
-        clipToCompositionBounds,
+        composition = composition,
+        progress = { progress },
+        modifier = modifier,
+        outlineMasksAndMattes = outlineMasksAndMattes,
+        applyOpacityToLayers = applyOpacityToLayers,
+        enableMergePaths = enableMergePaths,
+        renderMode = renderMode,
+        maintainOriginalImageBounds = maintainOriginalImageBounds,
+        dynamicProperties = dynamicProperties,
+        alignment = alignment,
+        contentScale = contentScale,
+        clipToCompositionBounds = clipToCompositionBounds,
+        asyncUpdates = asyncUpdates,
     )
 }
 
@@ -189,6 +196,7 @@
     contentScale: ContentScale = ContentScale.Fit,
     clipToCompositionBounds: Boolean = true,
     fontMap: Map<String, Typeface>? = null,
+    asyncUpdates: AsyncUpdates = AsyncUpdates.AUTOMATIC,
 ) {
     val progress by animateLottieCompositionAsState(
         composition,
@@ -213,6 +221,7 @@
         contentScale = contentScale,
         clipToCompositionBounds = clipToCompositionBounds,
         fontMap = fontMap,
+        asyncUpdates = asyncUpdates,
     )
 }
 
diff --git a/lottie/src/main/java/com/airbnb/lottie/AsyncUpdates.java b/lottie/src/main/java/com/airbnb/lottie/AsyncUpdates.java
new file mode 100644
index 0000000..842b325
--- /dev/null
+++ b/lottie/src/main/java/com/airbnb/lottie/AsyncUpdates.java
@@ -0,0 +1,54 @@
+package com.airbnb.lottie;
+
+/**
+ * **Note: this API is experimental and may changed.**
+ * <p/>
+ * When async updates are enabled, parts of animation updates will happen off of the main thread.
+ * <p/>
+ * At a high level, during the animation loop, there are two main code paths:
+ * 1. setProgress
+ * 2. draw
+ * <p/>
+ * setProgress is called on every frame when the internal animator updates or if you manually call setProgress.
+ * setProgress must then iterate through every single node in the animation (every shape, fill, mask, stroke, etc.)
+ * and call setProgress on it. When progress is set on a node, it will:
+ * 1. Call the dynamic property value callback if one has been set by you.
+ * 2. Recalculate what its own progress is. Various animation features like interpolators or time remapping
+ *    will cause the progress value for a given node to be different than the top level progress.
+ * 3. If a node's progress has changed, it will call invalidate which will invalidate values that are
+ *    cached and derived from that node's progress and then bubble up the invalidation to LottieDrawable
+ *    to ensure that Android renders a new frame.
+ * <p/>
+ * draw is what actually draws your animation to a canvas. Many of Lottie's operations are completed or
+ * cached in the setProgress path. However, there are a few things (like parentMatrix) that Lottie only has access
+ * to in the draw path and it, of course, needs to actually execute the canvas operations to draw the animation.
+ * <p/>
+ * Without async updates, in a single main thread frame, Lottie will call setProgress immediately followed by draw.
+ * <p/>
+ * With async updates, Lottie will determine if the most recent setProgress is still close enough to be considered
+ * valid. An existing progress will be considered valid if it is within LottieDrawable.MAX_DELTA_MS_ASYNC_SET_PROGRESS
+ * milliseconds from the current actual progress.
+ * If the calculated progress is close enough, it will only execute draw. Once draw completes, it will schedule a
+ * setProgress to be run on a background thread immediately after draw finishes and it will likely complete well
+ * before the next frame starts.
+ * <p/>
+ * The background thread is created via LottieDrawable.setProgressExecutor. You can refer to it for the current default
+ * thread pool configuration.
+ */
+public enum AsyncUpdates {
+  /**
+   * Default value.
+   * <p/>
+   * This will default to DISABLED until this feature has had time to incubate.
+   * The behavior of AUTOMATIC may change over time.
+   */
+  AUTOMATIC,
+  /**
+   * Use the async update path. Refer to the docs for {@link AsyncUpdates} for more details.
+   */
+  ENABLED,
+  /**
+   * Do not use the async update path. Refer to the docs for {@link AsyncUpdates} for more details.
+   */
+  DISABLED,
+}
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
index 4f451fb..b17f60e 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
@@ -209,6 +209,14 @@
       setRenderMode(RenderMode.values()[renderModeOrdinal]);
     }
 
+    if (ta.hasValue(R.styleable.LottieAnimationView_lottie_asyncUpdates)) {
+      int asyncUpdatesOrdinal = ta.getInt(R.styleable.LottieAnimationView_lottie_asyncUpdates, AsyncUpdates.AUTOMATIC.ordinal());
+      if (asyncUpdatesOrdinal >= RenderMode.values().length) {
+        asyncUpdatesOrdinal = AsyncUpdates.AUTOMATIC.ordinal();
+      }
+      setAsyncUpdates(AsyncUpdates.values()[asyncUpdatesOrdinal]);
+    }
+
     setIgnoreDisabledSystemAnimations(
         ta.getBoolean(
             R.styleable.LottieAnimationView_lottie_ignoreDisabledSystemAnimations,
@@ -1113,6 +1121,30 @@
   }
 
   /**
+   * Returns the current value of {@link AsyncUpdates}. Refer to the docs for {@link AsyncUpdates} for more info.
+   */
+  public AsyncUpdates getAsyncUpdates() {
+    return lottieDrawable.getAsyncUpdates();
+  }
+
+  /**
+   * Similar to {@link #getAsyncUpdates()} except it returns the actual
+   * boolean value for whether async updates are enabled or not.
+   */
+  public boolean getAsyncUpdatesEnabled() {
+    return lottieDrawable.getAsyncUpdatesEnabled();
+  }
+
+  /**
+   * **Note: this API is experimental and may changed.**
+   * <p/>
+   * Sets the current value for {@link AsyncUpdates}. Refer to the docs for {@link AsyncUpdates} for more info.
+   */
+  public void setAsyncUpdates(AsyncUpdates asyncUpdates) {
+    lottieDrawable.setAsyncUpdates(asyncUpdates);
+  }
+
+  /**
    * Sets whether to apply opacity to the each layer instead of shape.
    * <p>
    * Opacity is normally applied directly to a shape. In cases where translucent shapes overlap, applying opacity to a layer will be more accurate
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
index 35fd123..e493288 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
@@ -39,6 +39,7 @@
 import com.airbnb.lottie.model.layer.CompositionLayer;
 import com.airbnb.lottie.parser.LayerParser;
 import com.airbnb.lottie.utils.Logger;
+import com.airbnb.lottie.utils.LottieThreadFactory;
 import com.airbnb.lottie.utils.LottieValueAnimator;
 import com.airbnb.lottie.utils.MiscUtils;
 import com.airbnb.lottie.value.LottieFrameInfo;
@@ -52,6 +53,11 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.Executor;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
 
 /**
  * This can be used to show an lottie animation in any place that would normally take a drawable.
@@ -87,14 +93,6 @@
   private OnVisibleAction onVisibleAction = OnVisibleAction.NONE;
 
   private final ArrayList<LazyCompositionTask> lazyCompositionTasks = new ArrayList<>();
-  private final ValueAnimator.AnimatorUpdateListener progressUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
-    @Override
-    public void onAnimationUpdate(ValueAnimator animation) {
-      if (compositionLayer != null) {
-        compositionLayer.setProgress(animator.getAnimatedValueAbsolute());
-      }
-    }
-  };
 
   /**
    * ImageAssetManager created automatically by Lottie for views.
@@ -147,6 +145,57 @@
   private Matrix softwareRenderingOriginalCanvasMatrix;
   private Matrix softwareRenderingOriginalCanvasMatrixInverse;
 
+  private AsyncUpdates asyncUpdates = AsyncUpdates.AUTOMATIC;
+  private final ValueAnimator.AnimatorUpdateListener progressUpdateListener = animation -> {
+    if (getAsyncUpdatesEnabled()) {
+      // Render a new frame.
+      // If draw is called while lastDrawnProgress is still recent enough, it will
+      // draw straight away and then enqueue a background setProgress immediately after draw
+      // finishes.
+      invalidateSelf();
+    } else if (compositionLayer != null) {
+      compositionLayer.setProgress(animator.getAnimatedValueAbsolute());
+    }
+  };
+
+  /**
+   * Ensures that setProgress and draw will never happen at the same time on different threads.
+   * If that were to happen, parts of the animation may be on one frame while other parts would
+   * be on another.
+   */
+  private final Semaphore setProgressDrawLock = new Semaphore(1);
+  /**
+   * The executor that {@link AsyncUpdates} will be run on.
+   * <p/>
+   * Defaults to a core size of 0 so that when no animations are playing, there will be no
+   * idle cores consuming resources.
+   * <p/>
+   * Allows up to two active threads so that if there are many animations, they can all work in parallel.
+   * Two was arbitrarily chosen but should be sufficient for most uses cases. In the case of a single
+   * animation, this should never exceed one.
+   * <p/>
+   * Each thread will timeout after 35ms which gives it enough time to persist for one frame, one dropped frame
+   * and a few extra ms just in case.
+   */
+  private static final Executor setProgressExecutor = new ThreadPoolExecutor(0, 2, 35, TimeUnit.MILLISECONDS,
+      new LinkedBlockingQueue<>(), new LottieThreadFactory());
+  private final Runnable updateProgressRunnable = () -> {
+    CompositionLayer compositionLayer = this.compositionLayer;
+    if (compositionLayer == null) {
+      return;
+    }
+    try {
+      setProgressDrawLock.acquire();
+      compositionLayer.setProgress(animator.getAnimatedValueAbsolute());
+    } catch (InterruptedException e) {
+      // Do nothing.
+    } finally {
+      setProgressDrawLock.release();
+    }
+  };
+  private float lastDrawnProgress = -Float.MAX_VALUE;
+  private static final float MAX_DELTA_MS_ASYNC_SET_PROGRESS = 3 / 60f * 1000;
+
   /**
    * True if the drawable has not been drawn since the last invalidateSelf.
    * We can do this to prevent things like bounds from getting recalculated
@@ -359,6 +408,32 @@
   }
 
   /**
+   * Returns the current value of {@link AsyncUpdates}. Refer to the docs for {@link AsyncUpdates} for more info.
+   */
+  public AsyncUpdates getAsyncUpdates() {
+    return asyncUpdates;
+  }
+
+  /**
+   * Similar to {@link #getAsyncUpdates()} except it returns the actual
+   * boolean value for whether async updates are enabled or not.
+   * This is useful when the mode is automatic and you want to know
+   * whether automatic is defaulting to enabled or not.
+   */
+  public boolean getAsyncUpdatesEnabled() {
+    return asyncUpdates == AsyncUpdates.ENABLED;
+  }
+
+  /**
+   * **Note: this API is experimental and may changed.**
+   * <p/>
+   * Sets the current value for {@link AsyncUpdates}. Refer to the docs for {@link AsyncUpdates} for more info.
+   */
+  public void setAsyncUpdates(AsyncUpdates asyncUpdates) {
+    this.asyncUpdates = asyncUpdates;
+  }
+
+  /**
    * Returns the actual render mode being used. It will always be {@link RenderMode#HARDWARE} or {@link RenderMode#SOFTWARE}.
    * When the render mode is set to AUTOMATIC, the value will be derived from {@link RenderMode#useSoftwareRendering(int, boolean, int)}.
    */
@@ -457,6 +532,7 @@
     composition = null;
     compositionLayer = null;
     imageAssetManager = null;
+    lastDrawnProgress = -Float.MAX_VALUE;
     animator.clearComposition();
     invalidateSelf();
   }
@@ -506,30 +582,77 @@
     return PixelFormat.TRANSLUCENT;
   }
 
+  /**
+   * Helper for the async execution path to potentially call setProgress
+   * before drawing if the current progress has drifted sufficiently far
+   * from the last set progress.
+   *
+   * @see AsyncUpdates
+   * @see #setAsyncUpdates(AsyncUpdates)
+   */
+  private boolean shouldSetProgressBeforeDrawing() {
+    LottieComposition composition = this.composition;
+    if (composition == null) {
+      return false;
+    }
+    float lastDrawnProgress = this.lastDrawnProgress;
+    float currentProgress = animator.getAnimatedValueAbsolute();
+    this.lastDrawnProgress = currentProgress;
+
+    float duration = composition.getDuration();
+
+    float deltaProgress = Math.abs(currentProgress - lastDrawnProgress);
+    float deltaMs = deltaProgress * duration;
+    return deltaMs >= MAX_DELTA_MS_ASYNC_SET_PROGRESS;
+  }
+
   @Override
   public void draw(@NonNull Canvas canvas) {
-    L.beginSection("Drawable#draw");
+    CompositionLayer compositionLayer = this.compositionLayer;
+    if (compositionLayer == null) {
+      return;
+    }
+    boolean asyncUpdatesEnabled = getAsyncUpdatesEnabled();
+    try {
+      if (asyncUpdatesEnabled) {
+        setProgressDrawLock.acquire();
+      }
+      L.beginSection("Drawable#draw");
 
-    if (safeMode) {
-      try {
+      if (asyncUpdatesEnabled && shouldSetProgressBeforeDrawing()) {
+        setProgress(animator.getAnimatedValueAbsolute());
+      }
+
+      if (safeMode) {
+        try {
+          if (useSoftwareRendering) {
+            renderAndDrawAsBitmap(canvas, compositionLayer);
+          } else {
+            drawDirectlyToCanvas(canvas);
+          }
+        } catch (Throwable e) {
+          Logger.error("Lottie crashed in draw!", e);
+        }
+      } else {
         if (useSoftwareRendering) {
           renderAndDrawAsBitmap(canvas, compositionLayer);
         } else {
           drawDirectlyToCanvas(canvas);
         }
-      } catch (Throwable e) {
-        Logger.error("Lottie crashed in draw!", e);
       }
-    } else {
-      if (useSoftwareRendering) {
-        renderAndDrawAsBitmap(canvas, compositionLayer);
-      } else {
-        drawDirectlyToCanvas(canvas);
+
+      isDirty = false;
+    } catch (InterruptedException e) {
+      // Do nothing.
+    } finally {
+      L.endSection("Drawable#draw");
+      if (asyncUpdatesEnabled) {
+        setProgressDrawLock.release();
+        if (compositionLayer.getProgress() != animator.getAnimatedValueAbsolute()) {
+          setProgressExecutor.execute(updateProgressRunnable);
+        }
       }
     }
-
-    isDirty = false;
-    L.endSection("Drawable#draw");
   }
 
   /**
@@ -542,16 +665,34 @@
     if (compositionLayer == null || composition == null) {
       return;
     }
+    boolean asyncUpdatesEnabled = getAsyncUpdatesEnabled();
+    try {
+      if (asyncUpdatesEnabled) {
+        setProgressDrawLock.acquire();
+        if (shouldSetProgressBeforeDrawing()) {
+          setProgress(animator.getAnimatedValueAbsolute());
+        }
+      }
 
-    if (useSoftwareRendering) {
-      canvas.save();
-      canvas.concat(matrix);
-      renderAndDrawAsBitmap(canvas, compositionLayer);
-      canvas.restore();
-    } else {
-      compositionLayer.draw(canvas, matrix, alpha);
+      if (useSoftwareRendering) {
+        canvas.save();
+        canvas.concat(matrix);
+        renderAndDrawAsBitmap(canvas, compositionLayer);
+        canvas.restore();
+      } else {
+        compositionLayer.draw(canvas, matrix, alpha);
+      }
+      isDirty = false;
+    } catch (InterruptedException e) {
+      // Do nothing.
+    } finally {
+      if (asyncUpdatesEnabled) {
+        setProgressDrawLock.release();
+        if (compositionLayer.getProgress() != animator.getAnimatedValueAbsolute()) {
+          setProgressExecutor.execute(updateProgressRunnable);
+        }
+      }
     }
-    isDirty = false;
   }
 
   // <editor-fold desc="animator">
@@ -1182,7 +1323,7 @@
    */
   public <T> void addValueCallback(KeyPath keyPath, T property,
       final SimpleLottieValueCallback<T> callback) {
-    addValueCallback(keyPath, property, new LottieValueCallback<T>() {
+    addValueCallback(keyPath, property, new LottieValueCallback<>() {
       @Override
       public T getValue(LottieFrameInfo<T> frameInfo) {
         return callback.getValue(frameInfo);
diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/CompositionLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/CompositionLayer.java
index 113c80d..7c923a6 100644
--- a/lottie/src/main/java/com/airbnb/lottie/model/layer/CompositionLayer.java
+++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/CompositionLayer.java
@@ -33,6 +33,7 @@
 
   @Nullable private Boolean hasMatte;
   @Nullable private Boolean hasMasks;
+  private float progress;
 
   private boolean clipToCompositionBounds = true;
 
@@ -144,6 +145,7 @@
 
   @Override public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
     L.beginSection("CompositionLayer#setProgress");
+    this.progress = progress;
     super.setProgress(progress);
     if (timeRemapping != null) {
       // The duration has 0.01 frame offset to show end of animation properly.
@@ -167,6 +169,10 @@
     L.endSection("CompositionLayer#setProgress");
   }
 
+  public float getProgress() {
+    return progress;
+  }
+
   public boolean hasMasks() {
     if (hasMasks == null) {
       for (int i = layers.size() - 1; i >= 0; i--) {
diff --git a/lottie/src/main/java/com/airbnb/lottie/utils/LottieThreadFactory.java b/lottie/src/main/java/com/airbnb/lottie/utils/LottieThreadFactory.java
new file mode 100644
index 0000000..4d59f5a
--- /dev/null
+++ b/lottie/src/main/java/com/airbnb/lottie/utils/LottieThreadFactory.java
@@ -0,0 +1,27 @@
+package com.airbnb.lottie.utils;
+
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class LottieThreadFactory implements ThreadFactory {
+  private static final AtomicInteger poolNumber = new AtomicInteger(1);
+  private final ThreadGroup group;
+  private final AtomicInteger threadNumber = new AtomicInteger(1);
+  private final String namePrefix;
+
+  public LottieThreadFactory() {
+    SecurityManager s = System.getSecurityManager();
+    group = (s == null) ? Thread.currentThread().getThreadGroup() : s.getThreadGroup();
+    namePrefix = "lottie-" + poolNumber.getAndIncrement() + "-thread-";
+  }
+
+  public Thread newThread(Runnable r) {
+    Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0);
+    // Don't prevent this thread from letting Android kill the app process if it wants to.
+    t.setDaemon(false);
+    // This will block the main thread if it isn't high enough priority
+    // so this thread should be as close to the main thread priority as possible.
+    t.setPriority(Thread.MAX_PRIORITY);
+    return t;
+  }
+}
\ No newline at end of file
diff --git a/lottie/src/main/java/com/airbnb/lottie/utils/LottieTrace.java b/lottie/src/main/java/com/airbnb/lottie/utils/LottieTrace.java
index fcac145..3223e78 100644
--- a/lottie/src/main/java/com/airbnb/lottie/utils/LottieTrace.java
+++ b/lottie/src/main/java/com/airbnb/lottie/utils/LottieTrace.java
@@ -3,7 +3,7 @@
 import androidx.core.os.TraceCompat;
 
 public class LottieTrace {
-  private static final int MAX_DEPTH = 20;
+  private static final int MAX_DEPTH = 5;
 
   private final String[] sections = new String[MAX_DEPTH];
   private final long[] startTimeNs = new long[MAX_DEPTH];
diff --git a/lottie/src/main/res/values/attrs.xml b/lottie/src/main/res/values/attrs.xml
index c9fa4f3..b9012a6 100644
--- a/lottie/src/main/res/values/attrs.xml
+++ b/lottie/src/main/res/values/attrs.xml
@@ -31,5 +31,10 @@
             <enum name="hardware" value="1" />
             <enum name="software" value="2" />
         </attr>
+        <attr name="lottie_asyncUpdates" format="enum">
+            <enum name="automatic" value="0" />
+            <enum name="enabled" value="1" />
+            <enum name="disabled" value="2" />
+        </attr>
     </declare-styleable>
 </resources>
\ No newline at end of file
diff --git a/sample/src/main/kotlin/com/airbnb/lottie/samples/LottieApplication.kt b/sample/src/main/kotlin/com/airbnb/lottie/samples/LottieApplication.kt
index 269ae98..9ea95a4 100644
--- a/sample/src/main/kotlin/com/airbnb/lottie/samples/LottieApplication.kt
+++ b/sample/src/main/kotlin/com/airbnb/lottie/samples/LottieApplication.kt
@@ -38,7 +38,5 @@
     override fun onCreate() {
         super.onCreate()
         L.DBG = true
-        @Suppress("RestrictedApi")
-        L.setTraceEnabled(true)
     }
 }
\ No newline at end of file
diff --git a/sample/src/main/res/layout/player_fragment.xml b/sample/src/main/res/layout/player_fragment.xml
index 7482c2e..c9d9d64 100644
--- a/sample/src/main/res/layout/player_fragment.xml
+++ b/sample/src/main/res/layout/player_fragment.xml
@@ -25,6 +25,7 @@
                 android:scaleType="centerInside"
                 android:layout_gravity="center"
                 android:background="@drawable/outline"
+                app:lottie_asyncUpdates="enabled"
                 app:lottie_autoPlay="true" />
 
             <ProgressBar