Skip to content

Commit

Permalink
[Android] AsyncHydrationTrackerPlugin (#296)
Browse files Browse the repository at this point in the history
* initial working rehydration tracker

* callback approach

* working callback impl

* lint

* explicit api instead of callback

* oops

* ensure beacon call captures view scope synchronously

* ensure we track hydration as early as possible

* expose onHydrationStarted hook

* simple async hydration tracker test

* remove redundant trackHydration
  • Loading branch information
sugarmanz authored Mar 5, 2024
1 parent b28b824 commit 8b5dc69
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package com.intuit.playerui.android.reference.demo.lifecycle

import com.intuit.playerui.android.AndroidPlayer
import com.intuit.playerui.android.AndroidPlayer.Config
import com.intuit.playerui.android.asset.SuspendableAsset.AsyncHydrationTrackerPlugin
import com.intuit.playerui.android.asset.asyncHydrationTrackerPlugin
import com.intuit.playerui.android.lifecycle.PlayerViewModel
import com.intuit.playerui.android.reference.assets.ReferenceAssetsPlugin
import com.intuit.playerui.core.experimental.ExperimentalPlayerApi
import com.intuit.playerui.core.managed.AsyncFlowIterator
import com.intuit.playerui.core.player.state.PlayerFlowState
import com.intuit.playerui.plugins.transactions.PendingTransactionPlugin
Expand All @@ -18,6 +21,7 @@ class DemoPlayerViewModel(iterator: AsyncFlowIterator) : PlayerViewModel(iterato
CommonTypesPlugin(),
ReferenceAssetsPlugin(),
PendingTransactionPlugin(),
AsyncHydrationTrackerPlugin(),
)

override val config: Config = Config(
Expand All @@ -28,11 +32,16 @@ class DemoPlayerViewModel(iterator: AsyncFlowIterator) : PlayerViewModel(iterato

public val playerFlowState: StateFlow<PlayerFlowState?> = _playerFlowState.asStateFlow()

@OptIn(ExperimentalPlayerApi::class)
override fun apply(androidPlayer: AndroidPlayer) {
super.apply(androidPlayer)

androidPlayer.hooks.state.tap { state ->
_playerFlowState.tryEmit(state)
}

androidPlayer.asyncHydrationTrackerPlugin!!.hooks.onHydrationComplete.tap(this::class.java.name) {
androidPlayer.logger.info("Done hydrating!!!!")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@ import android.view.View
import android.view.View.OnAttachStateChangeListener
import android.view.ViewGroup
import androidx.core.view.children
import com.intuit.hooks.HookContext
import com.intuit.hooks.SyncHook
import com.intuit.playerui.android.AndroidPlayer
import com.intuit.playerui.android.AndroidPlayerPlugin
import com.intuit.playerui.android.AssetContext
import com.intuit.playerui.android.R
import com.intuit.playerui.android.asset.SuspendableAsset.AsyncHydrationTrackerPlugin
import com.intuit.playerui.core.experimental.ExperimentalPlayerApi
import com.intuit.playerui.core.player.PlayerException
import com.intuit.playerui.core.player.state.inProgressState
import com.intuit.playerui.core.plugins.findPlugin
import com.intuit.playerui.core.utils.InternalPlayerApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
Expand All @@ -30,11 +37,15 @@ public abstract class SuspendableAsset<Data>(assetContext: AssetContext, seriali
// To be launched in Dispatchers.Default
public abstract suspend fun initView(data: Data): View

final override fun initView(): View = AsyncViewStub(
hydrationScope,
hydrationScope.async { doInitView() },
requireContext(),
) { doHydrate(); player.cacheAssetView(assetContext, this) }
final override fun initView(): View {
// ensure we pre-track hydration to ensure all assets are accounted for during async hydration
player.asyncHydrationTrackerPlugin?.trackHydration(this@SuspendableAsset)
return AsyncViewStub(
hydrationScope,
hydrationScope.async { doInitView() },
requireContext(),
) { doHydrate(); player.cacheAssetView(assetContext, this) }
}

private suspend fun doInitView() = withContext(Dispatchers.Default) {
initView(getData()).apply { setTag(R.bool.view_hydrated, false) }
Expand All @@ -46,6 +57,7 @@ public abstract class SuspendableAsset<Data>(assetContext: AssetContext, seriali
final override fun View.hydrate() {
if (this is AsyncViewStub) return

player.asyncHydrationTrackerPlugin?.trackHydration(this@SuspendableAsset)
setTag(R.bool.view_hydrated, false)
hydrationScope.launch(Dispatchers.Main) { doHydrate() }
}
Expand All @@ -57,6 +69,58 @@ public abstract class SuspendableAsset<Data>(assetContext: AssetContext, seriali
} catch (exception: StaleViewException) {
// b/c we're launched in a scope that isn't cared about anymore, we can't appropriately handle this, so just fast fail
player.inProgressState?.fail(PlayerException("SuspendableAssets can't appropriately handle invalidateViews currently, this should be handled in a future major", exception))
} finally {
player.asyncHydrationTrackerPlugin?.hydrationDone(this@SuspendableAsset)
}
}

@ExperimentalPlayerApi
public class AsyncHydrationTrackerPlugin : AndroidPlayerPlugin {
private var trackedHydrations = mutableSetOf<String>()

public val hooks: Hooks = Hooks()

public fun trackHydration(asset: SuspendableAsset<*>) {
synchronized(trackedHydrations) {
if (trackedHydrations.isEmpty()) hooks.onHydrationStarted.call()
trackedHydrations.add(asset.assetContext.id)
}
}

public fun hydrationDone(asset: SuspendableAsset<*>) {
val doneHydrating = synchronized(trackedHydrations) {
trackedHydrations.remove(asset.assetContext.id)
trackedHydrations.isEmpty()
}

if (doneHydrating) {
hooks.onHydrationComplete.call()
}
}

override fun apply(androidPlayer: AndroidPlayer) {
androidPlayer.onUpdate { _, _ ->
synchronized(trackedHydrations) {
trackedHydrations.clear()
}
}
}

public class Hooks {
public class OnHydrationStartedHook : SyncHook<(HookContext) -> Unit>() {
public fun call(): Unit = super.call { f, context ->
f(context)
}
}

public class OnHydrationCompleteHook : SyncHook<(HookContext) -> Unit>() {
public fun call(): Unit = super.call { f, context ->
f(context)
}
}

public val onHydrationStarted: OnHydrationStartedHook = OnHydrationStartedHook()
public val onHydrationComplete: OnHydrationCompleteHook = OnHydrationCompleteHook()
}
}

Expand Down Expand Up @@ -141,3 +205,5 @@ public abstract class SuspendableAsset<Data>(assetContext: AssetContext, seriali
}
}
}

public val AndroidPlayer.asyncHydrationTrackerPlugin: AsyncHydrationTrackerPlugin? get() = findPlugin()
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,29 @@ import android.widget.LinearLayout
import com.intuit.playerui.android.AndroidPlayer
import com.intuit.playerui.android.AssetContext
import com.intuit.playerui.android.asset.SuspendableAsset
import com.intuit.playerui.android.asset.SuspendableAsset.AsyncHydrationTrackerPlugin
import com.intuit.playerui.android.asset.SuspendableAsset.AsyncViewStub
import com.intuit.playerui.android.asset.asyncHydrationTrackerPlugin
import com.intuit.playerui.android.utils.NestedAsset
import com.intuit.playerui.android.utils.SimpleAsset
import com.intuit.playerui.android.utils.awaitFirstView
import com.intuit.playerui.core.experimental.ExperimentalPlayerApi
import com.intuit.playerui.core.flow.Flow
import com.intuit.playerui.core.player.state.InProgressState
import com.intuit.playerui.utils.test.runBlockingTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

internal class NestedAssetTest : BaseRenderableAssetTest() {

override val asset get() = NestedAsset.sampleAsset

override val player get() = AndroidPlayer(beaconPlugin).apply {
override val player get() = AndroidPlayer(beaconPlugin, AsyncHydrationTrackerPlugin()).apply {
registerAsset("simple", ::SimpleAsset)
registerAsset("nested", ::NestedAsset)
val inProgressState = mockk<InProgressState>()
Expand Down Expand Up @@ -52,4 +57,30 @@ internal class NestedAssetTest : BaseRenderableAssetTest() {
assertEquals(mockContext, it?.context)
} ?: Unit
}

@OptIn(ExperimentalPlayerApi::class)
@Test
fun `test hydration tracker`() = runBlockingTest {
val player = player
val plugin = player.asyncHydrationTrackerPlugin!!
var onHydrationStarted = false
plugin.hooks.onHydrationStarted.tap("test") {
onHydrationStarted = true
}
var onHydrationCompleted = false
plugin.hooks.onHydrationComplete.tap("test") {
onHydrationCompleted = true
}

val asset = player.awaitFirstView(NestedAsset.sampleFlow) as NestedAsset

assertFalse(onHydrationStarted)
assertFalse(onHydrationCompleted)
val view = asset.render(mockContext) as AsyncViewStub
assertTrue(onHydrationStarted)
assertFalse(onHydrationCompleted)
view.awaitView()
assertTrue(onHydrationStarted)
assertTrue(onHydrationCompleted)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.intuit.playerui.plugins.beacon

import com.intuit.playerui.core.asset.Asset
import com.intuit.playerui.core.bridge.getInvokable
import com.intuit.playerui.core.bridge.runtime
import com.intuit.playerui.core.bridge.runtime.Runtime
import com.intuit.playerui.core.bridge.runtime.ScriptContext
import com.intuit.playerui.core.bridge.runtime.add
Expand All @@ -13,7 +12,6 @@ import com.intuit.playerui.core.plugins.JSScriptPluginWrapper
import com.intuit.playerui.core.plugins.Pluggable
import com.intuit.playerui.core.plugins.findPlugin
import com.intuit.playerui.plugins.settimeout.SetTimeoutPlugin
import kotlinx.coroutines.launch
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -57,18 +55,17 @@ public class BeaconPlugin(override val plugins: List<JSPluginWrapper>) : JSScrip
handlers.add(handler)
}

// TODO: Convert to suspend method to ensure view scope is captured in a non-blocking way
/** Fire a beacon event */
public fun beacon(action: String, element: String, asset: Asset, data: Any? = null) {
runtime.scope.launch {
instance.getInvokable<Any?>("beacon")!!.invoke(
mapOf(
"action" to action,
"element" to element,
"asset" to asset,
"data" to data,
),
)
}
instance.getInvokable<Any?>("beacon")!!.invoke(
mapOf(
"action" to action,
"element" to element,
"asset" to asset,
"data" to data,
),
)
}

private companion object {
Expand Down

0 comments on commit 8b5dc69

Please sign in to comment.