From 7c639b40c31619dfae8a89a9957840557202bc8d Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Fri, 19 Dec 2025 15:15:02 +0800 Subject: [PATCH 01/18] Assest release & async loading --- Editor/SceneReferenceEditor.cs | 12 +- Editor/SmartReference.Editor.asmdef | 2 +- Editor/SmartReferenceEditor.cs | 9 +- Editor/SmartReferencePreBuildProcessor.cs | 6 +- Editor/SmartReferenceUtils.cs | 22 +- Runtime/Handles.meta | 3 + Runtime/Handles/AddressablesHandle.cs | 10 + Runtime/Handles/AddressablesHandle.cs.meta | 3 + Runtime/Handles/ISmartReferenceHandle.cs | 7 + Runtime/Handles/ISmartReferenceHandle.cs.meta | 3 + Runtime/Handles/ResourcesHandle.cs | 7 + Runtime/Handles/ResourcesHandle.cs.meta | 3 + Runtime/Loaders/AddressablesLoader.cs | 35 +- Runtime/Loaders/CustomLoader.cs | 20 -- Runtime/Loaders/CustomLoader.cs.meta | 3 - Runtime/Loaders/ISmartReferenceLoader.cs | 13 +- Runtime/Loaders/ResourcesLoader.cs | 34 +- Runtime/SceneReference.cs | 15 +- Runtime/SmartReference.Runtime.asmdef | 12 +- Runtime/SmartReference.cs | 314 +++++++++++++++--- 20 files changed, 430 insertions(+), 103 deletions(-) create mode 100644 Runtime/Handles.meta create mode 100644 Runtime/Handles/AddressablesHandle.cs create mode 100644 Runtime/Handles/AddressablesHandle.cs.meta create mode 100644 Runtime/Handles/ISmartReferenceHandle.cs create mode 100644 Runtime/Handles/ISmartReferenceHandle.cs.meta create mode 100644 Runtime/Handles/ResourcesHandle.cs create mode 100644 Runtime/Handles/ResourcesHandle.cs.meta delete mode 100644 Runtime/Loaders/CustomLoader.cs delete mode 100644 Runtime/Loaders/CustomLoader.cs.meta 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..dc3e48b 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; 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/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/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..e4a4701 --- /dev/null +++ b/Runtime/Handles/AddressablesHandle.cs @@ -0,0 +1,10 @@ +using UnityEngine.ResourceManagement.AsyncOperations; + +namespace SmartReference.Runtime +{ + public class AddressablesHandle : ISmartReferenceHandle + { + public AsyncOperationHandle Op; + public bool IsValid => Op.IsValid(); + } +} \ 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..c507f27 --- /dev/null +++ b/Runtime/Handles/ISmartReferenceHandle.cs @@ -0,0 +1,7 @@ +namespace SmartReference.Runtime +{ + 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..d4fbc52 --- /dev/null +++ b/Runtime/Handles/ResourcesHandle.cs @@ -0,0 +1,7 @@ +namespace SmartReference.Runtime +{ + public class ResourcesHandle : ISmartReferenceHandle + { + + } +} \ 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..148e6f5 100644 --- a/Runtime/Loaders/AddressablesLoader.cs +++ b/Runtime/Loaders/AddressablesLoader.cs @@ -1,11 +1,12 @@ -#if USE_UNITY_ADDRESSABLES +#if SMARTREFERENCE_ADDRESSABLES_SUPPORT using System; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; using Object = UnityEngine.Object; -namespace SmartReference.Runtime { +namespace SmartReference.Runtime +{ public class AddressablesLoader: ISmartReferenceLoader { public Object Load(string path, Type type) { Object result = null; @@ -22,13 +23,41 @@ public Object Load(string path, Type type) { return result; } - 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); } }; + var h = new AddressablesHandle { Op = handle }; + return h; + } + + public void Release(ISmartReferenceHandle handle, UnityEngine.Object asset) + { + if (handle is AddressablesHandle ah && ah.IsValid) + { + Addressables.Release(ah.Op); + return; + } + + // fallback (less ideal): if someone passed only asset + if (asset != null) + { + Addressables.Release(asset); + } + } + + public void Cancel(ISmartReferenceHandle handle) + { + // Addressables doesn't truly "cancel" loads the same way; you can release handle + // but behavior depends on ref counting/state. We'll best-effort release op if valid. + if (handle is AddressablesHandle ah && ah.IsValid) + { + Addressables.Release(ah.Op); + } } } } 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..5c3816b 100644 --- a/Runtime/Loaders/ISmartReferenceLoader.cs +++ b/Runtime/Loaders/ISmartReferenceLoader.cs @@ -4,6 +4,17 @@ namespace SmartReference.Runtime { public interface ISmartReferenceLoader { public Object Load(string path, Type type); - public void LoadAsync(string path, Type type, Action callback); + public ISmartReferenceHandle LoadAsync(string path, Type type, Action callback); + + /// + /// Release an asset/handle created by this loader. + /// If handle is null, loader may optionally attempt to release by asset reference. + /// + void Release(ISmartReferenceHandle handle, UnityEngine.Object asset); + + /// + /// Optional: try cancel an in-flight load. If unsupported, no-op. + /// + void Cancel(ISmartReferenceHandle handle); } } \ No newline at end of file diff --git a/Runtime/Loaders/ResourcesLoader.cs b/Runtime/Loaders/ResourcesLoader.cs index 39c1bd5..0338869 100644 --- a/Runtime/Loaders/ResourcesLoader.cs +++ b/Runtime/Loaders/ResourcesLoader.cs @@ -2,20 +2,42 @@ 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 Object Load(string path, Type type) + { var resourcesPath = GetResourcesPath(path); return Resources.Load(resourcesPath, type); } - 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(); } - - private string GetResourcesPath(string path) { + + public void Release(ISmartReferenceHandle handle, UnityEngine.Object asset) + { + // Best effort: only safe for certain asset types loaded from Resources. + if (asset != null) + { + try { Resources.UnloadAsset(asset); } + catch { /* ignore */ } + } + } + + public void Cancel(ISmartReferenceHandle handle) + { + // Resources load cannot be cancelled; no-op. + } + + // todo fix resources path + 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"); 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..9bc924f 100644 --- a/Runtime/SmartReference.cs +++ b/Runtime/SmartReference.cs @@ -1,59 +1,78 @@ using System; +using System.Threading; +using System.Threading.Tasks; using UnityEngine; using Object = UnityEngine.Object; + +#if SMARTREFERENCE_UNITASK_SUPPORT +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. - /// - /// 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, - }; + 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; + + // public event Action OnAsyncLoadComplete; + + [NonSerialized] private bool isLoading; + [NonSerialized] private TaskCompletionSource inFlightTaskTcs; +#if SMARTREFERENCE_UNITASK_SUPPORT + [NonSerialized] private UniTaskCompletionSource inFlightUniTaskTcs; +#endif + + // NEW: track loader handle for release/cancel. + [NonSerialized] private ISmartReferenceHandle handle; + + // NEW: if disposed while loading, we’ll release after completion. + [NonSerialized] private bool releaseRequested; + + public bool IsLoaded => value != null; + public bool IsLoading => isLoading; + /// /// Get the asset. If the asset is not loaded, it will be loaded automatically. @@ -76,37 +95,246 @@ public static implicit operator T(SmartReference reference) { /// 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; + if (string.IsNullOrEmpty(path)) + { + LogEmptyAssetError(); + return; + } - if (loader == null) { - Debug.LogError($"[SmartReference] loader is null, path: {path}"); + if (Loader == null) { + LogEmptyLoaderError(); return; } - value = (T) loader.Load(path, typeof(T)); + value = (T) Loader.Load(path, typeof(T)); if (value == null) { - Debug.LogWarning($"[SmartReference] load failed, path: {path}"); + LogLoadAssetNullError(); } } /// /// Call this method to load the asset asynchronously. Useful if you want to preload the asset. /// - 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) + { + // OnAsyncLoadComplete?.Invoke(value); 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 (caller can subscribe to event or await task/unitask). + if (isLoading) return; + + isLoading = true; + releaseRequested = false; + handle = Loader.LoadAsync(path, typeof(T), OnAsyncLoadCompleteCallback); + } + + /// + /// Load the asset asynchronously with 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); + } + + // Share the same in-flight request. + if (inFlightTaskTcs != null) + { + return inFlightTaskTcs.Task; + } + + inFlightTaskTcs = new TaskCompletionSource(); + LoadAsync(); // will complete TCS via CompleteInFlight(...) + 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 + cancellation token. + /// Note: since your loader is callback-based, cancellation here only cancels the awaiting side. + /// If you want true cancel (e.g., Addressables handle release), extend ISmartReferenceLoader to support it. + /// + 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); + } + + // Share in-flight request. + if (inFlightUniTaskTcs != null) + { + return inFlightUniTaskTcs.Task.AttachExternalCancellation(cancellationToken); + } + + inFlightUniTaskTcs = new UniTaskCompletionSource(); + LoadAsync(); // will complete UniTask TCS via CompleteInFlight(...) + return inFlightUniTaskTcs.Task.AttachExternalCancellation(cancellationToken); + } + +#endif + + /// + /// NEW: Release/unload the loaded asset (and any loader handle). + /// Safe to call multiple times. + /// If called during loading, attempts cancel + releases upon completion (best-effort). + /// + public void Release() + { + // if no loader, just clear references + if (Loader == null) + { + LogEmptyLoaderError(); + return; + } + + // If loading, request release; try cancel if supported. + if (isLoading) + { + releaseRequested = true; + try + { + Loader.Cancel(handle); + } + catch + { + // ignore - cancel may not be supported + } + return; + } + + if (value == null && handle == null) + { + return; + } + + try + { + Loader.Release(handle, value); + } + catch (Exception e) + { + Debug.LogWarning($"[SmartReference] Release failed for path: {path}. Exception: {e}"); + } + finally + { + value = null; + handle = null; + releaseRequested = false; + } + } + + private void CompleteInFlight(T 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) + { + LogLoadAssetNullError(); + CompleteInFlight(null); + + // If we were asked to release while loading, clean up handle too. + if (releaseRequested) + { + Release(); } - value = (T) obj; - }); + return; + } + + value = (T) obj; + // OnAsyncLoadComplete?.Invoke(value); + CompleteInFlight(value); + + // If Dispose/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() { From 0e58eb35908f01fee7be8e40f09b523fa0f20ee3 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Fri, 19 Dec 2025 17:32:35 +0800 Subject: [PATCH 02/18] Add tools & tests --- Editor/SmartReferenceEditor.cs | 26 +- Editor/SmartReferenceTool.cs | 77 ++++ Editor/SmartReferenceTool.cs.meta | 3 + Runtime/Handles/AddressablesHandle.cs | 8 +- Runtime/Loaders/ISmartReferenceLoader.cs | 20 +- Runtime/Loaders/ResourcesLoader.cs | 74 +++- Runtime/SmartReference.cs | 6 +- Tests.meta | 3 + Tests/Runtime.meta | 3 + Tests/Runtime/AssetSetup.cs | 123 ++++++ Tests/Runtime/AssetSetup.cs.meta | 3 + Tests/Runtime/PlayModeTests.cs | 416 ++++++++++++++++++ Tests/Runtime/PlayModeTests.cs.meta | 3 + .../SmartReference.Runtime.Tests.asmdef | 33 ++ .../SmartReference.Runtime.Tests.asmdef.meta | 3 + 15 files changed, 768 insertions(+), 33 deletions(-) create mode 100644 Editor/SmartReferenceTool.cs create mode 100644 Editor/SmartReferenceTool.cs.meta create mode 100644 Tests.meta create mode 100644 Tests/Runtime.meta create mode 100644 Tests/Runtime/AssetSetup.cs create mode 100644 Tests/Runtime/AssetSetup.cs.meta create mode 100644 Tests/Runtime/PlayModeTests.cs create mode 100644 Tests/Runtime/PlayModeTests.cs.meta create mode 100644 Tests/Runtime/SmartReference.Runtime.Tests.asmdef create mode 100644 Tests/Runtime/SmartReference.Runtime.Tests.asmdef.meta diff --git a/Editor/SmartReferenceEditor.cs b/Editor/SmartReferenceEditor.cs index dc3e48b..f94fea0 100644 --- a/Editor/SmartReferenceEditor.cs +++ b/Editor/SmartReferenceEditor.cs @@ -41,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/SmartReferenceTool.cs b/Editor/SmartReferenceTool.cs new file mode 100644 index 0000000..3f5fff1 --- /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); + } + + 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.FindPropertyRelative("type").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}"); + + 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/Runtime/Handles/AddressablesHandle.cs b/Runtime/Handles/AddressablesHandle.cs index e4a4701..e0dfa3b 100644 --- a/Runtime/Handles/AddressablesHandle.cs +++ b/Runtime/Handles/AddressablesHandle.cs @@ -1,4 +1,6 @@ -using UnityEngine.ResourceManagement.AsyncOperations; +#if SMARTREFERENCE_ADDRESSABLES_SUPPORT + +using UnityEngine.ResourceManagement.AsyncOperations; namespace SmartReference.Runtime { @@ -7,4 +9,6 @@ public class AddressablesHandle : ISmartReferenceHandle public AsyncOperationHandle Op; public bool IsValid => Op.IsValid(); } -} \ No newline at end of file +} + +#endif \ No newline at end of file diff --git a/Runtime/Loaders/ISmartReferenceLoader.cs b/Runtime/Loaders/ISmartReferenceLoader.cs index 5c3816b..c3595d7 100644 --- a/Runtime/Loaders/ISmartReferenceLoader.cs +++ b/Runtime/Loaders/ISmartReferenceLoader.cs @@ -1,9 +1,25 @@ using System; using Object = UnityEngine.Object; -namespace SmartReference.Runtime { - public interface ISmartReferenceLoader { +namespace SmartReference.Runtime +{ + public interface ISmartReferenceLoader + { + /// + /// Load an asset synchronously. + /// + /// The path of the asset, begin with `Assets/` + /// The type of the asset. + /// public Object Load(string path, Type type); + + /// + /// Load an asset asynchronously. + /// + /// + /// he type of the asset. + /// + /// public ISmartReferenceHandle LoadAsync(string path, Type type, Action callback); /// diff --git a/Runtime/Loaders/ResourcesLoader.cs b/Runtime/Loaders/ResourcesLoader.cs index 0338869..89f8368 100644 --- a/Runtime/Loaders/ResourcesLoader.cs +++ b/Runtime/Loaders/ResourcesLoader.cs @@ -35,17 +35,75 @@ public void Cancel(ISmartReferenceHandle handle) // Resources load cannot be cancelled; no-op. } - // todo fix resources path - private string GetResourcesPath(string path) + private static string GetResourcesPath(string path) +{ + if (string.IsNullOrEmpty(path)) + return path; + + // Normalize slashes so Windows paths work. + var p = path.Replace('\\', '/'); + + // If caller already passed a Resources-relative path (common), we can accept it. + // (We still strip extension if present.) + // Example: "UI/Icons/MyIcon.png" => "UI/Icons/MyIcon" + // But we should prefer detecting an actual "/Resources/" segment first. + + // Find the LAST "/Resources/" segment, so nested Resources works: + // "Assets/A/Resources/X.prefab" and "Assets/A/Resources/Sub/Resources/Y.prefab" + // should both load correctly (use the deepest Resources root). + const string segment = "/Resources/"; + int idx = p.LastIndexOf(segment, StringComparison.OrdinalIgnoreCase); + + // Also handle when path contains ".../Resources" with no trailing slash (rare but possible). + if (idx < 0) + { + const string segmentNoSlash = "/Resources"; + int idx2 = p.LastIndexOf(segmentNoSlash, StringComparison.OrdinalIgnoreCase); + if (idx2 >= 0) { - var index = path.LastIndexOf("Resources/", StringComparison.Ordinal); - if (index == -1) { - Debug.LogError($"[SmartReference] ResourcesLoader: Path {path} is not in Resources folder"); - return path; + // Ensure it's a folder boundary: next char is '/' or end-of-string. + int next = idx2 + segmentNoSlash.Length; + if (next == p.Length) + { + // Path points to the Resources folder itself (not loadable). + Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); + return StripExtension(p); } + if (p[next] == '/') + idx = idx2; // treat as "/Resources/" + } + } - var extensionIndex = path.LastIndexOf(".", StringComparison.Ordinal); - return path[(index + "Resources/".Length)..extensionIndex]; + string relative; + if (idx >= 0) + { + int start = idx + segment.Length; // after "/Resources/" + if (start >= p.Length) + { + Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); + return StripExtension(p); } + + relative = p.Substring(start); + } + else + { + // No Resources segment found. Best effort: assume it's already relative. + Debug.LogWarning($"[SmartReference] ResourcesLoader: Path '{path}' does not contain a Resources folder segment. Assuming it's already Resources-relative."); + relative = p; + } + + return StripExtension(relative); + + static string StripExtension(string s) + { + // Remove extension only if it's after the last slash. + int slash = s.LastIndexOf('/'); + int dot = s.LastIndexOf('.'); + if (dot > slash) return s.Substring(0, dot); + return s; + } +} + } } \ No newline at end of file diff --git a/Runtime/SmartReference.cs b/Runtime/SmartReference.cs index 9bc924f..0bd3db1 100644 --- a/Runtime/SmartReference.cs +++ b/Runtime/SmartReference.cs @@ -299,15 +299,15 @@ private void OnAsyncLoadCompleteCallback(Object obj) isLoading = false; if (obj == null) { - LogLoadAssetNullError(); CompleteInFlight(null); - - // If we were asked to release while loading, clean up handle too. if (releaseRequested) { Release(); + return; } + LogLoadAssetNullError(); + return; } 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/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..af7f66c --- /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 = "SmartReferenceTest/TestSO"; + public const string TextureResourcesPath = "SmartReferenceTest/TestTex"; + public const string MaterialResourcesPath = "SmartReferenceTest/TestMat"; + public const string PrefabResourcesPath = "SmartReferenceTest/TestPrefab"; + + 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..4e0ea65 --- /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 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() + { + 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.Runtime.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.Runtime.SmartReference + { + path = AssetSetup.SoResourcesPath + }; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var canceled = false; + try + { + yield return r.LoadAsyncUniTask(cts.Token).ToCoroutine(_ => { }); + } + catch (OperationCanceledException) + { + canceled = true; + } + + Assert.True(canceled); + + // Let the underlying callback-based load finish. + yield return null; + yield return null; + + // Asset may have loaded anyway (by design with callback-based loaders). + 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(); + + // Depending on your implementation, cancel may be called (best-effort) + Assert.GreaterOrEqual(stub.CancelCalls, 0); + + yield return WaitForTask(t); + + // After completion, release should have been performed + // (Your SmartReference.Release() logic might release immediately or defer until callback.) + Assert.GreaterOrEqual(stub.ReleaseCalls, 1); + } + + // ---------------------------- + // 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 Object Load(string path, Type type) + { + LoadCalls++; + return CreateObject(type); + } + + 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, Object asset) + { + 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..412441b --- /dev/null +++ b/Tests/Runtime/SmartReference.Runtime.Tests.asmdef @@ -0,0 +1,33 @@ +{ + "name": "SmartReference.Runtime.Tests", + "rootNamespace": "SmartReference.Runtime.Tests", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "SmartReference.Runtime" + ], + "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 From a2ae24e8ae33e7f7b52882d5b63b280521deffd0 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Fri, 19 Dec 2025 23:44:27 +0800 Subject: [PATCH 03/18] Edit mode tests --- Editor/SmartReferenceTool.cs | 1 - Tests/Editor.meta | 3 + Tests/Editor/EditModeTests.cs | 278 ++++++++++++++++++ Tests/Editor/EditModeTests.cs.meta | 3 + .../Editor/SmartReference.Editor.Tests.asmdef | 36 +++ .../SmartReference.Editor.Tests.asmdef.meta | 3 + 6 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 Tests/Editor.meta create mode 100644 Tests/Editor/EditModeTests.cs create mode 100644 Tests/Editor/EditModeTests.cs.meta create mode 100644 Tests/Editor/SmartReference.Editor.Tests.asmdef create mode 100644 Tests/Editor/SmartReference.Editor.Tests.asmdef.meta diff --git a/Editor/SmartReferenceTool.cs b/Editor/SmartReferenceTool.cs index 3f5fff1..b76f291 100644 --- a/Editor/SmartReferenceTool.cs +++ b/Editor/SmartReferenceTool.cs @@ -53,7 +53,6 @@ public static void ClearReference(SerializedProperty smartReferenceProperty) smartReferenceProperty.FindPropertyRelative("guid").stringValue = string.Empty; smartReferenceProperty.FindPropertyRelative("fileID").longValue = 0; smartReferenceProperty.FindPropertyRelative("path").stringValue = string.Empty; - smartReferenceProperty.FindPropertyRelative("type").stringValue = string.Empty; smartReferenceProperty.serializedObject.ApplyModifiedProperties(); } 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..c1ed0af --- /dev/null +++ b/Tests/Editor/EditModeTests.cs @@ -0,0 +1,278 @@ +// 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(); + + 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 does not look like a SmartReference", 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 From d0a2a2abecad08fc685912ae8c6aceaf76cff459 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Sat, 20 Dec 2025 12:08:27 +0800 Subject: [PATCH 04/18] Fix clear reference --- Editor/SmartReferenceTool.cs | 3 ++- Tests/Editor/EditModeTests.cs | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Editor/SmartReferenceTool.cs b/Editor/SmartReferenceTool.cs index b76f291..a01eda1 100644 --- a/Editor/SmartReferenceTool.cs +++ b/Editor/SmartReferenceTool.cs @@ -16,6 +16,7 @@ public static void SetReference(SerializedProperty smartReferenceProperty, Objec if (obj == null) { ClearReference(smartReferenceProperty); + return; } EnsureIsSmartReferenceProperty(smartReferenceProperty); @@ -62,7 +63,7 @@ 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}"); + throw new ArgumentException($"Property is not a serialized object/struct: {p.propertyPath}, type: {p.propertyType}"); if (p.FindPropertyRelative("guid") == null || p.FindPropertyRelative("fileID") == null || diff --git a/Tests/Editor/EditModeTests.cs b/Tests/Editor/EditModeTests.cs index c1ed0af..18c43cd 100644 --- a/Tests/Editor/EditModeTests.cs +++ b/Tests/Editor/EditModeTests.cs @@ -109,9 +109,10 @@ public void SetReference_WhenTypeMismatch_Throws() { // 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(); + // AssetDatabase.CreateAsset(gameObject, goPath); + // AssetDatabase.SaveAssets(); + // AssetDatabase.Refresh(); + PrefabUtility.SaveAsPrefabAsset(gameObject, goPath); var goAsset = AssetDatabase.LoadAssetAtPath(goPath); Assert.NotNull(goAsset); @@ -196,7 +197,7 @@ public void ClearReference_WhenNotSmartReferenceProperty_Throws() Assert.NotNull(badProp); var ex = Assert.Throws(() => SmartReferenceTool.ClearReference(badProp)); - StringAssert.Contains("Property does not look like a SmartReference", ex.Message); + StringAssert.Contains("Property is not a serialized object/struct", ex.Message); } // --------------------------- From 3e95cf3376be8aa01d920dba4b17be048aa65a33 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Mon, 22 Dec 2025 17:57:21 +0800 Subject: [PATCH 05/18] Fix addressables loader, add docs --- Runtime/Loaders/AddressablesLoader.cs | 8 +- Runtime/SmartReference.cs | 23 ++- Tools~/DocFx/docfx.json | 180 +++++++++++++----------- Tools~/DocFx/docs.meta | 3 + Tools~/DocFx/docs/custom_loader.md | 1 + Tools~/DocFx/docs/custom_loader.md.meta | 3 + Tools~/DocFx/docs/init.md | 63 +++++++++ Tools~/DocFx/docs/init.md.meta | 3 + Tools~/DocFx/docs/loading_async.md | 29 ++++ Tools~/DocFx/docs/loading_async.md.meta | 3 + Tools~/DocFx/docs/loading_sync.md | 12 ++ Tools~/DocFx/docs/loading_sync.md.meta | 3 + Tools~/DocFx/index.md | 2 +- Tools~/DocFx/toc.yml | 24 +++- 14 files changed, 249 insertions(+), 108 deletions(-) create mode 100644 Tools~/DocFx/docs.meta create mode 100644 Tools~/DocFx/docs/custom_loader.md create mode 100644 Tools~/DocFx/docs/custom_loader.md.meta create mode 100644 Tools~/DocFx/docs/init.md create mode 100644 Tools~/DocFx/docs/init.md.meta create mode 100644 Tools~/DocFx/docs/loading_async.md create mode 100644 Tools~/DocFx/docs/loading_async.md.meta create mode 100644 Tools~/DocFx/docs/loading_sync.md create mode 100644 Tools~/DocFx/docs/loading_sync.md.meta diff --git a/Runtime/Loaders/AddressablesLoader.cs b/Runtime/Loaders/AddressablesLoader.cs index 148e6f5..a3f2950 100644 --- a/Runtime/Loaders/AddressablesLoader.cs +++ b/Runtime/Loaders/AddressablesLoader.cs @@ -54,10 +54,10 @@ public void Cancel(ISmartReferenceHandle handle) { // Addressables doesn't truly "cancel" loads the same way; you can release handle // but behavior depends on ref counting/state. We'll best-effort release op if valid. - if (handle is AddressablesHandle ah && ah.IsValid) - { - Addressables.Release(ah.Op); - } + // if (handle is AddressablesHandle ah && ah.IsValid) + // { + // Addressables.Release(ah.Op); + // } } } } diff --git a/Runtime/SmartReference.cs b/Runtime/SmartReference.cs index 0bd3db1..1b3f124 100644 --- a/Runtime/SmartReference.cs +++ b/Runtime/SmartReference.cs @@ -56,7 +56,7 @@ public class SmartReference: SmartReference, ISerializationCallbackReceiver w { [NonSerialized] private T value; - // public event Action OnAsyncLoadComplete; + public event Action OnAsyncLoadComplete; [NonSerialized] private bool isLoading; [NonSerialized] private TaskCompletionSource inFlightTaskTcs; @@ -72,8 +72,9 @@ public class SmartReference: SmartReference, ISerializationCallbackReceiver w public bool IsLoaded => value != null; public bool IsLoading => isLoading; + public bool ReleaseRequested => releaseRequested; + - /// /// Get the asset. If the asset is not loaded, it will be loaded automatically. /// @@ -119,7 +120,7 @@ public void LoadAsync() { if (value != null) { - // OnAsyncLoadComplete?.Invoke(value); + OnAsyncLoadComplete?.Invoke(value); return; } @@ -245,9 +246,9 @@ public void Release() { Loader.Cancel(handle); } - catch + catch (Exception e) { - // ignore - cancel may not be supported + Debug.LogWarning($"[SmartReference] Cancel loading failed for asset at path: {path}. Exception: {e}"); } return; } @@ -263,7 +264,7 @@ public void Release() } catch (Exception e) { - Debug.LogWarning($"[SmartReference] Release failed for path: {path}. Exception: {e}"); + Debug.LogWarning($"[SmartReference] Release failed for asset at path: {path}. Exception: {e}"); } finally { @@ -275,6 +276,8 @@ public void Release() private void CompleteInFlight(T result) { + OnAsyncLoadComplete?.Invoke(value); + // Task if (inFlightTaskTcs != null) { @@ -300,19 +303,11 @@ private void OnAsyncLoadCompleteCallback(Object obj) if (obj == null) { CompleteInFlight(null); - if (releaseRequested) - { - Release(); - return; - } - LogLoadAssetNullError(); - return; } value = (T) obj; - // OnAsyncLoadComplete?.Invoke(value); CompleteInFlight(value); // If Dispose/Release was called during loading, release immediately after completion. diff --git a/Tools~/DocFx/docfx.json b/Tools~/DocFx/docfx.json index 42b8ceb..d571acf 100644 --- a/Tools~/DocFx/docfx.json +++ b/Tools~/DocFx/docfx.json @@ -1,92 +1,102 @@ { - "metadata": [ - { - "src": [ + "metadata": [ { - "src": "../../Runtime", - "files": [ - "**/*.cs" - ] + "src": [ + { + "src": "../../Runtime", + "files": [ + "**/*.cs" + ] + } + ], + "dest": "api", + "filter": "filterConfig.yml", + "disableGitFeatures": false, + "disableDefaultFilter": false } - ], - "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 + }, + "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" + ], + "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" + } + ], + "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..b951bb4 --- /dev/null +++ b/Tools~/DocFx/docs/custom_loader.md @@ -0,0 +1 @@ +# Using Custom Loader 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..b85c826 --- /dev/null +++ b/Tools~/DocFx/docs/init.md @@ -0,0 +1,63 @@ +# Initialization +Smart Reference requires a global initialization step to define how assets are loaded. + +Initialization should be performed: +- Once per application +- As early as possible (before any asset access) + +--- + +## Init with Resources Loader + +```csharp +using SmartReference.Runtime; + +SmartReference.InitWithResourcesLoader(); +``` + +--- + +## Init with Addressables Loader + +```csharp +using SmartReference.Runtime; + +SmartReference.InitWithAddressablesLoader(); +``` + +--- + +## Init with Custom Loader + +```csharp +using SmartReference.Runtime; + +public class MyLoader : ISmartReferenceLoader +{ + public Object Load(string path, Type type) + { + // 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, Object asset) + { + // Your custom release logic here + } + + public void Cancel(ISmartReferenceHandle handle) + { + // Your custom cancel logic here + } +} + +SmartReference.InitWithCustomLoader(new MyLoader()); +``` + +--- + +## Where to Initialize \ No newline at end of file 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..3e32c05 --- /dev/null +++ b/Tools~/DocFx/docs/loading_async.md @@ -0,0 +1,29 @@ +# Loading Assets Asynchronously + +Smart Reference supports multiple async styles to fit different workflows. + +--- + +## With Event Callback + +```csharp +reference.LoadAsync(); +``` + +--- + +## With C# Async/Await + +```csharp +var asset = await reference.LoadAsyncTask(); +``` + +--- + +## With UniTask + +```csharp +using Cysharp.Threading.Tasks; + +var asset = await reference.LoadAsyncUniTask(); +``` \ 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..b502688 --- /dev/null +++ b/Tools~/DocFx/docs/loading_sync.md @@ -0,0 +1,12 @@ +# Loading Assets Synchronously +Synchronous loading blocks execution until the asset is available. + +--- + +## Basic Sync Load + +```csharp +SmartReference icon; + +var texture = icon.Load(); +``` \ No newline at end of file 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/index.md b/Tools~/DocFx/index.md index c673c26..87d858f 100644 --- a/Tools~/DocFx/index.md +++ b/Tools~/DocFx/index.md @@ -1,5 +1,5 @@ # Smart Reference -#### version 0.7.0 +#### version 2.0.0 ## Summary Smart Reference is a Unity plugin that allows you to lazy load references to other objects in ScriptableObject and MonoBehaviour. diff --git a/Tools~/DocFx/toc.yml b/Tools~/DocFx/toc.yml index b401f9d..2431f6b 100644 --- a/Tools~/DocFx/toc.yml +++ b/Tools~/DocFx/toc.yml @@ -1,5 +1,21 @@ items: -- name: Quick Start - href: index.md -- name: API Documentation - href: api/ + - name: Manual + items: + - name: Smart Reference 2.0 + href: index.md + + - name: Initialization + href: docs/init.md + + - name: Loading Assets + items: + - name: Synchronous Loading + href: docs/loading_sync.md + - name: Asynchronous Loading + href: docs/loading_async.md + + - name: Custom Loader + href: docs/custom_loader.md + + - name: API Documentation + href: api/ \ No newline at end of file From 3b7b2841fd4b84c9a5de0f0664b32978c4ee90f0 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Mon, 22 Dec 2025 18:08:11 +0800 Subject: [PATCH 06/18] Update gh actions --- .github/workflows/docfx-build-publish.yml | 38 +++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index 454393f..e4d4a7e 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -23,20 +23,20 @@ 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 + uses: actions/setup-dotnet@v5.0.1 with: dotnet-version: 3.1.101 - name: Setup DocFX - uses: crazy-max/ghaction-chocolatey@v1 + uses: crazy-max/ghaction-chocolatey@v3.4.0 with: args: install docfx --ignore-checksums - name: Setup wkhtmltopdf - uses: crazy-max/ghaction-chocolatey@v1 + uses: crazy-max/ghaction-chocolatey@v3.4.0 with: args: install wkhtmltopdf --ignore-checksums @@ -50,32 +50,52 @@ 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 + uses: actions/upload-artifact@v6.0.0 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 + + - 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 - 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 From ef42e32b000e37f8dfa18a66112a0da7b307e58e Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Mon, 22 Dec 2025 18:35:32 +0800 Subject: [PATCH 07/18] Update docs & gh action --- .github/workflows/docfx-build-publish.yml | 2 +- Tools~/DocFx/docfx.json | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index e4d4a7e..16a36dc 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -77,7 +77,7 @@ jobs: uses: peaceiris/actions-gh-pages@v4.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_branch: gh-pages-test + publish_branch: gh-pages publish_dir: _site force_orphan: true diff --git a/Tools~/DocFx/docfx.json b/Tools~/DocFx/docfx.json index d571acf..cd4c67e 100644 --- a/Tools~/DocFx/docfx.json +++ b/Tools~/DocFx/docfx.json @@ -77,6 +77,14 @@ "*.yml" ], "dest": "api" + }, + { + "src": "docs", + "files": [ + "*.yml", + "*.md" + ], + "dest": "docs" } ], "xref": [ From 11d1e6f600dac7ab331fde1b1bae3527082c4247 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Mon, 22 Dec 2025 18:57:21 +0800 Subject: [PATCH 08/18] Test actions --- .github/workflows/docfx-build-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index 16a36dc..79e710a 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -77,8 +77,8 @@ jobs: uses: peaceiris/actions-gh-pages@v4.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_branch: gh-pages - publish_dir: _site + publish_branch: gh-pages-test +# publish_dir: _site force_orphan: true deploy: From 03491d5afa12cd871ca151af1c3f012f22839b49 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Mon, 22 Dec 2025 19:01:10 +0800 Subject: [PATCH 09/18] Test action --- .github/workflows/docfx-build-publish.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index 79e710a..46fb9b8 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -72,13 +72,16 @@ jobs: uses: actions/download-artifact@v7.0.0 with: name: _site + + - name: Display structure of downloaded files + run: ls -R - name: Publish uses: peaceiris/actions-gh-pages@v4.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_branch: gh-pages-test -# publish_dir: _site + publish_dir: ./ force_orphan: true deploy: From 8497849cb0a09e9ebe1a6de8e5169aa9f98fa7d1 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Mon, 22 Dec 2025 19:05:44 +0800 Subject: [PATCH 10/18] Test action --- .github/workflows/docfx-build-publish.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index 46fb9b8..f015ce6 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -72,6 +72,7 @@ jobs: uses: actions/download-artifact@v7.0.0 with: name: _site + path: _site - name: Display structure of downloaded files run: ls -R @@ -81,7 +82,7 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_branch: gh-pages-test - publish_dir: ./ + publish_dir: _site force_orphan: true deploy: From 096adda6382fe534549fe4b9699f27656e94865c Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Mon, 22 Dec 2025 19:10:11 +0800 Subject: [PATCH 11/18] Test action --- .github/workflows/docfx-build-publish.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index f015ce6..23bb79d 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -76,6 +76,9 @@ jobs: - name: Display structure of downloaded files run: ls -R + + - name: Display current directory + run: pwd - name: Publish uses: peaceiris/actions-gh-pages@v4.0.0 From 569a2b017dd2e6ab63250ae18c0e408ad2454f16 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Mon, 22 Dec 2025 19:47:23 +0800 Subject: [PATCH 12/18] Actions & toc --- .github/workflows/docfx-build-publish.yml | 26 +------ Tools~/DocFx/docfx.json | 91 ++++++++++++----------- Tools~/DocFx/docs/toc.yml | 13 ++++ Tools~/DocFx/docs/toc.yml.meta | 3 + Tools~/DocFx/toc.yml | 20 +---- 5 files changed, 70 insertions(+), 83 deletions(-) create mode 100644 Tools~/DocFx/docs/toc.yml create mode 100644 Tools~/DocFx/docs/toc.yml.meta diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index 23bb79d..bfb4e32 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -25,20 +25,13 @@ jobs: - name: Checkout uses: actions/checkout@v6.0.1 - - name: Setup .NET Core + - 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@v3.4.0 - with: - args: install docfx --ignore-checksums - - - name: Setup wkhtmltopdf - uses: crazy-max/ghaction-chocolatey@v3.4.0 - with: - args: install wkhtmltopdf --ignore-checksums + run: dotnet tool update -g docfx - name: Copy readme run: cp .\README.md .\Tools~\DocFx\index.md @@ -54,12 +47,6 @@ jobs: with: name: _site path: Tools~/DocFx/_site - - - name: Upload PDF - uses: actions/upload-artifact@v6.0.0 - with: - name: _site_pdf - path: Tools~/DocFx/_site_pdf/DocFx.pdf deploy_test: needs: build @@ -73,12 +60,6 @@ jobs: with: name: _site path: _site - - - name: Display structure of downloaded files - run: ls -R - - - name: Display current directory - run: pwd - name: Publish uses: peaceiris/actions-gh-pages@v4.0.0 @@ -100,6 +81,7 @@ jobs: uses: actions/download-artifact@v7.0.0 with: name: _site + path: _site - name: Publish uses: peaceiris/actions-gh-pages@v4.0.0 diff --git a/Tools~/DocFx/docfx.json b/Tools~/DocFx/docfx.json index cd4c67e..91341d2 100644 --- a/Tools~/DocFx/docfx.json +++ b/Tools~/DocFx/docfx.json @@ -19,7 +19,8 @@ "globalMetadata": { "_appTitle": "Smart Reference", "_appFooter": "Smart Reference", - "_enableSearch": true + "_enableSearch": true, + "pdf": true, }, "content": [ { @@ -63,48 +64,48 @@ "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 - } +// "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/toc.yml b/Tools~/DocFx/docs/toc.yml new file mode 100644 index 0000000..3604865 --- /dev/null +++ b/Tools~/DocFx/docs/toc.yml @@ -0,0 +1,13 @@ +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: 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/toc.yml b/Tools~/DocFx/toc.yml index 2431f6b..b4c158e 100644 --- a/Tools~/DocFx/toc.yml +++ b/Tools~/DocFx/toc.yml @@ -1,21 +1,9 @@ items: - - name: Manual - items: - - name: Smart Reference 2.0 - href: index.md - - - name: Initialization - href: docs/init.md + - name: Quick Start + href: index.md - - name: Loading Assets - items: - - name: Synchronous Loading - href: docs/loading_sync.md - - name: Asynchronous Loading - href: docs/loading_async.md - - - name: Custom Loader - href: docs/custom_loader.md + - name: Manual + href: docs/ - name: API Documentation href: api/ \ No newline at end of file From c0bd2ad870f6092b5e12e6900e4793a12ecf9269 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Mon, 22 Dec 2025 20:13:48 +0800 Subject: [PATCH 13/18] Test action --- .github/workflows/docfx-build-publish.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index bfb4e32..aaf8fd0 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -31,7 +31,9 @@ jobs: dotnet-version: '8.0.x' - name: Setup DocFX - run: dotnet tool update -g docfx + uses: crazy-max/ghaction-chocolatey@v3.4.0 + with: + args: install docfx --ignore-checksums - name: Copy readme run: cp .\README.md .\Tools~\DocFx\index.md From 75ad3ba5cf58a7e6e3ea370fd34c555485bd4cc2 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Tue, 23 Dec 2025 16:45:59 +0800 Subject: [PATCH 14/18] Update document, add error for webgl sync loading --- .github/workflows/docfx-build-publish.yml | 9 +- CHANGELOG.md | 7 ++ README.md | 142 +++++++++++++--------- Runtime/Loaders/AddressablesLoader.cs | 8 +- Runtime/Loaders/ResourcesLoader.cs | 118 +++++++++--------- Runtime/SmartReference.cs | 6 +- Tools~/DocFx/api.meta | 3 - Tools~/DocFx/api/.gitignore | 5 - Tools~/DocFx/docfx.json | 6 +- Tools~/DocFx/docs/custom_loader.md | 52 ++++++++ Tools~/DocFx/docs/init.md | 73 +++++++---- Tools~/DocFx/docs/loading_async.md | 69 ++++++++++- Tools~/DocFx/docs/loading_sync.md | 41 ++++++- Tools~/DocFx/docs/releasing.md | 31 +++++ Tools~/DocFx/docs/releasing.md.meta | 3 + Tools~/DocFx/docs/toc.yml | 3 + Tools~/DocFx/index.md | 97 ++++++++++++--- 17 files changed, 486 insertions(+), 187 deletions(-) delete mode 100644 Tools~/DocFx/api.meta delete mode 100644 Tools~/DocFx/api/.gitignore create mode 100644 Tools~/DocFx/docs/releasing.md create mode 100644 Tools~/DocFx/docs/releasing.md.meta diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index aaf8fd0..460e07a 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -30,10 +30,13 @@ jobs: with: dotnet-version: '8.0.x' +# - name: Setup DocFX +# uses: crazy-max/ghaction-chocolatey@v3.4.0 +# with: +# args: install docfx --ignore-checksums + - name: Setup DocFX - uses: crazy-max/ghaction-chocolatey@v3.4.0 - with: - args: install docfx --ignore-checksums + run: dotnet tool install -g docfx --version 2.61.0 - name: Copy readme run: cp .\README.md .\Tools~\DocFx\index.md 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/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/Loaders/AddressablesLoader.cs b/Runtime/Loaders/AddressablesLoader.cs index a3f2950..4ba561b 100644 --- a/Runtime/Loaders/AddressablesLoader.cs +++ b/Runtime/Loaders/AddressablesLoader.cs @@ -7,8 +7,10 @@ namespace SmartReference.Runtime { - public class AddressablesLoader: ISmartReferenceLoader { - public Object Load(string path, Type type) { + public class AddressablesLoader: ISmartReferenceLoader + { + public Object Load(string path, Type type) + { Object result = null; var handle = Addressables.LoadAssetAsync(path); handle.Completed += operation => { @@ -19,6 +21,8 @@ public Object Load(string path, Type type) { #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; } diff --git a/Runtime/Loaders/ResourcesLoader.cs b/Runtime/Loaders/ResourcesLoader.cs index 89f8368..2c8e3cf 100644 --- a/Runtime/Loaders/ResourcesLoader.cs +++ b/Runtime/Loaders/ResourcesLoader.cs @@ -20,7 +20,7 @@ public ISmartReferenceHandle LoadAsync(string path, Type type, Action ca return new ResourcesHandle(); } - public void Release(ISmartReferenceHandle handle, UnityEngine.Object asset) + public void Release(ISmartReferenceHandle handle, Object asset) { // Best effort: only safe for certain asset types loaded from Resources. if (asset != null) @@ -36,74 +36,74 @@ public void Cancel(ISmartReferenceHandle handle) } private static string GetResourcesPath(string path) -{ - if (string.IsNullOrEmpty(path)) - return path; + { + if (string.IsNullOrEmpty(path)) + return path; - // Normalize slashes so Windows paths work. - var p = path.Replace('\\', '/'); + // Normalize slashes so Windows paths work. + var p = path.Replace('\\', '/'); - // If caller already passed a Resources-relative path (common), we can accept it. - // (We still strip extension if present.) - // Example: "UI/Icons/MyIcon.png" => "UI/Icons/MyIcon" - // But we should prefer detecting an actual "/Resources/" segment first. + // If caller already passed a Resources-relative path (common), we can accept it. + // (We still strip extension if present.) + // Example: "UI/Icons/MyIcon.png" => "UI/Icons/MyIcon" + // But we should prefer detecting an actual "/Resources/" segment first. - // Find the LAST "/Resources/" segment, so nested Resources works: - // "Assets/A/Resources/X.prefab" and "Assets/A/Resources/Sub/Resources/Y.prefab" - // should both load correctly (use the deepest Resources root). - const string segment = "/Resources/"; - int idx = p.LastIndexOf(segment, StringComparison.OrdinalIgnoreCase); + // Find the LAST "/Resources/" segment, so nested Resources works: + // "Assets/A/Resources/X.prefab" and "Assets/A/Resources/Sub/Resources/Y.prefab" + // should both load correctly (use the deepest Resources root). + const string segment = "/Resources/"; + int idx = p.LastIndexOf(segment, StringComparison.OrdinalIgnoreCase); - // Also handle when path contains ".../Resources" with no trailing slash (rare but possible). - if (idx < 0) - { - const string segmentNoSlash = "/Resources"; - int idx2 = p.LastIndexOf(segmentNoSlash, StringComparison.OrdinalIgnoreCase); - if (idx2 >= 0) - { - // Ensure it's a folder boundary: next char is '/' or end-of-string. - int next = idx2 + segmentNoSlash.Length; - if (next == p.Length) + // Also handle when path contains ".../Resources" with no trailing slash (rare but possible). + if (idx < 0) { - // Path points to the Resources folder itself (not loadable). - Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); - return StripExtension(p); + const string segmentNoSlash = "/Resources"; + int idx2 = p.LastIndexOf(segmentNoSlash, StringComparison.OrdinalIgnoreCase); + if (idx2 >= 0) + { + // Ensure it's a folder boundary: next char is '/' or end-of-string. + int next = idx2 + segmentNoSlash.Length; + if (next == p.Length) + { + // Path points to the Resources folder itself (not loadable). + Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); + return StripExtension(p); + } + if (p[next] == '/') + idx = idx2; // treat as "/Resources/" + } } - if (p[next] == '/') - idx = idx2; // treat as "/Resources/" - } - } - string relative; - if (idx >= 0) - { - int start = idx + segment.Length; // after "/Resources/" - if (start >= p.Length) - { - Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); - return StripExtension(p); - } + string relative; + if (idx >= 0) + { + int start = idx + segment.Length; // after "/Resources/" + if (start >= p.Length) + { + Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); + return StripExtension(p); + } - relative = p.Substring(start); - } - else - { - // No Resources segment found. Best effort: assume it's already relative. - Debug.LogWarning($"[SmartReference] ResourcesLoader: Path '{path}' does not contain a Resources folder segment. Assuming it's already Resources-relative."); - relative = p; - } + relative = p.Substring(start); + } + else + { + // No Resources segment found. Best effort: assume it's already relative. + Debug.LogWarning($"[SmartReference] ResourcesLoader: Path '{path}' does not contain a Resources folder segment. Assuming it's already Resources-relative."); + relative = p; + } - return StripExtension(relative); + return StripExtension(relative); - static string StripExtension(string s) - { - // Remove extension only if it's after the last slash. - int slash = s.LastIndexOf('/'); - int dot = s.LastIndexOf('.'); - if (dot > slash) return s.Substring(0, dot); - return s; - } -} + static string StripExtension(string s) + { + // Remove extension only if it's after the last slash. + int slash = s.LastIndexOf('/'); + int dot = s.LastIndexOf('.'); + if (dot > slash) return s.Substring(0, dot); + return s; + } + } } } \ No newline at end of file diff --git a/Runtime/SmartReference.cs b/Runtime/SmartReference.cs index 1b3f124..700a0e1 100644 --- a/Runtime/SmartReference.cs +++ b/Runtime/SmartReference.cs @@ -78,7 +78,8 @@ public class SmartReference: SmartReference, ISerializationCallbackReceiver w /// /// 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(); @@ -95,7 +96,8 @@ public static implicit operator T(SmartReference reference) { /// /// Call this method to load the asset. This would be called automatically when you access the Value property. /// - public void Load() { + public void Load() + { if (string.IsNullOrEmpty(path)) { LogEmptyAssetError(); 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 91341d2..28df51a 100644 --- a/Tools~/DocFx/docfx.json +++ b/Tools~/DocFx/docfx.json @@ -12,7 +12,8 @@ "dest": "api", "filter": "filterConfig.yml", "disableGitFeatures": false, - "disableDefaultFilter": false + "disableDefaultFilter": false, + "allowCompilationErrors": true, } ], "build": { @@ -46,7 +47,8 @@ } ], "xref": [ - "https://normanderwan.github.io/UnityXrefMaps/xrefmap.yml" + "https://normanderwan.github.io/UnityXrefMaps/xrefmap.yml", + "https://docs.microsoft.com/dotnet/xrefmap.yml" ], "xrefService": [ "https://xref.docs.microsoft.com/query?uid={uid}" diff --git a/Tools~/DocFx/docs/custom_loader.md b/Tools~/DocFx/docs/custom_loader.md index b951bb4..352345a 100644 --- a/Tools~/DocFx/docs/custom_loader.md +++ b/Tools~/DocFx/docs/custom_loader.md @@ -1 +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 Object Load(string path, Type type) + { + // 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, Object asset) + { + // 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/init.md b/Tools~/DocFx/docs/init.md index b85c826..db66be3 100644 --- a/Tools~/DocFx/docs/init.md +++ b/Tools~/DocFx/docs/init.md @@ -1,13 +1,14 @@ # Initialization -Smart Reference requires a global initialization step to define how assets are loaded. +Smart Reference requires a global initialization step to define how assets are resolved and loaded at runtime. -Initialization should be performed: -- Once per application -- As early as possible (before any asset access) +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; @@ -15,9 +16,15 @@ 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 the 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; @@ -25,39 +32,55 @@ 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. -```csharp -using SmartReference.Runtime; +This is useful when you already have an existing asset management system or need specialized loading behavior. -public class MyLoader : ISmartReferenceLoader -{ - public Object Load(string path, Type type) - { - // Your custom synchronous load logic here - } +For implementation details, see [Using Custom Loader](custom_loader.md). - public ISmartReferenceHandle LoadAsync(string path, Type type, Action callback) - { - // Your custom asynchronous load logic here - } +--- + +## Where to Initialize +Initialization must be performed: +- Once per application lifetime +- Before any SmartReference is accessed - public void Release(ISmartReferenceHandle handle, Object asset) +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() { - // Your custom release logic here + SmartReference.InitWithAddressablesLoader(); } +} +``` +This ensures Smart Reference is ready before any scene or script attempts to load assets. - public void Cancel(ISmartReferenceHandle handle) +### Alternative: Bootstrap MonoBehaviour +```csharp +public class Bootstrap : MonoBehaviour +{ + void Awake() { - // Your custom cancel logic here + SmartReference.InitWithAddressablesLoader(); } } - -SmartReference.InitWithCustomLoader(new MyLoader()); ``` +This approach is acceptable if you already have a guaranteed first-loaded scene. ---- - -## Where to Initialize \ No newline at end of file +## Reinitialization +Reinitialization is not supported because the smart reference handle used internally is different between loaders. diff --git a/Tools~/DocFx/docs/loading_async.md b/Tools~/DocFx/docs/loading_async.md index 3e32c05..fd1dacb 100644 --- a/Tools~/DocFx/docs/loading_async.md +++ b/Tools~/DocFx/docs/loading_async.md @@ -1,29 +1,88 @@ # Loading Assets Asynchronously -Smart Reference supports multiple async styles to fit different workflows. +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 -reference.LoadAsync(); +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 -var asset = await reference.LoadAsyncTask(); +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; -var asset = await reference.LoadAsyncUniTask(); -``` \ No newline at end of file +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_sync.md b/Tools~/DocFx/docs/loading_sync.md index b502688..ba68e74 100644 --- a/Tools~/DocFx/docs/loading_sync.md +++ b/Tools~/DocFx/docs/loading_sync.md @@ -1,12 +1,45 @@ # Loading Assets Synchronously -Synchronous loading blocks execution until the asset is available. +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 -SmartReference icon; +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. -var texture = icon.Load(); -``` \ No newline at end of file +For WebGL builds, always use asynchronous loading. 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 index 3604865..84902bd 100644 --- a/Tools~/DocFx/docs/toc.yml +++ b/Tools~/DocFx/docs/toc.yml @@ -8,6 +8,9 @@ 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/index.md b/Tools~/DocFx/index.md index 87d858f..793af80 100644 --- a/Tools~/DocFx/index.md +++ b/Tools~/DocFx/index.md @@ -1,33 +1,92 @@ # Smart Reference #### 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 From 496ecc78f84c2e18b0a335f8945c81716c99f276 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Tue, 23 Dec 2025 17:49:35 +0800 Subject: [PATCH 15/18] Docs & tests --- .github/workflows/docfx-build-publish.yml | 5 -- Runtime/Handles/AddressablesHandle.cs | 5 +- Runtime/Handles/ISmartReferenceHandle.cs | 4 ++ Runtime/Handles/ResourcesHandle.cs | 7 ++- Runtime/Loaders/AddressablesLoader.cs | 34 ++++------ Runtime/Loaders/ISmartReferenceLoader.cs | 19 +++--- Runtime/Loaders/ResourcesLoader.cs | 55 +++++++--------- Runtime/SmartReference.cs | 62 ++++++++++++------- Tests/Runtime/AssetSetup.cs | 2 +- Tests/Runtime/PlayModeTests.cs | 34 +++++----- .../SmartReference.Runtime.Tests.asmdef | 3 +- package.json | 11 ++-- 12 files changed, 124 insertions(+), 117 deletions(-) diff --git a/.github/workflows/docfx-build-publish.yml b/.github/workflows/docfx-build-publish.yml index 460e07a..78d090b 100644 --- a/.github/workflows/docfx-build-publish.yml +++ b/.github/workflows/docfx-build-publish.yml @@ -30,11 +30,6 @@ jobs: with: dotnet-version: '8.0.x' -# - name: Setup DocFX -# uses: crazy-max/ghaction-chocolatey@v3.4.0 -# with: -# args: install docfx --ignore-checksums - - name: Setup DocFX run: dotnet tool install -g docfx --version 2.61.0 diff --git a/Runtime/Handles/AddressablesHandle.cs b/Runtime/Handles/AddressablesHandle.cs index e0dfa3b..08de078 100644 --- a/Runtime/Handles/AddressablesHandle.cs +++ b/Runtime/Handles/AddressablesHandle.cs @@ -1,13 +1,14 @@ #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(); + public AsyncOperationHandle op; + public bool IsValid => op.IsValid(); } } diff --git a/Runtime/Handles/ISmartReferenceHandle.cs b/Runtime/Handles/ISmartReferenceHandle.cs index c507f27..66f4492 100644 --- a/Runtime/Handles/ISmartReferenceHandle.cs +++ b/Runtime/Handles/ISmartReferenceHandle.cs @@ -1,5 +1,9 @@ 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 `LoadAsync` method and passed to `Release` and `Cancel` methods of so the loader knows what assets to operate on. + /// public interface ISmartReferenceHandle { diff --git a/Runtime/Handles/ResourcesHandle.cs b/Runtime/Handles/ResourcesHandle.cs index d4fbc52..d863519 100644 --- a/Runtime/Handles/ResourcesHandle.cs +++ b/Runtime/Handles/ResourcesHandle.cs @@ -1,7 +1,10 @@ -namespace SmartReference.Runtime +using UnityEngine; + +namespace SmartReference.Runtime { public class ResourcesHandle : ISmartReferenceHandle { - + public Object loadedObject; + public bool IsValid => loadedObject != null; } } \ No newline at end of file diff --git a/Runtime/Loaders/AddressablesLoader.cs b/Runtime/Loaders/AddressablesLoader.cs index 4ba561b..b9a4771 100644 --- a/Runtime/Loaders/AddressablesLoader.cs +++ b/Runtime/Loaders/AddressablesLoader.cs @@ -13,8 +13,10 @@ public Object Load(string path, Type type) { Object result = null; var handle = Addressables.LoadAssetAsync(path); - handle.Completed += operation => { - if (operation.Status == AsyncOperationStatus.Succeeded) { + handle.Completed += operation => + { + if (operation.Status == AsyncOperationStatus.Succeeded) + { result = operation.Result; } }; @@ -30,38 +32,28 @@ public Object Load(string path, Type type) public ISmartReferenceHandle LoadAsync(string path, Type type, Action callback) { var handle = Addressables.LoadAssetAsync(path); - handle.Completed += operation => { - if (operation.Status == AsyncOperationStatus.Succeeded) { + handle.Completed += operation => + { + if (operation.Status == AsyncOperationStatus.Succeeded) + { callback?.Invoke(operation.Result); } }; - var h = new AddressablesHandle { Op = handle }; + var h = new AddressablesHandle { op = handle }; return h; } - public void Release(ISmartReferenceHandle handle, UnityEngine.Object asset) + public void Release(ISmartReferenceHandle handle) { - if (handle is AddressablesHandle ah && ah.IsValid) - { - Addressables.Release(ah.Op); - return; - } - - // fallback (less ideal): if someone passed only asset - if (asset != null) + if (handle is AddressablesHandle { IsValid: true } addressablesHandle) { - Addressables.Release(asset); + Addressables.Release(addressablesHandle.op); } } public void Cancel(ISmartReferenceHandle handle) { - // Addressables doesn't truly "cancel" loads the same way; you can release handle - // but behavior depends on ref counting/state. We'll best-effort release op if valid. - // if (handle is AddressablesHandle ah && ah.IsValid) - // { - // Addressables.Release(ah.Op); - // } + } } } diff --git a/Runtime/Loaders/ISmartReferenceLoader.cs b/Runtime/Loaders/ISmartReferenceLoader.cs index c3595d7..dcaeb2e 100644 --- a/Runtime/Loaders/ISmartReferenceLoader.cs +++ b/Runtime/Loaders/ISmartReferenceLoader.cs @@ -8,29 +8,32 @@ public interface ISmartReferenceLoader /// /// Load an asset synchronously. /// - /// The path of the asset, begin with `Assets/` + /// The path of the asset, begin with `Assets/`. /// The type of the asset. - /// + /// The loaded Object. public Object Load(string path, Type type); /// /// Load an asset asynchronously. /// - /// - /// he type of the asset. - /// - /// + /// The path of the asset, begin with `Assets/`. + /// the type of the asset. + /// The callback to invoke once the load has finished. + /// 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. /// If handle is null, loader may optionally attempt to release by asset reference. /// - void Release(ISmartReferenceHandle handle, UnityEngine.Object asset); + /// The handle returned by `LoadAsync`. + void Release(ISmartReferenceHandle handle); /// - /// Optional: try cancel an in-flight load. If unsupported, no-op. + /// 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 a 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 2c8e3cf..cc4dd6b 100644 --- a/Runtime/Loaders/ResourcesLoader.cs +++ b/Runtime/Loaders/ResourcesLoader.cs @@ -20,19 +20,17 @@ public ISmartReferenceHandle LoadAsync(string path, Type type, Action ca return new ResourcesHandle(); } - public void Release(ISmartReferenceHandle handle, Object asset) + public void Release(ISmartReferenceHandle handle) { - // Best effort: only safe for certain asset types loaded from Resources. - if (asset != null) + if (handle is ResourcesHandle { IsValid: true } resourcesHandle) { - try { Resources.UnloadAsset(asset); } - catch { /* ignore */ } + Resources.UnloadAsset(resourcesHandle.loadedObject); } } public void Cancel(ISmartReferenceHandle handle) { - // Resources load cannot be cancelled; no-op. + } private static string GetResourcesPath(string path) @@ -42,42 +40,32 @@ private static string GetResourcesPath(string path) // Normalize slashes so Windows paths work. var p = path.Replace('\\', '/'); - - // If caller already passed a Resources-relative path (common), we can accept it. - // (We still strip extension if present.) - // Example: "UI/Icons/MyIcon.png" => "UI/Icons/MyIcon" - // But we should prefer detecting an actual "/Resources/" segment first. - - // Find the LAST "/Resources/" segment, so nested Resources works: - // "Assets/A/Resources/X.prefab" and "Assets/A/Resources/Sub/Resources/Y.prefab" - // should both load correctly (use the deepest Resources root). const string segment = "/Resources/"; - int idx = p.LastIndexOf(segment, StringComparison.OrdinalIgnoreCase); - - // Also handle when path contains ".../Resources" with no trailing slash (rare but possible). + var idx = p.LastIndexOf(segment, StringComparison.OrdinalIgnoreCase); if (idx < 0) { const string segmentNoSlash = "/Resources"; - int idx2 = p.LastIndexOf(segmentNoSlash, StringComparison.OrdinalIgnoreCase); + var idx2 = p.LastIndexOf(segmentNoSlash, StringComparison.OrdinalIgnoreCase); if (idx2 >= 0) { - // Ensure it's a folder boundary: next char is '/' or end-of-string. - int next = idx2 + segmentNoSlash.Length; + var next = idx2 + segmentNoSlash.Length; if (next == p.Length) { - // Path points to the Resources folder itself (not loadable). Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); return StripExtension(p); } + if (p[next] == '/') - idx = idx2; // treat as "/Resources/" + { + idx = idx2; + } } } string relative; if (idx >= 0) { - int start = idx + segment.Length; // after "/Resources/" + var start = idx + segment.Length; if (start >= p.Length) { Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); @@ -88,22 +76,23 @@ private static string GetResourcesPath(string path) } else { - // No Resources segment found. Best effort: assume it's already relative. - Debug.LogWarning($"[SmartReference] ResourcesLoader: Path '{path}' does not contain a Resources folder segment. Assuming it's already Resources-relative."); + Debug.LogWarning($"[SmartReference] ResourcesLoader: Path '{path}' does not contain a Resources folder segment."); relative = p; } return StripExtension(relative); + } - static string StripExtension(string s) + private static string StripExtension(string s) + { + var slash = s.LastIndexOf('/'); + var dot = s.LastIndexOf('.'); + if (dot > slash) { - // Remove extension only if it's after the last slash. - int slash = s.LastIndexOf('/'); - int dot = s.LastIndexOf('.'); - if (dot > slash) return s.Substring(0, dot); - return s; + return s.Substring(0, dot); } + + return s; } - } } \ No newline at end of file diff --git a/Runtime/SmartReference.cs b/Runtime/SmartReference.cs index 700a0e1..2b29ac9 100644 --- a/Runtime/SmartReference.cs +++ b/Runtime/SmartReference.cs @@ -1,10 +1,10 @@ using System; -using System.Threading; using System.Threading.Tasks; using UnityEngine; using Object = UnityEngine.Object; #if SMARTREFERENCE_UNITASK_SUPPORT +using System.Threading; using Cysharp.Threading.Tasks; #endif @@ -43,6 +43,10 @@ public static void InitWithAddressablesLoader() } #endif + /// + /// Use this method to initialize the loader with a custom implementation of ISmartReferenceLoader. + /// + /// The custom loader implementation. public static void InitWithCustomLoader(ISmartReferenceLoader loader) { Loader = loader; @@ -52,10 +56,14 @@ public static void InitWithCustomLoader(ISmartReferenceLoader loader) } [Serializable] - public class SmartReference: SmartReference, ISerializationCallbackReceiver where T: Object + 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; @@ -64,17 +72,24 @@ public class SmartReference: SmartReference, ISerializationCallbackReceiver w [NonSerialized] private UniTaskCompletionSource inFlightUniTaskTcs; #endif - // NEW: track loader handle for release/cancel. [NonSerialized] private ISmartReferenceHandle handle; - - // NEW: if disposed while loading, we’ll release after completion. [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. /// @@ -116,7 +131,7 @@ public void Load() } /// - /// 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() { @@ -138,7 +153,7 @@ public void LoadAsync() return; } - // If already loading, do nothing (caller can subscribe to event or await task/unitask). + // If already loading, do nothing if (isLoading) return; isLoading = true; @@ -147,7 +162,7 @@ public void LoadAsync() } /// - /// Load the asset asynchronously with async/await. + /// Load the asset asynchronously with C# async/await. /// public Task LoadAsyncTask() { @@ -168,14 +183,13 @@ public Task LoadAsyncTask() return Task.FromResult(null); } - // Share the same in-flight request. if (inFlightTaskTcs != null) { return inFlightTaskTcs.Task; } inFlightTaskTcs = new TaskCompletionSource(); - LoadAsync(); // will complete TCS via CompleteInFlight(...) + LoadAsync(); return inFlightTaskTcs.Task; } @@ -190,9 +204,7 @@ public UniTask LoadAsyncUniTask() } /// - /// Load the asset asynchronously with UniTask + cancellation token. - /// Note: since your loader is callback-based, cancellation here only cancels the awaiting side. - /// If you want true cancel (e.g., Addressables handle release), extend ISmartReferenceLoader to support it. + /// Load the asset asynchronously with UniTask and cancellation token. /// public UniTask LoadAsyncUniTask(CancellationToken cancellationToken) { @@ -213,34 +225,31 @@ public UniTask LoadAsyncUniTask(CancellationToken cancellationToken) return UniTask.FromResult(null); } - // Share in-flight request. if (inFlightUniTaskTcs != null) { return inFlightUniTaskTcs.Task.AttachExternalCancellation(cancellationToken); } inFlightUniTaskTcs = new UniTaskCompletionSource(); - LoadAsync(); // will complete UniTask TCS via CompleteInFlight(...) + LoadAsync(); return inFlightUniTaskTcs.Task.AttachExternalCancellation(cancellationToken); } #endif /// - /// NEW: Release/unload the loaded asset (and any loader handle). + /// Release the loaded asset. /// Safe to call multiple times. - /// If called during loading, attempts cancel + releases upon completion (best-effort). + /// If called during loading, attempts cancel the loading. /// public void Release() { - // if no loader, just clear references if (Loader == null) { LogEmptyLoaderError(); return; } - // If loading, request release; try cancel if supported. if (isLoading) { releaseRequested = true; @@ -252,6 +261,7 @@ public void Release() { Debug.LogWarning($"[SmartReference] Cancel loading failed for asset at path: {path}. Exception: {e}"); } + return; } @@ -262,7 +272,7 @@ public void Release() try { - Loader.Release(handle, value); + Loader.Release(handle); } catch (Exception e) { @@ -305,6 +315,8 @@ private void OnAsyncLoadCompleteCallback(Object obj) if (obj == null) { CompleteInFlight(null); + if (releaseRequested) return; + LogLoadAssetNullError(); return; } @@ -312,7 +324,7 @@ private void OnAsyncLoadCompleteCallback(Object obj) value = (T) obj; CompleteInFlight(value); - // If Dispose/Release was called during loading, release immediately after completion. + // If Release was called during loading, release immediately after completion. if (releaseRequested) { Release(); @@ -334,11 +346,13 @@ 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/Runtime/AssetSetup.cs b/Tests/Runtime/AssetSetup.cs index af7f66c..39e0f8e 100644 --- a/Tests/Runtime/AssetSetup.cs +++ b/Tests/Runtime/AssetSetup.cs @@ -21,7 +21,7 @@ public static class AssetSetup public const string PrefabAssetPath = ResourcesSubFolder + "/TestPrefab.prefab"; // Resources.Load path (relative to any Resources folder) - public const string SoResourcesPath = "SmartReferenceTest/TestSO"; + public const string SoResourcesPath = SoAssetPath; public const string TextureResourcesPath = "SmartReferenceTest/TestTex"; public const string MaterialResourcesPath = "SmartReferenceTest/TestMat"; public const string PrefabResourcesPath = "SmartReferenceTest/TestPrefab"; diff --git a/Tests/Runtime/PlayModeTests.cs b/Tests/Runtime/PlayModeTests.cs index 4e0ea65..99cccfc 100644 --- a/Tests/Runtime/PlayModeTests.cs +++ b/Tests/Runtime/PlayModeTests.cs @@ -7,6 +7,8 @@ using Object = UnityEngine.Object; #if SMARTREFERENCE_UNITASK_SUPPORT +using System.Threading; +using System.Threading.Tasks; using Cysharp.Threading.Tasks; #endif @@ -128,6 +130,8 @@ public void ImplicitOperator_ReturnsValue() [UnityTest] public IEnumerator AsyncLoad_Task_CompletesAndReturnsAsset() { + SmartReference.InitWithResourcesLoader(); + var r = new global::SmartReference.Runtime.SmartReference { path = AssetSetup.SoResourcesPath @@ -148,7 +152,7 @@ public IEnumerator AsyncLoad_Task_CompletesAndReturnsAsset() [UnityTest] public IEnumerator AsyncLoad_UniTask_CompletesAndReturnsAsset() { - var r = new SmartReference.Runtime.SmartReference + var r = new SmartReference { path = AssetSetup.SoResourcesPath }; @@ -165,7 +169,7 @@ public IEnumerator AsyncLoad_UniTask_CompletesAndReturnsAsset() public IEnumerator UniTask_ExternalCancellation_CancelsAwaitButLoadMayStillComplete() { // Cancellation here is "await-side" cancellation (AttachExternalCancellation). - var r = new SmartReference.Runtime.SmartReference + var r = new SmartReference { path = AssetSetup.SoResourcesPath }; @@ -173,23 +177,21 @@ public IEnumerator UniTask_ExternalCancellation_CancelsAwaitButLoadMayStillCompl using var cts = new CancellationTokenSource(); cts.Cancel(); - var canceled = false; - try - { - yield return r.LoadAsyncUniTask(cts.Token).ToCoroutine(_ => { }); - } - catch (OperationCanceledException) - { - canceled = true; - } + Exception capturedException = null; + + // IMPORTANT: yield the coroutine + yield return r + .LoadAsyncUniTask(cts.Token) + .ToCoroutine(exceptionHandler: ex => capturedException = ex); - Assert.True(canceled); + // Verify await-side cancellation + Assert.NotNull(capturedException); + Assert.IsInstanceOf(capturedException); // Let the underlying callback-based load finish. yield return null; yield return null; - // Asset may have loaded anyway (by design with callback-based loaders). Assert.NotNull(r.Value); } #endif @@ -260,13 +262,13 @@ public IEnumerator Release_DuringLoading_BestEffortCancel_ThenReleaseOnceComplet r.Release(); // Depending on your implementation, cancel may be called (best-effort) - Assert.GreaterOrEqual(stub.CancelCalls, 0); + Assert.AreEqual(stub.CancelCalls, 1); yield return WaitForTask(t); // After completion, release should have been performed // (Your SmartReference.Release() logic might release immediately or defer until callback.) - Assert.GreaterOrEqual(stub.ReleaseCalls, 1); + Assert.AreEqual(stub.ReleaseCalls, 0); } // ---------------------------- @@ -349,7 +351,7 @@ public Object Load(string path, Type type) return h; } - public void Release(global::SmartReference.Runtime.ISmartReferenceHandle handle, Object asset) + public void Release(global::SmartReference.Runtime.ISmartReferenceHandle handle) { ReleaseCalls++; // In a real loader, you'd release handle/asset (Addressables.Release, etc.) diff --git a/Tests/Runtime/SmartReference.Runtime.Tests.asmdef b/Tests/Runtime/SmartReference.Runtime.Tests.asmdef index 412441b..d575cef 100644 --- a/Tests/Runtime/SmartReference.Runtime.Tests.asmdef +++ b/Tests/Runtime/SmartReference.Runtime.Tests.asmdef @@ -4,7 +4,8 @@ "references": [ "UnityEngine.TestRunner", "UnityEditor.TestRunner", - "SmartReference.Runtime" + "SmartReference.Runtime", + "UniTask" ], "includePlatforms": [], "excludePlatforms": [], 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 From f982c1386ffc37a264b80eb6ad1843c57a0fc077 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Tue, 23 Dec 2025 22:10:55 +0800 Subject: [PATCH 16/18] Fix bug & simplify logic --- Runtime/Loaders/AddressablesLoader.cs | 7 ++- Runtime/Loaders/ISmartReferenceLoader.cs | 3 +- Runtime/Loaders/ResourcesLoader.cs | 56 ++++++------------------ Runtime/SmartReference.cs | 3 +- Tests/Runtime/AssetSetup.cs | 4 +- Tools~/DocFx/docfx.json | 2 +- 6 files changed, 22 insertions(+), 53 deletions(-) diff --git a/Runtime/Loaders/AddressablesLoader.cs b/Runtime/Loaders/AddressablesLoader.cs index b9a4771..f28fb22 100644 --- a/Runtime/Loaders/AddressablesLoader.cs +++ b/Runtime/Loaders/AddressablesLoader.cs @@ -34,11 +34,10 @@ public ISmartReferenceHandle LoadAsync(string path, Type type, Action ca var handle = Addressables.LoadAssetAsync(path); handle.Completed += operation => { - if (operation.Status == AsyncOperationStatus.Succeeded) - { - callback?.Invoke(operation.Result); - } + var result = operation.Status == AsyncOperationStatus.Succeeded ? operation.Result : null; + callback?.Invoke(result); }; + var h = new AddressablesHandle { op = handle }; return h; } diff --git a/Runtime/Loaders/ISmartReferenceLoader.cs b/Runtime/Loaders/ISmartReferenceLoader.cs index dcaeb2e..c726f48 100644 --- a/Runtime/Loaders/ISmartReferenceLoader.cs +++ b/Runtime/Loaders/ISmartReferenceLoader.cs @@ -23,8 +23,7 @@ public interface ISmartReferenceLoader public ISmartReferenceHandle LoadAsync(string path, Type type, Action callback); /// - /// Release an asset/handle created by this loader. - /// If handle is null, loader may optionally attempt to release by asset reference. + /// Release an asset/handle created by this loader using the provided handle. /// /// The handle returned by `LoadAsync`. void Release(ISmartReferenceHandle handle); diff --git a/Runtime/Loaders/ResourcesLoader.cs b/Runtime/Loaders/ResourcesLoader.cs index cc4dd6b..75bdddd 100644 --- a/Runtime/Loaders/ResourcesLoader.cs +++ b/Runtime/Loaders/ResourcesLoader.cs @@ -38,61 +38,33 @@ private static string GetResourcesPath(string path) if (string.IsNullOrEmpty(path)) return path; - // Normalize slashes so Windows paths work. + // Normalize slashes for Windows paths var p = path.Replace('\\', '/'); + const string segment = "/Resources/"; - var idx = p.LastIndexOf(segment, StringComparison.OrdinalIgnoreCase); + var idx = p.IndexOf(segment, StringComparison.OrdinalIgnoreCase); if (idx < 0) { - const string segmentNoSlash = "/Resources"; - var idx2 = p.LastIndexOf(segmentNoSlash, StringComparison.OrdinalIgnoreCase); - if (idx2 >= 0) - { - var next = idx2 + segmentNoSlash.Length; - if (next == p.Length) - { - Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); - return StripExtension(p); - } - - if (p[next] == '/') - { - idx = idx2; - } - } + Debug.LogError($"[SmartReference] ResourcesLoader: Path does not contain '/Resources/': {path}"); + return StripExtension(p); } - string relative; - if (idx >= 0) - { - 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); - } - - relative = p.Substring(start); - } - else + var start = idx + segment.Length; + if (start >= p.Length) { - Debug.LogWarning($"[SmartReference] ResourcesLoader: Path '{path}' does not contain a Resources folder segment."); - relative = p; + Debug.LogError($"[SmartReference] ResourcesLoader: Path points to Resources folder, not an asset: {path}"); + return StripExtension(p); } + var relative = p.Substring(start); return StripExtension(relative); } - private static string StripExtension(string s) + private static string StripExtension(string path) { - var slash = s.LastIndexOf('/'); - var dot = s.LastIndexOf('.'); - if (dot > slash) - { - return s.Substring(0, dot); - } - - return s; + 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/SmartReference.cs b/Runtime/SmartReference.cs index 2b29ac9..43d3981 100644 --- a/Runtime/SmartReference.cs +++ b/Runtime/SmartReference.cs @@ -137,7 +137,6 @@ public void LoadAsync() { if (value != null) { - OnAsyncLoadComplete?.Invoke(value); return; } @@ -288,7 +287,7 @@ public void Release() private void CompleteInFlight(T result) { - OnAsyncLoadComplete?.Invoke(value); + OnAsyncLoadComplete?.Invoke(result); // Task if (inFlightTaskTcs != null) diff --git a/Tests/Runtime/AssetSetup.cs b/Tests/Runtime/AssetSetup.cs index 39e0f8e..fb602a8 100644 --- a/Tests/Runtime/AssetSetup.cs +++ b/Tests/Runtime/AssetSetup.cs @@ -22,9 +22,9 @@ public static class AssetSetup // Resources.Load path (relative to any Resources folder) public const string SoResourcesPath = SoAssetPath; - public const string TextureResourcesPath = "SmartReferenceTest/TestTex"; + public const string TextureResourcesPath = TextureAssetPath; public const string MaterialResourcesPath = "SmartReferenceTest/TestMat"; - public const string PrefabResourcesPath = "SmartReferenceTest/TestPrefab"; + public const string PrefabResourcesPath = PrefabAssetPath; public static void Setup() { diff --git a/Tools~/DocFx/docfx.json b/Tools~/DocFx/docfx.json index 28df51a..d072875 100644 --- a/Tools~/DocFx/docfx.json +++ b/Tools~/DocFx/docfx.json @@ -21,7 +21,7 @@ "_appTitle": "Smart Reference", "_appFooter": "Smart Reference", "_enableSearch": true, - "pdf": true, + "pdf": true }, "content": [ { From 0a651c01322b1e5f753768f9b72c94b3296198de Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Wed, 24 Dec 2025 10:49:10 +0800 Subject: [PATCH 17/18] Sync load handle & bug fix --- Runtime/Loaders/AddressablesLoader.cs | 16 ++++++---------- Runtime/Loaders/ISmartReferenceLoader.cs | 5 +++-- Runtime/Loaders/ResourcesLoader.cs | 8 +++++--- Runtime/SmartReference.cs | 18 ++++++++++++++---- Tests/Runtime/PlayModeTests.cs | 12 +++++------- Tools~/DocFx/docs/custom_loader.md | 4 ++-- 6 files changed, 35 insertions(+), 28 deletions(-) diff --git a/Runtime/Loaders/AddressablesLoader.cs b/Runtime/Loaders/AddressablesLoader.cs index f28fb22..4646dd4 100644 --- a/Runtime/Loaders/AddressablesLoader.cs +++ b/Runtime/Loaders/AddressablesLoader.cs @@ -9,24 +9,19 @@ namespace SmartReference.Runtime { public class AddressablesLoader: ISmartReferenceLoader { - public Object Load(string path, Type type) + public ISmartReferenceHandle Load(string path, Type type, out Object loadedObject) { - Object result = null; 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 ISmartReferenceHandle LoadAsync(string path, Type type, Action callback) @@ -47,6 +42,7 @@ public void Release(ISmartReferenceHandle handle) if (handle is AddressablesHandle { IsValid: true } addressablesHandle) { Addressables.Release(addressablesHandle.op); + addressablesHandle.op = default; } } diff --git a/Runtime/Loaders/ISmartReferenceLoader.cs b/Runtime/Loaders/ISmartReferenceLoader.cs index c726f48..c611d80 100644 --- a/Runtime/Loaders/ISmartReferenceLoader.cs +++ b/Runtime/Loaders/ISmartReferenceLoader.cs @@ -10,8 +10,9 @@ public interface ISmartReferenceLoader /// /// The path of the asset, begin with `Assets/`. /// The type of the asset. - /// The loaded Object. - public Object Load(string path, Type type); + /// 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. diff --git a/Runtime/Loaders/ResourcesLoader.cs b/Runtime/Loaders/ResourcesLoader.cs index 75bdddd..9554599 100644 --- a/Runtime/Loaders/ResourcesLoader.cs +++ b/Runtime/Loaders/ResourcesLoader.cs @@ -6,10 +6,12 @@ namespace SmartReference.Runtime { public class ResourcesLoader: ISmartReferenceLoader { - public Object Load(string path, Type type) + 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 ISmartReferenceHandle LoadAsync(string path, Type type, Action callback) @@ -17,7 +19,7 @@ public ISmartReferenceHandle LoadAsync(string path, Type type, Action ca var resourcesPath = GetResourcesPath(path); var request = Resources.LoadAsync(resourcesPath, type); request.completed += _ => callback?.Invoke(request.asset); - return new ResourcesHandle(); + return new ResourcesHandle { loadedObject = request.asset }; } public void Release(ISmartReferenceHandle handle) diff --git a/Runtime/SmartReference.cs b/Runtime/SmartReference.cs index 43d3981..a200b95 100644 --- a/Runtime/SmartReference.cs +++ b/Runtime/SmartReference.cs @@ -104,7 +104,8 @@ public T Value } } - public static implicit operator T(SmartReference reference) { + public static implicit operator T(SmartReference reference) + { return reference.Value; } @@ -119,13 +120,22 @@ public void Load() return; } - if (Loader == null) { + 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) { + handle = Loader.Load(path, typeof(T), out var result); + value = (T) result; + if (value == null) + { LogLoadAssetNullError(); } } diff --git a/Tests/Runtime/PlayModeTests.cs b/Tests/Runtime/PlayModeTests.cs index 99cccfc..a682741 100644 --- a/Tests/Runtime/PlayModeTests.cs +++ b/Tests/Runtime/PlayModeTests.cs @@ -260,14 +260,11 @@ public IEnumerator Release_DuringLoading_BestEffortCancel_ThenReleaseOnceComplet // Immediately request release while loading r.Release(); - - // Depending on your implementation, cancel may be called (best-effort) + Assert.AreEqual(stub.CancelCalls, 1); yield return WaitForTask(t); - - // After completion, release should have been performed - // (Your SmartReference.Release() logic might release immediately or defer until callback.) + Assert.AreEqual(stub.ReleaseCalls, 0); } @@ -337,10 +334,11 @@ public StubLoader(int delayFrames) this.delayFrames = Mathf.Max(0, delayFrames); } - public Object Load(string path, Type type) + public ISmartReferenceHandle Load(string path, Type type, out Object loadedObject) { LoadCalls++; - return CreateObject(type); + loadedObject = CreateObject(type); + return new H(); } public global::SmartReference.Runtime.ISmartReferenceHandle LoadAsync(string path, Type type, Action onComplete) diff --git a/Tools~/DocFx/docs/custom_loader.md b/Tools~/DocFx/docs/custom_loader.md index 352345a..5b06594 100644 --- a/Tools~/DocFx/docs/custom_loader.md +++ b/Tools~/DocFx/docs/custom_loader.md @@ -14,7 +14,7 @@ public class MyHandler : ISmartReferenceHandle public class MyLoader : ISmartReferenceLoader { - public Object Load(string path, Type type) + public ISmartReferenceHandle Load(string path, Type type, out Object loadedObject) { // Your custom synchronous load logic here } @@ -24,7 +24,7 @@ public class MyLoader : ISmartReferenceLoader // Your custom asynchronous load logic here } - public void Release(ISmartReferenceHandle handle, Object asset) + public void Release(ISmartReferenceHandle handle) { // Your custom release logic here } From f43cbe41ccd63e7b76114d8e34937ff80bfa9107 Mon Sep 17 00:00:00 2001 From: Xiao Jiang Date: Wed, 24 Dec 2025 16:08:07 +0800 Subject: [PATCH 18/18] Update doc & resources loader --- Runtime/Handles/ISmartReferenceHandle.cs | 2 +- Runtime/Handles/ResourcesHandle.cs | 2 +- Runtime/Loaders/ISmartReferenceLoader.cs | 6 ++++-- Runtime/Loaders/ResourcesLoader.cs | 16 +++++++++++++--- Runtime/SmartReference.cs | 2 +- Tools~/DocFx/docs/init.md | 2 +- 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/Runtime/Handles/ISmartReferenceHandle.cs b/Runtime/Handles/ISmartReferenceHandle.cs index 66f4492..7a22a25 100644 --- a/Runtime/Handles/ISmartReferenceHandle.cs +++ b/Runtime/Handles/ISmartReferenceHandle.cs @@ -2,7 +2,7 @@ { /// /// This is inherited by different handle types used by SmartReference. It stores current state of the reference. - /// It should be returned by `LoadAsync` method and passed to `Release` and `Cancel` methods of so the loader knows what assets to operate on. + /// 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 { diff --git a/Runtime/Handles/ResourcesHandle.cs b/Runtime/Handles/ResourcesHandle.cs index d863519..9a8de3d 100644 --- a/Runtime/Handles/ResourcesHandle.cs +++ b/Runtime/Handles/ResourcesHandle.cs @@ -4,7 +4,7 @@ namespace SmartReference.Runtime { public class ResourcesHandle : ISmartReferenceHandle { + public ResourceRequest request; public Object loadedObject; - public bool IsValid => loadedObject != null; } } \ No newline at end of file diff --git a/Runtime/Loaders/ISmartReferenceLoader.cs b/Runtime/Loaders/ISmartReferenceLoader.cs index c611d80..a534eb1 100644 --- a/Runtime/Loaders/ISmartReferenceLoader.cs +++ b/Runtime/Loaders/ISmartReferenceLoader.cs @@ -19,7 +19,9 @@ public interface ISmartReferenceLoader /// /// The path of the asset, begin with `Assets/`. /// the type of the asset. - /// The callback to invoke once the load has finished. + /// 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); @@ -31,7 +33,7 @@ public interface ISmartReferenceLoader /// /// 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 a loading is canceled, the `Release` method will be called to clean up the loaded asset. + /// 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); diff --git a/Runtime/Loaders/ResourcesLoader.cs b/Runtime/Loaders/ResourcesLoader.cs index 9554599..ad976fb 100644 --- a/Runtime/Loaders/ResourcesLoader.cs +++ b/Runtime/Loaders/ResourcesLoader.cs @@ -19,14 +19,24 @@ public ISmartReferenceHandle LoadAsync(string path, Type type, Action ca var resourcesPath = GetResourcesPath(path); var request = Resources.LoadAsync(resourcesPath, type); request.completed += _ => callback?.Invoke(request.asset); - return new ResourcesHandle { loadedObject = request.asset }; + return new ResourcesHandle { request = request }; } public void Release(ISmartReferenceHandle handle) { - if (handle is ResourcesHandle { IsValid: true } resourcesHandle) + if (handle is ResourcesHandle resourcesHandle) { - Resources.UnloadAsset(resourcesHandle.loadedObject); + if (resourcesHandle.loadedObject != null) + { + Resources.UnloadAsset(resourcesHandle.loadedObject); + } else if (resourcesHandle.request != null) + { + var loadedAsset = resourcesHandle.request.asset; + if (loadedAsset != null) + { + Resources.UnloadAsset(loadedAsset); + } + } } } diff --git a/Runtime/SmartReference.cs b/Runtime/SmartReference.cs index a200b95..6b669dc 100644 --- a/Runtime/SmartReference.cs +++ b/Runtime/SmartReference.cs @@ -249,7 +249,7 @@ public UniTask LoadAsyncUniTask(CancellationToken cancellationToken) /// /// Release the loaded asset. /// Safe to call multiple times. - /// If called during loading, attempts cancel the loading. + /// If called during loading, attempts to cancel the loading. /// public void Release() { diff --git a/Tools~/DocFx/docs/init.md b/Tools~/DocFx/docs/init.md index db66be3..3e13b4f 100644 --- a/Tools~/DocFx/docs/init.md +++ b/Tools~/DocFx/docs/init.md @@ -19,7 +19,7 @@ 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 the limitation, this loader is best suited for small projects, tools, or prototypes. +Because of this limitation, this loader is best suited for small projects, tools, or prototypes. ---