From 710c4be511d0d003248260607b485e209887d5ed Mon Sep 17 00:00:00 2001 From: MJ Date: Sun, 29 Mar 2020 17:53:43 +0200 Subject: [PATCH 01/17] Async API improvements. #182 --- CHANGELOG.md | 3 + async/README.md | 6 ++ async/src/main/kotlin/ktx/async/async.kt | 20 ++-- .../src/main/kotlin/ktx/async/dispatchers.kt | 14 +-- async/src/test/kotlin/ktx/async/asyncTest.kt | 95 +++++++++++++++++++ 5 files changed, 120 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fed6140a..43f8fa00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ - **[CHANGE]** (`ktx-app`) `LetterboxingViewport` moved from `ktx-app` to `ktx-graphics`. - **[FEATURE]** (`ktx-ashley`) Added `Entity.contains` (`in` operator) that checks if an `Entity` has a `Component`. - **[FEATURE]** (`ktx-async`) Added `RenderingScope` factory function for custom scopes using rendering thread dispatcher. +- **[FEATURE]** (`ktx-async`) `newAsyncContext` and `newSingleThreadAsyncContext` now support `threadName` parameter +that allows to set thread name pattern of `AsyncExecutor` threads. +- **[FIX]** (`ktx-async`) `isOnRenderingThread` now behaves consistently regardless of launching coroutine context. - **[FEATURE]** (`ktx-graphics`) Added `LetterboxingViewport` from `ktx-app`. - **[FEATURE]** (`ktx-graphics`) Added `takeScreenshot` utility function that allows to save a screenshot of the application. - **[FEATURE]** (`ktx-graphics`) Added `BitmapFont.center` extension method that allows to center text on an object. diff --git a/async/README.md b/async/README.md index 51e14c5a..4b262b95 100644 --- a/async/README.md +++ b/async/README.md @@ -245,10 +245,16 @@ import ktx.async.AsyncExecutorDispatcher import ktx.async.newAsyncContext import ktx.async.newSingleThreadAsyncContext +// Context with a single thread: val singleThreaded = newSingleThreadAsyncContext() +// Context with multiple threads: val multiThreaded = newAsyncContext(threads = 4) +// Context with a custom thread name pattern: +val multiThreadedWithNamedThreads = newAsyncContext(threads = 4, threadName = "MyThread") + +// Context with an existing executor: val executor = AsyncExecutor(2) val fromExistingExecutor = AsyncExecutorDispatcher(executor, threads = 2) ``` diff --git a/async/src/main/kotlin/ktx/async/async.kt b/async/src/main/kotlin/ktx/async/async.kt index ee388b9e..7bf8ecea 100644 --- a/async/src/main/kotlin/ktx/async/async.kt +++ b/async/src/main/kotlin/ktx/async/async.kt @@ -18,11 +18,11 @@ object KtxAsync : CoroutineScope { override val coroutineContext = MainDispatcher /** - * Should be invoked on the main rendering thread before using KTX coroutines. Might slightly affect performance - * otherwise. + * Should be invoked on the main rendering thread before using KTX coroutines. + * Failing to do so will cause some parts of the API to throw exceptions. */ fun initiate() { - ImmediateDispatcher.initiate() + MainDispatcher.initiate() } } @@ -48,14 +48,19 @@ fun RenderingScope() = CoroutineScope(SupervisorJob() + MainDispatcher) /** * Creates a new [AsyncExecutorDispatcher] wrapping around an [AsyncExecutor] with a single thread to execute tasks * asynchronously outside of the main rendering thread. + * + * [AsyncExecutor] thread will be named according to the [threadName] pattern. */ -fun newSingleThreadAsyncContext() = newAsyncContext(1) +fun newSingleThreadAsyncContext(threadName: String = "AsyncExecutor-Thread") = newAsyncContext(1, threadName) /** * Creates a new [AsyncExecutorDispatcher] wrapping around an [AsyncExecutor] with the chosen amount of [threads] * to execute tasks asynchronously outside of the main rendering thread. + * + * [AsyncExecutor] threads will be named according to the [threadName] pattern. */ -fun newAsyncContext(threads: Int) = AsyncExecutorDispatcher(AsyncExecutor(threads), threads) +fun newAsyncContext(threads: Int, threadName: String = "AsyncExecutor-Thread") = + AsyncExecutorDispatcher(AsyncExecutor(threads, threadName), threads) /** * Suspends the coroutine to execute the defined [block] on the main rendering thread and return its result. @@ -65,7 +70,10 @@ suspend fun onRenderingThread(block: suspend CoroutineScope.() -> T) = withC /** * Returns true if the coroutine was launched from a rendering thread dispatcher. */ -fun CoroutineScope.isOnRenderingThread() = coroutineContext[ContinuationInterceptor.Key] is RenderingThreadDispatcher +fun CoroutineScope.isOnRenderingThread() = + coroutineContext[ContinuationInterceptor.Key] is RenderingThreadDispatcher + && Thread.currentThread() === MainDispatcher.mainThread + /** * Attempts to skip the current frame. Resumes the execution using a task scheduled with [Application.postRunnable]. diff --git a/async/src/main/kotlin/ktx/async/dispatchers.kt b/async/src/main/kotlin/ktx/async/dispatchers.kt index dfcc91d7..7e7f302b 100644 --- a/async/src/main/kotlin/ktx/async/dispatchers.kt +++ b/async/src/main/kotlin/ktx/async/dispatchers.kt @@ -114,15 +114,7 @@ class RenderingThreadDispatcherFactory : MainDispatcherFactory { */ object MainDispatcher : RenderingThreadDispatcher() { @ExperimentalCoroutinesApi - override val immediate: MainCoroutineDispatcher = ImmediateDispatcher -} - -/** - * Supports immediate tasks execution in the main rendering thread context. - */ -object ImmediateDispatcher : RenderingThreadDispatcher() { - @ExperimentalCoroutinesApi - override val immediate: MainCoroutineDispatcher = this + override val immediate = this lateinit var mainThread: Thread /** Must be called **on the rendering thread** before using KTX coroutines. */ @@ -130,7 +122,5 @@ object ImmediateDispatcher : RenderingThreadDispatcher() { mainThread = Thread.currentThread() } - override fun isDispatchNeeded(context: CoroutineContext): Boolean = Thread.currentThread() != mainThread - - override fun toString(): String = super.toString() + "(immediate)" + override fun isDispatchNeeded(context: CoroutineContext): Boolean = !KtxAsync.isOnRenderingThread() } diff --git a/async/src/test/kotlin/ktx/async/asyncTest.kt b/async/src/test/kotlin/ktx/async/asyncTest.kt index f6011a8f..642e84a8 100644 --- a/async/src/test/kotlin/ktx/async/asyncTest.kt +++ b/async/src/test/kotlin/ktx/async/asyncTest.kt @@ -44,6 +44,16 @@ class KtxAsyncTest : AsyncTest() { assertEquals(1, dispatcher.threads) } + @Test + fun `should create a single-threaded AsyncExecutorDispatcher with selected name`() { + // When: + val dispatcher: AsyncExecutorDispatcher = newSingleThreadAsyncContext(threadName = "MyThreadName") + + // Then: + assertEquals(1, dispatcher.threads) + assertTrue("MyThreadName" in getExecutionThread(dispatcher.executor).name) + } + @Test fun `should create a multi-threaded AsyncExecutorDispatcher`() { // When: @@ -53,6 +63,16 @@ class KtxAsyncTest : AsyncTest() { assertEquals(4, dispatcher.threads) } + @Test + fun `should create a multi-threaded AsyncExecutorDispatcher with selected name`() { + // When: + val dispatcher: AsyncExecutorDispatcher = newAsyncContext(threads = 4, threadName = "MyThreadName") + + // Then: + assertEquals(4, dispatcher.threads) + assertTrue("MyThreadName" in getExecutionThread(dispatcher.executor).name) + } + @Test fun `should execute on the main rendering thread`() { // Given: @@ -134,6 +154,45 @@ class KtxAsyncTest : AsyncTest() { assertTrue(isOnRenderingThread.get()) } + @Test + fun `should detect rendering thread from coroutine with different context`() { + // Given: + val executionThread = CompletableFuture() + val isOnRenderingThread = AtomicBoolean(true) + val dispatcher: AsyncExecutorDispatcher = newSingleThreadAsyncContext() + + // When: + KtxAsync.launch(dispatcher) { + onRenderingThread { + isOnRenderingThread.set(isOnRenderingThread()) + executionThread.complete(Thread.currentThread()) + } + } + + // Then: + assertSame(getMainRenderingThread(), executionThread.join()) + assertTrue(isOnRenderingThread.get()) + } + + @Test + fun `should detect rendering thread from coroutine launched from global context`() { + // Given: + val executionThread = CompletableFuture() + val isOnRenderingThread = AtomicBoolean(false) + + // When: + GlobalScope.launch { + onRenderingThread { + isOnRenderingThread.set(isOnRenderingThread()) + executionThread.complete(Thread.currentThread()) + } + } + + // Then: + assertSame(getMainRenderingThread(), executionThread.join()) + assertTrue(isOnRenderingThread.get()) + } + @Test fun `should detect non-rendering threads`() { // Given: @@ -152,6 +211,23 @@ class KtxAsyncTest : AsyncTest() { assertFalse(isOnRenderingThread.get()) } + @Test + fun `should detect non-rendering thread launched from global scope`() { + // Given: + val executionThread = CompletableFuture() + val isOnRenderingThread = AtomicBoolean(true) + + // When: + GlobalScope.launch { + isOnRenderingThread.set(isOnRenderingThread()) + executionThread.complete(Thread.currentThread()) + } + + // Then: + assertNotSame(getMainRenderingThread(), executionThread.join()) + assertFalse(isOnRenderingThread.get()) + } + @Test fun `should detect nested non-rendering threads`() { // Given: @@ -171,6 +247,25 @@ class KtxAsyncTest : AsyncTest() { assertNotSame(getMainRenderingThread(), executionThread.join()) assertFalse(isOnRenderingThread.get()) } + @Test + fun `should detect nested non-rendering threads with separate context`() { + // Given: + val executionThread = CompletableFuture() + val isOnRenderingThread = AtomicBoolean(true) + val dispatcher: AsyncExecutorDispatcher = newSingleThreadAsyncContext() + + // When: + KtxAsync.launch(Dispatchers.KTX) { + withContext(dispatcher) { + isOnRenderingThread.set(isOnRenderingThread()) + executionThread.complete(Thread.currentThread()) + } + } + + // Then: + assertNotSame(getMainRenderingThread(), executionThread.join()) + assertFalse(isOnRenderingThread.get()) + } @Test fun `should detect non-rendering threads with context switch`() { From ab1ab6a84312007f6a6e1c2ee10191be75102bdc Mon Sep 17 00:00:00 2001 From: MJ Date: Sun, 29 Mar 2020 19:01:06 +0200 Subject: [PATCH 02/17] Asynchronous asset manager implementation. #182 --- assets-async/README.md | 229 ++ assets-async/build.gradle | 9 + assets-async/gradle.properties | 2 + .../main/kotlin/ktx/assets/async/loaders.kt | 165 + .../main/kotlin/ktx/assets/async/storage.kt | 921 ++++++ .../main/kotlin/ktx/assets/async/wrapper.kt | 224 ++ .../kotlin/ktx/assets/async/loadersTest.kt | 266 ++ .../kotlin/ktx/assets/async/storageTest.kt | 2864 +++++++++++++++++ .../resources/ktx/assets/async/cubemap.zktx | Bin 0 -> 28338 bytes .../ktx/assets/async/i18n.properties | 1 + .../resources/ktx/assets/async/model.g3db | Bin 0 -> 1269 bytes .../resources/ktx/assets/async/model.g3dj | 83 + .../test/resources/ktx/assets/async/model.obj | 22 + .../resources/ktx/assets/async/particle.p2d | 135 + .../resources/ktx/assets/async/particle.p3d | 1 + .../resources/ktx/assets/async/shader.frag | 4 + .../resources/ktx/assets/async/shader.vert | 2 + .../resources/ktx/assets/async/skin.atlas | 15 + .../test/resources/ktx/assets/async/skin.json | 7 + .../test/resources/ktx/assets/async/sound.ogg | Bin 0 -> 14004 bytes .../resources/ktx/assets/async/string.txt | 1 + .../resources/ktx/assets/async/texture.png | Bin 0 -> 138 bytes .../org.mockito.plugins.MockMaker | 1 + async/src/test/kotlin/ktx/async/utils.kt | 7 +- settings.gradle | 1 + 25 files changed, 4957 insertions(+), 3 deletions(-) create mode 100644 assets-async/README.md create mode 100644 assets-async/build.gradle create mode 100644 assets-async/gradle.properties create mode 100644 assets-async/src/main/kotlin/ktx/assets/async/loaders.kt create mode 100644 assets-async/src/main/kotlin/ktx/assets/async/storage.kt create mode 100644 assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt create mode 100644 assets-async/src/test/kotlin/ktx/assets/async/loadersTest.kt create mode 100644 assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt create mode 100644 assets-async/src/test/resources/ktx/assets/async/cubemap.zktx create mode 100644 assets-async/src/test/resources/ktx/assets/async/i18n.properties create mode 100644 assets-async/src/test/resources/ktx/assets/async/model.g3db create mode 100644 assets-async/src/test/resources/ktx/assets/async/model.g3dj create mode 100644 assets-async/src/test/resources/ktx/assets/async/model.obj create mode 100644 assets-async/src/test/resources/ktx/assets/async/particle.p2d create mode 100644 assets-async/src/test/resources/ktx/assets/async/particle.p3d create mode 100644 assets-async/src/test/resources/ktx/assets/async/shader.frag create mode 100644 assets-async/src/test/resources/ktx/assets/async/shader.vert create mode 100644 assets-async/src/test/resources/ktx/assets/async/skin.atlas create mode 100644 assets-async/src/test/resources/ktx/assets/async/skin.json create mode 100644 assets-async/src/test/resources/ktx/assets/async/sound.ogg create mode 100644 assets-async/src/test/resources/ktx/assets/async/string.txt create mode 100644 assets-async/src/test/resources/ktx/assets/async/texture.png create mode 100644 assets-async/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/assets-async/README.md b/assets-async/README.md new file mode 100644 index 00000000..119b9dda --- /dev/null +++ b/assets-async/README.md @@ -0,0 +1,229 @@ +[![Maven Central](https://img.shields.io/maven-central/v/io.github.libktx/ktx-assets-async.svg)](https://search.maven.org/artifact/io.github.libktx/ktx-assets-async) + +# KTX: asynchronous file loading + +Asset manager using coroutines to load assets asynchronously. + +### Why? + +LibGDX provides an `AssetManager` class for loading and managing assets. Even with [KTX extensions](../assets), +`AssetManager` is not compatible with Kotlin concurrency model based on coroutines. While it does support +asynchronous asset loading, it uses only a single thread for asynchronous operations and achieves its thread +safety by synchronizing all of the methods. To achieve truly multi-threaded loading with multiple threads +for asynchronous loading, one must maintain multiple manager instances. Besides, it does not support +event listeners and its API relies on polling instead - one must repeatedly update its state until +the assets are loaded. + +This **KTX** module brings an `AssetManager` alternative - `AssetStorage`. It leverages Kotlin coroutines +for asynchronous operations. It ensures thread safety by using a single non-blocking `Mutex` for +a minimal set of operations mutating its state, while supporting truly multi-threaded asset loading +on any `CoroutineScope`. + +Feature | **KTX** `AssetStorage` | LibGDX `AssetManager` +--- | --- | --- +*Asynchronous loading* | **Supported.** Loading that can be done asynchronously is performed in the chosen coroutine context. Parts that require OpenGL context are performed on the main rendering thread. | **Supported.** Loading that can be performed asynchronously is done a dedicated thread, with necessary sections executed on the main rendering thread. +*Synchronous loading* | **Limited.** A blocking coroutine can be launched to selected assets eagerly, but it cannot block the rendering thread or loader threads to work correctly. | **Limited.** `finishLoading(String fileName)` method can be used to block the thread until the asset is loaded, but since it has no effect on loading order, all _other_ assets can be loaded before the requested one. +*Thread safety* | **Excellent.** Forces [`ktx-async`](../async) threading model based on coroutines. Executes blocking IO operations in a separate coroutine context and - when necessary - finishes loading on the main rendering thread. Same asset - or assets with same dependencies - can be safely scheduled for loading by multiple coroutines concurrently. Multi-threaded coroutine context can be used for asynchronous loading, possibly achieving loading performance boost. Concurrent `AssetStorage` usage is tested extensively by unit tests. | **Good.** Achieved through synchronizing most methods, which unfortunately blocks the threads that use them. Thread blocking might affect application performance, especially since even the basic `get` method is synchronized. +*Concurrency* | **Supported.** Multiple asset loading coroutines can be launched in parallel. Coroutine context used for asynchronous loading can have multiple threads that will be used concurrently. | **Not supported.** `update()` loads assets one by one. `AsyncExecutor` with a single thread is used internally by the `AssetManager`. To utilize multiple threads for loading, one must use multiple manager instances. +*Loading order* | **Controlled by the user.** `AssetStorage` starts loading assets as soon as the `load` method is called, giving the user full control over the order of asset loading. Selected assets can be loaded one after another or in parallel, depending on the need. | **Unpredictable.** If multiple assets are scheduled at once, it is difficult to reason about their loading order. `finishLoading` has no effect on loading order and instead blocks the thread until the asset is loaded. +*Exceptions* | **Customized.** All expected issues are given separate exception classes with common root type for easier handling. Each loading issue can be handled differently. | **Generic.** Throws either `GdxRuntimeException` or a built-in Java runtime exception. Specific issues are difficult to handle separately. +*Error handling* | **Build-in language syntax.** Use a regular try-catch block within coroutine body to handle loading errors. Provides a clean way to handle exceptions thrown by each asset separately. | **Via listener.** One can register a global error handling listener that will be notified if a loading exception is thrown. Flow of the application is undisturbed, which makes it difficult to handle exceptions. +*File name collisions* | **Multiple assets of different types can be loaded from same path.** For example, you can load both a `Texture` and a `Pixmap` from the same PNG file. | **File paths act as unique identifiers.** `AssetManager` cannot store multiple assets with the same path, even if they have different types. +*Progress tracking* | **Limited.** Since `AssetStorage` does not force the users to schedule loading of all assets up front, it does not know the exact percent of loaded assets. Progress must be tracked externally. | **Supported.** Since all loaded assets have to be scheduled up front, `AssetManager` can track total loading progress. +*Usage* | **Launch coroutine, load assets, use as soon as loaded.** Asynchronous complexity is hidden by the coroutines and looks like regular synchronous code. | **Schedule loading, update in loop until loaded, extract from manager.** API based on polling _(are you done yet?)_ rather than callbacks, which might prove tedious during loading phase. Event listeners or callbacks are not supported. + +#### Usage comparison + +Here's an example of a simple application that loads three assets and switches to the next view, +passing the loaded assets. + +Implemented using LibGDX `AssetManager`: + +```kotlin +class Application: ApplicationAdapter() { + private lateinit var assetManager: AssetManager + + override fun create() { + assetManager = AssetManager().apply { + // Assets scheduled for loading up front. Notice no returns. + load("logo.png", Texture::class.java) + load("i18n.properties", I18NBundle::class.java) + load("ui.json", Skin::class.java) + } + } + + override fun render() { + // Manager requires constant updating: + if (assetManager.update()) { + // When update() returns true, all scheduled assets are loaded: + finishLoading() + } + // Render loading prompt here. + // Other than slight performance impact of calling update() each frame, + // AssetManager does not block the rendering thread. + } + + private fun finishLoading() { + assetManager.apply { + // Assets have to be retrieved from the manager manually: + val logo = get("logo.png") + val bundle = get("i18n.properties") + val skin = get("ui.json") + goToNextView(logo, bundle, skin) + } + } + + private fun goToNextView(logo: Texture, bundle: I18NBundle, skin: Skin) { TODO() } +} +``` + +The same use case rewritten with **KTX** `AssetStorage`: + +```kotlin +class Application: ApplicationAdapter() { + override fun create() { + // ktx-async module requires initiating Kotlin coroutines context: + KtxAsync.initiate() + val assetStorage = AssetStorage() + + // Launching asynchronous coroutine that will _not_ block the rendering thread: + KtxAsync.launch { + assetStorage.apply { + // Loading assets. Notice the immediate returns. + // The coroutine will suspend until each asset is loaded: + val logo = load("logo.png") + val bundle = load("i18n.properties") + val skin = load("ui.json") + // Assets are loaded and we already have references to all of them: + goToNextView(logo, bundle, skin) + } + } + } + + override fun render() { + // Render loading prompt. AssetStorage does not block the rendering thread. + } + + private fun goToNextView(logo: Texture, bundle: I18NBundle, skin: Skin) { TODO() } +} +``` + +Without the polling-based `AssetManager` API with its constant `update()` calls and +non-returning `load`, application using `AssetStorage` is shorter and easier to read. + +After the assets are loaded, `AssetStorage` and `AssetManager` behave more or less the same: +they store assets mapped by their file path that can be retrieved or disposed on demand. +In case of the `AssetManager`, assets are uniquely identified by their paths; `AssetStorage` +identifies assets by their paths and types, i.e. you can load multiple assets with different +classes from the same file. + +The key difference between **KTX** storage and LibGDX manager is the threading model: +`AssetStorage` provides suspending methods executed via coroutines that resume the +coroutine as soon as the asset is loaded, while `AssetManager` requires scheduling +of asset loading up front, continuous updating until the assets are loaded and +retrieving the assets once the loading is finished. `AssetManager` leverages +a single thread for asynchronous loading operations, while `AssetStorage` can utilize +any chosen number of threads by specifying a coroutine context. + +### Guide + +#### Setup + +See [`ktx-async`](../async) setup section to enable coroutines in your project. + +`KtxAsync.initiate()` must be called on the main rendering thread before `AssetStorage` is used. + +#### API + +`AssetStorage` contains the following core methods: + +- `get: Deferred` - returns a `Deferred` reference to the asset if it was scheduled for loading. +Suspending `await()` can be called to obtain the asset instance. `isCompleted` can be used to check +if the asset loading was finished. +- `load: T` _(suspending)_ - schedules asset for asynchronous loading. Suspends the coroutine until +the asset is fully loaded. Resumes the coroutine and returns the asset once it is loaded. +- `unload: Boolean` _(suspending)_ - unloads the selected asset. If the asset is no longer referenced, +it will be removed from the storage and disposed of. Suspends the coroutine until the asset is unloaded. +Returns `true` is the selected asset was present in storage or `false` if the asset was absent. +- `add` _(suspending)_ - manually adds a fully loaded asset to storage. The storage will take care of +disposing of the asset. +- `dispose` (blocking and suspending variants available) - unloads all assets. Cancels all current +loadings. Depending on the variant, will block the current thread or suspend the coroutine until +all of the assets are unloaded. + +Additional debugging and management methods are available: + +- `getLoader` - allows to obtain `AssetLoader` instance for the given file. +- `setLoader` - allows to associate a custom `AssetLoader` with the selected file and asset types. +- `isLoaded: Boolean` - checks if the selected asset is fully loaded. +- `contains: Boolean` - checks if the selected asset is present in storage, loaded or not. +- `getReferenceCount: Int` - allows to check how many times the asset was loaded, added or required +as dependency by other assets. Returns 0 if the asset is not present in storage. +- `getDependencies: List` - returns list of dependencies of the selected asset. +If the asset is not present in the storage, an empty list will be returned. + +Assets are uniquely identified by their path and `Class` by the storage. +Since these values can be passed in 3 basic ways, most methods are available in 3 variants: + +- Inlined, with reified type and `String` path parameter. +- With `Identifier` parameter, which stores `Class` and path of the asset. +- With LibGDX `AssetDescriptor` storing `Class`, path and loading data of the asset. + +All three variants behave identically and are available for convenience. + +### Usage examples + +Creating an `AssetStorage` with default settings: + +```kotlin +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun create() { + // Necessary to initiate the coroutines context: + KtxAsync.initiate() + val assetStorage = AssetStorage() +} +``` + +Customizing `AssetStorage`. In this example a multi-threaded coroutine context +was assigned to storage, so the assets will be loaded in parallel on multiple threads: + +```kotlin +import com.badlogic.gdx.assets.loaders.resolvers.InternalFileHandleResolver +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.async.newAsyncContext + +fun create() { + KtxAsync.initiate() + val assetStorage = AssetStorage( + // Used to asynchronous file loading: + asyncContext = newAsyncContext(threads = 4), + // Used for resolving file paths: + fileResolver = InternalFileHandleResolver(), + // Whether to add standard LibGDX loaders for common assets: + useDefaultLoaders = true + ) +} +``` + +TODO + +### Alternatives + +There seem to be no other coroutines-based asset loaders available. +However, LibGDX `AssetManager` is still viable when multi-threading is not a requirement. +Alternatives include: + +- Using [`AssetManager`](https://github.com/libgdx/libgdx/wiki/Managing-your-assets) directly. +- Using [`ktx-assets`](../assets) extensions for `AssetManager`. +- [`AnnotationAssetManager`](https://bitbucket.org/dermetfan/libgdx-utils/wiki/net.dermetfan.gdx.assets.AnnotationAssetManager) +from [`libgdx-utils`](https://bitbucket.org/dermetfan/libgdx-utils) that extends `AssetManager` and allows +to specify assets for loading by marking fields with annotations. However, it's annotation-based API relies +on reflection and is not really idiomatic in Kotlin. + +#### Additional documentation + +- [`ktx-async` module](../async), which is used extensively by this extension. +- [Official `AssetManager` article.](https://github.com/libgdx/libgdx/wiki/Managing-your-assets) diff --git a/assets-async/build.gradle b/assets-async/build.gradle new file mode 100644 index 00000000..0b5d90e5 --- /dev/null +++ b/assets-async/build.gradle @@ -0,0 +1,9 @@ +dependencies { + compile project(':assets') + compile project(':async') + testCompile project(':async').sourceSets.test.output + testCompile "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$kotlinCoroutinesVersion" + testCompile "com.badlogicgames.gdx:gdx-backend-headless:$gdxVersion" + testCompile "com.badlogicgames.gdx:gdx-backend-lwjgl:$gdxVersion" + testCompile "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" +} diff --git a/assets-async/gradle.properties b/assets-async/gradle.properties new file mode 100644 index 00000000..084d33c9 --- /dev/null +++ b/assets-async/gradle.properties @@ -0,0 +1,2 @@ +projectName=ktx-assets-async +projectDesc=Asynchronous coroutines-based asset loader for LibGDX. diff --git a/assets-async/src/main/kotlin/ktx/assets/async/loaders.kt b/assets-async/src/main/kotlin/ktx/assets/async/loaders.kt new file mode 100644 index 00000000..190169b2 --- /dev/null +++ b/assets-async/src/main/kotlin/ktx/assets/async/loaders.kt @@ -0,0 +1,165 @@ +package ktx.assets.async + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.AssetLoader +import com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.assets.loaders.resolvers.AbsoluteFileHandleResolver +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.utils.ObjectMap +import com.badlogic.gdx.utils.Array as GdxArray + +/** + * Stores [AssetLoader] instances mapped by loaded asset type. Internal [AssetStorage] utility. + * + * Implementation note: LibGDX loaders are not thread-safe. Instead, they assume that only a single asset is loaded + * at a time and use internal, unsynchronized fields to store temporary variables like the dependencies. To avoid + * threading issues, we use a separate loader for each loaded asset instead of singleton instances - hence + * the functional loader providers. + */ +internal class AssetLoaderStorage { + private val loaders: ObjectMap, AssetLoaderContainer<*>> = ObjectMap() + + /** + * Provides a [Loader] for the given asset [type]. Optionally, file [path] can be given, + * as depending on the file suffix, a different loader might be used for the same asset type. + */ + fun getLoader(type: Class, path: String? = null): Loader? { + @Suppress("UNCHECKED_CAST") + val loadersForType = loaders[type] as AssetLoaderContainer? ?: return null + if (path == null || loadersForType.loadersBySuffix.size == 0) { + return loadersForType.mainLoader?.invoke() + } + var maxMatchingSuffixLength = 0 + var loaderProvider = loadersForType.mainLoader + loadersForType.loadersBySuffix.forEach { + val suffix = it.key + if (maxMatchingSuffixLength < suffix.length && path.endsWith(suffix)) { + maxMatchingSuffixLength = suffix.length + loaderProvider = it.value + } + } + return loaderProvider?.invoke() + } + + /** + * Adds or replaces [Loader] for the given class. [loaderProvider] is invoked + * each time an instance of the selected loader is requested. The loader will be + * associated with the given asset [type]. Optionally, a [suffix] can be given + * to a associate the loader with specific file paths. + */ + fun setLoaderProvider(type: Class, suffix: String? = null, loaderProvider: () -> Loader) { + validate(loaderProvider) + getOrCreateLoadersContainer(type).apply { + if (suffix.isNullOrEmpty()) { + mainLoader = loaderProvider + } else { + loadersBySuffix.put(suffix, loaderProvider) + } + } + } + + private fun validate(loaderProvider: () -> Loader) { + val loader = loaderProvider() + if (loader !is SynchronousAssetLoader<*, *> && loader !is AsynchronousAssetLoader<*, *>) { + throw InvalidLoaderException(loader) + } + } + + private fun getOrCreateLoadersContainer(type: Class): AssetLoaderContainer { + val loadersForType = loaders[type] + if (loadersForType == null) { + val container = AssetLoaderContainer() + loaders.put(type, container) + return container + } + @Suppress("UNCHECKED_CAST") + return loadersForType as AssetLoaderContainer + } + + override fun toString(): String = "AssetLoaderStorage[loaders=$loaders]" + + private class AssetLoaderContainer { + val loadersBySuffix: ObjectMap Loader> = ObjectMap() + var mainLoader: (() -> Loader)? + get() = loadersBySuffix[""] + set(value) { + loadersBySuffix.put("", value) + } + + override fun toString(): String = loadersBySuffix.toString() + } +} + +// Workarounds for LibGDX generics API. + +/** [AssetLoader] with improved generics. */ +typealias Loader = AssetLoader> + +/** [SynchronousAssetLoader] with improved generics. */ +typealias SynchronousLoader = SynchronousAssetLoader> + +/** [AsynchronousAssetLoader] with improved generics. */ +typealias AsynchronousLoader = AsynchronousAssetLoader> + +/** Casts [AssetDescriptor.params] stored with raw type. */ +private val AssetDescriptor.parameters: AssetLoaderParameters? + @Suppress("UNCHECKED_CAST") + get() = params as AssetLoaderParameters? + +/** + * Allows to use [AssetLoader.getDependencies] method with [AssetDescriptor]. + * [assetDescriptor] contains asset data. + * Returns a [com.badlogic.gdx.utils.Array] with asset dependencies described + * with [AssetDescriptor] instances. Null if here are no dependencies. + */ +fun Loader<*>.getDependencies(assetDescriptor: AssetDescriptor<*>): GdxArray> = + @Suppress("UNCHECKED_CAST") + (this as AssetLoader<*, AssetLoaderParameters<*>>) + .getDependencies(assetDescriptor.fileName, assetDescriptor.file, assetDescriptor.parameters) ?: GdxArray(0) + +/** + * Allows to use [SynchronousAssetLoader.load] method with [AssetDescriptor]. + * [assetManager] provides asset dependencies for the loader. + * [assetDescriptor] contains asset data. Returns fully loaded [Asset] instance. + */ +fun SynchronousLoader.load(assetManager: AssetManager, assetDescriptor: AssetDescriptor): Asset = + @Suppress("UNCHECKED_CAST") + (this as SynchronousAssetLoader>) + .load(assetManager, assetDescriptor.fileName, assetDescriptor.file, assetDescriptor.parameters) + +/** + * Allows to use [AsynchronousAssetLoader.loadAsync] method with [AssetDescriptor]. + * Performs the asynchronous asset loading part without yielding results. + * [assetManager] provides asset dependencies for the loader. + * [assetDescriptor] contains asset data. + */ +fun AsynchronousLoader.loadAsync(assetManager: AssetManager, assetDescriptor: AssetDescriptor) = + @Suppress("UNCHECKED_CAST") + (this as AsynchronousAssetLoader>) + .loadAsync(assetManager, assetDescriptor.fileName, assetDescriptor.file, assetDescriptor.parameters) + +/** + * Allows to use [AsynchronousAssetLoader.loadSync] method with [AssetDescriptor]. + * Note that [loadAsync] must have been called first with the same asset data. + * [assetManager] provides asset dependencies for the loader. + * [assetDescriptor] contains asset data. Returns fully loaded [Asset] instance. + */ +fun AsynchronousLoader.loadSync(assetManager: AssetManager, assetDescriptor: AssetDescriptor): Asset = + @Suppress("UNCHECKED_CAST") + (this as AsynchronousAssetLoader>) + .loadSync(assetManager, assetDescriptor.fileName, assetDescriptor.file, assetDescriptor.parameters) + +/** Required for [ManualLoader] by LibGDX API. */ +internal class ManualLoadingParameters : AssetLoaderParameters() + +/** Mocks [AssetLoader] API for assets manually added to the [AssetStorage]. See [AssetStorage.add]. */ +internal object ManualLoader : AssetLoader(AbsoluteFileHandleResolver()) { + private val emptyDependencies = GdxArray>(0) + override fun getDependencies( + fileName: String?, file: FileHandle?, + parameter: ManualLoadingParameters? + ): GdxArray> = emptyDependencies +} diff --git a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt new file mode 100644 index 00000000..26feab29 --- /dev/null +++ b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt @@ -0,0 +1,921 @@ +@file:Suppress("DeferredIsResult") + +package ktx.assets.async + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.* +import com.badlogic.gdx.assets.loaders.resolvers.InternalFileHandleResolver +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.graphics.g3d.loader.G3dModelLoader +import com.badlogic.gdx.graphics.g3d.loader.ObjLoader +import com.badlogic.gdx.utils.* +import com.badlogic.gdx.utils.async.AsyncExecutor +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import ktx.assets.TextAssetLoader +import ktx.async.KtxAsync +import ktx.async.newSingleThreadAsyncContext +import ktx.async.onRenderingThread +import kotlin.coroutines.CoroutineContext +import com.badlogic.gdx.graphics.g3d.particles.ParticleEffectLoader as ParticleEffect3dLoader + +/** + * Asynchronous asset loader based on coroutines API. An [AssetManager] alternative. + * + * Note that [KtxAsync.initiate] must be called before creating an [AssetStorage]. + * + * [fileResolver] determines how file paths are interpreted. Defaults to [InternalFileHandleResolver], which loads + * internal files. + * + * [asyncContext] is used to perform asynchronous file loading. Defaults to a single-threaded context using an + * [AsyncExecutor]. See [newSingleThreadAsyncContext] or [ktx.async.newAsyncContext] functions to create a custom + * loading context. Multi-threaded contexts are supported and might boost loading performance if the assets + * are loaded asynchronously. + * + * If `useDefaultLoaders` is true (which is the default), all default LibGDX asset loaders will be registered. + */ +class AssetStorage( + val fileResolver: FileHandleResolver = InternalFileHandleResolver(), + val asyncContext: CoroutineContext = newSingleThreadAsyncContext(threadName = "AssetStorage-Thread"), + useDefaultLoaders: Boolean = true +) : Disposable { + @Suppress("LeakingThis") + private val asAssetManager: AssetManager = AssetManagerWrapper(this) + private val loaderStorage = AssetLoaderStorage() + + private val lock = Mutex() + private val assets = mutableMapOf, Asset<*>>() + + /** LibGDX Logger used internally by the asset loaders, usually to report issues. */ + var logger: Logger + get() = asAssetManager.logger + set(value) { + asAssetManager.logger = value + } + + init { + if (useDefaultLoaders) { + setLoader { TextAssetLoader(fileResolver) } + setLoader { BitmapFontLoader(fileResolver) } + setLoader { MusicLoader(fileResolver) } + setLoader { PixmapLoader(fileResolver) } + setLoader { SoundLoader(fileResolver) } + setLoader { TextureAtlasLoader(fileResolver) } + setLoader { TextureLoader(fileResolver) } + setLoader { SkinLoader(fileResolver) } + setLoader { ParticleEffectLoader(fileResolver) } + setLoader { ParticleEffect3dLoader(fileResolver) } + setLoader { I18NBundleLoader(fileResolver) } + setLoader(suffix = ".g3dj") { G3dModelLoader(JsonReader(), fileResolver) } + setLoader(suffix = ".g3db") { G3dModelLoader(UBJsonReader(), fileResolver) } + setLoader(suffix = ".obj") { ObjLoader(fileResolver) } + setLoader { ShaderProgramLoader(fileResolver) } + setLoader { CubemapLoader(fileResolver) } + } + } + + /** + * Creates a new [Identifier] that allows to uniquely describe an asset by [path] and class. + * Uses reified [T] type to obtain the asset class. + * + * [T] is type of the loaded asset. + * [path] to the file should be consistent with [fileResolver] asset type. + */ + inline fun getIdentifier(path: String): Identifier = Identifier(T::class.java, path) + + /** + * Creates a new [AssetDescriptor] for the selected asset. + * [T] is type of the loaded asset. + * [path] to the file should be consistent with [fileResolver] asset type. + * Loading [parameters] are optional and passed to the associated [AssetLoader]. + * Returns a new instance of [AssetDescriptor] with a resolved [FileHandle]. + */ + inline fun getAssetDescriptor( + path: String, + parameters: AssetLoaderParameters? = null + ): AssetDescriptor { + val descriptor = AssetDescriptor(path.normalizePath(), T::class.java, parameters) + descriptor.file = fileResolver.resolve(path) + return descriptor + } + + /** + * Returns the reference to the asset wrapped with [Deferred]. + * Use [Deferred.await] to obtain the instance. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [identifier] uniquely identifies a file by its path and type. + * + * To avoid concurrency issues, it is encouraged to load assets with [load] and save the returned instances + * of the loaded assets rather than to rely on [get]. + * + * Note that while the result is a [CompletableDeferred], it should never be completed manually. + * Instead, rely on the [AssetStorage] to load the asset. + * + * Using [Deferred.await] might throw the following exceptions: + * - [MissingAssetException] if the asset with [identifier] was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * Otherwise, using [Deferred.await] will suspend the coroutine until the asset is loaded + * and return its instance. + */ + operator fun get(identifier: Identifier): Deferred { + val asset = assets[identifier] + @Suppress("UNCHECKED_CAST") + return if (asset != null) asset.reference as Deferred else getMissingAssetReference(identifier) + } + + private fun getMissingAssetReference(identifier: Identifier): Deferred = CompletableDeferred().apply { + completeExceptionally(MissingAssetException(identifier)) + } + + /** + * Returns the reference to the asset wrapped with [Deferred]. + * Use [Deferred.await] to obtain the instance. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [path] must match the asset path passed during loading. + * + * To avoid concurrency issues, it is encouraged to load assets with [load] and save the returned instances + * of the loaded assets rather than to rely on [get]. + * + * Note that while the result is a [CompletableDeferred], it should never be completed manually. + * Instead, rely on the [AssetStorage] to load the asset. + * + * Using [Deferred.await] might throw the following exceptions: + * - [MissingAssetException] if the asset at [path] was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * Otherwise, using [Deferred.await] will suspend the coroutine until the asset is loaded + * and return its instance. + */ + inline operator fun get(path: String): Deferred = get(getIdentifier(path)) + + /** + * Returns the reference to the asset wrapped with [Deferred]. Use [Deferred.await] to obtain the instance. + * Throws [AssetStorageException] if the asset was unloaded or never scheduled to begin with. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [descriptor] contains the asset data. See [getAssetDescriptor]. + * + * To avoid concurrency issues, it is encouraged to load assets with [load] and save the returned instances + * of the loaded assets rather than to rely on [get]. + * + * Note that while the result is a [CompletableDeferred], it should never be completed manually. + * Instead, rely on the [AssetStorage] to load the asset. + * + * Using [Deferred.await] might throw the following exceptions: + * - [MissingAssetException] if the asset was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * Otherwise, using [Deferred.await] will suspend the coroutine until the asset is loaded + * and return its instance. + */ + operator fun get(descriptor: AssetDescriptor): Deferred = get(descriptor.toIdentifier()) + + /** + * Checks whether an asset in the selected [path] with [T] type is already loaded. + * Returns false if the asset is not loaded yet, is unloaded or was never loaded to begin with. + * + * Note that assets that loaded exceptionally (i.e. asset loader threw an exception) will + * also report as loaded, but trying to obtain their instance will cause rethrowing of that + * exception, forcing the user to handle it. + */ + inline fun isLoaded(path: String): Boolean = isLoaded(getIdentifier(path)) + + /** + * Checks whether an asset described with [descriptor] is already loaded. + * Returns false if the asset is not loaded yet, is unloaded or was never loaded to begin with. + * + * Note that assets that loaded exceptionally (i.e. asset loader threw an exception) will + * also report as loaded, but trying to obtain their instance will cause rethrowing of that + * exception, forcing the user to handle it. + */ + fun isLoaded(descriptor: AssetDescriptor<*>): Boolean = isLoaded(descriptor.toIdentifier()) + + /** + * Checks whether an asset identified with [identifier] is already loaded. + * Returns false if the asset is not loaded yet, is unloaded or was never loaded to begin with. + * + * Note that assets that loaded exceptionally (i.e. asset loader threw an exception) will + * also report as loaded, but trying to obtain their instance will cause rethrowing of that + * exception, forcing the user to handle it. + */ + fun isLoaded(identifier: Identifier<*>): Boolean = + assets[identifier]?.reference?.isCompleted ?: false + + /** + * Checks whether an asset in the selected [path] and [T] type is currently managed by the storage. + * This will return true for assets that are currently being loaded or + */ + inline operator fun contains(path: String): Boolean = contains(getIdentifier(path)) + + /** + * Checks whether an asset described by [descriptor] is currently managed by the storage. + * This will return true for assets that are currently being loaded or + */ + operator fun contains(descriptor: AssetDescriptor<*>): Boolean = contains(descriptor.toIdentifier()) + + /** + * Checks whether an asset identified by [identifier] is currently managed by the storage. + * This will return true for assets that are currently being loaded or + */ + operator fun contains(identifier: Identifier<*>): Boolean = identifier in assets + + /** + * Adds a fully loaded [asset] to the storage. Allows to avoid loading the asset with the [AssetStorage] + * and to manually add it to storage context. + * + * [T] is the type of the [asset]. Note that a superclass of the asset can be chosen to associate the file with. + * [path] must be a unique ID that will be used to retrieve the asset. Since the [asset] is loaded manually, + * it does not have to be an actual file path. + * + * Throws [AlreadyLoadedAssetException] if an asset with the same path is already loaded or scheduled for loading. + */ + suspend inline fun add(path: String, asset: T) = + add(getAssetDescriptor(path), asset) + + /** + * Adds a fully loaded [asset] to the storage. Allows to avoid loading the asset with the [AssetStorage] + * and to manually add it to storage context. + * + * [T] is the type of the [asset]. Note that a superclass of the asset can be chosen to associate the file with. + * [identifier] uniquely identifies the assets and defines its type. Since the [asset] is loaded manually, + * [Identifier.path] does not have to be an actual file path. See [getIdentifier]. + * + * Throws [AlreadyLoadedAssetException] if an asset with the same path is already loaded or scheduled for loading. + */ + suspend fun add(identifier: Identifier, asset: T) = + add(AssetDescriptor(identifier.path, identifier.type), asset) + + /** + * Adds a fully loaded [asset] to the storage. Allows to avoid loading the asset with the [AssetStorage] + * and to manually add it to storage context. + * + * [T] is the type of the [asset]. Note that a superclass of the asset can be chosen to associate the file with. + * [descriptor] contains the asset data. See [getAssetDescriptor]. + * + * Throws [AlreadyLoadedAssetException] if an asset with the same path is already loaded or scheduled for loading. + */ + suspend fun add(descriptor: AssetDescriptor, asset: T) { + val identifier = descriptor.toIdentifier() + lock.withLock { + @Suppress("UNCHECKED_CAST") + val existingAsset = assets[identifier] as? Asset + if (existingAsset != null) { + // Asset is already stored. Will fail to replace. + throw AlreadyLoadedAssetException(identifier) + } + // Asset is currently not stored. Creating. + @Suppress("UNCHECKED_CAST") + assets[identifier] = Asset( + descriptor = descriptor, + reference = CompletableDeferred(asset), + dependencies = emptyList(), + referenceCount = 1, + loader = ManualLoader as Loader + ) + } + } + + /** + * Schedules loading of an asset of [T] type located at [path]. + * [path] must be compatible with the [fileResolver]. + * Loading [parameters] are optional and can be used to configure the loaded asset. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * + * Might throw the following exceptions exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load]. + * Asset dependencies should not be unloaded directly; instead, unload the asset that required them + * and caused them to load in the first place. + */ + suspend inline fun load(path: String, parameters: AssetLoaderParameters? = null): T = + load(getAssetDescriptor(path, parameters)) + + /** + * Schedules loading of an asset with path and type specified by [identifier] + * [Identifier.path] must be compatible with the [fileResolver]. + * Loading [parameters] are optional and can be used to configure the loaded asset. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * + * Might throw the following exceptions exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load]. + * Asset dependencies should not be unloaded directly; instead, unload the asset that required them + * and caused them to load in the first place. + */ + suspend fun load(identifier: Identifier, parameters: AssetLoaderParameters? = null): T = + load(AssetDescriptor(identifier.path, identifier.type, parameters)) + + /** + * Schedules loading of an asset of [T] type described by the [descriptor]. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * + * Might throw the following exceptions exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load]. + * Asset dependencies should not be unloaded directly; instead, unload the asset that required them + * and caused them to load in the first place. + */ + suspend fun load(descriptor: AssetDescriptor): T { + lateinit var newAssets: List> + lateinit var asset: Asset + lock.withLock { + asset = obtainAsset(descriptor) + newAssets = updateReferences(asset) + } + newAssets.forEach { assetToLoad -> + // Loading new assets asynchronously: + KtxAsync.launch(asyncContext) { + withAssetLoadingErrorHandling(assetToLoad) { + loadAsset(assetToLoad) + } + } + } + // Awaiting for our asset to load: + return asset.reference.await() + } + + /** Must be called with [lock]. */ + private suspend fun obtainAsset(descriptor: AssetDescriptor): Asset { + val identifier = descriptor.toIdentifier() + val asset = assets[identifier] + if (asset != null) { + // Asset already exists and identifier ensures same type - returning: + @Suppress("UNCHECKED_CAST") + return asset as Asset + } + return createNewAsset(descriptor).also { + assets[identifier] = it + } + } + + private suspend fun createNewAsset(descriptor: AssetDescriptor): Asset = + withContext(asyncContext) { + resolveFileHandle(descriptor) + val loader = getLoader(descriptor.type, descriptor.fileName) ?: throw MissingLoaderException(descriptor) + val dependencies = loader.getDependencies(descriptor) + Asset( + descriptor = descriptor, + dependencies = dependencies.map { obtainAsset(it) }, + loader = loader, + referenceCount = 0 + ) + } + + private fun resolveFileHandle(descriptor: AssetDescriptor<*>) { + if (descriptor.file == null) { + descriptor.file = fileResolver.resolve(descriptor.fileName) + } + } + + /** + * Must be executed with [lock]. + * Updates reference counts of entire dependency tree starting with [root]. + * Returns a list of new assets that have to be loaded. + */ + private fun updateReferences(root: Asset<*>): List> { + val queue = Queue>() + queue.addLast(root) + val newAssets = mutableListOf>() + while (!queue.isEmpty) { + val asset = queue.removeFirst() + asset.referenceCount++ + if (asset.referenceCount == 1) { + newAssets.add(asset) + } + asset.dependencies.forEach(queue::addLast) + } + return newAssets + } + + private suspend fun loadAsset( + asset: Asset + ): T { + asset.dependencies.forEach { dependency -> + withAssetLoadingErrorHandling(asset) { + dependency.reference.await() + } + } + if (asset.reference.isCompleted) { + // The asset failed to load due to its dependencies or asynchronous unloading: + return asset.reference.await() + } + withAssetLoadingErrorHandling(asset) { + when (val loader = asset.loader) { + is SynchronousLoader -> loadWithSynchronousLoader(loader, asset) + is AsynchronousLoader -> loadWithAsynchronousLoader(loader, asset) + else -> throw InvalidLoaderException(loader) + } + } + return asset.reference.await() + } + + private inline fun withAssetLoadingErrorHandling(asset: Asset<*>, operation: () -> Unit) { + try { + operation() + } catch (exception: AssetStorageException) { + asset.reference.completeExceptionally(exception) + } catch (exception: Throwable) { + asset.reference.completeExceptionally( + AssetLoadingException(asset.descriptor, cause = exception) + ) + } + } + + private suspend fun loadWithSynchronousLoader( + synchronousLoader: SynchronousLoader, + asset: Asset + ) { + if (asset.reference.isCompleted) { + return + } + onRenderingThread { + if (!asset.reference.isCompleted) { + val value = synchronousLoader.load(asAssetManager, asset.descriptor) + setLoaded(asset, value) + } + } + } + + private suspend fun loadWithAsynchronousLoader( + asynchronousLoader: AsynchronousLoader, + asset: Asset + ) { + withContext(asyncContext) { + if (!asset.reference.isCompleted) { + asynchronousLoader.loadAsync(asAssetManager, asset.descriptor) + } + } + if (asset.reference.isCompleted) { + return + } + onRenderingThread { + if (!asset.reference.isCompleted) { + val value = asynchronousLoader.loadSync(asAssetManager, asset.descriptor) + setLoaded(asset, value) + } + } + } + + private fun setLoaded(asset: Asset, value: T) { + val isAssigned = asset.reference.complete(value) + if (!isAssigned) { + // The asset was unloaded asynchronously. The deferred was likely completed with an exception. + // Now we have to take care of the loaded value or it will remain loaded and unreferenced. + try { + value.dispose() + } catch (exception: Throwable) { + logger.error("Failed to dispose asset: ${asset.descriptor}", exception) + } + } + } + + /** + * Removes asset loaded with the given [path] and [T] type and all of its dependencies. + * Does nothing if asset was not loaded in the first place. + * Will not dispose of the asset if it still is referenced by any other assets. + * Any removed assets that implement [Disposable] will be disposed. + * + * Note: only assets that were explicitly scheduled for loading with [load] + * or manually added to storage with [add] should be unloaded. + * Dependencies of assets will be removed automatically along with the original assets + * that caused them to load in the first place. + * + * Assets scheduled for loading multiple times must be explicitly unloaded multiple times - + * until the asset is unloaded as many times as it was referenced, it is assumed that it is + * still used. Manually unloading dependencies of other assets (that were not scheduled + * for loading explicitly) might lead to unexpected runtime exceptions. + * + * Will log all exceptions related to unloading of the assets. Silence the [logger] + * to avoid exception logging. + * + * Returns `true` if the asset was present in the [AssetStorage]. Note that if the asset + * is still referenced (i.e. [load] was called multiple times or the asset is a dependency + * of an asset that is still loaded), the asset will not be disposed of and will remain + * in the storage even if `true` is returned. + */ + suspend inline fun unload(path: String): Boolean = unload(getIdentifier(path)) + + /** + * Removes asset described by the [descriptor] and all of its dependencies. + * Does nothing if asset was not loaded in the first place. + * Will not dispose of the asset if it still is referenced by any other assets. + * Any removed assets that implement [Disposable] will be disposed. + * + * Note: only assets that were explicitly scheduled for loading with [load] + * or manually added to storage with [add] should be unloaded. + * Dependencies of assets will be removed automatically along with the original assets + * that caused them to load in the first place. + * + * Assets scheduled for loading multiple times must be explicitly unloaded multiple times - + * until the asset is unloaded as many times as it was referenced, it is assumed that it is + * still used. Manually unloading dependencies of other assets (that were not scheduled + * for loading explicitly) might lead to unexpected runtime exceptions. + * + * Will log all exceptions related to unloading of the assets. Silence the [logger] + * to avoid exception logging. + * + * Returns `true` if the asset was present in the [AssetStorage]. Note that if the asset + * is still referenced (i.e. [load] was called multiple times or the asset is a dependency + * of an asset that is still loaded), the asset will not be disposed of and will remain + * in the storage even if `true` is returned. + */ + suspend fun unload(descriptor: AssetDescriptor<*>): Boolean = unload(descriptor.toIdentifier()) + + /** + * Removes asset loaded with the given [identifier] and all of its dependencies. + * Does nothing if asset was not loaded in the first place. + * Will not dispose of the asset if it still is referenced by any other assets. + * Any removed assets that implement [Disposable] will be disposed. + * + * Note: only assets that were explicitly scheduled for loading with [load] + * or manually added to storage with [add] should be unloaded. + * Dependencies of assets will be removed automatically along with the original assets + * that caused them to load in the first place. + * + * Assets scheduled for loading multiple times must be explicitly unloaded multiple times - + * until the asset is unloaded as many times as it was referenced, it is assumed that it is + * still used. Manually unloading dependencies of other assets (that were not scheduled + * for loading explicitly) might lead to unexpected runtime exceptions. + * + * Will log all exceptions related to unloading of the assets. Silence the [logger] + * to avoid exception logging. + * + * Returns `true` if the asset was present in the [AssetStorage]. Note that if the asset + * is still referenced (i.e. [load] was called multiple times or the asset is a dependency + * of an asset that is still loaded), the asset will not be disposed of and will remain + * in the storage even if `true` is returned. + */ + suspend fun unload(identifier: Identifier<*>): Boolean { + var unloaded = true + lock.withLock { + val root = assets[identifier] + if (root == null) { + unloaded = false + } else { + val queue = Queue>() + queue.addLast(root) + while (!queue.isEmpty) { + val asset = queue.removeFirst() + asset.referenceCount-- + if (asset.referenceCount == 0) { + disposeOf(asset) + assets.remove(asset.identifier) + } + asset.dependencies.forEach(queue::addLast) + } + } + } + return unloaded + } + + private suspend fun disposeOf(asset: Asset<*>) { + val path = asset.descriptor.fileName + if (!asset.reference.isCompleted) { + val exception = UnloadedAssetException(asset.identifier) + // If the asset is not loaded yet, we complete the reference with exception: + val cancelled = asset.reference.completeExceptionally(exception) + if (cancelled) { + // We managed to complete the reference exceptionally. The loading coroutine will dispose of the asset. + return + } + } + try { + // We did not manage to complete the reference. Asset should be disposed of. + val value = asset.reference.await() + value.dispose() + } catch (exception: UnloadedAssetException) { + // The asset was already unloaded. Should not happen, but it's not an issue. + } catch (exception: Throwable) { + // Asset failed to load or failed to dispose. Either way, we just log the exception. + logger.error("Failed to dispose asset with path: $path", exception) + } + } + + private fun Any?.dispose() { + (this as? Disposable)?.dispose() + } + + /** + * Returns the [AssetLoader] associated with the file. [Asset] is used to determine the type + * of the loaded file. [path] might be necessary to choose the correct loader, as some loaders + * might be assigned to specific file suffixes or extensions. + */ + inline fun getLoader(path: String? = null): Loader? = + getLoader(Asset::class.java, path) + + /** + * Internal API exposed for inlined method. See inlined [getLoader] with generics. + * [type] is the class of the loaded asset, while path is used to determine if + * a loader specifically assigned to a file suffix or extension is necessary. + */ + fun getLoader(type: Class, path: String?): Loader? = + loaderStorage.getLoader(type, path?.normalizePath()) + + /** + * Associates the [AssetLoader] with specific asset type determined by [T]. + * [loaderProvider] should create a new instance of loader of the selected types. + * Optional [suffix] can be passed if the loader should handle only the files + * with a specific file name suffix or extension. + * + * Throws [InvalidLoaderException] if the [AssetLoader] does not extend + * [SynchronousAssetLoader] or [AsynchronousAssetLoader]. + */ + inline fun setLoader(suffix: String? = null, noinline loaderProvider: () -> Loader) = + setLoader(T::class.java, suffix, loaderProvider) + + /** + * Internal API exposed for inlined method. See inlined [setLoader] with reified generics. + * Associates the [AssetLoader] [loaderProvider] with [type] under the specified optional [suffix]. + * + * Throws [InvalidLoaderException] if the [AssetLoader] does not extend + * [SynchronousAssetLoader] or [AsynchronousAssetLoader]. + */ + fun setLoader(type: Class, suffix: String? = null, loaderProvider: () -> Loader) { + loaderStorage.setLoaderProvider(type, suffix, loaderProvider) + } + + /** + * Matches [AssetDescriptor] pre-processing. Return this [String] with normalized file separators. + */ + fun String.normalizePath() = replace('\\', '/') + + /** + * Returns the amount of references to the asset under the given [path] of [T] type. + * References include manual registration of the asset with [add], + * scheduling the asset for loading with [load] and the amount of times + * the asset was referenced as a dependency of other assets. + */ + inline fun getReferenceCount(path: String): Int = getReferenceCount(getIdentifier(path)) + + /** + * Returns the amount of references to the asset described by [descriptor]. + * References include manual registration of the asset with [add], + * scheduling the asset for loading with [load] and the amount of times + * the asset was referenced as a dependency of other assets. + */ + fun getReferenceCount(descriptor: AssetDescriptor<*>): Int = getReferenceCount(descriptor.toIdentifier()) + + /** + * Returns the amount of references to the asset identified by [identifier]. + * References include manual registration of the asset with [add], + * scheduling the asset for loading with [load] and the amount of times + * the asset was referenced as a dependency of other assets. + */ + fun getReferenceCount(identifier: Identifier<*>): Int = assets[identifier]?.referenceCount ?: 0 + + /** + * Returns a copy of the list of dependencies of the asset under [path] with [T] type. + * If the asset is not loaded or has no dependencies, an empty list is returned. + */ + inline fun getDependencies(path: String): List> = + getDependencies(getIdentifier(path)) + + /** + * Returns a copy of the list of dependencies of the asset described by [descriptor]. + * If the asset is not loaded or has no dependencies, an empty list is returned. + */ + fun getDependencies(descriptor: AssetDescriptor<*>): List> = + getDependencies(descriptor.toIdentifier()) + + /** + * Returns a copy of the list of dependencies of the asset identified by [identifier]. + * If the asset is not loaded or has no dependencies, an empty list is returned. + */ + fun getDependencies(identifier: Identifier<*>): List> { + val dependencies = assets[identifier]?.dependencies + return dependencies?.map { it.identifier } ?: emptyList() + } + + /** + * Unloads all assets. Blocks current thread until are assets are unloaded. + * Logs all disposing exceptions. + * + * Prefer suspending [dispose] method that takes an error handler as parameter. + */ + override fun dispose() { + runBlocking { + dispose { identifier, cause -> + logger.error("Unable to dispose of $identifier.", cause) + } + } + } + + /** + * Unloads all assets. Cancels loading of all scheduled assets. + * [onError] will be invoked on every caught disposing exception. + */ + suspend fun dispose(onError: (identifier: Identifier<*>, cause: Throwable) -> Unit) { + lock.withLock { + for (asset in assets.values) { + if (!asset.reference.isCompleted) { + val exception = UnloadedAssetException(asset.identifier) + if (asset.reference.completeExceptionally(exception)) { + // We managed to complete the deferred exceptionally, + // so the loading coroutine will take care of the rest. + continue + } + } + try { + asset.reference.await().dispose() + } catch (exception: Throwable) { + onError(asset.identifier, exception) + } + asset.referenceCount = 0 + } + assets.clear() + } + } + + override fun toString(): String = "AssetStorage(assets=${ + assets.keys.sortedBy { it.path }.joinToString(separator = ", ", prefix = "[", postfix = "]") + })" +} + +/** + * Container for a single asset of type [T] managed by [AssetStorage]. + */ +internal data class Asset( + /** Stores asset loading data. */ + val descriptor: AssetDescriptor, + /** Unique identifier of the asset. */ + val identifier: Identifier = descriptor.toIdentifier(), + /** Stores reference to the actual asset once it is loaded. */ + val reference: CompletableDeferred = CompletableDeferred(), + /** Lists asset dependencies that require loading. */ + val dependencies: List>, + /** Used to load the asset. */ + val loader: Loader, + /** Control variable. Lists how many times the asset is referenced by other assets as dependency + * or by direct manual load requests. */ + @Volatile var referenceCount: Int = 0 +) + +/** + * Thrown by [AssetStorage] and related services. + * [message] describes the problem, while [cause] is the optional cause of the exception. + * + * Note that [AssetStorage] usually throws subclasses of this exception, rather than + * instances of this exception directly. This class acts as the common superclass + * with which all [AssetStorage]-related exceptions can be caught and handled. + */ +open class AssetStorageException(message: String, cause: Throwable? = null) : GdxRuntimeException(message, cause) + +/** + * Thrown when the asset requested by [AssetStorage.get] is not available in the [AssetStorage]. + */ +class MissingAssetException(identifier: Identifier<*>) : + AssetStorageException(message = "Asset: $identifier is not loaded.") + +/** + * Thrown by [AssetStorage.load] or [AssetStorage.get] when the requested asset + * was unloaded asynchronously. + */ +class UnloadedAssetException(identifier: Identifier<*>) : + AssetStorageException(message = "Asset: $identifier was unloaded.") + +/** + * Thrown by [AssetStorage.add] when attempting to add an asset with [Identifier] + * that is already present in the [AssetStorage]. + */ +class AlreadyLoadedAssetException(identifier: Identifier<*>) : + AssetStorageException(message = "Asset: $identifier was already added to storage.") + +/** + * Thrown by [AssetStorage.load] when the [AssetLoader] for the requested asset type + * and path is unavailable. See [AssetStorage.setLoader]. + */ +class MissingLoaderException(descriptor: AssetDescriptor<*>) : + AssetStorageException( + message = "No loader available for assets of type: ${descriptor.type} " + + "with path: ${descriptor.fileName}." + ) + +/** + * Thrown by [AssetStorage.load] or [AssetStorage.get] when the asset failed to load + * due to invalid loader implementation. Since loaders are pre-validated during + * registration, normally this exception is extremely rare and caused by invalid + * [AssetStorage.setLoader] usage. + */ +class InvalidLoaderException(loader: Loader<*>) : + AssetStorageException( + message = "Invalid loader: $loader. It must extend either " + + "SynchronousAssetLoader or AsynchronousAssetLoader." + ) + +/** + * Thrown by [AssetStorage.load] or [AssetStorage.get] when the asset failed to load + * due to an unexpected loading exception, usually thrown by the associated [AssetLoader]. + */ +class AssetLoadingException(descriptor: AssetDescriptor<*>, cause: Throwable) + : AssetStorageException(message = "Unable to load asset: $descriptor", cause = cause) + +/** + * [AssetStorage] reuses official [AssetLoader] implementations to load the assets. + * [SynchronousAssetLoader] and [AsynchronousAssetLoader] both expect an instance of [AssetManager] + * to perform some basic operations on assets. To support the loaders API, [AssetStorage] is wrapped + * with an [AssetManagerWrapper] which delegates supported methods to [AssetStorage] and throws + * this exception otherwise. + * + * Most official loaders only call [AssetManager.get] to obtain asset dependencies, but custom loaders + * can perform operations that are unsupported by [AssetStorage] due to its asynchronous nature + * and storing assets mapped by path and type rather than path alone. If this exception causes the loading + * to fail, [AssetLoader] associated with the asset has to be refactored. + */ +class UnsupportedMethodException(method: String) : + AssetStorageException( + message = "AssetLoader used unsupported operation of AssetManager wrapper: $method " + + "Please refactor AssetLoader not to call this method on AssetManager." + ) + +/** + * This exception is only ever thrown when trying to access assets via [AssetManagerWrapper]. + * It is typically only called by [AssetLoader] instances. + * + * If this exception is thrown, it means that [AssetLoader] attempts to access an asset that either: + * - Is already unloaded. + * - Failed to load with exception. + * - Was not listed by [AssetLoader.getDependencies]. + * - Has not loaded yet, which should never happen if the dependency was listed correctly. + * + * This exception is only expected in case of concurrent loading and unloading of the same asset. + * If it occurs otherwise, the [AssetLoader] associated with the asset might incorrect list + * asset's dependencies. + */ +class MissingDependencyException(identifier: Identifier<*>, cause: Throwable? = null) : + AssetStorageException( + message = "A loader has requested an instance of ${identifier.type} at path ${identifier.path}. " + + "This asset was either not listed in dependencies, loaded with exception, not loaded yet " + + "or unloaded asynchronously.", + cause = cause + ) + +/** + * Uniquely identifies a single asset stored in an [AssetStorage] by its [type] and [path]. + * + * Multiple assets with the same [path] can be stored in an [AssetStorage] as long as they + * have a different [type]. Similarly, [AssetStorage] can store multiple assets of the same + * [type], as long as each has a different [path]. + */ +data class Identifier( + /** [Class] of the asset specified during loading. */ + val type: Class, + /** File path to the asset compatible with the [AssetStorage.fileResolver]. */ + val path: String +) + +/** + * Converts this [AssetDescriptor] to an [AssetStorage] [Identifier]. + * Copies [AssetDescriptor.type] to [Identifier.type] and [AssetDescriptor.fileName] to [Identifier.path]. + */ +fun AssetDescriptor.toIdentifier(): Identifier = Identifier(type, fileName) diff --git a/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt new file mode 100644 index 00000000..ce6b5644 --- /dev/null +++ b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt @@ -0,0 +1,224 @@ +package ktx.assets.async + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetErrorListener +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.AssetLoader +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.utils.Logger +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import ktx.async.KtxAsync +import com.badlogic.gdx.utils.Array as GdxArray + +/** + * Extends [AssetManager], delegating all of its asset-related method calls to [assetStorage]. + * Allows to use classic [AssetLoader] implementations with [AssetStorage]. Internal API, + * DO NOT use directly. + */ +@Suppress("DEPRECATION") +internal class AssetManagerWrapper(val assetStorage: AssetStorage) + : AssetManager(assetStorage.fileResolver, false) { + private var initiated = false + + init { + // Shutting down super's executor: + super.dispose() + // Replacing logger: + val logger = Logger("AssetStorage") + logger.level = Logger.ERROR + super.setLogger(logger) + + initiated = true + } + + override fun clear() = dispose() + + + @Deprecated("This operation is non-blocking. Assets might still be loaded after this call.", + replaceWith = ReplaceWith("AssetStorage.dispose")) + override fun dispose() { + if (initiated) { + logger.error("Not fully supported AssetManagerWrapper.dispose called by AssetLoader.") + KtxAsync.launch { + assetStorage.dispose { path, error -> + logger.error("Unable to dispose of the asset: $path", error) + } + } + } + } + + @Deprecated("Not supported by AssetStorage.", + replaceWith = ReplaceWith("contains(fileName, type)")) + override fun contains(fileName: String): Boolean = false + override fun contains(fileName: String, type: Class<*>?): Boolean = + assetStorage.contains(AssetDescriptor(fileName, type)) + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun isFinished(): Boolean = true + + @Deprecated("This operation is non-blocking. Assets might not be available in storage after call.", + replaceWith = ReplaceWith("AssetStorage.add")) + override fun addAsset(fileName: String, type: Class, asset: T) { + logger.error("Not fully supported AssetManagerWrapper.addAsset called by AssetLoader.") + KtxAsync.launch { + assetStorage.add(AssetDescriptor(fileName, type), asset) + } + } + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun taskFailed(assetDesc: AssetDescriptor<*>?, ex: RuntimeException?) = + throw UnsupportedMethodException("taskFailed") + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun setErrorListener(listener: AssetErrorListener?) { + logger.error("Not fully supported AssetManagerWrapper.setErrorListener called by AssetLoader.") + } + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun containsAsset(asset: T): Boolean = throw UnsupportedMethodException("containsAsset") + + override fun get(assetDescriptor: AssetDescriptor): Asset = + get(assetDescriptor.fileName, assetDescriptor.type) + + override fun get(fileName: String, type: Class): Asset = + runBlocking { + val identifier = Identifier(type, fileName) + val asset = assetStorage[identifier] + if (asset.isCompleted) { + try { + asset.await() + } catch (exception: Throwable) { + throw MissingDependencyException(identifier, exception) + } + } else { + throw MissingDependencyException(identifier) + } + } + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("get(fileName, type)")) + override fun get(fileName: String): Asset = throw UnsupportedMethodException("get(String)") + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun getAssetNames(): GdxArray = throw UnsupportedMethodException("getAssetNames") + + @Deprecated("Multiple assets with different types can be listed under the same path.", + replaceWith = ReplaceWith("Nothing")) + override fun getAssetType(fileName: String): Class<*>? = throw UnsupportedMethodException("getAssetType") + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun getAll(type: Class, out: GdxArray): GdxArray = + throw UnsupportedMethodException("getAll") + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun getAssetFileName(asset: T): String? { + logger.error("Not supported AssetManagerWrapper.getAssetFileName called by AssetLoader.") + return null + } + + override fun getDiagnostics(): String = assetStorage.toString() + override fun getFileHandleResolver(): FileHandleResolver = assetStorage.fileResolver + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun getLoadedAssets(): Int = 0.also { + logger.error("Not supported AssetManagerWrapper.getLoadedAssets called by AssetLoader.") + } + + override fun getLoader(type: Class): AssetLoader<*, *>? = getLoader(type, "") + override fun getLoader(type: Class, fileName: String): AssetLoader<*, *>? = assetStorage.getLoader(type, fileName) + + override fun isLoaded(assetDesc: AssetDescriptor<*>): Boolean = assetStorage.isLoaded(assetDesc) + override fun isLoaded(fileName: String, type: Class<*>): Boolean = isLoaded(AssetDescriptor(fileName, type)) + + @Deprecated("Not supported by AssetStorage.", + replaceWith = ReplaceWith("isLoaded(fileName, type)")) + override fun isLoaded(fileName: String): Boolean = false.also { + logger.error("Not supported AssetManagerWrapper.addAsset called by AssetLoader.") + } + + @Deprecated("AssetStorage requires type of asset to unload.", + replaceWith = ReplaceWith("AssetStorage.unload")) + override fun unload(fileName: String) { + logger.error("Not supported AssetManagerWrapper.unload called by AssetLoader.") + } + + override fun load(fileName: String, type: Class) = load(fileName, type, null) + override fun load(fileName: String, type: Class, parameters: AssetLoaderParameters?) = + load(AssetDescriptor(fileName, type, parameters)) + + override fun load(descriptor: AssetDescriptor<*>) { + KtxAsync.launch { + assetStorage.load(descriptor) + } + } + + @Deprecated("AssetLoader instances can be mutable." + + "AssetStorage requires functional providers of loaders rather than singular instances.", + replaceWith = ReplaceWith("AssetStorage.setLoader")) + override fun ?> setLoader(type: Class, loader: AssetLoader) = setLoader(type, null, loader) + + @Deprecated("AssetLoader instances can be mutable." + + "AssetStorage requires functional providers of loaders rather than singular instances.", + replaceWith = ReplaceWith("AssetStorage.setLoader")) + override fun ?> setLoader( + type: Class, suffix: String?, loader: AssetLoader + ) { + logger.error("Not fully supported AssetManagerWrapper.setLoader called by AssetLoader.") + assetStorage.setLoader(type, suffix) { + @Suppress("UNCHECKED_CAST") + loader as Loader + } + } + + @Deprecated("AssetStorage requires type to find dependencies.", + replaceWith = ReplaceWith("AssetStorage.getDependencies")) + override fun getDependencies(fileName: String): GdxArray = GdxArray.with().also { + logger.error("Not supported AssetManagerWrapper.getDependencies called by AssetLoader.") + } + + @Deprecated("AssetStorage requires type to find reference count.", + replaceWith = ReplaceWith("AssetStorage.getReferenceCount")) + override fun getReferenceCount(fileName: String): Int = 0.also { + logger.error("Not supported AssetManagerWrapper.getReferenceCount called by AssetLoader.") + } + + @Deprecated("AssetStorage does not have to be updated.", ReplaceWith("Nothing")) + override fun update(millis: Int): Boolean = true + + @Deprecated("AssetStorage does not have to be updated.", ReplaceWith("Nothing")) + override fun update(): Boolean = true + + @Deprecated("Unsupported operation.", ReplaceWith("Nothing")) + override fun setReferenceCount(fileName: String, refCount: Int) = + throw UnsupportedMethodException("setReferenceCount") + + @Deprecated("Since AssetStorage does not force asset scheduling up front, " + + "it cannot track the file loading progress.", + ReplaceWith("Nothing")) + override fun getProgress(): Float = 1f + + @Deprecated("AssetStorage does not maintain an assets queue.", ReplaceWith("Nothing")) + override fun getQueuedAssets(): Int = 0.also { + logger.error("Not supported AssetManagerWrapper.getQueuedAssets called by AssetLoader.") + } + + @Deprecated("Unsupported operation.", ReplaceWith("Nothing")) + override fun finishLoading() { + logger.error("Not supported AssetManagerWrapper.finishLoading called by AssetLoader.") + } + + @Suppress("UNCHECKED_CAST") + override fun finishLoadingAsset(assetDesc: AssetDescriptor<*>): T = + get(assetDesc as AssetDescriptor) + + @Deprecated("Unsupported without asset type.", + ReplaceWith("finishLoadingAsset(assetDescriptor)")) + override fun finishLoadingAsset(fileName: String): T = + throw UnsupportedMethodException("finishLoadingAsset(String)") + + override fun toString(): String = "AssetManagerWrapper(storage=$assetStorage)" + override fun hashCode(): Int = assetStorage.hashCode() + override fun equals(other: Any?): Boolean = + other is AssetManagerWrapper && other.assetStorage === assetStorage +} diff --git a/assets-async/src/test/kotlin/ktx/assets/async/loadersTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/loadersTest.kt new file mode 100644 index 00000000..8d2f4d5e --- /dev/null +++ b/assets-async/src/test/kotlin/ktx/assets/async/loadersTest.kt @@ -0,0 +1,266 @@ +package ktx.assets.async + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.AssetLoader +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.files.FileHandle +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import io.kotlintest.matchers.shouldThrow +import org.junit.Assert.* +import org.junit.Test +import java.io.File +import com.badlogic.gdx.utils.Array as GdxArray + +/** + * Tests [AssetLoaderStorage] - manager of [AssetLoader] instances used by an [AssetStorage] to load assets. + */ +class AssetLoaderStorageTest { + @Test + fun `should return null when requested to return loader for unknown type`() { + // Given: + val storage = AssetLoaderStorage() + + // When: + val loader = storage.getLoader(String::class.java) + + // Then: + assertNull(loader) + } + + @Test + fun `should return null when requested to return loader with suffix for unknown type`() { + // Given: + val storage = AssetLoaderStorage() + + // When: + val loader = storage.getLoader(String::class.java, ".txt") + + // Then: + assertNull(loader) + } + + @Test + fun `should return loader for known type`() { + // Given: + val storage = AssetLoaderStorage() + val stringLoader = mockStringLoader() + storage.setLoaderProvider(String::class.java) { stringLoader } + + // When: + val loader = storage.getLoader(String::class.java) + + // Then: + assertSame(stringLoader, loader) + } + + @Test + fun `should return null when requested main loader for known type without appropriate suffix`() { + // Given: + val storage = AssetLoaderStorage() + storage.setLoaderProvider(String::class.java, suffix = ".txt") { mockStringLoader() } + + // When: + val loader = storage.getLoader(String::class.java) + + // Then: + assertNull(loader) + } + + @Test + fun `should return null when requested loader for known type with invalid suffix`() { + // Given: + val storage = AssetLoaderStorage() + storage.setLoaderProvider(String::class.java, suffix = ".txt") { mockStringLoader() } + + // When: + val loader = storage.getLoader(String::class.java, path = "invalid.md") + + // Then: + assertNull(loader) + } + + @Test + fun `should return main loader if suffix does not match specific loader`() { + // Given: + val storage = AssetLoaderStorage() + val mainLoader = mockStringLoader() + val suffixLoader = mockStringLoader() + storage.setLoaderProvider(String::class.java) { mainLoader } + storage.setLoaderProvider(String::class.java, suffix = ".md") { suffixLoader } + + // When: + val loader = storage.getLoader(String::class.java, path = "test.txt") + + // Then: + assertSame(mainLoader, loader) + } + + @Test + fun `should return loader for known type with valid suffix`() { + // Given: + val storage = AssetLoaderStorage() + val stringLoader = mockStringLoader() + storage.setLoaderProvider(String::class.java, suffix = ".txt") { stringLoader } + + // When: + val loader = storage.getLoader(String::class.java, path = "test.txt") + + // Then: + assertSame(stringLoader, loader) + } + + @Test + fun `should override main loader`() { + // Given: + val storage = AssetLoaderStorage() + val previousLoader = mockStringLoader() + val stringLoader = mockStringLoader() + storage.setLoaderProvider(String::class.java) { previousLoader } + storage.setLoaderProvider(String::class.java) { stringLoader } + + // When: + val loader = storage.getLoader(String::class.java) + + // Then: + assertNotSame(previousLoader, loader) + assertSame(stringLoader, loader) + } + + @Test + fun `should override loader with suffix`() { + // Given: + val storage = AssetLoaderStorage() + val previousLoader = mockStringLoader() + val stringLoader = mockStringLoader() + storage.setLoaderProvider(String::class.java) { previousLoader } + storage.setLoaderProvider(String::class.java) { stringLoader } + + // When: + val loader = storage.getLoader(String::class.java) + + // Then: + assertNotSame(previousLoader, loader) + assertSame(stringLoader, loader) + } + + @Test + fun `should not cache loaders and invoke loader provider for each request`() { + // Given: + val storage = AssetLoaderStorage() + storage.setLoaderProvider(String::class.java) { mockStringLoader() } + + // When: + val firstLoader = storage.getLoader(String::class.java) + val secondLoader = storage.getLoader(String::class.java) + + // Then: + assertNotSame(firstLoader, secondLoader) + } + + @Test + fun `should reject loader that does not extend SynchronousAssetLoader or AsynchronousAssetLoader`() { + // Given: + val storage = AssetLoaderStorage() + val invalidLoader = mock>>() + + // Expect: + shouldThrow { + storage.setLoaderProvider(String::class.java) { invalidLoader } + } + } + + private fun mockStringLoader() = mock>>() +} + +/** + * Tests [AssetLoader] extensions. + */ +class LoadersTest { + @Test + fun `should get dependencies`() { + // Given: + val dependencies = GdxArray>() + val file = FileHandle(File("test")) + val assetDescriptor = AssetDescriptor("test", String::class.java) + assetDescriptor.file = file + val loader = mock> { + on(it.getDependencies("test", file, null)) doReturn dependencies + } + + // When: + val result = loader.getDependencies(assetDescriptor) + + // Then: + assertSame(dependencies, result) + verify(loader).getDependencies("test", file, null) + } + + @Test + fun `should load synchronously`() { + // Given: + val assetManager = mock() + val file = FileHandle(File("test")) + val assetDescriptor = AssetDescriptor("test", String::class.java) + assetDescriptor.file = file + val loader = mock> { + on(it.load(assetManager, "test", file, null)) doReturn "Asset." + } + + // When: + val asset = loader.load(assetManager, assetDescriptor) + + // Then: + assertSame("Asset.", asset) + verify(loader).load(assetManager, "test", file, null) + } + + @Test + fun `should load asynchronously`() { + // Given: + val assetManager = mock() + val file = FileHandle(File("test")) + val assetDescriptor = AssetDescriptor("test", String::class.java) + assetDescriptor.file = file + val loader = mock> { + on(it.loadSync(assetManager, "test", file, null)) doReturn "Asset." + } + + // When: + loader.loadAsync(assetManager, assetDescriptor) + val asset = loader.loadSync(assetManager, assetDescriptor) + + // Then: + assertSame("Asset.", asset) + verify(loader).loadAsync(assetManager, "test", file, null) + verify(loader).loadSync(assetManager, "test", file, null) + } +} + +/** + * Tests [ManualLoader] implementation. + */ +class ManualLoaderTest { + @Test + fun `should return empty dependencies array`() { + // When: + val dependencies = ManualLoader.getDependencies("file.path", mock(), null) + + // Then: + assertEquals(GdxArray>(0), dependencies) + } + + @Test + fun `should return empty dependencies array with loading parameters`() { + // When: + val dependencies = ManualLoader.getDependencies( + "file.path", mock(), ManualLoadingParameters() + ) + + // Then: + assertEquals(GdxArray>(0), dependencies) + } +} diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt new file mode 100644 index 00000000..d5929975 --- /dev/null +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt @@ -0,0 +1,2864 @@ +package ktx.assets.async + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.assets.loaders.resolvers.ClasspathFileHandleResolver +import com.badlogic.gdx.audio.Music +import com.badlogic.gdx.audio.Sound +import com.badlogic.gdx.backends.lwjgl.LwjglNativesLoader +import com.badlogic.gdx.backends.lwjgl.audio.OpenALAudio +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.graphics.Cubemap +import com.badlogic.gdx.graphics.Pixmap +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import com.badlogic.gdx.graphics.g2d.ParticleEffect +import com.badlogic.gdx.graphics.g2d.TextureAtlas +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.glutils.ShaderProgram +import com.badlogic.gdx.math.Vector2 +import com.badlogic.gdx.scenes.scene2d.ui.Button +import com.badlogic.gdx.scenes.scene2d.ui.Skin +import com.badlogic.gdx.utils.Disposable +import com.badlogic.gdx.utils.GdxRuntimeException +import com.badlogic.gdx.utils.I18NBundle +import com.badlogic.gdx.utils.Logger +import com.google.common.collect.Sets +import com.nhaarman.mockitokotlin2.* +import io.kotlintest.matchers.shouldThrow +import kotlinx.coroutines.* +import ktx.assets.TextAssetLoader.TextAssetLoaderParameters +import ktx.async.* +import org.junit.* +import org.junit.Assert.* +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ThreadLocalRandom +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.max +import com.badlogic.gdx.graphics.g3d.particles.ParticleEffect as ParticleEffect3D +import com.badlogic.gdx.utils.Array as GdxArray + +/** + * Tests [AssetStorage]: coroutines-based asset manager. + * + * Implementation note: the tests use [runBlocking] to launch the coroutines for simplicity + * of asserts. Normally [KtxAsync].launch is highly encouraged to build truly asynchronous applications. + * Using [runBlocking] on the main rendering thread might lead to deadlocks, as the rendering thread + * is necessary to load some assets (e.g. textures). + * + * In a similar manner, some [AssetDescriptor] instances are created manually. In an actual application, + * using [AssetStorage.getAssetDescriptor] is a much easier way of obtaining [AssetDescriptor] instances. + */ +class AssetStorageTest : AsyncTest() { + companion object { + @JvmStatic + @BeforeClass + fun `load LibGDX statics`() { + // Necessary for LibGDX asset loaders to work. + LwjglNativesLoader.load() + Gdx.graphics = mock() + Gdx.gl20 = mock() + Gdx.gl = Gdx.gl20 + } + + @JvmStatic + @AfterClass + fun `dispose of LibGDX statics`() { + Gdx.graphics = null + Gdx.audio = null + Gdx.gl20 = null + Gdx.gl = null + } + } + + @Before + override fun `setup LibGDX application`() { + super.`setup LibGDX application`() + Gdx.audio = OpenALAudio() + } + + @After + override fun `exit LibGDX application`() { + super.`exit LibGDX application`() + (Gdx.audio as OpenALAudio).dispose() + } + + /** + * Testing utility. Obtains instance of [T] by blocking the thread until the + * [Deferred] is completed. Rethrows any exceptions caught by [Deferred]. + */ + private fun Deferred.joinAndGet(): T = runBlocking { await() } + + @Test + fun `should load text assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + val asset = runBlocking { storage.load(path) } + + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + } + + @Test + fun `should load text assets with parameters`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + val asset = runBlocking { + storage.load(path, parameters = TextAssetLoaderParameters("UTF-8")) + } + + // Then: + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + } + + @Test + fun `should load text assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val descriptor = AssetDescriptor(path, String::class.java, TextAssetLoaderParameters("UTF-8")) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + } + + @Test + fun `should load text assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + } + + @Test + fun `should load text assets with identifier and parameters`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { + storage.load(identifier, TextAssetLoaderParameters("UTF-8")) + } + + // Then: + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + } + + @Test + fun `should unload text assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load BitmapFont assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Font dependencies: + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertSame(asset.region.texture, storage.get(dependency).joinAndGet()) + + storage.dispose() + } + + @Test + fun `should load BitmapFont assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + val descriptor = AssetDescriptor(path, BitmapFont::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Font dependencies: + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertSame(asset.region.texture, storage.get(dependency).joinAndGet()) + + storage.dispose() + } + + @Test + fun `should load BitmapFont assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Font dependencies: + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertSame(asset.region.texture, storage.get(dependency).joinAndGet()) + + storage.dispose() + } + + @Test + fun `should unload BitmapFont with dependencies`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Music assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load Music assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + val descriptor = AssetDescriptor(path, Music::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + + @Test + fun `should load Music assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Music assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + + @Test + fun `should load Sound assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load Sound assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + val descriptor = AssetDescriptor(path, Sound::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load Sound assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Sound assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load TextureAtlas assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.atlas" + val dependency = "ktx/assets/async/texture.png" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Atlas dependencies: + assertTrue(storage.isLoaded(dependency)) + assertSame(asset.textures.first(), storage.get(dependency).joinAndGet()) + assertEquals(1, storage.getReferenceCount(dependency)) + + storage.dispose() + } + + @Test + fun `should load TextureAtlas assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.atlas" + val dependency = "ktx/assets/async/texture.png" + val descriptor = AssetDescriptor(path, TextureAtlas::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Atlas dependencies: + assertTrue(storage.isLoaded(dependency)) + assertSame(asset.textures.first(), storage.get(dependency).joinAndGet()) + assertEquals(1, storage.getReferenceCount(dependency)) + + storage.dispose() + } + + @Test + fun `should load TextureAtlas assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.atlas" + val dependency = "ktx/assets/async/texture.png" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Atlas dependencies: + assertTrue(storage.isLoaded(dependency)) + assertSame(asset.textures.first(), storage.get(dependency).joinAndGet()) + assertEquals(1, storage.getReferenceCount(dependency)) + + storage.dispose() + } + + @Test + fun `should unload TextureAtlas assets with dependencies`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.atlas" + val dependency = "ktx/assets/async/texture.png" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + assertEquals(0, storage.getReferenceCount(dependency)) + } + + @Test + fun `should load Texture assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load Texture assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val descriptor = AssetDescriptor(path, Texture::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load Texture assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Texture assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + + storage.dispose() + } + + @Test + fun `should load Pixmap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load Pixmap assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val descriptor = AssetDescriptor(path, Pixmap::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load Pixmap assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Pixmap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + + storage.dispose() + } + + @Test + fun `should load Skin assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val atlas = "ktx/assets/async/skin.atlas" + val texture = "ktx/assets/async/texture.png" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) + // Skin dependencies: + assertTrue(storage.isLoaded(atlas)) + assertEquals(1, storage.getReferenceCount(atlas)) + assertSame(asset.atlas, storage.get(atlas).joinAndGet()) + assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) + // Atlas dependencies: + assertTrue(storage.isLoaded(texture)) + assertSame(asset.atlas.textures.first(), storage.get(texture).joinAndGet()) + assertEquals(1, storage.getReferenceCount(texture)) + + storage.dispose() + } + + @Test + fun `should load Skin assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val atlas = "ktx/assets/async/skin.atlas" + val texture = "ktx/assets/async/texture.png" + val descriptor = AssetDescriptor(path, Skin::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) + // Skin dependencies: + assertTrue(storage.isLoaded(atlas)) + assertEquals(1, storage.getReferenceCount(atlas)) + assertSame(asset.atlas, storage.get(atlas).joinAndGet()) + assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) + // Atlas dependencies: + assertTrue(storage.isLoaded(texture)) + assertSame(asset.atlas.textures.first(), storage.get(texture).joinAndGet()) + assertEquals(1, storage.getReferenceCount(texture)) + + storage.dispose() + } + + @Test + fun `should load Skin assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val atlas = "ktx/assets/async/skin.atlas" + val texture = "ktx/assets/async/texture.png" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) + // Skin dependencies: + assertTrue(storage.isLoaded(atlas)) + assertEquals(1, storage.getReferenceCount(atlas)) + assertSame(asset.atlas, storage.get(atlas).joinAndGet()) + assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) + // Atlas dependencies: + assertTrue(storage.isLoaded(texture)) + assertSame(asset.atlas.textures.first(), storage.get(texture).joinAndGet()) + assertEquals(1, storage.getReferenceCount(texture)) + + storage.dispose() + } + + @Test + fun `should unload Skin assets with dependencies`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val atlas = "ktx/assets/async/skin.atlas" + val texture = "ktx/assets/async/texture.png" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(atlas)) + assertEquals(0, storage.getReferenceCount(atlas)) + assertFalse(storage.isLoaded(texture)) + assertEquals(0, storage.getReferenceCount(texture)) + } + + @Test + fun `should load I18NBundle assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/i18n" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertEquals("Value.", asset["key"]) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load I18NBundle assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/i18n" + val descriptor = AssetDescriptor(path, I18NBundle::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertEquals("Value.", asset["key"]) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load I18NBundle assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/i18n" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertEquals("Value.", asset["key"]) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload I18NBundle assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/i18n" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load ParticleEffect assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p2d" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load ParticleEffect assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p2d" + val descriptor = AssetDescriptor(path, ParticleEffect::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load ParticleEffect assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p2d" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload ParticleEffect assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p2d" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load ParticleEffect3D assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p3d" + val dependency = "ktx/assets/async/texture.png" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Particle dependencies: + assertTrue(storage.isLoaded(dependency)) + assertNotNull(storage.get(dependency).joinAndGet()) + assertEquals(1, storage.getReferenceCount(dependency)) + + storage.dispose() + } + + @Test + fun `should load ParticleEffect3D assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p3d" + val descriptor = AssetDescriptor(path, ParticleEffect3D::class.java) + val dependency = "ktx/assets/async/texture.png" + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Particle dependencies: + assertTrue(storage.isLoaded(dependency)) + assertNotNull(storage.get(dependency).joinAndGet()) + assertEquals(1, storage.getReferenceCount(dependency)) + + storage.dispose() + } + + @Test + fun `should load ParticleEffect3D assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p3d" + val dependency = "ktx/assets/async/texture.png" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Particle dependencies: + assertTrue(storage.isLoaded(dependency)) + assertNotNull(storage.get(dependency).joinAndGet()) + assertEquals(1, storage.getReferenceCount(dependency)) + + storage.dispose() + } + + @Test + fun `should unload ParticleEffect3D assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p3d" + val dependency = "ktx/assets/async/texture.png" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + assertEquals(0, storage.getReferenceCount(dependency)) + } + + @Test + fun `should load OBJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.obj" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load OBJ Model assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.obj" + val descriptor = AssetDescriptor(path, Model::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load OBJ Model assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.obj" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload OBJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.obj" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load G3DJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3dj" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load G3DJ Model assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3dj" + val descriptor = AssetDescriptor(path, Model::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load G3DJ Model assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3dj" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload G3DJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3dj" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load G3DB Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3db" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load G3DB Model assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3db" + val descriptor = AssetDescriptor(path, Model::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load G3DB Model assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3db" + val descriptor = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload G3DB Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3db" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load ShaderProgram assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/shader.frag" + // Silencing logs - shader will fail to compile, as GL is mocked: + storage.logger.level = Logger.NONE + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load ShaderProgram assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/shader.vert" + val descriptor = AssetDescriptor(path, ShaderProgram::class.java) + // Silencing logs - shader will fail to compile, as GL is mocked: + storage.logger.level = Logger.NONE + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load ShaderProgram assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/shader.vert" + val identifier = storage.getIdentifier(path) + // Silencing logs - shader will fail to compile, as GL is mocked: + storage.logger.level = Logger.NONE + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload ShaderProgram assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/shader.frag" + // Silencing logs - shader will fail to compile, as GL is mocked: + storage.logger.level = Logger.NONE + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Cubemap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/cubemap.zktx" + + // When: + val asset = runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load Cubemap assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/cubemap.zktx" + val descriptor = AssetDescriptor(path, Cubemap::class.java) + + // When: + val asset = runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should load Cubemap assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/cubemap.zktx" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Cubemap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/cubemap.zktx" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should return deferred that throws exception when attempting to get unloaded asset`() { + // Given: + val storage = AssetStorage() + + // When: + val result = storage.get("ktx/assets/async/string.txt") + + // Expect: + shouldThrow { + runBlocking { result.await() } + } + } + + @Test + fun `should obtain loaded asset with path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.contains(path)) + assertTrue(storage.isLoaded(path)) + assertEquals("Content.", storage.get(path).joinAndGet()) + assertEquals(emptyList(), storage.getDependencies(path)) + } + + @Test + fun `should obtain loaded asset with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val identifier = storage.getIdentifier("ktx/assets/async/string.txt") + + // When: + runBlocking { storage.load(identifier) } + + // Then: + assertTrue(identifier in storage) + assertTrue(storage.isLoaded(identifier)) + assertEquals("Content.", storage[identifier].joinAndGet()) + assertEquals(emptyList(), storage.getDependencies(identifier)) + } + + @Test + fun `should obtain loaded asset with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // When: + runBlocking { storage.load(descriptor) } + + // Then: + assertTrue(descriptor in storage) + assertTrue(storage.isLoaded(descriptor)) + assertEquals("Content.", storage[descriptor].joinAndGet()) + assertEquals(emptyList(), storage.getDependencies(descriptor)) + } + + @Test + fun `should differentiate assets by path and type`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + + // When: + runBlocking { storage.add("path", "ASSET") } + + // Then: + assertTrue(storage.contains("path")) + assertFalse(storage.contains("different path")) + assertFalse(storage.contains("path")) // Different type. + } + + @Test + fun `should point to the same asset when loading with path, descriptor and identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val descriptor = storage.getAssetDescriptor(path) + val identifier = storage.getIdentifier(path) + + // When: + val viaPath = runBlocking { storage.load(path) } + val viaDescriptor = runBlocking { storage.load(descriptor) } + val viaIdentifier = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(viaPath, viaDescriptor) + assertSame(viaDescriptor, viaIdentifier) + assertEquals(3, storage.getReferenceCount(path)) + } + + @Test + fun `should manually add asset to storage`() { + // Given: + val storage = AssetStorage() + val asset = Vector2(20f, 10f) + val fakePath = "myVector" + + // When: + runBlocking { storage.add(fakePath, asset) } + + // Then: + assertTrue(storage.isLoaded(fakePath)) + assertSame(asset, storage.get(fakePath).joinAndGet()) + assertEquals(1, storage.getReferenceCount(fakePath)) + } + + @Test + fun `should manually add asset to storage with descriptor`() { + // Given: + val storage = AssetStorage() + val asset = Vector2(20f, 10f) + val fakePath = "myVector" + val descriptor = storage.getAssetDescriptor(fakePath) + + // When: + runBlocking { storage.add(descriptor, asset) } + + // Then: + assertTrue(storage.isLoaded(descriptor)) + assertSame(asset, storage[descriptor].joinAndGet()) + assertEquals(1, storage.getReferenceCount(descriptor)) + } + + @Test + fun `should manually add asset to storage with identifier`() { + // Given: + val storage = AssetStorage() + val asset = Vector2(20f, 10f) + val fakePath = "myVector" + val identifier = storage.getIdentifier(fakePath) + + // When: + runBlocking { storage.add(identifier, asset) } + + // Then: + assertTrue(storage.isLoaded(identifier)) + assertSame(asset, storage[identifier].joinAndGet()) + assertEquals(1, storage.getReferenceCount(identifier)) + } + + @Test + fun `should add asset after it was unloaded`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val asset = mock() + runBlocking { storage.load(path) } + runBlocking { storage.unload(path) } + + // When: + runBlocking { storage.add(path, asset) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + } + + @Test + fun `should allow to add asset with different type than loaded`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val asset = mock() + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.add(path, asset) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path).joinAndGet()) + + storage.dispose() + } + + @Test + fun `should allow to load multiple assets with different type and same path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + runBlocking { + storage.load(path) + storage.load(path) + } + + // Then: + assertTrue(storage.isLoaded(path)) + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertNotSame(storage.get(path).joinAndGet(), storage.get(path).joinAndGet()) + + storage.dispose() + } + + @Test + fun `should support loading assets in parallel`() { + // Given: + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = newAsyncContext(2) + ) + val firstPath = "ktx/assets/async/texture.png" + val secondPath = "ktx/assets/async/model.obj" + val scheduler = newAsyncContext(2) + val tasksReference = CompletableFuture>>() + + // When: + KtxAsync.launch { + val first = async(scheduler) { storage.load(firstPath) } + val second = async(scheduler) { storage.load(secondPath) } + tasksReference.complete(listOf(first, second)) + } + + // Then: + val tasks = tasksReference.join() + runBlocking { tasks.joinAll() } + assertTrue(storage.isLoaded(firstPath)) + assertTrue(storage.isLoaded(secondPath)) + assertSame(tasks[0].joinAndGet(), storage.get(firstPath).joinAndGet()) + assertSame(tasks[1].joinAndGet(), storage.get(secondPath).joinAndGet()) + + storage.dispose() + } + + @Test + fun `should fail to add asset that was already loaded`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + runBlocking { storage.load(path) } + + // When: + shouldThrow { + runBlocking { + storage.add(path, "ASSET") + } + } + + // Then: + assertEquals(1, storage.getReferenceCount(path)) + } + + @Test + fun `should fail to add asset that was already added`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + runBlocking { storage.add(path, "ASSET") } + + // When: + shouldThrow { + runBlocking { + // Even though the asset is the same, `add` should work only once. + // This is because finding all dependencies of an asset is tricky + // if it is both loaded and added. To keep the dependencies reference + // counts in check, we're only allowing to add an asset once. + storage.add(path, "ASSET") + } + } + + // Then: + assertEquals(1, storage.getReferenceCount(path)) + } + + @Test + fun `should unload and dispose assets manually added to storage`() { + // Given: + val storage = AssetStorage() + val asset = FakeAsset() + val fakePath = "disposable" + runBlocking { storage.add(fakePath, asset) } + + // When: + val unloaded = runBlocking { storage.unload(fakePath) } + + // Then: + assertTrue(unloaded) + assertFalse(storage.isLoaded(fakePath)) + assertEquals(0, storage.getReferenceCount(fakePath)) + assertTrue(asset.isDisposed) + } + + @Test + fun `should increase references count and return the same asset when trying to load asset with same path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val elements = IdentityHashMap() + + // When: + runBlocking { + repeat(3) { + val asset = storage.load(path) + elements[asset] = true + } + } + + // Then: + assertEquals(3, storage.getReferenceCount(path)) + assertEquals(1, elements.size) + } + + @Test + fun `should fail to load asset with missing loader`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + shouldThrow { + runBlocking { storage.load(path) } + } + + // Then: + assertFalse(storage.contains(path)) + } + + @Test + fun `should register default loaders`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = true) + + // Expect: + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader(path = ".obj")) + assertNotNull(storage.getLoader(path = ".g3dj")) + assertNotNull(storage.getLoader(path = ".g3db")) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + } + + @Test + fun `should not register default loaders`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + + // Expect: + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader(path = ".obj")) + assertNull(storage.getLoader(path = ".g3dj")) + assertNull(storage.getLoader(path = ".g3db")) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + } + + + @Test + fun `should increase references counts of dependencies when loading asset with same path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val dependencies = arrayOf( + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png") + ) + val loadedAssets = IdentityHashMap() + + // When: + runBlocking { + repeat(3) { + val asset = storage.load(path) + loadedAssets[asset] = true + } + } + + // Then: + assertEquals(3, storage.getReferenceCount(path)) + dependencies.forEach { + assertEquals(3, storage.getReferenceCount(it)) + } + assertEquals(1, loadedAssets.size) + } + + @Test + fun `should increase references counts of dependencies when loading asset with same descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val descriptor = storage.getAssetDescriptor("ktx/assets/async/skin.json") + val dependencies = arrayOf( + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png") + ) + val loadedAssets = IdentityHashMap() + + // When: + runBlocking { + repeat(3) { + val asset = storage.load(descriptor) + loadedAssets[asset] = true + } + } + + // Then: + assertEquals(3, storage.getReferenceCount(descriptor)) + dependencies.forEach { + assertEquals(3, storage.getReferenceCount(it)) + } + assertEquals(1, loadedAssets.size) + + storage.dispose() + } + + @Test + fun `should increase references counts of dependencies when loading asset with same identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val identifier = storage.getIdentifier("ktx/assets/async/skin.json") + val dependencies = arrayOf( + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png") + ) + val loadedAssets = IdentityHashMap() + + // When: + runBlocking { + repeat(3) { + val asset = storage.load(identifier) + loadedAssets[asset] = true + } + } + + // Then: + assertEquals(3, storage.getReferenceCount(identifier)) + dependencies.forEach { + assertEquals(3, storage.getReferenceCount(it)) + } + assertEquals(1, loadedAssets.size) + + storage.dispose() + } + + @Test + fun `should not unload dependencies that still referenced by other assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val dependency = "ktx/assets/async/skin.atlas" + val nestedDependency = "ktx/assets/async/texture.png" + runBlocking { + storage.load(path) + storage.load(dependency) + } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(nestedDependency)) + assertTrue(storage.isLoaded(nestedDependency)) + + storage.dispose() + } + + @Test + fun `should support concurrent loading`() { + // Given: + val schedulers = newAsyncContext(threads = 16) + val loaders = newAsyncContext(threads = 4) + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = loaders + ) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + + // When: + val assets = (1..100).map { + val result = CompletableDeferred() + KtxAsync.launch(schedulers) { + val asset = storage.load(path) + result.complete(asset) + } + result + } + + // Then: + runBlocking { assets.joinAll() } + assertTrue(storage.isLoaded(path)) + assertEquals(100, storage.getReferenceCount(path)) + assertTrue(storage.isLoaded(dependency)) + assertEquals(100, storage.getReferenceCount(dependency)) + assertEquals(1, assets.map { it.joinAndGet() }.toSet().size) + + storage.dispose() + } + + /** + * Allows to test concurrent loading and unloading. + * + * During concurrent unloading, some assets are expected to be requested for unloading + * before they are fully loaded, resulting in [UnloadedAssetException] caught by the + * coroutines waiting for or loading the asset. This method allows to ignore these exceptions. + */ + private suspend inline fun loadIgnoringUnloadException(storage: AssetStorage, path: String) { + try { + storage.load(path) + } catch (exception: UnloadedAssetException) { + // Expected. + } + } + + @Test + fun `should support concurrent loading and unloading`() { + // Given: + val schedulers = newAsyncContext(threads = 16) + val loaders = newAsyncContext(threads = 4) + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = loaders + ) + val path = "ktx/assets/async/string.txt" + + // When: spawning 100 coroutines that load and unload the asset, 1 of which loads it 2 times: + val assets = (1..100).map { id -> + val result = CompletableDeferred() + KtxAsync.launch(schedulers) { + loadIgnoringUnloadException(storage, path) + storage.unload(path) + if (id == 99) { + // Loading 1 additional asset: + loadIgnoringUnloadException(storage, path) + } + result.complete(true) + } + result + } + + // Then: + runBlocking { assets.joinAll() } + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals("Content.", storage.get(path).joinAndGet()) + + storage.dispose() + } + + @Test + fun `should support concurrent loading and unloading with dependencies`() { + // Given: + val schedulers = newAsyncContext(threads = 16) + val loaders = newAsyncContext(threads = 4) + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = loaders + ) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + + // When: spawning 100 coroutines that load and unload the asset, 1 of which loads it 2 times: + val assets = (1..100).map { id -> + val result = CompletableDeferred() + KtxAsync.launch(schedulers) { + loadIgnoringUnloadException(storage, path) + storage.unload(path) + if (id == 1) { + // Loading 1 additional asset: + loadIgnoringUnloadException(storage, path) + } + result.complete(true) + } + result + } + + // Then: + runBlocking { assets.joinAll() } + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertSame( + storage.get(path).joinAndGet().region.texture, + storage.get(dependency).joinAndGet() + ) + + storage.dispose() + } + + @Test + fun `should support concurrent loading and unloading with deeply nested dependencies`() { + // Given: + val schedulers = newAsyncContext(threads = 16) + val loaders = newAsyncContext(threads = 4) + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = loaders + ) + val path = "ktx/assets/async/skin.json" + val dependency = "ktx/assets/async/skin.atlas" + val nestedDependency = "ktx/assets/async/texture.png" + + // When: spawning 100 coroutines that load and unload the asset, 1 of which loads it 2 times: + val assets = (1..100).map { id -> + val result = CompletableDeferred() + KtxAsync.launch(schedulers) { + loadIgnoringUnloadException(storage, path) + storage.unload(path) + if (id == 1) { + loadIgnoringUnloadException(storage, path) + } + result.complete(true) + } + result + } + + // Then: + runBlocking { assets.joinAll() } + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertTrue(storage.isLoaded(nestedDependency)) + assertEquals(1, storage.getReferenceCount(nestedDependency)) + val skin = storage.get(path).joinAndGet() + val atlas = storage.get(dependency).joinAndGet() + val texture = storage.get(nestedDependency).joinAndGet() + assertSame(skin.atlas, atlas) + assertSame(atlas.textures.first(), texture) + + storage.dispose() + } + + @Test + fun `should handle stress test`() { + // Given: + val schedulers = newAsyncContext(threads = 16) + val loaders = newAsyncContext(threads = 4) + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = loaders + ) + val path = "ktx/assets/async/skin.json" + val dependency = "ktx/assets/async/skin.atlas" + val nestedDependency = "ktx/assets/async/texture.png" + val loads = AtomicInteger() + val unloads = AtomicInteger() + + // When: spawning 1000 coroutines that randomly load or unload the asset: + val assets = (1..1000).map { + val result = CompletableDeferred() + KtxAsync.launch(schedulers) { + repeat(ThreadLocalRandom.current().nextInt(50)) { skipFrame() } + if (ThreadLocalRandom.current().nextFloat() < 0.4f) { + // Some unloads are expected to miss and loads are longer, + // so there's a lower probability for load. + loads.incrementAndGet() + loadIgnoringUnloadException(storage, path) + } else { + val unloaded = storage.unload(path) + if (unloaded) unloads.incrementAndGet() + } + result.complete(true) + } + result + } + + // Then: + runBlocking { assets.joinAll() } + val expectedReferences = max(0, loads.get() - unloads.get()) + assertEquals(expectedReferences, storage.getReferenceCount(path)) + assertEquals(expectedReferences, storage.getReferenceCount(dependency)) + assertEquals(expectedReferences, storage.getReferenceCount(nestedDependency)) + + storage.dispose() + } + + + @Test + fun `should register asset loader`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val loader = mock>() + + // When: + storage.setLoader { loader } + + // Then: + assertSame(loader, storage.getLoader()) + + // Implementation note: normally you create a new instance in the setLoader lambda, + // this is just for verification. + } + + @Test + fun `should register asset loader with suffix`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val loader = mock>() + + // When: + storage.setLoader(suffix = ".txt") { loader } + + // Then: + assertSame(loader, storage.getLoader("file.txt")) + assertNull(storage.getLoader("file.md")) + } + + @Test + fun `should return null if asset loader is not available`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + + // Expect: + assertNull(storage.getLoader()) + } + + @Test + fun `should reject invalid asset loader implementations`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + // Does not extend Synchronous/AsynchronousAssetLoader: + val invalidLoader = mock>() + + // Expect: + shouldThrow { + storage.setLoader { invalidLoader } + } + } + + @Test + fun `should dispose of all assets`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val paths = (1..5).map { storage.getIdentifier(it.toString()) } + val assets = paths.map { it to FakeAsset() } + .onEach { (identifier, asset) -> runBlocking { storage.add(identifier, asset) } } + .map { (_, asset) -> asset } + + // When: + storage.dispose() + + // Then: + assertTrue(assets.all { it.isDisposed }) + assertTrue(paths.all { it !in storage }) + } + + @Test + fun `should dispose of multiple assets of different types without errors`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + storage.logger.level = Logger.NONE + val assets = listOf( + storage.getIdentifier("ktx/assets/async/string.txt"), + storage.getIdentifier("com/badlogic/gdx/utils/arial-15.fnt"), + storage.getIdentifier("ktx/assets/async/sound.ogg"), + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png"), + storage.getIdentifier("ktx/assets/async/skin.json"), + storage.getIdentifier("ktx/assets/async/i18n"), + storage.getIdentifier("ktx/assets/async/particle.p2d"), + storage.getIdentifier("ktx/assets/async/particle.p3d"), + storage.getIdentifier("ktx/assets/async/model.obj"), + storage.getIdentifier("ktx/assets/async/model.g3dj"), + storage.getIdentifier("ktx/assets/async/model.g3db"), + storage.getIdentifier("ktx/assets/async/shader.frag"), + storage.getIdentifier("ktx/assets/async/cubemap.zktx") + ) + runBlocking { + assets.forEach { + storage.load(it) + assertTrue(storage.isLoaded(it)) + } + } + + // When: + storage.dispose() + + // Then: + assets.forEach { + assertFalse(it in storage) + assertFalse(storage.isLoaded(it)) + assertEquals(0, storage.getReferenceCount(it)) + assertEquals(emptyList(), storage.getDependencies(it)) + shouldThrow { + storage[it].joinAndGet() + } + } + } + + @Test + fun `should dispose of all assets with optional error handling`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val paths = (1..5).map { storage.getIdentifier(it.toString()) } + val assets = paths.map { it to FakeAsset() } + .onEach { (identifier, asset) -> runBlocking { storage.add(identifier, asset) } } + .map { (_, asset) -> asset } + + // When: + runBlocking { + storage.dispose { identifier, cause -> + fail("Unexpected exception when unloading $identifier: $cause") + throw cause + } + } + + // Then: + assertTrue(assets.all { it.isDisposed }) + assertTrue(paths.all { it !in storage }) + } + + @Test + fun `should catch errors during disposing with handler`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val validAsset = mock() + val brokenAsset = mock { + on(it.dispose()) doThrow GdxRuntimeException("Expected.") + } + runBlocking { + storage.add("broken", brokenAsset) + storage.add("valid", validAsset) + } + + // When: + runBlocking { + storage.dispose { identifier, error -> + assertEquals(storage.getIdentifier("broken"), identifier) + assertEquals("Expected.", error.message) + assertTrue(error is GdxRuntimeException) + } + } + + // Then: + verify(brokenAsset).dispose() + verify(validAsset).dispose() + } + + @Test + fun `should log errors during disposing`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val logger = mock() + storage.logger = logger + val validAsset = mock() + val exception = GdxRuntimeException("Expected.") + val brokenAsset = mock { + on(it.dispose()) doThrow exception + } + runBlocking { + storage.add("broken", brokenAsset) + storage.add("valid", validAsset) + } + + // When: + storage.dispose() + + // Then: + verify(brokenAsset).dispose() + verify(validAsset).dispose() + verify(logger).error(any(), eq(exception)) + } + + @Test + fun `should create AssetDescriptor`() { + // When: + val storage = AssetStorage(useDefaultLoaders = false) + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // Then: + assertEquals("ktx/assets/async/string.txt", descriptor.fileName) + assertEquals("ktx/assets/async/string.txt", descriptor.file.path()) + assertEquals(String::class.java, descriptor.type) + assertNull(descriptor.params) + } + + @Test + fun `should create AssetDescriptor with loading parameters`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val parameters = mock>() + + // When: + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt", parameters) + + // Then: + assertEquals("ktx/assets/async/string.txt", descriptor.fileName) + assertEquals("ktx/assets/async/string.txt", descriptor.file.path()) + assertEquals(String::class.java, descriptor.type) + assertSame(parameters, descriptor.params) + } + + @Test + fun `should normalize file paths`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val paths = mapOf( + "path.txt" to "path.txt", + "dir/path.txt" to "dir/path.txt", + "\\path.txt" to "/path.txt", + "dir\\path.txt" to "dir/path.txt", + "home\\dir\\path.txt" to "home/dir/path.txt", + "\\home\\dir\\dir\\" to "/home/dir/dir/" + ) + + paths.forEach { (original, expected) -> + // When: + val normalized = with(storage) { original.normalizePath() } + + // Then: Should match AssetDescriptor logic. + assertEquals(expected, normalized) + assertEquals(AssetDescriptor(expected, String::class.java).fileName, normalized) + } + } + + @Test + fun `should convert AssetDescriptor to Identifier`() { + // Given: + val assetDescriptor = AssetDescriptor("file.path", String::class.java, TextAssetLoaderParameters("UTF-8")) + + // When: + val identifier = assetDescriptor.toIdentifier() + + // Then: should copy path and class without loading parameters: + assertEquals(assetDescriptor.fileName, identifier.path) + assertEquals(assetDescriptor.type, identifier.type) + } + + /** For [Disposable.dispose] interface testing and loaders testing. */ + class FakeAsset : Disposable { + val disposingFinished = CompletableFuture() + var isDisposed: Boolean = false + override fun dispose() { + isDisposed = true + disposingFinished.complete(true) + } + } + + /** For loaders testing. */ + class FakeAsyncLoader( + private val onAsync: (assetManager: AssetManager) -> Unit, + private val onSync: (asset: FakeAsset) -> Unit, + private val dependencies: GdxArray> = GdxArray.with() + ) : AsynchronousAssetLoader(ClasspathFileHandleResolver()) { + override fun loadAsync( + manager: AssetManager, fileName: String?, file: FileHandle?, parameter: FakeParameters? + ) { + onAsync(manager) + } + + override fun loadSync( + manager: AssetManager, fileName: String?, file: FileHandle?, parameter: FakeParameters? + ): FakeAsset = FakeAsset().also(onSync) + + @Suppress("UNCHECKED_CAST") + override fun getDependencies( + fileName: String?, file: FileHandle?, parameter: FakeParameters? + ): GdxArray> = dependencies as GdxArray> + } + + /** For loaders testing. */ + class FakeSyncLoader( + private val onLoad: (asset: FakeAsset) -> Unit, + private val dependencies: GdxArray> = GdxArray.with() + ) : SynchronousAssetLoader(ClasspathFileHandleResolver()) { + @Suppress("UNCHECKED_CAST") + override fun getDependencies( + fileName: String?, file: FileHandle?, parameter: FakeParameters? + ): GdxArray> = dependencies as GdxArray> + + override fun load( + assetManager: AssetManager, fileName: String?, file: FileHandle?, parameter: FakeParameters? + ): FakeAsset = FakeAsset().also(onLoad) + } + + /** For loaders testing. */ + class FakeParameters : AssetLoaderParameters() + + @Test + fun `should execute asynchronous loading on specified context and synchronous loading on rendering thread`() { + // Given: + val asyncContext = newSingleThreadAsyncContext(threadName = "CustomThreadName") + val asyncThread = getExecutionThread(asyncContext.executor) + val isAsyncThread = CompletableFuture() + val isRenderingThread = CompletableFuture() + val isRenderingThreadDuringAsync = CompletableFuture() + val loader = FakeAsyncLoader( + onSync = { isRenderingThread.complete(KtxAsync.isOnRenderingThread()) }, + onAsync = { + isAsyncThread.complete(asyncThread === Thread.currentThread()) + isRenderingThreadDuringAsync.complete(KtxAsync.isOnRenderingThread()) + } + ) + val storage = AssetStorage(asyncContext = asyncContext, useDefaultLoaders = false) + storage.setLoader { loader } + + // When: + runBlocking { storage.load("fake.path") } + + // Then: + assertTrue(isRenderingThread.getNow(false)) + assertTrue(isAsyncThread.getNow(false)) + assertFalse(isRenderingThreadDuringAsync.getNow(true)) + } + + @Test + fun `should execute synchronous loading on rendering thread`() { + // Given: + val isRenderingThread = CompletableFuture() + val loader = FakeSyncLoader( + onLoad = { isRenderingThread.complete(KtxAsync.isOnRenderingThread()) } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + + // When: + runBlocking { storage.load("fake.path") } + + // Then: + assertTrue(isRenderingThread.getNow(false)) + } + + @Test + fun `should handle loading exceptions`() { + // Given: + val loader = FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + runBlocking { storage.load(path) } + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path).joinAndGet() + } + } + + @Test + fun `should throw exception when asset is unloaded asynchronously`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val loadingStarted = CompletableFuture() + val unloadingFinished = CompletableFuture() + val exceptionCaught = CompletableFuture() + val path = "fake path" + val unloader = newSingleThreadAsyncContext() + lateinit var exception: Throwable + storage.setLoader { + FakeSyncLoader( + onLoad = { + loadingStarted.complete(true) + unloadingFinished.join() + } + ) + } + KtxAsync.launch { + try { + storage.load(path) + } catch (expected: Throwable) { + exception = expected + } + exceptionCaught.complete(true) + } + + // When: + KtxAsync.launch(unloader) { + loadingStarted.join() + storage.unload(path) + unloadingFinished.complete(true) + } + + // Then: + exceptionCaught.join() + assertTrue(exception is UnloadedAssetException) + assertFalse(storage.contains(path)) + shouldThrow { + storage.get(path).joinAndGet() + } + } + + @Test + fun `should fail to load assets with loaders that use unsupported AssetManagerWrapper methods`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + storage.setLoader { + FakeAsyncLoader( + onAsync = { assetManager -> assetManager.get("Trying to access asset without its type.") }, + onSync = {} + ) + } + + // When: + shouldThrow { + runBlocking { + storage.load(path) + } + } + + // Then: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path).joinAndGet() + } + } + + @Test + fun `should fail to load assets when loaders try to access unregistered unloaded dependency`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + storage.setLoader { + FakeAsyncLoader( + onAsync = { assetManager -> assetManager.get("Missing", FakeAsset::class.java) }, + onSync = {} + ) + } + + // When: + shouldThrow { + runBlocking { + storage.load(path) + } + } + + // Then: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path).joinAndGet() + } + } + + @Test + fun `should handle asynchronous loading exceptions`() { + // Given: + val loader = FakeAsyncLoader( + onAsync = { throw IllegalStateException("Expected.") }, + onSync = {} + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + runBlocking { storage.load(path) } + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path).joinAndGet() + } + } + + @Test + fun `should handle synchronous loading exceptions`() { + // Given: + val loader = FakeAsyncLoader( + onAsync = { }, + onSync = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + runBlocking { storage.load(path) } + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path).joinAndGet() + } + } + + @Test + fun `should not fail to unload asset that was loaded exceptionally`() { + // Given: + val loader = FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + storage.setLoader { loader } + storage.logger.level = Logger.NONE // Disposing exception will be logged. + runBlocking { + try { + storage.load(path) + } catch (exception: AssetLoadingException) { + // Expected. + } + } + + // When: + val unloaded = runBlocking { + storage.unload(path) + } + + // Then: + assertTrue(unloaded) + assertFalse(storage.contains(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should fail to load an asset or any of its dependencies with a dependency loader is missing`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + val dependency = "dependency with missing loader" + storage.setLoader { + FakeSyncLoader( + onLoad = {}, + dependencies = GdxArray.with(storage.getAssetDescriptor(dependency)) + ) + } + + // When: + shouldThrow { + runBlocking { storage.load("path") } + } + + // Then: + assertFalse(storage.contains(path)) + assertFalse(storage.contains(dependency)) + } + + @Test + fun `should not fail to unload asset that was loaded exceptionally with dependencies`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "path.sync" + val dependency = "path.async" + val loader = FakeSyncLoader( + onLoad = {}, + dependencies = GdxArray.with(storage.getAssetDescriptor(dependency)) + ) + val dependencyLoader = FakeAsyncLoader( + onAsync = {}, + onSync = { throw IllegalStateException("Expected.") } + ) + storage.setLoader(suffix = ".sync") { loader } + storage.setLoader(suffix = ".async") { dependencyLoader } + storage.logger.level = Logger.NONE // Disposing exception will be logged. + runBlocking { + try { + storage.load(path) + } catch (exception: AssetLoadingException) { + // Expected. Asset fails to load due to dependency. + } + } + assertTrue(storage.contains(path)) + assertTrue(storage.contains(dependency)) + + // When: + val unloaded = runBlocking { + storage.unload(path) + } + + // Then: + assertTrue(unloaded) + assertFalse(storage.contains(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.contains(dependency)) + assertEquals(0, storage.getReferenceCount(dependency)) + } + + @Test + fun `should report as unloaded until the asset has finished loading`() { + // Given: + val loadingStarted = CompletableFuture() + val loading = CompletableFuture() + val loadingFinished = CompletableFuture() + val loader = FakeSyncLoader( + onLoad = { + loadingStarted.join() + loading.complete(true) + loadingFinished.join() + } + ) + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake.path" + val identifier = storage.getIdentifier(path) + storage.setLoader { loader } + + // When: + KtxAsync.launch { storage.load(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + + loadingStarted.complete(true) + loading.join() + assertTrue(identifier in storage) + assertFalse(storage.isLoaded(identifier)) + + loadingFinished.complete(true) + runBlocking { storage.get(path).await() } + assertTrue(identifier in storage) + assertTrue(storage.isLoaded(identifier)) + } + + @Test + fun `should utilize asynchronous threads for asset loading`() { + // Given: + val asyncThreadNameSuffix = "AssetStorageAsyncThread" + val renderingThread = getMainRenderingThread() + val loaders = newAsyncContext(4, threadName = asyncThreadNameSuffix) + val schedulers = newAsyncContext(4) + val asyncLoadingThreads: MutableSet = Sets.newConcurrentHashSet() + val syncLoadingThreads: MutableSet = Sets.newConcurrentHashSet() + val storage = AssetStorage(asyncContext = loaders, useDefaultLoaders = false) + storage.setLoader { + FakeAsyncLoader( + onAsync = { asyncLoadingThreads.add(Thread.currentThread()) }, + onSync = { syncLoadingThreads.add(Thread.currentThread()) } + ) + } + + // When: asynchronously loading multiple assets: + val tasks = (1..100).map { index -> + val finished = CompletableDeferred() + KtxAsync.launch(schedulers) { + storage.load(path = index.toString()) + finished.complete(true) + } + finished + } + + // Then: + runBlocking { tasks.joinAll() } + assertEquals(1, syncLoadingThreads.size) + assertTrue(renderingThread in syncLoadingThreads) + assertEquals(4, asyncLoadingThreads.size) + assertTrue(asyncLoadingThreads.all { asyncThreadNameSuffix in it.name }) + } + + @Test + fun `should dispose of assets that were unloaded before assignment`() { + // Given: + val path = "fake path" + val loadingStarted = CompletableFuture() + val unloadingFinished = CompletableFuture() + val loadingFinished = CompletableFuture() + lateinit var asset: FakeAsset + lateinit var exception: Throwable + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { + FakeSyncLoader( + onLoad = { + asset = it + loadingStarted.complete(true) + unloadingFinished.join() + } + ) + } + + // When: + KtxAsync.launch { + try { + // Scheduling asset for loading - will be unloaded before it is finished: + storage.load(path) + } catch (expected: AssetStorageException) { + // UnloadedAssetException should be caught: + exception = expected + loadingFinished.complete(true) + } + } + // Waiting for the loading to start: + loadingStarted.join() + runBlocking { + // Unloading the asset: + storage.unload(path) + // Loader is waiting for the unloading to finish - unlocking thread: + unloadingFinished.complete(true) + } + + // Then: + asset.disposingFinished.join() + assertTrue(asset.isDisposed) + loadingFinished.join() + shouldThrow { + throw exception + } + } + + @Test + fun `should safely dispose of assets that failed to load`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + storage.logger.level = Logger.NONE + val path = "fake path" + storage.setLoader { + FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + } + runBlocking { + try { + storage.load(path) + } catch (exception: AssetLoadingException) { + // Expected. + } + } + + // When: + storage.dispose() + + // Then: + assertFalse(storage.contains(path)) + } + + @Test + fun `should safely dispose of assets that failed to load with error handling`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + storage.logger.level = Logger.NONE + val path = "fake path" + lateinit var exception: Throwable + lateinit var identifier: Identifier<*> + storage.setLoader { + FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + } + runBlocking { + try { + storage.load(path) + } catch (exception: AssetLoadingException) { + // Expected. + } + } + + // When: + runBlocking { + storage.dispose { id, cause -> + exception = cause + identifier = id + } + } + + // Then: + assertFalse(storage.contains(path)) + assertTrue(exception is AssetLoadingException) + assertEquals(storage.getIdentifier(path), identifier) + } +} diff --git a/assets-async/src/test/resources/ktx/assets/async/cubemap.zktx b/assets-async/src/test/resources/ktx/assets/async/cubemap.zktx new file mode 100644 index 0000000000000000000000000000000000000000..534146fb1010469baaeb4b004e8bce9cff08f997 GIT binary patch literal 28338 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 b^D9qYZNS=MKfGfA_~$2X2sB>0a_Rp7Amv{@ literal 0 HcmV?d00001 diff --git a/assets-async/src/test/resources/ktx/assets/async/i18n.properties b/assets-async/src/test/resources/ktx/assets/async/i18n.properties new file mode 100644 index 00000000..1d64a2e4 --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/i18n.properties @@ -0,0 +1 @@ +key=Value. \ No newline at end of file diff --git a/assets-async/src/test/resources/ktx/assets/async/model.g3db b/assets-async/src/test/resources/ktx/assets/async/model.g3db new file mode 100644 index 0000000000000000000000000000000000000000..47f1e9ab54ba5d66f7caa0a11ac0e0822308c331 GIT binary patch literal 1269 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 M)dL9%Ck`Y33k|b|6aWAK literal 0 HcmV?d00001 diff --git a/assets-async/src/test/resources/ktx/assets/async/string.txt b/assets-async/src/test/resources/ktx/assets/async/string.txt new file mode 100644 index 00000000..fffc22b4 --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/string.txt @@ -0,0 +1 @@ +Content. \ No newline at end of file diff --git a/assets-async/src/test/resources/ktx/assets/async/texture.png b/assets-async/src/test/resources/ktx/assets/async/texture.png new file mode 100644 index 0000000000000000000000000000000000000000..76dcb0797fbbbb13ca3900be5700d8b68b44dfca GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJKu;IPkcwN$KmPx>KkSfT(ZF>< zxW}qt0dt1nbLR_;R~SUB8!qLTt&wpE(~>Veu!7l5*5Fk`AbU@koAYj4gGIAb7N7mZ lSa_h}J1_e=MrAn$hMa&X$0 Date: Sun, 29 Mar 2020 21:07:39 +0200 Subject: [PATCH 03/17] FreeType module restored and updated to the new AssetStorage API. #182 --- CHANGELOG.md | 17 + assets-async/README.md | 5 + .../kotlin/ktx/assets/async/storageTest.kt | 6 +- freetype-async/README.md | 70 +-- freetype-async/build.gradle | 4 +- .../ktx/freetype/async/freetypeAsync.kt | 50 +- .../ktx/freetype/async/freetypeAsyncTest.kt | 428 +++++++++++++----- settings.gradle | 1 + 8 files changed, 402 insertions(+), 179 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f8fa00..e98d9590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,27 @@ - `PerformanceCounter.prettyPrint` allows to print basic performance data after profiling. - **[CHANGE]** (`ktx-app`) `LetterboxingViewport` moved from `ktx-app` to `ktx-graphics`. - **[FEATURE]** (`ktx-ashley`) Added `Entity.contains` (`in` operator) that checks if an `Entity` has a `Component`. +- **[FEATURE]** (`ktx-assets-async`) Added a new KTX module: coroutines-based asset loading. + - `AssetStorage` is a non-blocking coroutines-based alternative to LibGDX `AssetManager`. + - `get` operator obtains an asset from the storage as `Deferred`. + - `load` schedules asynchronous loading of an asset. + - `unload` schedules asynchronous unloading of an asset. + - `add` allows to manually add a loaded asset to `AssetManager`. + - `dispose` unloads all assets from the storage. + - `getLoader` and `setLoader` manage `AssetLoader` instances used to load assets. + - `isLoaded` checks if loading of an asset was finished. + - `contains` operator checks if the asset was scheduled for loading or added to the storage. + - `getReferenceCount` returns how many times the asset was loaded or referenced by other assets as a dependency. + - `getDependencies` returns a list of dependencies of the selected asset. + - `getAssetDescriptor` creates an `AssetDescriptor` with loading data for the selected asset. + - `getIdentifier` creates an `Identifier` uniquely pointing to an asset of selected type and file path. + - `AssetDescriptor.toIdentifier` allows to convert an `AssetDescriptor` to `Identifier` used to uniquely identify `AssetStorage` assets. - **[FEATURE]** (`ktx-async`) Added `RenderingScope` factory function for custom scopes using rendering thread dispatcher. - **[FEATURE]** (`ktx-async`) `newAsyncContext` and `newSingleThreadAsyncContext` now support `threadName` parameter that allows to set thread name pattern of `AsyncExecutor` threads. - **[FIX]** (`ktx-async`) `isOnRenderingThread` now behaves consistently regardless of launching coroutine context. +- **[FEATURE]** (`ktx-freetype-async`) This KTX module is now restored and updated to the new `AssetStorage` API. +There are no public API changes since the last released version. - **[FEATURE]** (`ktx-graphics`) Added `LetterboxingViewport` from `ktx-app`. - **[FEATURE]** (`ktx-graphics`) Added `takeScreenshot` utility function that allows to save a screenshot of the application. - **[FEATURE]** (`ktx-graphics`) Added `BitmapFont.center` extension method that allows to center text on an object. diff --git a/assets-async/README.md b/assets-async/README.md index 119b9dda..2464737d 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -170,6 +170,11 @@ Since these values can be passed in 3 basic ways, most methods are available in - With LibGDX `AssetDescriptor` storing `Class`, path and loading data of the asset. All three variants behave identically and are available for convenience. +To ease the API usage, the following utilities are provided: + +- `AssetStorage.getAssetDescriptor` - creates an `AssetDescriptor` instance that has loading data of an asset. +- `AssetStorage.getIdentifier` - creates an `Identifier` instance that uniquely identifies a stored asset. +- `AssetDescriptor.toIdentifier` - converts an `AssetDescriptor` to an `Identifier`. ### Usage examples diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt index d5929975..50645290 100644 --- a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt @@ -100,8 +100,10 @@ class AssetStorageTest : AsyncTest() { val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) val path = "ktx/assets/async/string.txt" + // When: val asset = runBlocking { storage.load(path) } + // Then: assertEquals("Content.", asset) assertTrue(storage.isLoaded(path)) assertSame(asset, storage.get(path).joinAndGet()) @@ -590,8 +592,6 @@ class AssetStorageTest : AsyncTest() { // Then: assertFalse(storage.isLoaded(path)) assertEquals(0, storage.getReferenceCount(path)) - - storage.dispose() } @Test @@ -663,8 +663,6 @@ class AssetStorageTest : AsyncTest() { // Then: assertFalse(storage.isLoaded(path)) assertEquals(0, storage.getReferenceCount(path)) - - storage.dispose() } @Test diff --git a/freetype-async/README.md b/freetype-async/README.md index 71050dbb..a2bd2003 100644 --- a/freetype-async/README.md +++ b/freetype-async/README.md @@ -1,34 +1,29 @@ [![Maven Central](https://img.shields.io/maven-central/v/io.github.libktx/ktx-freetype-async.svg)](https://search.maven.org/artifact/io.github.libktx/ktx-freetype-async) -# Warning - -As of the `1.9.9-b1` version, the `ktx-freetype-async` module is **disabled**, since `AssetStorage` was removed -from `ktx-async`. `AssetStorage` will eventually be refactored to use the new coroutines API and `ktx-freetype-async` -will be enabled. However, until then please use [`ktx-freetype`](../freetype) or a previous **KTX** version instead. - # KTX: FreeType font asynchronous loading utilities -A tiny modules that makes it easier to use [`gdx-freetype`](https://github.com/libgdx/libgdx/wiki/Gdx-freetype) library -along with the coroutines-based `AssetStorage` from [`ktx-async`](../async). +A tiny modules that makes it easier to use [`gdx-freetype`](https://github.com/libgdx/libgdx/wiki/Gdx-freetype) +library along with the coroutines-based `AssetStorage` from [`ktx-assets-async`](../assets-async). ### Why? -`gdx-freetype` requires quite a bit of setup before it can be fully integrated with LibGDX `AssetStorage` due to how -LibGDX `AssetManager` loaders are implemented. This module aims to limit the boilerplate necessary to load FreeType -fonts in LibGDX applications. +`gdx-freetype` requires quite a bit of setup before it can be fully integrated with `AssetManager` or `AssetStorage` +due to how LibGDX `AssetManager` loaders are implemented. This module aims to limit the boilerplate necessary to load +FreeType fonts in LibGDX applications with the asynchronous **KTX** `AssetStorage`. -See also [`ktx-freetype`](../freetype). +See also: [`ktx-freetype`](../freetype). ### Guide -This module consists of the following functions: +This module consists of the following utilities: * Extension method `AssetStorage.registerFreeTypeFontLoaders` allows to register all loaders required to load FreeType font assets. It should be called right after constructing a `AssetStorage` instance and before loading any assets. * Extension method `AssetStorage.loadFreeTypeFont` allows to easily configure loaded `BitmapFont` instances with Kotlin DSL. -Since it depends on the [`ktx-freetype`](../freetype) module, it also comes with the following functions: +Since it depends on the [`ktx-freetype`](../freetype) module, it also comes with the following utilities that might +prove useful even when using `AssetStorage`: * `ktx.freetype.freeTypeFontParameters` function is a Kotlin DSL for customizing font loading parameters. * `FreeTypeFontGenerator.generateFont` extension function allows to generate `BitmapFont` instances using a @@ -39,23 +34,25 @@ Since it depends on the [`ktx-freetype`](../freetype) module, it also comes with Creating `AssetStorage` with registered FreeType font loaders: ```kotlin -import ktx.async.assets.AssetStorage -import ktx.async.enableKtxCoroutines -import ktx.freetype.async.* +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.freetype.async.registerFreeTypeFontLoaders fun initiateAssetStorage(): AssetStorage { - // Coroutines have to be enabled. AssetStorage uses an asynchronous executor, - // so concurrency level has to be above 0. See ktx-async documentation. - enableKtxCoroutines(asynchronousExecutorConcurrencyLevel = 1) - + // Coroutines have to be enabled. + // This has to be called on the main rendering thread: + KtxAsync.initiate() + val assetStorage = AssetStorage() - // Calling registerFreeTypeFontLoaders is necessary in order to load TTF/OTF files. + // Registering TTF/OTF file loaders: assetStorage.registerFreeTypeFontLoaders() + + // AssetStorage is now ready to load FreeType fonts. return assetStorage } ``` -Registering `BitmapFont` loaders for custom file extensions: +Registering `BitmapFont` loaders only for custom file extensions: ```kotlin import ktx.freetype.async.* @@ -74,18 +71,26 @@ assetStorage.registerFreeTypeFontLoaders(replaceDefaultBitmapFontLoader = true) Loading a FreeType font using `AssetStorage` in a coroutine: ```kotlin -import ktx.async.ktxAsync -import ktx.freetype.async.* - -ktxAsync { - val font = assetStorage.loadFreeTypeFont("font.ttf") - // font is BitmapFont +import com.badlogic.gdx.graphics.g2d.BitmapFont +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.freetype.async.loadFreeTypeFont + +fun loadFont(assetStorage: AssetStorage) { + // Launching a coroutine: + KtxAsync.launch { + // Loading a font: + val font: BitmapFont = assetStorage.loadFreeTypeFont("font.ttf") + // Font is now ready to use. + } } ``` Loading a FreeType font with custom parameters using `AssetStorage`: ```kotlin +import com.badlogic.gdx.graphics.Color import ktx.freetype.async.* val font = assetStorage.loadFreeTypeFont("font.ttf") { @@ -101,7 +106,7 @@ Accessing a fully loaded font: ```kotlin import com.badlogic.gdx.graphics.g2d.BitmapFont -val font = assetStorage.get("font.ttf") +val font = assetStorage.get("font.ttf").await() ``` Loading a `FreeTypeFontGenerator`: @@ -132,7 +137,7 @@ Generating a new `BitmapFont` using LibGDX `FreeTypeFontGenerator`: import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator import ktx.freetype.* -val generator: FreeTypeFontGenerator = getGenerator() +val generator: FreeTypeFontGenerator // Default parameters: val fontA = generator.generateFont() // Customized: @@ -149,5 +154,6 @@ FreeType font loaders can be registered manually. See #### Additional documentation -- [`gdx-freetype` article.](https://github.com/libgdx/libgdx/wiki/Gdx-freetype) +- [Official `gdx-freetype` article.](https://github.com/libgdx/libgdx/wiki/Gdx-freetype) - [`ktx-async` module.](../async) +- [`ktx-assets-async` module.](../assets-async) diff --git a/freetype-async/build.gradle b/freetype-async/build.gradle index 1092aa09..65112a77 100644 --- a/freetype-async/build.gradle +++ b/freetype-async/build.gradle @@ -1,11 +1,13 @@ dependencies { - compile project(':async') + compile project(':assets-async') compile project(':freetype') provided "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" provided "com.badlogicgames.gdx:gdx-freetype:$gdxVersion" testCompile project(':async').sourceSets.test.output + testCompile "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$kotlinCoroutinesVersion" testCompile "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-desktop" + testCompile "com.badlogicgames.gdx:gdx-backend-headless:$gdxVersion" testCompile "com.badlogicgames.gdx:gdx-backend-lwjgl:$gdxVersion" testCompile "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" } diff --git a/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt b/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt index 317709e8..f89d8da9 100644 --- a/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt +++ b/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt @@ -5,41 +5,49 @@ import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator.FreeTypeFontParameter import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGeneratorLoader import com.badlogic.gdx.graphics.g2d.freetype.FreetypeFontLoader -import ktx.async.assets.AssetStorage +import ktx.assets.async.AssetStorage import ktx.freetype.freeTypeFontParameters /** - * Registers all loaders necessary to load [BitmapFont] and [FreeTypeFontGenerator] instances from TTF and OTF files. - * @param fileExtensions a collection of supported file extensions. If an empty array is passed, [BitmapFont] loaders - * will not be registered. Defaults to ".ttf" and ".otf". - * @param replaceDefaultBitmapFontLoader if true, default [BitmapFont] loader will be replaced and any attempts to load - * [BitmapFont] will result in use of [FreetypeFontLoader] instead. [fileExtensions] will be ignored and FreeType loader - * will be used by default unless overridden. + * Registers all loaders necessary to load [BitmapFont] and [FreeTypeFontGenerator] + * instances from TTF and OTF files. + * + * [fileExtensions] is a collection of supported file extensions. If an empty array is passed, + * [BitmapFont] loaders will not be registered. Defaults to ".ttf" and ".otf". + * + * If [replaceDefaultBitmapFontLoader] is true, default [BitmapFont] loader will be replaced + * and any attempts to load [BitmapFont] will result in the use of [FreetypeFontLoader] instead. + * [fileExtensions] will be ignored and FreeType loader will be used by default for all font + * assets unless overridden. */ fun AssetStorage.registerFreeTypeFontLoaders( - fileExtensions: Array = arrayOf(".ttf", ".otf"), - replaceDefaultBitmapFontLoader: Boolean = false) { - val fontGeneratorLoader = FreeTypeFontGeneratorLoader(fileResolver) - setLoader(fontGeneratorLoader) + fileExtensions: Array = arrayOf(".ttf", ".otf"), + replaceDefaultBitmapFontLoader: Boolean = false +) { + setLoader { FreeTypeFontGeneratorLoader(fileResolver) } - val fontLoader = FreetypeFontLoader(fileResolver) if (replaceDefaultBitmapFontLoader) { - setLoader(fontLoader) + setLoader { FreetypeFontLoader(fileResolver) } } else { fileExtensions.forEach { extension -> - setLoader(fontLoader, suffix = extension) + setLoader(suffix = extension) { FreetypeFontLoader(fileResolver) } } } } /** * Allows to customize parameters of a loaded FreeType font. - * @param file path to the FreeType font file. - * @param setup should specify font parameters. Will be invoked on a new instance of [FreeTypeFontParameter]. Inlined. - * @return fully loaded BitmapFont. Note that this method will suspend the current coroutine to perform asynchronous - * font loading. + * + * [path] is the file path to the FreeType font file. + * Must be compatible with the [AssetStorage.fileResolver]. + * + * [setup] can be used to specify and customize the parameters of the loaded font. + * It will be inlined and invoked on a [FreeTypeFontParameter]. + * + * Returns the result of font loading. See [AssetStorage.load] for lists of possible outcomes. */ suspend inline fun AssetStorage.loadFreeTypeFont( - file: String, - setup: FreeTypeFontParameter.() -> Unit = {}): BitmapFont = - load(file, parameters = freeTypeFontParameters(file, setup)) + path: String, + setup: FreeTypeFontParameter.() -> Unit = {} +): BitmapFont = + load(path, parameters = freeTypeFontParameters(path, setup)) diff --git a/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt b/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt index c817e0de..b27b6a9d 100644 --- a/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt +++ b/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt @@ -2,199 +2,385 @@ package ktx.freetype.async import com.badlogic.gdx.Gdx import com.badlogic.gdx.assets.loaders.resolvers.ClasspathFileHandleResolver -import com.badlogic.gdx.backends.lwjgl.LwjglFiles import com.badlogic.gdx.backends.lwjgl.LwjglNativesLoader import com.badlogic.gdx.graphics.g2d.BitmapFont import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGeneratorLoader import com.badlogic.gdx.graphics.g2d.freetype.FreetypeFontLoader import com.nhaarman.mockitokotlin2.mock -import ktx.async.`coroutine test` -import ktx.async.`destroy coroutines context` -import ktx.async.assets.AssetStorage +import io.kotlintest.matchers.shouldThrow +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import ktx.assets.async.AssetStorage +import ktx.assets.async.MissingAssetException +import ktx.async.AsyncTest +import ktx.async.newAsyncContext import ktx.freetype.freeTypeFontParameters -import org.junit.After import org.junit.Assert.* -import org.junit.Before +import org.junit.BeforeClass import org.junit.Test /** - * Tests FreeType font loading utilities. Uses Hack font for testing. See https://github.com/source-foundry/Hack for - * font details. + * Tests FreeType font loading utilities. + * + * Uses Hack font for testing. See https://github.com/source-foundry/Hack for font details. + * + * Implementation note: tests use [runBlocking] to simplify the implementation. This is + * a blocking operation and might permanently block your rendering thread. In an actual + * application, prefer [ktx.async.KtxAsync].launch to run your coroutines. */ -class FreeTypeAsyncTest { +class FreeTypeAsyncTest : AsyncTest() { private val ttfFile = "ktx/freetype/async/hack.ttf" private val otfFile = "ktx/freetype/async/hack.otf" - @Test - fun `should register FreeType font loaders`() = `coroutine test`(concurrencyLevel = 1) { - val assetStorage = assetStorage() - - assetStorage.registerFreeTypeFontLoaders() + companion object { + @JvmStatic + @BeforeClass + fun `initiate LibGDX`() { + LwjglNativesLoader.load() + Gdx.graphics = mock() + Gdx.gl20 = mock() + Gdx.gl = Gdx.gl20 + } + } - assertTrue(assetStorage.getLoader() is FreeTypeFontGeneratorLoader) - assertTrue(assetStorage.getLoader(".ttf") is FreetypeFontLoader) - assertTrue(assetStorage.getLoader(".otf") is FreetypeFontLoader) + @Test + fun `should register FreeType font loaders`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + + // When: + storage.registerFreeTypeFontLoaders() + + // Then: + assertTrue(storage.getLoader() is FreeTypeFontGeneratorLoader) + assertTrue(storage.getLoader(".ttf") is FreetypeFontLoader) + assertTrue(storage.getLoader(".otf") is FreetypeFontLoader) + // Default font loader should not be registered: + assertNull(storage.getLoader()) } @Test - fun `should register FreeType font loaders with custom extensions`() = `coroutine test`(concurrencyLevel = 1) { - val assetStorage = assetStorage() + fun `should register FreeType font loaders with custom extensions`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) - assetStorage.registerFreeTypeFontLoaders(fileExtensions = arrayOf(".custom")) + // When: + storage.registerFreeTypeFontLoaders(fileExtensions = arrayOf(".custom")) - assertTrue(assetStorage.getLoader() is FreeTypeFontGeneratorLoader) - assertTrue(assetStorage.getLoader(".custom") is FreetypeFontLoader) + // Then: + assertTrue(storage.getLoader() is FreeTypeFontGeneratorLoader) + assertTrue(storage.getLoader(".custom") is FreetypeFontLoader) // Should not register loader for unlisted extensions: - assertFalse(assetStorage.getLoader(".ttf") is FreetypeFontLoader) - assertFalse(assetStorage.getLoader(".otf") is FreetypeFontLoader) + assertNull(storage.getLoader(".ttf")) + assertNull(storage.getLoader(".otf")) + assertNull(storage.getLoader()) } @Test - fun `should register FreeType font loaders with default loader override`() = `coroutine test`(concurrencyLevel = 1) { - val assetStorage = assetStorage() + fun `should register FreeType font loaders with default font loader override`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = true) - assetStorage.registerFreeTypeFontLoaders(replaceDefaultBitmapFontLoader = true) + // When: + storage.registerFreeTypeFontLoaders(replaceDefaultBitmapFontLoader = true) - assertTrue(assetStorage.getLoader() is FreeTypeFontGeneratorLoader) - assertTrue(assetStorage.getLoader() is FreetypeFontLoader) + // Then: + assertTrue(storage.getLoader() is FreeTypeFontGeneratorLoader) + assertTrue(storage.getLoader() is FreetypeFontLoader) } - @Test - fun `should load OTF file into BitmapFont`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + /** + * Testing utility. Obtains instance of [T] by blocking the thread until the + * [Deferred] is completed. Rethrows any exceptions caught by [Deferred]. + */ + private fun Deferred.joinAndGet(): T = runBlocking { await() } - async { - val asset = assetStorage.loadFreeTypeFont(otfFile) - - val font = assetStorage.get(otfFile) - assertTrue(font is BitmapFont) - assertSame(asset, font) - } + @Test + fun `should load OTF file into BitmapFont`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + + // When: + val asset = runBlocking { storage.loadFreeTypeFont(otfFile) } + + // Then: + assertTrue(storage.isLoaded(otfFile)) + assertSame(asset, storage.get(otfFile).joinAndGet()) + assertEquals(1, storage.getReferenceCount(otfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$otfFile.gen")), + storage.getDependencies(otfFile)) + assertTrue(storage.isLoaded("$otfFile.gen")) + + storage.dispose() } @Test - fun `should load OTF file into BitmapFont with custom params`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() - - async { - val asset = assetStorage.loadFreeTypeFont(otfFile) { + fun `should load OTF file into BitmapFont with custom params`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + + // When: + val asset = runBlocking { + storage.loadFreeTypeFont(otfFile) { size = 12 borderWidth = 1f } - - val font = assetStorage.get(otfFile) - assertTrue(font is BitmapFont) - assertSame(asset, font) } - } - @Test - fun `should load TTF file into BitmapFont`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + // Then: + assertTrue(storage.isLoaded(otfFile)) + assertSame(asset, storage.get(otfFile).joinAndGet()) + assertEquals(1, storage.getReferenceCount(otfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$otfFile.gen")), + storage.getDependencies(otfFile)) + assertTrue(storage.isLoaded("$otfFile.gen")) - async { - val asset = assetStorage.loadFreeTypeFont(ttfFile) + storage.dispose() + } - val font = assetStorage.get(ttfFile) - assertTrue(font is BitmapFont) - assertSame(asset, font) - } + @Test + fun `should unload BitmapFont assets loaded from OTF file`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + runBlocking { storage.loadFreeTypeFont(otfFile) } + + // When: + runBlocking { storage.unload(otfFile) } + + // Then: + assertFalse(storage.isLoaded(otfFile)) + assertEquals(0, storage.getReferenceCount(otfFile)) + assertFalse(storage.isLoaded("$otfFile.gen")) + assertEquals(0, storage.getReferenceCount("$otfFile.gen")) } @Test - fun `should load TTF file into BitmapFont with custom params`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + fun `should load TTF file into BitmapFont`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + + // When: + val asset = runBlocking { storage.loadFreeTypeFont(ttfFile) } + + // Then: + assertTrue(storage.isLoaded(ttfFile)) + assertSame(asset, storage.get(ttfFile).joinAndGet()) + assertEquals(1, storage.getReferenceCount(ttfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$ttfFile.gen")), + storage.getDependencies(ttfFile)) + assertTrue(storage.isLoaded("$ttfFile.gen")) + + storage.dispose() + } - async { - val asset = assetStorage.loadFreeTypeFont(ttfFile) { + @Test + fun `should load TTF file into BitmapFont with custom params`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + + // When: + val asset = runBlocking { + storage.loadFreeTypeFont(ttfFile) { size = 12 borderWidth = 1f } - - val font = assetStorage.get(ttfFile) - assertTrue(font is BitmapFont) - assertSame(asset, font) } + + // Then: + assertTrue(storage.isLoaded(ttfFile)) + assertSame(asset, storage.get(ttfFile).joinAndGet()) + assertEquals(1, storage.getReferenceCount(ttfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$ttfFile.gen")), + storage.getDependencies(ttfFile)) + assertTrue(storage.isLoaded("$ttfFile.gen")) + + storage.dispose() } @Test - fun `should use FreeType loader to load OTF file into BitmapFont`() = `coroutine test`(concurrencyLevel = 1) { async -> - // Note that this method uses "raw" AssetStorage API without font loading utilities. - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + fun `should unload BitmapFont assets loaded from TTF file`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + runBlocking { storage.loadFreeTypeFont(ttfFile) } + + // When: + runBlocking { storage.unload(ttfFile) } + + // Then: + assertFalse(storage.isLoaded(ttfFile)) + assertEquals(0, storage.getReferenceCount(ttfFile)) + assertFalse(storage.isLoaded("$ttfFile.gen")) + assertEquals(0, storage.getReferenceCount("$ttfFile.gen")) + } + + @Test + fun `should use FreeType loader to load OTF file into BitmapFont`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() - async { - val asset = assetStorage.load(otfFile, parameters = freeTypeFontParameters(otfFile)) - val font = assetStorage.get(otfFile) - assertTrue(font is BitmapFont) - assertSame(font, asset) + // When: + val asset = runBlocking { + // Note that this method uses "raw" AssetStorage API without font loading utilities. + // Without the freeTypeFontParameters, this will fail to load. + storage.load(otfFile, parameters = freeTypeFontParameters(otfFile)) } + + // Then: + assertTrue(storage.isLoaded(otfFile)) + assertSame(asset, storage.get(otfFile).joinAndGet()) + assertEquals(1, storage.getReferenceCount(otfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$otfFile.gen")), + storage.getDependencies(otfFile)) + assertTrue(storage.isLoaded("$otfFile.gen")) + + storage.dispose() } @Test - fun `should use FreeType loader to load TTF file into BitmapFont`() = `coroutine test`(concurrencyLevel = 1) { async -> - // Note that this method uses "raw" AssetStorage API without font loading utilities. - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + fun `should use FreeType loader to load TTF file into BitmapFont`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + + // When: + val asset = runBlocking { + // Note that this method uses "raw" AssetStorage API without font loading utilities. + // Without the freeTypeFontParameters, this will fail to load. + storage.load(ttfFile, parameters = freeTypeFontParameters(ttfFile)) + } - async { - val asset = assetStorage.load(ttfFile, parameters = freeTypeFontParameters(ttfFile)) + // Then: + assertTrue(storage.isLoaded(ttfFile)) + assertSame(asset, storage.get(ttfFile).joinAndGet()) + assertEquals(1, storage.getReferenceCount(ttfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$ttfFile.gen")), + storage.getDependencies(ttfFile)) + assertTrue(storage.isLoaded("$ttfFile.gen")) - val font = assetStorage.get(ttfFile) - assertTrue(font is BitmapFont) - assertSame(font, asset) - } + storage.dispose() } + @Test - fun `should load OTF file into FreeTypeFontGenerator`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + fun `should load OTF file into FreeTypeFontGenerator`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() - async { - val fontGenerator = assetStorage.load(otfFile) + // When: + val asset = runBlocking { storage.load(otfFile) } - assertNotNull(fontGenerator) - assertSame(fontGenerator, assetStorage.get(otfFile)) - } + // Then: + assertTrue(storage.isLoaded(otfFile)) + assertSame(asset, storage.get(otfFile).joinAndGet()) + assertEquals(1, storage.getReferenceCount(otfFile)) + assertEquals(emptyList(), storage.getDependencies(otfFile)) + + storage.dispose() } @Test - fun `should load TTF file into FreeTypeFontGenerator`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + fun `should load TTF file into FreeTypeFontGenerator`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() - async { - val fontGenerator = assetStorage.load(ttfFile) + // When: + val asset = runBlocking { storage.load(ttfFile) } - assertNotNull(fontGenerator) - assertSame(fontGenerator, assetStorage.get(ttfFile)) - } - } + // Then: + assertTrue(storage.isLoaded(ttfFile)) + assertSame(asset, storage.get(ttfFile).joinAndGet()) + assertEquals(1, storage.getReferenceCount(ttfFile)) + assertEquals(emptyList(), storage.getDependencies(ttfFile)) - @Before - fun `setup LibGDX`() { - LwjglNativesLoader.load() - Gdx.gl = mock() - Gdx.gl20 = Gdx.gl - Gdx.graphics = mock() - Gdx.files = LwjglFiles() + storage.dispose() } - @After - fun `cleanup LibGDX`() { - Gdx.gl = null - Gdx.gl20 = null - Gdx.files = null - Gdx.graphics = null - `destroy coroutines context`() + @Test + fun `should allow to load BitmapFont and FreeTypeFontGenerator assets in parallel`() { + // Given: + val storage = AssetStorage( + useDefaultLoaders = false, + fileResolver = ClasspathFileHandleResolver(), + asyncContext = newAsyncContext(threads = 4) + ) + storage.registerFreeTypeFontLoaders() + + // When: + runBlocking { + val otf = async { storage.loadFreeTypeFont(otfFile) } + val ttf = async { storage.loadFreeTypeFont(ttfFile) } + val otfGenerator = async { storage.load(otfFile) } + val ttfGenerator = async { storage.load(ttfFile) } + + otf.await(); ttf.await(); otfGenerator.await(); ttfGenerator.await() + } + + // Then: + assertTrue(storage.isLoaded(otfFile)) + assertTrue(storage.isLoaded(ttfFile)) + assertTrue(storage.isLoaded(otfFile)) + assertTrue(storage.isLoaded(ttfFile)) + assertTrue(storage.isLoaded("$otfFile.gen")) + assertTrue(storage.isLoaded("$ttfFile.gen")) + + assertEquals(1, storage.getReferenceCount(otfFile)) + assertEquals(1, storage.getReferenceCount(ttfFile)) + assertEquals(1, storage.getReferenceCount(otfFile)) + assertEquals(1, storage.getReferenceCount(ttfFile)) + assertEquals(1, storage.getReferenceCount("$otfFile.gen")) + assertEquals(1, storage.getReferenceCount("$ttfFile.gen")) + + storage.dispose() } - private fun assetStorage() = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + @Test + fun `should dispose of multiple font assets without errors`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + val assets = listOf( + storage.getAssetDescriptor(otfFile, parameters = freeTypeFontParameters(otfFile)), + storage.getAssetDescriptor(ttfFile, parameters = freeTypeFontParameters(otfFile)), + storage.getAssetDescriptor(otfFile), + storage.getAssetDescriptor(ttfFile) + ) + runBlocking { + assets.forEach { + storage.load(it) + assertTrue(storage.isLoaded(it)) + } + } + + // When: + storage.dispose() + + // Then: + assets.forEach { + assertFalse(it in storage) + assertFalse(storage.isLoaded(it)) + assertEquals(0, storage.getReferenceCount(it)) + assertEquals(emptyList(), storage.getDependencies(it)) + shouldThrow { + storage[it].joinAndGet() + } + } + } } diff --git a/settings.gradle b/settings.gradle index 91bd1cfb..99782aed 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,6 +10,7 @@ include( 'json', 'graphics', 'freetype', + 'freetype-async', 'i18n', 'inject', 'log', From f8e237e4b6d77791ad00885f9c78b43c5c5a8ffd Mon Sep 17 00:00:00 2001 From: MJ Date: Sun, 29 Mar 2020 22:42:08 +0200 Subject: [PATCH 04/17] Mentioned ktx-assets-async in README files of related modules. #182 --- assets/README.md | 9 ++++++--- scene2d/README.md | 5 +++-- style/README.md | 5 +++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/assets/README.md b/assets/README.md index 60e9fbfe..423a8a56 100644 --- a/assets/README.md +++ b/assets/README.md @@ -6,9 +6,9 @@ Utilities for management of assets and heavy resources. ### Why? -LibGDX does a good job of helping you with assets (through `AssetManager`, for example), but - as usual in case of Java -APIs - it does not use the full potential of Kotlin features. This library aims to provide Kotlin extensions and wrappers -for the existing API to make assets usage more natural in Kotlin applications. +LibGDX does a good job of helping you with assets through `AssetManager` and related APIs, but - as usual in case of +Java libraries - it does not allow to use the full potential of Kotlin features. This library aims to provide Kotlin +extensions and wrappers for the existing asset APIs to make assets usage more idiomatic in Kotlin applications. ### Guide @@ -364,6 +364,9 @@ val textures = Images.values().map { it() } ### Alternatives +- [`ktx-assets-async`](../assets-async) provides an alternative asset manager with non-blocking API based on coroutines. +In contrary to LibGDX `AssetManager`, **KTX** `AssetStorage` supports concurrent loading of assets on multiple threads +performing asynchronous operations. - [libgdx-utils](https://bitbucket.org/dermetfan/libgdx-utils/) feature an annotation-based asset manager implementation which easies loading of assets (through internal reflection usage). - [Autumn MVC](https://github.com/czyzby/gdx-lml/tree/master/mvc) is a [Spring](https://spring.io/)-inspired diff --git a/scene2d/README.md b/scene2d/README.md index 754f5385..076f2079 100644 --- a/scene2d/README.md +++ b/scene2d/README.md @@ -361,8 +361,9 @@ table { #### Synergy -Pair this library with [`ktx-style`](../style) for type-safe styles building and [`ktx-actors`](../actors) for useful -extension methods for `Scene2D` API. [`ktx-assets`](../assets) might help with `Skin` management. +Pair this library with [`ktx-style`](../style) for type-safe actor styles building and [`ktx-actors`](../actors) +for useful extension methods for `Scene2D` API. [`ktx-assets`](../assets) or [`ktx-assets-async`](../assets-async) +might help with `Skin` loading and management. ### Alternatives diff --git a/style/README.md b/style/README.md index 77d5bcce..fbb2e08a 100644 --- a/style/README.md +++ b/style/README.md @@ -338,6 +338,11 @@ The advantage of using an `enum` over a "standard" singleton (`object`) with `St constants is that you can easily extract a list of all values from an `enum`, while getting all fields from an object or constants from a package is not trivial. +#### Synergy + +[`ktx-assets`](../assets) or [`ktx-assets-async`](../assets-async) might prove useful for loading and management +of `Skin` assets including `Textures` and `TextureAtlases`. + ### Alternatives - Default LibGDX JSON skin loading mechanism allows to customize `Skin` instances thanks to reflection. Apart from the From e03bda59921d909d81e0b904dc2220e851f6e92b Mon Sep 17 00:00:00 2001 From: MJ Date: Mon, 30 Mar 2020 13:01:39 +0200 Subject: [PATCH 05/17] Expanded documentation of ktx-assets-async. #182 --- assets-async/README.md | 359 +++++++++++++++++- .../ktx/assets/async/i18n.properties | 2 +- .../resources/ktx/assets/async/model.g3dj | 2 +- async/src/main/kotlin/ktx/async/async.kt | 6 +- 4 files changed, 361 insertions(+), 8 deletions(-) diff --git a/assets-async/README.md b/assets-async/README.md index 2464737d..7b541a07 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -191,8 +191,8 @@ fun create() { } ``` -Customizing `AssetStorage`. In this example a multi-threaded coroutine context -was assigned to storage, so the assets will be loaded in parallel on multiple threads: +Customizing an `AssetStorage`. In this example a multi-threaded coroutine context +was assigned to storage, so the assets can be loaded in parallel on multiple threads: ```kotlin import com.badlogic.gdx.assets.loaders.resolvers.InternalFileHandleResolver @@ -203,7 +203,7 @@ import ktx.async.newAsyncContext fun create() { KtxAsync.initiate() val assetStorage = AssetStorage( - // Used to asynchronous file loading: + // Used to perform asynchronous file loading: asyncContext = newAsyncContext(threads = 4), // Used for resolving file paths: fileResolver = InternalFileHandleResolver(), @@ -213,7 +213,358 @@ fun create() { } ``` -TODO +Loading assets using `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun loadAsset(assetStorage: AssetStorage) { + // Launching a coroutine to load the assets asynchronously: + KtxAsync.launch { + // This will suspend the coroutine until the texture is loaded: + val texture = assetStorage.load("images/logo.png") + // Now the coroutine resumes and the texture can be used. + } +} +``` + +Loading assets with customized loading parameters: + +```kotlin +import com.badlogic.gdx.assets.loaders.TextureLoader.TextureParameter +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun loadAsset(assetStorage: AssetStorage) { + KtxAsync.launch { + // You can optionally specify loading parameters for each asset. + // AssetStorage reuses default LibGDX asset loaders and their + // parameters classes. + val texture = assetStorage.load( + path = "images/logo.png", + parameters = TextureParameter().apply { + genMipMaps = true + } + ) + // Now the texture is loaded and can be used. + } +} +``` + +Loading assets asynchronously: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun loadAssets(assetStorage: AssetStorage) { + KtxAsync.launch { + // Launching asynchronous asset loading: + val texture = async { assetStorage.load("images/logo.png") } + val font = async { assetStorage.load("com/badlogic/gdx/utils/arial-15.fnt") } + // Suspending the coroutine until both assets are loaded: + doSomethingWithTextureAndFont(texture.await(), font.await()) + } +} +``` + +Loading assets in parallel: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.async.newAsyncContext + +fun loadAssets() { + // Using Kotlin's `async` will ensure that the coroutine is not + // immediately suspended and assets are scheduled asynchronously, + // but to take advantage of parallel asset loading, we have to pass + // a context with multiple threads to AssetStorage: + val assetStorage = AssetStorage(asyncContext = newAsyncContext(threads = 2)) + + KtxAsync.launch { + // Passing context to `async` is optional, but it allows you to + // perform even less operations on the main rendering thread: + val texture = async(assetStorage.asyncContext) { + assetStorage.load("images/logo.png") + } + val font = async(assetStorage.asyncContext) { + assetStorage.load("com/badlogic/gdx/utils/arial-15.fnt") + } + // Suspending the coroutine until both assets are loaded: + doSomethingWithTextureAndFont(texture.await(), font.await()) + } +} +``` + +Unloading assets from `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun unloadAsset(assetStorage: AssetStorage) { + KtxAsync.launch { + // Suspends the coroutine until the asset is unloaded: + assetStorage.unload("images/logo.png") + // When the coroutine resumes here, the asset is unloaded. + // If no other assets use it as dependency, it will + // be removed from the asset storage and disposed of. + } +} +``` + +Accessing assets from `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun accessAsset(assetStorage: AssetStorage) { + // Typically you can use assets returned by `load`, + // but AssetStorage also allows you to access assets + // already loaded by other coroutines. + + // Returns true is asset is in the storage, loaded or not: + assetStorage.contains("images/logo.png") + // Returns true if the asset loading has finished: + assetStorage.isLoaded("images/logo.png") + // Checks how many times the asset was loaded or used as dependency: + assetStorage.getReferenceCount("images/logo.png") + // Returns a list of dependencies loaded along with the asset: + assetStorage.getDependencies("images/logo.png") + + KtxAsync.launch { + // By default, AssetStorage will not suspend the coroutine + // to get the asset and instead will return a Kotlin Deferred + // reference. This allows you to handle the asset however + // you need: + val asset: Deferred = assetStorage["images/logo.png"] + // Checking if the asset loading has finished: + asset.isCompleted + // Suspending the coroutine to obtain asset instance: + var texture = asset.await() + + // If you want to suspend the coroutine to wait for the asset, + // you can do this in a single line: + texture = assetStorage.get("images/logo.png").await() + + // Now the coroutine is resumed and `texture` can be used. + } +} +``` + +Adding a fully loaded asset manually to the `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.g2d.Batch +import com.badlogic.gdx.graphics.g2d.SpriteBatch +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun addAsset(assetStorage: AssetStorage) { + KtxAsync.launch { + // You can store arbitrary objects in AssetStorage. + // They will be marked as loaded and accessible with `get`. + // If they are disposable, calling `unload` will also + // dispose of these assets. This might be useful for + // expensive objects such as the Batch. + val batch: Batch = SpriteBatch() + // Suspending the coroutine until the `batch` is added: + assetStorage.add("batch", batch) + // Now our `batch` will be available under "batch" path. + } +} +``` + +Disposing of all assets stored by `AssetStorage`: + +```kotlin +// Will block the current thread to unload all assets: +assetStorage.dispose() +// This will also disrupt loading of all unloaded assets. +// Disposing errors are logged by default, but do not +// cancel the process. +``` + +Disposing of all assets asynchronously: + +```kotlin +import com.badlogic.gdx.Gdx +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun unloadAllAssets(assetStorage: AssetStorage) { + KtxAsync.launch { + assetStorage.dispose { identifier, exception -> + // This lambda will be invoked for each encountered disposing error: + Gdx.app.error("KTX","Unable to dispose of asset: $identifier", exception) + } + } +} +``` + +Loading assets with error handling: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import ktx.assets.async.AssetLoadingException +import ktx.assets.async.AssetStorage +import ktx.assets.async.AssetStorageException +import ktx.async.KtxAsync + +fun loadAsset(assetStorage: AssetStorage) { + KtxAsync.launch { + // You can handle loading errors with a classic try-catch: + try { + val texture = assetStorage.load("images/logo.png") + } catch (exception: AssetLoadingException) { + // Asset loader threw an exception - unable to load the asset. + } catch (exception: AssetStorageException) { + // Other error occurred. See AssetStorageException subclasses. + } + + // Note that is the asset threw an exception during loading, + // the exception will be rethrown by Deferred from `get`. + } +} +``` + +Adding a custom `AssetLoader` to `AssetStorage`: + +```kotlin +import ktx.assets.async.AssetStorage + +fun createCustomAssetStorage(): AssetStorage { + val assetStorage = AssetStorage() + // Custom asset loaders should be added before + // loading any assets: + assetStorage.setLoader(suffix = ".file.extension") { + MyCustomAssetLoader(assetStorage.fileResolver) + } + // Remember to extend one of: + // com.badlogic.gdx.assets.loaders.SynchronousAssetLoader + // com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader + + return assetStorage +} +``` + +#### Implementation notes + +##### Multiple calls of `load` and `unload` + +It is completely safe to call `load` multiple times, even to obtain asset instances without dealing +with `Deferred`. In that sense, it can be used as an alternative to `get`. Instead of loading the same +asset multiple times, `AssetStorage` will just increase the reference count to the asset and return +the same instance on each request. + +However, to eventually unload the asset, you have to call `unload` the same number of times as `load`. +That, or simply dispose of all assets with `dispose`, which clears all reference counts and unloads everything. + +##### `runBlocking` + +Kotlin's `runBlocking` function allows to launch a coroutine and block the current thread until the coroutine +is finished. In general, you should **avoid** `runBlocking` calls from the main rendering thread or the threads +assigned to `AssetStorage` for asynchronous loading. + +This simple example will cause a deadlock: + +```kotlin +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.backends.lwjgl.LwjglApplication +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.runBlocking +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun main() { + LwjglApplication(App()) +} + +class App : ApplicationAdapter() { + override fun create() { + KtxAsync.initiate() + val assetStorage = AssetStorage() + runBlocking { + assetStorage.load("images/logo.png") + } + println("Will never be printed.") + } +} +``` + +This is because `AssetStorage` needs access to the main rendering thread to finish loading the `Texture` +with OpenGL context, but we have blocked the main rendering thread to wait for the asset. + +In as similar manner, this example blocks the only thread assigned to `AssetStorage` for asynchronous operations: + +```kotlin +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.backends.lwjgl.LwjglApplication +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.async.newSingleThreadAsyncContext + +fun main() { + LwjglApplication(App()) +} + +class App : ApplicationAdapter() { + override fun create() { + KtxAsync.initiate() + val asyncContext = newSingleThreadAsyncContext() + val assetStorage = AssetStorage(asyncContext = asyncContext) + KtxAsync.launch(asyncContext) { + runBlocking { + assetStorage.load("images/logo.png") + } + println("Will never be printed and AssetStorage will be unusable.") + } + } +} +``` + +As a rule of thumb, you should prefer to use suspending `AssetStorage` only from non-blocking coroutines, +e.g. launched with `KtxAsync.launch` or `GlobalScope.launch`. If you change `runBlocking` in either of the +examples to a proper coroutine launch, you will notice that the deadlocks no longer occur. + +It does not mean that `runBlocking` will always cause a deadlock, however. You can safely use `runBlocking`: + +- From within other threads than the main rendering thread and `AssetStorage` loading threads. +- For `dispose`, both suspending and non-suspending variants. +- For all non-suspending methods such as `contains`, `isLoaded`, `getReferenceCount`, `setLoader`, `getLoader`. +- For `load` and `get` calls on already loaded assets. **With caution.** + +#### Synergy + +While [`ktx-assets`](../assets) module does provide some extensions to the `AssetManager`, which is a direct +alternative to the `AssetStorage`, this module's other utilities for LibGDX assets and files APIs might still +prove useful. ### Alternatives diff --git a/assets-async/src/test/resources/ktx/assets/async/i18n.properties b/assets-async/src/test/resources/ktx/assets/async/i18n.properties index 1d64a2e4..9819b570 100644 --- a/assets-async/src/test/resources/ktx/assets/async/i18n.properties +++ b/assets-async/src/test/resources/ktx/assets/async/i18n.properties @@ -1 +1 @@ -key=Value. \ No newline at end of file +key=Value. diff --git a/assets-async/src/test/resources/ktx/assets/async/model.g3dj b/assets-async/src/test/resources/ktx/assets/async/model.g3dj index 110bc919..2339d9c0 100644 --- a/assets-async/src/test/resources/ktx/assets/async/model.g3dj +++ b/assets-async/src/test/resources/ktx/assets/async/model.g3dj @@ -80,4 +80,4 @@ } ], "animations": [] -} \ No newline at end of file +} diff --git a/async/src/main/kotlin/ktx/async/async.kt b/async/src/main/kotlin/ktx/async/async.kt index 7bf8ecea..7a8003e0 100644 --- a/async/src/main/kotlin/ktx/async/async.kt +++ b/async/src/main/kotlin/ktx/async/async.kt @@ -77,14 +77,16 @@ fun CoroutineScope.isOnRenderingThread() = /** * Attempts to skip the current frame. Resumes the execution using a task scheduled with [Application.postRunnable]. + * * Due to asynchronous nature of the execution, there is no guarantee that this method will always skip only a *single* - * frame before further method calls are executed, but it will always skip *at least one* frame. + * frame before resuming, but it will always suspend the current coroutine until the [Runnable] instances scheduled + * with [Application.postRunnable] are executed by the [Application]. */ suspend fun skipFrame() { suspendCancellableCoroutine { continuation -> Gdx.app.postRunnable { - val context = continuation.context[ContinuationInterceptor.Key] if (continuation.isActive) { + val context = continuation.context[ContinuationInterceptor.Key] if (context is RenderingThreadDispatcher) { // Executed via main thread dispatcher and already on the main thread - resuming immediately: with(continuation) { context.resumeUndispatched(Unit) } From 297ef6ae04a23ec266a81d8902b967e33464cd9f Mon Sep 17 00:00:00 2001 From: MJ Date: Mon, 30 Mar 2020 13:10:47 +0200 Subject: [PATCH 06/17] Documentation fix. #182 --- assets-async/README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/assets-async/README.md b/assets-async/README.md index 7b541a07..67071147 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -549,16 +549,16 @@ class App : ApplicationAdapter() { } ``` -As a rule of thumb, you should prefer to use suspending `AssetStorage` only from non-blocking coroutines, -e.g. launched with `KtxAsync.launch` or `GlobalScope.launch`. If you change `runBlocking` in either of the -examples to a proper coroutine launch, you will notice that the deadlocks no longer occur. +As a rule of thumb, you should prefer to use suspending `AssetStorage` methods only from non-blocking coroutines, +e.g. those launched with `KtxAsync.launch` or `GlobalScope.launch`. If you change `runBlocking` to a proper coroutine +launch in either of the examples, you will notice that the deadlocks no longer occur. It does not mean that `runBlocking` will always cause a deadlock, however. You can safely use `runBlocking`: -- From within other threads than the main rendering thread and `AssetStorage` loading threads. +- From within other threads than the main rendering thread and the `AssetStorage` loading threads. - For `dispose`, both suspending and non-suspending variants. - For all non-suspending methods such as `contains`, `isLoaded`, `getReferenceCount`, `setLoader`, `getLoader`. -- For `load` and `get` calls on already loaded assets. **With caution.** +- For `load` and `get` calls requesting already loaded assets. **With caution.** #### Synergy @@ -576,8 +576,7 @@ Alternatives include: - Using [`ktx-assets`](../assets) extensions for `AssetManager`. - [`AnnotationAssetManager`](https://bitbucket.org/dermetfan/libgdx-utils/wiki/net.dermetfan.gdx.assets.AnnotationAssetManager) from [`libgdx-utils`](https://bitbucket.org/dermetfan/libgdx-utils) that extends `AssetManager` and allows -to specify assets for loading by marking fields with annotations. However, it's annotation-based API relies -on reflection and is not really idiomatic in Kotlin. +to specify assets for loading by marking fields with annotations. #### Additional documentation From 1fac0b1eee08927b60d37d1ce91ee0553eda26e3 Mon Sep 17 00:00:00 2001 From: MJ Date: Mon, 30 Mar 2020 16:43:45 +0200 Subject: [PATCH 07/17] Errors moved to separate file. Added Identifier.toAssetDescriptor. #182 --- .../main/kotlin/ktx/assets/async/errors.kt | 107 ++++++++++++ .../main/kotlin/ktx/assets/async/storage.kt | 164 ++++++------------ .../kotlin/ktx/assets/async/storageTest.kt | 119 +++++++++++++ 3 files changed, 278 insertions(+), 112 deletions(-) create mode 100644 assets-async/src/main/kotlin/ktx/assets/async/errors.kt diff --git a/assets-async/src/main/kotlin/ktx/assets/async/errors.kt b/assets-async/src/main/kotlin/ktx/assets/async/errors.kt new file mode 100644 index 00000000..54c1d9b4 --- /dev/null +++ b/assets-async/src/main/kotlin/ktx/assets/async/errors.kt @@ -0,0 +1,107 @@ +package ktx.assets.async + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.AssetLoader +import com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.utils.GdxRuntimeException + +/** + * Thrown by [AssetStorage] and related services. + * [message] describes the problem, while [cause] is the optional cause of the exception. + * + * Note that [AssetStorage] usually throws subclasses of this exception, rather than + * instances of this exception directly. This class acts as the common superclass + * with which all [AssetStorage]-related exceptions can be caught and handled. + */ +open class AssetStorageException(message: String, cause: Throwable? = null) : GdxRuntimeException(message, cause) + +/** + * Thrown when the asset requested by [AssetStorage.get] is not available in the [AssetStorage]. + */ +class MissingAssetException(identifier: Identifier<*>) : + AssetStorageException(message = "Asset: $identifier is not loaded.") + +/** + * Thrown by [AssetStorage.load] or [AssetStorage.get] when the requested asset + * was unloaded asynchronously. + */ +class UnloadedAssetException(identifier: Identifier<*>) : + AssetStorageException(message = "Asset: $identifier was unloaded.") + +/** + * Thrown by [AssetStorage.add] when attempting to add an asset with [Identifier] + * that is already present in the [AssetStorage]. + */ +class AlreadyLoadedAssetException(identifier: Identifier<*>) : + AssetStorageException(message = "Asset: $identifier was already added to storage.") + +/** + * Thrown by [AssetStorage.load] when the [AssetLoader] for the requested asset type + * and path is unavailable. See [AssetStorage.setLoader]. + */ +class MissingLoaderException(descriptor: AssetDescriptor<*>) : + AssetStorageException( + message = "No loader available for assets of type: ${descriptor.type} " + + "with path: ${descriptor.fileName}." + ) + +/** + * Thrown by [AssetStorage.load] or [AssetStorage.get] when the asset failed to load + * due to invalid loader implementation. Since loaders are pre-validated during + * registration, normally this exception is extremely rare and caused by invalid + * [AssetStorage.setLoader] usage. + */ +class InvalidLoaderException(loader: Loader<*>) : + AssetStorageException( + message = "Invalid loader: $loader. It must extend either " + + "SynchronousAssetLoader or AsynchronousAssetLoader." + ) + +/** + * Thrown by [AssetStorage.load] or [AssetStorage.get] when the asset failed to load + * due to an unexpected loading exception, usually thrown by the associated [AssetLoader]. + */ +class AssetLoadingException(descriptor: AssetDescriptor<*>, cause: Throwable) + : AssetStorageException(message = "Unable to load asset: $descriptor", cause = cause) + +/** + * [AssetStorage] reuses official [AssetLoader] implementations to load the assets. + * [SynchronousAssetLoader] and [AsynchronousAssetLoader] both expect an instance of [AssetManager] + * to perform some basic operations on assets. To support the loaders API, [AssetStorage] is wrapped + * with an [AssetManagerWrapper] which delegates supported methods to [AssetStorage] and throws + * this exception otherwise. + * + * Most official loaders only call [AssetManager.get] to obtain asset dependencies, but custom loaders + * can perform operations that are unsupported by [AssetStorage] due to its asynchronous nature + * and storing assets mapped by path and type rather than path alone. If this exception causes the loading + * to fail, [AssetLoader] associated with the asset has to be refactored. + */ +class UnsupportedMethodException(method: String) : + AssetStorageException( + message = "AssetLoader used unsupported operation of AssetManager wrapper: $method " + + "Please refactor AssetLoader not to call this method on AssetManager." + ) + +/** + * This exception is only ever thrown when trying to access assets via [AssetManagerWrapper]. + * It is typically only called by [AssetLoader] instances. + * + * If this exception is thrown, it means that [AssetLoader] attempts to access an asset that either: + * - Is already unloaded. + * - Failed to load with exception. + * - Was not listed by [AssetLoader.getDependencies]. + * - Has not loaded yet, which should never happen if the dependency was listed correctly. + * + * This exception is only expected in case of concurrent loading and unloading of the same asset. + * If it occurs otherwise, the [AssetLoader] associated with the asset might incorrect list + * asset's dependencies. + */ +class MissingDependencyException(identifier: Identifier<*>, cause: Throwable? = null) : + AssetStorageException( + message = "A loader has requested an instance of ${identifier.type} at path ${identifier.path}. " + + "This asset was either not listed in dependencies, loaded with exception, not loaded yet " + + "or unloaded asynchronously.", + cause = cause + ) diff --git a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt index 26feab29..83ffb77a 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt @@ -27,19 +27,20 @@ import com.badlogic.gdx.graphics.g3d.particles.ParticleEffectLoader as ParticleE * * Note that [KtxAsync.initiate] must be called before creating an [AssetStorage]. * - * [fileResolver] determines how file paths are interpreted. Defaults to [InternalFileHandleResolver], which loads - * internal files. - * * [asyncContext] is used to perform asynchronous file loading. Defaults to a single-threaded context using an * [AsyncExecutor]. See [newSingleThreadAsyncContext] or [ktx.async.newAsyncContext] functions to create a custom * loading context. Multi-threaded contexts are supported and might boost loading performance if the assets * are loaded asynchronously. * - * If `useDefaultLoaders` is true (which is the default), all default LibGDX asset loaders will be registered. + * [fileResolver] determines how file paths are interpreted. Defaults to [InternalFileHandleResolver], which loads + * internal files. + * + * If `useDefaultLoaders` is true (which is the default), all default LibGDX [AssetLoader] implementations + * will be registered. */ class AssetStorage( - val fileResolver: FileHandleResolver = InternalFileHandleResolver(), val asyncContext: CoroutineContext = newSingleThreadAsyncContext(threadName = "AssetStorage-Thread"), + val fileResolver: FileHandleResolver = InternalFileHandleResolver(), useDefaultLoaders: Boolean = true ) : Disposable { @Suppress("LeakingThis") @@ -82,23 +83,28 @@ class AssetStorage( * Uses reified [T] type to obtain the asset class. * * [T] is type of the loaded asset. - * [path] to the file should be consistent with [fileResolver] asset type. + * [path] to the file must be consistent with [fileResolver] asset type. */ - inline fun getIdentifier(path: String): Identifier = Identifier(T::class.java, path) + inline fun getIdentifier(path: String): Identifier = Identifier(T::class.java, path.normalizePath()) /** * Creates a new [AssetDescriptor] for the selected asset. + * * [T] is type of the loaded asset. * [path] to the file should be consistent with [fileResolver] asset type. * Loading [parameters] are optional and passed to the associated [AssetLoader]. * Returns a new instance of [AssetDescriptor] with a resolved [FileHandle]. + * + * If the asset requires a [FileHandle] incompatible with the storage [fileResolver], + * use the [fileHandle] parameter to set it. */ inline fun getAssetDescriptor( path: String, - parameters: AssetLoaderParameters? = null + parameters: AssetLoaderParameters? = null, + fileHandle: FileHandle? = null ): AssetDescriptor { val descriptor = AssetDescriptor(path.normalizePath(), T::class.java, parameters) - descriptor.file = fileResolver.resolve(path) + descriptor.file = fileHandle ?: fileResolver.resolve(path) return descriptor } @@ -264,7 +270,7 @@ class AssetStorage( * Throws [AlreadyLoadedAssetException] if an asset with the same path is already loaded or scheduled for loading. */ suspend fun add(identifier: Identifier, asset: T) = - add(AssetDescriptor(identifier.path, identifier.type), asset) + add(identifier.toAssetDescriptor(), asset) /** * Adds a fully loaded [asset] to the storage. Allows to avoid loading the asset with the [AssetStorage] @@ -344,7 +350,7 @@ class AssetStorage( * and caused them to load in the first place. */ suspend fun load(identifier: Identifier, parameters: AssetLoaderParameters? = null): T = - load(AssetDescriptor(identifier.path, identifier.type, parameters)) + load(identifier.toAssetDescriptor(parameters)) /** * Schedules loading of an asset of [T] type described by the [descriptor]. @@ -801,121 +807,55 @@ internal data class Asset( @Volatile var referenceCount: Int = 0 ) -/** - * Thrown by [AssetStorage] and related services. - * [message] describes the problem, while [cause] is the optional cause of the exception. - * - * Note that [AssetStorage] usually throws subclasses of this exception, rather than - * instances of this exception directly. This class acts as the common superclass - * with which all [AssetStorage]-related exceptions can be caught and handled. - */ -open class AssetStorageException(message: String, cause: Throwable? = null) : GdxRuntimeException(message, cause) - -/** - * Thrown when the asset requested by [AssetStorage.get] is not available in the [AssetStorage]. - */ -class MissingAssetException(identifier: Identifier<*>) : - AssetStorageException(message = "Asset: $identifier is not loaded.") - -/** - * Thrown by [AssetStorage.load] or [AssetStorage.get] when the requested asset - * was unloaded asynchronously. - */ -class UnloadedAssetException(identifier: Identifier<*>) : - AssetStorageException(message = "Asset: $identifier was unloaded.") - -/** - * Thrown by [AssetStorage.add] when attempting to add an asset with [Identifier] - * that is already present in the [AssetStorage]. - */ -class AlreadyLoadedAssetException(identifier: Identifier<*>) : - AssetStorageException(message = "Asset: $identifier was already added to storage.") - -/** - * Thrown by [AssetStorage.load] when the [AssetLoader] for the requested asset type - * and path is unavailable. See [AssetStorage.setLoader]. - */ -class MissingLoaderException(descriptor: AssetDescriptor<*>) : - AssetStorageException( - message = "No loader available for assets of type: ${descriptor.type} " + - "with path: ${descriptor.fileName}." - ) - -/** - * Thrown by [AssetStorage.load] or [AssetStorage.get] when the asset failed to load - * due to invalid loader implementation. Since loaders are pre-validated during - * registration, normally this exception is extremely rare and caused by invalid - * [AssetStorage.setLoader] usage. - */ -class InvalidLoaderException(loader: Loader<*>) : - AssetStorageException( - message = "Invalid loader: $loader. It must extend either " + - "SynchronousAssetLoader or AsynchronousAssetLoader." - ) - -/** - * Thrown by [AssetStorage.load] or [AssetStorage.get] when the asset failed to load - * due to an unexpected loading exception, usually thrown by the associated [AssetLoader]. - */ -class AssetLoadingException(descriptor: AssetDescriptor<*>, cause: Throwable) - : AssetStorageException(message = "Unable to load asset: $descriptor", cause = cause) - -/** - * [AssetStorage] reuses official [AssetLoader] implementations to load the assets. - * [SynchronousAssetLoader] and [AsynchronousAssetLoader] both expect an instance of [AssetManager] - * to perform some basic operations on assets. To support the loaders API, [AssetStorage] is wrapped - * with an [AssetManagerWrapper] which delegates supported methods to [AssetStorage] and throws - * this exception otherwise. - * - * Most official loaders only call [AssetManager.get] to obtain asset dependencies, but custom loaders - * can perform operations that are unsupported by [AssetStorage] due to its asynchronous nature - * and storing assets mapped by path and type rather than path alone. If this exception causes the loading - * to fail, [AssetLoader] associated with the asset has to be refactored. - */ -class UnsupportedMethodException(method: String) : - AssetStorageException( - message = "AssetLoader used unsupported operation of AssetManager wrapper: $method " + - "Please refactor AssetLoader not to call this method on AssetManager." - ) - -/** - * This exception is only ever thrown when trying to access assets via [AssetManagerWrapper]. - * It is typically only called by [AssetLoader] instances. - * - * If this exception is thrown, it means that [AssetLoader] attempts to access an asset that either: - * - Is already unloaded. - * - Failed to load with exception. - * - Was not listed by [AssetLoader.getDependencies]. - * - Has not loaded yet, which should never happen if the dependency was listed correctly. - * - * This exception is only expected in case of concurrent loading and unloading of the same asset. - * If it occurs otherwise, the [AssetLoader] associated with the asset might incorrect list - * asset's dependencies. - */ -class MissingDependencyException(identifier: Identifier<*>, cause: Throwable? = null) : - AssetStorageException( - message = "A loader has requested an instance of ${identifier.type} at path ${identifier.path}. " + - "This asset was either not listed in dependencies, loaded with exception, not loaded yet " + - "or unloaded asynchronously.", - cause = cause - ) - /** * Uniquely identifies a single asset stored in an [AssetStorage] by its [type] and [path]. * * Multiple assets with the same [path] can be stored in an [AssetStorage] as long as they * have a different [type]. Similarly, [AssetStorage] can store multiple assets of the same * [type], as long as each has a different [path]. + * + * Avoid using [Identifier] constructor directly. Instead, rely on [AssetStorage.getIdentifier] + * or [AssetDescriptor.toIdentifier]. */ data class Identifier( /** [Class] of the asset specified during loading. */ val type: Class, - /** File path to the asset compatible with the [AssetStorage.fileResolver]. */ + /** File path to the asset compatible with the [AssetStorage.fileResolver]. Must be normalized. */ val path: String -) +) { + /** + * Converts this [Identifier] to an [AssetDescriptor] that describes the asset and its loading data. + * + * If the returned [AssetDescriptor] is used to load an asset, and the asset requires specific loading + * instructions, make sure to pass the loading [parameters] to set [AssetDescriptor.parameters]. Similarly, + * if the asset requires a custom [FileHandle] incompatible with [AssetStorage.fileResolver], pass the + * [fileHandle] parameter to set it as [AssetDescriptor.file]. + * + * If the [AssetDescriptor] is used to simply identify an asset similarly to an [Identifier], + * [parameters] and [fileHandle] are not required. You can retrieve a loaded asset from the + * [AssetStorage] with either its [Identifier] or an [AssetDescriptor] without loading data - + * the parameters and file are only used when calling [AssetStorage.load]. + */ + fun toAssetDescriptor( + parameters: AssetLoaderParameters? = null, fileHandle: FileHandle? = null + ): AssetDescriptor = + AssetDescriptor(path, type, parameters).apply { + if (fileHandle != null) { + file = fileHandle + } + } +} /** * Converts this [AssetDescriptor] to an [AssetStorage] [Identifier]. * Copies [AssetDescriptor.type] to [Identifier.type] and [AssetDescriptor.fileName] to [Identifier.path]. + * + * Note that loading parameters from [AssetDescriptor.parameters] are ignored. If the returned [Identifier] + * is used to load an asset, and the asset requires specific loading instructions, make sure to pass the + * loading parameters to the [AssetStorage.load] method. + * + * Similarly, [AssetDescriptor.file] is not used by the [Identifier]. Instead, [AssetDescriptor.fileName] + * will be used to resolve the file using [AssetStorage.fileResolver]. If a [FileHandle] of different type + * is required, use [AssetDescriptor] for loading instead. */ fun AssetDescriptor.toIdentifier(): Identifier = Identifier(type, fileName) diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt index 50645290..633062a1 100644 --- a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt @@ -1437,6 +1437,53 @@ class AssetStorageTest : AsyncTest() { assertEquals(emptyList(), storage.getDependencies(descriptor)) } + @Test + fun `should unload assets with path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should unload assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val descriptor = storage.getAssetDescriptor(path) + runBlocking { storage.load(descriptor) } + + // When: + runBlocking { storage.unload(descriptor) } + + // Then: + assertFalse(storage.isLoaded(descriptor)) + assertEquals(0, storage.getReferenceCount(descriptor)) + } + + @Test + fun `should unload assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val identifier = storage.getIdentifier(path) + runBlocking { storage.load(identifier) } + + // When: + runBlocking { storage.unload(identifier) } + + // Then: + assertFalse(storage.isLoaded(identifier)) + assertEquals(0, storage.getReferenceCount(identifier)) + } + @Test fun `should differentiate assets by path and type`() { // Given: @@ -2290,6 +2337,35 @@ class AssetStorageTest : AsyncTest() { assertSame(parameters, descriptor.params) } + @Test + fun `should create AssetDescriptor with a custom file`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val file = mock() + + // When: + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt", fileHandle = file) + + // Then: + assertEquals("ktx/assets/async/string.txt", descriptor.fileName) + assertEquals(String::class.java, descriptor.type) + assertSame(file, descriptor.file) + } + + @Test + fun `should create Identifier`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "my\\file.png" + + // When: + val identifier = storage.getIdentifier(path) + + // Then: should normalize path and extract reified type: + assertEquals("my/file.png", identifier.path) + assertSame(Vector2::class.java, identifier.type) + } + @Test fun `should normalize file paths`() { // Given: @@ -2326,6 +2402,49 @@ class AssetStorageTest : AsyncTest() { assertEquals(assetDescriptor.type, identifier.type) } + @Test + fun `should convert Identifier to AssetDescriptor`() { + // Given: + val identifier = Identifier(String::class.java, "file.path") + + // When: + val assetDescriptor = identifier.toAssetDescriptor() + + // Then: + assertEquals("file.path", assetDescriptor.fileName) + assertSame(String::class.java, assetDescriptor.type) + } + + @Test + fun `should convert Identifier to AssetDescriptor with loading parameters`() { + // Given: + val identifier = Identifier(String::class.java, "file.path") + val parameters = mock>() + + // When: + val assetDescriptor = identifier.toAssetDescriptor(parameters) + + // Then: + assertEquals("file.path", assetDescriptor.fileName) + assertSame(String::class.java, assetDescriptor.type) + assertSame(parameters, assetDescriptor.params) + } + + @Test + fun `should convert Identifier to AssetDescriptor with a custom file`() { + // Given: + val identifier = Identifier(String::class.java, "file.path") + val file = mock() + + // When: + val assetDescriptor = identifier.toAssetDescriptor(fileHandle = file) + + // Then: + assertEquals("file.path", assetDescriptor.fileName) + assertSame(String::class.java, assetDescriptor.type) + assertSame(file, assetDescriptor.file) + } + /** For [Disposable.dispose] interface testing and loaders testing. */ class FakeAsset : Disposable { val disposingFinished = CompletableFuture() From 1ecbe00f3fba1542e57ac0cb5460b3ba75df1457 Mon Sep 17 00:00:00 2001 From: MJ Date: Mon, 30 Mar 2020 16:43:53 +0200 Subject: [PATCH 08/17] Documentation improvements. #182 --- assets-async/README.md | 104 ++++++++++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/assets-async/README.md b/assets-async/README.md index 67071147..486d1813 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -17,7 +17,7 @@ the assets are loaded. This **KTX** module brings an `AssetManager` alternative - `AssetStorage`. It leverages Kotlin coroutines for asynchronous operations. It ensures thread safety by using a single non-blocking `Mutex` for a minimal set of operations mutating its state, while supporting truly multi-threaded asset loading -on any `CoroutineScope`. +on any `CoroutineContext`. Feature | **KTX** `AssetStorage` | LibGDX `AssetManager` --- | --- | --- @@ -25,12 +25,12 @@ Feature | **KTX** `AssetStorage` | LibGDX `AssetManager` *Synchronous loading* | **Limited.** A blocking coroutine can be launched to selected assets eagerly, but it cannot block the rendering thread or loader threads to work correctly. | **Limited.** `finishLoading(String fileName)` method can be used to block the thread until the asset is loaded, but since it has no effect on loading order, all _other_ assets can be loaded before the requested one. *Thread safety* | **Excellent.** Forces [`ktx-async`](../async) threading model based on coroutines. Executes blocking IO operations in a separate coroutine context and - when necessary - finishes loading on the main rendering thread. Same asset - or assets with same dependencies - can be safely scheduled for loading by multiple coroutines concurrently. Multi-threaded coroutine context can be used for asynchronous loading, possibly achieving loading performance boost. Concurrent `AssetStorage` usage is tested extensively by unit tests. | **Good.** Achieved through synchronizing most methods, which unfortunately blocks the threads that use them. Thread blocking might affect application performance, especially since even the basic `get` method is synchronized. *Concurrency* | **Supported.** Multiple asset loading coroutines can be launched in parallel. Coroutine context used for asynchronous loading can have multiple threads that will be used concurrently. | **Not supported.** `update()` loads assets one by one. `AsyncExecutor` with a single thread is used internally by the `AssetManager`. To utilize multiple threads for loading, one must use multiple manager instances. -*Loading order* | **Controlled by the user.** `AssetStorage` starts loading assets as soon as the `load` method is called, giving the user full control over the order of asset loading. Selected assets can be loaded one after another or in parallel, depending on the need. | **Unpredictable.** If multiple assets are scheduled at once, it is difficult to reason about their loading order. `finishLoading` has no effect on loading order and instead blocks the thread until the asset is loaded. +*Loading order* | **Controlled by the user.** `AssetStorage` starts loading assets as soon as the `load` method is called, giving the user full control over the order of asset loading. Selected assets can be loaded one after another within a single coroutine or in parallel with multiple coroutines, depending on the need. | **Unpredictable.** If multiple assets are scheduled at once, it is difficult to reason about their loading order. `finishLoading` has no effect on loading order and instead blocks the thread until the asset is loaded. *Exceptions* | **Customized.** All expected issues are given separate exception classes with common root type for easier handling. Each loading issue can be handled differently. | **Generic.** Throws either `GdxRuntimeException` or a built-in Java runtime exception. Specific issues are difficult to handle separately. -*Error handling* | **Build-in language syntax.** Use a regular try-catch block within coroutine body to handle loading errors. Provides a clean way to handle exceptions thrown by each asset separately. | **Via listener.** One can register a global error handling listener that will be notified if a loading exception is thrown. Flow of the application is undisturbed, which makes it difficult to handle exceptions. +*Error handling* | **Build-in language syntax.** A regular try-catch block within coroutine body can be used to handle loading errors. Provides a clean way to handle exceptions thrown by each asset separately. | **Via listener.** One can register a global error handling listener that will be notified if a loading exception is thrown. Flow of the application is undisturbed, which makes it difficult to handle exceptions of specific assets. *File name collisions* | **Multiple assets of different types can be loaded from same path.** For example, you can load both a `Texture` and a `Pixmap` from the same PNG file. | **File paths act as unique identifiers.** `AssetManager` cannot store multiple assets with the same path, even if they have different types. -*Progress tracking* | **Limited.** Since `AssetStorage` does not force the users to schedule loading of all assets up front, it does not know the exact percent of loaded assets. Progress must be tracked externally. | **Supported.** Since all loaded assets have to be scheduled up front, `AssetManager` can track total loading progress. -*Usage* | **Launch coroutine, load assets, use as soon as loaded.** Asynchronous complexity is hidden by the coroutines and looks like regular synchronous code. | **Schedule loading, update in loop until loaded, extract from manager.** API based on polling _(are you done yet?)_ rather than callbacks, which might prove tedious during loading phase. Event listeners or callbacks are not supported. +*Progress tracking* | **Limited.** Since `AssetStorage` does not force the users to schedule loading of all assets up front, it does not know the exact percent of the loaded assets. Progress must be tracked externally. | **Supported.** Since all loaded assets have to be scheduled up front, `AssetManager` can track total loading progress. +*Usage* | **Launch coroutine, load assets, use as soon as loaded.** Asynchronous complexity is hidden by the coroutines. | **Schedule loading, update in loop until loaded, extract from manager.** API based on polling _(are you done yet?)_ rather than callbacks, which might prove tedious during loading phase. Event listeners or callbacks are not supported. #### Usage comparison @@ -59,7 +59,7 @@ class Application: ApplicationAdapter() { finishLoading() } // Render loading prompt here. - // Other than slight performance impact of calling update() each frame, + // Other than performance impact of calling synchronized update() each frame, // AssetManager does not block the rendering thread. } @@ -151,30 +151,40 @@ disposing of the asset. loadings. Depending on the variant, will block the current thread or suspend the coroutine until all of the assets are unloaded. -Additional debugging and management methods are available: +Additional asset management methods include: -- `getLoader` - allows to obtain `AssetLoader` instance for the given file. -- `setLoader` - allows to associate a custom `AssetLoader` with the selected file and asset types. - `isLoaded: Boolean` - checks if the selected asset is fully loaded. - `contains: Boolean` - checks if the selected asset is present in storage, loaded or not. - `getReferenceCount: Int` - allows to check how many times the asset was loaded, added or required -as dependency by other assets. Returns 0 if the asset is not present in storage. +as dependency by other assets. Returns 0 if the asset is not present in the storage. - `getDependencies: List` - returns list of dependencies of the selected asset. If the asset is not present in the storage, an empty list will be returned. +- `getLoader: AssetLoader` - allows to obtain `AssetLoader` instance for the given file. +- `setLoader` - allows to associate a custom `AssetLoader` with the selected file and asset types. -Assets are uniquely identified by their path and `Class` by the storage. +`AssetStorage` uniquely identifies assets by their path and `Class`. Since these values can be passed in 3 basic ways, most methods are available in 3 variants: - Inlined, with reified type and `String` path parameter. -- With `Identifier` parameter, which stores `Class` and path of the asset. -- With LibGDX `AssetDescriptor` storing `Class`, path and loading data of the asset. +- With `Identifier` parameter, which stores `Class` and `String` path of the asset. +- With LibGDX `AssetDescriptor` storing `Class`, `String` file name and loading data of the asset. All three variants behave identically and are available for convenience. +If any asset data is missing from either `String` path or `Identifier`, additional parameters are available +to match the `AssetDescriptor` API. + To ease the API usage, the following utilities are provided: - `AssetStorage.getAssetDescriptor` - creates an `AssetDescriptor` instance that has loading data of an asset. - `AssetStorage.getIdentifier` - creates an `Identifier` instance that uniquely identifies a stored asset. - `AssetDescriptor.toIdentifier` - converts an `AssetDescriptor` to an `Identifier`. +- `Identifier.toAssetDescriptor` - converts an `Identifier` to an `AssetDescriptor` with optional loading parameters. + +#### Error handling + +`AssetStorage` throws exceptions extending the `AssetStorageException` class. All of its subclasses are documented, +explaining when and why they are thrown. +Please refer to the [sources documentation](src/main/kotlin/ktx/assets/async/errors.kt) for further details. ### Usage examples @@ -187,6 +197,7 @@ import ktx.async.KtxAsync fun create() { // Necessary to initiate the coroutines context: KtxAsync.initiate() + val assetStorage = AssetStorage() } ``` @@ -202,6 +213,7 @@ import ktx.async.newAsyncContext fun create() { KtxAsync.initiate() + val assetStorage = AssetStorage( // Used to perform asynchronous file loading: asyncContext = newAsyncContext(threads = 4), @@ -347,7 +359,7 @@ fun accessAsset(assetStorage: AssetStorage) { assetStorage.contains("images/logo.png") // Returns true if the asset loading has finished: assetStorage.isLoaded("images/logo.png") - // Checks how many times the asset was loaded or used as dependency: + // Checks how many times the asset was loaded or used as a dependency: assetStorage.getReferenceCount("images/logo.png") // Returns a list of dependencies loaded along with the asset: assetStorage.getDependencies("images/logo.png") @@ -401,6 +413,7 @@ Disposing of all assets stored by `AssetStorage`: ```kotlin // Will block the current thread to unload all assets: assetStorage.dispose() + // This will also disrupt loading of all unloaded assets. // Disposing errors are logged by default, but do not // cancel the process. @@ -415,10 +428,11 @@ import ktx.assets.async.AssetStorage import ktx.async.KtxAsync fun unloadAllAssets(assetStorage: AssetStorage) { - KtxAsync.launch { + KtxAsync.launch { + // Suspends the coroutine until all assets are disposed of: assetStorage.dispose { identifier, exception -> // This lambda will be invoked for each encountered disposing error: - Gdx.app.error("KTX","Unable to dispose of asset: $identifier", exception) + Gdx.app.error("KTX", "Unable to dispose of asset: $identifier", exception) } } } @@ -436,17 +450,18 @@ import ktx.async.KtxAsync fun loadAsset(assetStorage: AssetStorage) { KtxAsync.launch { - // You can handle loading errors with a classic try-catch: + // You can handle loading errors with a classic try-catch block: try { val texture = assetStorage.load("images/logo.png") } catch (exception: AssetLoadingException) { // Asset loader threw an exception - unable to load the asset. } catch (exception: AssetStorageException) { - // Other error occurred. See AssetStorageException subclasses. + // Another error occurred. See AssetStorageException subclasses. } - // Note that is the asset threw an exception during loading, - // the exception will be rethrown by Deferred from `get`. + // Note that if the asset loading ended with an exception, + // the same exception will be rethrown each time the asset + // is accessed with `get.await()` or `load`. } } ``` @@ -458,8 +473,7 @@ import ktx.assets.async.AssetStorage fun createCustomAssetStorage(): AssetStorage { val assetStorage = AssetStorage() - // Custom asset loaders should be added before - // loading any assets: + // Custom asset loaders should be added before loading any assets: assetStorage.setLoader(suffix = ".file.extension") { MyCustomAssetLoader(assetStorage.fileResolver) } @@ -475,13 +489,26 @@ fun createCustomAssetStorage(): AssetStorage { ##### Multiple calls of `load` and `unload` -It is completely safe to call `load` multiple times, even to obtain asset instances without dealing -with `Deferred`. In that sense, it can be used as an alternative to `get`. Instead of loading the same -asset multiple times, `AssetStorage` will just increase the reference count to the asset and return -the same instance on each request. +It is completely safe to call `load` multiple times with the same asset data, even to obtain asset instances +without dealing with `Deferred`. In that sense, it can be used as an alternative to `get`. + +Instead of loading the same asset multiple times, `AssetStorage` will just increase the reference count +to the asset and return the same instance on each request. This also works concurrently - the storage will +always load just one asset instance, regardless of how many different threads and coroutines called `load` +in parallel. + +However, to eventually unload the asset, you have to call `unload` the same number of times as `load`, +or simply dispose of all assets with `dispose`, which clears all reference counts and unloads everything +from the storage. + +Unlike `load`, `add` should be called only once on a single asset, and only when the path and type are absent +in the storage. You cannot add existing assets to the storage, even if they were previously loaded by it. +This is because if we are not sure that `AssetStorage` handled the loading (or creation) of an object, tracking +its dependencies and lifecycle is difficult and left to the user. Trying to `add` an existing asset will not +increase its reference count, and instead throw an exception. -However, to eventually unload the asset, you have to call `unload` the same number of times as `load`. -That, or simply dispose of all assets with `dispose`, which clears all reference counts and unloads everything. +Adding assets that were previously loaded by the `AssetStorage` under different paths is also a misuse of the API, +which might result in unloading the asset or its dependencies prematurely. True aliases are currently unsupported. ##### `runBlocking` @@ -507,7 +534,7 @@ class App : ApplicationAdapter() { override fun create() { KtxAsync.initiate() val assetStorage = AssetStorage() - runBlocking { + runBlocking { // <- !!! Do NOT do this. !!! assetStorage.load("images/logo.png") } println("Will never be printed.") @@ -518,7 +545,7 @@ class App : ApplicationAdapter() { This is because `AssetStorage` needs access to the main rendering thread to finish loading the `Texture` with OpenGL context, but we have blocked the main rendering thread to wait for the asset. -In as similar manner, this example blocks the only thread assigned to `AssetStorage` for asynchronous operations: +In a similar manner, this example blocks the only thread assigned to `AssetStorage` for asynchronous operations: ```kotlin import com.badlogic.gdx.ApplicationAdapter @@ -539,8 +566,9 @@ class App : ApplicationAdapter() { KtxAsync.initiate() val asyncContext = newSingleThreadAsyncContext() val assetStorage = AssetStorage(asyncContext = asyncContext) + // Launching coroutine on the storage thread: KtxAsync.launch(asyncContext) { - runBlocking { + runBlocking { // <- !!! Do NOT do this. !!! assetStorage.load("images/logo.png") } println("Will never be printed and AssetStorage will be unusable.") @@ -549,16 +577,17 @@ class App : ApplicationAdapter() { } ``` -As a rule of thumb, you should prefer to use suspending `AssetStorage` methods only from non-blocking coroutines, -e.g. those launched with `KtxAsync.launch` or `GlobalScope.launch`. If you change `runBlocking` to a proper coroutine -launch in either of the examples, you will notice that the deadlocks no longer occur. +As a rule of thumb, you should use suspending `AssetStorage` methods only from non-blocking coroutines, e.g. those +launched with `KtxAsync.launch` or `GlobalScope.launch`. If you change `runBlocking` to a proper coroutine launch +in either of the examples, you will notice that the deadlocks no longer occur. It does not mean that `runBlocking` will always cause a deadlock, however. You can safely use `runBlocking`: -- From within other threads than the main rendering thread and the `AssetStorage` loading threads. - For `dispose`, both suspending and non-suspending variants. - For all non-suspending methods such as `contains`, `isLoaded`, `getReferenceCount`, `setLoader`, `getLoader`. -- For `load` and `get` calls requesting already loaded assets. **With caution.** +- For `load` and `get.await` calls requesting already loaded assets. **Use with caution.** +- From within other threads than the main rendering thread and the `AssetStorage` loading threads. These threads +will be blocked until the operation is finished, which isn't ideal, but at least the loading will remain possible. #### Synergy @@ -569,7 +598,7 @@ prove useful. ### Alternatives There seem to be no other coroutines-based asset loaders available. -However, LibGDX `AssetManager` is still viable when multi-threading is not a requirement. +However, LibGDX `AssetManager` is still viable when efficient parallel loading is not a requirement. Alternatives include: - Using [`AssetManager`](https://github.com/libgdx/libgdx/wiki/Managing-your-assets) directly. @@ -577,6 +606,7 @@ Alternatives include: - [`AnnotationAssetManager`](https://bitbucket.org/dermetfan/libgdx-utils/wiki/net.dermetfan.gdx.assets.AnnotationAssetManager) from [`libgdx-utils`](https://bitbucket.org/dermetfan/libgdx-utils) that extends `AssetManager` and allows to specify assets for loading by marking fields with annotations. +- Loading assets without a manager. #### Additional documentation From 2996debbd303e257ac4cb485f91ff1ed84f37bb0 Mon Sep 17 00:00:00 2001 From: MJ Date: Mon, 30 Mar 2020 18:08:40 +0200 Subject: [PATCH 09/17] Added support for AssetManager loading callbacks. #182 --- assets-async/README.md | 17 ++++- .../main/kotlin/ktx/assets/async/errors.kt | 13 +++- .../main/kotlin/ktx/assets/async/storage.kt | 34 +++++++++- .../main/kotlin/ktx/assets/async/wrapper.kt | 6 +- .../kotlin/ktx/assets/async/storageTest.kt | 62 +++++++++++++++++++ 5 files changed, 125 insertions(+), 7 deletions(-) diff --git a/assets-async/README.md b/assets-async/README.md index 486d1813..c097b673 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -30,7 +30,7 @@ Feature | **KTX** `AssetStorage` | LibGDX `AssetManager` *Error handling* | **Build-in language syntax.** A regular try-catch block within coroutine body can be used to handle loading errors. Provides a clean way to handle exceptions thrown by each asset separately. | **Via listener.** One can register a global error handling listener that will be notified if a loading exception is thrown. Flow of the application is undisturbed, which makes it difficult to handle exceptions of specific assets. *File name collisions* | **Multiple assets of different types can be loaded from same path.** For example, you can load both a `Texture` and a `Pixmap` from the same PNG file. | **File paths act as unique identifiers.** `AssetManager` cannot store multiple assets with the same path, even if they have different types. *Progress tracking* | **Limited.** Since `AssetStorage` does not force the users to schedule loading of all assets up front, it does not know the exact percent of the loaded assets. Progress must be tracked externally. | **Supported.** Since all loaded assets have to be scheduled up front, `AssetManager` can track total loading progress. -*Usage* | **Launch coroutine, load assets, use as soon as loaded.** Asynchronous complexity is hidden by the coroutines. | **Schedule loading, update in loop until loaded, extract from manager.** API based on polling _(are you done yet?)_ rather than callbacks, which might prove tedious during loading phase. Event listeners or callbacks are not supported. +*Usage* | **Launch coroutine, load assets, use as soon as loaded.** Asynchronous complexity is hidden by the coroutines. | **Schedule loading, update in loop until loaded, extract from manager.** API based on polling _(are you done yet?),_ which might prove tedious during loading phase. Loading callbacks are available, but have obscure API and still require constant updating of the manager. #### Usage comparison @@ -589,6 +589,21 @@ It does not mean that `runBlocking` will always cause a deadlock, however. You c - From within other threads than the main rendering thread and the `AssetStorage` loading threads. These threads will be blocked until the operation is finished, which isn't ideal, but at least the loading will remain possible. +##### Integration with LibGDX and known unsupported features + +`AssetStorage` does its best to integrate with LibGDX APIs - including the `AssetLoader` implementations, which were +designed for the `AssetManager`. [A dedicated wrapper](src/main/kotlin/ktx/assets/async/wrapper.kt) extends and +overrides `AssetManager`, delegating a subset of supported methods to `AssetStorage`. The official `AssetLoader` +implementations use supported methods such as `get`, but please note that some third-party loaders might not +work out of the box with `AssetStorage`. Exceptions related to broken loaders include `UnsupportedMethodException` +and `MissingDependencyException`. + +`AssetStorage`, even with its wrapper, cannot be used as drop-in replacement for `AssetManager` throughout the +official APIs. In particular, `Texture.setAssetManager` and `Cubemap.setAssetManager` are both unsupported. + +If you heavily rely on these unsupported APIs or custom asset loaders, you might need to use `AssetManager` instead. +See [`ktx-assets`](../assets) module for `AssetManager` utilities. + #### Synergy While [`ktx-assets`](../assets) module does provide some extensions to the `AssetManager`, which is a direct diff --git a/assets-async/src/main/kotlin/ktx/assets/async/errors.kt b/assets-async/src/main/kotlin/ktx/assets/async/errors.kt index 54c1d9b4..be663ea9 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/errors.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/errors.kt @@ -1,6 +1,7 @@ package ktx.assets.async import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters import com.badlogic.gdx.assets.AssetManager import com.badlogic.gdx.assets.loaders.AssetLoader import com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader @@ -67,6 +68,9 @@ class AssetLoadingException(descriptor: AssetDescriptor<*>, cause: Throwable) : AssetStorageException(message = "Unable to load asset: $descriptor", cause = cause) /** + * Thrown when unsupported methods are called on the [AssetManagerWrapper]. + * It is typically only caused by [AssetLoader] instances or a [AssetLoaderParameters.LoadedCallback]. + * * [AssetStorage] reuses official [AssetLoader] implementations to load the assets. * [SynchronousAssetLoader] and [AsynchronousAssetLoader] both expect an instance of [AssetManager] * to perform some basic operations on assets. To support the loaders API, [AssetStorage] is wrapped @@ -86,15 +90,18 @@ class UnsupportedMethodException(method: String) : /** * This exception is only ever thrown when trying to access assets via [AssetManagerWrapper]. - * It is typically only called by [AssetLoader] instances. + * It is typically only caused by [AssetLoader] instances or a [AssetLoaderParameters.LoadedCallback]. * - * If this exception is thrown, it means that [AssetLoader] attempts to access an asset that either: + * If this exception is thrown, it usually means that [AssetLoader] attempts to access an asset that either: * - Is already unloaded. * - Failed to load with exception. * - Was not listed by [AssetLoader.getDependencies]. * - Has not loaded yet, which should never happen if the dependency was listed correctly. * - * This exception is only expected in case of concurrent loading and unloading of the same asset. + * It can also be caused by an [AssetLoaderParameters.LoadedCallback] assigned to an asset when it tries + * to access unloaded assets with [AssetManagerWrapper.get]. + * + * Normally this exception is only expected in case of concurrent loading and unloading of the same asset. * If it occurs otherwise, the [AssetLoader] associated with the asset might incorrect list * asset's dependencies. */ diff --git a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt index 83ffb77a..3c92fe5f 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt @@ -323,6 +323,12 @@ class AssetStorage( * Note that to unload an asset, [unload] method should be called the same amount of times as [load]. * Asset dependencies should not be unloaded directly; instead, unload the asset that required them * and caused them to load in the first place. + * + * If the [parameters] define a [AssetLoaderParameters.loadedCallback], it will be invoked on the main + * rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. */ suspend inline fun load(path: String, parameters: AssetLoaderParameters? = null): T = load(getAssetDescriptor(path, parameters)) @@ -348,6 +354,12 @@ class AssetStorage( * Note that to unload an asset, [unload] method should be called the same amount of times as [load]. * Asset dependencies should not be unloaded directly; instead, unload the asset that required them * and caused them to load in the first place. + * + * If the [parameters] define a [AssetLoaderParameters.loadedCallback], it will be invoked on the main + * rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. */ suspend fun load(identifier: Identifier, parameters: AssetLoaderParameters? = null): T = load(identifier.toAssetDescriptor(parameters)) @@ -371,6 +383,12 @@ class AssetStorage( * Note that to unload an asset, [unload] method should be called the same amount of times as [load]. * Asset dependencies should not be unloaded directly; instead, unload the asset that required them * and caused them to load in the first place. + * + * If the [AssetDescriptor.params] define a [AssetLoaderParameters.loadedCallback], it will be invoked on + * the main rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. */ suspend fun load(descriptor: AssetDescriptor): T { lateinit var newAssets: List> @@ -515,7 +533,21 @@ class AssetStorage( private fun setLoaded(asset: Asset, value: T) { val isAssigned = asset.reference.complete(value) - if (!isAssigned) { + if (isAssigned) { + // The asset was correctly loaded and assigned. + try { + // Notifying the LibGDX loading callback to support AssetManager behavior: + asset.descriptor.params?.loadedCallback?.finishedLoading( + asAssetManager, asset.identifier.path, asset.identifier.type + ) + } catch (exception: Throwable) { + // We are unable to propagate the exception at this point, so we just log it: + logger.error( + "Exception occurred during execution of loaded callback of asset: ${asset.identifier}", + exception + ) + } + } else { // The asset was unloaded asynchronously. The deferred was likely completed with an exception. // Now we have to take care of the loaded value or it will remain loaded and unreferenced. try { diff --git a/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt index ce6b5644..06d850d2 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt @@ -126,7 +126,8 @@ internal class AssetManagerWrapper(val assetStorage: AssetStorage) } override fun getLoader(type: Class): AssetLoader<*, *>? = getLoader(type, "") - override fun getLoader(type: Class, fileName: String): AssetLoader<*, *>? = assetStorage.getLoader(type, fileName) + override fun getLoader(type: Class, fileName: String): AssetLoader<*, *>? = + assetStorage.getLoader(type, fileName) override fun isLoaded(assetDesc: AssetDescriptor<*>): Boolean = assetStorage.isLoaded(assetDesc) override fun isLoaded(fileName: String, type: Class<*>): Boolean = isLoaded(AssetDescriptor(fileName, type)) @@ -156,7 +157,8 @@ internal class AssetManagerWrapper(val assetStorage: AssetStorage) @Deprecated("AssetLoader instances can be mutable." + "AssetStorage requires functional providers of loaders rather than singular instances.", replaceWith = ReplaceWith("AssetStorage.setLoader")) - override fun ?> setLoader(type: Class, loader: AssetLoader) = setLoader(type, null, loader) + override fun ?> setLoader(type: Class, loader: AssetLoader) = + setLoader(type, null, loader) @Deprecated("AssetLoader instances can be mutable." + "AssetStorage requires functional providers of loaders rather than singular instances.", diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt index 633062a1..350fd67c 100644 --- a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt @@ -2445,6 +2445,68 @@ class AssetStorageTest : AsyncTest() { assertSame(file, assetDescriptor.file) } + @Test + fun `should invoke loaded callback on rendering thread to match AssetManager behavior`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val callbackFinished = CompletableFuture() + val callbackExecutions = AtomicInteger() + var callbackExecutedOnRenderingThread = false + lateinit var callbackManager: AssetManager + var callbackPath = "" + var callbackType = Any::class.java + val parameters = TextAssetLoaderParameters().apply { + loadedCallback = AssetLoaderParameters.LoadedCallback { assetManager, fileName, type -> + callbackExecutions.incrementAndGet() + callbackExecutedOnRenderingThread = KtxAsync.isOnRenderingThread() + callbackManager = assetManager + callbackPath = fileName + callbackType = type + callbackFinished.complete(true) + } + } + + // When: + runBlocking { storage.load(path, parameters) } + + // Then: + callbackFinished.join() + assertEquals(1, callbackExecutions.get()) + assertTrue(callbackExecutedOnRenderingThread) + assertTrue(callbackManager is AssetManagerWrapper) + assertSame(storage, (callbackManager as AssetManagerWrapper).assetStorage) + assertEquals(path, callbackPath) + assertSame(String::class.java, callbackType) + } + + @Test + fun `should log exceptions thrown by loading callbacks`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val loggingFinished = CompletableFuture() + val exception = IllegalStateException("Expected.") + val logger = mock { + on(it.error(any(), any())) doAnswer { loggingFinished.complete(true); Unit } + } + storage.logger = logger + val parameters = TextAssetLoaderParameters().apply { + loadedCallback = AssetLoaderParameters.LoadedCallback { _, _, _ -> + throw exception + } + } + + // When: + runBlocking { storage.load(path, parameters) } + + // Then: asset should still be loaded, but the callback exception must be logged: + loggingFinished.join() + assertTrue(storage.isLoaded(path)) + assertEquals("Content.", storage.get(path).joinAndGet()) + verify(logger).error(any(), eq(exception)) + } + /** For [Disposable.dispose] interface testing and loaders testing. */ class FakeAsset : Disposable { val disposingFinished = CompletableFuture() From f6602f6480c12cb03014d4329e3bcfdca452de6f Mon Sep 17 00:00:00 2001 From: MJ Date: Mon, 30 Mar 2020 21:11:09 +0200 Subject: [PATCH 10/17] Added ktx-assets-async and ktx-freetype-async to main README. #182 --- README.md | 12 +++++++----- assets-async/README.md | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 13528959..c52bcb42 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Examples of Kotlin language features used to improve usability, performance and * *Nullable types* which improve typing information of selected interfaces and functions. * *Default interface methods* simplifying the implementation. * *Type-safe builders* for GUI, styling and physics engine. -* *Coroutines context* providing concurrency utilities. +* *Coroutines context* providing concurrency utilities and non-blocking asset loading. * *Reified types* that simplify usage of methods normally consuming `Class` parameters. See the [_Choosing KTX_](https://github.com/libktx/ktx/wiki/Choosing-KTX) article for pros and cons of this framework. @@ -34,13 +34,15 @@ You can include selected **KTX** modules based on the needs of your application. Module | Dependency name | Description :---: | :--- | --- [actors](actors) | `ktx-actors` | General [`Scene2D`](https://github.com/libgdx/libgdx/wiki/Scene2d) GUI utilities for stages, actors, actions and event listeners. -[app](app) | `ktx-app` | `ApplicationListener` implementations and other general application utilities. +[app](app) | `ktx-app` | `ApplicationListener` implementations and general application utilities. [ashley](ashley) | `ktx-ashley` | [`Ashley`](https://github.com/libgdx/ashley) entity-component-system utilities. [assets](assets) | `ktx-assets` | Resources management utilities. +[assets-async](assets-async) | `ktx-assets-async` | Non-blocking asset loading using coroutines. [async](async) | `ktx-async` | [Coroutines](https://kotlinlang.org/docs/reference/coroutines.html) context based on LibGDX threading model. [box2d](box2d) | `ktx-box2d` | [`Box2D`](https://github.com/libgdx/libgdx/wiki/Box2d) physics engine utilities. [collections](collections) | `ktx-collections` | Extensions for LibGDX custom collections. -[freetype](freetype) | `ktx-freetype` | `FreeType` font loading utilities. +[freetype](freetype) | `ktx-freetype` | `FreeType` fonts loading utilities. +[freetype-async](freetype-async) | `ktx-freetype-async` | Non-blocking `FreeType` fonts loading using coroutines. [graphics](graphics) | `ktx-graphics` | Utilities related to rendering tools and graphics. [i18n](i18n) | `ktx-i18n` | Internationalization API utilities. [inject](inject) | `ktx-inject` | A simple dependency injection system with low overhead and no reflection usage. @@ -51,8 +53,8 @@ Module | Dependency name | Description [scene2d](scene2d) | `ktx-scene2d` | Type-safe Kotlin builders for [`Scene2D`](https://github.com/libgdx/libgdx/wiki/Scene2d) GUI. [style](style) | `ktx-style` | Type-safe Kotlin builders for `Scene2D` widget styles extending `Skin` API. [tiled](tiled) | `ktx-tiled` | Utilities for [Tiled](https://www.mapeditor.org/) maps. -[vis](vis) | `ktx-vis` | Type-safe Kotlin builders for [`VisUI`](https://github.com/kotcrab/vis-ui/). An _alternative_ to the [scene2d](scene2d) module. -[vis-style](vis-style) | `ktx-vis-style` | Type-safe Kotlin builders for `VisUI` widget styles. An _extension_ of [style](style) module. +[vis](vis) | `ktx-vis` | Type-safe Kotlin builders for [`VisUI`](https://github.com/kotcrab/vis-ui/). An _alternative_ to [scene2d](scene2d). +[vis-style](vis-style) | `ktx-vis-style` | Type-safe Kotlin builders for `VisUI` widget styles. ### Installation diff --git a/assets-async/README.md b/assets-async/README.md index c097b673..bdaad9fe 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -23,7 +23,7 @@ Feature | **KTX** `AssetStorage` | LibGDX `AssetManager` --- | --- | --- *Asynchronous loading* | **Supported.** Loading that can be done asynchronously is performed in the chosen coroutine context. Parts that require OpenGL context are performed on the main rendering thread. | **Supported.** Loading that can be performed asynchronously is done a dedicated thread, with necessary sections executed on the main rendering thread. *Synchronous loading* | **Limited.** A blocking coroutine can be launched to selected assets eagerly, but it cannot block the rendering thread or loader threads to work correctly. | **Limited.** `finishLoading(String fileName)` method can be used to block the thread until the asset is loaded, but since it has no effect on loading order, all _other_ assets can be loaded before the requested one. -*Thread safety* | **Excellent.** Forces [`ktx-async`](../async) threading model based on coroutines. Executes blocking IO operations in a separate coroutine context and - when necessary - finishes loading on the main rendering thread. Same asset - or assets with same dependencies - can be safely scheduled for loading by multiple coroutines concurrently. Multi-threaded coroutine context can be used for asynchronous loading, possibly achieving loading performance boost. Concurrent `AssetStorage` usage is tested extensively by unit tests. | **Good.** Achieved through synchronizing most methods, which unfortunately blocks the threads that use them. Thread blocking might affect application performance, especially since even the basic `get` method is synchronized. +*Thread safety* | **Excellent.** Forces [`ktx-async`](../async) threading model based on coroutines. Executes blocking IO operations in a separate coroutine context and - when necessary - finishes loading on the main rendering thread. Same asset - or assets with same dependencies - can be safely scheduled for loading by multiple coroutines concurrently. Multi-threaded coroutine context can be used for asynchronous loading, possibly achieving loading performance boost. Concurrent `AssetStorage` usage is tested extensively by unit tests. | **Good.** Achieved through synchronizing most methods, which unfortunately blocks the threads that use them. Thread blocking might affect application performance, especially since even the basic `get` method is synchronized. Some operations, such as `update` or `finishLoading`, must be called from specific threads (i.e. rendering thread). *Concurrency* | **Supported.** Multiple asset loading coroutines can be launched in parallel. Coroutine context used for asynchronous loading can have multiple threads that will be used concurrently. | **Not supported.** `update()` loads assets one by one. `AsyncExecutor` with a single thread is used internally by the `AssetManager`. To utilize multiple threads for loading, one must use multiple manager instances. *Loading order* | **Controlled by the user.** `AssetStorage` starts loading assets as soon as the `load` method is called, giving the user full control over the order of asset loading. Selected assets can be loaded one after another within a single coroutine or in parallel with multiple coroutines, depending on the need. | **Unpredictable.** If multiple assets are scheduled at once, it is difficult to reason about their loading order. `finishLoading` has no effect on loading order and instead blocks the thread until the asset is loaded. *Exceptions* | **Customized.** All expected issues are given separate exception classes with common root type for easier handling. Each loading issue can be handled differently. | **Generic.** Throws either `GdxRuntimeException` or a built-in Java runtime exception. Specific issues are difficult to handle separately. From 7e8003ec4d33e2f4c06cc49f0ffeec55fecaaf25 Mon Sep 17 00:00:00 2001 From: MJ Date: Wed, 1 Apr 2020 00:22:49 +0200 Subject: [PATCH 11/17] Added synchronous get and getOrNull to AssetStorage. #182 --- CHANGELOG.md | 6 +- README.md | 6 +- assets-async/README.md | 33 +- .../main/kotlin/ktx/assets/async/errors.kt | 5 +- .../main/kotlin/ktx/assets/async/storage.kt | 179 ++++++++-- .../main/kotlin/ktx/assets/async/wrapper.kt | 21 +- .../kotlin/ktx/assets/async/storageTest.kt | 312 ++++++++++++------ .../ktx/freetype/async/freetypeAsyncTest.kt | 25 +- 8 files changed, 412 insertions(+), 175 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e98d9590..8776aec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ - **[FEATURE]** (`ktx-ashley`) Added `Entity.contains` (`in` operator) that checks if an `Entity` has a `Component`. - **[FEATURE]** (`ktx-assets-async`) Added a new KTX module: coroutines-based asset loading. - `AssetStorage` is a non-blocking coroutines-based alternative to LibGDX `AssetManager`. - - `get` operator obtains an asset from the storage as `Deferred`. + - `get` operator obtains an asset from the storage or throws a `MissingAssetException`. + - `getOrNull` obtains an asset from the storage or return `null` if the asset is unavailable. + - `getAsync` obtains a reference to the asset from the storage as `Deferred`. - `load` schedules asynchronous loading of an asset. - `unload` schedules asynchronous unloading of an asset. - `add` allows to manually add a loaded asset to `AssetManager`. @@ -23,6 +25,8 @@ - `getDependencies` returns a list of dependencies of the selected asset. - `getAssetDescriptor` creates an `AssetDescriptor` with loading data for the selected asset. - `getIdentifier` creates an `Identifier` uniquely pointing to an asset of selected type and file path. + - `Identifier` data class added as an utility to uniquely identify assets by their type and path. + - `Identifier.toAssetDescriptor` allows to convert an `Identifier` to an `AssetDescriptor`. - `AssetDescriptor.toIdentifier` allows to convert an `AssetDescriptor` to `Identifier` used to uniquely identify `AssetStorage` assets. - **[FEATURE]** (`ktx-async`) Added `RenderingScope` factory function for custom scopes using rendering thread dispatcher. - **[FEATURE]** (`ktx-async`) `newAsyncContext` and `newSingleThreadAsyncContext` now support `threadName` parameter diff --git a/README.md b/README.md index c52bcb42..5ad89a6a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Examples of Kotlin language features used to improve usability, performance and * *Extension methods* with sensible *default parameters*. * *Inline methods* with reduced runtime overhead for various listeners, builders and loggers. * *Nullable types* which improve typing information of selected interfaces and functions. -* *Default interface methods* simplifying the implementation. +* *Default interface methods* for common interfaces, simplifying their implementations. * *Type-safe builders* for GUI, styling and physics engine. * *Coroutines context* providing concurrency utilities and non-blocking asset loading. * *Reified types* that simplify usage of methods normally consuming `Class` parameters. @@ -33,7 +33,7 @@ You can include selected **KTX** modules based on the needs of your application. Module | Dependency name | Description :---: | :--- | --- -[actors](actors) | `ktx-actors` | General [`Scene2D`](https://github.com/libgdx/libgdx/wiki/Scene2d) GUI utilities for stages, actors, actions and event listeners. +[actors](actors) | `ktx-actors` | [`Scene2D`](https://github.com/libgdx/libgdx/wiki/Scene2d) GUI extensions for stages, actors, actions and event listeners. [app](app) | `ktx-app` | `ApplicationListener` implementations and general application utilities. [ashley](ashley) | `ktx-ashley` | [`Ashley`](https://github.com/libgdx/ashley) entity-component-system utilities. [assets](assets) | `ktx-assets` | Resources management utilities. @@ -45,7 +45,7 @@ Module | Dependency name | Description [freetype-async](freetype-async) | `ktx-freetype-async` | Non-blocking `FreeType` fonts loading using coroutines. [graphics](graphics) | `ktx-graphics` | Utilities related to rendering tools and graphics. [i18n](i18n) | `ktx-i18n` | Internationalization API utilities. -[inject](inject) | `ktx-inject` | A simple dependency injection system with low overhead and no reflection usage. +[inject](inject) | `ktx-inject` | A dependency injection system with low overhead and no reflection usage. [json](json) | `ktx-json` | Utilities for LibGDX [JSON](https://github.com/libgdx/libgdx/wiki/Reading-and-writing-JSON) serialization API. [log](log) | `ktx-log` | Minimal runtime overhead cross-platform logging using inlined functions. [math](math) | `ktx-math` | Operator functions for LibGDX math API and general math utilities. diff --git a/assets-async/README.md b/assets-async/README.md index bdaad9fe..2a46664f 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -137,7 +137,9 @@ See [`ktx-async`](../async) setup section to enable coroutines in your project. `AssetStorage` contains the following core methods: -- `get: Deferred` - returns a `Deferred` reference to the asset if it was scheduled for loading. +- `get: T` - returns a loaded asset or throws `MissingAssetException` if the asset is unavailable. +- `getOrNull: T?` - returns a loaded asset or `null` if the asset is unavailable. +- `getAsync: Deferred` - returns a `Deferred` reference to the asset if it was scheduled for loading. Suspending `await()` can be called to obtain the asset instance. `isCompleted` can be used to check if the asset loading was finished. - `load: T` _(suspending)_ - schedules asset for asynchronous loading. Suspends the coroutine until @@ -355,6 +357,11 @@ fun accessAsset(assetStorage: AssetStorage) { // but AssetStorage also allows you to access assets // already loaded by other coroutines. + // Immediately returns loaded asset or throws exception if missing: + var texture = assetStorage.get("images/logo.png") + // Immediately returns loaded asset or null if missing: + val textureOrNull = assetStorage.getOrNull("images/logo.png") + // Returns true is asset is in the storage, loaded or not: assetStorage.contains("images/logo.png") // Returns true if the asset loading has finished: @@ -365,19 +372,22 @@ fun accessAsset(assetStorage: AssetStorage) { assetStorage.getDependencies("images/logo.png") KtxAsync.launch { - // By default, AssetStorage will not suspend the coroutine - // to get the asset and instead will return a Kotlin Deferred - // reference. This allows you to handle the asset however - // you need: - val asset: Deferred = assetStorage["images/logo.png"] + // You can also access your assets in coroutines, so you can + // wait for the assets to be loaded. + + // When calling getAsync, AssetStorage will not throw an exception + // or return null if the asset is still loading. Instead, it will + // return a Kotlin Deferred reference. This allows you suspend the + // coroutine until the asset is loaded: + val asset: Deferred = assetStorage.getAsync("images/logo.png") // Checking if the asset loading has finished: asset.isCompleted // Suspending the coroutine to obtain asset instance: - var texture = asset.await() + texture = asset.await() // If you want to suspend the coroutine to wait for the asset, // you can do this in a single line: - texture = assetStorage.get("images/logo.png").await() + texture = assetStorage.getAsync("images/logo.png").await() // Now the coroutine is resumed and `texture` can be used. } @@ -461,7 +471,7 @@ fun loadAsset(assetStorage: AssetStorage) { // Note that if the asset loading ended with an exception, // the same exception will be rethrown each time the asset - // is accessed with `get.await()` or `load`. + // is accessed with `get`, `getOrNull`, `getAsync.await` or `load`. } } ``` @@ -490,7 +500,7 @@ fun createCustomAssetStorage(): AssetStorage { ##### Multiple calls of `load` and `unload` It is completely safe to call `load` multiple times with the same asset data, even to obtain asset instances -without dealing with `Deferred`. In that sense, it can be used as an alternative to `get`. +without dealing with `Deferred`. In that sense, it can be used as an alternative to `getAsync` inside coroutines. Instead of loading the same asset multiple times, `AssetStorage` will just increase the reference count to the asset and return the same instance on each request. This also works concurrently - the storage will @@ -584,7 +594,8 @@ in either of the examples, you will notice that the deadlocks no longer occur. It does not mean that `runBlocking` will always cause a deadlock, however. You can safely use `runBlocking`: - For `dispose`, both suspending and non-suspending variants. -- For all non-suspending methods such as `contains`, `isLoaded`, `getReferenceCount`, `setLoader`, `getLoader`. +- For all non-suspending methods such as `get`, `getOrNull`, `contains`, `isLoaded`, `setLoader`, `getLoader`. +- For `add`. While `add` does suspend the coroutine, it requires neither the rendering thread nor the loading threads. - For `load` and `get.await` calls requesting already loaded assets. **Use with caution.** - From within other threads than the main rendering thread and the `AssetStorage` loading threads. These threads will be blocked until the operation is finished, which isn't ideal, but at least the loading will remain possible. diff --git a/assets-async/src/main/kotlin/ktx/assets/async/errors.kt b/assets-async/src/main/kotlin/ktx/assets/async/errors.kt index be663ea9..1c839127 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/errors.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/errors.kt @@ -19,13 +19,14 @@ import com.badlogic.gdx.utils.GdxRuntimeException open class AssetStorageException(message: String, cause: Throwable? = null) : GdxRuntimeException(message, cause) /** - * Thrown when the asset requested by [AssetStorage.get] is not available in the [AssetStorage]. + * Thrown when the asset requested by an [AssetStorage.get] variant is not available + * in the [AssetStorage] at all or has not been loaded yet. */ class MissingAssetException(identifier: Identifier<*>) : AssetStorageException(message = "Asset: $identifier is not loaded.") /** - * Thrown by [AssetStorage.load] or [AssetStorage.get] when the requested asset + * Thrown by [AssetStorage.load] or [AssetStorage.get] variant when the requested asset * was unloaded asynchronously. */ class UnloadedAssetException(identifier: Identifier<*>) : diff --git a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt index 3c92fe5f..6c3b8a43 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt @@ -1,5 +1,3 @@ -@file:Suppress("DeferredIsResult") - package ktx.assets.async import com.badlogic.gdx.assets.AssetDescriptor @@ -109,20 +107,36 @@ class AssetStorage( } /** - * Returns the reference to the asset wrapped with [Deferred]. - * Use [Deferred.await] to obtain the instance. + * Returns a loaded asset of type [T] loaded from selected [path] or throws [MissingAssetException] + * if the asset is not loaded yet or was never scheduled for loading. Rethrows any exceptions + * encountered during asset loading. * * [T] is the type of the asset. Must match the type requested during loading. - * [identifier] uniquely identifies a file by its path and type. + * [path] must match the asset path passed during loading. * - * To avoid concurrency issues, it is encouraged to load assets with [load] and save the returned instances - * of the loaded assets rather than to rely on [get]. + * This method might throw the following exceptions: + * - [MissingAssetException] if the asset of [T] type with the given [path] was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. * - * Note that while the result is a [CompletableDeferred], it should never be completed manually. - * Instead, rely on the [AssetStorage] to load the asset. + * See also [getOrNull] and [getAsync]. + */ + inline operator fun get(path: String): T = this[getIdentifier(path)] + + /** + * Returns a loaded asset of type [T] described by [descriptor] or throws [MissingAssetException] + * if the asset is not loaded yet or was never scheduled for loading. Rethrows any exceptions + * encountered during asset loading. * - * Using [Deferred.await] might throw the following exceptions: - * - [MissingAssetException] if the asset with [identifier] was never added with [load] or [add]. + * [T] is the type of the asset. Must match the type requested during loading. + * [descriptor] contains the asset data. See [getAssetDescriptor]. + * + * This method might throw the following exceptions: + * - [MissingAssetException] if the asset of [T] type described by [descriptor] was never added with [load] or [add]. * - [UnloadedAssetException] if the asset was already unloaded asynchronously. * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. @@ -130,17 +144,96 @@ class AssetStorage( * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. * - * Otherwise, using [Deferred.await] will suspend the coroutine until the asset is loaded - * and return its instance. + * See also [getOrNull] and [getAsync]. */ - operator fun get(identifier: Identifier): Deferred { - val asset = assets[identifier] - @Suppress("UNCHECKED_CAST") - return if (asset != null) asset.reference as Deferred else getMissingAssetReference(identifier) + operator fun get(descriptor: AssetDescriptor): T = this[descriptor.toIdentifier()] + + /** + * Returns a loaded asset of type [T] identified by [identifier] or throws [MissingAssetException] + * if the asset is not loaded yet or was never scheduled for loading. Rethrows any exceptions + * encountered during asset loading. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [identifier] uniquely identifies a file by its path and type. See [Identifier]. + * + * This method might throw the following exceptions: + * - [MissingAssetException] if the asset of [T] type identified by [identifier] was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * See also [getOrNull] and [getAsync]. + */ + operator fun get(identifier: Identifier): T { + val reference = getAsync(identifier) + @Suppress( "EXPERIMENTAL_API_USAGE") // Avoids runBlocking call. + return if (reference.isCompleted) reference.getCompleted() else throw MissingAssetException(identifier) } + /** + * Returns a loaded asset of type [T] loaded from selected [path] or `null` + * if the asset is not loaded yet or was never scheduled for loading. + * Rethrows any exceptions encountered during asset loading. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [path] must match the asset path passed during loading. + * + * This method might throw the following exceptions: + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * See also [get] and [getAsync]. + */ + inline fun getOrNull(path: String): T? = getOrNull(getIdentifier(path)) - private fun getMissingAssetReference(identifier: Identifier): Deferred = CompletableDeferred().apply { - completeExceptionally(MissingAssetException(identifier)) + /** + * Returns a loaded asset of type [T] described by [descriptor] or `null` + * if the asset is not loaded yet or was never scheduled for loading. + * Rethrows any exceptions encountered during asset loading. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [descriptor] contains the asset data. See [getAssetDescriptor]. + * + * This method might throw the following exceptions: + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * See also [get] and [getAsync]. + */ + fun getOrNull(descriptor: AssetDescriptor): T? = getOrNull(descriptor.toIdentifier()) + + /** + * Returns a loaded asset of type [T] identified by [identifier] or `null` + * if the asset is not loaded yet or was never scheduled for loading. + * Rethrows any exceptions encountered during asset loading. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [identifier] uniquely identifies a file by its path and type. See [Identifier]. + * + * This method might throw the following exceptions: + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * See also [get] and [getAsync]. + */ + fun getOrNull(identifier: Identifier): T? { + val asset = assets[identifier] + @Suppress( "UNCHECKED_CAST", "EXPERIMENTAL_API_USAGE") // Avoids runBlocking call. + return if (asset == null || !asset.reference.isCompleted) null else asset.reference.getCompleted() as T } /** @@ -150,9 +243,6 @@ class AssetStorage( * [T] is the type of the asset. Must match the type requested during loading. * [path] must match the asset path passed during loading. * - * To avoid concurrency issues, it is encouraged to load assets with [load] and save the returned instances - * of the loaded assets rather than to rely on [get]. - * * Note that while the result is a [CompletableDeferred], it should never be completed manually. * Instead, rely on the [AssetStorage] to load the asset. * @@ -167,8 +257,10 @@ class AssetStorage( * * Otherwise, using [Deferred.await] will suspend the coroutine until the asset is loaded * and return its instance. + * + * See also [get] and [getOrNull] for synchronous alternatives. */ - inline operator fun get(path: String): Deferred = get(getIdentifier(path)) + inline fun getAsync(path: String): Deferred = getAsync(getIdentifier(path)) /** * Returns the reference to the asset wrapped with [Deferred]. Use [Deferred.await] to obtain the instance. @@ -177,9 +269,6 @@ class AssetStorage( * [T] is the type of the asset. Must match the type requested during loading. * [descriptor] contains the asset data. See [getAssetDescriptor]. * - * To avoid concurrency issues, it is encouraged to load assets with [load] and save the returned instances - * of the loaded assets rather than to rely on [get]. - * * Note that while the result is a [CompletableDeferred], it should never be completed manually. * Instead, rely on the [AssetStorage] to load the asset. * @@ -194,8 +283,44 @@ class AssetStorage( * * Otherwise, using [Deferred.await] will suspend the coroutine until the asset is loaded * and return its instance. + * + * See also [get] and [getOrNull] for synchronous alternatives. */ - operator fun get(descriptor: AssetDescriptor): Deferred = get(descriptor.toIdentifier()) + fun getAsync(descriptor: AssetDescriptor): Deferred = getAsync(descriptor.toIdentifier()) + + /** + * Returns the reference to the asset wrapped with [Deferred]. + * Use [Deferred.await] to obtain the instance. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [identifier] uniquely identifies a file by its path and type. See [Identifier]. + * + * Note that while the result is a [CompletableDeferred], it should never be completed manually. + * Instead, rely on the [AssetStorage] to load the asset. + * + * Using [Deferred.await] might throw the following exceptions: + * - [MissingAssetException] if the asset with [identifier] was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * Otherwise, using [Deferred.await] will suspend the coroutine until the asset is loaded + * and return its instance. + * + * See also [get] and [getOrNull] for synchronous alternatives. + */ + fun getAsync(identifier: Identifier): Deferred { + val asset = assets[identifier] + @Suppress("UNCHECKED_CAST") + return if (asset != null) asset.reference as Deferred else getMissingAssetReference(identifier) + } + + private fun getMissingAssetReference(identifier: Identifier): Deferred = CompletableDeferred().apply { + completeExceptionally(MissingAssetException(identifier)) + } /** * Checks whether an asset in the selected [path] with [T] type is already loaded. diff --git a/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt index 06d850d2..cb02f0ed 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt @@ -8,7 +8,6 @@ import com.badlogic.gdx.assets.loaders.AssetLoader import com.badlogic.gdx.assets.loaders.FileHandleResolver import com.badlogic.gdx.utils.Logger import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import ktx.async.KtxAsync import com.badlogic.gdx.utils.Array as GdxArray @@ -82,20 +81,14 @@ internal class AssetManagerWrapper(val assetStorage: AssetStorage) override fun get(assetDescriptor: AssetDescriptor): Asset = get(assetDescriptor.fileName, assetDescriptor.type) - override fun get(fileName: String, type: Class): Asset = - runBlocking { - val identifier = Identifier(type, fileName) - val asset = assetStorage[identifier] - if (asset.isCompleted) { - try { - asset.await() - } catch (exception: Throwable) { - throw MissingDependencyException(identifier, exception) - } - } else { - throw MissingDependencyException(identifier) - } + override fun get(fileName: String, type: Class): Asset { + val identifier = Identifier(type, fileName) + return try { + assetStorage[identifier] + } catch (exception: Throwable) { + throw MissingDependencyException(identifier, exception) } + } @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("get(fileName, type)")) override fun get(fileName: String): Asset = throw UnsupportedMethodException("get(String)") diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt index 350fd67c..a89a0f91 100644 --- a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt @@ -31,6 +31,7 @@ import com.google.common.collect.Sets import com.nhaarman.mockitokotlin2.* import io.kotlintest.matchers.shouldThrow import kotlinx.coroutines.* +import kotlinx.coroutines.future.asCompletableFuture import ktx.assets.TextAssetLoader.TextAssetLoaderParameters import ktx.async.* import org.junit.* @@ -88,12 +89,6 @@ class AssetStorageTest : AsyncTest() { (Gdx.audio as OpenALAudio).dispose() } - /** - * Testing utility. Obtains instance of [T] by blocking the thread until the - * [Deferred] is completed. Rethrows any exceptions caught by [Deferred]. - */ - private fun Deferred.joinAndGet(): T = runBlocking { await() } - @Test fun `should load text assets`() { // Given: @@ -106,7 +101,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertEquals("Content.", asset) assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) } @@ -125,7 +120,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertEquals("Content.", asset) assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) } @@ -143,7 +138,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertEquals("Content.", asset) assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) } @@ -161,7 +156,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertEquals("Content.", asset) assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) } @@ -181,7 +176,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertEquals("Content.", asset) assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) } @@ -213,13 +208,13 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) // Font dependencies: assertTrue(storage.isLoaded(dependency)) assertEquals(1, storage.getReferenceCount(dependency)) - assertSame(asset.region.texture, storage.get(dependency).joinAndGet()) + assertSame(asset.region.texture, storage.get(dependency)) storage.dispose() } @@ -237,13 +232,13 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) // Font dependencies: assertTrue(storage.isLoaded(dependency)) assertEquals(1, storage.getReferenceCount(dependency)) - assertSame(asset.region.texture, storage.get(dependency).joinAndGet()) + assertSame(asset.region.texture, storage.get(dependency)) storage.dispose() } @@ -261,13 +256,13 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) // Font dependencies: assertTrue(storage.isLoaded(dependency)) assertEquals(1, storage.getReferenceCount(dependency)) - assertSame(asset.region.texture, storage.get(dependency).joinAndGet()) + assertSame(asset.region.texture, storage.get(dependency)) storage.dispose() } @@ -301,7 +296,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -320,7 +315,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -340,7 +335,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -374,7 +369,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -393,7 +388,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -412,7 +407,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -446,12 +441,12 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) // Atlas dependencies: assertTrue(storage.isLoaded(dependency)) - assertSame(asset.textures.first(), storage.get(dependency).joinAndGet()) + assertSame(asset.textures.first(), storage.get(dependency)) assertEquals(1, storage.getReferenceCount(dependency)) storage.dispose() @@ -470,12 +465,12 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) // Atlas dependencies: assertTrue(storage.isLoaded(dependency)) - assertSame(asset.textures.first(), storage.get(dependency).joinAndGet()) + assertSame(asset.textures.first(), storage.get(dependency)) assertEquals(1, storage.getReferenceCount(dependency)) storage.dispose() @@ -494,12 +489,12 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) // Atlas dependencies: assertTrue(storage.isLoaded(dependency)) - assertSame(asset.textures.first(), storage.get(dependency).joinAndGet()) + assertSame(asset.textures.first(), storage.get(dependency)) assertEquals(1, storage.getReferenceCount(dependency)) storage.dispose() @@ -534,7 +529,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -553,7 +548,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -572,7 +567,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -605,7 +600,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -624,7 +619,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -643,7 +638,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -678,18 +673,18 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) // Skin dependencies: assertTrue(storage.isLoaded(atlas)) assertEquals(1, storage.getReferenceCount(atlas)) - assertSame(asset.atlas, storage.get(atlas).joinAndGet()) + assertSame(asset.atlas, storage.get(atlas)) assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) // Atlas dependencies: assertTrue(storage.isLoaded(texture)) - assertSame(asset.atlas.textures.first(), storage.get(texture).joinAndGet()) + assertSame(asset.atlas.textures.first(), storage.get(texture)) assertEquals(1, storage.getReferenceCount(texture)) storage.dispose() @@ -709,18 +704,18 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) // Skin dependencies: assertTrue(storage.isLoaded(atlas)) assertEquals(1, storage.getReferenceCount(atlas)) - assertSame(asset.atlas, storage.get(atlas).joinAndGet()) + assertSame(asset.atlas, storage.get(atlas)) assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) // Atlas dependencies: assertTrue(storage.isLoaded(texture)) - assertSame(asset.atlas.textures.first(), storage.get(texture).joinAndGet()) + assertSame(asset.atlas.textures.first(), storage.get(texture)) assertEquals(1, storage.getReferenceCount(texture)) storage.dispose() @@ -740,18 +735,18 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) // Skin dependencies: assertTrue(storage.isLoaded(atlas)) assertEquals(1, storage.getReferenceCount(atlas)) - assertSame(asset.atlas, storage.get(atlas).joinAndGet()) + assertSame(asset.atlas, storage.get(atlas)) assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) // Atlas dependencies: assertTrue(storage.isLoaded(texture)) - assertSame(asset.atlas.textures.first(), storage.get(texture).joinAndGet()) + assertSame(asset.atlas.textures.first(), storage.get(texture)) assertEquals(1, storage.getReferenceCount(texture)) storage.dispose() @@ -790,7 +785,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) assertEquals("Value.", asset["key"]) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -810,7 +805,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) assertEquals("Value.", asset["key"]) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -830,7 +825,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) assertEquals("Value.", asset["key"]) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -863,7 +858,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -882,7 +877,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -901,7 +896,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -935,12 +930,12 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) // Particle dependencies: assertTrue(storage.isLoaded(dependency)) - assertNotNull(storage.get(dependency).joinAndGet()) + assertNotNull(storage.get(dependency)) assertEquals(1, storage.getReferenceCount(dependency)) storage.dispose() @@ -959,12 +954,12 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) // Particle dependencies: assertTrue(storage.isLoaded(dependency)) - assertNotNull(storage.get(dependency).joinAndGet()) + assertNotNull(storage.get(dependency)) assertEquals(1, storage.getReferenceCount(dependency)) storage.dispose() @@ -983,12 +978,12 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) // Particle dependencies: assertTrue(storage.isLoaded(dependency)) - assertNotNull(storage.get(dependency).joinAndGet()) + assertNotNull(storage.get(dependency)) assertEquals(1, storage.getReferenceCount(dependency)) storage.dispose() @@ -1023,7 +1018,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1042,7 +1037,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1061,7 +1056,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1094,7 +1089,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1113,7 +1108,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1132,7 +1127,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1165,7 +1160,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1184,7 +1179,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1203,7 +1198,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1238,7 +1233,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1259,7 +1254,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1280,7 +1275,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1315,7 +1310,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1334,7 +1329,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1353,7 +1348,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(emptyList(), storage.getDependencies(path)) @@ -1376,12 +1371,115 @@ class AssetStorageTest : AsyncTest() { } @Test - fun `should return deferred that throws exception when attempting to get unloaded asset`() { + fun `should throw exception when attempting to get unloaded asset`() { + // Given: + val storage = AssetStorage() + + // Expect: + shouldThrow { + storage.get("ktx/assets/async/string.txt") + } + } + + @Test + fun `should return null when attempting to get unloaded asset or null`() { + // Given: + val storage = AssetStorage() + + // When: + val asset = storage.getOrNull("ktx/assets/async/string.txt") + + // Then: + assertNull(asset) + } + + @Test + fun `should return deferred that throws exception when attempting to get unloaded asset asynchronously`() { + // Given: + val storage = AssetStorage() + + // When: + val result = storage.getAsync("ktx/assets/async/string.txt") + + // Expect: + shouldThrow { + runBlocking { result.await() } + } + } + + @Test + fun `should throw exception when attempting to get unloaded asset with identifier`() { + // Given: + val storage = AssetStorage() + val identifier = storage.getIdentifier("ktx/assets/async/string.txt") + + // Expect: + shouldThrow { + storage[identifier] + } + } + + @Test + fun `should return null when attempting to get unloaded asset or null with identifier`() { + // Given: + val storage = AssetStorage() + val identifier = storage.getIdentifier("ktx/assets/async/string.txt") + + // When: + val asset = storage.getOrNull(identifier) + + // Then: + assertNull(asset) + } + + @Test + fun `should return deferred that throws exception when attempting to get unloaded asset asynchronously with identifier`() { + // Given: + val storage = AssetStorage() + val identifier = storage.getIdentifier("ktx/assets/async/string.txt") + + // When: + val result = storage.getAsync(identifier) + + // Expect: + shouldThrow { + runBlocking { result.await() } + } + } + + @Test + fun `should throw exception when attempting to get unloaded asset with descriptor`() { // Given: val storage = AssetStorage() + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // Expect: + shouldThrow { + storage[descriptor] + } + } + + @Test + fun `should return null when attempting to get unloaded asset or null with descriptor`() { + // Given: + val storage = AssetStorage() + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") // When: - val result = storage.get("ktx/assets/async/string.txt") + val asset = storage.getOrNull(descriptor) + + // Then: + assertNull(asset) + } + + @Test + fun `should return deferred that throws exception when attempting to get unloaded asset asynchronously with descriptor`() { + // Given: + val storage = AssetStorage() + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // When: + val result = storage.getAsync(descriptor) // Expect: shouldThrow { @@ -1401,7 +1499,9 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.contains(path)) assertTrue(storage.isLoaded(path)) - assertEquals("Content.", storage.get(path).joinAndGet()) + assertEquals("Content.", storage.get(path)) + assertEquals("Content.", storage.getOrNull(path)) + assertEquals("Content.", runBlocking { storage.getAsync(path).await() }) assertEquals(emptyList(), storage.getDependencies(path)) } @@ -1417,7 +1517,9 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(identifier in storage) assertTrue(storage.isLoaded(identifier)) - assertEquals("Content.", storage[identifier].joinAndGet()) + assertEquals("Content.", storage[identifier]) + assertEquals("Content.", storage.getOrNull(identifier)) + assertEquals("Content.", runBlocking { storage.getAsync(identifier).await() }) assertEquals(emptyList(), storage.getDependencies(identifier)) } @@ -1433,7 +1535,9 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(descriptor in storage) assertTrue(storage.isLoaded(descriptor)) - assertEquals("Content.", storage[descriptor].joinAndGet()) + assertEquals("Content.", storage[descriptor]) + assertEquals("Content.", storage.getOrNull(descriptor)) + assertEquals("Content.", runBlocking { storage.getAsync(descriptor).await() }) assertEquals(emptyList(), storage.getDependencies(descriptor)) } @@ -1530,7 +1634,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(fakePath)) - assertSame(asset, storage.get(fakePath).joinAndGet()) + assertSame(asset, storage.get(fakePath)) assertEquals(1, storage.getReferenceCount(fakePath)) } @@ -1547,7 +1651,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(descriptor)) - assertSame(asset, storage[descriptor].joinAndGet()) + assertSame(asset, storage[descriptor]) assertEquals(1, storage.getReferenceCount(descriptor)) } @@ -1564,7 +1668,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(identifier)) - assertSame(asset, storage[identifier].joinAndGet()) + assertSame(asset, storage[identifier]) assertEquals(1, storage.getReferenceCount(identifier)) } @@ -1582,7 +1686,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) } @Test @@ -1599,7 +1703,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(path)) assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path).joinAndGet()) + assertSame(asset, storage.get(path)) storage.dispose() } @@ -1621,7 +1725,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals(1, storage.getReferenceCount(path)) - assertNotSame(storage.get(path).joinAndGet(), storage.get(path).joinAndGet()) + assertNotSame(storage.get(path), storage.get(path)) storage.dispose() } @@ -1650,8 +1754,8 @@ class AssetStorageTest : AsyncTest() { runBlocking { tasks.joinAll() } assertTrue(storage.isLoaded(firstPath)) assertTrue(storage.isLoaded(secondPath)) - assertSame(tasks[0].joinAndGet(), storage.get(firstPath).joinAndGet()) - assertSame(tasks[1].joinAndGet(), storage.get(secondPath).joinAndGet()) + assertSame(tasks[0].asCompletableFuture().join(), storage.get(firstPath)) + assertSame(tasks[1].asCompletableFuture().join(), storage.get(secondPath)) storage.dispose() } @@ -1937,7 +2041,7 @@ class AssetStorageTest : AsyncTest() { assertEquals(100, storage.getReferenceCount(path)) assertTrue(storage.isLoaded(dependency)) assertEquals(100, storage.getReferenceCount(dependency)) - assertEquals(1, assets.map { it.joinAndGet() }.toSet().size) + assertEquals(1, assets.map { it.asCompletableFuture().join() }.toSet().size) storage.dispose() } @@ -1987,7 +2091,7 @@ class AssetStorageTest : AsyncTest() { runBlocking { assets.joinAll() } assertTrue(storage.isLoaded(path)) assertEquals(1, storage.getReferenceCount(path)) - assertEquals("Content.", storage.get(path).joinAndGet()) + assertEquals("Content.", storage.get(path)) storage.dispose() } @@ -2026,8 +2130,8 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(dependency)) assertEquals(1, storage.getReferenceCount(dependency)) assertSame( - storage.get(path).joinAndGet().region.texture, - storage.get(dependency).joinAndGet() + storage.get(path).region.texture, + storage.get(dependency) ) storage.dispose() @@ -2068,9 +2172,9 @@ class AssetStorageTest : AsyncTest() { assertEquals(1, storage.getReferenceCount(dependency)) assertTrue(storage.isLoaded(nestedDependency)) assertEquals(1, storage.getReferenceCount(nestedDependency)) - val skin = storage.get(path).joinAndGet() - val atlas = storage.get(dependency).joinAndGet() - val texture = storage.get(nestedDependency).joinAndGet() + val skin = storage.get(path) + val atlas = storage.get(dependency) + val texture = storage.get(nestedDependency) assertSame(skin.atlas, atlas) assertSame(atlas.textures.first(), texture) @@ -2092,7 +2196,7 @@ class AssetStorageTest : AsyncTest() { val loads = AtomicInteger() val unloads = AtomicInteger() - // When: spawning 1000 coroutines that randomly load or unload the asset: + // When: spawning 1000 coroutines that randomly load or unload the asset and try to access it: val assets = (1..1000).map { val result = CompletableDeferred() KtxAsync.launch(schedulers) { @@ -2106,6 +2210,12 @@ class AssetStorageTest : AsyncTest() { val unloaded = storage.unload(path) if (unloaded) unloads.incrementAndGet() } + try { + // Concurrent access: + storage.getOrNull(path) + } catch (expected: UnloadedAssetException) { + // Assets can be unloaded asynchronously. This is OK. + } result.complete(true) } result @@ -2229,7 +2339,7 @@ class AssetStorageTest : AsyncTest() { assertEquals(0, storage.getReferenceCount(it)) assertEquals(emptyList(), storage.getDependencies(it)) shouldThrow { - storage[it].joinAndGet() + storage[it] } } } @@ -2503,7 +2613,7 @@ class AssetStorageTest : AsyncTest() { // Then: asset should still be loaded, but the callback exception must be logged: loggingFinished.join() assertTrue(storage.isLoaded(path)) - assertEquals("Content.", storage.get(path).joinAndGet()) + assertEquals("Content.", storage.get(path)) verify(logger).error(any(), eq(exception)) } @@ -2620,7 +2730,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.contains(path)) assertEquals(1, storage.getReferenceCount(path)) shouldThrow { - storage.get(path).joinAndGet() + storage.get(path) } } @@ -2663,7 +2773,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(exception is UnloadedAssetException) assertFalse(storage.contains(path)) shouldThrow { - storage.get(path).joinAndGet() + storage.get(path) } } @@ -2690,7 +2800,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.contains(path)) assertEquals(1, storage.getReferenceCount(path)) shouldThrow { - storage.get(path).joinAndGet() + storage.get(path) } } @@ -2717,7 +2827,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.contains(path)) assertEquals(1, storage.getReferenceCount(path)) shouldThrow { - storage.get(path).joinAndGet() + storage.get(path) } } @@ -2741,7 +2851,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.contains(path)) assertEquals(1, storage.getReferenceCount(path)) shouldThrow { - storage.get(path).joinAndGet() + storage.get(path) } } @@ -2765,7 +2875,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.contains(path)) assertEquals(1, storage.getReferenceCount(path)) shouldThrow { - storage.get(path).joinAndGet() + storage.get(path) } } @@ -2891,7 +3001,7 @@ class AssetStorageTest : AsyncTest() { assertFalse(storage.isLoaded(identifier)) loadingFinished.complete(true) - runBlocking { storage.get(path).await() } + runBlocking { storage.getAsync(path).await() } assertTrue(identifier in storage) assertTrue(storage.isLoaded(identifier)) } diff --git a/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt b/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt index b27b6a9d..6a7a5819 100644 --- a/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt +++ b/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt @@ -9,7 +9,6 @@ import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGeneratorLoader import com.badlogic.gdx.graphics.g2d.freetype.FreetypeFontLoader import com.nhaarman.mockitokotlin2.mock import io.kotlintest.matchers.shouldThrow -import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import ktx.assets.async.AssetStorage @@ -91,12 +90,6 @@ class FreeTypeAsyncTest : AsyncTest() { assertTrue(storage.getLoader() is FreetypeFontLoader) } - /** - * Testing utility. Obtains instance of [T] by blocking the thread until the - * [Deferred] is completed. Rethrows any exceptions caught by [Deferred]. - */ - private fun Deferred.joinAndGet(): T = runBlocking { await() } - @Test fun `should load OTF file into BitmapFont`() { // Given: @@ -108,7 +101,7 @@ class FreeTypeAsyncTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(otfFile)) - assertSame(asset, storage.get(otfFile).joinAndGet()) + assertSame(asset, storage.get(otfFile)) assertEquals(1, storage.getReferenceCount(otfFile)) // Automatically loads a generator for the font: assertEquals(listOf(storage.getIdentifier("$otfFile.gen")), @@ -134,7 +127,7 @@ class FreeTypeAsyncTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(otfFile)) - assertSame(asset, storage.get(otfFile).joinAndGet()) + assertSame(asset, storage.get(otfFile)) assertEquals(1, storage.getReferenceCount(otfFile)) // Automatically loads a generator for the font: assertEquals(listOf(storage.getIdentifier("$otfFile.gen")), @@ -172,7 +165,7 @@ class FreeTypeAsyncTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(ttfFile)) - assertSame(asset, storage.get(ttfFile).joinAndGet()) + assertSame(asset, storage.get(ttfFile)) assertEquals(1, storage.getReferenceCount(ttfFile)) // Automatically loads a generator for the font: assertEquals(listOf(storage.getIdentifier("$ttfFile.gen")), @@ -198,7 +191,7 @@ class FreeTypeAsyncTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(ttfFile)) - assertSame(asset, storage.get(ttfFile).joinAndGet()) + assertSame(asset, storage.get(ttfFile)) assertEquals(1, storage.getReferenceCount(ttfFile)) // Automatically loads a generator for the font: assertEquals(listOf(storage.getIdentifier("$ttfFile.gen")), @@ -241,7 +234,7 @@ class FreeTypeAsyncTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(otfFile)) - assertSame(asset, storage.get(otfFile).joinAndGet()) + assertSame(asset, storage.get(otfFile)) assertEquals(1, storage.getReferenceCount(otfFile)) // Automatically loads a generator for the font: assertEquals(listOf(storage.getIdentifier("$otfFile.gen")), @@ -266,7 +259,7 @@ class FreeTypeAsyncTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(ttfFile)) - assertSame(asset, storage.get(ttfFile).joinAndGet()) + assertSame(asset, storage.get(ttfFile)) assertEquals(1, storage.getReferenceCount(ttfFile)) // Automatically loads a generator for the font: assertEquals(listOf(storage.getIdentifier("$ttfFile.gen")), @@ -288,7 +281,7 @@ class FreeTypeAsyncTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(otfFile)) - assertSame(asset, storage.get(otfFile).joinAndGet()) + assertSame(asset, storage.get(otfFile)) assertEquals(1, storage.getReferenceCount(otfFile)) assertEquals(emptyList(), storage.getDependencies(otfFile)) @@ -306,7 +299,7 @@ class FreeTypeAsyncTest : AsyncTest() { // Then: assertTrue(storage.isLoaded(ttfFile)) - assertSame(asset, storage.get(ttfFile).joinAndGet()) + assertSame(asset, storage.get(ttfFile)) assertEquals(1, storage.getReferenceCount(ttfFile)) assertEquals(emptyList(), storage.getDependencies(ttfFile)) @@ -379,7 +372,7 @@ class FreeTypeAsyncTest : AsyncTest() { assertEquals(0, storage.getReferenceCount(it)) assertEquals(emptyList(), storage.getDependencies(it)) shouldThrow { - storage[it].joinAndGet() + storage[it] } } } From 39ce932c8245054ed83eb828119364c20b13beb3 Mon Sep 17 00:00:00 2001 From: MJ Date: Wed, 1 Apr 2020 21:35:34 +0200 Subject: [PATCH 12/17] Added loadAsync to AssetStorage. #182 --- CHANGELOG.md | 3 +- assets-async/README.md | 220 +++++++++++++++--- .../main/kotlin/ktx/assets/async/storage.kt | 108 ++++++++- .../kotlin/ktx/assets/async/storageTest.kt | 190 +++++++++++---- 4 files changed, 430 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8776aec4..b686ca0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ - `get` operator obtains an asset from the storage or throws a `MissingAssetException`. - `getOrNull` obtains an asset from the storage or return `null` if the asset is unavailable. - `getAsync` obtains a reference to the asset from the storage as `Deferred`. - - `load` schedules asynchronous loading of an asset. + - `load` suspends a coroutine until an asset is loaded and returns its instance. + - `loadAsync` schedules asynchronous loading of an asset. - `unload` schedules asynchronous unloading of an asset. - `add` allows to manually add a loaded asset to `AssetManager`. - `dispose` unloads all assets from the storage. diff --git a/assets-async/README.md b/assets-async/README.md index 2a46664f..7957cf48 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -139,11 +139,12 @@ See [`ktx-async`](../async) setup section to enable coroutines in your project. - `get: T` - returns a loaded asset or throws `MissingAssetException` if the asset is unavailable. - `getOrNull: T?` - returns a loaded asset or `null` if the asset is unavailable. -- `getAsync: Deferred` - returns a `Deferred` reference to the asset if it was scheduled for loading. -Suspending `await()` can be called to obtain the asset instance. `isCompleted` can be used to check -if the asset loading was finished. +- `getAsync: Deferred` - returns a `Deferred` reference to the asset. Suspending `await()` can be +called to obtain the asset instance. `isCompleted` can be used to check if the asset loading was finished. - `load: T` _(suspending)_ - schedules asset for asynchronous loading. Suspends the coroutine until the asset is fully loaded. Resumes the coroutine and returns the asset once it is loaded. +- `loadAsync: Deferred` - schedules asset for asynchronous loading. Returns a `Deferred` reference +to the asset which will be completed after the loading is finished. - `unload: Boolean` _(suspending)_ - unloads the selected asset. If the asset is no longer referenced, it will be removed from the storage and disposed of. Suspends the coroutine until the asset is unloaded. Returns `true` is the selected asset was present in storage or `false` if the asset was absent. @@ -270,6 +271,28 @@ fun loadAsset(assetStorage: AssetStorage) { } ``` +Loading assets sequentially using `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun loadAssets(assetStorage: AssetStorage) { + // Unlike AssetManager, AssetStorage allows to easily control the order of loading. + KtxAsync.launch { + // This will suspend the coroutine until the texture is loaded: + val logo = assetStorage.load("images/logo.png") + // After the first image is loaded, + // the coroutine resumes and loads the second asset: + val background = assetStorage.load("images/background.png") + + // Now both images are loaded and can be used. + } +} +``` + Loading assets asynchronously: ```kotlin @@ -281,10 +304,13 @@ import ktx.assets.async.AssetStorage import ktx.async.KtxAsync fun loadAssets(assetStorage: AssetStorage) { + // You can also schedule the assets for asynchronous loading + // without suspending the coroutine. KtxAsync.launch { - // Launching asynchronous asset loading: + // Launching asynchronous asset loading with Kotlin's built-in `async`: val texture = async { assetStorage.load("images/logo.png") } - val font = async { assetStorage.load("com/badlogic/gdx/utils/arial-15.fnt") } + val font = async { assetStorage.load("fonts/font.fnt") } + // Suspending the coroutine until both assets are loaded: doSomethingWithTextureAndFont(texture.await(), font.await()) } @@ -296,7 +322,6 @@ Loading assets in parallel: ```kotlin import com.badlogic.gdx.graphics.Texture import com.badlogic.gdx.graphics.g2d.BitmapFont -import kotlinx.coroutines.async import kotlinx.coroutines.launch import ktx.assets.async.AssetStorage import ktx.async.KtxAsync @@ -305,19 +330,18 @@ import ktx.async.newAsyncContext fun loadAssets() { // Using Kotlin's `async` will ensure that the coroutine is not // immediately suspended and assets are scheduled asynchronously, - // but to take advantage of parallel asset loading, we have to pass - // a context with multiple threads to AssetStorage: + // but to take advantage of really parallel asset loading, we have + // to pass a context with multiple loading threads to AssetStorage: val assetStorage = AssetStorage(asyncContext = newAsyncContext(threads = 2)) - + + // Instead of using Kotlin's built-in `async`, you can also use + // the `loadAsync` method of AssetStorage with is a shortcut for + // `async(assetStorage.asyncContext) { assetStorage.load }`: + val texture = assetStorage.loadAsync("images/logo.png") + val font = assetStorage.loadAsync("fonts/font.fnt") + // Now both assets will be loaded asynchronously, in parallel. + KtxAsync.launch { - // Passing context to `async` is optional, but it allows you to - // perform even less operations on the main rendering thread: - val texture = async(assetStorage.asyncContext) { - assetStorage.load("images/logo.png") - } - val font = async(assetStorage.asyncContext) { - assetStorage.load("com/badlogic/gdx/utils/arial-15.fnt") - } // Suspending the coroutine until both assets are loaded: doSomethingWithTextureAndFont(texture.await(), font.await()) } @@ -339,6 +363,10 @@ fun unloadAsset(assetStorage: AssetStorage) { // When the coroutine resumes here, the asset is unloaded. // If no other assets use it as dependency, it will // be removed from the asset storage and disposed of. + + // Note that you can also do this asynchronously if you don't + // want to suspend the coroutine: + async { assetStorage.unload("images/logo.png") } } } ``` @@ -353,13 +381,13 @@ import ktx.assets.async.AssetStorage import ktx.async.KtxAsync fun accessAsset(assetStorage: AssetStorage) { - // Typically you can use assets returned by `load`, + // Typically you can simply use assets returned by `load`, // but AssetStorage also allows you to access assets // already loaded by other coroutines. - // Immediately returns loaded asset or throws exception if missing: + // Immediately returns loaded asset or throws an exception if missing: var texture = assetStorage.get("images/logo.png") - // Immediately returns loaded asset or null if missing: + // Immediately returns loaded asset or returns null if missing: val textureOrNull = assetStorage.getOrNull("images/logo.png") // Returns true is asset is in the storage, loaded or not: @@ -372,10 +400,10 @@ fun accessAsset(assetStorage: AssetStorage) { assetStorage.getDependencies("images/logo.png") KtxAsync.launch { - // You can also access your assets in coroutines, so you can - // wait for the assets to be loaded. + // There is also a special way to access your assets within coroutines + // when you need to wait until they are loaded asynchronously. - // When calling getAsync, AssetStorage will not throw an exception + // When calling `getAsync`, AssetStorage will not throw an exception // or return null if the asset is still loading. Instead, it will // return a Kotlin Deferred reference. This allows you suspend the // coroutine until the asset is loaded: @@ -425,8 +453,7 @@ Disposing of all assets stored by `AssetStorage`: assetStorage.dispose() // This will also disrupt loading of all unloaded assets. -// Disposing errors are logged by default, but do not -// cancel the process. +// Disposing errors are logged by default, and do not stop the process. ``` Disposing of all assets asynchronously: @@ -497,27 +524,27 @@ fun createCustomAssetStorage(): AssetStorage { #### Implementation notes -##### Multiple calls of `load` and `unload` +##### Multiple calls of `load`, `loadAsync` and `unload` -It is completely safe to call `load` multiple times with the same asset data, even to obtain asset instances -without dealing with `Deferred`. In that sense, it can be used as an alternative to `getAsync` inside coroutines. +It is completely safe to call `load` and `loadAsync` multiple times with the same asset data, even just to obtain +asset instances. In that sense, they can be used as an alternative to `getAsync` inside coroutines. Instead of loading the same asset multiple times, `AssetStorage` will just increase the reference count to the asset and return the same instance on each request. This also works concurrently - the storage will -always load just one asset instance, regardless of how many different threads and coroutines called `load` +always load just _one_ asset instance, regardless of how many different threads and coroutines called `load` in parallel. -However, to eventually unload the asset, you have to call `unload` the same number of times as `load`, +However, to eventually unload the asset, you have to call `unload` the same number of times as `load`/`loadAsync`, or simply dispose of all assets with `dispose`, which clears all reference counts and unloads everything from the storage. -Unlike `load`, `add` should be called only once on a single asset, and only when the path and type are absent +Unlike `load` and `loadAsync`, `add` should be called only once on a single asset, and when it is not stored in the storage. You cannot add existing assets to the storage, even if they were previously loaded by it. This is because if we are not sure that `AssetStorage` handled the loading (or creation) of an object, tracking -its dependencies and lifecycle is difficult and left to the user. Trying to `add` an existing asset will not -increase its reference count, and instead throw an exception. +its dependencies and lifecycle is difficult and left to the user. Trying to `add` an asset with existing path +and type will not increase its reference count, and will throw an exception instead. -Adding assets that were previously loaded by the `AssetStorage` under different paths is also a misuse of the API, +Adding assets that were previously loaded by the `AssetStorage` under different paths is also a misuse of the API which might result in unloading the asset or its dependencies prematurely. True aliases are currently unsupported. ##### `runBlocking` @@ -600,6 +627,131 @@ It does not mean that `runBlocking` will always cause a deadlock, however. You c - From within other threads than the main rendering thread and the `AssetStorage` loading threads. These threads will be blocked until the operation is finished, which isn't ideal, but at least the loading will remain possible. +##### Asynchronous operations + +Most common operations - `get` and `load` - offer both synchronous/suspending and asynchronous variants. +To perform other methods asynchronously, use `KtxAsync.launch` if you do not need the result or `KtxAsync.async` +to get a `Deferred` reference to the result which will be completed after the operation is finished. + +```kotlin +// Unloading an asset asynchronously: +KtxAsync.launch { storage.unload(path) } +``` + +##### `AssetStorage` as a drop-in replacement for `AssetManager` + +Consider this typical application using `AssetManager`: + +```kotlin +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont + +class WithAssetManager: ApplicationAdapter() { + private lateinit var assetManager: AssetManager + + override fun create() { + assetManager = AssetManager() + + // Scheduling assets for asynchronous loading: + assetManager.load("images/logo.png", Texture::class.java) + assetManager.load("com/badlogic/gdx/utils/arial-15.fnt", BitmapFont::class.java) + } + + override fun render() { + // Manager has to be constantly updated until the assets are loaded: + if (assetManager.update()) { + // Now the assets are loaded: + changeView() + } + // Render loading prompt. + } + + private fun changeView() { + val texture: Texture = assetManager["images/logo.png"] + TODO("Now the assets are loaded and can be accessed with $assetManager.get!") + } +} +``` + +Since usually applications have more assets than just 2, many developers choose to treat `AssetManager` as a map +of loaded assets with file paths as keys and loaded assets are values. You typically call load all or most assets +on the loading screen and then just use `AssetManager.get(path)` to obtain the assets after they are loaded. + +However, this approach has some inconveniences and problems: + +- The API is not very idiomatic to Kotlin, but in this particular case [ktx-assets](../assets) can help with that. +- `update` has to be called on render during loading. +- If you forget to stop updating the manager after the assets are loaded, the initiation code (such as `changeView` +in our example) can be ran multiple times. If you replace `TODO` with `println` in the example, you will notice that +`changeView` is invoked on every render after the loading is finished. +- The majority of `AssetManager` methods are `synchronized`, which means they block the thread that they are +executed in and are usually more expensive to call than regular methods. This includes the `get` method, which does +not change the internal state of the manager at all. Even if the assets are fully loaded and you no longer modify +the `AssetManager` state, you still pay the cost of synchronization. This is especially relevant if you use multiple +threads, as they can block each other waiting for the assets. +- `AssetManager` stores assets mapped only by their paths. `manager.get(path)` and `manager.get(path)` +are both valid calls that will throw a runtime class cast exception. + +`AssetStorage` avoids most of these problems. + +Similarly to `AssetManager`, `AssetStorage` offers API to `get` your loaded assets, so if you want to migrate +from `AssetManager` to `AssetStorage`, all you have to change initially is the loading code: + +```kotlin +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.async.newAsyncContext + +class WithAssetStorage: ApplicationAdapter() { + override fun create() { + KtxAsync.initiate() + + // Using multiple threads to load the assets in parallel: + val assetStorage = AssetStorage(newAsyncContext(threads = 4)) + + // Scheduling assets for asynchronous loading: + val assets = listOf( + assetStorage.loadAsync("images/logo.png"), + assetStorage.loadAsync("com/badlogic/gdx/utils/arial-15.fnt") + ) + + // Instead of updating, we're launching a coroutine that waits for the assets: + KtxAsync.launch { + // Suspending coroutine until all assets are loaded: + assets.joinAll() + // Now the assets are loaded and we can use them with `get`: + changeView(assetStorage) + } + } + + private fun changeView(assetStorage: AssetStorage) { + val texture: Texture = assetStorage["images/logo.png"] + TODO("Now the assets are loaded and can be accessed with $assetStorage.get!") + } +} +``` + +As you can see, after the assets are loaded, the API of both `AssetManager` and `AssetStorage` is similar. + +The code using `AssetStorage` is not necessarily shorter in this case, but: + +- You get the performance improvements of loading assets in parallel. +- `AssetStorage` does not have to be updated on render. +- Your code is reactive and `changeView` is called only once as soon as the assets are loaded. +- You can easily integrate more coroutines into your application later for other asynchronous operations. +- `AssetStorage.get` is non-blocking and faster. `AssetStorage` does a better job of storing your assets +efficiently after loading. +- `AssetStorage` stores assets mapped by their path _and_ type. You will not have to deal with class cast exceptions. + +Besides, you get the additional benefits of other `AssetStorage` features and methods described in this file. + ##### Integration with LibGDX and known unsupported features `AssetStorage` does its best to integrate with LibGDX APIs - including the `AssetLoader` implementations, which were diff --git a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt index 6c3b8a43..10c8c22e 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt @@ -428,12 +428,107 @@ class AssetStorage( } /** - * Schedules loading of an asset of [T] type located at [path]. + * Schedules asynchronous loading of an asset of [T] type located at [path]. + * Return a [Deferred] reference which will eventually point to a fully loaded instance of [T]. + * * [path] must be compatible with the [fileResolver]. * Loading [parameters] are optional and can be used to configure the loaded asset. + * + * [Deferred.await] might throw the following exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load] + * or [loadAsync]. Asset dependencies should not be unloaded directly; instead, unload the asset that + * required them and caused them to load in the first place. + * + * If the [parameters] define a [AssetLoaderParameters.loadedCallback], it will be invoked on the main + * rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. + */ + inline fun loadAsync(path: String, parameters: AssetLoaderParameters? = null): Deferred = + loadAsync(getAssetDescriptor(path, parameters)) + + /** + * Schedules loading of an asset with path and type specified by [identifier]. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * + * [Identifier.path] must be compatible with the [fileResolver]. + * Loading [parameters] are optional and can be used to configure the loaded asset. + * + * [Deferred.await] might throw the following exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load] + * or [loadAsync]. Asset dependencies should not be unloaded directly; instead, unload the asset that + * required them and caused them to load in the first place. + * + * If the [parameters] define a [AssetLoaderParameters.loadedCallback], it will be invoked on the main + * rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. + */ + fun loadAsync(identifier: Identifier, parameters: AssetLoaderParameters? = null): Deferred = + loadAsync(identifier.toAssetDescriptor(parameters)) + + /** + * Schedules loading of an asset of [T] type described by the [descriptor]. * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. * - * Might throw the following exceptions exceptions: + * [Deferred.await] might throw the following exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load] + * or [loadAsync]. Asset dependencies should not be unloaded directly; instead, unload the asset that + * required them and caused them to load in the first place. + * + * If the [AssetDescriptor.params] define a [AssetLoaderParameters.loadedCallback], it will be invoked on + * the main rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. + */ + fun loadAsync(descriptor: AssetDescriptor): Deferred = KtxAsync.async(asyncContext) { + load(descriptor) + } + + /** + * Schedules loading of an asset of [T] type located at [path]. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * + * [path] must be compatible with the [fileResolver]. + * Loading [parameters] are optional and can be used to configure the loaded asset. + * + * Might throw the following exceptions: * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. @@ -459,12 +554,13 @@ class AssetStorage( load(getAssetDescriptor(path, parameters)) /** - * Schedules loading of an asset with path and type specified by [identifier] + * Schedules loading of an asset with path and type specified by [identifier]. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * * [Identifier.path] must be compatible with the [fileResolver]. * Loading [parameters] are optional and can be used to configure the loaded asset. - * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. * - * Might throw the following exceptions exceptions: + * Might throw the following exceptions: * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. @@ -493,7 +589,7 @@ class AssetStorage( * Schedules loading of an asset of [T] type described by the [descriptor]. * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. * - * Might throw the following exceptions exceptions: + * Might throw the following exceptions: * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt index a89a0f91..51cc365b 100644 --- a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt @@ -1487,6 +1487,25 @@ class AssetStorageTest : AsyncTest() { } } + @Test + fun `should return same asset instance with subsequent load calls on loaded asset`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val loaded = runBlocking { storage.load(path) } + + // When: + val assets = (1..10).map { runBlocking { storage.load(path) } } + + // Then: + assertEquals(11, storage.getReferenceCount(path)) + assets.forEach { asset -> + assertSame(loaded, asset) + } + + storage.dispose() + } + @Test fun `should obtain loaded asset with path`() { // Given: @@ -1588,6 +1607,56 @@ class AssetStorageTest : AsyncTest() { assertEquals(0, storage.getReferenceCount(identifier)) } + @Test + fun `should load assets asynchronously with path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + val asset = runBlocking { storage.loadAsync(path).await() } + + // Then: + assertTrue(storage.contains(path)) + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertSame(asset, storage.get(path)) + } + + @Test + fun `should load assets asynchronously with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val identifier = storage.getIdentifier(path) + + // When: + val asset = runBlocking { storage.loadAsync(identifier).await() } + + // Then: + assertTrue(identifier in storage) + assertTrue(storage.isLoaded(identifier)) + assertEquals(1, storage.getReferenceCount(identifier)) + assertSame(asset, storage[identifier]) + } + + @Test + fun `should load assets asynchronously with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val descriptor = storage.getAssetDescriptor(path) + + // When: + val asset = runBlocking { storage.loadAsync(descriptor).await() } + + // Then: + assertTrue(descriptor in storage) + assertTrue(storage.isLoaded(descriptor)) + assertEquals(1, storage.getReferenceCount(descriptor)) + assertSame(asset, storage[descriptor]) + } + @Test fun `should differentiate assets by path and type`() { // Given: @@ -2578,7 +2647,7 @@ class AssetStorageTest : AsyncTest() { } // When: - runBlocking { storage.load(path, parameters) } + runBlocking { storage.load(path, parameters) } // Then: callbackFinished.join() @@ -2608,7 +2677,7 @@ class AssetStorageTest : AsyncTest() { } // When: - runBlocking { storage.load(path, parameters) } + runBlocking { storage.load(path, parameters) } // Then: asset should still be loaded, but the callback exception must be logged: loggingFinished.join() @@ -2732,6 +2801,75 @@ class AssetStorageTest : AsyncTest() { shouldThrow { storage.get(path) } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + } + + @Test + fun `should handle asynchronous loading exceptions`() { + // Given: + val loader = FakeAsyncLoader( + onAsync = { throw IllegalStateException("Expected.") }, + onSync = {} + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + runBlocking { storage.load(path) } + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + } + + @Test + fun `should handle synchronous loading exceptions`() { + // Given: + val loader = FakeAsyncLoader( + onAsync = { }, + onSync = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + runBlocking { storage.load(path) } + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } } @Test @@ -2831,54 +2969,6 @@ class AssetStorageTest : AsyncTest() { } } - @Test - fun `should handle asynchronous loading exceptions`() { - // Given: - val loader = FakeAsyncLoader( - onAsync = { throw IllegalStateException("Expected.") }, - onSync = {} - ) - val storage = AssetStorage(useDefaultLoaders = false) - storage.setLoader { loader } - val path = "fake path" - - // When: - shouldThrow { - runBlocking { storage.load(path) } - } - - // Then: asset should still be in storage, but rethrowing original exception: - assertTrue(storage.contains(path)) - assertEquals(1, storage.getReferenceCount(path)) - shouldThrow { - storage.get(path) - } - } - - @Test - fun `should handle synchronous loading exceptions`() { - // Given: - val loader = FakeAsyncLoader( - onAsync = { }, - onSync = { throw IllegalStateException("Expected.") } - ) - val storage = AssetStorage(useDefaultLoaders = false) - storage.setLoader { loader } - val path = "fake path" - - // When: - shouldThrow { - runBlocking { storage.load(path) } - } - - // Then: asset should still be in storage, but rethrowing original exception: - assertTrue(storage.contains(path)) - assertEquals(1, storage.getReferenceCount(path)) - shouldThrow { - storage.get(path) - } - } - @Test fun `should not fail to unload asset that was loaded exceptionally`() { // Given: From e8cb7bff8963771f6ee35f24bf3785a8b282628e Mon Sep 17 00:00:00 2001 From: MJ Date: Thu, 2 Apr 2020 19:19:35 +0200 Subject: [PATCH 13/17] Added progress tracking to AssetStorage. #182 #259 --- assets-async/README.md | 92 +++++- .../main/kotlin/ktx/assets/async/progress.kt | 108 +++++++ .../main/kotlin/ktx/assets/async/storage.kt | 135 +++++---- .../main/kotlin/ktx/assets/async/wrapper.kt | 22 +- .../kotlin/ktx/assets/async/progressTest.kt | 282 ++++++++++++++++++ .../kotlin/ktx/assets/async/storageTest.kt | 152 ++++++++-- 6 files changed, 685 insertions(+), 106 deletions(-) create mode 100644 assets-async/src/main/kotlin/ktx/assets/async/progress.kt create mode 100644 assets-async/src/test/kotlin/ktx/assets/async/progressTest.kt diff --git a/assets-async/README.md b/assets-async/README.md index 7957cf48..ce9704bf 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -503,6 +503,32 @@ fun loadAsset(assetStorage: AssetStorage) { } ``` +Tracking loading progress: + +```kotlin +import ktx.assets.async.AssetStorage + +fun trackProgress(assetStorage: AssetStorage) { + // Current loading progress can be tracked with the `progress` property: + val progress = assetStorage.progress + + // Total number of scheduled assets: + progress.total + // Total number of loaded assets: + progress.loaded + // Current progress percentage in [0, 1]: + progress.percent + // Checks if all scheduled assets are loaded: + progress.isFinished + + // Remember that due to the asynchronous nature of AssetStorage, + // the progress is only _eventually consistent_ with the storage. + // It will not know about the assets that are not fully scheduled + // for loading yet. Use progress for display only and base your + // application logic on coroutine callbacks instead. +} +``` + Adding a custom `AssetLoader` to `AssetStorage`: ```kotlin @@ -699,6 +725,53 @@ are both valid calls that will throw a runtime class cast exception. Similarly to `AssetManager`, `AssetStorage` offers API to `get` your loaded assets, so if you want to migrate from `AssetManager` to `AssetStorage`, all you have to change initially is the loading code: +```kotlin +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.async.newAsyncContext + +class WithAssetStorageBasic: ApplicationAdapter() { + // Using multiple threads to load the assets in parallel: + private val assetStorage = AssetStorage(newAsyncContext(threads = 2)) + + override fun create() { + KtxAsync.initiate() + + // Scheduling assets for asynchronous loading: + assetStorage.loadAsync("images/logo.png") + assetStorage.loadAsync("com/badlogic/gdx/utils/arial-15.fnt") + } + + override fun render() { + // The closest alternative to `update` would be to check the progress on each render: + if (assetStorage.progress.isFinished) { + changeView() + } + } + + private fun changeView() { + val texture: Texture = assetStorage["images/logo.png"] + println(texture) + TODO("Now the assets are loaded and you can get them from $assetStorage.get!") + } +} +``` + +As you can see, after the assets are loaded, the API of both `AssetManager` and `AssetStorage` is very similar. + +Now, this example looks almost identically to the `AssetManager` code and it might just work in some cases, +but there are two things we should address: + +- You will notice that your IDE warns you about not using the results of `loadAsync` which return `Deferred` instances. +This means we're launching asynchronous coroutines and ignore their results. +- `AssetStorage.progress` should be used only for display and debugging. You generally should not base your application +logic on `progress`, as it is only _eventually consistent_ with the `AssetStorage` state. + +Let's rewrite it again - this time with coroutines: + ```kotlin import com.badlogic.gdx.ApplicationAdapter import com.badlogic.gdx.graphics.Texture @@ -710,44 +783,43 @@ import ktx.async.KtxAsync import ktx.async.newAsyncContext class WithAssetStorage: ApplicationAdapter() { + // Using multiple threads to load the assets in parallel: + private val assetStorage = AssetStorage(newAsyncContext(threads = 2)) + override fun create() { KtxAsync.initiate() - // Using multiple threads to load the assets in parallel: - val assetStorage = AssetStorage(newAsyncContext(threads = 4)) - // Scheduling assets for asynchronous loading: val assets = listOf( assetStorage.loadAsync("images/logo.png"), assetStorage.loadAsync("com/badlogic/gdx/utils/arial-15.fnt") ) - // Instead of updating, we're launching a coroutine that waits for the assets: + // Instead of constantly updating or checking the progress, + // we're launching a coroutine that "waits" for the assets: KtxAsync.launch { // Suspending coroutine until all assets are loaded: assets.joinAll() - // Now the assets are loaded and we can use them with `get`: + // Resuming! Now the assets are loaded and we can obtain them with `get`: changeView(assetStorage) } } - private fun changeView(assetStorage: AssetStorage) { + private fun changeView() { val texture: Texture = assetStorage["images/logo.png"] TODO("Now the assets are loaded and can be accessed with $assetStorage.get!") } } ``` -As you can see, after the assets are loaded, the API of both `AssetManager` and `AssetStorage` is similar. - The code using `AssetStorage` is not necessarily shorter in this case, but: - You get the performance improvements of loading assets in parallel. - `AssetStorage` does not have to be updated on render. - Your code is reactive and `changeView` is called only once as soon as the assets are loaded. - You can easily integrate more coroutines into your application later for other asynchronous operations. -- `AssetStorage.get` is non-blocking and faster. `AssetStorage` does a better job of storing your assets -efficiently after loading. +- `AssetStorage.get` is non-blocking and faster than `AssetManager.get`. `AssetStorage` does a better job of storing +your assets efficiently after loading. - `AssetStorage` stores assets mapped by their path _and_ type. You will not have to deal with class cast exceptions. Besides, you get the additional benefits of other `AssetStorage` features and methods described in this file. diff --git a/assets-async/src/main/kotlin/ktx/assets/async/progress.kt b/assets-async/src/main/kotlin/ktx/assets/async/progress.kt new file mode 100644 index 00000000..488d1e81 --- /dev/null +++ b/assets-async/src/main/kotlin/ktx/assets/async/progress.kt @@ -0,0 +1,108 @@ +package ktx.assets.async + +import kotlin.math.max +import kotlin.math.min +import java.util.concurrent.atomic.AtomicInteger + +/** + * Tracks the loading progress of the [AssetStorage]. + * + * Counts the [total], [loaded] and [failed] assets. + * + * [percent] allows to see current loading progress in range of [0, 1]. + * + * The values stored by the [LoadingProgress] are _eventually consistent._ + * The progress can go slightly out of sync of the actual amounts of loaded assets, + * as it is not protected by the [AssetStorage.lock]. + * + * Due to the asynchronous nature of [AssetStorage], some assets that will eventually + * be scheduled by coroutines might not be counted by [LoadingProgress] yet. + * Calling [AssetStorage.load] or [AssetStorage.loadAsync] is not guaranteed + * to immediately update the [total] number of assets. + * + * Use the [LoadingProgress] for display only and base your actual application + * logic on [AssetStorage] API instead. + */ +class LoadingProgress { + private val totalCounter = AtomicInteger() + private val loadedCounter = AtomicInteger() + private val failedCounter = AtomicInteger() + + /** Total number of scheduled assets. */ + val total: Int + get() = totalCounter.get() + /** Total number of successfully loaded assets. */ + val loaded: Int + get() = loadedCounter.get() + /** Total number of assets that failed to load. */ + val failed: Int + get() = failedCounter.get() + /** Current asset loading percent. Does not take [failed] assets into account. */ + val percent: Float + get() { + val total = max(totalCounter.get(), 0) + val loaded = max(min(loadedCounter.get(), total), 0) + return when { + total == 0 || loaded == 0 -> 0f + total == loaded -> 1f + else -> loaded.toFloat() / total.toFloat() + } + } + + /** + * True if all registered assets are loaded or failed to load. + * + * Remember that his value might not reflect the actual state of all assets that are being scheduled + * due to the asynchronous nature of [AssetStorage]. + */ + val isFinished: Boolean + get() = total > 0 && (loadedCounter.get() + failedCounter.get()) == totalCounter.get() + /** True if there are any [failed] assets. */ + val isFailed: Boolean + get() = failedCounter.get() > 0 + + /** Must be called after a new asset was scheduled for loading. */ + internal fun registerScheduledAsset() { + totalCounter.incrementAndGet() + } + + /** Must be called after a new loaded asset was added manually. */ + internal fun registerAddedAsset() { + totalCounter.incrementAndGet() + loadedCounter.incrementAndGet() + } + + /** Must be called after an asset has finished loading. */ + internal fun registerLoadedAsset() { + loadedCounter.incrementAndGet() + } + + /** Must be called after an asset failed to load successfully. */ + internal fun registerFailedAsset() { + failedCounter.incrementAndGet() + } + + /** Must be called after a fully loaded asset was unloaded. */ + internal fun removeLoadedAsset() { + totalCounter.decrementAndGet() + loadedCounter.decrementAndGet() + } + + /** Must be called after an asset currently in the process of loading was cancelled and unloaded. */ + internal fun removeScheduledAsset() { + totalCounter.decrementAndGet() + } + + /** Must be called after an unsuccessfully loaded asset was unloaded. */ + internal fun removeFailedAsset() { + totalCounter.decrementAndGet() + failedCounter.decrementAndGet() + } + + /** Must be called after disposal of all assets. */ + internal fun reset() { + totalCounter.set(0) + loadedCounter.set(0) + failedCounter.set(0) + } +} diff --git a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt index 10c8c22e..f66d1782 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt @@ -48,6 +48,22 @@ class AssetStorage( private val lock = Mutex() private val assets = mutableMapOf, Asset<*>>() + /** + * Allows to track progress of the loaded assets. + * + * The values stored by the [LoadingProgress] are _eventually consistent._ + * The progress can go slightly out of sync of the actual amounts of loaded assets, + * as it is not protected by the [lock]. + * + * Due to the asynchronous nature of [AssetStorage], some assets that will eventually + * be scheduled by coroutines might not be counted by [LoadingProgress] yet. + * Calling [load] and [loadAsync] is not guaranteed to immediately update the + * [LoadingProgress.total] number of assets. + * + * Use the [progress] for display only and base your actual application logic on [AssetStorage] API. + */ + val progress = LoadingProgress() + /** LibGDX Logger used internally by the asset loaders, usually to report issues. */ var logger: Logger get() = asAssetManager.logger @@ -167,11 +183,12 @@ class AssetStorage( * * See also [getOrNull] and [getAsync]. */ - operator fun get(identifier: Identifier): T { + operator fun get(identifier: Identifier): T { val reference = getAsync(identifier) - @Suppress( "EXPERIMENTAL_API_USAGE") // Avoids runBlocking call. + @Suppress("EXPERIMENTAL_API_USAGE") // Avoids runBlocking call. return if (reference.isCompleted) reference.getCompleted() else throw MissingAssetException(identifier) } + /** * Returns a loaded asset of type [T] loaded from selected [path] or `null` * if the asset is not loaded yet or was never scheduled for loading. @@ -230,9 +247,9 @@ class AssetStorage( * * See also [get] and [getAsync]. */ - fun getOrNull(identifier: Identifier): T? { + fun getOrNull(identifier: Identifier): T? { val asset = assets[identifier] - @Suppress( "UNCHECKED_CAST", "EXPERIMENTAL_API_USAGE") // Avoids runBlocking call. + @Suppress("UNCHECKED_CAST", "EXPERIMENTAL_API_USAGE") // Avoids runBlocking call. return if (asset == null || !asset.reference.isCompleted) null else asset.reference.getCompleted() as T } @@ -315,10 +332,10 @@ class AssetStorage( fun getAsync(identifier: Identifier): Deferred { val asset = assets[identifier] @Suppress("UNCHECKED_CAST") - return if (asset != null) asset.reference as Deferred else getMissingAssetReference(identifier) + return if (asset != null) asset.reference as Deferred else getMissingAssetAsync(identifier) } - private fun getMissingAssetReference(identifier: Identifier): Deferred = CompletableDeferred().apply { + private fun getMissingAssetAsync(identifier: Identifier): Deferred = CompletableDeferred().apply { completeExceptionally(MissingAssetException(identifier)) } @@ -424,6 +441,7 @@ class AssetStorage( referenceCount = 1, loader = ManualLoader as Loader ) + progress.registerAddedAsset() } } @@ -462,7 +480,7 @@ class AssetStorage( /** * Schedules loading of an asset with path and type specified by [identifier]. * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. - * + * * [Identifier.path] must be compatible with the [fileResolver]. * Loading [parameters] are optional and can be used to configure the loaded asset. * @@ -620,6 +638,7 @@ class AssetStorage( } newAssets.forEach { assetToLoad -> // Loading new assets asynchronously: + progress.registerScheduledAsset() KtxAsync.launch(asyncContext) { withAssetLoadingErrorHandling(assetToLoad) { loadAsset(assetToLoad) @@ -709,11 +728,13 @@ class AssetStorage( try { operation() } catch (exception: AssetStorageException) { - asset.reference.completeExceptionally(exception) + if (asset.reference.completeExceptionally(exception)) { + progress.registerFailedAsset() + } } catch (exception: Throwable) { - asset.reference.completeExceptionally( - AssetLoadingException(asset.descriptor, cause = exception) - ) + if (asset.reference.completeExceptionally(AssetLoadingException(asset.descriptor, cause = exception))) { + progress.registerFailedAsset() + } } } @@ -753,10 +774,10 @@ class AssetStorage( } private fun setLoaded(asset: Asset, value: T) { - val isAssigned = asset.reference.complete(value) - if (isAssigned) { + if (asset.reference.complete(value)) { // The asset was correctly loaded and assigned. try { + progress.registerLoadedAsset() // Notifying the LibGDX loading callback to support AssetManager behavior: asset.descriptor.params?.loadedCallback?.finishedLoading( asAssetManager, asset.identifier.path, asset.identifier.type @@ -771,11 +792,7 @@ class AssetStorage( } else { // The asset was unloaded asynchronously. The deferred was likely completed with an exception. // Now we have to take care of the loaded value or it will remain loaded and unreferenced. - try { - value.dispose() - } catch (exception: Throwable) { - logger.error("Failed to dispose asset: ${asset.descriptor}", exception) - } + value.dispose(asset.identifier) } } @@ -855,54 +872,61 @@ class AssetStorage( * of an asset that is still loaded), the asset will not be disposed of and will remain * in the storage even if `true` is returned. */ - suspend fun unload(identifier: Identifier<*>): Boolean { - var unloaded = true - lock.withLock { - val root = assets[identifier] - if (root == null) { - unloaded = false - } else { - val queue = Queue>() - queue.addLast(root) - while (!queue.isEmpty) { - val asset = queue.removeFirst() - asset.referenceCount-- - if (asset.referenceCount == 0) { - disposeOf(asset) - assets.remove(asset.identifier) - } - asset.dependencies.forEach(queue::addLast) + suspend fun unload(identifier: Identifier<*>): Boolean = lock.withLock { + val root = assets[identifier] + if (root == null) { + // Asset is absent in the storage. Returning false - unsuccessful unload: + false + } else { + val queue = Queue>() + queue.addLast(root) + while (!queue.isEmpty) { + val asset = queue.removeFirst() + asset.referenceCount-- + if (asset.referenceCount == 0) { + // The asset is no longer referenced by the user or any dependencies. Removing and disposing. + assets.remove(asset.identifier) + disposeOf(asset) } + asset.dependencies.forEach(queue::addLast) } + // Asset was present in the storage. Returning true - successful unload: + true } - return unloaded } - private suspend fun disposeOf(asset: Asset<*>) { - val path = asset.descriptor.fileName + @Suppress("EXPERIMENTAL_API_USAGE") // Allows to dispose of assets without suspending calls. + private fun disposeOf(asset: Asset<*>) { if (!asset.reference.isCompleted) { val exception = UnloadedAssetException(asset.identifier) // If the asset is not loaded yet, we complete the reference with exception: val cancelled = asset.reference.completeExceptionally(exception) if (cancelled) { + progress.removeScheduledAsset() // We managed to complete the reference exceptionally. The loading coroutine will dispose of the asset. return } } - try { - // We did not manage to complete the reference. Asset should be disposed of. - val value = asset.reference.await() - value.dispose() - } catch (exception: UnloadedAssetException) { - // The asset was already unloaded. Should not happen, but it's not an issue. - } catch (exception: Throwable) { - // Asset failed to load or failed to dispose. Either way, we just log the exception. - logger.error("Failed to dispose asset with path: $path", exception) + val exception = asset.reference.getCompletionExceptionOrNull() + if (exception != null) { + // The asset was not loaded successfully. Nothing to dispose of. + progress.removeFailedAsset() + } else { + progress.removeLoadedAsset() + asset.reference.getCompleted().dispose(asset.identifier) } } - private fun Any?.dispose() { - (this as? Disposable)?.dispose() + /** + * Performs cast to [Disposable] if possible and disposes of the object with [Disposable.dispose]. + * Logs any disposing errors. + */ + private fun Any?.dispose(identifier: Identifier<*>) { + try { + (this as? Disposable)?.dispose() + } catch (exception: Throwable) { + logger.error("Failed to dispose of asset: $identifier", exception) + } } /** @@ -1001,6 +1025,11 @@ class AssetStorage( * Logs all disposing exceptions. * * Prefer suspending [dispose] method that takes an error handler as parameter. + * + * Calling [dispose] is not guaranteed to keep the eventual consistency of [progress] + * if [dispose] is called during asynchronous asset loading. + * If exact loading progress is crucial, prefer creating another instance of [AssetStorage] + * than reusing existing one that has been disposed. */ override fun dispose() { runBlocking { @@ -1013,6 +1042,11 @@ class AssetStorage( /** * Unloads all assets. Cancels loading of all scheduled assets. * [onError] will be invoked on every caught disposing exception. + * + * Calling [dispose] is not guaranteed to keep the eventual consistency of [progress] + * if [dispose] is called during asynchronous asset loading. + * If exact loading progress is crucial, prefer creating another instance of [AssetStorage] + * than reusing existing one that has been disposed. */ suspend fun dispose(onError: (identifier: Identifier<*>, cause: Throwable) -> Unit) { lock.withLock { @@ -1026,13 +1060,14 @@ class AssetStorage( } } try { - asset.reference.await().dispose() + (asset.reference.await() as? Disposable)?.dispose() } catch (exception: Throwable) { onError(asset.identifier, exception) } asset.referenceCount = 0 } assets.clear() + progress.reset() } } diff --git a/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt index cb02f0ed..2d6b76c7 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt @@ -54,9 +54,6 @@ internal class AssetManagerWrapper(val assetStorage: AssetStorage) override fun contains(fileName: String, type: Class<*>?): Boolean = assetStorage.contains(AssetDescriptor(fileName, type)) - @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) - override fun isFinished(): Boolean = true - @Deprecated("This operation is non-blocking. Assets might not be available in storage after call.", replaceWith = ReplaceWith("AssetStorage.add")) override fun addAsset(fileName: String, type: Class, asset: T) { @@ -113,10 +110,9 @@ internal class AssetManagerWrapper(val assetStorage: AssetStorage) override fun getDiagnostics(): String = assetStorage.toString() override fun getFileHandleResolver(): FileHandleResolver = assetStorage.fileResolver - @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) - override fun getLoadedAssets(): Int = 0.also { - logger.error("Not supported AssetManagerWrapper.getLoadedAssets called by AssetLoader.") - } + override fun getProgress(): Float = assetStorage.progress.percent + override fun getLoadedAssets(): Int = assetStorage.progress.loaded + override fun isFinished(): Boolean = assetStorage.progress.isFinished override fun getLoader(type: Class): AssetLoader<*, *>? = getLoader(type, "") override fun getLoader(type: Class, fileName: String): AssetLoader<*, *>? = @@ -179,20 +175,15 @@ internal class AssetManagerWrapper(val assetStorage: AssetStorage) } @Deprecated("AssetStorage does not have to be updated.", ReplaceWith("Nothing")) - override fun update(millis: Int): Boolean = true + override fun update(millis: Int): Boolean = isFinished @Deprecated("AssetStorage does not have to be updated.", ReplaceWith("Nothing")) - override fun update(): Boolean = true + override fun update(): Boolean = isFinished @Deprecated("Unsupported operation.", ReplaceWith("Nothing")) override fun setReferenceCount(fileName: String, refCount: Int) = throw UnsupportedMethodException("setReferenceCount") - @Deprecated("Since AssetStorage does not force asset scheduling up front, " + - "it cannot track the file loading progress.", - ReplaceWith("Nothing")) - override fun getProgress(): Float = 1f - @Deprecated("AssetStorage does not maintain an assets queue.", ReplaceWith("Nothing")) override fun getQueuedAssets(): Int = 0.also { logger.error("Not supported AssetManagerWrapper.getQueuedAssets called by AssetLoader.") @@ -207,8 +198,7 @@ internal class AssetManagerWrapper(val assetStorage: AssetStorage) override fun finishLoadingAsset(assetDesc: AssetDescriptor<*>): T = get(assetDesc as AssetDescriptor) - @Deprecated("Unsupported without asset type.", - ReplaceWith("finishLoadingAsset(assetDescriptor)")) + @Deprecated("Unsupported without asset type.", ReplaceWith("finishLoadingAsset(assetDescriptor)")) override fun finishLoadingAsset(fileName: String): T = throw UnsupportedMethodException("finishLoadingAsset(String)") diff --git a/assets-async/src/test/kotlin/ktx/assets/async/progressTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/progressTest.kt new file mode 100644 index 00000000..ce922771 --- /dev/null +++ b/assets-async/src/test/kotlin/ktx/assets/async/progressTest.kt @@ -0,0 +1,282 @@ +package ktx.assets.async + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test + +class LoadingProgressTest { + @Test + fun `should increase count of scheduled assets`() { + // Given: + val progress = LoadingProgress() + + // When: + progress.registerScheduledAsset() + + // Then: + assertEquals(1, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should increase count of loaded assets`() { + // Given: + val progress = LoadingProgress() + progress.registerScheduledAsset() + + // When: + progress.registerLoadedAsset() + + // Then: + assertEquals(1, progress.total) + assertEquals(1, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should increase count of added assets`() { + // Given: + val progress = LoadingProgress() + + // When: + progress.registerAddedAsset() + + // Then: + assertEquals(1, progress.total) + assertEquals(1, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should increase count of failed assets`() { + // Given: + val progress = LoadingProgress() + progress.registerScheduledAsset() + + // When: + progress.registerFailedAsset() + + // Then: + assertEquals(1, progress.total) + assertEquals(0, progress.loaded) + assertEquals(1, progress.failed) + } + + @Test + fun `should decrease count of scheduled assets`() { + // Given: + val progress = LoadingProgress() + progress.registerScheduledAsset() + + // When: + progress.removeScheduledAsset() + + // Then: + assertEquals(0, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should decrease count of loaded assets`() { + // Given: + val progress = LoadingProgress() + progress.registerScheduledAsset() + progress.registerLoadedAsset() + + // When: + progress.removeLoadedAsset() + + // Then: + assertEquals(0, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should decrease count of added assets`() { + // Given: + val progress = LoadingProgress() + progress.registerAddedAsset() + + // When: + progress.removeLoadedAsset() + + // Then: + assertEquals(0, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should decrease count of failed assets`() { + // Given: + val progress = LoadingProgress() + progress.registerScheduledAsset() + progress.registerFailedAsset() + + // When: + progress.removeFailedAsset() + + // Then: + assertEquals(0, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should report progress`() { + // Given: + val progress = LoadingProgress() + + // When: + repeat(5) { progress.registerScheduledAsset() } + // Then: + assertEquals(0f, progress.percent) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + + // When: + progress.registerLoadedAsset() + // Then: + assertEquals(0.2f, progress.percent) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + + // When: + progress.registerLoadedAsset() + // Then: + assertEquals(0.4f, progress.percent) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + + // When: + progress.registerLoadedAsset() + // Then: + assertEquals(0.6f, progress.percent) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + + // When: + progress.registerLoadedAsset() + // Then: + assertEquals(0.8f, progress.percent) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + + // When: + progress.registerLoadedAsset() + // Then: + assertEquals(1f, progress.percent) + assertTrue(progress.isFinished) + assertFalse(progress.isFailed) + } + + @Test + fun `should report progress within 0, 1 range even when out of sync`() { + // Given: + val progress = LoadingProgress() + + // When: loaded assets count is higher than scheduled assets: + progress.registerScheduledAsset() + progress.registerLoadedAsset() + progress.registerLoadedAsset() + + // Then: + assertEquals(1f, progress.percent) + } + + @Test + fun `should not include failed assets in progress`() { + // Given: + val progress = LoadingProgress() + repeat(4) { progress.registerScheduledAsset() } + repeat(3) { progress.registerLoadedAsset() } + + // When: + progress.registerFailedAsset() + + // Then: + assertEquals(0.75f, progress.percent) + assertTrue(progress.isFailed) + assertTrue(progress.isFinished) + } + + @Test + fun `should reset the progress`() { + // Given: + val progress = LoadingProgress() + repeat(100) { progress.registerScheduledAsset() } + repeat(75) { progress.registerLoadedAsset() } + repeat(25) { progress.registerFailedAsset() } + repeat(50) { progress.registerAddedAsset() } + + // When: + progress.reset() + + // Then: + assertEquals(0, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + } + + @Test + fun `should handle concurrent updates`() { + // Given: + val progress = LoadingProgress() + + // When: + val jobs = (1..1000).map { id -> + GlobalScope.launch { + when (id % 4) { + 0 -> progress.registerScheduledAsset() + 1 -> progress.registerLoadedAsset() + 2 -> progress.registerFailedAsset() + 3 -> progress.registerAddedAsset() + } + } + } + + // Then: + runBlocking { jobs.joinAll() } + assertEquals(500, progress.total) + assertEquals(500, progress.loaded) + assertEquals(250, progress.failed) + } + + @Test + fun `should handle concurrent registration and removal`() { + // Given: + val progress = LoadingProgress() + + // When: + val jobs = (1..1000).map { id -> + GlobalScope.launch { + when (id % 4) { + 0 -> progress.registerScheduledAsset() + 1 -> progress.registerLoadedAsset() + 2 -> progress.registerFailedAsset() + 3 -> progress.registerAddedAsset() + } + when (id % 5) { + 0 -> progress.removeScheduledAsset() + 1 -> progress.removeLoadedAsset() + 2 -> progress.removeFailedAsset() + else -> progress.registerScheduledAsset() + } + } + } + + // Then: + runBlocking { jobs.joinAll() } + assertEquals(900 - 600, progress.total) + assertEquals(500 - 200, progress.loaded) + assertEquals(250 - 200, progress.failed) + } +} diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt index 51cc365b..2e2126fd 100644 --- a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt @@ -36,6 +36,8 @@ import ktx.assets.TextAssetLoader.TextAssetLoaderParameters import ktx.async.* import org.junit.* import org.junit.Assert.* +import org.junit.rules.TestName +import java.lang.Integer.min import java.util.* import java.util.concurrent.CompletableFuture import java.util.concurrent.ThreadLocalRandom @@ -56,6 +58,9 @@ import com.badlogic.gdx.utils.Array as GdxArray * using [AssetStorage.getAssetDescriptor] is a much easier way of obtaining [AssetDescriptor] instances. */ class AssetStorageTest : AsyncTest() { + @get:Rule + var testName = TestName() + companion object { @JvmStatic @BeforeClass @@ -1370,15 +1375,54 @@ class AssetStorageTest : AsyncTest() { assertEquals(0, storage.getReferenceCount(path)) } + /** + * Allows to validate state of [LoadingProgress] without failing the test case. + * Pass [warn] not to fail the test on progress mismatch. + * + * Progress is eventually consistent. It does not have to be up to date with the [AssetStorage] state. + * Usually it will be and all tests would pass just fine, but there are these rare situations where + * the asserts are evaluated before the progress is updated. That's why if such case is possible, + * only a warning will be printed instead of failing the test. + * + * If the warnings are common, it might point to a bug within the progress updating. + */ + private fun checkProgress( + storage: AssetStorage, + loaded: Int = 0, failed: Int = 0, + total: Int = loaded + failed, + warn: Boolean = false + ) { + if (warn) { + val progress = storage.progress + if (total != progress.total || loaded != progress.loaded || failed != progress.failed) { + System.err.println(""" + Warning: mismatch in progress value in `${testName.methodName}`. + Value | Expected | Actual + total | ${"%8d".format(total)} | ${progress.total} + loaded | ${"%8d".format(loaded)} | ${progress.loaded} + failed | ${"%8d".format(failed)} | ${progress.failed} + If this warning is repeated consistently, there might be a related bug in progress reporting. + """.trimIndent()) + } + } else { + assertEquals(total, storage.progress.total) + assertEquals(loaded, storage.progress.loaded) + assertEquals(failed, storage.progress.failed) + } + } + @Test fun `should throw exception when attempting to get unloaded asset`() { // Given: val storage = AssetStorage() - // Expect: + // When: shouldThrow { storage.get("ktx/assets/async/string.txt") } + + // Then: + checkProgress(storage, total = 0) } @Test @@ -1391,6 +1435,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertNull(asset) + checkProgress(storage, total = 0) } @Test @@ -1401,10 +1446,11 @@ class AssetStorageTest : AsyncTest() { // When: val result = storage.getAsync("ktx/assets/async/string.txt") - // Expect: + // Then: shouldThrow { runBlocking { result.await() } } + checkProgress(storage, total = 0) } @Test @@ -1413,10 +1459,13 @@ class AssetStorageTest : AsyncTest() { val storage = AssetStorage() val identifier = storage.getIdentifier("ktx/assets/async/string.txt") - // Expect: + // When: shouldThrow { storage[identifier] } + + // Then: + checkProgress(storage, total = 0) } @Test @@ -1430,6 +1479,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertNull(asset) + checkProgress(storage, total = 0) } @Test @@ -1441,10 +1491,11 @@ class AssetStorageTest : AsyncTest() { // When: val result = storage.getAsync(identifier) - // Expect: + // Then: shouldThrow { runBlocking { result.await() } } + checkProgress(storage, total = 0) } @Test @@ -1453,10 +1504,13 @@ class AssetStorageTest : AsyncTest() { val storage = AssetStorage() val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") - // Expect: + // When: shouldThrow { storage[descriptor] } + + // Then: + checkProgress(storage, total = 0) } @Test @@ -1470,6 +1524,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertNull(asset) + checkProgress(storage, total = 0) } @Test @@ -1481,10 +1536,11 @@ class AssetStorageTest : AsyncTest() { // When: val result = storage.getAsync(descriptor) - // Expect: + // Then: shouldThrow { runBlocking { result.await() } } + checkProgress(storage, total = 0) } @Test @@ -1502,6 +1558,7 @@ class AssetStorageTest : AsyncTest() { assets.forEach { asset -> assertSame(loaded, asset) } + checkProgress(storage, loaded = 1) storage.dispose() } @@ -1522,6 +1579,7 @@ class AssetStorageTest : AsyncTest() { assertEquals("Content.", storage.getOrNull(path)) assertEquals("Content.", runBlocking { storage.getAsync(path).await() }) assertEquals(emptyList(), storage.getDependencies(path)) + checkProgress(storage, loaded = 1, warn = true) } @Test @@ -1540,6 +1598,7 @@ class AssetStorageTest : AsyncTest() { assertEquals("Content.", storage.getOrNull(identifier)) assertEquals("Content.", runBlocking { storage.getAsync(identifier).await() }) assertEquals(emptyList(), storage.getDependencies(identifier)) + checkProgress(storage, loaded = 1, warn = true) } @Test @@ -1558,6 +1617,7 @@ class AssetStorageTest : AsyncTest() { assertEquals("Content.", storage.getOrNull(descriptor)) assertEquals("Content.", runBlocking { storage.getAsync(descriptor).await() }) assertEquals(emptyList(), storage.getDependencies(descriptor)) + checkProgress(storage, loaded = 1, warn = true) } @Test @@ -1573,6 +1633,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertFalse(storage.isLoaded(path)) assertEquals(0, storage.getReferenceCount(path)) + checkProgress(storage, total = 0) } @Test @@ -1589,6 +1650,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertFalse(storage.isLoaded(descriptor)) assertEquals(0, storage.getReferenceCount(descriptor)) + checkProgress(storage, total = 0) } @Test @@ -1605,6 +1667,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertFalse(storage.isLoaded(identifier)) assertEquals(0, storage.getReferenceCount(identifier)) + checkProgress(storage, total = 0) } @Test @@ -1621,6 +1684,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(path)) assertEquals(1, storage.getReferenceCount(path)) assertSame(asset, storage.get(path)) + checkProgress(storage, loaded = 1, warn = true) } @Test @@ -1638,6 +1702,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(identifier)) assertEquals(1, storage.getReferenceCount(identifier)) assertSame(asset, storage[identifier]) + checkProgress(storage, loaded = 1, warn = true) } @Test @@ -1655,6 +1720,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(descriptor)) assertEquals(1, storage.getReferenceCount(descriptor)) assertSame(asset, storage[descriptor]) + checkProgress(storage, loaded = 1, warn = true) } @Test @@ -1689,6 +1755,7 @@ class AssetStorageTest : AsyncTest() { assertSame(viaPath, viaDescriptor) assertSame(viaDescriptor, viaIdentifier) assertEquals(3, storage.getReferenceCount(path)) + checkProgress(storage, loaded = 1, warn = true) } @Test @@ -1705,6 +1772,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(fakePath)) assertSame(asset, storage.get(fakePath)) assertEquals(1, storage.getReferenceCount(fakePath)) + checkProgress(storage, loaded = 1) } @Test @@ -1722,6 +1790,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(descriptor)) assertSame(asset, storage[descriptor]) assertEquals(1, storage.getReferenceCount(descriptor)) + checkProgress(storage, loaded = 1) } @Test @@ -1739,6 +1808,26 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(identifier)) assertSame(asset, storage[identifier]) assertEquals(1, storage.getReferenceCount(identifier)) + checkProgress(storage, loaded = 1) + } + + @Test + fun `should unload and dispose assets manually added to storage`() { + // Given: + val storage = AssetStorage() + val asset = FakeAsset() + val fakePath = "disposable" + runBlocking { storage.add(fakePath, asset) } + + // When: + val unloaded = runBlocking { storage.unload(fakePath) } + + // Then: + assertTrue(unloaded) + assertFalse(storage.isLoaded(fakePath)) + assertEquals(0, storage.getReferenceCount(fakePath)) + assertTrue(asset.isDisposed) + checkProgress(storage, total = 0) } @Test @@ -1795,6 +1884,7 @@ class AssetStorageTest : AsyncTest() { assertEquals(1, storage.getReferenceCount(path)) assertEquals(1, storage.getReferenceCount(path)) assertNotSame(storage.get(path), storage.get(path)) + checkProgress(storage, loaded = 2, warn = true) storage.dispose() } @@ -1825,6 +1915,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(secondPath)) assertSame(tasks[0].asCompletableFuture().join(), storage.get(firstPath)) assertSame(tasks[1].asCompletableFuture().join(), storage.get(secondPath)) + checkProgress(storage, loaded = 2, warn = true) storage.dispose() } @@ -1869,24 +1960,6 @@ class AssetStorageTest : AsyncTest() { assertEquals(1, storage.getReferenceCount(path)) } - @Test - fun `should unload and dispose assets manually added to storage`() { - // Given: - val storage = AssetStorage() - val asset = FakeAsset() - val fakePath = "disposable" - runBlocking { storage.add(fakePath, asset) } - - // When: - val unloaded = runBlocking { storage.unload(fakePath) } - - // Then: - assertTrue(unloaded) - assertFalse(storage.isLoaded(fakePath)) - assertEquals(0, storage.getReferenceCount(fakePath)) - assertTrue(asset.isDisposed) - } - @Test fun `should increase references count and return the same asset when trying to load asset with same path`() { // Given: @@ -1920,6 +1993,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertFalse(storage.contains(path)) + checkProgress(storage, total = 0) } @Test @@ -1970,7 +2044,6 @@ class AssetStorageTest : AsyncTest() { assertNull(storage.getLoader()) } - @Test fun `should increase references counts of dependencies when loading asset with same path`() { // Given: @@ -1996,6 +2069,7 @@ class AssetStorageTest : AsyncTest() { assertEquals(3, storage.getReferenceCount(it)) } assertEquals(1, loadedAssets.size) + checkProgress(storage, loaded = 3, warn = true) } @Test @@ -2023,6 +2097,7 @@ class AssetStorageTest : AsyncTest() { assertEquals(3, storage.getReferenceCount(it)) } assertEquals(1, loadedAssets.size) + checkProgress(storage, loaded = 3, warn = true) storage.dispose() } @@ -2052,6 +2127,7 @@ class AssetStorageTest : AsyncTest() { assertEquals(3, storage.getReferenceCount(it)) } assertEquals(1, loadedAssets.size) + checkProgress(storage, loaded = 3, warn = true) storage.dispose() } @@ -2078,6 +2154,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(dependency)) assertEquals(1, storage.getReferenceCount(nestedDependency)) assertTrue(storage.isLoaded(nestedDependency)) + checkProgress(storage, loaded = 2, warn = true) storage.dispose() } @@ -2111,6 +2188,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(dependency)) assertEquals(100, storage.getReferenceCount(dependency)) assertEquals(1, assets.map { it.asCompletableFuture().join() }.toSet().size) + checkProgress(storage, loaded = 2) storage.dispose() } @@ -2161,6 +2239,7 @@ class AssetStorageTest : AsyncTest() { assertTrue(storage.isLoaded(path)) assertEquals(1, storage.getReferenceCount(path)) assertEquals("Content.", storage.get(path)) + checkProgress(storage, loaded = 1, warn = true) storage.dispose() } @@ -2202,6 +2281,7 @@ class AssetStorageTest : AsyncTest() { storage.get(path).region.texture, storage.get(dependency) ) + checkProgress(storage, loaded = 2, warn = true) storage.dispose() } @@ -2246,6 +2326,7 @@ class AssetStorageTest : AsyncTest() { val texture = storage.get(nestedDependency) assertSame(skin.atlas, atlas) assertSame(atlas.textures.first(), texture) + checkProgress(storage, loaded = 3, warn = true) storage.dispose() } @@ -2296,11 +2377,13 @@ class AssetStorageTest : AsyncTest() { assertEquals(expectedReferences, storage.getReferenceCount(path)) assertEquals(expectedReferences, storage.getReferenceCount(dependency)) assertEquals(expectedReferences, storage.getReferenceCount(nestedDependency)) + // Either the skin is unloaded or there are 3 assets - skin, atlas, texture: + val assetsCount = min(1, expectedReferences) * 3 + checkProgress(storage, loaded = assetsCount, warn = true) storage.dispose() } - @Test fun `should register asset loader`() { // Given: @@ -2344,10 +2427,11 @@ class AssetStorageTest : AsyncTest() { fun `should reject invalid asset loader implementations`() { // Given: val storage = AssetStorage(useDefaultLoaders = false) - // Does not extend Synchronous/AsynchronousAssetLoader: + + // When: loader does not extend Synchronous/AsynchronousAssetLoader: val invalidLoader = mock>() - // Expect: + // Then: shouldThrow { storage.setLoader { invalidLoader } } @@ -2368,6 +2452,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertTrue(assets.all { it.isDisposed }) assertTrue(paths.all { it !in storage }) + checkProgress(storage, total = 0) } @Test @@ -2808,6 +2893,7 @@ class AssetStorageTest : AsyncTest() { shouldThrow { runBlocking { reference.await() } } + checkProgress(storage, failed = 1, warn = true) } @Test @@ -2839,6 +2925,7 @@ class AssetStorageTest : AsyncTest() { shouldThrow { runBlocking { reference.await() } } + checkProgress(storage, failed = 1, warn = true) } @Test @@ -2870,6 +2957,7 @@ class AssetStorageTest : AsyncTest() { shouldThrow { runBlocking { reference.await() } } + checkProgress(storage, failed = 1, warn = true) } @Test @@ -2913,6 +3001,7 @@ class AssetStorageTest : AsyncTest() { shouldThrow { storage.get(path) } + checkProgress(storage, total = 0, warn = true) } @Test @@ -3019,6 +3108,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertFalse(storage.contains(path)) assertFalse(storage.contains(dependency)) + checkProgress(storage, total = 0) } @Test @@ -3080,20 +3170,22 @@ class AssetStorageTest : AsyncTest() { storage.setLoader { loader } // When: - KtxAsync.launch { storage.load(path) } + KtxAsync.launch { storage.load(identifier) } // Then: - assertFalse(storage.isLoaded(path)) + assertFalse(storage.isLoaded(identifier)) loadingStarted.complete(true) loading.join() assertTrue(identifier in storage) assertFalse(storage.isLoaded(identifier)) + checkProgress(storage, loaded = 0, total = 1) loadingFinished.complete(true) runBlocking { storage.getAsync(path).await() } assertTrue(identifier in storage) assertTrue(storage.isLoaded(identifier)) + checkProgress(storage, loaded = 1, total = 1, warn = true) } @Test From f141dc567079e0ffcd93ade7e77ecb6a3c84be5a Mon Sep 17 00:00:00 2001 From: MJ Date: Sun, 5 Apr 2020 18:24:56 +0200 Subject: [PATCH 14/17] Added loadSync to AssetStorage. #182 --- CHANGELOG.md | 1 + assets-async/README.md | 112 +- .../main/kotlin/ktx/assets/async/errors.kt | 25 +- .../main/kotlin/ktx/assets/async/storage.kt | 193 +- .../main/kotlin/ktx/assets/async/wrapper.kt | 3 +- .../ktx/assets/async/storageLoadingTest.kt | 1164 ++++++++++++ .../kotlin/ktx/assets/async/storageTest.kt | 1691 +++-------------- .../ktx/freetype/async/freetypeAsync.kt | 3 + 8 files changed, 1681 insertions(+), 1511 deletions(-) create mode 100644 assets-async/src/test/kotlin/ktx/assets/async/storageLoadingTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b686ca0a..d0cbfad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - `getAsync` obtains a reference to the asset from the storage as `Deferred`. - `load` suspends a coroutine until an asset is loaded and returns its instance. - `loadAsync` schedules asynchronous loading of an asset. + - `loadSync` blocks the thread until selected asset is loaded. - `unload` schedules asynchronous unloading of an asset. - `add` allows to manually add a loaded asset to `AssetManager`. - `dispose` unloads all assets from the storage. diff --git a/assets-async/README.md b/assets-async/README.md index ce9704bf..ed6c3576 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -22,15 +22,15 @@ on any `CoroutineContext`. Feature | **KTX** `AssetStorage` | LibGDX `AssetManager` --- | --- | --- *Asynchronous loading* | **Supported.** Loading that can be done asynchronously is performed in the chosen coroutine context. Parts that require OpenGL context are performed on the main rendering thread. | **Supported.** Loading that can be performed asynchronously is done a dedicated thread, with necessary sections executed on the main rendering thread. -*Synchronous loading* | **Limited.** A blocking coroutine can be launched to selected assets eagerly, but it cannot block the rendering thread or loader threads to work correctly. | **Limited.** `finishLoading(String fileName)` method can be used to block the thread until the asset is loaded, but since it has no effect on loading order, all _other_ assets can be loaded before the requested one. -*Thread safety* | **Excellent.** Forces [`ktx-async`](../async) threading model based on coroutines. Executes blocking IO operations in a separate coroutine context and - when necessary - finishes loading on the main rendering thread. Same asset - or assets with same dependencies - can be safely scheduled for loading by multiple coroutines concurrently. Multi-threaded coroutine context can be used for asynchronous loading, possibly achieving loading performance boost. Concurrent `AssetStorage` usage is tested extensively by unit tests. | **Good.** Achieved through synchronizing most methods, which unfortunately blocks the threads that use them. Thread blocking might affect application performance, especially since even the basic `get` method is synchronized. Some operations, such as `update` or `finishLoading`, must be called from specific threads (i.e. rendering thread). -*Concurrency* | **Supported.** Multiple asset loading coroutines can be launched in parallel. Coroutine context used for asynchronous loading can have multiple threads that will be used concurrently. | **Not supported.** `update()` loads assets one by one. `AsyncExecutor` with a single thread is used internally by the `AssetManager`. To utilize multiple threads for loading, one must use multiple manager instances. -*Loading order* | **Controlled by the user.** `AssetStorage` starts loading assets as soon as the `load` method is called, giving the user full control over the order of asset loading. Selected assets can be loaded one after another within a single coroutine or in parallel with multiple coroutines, depending on the need. | **Unpredictable.** If multiple assets are scheduled at once, it is difficult to reason about their loading order. `finishLoading` has no effect on loading order and instead blocks the thread until the asset is loaded. +*Synchronous loading* | **Supported.** `loadSync` blocks the current thread until a selected asset is loaded. A blocking coroutine can also be launched to load selected assets eagerly, but it cannot block the rendering thread or loader threads to work correctly. | **Limited.** `finishLoading` method can be used to block the thread until the asset is loaded, but since it has no effect on loading order, it requires precise scheduling or it will block the thread until some or all unselected assets are loaded. +*Thread safety* | **Excellent.** Uses [`ktx-async`](../async) threading model based on coroutines. Executes blocking IO operations in a separate coroutine context and - when necessary - finishes loading on the main rendering thread. Same asset - or assets with same dependencies - can be safely scheduled for loading by multiple coroutines concurrently. Multi-threaded coroutine context can be used for asynchronous loading, possibly achieving loading performance boost. Concurrent `AssetStorage` usage is tested extensively by unit tests. | **Good.** Achieved through synchronizing most methods, which unfortunately blocks the threads that use them. Thread blocking might affect application performance, especially since even the basic `get` method is synchronized. Some operations, such as `update` or `finishLoading`, must be called from specific threads (i.e. rendering thread). +*Concurrency* | **Supported.** Multiple asset loading coroutines can be launched in parallel. Coroutine context used for asynchronous loading can have multiple threads that will be used concurrently. | **Limited.** `update()` loads assets one by one. `AsyncExecutor` with only a single thread is used internally by the `AssetManager`. To utilize multiple threads for loading, one must use multiple manager instances. +*Loading order* | **Controlled by the user.** With suspending `load`, synchronous `loadSync` and `Deferred`-returning `loadAsync`, the user can have full control over asset loading order. Selected assets can be loaded one after another within a single coroutine or in parallel with multiple coroutines, depending on the need. | **Unpredictable.** If multiple assets are scheduled at once, it is difficult to reason about their loading order. `finishLoading` has no effect on loading order and instead blocks the thread until the selected asset is loaded. *Exceptions* | **Customized.** All expected issues are given separate exception classes with common root type for easier handling. Each loading issue can be handled differently. | **Generic.** Throws either `GdxRuntimeException` or a built-in Java runtime exception. Specific issues are difficult to handle separately. -*Error handling* | **Build-in language syntax.** A regular try-catch block within coroutine body can be used to handle loading errors. Provides a clean way to handle exceptions thrown by each asset separately. | **Via listener.** One can register a global error handling listener that will be notified if a loading exception is thrown. Flow of the application is undisturbed, which makes it difficult to handle exceptions of specific assets. +*Error handling* | **Build-in language syntax.** A regular try-catch block within coroutine body can be used to handle asynchronous loading errors. Provides a clean way to handle exceptions thrown by each asset separately. | **Via listener.** One can register a global error handling listener that will be notified if a loading exception is thrown. Flow of the application is undisturbed, which makes it difficult to handle exceptions of specific assets. *File name collisions* | **Multiple assets of different types can be loaded from same path.** For example, you can load both a `Texture` and a `Pixmap` from the same PNG file. | **File paths act as unique identifiers.** `AssetManager` cannot store multiple assets with the same path, even if they have different types. -*Progress tracking* | **Limited.** Since `AssetStorage` does not force the users to schedule loading of all assets up front, it does not know the exact percent of the loaded assets. Progress must be tracked externally. | **Supported.** Since all loaded assets have to be scheduled up front, `AssetManager` can track total loading progress. -*Usage* | **Launch coroutine, load assets, use as soon as loaded.** Asynchronous complexity is hidden by the coroutines. | **Schedule loading, update in loop until loaded, extract from manager.** API based on polling _(are you done yet?),_ which might prove tedious during loading phase. Loading callbacks are available, but have obscure API and still require constant updating of the manager. +*Progress tracking* | **Supported with caveats.** `AssetStorage` does not force the users to schedule loading of all assets up front. To get the exact percent of loaded assets, all assets must be scheduled first (e.g. with `loadAsync`). | **Supported.** Since all loaded assets have to be scheduled up front, `AssetManager` can track total loading progress. +*Usage* | **Launch coroutine, load assets, use as soon as loaded.** Asynchronous complexity is hidden by the coroutines. | **Schedule loading, update in loop until loaded, extract from manager.** API based on polling _(are you done yet?),_ which might prove tedious during loading phase. Loading callbacks for individual assets are available, but have obscure API and still require constant updating of the manager. #### Usage comparison @@ -118,12 +118,10 @@ identifies assets by their paths and types, i.e. you can load multiple assets wi classes from the same file. The key difference between **KTX** storage and LibGDX manager is the threading model: -`AssetStorage` provides suspending methods executed via coroutines that resume the -coroutine as soon as the asset is loaded, while `AssetManager` requires scheduling -of asset loading up front, continuous updating until the assets are loaded and -retrieving the assets once the loading is finished. `AssetManager` leverages -a single thread for asynchronous loading operations, while `AssetStorage` can utilize -any chosen number of threads by specifying a coroutine context. + `AssetManager` leverages only a single thread for asynchronous loading operations and ensures +thread safety by relying on the `synchronized` methods, +while `AssetStorage` can utilize any chosen number of threads specified by its coroutine +context and uses non-blocking coroutines to load the assets. ### Guide @@ -145,6 +143,8 @@ called to obtain the asset instance. `isCompleted` can be used to check if the a the asset is fully loaded. Resumes the coroutine and returns the asset once it is loaded. - `loadAsync: Deferred` - schedules asset for asynchronous loading. Returns a `Deferred` reference to the asset which will be completed after the loading is finished. +- `loadSync: T` - blocks the current thread until the selected asset is loaded. Use only for crucial +assets that need to be loaded synchronously (e.g. loading screen assets). - `unload: Boolean` _(suspending)_ - unloads the selected asset. If the asset is no longer referenced, it will be removed from the storage and disposed of. Suspends the coroutine until the asset is unloaded. Returns `true` is the selected asset was present in storage or `false` if the asset was absent. @@ -446,6 +446,21 @@ fun addAsset(assetStorage: AssetStorage) { } ``` +Loading assets _synchronously_ with `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.g2d.BitmapFont + +// Sometimes you need to load assets immediately and coroutines just get in the way. +// A common example of this would be getting the assets for the loading screen. +// In this case you can use `loadSync`, which will block the current thread until +// the asset is loaded: +val font = assetStorage.loadSync("com/badlogic/gdx/utils/arial-15.fnt") + +// Whenever possible, prefer `load` or `loadAsync`. Try not to mix synchronous and +// asynchronous loading, especially on the same assets or assets with same dependencies. +``` + Disposing of all assets stored by `AssetStorage`: ```kotlin @@ -475,7 +490,7 @@ fun unloadAllAssets(assetStorage: AssetStorage) { } ``` -Loading assets with error handling: +Loading assets with custom error handling: ```kotlin import com.badlogic.gdx.graphics.Texture @@ -550,7 +565,7 @@ fun createCustomAssetStorage(): AssetStorage { #### Implementation notes -##### Multiple calls of `load`, `loadAsync` and `unload` +##### Multiple calls of `load`, `loadAsync`, `loadSync` and `unload` It is completely safe to call `load` and `loadAsync` multiple times with the same asset data, even just to obtain asset instances. In that sense, they can be used as an alternative to `getAsync` inside coroutines. @@ -573,7 +588,31 @@ and type will not increase its reference count, and will throw an exception inst Adding assets that were previously loaded by the `AssetStorage` under different paths is also a misuse of the API which might result in unloading the asset or its dependencies prematurely. True aliases are currently unsupported. -##### `runBlocking` +`loadSync` can also be used multiple times on the same asset in addition to `load` and `loadAsync`, but keep in mind +that mixing synchronous and asynchronous loading can cause loading exceptions. In particular, `loadSync` will not wait +for asset dependencies loaded by other asynchronous coroutines and will raise an exception instead. As a rule of thumb, +it is best not to mix synchronous and asynchronous loading of the same asset or assets with the same dependencies. + +##### Loading methods comparison: `load` vs `loadAsync` vs `loadSync` + +Loading method | Return type | Suspending | Loading type | When to use +:---: | :---: | :---: | --- | --- +`load` | `Asset` | Yes | Asynchronous. Schedules asset loading and awaits for the loaded asset, suspending the coroutine. | Within coroutines for controlled, sequential loading. +`loadAsync` | `Deferred` | No, but can be with `Deferred.await` | Asynchronous. Schedules asset loading and returns a `Deferred` reference to the asset. | Outside of coroutines: for scheduling of assets that can be loaded non-sequentially and in parallel. Within coroutines: for controlled order of loading with `Deferred.await`. +`loadSync` | `Asset` | No | Synchronous. Blocks the current thread until an asset is loaded. Loading is performed on the rendering thread. | **Only** outside of coroutines, for crucial assets that need to be loaded synchronously (e.g. loading screen assets). + +_`Asset` is the generic type of the loaded asset._ + +* Avoid mixing synchronous (`loadSync`) and asynchronous (`load`, `loadAsync`) loading of the same asset or assets with +the same dependencies concurrently. +* Prefer asynchronous (`load`, `loadAsync`) asset loading methods whenever possible due to better performance and +compatibility with coroutines. +* Note that `loadSync` is not _necessary_ to load initial assets. You launch a coroutine with `KtxAsync.launch`, +load the assets sequentially with `load` or in parallel with `loadAsync`/`await`, and switch to the first view +that uses the assets once they are loaded. Loading assets asynchronously might be faster - especially when done +in parallel, but ultimately it comes down to personal preference and code maintainability. + +##### Avoid `runBlocking` Kotlin's `runBlocking` function allows to launch a coroutine and block the current thread until the coroutine is finished. In general, you should **avoid** `runBlocking` calls from the main rendering thread or the threads @@ -653,17 +692,6 @@ It does not mean that `runBlocking` will always cause a deadlock, however. You c - From within other threads than the main rendering thread and the `AssetStorage` loading threads. These threads will be blocked until the operation is finished, which isn't ideal, but at least the loading will remain possible. -##### Asynchronous operations - -Most common operations - `get` and `load` - offer both synchronous/suspending and asynchronous variants. -To perform other methods asynchronously, use `KtxAsync.launch` if you do not need the result or `KtxAsync.async` -to get a `Deferred` reference to the result which will be completed after the operation is finished. - -```kotlin -// Unloading an asset asynchronously: -KtxAsync.launch { storage.unload(path) } -``` - ##### `AssetStorage` as a drop-in replacement for `AssetManager` Consider this typical application using `AssetManager`: @@ -801,7 +829,7 @@ class WithAssetStorage: ApplicationAdapter() { // Suspending coroutine until all assets are loaded: assets.joinAll() // Resuming! Now the assets are loaded and we can obtain them with `get`: - changeView(assetStorage) + changeView() } } @@ -824,6 +852,32 @@ your assets efficiently after loading. Besides, you get the additional benefits of other `AssetStorage` features and methods described in this file. +Closest method equivalents in `AssetManager` and `AssetStorage` APIs: + +`AssetManager` | `AssetStorage` | Note +:---: | :---: | --- +`get(String)` | `get(String)` | +`get(String, Class)` | `get(Identifier)` | +`get(AssetDescriptor)` | `get(AssetDescriptor)` | +`load(String, Class)` | `loadAsync(String)` | `load(String)` can also be used as an alternative within coroutines. +`load(String, Class, AssetLoaderParameters)` | `loadAsync(String, AssetLoaderParameters)` | `load(String, AssetLoaderParameters)` can also be used as an alternative within coroutines. +`load(AssetDescriptor)` | `loadAsync(AssetDescriptor)` | `load(AssetDescriptor)` can also be used as an alternative within coroutines. +`isLoaded(String)` | `isLoaded(String)` | `AssetStorage` requires asset type, so the method is generic. +`isLoaded(String, Class)` | `isLoaded(Identifier)` | +`isLoaded(AssetDescriptor)` | `isLoaded(AssetDescriptor)` | +`unload(String)` | `unload(String)`, `unload(Identifier)` | `AssetStorage` requires asset type, so the methods are generic. +`getProgress()` | `progress.percent` | +`isFinished()` | `progress.isFinished` | +`update()`, `update(Int)` | N/A | `AssetStorage` does not need to be updated. Rely on coroutines to execute code when the assets are loaded or use `progress.isFinished`. +`finishLoadingAsset(String)` | `loadSync(String)` | Assets that need to be loaded immediately (e.g. loading screen assets) can be loaded with `loadSync` instead of asynchronous `load` or `loadAsync` for convenience. +`finishLoadingAsset(AssetDescriptor)` | `loadSync(AssetDescriptor)` | +`finishLoading()` | N/A | `AssetStorage` does not provide methods that block the thread until all assets are loaded. Rely on `progress.isFinished` instead. +`addAsset(String, Class, T)` | `add(String, T)` | +`contains(String)` | `contains(String)`, `contains(Identifier)` | `AssetStorage` requires asset type, so the methods are generic. +`setErrorHandler` | N/A, `try-catch` | With `AssetStorage` you can handle loading errors immediately with regular built-in `try-catch` syntax. Error listener is not required. +`clear()` | `dispose()` | `AssetStorage.dispose` will not kill `AssetStorage` threads and can be safely used multiple times like `AssetManager.clear`. +`dispose()` | `dispose()` | `AssetStorage` also provides a suspending variant with custom error handling. + ##### Integration with LibGDX and known unsupported features `AssetStorage` does its best to integrate with LibGDX APIs - including the `AssetLoader` implementations, which were @@ -849,7 +903,7 @@ prove useful. There seem to be no other coroutines-based asset loaders available. However, LibGDX `AssetManager` is still viable when efficient parallel loading is not a requirement. -Alternatives include: +Alternatives to the `AssetStorage` include: - Using [`AssetManager`](https://github.com/libgdx/libgdx/wiki/Managing-your-assets) directly. - Using [`ktx-assets`](../assets) extensions for `AssetManager`. diff --git a/assets-async/src/main/kotlin/ktx/assets/async/errors.kt b/assets-async/src/main/kotlin/ktx/assets/async/errors.kt index 1c839127..4c7a36c0 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/errors.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/errors.kt @@ -21,6 +21,9 @@ open class AssetStorageException(message: String, cause: Throwable? = null) : Gd /** * Thrown when the asset requested by an [AssetStorage.get] variant is not available * in the [AssetStorage] at all or has not been loaded yet. + * + * This exception can also be thrown by [AssetStorage.loadSync] when mixing synchronous asset + * loading with asynchronous loading via [AssetStorage.load] or [AssetStorage.loadAsync]. */ class MissingAssetException(identifier: Identifier<*>) : AssetStorageException(message = "Asset: $identifier is not loaded.") @@ -90,10 +93,16 @@ class UnsupportedMethodException(method: String) : ) /** - * This exception is only ever thrown when trying to access assets via [AssetManagerWrapper]. - * It is typically only caused by [AssetLoader] instances or a [AssetLoaderParameters.LoadedCallback]. + * This exception can be thrown by [AssetStorage.loadSync] if dependencies of an asset were scheduled + * for asynchronous loading, but are not loaded yet. [AssetStorage.loadSync] will not wait for the + * assets and instead will throw this exception. + * + * If [AssetStorage.loadSync] was not used, this exception is only ever thrown when trying to access + * assets via [AssetManagerWrapper]. It is then typically only caused by [AssetLoader] instances or + * a [AssetLoaderParameters.LoadedCallback]. * - * If this exception is thrown, it usually means that [AssetLoader] attempts to access an asset that either: + * If you did not use [AssetStorage.loadSync], it usually means that [AssetLoader] attempts to access + * an asset that either: * - Is already unloaded. * - Failed to load with exception. * - Was not listed by [AssetLoader.getDependencies]. @@ -102,14 +111,14 @@ class UnsupportedMethodException(method: String) : * It can also be caused by an [AssetLoaderParameters.LoadedCallback] assigned to an asset when it tries * to access unloaded assets with [AssetManagerWrapper.get]. * - * Normally this exception is only expected in case of concurrent loading and unloading of the same asset. - * If it occurs otherwise, the [AssetLoader] associated with the asset might incorrect list - * asset's dependencies. + * Normally this exception is only expected in case of concurrent loading and unloading of the same asset, or when + * mixing synchronous [AssetStorage.loadSync] with asynchronous [AssetStorage.load] or [AssetStorage.loadAsync]. + * If it occurs otherwise, the [AssetLoader] associated with the asset might incorrectly list its dependencies. */ class MissingDependencyException(identifier: Identifier<*>, cause: Throwable? = null) : AssetStorageException( message = "A loader has requested an instance of ${identifier.type} at path ${identifier.path}. " + - "This asset was either not listed in dependencies, loaded with exception, not loaded yet " + - "or unloaded asynchronously.", + "This asset was either not listed in dependencies, loaded with exception, is not loaded yet " + + "or was unloaded asynchronously.", cause = cause ) diff --git a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt index f66d1782..f8d8efc5 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt @@ -99,7 +99,7 @@ class AssetStorage( * [T] is type of the loaded asset. * [path] to the file must be consistent with [fileResolver] asset type. */ - inline fun getIdentifier(path: String): Identifier = Identifier(T::class.java, path.normalizePath()) + inline fun getIdentifier(path: String): Identifier = Identifier(path.normalizePath(), T::class.java) /** * Creates a new [AssetDescriptor] for the selected asset. @@ -185,10 +185,15 @@ class AssetStorage( */ operator fun get(identifier: Identifier): T { val reference = getAsync(identifier) - @Suppress("EXPERIMENTAL_API_USAGE") // Avoids runBlocking call. - return if (reference.isCompleted) reference.getCompleted() else throw MissingAssetException(identifier) + return getOrThrow(identifier, reference) } + private fun getOrThrow(asset: Asset): T = getOrThrow(asset.identifier, asset.reference) + + private fun getOrThrow(identifier: Identifier, reference: Deferred): T = + @Suppress("EXPERIMENTAL_API_USAGE") // Avoids runBlocking call. + if (reference.isCompleted) reference.getCompleted() else throw MissingAssetException(identifier) + /** * Returns a loaded asset of type [T] loaded from selected [path] or `null` * if the asset is not loaded yet or was never scheduled for loading. @@ -728,13 +733,15 @@ class AssetStorage( try { operation() } catch (exception: AssetStorageException) { - if (asset.reference.completeExceptionally(exception)) { - progress.registerFailedAsset() - } + setLoadedExceptionally(asset, exception) } catch (exception: Throwable) { - if (asset.reference.completeExceptionally(AssetLoadingException(asset.descriptor, cause = exception))) { - progress.registerFailedAsset() - } + setLoadedExceptionally(asset, AssetLoadingException(asset.descriptor, cause = exception)) + } + } + + private fun setLoadedExceptionally(asset: Asset<*>, exception: AssetStorageException) { + if (asset.reference.completeExceptionally(exception)) { + progress.registerFailedAsset() } } @@ -796,14 +803,147 @@ class AssetStorage( } } + /** + * Blocks the current thread until the asset with [T] type is loaded from the given [path]. + * + * This method is safe to call from the main rendering thread, as well as other application threads. + * However, avoid loading the same asset or assets with the same dependencies with both synchronous + * [loadSync] and asynchronous [load] or [loadAsync]. + * + * This method should be used only to load crucial assets that are needed to initiate the application, + * e.g. assets required to display the loading screen. Whenever possible, prefer [load] and [loadAsync]. + * + * Might throw the following exceptions: + * - [MissingAssetException] when attempting to load an asset that was already scheduled for asynchronous loading. + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will either return the asset + * immediately if it is loaded, or throw [MissingAssetException] if it is unloaded. In either case, it will + * increase the reference count of the asset - see [getReferenceCount] and [unload] for details. + */ + inline fun loadSync(path: String): T = loadSync(getAssetDescriptor(path)) + + /** + * Blocks the current thread until the asset with [T] type is loaded with data specified by the [identifier] + * and optional loading [parameters]. + * + * This method is safe to call from the main rendering thread, as well as other application threads. + * However, avoid loading the same asset or assets with the same dependencies with both synchronous + * [loadSync] and asynchronous [load] or [loadAsync]. + * + * This method should be used only to load crucial assets that are needed to initiate the application, + * e.g. assets required to display the loading screen. Whenever possible, prefer [load] and [loadAsync]. + * + * Might throw the following exceptions: + * - [MissingAssetException] when attempting to load an asset that was already scheduled for asynchronous loading. + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will either return the asset + * immediately if it is loaded, or throw [MissingAssetException] if it is unloaded. In either case, it will + * increase the reference count of the asset - see [getReferenceCount] and [unload] for details. + */ + fun loadSync(identifier: Identifier, parameters: AssetLoaderParameters? = null) = + loadSync(identifier.toAssetDescriptor(parameters)) + + /** + * Blocks the current thread until the asset with [T] type is loaded using the asset [descriptor]. + * + * This method is safe to call from the main rendering thread, as well as other application threads. + * However, avoid loading the same asset or assets with the same dependencies with both synchronous + * [loadSync] and asynchronous [load] or [loadAsync]. + * + * This method should be used only to load crucial assets that are needed to initiate the application, + * e.g. assets required to display the loading screen. Whenever possible, prefer [load] and [loadAsync]. + * + * Might throw the following exceptions: + * - [MissingAssetException] when attempting to load an asset that was already scheduled for asynchronous loading. + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will either return the asset + * immediately if it is loaded, or throw [MissingAssetException] if it is unloaded. In either case, it will + * increase the reference count of the asset - see [getReferenceCount] and [unload] for details. + */ + fun loadSync(descriptor: AssetDescriptor): T = runBlocking { + lateinit var asset: Asset + val newAssets = lock.withLock { + asset = obtainAsset(descriptor) + updateReferences(asset) + } + loadSync(newAssets) + getOrThrow(asset) + } + + private suspend fun loadSync(assets: List>) { + val queue = Queue>(assets.size) + assets.forEach { + progress.registerScheduledAsset() + // Adding assets in reversed order - dependencies should be first: + queue.addFirst(it) + } + verifyDependenciesForSynchronousLoading(assets) + while (!queue.isEmpty) { + val asset = queue.removeFirst() + // "Awaiting" for dependencies to be loaded without suspending: + if (asset.dependencies.any { !it.reference.isCompleted }) { + queue.addLast(asset) + continue + } + onRenderingThread { + withAssetLoadingErrorHandling(asset) { + loadAssetSync(asset) + } + } + } + } + + private fun verifyDependenciesForSynchronousLoading(assets: List>) { + val identifiers = assets.map { it.identifier }.toSet() + val exceptions = mutableListOf() + assets.forEach { asset -> + // Gathering all dependencies that are not loaded and were scheduled for asynchronous loading: + asset.dependencies.filter { !it.reference.isCompleted && it.identifier !in identifiers } + // Preparing an exception if such a dependency occurs: + .map { MissingDependencyException(it.identifier) } + // Setting parent asset as exceptionally loaded: + .forEach { exception -> setLoadedExceptionally(asset, exception); exceptions.add(exception) } + } + // Throwing one of the exceptions if any occurs: + exceptions.firstOrNull()?.let { throw it } + } + + private fun loadAssetSync(asset: Asset) = + when (val loader = asset.loader) { + is SynchronousLoader -> setLoaded(asset, loader.load(asAssetManager, asset.descriptor)) + is AsynchronousLoader -> { + loader.loadAsync(asAssetManager, asset.descriptor) + setLoaded(asset, loader.loadSync(asAssetManager, asset.descriptor)) + } + else -> throw InvalidLoaderException(loader) + } + /** * Removes asset loaded with the given [path] and [T] type and all of its dependencies. * Does nothing if asset was not loaded in the first place. * Will not dispose of the asset if it still is referenced by any other assets. * Any removed assets that implement [Disposable] will be disposed. * - * Note: only assets that were explicitly scheduled for loading with [load] - * or manually added to storage with [add] should be unloaded. + * Note: only assets that were explicitly scheduled for loading with [load], [loadAsync] or + * [loadSync], or manually added to storage with [add] should be unloaded. * Dependencies of assets will be removed automatically along with the original assets * that caused them to load in the first place. * @@ -828,8 +968,8 @@ class AssetStorage( * Will not dispose of the asset if it still is referenced by any other assets. * Any removed assets that implement [Disposable] will be disposed. * - * Note: only assets that were explicitly scheduled for loading with [load] - * or manually added to storage with [add] should be unloaded. + * Note: only assets that were explicitly scheduled for loading with [load], [loadAsync] or + * [loadSync], or manually added to storage with [add] should be unloaded. * Dependencies of assets will be removed automatically along with the original assets * that caused them to load in the first place. * @@ -854,8 +994,8 @@ class AssetStorage( * Will not dispose of the asset if it still is referenced by any other assets. * Any removed assets that implement [Disposable] will be disposed. * - * Note: only assets that were explicitly scheduled for loading with [load] - * or manually added to storage with [add] should be unloaded. + * Note: only assets that were explicitly scheduled for loading with [load], [loadAsync] or + * [loadSync], or manually added to storage with [add] should be unloaded. * Dependencies of assets will be removed automatically along with the original assets * that caused them to load in the first place. * @@ -975,25 +1115,28 @@ class AssetStorage( /** * Returns the amount of references to the asset under the given [path] of [T] type. + * * References include manual registration of the asset with [add], - * scheduling the asset for loading with [load] and the amount of times - * the asset was referenced as a dependency of other assets. + * scheduling the asset for loading with [load], [loadAsync] or [loadSync], + * and the amount of times the asset was referenced as a dependency of other assets. */ inline fun getReferenceCount(path: String): Int = getReferenceCount(getIdentifier(path)) /** * Returns the amount of references to the asset described by [descriptor]. + * * References include manual registration of the asset with [add], - * scheduling the asset for loading with [load] and the amount of times - * the asset was referenced as a dependency of other assets. + * scheduling the asset for loading with [load], [loadAsync] or [loadSync], + * and the amount of times the asset was referenced as a dependency of other assets. */ fun getReferenceCount(descriptor: AssetDescriptor<*>): Int = getReferenceCount(descriptor.toIdentifier()) /** * Returns the amount of references to the asset identified by [identifier]. + * * References include manual registration of the asset with [add], - * scheduling the asset for loading with [load] and the amount of times - * the asset was referenced as a dependency of other assets. + * scheduling the asset for loading with [load], [loadAsync] or [loadSync], + * and the amount of times the asset was referenced as a dependency of other assets. */ fun getReferenceCount(identifier: Identifier<*>): Int = assets[identifier]?.referenceCount ?: 0 @@ -1106,10 +1249,10 @@ internal data class Asset( * or [AssetDescriptor.toIdentifier]. */ data class Identifier( - /** [Class] of the asset specified during loading. */ - val type: Class, /** File path to the asset compatible with the [AssetStorage.fileResolver]. Must be normalized. */ - val path: String + val path: String, + /** [Class] of the asset specified during loading. */ + val type: Class ) { /** * Converts this [Identifier] to an [AssetDescriptor] that describes the asset and its loading data. @@ -1146,4 +1289,4 @@ data class Identifier( * will be used to resolve the file using [AssetStorage.fileResolver]. If a [FileHandle] of different type * is required, use [AssetDescriptor] for loading instead. */ -fun AssetDescriptor.toIdentifier(): Identifier = Identifier(type, fileName) +fun AssetDescriptor.toIdentifier(): Identifier = Identifier(fileName, type) diff --git a/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt index 2d6b76c7..f478fc4e 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt @@ -34,7 +34,6 @@ internal class AssetManagerWrapper(val assetStorage: AssetStorage) override fun clear() = dispose() - @Deprecated("This operation is non-blocking. Assets might still be loaded after this call.", replaceWith = ReplaceWith("AssetStorage.dispose")) override fun dispose() { @@ -79,7 +78,7 @@ internal class AssetManagerWrapper(val assetStorage: AssetStorage) get(assetDescriptor.fileName, assetDescriptor.type) override fun get(fileName: String, type: Class): Asset { - val identifier = Identifier(type, fileName) + val identifier = Identifier(fileName, type) return try { assetStorage[identifier] } catch (exception: Throwable) { diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageLoadingTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageLoadingTest.kt new file mode 100644 index 00000000..d2e85d83 --- /dev/null +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageLoadingTest.kt @@ -0,0 +1,1164 @@ +package ktx.assets.async + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.loaders.resolvers.ClasspathFileHandleResolver +import com.badlogic.gdx.audio.Music +import com.badlogic.gdx.audio.Sound +import com.badlogic.gdx.backends.lwjgl.LwjglNativesLoader +import com.badlogic.gdx.backends.lwjgl.audio.OpenALAudio +import com.badlogic.gdx.graphics.Cubemap +import com.badlogic.gdx.graphics.Pixmap +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import com.badlogic.gdx.graphics.g2d.ParticleEffect +import com.badlogic.gdx.graphics.g2d.TextureAtlas +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.glutils.ShaderProgram +import com.badlogic.gdx.math.Vector2 +import com.badlogic.gdx.scenes.scene2d.ui.Button +import com.badlogic.gdx.scenes.scene2d.ui.Skin +import com.badlogic.gdx.utils.I18NBundle +import com.badlogic.gdx.utils.Logger +import com.nhaarman.mockitokotlin2.mock +import io.kotlintest.matchers.shouldThrow +import kotlinx.coroutines.runBlocking +import ktx.assets.TextAssetLoader +import ktx.async.AsyncTest +import org.junit.* +import org.junit.Assert.* +import org.junit.rules.TestName +import java.util.* +import com.badlogic.gdx.graphics.g3d.particles.ParticleEffect as ParticleEffect3D + +/** + * [AssetStorage] has 3 main variants of asset loading: [AssetStorage.load], [AssetStorage.loadAsync] + * and [AssetStorage.loadSync]. To test each and every one, a common abstract test suite is provided. + * + * This test suite ensures that each method supports loading of every default asset type + * and performs basic asset loading logic tests. + * + * Note that variants consuming [String] path and reified asset types could not be easily tested, + * as they cannot be invoked in abstract methods. However, since all of them are just aliases and + * contain no logic other than [AssetDescriptor] or [Identifier] initiation, the associated loading + * methods are still tested. + * + * See also: [AssetStorageTest]. + */ +abstract class AbstractAssetStorageLoadingTest : AsyncTest() { + @get:Rule + var testName = TestName() + + /** + * Must be overridden with the tested loading method variant. + * Blocks the current thread until the selected asset is loaded. + */ + protected abstract fun AssetStorage.testLoad( + path: String, + type: Class, + parameters: AssetLoaderParameters? + ): T + + private inline fun AssetStorage.testLoad( + path: String, + parameters: AssetLoaderParameters? = null + ): T = testLoad(path, T::class.java, parameters) + + // --- Asset support tests: + + @Test + fun `should load text assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + } + + @Test + fun `should load text assets with parameters`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + val asset = storage.testLoad(path, parameters = TextAssetLoader.TextAssetLoaderParameters("UTF-8")) + + // Then: + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + } + + @Test + fun `should unload text assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load BitmapFont assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Font dependencies: + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertSame(asset.region.texture, storage.get(dependency)) + + storage.dispose() + } + + @Test + fun `should unload BitmapFont with dependencies`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Music assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Music assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Sound assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Sound assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load TextureAtlas assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.atlas" + val dependency = "ktx/assets/async/texture.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Atlas dependencies: + assertTrue(storage.isLoaded(dependency)) + assertSame(asset.textures.first(), storage.get(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + + storage.dispose() + } + + @Test + fun `should unload TextureAtlas assets with dependencies`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.atlas" + val dependency = "ktx/assets/async/texture.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + assertEquals(0, storage.getReferenceCount(dependency)) + } + + @Test + fun `should load Texture assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Texture assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Pixmap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Pixmap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Skin assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val atlas = "ktx/assets/async/skin.atlas" + val texture = "ktx/assets/async/texture.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) + // Skin dependencies: + assertTrue(storage.isLoaded(atlas)) + assertEquals(1, storage.getReferenceCount(atlas)) + assertSame(asset.atlas, storage.get(atlas)) + assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) + // Atlas dependencies: + assertTrue(storage.isLoaded(texture)) + assertSame(asset.atlas.textures.first(), storage.get(texture)) + assertEquals(1, storage.getReferenceCount(texture)) + + storage.dispose() + } + + @Test + fun `should unload Skin assets with dependencies`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val atlas = "ktx/assets/async/skin.atlas" + val texture = "ktx/assets/async/texture.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(atlas)) + assertEquals(0, storage.getReferenceCount(atlas)) + assertFalse(storage.isLoaded(texture)) + assertEquals(0, storage.getReferenceCount(texture)) + } + + @Test + fun `should load I18NBundle assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/i18n" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertEquals("Value.", asset["key"]) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload I18NBundle assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/i18n" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load ParticleEffect assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p2d" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload ParticleEffect assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p2d" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load ParticleEffect3D assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p3d" + val dependency = "ktx/assets/async/texture.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Particle dependencies: + assertTrue(storage.isLoaded(dependency)) + assertNotNull(storage.get(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + + storage.dispose() + } + + @Test + fun `should unload ParticleEffect3D assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p3d" + val dependency = "ktx/assets/async/texture.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + assertEquals(0, storage.getReferenceCount(dependency)) + } + + @Test + fun `should load OBJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.obj" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload OBJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.obj" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load G3DJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3dj" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload G3DJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3dj" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load G3DB Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3db" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload G3DB Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3db" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load ShaderProgram assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/shader.frag" + // Silencing logs - shader will fail to compile, as GL is mocked: + storage.logger.level = Logger.NONE + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload ShaderProgram assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/shader.frag" + // Silencing logs - shader will fail to compile, as GL is mocked: + storage.logger.level = Logger.NONE + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Cubemap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/cubemap.zktx" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Cubemap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/cubemap.zktx" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should dispose of multiple assets of different types without errors`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + storage.logger.level = Logger.NONE + val assets = listOf( + storage.getIdentifier("ktx/assets/async/string.txt"), + storage.getIdentifier("com/badlogic/gdx/utils/arial-15.fnt"), + storage.getIdentifier("ktx/assets/async/sound.ogg"), + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png"), + storage.getIdentifier("ktx/assets/async/skin.json"), + storage.getIdentifier("ktx/assets/async/i18n"), + storage.getIdentifier("ktx/assets/async/particle.p2d"), + storage.getIdentifier("ktx/assets/async/particle.p3d"), + storage.getIdentifier("ktx/assets/async/model.obj"), + storage.getIdentifier("ktx/assets/async/model.g3dj"), + storage.getIdentifier("ktx/assets/async/model.g3db"), + storage.getIdentifier("ktx/assets/async/shader.frag"), + storage.getIdentifier("ktx/assets/async/cubemap.zktx") + ) + assets.forEach { + storage.testLoad(it.path, it.type, parameters = null) + assertTrue(storage.isLoaded(it)) + } + + // When: + storage.dispose() + + // Then: + assets.forEach { + assertFalse(it in storage) + assertFalse(storage.isLoaded(it)) + assertEquals(0, storage.getReferenceCount(it)) + assertEquals(emptyList(), storage.getDependencies(it)) + shouldThrow { + storage[it] + } + } + } + + // --- Behavior tests: + + @Test + fun `should return same asset instance with subsequent load calls on loaded asset`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val loaded = storage.testLoad(path) + + // When: + val assets = (1..10).map { storage.testLoad(path) } + + // Then: + assertEquals(11, storage.getReferenceCount(path)) + assets.forEach { asset -> + assertSame(loaded, asset) + } + checkProgress(storage, loaded = 1) + + storage.dispose() + } + + @Test + fun `should obtain loaded asset with path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + storage.testLoad(path) + + // Then: + assertTrue(storage.contains(path)) + assertTrue(storage.isLoaded(path)) + assertEquals("Content.", storage.get(path)) + assertEquals("Content.", storage.getOrNull(path)) + assertEquals("Content.", runBlocking { storage.getAsync(path).await() }) + assertEquals(emptyList(), storage.getDependencies(path)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should obtain loaded asset with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val identifier = storage.getIdentifier("ktx/assets/async/string.txt") + + // When: + storage.testLoad(identifier.path) + + // Then: + assertTrue(identifier in storage) + assertTrue(storage.isLoaded(identifier)) + assertEquals("Content.", storage[identifier]) + assertEquals("Content.", storage.getOrNull(identifier)) + assertEquals("Content.", runBlocking { storage.getAsync(identifier).await() }) + assertEquals(emptyList(), storage.getDependencies(identifier)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should obtain loaded asset with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // When: + storage.testLoad(descriptor.fileName) + + // Then: + assertTrue(descriptor in storage) + assertTrue(storage.isLoaded(descriptor)) + assertEquals("Content.", storage[descriptor]) + assertEquals("Content.", storage.getOrNull(descriptor)) + assertEquals("Content.", runBlocking { storage.getAsync(descriptor).await() }) + assertEquals(emptyList(), storage.getDependencies(descriptor)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should unload assets with path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + checkProgress(storage, total = 0) + } + + @Test + fun `should unload assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val descriptor = storage.getAssetDescriptor(path) + storage.testLoad(path) + + // When: + runBlocking { storage.unload(descriptor) } + + // Then: + assertFalse(storage.isLoaded(descriptor)) + assertEquals(0, storage.getReferenceCount(descriptor)) + checkProgress(storage, total = 0) + } + + @Test + fun `should unload assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val identifier = storage.getIdentifier(path) + storage.testLoad(path) + + // When: + runBlocking { storage.unload(identifier) } + + // Then: + assertFalse(storage.isLoaded(identifier)) + assertEquals(0, storage.getReferenceCount(identifier)) + checkProgress(storage, total = 0) + } + + @Test + fun `should allow to load multiple assets with different type and same path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + storage.testLoad(path) + storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertNotSame(storage.get(path), storage.get(path)) + checkProgress(storage, loaded = 2, warn = true) + + storage.dispose() + } + + @Test + fun `should increase references count and return the same asset when trying to load asset with same path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val elements = IdentityHashMap() + + // When: + repeat(3) { + val asset = storage.testLoad(path) + elements[asset] = true + } + + // Then: + assertEquals(3, storage.getReferenceCount(path)) + assertEquals(1, elements.size) + } + + @Test + fun `should fail to load asset with missing loader`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + shouldThrow { + storage.testLoad(path) + } + + // Then: + assertFalse(storage.contains(path)) + checkProgress(storage, total = 0) + } + + @Test + fun `should increase references counts of dependencies when loading same asset`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val dependencies = arrayOf( + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png") + ) + val loadedAssets = IdentityHashMap() + + // When: + repeat(3) { + val asset = storage.testLoad(path) + loadedAssets[asset] = true + } + + // Then: + assertEquals(3, storage.getReferenceCount(path)) + dependencies.forEach { + assertEquals(3, storage.getReferenceCount(it)) + } + assertEquals(1, loadedAssets.size) + checkProgress(storage, loaded = 3, warn = true) + } + + @Test + fun `should handle loading exceptions`() { + // Given: + val loader = AssetStorageTest.FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + storage.testLoad(path) + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + checkProgress(storage, failed = 1, warn = true) + } + + @Test + fun `should handle asynchronous loading exceptions`() { + // Given: + val loader = AssetStorageTest.FakeAsyncLoader( + onAsync = { throw IllegalStateException("Expected.") }, + onSync = {} + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + storage.testLoad(path) + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + checkProgress(storage, failed = 1, warn = true) + } + + @Test + fun `should handle synchronous loading exceptions`() { + // Given: + val loader = AssetStorageTest.FakeAsyncLoader( + onAsync = { }, + onSync = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + storage.testLoad(path) + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + checkProgress(storage, failed = 1, warn = true) + } + + @Test + fun `should not fail to unload asset that was loaded exceptionally`() { + // Given: + val loader = AssetStorageTest.FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + storage.setLoader { loader } + storage.logger.level = Logger.NONE // Disposing exception will be logged. + try { + storage.testLoad(path) + } catch (exception: AssetLoadingException) { + // Expected. + } + + // When: + val unloaded = runBlocking { + storage.unload(path) + } + + // Then: + assertTrue(unloaded) + assertFalse(storage.contains(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + /** + * Allows to validate state of [LoadingProgress] without failing the test case. + * Pass [warn] not to fail the test on progress mismatch. + * + * Progress is eventually consistent. It does not have to be up to date with the [AssetStorage] state. + * Usually it will be and all tests would pass just fine, but there are these rare situations where + * the asserts are evaluated before the progress is updated. That's why if such case is possible, + * only a warning will be printed instead of failing the test. + * + * If the warnings are common, it might point to a bug within the progress updating. + */ + private fun checkProgress( + storage: AssetStorage, + loaded: Int = 0, failed: Int = 0, + total: Int = loaded + failed, + warn: Boolean = false + ) { + if (warn) { + val progress = storage.progress + if (total != progress.total || loaded != progress.loaded || failed != progress.failed) { + System.err.println(""" + Warning: mismatch in progress value in `${testName.methodName}`. + Value | Expected | Actual + total | ${"%8d".format(total)} | ${progress.total} + loaded | ${"%8d".format(loaded)} | ${progress.loaded} + failed | ${"%8d".format(failed)} | ${progress.failed} + If this warning is repeated consistently, there might be a related bug in progress reporting. + """.trimIndent()) + } + } else { + assertEquals(total, storage.progress.total) + assertEquals(loaded, storage.progress.loaded) + assertEquals(failed, storage.progress.failed) + } + } + + companion object { + @JvmStatic + @BeforeClass + fun `load LibGDX statics`() { + // Necessary for LibGDX asset loaders to work. + LwjglNativesLoader.load() + Gdx.graphics = mock() + Gdx.gl20 = mock() + Gdx.gl = Gdx.gl20 + } + + @JvmStatic + @AfterClass + fun `dispose of LibGDX statics`() { + Gdx.graphics = null + Gdx.audio = null + Gdx.gl20 = null + Gdx.gl = null + } + } + + @Before + override fun `setup LibGDX application`() { + super.`setup LibGDX application`() + Gdx.audio = OpenALAudio() + } + + @After + override fun `exit LibGDX application`() { + super.`exit LibGDX application`() + (Gdx.audio as OpenALAudio).dispose() + } +} + +/** + * Performs asset loading tests with [AssetStorage.loadAsync] consuming [AssetDescriptor]. + */ +class AssetStorageLoadingTestWithAssetDescriptorLoadAsync : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = runBlocking { + loadAsync(AssetDescriptor(path, type, parameters)).await() + } +} + +/** + * Performs asset loading tests with [AssetStorage.loadAsync] consuming [Identifier]. + */ +class AssetStorageLoadingTestWithIdentifierLoadAsync : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = runBlocking { + loadAsync(Identifier(path, type), parameters).await() + } +} + +/** + * Performs asset loading tests with [AssetStorage.load] consuming [AssetDescriptor]. + */ +class AssetStorageLoadingTestWithAssetDescriptorLoad : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = runBlocking { + load(AssetDescriptor(path, type, parameters)) + } +} + +/** + * Performs asset loading tests with [AssetStorage.load] consuming [Identifier]. + */ +class AssetStorageLoadingTestWithIdentifierLoad : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = runBlocking { + load(Identifier(path, type), parameters) + } +} + +/** + * Performs asset loading tests with [AssetStorage.loadSync] consuming [AssetDescriptor]. + */ +class AssetStorageLoadingTestWithAssetDescriptorLoadSync : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = loadSync(AssetDescriptor(path, type, parameters)) +} + +/** + * Performs asset loading tests with [AssetStorage.loadSync] consuming [Identifier]. + */ +class AssetStorageLoadingTestWithIdentifierLoadSync : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = loadSync(Identifier(path, type), parameters) +} diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt index 2e2126fd..320a3194 100644 --- a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt @@ -10,7 +10,6 @@ import com.badlogic.gdx.assets.loaders.resolvers.ClasspathFileHandleResolver import com.badlogic.gdx.audio.Music import com.badlogic.gdx.audio.Sound import com.badlogic.gdx.backends.lwjgl.LwjglNativesLoader -import com.badlogic.gdx.backends.lwjgl.audio.OpenALAudio import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.graphics.Cubemap import com.badlogic.gdx.graphics.Pixmap @@ -21,7 +20,6 @@ import com.badlogic.gdx.graphics.g2d.TextureAtlas import com.badlogic.gdx.graphics.g3d.Model import com.badlogic.gdx.graphics.glutils.ShaderProgram import com.badlogic.gdx.math.Vector2 -import com.badlogic.gdx.scenes.scene2d.ui.Button import com.badlogic.gdx.scenes.scene2d.ui.Skin import com.badlogic.gdx.utils.Disposable import com.badlogic.gdx.utils.GdxRuntimeException @@ -34,8 +32,11 @@ import kotlinx.coroutines.* import kotlinx.coroutines.future.asCompletableFuture import ktx.assets.TextAssetLoader.TextAssetLoaderParameters import ktx.async.* -import org.junit.* +import org.junit.AfterClass import org.junit.Assert.* +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test import org.junit.rules.TestName import java.lang.Integer.min import java.util.* @@ -49,1330 +50,40 @@ import com.badlogic.gdx.utils.Array as GdxArray /** * Tests [AssetStorage]: coroutines-based asset manager. * - * Implementation note: the tests use [runBlocking] to launch the coroutines for simplicity - * of asserts. Normally [KtxAsync].launch is highly encouraged to build truly asynchronous applications. - * Using [runBlocking] on the main rendering thread might lead to deadlocks, as the rendering thread - * is necessary to load some assets (e.g. textures). - * - * In a similar manner, some [AssetDescriptor] instances are created manually. In an actual application, - * using [AssetStorage.getAssetDescriptor] is a much easier way of obtaining [AssetDescriptor] instances. - */ -class AssetStorageTest : AsyncTest() { - @get:Rule - var testName = TestName() - - companion object { - @JvmStatic - @BeforeClass - fun `load LibGDX statics`() { - // Necessary for LibGDX asset loaders to work. - LwjglNativesLoader.load() - Gdx.graphics = mock() - Gdx.gl20 = mock() - Gdx.gl = Gdx.gl20 - } - - @JvmStatic - @AfterClass - fun `dispose of LibGDX statics`() { - Gdx.graphics = null - Gdx.audio = null - Gdx.gl20 = null - Gdx.gl = null - } - } - - @Before - override fun `setup LibGDX application`() { - super.`setup LibGDX application`() - Gdx.audio = OpenALAudio() - } - - @After - override fun `exit LibGDX application`() { - super.`exit LibGDX application`() - (Gdx.audio as OpenALAudio).dispose() - } - - @Test - fun `should load text assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/string.txt" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertEquals("Content.", asset) - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - } - - @Test - fun `should load text assets with parameters`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/string.txt" - - // When: - val asset = runBlocking { - storage.load(path, parameters = TextAssetLoaderParameters("UTF-8")) - } - - // Then: - assertEquals("Content.", asset) - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - } - - @Test - fun `should load text assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/string.txt" - val descriptor = AssetDescriptor(path, String::class.java, TextAssetLoaderParameters("UTF-8")) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertEquals("Content.", asset) - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - } - - @Test - fun `should load text assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/string.txt" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertEquals("Content.", asset) - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - } - - @Test - fun `should load text assets with identifier and parameters`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/string.txt" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { - storage.load(identifier, TextAssetLoaderParameters("UTF-8")) - } - - // Then: - assertEquals("Content.", asset) - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - } - - @Test - fun `should unload text assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/string.txt" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load BitmapFont assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "com/badlogic/gdx/utils/arial-15.fnt" - val dependency = "com/badlogic/gdx/utils/arial-15.png" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) - // Font dependencies: - assertTrue(storage.isLoaded(dependency)) - assertEquals(1, storage.getReferenceCount(dependency)) - assertSame(asset.region.texture, storage.get(dependency)) - - storage.dispose() - } - - @Test - fun `should load BitmapFont assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "com/badlogic/gdx/utils/arial-15.fnt" - val dependency = "com/badlogic/gdx/utils/arial-15.png" - val descriptor = AssetDescriptor(path, BitmapFont::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) - // Font dependencies: - assertTrue(storage.isLoaded(dependency)) - assertEquals(1, storage.getReferenceCount(dependency)) - assertSame(asset.region.texture, storage.get(dependency)) - - storage.dispose() - } - - @Test - fun `should load BitmapFont assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "com/badlogic/gdx/utils/arial-15.fnt" - val dependency = "com/badlogic/gdx/utils/arial-15.png" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) - // Font dependencies: - assertTrue(storage.isLoaded(dependency)) - assertEquals(1, storage.getReferenceCount(dependency)) - assertSame(asset.region.texture, storage.get(dependency)) - - storage.dispose() - } - - @Test - fun `should unload BitmapFont with dependencies`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "com/badlogic/gdx/utils/arial-15.fnt" - val dependency = "com/badlogic/gdx/utils/arial-15.png" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - assertFalse(storage.isLoaded(dependency)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load Music assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/sound.ogg" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load Music assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/sound.ogg" - val descriptor = AssetDescriptor(path, Music::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - - @Test - fun `should load Music assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/sound.ogg" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload Music assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/sound.ogg" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - - @Test - fun `should load Sound assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/sound.ogg" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load Sound assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/sound.ogg" - val descriptor = AssetDescriptor(path, Sound::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load Sound assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/sound.ogg" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload Sound assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/sound.ogg" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load TextureAtlas assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/skin.atlas" - val dependency = "ktx/assets/async/texture.png" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) - // Atlas dependencies: - assertTrue(storage.isLoaded(dependency)) - assertSame(asset.textures.first(), storage.get(dependency)) - assertEquals(1, storage.getReferenceCount(dependency)) - - storage.dispose() - } - - @Test - fun `should load TextureAtlas assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/skin.atlas" - val dependency = "ktx/assets/async/texture.png" - val descriptor = AssetDescriptor(path, TextureAtlas::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) - // Atlas dependencies: - assertTrue(storage.isLoaded(dependency)) - assertSame(asset.textures.first(), storage.get(dependency)) - assertEquals(1, storage.getReferenceCount(dependency)) - - storage.dispose() - } - - @Test - fun `should load TextureAtlas assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/skin.atlas" - val dependency = "ktx/assets/async/texture.png" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) - // Atlas dependencies: - assertTrue(storage.isLoaded(dependency)) - assertSame(asset.textures.first(), storage.get(dependency)) - assertEquals(1, storage.getReferenceCount(dependency)) - - storage.dispose() - } - - @Test - fun `should unload TextureAtlas assets with dependencies`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/skin.atlas" - val dependency = "ktx/assets/async/texture.png" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - assertFalse(storage.isLoaded(dependency)) - assertEquals(0, storage.getReferenceCount(dependency)) - } - - @Test - fun `should load Texture assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load Texture assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - val descriptor = AssetDescriptor(path, Texture::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load Texture assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload Texture assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load Pixmap assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load Pixmap assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - val descriptor = AssetDescriptor(path, Pixmap::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load Pixmap assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload Pixmap assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load Skin assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/skin.json" - val atlas = "ktx/assets/async/skin.atlas" - val texture = "ktx/assets/async/texture.png" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) - // Skin dependencies: - assertTrue(storage.isLoaded(atlas)) - assertEquals(1, storage.getReferenceCount(atlas)) - assertSame(asset.atlas, storage.get(atlas)) - assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) - // Atlas dependencies: - assertTrue(storage.isLoaded(texture)) - assertSame(asset.atlas.textures.first(), storage.get(texture)) - assertEquals(1, storage.getReferenceCount(texture)) - - storage.dispose() - } - - @Test - fun `should load Skin assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/skin.json" - val atlas = "ktx/assets/async/skin.atlas" - val texture = "ktx/assets/async/texture.png" - val descriptor = AssetDescriptor(path, Skin::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) - // Skin dependencies: - assertTrue(storage.isLoaded(atlas)) - assertEquals(1, storage.getReferenceCount(atlas)) - assertSame(asset.atlas, storage.get(atlas)) - assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) - // Atlas dependencies: - assertTrue(storage.isLoaded(texture)) - assertSame(asset.atlas.textures.first(), storage.get(texture)) - assertEquals(1, storage.getReferenceCount(texture)) - - storage.dispose() - } - - @Test - fun `should load Skin assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/skin.json" - val atlas = "ktx/assets/async/skin.atlas" - val texture = "ktx/assets/async/texture.png" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) - // Skin dependencies: - assertTrue(storage.isLoaded(atlas)) - assertEquals(1, storage.getReferenceCount(atlas)) - assertSame(asset.atlas, storage.get(atlas)) - assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) - // Atlas dependencies: - assertTrue(storage.isLoaded(texture)) - assertSame(asset.atlas.textures.first(), storage.get(texture)) - assertEquals(1, storage.getReferenceCount(texture)) - - storage.dispose() - } - - @Test - fun `should unload Skin assets with dependencies`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/skin.json" - val atlas = "ktx/assets/async/skin.atlas" - val texture = "ktx/assets/async/texture.png" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - assertFalse(storage.isLoaded(atlas)) - assertEquals(0, storage.getReferenceCount(atlas)) - assertFalse(storage.isLoaded(texture)) - assertEquals(0, storage.getReferenceCount(texture)) - } - - @Test - fun `should load I18NBundle assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/i18n" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertEquals("Value.", asset["key"]) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load I18NBundle assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/i18n" - val descriptor = AssetDescriptor(path, I18NBundle::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertEquals("Value.", asset["key"]) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load I18NBundle assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/i18n" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertEquals("Value.", asset["key"]) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload I18NBundle assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/i18n" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load ParticleEffect assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/particle.p2d" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load ParticleEffect assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/particle.p2d" - val descriptor = AssetDescriptor(path, ParticleEffect::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load ParticleEffect assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/particle.p2d" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload ParticleEffect assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/particle.p2d" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load ParticleEffect3D assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/particle.p3d" - val dependency = "ktx/assets/async/texture.png" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) - // Particle dependencies: - assertTrue(storage.isLoaded(dependency)) - assertNotNull(storage.get(dependency)) - assertEquals(1, storage.getReferenceCount(dependency)) - - storage.dispose() - } - - @Test - fun `should load ParticleEffect3D assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/particle.p3d" - val descriptor = AssetDescriptor(path, ParticleEffect3D::class.java) - val dependency = "ktx/assets/async/texture.png" - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) - // Particle dependencies: - assertTrue(storage.isLoaded(dependency)) - assertNotNull(storage.get(dependency)) - assertEquals(1, storage.getReferenceCount(dependency)) - - storage.dispose() - } - - @Test - fun `should load ParticleEffect3D assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/particle.p3d" - val dependency = "ktx/assets/async/texture.png" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) - // Particle dependencies: - assertTrue(storage.isLoaded(dependency)) - assertNotNull(storage.get(dependency)) - assertEquals(1, storage.getReferenceCount(dependency)) - - storage.dispose() - } - - @Test - fun `should unload ParticleEffect3D assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/particle.p3d" - val dependency = "ktx/assets/async/texture.png" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - assertFalse(storage.isLoaded(dependency)) - assertEquals(0, storage.getReferenceCount(dependency)) - } - - @Test - fun `should load OBJ Model assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.obj" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load OBJ Model assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.obj" - val descriptor = AssetDescriptor(path, Model::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load OBJ Model assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.obj" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload OBJ Model assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.obj" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load G3DJ Model assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.g3dj" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load G3DJ Model assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.g3dj" - val descriptor = AssetDescriptor(path, Model::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load G3DJ Model assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.g3dj" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload G3DJ Model assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.g3dj" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load G3DB Model assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.g3db" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load G3DB Model assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.g3db" - val descriptor = AssetDescriptor(path, Model::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load G3DB Model assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.g3db" - val descriptor = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload G3DB Model assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/model.g3db" - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load ShaderProgram assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/shader.frag" - // Silencing logs - shader will fail to compile, as GL is mocked: - storage.logger.level = Logger.NONE - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load ShaderProgram assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/shader.vert" - val descriptor = AssetDescriptor(path, ShaderProgram::class.java) - // Silencing logs - shader will fail to compile, as GL is mocked: - storage.logger.level = Logger.NONE - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load ShaderProgram assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/shader.vert" - val identifier = storage.getIdentifier(path) - // Silencing logs - shader will fail to compile, as GL is mocked: - storage.logger.level = Logger.NONE - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload ShaderProgram assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/shader.frag" - // Silencing logs - shader will fail to compile, as GL is mocked: - storage.logger.level = Logger.NONE - runBlocking { storage.load(path) } - - // When: - runBlocking { storage.unload(path) } - - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) - } - - @Test - fun `should load Cubemap assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/cubemap.zktx" - - // When: - val asset = runBlocking { storage.load(path) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load Cubemap assets with descriptor`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/cubemap.zktx" - val descriptor = AssetDescriptor(path, Cubemap::class.java) - - // When: - val asset = runBlocking { storage.load(descriptor) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should load Cubemap assets with identifier`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/cubemap.zktx" - val identifier = storage.getIdentifier(path) - - // When: - val asset = runBlocking { storage.load(identifier) } - - // Then: - assertTrue(storage.isLoaded(path)) - assertSame(asset, storage.get(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertEquals(emptyList(), storage.getDependencies(path)) - - storage.dispose() - } - - @Test - fun `should unload Cubemap assets`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/cubemap.zktx" - runBlocking { storage.load(path) } + * This suite tests the behavior and logic of the [AssetStorage]. [AbstractAssetStorageLoadingTest] + * and its extensions test whether [AssetStorage] can correctly load all default asset types. + * + * Implementation note: the tests use [runBlocking] to launch the coroutines for simplicity + * of asserts. Normally [KtxAsync].launch is highly encouraged to build truly asynchronous applications. + * Using [runBlocking] on the main rendering thread might lead to deadlocks, as the rendering thread + * is necessary to load some assets (e.g. textures). + * + * In a similar manner, some [AssetDescriptor] instances are created manually. In an actual application, + * using [AssetStorage.getAssetDescriptor] is a much easier way of obtaining [AssetDescriptor] instances. + */ +class AssetStorageTest : AsyncTest() { + @get:Rule + var testName = TestName() - // When: - runBlocking { storage.unload(path) } + companion object { + @JvmStatic + @BeforeClass + fun `load LibGDX statics`() { + // Necessary for LibGDX asset loaders to work. + LwjglNativesLoader.load() + Gdx.graphics = mock() + Gdx.gl20 = mock() + Gdx.gl = Gdx.gl20 + } - // Then: - assertFalse(storage.isLoaded(path)) - assertEquals(0, storage.getReferenceCount(path)) + @JvmStatic + @AfterClass + fun `dispose of LibGDX statics`() { + Gdx.graphics = null + Gdx.audio = null + Gdx.gl20 = null + Gdx.gl = null + } } /** @@ -1543,26 +254,6 @@ class AssetStorageTest : AsyncTest() { checkProgress(storage, total = 0) } - @Test - fun `should return same asset instance with subsequent load calls on loaded asset`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - val loaded = runBlocking { storage.load(path) } - - // When: - val assets = (1..10).map { runBlocking { storage.load(path) } } - - // Then: - assertEquals(11, storage.getReferenceCount(path)) - assets.forEach { asset -> - assertSame(loaded, asset) - } - checkProgress(storage, loaded = 1) - - storage.dispose() - } - @Test fun `should obtain loaded asset with path`() { // Given: @@ -1589,7 +280,7 @@ class AssetStorageTest : AsyncTest() { val identifier = storage.getIdentifier("ktx/assets/async/string.txt") // When: - runBlocking { storage.load(identifier) } + runBlocking { storage.loadAsync(identifier.path).await() } // Then: assertTrue(identifier in storage) @@ -1608,7 +299,7 @@ class AssetStorageTest : AsyncTest() { val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") // When: - runBlocking { storage.load(descriptor) } + storage.loadSync(descriptor.fileName) // Then: assertTrue(descriptor in storage) @@ -1642,7 +333,7 @@ class AssetStorageTest : AsyncTest() { val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) val path = "ktx/assets/async/string.txt" val descriptor = storage.getAssetDescriptor(path) - runBlocking { storage.load(descriptor) } + runBlocking { storage.loadAsync(path).await() } // When: runBlocking { storage.unload(descriptor) } @@ -1659,7 +350,7 @@ class AssetStorageTest : AsyncTest() { val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) val path = "ktx/assets/async/string.txt" val identifier = storage.getIdentifier(path) - runBlocking { storage.load(identifier) } + storage.loadSync(path) // When: runBlocking { storage.unload(identifier) } @@ -1671,74 +362,66 @@ class AssetStorageTest : AsyncTest() { } @Test - fun `should load assets asynchronously with path`() { + fun `should differentiate assets by path and type`() { // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" + val storage = AssetStorage(useDefaultLoaders = false) // When: - val asset = runBlocking { storage.loadAsync(path).await() } + runBlocking { storage.add("path", "ASSET") } // Then: - assertTrue(storage.contains(path)) - assertTrue(storage.isLoaded(path)) - assertEquals(1, storage.getReferenceCount(path)) - assertSame(asset, storage.get(path)) - checkProgress(storage, loaded = 1, warn = true) + assertTrue(storage.contains("path")) + assertFalse(storage.contains("different path")) + assertFalse(storage.contains("path")) // Different type. } @Test - fun `should load assets asynchronously with identifier`() { + fun `should point to the same asset when loading with path, descriptor and identifier`() { // Given: val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - val identifier = storage.getIdentifier(path) + val path = "ktx/assets/async/string.txt" + val descriptor = storage.getAssetDescriptor(path) + val identifier = storage.getIdentifier(path) // When: - val asset = runBlocking { storage.loadAsync(identifier).await() } + val viaPath = runBlocking { storage.load(path) } + val viaDescriptor = runBlocking { storage.load(descriptor) } + val viaIdentifier = runBlocking { storage.load(identifier) } // Then: - assertTrue(identifier in storage) - assertTrue(storage.isLoaded(identifier)) - assertEquals(1, storage.getReferenceCount(identifier)) - assertSame(asset, storage[identifier]) + assertTrue(storage.isLoaded(path)) + assertSame(viaPath, viaDescriptor) + assertSame(viaDescriptor, viaIdentifier) + assertEquals(3, storage.getReferenceCount(path)) checkProgress(storage, loaded = 1, warn = true) } @Test - fun `should load assets asynchronously with descriptor`() { + fun `should point to the same asset when loading with path, descriptor and identifier asynchronously`() { // Given: val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - val path = "ktx/assets/async/texture.png" - val descriptor = storage.getAssetDescriptor(path) + val path = "ktx/assets/async/string.txt" + val descriptor = storage.getAssetDescriptor(path) + val identifier = storage.getIdentifier(path) + val pathReference = storage.loadAsync(path) + val descriptorReference = storage.loadAsync(descriptor) + val identifierReference = storage.loadAsync(identifier) // When: - val asset = runBlocking { storage.loadAsync(descriptor).await() } + val viaPath = runBlocking { pathReference.await() } + val viaDescriptor = runBlocking { descriptorReference.await() } + val viaIdentifier = runBlocking { identifierReference.await() } // Then: - assertTrue(descriptor in storage) - assertTrue(storage.isLoaded(descriptor)) - assertEquals(1, storage.getReferenceCount(descriptor)) - assertSame(asset, storage[descriptor]) + assertTrue(storage.isLoaded(path)) + assertSame(viaPath, viaDescriptor) + assertSame(viaDescriptor, viaIdentifier) + assertEquals(3, storage.getReferenceCount(path)) checkProgress(storage, loaded = 1, warn = true) } @Test - fun `should differentiate assets by path and type`() { - // Given: - val storage = AssetStorage(useDefaultLoaders = false) - - // When: - runBlocking { storage.add("path", "ASSET") } - - // Then: - assertTrue(storage.contains("path")) - assertFalse(storage.contains("different path")) - assertFalse(storage.contains("path")) // Different type. - } - - @Test - fun `should point to the same asset when loading with path, descriptor and identifier`() { + fun `should point to the same asset when loading with path, descriptor and identifier synchronously`() { // Given: val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) val path = "ktx/assets/async/string.txt" @@ -1746,9 +429,9 @@ class AssetStorageTest : AsyncTest() { val identifier = storage.getIdentifier(path) // When: - val viaPath = runBlocking { storage.load(path) } - val viaDescriptor = runBlocking { storage.load(descriptor) } - val viaIdentifier = runBlocking { storage.load(identifier) } + val viaPath = storage.loadSync(path) + val viaDescriptor = storage.loadSync(descriptor) + val viaIdentifier = storage.loadSync(identifier) // Then: assertTrue(storage.isLoaded(path)) @@ -2086,7 +769,7 @@ class AssetStorageTest : AsyncTest() { // When: runBlocking { repeat(3) { - val asset = storage.load(descriptor) + val asset = storage.loadAsync(descriptor).await() loadedAssets[asset] = true } } @@ -2114,11 +797,9 @@ class AssetStorageTest : AsyncTest() { val loadedAssets = IdentityHashMap() // When: - runBlocking { - repeat(3) { - val asset = storage.load(identifier) - loadedAssets[asset] = true - } + repeat(3) { + val asset = storage.loadSync(identifier) + loadedAssets[asset] = true } // Then: @@ -2455,49 +1136,6 @@ class AssetStorageTest : AsyncTest() { checkProgress(storage, total = 0) } - @Test - fun `should dispose of multiple assets of different types without errors`() { - // Given: - val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) - storage.logger.level = Logger.NONE - val assets = listOf( - storage.getIdentifier("ktx/assets/async/string.txt"), - storage.getIdentifier("com/badlogic/gdx/utils/arial-15.fnt"), - storage.getIdentifier("ktx/assets/async/sound.ogg"), - storage.getIdentifier("ktx/assets/async/skin.atlas"), - storage.getIdentifier("ktx/assets/async/texture.png"), - storage.getIdentifier("ktx/assets/async/skin.json"), - storage.getIdentifier("ktx/assets/async/i18n"), - storage.getIdentifier("ktx/assets/async/particle.p2d"), - storage.getIdentifier("ktx/assets/async/particle.p3d"), - storage.getIdentifier("ktx/assets/async/model.obj"), - storage.getIdentifier("ktx/assets/async/model.g3dj"), - storage.getIdentifier("ktx/assets/async/model.g3db"), - storage.getIdentifier("ktx/assets/async/shader.frag"), - storage.getIdentifier("ktx/assets/async/cubemap.zktx") - ) - runBlocking { - assets.forEach { - storage.load(it) - assertTrue(storage.isLoaded(it)) - } - } - - // When: - storage.dispose() - - // Then: - assets.forEach { - assertFalse(it in storage) - assertFalse(storage.isLoaded(it)) - assertEquals(0, storage.getReferenceCount(it)) - assertEquals(emptyList(), storage.getDependencies(it)) - shouldThrow { - storage[it] - } - } - } - @Test fun `should dispose of all assets with optional error handling`() { // Given: @@ -2669,7 +1307,7 @@ class AssetStorageTest : AsyncTest() { @Test fun `should convert Identifier to AssetDescriptor`() { // Given: - val identifier = Identifier(String::class.java, "file.path") + val identifier = Identifier("file.path", String::class.java) // When: val assetDescriptor = identifier.toAssetDescriptor() @@ -2682,7 +1320,7 @@ class AssetStorageTest : AsyncTest() { @Test fun `should convert Identifier to AssetDescriptor with loading parameters`() { // Given: - val identifier = Identifier(String::class.java, "file.path") + val identifier = Identifier("file.path", String::class.java) val parameters = mock>() // When: @@ -2697,7 +1335,7 @@ class AssetStorageTest : AsyncTest() { @Test fun `should convert Identifier to AssetDescriptor with a custom file`() { // Given: - val identifier = Identifier(String::class.java, "file.path") + val identifier = Identifier("file.path", String::class.java) val file = mock() // When: @@ -2771,6 +1409,45 @@ class AssetStorageTest : AsyncTest() { verify(logger).error(any(), eq(exception)) } + @Test + fun `should not block the main rendering thread when loading assets synchronously`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + val asset = runBlocking(KtxAsync.coroutineContext) { + storage.loadSync(path) + } + + // Then: + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + } + + @Test + fun `should not block the main rendering thread when loading assets with dependencies synchronously`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val dependencies = arrayOf( + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png") + ) + + // When: + val asset = runBlocking(KtxAsync.coroutineContext) { + storage.loadSync(path) + } + + // Then: + assertTrue(storage.isLoaded(path)) + dependencies.forEach { + assertTrue(storage.isLoaded(it)) + } + assertSame(asset.atlas, storage[dependencies[0]]) + } + /** For [Disposable.dispose] interface testing and loaders testing. */ class FakeAsset : Disposable { val disposingFinished = CompletableFuture() @@ -2804,7 +1481,7 @@ class AssetStorageTest : AsyncTest() { } /** For loaders testing. */ - class FakeSyncLoader( + open class FakeSyncLoader( private val onLoad: (asset: FakeAsset) -> Unit, private val dependencies: GdxArray> = GdxArray.with() ) : SynchronousAssetLoader(ClasspathFileHandleResolver()) { @@ -2849,7 +1526,7 @@ class AssetStorageTest : AsyncTest() { } @Test - fun `should execute synchronous loading on rendering thread`() { + fun `should load assets on rendering thread with synchronous loading `() { // Given: val isRenderingThread = CompletableFuture() val loader = FakeSyncLoader( @@ -2859,12 +1536,49 @@ class AssetStorageTest : AsyncTest() { storage.setLoader { loader } // When: - runBlocking { storage.load("fake.path") } + storage.loadSync("fake.path") + + // Then: + assertTrue(isRenderingThread.getNow(false)) + } + + @Test + fun `should load assets synchronously on rendering thread with synchronous loader`() { + // Given: + val isRenderingThread = CompletableFuture() + val loader = FakeSyncLoader( + onLoad = { isRenderingThread.complete(KtxAsync.isOnRenderingThread()) } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + + // When: + storage.loadSync("fake.path") // Then: assertTrue(isRenderingThread.getNow(false)) } + @Test + fun `should load assets synchronously on rendering thread with asynchronous loader`() { + // Given: + val isRenderingThreadDuringAsync = CompletableFuture() + val isRenderingThreadDuringSync = CompletableFuture() + val loader = FakeAsyncLoader( + onAsync = { isRenderingThreadDuringAsync.complete(KtxAsync.isOnRenderingThread()) }, + onSync = { isRenderingThreadDuringSync.complete(KtxAsync.isOnRenderingThread()) } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + + // When: + storage.loadSync("fake.path") + + // Then: entire synchronous loading should be executed on the rendering thread: + assertTrue(isRenderingThreadDuringAsync.getNow(false)) + assertTrue(isRenderingThreadDuringSync.getNow(false)) + } + @Test fun `should handle loading exceptions`() { // Given: @@ -3332,4 +2046,87 @@ class AssetStorageTest : AsyncTest() { assertTrue(exception is AssetLoadingException) assertEquals(storage.getIdentifier(path), identifier) } + + @Test + fun `should immediately throw exception when attempting to synchronously load already scheduled asset`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + val loadingStarted = CompletableFuture() + val loadingFinished = CompletableFuture() + storage.setLoader { + FakeSyncLoader( + onLoad = { + loadingStarted.complete(true) + loadingFinished.join() + } + ) + } + val reference = storage.loadAsync(path) + loadingStarted.join() + + // When: asset is not loaded yet: + shouldThrow { + storage.loadSync(path) + } + + // Then: + assertFalse(storage.isLoaded(path)) + loadingFinished.complete(true) + runBlocking { reference.await() } + assertTrue(storage.isLoaded(path)) + // Should still count the reference, since load was called: + assertEquals(2, storage.getReferenceCount(path)) + } + + @Test + fun `should throw exception when a dependency of a synchronously loaded asset is being loaded asynchronously`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake.path" + val dependency = "path.dep" + val loadingStarted = CompletableFuture() + val loadingFinished = CompletableFuture() + storage.setLoader { + object: FakeSyncLoader( + dependencies = GdxArray.with(storage.getAssetDescriptor(dependency)), + onLoad = {} + ) { + override fun load( + assetManager: AssetManager, + fileName: String?, + file: FileHandle?, + parameter: FakeParameters? + ): FakeAsset { + assetManager.get(dependency, FakeAsset::class.java) + return super.load(assetManager, fileName, file, parameter) + } + } + } + storage.setLoader(suffix = ".dep") { + FakeSyncLoader( + onLoad = { + loadingStarted.complete(true) + loadingFinished.join() + } + ) + } + val reference = storage.loadAsync(dependency) + loadingStarted.join() + + // When: dependency is not loaded yet: + shouldThrow { + storage.loadSync(path) + } + + // Then: + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + loadingFinished.complete(true) + runBlocking { reference.await() } + assertTrue(storage.isLoaded(dependency)) + // Should still count the reference, since load was called: + assertEquals(2, storage.getReferenceCount(dependency)) + } } diff --git a/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt b/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt index f89d8da9..2a537c41 100644 --- a/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt +++ b/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt @@ -45,6 +45,9 @@ fun AssetStorage.registerFreeTypeFontLoaders( * It will be inlined and invoked on a [FreeTypeFontParameter]. * * Returns the result of font loading. See [AssetStorage.load] for lists of possible outcomes. + * + * Note that you can also call [AssetStorage.loadSync] or [AssetStorage.loadAsync] directly if needed, + * but you must pass [FreeTypeFontParameter]. See [freeTypeFontParameters] utility. */ suspend inline fun AssetStorage.loadFreeTypeFont( path: String, From 4e78cfc17c873728b5b0308b2a62349551cf0f14 Mon Sep 17 00:00:00 2001 From: MJ Date: Fri, 10 Apr 2020 20:02:58 +0200 Subject: [PATCH 15/17] Updated AssetStorage documentation. #182 --- CHANGELOG.md | 2 + assets-async/README.md | 135 +++++++++++------- .../main/kotlin/ktx/assets/async/storage.kt | 24 ++-- 3 files changed, 98 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cbfad6..6435f050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - `getLoader` and `setLoader` manage `AssetLoader` instances used to load assets. - `isLoaded` checks if loading of an asset was finished. - `contains` operator checks if the asset was scheduled for loading or added to the storage. + - `progress` allows to check asset loading progress. - `getReferenceCount` returns how many times the asset was loaded or referenced by other assets as a dependency. - `getDependencies` returns a list of dependencies of the selected asset. - `getAssetDescriptor` creates an `AssetDescriptor` with loading data for the selected asset. @@ -30,6 +31,7 @@ - `Identifier` data class added as an utility to uniquely identify assets by their type and path. - `Identifier.toAssetDescriptor` allows to convert an `Identifier` to an `AssetDescriptor`. - `AssetDescriptor.toIdentifier` allows to convert an `AssetDescriptor` to `Identifier` used to uniquely identify `AssetStorage` assets. + - `LoadingProgress` is an internal class used by the `AssetStorage` to track loading progress. - **[FEATURE]** (`ktx-async`) Added `RenderingScope` factory function for custom scopes using rendering thread dispatcher. - **[FEATURE]** (`ktx-async`) `newAsyncContext` and `newSingleThreadAsyncContext` now support `threadName` parameter that allows to set thread name pattern of `AsyncExecutor` threads. diff --git a/assets-async/README.md b/assets-async/README.md index ed6c3576..004ca81c 100644 --- a/assets-async/README.md +++ b/assets-async/README.md @@ -7,12 +7,11 @@ Asset manager using coroutines to load assets asynchronously. ### Why? LibGDX provides an `AssetManager` class for loading and managing assets. Even with [KTX extensions](../assets), -`AssetManager` is not compatible with Kotlin concurrency model based on coroutines. While it does support -asynchronous asset loading, it uses only a single thread for asynchronous operations and achieves its thread -safety by synchronizing all of the methods. To achieve truly multi-threaded loading with multiple threads -for asynchronous loading, one must maintain multiple manager instances. Besides, it does not support -event listeners and its API relies on polling instead - one must repeatedly update its state until -the assets are loaded. +`AssetManager` is not fully compatible with Kotlin concurrency model based on coroutines due to thread blocking. +While it does support asynchronous asset loading, it uses only a single thread for asynchronous operations and +achieves its thread safety by synchronizing all of its methods. To achieve truly multi-threaded loading with +multiple threads for asynchronous loading, one must maintain multiple manager instances. Besides, its API relies +on polling - one must repeatedly update its state until the assets are loaded. This **KTX** module brings an `AssetManager` alternative - `AssetStorage`. It leverages Kotlin coroutines for asynchronous operations. It ensures thread safety by using a single non-blocking `Mutex` for @@ -25,7 +24,7 @@ Feature | **KTX** `AssetStorage` | LibGDX `AssetManager` *Synchronous loading* | **Supported.** `loadSync` blocks the current thread until a selected asset is loaded. A blocking coroutine can also be launched to load selected assets eagerly, but it cannot block the rendering thread or loader threads to work correctly. | **Limited.** `finishLoading` method can be used to block the thread until the asset is loaded, but since it has no effect on loading order, it requires precise scheduling or it will block the thread until some or all unselected assets are loaded. *Thread safety* | **Excellent.** Uses [`ktx-async`](../async) threading model based on coroutines. Executes blocking IO operations in a separate coroutine context and - when necessary - finishes loading on the main rendering thread. Same asset - or assets with same dependencies - can be safely scheduled for loading by multiple coroutines concurrently. Multi-threaded coroutine context can be used for asynchronous loading, possibly achieving loading performance boost. Concurrent `AssetStorage` usage is tested extensively by unit tests. | **Good.** Achieved through synchronizing most methods, which unfortunately blocks the threads that use them. Thread blocking might affect application performance, especially since even the basic `get` method is synchronized. Some operations, such as `update` or `finishLoading`, must be called from specific threads (i.e. rendering thread). *Concurrency* | **Supported.** Multiple asset loading coroutines can be launched in parallel. Coroutine context used for asynchronous loading can have multiple threads that will be used concurrently. | **Limited.** `update()` loads assets one by one. `AsyncExecutor` with only a single thread is used internally by the `AssetManager`. To utilize multiple threads for loading, one must use multiple manager instances. -*Loading order* | **Controlled by the user.** With suspending `load`, synchronous `loadSync` and `Deferred`-returning `loadAsync`, the user can have full control over asset loading order. Selected assets can be loaded one after another within a single coroutine or in parallel with multiple coroutines, depending on the need. | **Unpredictable.** If multiple assets are scheduled at once, it is difficult to reason about their loading order. `finishLoading` has no effect on loading order and instead blocks the thread until the selected asset is loaded. +*Loading order* | **Controlled by the user.** With suspending `load`, synchronous `loadSync` and `Deferred`-returning `loadAsync`, the user can have full control over asset loading order and parallelization. Selected assets can be loaded one after another within a single coroutine or in parallel with multiple coroutines, depending on the need. | **Unpredictable.** If multiple assets are scheduled at once, it is difficult to reason about their loading order. `finishLoading` has no effect on loading order and instead blocks the thread until the selected asset is loaded. *Exceptions* | **Customized.** All expected issues are given separate exception classes with common root type for easier handling. Each loading issue can be handled differently. | **Generic.** Throws either `GdxRuntimeException` or a built-in Java runtime exception. Specific issues are difficult to handle separately. *Error handling* | **Build-in language syntax.** A regular try-catch block within coroutine body can be used to handle asynchronous loading errors. Provides a clean way to handle exceptions thrown by each asset separately. | **Via listener.** One can register a global error handling listener that will be notified if a loading exception is thrown. Flow of the application is undisturbed, which makes it difficult to handle exceptions of specific assets. *File name collisions* | **Multiple assets of different types can be loaded from same path.** For example, you can load both a `Texture` and a `Pixmap` from the same PNG file. | **File paths act as unique identifiers.** `AssetManager` cannot store multiple assets with the same path, even if they have different types. @@ -143,8 +142,8 @@ called to obtain the asset instance. `isCompleted` can be used to check if the a the asset is fully loaded. Resumes the coroutine and returns the asset once it is loaded. - `loadAsync: Deferred` - schedules asset for asynchronous loading. Returns a `Deferred` reference to the asset which will be completed after the loading is finished. -- `loadSync: T` - blocks the current thread until the selected asset is loaded. Use only for crucial -assets that need to be loaded synchronously (e.g. loading screen assets). +- `loadSync: T` - blocks the current thread until the selected asset is loaded. Use _only outside of coroutines_ +for crucial assets that need to be loaded synchronously (e.g. loading screen assets). - `unload: Boolean` _(suspending)_ - unloads the selected asset. If the asset is no longer referenced, it will be removed from the storage and disposed of. Suspends the coroutine until the asset is unloaded. Returns `true` is the selected asset was present in storage or `false` if the asset was absent. @@ -158,6 +157,7 @@ Additional asset management methods include: - `isLoaded: Boolean` - checks if the selected asset is fully loaded. - `contains: Boolean` - checks if the selected asset is present in storage, loaded or not. +- `progress` - allows to access loading progress data. - `getReferenceCount: Int` - allows to check how many times the asset was loaded, added or required as dependency by other assets. Returns 0 if the asset is not present in the storage. - `getDependencies: List` - returns list of dependencies of the selected asset. @@ -191,6 +191,9 @@ Please refer to the [sources documentation](src/main/kotlin/ktx/assets/async/err ### Usage examples +Note that usage example require basic understanding of the [`ktx-async`](../async) module and Kotlin +[coroutines](https://kotlinlang.org/docs/reference/coroutines.html). + Creating an `AssetStorage` with default settings: ```kotlin @@ -360,6 +363,7 @@ fun unloadAsset(assetStorage: AssetStorage) { KtxAsync.launch { // Suspends the coroutine until the asset is unloaded: assetStorage.unload("images/logo.png") + // When the coroutine resumes here, the asset is unloaded. // If no other assets use it as dependency, it will // be removed from the asset storage and disposed of. @@ -387,17 +391,11 @@ fun accessAsset(assetStorage: AssetStorage) { // Immediately returns loaded asset or throws an exception if missing: var texture = assetStorage.get("images/logo.png") + // If you specify the variable type, you can use the braces operator as well: + val thisIsA: Texture = assetStorage["images/logo.png"] + // Immediately returns loaded asset or returns null if missing: val textureOrNull = assetStorage.getOrNull("images/logo.png") - - // Returns true is asset is in the storage, loaded or not: - assetStorage.contains("images/logo.png") - // Returns true if the asset loading has finished: - assetStorage.isLoaded("images/logo.png") - // Checks how many times the asset was loaded or used as a dependency: - assetStorage.getReferenceCount("images/logo.png") - // Returns a list of dependencies loaded along with the asset: - assetStorage.getDependencies("images/logo.png") KtxAsync.launch { // There is also a special way to access your assets within coroutines @@ -412,16 +410,34 @@ fun accessAsset(assetStorage: AssetStorage) { asset.isCompleted // Suspending the coroutine to obtain asset instance: texture = asset.await() - + // If you want to suspend the coroutine to wait for the asset, // you can do this in a single line: texture = assetStorage.getAsync("images/logo.png").await() - + // Now the coroutine is resumed and `texture` can be used. } } ``` +Accessing additional data about an asset: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import ktx.assets.async.AssetStorage + +fun inspectAsset(assetStorage: AssetStorage) { + // Returns true if asset is in the storage, loaded or not: + assetStorage.contains("images/logo.png") + // Returns true if the asset loading has finished: + assetStorage.isLoaded("images/logo.png") + // Checks how many times the asset was loaded or used as a dependency: + assetStorage.getReferenceCount("images/logo.png") + // Returns a list of dependencies loaded along with the asset: + assetStorage.getDependencies("images/logo.png") +} +``` + Adding a fully loaded asset manually to the `AssetStorage`: ```kotlin @@ -441,7 +457,8 @@ fun addAsset(assetStorage: AssetStorage) { val batch: Batch = SpriteBatch() // Suspending the coroutine until the `batch` is added: assetStorage.add("batch", batch) - // Now our `batch` will be available under "batch" path. + // Now our `batch` will be available under "batch" path + // and `Batch` class. } } ``` @@ -457,6 +474,8 @@ import com.badlogic.gdx.graphics.g2d.BitmapFont // the asset is loaded: val font = assetStorage.loadSync("com/badlogic/gdx/utils/arial-15.fnt") +// Note that you should not use `loadSync` from within coroutines. + // Whenever possible, prefer `load` or `loadAsync`. Try not to mix synchronous and // asynchronous loading, especially on the same assets or assets with same dependencies. ``` @@ -603,14 +622,14 @@ Loading method | Return type | Suspending | Loading type | When to use _`Asset` is the generic type of the loaded asset._ -* Avoid mixing synchronous (`loadSync`) and asynchronous (`load`, `loadAsync`) loading of the same asset or assets with -the same dependencies concurrently. +* Avoid mixing concurrent synchronous (`loadSync`) and asynchronous (`load`, `loadAsync`) loading of the same asset, +or assets with the same dependencies. * Prefer asynchronous (`load`, `loadAsync`) asset loading methods whenever possible due to better performance and compatibility with coroutines. -* Note that `loadSync` is not _necessary_ to load initial assets. You launch a coroutine with `KtxAsync.launch`, -load the assets sequentially with `load` or in parallel with `loadAsync`/`await`, and switch to the first view -that uses the assets once they are loaded. Loading assets asynchronously might be faster - especially when done -in parallel, but ultimately it comes down to personal preference and code maintainability. +* Note that `loadSync` is _not necessary_ to load initial assets. You launch a coroutine with `KtxAsync.launch`, +load the assets sequentially with `load` or in parallel with `loadAsync`/`await`, and then switch to the first view +that uses the assets once they are loaded. Loading assets asynchronously might be faster, especially when done +in parallel - but ultimately it comes down to personal preference and code maintainability. ##### Avoid `runBlocking` @@ -688,9 +707,9 @@ It does not mean that `runBlocking` will always cause a deadlock, however. You c - For `dispose`, both suspending and non-suspending variants. - For all non-suspending methods such as `get`, `getOrNull`, `contains`, `isLoaded`, `setLoader`, `getLoader`. - For `add`. While `add` does suspend the coroutine, it requires neither the rendering thread nor the loading threads. -- For `load` and `get.await` calls requesting already loaded assets. **Use with caution.** +- For `load` and `getAsync.await` calls requesting already loaded assets. **Use with caution.** - From within other threads than the main rendering thread and the `AssetStorage` loading threads. These threads -will be blocked until the operation is finished, which isn't ideal, but at least the loading will remain possible. +will be blocked until the operation is finished, which is not ideal, but at least the loading will remain possible. ##### `AssetStorage` as a drop-in replacement for `AssetManager` @@ -729,29 +748,30 @@ class WithAssetManager: ApplicationAdapter() { } ``` -Since usually applications have more assets than just 2, many developers choose to treat `AssetManager` as a map -of loaded assets with file paths as keys and loaded assets are values. You typically call load all or most assets -on the loading screen and then just use `AssetManager.get(path)` to obtain the assets after they are loaded. +Since usually applications have more than just 2 assets, many developers choose to treat `AssetManager` as +a container of assets: a map with file paths as keys and loaded assets are values. You typically load most +or all assets at the very beginning while showing the loading screen, and then just use `AssetManager.get(path)` +to obtain the assets after they are loaded. However, this approach has some inconveniences and problems: -- The API is not very idiomatic to Kotlin, but in this particular case [ktx-assets](../assets) can help with that. -- `update` has to be called on render during loading. +- The API is not very idiomatic to Kotlin, but in this particular case [ktx-assets](../assets) can help. +- `update` has to be called on rendering thread during loading. - If you forget to stop updating the manager after the assets are loaded, the initiation code (such as `changeView` -in our example) can be ran multiple times. If you replace `TODO` with `println` in the example, you will notice that -`changeView` is invoked on every render after the loading is finished. +in our example) can be ran multiple times. If you replace `TODO` with `println` in the example above, you will notice +that `changeView` is invoked on every `render` after the loading is finished. - The majority of `AssetManager` methods are `synchronized`, which means they block the thread that they are executed in and are usually more expensive to call than regular methods. This includes the `get` method, which does not change the internal state of the manager at all. Even if the assets are fully loaded and you no longer modify the `AssetManager` state, you still pay the cost of synchronization. This is especially relevant if you use multiple -threads, as they can block each other waiting for the assets. +threads, as they can slow each other down, both waiting for the lock. - `AssetManager` stores assets mapped only by their paths. `manager.get(path)` and `manager.get(path)` -are both valid calls that will throw a runtime class cast exception. +are both valid method calls that will throw a _runtime_ class cast exception. `AssetStorage` avoids most of these problems. -Similarly to `AssetManager`, `AssetStorage` offers API to `get` your loaded assets, so if you want to migrate -from `AssetManager` to `AssetStorage`, all you have to change initially is the loading code: +Similarly to `AssetManager`, `AssetStorage` stores and allows you to `get` your loaded assets, so if you want to +migrate from `AssetManager` to `AssetStorage`, all you have to change initially is the loading code: ```kotlin import com.badlogic.gdx.ApplicationAdapter @@ -782,23 +802,24 @@ class WithAssetStorageBasic: ApplicationAdapter() { private fun changeView() { val texture: Texture = assetStorage["images/logo.png"] - println(texture) - TODO("Now the assets are loaded and you can get them from $assetStorage.get!") + TODO("Now the assets are loaded and can be accessed with $assetStorage.get!") } } ``` As you can see, after the assets are loaded, the API of both `AssetManager` and `AssetStorage` is very similar. -Now, this example looks almost identically to the `AssetManager` code and it might just work in some cases, -but there are two things we should address: +While this example might seem almost identical to the `AssetManager` code, you already get the benefit of parallel +loading and using coroutines under the hood. + +Now, this approach might just work in most cases, but there are still two things we should address: - You will notice that your IDE warns you about not using the results of `loadAsync` which return `Deferred` instances. This means we're launching asynchronous coroutines and ignore their results. - `AssetStorage.progress` should be used only for display and debugging. You generally should not base your application logic on `progress`, as it is only _eventually consistent_ with the `AssetStorage` state. -Let's rewrite it again - this time with coroutines: +Let's rewrite it again - this time with proper coroutines: ```kotlin import com.badlogic.gdx.ApplicationAdapter @@ -840,27 +861,33 @@ class WithAssetStorage: ApplicationAdapter() { } ``` +The IDE no longer warns us about ignoring `Deferred` results and we no longer have to check the progress all the time. +Instead, we suspend a coroutine to "wait" for the assets and run our initiation code once the assets are loaded. + The code using `AssetStorage` is not necessarily shorter in this case, but: - You get the performance improvements of loading assets in parallel. - `AssetStorage` does not have to be updated on render. -- Your code is reactive and `changeView` is called only once as soon as the assets are loaded. -- You can easily integrate more coroutines into your application later for other asynchronous operations. +- Your code is reactive and `changeView` is guaranteed to be called only once as soon as the assets are loaded. - `AssetStorage.get` is non-blocking and faster than `AssetManager.get`. `AssetStorage` does a better job of storing -your assets efficiently after loading. +your assets efficiently after the loading is finished. - `AssetStorage` stores assets mapped by their path _and_ type. You will not have to deal with class cast exceptions. +- You can easily expand the launched coroutine or integrate more coroutines into your application later for other +asynchronous operations. Besides, you get the additional benefits of other `AssetStorage` features and methods described in this file. -Closest method equivalents in `AssetManager` and `AssetStorage` APIs: +##### API equivalents + +Closest equivalents in `AssetManager` and `AssetStorage` APIs: `AssetManager` | `AssetStorage` | Note :---: | :---: | --- `get(String)` | `get(String)` | -`get(String, Class)` | `get(Identifier)` | -`get(AssetDescriptor)` | `get(AssetDescriptor)` | -`load(String, Class)` | `loadAsync(String)` | `load(String)` can also be used as an alternative within coroutines. -`load(String, Class, AssetLoaderParameters)` | `loadAsync(String, AssetLoaderParameters)` | `load(String, AssetLoaderParameters)` can also be used as an alternative within coroutines. +`get(String, Class)` | `get(Identifier)` | +`get(AssetDescriptor)` | `get(AssetDescriptor)` | +`load(String, Class)` | `loadAsync(String)` | `load(String)` can also be used as an alternative within coroutines. +`load(String, Class, AssetLoaderParameters)` | `loadAsync(String, AssetLoaderParameters)` | `load(String, AssetLoaderParameters)` can also be used as an alternative within coroutines. `load(AssetDescriptor)` | `loadAsync(AssetDescriptor)` | `load(AssetDescriptor)` can also be used as an alternative within coroutines. `isLoaded(String)` | `isLoaded(String)` | `AssetStorage` requires asset type, so the method is generic. `isLoaded(String, Class)` | `isLoaded(Identifier)` | @@ -872,7 +899,7 @@ Closest method equivalents in `AssetManager` and `AssetStorage` APIs: `finishLoadingAsset(String)` | `loadSync(String)` | Assets that need to be loaded immediately (e.g. loading screen assets) can be loaded with `loadSync` instead of asynchronous `load` or `loadAsync` for convenience. `finishLoadingAsset(AssetDescriptor)` | `loadSync(AssetDescriptor)` | `finishLoading()` | N/A | `AssetStorage` does not provide methods that block the thread until all assets are loaded. Rely on `progress.isFinished` instead. -`addAsset(String, Class, T)` | `add(String, T)` | +`addAsset(String, Class, T)` | `add(String, T)` | `contains(String)` | `contains(String)`, `contains(Identifier)` | `AssetStorage` requires asset type, so the methods are generic. `setErrorHandler` | N/A, `try-catch` | With `AssetStorage` you can handle loading errors immediately with regular built-in `try-catch` syntax. Error listener is not required. `clear()` | `dispose()` | `AssetStorage.dispose` will not kill `AssetStorage` threads and can be safely used multiple times like `AssetManager.clear`. diff --git a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt index f8d8efc5..f17e2c85 100644 --- a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt +++ b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt @@ -23,12 +23,12 @@ import com.badlogic.gdx.graphics.g3d.particles.ParticleEffectLoader as ParticleE /** * Asynchronous asset loader based on coroutines API. An [AssetManager] alternative. * - * Note that [KtxAsync.initiate] must be called before creating an [AssetStorage]. + * Note that [KtxAsync.initiate] must be called on the rendering thread before creating an [AssetStorage]. * * [asyncContext] is used to perform asynchronous file loading. Defaults to a single-threaded context using an * [AsyncExecutor]. See [newSingleThreadAsyncContext] or [ktx.async.newAsyncContext] functions to create a custom - * loading context. Multi-threaded contexts are supported and might boost loading performance if the assets - * are loaded asynchronously. + * loading context. Multi-threaded contexts are fully supported and might boost loading performance if the assets + * are loaded asynchronously in parallel. * * [fileResolver] determines how file paths are interpreted. Defaults to [InternalFileHandleResolver], which loads * internal files. @@ -64,7 +64,7 @@ class AssetStorage( */ val progress = LoadingProgress() - /** LibGDX Logger used internally by the asset loaders, usually to report issues. */ + /** LibGDX [Logger] used internally, usually to report issues. */ var logger: Logger get() = asAssetManager.logger set(value) { @@ -741,6 +741,7 @@ class AssetStorage( private fun setLoadedExceptionally(asset: Asset<*>, exception: AssetStorageException) { if (asset.reference.completeExceptionally(exception)) { + // This the passed exception managed to complete the loading, we record a failed asset loading: progress.registerFailedAsset() } } @@ -749,6 +750,7 @@ class AssetStorage( synchronousLoader: SynchronousLoader, asset: Asset ) { + // If any of the isCompleted checks returns true, asset is likely to be unloaded asynchronously. if (asset.reference.isCompleted) { return } @@ -764,6 +766,7 @@ class AssetStorage( asynchronousLoader: AsynchronousLoader, asset: Asset ) { + // If any of the isCompleted checks returns true, asset is likely to be unloaded asynchronously. withContext(asyncContext) { if (!asset.reference.isCompleted) { asynchronousLoader.loadAsync(asAssetManager, asset.descriptor) @@ -783,8 +786,8 @@ class AssetStorage( private fun setLoaded(asset: Asset, value: T) { if (asset.reference.complete(value)) { // The asset was correctly loaded and assigned. + progress.registerLoadedAsset() try { - progress.registerLoadedAsset() // Notifying the LibGDX loading callback to support AssetManager behavior: asset.descriptor.params?.loadedCallback?.finishedLoading( asAssetManager, asset.identifier.path, asset.identifier.type @@ -808,7 +811,8 @@ class AssetStorage( * * This method is safe to call from the main rendering thread, as well as other application threads. * However, avoid loading the same asset or assets with the same dependencies with both synchronous - * [loadSync] and asynchronous [load] or [loadAsync]. + * [loadSync] and asynchronous [load] or [loadAsync], and avoid running this method from within + * coroutines. * * This method should be used only to load crucial assets that are needed to initiate the application, * e.g. assets required to display the loading screen. Whenever possible, prefer [load] and [loadAsync]. @@ -834,7 +838,8 @@ class AssetStorage( * * This method is safe to call from the main rendering thread, as well as other application threads. * However, avoid loading the same asset or assets with the same dependencies with both synchronous - * [loadSync] and asynchronous [load] or [loadAsync]. + * [loadSync] and asynchronous [load] or [loadAsync], and avoid running this method from within + * coroutines. * * This method should be used only to load crucial assets that are needed to initiate the application, * e.g. assets required to display the loading screen. Whenever possible, prefer [load] and [loadAsync]. @@ -860,7 +865,8 @@ class AssetStorage( * * This method is safe to call from the main rendering thread, as well as other application threads. * However, avoid loading the same asset or assets with the same dependencies with both synchronous - * [loadSync] and asynchronous [load] or [loadAsync]. + * [loadSync] and asynchronous [load] or [loadAsync], and avoid running this method from within + * coroutines. * * This method should be used only to load crucial assets that are needed to initiate the application, * e.g. assets required to display the loading screen. Whenever possible, prefer [load] and [loadAsync]. @@ -1286,7 +1292,7 @@ data class Identifier( * loading parameters to the [AssetStorage.load] method. * * Similarly, [AssetDescriptor.file] is not used by the [Identifier]. Instead, [AssetDescriptor.fileName] - * will be used to resolve the file using [AssetStorage.fileResolver]. If a [FileHandle] of different type + * will be used to resolve the file using [AssetStorage.fileResolver]. If a [FileHandle] of a different type * is required, use [AssetDescriptor] for loading instead. */ fun AssetDescriptor.toIdentifier(): Identifier = Identifier(fileName, type) From b18b5921b88f8871f2e8bf1bafc650e93f56eee0 Mon Sep 17 00:00:00 2001 From: MJ Date: Fri, 10 Apr 2020 20:05:23 +0200 Subject: [PATCH 16/17] Updated contributors. #182 #258 --- .github/CONTRIBUTORS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/CONTRIBUTORS.md b/.github/CONTRIBUTORS.md index 5c5170c8..7cd4b420 100644 --- a/.github/CONTRIBUTORS.md +++ b/.github/CONTRIBUTORS.md @@ -10,7 +10,7 @@ Project contributors listed chronologically. * Contributed LibGDX [collections](../collections) utilities. * [@sreich](https://github.com/sreich) * Contributed various utilities from the [Ore Infinium](https://github.com/sreich/ore-infinium) project. -* [@raincole](https://github.com/raincole) +* [@yhlai-code](https://github.com/yhlai-code) (_raincole_) * Contributed LibGDX [collections](../collections) utilities. * Provided insightful review of the [`Async`](../async) module. * [@Jkly](https://github.com/Jkly) @@ -46,6 +46,7 @@ Project contributors listed chronologically. * Contributed [actors](../actors) utilities. * Wrote a complete [KTX tutorial](https://github.com/Quillraven/SimpleKtxGame/wiki) based on the original LibGDX introduction. * Author of the [preferences](../preferences) module. + * Tested and reviewed the [assets async](../assets-async) module. * [@FocusPo1nt](https://github.com/FocusPo1nt) * Added utilities to [style module](../style). * [@maltaisn](https://github.com/maltaisn) From c1406fec1d5385aa6368bfca754fd55a8672a1e2 Mon Sep 17 00:00:00 2001 From: MJ Date: Fri, 10 Apr 2020 20:15:53 +0200 Subject: [PATCH 17/17] Eased off test for eventual consistency of loading progress. #182 --- .../src/test/kotlin/ktx/assets/async/storageTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt index 320a3194..4ba90546 100644 --- a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt @@ -324,7 +324,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertFalse(storage.isLoaded(path)) assertEquals(0, storage.getReferenceCount(path)) - checkProgress(storage, total = 0) + checkProgress(storage, total = 0, warn = true) } @Test @@ -341,7 +341,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertFalse(storage.isLoaded(descriptor)) assertEquals(0, storage.getReferenceCount(descriptor)) - checkProgress(storage, total = 0) + checkProgress(storage, total = 0, warn = true) } @Test @@ -358,7 +358,7 @@ class AssetStorageTest : AsyncTest() { // Then: assertFalse(storage.isLoaded(identifier)) assertEquals(0, storage.getReferenceCount(identifier)) - checkProgress(storage, total = 0) + checkProgress(storage, total = 0, warn = true) } @Test