From 5765f411e9b7df6c82d66e96fde64064bedf02c2 Mon Sep 17 00:00:00 2001 From: cneicy Date: Mon, 17 Aug 2026 01:58:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(cordis):=20=E5=AE=8C=E6=88=90=E9=98=B6?= =?UTF-8?q?=E6=AE=B55=E9=85=8D=E7=BD=AE=E4=B8=8E=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Editor/ShrinkAppEditorMenu.cs | 2 +- .../ShrinkApp.Starter.Basic.Editor.asmdef | 1 + .../Editor/ShrinkAppBasicStarterGenerator.cs | 39 +- .../Modules/ShrinkApp.Starter.Basic/README.md | 3 + .../ShrinkAppBasicContextComposition.cs | 22 ++ .../ShrinkContext.AppAdapter/Editor.meta | 8 + .../ShrinkContext.AppAdapter.Editor.asmdef | 19 + ...hrinkContext.AppAdapter.Editor.asmdef.meta | 7 + .../Editor/ShrinkContextBenchmarkRunner.cs | 189 +++++++++ .../ShrinkContextBenchmarkRunner.cs.meta | 11 + .../Editor/ShrinkContextDiagnosticsWindow.cs | 370 ++++++++++++++++++ .../ShrinkContextDiagnosticsWindow.cs.meta | 11 + .../ShrinkContext.AppAdapter/README.md | 46 ++- .../Runtime/ShrinkAppCompositionProfile.cs | 282 +++++++++++++ .../ShrinkAppCompositionProfile.cs.meta | 11 + .../Runtime/ShrinkAppLoaderBootstrapper.cs | 3 + .../Runtime/ShrinkAppLoaderHost.cs | 80 +++- .../Tests/CompositionProfileTests.cs | 113 ++++++ .../Tests/CompositionProfileTests.cs.meta | 11 + .../Tests/ContextBenchmarkTests.cs | 27 ++ .../Tests/ContextBenchmarkTests.cs.meta | 11 + .../Tests/NativeWiringTests.cs | 1 + .../Tests/ShrinkAppInstallerComponentTests.cs | 5 + .../ShrinkContext.AppAdapter.Tests.asmdef | 1 + Assets/Modules/ShrinkContext.Core/README.md | 2 +- Assets/Modules/ShrinkModFramework/README.md | 2 + Assets/Resources/ShrinkAppComposition.asset | 45 +++ .../Resources/ShrinkAppComposition.asset.meta | 8 + CORDIS_MIGRATION.md | 10 +- 29 files changed, 1322 insertions(+), 18 deletions(-) create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Editor.meta create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContext.AppAdapter.Editor.asmdef create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContext.AppAdapter.Editor.asmdef.meta create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextBenchmarkRunner.cs create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextBenchmarkRunner.cs.meta create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextDiagnosticsWindow.cs create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextDiagnosticsWindow.cs.meta create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppCompositionProfile.cs create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppCompositionProfile.cs.meta create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Tests/CompositionProfileTests.cs create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Tests/CompositionProfileTests.cs.meta create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Tests/ContextBenchmarkTests.cs create mode 100644 Assets/Modules/ShrinkContext.AppAdapter/Tests/ContextBenchmarkTests.cs.meta create mode 100644 Assets/Resources/ShrinkAppComposition.asset create mode 100644 Assets/Resources/ShrinkAppComposition.asset.meta diff --git a/Assets/Modules/ShrinkApp.Core/Editor/ShrinkAppEditorMenu.cs b/Assets/Modules/ShrinkApp.Core/Editor/ShrinkAppEditorMenu.cs index 5240aa3..e95c817 100644 --- a/Assets/Modules/ShrinkApp.Core/Editor/ShrinkAppEditorMenu.cs +++ b/Assets/Modules/ShrinkApp.Core/Editor/ShrinkAppEditorMenu.cs @@ -24,7 +24,7 @@ namespace ShrinkApp.Editor // 旧版本曾生成过 m_Script=fileID:0 的无效占位资产。删除该占位后 // 才能在同一路径创建真正绑定 ShrinkAppSettings 类型的资产。 - if (AssetDatabase.LoadMainAssetAtPath(assetPath) != null) + if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(assetPath))) AssetDatabase.DeleteAsset(assetPath); var asset = ScriptableObject.CreateInstance(); diff --git a/Assets/Modules/ShrinkApp.Starter.Basic/Editor/ShrinkApp.Starter.Basic.Editor.asmdef b/Assets/Modules/ShrinkApp.Starter.Basic/Editor/ShrinkApp.Starter.Basic.Editor.asmdef index 66e68bd..73fa11f 100644 --- a/Assets/Modules/ShrinkApp.Starter.Basic/Editor/ShrinkApp.Starter.Basic.Editor.asmdef +++ b/Assets/Modules/ShrinkApp.Starter.Basic/Editor/ShrinkApp.Starter.Basic.Editor.asmdef @@ -5,6 +5,7 @@ "ShrinkApp.Starter.Basic.Runtime", "ShrinkApp.Core.Runtime", "ShrinkApp.Core.Editor", + "ShrinkContext.AppAdapter.Runtime", "ShrinkDataSaver.Runtime", "ShrinkDataSaver.Integration.App", "UnityEngine.UI" diff --git a/Assets/Modules/ShrinkApp.Starter.Basic/Editor/ShrinkAppBasicStarterGenerator.cs b/Assets/Modules/ShrinkApp.Starter.Basic/Editor/ShrinkAppBasicStarterGenerator.cs index b80a754..a6394bb 100644 --- a/Assets/Modules/ShrinkApp.Starter.Basic/Editor/ShrinkAppBasicStarterGenerator.cs +++ b/Assets/Modules/ShrinkApp.Starter.Basic/Editor/ShrinkAppBasicStarterGenerator.cs @@ -1,5 +1,6 @@ #if UNITY_EDITOR using ShrinkApp.Editor; +using ShrinkContext.AppAdapter; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; @@ -10,6 +11,8 @@ namespace ShrinkApp.Starter.Basic.Editor public static class ShrinkAppBasicStarterGenerator { private const string MenuPath = "ShrinkApp/Starter/生成 Basic Entry 场景"; + private const string CompositionMenuPath = "ShrinkApp/Starter/创建默认 Cordis Composition"; + private const string CompositionPath = "Assets/Resources/ShrinkAppComposition.asset"; [MenuItem(MenuPath)] public static void Generate() @@ -21,13 +24,34 @@ namespace ShrinkApp.Starter.Basic.Editor Debug.Log("[ShrinkApp.Starter.Basic] 已生成 Basic Entry 场景与配置。"); } + [MenuItem(CompositionMenuPath)] + public static void CreateDefaultCompositionAsset() + { + EnsureResourcesDirectory(); + var existing = AssetDatabase.LoadAssetAtPath(CompositionPath); + if (existing != null) + { + Selection.activeObject = existing; + return; + } + + // 修复脚本/asmdef 变更期间可能留下的无主 .asset,而不是在同一路径叠加创建。 + if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(CompositionPath))) + AssetDatabase.DeleteAsset(CompositionPath); + + var composition = ScriptableObject.CreateInstance(); + composition.SetDocument(ShrinkAppBasicContextComposition.CreateDefaultDocument()); + AssetDatabase.CreateAsset(composition, CompositionPath); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Selection.activeObject = composition; + } + private static void EnsureAssets() { ShrinkAppEditorMenu.CreateSettingsAsset(); - const string resourcesDir = "Assets/Resources"; - if (!AssetDatabase.IsValidFolder(resourcesDir)) - AssetDatabase.CreateFolder("Assets", "Resources"); + EnsureResourcesDirectory(); const string dataSaverSettingsPath = "Assets/Resources/ShrinkDataSaverSettings.asset"; if (AssetDatabase.LoadAssetAtPath(dataSaverSettingsPath) == null) @@ -51,6 +75,15 @@ namespace ShrinkApp.Starter.Basic.Editor starterAsset.maxCommandOutputLines = 12; AssetDatabase.CreateAsset(starterAsset, starterSettingsPath); } + + CreateDefaultCompositionAsset(); + } + + private static void EnsureResourcesDirectory() + { + const string resourcesDir = "Assets/Resources"; + if (!AssetDatabase.IsValidFolder(resourcesDir)) + AssetDatabase.CreateFolder("Assets", "Resources"); } private static void BuildScene() diff --git a/Assets/Modules/ShrinkApp.Starter.Basic/README.md b/Assets/Modules/ShrinkApp.Starter.Basic/README.md index 1512553..223581f 100644 --- a/Assets/Modules/ShrinkApp.Starter.Basic/README.md +++ b/Assets/Modules/ShrinkApp.Starter.Basic/README.md @@ -9,10 +9,13 @@ - 生成 `ShrinkAppSettings.asset` - 生成或补齐 `ShrinkDataSaverSettings.asset` - 生成 `ShrinkAppBasicStarterSettings.asset` +- 生成 `ShrinkAppComposition.asset`(7 个默认 Cordis 条目;组件工厂仍由代码组合根提供) - 生成 `Assets/Scenes/ShrinkAppEntry.unity` - 生成最小示例 UI 与 `ShrinkAppBasicSampleController` - 演示 `ShrinkApp + ShrinkDataSaver + Command + Network` 的最小闭环 +可以单独执行 `ShrinkApp/Starter/创建默认 Cordis Composition` 创建或修复组合资产,不需要重建场景。 + 当前示例场景内置的最小入口已经扩成一个小型调试台,包括: - `Save Slot 0` diff --git a/Assets/Modules/ShrinkApp.Starter.Basic/Runtime/ShrinkAppBasicContextComposition.cs b/Assets/Modules/ShrinkApp.Starter.Basic/Runtime/ShrinkAppBasicContextComposition.cs index a4a91e3..53409b5 100644 --- a/Assets/Modules/ShrinkApp.Starter.Basic/Runtime/ShrinkAppBasicContextComposition.cs +++ b/Assets/Modules/ShrinkApp.Starter.Basic/Runtime/ShrinkAppBasicContextComposition.cs @@ -1,5 +1,6 @@ #nullable enable +using System.Collections.Generic; using ShrinkCommand.Integration.App; using ShrinkCommand.Integration; using ShrinkContext.AppAdapter; @@ -17,6 +18,19 @@ namespace ShrinkApp.Starter.Basic /// public static class ShrinkAppBasicContextComposition { + private static readonly string[] DefaultModuleIdValues = + { + "shrink.network", + "shrink.command", + "shrink.datasaver", + "shrink.integration.command-network", + "shrink.integration.command-eventbus", + "shrink.integration.datasaver-eventbus", + "shrink.integration.network-eventbus" + }; + + public static IReadOnlyList DefaultModuleIds => DefaultModuleIdValues; + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void RegisterDefaultComposition() { @@ -40,5 +54,13 @@ namespace ShrinkApp.Starter.Basic host.AddModuleComponent("shrink.integration.network-eventbus", static () => new ShrinkNetworkEventBusComponent()); } + + public static ShrinkAppCompositionDocument CreateDefaultDocument() + { + var entries = new List(DefaultModuleIdValues.Length); + foreach (var moduleId in DefaultModuleIdValues) + entries.Add(new ShrinkAppCompositionEntry(moduleId)); + return new ShrinkAppCompositionDocument(includeUnlistedEntries: false, entries); + } } } diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Editor.meta b/Assets/Modules/ShrinkContext.AppAdapter/Editor.meta new file mode 100644 index 0000000..9ff0158 --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c5f779c094bc74b43a46c1d45002a7a1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContext.AppAdapter.Editor.asmdef b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContext.AppAdapter.Editor.asmdef new file mode 100644 index 0000000..0265b6a --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContext.AppAdapter.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "ShrinkContext.AppAdapter.Editor", + "rootNamespace": "ShrinkContext.AppAdapter.Editor", + "references": [ + "ShrinkContext.AppAdapter.Runtime", + "ShrinkContext.Core.Runtime", + "UniTask" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContext.AppAdapter.Editor.asmdef.meta b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContext.AppAdapter.Editor.asmdef.meta new file mode 100644 index 0000000..6852628 --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContext.AppAdapter.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 71b936124873ba345a9430e664e7cfcd +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextBenchmarkRunner.cs b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextBenchmarkRunner.cs new file mode 100644 index 0000000..afc84e0 --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextBenchmarkRunner.cs @@ -0,0 +1,189 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Cysharp.Threading.Tasks; + +namespace ShrinkContext.AppAdapter.Editor +{ + public sealed class ShrinkContextBenchmarkReport + { + internal ShrinkContextBenchmarkReport(int unrelatedFiberCount, int reloadIterations, + double notifyMilliseconds, long notificationDispatchCount, long notificationCandidateVisits, + long naiveFiberVisits, int consumerApplyCount, int failureIterations, + int restoredFailureCount, double restoreMilliseconds, long managedMemoryDeltaBytes) + { + UnrelatedFiberCount = unrelatedFiberCount; + ReloadIterations = reloadIterations; + NotifyMilliseconds = notifyMilliseconds; + NotificationDispatchCount = notificationDispatchCount; + NotificationCandidateVisits = notificationCandidateVisits; + NaiveFiberVisits = naiveFiberVisits; + ConsumerApplyCount = consumerApplyCount; + FailureIterations = failureIterations; + RestoredFailureCount = restoredFailureCount; + RestoreMilliseconds = restoreMilliseconds; + ManagedMemoryDeltaBytes = managedMemoryDeltaBytes; + } + + public int UnrelatedFiberCount { get; } + public int ReloadIterations { get; } + public double NotifyMilliseconds { get; } + public long NotificationDispatchCount { get; } + public long NotificationCandidateVisits { get; } + public long NaiveFiberVisits { get; } + public int ConsumerApplyCount { get; } + public int FailureIterations { get; } + public int RestoredFailureCount { get; } + public double RestoreMilliseconds { get; } + public long ManagedMemoryDeltaBytes { get; } + } + + /// Editor 可重复容量基准;耗时仅报告,不作为跨机器通过阈值。 + public static class ShrinkContextBenchmarkRunner + { + public static async UniTask RunAsync( + int unrelatedFiberCount = 1000, int reloadIterations = 100, int failureIterations = 25) + { + if (unrelatedFiberCount < 0) + throw new ArgumentOutOfRangeException(nameof(unrelatedFiberCount)); + if (reloadIterations <= 0) + throw new ArgumentOutOfRangeException(nameof(reloadIterations)); + if (failureIterations <= 0) + throw new ArgumentOutOfRangeException(nameof(failureIterations)); + + var memoryBefore = GC.GetTotalMemory(false); + var runtime = new ShrinkContextRuntime(); + var targetConsumer = new BenchmarkConsumer("benchmark.target"); + runtime.Use(targetConsumer); + for (var i = 0; i < unrelatedFiberCount; i++) + runtime.Use(new BenchmarkConsumer("benchmark.unrelated." + i)); + + var notifyStopwatch = Stopwatch.StartNew(); + for (var i = 0; i < reloadIterations; i++) + { + var provider = runtime.Use(new BenchmarkProvider("provider-" + i, "benchmark.target")); + if (provider.LastError != null) + throw new InvalidOperationException("Benchmark provider failed.", provider.LastError); + await runtime.RetireAsync(provider); + } + notifyStopwatch.Stop(); + + var notifySnapshot = runtime.CaptureDiagnostic(); + var naiveFiberVisits = notifySnapshot.Notifications.DispatchCount * runtime.Fibers.Count; + + var catalog = new ShrinkComponentCatalog(); + catalog.Register("stable", () => new BenchmarkProvider("stable", "benchmark.restore")); + catalog.Register("failing", () => new BenchmarkFailingProvider("benchmark.restore")); + var restoreRuntime = new ShrinkContextRuntime(); + var loader = new ShrinkContextLoader(restoreRuntime, catalog); + await loader.ApplyAsync(new[] { new ShrinkLoaderEntry("provider", "stable") }); + + var restoredFailures = 0; + var restoreStopwatch = Stopwatch.StartNew(); + for (var i = 0; i < failureIterations; i++) + { + try + { + await loader.ApplyAsync(new[] { new ShrinkLoaderEntry("provider", "failing") }); + } + catch (ShrinkLoaderException) + { + if (loader.LastTransaction?.PreviousCompositionRestored == true && + loader.TryGetFiber("provider", out var restored) && + restored.State == ShrinkFiberState.Active) + { + restoredFailures++; + } + } + } + restoreStopwatch.Stop(); + + await runtime.ShutdownAsync(); + await restoreRuntime.ShutdownAsync(); + var memoryAfter = GC.GetTotalMemory(false); + + return new ShrinkContextBenchmarkReport( + unrelatedFiberCount, + reloadIterations, + notifyStopwatch.Elapsed.TotalMilliseconds, + notifySnapshot.Notifications.DispatchCount, + notifySnapshot.Notifications.CandidateVisitCount, + naiveFiberVisits, + targetConsumer.ApplyCount, + failureIterations, + restoredFailures, + restoreStopwatch.Elapsed.TotalMilliseconds, + memoryAfter - memoryBefore); + } + + private sealed class BenchmarkProvider : IShrinkComponent + { + private readonly string[] _provide; + + public BenchmarkProvider(string name, string key) + { + Name = name; + Key = key; + _provide = new[] { key }; + } + + public string Name { get; } + private string Key { get; } + public IReadOnlyList Inject => Array.Empty(); + public IReadOnlyList Provide => _provide; + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + ctx.Set(Key, Name); + return UniTask.CompletedTask; + } + } + + private sealed class BenchmarkConsumer : IShrinkComponent + { + private readonly string[] _inject; + + public BenchmarkConsumer(string key) + { + Key = key; + _inject = new[] { key }; + } + + private string Key { get; } + public string Name => "consumer:" + Key; + public int ApplyCount { get; private set; } + public IReadOnlyList Inject => _inject; + public IReadOnlyList Provide => Array.Empty(); + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + ctx.Get(Key); + ApplyCount++; + return UniTask.CompletedTask; + } + } + + private sealed class BenchmarkFailingProvider : IShrinkComponent + { + private readonly string[] _provide; + + public BenchmarkFailingProvider(string key) + { + Key = key; + _provide = new[] { key }; + } + + private string Key { get; } + public string Name => "benchmark-failing"; + public IReadOnlyList Inject => Array.Empty(); + public IReadOnlyList Provide => _provide; + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + ctx.Set(Key, Name); + throw new InvalidOperationException("Expected benchmark replacement failure."); + } + } + } +} diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextBenchmarkRunner.cs.meta b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextBenchmarkRunner.cs.meta new file mode 100644 index 0000000..05ac4f5 --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextBenchmarkRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db4f7dd3ed1ecab4e8a04fd83ca0b0fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextDiagnosticsWindow.cs b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextDiagnosticsWindow.cs new file mode 100644 index 0000000..d2f6c3d --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextDiagnosticsWindow.cs @@ -0,0 +1,370 @@ +#nullable enable +using System; +using System.Collections; +using System.IO; +using System.Linq; +using System.Reflection; +using Cysharp.Threading.Tasks; +using UnityEditor; +using UnityEngine; + +namespace ShrinkContext.AppAdapter.Editor +{ + public sealed class ShrinkContextDiagnosticsWindow : EditorWindow + { + private readonly string[] _tabs = { "运行时", "组合配置", "容量基准" }; + private int _selectedTab; + private Vector2 _runtimeScroll; + private Vector2 _jsonScroll; + private Vector2 _benchmarkScroll; + private bool _autoRefresh = true; + private ShrinkAppCompositionProfile? _profile; + private string _compositionJson = string.Empty; + private string? _compositionError; + private int _unrelatedFibers = 1000; + private int _reloadIterations = 100; + private int _failureIterations = 25; + private bool _benchmarkRunning; + private ShrinkContextBenchmarkReport? _benchmarkReport; + private string? _benchmarkError; + + [MenuItem("ShrinkSDK/Cordis/诊断与组合")] + public static void ShowWindow() + { + var window = GetWindow("Cordis Diagnostics"); + window.minSize = new Vector2(720f, 520f); + } + + private void OnEnable() + { + _profile = ShrinkAppCompositionProfile.LoadDefault(); + RefreshCompositionJson(); + } + + private void OnInspectorUpdate() + { + if (_autoRefresh && _selectedTab == 0) + Repaint(); + } + + private void OnGUI() + { + DrawToolbar(); + switch (_selectedTab) + { + case 0: + DrawRuntime(); + break; + case 1: + DrawComposition(); + break; + case 2: + DrawBenchmarks(); + break; + } + } + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + _selectedTab = GUILayout.Toolbar(_selectedTab, _tabs, EditorStyles.toolbarButton); + GUILayout.FlexibleSpace(); + if (_selectedTab == 0) + _autoRefresh = GUILayout.Toggle(_autoRefresh, "自动刷新", EditorStyles.toolbarButton); + EditorGUILayout.EndHorizontal(); + } + + private void DrawRuntime() + { + if (!Application.isPlaying) + { + EditorGUILayout.HelpBox("进入 Play Mode 后显示真实 ShrinkAppLoaderHost 状态。", MessageType.Info); + DrawModDiagnostics(); + return; + } + + var bootstrap = ShrinkAppLoaderBootstrapper.Instance; + if (bootstrap == null || bootstrap.Host == null) + { + EditorGUILayout.HelpBox("当前 Play Mode 没有 ShrinkAppLoaderBootstrapper。", MessageType.Warning); + DrawModDiagnostics(); + return; + } + + var host = bootstrap.Host; + var snapshot = host.Context.CaptureDiagnostic(); + EditorGUILayout.LabelField("ContextLoader", EditorStyles.boldLabel); + EditorGUILayout.LabelField("状态", host.IsRunning ? "运行中" : "未启动"); + EditorGUILayout.LabelField("模块", $"active={host.ActiveModuleIds.Count}, waiting={host.WaitingModuleIds.Count}"); + EditorGUILayout.LabelField("余效应", $"bindings={snapshot.BindingCount}, indexedKeys={snapshot.Notifications.IndexedKeyCount}"); + EditorGUILayout.LabelField("通知", $"dispatch={snapshot.Notifications.DispatchCount}, candidateVisits={snapshot.Notifications.CandidateVisitCount}"); + + DrawTransaction(host.Loader.LastTransaction); + EditorGUILayout.Space(6f); + EditorGUILayout.LabelField($"Fibers ({snapshot.Fibers.Count})", EditorStyles.boldLabel); + _runtimeScroll = EditorGUILayout.BeginScrollView(_runtimeScroll); + foreach (var fiber in snapshot.Fibers) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField($"#{fiber.Uid} {fiber.Name}", EditorStyles.boldLabel); + GUILayout.FlexibleSpace(); + EditorGUILayout.LabelField(fiber.Status.ToString(), GUILayout.Width(90f)); + EditorGUILayout.EndHorizontal(); + EditorGUILayout.LabelField("target", fiber.TargetProviderUids.Count == 0 + ? "-" + : string.Join(", ", fiber.TargetProviderUids)); + EditorGUILayout.LabelField("inject", fiber.Inject.Count == 0 ? "-" : string.Join(", ", fiber.Inject)); + EditorGUILayout.LabelField("provide", fiber.Provide.Count == 0 ? "-" : string.Join(", ", fiber.Provide)); + if (fiber.InterceptKeys.Count > 0) + EditorGUILayout.LabelField("intercept", string.Join(", ", fiber.InterceptKeys)); + if (!string.IsNullOrWhiteSpace(fiber.ErrorMessage)) + EditorGUILayout.HelpBox($"{fiber.ErrorType}: {fiber.ErrorMessage}", MessageType.Error); + foreach (var dependency in fiber.Dependencies.Where(item => !item.IsSatisfied)) + { + EditorGUILayout.LabelField("waiting", + $"{dependency.Key} @ {dependency.Realm}; potential=[{string.Join(", ", dependency.PotentialProviderUids)}]"); + } + EditorGUILayout.EndVertical(); + } + EditorGUILayout.EndScrollView(); + DrawModDiagnostics(); + } + + private static void DrawTransaction(ShrinkLoaderTransactionDiagnostic? transaction) + { + EditorGUILayout.Space(6f); + EditorGUILayout.LabelField("最近事务", EditorStyles.boldLabel); + if (transaction == null) + { + EditorGUILayout.LabelField("尚无事务。"); + return; + } + + EditorGUILayout.LabelField("阶段", $"#{transaction.Generation} {transaction.Phase}"); + if (!string.IsNullOrWhiteSpace(transaction.CurrentEntryId)) + EditorGUILayout.LabelField("条目", transaction.CurrentEntryId); + if (!string.IsNullOrWhiteSpace(transaction.ErrorMessage)) + { + var restore = transaction.RestoreAttempted + ? $"restore={transaction.PreviousCompositionRestored}" + : "restore=not-attempted"; + EditorGUILayout.HelpBox($"{transaction.ErrorType}: {transaction.ErrorMessage}\n{restore}", + transaction.PreviousCompositionRestored ? MessageType.Warning : MessageType.Error); + } + } + + private void DrawComposition() + { + var selected = (ShrinkAppCompositionProfile?)EditorGUILayout.ObjectField( + "Profile", _profile, typeof(ShrinkAppCompositionProfile), false); + if (selected != _profile) + { + _profile = selected; + RefreshCompositionJson(); + } + + if (_profile == null) + { + EditorGUILayout.HelpBox( + "未选择 Composition Profile。可通过 Assets/Create/ShrinkSDK/Cordis/Composition Profile 创建。", + MessageType.Info); + return; + } + + EditorGUILayout.LabelField("条目", _profile.Entries.Count.ToString()); + EditorGUILayout.LabelField("未列出条目", _profile.IncludeUnlistedEntries ? "保留" : "排除"); + if (Application.isPlaying) + EditorGUILayout.HelpBox("修改资产只影响下一次宿主启动,不会重写当前运行中的组合。", MessageType.Info); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("从资产刷新 JSON")) + RefreshCompositionJson(); + if (GUILayout.Button("将 JSON 写回资产")) + ApplyCompositionJson(); + if (GUILayout.Button("导入 JSON...")) + ImportCompositionJson(); + if (GUILayout.Button("导出 JSON...")) + ExportCompositionJson(); + EditorGUILayout.EndHorizontal(); + + if (!string.IsNullOrWhiteSpace(_compositionError)) + EditorGUILayout.HelpBox(_compositionError, MessageType.Error); + + _jsonScroll = EditorGUILayout.BeginScrollView(_jsonScroll); + _compositionJson = EditorGUILayout.TextArea(_compositionJson, GUILayout.ExpandHeight(true)); + EditorGUILayout.EndScrollView(); + } + + private void RefreshCompositionJson() + { + try + { + _compositionJson = _profile != null ? _profile.ToJson() : string.Empty; + _compositionError = null; + } + catch (Exception ex) + { + _compositionError = ex.Message; + } + } + + private void ApplyCompositionJson() + { + if (_profile == null) + return; + try + { + var document = ShrinkAppCompositionDocument.FromJson(_compositionJson); + Undo.RecordObject(_profile, "Apply Cordis composition JSON"); + _profile.SetDocument(document); + EditorUtility.SetDirty(_profile); + AssetDatabase.SaveAssets(); + _compositionError = null; + } + catch (Exception ex) + { + _compositionError = ex.Message; + } + } + + private void ImportCompositionJson() + { + var path = EditorUtility.OpenFilePanel("导入 Cordis Composition", string.Empty, "json"); + if (string.IsNullOrWhiteSpace(path)) + return; + try + { + _compositionJson = File.ReadAllText(path); + ShrinkAppCompositionDocument.FromJson(_compositionJson); + _compositionError = null; + } + catch (Exception ex) + { + _compositionError = ex.Message; + } + } + + private void ExportCompositionJson() + { + var path = EditorUtility.SaveFilePanel("导出 Cordis Composition", string.Empty, + "ShrinkAppComposition", "json"); + if (string.IsNullOrWhiteSpace(path)) + return; + try + { + ShrinkAppCompositionDocument.FromJson(_compositionJson); + File.WriteAllText(path, _compositionJson); + _compositionError = null; + } + catch (Exception ex) + { + _compositionError = ex.Message; + } + } + + private void DrawBenchmarks() + { + EditorGUILayout.HelpBox( + "耗时只用于本机前后对比;自动测试验证倒排索引候选规模和失败恢复次数,不设置毫秒阈值。", + MessageType.Info); + _unrelatedFibers = Mathf.Max(0, EditorGUILayout.IntField("无关 fibers", _unrelatedFibers)); + _reloadIterations = Mathf.Max(1, EditorGUILayout.IntField("provider 替换次数", _reloadIterations)); + _failureIterations = Mathf.Max(1, EditorGUILayout.IntField("失败恢复次数", _failureIterations)); + + EditorGUI.BeginDisabledGroup(_benchmarkRunning || Application.isPlaying); + if (GUILayout.Button(_benchmarkRunning ? "运行中..." : "运行基准")) + RunBenchmarksAsync().Forget(); + EditorGUI.EndDisabledGroup(); + if (Application.isPlaying) + EditorGUILayout.HelpBox("容量基准仅在 Edit Mode 运行,避免污染当前游戏上下文。", MessageType.Warning); + if (!string.IsNullOrWhiteSpace(_benchmarkError)) + EditorGUILayout.HelpBox(_benchmarkError, MessageType.Error); + + _benchmarkScroll = EditorGUILayout.BeginScrollView(_benchmarkScroll); + if (_benchmarkReport != null) + { + EditorGUILayout.LabelField("Notify / Reload", EditorStyles.boldLabel); + EditorGUILayout.LabelField("耗时", $"{_benchmarkReport.NotifyMilliseconds:F3} ms"); + EditorGUILayout.LabelField("dispatch", _benchmarkReport.NotificationDispatchCount.ToString()); + EditorGUILayout.LabelField("indexed candidate visits", _benchmarkReport.NotificationCandidateVisits.ToString()); + EditorGUILayout.LabelField("naive all-fiber visits", _benchmarkReport.NaiveFiberVisits.ToString()); + EditorGUILayout.LabelField("consumer apply", $"{_benchmarkReport.ConsumerApplyCount}/{_benchmarkReport.ReloadIterations}"); + EditorGUILayout.Space(6f); + EditorGUILayout.LabelField("失败恢复", EditorStyles.boldLabel); + EditorGUILayout.LabelField("耗时", $"{_benchmarkReport.RestoreMilliseconds:F3} ms"); + EditorGUILayout.LabelField("恢复成功", $"{_benchmarkReport.RestoredFailureCount}/{_benchmarkReport.FailureIterations}"); + EditorGUILayout.LabelField("GC 管理堆差值(噪声参考)", $"{_benchmarkReport.ManagedMemoryDeltaBytes} bytes"); + } + EditorGUILayout.EndScrollView(); + DrawModDiagnostics(); + } + + private async UniTaskVoid RunBenchmarksAsync() + { + _benchmarkRunning = true; + _benchmarkError = null; + try + { + _benchmarkReport = await ShrinkContextBenchmarkRunner.RunAsync( + _unrelatedFibers, _reloadIterations, _failureIterations); + } + catch (Exception ex) + { + _benchmarkError = ex.ToString(); + } + finally + { + _benchmarkRunning = false; + Repaint(); + } + } + + private static void DrawModDiagnostics() + { + EditorGUILayout.Space(8f); + EditorGUILayout.LabelField("外部程序集常驻", EditorStyles.boldLabel); + var snapshot = CaptureOptionalModDiagnostic(); + if (snapshot == null) + { + EditorGUILayout.LabelField("ShrinkModFramework 未载入或未提供诊断 API。"); + return; + } + + var current = ReadProperty(snapshot, "CurrentRevisionCount"); + var resident = ReadProperty(snapshot, "ResidentRevisionCount"); + var bytes = ReadProperty(snapshot, "EstimatedResidentBytes"); + var limit = ReadProperty(snapshot, "SoftLimit"); + var exceeded = ReadProperty(snapshot, "IsSoftLimitExceeded"); + EditorGUILayout.LabelField("汇总", + $"current={current}, resident={resident}, bytes={bytes}, softLimit={limit}, exceeded={exceeded}"); + + if (ReadProperty(snapshot, "ResidentRevisions") is not IEnumerable revisions) + return; + foreach (var revision in revisions) + { + if (revision == null) + continue; + var marker = Equals(ReadProperty(revision, "IsCurrent"), true) ? "current" : "resident"; + var name = ReadProperty(revision, "AssemblyName"); + var hash = ReadProperty(revision, "Revision")?.ToString() ?? string.Empty; + if (hash.Length > 12) + hash = hash.Substring(0, 12); + EditorGUILayout.LabelField($"{marker}: {name}", + $"{hash}, {ReadProperty(revision, "LoadedBytes")} bytes"); + } + } + + private static object? CaptureOptionalModDiagnostic() + { + var type = AppDomain.CurrentDomain.GetAssemblies() + .Select(assembly => assembly.GetType("ShrinkModFramework.ShrinkModDiagnostics", false)) + .FirstOrDefault(candidate => candidate != null); + var method = type?.GetMethod("CaptureExternalAssemblies", BindingFlags.Public | BindingFlags.Static); + return method?.Invoke(null, new object?[] { null }); + } + + private static object? ReadProperty(object instance, string name) => + instance.GetType().GetProperty(name, BindingFlags.Public | BindingFlags.Instance)?.GetValue(instance); + } +} diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextDiagnosticsWindow.cs.meta b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextDiagnosticsWindow.cs.meta new file mode 100644 index 0000000..f57dfd7 --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Editor/ShrinkContextDiagnosticsWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b6ea571e4b10e2488d1024c473528b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkContext.AppAdapter/README.md b/Assets/Modules/ShrinkContext.AppAdapter/README.md index 519efd9..94d42cb 100644 --- a/Assets/Modules/ShrinkContext.AppAdapter/README.md +++ b/Assets/Modules/ShrinkContext.AppAdapter/README.md @@ -7,8 +7,10 @@ ShrinkApp ↔ ShrinkContext 的桥接层:把现有 `IShrinkAppModuleInstaller` | 类型 | 职责 | |---|---| | `ShrinkAppInstallerComponent` | 安装器 → 组件:`DependsOn` 映射为 `app.module.*` 注入键(依赖缺失**等待**而非抛错);发布模块键;apply = RegisterServices + InitializeAsync | -| `ShrinkAppLoaderHost` | 加载器驱动的 ShrinkApp 宿主:发现安装器(注入覆盖可测试)、经 `ShrinkContextLoader` 增量协调、`SetModuleDisabledAsync` 运行中按模块开关、`Active/WaitingModuleIds` 状态查询、启动发布 `ShrinkAppStartedEvent` | -| `ShrinkAppLoaderBootstrapper` | `hostingMode == ContextLoader` 时在 BeforeSceneLoad 自动创建常驻宿主,并把服务容器接回 `ShrinkApp.Services`;Classic 模式完全不动 | +| `ShrinkAppLoaderHost` | 加载器驱动的 ShrinkApp 宿主:发现安装器(注入覆盖可测试)、应用声明式组合、经 `ShrinkContextLoader` 增量协调、运行中按模块开关 | +| `ShrinkAppCompositionProfile` | ScriptableObject/JSON 组合文档:描述条目启用、显式排除、isolate 与 intercept;组件工厂仍由代码目录注册 | +| `ShrinkAppLoaderBootstrapper` | `hostingMode == ContextLoader` 时自动创建常驻宿主,应用 `Resources/ShrinkAppComposition`,并把服务容器接回 `ShrinkApp.Services` | +| `ShrinkContextDiagnosticsWindow` | `ShrinkSDK/Cordis/诊断与组合`:查看运行时 fiber/依赖/事务、编辑 Profile JSON、读取可选的 ModFramework 常驻统计并运行容量基准 | ## 启用方式 @@ -20,6 +22,39 @@ var host = ShrinkAppLoaderBootstrapper.Instance!.Host; await host.SetModuleDisabledAsync("shrink.network", true); ``` +## 声明式组合 + +默认资源名为 `Assets/Resources/ShrinkAppComposition.asset`。组合根先用代码注册安装器或原生组件工厂,Bootstrapper 再应用 Profile;配置文件不能通过类型名反射实例化任意组件。 + +Profile 的 JSON 结构如下: + +```json +{ + "includeUnlistedEntries": false, + "entries": [ + { + "id": "shrink.datasaver", + "enabled": true, + "isolate": [], + "intercept": [ + { + "key": "shrink.service.datasaver", + "metadata": [ + { "name": "access", "value": "read-only" } + ] + } + ] + } + ] +} +``` + +- `includeUnlistedEntries = false` 时,没有列出的已注册模块不会进入 loader。 +- `ShrinkAppSettings.disabledModuleIds` 与 Profile 的 `enabled = false` 取并集;Profile 不会偷偷启用 Settings 已禁用的模块。 +- isolate 与 intercept 都在进入 loader 前校验空键和重复键。 +- intercept metadata 当前是字符串键值,由领域访问策略解释,不是任意对象反序列化入口。 +- Profile 可引用一个 JSON `TextAsset` 作为覆盖;诊断窗口也支持 JSON 导入、校验、写回和导出。 + ## 行为差异(对照经典 ShrinkAppHost) | 场景 | ClassicHost | LoaderHost | @@ -32,9 +67,10 @@ await host.SetModuleDisabledAsync("shrink.network", true); ## 已知边界 - `ShrinkAppServices` 无注销能力:安装器停用不撤回已注册服务(重新启用会重跑 RegisterServices,同键覆盖)。 -- 配置变化走条目重建而非组件自决 diff(`ShrinkContext.Core` 加载器原型语义)。 -- 编排配置当前由代码构建条目;持久化配置资产(ScriptableObject/JSON)属后续切片。 +- config/isolate 变化走条目重建;intercept metadata 可原位更新而不改变 fiber generation。 +- 运行中不支持重新应用整份 Profile;资产修改在下一次宿主启动生效,运行中模块开关仍使用 `SetModuleDisabledAsync`。 +- Editor 基准的毫秒数和 GC 管理堆差值只用于同机前后对比;自动测试只断言索引候选规模和事务恢复结果。 ## 测试 -`Tests/` 覆盖:安装器组件 5 项(依赖等待/次序/供给冲突/退役/配置缺失)+ LoaderHost 6 项(启动事件、运行中开关、设置级初始禁用、未知模块、重复 ModuleId、Shutdown)。 +`Tests/` 覆盖安装器生命周期、LoaderHost 开关、原生 Starter 接线、Profile/JSON 校验、显式条目选择、设置禁用优先级,以及 notify/失败恢复结构性基准。 diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppCompositionProfile.cs b/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppCompositionProfile.cs new file mode 100644 index 0000000..1d093ab --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppCompositionProfile.cs @@ -0,0 +1,282 @@ +#nullable enable +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace ShrinkContext.AppAdapter +{ + [Serializable] + public sealed class ShrinkAppCompositionIsolate + { + public string key = string.Empty; + public string realm = string.Empty; + + public ShrinkAppCompositionIsolate() + { + } + + public ShrinkAppCompositionIsolate(string key, string realm) + { + this.key = key; + this.realm = realm; + } + + internal ShrinkAppCompositionIsolate Clone() => new(key, realm); + } + + [Serializable] + public sealed class ShrinkAppCompositionMetadata + { + public string name = string.Empty; + public string value = string.Empty; + + public ShrinkAppCompositionMetadata() + { + } + + public ShrinkAppCompositionMetadata(string name, string value) + { + this.name = name; + this.value = value; + } + + internal ShrinkAppCompositionMetadata Clone() => new(name, value); + } + + [Serializable] + public sealed class ShrinkAppCompositionIntercept + { + public string key = string.Empty; + public List metadata = new(); + + public ShrinkAppCompositionIntercept() + { + } + + public ShrinkAppCompositionIntercept(string key, + IEnumerable? metadata = null) + { + this.key = key; + if (metadata != null) + { + foreach (var item in metadata) + this.metadata.Add(item?.Clone() ?? throw new ArgumentException("Metadata must not contain null.")); + } + } + + internal ShrinkAppCompositionIntercept Clone() => new(key, metadata); + } + + [Serializable] + public sealed class ShrinkAppCompositionEntry + { + public string id = string.Empty; + public bool enabled = true; + public List isolate = new(); + public List intercept = new(); + + public ShrinkAppCompositionEntry() + { + } + + public ShrinkAppCompositionEntry(string id, bool enabled = true, + IEnumerable? isolate = null, + IEnumerable? intercept = null) + { + this.id = id; + this.enabled = enabled; + if (isolate != null) + { + foreach (var item in isolate) + this.isolate.Add(item?.Clone() ?? throw new ArgumentException("Isolate must not contain null.")); + } + + if (intercept != null) + { + foreach (var item in intercept) + this.intercept.Add(item?.Clone() ?? throw new ArgumentException("Intercept must not contain null.")); + } + } + + internal string NormalizedId => id?.Trim() ?? string.Empty; + + internal ShrinkAppCompositionEntry Clone() => new(id, enabled, isolate, intercept); + + internal IReadOnlyDictionary? BuildIsolate() + { + if (isolate == null || isolate.Count == 0) + return null; + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var item in isolate) + result.Add(item.key.Trim(), item.realm.Trim()); + return result; + } + + internal IReadOnlyDictionary>? BuildIntercept() + { + if (intercept == null || intercept.Count == 0) + return null; + + var result = new Dictionary>(StringComparer.Ordinal); + foreach (var item in intercept) + { + var values = new Dictionary(StringComparer.Ordinal); + foreach (var pair in item.metadata) + values.Add(pair.name.Trim(), pair.value); + result.Add(item.key.Trim(), values); + } + + return result; + } + } + + /// + /// 可序列化的期望组合。组件工厂仍由代码目录注册;文档只控制条目选择、isolate 与 intercept。 + /// intercept metadata 使用字符串值,领域策略负责解释其语义。 + /// + [Serializable] + public sealed class ShrinkAppCompositionDocument + { + public bool includeUnlistedEntries = true; + public List entries = new(); + + public ShrinkAppCompositionDocument() + { + } + + public ShrinkAppCompositionDocument(bool includeUnlistedEntries, + IEnumerable? entries = null) + { + this.includeUnlistedEntries = includeUnlistedEntries; + if (entries != null) + { + foreach (var entry in entries) + this.entries.Add(entry?.Clone() ?? throw new ArgumentException("Entries must not contain null.")); + } + } + + public void Validate() + { + entries ??= new List(); + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in entries) + { + if (entry == null) + throw new ShrinkLoaderException("Composition entries must not contain null."); + if (string.IsNullOrWhiteSpace(entry.id)) + throw new ShrinkLoaderException("Composition entry id must not be empty."); + if (!ids.Add(entry.NormalizedId)) + throw new ShrinkLoaderException($"Duplicate composition entry id: '{entry.NormalizedId}'."); + + ValidateIsolate(entry); + ValidateIntercept(entry); + } + } + + public ShrinkAppCompositionDocument Clone() + { + Validate(); + return new ShrinkAppCompositionDocument(includeUnlistedEntries, entries); + } + + public string ToJson(bool prettyPrint = true) + { + Validate(); + return JsonUtility.ToJson(this, prettyPrint); + } + + public static ShrinkAppCompositionDocument FromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + throw new ArgumentException("Composition JSON must not be empty.", nameof(json)); + + var document = JsonUtility.FromJson(json); + if (document == null) + throw new ShrinkLoaderException("Composition JSON did not produce a document."); + document.entries ??= new List(); + document.Validate(); + return document; + } + + private static void ValidateIsolate(ShrinkAppCompositionEntry entry) + { + entry.isolate ??= new List(); + var keys = new HashSet(StringComparer.Ordinal); + foreach (var item in entry.isolate) + { + if (item == null || string.IsNullOrWhiteSpace(item.key) || string.IsNullOrWhiteSpace(item.realm)) + throw new ShrinkLoaderException( + $"Composition entry '{entry.NormalizedId}' has an empty isolate key or realm."); + if (!keys.Add(item.key.Trim())) + throw new ShrinkLoaderException( + $"Composition entry '{entry.NormalizedId}' has duplicate isolate key '{item.key.Trim()}'."); + } + } + + private static void ValidateIntercept(ShrinkAppCompositionEntry entry) + { + entry.intercept ??= new List(); + var keys = new HashSet(StringComparer.Ordinal); + foreach (var item in entry.intercept) + { + if (item == null || string.IsNullOrWhiteSpace(item.key)) + throw new ShrinkLoaderException( + $"Composition entry '{entry.NormalizedId}' has an empty intercept key."); + if (!keys.Add(item.key.Trim())) + throw new ShrinkLoaderException( + $"Composition entry '{entry.NormalizedId}' has duplicate intercept key '{item.key.Trim()}'."); + + item.metadata ??= new List(); + var names = new HashSet(StringComparer.Ordinal); + foreach (var pair in item.metadata) + { + if (pair == null || string.IsNullOrWhiteSpace(pair.name)) + throw new ShrinkLoaderException( + $"Composition entry '{entry.NormalizedId}' has empty intercept metadata."); + if (!names.Add(pair.name.Trim())) + throw new ShrinkLoaderException( + $"Composition entry '{entry.NormalizedId}' has duplicate metadata '{pair.name.Trim()}'."); + } + } + } + } + + [CreateAssetMenu(fileName = DefaultResourceName, menuName = "ShrinkSDK/Cordis/Composition Profile")] + public sealed class ShrinkAppCompositionProfile : ScriptableObject + { + public const string DefaultResourceName = "ShrinkAppComposition"; + + [SerializeField] private bool includeUnlistedEntries = true; + [SerializeField] private List entries = new(); + [Tooltip("设置后以该 TextAsset 的 JSON 文档为准;留空则使用上面的序列化条目。")] + [SerializeField] private TextAsset? jsonOverride; + + public bool IncludeUnlistedEntries => includeUnlistedEntries; + public IReadOnlyList Entries => + entries ??= new List(); + public TextAsset? JsonOverride => jsonOverride; + + public ShrinkAppCompositionDocument ResolveDocument() + { + if (jsonOverride != null && !string.IsNullOrWhiteSpace(jsonOverride.text)) + return ShrinkAppCompositionDocument.FromJson(jsonOverride.text); + return new ShrinkAppCompositionDocument(includeUnlistedEntries, entries); + } + + public string ToJson(bool prettyPrint = true) => ResolveDocument().ToJson(prettyPrint); + + public void SetDocument(ShrinkAppCompositionDocument document) + { + var copy = (document ?? throw new ArgumentNullException(nameof(document))).Clone(); + includeUnlistedEntries = copy.includeUnlistedEntries; + entries = copy.entries; + jsonOverride = null; + } + + public void SetJsonOverride(TextAsset? value) => jsonOverride = value; + + public static ShrinkAppCompositionProfile? LoadDefault() => + Resources.Load(DefaultResourceName); + } +} diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppCompositionProfile.cs.meta b/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppCompositionProfile.cs.meta new file mode 100644 index 0000000..9497dd1 --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppCompositionProfile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 469746f53f0218442b72603e3c1f6955 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppLoaderBootstrapper.cs b/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppLoaderBootstrapper.cs index 688872d..d52e39f 100644 --- a/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppLoaderBootstrapper.cs +++ b/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppLoaderBootstrapper.cs @@ -47,6 +47,9 @@ namespace ShrinkContext.AppAdapter _instance = this; Host = new ShrinkAppLoaderHost(); DefaultComposition?.Invoke(Host); + var profile = ShrinkAppCompositionProfile.LoadDefault(); + if (profile != null) + Host.ApplyComposition(profile.ResolveDocument()); global::ShrinkApp.ShrinkApp.InitializeForExternalHost(Host.Services); Host.StartAsync().Forget(ex => { diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppLoaderHost.cs b/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppLoaderHost.cs index 03accc4..3c4ba3a 100644 --- a/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppLoaderHost.cs +++ b/Assets/Modules/ShrinkContext.AppAdapter/Runtime/ShrinkAppLoaderHost.cs @@ -27,9 +27,19 @@ namespace ShrinkContext.AppAdapter public string ModuleId = string.Empty; } + private sealed class CompositionOptions + { + public bool Enabled; + public IReadOnlyDictionary? Isolate; + public IReadOnlyDictionary>? Intercept; + } + private readonly Dictionary _modules = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _disabled = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _settingsDisabled = new(StringComparer.OrdinalIgnoreCase); private readonly ShrinkComponentCatalog _catalog; + private Dictionary? _compositionOptions; + private bool _includeUnlistedEntries = true; private bool _started; public ShrinkAppLoaderHost(ShrinkAppSettings? settings = null, @@ -60,7 +70,10 @@ namespace ShrinkContext.AppAdapter foreach (var rawId in Settings.disabledModuleIds ?? Array.Empty()) { if (!string.IsNullOrWhiteSpace(rawId)) + { + _settingsDisabled.Add(rawId.Trim()); _disabled.Add(rawId.Trim()); + } } } @@ -69,6 +82,7 @@ namespace ShrinkContext.AppAdapter public ShrinkContextRuntime Context { get; } public ShrinkContextLoader Loader { get; } public bool IsRunning { get; private set; } + public ShrinkAppCompositionDocument? AppliedComposition { get; private set; } /// 全部已注册模块 id(有序)。 public IReadOnlyList ModuleIds => @@ -88,7 +102,10 @@ namespace ShrinkContext.AppAdapter TryGetLoaderFiber(moduleId, out var fiber) && fiber.State == ShrinkFiberState.Inactive; public bool IsModuleDisabled(string moduleId) => - !string.IsNullOrWhiteSpace(moduleId) && _disabled.Contains(moduleId.Trim()); + !string.IsNullOrWhiteSpace(moduleId) && + (_disabled.Contains(moduleId.Trim()) || + (!_includeUnlistedEntries && _compositionOptions != null && + !_compositionOptions.ContainsKey(moduleId.Trim()))); /// 查询模块当前纤程(被禁用模块返回 false)。 public bool TryGetModuleFiber(string moduleId, out ShrinkFiber fiber) => @@ -142,6 +159,46 @@ namespace ShrinkContext.AppAdapter _catalog.Register(normalizedId, componentFactory); } + /// + /// 在启动前应用声明式条目策略。组件工厂必须已经由组合根注册;配置不能实例化任意类型。 + /// ShrinkAppSettings.disabledModuleIds 仍作为额外禁用集合保留。 + /// + public void ApplyComposition(ShrinkAppCompositionDocument document) + { + if (_started) + throw new InvalidOperationException("Composition can only be applied before StartAsync."); + + var copy = (document ?? throw new ArgumentNullException(nameof(document))).Clone(); + var options = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in copy.entries) + { + var id = entry.NormalizedId; + if (!_modules.ContainsKey(id)) + throw new ShrinkLoaderException( + $"Composition entry '{id}' has no registered module/component factory."); + + options.Add(id, new CompositionOptions + { + Enabled = entry.enabled, + Isolate = entry.BuildIsolate(), + Intercept = entry.BuildIntercept() + }); + } + + _compositionOptions = options; + _includeUnlistedEntries = copy.includeUnlistedEntries; + AppliedComposition = copy; + + _disabled.Clear(); + foreach (var id in _settingsDisabled) + _disabled.Add(id); + foreach (var pair in options) + { + if (!pair.Value.Enabled) + _disabled.Add(pair.Key); + } + } + public async UniTask StartAsync() { if (_started) @@ -172,10 +229,18 @@ namespace ShrinkContext.AppAdapter if (!TryGetRecord(moduleId, out _)) throw new InvalidOperationException($"Unknown module id: '{moduleId}'."); + var normalizedId = moduleId.Trim(); + if (!disabled && !_includeUnlistedEntries && _compositionOptions != null && + !_compositionOptions.ContainsKey(normalizedId)) + { + throw new InvalidOperationException( + $"Module '{normalizedId}' is excluded by the active composition profile."); + } + if (disabled) - _disabled.Add(moduleId.Trim()); + _disabled.Add(normalizedId); else - _disabled.Remove(moduleId.Trim()); + _disabled.Remove(normalizedId); await Loader.ApplyAsync(BuildEntries()); } @@ -192,8 +257,15 @@ namespace ShrinkContext.AppAdapter { var entries = new List(); foreach (var record in _modules.Values.OrderBy(m => m.ModuleId, StringComparer.OrdinalIgnoreCase)) + { + CompositionOptions? options = null; + _compositionOptions?.TryGetValue(record.ModuleId, out options); + if (!_includeUnlistedEntries && _compositionOptions != null && options == null) + continue; + entries.Add(new ShrinkLoaderEntry(record.ModuleId, record.ModuleId, Services, - _disabled.Contains(record.ModuleId))); + _disabled.Contains(record.ModuleId), options?.Isolate, options?.Intercept)); + } return entries; } diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Tests/CompositionProfileTests.cs b/Assets/Modules/ShrinkContext.AppAdapter/Tests/CompositionProfileTests.cs new file mode 100644 index 0000000..3b6197d --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Tests/CompositionProfileTests.cs @@ -0,0 +1,113 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; +using NUnit.Framework; +using ShrinkApp; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace ShrinkContext.AppAdapter.Tests +{ + public class CompositionProfileTests + { + [Test] + public void JsonRoundTrip_PreservesEntryIsolationAndInterceptMetadata() + { + var document = new ShrinkAppCompositionDocument(false, new[] + { + new ShrinkAppCompositionEntry( + "app.a", + enabled: false, + isolate: new[] { new ShrinkAppCompositionIsolate("service", "community") }, + intercept: new[] + { + new ShrinkAppCompositionIntercept("service", new[] + { + new ShrinkAppCompositionMetadata("access", "read-only") + }) + }) + }); + + var restored = ShrinkAppCompositionDocument.FromJson(document.ToJson()); + + Assert.IsFalse(restored.includeUnlistedEntries); + Assert.AreEqual(1, restored.entries.Count); + Assert.IsFalse(restored.entries[0].enabled); + Assert.AreEqual("community", restored.entries[0].isolate[0].realm); + Assert.AreEqual("read-only", restored.entries[0].intercept[0].metadata[0].value); + } + + [Test] + public void ApplyComposition_RestrictsEntriesAndKeepsSettingsDisabledModules() + { + var settings = ScriptableObject.CreateInstance(); + settings.disabledModuleIds = new[] { "app.a" }; + var host = new ShrinkAppLoaderHost(settings, new IShrinkAppModuleInstaller[] + { + new ProfileInstaller("app.a"), + new ProfileInstaller("app.b") + }); + + try + { + host.ApplyComposition(new ShrinkAppCompositionDocument(false, new[] + { + new ShrinkAppCompositionEntry("app.a", enabled: true) + })); + TestAwait.Run(host.StartAsync()); + + Assert.IsTrue(host.IsModuleDisabled("app.a"), + "profile enabled 不应覆盖 ShrinkAppSettings 的显式禁用"); + Assert.IsTrue(host.IsModuleDisabled("app.b"), "未列出的条目应被显式组合排除"); + Assert.IsFalse(host.TryGetModuleFiber("app.a", out _)); + Assert.IsFalse(host.TryGetModuleFiber("app.b", out _)); + Assert.Throws(() => + TestAwait.Run(host.SetModuleDisabledAsync("app.b", false))); + } + finally + { + if (host.IsRunning) + TestAwait.Run(host.ShutdownAsync()); + Object.DestroyImmediate(settings); + } + } + + [Test] + public void ApplyComposition_RejectsUnknownFactoryBeforeStarting() + { + var settings = ScriptableObject.CreateInstance(); + try + { + var host = new ShrinkAppLoaderHost(settings, new[] { new ProfileInstaller("app.a") }); + var error = Assert.Throws(() => host.ApplyComposition( + new ShrinkAppCompositionDocument(false, new[] + { + new ShrinkAppCompositionEntry("app.unknown") + }))); + StringAssert.Contains("no registered module/component factory", error!.Message); + } + finally + { + Object.DestroyImmediate(settings); + } + } + + private sealed class ProfileInstaller : IShrinkAppModuleInstaller + { + public ProfileInstaller(string moduleId) + { + ModuleId = moduleId; + } + + public string ModuleId { get; } + public int Order => 0; + public IReadOnlyList DependsOn => Array.Empty(); + public void RegisterServices(ShrinkAppContext context) + { + } + + public UniTask InitializeAsync(ShrinkAppContext context) => UniTask.CompletedTask; + } + } +} diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Tests/CompositionProfileTests.cs.meta b/Assets/Modules/ShrinkContext.AppAdapter/Tests/CompositionProfileTests.cs.meta new file mode 100644 index 0000000..9a08906 --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Tests/CompositionProfileTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ef8ea53988c89c848b9bd7e147ee34bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Tests/ContextBenchmarkTests.cs b/Assets/Modules/ShrinkContext.AppAdapter/Tests/ContextBenchmarkTests.cs new file mode 100644 index 0000000..850cc1d --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Tests/ContextBenchmarkTests.cs @@ -0,0 +1,27 @@ +#nullable enable +using NUnit.Framework; +using ShrinkContext.AppAdapter.Editor; + +namespace ShrinkContext.AppAdapter.Tests +{ + public class ContextBenchmarkTests + { + [Test] + public void Benchmark_UsesIndexedCandidatesAndRestoresEveryFailedReplacement() + { + var report = TestAwait.Run(ShrinkContextBenchmarkRunner.RunAsync( + unrelatedFiberCount: 10, + reloadIterations: 3, + failureIterations: 2)); + + Assert.AreEqual(3, report.ConsumerApplyCount); + Assert.AreEqual(12L, report.NotificationDispatchCount); + Assert.AreEqual(12L, report.NotificationCandidateVisits, + "每次 provider 生命周期有四次键通知,每次只访问目标 consumer"); + Assert.Less(report.NotificationCandidateVisits, report.NaiveFiberVisits); + Assert.AreEqual(2, report.RestoredFailureCount); + Assert.GreaterOrEqual(report.NotifyMilliseconds, 0d); + Assert.GreaterOrEqual(report.RestoreMilliseconds, 0d); + } + } +} diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Tests/ContextBenchmarkTests.cs.meta b/Assets/Modules/ShrinkContext.AppAdapter/Tests/ContextBenchmarkTests.cs.meta new file mode 100644 index 0000000..2063a1d --- /dev/null +++ b/Assets/Modules/ShrinkContext.AppAdapter/Tests/ContextBenchmarkTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f3f54c253e16c954097f47fdd34e5f28 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Tests/NativeWiringTests.cs b/Assets/Modules/ShrinkContext.AppAdapter/Tests/NativeWiringTests.cs index 89a37de..5777e76 100644 --- a/Assets/Modules/ShrinkContext.AppAdapter/Tests/NativeWiringTests.cs +++ b/Assets/Modules/ShrinkContext.AppAdapter/Tests/NativeWiringTests.cs @@ -28,6 +28,7 @@ namespace ShrinkContext.AppAdapter.Tests { host = new ShrinkAppLoaderHost(settings); ShrinkAppBasicContextComposition.Configure(host); + host.ApplyComposition(ShrinkAppBasicContextComposition.CreateDefaultDocument()); TestAwait.Run(host.StartAsync()); diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Tests/ShrinkAppInstallerComponentTests.cs b/Assets/Modules/ShrinkContext.AppAdapter/Tests/ShrinkAppInstallerComponentTests.cs index 49fe6e1..2e775e1 100644 --- a/Assets/Modules/ShrinkContext.AppAdapter/Tests/ShrinkAppInstallerComponentTests.cs +++ b/Assets/Modules/ShrinkContext.AppAdapter/Tests/ShrinkAppInstallerComponentTests.cs @@ -170,5 +170,10 @@ namespace ShrinkContext.AppAdapter.Tests { task.GetAwaiter().GetResult(); } + + public static T Run(UniTask task) + { + return task.GetAwaiter().GetResult(); + } } } diff --git a/Assets/Modules/ShrinkContext.AppAdapter/Tests/ShrinkContext.AppAdapter.Tests.asmdef b/Assets/Modules/ShrinkContext.AppAdapter/Tests/ShrinkContext.AppAdapter.Tests.asmdef index 4993893..9ea0043 100644 --- a/Assets/Modules/ShrinkContext.AppAdapter/Tests/ShrinkContext.AppAdapter.Tests.asmdef +++ b/Assets/Modules/ShrinkContext.AppAdapter/Tests/ShrinkContext.AppAdapter.Tests.asmdef @@ -3,6 +3,7 @@ "rootNamespace": "ShrinkContext.AppAdapter.Tests", "references": [ "ShrinkContext.AppAdapter.Runtime", + "ShrinkContext.AppAdapter.Editor", "ShrinkContext.Core.Runtime", "ShrinkApp.Core.Runtime", "ShrinkApp.Starter.Basic.Runtime", diff --git a/Assets/Modules/ShrinkContext.Core/README.md b/Assets/Modules/ShrinkContext.Core/README.md index 2bd5bc8..cb7efa6 100644 --- a/Assets/Modules/ShrinkContext.Core/README.md +++ b/Assets/Modules/ShrinkContext.Core/README.md @@ -59,7 +59,7 @@ - `intercept` 是经 `ShrinkCtx.Get` 的能力介导,不是对不可信 DLL 的内存、反射、文件或网络沙箱。 - loader 的 isolate 变化仍通过重建条目生效;尚未提供运行中 fiber 的原位 realm 迁移。 - 强类型版本键是新增契约,现有字符串键不会在阶段 5 被一次性重写。 -- 尚未提供 ScriptableObject/JSON 配置资产、Editor 调试面和正式容量基准。 +- Core 保持不依赖具体配置资产;`ShrinkContext.AppAdapter` 提供 ScriptableObject/JSON 组合、Editor 诊断窗和可重复容量基准。 - 主线程宿主与 Unity PlayerLoop 调度仍由上层应用负责。 - 跨进程/独立服务器上下文 diff --git a/Assets/Modules/ShrinkModFramework/README.md b/Assets/Modules/ShrinkModFramework/README.md index a778a9b..265c399 100644 --- a/Assets/Modules/ShrinkModFramework/README.md +++ b/Assets/Modules/ShrinkModFramework/README.md @@ -221,6 +221,8 @@ var snapshot = ShrinkModDiagnostics.CaptureExternalAssemblies(settings); 快照包含当前与历史 revision、来源路径、程序集名、SHA-256 revision、载入字节数、常驻数量和软阈值状态。失败 revision 可以成为已载入的历史程序集,但不会成为 current;达到软阈值只告警,不伪造卸载行为。 +导入 `ShrinkContext.AppAdapter` 的 Editor 工具后,也可以从 `ShrinkSDK/Cordis/诊断与组合` 查看同一份常驻快照;该窗口通过可选反射读取,不会让 AppAdapter 对 ModFramework 建立硬依赖。 + ## Harmony 热补丁 如果运行环境里存在 `0Harmony`,ContextHost 会把每个模组的补丁租约作为可逆效应管理:激活时调用 diff --git a/Assets/Resources/ShrinkAppComposition.asset b/Assets/Resources/ShrinkAppComposition.asset new file mode 100644 index 0000000..14d9e49 --- /dev/null +++ b/Assets/Resources/ShrinkAppComposition.asset @@ -0,0 +1,45 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 469746f53f0218442b72603e3c1f6955, type: 3} + m_Name: ShrinkAppComposition + m_EditorClassIdentifier: + includeUnlistedEntries: 0 + entries: + - id: shrink.network + enabled: 1 + isolate: [] + intercept: [] + - id: shrink.command + enabled: 1 + isolate: [] + intercept: [] + - id: shrink.datasaver + enabled: 1 + isolate: [] + intercept: [] + - id: shrink.integration.command-network + enabled: 1 + isolate: [] + intercept: [] + - id: shrink.integration.command-eventbus + enabled: 1 + isolate: [] + intercept: [] + - id: shrink.integration.datasaver-eventbus + enabled: 1 + isolate: [] + intercept: [] + - id: shrink.integration.network-eventbus + enabled: 1 + isolate: [] + intercept: [] + jsonOverride: {fileID: 0} diff --git a/Assets/Resources/ShrinkAppComposition.asset.meta b/Assets/Resources/ShrinkAppComposition.asset.meta new file mode 100644 index 0000000..78f3234 --- /dev/null +++ b/Assets/Resources/ShrinkAppComposition.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b362a9170c5b20b45bec29c50b0c2c23 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/CORDIS_MIGRATION.md b/CORDIS_MIGRATION.md index dc6fd9c..1143042 100644 --- a/CORDIS_MIGRATION.md +++ b/CORDIS_MIGRATION.md @@ -260,7 +260,7 @@ await loader.ApplyConfigAsync(configTree); // 增量协调:diff → reload/u - 真实 fixture 验收覆盖:编译 DLL `valid → changed(fail) → restore`、损坏字节不污染当前 revision、有效 revision 恢复、watcher burst 去抖、删除卸载、依赖 DLL 删除/恢复。`ShrinkModFramework.Tests` **9/9**,全仓 EditMode **180/180**。 - 真实 Starter PlayMode 确认 `modCordis=true`、`modLoaded=true`、宿主 7 个模块 active 且无错误。Unity 程序集不可卸载,因此回滚单位仍是组件实例与效应,而不是类型本身。 -### 阶段 5:生产化边界、隔离与拦截(进行中,2026-08-17 启动) +### 阶段 5:生产化边界、隔离与拦截 ✅ 完成(2026-08-17) #### 阶段 5A:访问契约、诊断与通知索引 ✅ 首批完成(2026-08-17) @@ -295,15 +295,17 @@ await loader.ApplyConfigAsync(configTree); // 增量协调:diff → reload/u **已落地:** `ShrinkModDiagnostics.CaptureExternalAssemblies` 暴露 current/history、路径、程序集、revision、载入字节与软阈值状态;达到 `externalAssemblyRevisionSoftLimit` 时只给出 Domain Reload/进程重启建议。真实 DLL fixture 覆盖有效、失败、再有效三个 revision,确认失败程序集可以常驻但不会成为 current。 -#### 阶段 5D:配置与调试体验 +#### 阶段 5D:配置与调试体验 ✅ 完成(2026-08-17) - 将代码构建的默认组合逐步映射到 ScriptableObject/JSON 条目,并保持代码目录作为组件工厂来源。 - 提供 Editor/运行时调试面,查看 active/waiting/failed fiber、provider target、最近事务和常驻程序集统计。 - 建立 notify、重载延迟、失败恢复和常驻内存的基准,避免仅以功能测试替代容量判断。 +**已落地:** `ShrinkAppCompositionProfile / Document` 将条目启用、显式排除、isolate 与 intercept 映射为 ScriptableObject/JSON;组件工厂继续由代码组合根注册,配置不能反射实例化任意类型。Basic Starter 提交 `Resources/ShrinkAppComposition.asset`,生成器可创建或修复该资产。`ShrinkSDK/Cordis/诊断与组合` 在 Play Mode 展示 fiber、等待依赖、target、最近事务和外部 Assembly 常驻快照,并提供 Profile JSON 导入/校验/写回/导出。Editor 基准可重复测 notify/reload 与失败恢复;耗时和 GC 堆差值只作同机对比,自动测试断言结构性规模。 + **阶段 5 明确非目标:** 不在本阶段实现不可信 DLL 沙箱;不承诺已发出的网络数据或外部文件写入可以真正撤回;不删除 ClassicHost 兼容面,除非其调用方已完成独立迁移验证。 -**2026-08-17 验证基线:** Unity 编译 **0 error / 0 warning**;全仓 EditMode **189/189**;PlayMode Test Runner 当前没有测试项,因此另行启动真实 `Assets/Scenes/ShrinkAppEntry.unity` 验收:7 个模块全部 Active、0 Waiting、10 个绑定、3 个 notify 索引键,启动与退出过程无控制台 error。阶段 5D 的配置资产、调试面与容量基准尚未开始。 +**2026-08-17 验证基线:** Unity 编译 **0 error / 0 warning**;全仓 EditMode **193/193**;PlayMode Test Runner 当前没有测试项,因此另行启动真实 `Assets/Scenes/ShrinkAppEntry.unity` 验收:`hostingMode=ContextLoader`、Profile 已应用且显式列出 7 个条目、7 个模块全部 Active、0 Waiting/Failed、10 个绑定、3 个 notify 索引键,启动与退出过程无控制台 error。容量基准在 1000 个无关 fiber、100 次 provider 重载下记录 400 次 indexed candidate visit,对照全量扫描估算 440400 次;25/25 次失败替换均恢复旧组合。本机单次耗时样本约为 notify/reload 1.9 ms、失败恢复 2.8 ms,仅作后续同机对比,不作为跨机器承诺。 --- @@ -323,4 +325,4 @@ await loader.ApplyConfigAsync(configTree); // 增量协调:diff → reload/u - ShrinkSDK 与 Cordis 的**分层直觉一致**(核心库 / 编排层 / 领域层),差距集中在**核心库的两个原语缺失**:统一可逆效应追踪(时间)与响应式依赖解析(空间)。 - 改造的本质不是重写功能模块,而是**把 ShrinkApp 的"一次性安装器"升级为 Cordis 的"持续协调的组件加载器"**,并让七个 Integration 桥接包退化为薄适配直至消失。 - Unity 的"程序集不可卸载"不阻塞该范式——可回滚单位是组件实例与效应,而非类型;这与 ModFramework 既有边界声明兼容。 -- 阶段 0-4 已完成到 Basic Starter 的默认运行主路径与 ModFramework 事务性 HMR;阶段 5 已完成访问契约、诊断/notify 索引、isolate/intercept 首批能力和程序集常驻策略,下一步是阶段 5D 的配置、调试面与容量基准,不再新增第三套生命周期。 +- 阶段 0-4 已完成到 Basic Starter 的默认运行主路径与 ModFramework 事务性 HMR;阶段 5 已完成访问契约、诊断/notify 索引、isolate/intercept、程序集常驻策略、配置资产、调试面与容量基准。后续应以真实项目负载持续采样和收紧领域策略,不再新增第三套生命周期。