diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index 454393f..78d090b 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -23,22 +23,15 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6.0.1 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 + - name: Setup .NET + uses: actions/setup-dotnet@v5.0.1 with: - dotnet-version: 3.1.101 + dotnet-version: '8.0.x' - name: Setup DocFX - uses: crazy-max/ghaction-chocolatey@v1 - with: - args: install docfx --ignore-checksums - - - name: Setup wkhtmltopdf - uses: crazy-max/ghaction-chocolatey@v1 - with: - args: install wkhtmltopdf --ignore-checksums + run: dotnet tool install -g docfx --version 2.61.0 - name: Copy readme run: cp .\README.md .\Tools~\DocFx\index.md @@ -50,32 +43,48 @@ jobs: continue-on-error: false - name: Upload site artifact - uses: actions/upload-artifact@v2.2.4 + uses: actions/upload-artifact@v6.0.0 with: name: _site path: Tools~/DocFx/_site - - - name: Upload PDF - uses: actions/upload-artifact@v2.2.4 - with: - name: _site_pdf - path: Tools~/DocFx/_site_pdf/DocFx.pdf + deploy_test: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6.0.1 + + - name: Download site artifact + uses: actions/download-artifact@v7.0.0 + with: + name: _site + path: _site + + - name: Publish + uses: peaceiris/actions-gh-pages@v4.0.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages-test + publish_dir: _site + force_orphan: true + deploy: if: github.event_name == 'push' needs: build runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6.0.1 - name: Download site artifact - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v7.0.0 with: name: _site + path: _site - name: Publish - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_branch: gh-pages diff --git a/CHANGELOG.md b/CHANGELOG.md index cb49f05..5df2bad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2.0.0 +- Add asset release API +- Add async loading cancellation support +- Support UniTask for async loading +- Add editor tool for setting references through editor scripts +- Update documentation + ## 1.0.0 - Improve scene reference, add auto fix button - Use version defines diff --git a/Editor/SceneReferenceEditor.cs b/Editor/SceneReferenceEditor.cs index 8cb4ae9..f4b07b6 100644 --- a/Editor/SceneReferenceEditor.cs +++ b/Editor/SceneReferenceEditor.cs @@ -2,14 +2,17 @@ using UnityEditor; using UnityEngine; -namespace SmartReference.Editor { +namespace SmartReference.Editor +{ [CustomPropertyDrawer(typeof(Runtime.SceneReference), true)] - internal class SceneReferenceEditor: PropertyDrawer { + internal class SceneReferenceEditor: PropertyDrawer + { private SerializedProperty cacheProperty; private SceneAsset currentScene; private SerializedProperty scenePathProp; - public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) + { if (!SerializedProperty.EqualContents(property, cacheProperty)) { cacheProperty = property; scenePathProp = property.FindPropertyRelative("scenePath"); @@ -64,7 +67,8 @@ public override void OnGUI(Rect position, SerializedProperty property, GUIConten } } - public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { + public override float GetPropertyHeight(SerializedProperty property, GUIContent label) + { var height = EditorGUIUtility.singleLineHeight; var scenePath = property.FindPropertyRelative("scenePath").stringValue; var buildSettingsScenes = EditorBuildSettings.scenes; diff --git a/Editor/SmartReference.Editor.asmdef b/Editor/SmartReference.Editor.asmdef index 9e2de7e..bf80c6a 100644 --- a/Editor/SmartReference.Editor.asmdef +++ b/Editor/SmartReference.Editor.asmdef @@ -1,6 +1,6 @@ { "name": "SmartReference.Editor", - "rootNamespace": "SmartReference", + "rootNamespace": "SmartReference.Editor", "includePlatforms": [ "Editor" ], diff --git a/Editor/SmartReferenceEditor.cs b/Editor/SmartReferenceEditor.cs index 5d4aa0f..f94fea0 100644 --- a/Editor/SmartReferenceEditor.cs +++ b/Editor/SmartReferenceEditor.cs @@ -3,9 +3,11 @@ using UnityEngine; using Object = UnityEngine.Object; -namespace SmartReference.Editor { +namespace SmartReference.Editor +{ [CustomPropertyDrawer(typeof(Runtime.SmartReference), true)] - internal class SmartReferenceEditor: PropertyDrawer { + internal class SmartReferenceEditor: PropertyDrawer + { private SerializedProperty cacheProperty; private Object referencedObject; private SerializedProperty guidProp; @@ -13,7 +15,8 @@ internal class SmartReferenceEditor: PropertyDrawer { private SerializedProperty pathProp; private SerializedProperty typeProp; - public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) + { if (!SerializedProperty.EqualContents(property, cacheProperty)) { cacheProperty = property; referencedObject = null; @@ -38,26 +41,16 @@ public override void OnGUI(Rect position, SerializedProperty property, GUIConten } var type = Type.GetType(typeProp.stringValue); - var referenced = EditorGUI.ObjectField(position, label, referencedObject, type, false); - if (referencedObject != referenced) { - if (referenced == null) { - guidProp.stringValue = string.Empty; - fileIDProp.longValue = 0; - pathProp.stringValue = string.Empty; - return; + var newReferenced = EditorGUI.ObjectField(position, label, referencedObject, type, false); + if (referencedObject != newReferenced) { + referencedObject = newReferenced; + if (newReferenced == null) { + SmartReferenceTool.ClearReference(property); } - - if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(referenced, out var guid, out long fileID)) { - Debug.LogError( - $"[SmartReferenceEditor] Failed to get guid and fileID, path: {AssetDatabase.GetAssetPath(referenced)}"); - return; + else + { + SmartReferenceTool.SetReference(property, newReferenced); } - - guidProp.stringValue = guid; - fileIDProp.longValue = fileID; - pathProp.stringValue = AssetDatabase.GetAssetPath(referenced); - - referencedObject = referenced; } } } diff --git a/Editor/SmartReferencePreBuildProcessor.cs b/Editor/SmartReferencePreBuildProcessor.cs index 1a0b581..28906ff 100644 --- a/Editor/SmartReferencePreBuildProcessor.cs +++ b/Editor/SmartReferencePreBuildProcessor.cs @@ -2,8 +2,10 @@ using UnityEditor.Build.Reporting; using UnityEngine; -namespace SmartReference.Editor { - public class SmartReferencePreBuildProcessor: IPreprocessBuildWithReport { +namespace SmartReference.Editor +{ + public class SmartReferencePreBuildProcessor: IPreprocessBuildWithReport + { public int callbackOrder => 0; public void OnPreprocessBuild(BuildReport report) { diff --git a/Editor/SmartReferenceTool.cs b/Editor/SmartReferenceTool.cs new file mode 100644 index 0000000..a01eda1 --- /dev/null +++ b/Editor/SmartReferenceTool.cs @@ -0,0 +1,77 @@ +using System; +using UnityEditor; +using Object = UnityEngine.Object; + +namespace SmartReference.Editor +{ + public static class SmartReferenceTool + { + public static void SetReference(SerializedProperty smartReferenceProperty, Object obj) + { + if (smartReferenceProperty == null) + { + throw new ArgumentNullException(nameof(smartReferenceProperty)); + } + + if (obj == null) + { + ClearReference(smartReferenceProperty); + return; + } + + EnsureIsSmartReferenceProperty(smartReferenceProperty); + + var typeProp = smartReferenceProperty.FindPropertyRelative("type"); + var type = Type.GetType(typeProp.stringValue); + if (obj.GetType() != type) + { + throw new ArgumentException($"Object type does not match SmartReference type. Expected: {type}, Actual: {obj.GetType()}"); + } + + if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out var guid, out long fileID)) + { + throw new ArgumentException($"Object is not a valid asset with GUID/fileID: {obj}"); + } + + var path = AssetDatabase.GetAssetPath(obj); + smartReferenceProperty.FindPropertyRelative("guid").stringValue = guid; + smartReferenceProperty.FindPropertyRelative("fileID").longValue = fileID; + smartReferenceProperty.FindPropertyRelative("path").stringValue = path; + + smartReferenceProperty.serializedObject.ApplyModifiedProperties(); + } + + /// + /// Clears SmartReference fields (guid/fileID/path/type). + /// + public static void ClearReference(SerializedProperty smartReferenceProperty) + { + if (smartReferenceProperty == null) + throw new ArgumentNullException(nameof(smartReferenceProperty)); + + EnsureIsSmartReferenceProperty(smartReferenceProperty); + + smartReferenceProperty.FindPropertyRelative("guid").stringValue = string.Empty; + smartReferenceProperty.FindPropertyRelative("fileID").longValue = 0; + smartReferenceProperty.FindPropertyRelative("path").stringValue = string.Empty; + + smartReferenceProperty.serializedObject.ApplyModifiedProperties(); + } + + private static void EnsureIsSmartReferenceProperty(SerializedProperty p) + { + // We check presence of the expected fields rather than type name, so it works with SmartReference + // stored in any container. + if (p.propertyType != SerializedPropertyType.Generic) + throw new ArgumentException($"Property is not a serialized object/struct: {p.propertyPath}, type: {p.propertyType}"); + + if (p.FindPropertyRelative("guid") == null || + p.FindPropertyRelative("fileID") == null || + p.FindPropertyRelative("path") == null || + p.FindPropertyRelative("type") == null) + { + throw new ArgumentException($"Property does not look like a SmartReference (missing guid/fileID/path/type): {p.propertyPath}"); + } + } + } +} \ No newline at end of file diff --git a/Editor/SmartReferenceTool.cs.meta b/Editor/SmartReferenceTool.cs.meta new file mode 100644 index 0000000..45f4bf3 --- /dev/null +++ b/Editor/SmartReferenceTool.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1c250b6bc8094525bc4110b58a3b2e50 +timeCreated: 1766135892 \ No newline at end of file diff --git a/Editor/SmartReferenceUtils.cs b/Editor/SmartReferenceUtils.cs index 364148c..9e72bac 100644 --- a/Editor/SmartReferenceUtils.cs +++ b/Editor/SmartReferenceUtils.cs @@ -4,13 +4,17 @@ using UnityEditor; using UnityEngine; -namespace SmartReference.Editor { - public static class SmartReferenceUtils { +namespace SmartReference.Editor +{ + public static class SmartReferenceUtils + { [MenuItem("Tools/SmartReference/Update All References", priority = 100)] - public static void UpdateAllReferences() { + public static void UpdateAllReferences() + { EditorUtility.DisplayProgressBar("SmartReference", "Updating all references...", 0); - try { + try + { var typeList = GetTypesWithSpecificField(typeof(Runtime.SmartReference)); foreach (var type in typeList) { var guids = AssetDatabase.FindAssets($"t:{type.Name}"); @@ -37,7 +41,8 @@ public static void UpdateAllReferences() { } } - public static void UpdateReference(this Runtime.SmartReference smartReference) { + public static void UpdateReference(this Runtime.SmartReference smartReference) + { if (smartReference == null || string.IsNullOrEmpty(smartReference.guid)) return; var succeed = false; @@ -68,7 +73,8 @@ public static void UpdateReference(this Runtime.SmartReference smartReference) { } } - internal static void UpdateReferenceWithProperty(SerializedProperty property) { + internal static void UpdateReferenceWithProperty(SerializedProperty property) + { if (property == null) return; var guidProp = property.FindPropertyRelative("guid"); @@ -110,7 +116,7 @@ private static List GetTypesWithSpecificField(Type fieldType) List result = new List(); // Iterating over all assemblies - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { // Optional: Filter the assemblies by name if necessary var fullName = assembly.FullName; @@ -118,7 +124,7 @@ private static List GetTypesWithSpecificField(Type fieldType) fullName.StartsWith("Unity.") || fullName.StartsWith("Bee.") || fullName.StartsWith("System.") || fullName.StartsWith("Mono.")) continue; - foreach (Type type in assembly.GetTypes()) + foreach (var type in assembly.GetTypes()) { // Safeguard against types that might throw exceptions try diff --git a/README.md b/README.md index 69dcc1e..fc68acd 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,92 @@ # Smart Reference -#### version 1.0.0 +#### version 2.0.0 + +[![DocFX Build and Publish](https://github.com/Brian-Jiang/SmartReference/actions/workflows/docfx-build-publish.yml/badge.svg?branch=main)](https://github.com/Brian-Jiang/SmartReference/actions/workflows/docfx-build-publish.yml) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Brian-Jiang/SmartReference?label=github&style=flat-square)](https://github.com/Brian-Jiang/SmartReference/releases) +[![openupm](https://img.shields.io/npm/v/com.xiaojiang.smartreference?label=openupm®istry_uri=https://package.openupm.com)](https://openupm.com/packages/com.xiaojiang.smartreference/) +[![Unity Asset Store](https://img.shields.io/badge/Unity%20Asset%20Store-Smart%20Reference-blue.svg)](https://u3d.as/35Sh) + +--- ## Summary -Smart Reference is a Unity plugin that allows you to lazy load references to other objects in ScriptableObject and MonoBehaviour. -You may be familiar to use ScriptableObject store data, but when you reference other objects, -they will be treated as dependencies and will be loaded when the ScriptableObject is loaded. This could be slow if you have a lot of references. -For details, see [this article](https://medium.com/@bjjx1999/3-ways-to-reduce-load-time-in-runtime-for-unity-15d33003eb79). -Smart Reference allows you to load references only when you need them at runtime with same workflow in editor. +Smart Reference is a Unity plugin that enables lazy loading asset references in `ScriptableObject` and `MonoBehaviour`. +In Unity, referencing assets directly creates implicit dependencies. +When the `MonoBehaviour` or `ScriptableObject` is loaded, all referenced assets are loaded immediately, which can lead to slow startup times and unnecessary memory usage, especially in data driven projects. + +Smart Reference solves this by allowing you to reference assets without loading them upfront, while keeping the same editor workflow. +Assets are only loaded when you use them at runtime. ## Quick Start -1. Use SmartReference instead of Object. See how they look exactly same in the inspector. - ```csharp - public class MonsterData : ScriptableObject { - public SmartReference prefab; - public SmartReference icon; - public string description; - } - ``` - Use SceneReference instead of entering scene name as string. - See the info tip or auto fix button if the scene is missing or not enabled in build settings. - ```csharp - public class GameConfig : ScriptableObject { - public SceneReference startScene; - } - ``` - Use them exactly same way as you use regular Object and scene name. - ```csharp - Object.Instantiate(monsterData.prefab, position, rotation); - SceneManager.LoadSceneAsync(gameConfig.startScene); - ``` - -2. Initialize smart reference when your game start - - If you use Unity Addressables, call this when your game start - ```csharp - SmartReference.Runtime.SmartReference.InitWithAddressablesLoader(); - ``` - - If you use your own custom loader, call - ```csharp - SmartReference.Runtime.SmartReference.Init((path, type) => { - return MyLoadFunction.Load(path, type); - }, (path, type, callback) => { - MyLoadFunction.LoadAsync(path, type, obj => { - callback(obj); - }); - }); - ``` - or - ```csharp - var loader = new CustomLoader { - loader = MyLoadFunction, - loaderAsync = MyAsyncLoadFunction, - }; - SmartReference.Runtime.SmartReference.Init(loader); - ``` - - If you use Unity Resources(not recommended, see [why](https://medium.com/@bjjx1999/3-ways-to-reduce-load-time-in-runtime-for-unity-15d33003eb79)), call - ```csharp - SmartReference.Runtime.SmartReference.InitWithResourcesLoader(); - ``` - -3. SmartReference will automatically update paths before player build in case you move or rename the referenced asset. - If you want to manually update all references in the project, go to `Tools -> SmartReference -> Update All References` to update all references in the project. +### 1. Replace direct Unity references with `SmartReference` +In the Inspector, SmartReference looks and behaves like a normal object reference. +```csharp +using UnityEngine; +using SmartReference.Runtime; + +public class MonsterData : ScriptableObject +{ + public SmartReference prefab; + public SmartReference icon; + public string description; +} + +``` + +### 2. Initialize Smart Reference at startup +Choose the loader that matches your project setup. +#### Addressables (recommended) +```csharp +using SmartReference.Runtime; + +SmartReference.InitWithAddressablesLoader(); +``` + +#### Resources +```csharp +using SmartReference.Runtime; + +SmartReference.InitWithResourcesLoader(); +``` + +#### Custom Loader +```csharp +SmartReference.InitWithCustomLoader(new MyCustomLoader()); +``` + +Initialization must happen once, before any `SmartReference` is accessed. + +### 3. Load assets when needed +#### Asynchronous Loading with UniTask (recommended) +```csharp +var prefab = await monsterData.prefab.LoadAsyncUniTask(); +Instantiate(prefab); +``` + +#### Asynchronous Loading with C# Async/Await +```csharp +var prefab = await monsterData.prefab.LoadAsyncTask(); +Instantiate(prefab); +``` + +#### Asynchronous Loading with Callbacks +```csharp +monsterData.prefab.OnAsyncLoadComplete += go => +{ + Instantiate(go); +}; +monsterData.prefab.LoadAsync(); +``` + +#### Synchronous Loading +```csharp +var prefab = monsterData.prefab.Load(); +Instantiate(prefab); +``` ## Supports -If you have any questions, please leave an issue at [GitHub](https://github.com/Brian-Jiang/SmartReference/issues). +If you have questions or feedback: +- Leave an issue at [GitHub Issues](https://github.com/Brian-Jiang/SmartReference/issues) +- Leave a comment at [Asset Store](https://u3d.as/35Sh) +- Email: [bjjx1999@live.com](mailto:bjjx1999@live.com) + Thank you for your support! \ No newline at end of file diff --git a/Runtime/Handles.meta b/Runtime/Handles.meta new file mode 100644 index 0000000..17e075c --- /dev/null +++ b/Runtime/Handles.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f3b9f87c3cbe4e3c89a2f48978b5b4d4 +timeCreated: 1766125299 \ No newline at end of file diff --git a/Runtime/Handles/AddressablesHandle.cs b/Runtime/Handles/AddressablesHandle.cs new file mode 100644 index 0000000..08de078 --- /dev/null +++ b/Runtime/Handles/AddressablesHandle.cs @@ -0,0 +1,15 @@ +#if SMARTREFERENCE_ADDRESSABLES_SUPPORT + +using UnityEngine; +using UnityEngine.ResourceManagement.AsyncOperations; + +namespace SmartReference.Runtime +{ + public class AddressablesHandle : ISmartReferenceHandle + { + public AsyncOperationHandle op; + public bool IsValid => op.IsValid(); + } +} + +#endif \ No newline at end of file diff --git a/Runtime/Handles/AddressablesHandle.cs.meta b/Runtime/Handles/AddressablesHandle.cs.meta new file mode 100644 index 0000000..fd6f1ae --- /dev/null +++ b/Runtime/Handles/AddressablesHandle.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a6306b78b77a4e28b708ce5650c1c040 +timeCreated: 1766125561 \ No newline at end of file diff --git a/Runtime/Handles/ISmartReferenceHandle.cs b/Runtime/Handles/ISmartReferenceHandle.cs new file mode 100644 index 0000000..7a22a25 --- /dev/null +++ b/Runtime/Handles/ISmartReferenceHandle.cs @@ -0,0 +1,11 @@ +namespace SmartReference.Runtime +{ + /// + /// This is inherited by different handle types used by SmartReference. It stores current state of the reference. + /// It should be returned by the `LoadAsync` method and passed to the `Release` and `Cancel` methods so the loader knows what assets to operate on. + /// + public interface ISmartReferenceHandle + { + + } +} \ No newline at end of file diff --git a/Runtime/Handles/ISmartReferenceHandle.cs.meta b/Runtime/Handles/ISmartReferenceHandle.cs.meta new file mode 100644 index 0000000..075269f --- /dev/null +++ b/Runtime/Handles/ISmartReferenceHandle.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 00e8604676674b6b890d8e4bef766c3a +timeCreated: 1766125322 \ No newline at end of file diff --git a/Runtime/Handles/ResourcesHandle.cs b/Runtime/Handles/ResourcesHandle.cs new file mode 100644 index 0000000..9a8de3d --- /dev/null +++ b/Runtime/Handles/ResourcesHandle.cs @@ -0,0 +1,10 @@ +using UnityEngine; + +namespace SmartReference.Runtime +{ + public class ResourcesHandle : ISmartReferenceHandle + { + public ResourceRequest request; + public Object loadedObject; + } +} \ No newline at end of file diff --git a/Runtime/Handles/ResourcesHandle.cs.meta b/Runtime/Handles/ResourcesHandle.cs.meta new file mode 100644 index 0000000..337b206 --- /dev/null +++ b/Runtime/Handles/ResourcesHandle.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 804792165370431aad1e561ac8a2d793 +timeCreated: 1766125871 \ No newline at end of file diff --git a/Runtime/Loaders/AddressablesLoader.cs b/Runtime/Loaders/AddressablesLoader.cs index 2bb4cc4..4646dd4 100644 --- a/Runtime/Loaders/AddressablesLoader.cs +++ b/Runtime/Loaders/AddressablesLoader.cs @@ -1,34 +1,54 @@ -#if USE_UNITY_ADDRESSABLES +#if SMARTREFERENCE_ADDRESSABLES_SUPPORT using System; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; using Object = UnityEngine.Object; -namespace SmartReference.Runtime { - public class AddressablesLoader: ISmartReferenceLoader { - public Object Load(string path, Type type) { - Object result = null; +namespace SmartReference.Runtime +{ + public class AddressablesLoader: ISmartReferenceLoader + { + public ISmartReferenceHandle Load(string path, Type type, out Object loadedObject) + { var handle = Addressables.LoadAssetAsync(path); - handle.Completed += operation => { - if (operation.Status == AsyncOperationStatus.Succeeded) { - result = operation.Result; - } - }; #if !UNITY_WEBGL handle.WaitForCompletion(); +#else + UnityEngine.Debug.LogError("AddressablesLoader.Load called in WebGL build; synchronous loading is not supported. Consider using LoadAsync instead."); #endif - return result; + + loadedObject = handle.Result; + var h = new AddressablesHandle { op = handle }; + return h; } - public void LoadAsync(string path, Type type, Action callback) { + public ISmartReferenceHandle LoadAsync(string path, Type type, Action callback) + { var handle = Addressables.LoadAssetAsync(path); - handle.Completed += operation => { - if (operation.Status == AsyncOperationStatus.Succeeded) { - callback?.Invoke(operation.Result); - } + handle.Completed += operation => + { + var result = operation.Status == AsyncOperationStatus.Succeeded ? operation.Result : null; + callback?.Invoke(result); }; + + var h = new AddressablesHandle { op = handle }; + return h; + } + + public void Release(ISmartReferenceHandle handle) + { + if (handle is AddressablesHandle { IsValid: true } addressablesHandle) + { + Addressables.Release(addressablesHandle.op); + addressablesHandle.op = default; + } + } + + public void Cancel(ISmartReferenceHandle handle) + { + } } } diff --git a/Runtime/Loaders/CustomLoader.cs b/Runtime/Loaders/CustomLoader.cs deleted file mode 100644 index 43a2aba..0000000 --- a/Runtime/Loaders/CustomLoader.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Object = UnityEngine.Object; - -namespace SmartReference.Runtime { - public delegate Object SmartReferenceLoader(string path, Type type); - public delegate void SmartReferenceLoaderAsync(string path, Type type, Action callback); - - public class CustomLoader: ISmartReferenceLoader { - public SmartReferenceLoader loader; - public SmartReferenceLoaderAsync loaderAsync; - - public Object Load(string path, Type type) { - return loader(path, type); - } - - public void LoadAsync(string path, Type type, Action callback) { - loaderAsync(path, type, callback); - } - } -} \ No newline at end of file diff --git a/Runtime/Loaders/CustomLoader.cs.meta b/Runtime/Loaders/CustomLoader.cs.meta deleted file mode 100644 index 3d47c14..0000000 --- a/Runtime/Loaders/CustomLoader.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 6ea35c4589c248d9b9fe9dd427973c9d -timeCreated: 1687613949 \ No newline at end of file diff --git a/Runtime/Loaders/ISmartReferenceLoader.cs b/Runtime/Loaders/ISmartReferenceLoader.cs index 512c8d1..a534eb1 100644 --- a/Runtime/Loaders/ISmartReferenceLoader.cs +++ b/Runtime/Loaders/ISmartReferenceLoader.cs @@ -1,9 +1,41 @@ using System; using Object = UnityEngine.Object; -namespace SmartReference.Runtime { - public interface ISmartReferenceLoader { - public Object Load(string path, Type type); - public void LoadAsync(string path, Type type, Action callback); +namespace SmartReference.Runtime +{ + public interface ISmartReferenceLoader + { + /// + /// Load an asset synchronously. + /// + /// The path of the asset, begin with `Assets/`. + /// The type of the asset. + /// The loaded asset. + /// An `ISmartReferenceHandle` that stores custom data about the load operation. + public ISmartReferenceHandle Load(string path, Type type, out Object loadedObject); + + /// + /// Load an asset asynchronously. + /// + /// The path of the asset, begin with `Assets/`. + /// the type of the asset. + /// The callback to invoke once the load has finished. + /// If your loader cancels the async load, you should call this callback with `null`. + /// If not, you should call this with loaded `Object`, another `Release` will be called afterwards + /// An `ISmartReferenceHandle` that stores custom data about the load operation. + public ISmartReferenceHandle LoadAsync(string path, Type type, Action callback); + + /// + /// Release an asset/handle created by this loader using the provided handle. + /// + /// The handle returned by `LoadAsync`. + void Release(ISmartReferenceHandle handle); + + /// + /// Cancel an ongoing asynchronous load operation. This will be called when the asset is released when it's still loading asynchronously. + /// Note that if the async callback is called later after loading is canceled, the `Release` method will be called to clean up the loaded asset. + /// + /// The handle returned by `LoadAsync`. + void Cancel(ISmartReferenceHandle handle); } } \ No newline at end of file diff --git a/Runtime/Loaders/ResourcesLoader.cs b/Runtime/Loaders/ResourcesLoader.cs index 39c1bd5..ad976fb 100644 --- a/Runtime/Loaders/ResourcesLoader.cs +++ b/Runtime/Loaders/ResourcesLoader.cs @@ -2,28 +2,81 @@ using UnityEngine; using Object = UnityEngine.Object; -namespace SmartReference.Runtime { - public class ResourcesLoader: ISmartReferenceLoader { - public Object Load(string path, Type type) { +namespace SmartReference.Runtime +{ + public class ResourcesLoader: ISmartReferenceLoader + { + public ISmartReferenceHandle Load(string path, Type type, out Object loadedObject) + { var resourcesPath = GetResourcesPath(path); - return Resources.Load(resourcesPath, type); + var result = Resources.Load(resourcesPath, type); + loadedObject = result; + return new ResourcesHandle { loadedObject = result }; } - public void LoadAsync(string path, Type type, Action callback) { + public ISmartReferenceHandle LoadAsync(string path, Type type, Action callback) + { var resourcesPath = GetResourcesPath(path); var request = Resources.LoadAsync(resourcesPath, type); request.completed += _ => callback?.Invoke(request.asset); + return new ResourcesHandle { request = request }; } - - private string GetResourcesPath(string path) { - var index = path.LastIndexOf("Resources/", StringComparison.Ordinal); - if (index == -1) { - Debug.LogError($"[SmartReference] ResourcesLoader: Path {path} is not in Resources folder"); + + public void Release(ISmartReferenceHandle handle) + { + if (handle is ResourcesHandle resourcesHandle) + { + if (resourcesHandle.loadedObject != null) + { + Resources.UnloadAsset(resourcesHandle.loadedObject); + } else if (resourcesHandle.request != null) + { + var loadedAsset = resourcesHandle.request.asset; + if (loadedAsset != null) + { + Resources.UnloadAsset(loadedAsset); + } + } + } + } + + public void Cancel(ISmartReferenceHandle handle) + { + + } + + private static string GetResourcesPath(string path) + { + if (string.IsNullOrEmpty(path)) return path; + + // Normalize slashes for Windows paths + var p = path.Replace('\\', '/'); + + const string segment = "/Resources/"; + var idx = p.IndexOf(segment, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + Debug.LogError($"[SmartReference] ResourcesLoader: Path does not contain '/Resources/': {path}"); + return StripExtension(p); + } + + var start = idx + segment.Length; + if (start >= p.Length) + { + Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); + return StripExtension(p); } - var extensionIndex = path.LastIndexOf(".", StringComparison.Ordinal); - return path[(index + "Resources/".Length)..extensionIndex]; + var relative = p.Substring(start); + return StripExtension(relative); + } + + private static string StripExtension(string path) + { + var slashIndex = path.LastIndexOf('/'); + var dotIndex = path.LastIndexOf('.'); + return (dotIndex > slashIndex) ? path.Substring(0, dotIndex) : path; } } } \ No newline at end of file diff --git a/Runtime/SceneReference.cs b/Runtime/SceneReference.cs index 86c4d21..73c2301 100644 --- a/Runtime/SceneReference.cs +++ b/Runtime/SceneReference.cs @@ -1,11 +1,12 @@ using System; using UnityEngine; -namespace SmartReference.Runtime { +namespace SmartReference.Runtime +{ [Serializable] - public class SceneReference { - [SerializeField] - private string scenePath; + public class SceneReference + { + [SerializeField] private string scenePath; /// /// Get the scene path. @@ -13,11 +14,13 @@ public class SceneReference { // ReSharper disable once UnusedMember.Global public string ScenePath => scenePath; - public override string ToString() { + public override string ToString() + { return scenePath; } - public static implicit operator string(SceneReference sceneReference) { + public static implicit operator string(SceneReference sceneReference) + { return sceneReference.scenePath; } } diff --git a/Runtime/SmartReference.Runtime.asmdef b/Runtime/SmartReference.Runtime.asmdef index 28d36af..917d6ed 100644 --- a/Runtime/SmartReference.Runtime.asmdef +++ b/Runtime/SmartReference.Runtime.asmdef @@ -2,8 +2,9 @@ "name": "SmartReference.Runtime", "rootNamespace": "SmartReference.Runtime", "references": [ - "GUID:9e24947de15b9834991c9d8411ea37cf", - "GUID:84651a3751eca9349aac36a66bba901b" + "Unity.Addressables", + "Unity.ResourceManager", + "UniTask" ], "includePlatforms": [], "excludePlatforms": [], @@ -16,7 +17,12 @@ { "name": "com.unity.addressables", "expression": "", - "define": "USE_UNITY_ADDRESSABLES" + "define": "SMARTREFERENCE_ADDRESSABLES_SUPPORT" + }, + { + "name": "com.cysharp.unitask", + "expression": "", + "define": "SMARTREFERENCE_UNITASK_SUPPORT" } ], "noEngineReferences": false diff --git a/Runtime/SmartReference.cs b/Runtime/SmartReference.cs index d5bceca..6b669dc 100644 --- a/Runtime/SmartReference.cs +++ b/Runtime/SmartReference.cs @@ -1,64 +1,100 @@ using System; +using System.Threading.Tasks; using UnityEngine; using Object = UnityEngine.Object; + +#if SMARTREFERENCE_UNITASK_SUPPORT +using System.Threading; +using Cysharp.Threading.Tasks; +#endif + // ReSharper disable NotAccessedField.Global used in serialization // ReSharper disable ParameterHidesMember -namespace SmartReference.Runtime { +namespace SmartReference.Runtime +{ [Serializable] - public abstract class SmartReference { + public abstract class SmartReference + { public string guid; public long fileID; public string path; public string type; - protected static ISmartReferenceLoader loader; + #region Statics + + protected static ISmartReferenceLoader Loader; /// /// Use this method to initialize the loader if you want to use Resources for loading assets. /// - public static void InitWithResourcesLoader() { - loader = new ResourcesLoader(); + public static void InitWithResourcesLoader() + { + Loader = new ResourcesLoader(); } -#if USE_UNITY_ADDRESSABLES +#if SMARTREFERENCE_ADDRESSABLES_SUPPORT /// /// Use this method to initialize the loader if you want to use Unity Addressables for loading assets. /// - public static void InitWithAddressablesLoader() { - loader = new AddressablesLoader(); + public static void InitWithAddressablesLoader() + { + Loader = new AddressablesLoader(); } #endif /// - /// Use this method to initialize the loader if you want to use a custom loader. + /// Use this method to initialize the loader with a custom implementation of ISmartReferenceLoader. /// - /// Loader that load and return asset synchronously. - /// Loader that load and return asset asynchronously. - public static void InitWithCustomLoader(SmartReferenceLoader loader, SmartReferenceLoaderAsync loaderAsync) { - SmartReference.loader = new CustomLoader { - loader = loader, - loaderAsync = loaderAsync, - }; + /// The custom loader implementation. + public static void InitWithCustomLoader(ISmartReferenceLoader loader) + { + Loader = loader; } - /// - /// Use this method to initialize the loader if you want to use a custom loader. - /// - /// The custom loader that you create. - public static void InitWithCustomLoader(CustomLoader loader) { - SmartReference.loader = loader; - } + #endregion } [Serializable] - public class SmartReference: SmartReference, ISerializationCallbackReceiver where T: Object { - private T value; + public class SmartReference: SmartReference, ISerializationCallbackReceiver + where T: Object + { + [NonSerialized] private T value; + /// + /// Event invoked when the asynchronous load is complete. + /// + public event Action OnAsyncLoadComplete; + + [NonSerialized] private bool isLoading; + [NonSerialized] private TaskCompletionSource inFlightTaskTcs; +#if SMARTREFERENCE_UNITASK_SUPPORT + [NonSerialized] private UniTaskCompletionSource inFlightUniTaskTcs; +#endif + + [NonSerialized] private ISmartReferenceHandle handle; + [NonSerialized] private bool releaseRequested; + + /// + /// Check if the asset is loaded. + /// + public bool IsLoaded => value != null; + + /// + /// Check if the asset is loading asynchronously. + /// + public bool IsLoading => isLoading; + + /// + /// Check if a release has been requested while loading. + /// + public bool ReleaseRequested => releaseRequested; + /// /// Get the asset. If the asset is not loaded, it will be loaded automatically. /// - public T Value { + public T Value + { get { if (value == null) { Load(); @@ -68,52 +104,264 @@ public T Value { } } - public static implicit operator T(SmartReference reference) { + public static implicit operator T(SmartReference reference) + { return reference.Value; } /// /// Call this method to load the asset. This would be called automatically when you access the Value property. /// - public void Load() { - if (string.IsNullOrEmpty(path)) return; + public void Load() + { + if (string.IsNullOrEmpty(path)) + { + LogEmptyAssetError(); + return; + } - if (loader == null) { - Debug.LogError($"[SmartReference] loader is null, path: {path}"); + if (Loader == null) + { + LogEmptyLoaderError(); + return; + } + + if (isLoading) + { + Debug.LogWarning($"[SmartReference] Asset at path: {path} is currently loading asynchronously. Please wait for the OnAsyncLoadComplete event."); return; } - value = (T) loader.Load(path, typeof(T)); - if (value == null) { - Debug.LogWarning($"[SmartReference] load failed, path: {path}"); + handle = Loader.Load(path, typeof(T), out var result); + value = (T) result; + if (value == null) + { + LogLoadAssetNullError(); } } /// - /// Call this method to load the asset asynchronously. Useful if you want to preload the asset. + /// Call this method to load the asset asynchronously. You can subscribe to the OnAsyncLoadComplete event to get notified when the load is complete. /// - public void LoadAsync() { - if (string.IsNullOrEmpty(path)) return; - - if (loader == null) { - Debug.LogError($"[SmartReference] loaderAsync is null, path: {path}"); + public void LoadAsync() + { + if (value != null) + { return; } - - loader.LoadAsync(path, typeof(T), obj => { - if (obj == null) { - Debug.LogWarning($"[SmartReference] loadAsync failed, path: {path}"); + + if (string.IsNullOrEmpty(path)) + { + LogEmptyAssetError(); + return; + } + + if (Loader == null) + { + LogEmptyLoaderError(); + return; + } + + // If already loading, do nothing + if (isLoading) return; + + isLoading = true; + releaseRequested = false; + handle = Loader.LoadAsync(path, typeof(T), OnAsyncLoadCompleteCallback); + } + + /// + /// Load the asset asynchronously with C# async/await. + /// + public Task LoadAsyncTask() + { + if (value != null) + { + return Task.FromResult(value); + } + + if (string.IsNullOrEmpty(path)) + { + LogEmptyAssetError(); + return Task.FromResult(null); + } + + if (Loader == null) + { + LogEmptyLoaderError(); + return Task.FromResult(null); + } + + if (inFlightTaskTcs != null) + { + return inFlightTaskTcs.Task; + } + + inFlightTaskTcs = new TaskCompletionSource(); + LoadAsync(); + return inFlightTaskTcs.Task; + } + +#if SMARTREFERENCE_UNITASK_SUPPORT + + /// + /// Load the asset asynchronously with UniTask. + /// + public UniTask LoadAsyncUniTask() + { + return LoadAsyncUniTask(CancellationToken.None); + } + + /// + /// Load the asset asynchronously with UniTask and cancellation token. + /// + public UniTask LoadAsyncUniTask(CancellationToken cancellationToken) + { + if (value != null) + { + return UniTask.FromResult(value); + } + + if (string.IsNullOrEmpty(path)) + { + LogEmptyAssetError(); + return UniTask.FromResult(null); + } + + if (Loader == null) + { + LogEmptyLoaderError(); + return UniTask.FromResult(null); + } + + if (inFlightUniTaskTcs != null) + { + return inFlightUniTaskTcs.Task.AttachExternalCancellation(cancellationToken); + } + + inFlightUniTaskTcs = new UniTaskCompletionSource(); + LoadAsync(); + return inFlightUniTaskTcs.Task.AttachExternalCancellation(cancellationToken); + } + +#endif + + /// + /// Release the loaded asset. + /// Safe to call multiple times. + /// If called during loading, attempts to cancel the loading. + /// + public void Release() + { + if (Loader == null) + { + LogEmptyLoaderError(); + return; + } + + if (isLoading) + { + releaseRequested = true; + try + { + Loader.Cancel(handle); + } + catch (Exception e) + { + Debug.LogWarning($"[SmartReference] Cancel loading failed for asset at path: {path}. Exception: {e}"); } - value = (T) obj; - }); + return; + } + + if (value == null && handle == null) + { + return; + } + + try + { + Loader.Release(handle); + } + catch (Exception e) + { + Debug.LogWarning($"[SmartReference] Release failed for asset at path: {path}. Exception: {e}"); + } + finally + { + value = null; + handle = null; + releaseRequested = false; + } + } + + private void CompleteInFlight(T result) + { + OnAsyncLoadComplete?.Invoke(result); + + // Task + if (inFlightTaskTcs != null) + { + var tcs = inFlightTaskTcs; + inFlightTaskTcs = null; + tcs.TrySetResult(result); + } + +#if SMARTREFERENCE_UNITASK_SUPPORT + // UniTask + if (inFlightUniTaskTcs != null) + { + var utcs = inFlightUniTaskTcs; + inFlightUniTaskTcs = null; + utcs.TrySetResult(result); + } +#endif + } + + private void OnAsyncLoadCompleteCallback(Object obj) + { + isLoading = false; + if (obj == null) + { + CompleteInFlight(null); + if (releaseRequested) return; + + LogLoadAssetNullError(); + return; + } + + value = (T) obj; + CompleteInFlight(value); + + // If Release was called during loading, release immediately after completion. + if (releaseRequested) + { + Release(); + } + } + + private void LogEmptyAssetError() + { + Debug.LogError($"[SmartReference] Asset path is null."); + } + + private void LogEmptyLoaderError() + { + Debug.LogError($"[SmartReference] Loader is null, please init the SmartReference with a loader before using it."); + } + + private void LogLoadAssetNullError() + { + Debug.LogError($"[SmartReference] Loaded asset is null, path: {path}"); } - public void OnBeforeSerialize() { + public void OnBeforeSerialize() + { type = typeof(T).AssemblyQualifiedName; } - public void OnAfterDeserialize() { + public void OnAfterDeserialize() + { } } diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..c00739b --- /dev/null +++ b/Tests.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 4d0c8177fa8646809821372494e928ef +timeCreated: 1766129023 \ No newline at end of file diff --git a/Tests/Editor.meta b/Tests/Editor.meta new file mode 100644 index 0000000..fd02fe1 --- /dev/null +++ b/Tests/Editor.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: bfc284d7198c47ee96657c239439e454 +timeCreated: 1766137653 \ No newline at end of file diff --git a/Tests/Editor/EditModeTests.cs b/Tests/Editor/EditModeTests.cs new file mode 100644 index 0000000..18c43cd --- /dev/null +++ b/Tests/Editor/EditModeTests.cs @@ -0,0 +1,279 @@ +// SmartReferenceTool_EditModeTests.cs +// Put in an EditMode test assembly, e.g. +// Assets/Tests/EditMode/SmartReference/SmartReferenceTool_EditModeTests.cs +// or Packages/com.yourname.smartreference/Tests/EditMode/SmartReferenceTool_EditModeTests.cs + +using System; +using NUnit.Framework; +using SmartReference.Runtime; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace SmartReference.Editor.Tests +{ + public sealed class EditModeTests + { + private const string RootFolder = "Assets/SmartReferenceTool_EditModeTestAssets"; + private const string SoAssetPath = RootFolder + "/TestAsset.asset"; + + private TestContainerSO container; + private SerializedObject serializedContainer; + private SerializedProperty smartRefProp; + private SerializedProperty smartRefToGoProp; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + EnsureFolder(RootFolder); + + // Create a ScriptableObject asset we can reference + var testAsset = ScriptableObject.CreateInstance(); + testAsset.name = "TestAsset"; + CreateOrReplaceAsset(testAsset, SoAssetPath); + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + if (AssetDatabase.IsValidFolder(RootFolder)) + { + AssetDatabase.DeleteAsset(RootFolder); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + } + + [SetUp] + public void SetUp() + { + // Create a container object (not an asset) that has a SmartReference-like field + container = ScriptableObject.CreateInstance(); + serializedContainer = new SerializedObject(container); + smartRefProp = serializedContainer.FindProperty("refToAsset"); + Assert.NotNull(smartRefProp, "Failed to find refToAsset SerializedProperty"); + smartRefToGoProp = serializedContainer.FindProperty("refToGameObject"); + Assert.NotNull(smartRefToGoProp, "Failed to find refToGameObject SerializedProperty"); + + // Ensure type matches the asset type + var typeProp = smartRefProp.FindPropertyRelative("type"); + typeProp.stringValue = typeof(TestAssetSO).AssemblyQualifiedName; + serializedContainer.ApplyModifiedPropertiesWithoutUndo(); + } + + [TearDown] + public void TearDown() + { + if (container != null) + { + Object.DestroyImmediate(container); + container = null; + } + } + + // --------------------------- + // SetReference correctness + // --------------------------- + + [Test] + public void SetReference_WritesGuidFileIdPath() + { + var asset = AssetDatabase.LoadAssetAtPath(SoAssetPath); + Assert.NotNull(asset); + + SmartReferenceTool.SetReference(smartRefProp, asset); + + var guidProp = smartRefProp.FindPropertyRelative("guid"); + var fileIDProp = smartRefProp.FindPropertyRelative("fileID"); + var pathProp = smartRefProp.FindPropertyRelative("path"); + + Assert.IsFalse(string.IsNullOrEmpty(guidProp.stringValue), "guid should not be empty"); + Assert.Greater(fileIDProp.longValue, 0, "fileID should be > 0"); + Assert.AreEqual(SoAssetPath, pathProp.stringValue, "path should match AssetDatabase path"); + + // Verify it matches Unity's ground truth + Assert.IsTrue(AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out var expectedGuid, out long expectedFileID)); + Assert.AreEqual(expectedGuid, guidProp.stringValue); + Assert.AreEqual(expectedFileID, fileIDProp.longValue); + } + + [Test] + public void SetReference_WhenTypeMismatch_Throws() + { + // type expects TestAssetSO, but we pass a material + var gameObject = new GameObject(); + try + { + // Material is not an asset here; save it so it becomes an asset + var goPath = RootFolder + "/go.prefab"; + // AssetDatabase.CreateAsset(gameObject, goPath); + // AssetDatabase.SaveAssets(); + // AssetDatabase.Refresh(); + PrefabUtility.SaveAsPrefabAsset(gameObject, goPath); + + var goAsset = AssetDatabase.LoadAssetAtPath(goPath); + Assert.NotNull(goAsset); + + var ex = Assert.Throws(() => SmartReferenceTool.SetReference(smartRefProp, goAsset)); + StringAssert.Contains("Object type does not match SmartReference type", ex.Message); + } + finally + { + // cleanup + var goPath = RootFolder + "/go.prefab"; + if (AssetDatabase.LoadAssetAtPath(goPath) != null) + AssetDatabase.DeleteAsset(goPath); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + } + + [Test] + public void SetReference_WhenObjIsNotAsset_Throws() + { + var sceneGo = new GameObject("NotAnAsset"); + try + { + var ex = Assert.Throws(() => SmartReferenceTool.SetReference(smartRefToGoProp, sceneGo)); + StringAssert.Contains("Object is not a valid asset with GUID/fileID", ex.Message); + } + finally + { + Object.DestroyImmediate(sceneGo); + } + } + + [Test] + public void SetReference_WhenPropertyIsNull_Throws() + { + Assert.Throws(() => SmartReferenceTool.SetReference(null, AssetDatabase.LoadAssetAtPath(SoAssetPath))); + } + + [Test] + public void SetReference_WhenObjIsNull_Clears() + { + // First set something + var asset = AssetDatabase.LoadAssetAtPath(SoAssetPath); + SmartReferenceTool.SetReference(smartRefProp, asset); + + // Then set null to clear + SmartReferenceTool.SetReference(smartRefProp, null); + + Assert.AreEqual(string.Empty, smartRefProp.FindPropertyRelative("guid").stringValue); + Assert.AreEqual(0, smartRefProp.FindPropertyRelative("fileID").longValue); + Assert.AreEqual(string.Empty, smartRefProp.FindPropertyRelative("path").stringValue); + } + + // --------------------------- + // ClearReference correctness + // --------------------------- + + [Test] + public void ClearReference_ClearsGuidFileIdPath() + { + var asset = AssetDatabase.LoadAssetAtPath(SoAssetPath); + SmartReferenceTool.SetReference(smartRefProp, asset); + + SmartReferenceTool.ClearReference(smartRefProp); + + Assert.AreEqual(string.Empty, smartRefProp.FindPropertyRelative("guid").stringValue); + Assert.AreEqual(0, smartRefProp.FindPropertyRelative("fileID").longValue); + Assert.AreEqual(string.Empty, smartRefProp.FindPropertyRelative("path").stringValue); + } + + [Test] + public void ClearReference_WhenPropertyIsNull_Throws() + { + Assert.Throws(() => SmartReferenceTool.ClearReference(null)); + } + + [Test] + public void ClearReference_WhenNotSmartReferenceProperty_Throws() + { + var badProp = serializedContainer.FindProperty("notARef"); + Assert.NotNull(badProp); + + var ex = Assert.Throws(() => SmartReferenceTool.ClearReference(badProp)); + StringAssert.Contains("Property is not a serialized object/struct", ex.Message); + } + + // --------------------------- + // ApplyModifiedProperties behavior + // --------------------------- + + [Test] + public void SetReference_AppliesModifiedProperties_ToTargetObject() + { + var asset = AssetDatabase.LoadAssetAtPath(SoAssetPath); + + // Call SetReference (it calls ApplyModifiedProperties internally) + SmartReferenceTool.SetReference(smartRefProp, asset); + + // Rebuild SerializedObject to ensure the values were truly applied + var so2 = new SerializedObject(container); + var prop2 = so2.FindProperty("refToAsset"); + + Assert.AreEqual(smartRefProp.FindPropertyRelative("guid").stringValue, prop2.FindPropertyRelative("guid").stringValue); + Assert.AreEqual(smartRefProp.FindPropertyRelative("fileID").longValue, prop2.FindPropertyRelative("fileID").longValue); + Assert.AreEqual(smartRefProp.FindPropertyRelative("path").stringValue, prop2.FindPropertyRelative("path").stringValue); + } + + // --------------------------- + // Local test types + // --------------------------- + + // [Serializable] + // private struct SmartRefLike + // { + // public string guid; + // public long fileID; + // public string path; + // public string type; + // } + + private sealed class TestContainerSO : ScriptableObject + { + public SmartReference refToAsset; + public SmartReference refToGameObject; + public Object notARef; + } + + private sealed class TestAssetSO : ScriptableObject + { + public int dummy; + } + + // --------------------------- + // Asset helpers + // --------------------------- + + private static void EnsureFolder(string folder) + { + if (AssetDatabase.IsValidFolder(folder)) + return; + + // Create nested folders as needed + var parts = folder.Split('/'); + var current = parts[0]; + for (int i = 1; i < parts.Length; i++) + { + var next = current + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) + AssetDatabase.CreateFolder(current, parts[i]); + current = next; + } + } + + private static void CreateOrReplaceAsset(Object asset, string assetPath) + { + var existing = AssetDatabase.LoadAssetAtPath(assetPath); + if (existing != null) + AssetDatabase.DeleteAsset(assetPath); + + AssetDatabase.CreateAsset(asset, assetPath); + } + } +} diff --git a/Tests/Editor/EditModeTests.cs.meta b/Tests/Editor/EditModeTests.cs.meta new file mode 100644 index 0000000..53cc27c --- /dev/null +++ b/Tests/Editor/EditModeTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7df13add2a1e4caa80027fb6c72df79d +timeCreated: 1766137938 \ No newline at end of file diff --git a/Tests/Editor/SmartReference.Editor.Tests.asmdef b/Tests/Editor/SmartReference.Editor.Tests.asmdef new file mode 100644 index 0000000..10126d1 --- /dev/null +++ b/Tests/Editor/SmartReference.Editor.Tests.asmdef @@ -0,0 +1,36 @@ +{ + "name": "SmartReference.Editor.Tests", + "rootNamespace": "SmartReference.Editor.Tests", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "SmartReference.Runtime", + "SmartReference.Editor" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [ + { + "name": "com.unity.addressables", + "expression": "", + "define": "SMARTREFERENCE_ADDRESSABLES_SUPPORT" + }, + { + "name": "com.cysharp.unitask", + "expression": "", + "define": "SMARTREFERENCE_UNITASK_SUPPORT" + } + ], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Tests/Editor/SmartReference.Editor.Tests.asmdef.meta b/Tests/Editor/SmartReference.Editor.Tests.asmdef.meta new file mode 100644 index 0000000..0ec9022 --- /dev/null +++ b/Tests/Editor/SmartReference.Editor.Tests.asmdef.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c7008bfcee6c42ca81ef90f463bae8c1 +timeCreated: 1766137671 \ No newline at end of file diff --git a/Tests/Runtime.meta b/Tests/Runtime.meta new file mode 100644 index 0000000..8a578a3 --- /dev/null +++ b/Tests/Runtime.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8db03a84acb74d6f9cc1c4f5618a6446 +timeCreated: 1766129050 \ No newline at end of file diff --git a/Tests/Runtime/AssetSetup.cs b/Tests/Runtime/AssetSetup.cs new file mode 100644 index 0000000..fb602a8 --- /dev/null +++ b/Tests/Runtime/AssetSetup.cs @@ -0,0 +1,123 @@ +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; + +namespace SmartReference.Runtime.Tests +{ + /// + /// Creates test assets BEFORE entering Play Mode (but still part of the test pipeline), + /// and cleans them up afterward. + /// + public static class AssetSetup + { + public const string RootFolder = "Assets/SmartReference_PlayModeTestAssets"; + public const string ResourcesFolder = RootFolder + "/Resources"; + public const string ResourcesSubFolder = ResourcesFolder + "/SmartReferenceTest"; + + public const string SoAssetPath = ResourcesSubFolder + "/TestSO.asset"; + public const string TextureAssetPath = ResourcesSubFolder + "/TestTex.asset"; + public const string MaterialAssetPath = ResourcesSubFolder + "/TestMat.mat"; + public const string PrefabAssetPath = ResourcesSubFolder + "/TestPrefab.prefab"; + + // Resources.Load path (relative to any Resources folder) + public const string SoResourcesPath = SoAssetPath; + public const string TextureResourcesPath = TextureAssetPath; + public const string MaterialResourcesPath = "SmartReferenceTest/TestMat"; + public const string PrefabResourcesPath = PrefabAssetPath; + + public static void Setup() + { +#if UNITY_EDITOR + EnsureFolders(); + + // ScriptableObject asset + var so = ScriptableObject.CreateInstance(); + so.number = 123; + so.text = "hello"; + CreateOrReplaceAsset(so, SoAssetPath); + + // Texture asset + var tex = new Texture2D(16, 16, TextureFormat.RGBA32, false); + tex.name = "TestTex"; + // Fill a single pixel to avoid any "empty" edge cases + tex.SetPixel(0, 0, Color.white); + tex.Apply(); + CreateOrReplaceAsset(tex, TextureAssetPath); + + // Material asset + var mat = new Material(Shader.Find("Unlit/Color")); + mat.name = "TestMat"; + mat.color = Color.magenta; + CreateOrReplaceAsset(mat, MaterialAssetPath); + + // Prefab asset + var go = new GameObject("TestPrefab"); + go.AddComponent(); + CreateOrReplacePrefab(go, PrefabAssetPath); + Object.DestroyImmediate(go); + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); +#endif + } + + public static void Cleanup() + { +#if UNITY_EDITOR + if (AssetDatabase.IsValidFolder(RootFolder)) + { + AssetDatabase.DeleteAsset(RootFolder); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } +#endif + } + +#if UNITY_EDITOR + private static void EnsureFolders() + { + if (!AssetDatabase.IsValidFolder("Assets/SmartReference_PlayModeTestAssets")) + AssetDatabase.CreateFolder("Assets", "SmartReference_PlayModeTestAssets"); + + if (!AssetDatabase.IsValidFolder(ResourcesFolder)) + AssetDatabase.CreateFolder(RootFolder, "Resources"); + + if (!AssetDatabase.IsValidFolder(ResourcesSubFolder)) + AssetDatabase.CreateFolder(ResourcesFolder, "SmartReferenceTest"); + } + + private static void CreateOrReplaceAsset(Object asset, string assetPath) + { + var existing = AssetDatabase.LoadAssetAtPath(assetPath); + if (existing != null) + { + AssetDatabase.DeleteAsset(assetPath); + } + + AssetDatabase.CreateAsset(asset, assetPath); + } + + private static void CreateOrReplacePrefab(GameObject go, string prefabPath) + { + var existing = AssetDatabase.LoadAssetAtPath(prefabPath); + if (existing != null) + { + AssetDatabase.DeleteAsset(prefabPath); + } + + PrefabUtility.SaveAsPrefabAsset(go, prefabPath); + } +#endif + } + + /// + /// Test ScriptableObject type used in PlayMode. + /// Keeping it in test assembly is OK for editor PlayMode tests. + /// + public sealed class SmartReferenceTestSO : ScriptableObject + { + public int number; + public string text; + } +} diff --git a/Tests/Runtime/AssetSetup.cs.meta b/Tests/Runtime/AssetSetup.cs.meta new file mode 100644 index 0000000..494e075 --- /dev/null +++ b/Tests/Runtime/AssetSetup.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a2958f2eed9545bd9016df4783d41092 +timeCreated: 1766129489 \ No newline at end of file diff --git a/Tests/Runtime/PlayModeTests.cs b/Tests/Runtime/PlayModeTests.cs new file mode 100644 index 0000000..a682741 --- /dev/null +++ b/Tests/Runtime/PlayModeTests.cs @@ -0,0 +1,416 @@ +using System; +using System.Collections; +using System.Diagnostics; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +#if SMARTREFERENCE_UNITASK_SUPPORT +using System.Threading; +using System.Threading.Tasks; +using Cysharp.Threading.Tasks; +#endif + +namespace SmartReference.Runtime.Tests +{ + public sealed class PlayModeTests + { + [SetUp] + public void SetUp() + { + AssetSetup.Setup(); + + // Ensure we start from a known loader each test + global::SmartReference.Runtime.SmartReference.InitWithResourcesLoader(); + } + + [TearDown] + public void TearDown() + { + AssetSetup.Cleanup(); + } + + // ---------------------------- + // 1) Error/edge-case logging + // ---------------------------- + + [Test] + public void EmptyPath_LogsError_SyncLoad() + { + var r = new global::SmartReference.Runtime.SmartReference { path = "" }; + + LogAssert.Expect(LogType.Error, "[SmartReference] Asset path is null."); + _ = r.Value; + } + + [Test] + public void NullLoader_LogsError_SyncLoad() + { + // Force loader null via custom init + global::SmartReference.Runtime.SmartReference.InitWithCustomLoader(null); + + var r = new global::SmartReference.Runtime.SmartReference + { + path = AssetSetup.SoResourcesPath + }; + + LogAssert.Expect(LogType.Error, "[SmartReference] Loader is null, please init the SmartReference with a loader before using it."); + _ = r.Value; + } + + // ---------------------------- + // 2) Correctness - Resources sync load + // ---------------------------- + + [Test] + public void SyncLoad_ScriptableObject_CorrectData() + { + global::SmartReference.Runtime.SmartReference.InitWithResourcesLoader(); + + var r = new global::SmartReference.Runtime.SmartReference + { + path = AssetSetup.SoResourcesPath + }; + + var so = r.Value; + Assert.NotNull(so); + Assert.AreEqual(123, so.number); + Assert.AreEqual("hello", so.text); + } + + [Test] + public void SyncLoad_Texture_NotNull() + { + global::SmartReference.Runtime.SmartReference.InitWithResourcesLoader(); + + var r = new global::SmartReference.Runtime.SmartReference + { + path = AssetSetup.TextureResourcesPath + }; + + var tex = r.Value; + Assert.NotNull(tex); + Assert.AreEqual(16, tex.width); + Assert.AreEqual(16, tex.height); + } + + [Test] + public void SyncLoad_Prefab_NotNull() + { + global::SmartReference.Runtime.SmartReference.InitWithResourcesLoader(); + + var r = new global::SmartReference.Runtime.SmartReference + { + path = AssetSetup.PrefabResourcesPath + }; + + var prefab = r.Value; + Assert.NotNull(prefab); + Assert.AreEqual("TestPrefab", prefab.name); + } + + [Test] + public void ImplicitOperator_ReturnsValue() + { + var r = new global::SmartReference.Runtime.SmartReference + { + path = AssetSetup.SoResourcesPath + }; + + SmartReferenceTestSO so = r; // implicit + Assert.NotNull(so); + Assert.AreEqual(123, so.number); + } + + // ---------------------------- + // 3) Correctness - Resources async load (Task) + // ---------------------------- + + [UnityTest] + public IEnumerator AsyncLoad_Task_CompletesAndReturnsAsset() + { + SmartReference.InitWithResourcesLoader(); + + var r = new global::SmartReference.Runtime.SmartReference + { + path = AssetSetup.SoResourcesPath + }; + + var task = r.LoadAsyncTask(); + yield return WaitForTask(task); + + Assert.NotNull(task.Result); + Assert.AreEqual(123, task.Result.number); + } + +#if SMARTREFERENCE_UNITASK_SUPPORT + // ---------------------------- + // 4) Correctness - UniTask path + // ---------------------------- + + [UnityTest] + public IEnumerator AsyncLoad_UniTask_CompletesAndReturnsAsset() + { + var r = new SmartReference + { + path = AssetSetup.SoResourcesPath + }; + + var ut = r.LoadAsyncUniTask(); + yield return ut.ToCoroutine(result => + { + Assert.NotNull(result); + Assert.AreEqual(123, result.number); + }); + } + + [UnityTest] + public IEnumerator UniTask_ExternalCancellation_CancelsAwaitButLoadMayStillComplete() + { + // Cancellation here is "await-side" cancellation (AttachExternalCancellation). + var r = new SmartReference + { + path = AssetSetup.SoResourcesPath + }; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Exception capturedException = null; + + // IMPORTANT: yield the coroutine + yield return r + .LoadAsyncUniTask(cts.Token) + .ToCoroutine(exceptionHandler: ex => capturedException = ex); + + // Verify await-side cancellation + Assert.NotNull(capturedException); + Assert.IsInstanceOf(capturedException); + + // Let the underlying callback-based load finish. + yield return null; + yield return null; + + Assert.NotNull(r.Value); + } +#endif + + // ---------------------------- + // 5) In-flight sharing + release correctness using a stub loader + // ---------------------------- + + [UnityTest] + public IEnumerator InFlightSharing_Task_CallsLoaderOnce() + { + var stub = new StubLoader(delayFrames: 2); + global::SmartReference.Runtime.SmartReference.InitWithCustomLoader(stub); + + var r = new global::SmartReference.Runtime.SmartReference + { + path = "stub://so" + }; + + var t1 = r.LoadAsyncTask(); + var t2 = r.LoadAsyncTask(); + + // Both should share the same underlying load + Assert.AreEqual(1, stub.AsyncLoadCalls); + + yield return WaitForTask(t1); + yield return WaitForTask(t2); + + Assert.NotNull(t1.Result); + Assert.NotNull(t2.Result); + Assert.AreSame(t1.Result, t2.Result); + Assert.AreEqual(1, stub.AsyncLoadCalls); + } + + [UnityTest] + public IEnumerator Release_AfterLoaded_CallsRelease_AndAllowsReload() + { + var stub = new StubLoader(delayFrames: 1); + global::SmartReference.Runtime.SmartReference.InitWithCustomLoader(stub); + + var r = new global::SmartReference.Runtime.SmartReference { path = "stub://so" }; + + var t1 = r.LoadAsyncTask(); + yield return WaitForTask(t1); + Assert.NotNull(t1.Result); + + r.Release(); + Assert.AreEqual(1, stub.ReleaseCalls); + + // Reload should trigger a new load + var t2 = r.LoadAsyncTask(); + yield return WaitForTask(t2); + + Assert.NotNull(t2.Result); + Assert.AreEqual(2, stub.AsyncLoadCalls); + } + + [UnityTest] + public IEnumerator Release_DuringLoading_BestEffortCancel_ThenReleaseOnceCompleted() + { + var stub = new StubLoader(delayFrames: 3); + global::SmartReference.Runtime.SmartReference.InitWithCustomLoader(stub); + + var r = new global::SmartReference.Runtime.SmartReference { path = "stub://so" }; + var t = r.LoadAsyncTask(); + + // Immediately request release while loading + r.Release(); + + Assert.AreEqual(stub.CancelCalls, 1); + + yield return WaitForTask(t); + + Assert.AreEqual(stub.ReleaseCalls, 0); + } + + // ---------------------------- + // 6) Performance (PlayMode microbenchmarks) + // ---------------------------- + + [Test] + public void Performance_SyncValueAccess_AfterLoaded_IsFast() + { + // Micro: ensure Value property is cheap after load (no reload). + var r = new global::SmartReference.Runtime.SmartReference + { + path = AssetSetup.SoResourcesPath + }; + + // Warm-up load + var so = r.Value; + Assert.NotNull(so); + + // Measure repeated Value accesses + const int iterations = 200_000; + var sw = Stopwatch.StartNew(); + for (int i = 0; i < iterations; i++) + { + _ = r.Value; + } + sw.Stop(); + + // Very conservative threshold (editor machine variance is large). + // Adjust tighter for your CI/hardware once stable. + Assert.Less(sw.ElapsedMilliseconds, 200, $"Value access too slow: {sw.ElapsedMilliseconds}ms for {iterations} iterations."); + } + + // ---------------------------- + // Helpers + // ---------------------------- + + private static IEnumerator WaitForTask(System.Threading.Tasks.Task task) + { + while (!task.IsCompleted) + yield return null; + + if (task.IsFaulted) + throw task.Exception!; + } + + /// + /// Stub loader that simulates async loading + handle-based release/cancel. + /// This tests SmartReference's in-flight sharing & release semantics without depending on Resources internals. + /// + private sealed class StubLoader : ISmartReferenceLoader + { + private sealed class H : ISmartReferenceHandle + { + public bool canceled; + } + + private readonly int delayFrames; + public int LoadCalls { get; private set; } + public int AsyncLoadCalls { get; private set; } + public int ReleaseCalls { get; private set; } + public int CancelCalls { get; private set; } + + public StubLoader(int delayFrames) + { + this.delayFrames = Mathf.Max(0, delayFrames); + } + + public ISmartReferenceHandle Load(string path, Type type, out Object loadedObject) + { + LoadCalls++; + loadedObject = CreateObject(type); + return new H(); + } + + public global::SmartReference.Runtime.ISmartReferenceHandle LoadAsync(string path, Type type, Action onComplete) + { + AsyncLoadCalls++; + var h = new H(); + CoroutineRunner.Ensure().StartCoroutine(DelayedComplete(h, type, onComplete)); + return h; + } + + public void Release(global::SmartReference.Runtime.ISmartReferenceHandle handle) + { + ReleaseCalls++; + // In a real loader, you'd release handle/asset (Addressables.Release, etc.) + } + + public void Cancel(global::SmartReference.Runtime.ISmartReferenceHandle handle) + { + CancelCalls++; + if (handle is H h) h.canceled = true; + } + + private IEnumerator DelayedComplete(H h, Type type, Action onComplete) + { + for (int i = 0; i < delayFrames; i++) + yield return null; + + if (h.canceled) + { + onComplete?.Invoke(null); + yield break; + } + + onComplete?.Invoke(CreateObject(type)); + } + + private static Object CreateObject(Type type) + { + if (type == typeof(SmartReferenceTestSO) || typeof(ScriptableObject).IsAssignableFrom(type)) + { + var so = ScriptableObject.CreateInstance(); + so.number = 999; + so.text = "stub"; + return so; + } + + if (type == typeof(GameObject)) + { + return new GameObject("StubGO"); + } + + // Fallback: create a dummy ScriptableObject + var fallback = ScriptableObject.CreateInstance(); + fallback.number = 888; + fallback.text = "fallback"; + return fallback; + } + } + + private sealed class CoroutineRunner : MonoBehaviour + { + private static CoroutineRunner instance; + + public static CoroutineRunner Ensure() + { + if (instance != null) return instance; + + var go = new GameObject("SmartReference_TestCoroutineRunner"); + UnityEngine.Object.DontDestroyOnLoad(go); + instance = go.AddComponent(); + return instance; + } + } + } +} diff --git a/Tests/Runtime/PlayModeTests.cs.meta b/Tests/Runtime/PlayModeTests.cs.meta new file mode 100644 index 0000000..df143db --- /dev/null +++ b/Tests/Runtime/PlayModeTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1a31f33dc90d4af49eecbc4f9424856d +timeCreated: 1766130728 \ No newline at end of file diff --git a/Tests/Runtime/SmartReference.Runtime.Tests.asmdef b/Tests/Runtime/SmartReference.Runtime.Tests.asmdef new file mode 100644 index 0000000..d575cef --- /dev/null +++ b/Tests/Runtime/SmartReference.Runtime.Tests.asmdef @@ -0,0 +1,34 @@ +{ + "name": "SmartReference.Runtime.Tests", + "rootNamespace": "SmartReference.Runtime.Tests", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "SmartReference.Runtime", + "UniTask" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [ + { + "name": "com.unity.addressables", + "expression": "", + "define": "SMARTREFERENCE_ADDRESSABLES_SUPPORT" + }, + { + "name": "com.cysharp.unitask", + "expression": "", + "define": "SMARTREFERENCE_UNITASK_SUPPORT" + } + ], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Tests/Runtime/SmartReference.Runtime.Tests.asmdef.meta b/Tests/Runtime/SmartReference.Runtime.Tests.asmdef.meta new file mode 100644 index 0000000..56169d1 --- /dev/null +++ b/Tests/Runtime/SmartReference.Runtime.Tests.asmdef.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9fcb329a4a25436fb6cd281190c4cbe4 +timeCreated: 1766129148 \ No newline at end of file diff --git a/Tools~/DocFx/api.meta b/Tools~/DocFx/api.meta deleted file mode 100644 index 6a36e84..0000000 --- a/Tools~/DocFx/api.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 890fb3e5ef24439da1ddd66d530a16bf -timeCreated: 1686149018 \ No newline at end of file diff --git a/Tools~/DocFx/api/.gitignore b/Tools~/DocFx/api/.gitignore deleted file mode 100644 index e8079a3..0000000 --- a/Tools~/DocFx/api/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -############### -# temp file # -############### -*.yml -.manifest diff --git a/Tools~/DocFx/docfx.json b/Tools~/DocFx/docfx.json index 42b8ceb..d072875 100644 --- a/Tools~/DocFx/docfx.json +++ b/Tools~/DocFx/docfx.json @@ -1,92 +1,113 @@ { - "metadata": [ - { - "src": [ + "metadata": [ { - "src": "../../Runtime", - "files": [ - "**/*.cs" - ] + "src": [ + { + "src": "../../Runtime", + "files": [ + "**/*.cs" + ] + } + ], + "dest": "api", + "filter": "filterConfig.yml", + "disableGitFeatures": false, + "disableDefaultFilter": false, + "allowCompilationErrors": true, } - ], - "dest": "api", - "filter": "filterConfig.yml", - "disableGitFeatures": false, - "disableDefaultFilter": false - } - ], - "build": { - "globalMetadata": { - "_appTitle": "Smart Reference", - "_appFooter": "Smart Reference", - "_enableSearch": true - }, - "content": [ - { - "files": [ - "index.md", - "toc.yml" - ] - }, - { - "src": "api", - "files": [ - "*.yml" - ], - "dest": "api" - } - ], - "xref": [ - "https://normanderwan.github.io/UnityXrefMaps/xrefmap.yml" ], - "xrefService": [ - "https://xref.docs.microsoft.com/query?uid={uid}" - ], - "dest": "_site", - "globalMetadataFiles": [], - "fileMetadataFiles": [], - "template": ["default", "modern"], - "postProcessors": [], - "markdownEngineName": "markdig", - "noLangKeyword": false, - "keepFileLink": false, - "cleanupCacheHistory": true, - "disableGitFeatures": false - }, - "pdf": { - "content": [ - { - "files": [ - "index.md", - "toc.yml" - ] - }, - { - "src": "api", - "files": [ - "*.yml" + "build": { + "globalMetadata": { + "_appTitle": "Smart Reference", + "_appFooter": "Smart Reference", + "_enableSearch": true, + "pdf": true + }, + "content": [ + { + "files": [ + "index.md", + "toc.yml" + ] + }, + { + "src": "api", + "files": [ + "*.yml" + ], + "dest": "api" + }, + { + "src": "docs", + "files": [ + "*.yml", + "*.md" + ], + "dest": "docs" + } ], - "dest": "api" - } - ], - "xref": [ - "https://normanderwan.github.io/UnityXrefMaps/xrefmap.yml" - ], - "xrefService": [ - "https://xref.docs.microsoft.com/query?uid={uid}" - ], - "wkhtmltopdf": { - "additionalArguments": "--enable-local-file-access" + "xref": [ + "https://normanderwan.github.io/UnityXrefMaps/xrefmap.yml", + "https://docs.microsoft.com/dotnet/xrefmap.yml" + ], + "xrefService": [ + "https://xref.docs.microsoft.com/query?uid={uid}" + ], + "dest": "_site", + "globalMetadataFiles": [], + "fileMetadataFiles": [], + "template": [ + "default", + "modern" + ], + "markdownEngineName": "markdig", + "noLangKeyword": false, + "keepFileLink": false, + "cleanupCacheHistory": true, + "disableGitFeatures": false }, - "noStdin" : true, - "dest": "_site_pdf", - "globalMetadataFiles": [], - "fileMetadataFiles": [], - "postProcessors": [], - "markdownEngineName": "markdig", - "noLangKeyword": false, - "keepFileLink": false, - "cleanupCacheHistory": true, - "disableGitFeatures": false - } +// "pdf": { +// "content": [ +// { +// "files": [ +// "index.md", +// "toc.yml" +// ] +// }, +// { +// "src": "api", +// "files": [ +// "*.yml" +// ], +// "dest": "api" +// }, +// { +// "src": "docs", +// "files": [ +// "*.yml", +// "*.md" +// ], +// "dest": "docs" +// } +// ], +// "xref": [ +// "https://normanderwan.github.io/UnityXrefMaps/xrefmap.yml" +// ], +// "xrefService": [ +// "https://xref.docs.microsoft.com/query?uid={uid}" +// ], +// "wkhtmltopdf": { +// "additionalArguments": "--enable-local-file-access" +// }, +// "noStdin": true, +// "dest": "_site_pdf", +// "globalMetadataFiles": [], +// "fileMetadataFiles": [], +// "postProcessors": [], +// "markdownEngineName": "markdig", +// "noLangKeyword": false, +// "keepFileLink": false, +// "cleanupCacheHistory": true, +// "disableGitFeatures": false +// } } diff --git a/Tools~/DocFx/docs.meta b/Tools~/DocFx/docs.meta new file mode 100644 index 0000000..1b5a4c7 --- /dev/null +++ b/Tools~/DocFx/docs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c237ff1285e444eb96777c4fb6ff17f3 +timeCreated: 1766394312 \ No newline at end of file diff --git a/Tools~/DocFx/docs/custom_loader.md b/Tools~/DocFx/docs/custom_loader.md new file mode 100644 index 0000000..5b06594 --- /dev/null +++ b/Tools~/DocFx/docs/custom_loader.md @@ -0,0 +1,53 @@ +# Using Custom Loader + +## Write a Custom Loader +You can provide a custom loader to integrate Smart Reference with any asset source or loading pipeline. + +### Define the loader +```csharp +using SmartReference.Runtime; + +public class MyHandler : ISmartReferenceHandle +{ + // Implement the ISmartReferenceHandle interface members here +} + +public class MyLoader : ISmartReferenceLoader +{ + public ISmartReferenceHandle Load(string path, Type type, out Object loadedObject) + { + // Your custom synchronous load logic here + } + + public ISmartReferenceHandle LoadAsync(string path, Type type, Action callback) + { + // Your custom asynchronous load logic here + } + + public void Release(ISmartReferenceHandle handle) + { + // Your custom release logic here + } + + public void Cancel(ISmartReferenceHandle handle) + { + // Your custom cancel logic here + } +} +``` + +### Register the Loader +```csharp +using SmartReference.Runtime; + +SmartReference.InitWithCustomLoader(new MyLoader()); +``` + +There are 4 functions to implement: +- `Load`: Synchronously loads an asset from the given path. +- `LoadAsync`: Asynchronously loads an asset from the given path and invokes the callback when done. +- `Release`: Releases the loaded asset. +- `Cancel`: Cancels an ongoing asynchronous load operation. + +You also need to define a class that implements `ISmartReferenceHandle` to store your custom loading state. +The handle is only used to storing your data and passing to `Release` and `Cancel` functions that you implement. \ No newline at end of file diff --git a/Tools~/DocFx/docs/custom_loader.md.meta b/Tools~/DocFx/docs/custom_loader.md.meta new file mode 100644 index 0000000..2aa8ade --- /dev/null +++ b/Tools~/DocFx/docs/custom_loader.md.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a1a846e9d21c49c8a5ae4de0f1ac4c3e +timeCreated: 1766395097 \ No newline at end of file diff --git a/Tools~/DocFx/docs/init.md b/Tools~/DocFx/docs/init.md new file mode 100644 index 0000000..3e13b4f --- /dev/null +++ b/Tools~/DocFx/docs/init.md @@ -0,0 +1,86 @@ +# Initialization +Smart Reference requires a global initialization step to define how assets are resolved and loaded at runtime. + +During initialization, you need to register a loader implementation. +Smart Reference will delegate all synchronous loads, asynchronous loads, releases, and cancellations to this loader. + +Initialization must happen before any `SmartReference` is used. +--- + +## Init with Resources Loader +Initializes Smart Reference using Unity’s built-in Resources system. + +```csharp +using SmartReference.Runtime; + +SmartReference.InitWithResourcesLoader(); +``` + +Use this option only when all referenced assets are located inside a Resources folder. + +Note that all Resources assets are treated as a single bundle, meaning the entire Resources folder is loaded into memory at runtime. +Because of this limitation, this loader is best suited for small projects, tools, or prototypes. + +--- + +## Init with Addressables Loader +Initializes Smart Reference using Unity Addressables. + +```csharp +using SmartReference.Runtime; + +SmartReference.InitWithAddressablesLoader(); +``` + +This option is only available if you have the [Addressables](https://docs.unity3d.com/Packages/com.unity.addressables@2.7/manual/index.html) package installed in your project. +This is the recommended option for most production projects. +It supports asynchronous, non-blocking loading and integrates cleanly with Addressables’ reference counting and memory management. +It also enables advanced workflows such as remote content delivery, DLC, and asset bundles. + +--- + +## Init with Custom Loader +You can supply a custom loader to integrate Smart Reference with any asset source or loading pipeline. + +This is useful when you already have an existing asset management system or need specialized loading behavior. + +For implementation details, see [Using Custom Loader](custom_loader.md). + +--- + +## Where to Initialize +Initialization must be performed: +- Once per application lifetime +- Before any SmartReference is accessed + +If initialization is skipped, calling Load or LoadAsync will result in runtime errors. + +### Recommended: Runtime Initialization +```csharp +using UnityEngine; + +public static class SmartReferenceBootstrap +{ + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + static void Init() + { + SmartReference.InitWithAddressablesLoader(); + } +} +``` +This ensures Smart Reference is ready before any scene or script attempts to load assets. + +### Alternative: Bootstrap MonoBehaviour +```csharp +public class Bootstrap : MonoBehaviour +{ + void Awake() + { + SmartReference.InitWithAddressablesLoader(); + } +} +``` +This approach is acceptable if you already have a guaranteed first-loaded scene. + +## Reinitialization +Reinitialization is not supported because the smart reference handle used internally is different between loaders. diff --git a/Tools~/DocFx/docs/init.md.meta b/Tools~/DocFx/docs/init.md.meta new file mode 100644 index 0000000..f4b702d --- /dev/null +++ b/Tools~/DocFx/docs/init.md.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9213ee8c145e4d75a74824cb1dbed7a5 +timeCreated: 1766394323 \ No newline at end of file diff --git a/Tools~/DocFx/docs/loading_async.md b/Tools~/DocFx/docs/loading_async.md new file mode 100644 index 0000000..fd1dacb --- /dev/null +++ b/Tools~/DocFx/docs/loading_async.md @@ -0,0 +1,88 @@ +# Loading Assets Asynchronously + +Asynchronous loading allows assets to be loaded without blocking execution, helping maintain smooth frame rates and responsive gameplay. + +Smart Reference supports multiple async styles to fit different coding patterns and project needs. + +--- + +## With Event Callback +This approach uses an event-style callback and is suitable for fire-and-forget loading, such as UI elements or effects. + +```csharp +using UnityEngine; +using SmartReference.Runtime; + +public class CallbackExample : MonoBehaviour +{ + [SerializeField] private SmartReference reference; + + void Start() + { + reference.OnAsyncLoadComplete += OnLoaded; + reference.LoadAsync(); + } + + void OnLoaded(GameObject go) + { + Debug.Log("Reference loaded: " + go.name); + Instantiate(go); + } +} +``` +This style does not require `async/await` and works well when the load result is handled immediately. + +--- + +## With C# Async/Await +This approach integrates with standard C# `async/await` workflows and is suitable when composing multiple async operations using `Task`. + +```csharp +using UnityEngine; +using SmartReference.Runtime; +using System.Threading.Tasks; + +public class TaskExample : MonoBehaviour +{ + [SerializeField] private SmartReference prefab; + + private async Task LoadPrefabAsync() + { + var go = await prefab.LoadAsyncTask(); + return go; + } +} +``` +Avoid calling `.Result` or `.Wait()` on async methods, as this will block the main thread and defeats the purpose of asynchronous loading. + +--- + +## With UniTask +If your project uses UniTask, Smart Reference provides a zero-allocation async API optimized for performance-critical paths. + +```csharp +using UnityEngine; +using SmartReference.Runtime; +using Cysharp.Threading.Tasks; + +public class UniTaskExample : MonoBehaviour +{ + [SerializeField] private SmartReference reference; + + public async UniTaskVoid Spawn() + { + var asset = await reference.LoadAsyncUniTask(); + Instantiate(asset); + } +} +``` +This is the recommended async approach for gameplay code when UniTask is available. + +--- + +## Choosing an Async Style +- Event callback: Simple, lightweight, no `async/await` +- Task-based async: Integrates with standard .NET async flows +- UniTask: Best performance and lowest allocation cost + +All async methods delegate to the active loader. Cancellation and release behavior depend on the loader implementation. \ No newline at end of file diff --git a/Tools~/DocFx/docs/loading_async.md.meta b/Tools~/DocFx/docs/loading_async.md.meta new file mode 100644 index 0000000..20207b2 --- /dev/null +++ b/Tools~/DocFx/docs/loading_async.md.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3bb600c7f11a4bc2930c275507800fb9 +timeCreated: 1766394802 \ No newline at end of file diff --git a/Tools~/DocFx/docs/loading_sync.md b/Tools~/DocFx/docs/loading_sync.md new file mode 100644 index 0000000..ba68e74 --- /dev/null +++ b/Tools~/DocFx/docs/loading_sync.md @@ -0,0 +1,45 @@ +# Loading Assets Synchronously +Synchronous loading blocks execution until the referenced asset is fully loaded and available. + +This is the simplest loading method, but it should be used carefully, especially at runtime. + +--- + +## Basic Sync Load + +```csharp +using UnityEngine; +using SmartReference.Runtime; + +public class SyncLoadExample : MonoBehaviour +{ + [SerializeField] private SmartReference icon; + [SerializeField] private SmartReference prefab; + + void Start() + { + var texture = icon.Load(); + + var instance = Instantiate(prefab); + } +} +``` + +When Load() is called, Smart Reference delegates the request to the active loader and blocks until the asset is available. + +## When to Use Synchronous Loading +Synchronous loading is appropriate when loading cost is negligible or when blocking is acceptable. Typical use cases include early initialization code, and small assets such as icons or configuration data. + +For larger assets or frequently executed code paths, asynchronous loading is strongly recommended to avoid frame stalls and maintain responsiveness. + +## Loader Behavior +The exact behavior of synchronous loading depends on the active loader: +- The Resources loader performs a direct `Resources.Load`. +- The Addressables loader calls `handle.WaitForCompletion()` internally to block while waiting for an async operation. +- Custom loaders define their own synchronous behavior. + +## WebGL Considerations +WebGL runs on a single-threaded execution model. +When using the Addressables loader, synchronous loading is not supported on WebGL platforms and will return an empty load result. + +For WebGL builds, always use asynchronous loading. diff --git a/Tools~/DocFx/docs/loading_sync.md.meta b/Tools~/DocFx/docs/loading_sync.md.meta new file mode 100644 index 0000000..e63d376 --- /dev/null +++ b/Tools~/DocFx/docs/loading_sync.md.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9e232b08ba854135893135c6ade35028 +timeCreated: 1766394714 \ No newline at end of file diff --git a/Tools~/DocFx/docs/releasing.md b/Tools~/DocFx/docs/releasing.md new file mode 100644 index 0000000..7b709f8 --- /dev/null +++ b/Tools~/DocFx/docs/releasing.md @@ -0,0 +1,31 @@ +# Releasing Assets +Assets loaded through Smart Reference should be explicitly released when they are no longer needed. +Releasing allows the active loader to correctly manage memory, reference counts, and underlying resources. + +Failing to release assets may lead to increased memory usage and memory leaks. + +## Example +```csharp +private void UnloadCurrent() +{ + prefabReference.Release(); +} +``` +Calling `Release()` notifies the loader that the asset is no longer in use. +The next time you use this asset reference, it will trigger a new load operation. + +## When to Release +You should release assets when they are no longer required, such as: +- When unloading or switching scenes +- When destroying an object that owns the reference +- During explicit cleanup or teardown logic + +In general, every load should have a corresponding release at the appropriate lifecycle point, except for assets that are intended to remain loaded for the entire lifetime of the game. + +## Loader Specific Behavior +The effect of `Release()` depends on the active loader: +- Resources loader: Internally it calls `Resources.UnloadAsset(asset)`. +- Addressables loader: If the handle is valid it will call `Addressables.Release(handle)`. +- Custom loader: Release behavior is fully defined by your implementation + +Smart Reference itself does not decide when assets are unloaded, you need to explicitly call `Release()` to trigger the process. \ No newline at end of file diff --git a/Tools~/DocFx/docs/releasing.md.meta b/Tools~/DocFx/docs/releasing.md.meta new file mode 100644 index 0000000..416c333 --- /dev/null +++ b/Tools~/DocFx/docs/releasing.md.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e5d83f95cfc348429112a04c243b4d55 +timeCreated: 1766468114 \ No newline at end of file diff --git a/Tools~/DocFx/docs/toc.yml b/Tools~/DocFx/docs/toc.yml new file mode 100644 index 0000000..84902bd --- /dev/null +++ b/Tools~/DocFx/docs/toc.yml @@ -0,0 +1,16 @@ +items: + - name: Initialization + href: init.md + + - name: Loading Assets + items: + - name: Synchronous Loading + href: loading_sync.md + - name: Asynchronous Loading + href: loading_async.md + + - name: Releasing Assets + href: releasing.md + + - name: Custom Loader + href: custom_loader.md \ No newline at end of file diff --git a/Tools~/DocFx/docs/toc.yml.meta b/Tools~/DocFx/docs/toc.yml.meta new file mode 100644 index 0000000..103ee05 --- /dev/null +++ b/Tools~/DocFx/docs/toc.yml.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1c4f732fd22c4196b0618d1449d09b88 +timeCreated: 1766403779 \ No newline at end of file diff --git a/Tools~/DocFx/index.md b/Tools~/DocFx/index.md index c673c26..793af80 100644 --- a/Tools~/DocFx/index.md +++ b/Tools~/DocFx/index.md @@ -1,33 +1,92 @@ # Smart Reference -#### version 0.7.0 +#### version 2.0.0 + +[![DocFX Build and Publish](https://github.com/Brian-Jiang/SmartReference/actions/workflows/docfx-build-publish.yml/badge.svg?branch=main)](https://github.com/Brian-Jiang/SmartReference/actions/workflows/docfx-build-publish.yml) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Brian-Jiang/SmartReference?label=github&style=flat-square)](https://github.com/Brian-Jiang/SmartReference/releases) +[![openupm](https://img.shields.io/npm/v/com.xiaojiang.smartreference?label=openupm®istry_uri=https://package.openupm.com)](https://openupm.com/packages/com.xiaojiang.smartreference/) +[![Unity Asset Store](https://img.shields.io/badge/Unity%20Asset%20Store-Smart%20Reference-blue.svg)](https://u3d.as/35Sh) + +--- ## Summary -Smart Reference is a Unity plugin that allows you to lazy load references to other objects in ScriptableObject and MonoBehaviour. -People use ScriptableObject to store data, but when you reference other objects, they will be treated as dependencies and will be loaded when the ScriptableObject is loaded. -This could be slow if you have a lot of references. Smart Reference allows you to load references only when you need them at runtime with same workflow in editor. +Smart Reference is a Unity plugin that enables lazy loading asset references in `ScriptableObject` and `MonoBehaviour`. +In Unity, referencing assets directly creates implicit dependencies. +When the `MonoBehaviour` or `ScriptableObject` is loaded, all referenced assets are loaded immediately, which can lead to slow startup times and unnecessary memory usage, especially in data driven projects. + +Smart Reference solves this by allowing you to reference assets without loading them upfront, while keeping the same editor workflow. +Assets are only loaded when you use them at runtime. ## Quick Start -1. Use SmartReference instead of Object. See how they look exactly same in the inspector. +### 1. Replace direct Unity references with `SmartReference` +In the Inspector, SmartReference looks and behaves like a normal object reference. +```csharp +using UnityEngine; +using SmartReference.Runtime; + +public class MonsterData : ScriptableObject +{ + public SmartReference prefab; + public SmartReference icon; + public string description; +} + +``` + +### 2. Initialize Smart Reference at startup +Choose the loader that matches your project setup. +#### Addressables (recommended) ```csharp - public class MonsterData : ScriptableObject { - public SmartReference prefab; - public SmartReference icon; - public string description; - } +using SmartReference.Runtime; + +SmartReference.InitWithAddressablesLoader(); ``` -2. In your start up script, call SmartReference.Init() to initialize the SmartReference system with your load and async load functions. +#### Resources ```csharp - SmartReference.Runtime.SmartReference.Init((path, type) => { - return MyLoadFunction.Load(path, type); - }, (path, type, callback) => { - MyLoadFunction.LoadAsync(path, type, obj => { - callback(obj); - }); - }); +using SmartReference.Runtime; + +SmartReference.InitWithResourcesLoader(); +``` + +#### Custom Loader +```csharp +SmartReference.InitWithCustomLoader(new MyCustomLoader()); +``` + +Initialization must happen once, before any `SmartReference` is accessed. + +### 3. Load assets when needed +#### Asynchronous Loading with UniTask (recommended) +```csharp +var prefab = await monsterData.prefab.LoadAsyncUniTask(); +Instantiate(prefab); +``` + +#### Asynchronous Loading with C# Async/Await +```csharp +var prefab = await monsterData.prefab.LoadAsyncTask(); +Instantiate(prefab); +``` + +#### Asynchronous Loading with Callbacks +```csharp +monsterData.prefab.OnAsyncLoadComplete += go => +{ + Instantiate(go); +}; +monsterData.prefab.LoadAsync(); +``` + +#### Synchronous Loading +```csharp +var prefab = monsterData.prefab.Load(); +Instantiate(prefab); ``` ## Supports -If you have any questions, please comment at [Asset Store](https://u3d.as/35Sh) -Or email me directly at: [bjjx1999@live.com](mailto:bjjx1999@live.com) +If you have questions or feedback: +- Leave an issue at [GitHub Issues](https://github.com/Brian-Jiang/SmartReference/issues) +- Leave a comment at [Asset Store](https://u3d.as/35Sh) +- Email: [bjjx1999@live.com](mailto:bjjx1999@live.com) + Thank you for your support! \ No newline at end of file diff --git a/Tools~/DocFx/toc.yml b/Tools~/DocFx/toc.yml index b401f9d..b4c158e 100644 --- a/Tools~/DocFx/toc.yml +++ b/Tools~/DocFx/toc.yml @@ -1,5 +1,9 @@ items: -- name: Quick Start - href: index.md -- name: API Documentation - href: api/ + - name: Quick Start + href: index.md + + - name: Manual + href: docs/ + + - name: API Documentation + href: api/ \ No newline at end of file diff --git a/package.json b/package.json index efb34cc..f6ce8e6 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "com.xiaojiang.smartreference", "displayName": "Smart Reference", - "version": "1.0.0", + "version": "2.0.0", "unity": "2021.3", "license": "MIT", "author": { "name": "Xiao Jiang", - "email": "bjjx1999@live.com" + "email": "bjjx1999@live.com", + "url": "https://xiaojiangbrian.com/" }, - "description": "Smart Reference is a Unity plugin that allows you to lazy load references to other objects in ScriptableObject and MonoBehaviour.", + "description": "Smart Reference lets you lazily load asset references in Unity ScriptableObject and MonoBehaviour, reducing unnecessary loading and memory usage.", "documentationUrl": "https://brian-jiang.github.io/SmartReference/", "samples": [ { @@ -25,6 +26,8 @@ "lazy", "load", "smart", - "smartreference" + "smartreference", + "memory", + "optimize" ] } \ No newline at end of file