commit 2e8cc588c421cb27714e84b155a405144a8524a0 Author: cneicy Date: Wed Aug 26 02:49:53 2026 +0800 chore: initialize standalone UPM package diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..ac0cf22 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish UPM package + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + NODE_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }} + steps: + - name: Fetch tagged revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + test -n "$ref" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkContext.AppAdapter.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Validate immutable release version + shell: bash + run: | + set -eu + tag="$(git describe --exact-match --tags HEAD)" + version="$(node -p "require('./package.json').version")" + test "$tag" = "v$version" + npm pack --dry-run + + - name: Publish to ShrinkSDK registry + shell: bash + run: | + set -eu + : "${NODE_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}" + npmrc="$HOME/.npmrc" + cleanup() { rm -f "$npmrc"; } + trap cleanup EXIT + printf '%s\n' \ + 'registry=https://git.crash.work/api/packages/ShrinkSDK/npm/' \ + '//git.crash.work/api/packages/ShrinkSDK/npm/:_authToken=${NODE_AUTH_TOKEN}' > "$npmrc" + npm publish --registry=https://git.crash.work/api/packages/ShrinkSDK/npm/ \ No newline at end of file diff --git a/.gitea/workflows/unity-verify.yml b/.gitea/workflows/unity-verify.yml new file mode 100644 index 0000000..403cf51 --- /dev/null +++ b/.gitea/workflows/unity-verify.yml @@ -0,0 +1,39 @@ +name: Verify standalone Unity package + +on: + workflow_dispatch: + +jobs: + editmode: + runs-on: unity-2022.3.62f3 + container: + image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3 + volumes: + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses + steps: + - name: Fetch selected revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkContext.AppAdapter.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Run package EditMode tests + shell: bash + run: | + set -eu + unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)" + test -n "$unity_bin" + "$unity_bin" \ + -batchmode \ + -nographics \ + -quit \ + -projectPath "$PWD/Development~/UnityProject" \ + -runTests \ + -testPlatform EditMode \ + -testResults "$PWD/TestResults/editmode.xml" \ + -logFile "$PWD/TestResults/unity.log" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..246935d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/Development~/UnityProject/[Ll]ibrary/ +/Development~/UnityProject/[Tt]emp/ +/Development~/UnityProject/[Oo]bj/ +/Development~/UnityProject/[Ll]ogs/ +/Development~/UnityProject/[Uu]ser[Ss]ettings/ +/Development~/UnityProject/TestResults/ +/Tools~/**/[Bb]in/ +/Tools~/**/[Oo]bj/ +*.user +*.DotSettings.user diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..700f03d --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +.git/ +.gitea/ +Development~/ +Tools~/ +*.csproj +*.sln +*.user +*.DotSettings.user diff --git a/Development~/UnityProject/.gitignore b/Development~/UnityProject/.gitignore new file mode 100644 index 0000000..aa2eb00 --- /dev/null +++ b/Development~/UnityProject/.gitignore @@ -0,0 +1,6 @@ +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Ll]ogs/ +[Uu]ser[Ss]ettings/ +TestResults/ \ No newline at end of file diff --git a/Development~/UnityProject/Assets/.gitkeep b/Development~/UnityProject/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Development~/UnityProject/Packages/manifest.json b/Development~/UnityProject/Packages/manifest.json new file mode 100644 index 0000000..57585dd --- /dev/null +++ b/Development~/UnityProject/Packages/manifest.json @@ -0,0 +1,15 @@ +{ + "scopedRegistries": [ + { + "name": "ShrinkSDK", + "url": "https://git.crash.work/api/packages/ShrinkSDK/npm/", + "scopes": [ + "com.cneicy" + ] + } + ], + "dependencies": { + "com.unity.test-framework": "1.1.33", + "com.cneicy.shrink-context-app-adapter": "file:../../.." + } +} diff --git a/Development~/UnityProject/ProjectSettings/ProjectVersion.txt b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt new file mode 100644 index 0000000..587f809 --- /dev/null +++ b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt @@ -0,0 +1,2 @@ +m_EditorVersion: 2022.3.62f3 +m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7) diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 0000000..9ff0158 --- /dev/null +++ b/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c5f779c094bc74b43a46c1d45002a7a1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ShrinkContext.AppAdapter.Editor.asmdef b/Editor/ShrinkContext.AppAdapter.Editor.asmdef new file mode 100644 index 0000000..0265b6a --- /dev/null +++ b/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/Editor/ShrinkContext.AppAdapter.Editor.asmdef.meta b/Editor/ShrinkContext.AppAdapter.Editor.asmdef.meta new file mode 100644 index 0000000..6852628 --- /dev/null +++ b/Editor/ShrinkContext.AppAdapter.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 71b936124873ba345a9430e664e7cfcd +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ShrinkContextBenchmarkRunner.cs b/Editor/ShrinkContextBenchmarkRunner.cs new file mode 100644 index 0000000..afc84e0 --- /dev/null +++ b/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/Editor/ShrinkContextBenchmarkRunner.cs.meta b/Editor/ShrinkContextBenchmarkRunner.cs.meta new file mode 100644 index 0000000..05ac4f5 --- /dev/null +++ b/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/Editor/ShrinkContextDiagnosticsWindow.cs b/Editor/ShrinkContextDiagnosticsWindow.cs new file mode 100644 index 0000000..d2f6c3d --- /dev/null +++ b/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/Editor/ShrinkContextDiagnosticsWindow.cs.meta b/Editor/ShrinkContextDiagnosticsWindow.cs.meta new file mode 100644 index 0000000..f57dfd7 --- /dev/null +++ b/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/README.md b/README.md new file mode 100644 index 0000000..3a7df8b --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# ShrinkContext.AppAdapter + +ShrinkApp ↔ ShrinkContext 的桥接层:提供当前默认的 ContextLoader 宿主、声明式组合 Profile、诊断工具,并保留 `IShrinkAppModuleInstaller` 兼容适配。当前架构与边界见仓库根 `DESIGN.md`。 + +## 组成 + +| 类型 | 职责 | +|---|---| +| `ShrinkAppInstallerComponent` | 安装器 → 组件:`DependsOn` 映射为 `app.module.*` 注入键(依赖缺失**等待**而非抛错);发布模块键;apply = RegisterServices + InitializeAsync | +| `ShrinkAppLoaderHost` | 加载器驱动的 ShrinkApp 宿主:发现安装器(注入覆盖可测试)、应用声明式组合、经 `ShrinkContextLoader` 增量协调、运行中按模块开关 | +| `ShrinkAppCompositionProfile` | ScriptableObject/JSON 组合文档:描述条目启用、显式排除、isolate 与 intercept;组件工厂仍由代码目录注册 | +| `ShrinkAppLoaderBootstrapper` | `hostingMode == ContextLoader` 时自动创建常驻宿主,应用 `Resources/ShrinkAppComposition`,并把服务容器接回 `ShrinkApp.Services` | +| `ShrinkContextDiagnosticsWindow` | `ShrinkSDK/Cordis/诊断与组合`:查看运行时 fiber/依赖/事务、编辑 Profile JSON、读取可选的 ModFramework 常驻统计并运行容量基准 | + +## 启用方式 + +`ShrinkAppSettings.hostingMode` 设为 `ContextLoader`(Inspector 或资产字段)。经典项目默认 `ClassicHost`,行为零变化。 + +```csharp +// 运行中按模块开关(宿主与其余模块不重启) +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 | +|---|---|---| +| 依赖缺失 | 排序期抛错 | 安装器保持 Waiting,依赖出现自动激活 | +| 重复 ModuleId | 宿主级异常 | 构造期抛错(一致);运行期替换退化为供给冲突失败 | +| 运行中禁用模块 | 不支持(需重启) | `SetModuleDisabledAsync` 增量协调,重启用会重新执行安装器初始化 | +| `ShrinkApp.IsRunning` | Host 驱动 | LoaderHost 通过 `SetExternalHostRunning` 同步静态门面状态 | + +## 已知边界 + +- `ShrinkAppServices.TryUnregister(instance)` 支持按实例撤回服务;原生 Context 组件已使用该路径。旧 installer 包装器仍取决于安装器自身是否提供完整逆操作。 +- config/isolate 变化走条目重建;intercept metadata 可原位更新而不改变 fiber generation。 +- 运行中不支持重新应用整份 Profile;资产修改在下一次宿主启动生效,运行中模块开关仍使用 `SetModuleDisabledAsync`。 +- Editor 基准的毫秒数和 GC 管理堆差值只用于同机前后对比;自动测试只断言索引候选规模和事务恢复结果。 + +## 测试 + +`Tests/` 覆盖安装器生命周期、LoaderHost 开关、原生 Starter 接线、Profile/JSON 校验、显式条目选择、设置禁用优先级,以及 notify/失败恢复结构性基准。 diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 0000000..1f09ccf --- /dev/null +++ b/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: db29c6e4804421b4392cb2ae3752790b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..cdc16fd --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b0b5a8bafa4a1464b8359913f50298b0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkAppCompositionProfile.cs b/Runtime/ShrinkAppCompositionProfile.cs new file mode 100644 index 0000000..1d093ab --- /dev/null +++ b/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/Runtime/ShrinkAppCompositionProfile.cs.meta b/Runtime/ShrinkAppCompositionProfile.cs.meta new file mode 100644 index 0000000..9497dd1 --- /dev/null +++ b/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/Runtime/ShrinkAppInstallerComponent.cs b/Runtime/ShrinkAppInstallerComponent.cs new file mode 100644 index 0000000..1229f84 --- /dev/null +++ b/Runtime/ShrinkAppInstallerComponent.cs @@ -0,0 +1,62 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Cysharp.Threading.Tasks; +using ShrinkApp; + +namespace ShrinkContext.AppAdapter +{ + /// + /// 把现有 包装为 ShrinkContext 组件: + /// - inject 由 DependsOn 派生(模块依赖 → 模块键),依赖未就绪时安装器保持非活动等待, + /// 而不是像拓扑排序那样直接抛错; + /// - provide 发布模块键 app.module.<ModuleId>(安装器 Active 后对依赖者可见); + /// - apply 依次执行 RegisterServices + InitializeAsync,config 必须传入 。 + /// + /// 兼容安装器只负责执行既有 RegisterServices + InitializeAsync。需要停用时撤回服务的模块应使用 + /// 原生 Context 组件,并通过 ShrinkAppServices.TryUnregister(instance) 登记对应逆操作。 + /// + public sealed class ShrinkAppInstallerComponent : IShrinkComponent + { + /// 模块键前缀:依赖声明 DependsOn = ["a"] 映射为 app.module.a + public const string ModuleKeyPrefix = "app.module."; + + private readonly IShrinkAppModuleInstaller _installer; + private readonly ShrinkAppSettings? _settings; + private readonly string[] _inject; + + public ShrinkAppInstallerComponent(IShrinkAppModuleInstaller installer, ShrinkAppSettings? settings = null) + { + _installer = installer ?? throw new ArgumentNullException(nameof(installer)); + _settings = settings; + _inject = (installer.DependsOn ?? Array.Empty()) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => ModuleKeyPrefix + id.Trim()) + .ToArray(); + } + + public string Name => "app-installer:" + _installer.ModuleId; + + public IReadOnlyList Inject => _inject; + + public IReadOnlyList Provide => new[] { ModuleKeyPrefix + _installer.ModuleId }; + + public IShrinkAppModuleInstaller Installer => _installer; + + public async UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + if (config is not ShrinkAppServices services) + throw new InvalidOperationException( + $"ShrinkAppInstallerComponent '{_installer.ModuleId}' requires ShrinkAppServices as fiber config."); + + // 先发布模块键再执行安装:装载期绑定对依赖者不可见(提供者须 Active), + // 供给冲突(重复 ModuleId)在安装器初始化之前即失败 + ctx.Set(Provide[0], _installer.ModuleId); + + var appContext = ShrinkAppContext.CreateStandalone(services, _settings); + _installer.RegisterServices(appContext); + await _installer.InitializeAsync(appContext); + } + } +} diff --git a/Runtime/ShrinkAppInstallerComponent.cs.meta b/Runtime/ShrinkAppInstallerComponent.cs.meta new file mode 100644 index 0000000..65d0029 --- /dev/null +++ b/Runtime/ShrinkAppInstallerComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d8057e758d9edf46b18bd47145421c0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkAppLoaderBootstrapper.cs b/Runtime/ShrinkAppLoaderBootstrapper.cs new file mode 100644 index 0000000..c053b18 --- /dev/null +++ b/Runtime/ShrinkAppLoaderBootstrapper.cs @@ -0,0 +1,67 @@ +#nullable enable +using Cysharp.Threading.Tasks; +using ShrinkApp; +using UnityEngine; + +namespace ShrinkContext.AppAdapter +{ + /// + /// ShrinkApp 加载器宿主的自动引导: + /// hostingMode == ContextLoader 时在 BeforeSceneLoad 创建常驻 GameObject 并启动 ShrinkAppLoaderHost, + /// 同时把服务容器接回 ShrinkApp 静态入口(InitializeForExternalHost),保证场景脚本照常访问 ShrinkApp.Services。 + /// ClassicHost 模式下完全不动——经典路径零行为变化。 + /// + public sealed class ShrinkAppLoaderBootstrapper : MonoBehaviour + { + private static ShrinkAppLoaderBootstrapper? _instance; + + /// + /// Starter/组合包在 SubsystemRegistration 阶段设置的默认装配表。 + /// AppAdapter 保持不依赖具体业务模块,组合根负责注册原生组件;若兼容 installer 已被发现则覆盖同 id。 + /// + public static System.Action? DefaultComposition { get; set; } + + public static ShrinkAppLoaderBootstrapper? Instance => _instance; + + public ShrinkAppLoaderHost Host { get; private set; } = null!; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void AutoBootstrap() + { + if (ShrinkAppSettings.Instance.hostingMode != ShrinkAppHostingMode.ContextLoader) + return; + + var gameObject = new GameObject("ShrinkAppLoaderHost"); + DontDestroyOnLoad(gameObject); + gameObject.AddComponent(); + } + + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + + _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 => + { + Debug.LogException(ex); + Debug.LogError("[ShrinkApp.LoaderHost] 启动失败: " + ex.Message); + }); + } + + private void OnDestroy() + { + if (_instance == this) + _instance = null; + } + } +} diff --git a/Runtime/ShrinkAppLoaderBootstrapper.cs.meta b/Runtime/ShrinkAppLoaderBootstrapper.cs.meta new file mode 100644 index 0000000..8494d22 --- /dev/null +++ b/Runtime/ShrinkAppLoaderBootstrapper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f4021699debf79945a4e30e8a43d3049 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkAppLoaderHost.cs b/Runtime/ShrinkAppLoaderHost.cs new file mode 100644 index 0000000..9c5f5a2 --- /dev/null +++ b/Runtime/ShrinkAppLoaderHost.cs @@ -0,0 +1,300 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Cysharp.Threading.Tasks; +using ShrinkApp; +using ShrinkEventBus; +using UnityEngine; + +namespace ShrinkContext.AppAdapter +{ + /// + /// 由 ShrinkContext 加载器驱动的 ShrinkApp 宿主(阶段 2 过渡路径,对应 ShrinkAppSettings.hostingMode = ContextLoader)。 + /// + /// 与经典 ShrinkAppHost 的行为差异: + /// - 依赖缺失的安装器保持非活动等待(Waiting),而不是排序期抛错; + /// - 支持运行中 SetModuleDisabledAsync 按模块 disable/enable,无需重启宿主; + /// - 重复 ModuleId 在构造期抛错(与经典一致),依赖排序由响应式余效应结构性保证。 + /// + /// 与 ShrinkAppHost 相同的语义:安装器单例实例、ModuleId 大小写不敏感、 + /// disabledModuleIds 作为初始禁用集合、启动完成发布 ShrinkAppStartedEvent。 + /// + public sealed class ShrinkAppLoaderHost + { + private sealed class ModuleRecord + { + 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, + IReadOnlyList? installers = null) + { + Settings = settings ?? ShrinkAppSettings.Instance; + Services = new ShrinkAppServices(); + Context = new ShrinkContextRuntime(); + + var catalog = new ShrinkComponentCatalog(); + foreach (var installer in installers ?? DiscoverDefaultInstallers()) + { + if (installer == null || string.IsNullOrWhiteSpace(installer.ModuleId)) + throw new InvalidOperationException( + $"Installer '{installer?.GetType().FullName ?? ""}' has an empty ModuleId."); + + var moduleId = installer.ModuleId.Trim(); + if (!_modules.TryAdd(moduleId, new ModuleRecord { ModuleId = moduleId })) + throw new InvalidOperationException($"Duplicate installer ModuleId: {moduleId}"); + + // 工厂每次重建条目时包装同一个安装器实例(与经典宿主的单例安装器语义一致) + catalog.Register(moduleId, () => new ShrinkAppInstallerComponent(installer, Settings)); + } + + Loader = new ShrinkContextLoader(Context, catalog); + _catalog = catalog; + + foreach (var rawId in Settings.disabledModuleIds ?? Array.Empty()) + { + if (!string.IsNullOrWhiteSpace(rawId)) + { + _settingsDisabled.Add(rawId.Trim()); + _disabled.Add(rawId.Trim()); + } + } + } + + public ShrinkAppSettings Settings { get; } + public ShrinkAppServices Services { get; } + public ShrinkContextRuntime Context { get; } + public ShrinkContextLoader Loader { get; } + public bool IsRunning { get; private set; } + public ShrinkAppCompositionDocument? AppliedComposition { get; private set; } + + /// 全部已注册模块 id(有序)。 + public IReadOnlyList ModuleIds => + _modules.Values.Select(m => m.ModuleId).OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToArray(); + + public IReadOnlyList ActiveModuleIds => + ModuleIds.Where(IsModuleActive).ToArray(); + + /// 已注册但当前非活动的模块(依赖缺失等待中;被禁用的模块不计入,见 IsModuleDisabled)。 + public IReadOnlyList WaitingModuleIds => + ModuleIds.Where(IsModuleWaiting).ToArray(); + + public bool IsModuleActive(string moduleId) => + TryGetLoaderFiber(moduleId, out var fiber) && fiber is { State: ShrinkFiberState.Active }; + + public bool IsModuleWaiting(string moduleId) => + TryGetLoaderFiber(moduleId, out var fiber) && fiber is { State: ShrinkFiberState.Inactive }; + + public bool IsModuleDisabled(string moduleId) => + !string.IsNullOrWhiteSpace(moduleId) && + (_disabled.Contains(moduleId.Trim()) || + (!_includeUnlistedEntries && _compositionOptions != null && + !_compositionOptions.ContainsKey(moduleId.Trim()))); + + /// 查询模块当前纤程(被禁用模块返回 false)。 + public bool TryGetModuleFiber(string moduleId, out ShrinkFiber fiber) => + TryGetLoaderFiber(moduleId, out fiber!); + + private bool TryGetLoaderFiber(string moduleId, out ShrinkFiber? fiber) + { + fiber = null; + if (string.IsNullOrWhiteSpace(moduleId)) + return false; + if (!_modules.ContainsKey(moduleId.Trim())) + return false; + return Loader.TryGetFiber(moduleId.Trim(), out fiber!); + } + + /// + /// 阶段 3:用原生 Cordis 组件替换指定模块的安装器包装(须在 StartAsync 之前调用)。 + /// 目录同键覆盖——安装器不再被实例化包装,模块行为完全由原生组件定义。 + /// + public void OverrideModuleComponent(string moduleId, Func componentFactory) + { + if (_started) + throw new InvalidOperationException("Module components can only be overridden before StartAsync."); + if (string.IsNullOrWhiteSpace(moduleId)) + throw new ArgumentException("Module id must not be null or empty.", nameof(moduleId)); + if (componentFactory == null) + throw new ArgumentNullException(nameof(componentFactory)); + if (!_modules.ContainsKey(moduleId.Trim())) + throw new InvalidOperationException($"Unknown module id: '{moduleId}'."); + + _catalog.Register(moduleId.Trim(), componentFactory); + } + + /// + /// 向组合根加入没有旧安装器身份的原生组件(例如 Command-Network、EventBus 薄适配)。 + /// 须在 StartAsync 前调用;模块 id 同时作为加载器条目 id 与组件目录键。 + /// + public void AddModuleComponent(string moduleId, Func componentFactory) + { + if (_started) + throw new InvalidOperationException("Module components can only be added before StartAsync."); + if (string.IsNullOrWhiteSpace(moduleId)) + throw new ArgumentException("Module id must not be null or empty.", nameof(moduleId)); + if (componentFactory == null) + throw new ArgumentNullException(nameof(componentFactory)); + + var normalizedId = moduleId.Trim(); + if (!_modules.TryAdd(normalizedId, new ModuleRecord { ModuleId = normalizedId })) + throw new InvalidOperationException($"Duplicate module id: '{normalizedId}'."); + + _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) + return; + _started = true; + + await Loader.ApplyAsync(BuildEntries()); + IsRunning = true; + global::ShrinkApp.ShrinkApp.SetExternalHostRunning(true); + + var active = ActiveModuleIds; + Debug.Log($"[ShrinkApp.LoaderHost] 启动完成:active={active.Count} " + + $"waiting={WaitingModuleIds.Count} disabled={_disabled.Count} " + + $"modules=[{string.Join(", ", active)}]"); + + EventBus.Post(new ShrinkAppStartedEvent + { + ModuleIds = active.ToArray() + }); + } + + /// 运行中按模块 disable/enable:增量协调,宿主与其余模块不重启。 + public async UniTask SetModuleDisabledAsync(string moduleId, bool disabled) + { + EnsureStarted(); + if (string.IsNullOrWhiteSpace(moduleId)) + throw new ArgumentException("Module id must not be null or empty.", nameof(moduleId)); + 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(normalizedId); + else + _disabled.Remove(normalizedId); + + await Loader.ApplyAsync(BuildEntries()); + } + + public async UniTask ShutdownAsync() + { + EnsureStarted(); + await Context.ShutdownAsync(); + IsRunning = false; + global::ShrinkApp.ShrinkApp.SetExternalHostRunning(false); + } + + private List BuildEntries() + { + 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), options?.Isolate, options?.Intercept)); + } + return entries; + } + + private bool TryGetRecord(string moduleId, out ModuleRecord record) + { + if (!string.IsNullOrWhiteSpace(moduleId)) + return _modules.TryGetValue(moduleId.Trim(), out record!); + record = null!; + return false; + } + + private void EnsureStarted() + { + if (!_started) + throw new InvalidOperationException("ShrinkAppLoaderHost has not been started yet."); + } + + private static IReadOnlyList DiscoverDefaultInstallers() + { + var installers = new List(); + foreach (var type in ShrinkAppInstallers.GetDiscoveredInstallerTypes()) + { + if (Activator.CreateInstance(type) is IShrinkAppModuleInstaller installer) + installers.Add(installer); + else + throw new InvalidOperationException($"Failed to create installer: {type.FullName}"); + } + + return installers; + } + } +} diff --git a/Runtime/ShrinkAppLoaderHost.cs.meta b/Runtime/ShrinkAppLoaderHost.cs.meta new file mode 100644 index 0000000..546c6be --- /dev/null +++ b/Runtime/ShrinkAppLoaderHost.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4a5dccbb74da10d46837de4ba1240299 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkContext.AppAdapter.Runtime.asmdef b/Runtime/ShrinkContext.AppAdapter.Runtime.asmdef new file mode 100644 index 0000000..f261619 --- /dev/null +++ b/Runtime/ShrinkContext.AppAdapter.Runtime.asmdef @@ -0,0 +1,15 @@ +{ + "name": "ShrinkContext.AppAdapter.Runtime", + "rootNamespace": "ShrinkContext.AppAdapter", + "references": [ + "ShrinkContext.Core.Runtime", + "ShrinkApp.Core.Runtime", + "ShrinkEventBus.Runtime", + "UniTask" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": false +} \ No newline at end of file diff --git a/Runtime/ShrinkContext.AppAdapter.Runtime.asmdef.meta b/Runtime/ShrinkContext.AppAdapter.Runtime.asmdef.meta new file mode 100644 index 0000000..8f9bbd3 --- /dev/null +++ b/Runtime/ShrinkContext.AppAdapter.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 784bc035ead25ce438bcd7b9369a11c8 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..dda576b --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5898f3ec855a8e448892ff9976c9f6af +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/CompositionProfileTests.cs b/Tests/CompositionProfileTests.cs new file mode 100644 index 0000000..3b6197d --- /dev/null +++ b/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/Tests/CompositionProfileTests.cs.meta b/Tests/CompositionProfileTests.cs.meta new file mode 100644 index 0000000..9a08906 --- /dev/null +++ b/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/Tests/ContextBenchmarkTests.cs b/Tests/ContextBenchmarkTests.cs new file mode 100644 index 0000000..850cc1d --- /dev/null +++ b/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/Tests/ContextBenchmarkTests.cs.meta b/Tests/ContextBenchmarkTests.cs.meta new file mode 100644 index 0000000..2063a1d --- /dev/null +++ b/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/Tests/NativeWiringTests.cs b/Tests/NativeWiringTests.cs new file mode 100644 index 0000000..bc1d919 --- /dev/null +++ b/Tests/NativeWiringTests.cs @@ -0,0 +1,112 @@ +#nullable enable +using System; +using System.Collections.Generic; +using NUnit.Framework; +using ShrinkApp.Starter.Basic; +using ShrinkCommand.Integration.App; +using ShrinkContext; +using ShrinkDataSaver.Integration.App; +using ShrinkNetwork; +using ShrinkNetwork.Integration.App; +using ShrinkApp; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace ShrinkContext.AppAdapter.Tests +{ + /// + /// Basic Starter 默认装配表的端到端验证(阶段 3): + /// 三个功能模块走原生 Cordis 组件,四个跨包集成走按键注入的薄组件; + /// 提供者停用时依赖集成先退出,兼容服务门面也作为可逆效应注销。 + /// + public class NativeWiringTests + { + [Test] + public void BasicComposition_UsesNativeComponents_AndRetiresDependentsReversibly() + { + var settings = ScriptableObject.CreateInstance(); + ShrinkAppLoaderHost? host = null; + try + { + var coreInstallers = new List(); + foreach (var installerType in ShrinkAppInstallers.GetDiscoveredInstallerTypes()) + { + if (Activator.CreateInstance(installerType) is not IShrinkAppModuleInstaller installer) + continue; + if (installer.ModuleId is "shrink.network" or "shrink.command" or "shrink.datasaver") + coreInstallers.Add(installer); + } + + host = new ShrinkAppLoaderHost(settings, coreInstallers); + ShrinkAppBasicContextComposition.Configure(host); + host.ApplyComposition(ShrinkAppBasicContextComposition.CreateDefaultDocument()); + + TestAwait.Run(host.StartAsync()); + + Assert.AreEqual(7, host.ModuleIds.Count); + Assert.AreEqual(7, host.ActiveModuleIds.Count); + Assert.IsTrue(host.IsModuleActive("shrink.network")); + Assert.IsTrue(host.IsModuleActive("shrink.command")); + Assert.IsTrue(host.IsModuleActive("shrink.datasaver")); + Assert.IsTrue(host.IsModuleActive("shrink.integration.command-network")); + Assert.IsTrue(host.IsModuleActive("shrink.integration.command-eventbus")); + Assert.IsTrue(host.IsModuleActive("shrink.integration.datasaver-eventbus")); + Assert.IsTrue(host.IsModuleActive("shrink.integration.network-eventbus")); + + var root = host.Context.RootContext; + Assert.IsTrue(host.Context.TryGetRaw(root, + ShrinkNetworkAppComponent.ServiceKey, out _), "网络服务键由原生组件提供"); + Assert.IsTrue(host.Context.TryGetRaw(root, + ShrinkCommandAppComponent.ServiceKey, out _), "命令服务键由原生组件提供"); + Assert.IsTrue(host.Context.TryGetRaw(root, + ShrinkDataSaverAppComponent.ServiceKey, out _), "存档服务键由原生组件提供"); + Assert.IsTrue(host.Context.TryGetRaw(root, + "shrink.integration.command-network", out _)); + Assert.IsTrue(host.Context.TryGetRaw(root, + "shrink.integration.command-eventbus", out _)); + Assert.IsTrue(host.Context.TryGetRaw(root, + "shrink.integration.datasaver-eventbus", out _)); + Assert.IsTrue(host.Context.TryGetRaw(root, + "shrink.integration.network-eventbus", out _)); + Assert.IsTrue(host.Services.TryGet(out _)); + Assert.IsTrue(host.Services.TryGet(out _)); + Assert.IsTrue(host.Services.TryGet(out _)); + + TestAwait.Run(host.SetModuleDisabledAsync("shrink.command", true)); + + Assert.IsFalse(host.IsModuleActive("shrink.command")); + Assert.IsFalse(host.IsModuleActive("shrink.integration.command-network")); + Assert.IsFalse(host.IsModuleActive("shrink.integration.command-eventbus")); + Assert.IsTrue(host.IsModuleActive("shrink.network"), "无关提供者不得重载"); + Assert.IsTrue(host.IsModuleActive("shrink.datasaver"), "无关提供者不得重载"); + Assert.IsTrue(host.IsModuleActive("shrink.integration.network-eventbus")); + Assert.IsTrue(host.IsModuleActive("shrink.integration.datasaver-eventbus")); + Assert.IsFalse(host.Context.TryGetRaw(root, + ShrinkCommandAppComponent.ServiceKey, out _)); + Assert.IsFalse(host.Context.TryGetRaw(root, + "shrink.integration.command-network", out _)); + Assert.IsFalse(host.Context.TryGetRaw(root, + "shrink.integration.command-eventbus", out _)); + Assert.IsFalse(host.Services.TryGet(out _), + "兼容门面应随组件退役注销"); + + TestAwait.Run(host.SetModuleDisabledAsync("shrink.command", false)); + + Assert.AreEqual(7, host.ActiveModuleIds.Count); + Assert.IsTrue(host.Services.TryGet(out _)); + + TestAwait.Run(host.ShutdownAsync()); + Assert.AreEqual(0, host.ActiveModuleIds.Count); + Assert.IsFalse(host.Services.TryGet(out _)); + Assert.IsFalse(host.Services.TryGet(out _)); + Assert.IsFalse(host.Services.TryGet(out _)); + } + finally + { + if (host?.IsRunning == true) + TestAwait.Run(host.ShutdownAsync()); + Object.DestroyImmediate(settings); + } + } + } +} diff --git a/Tests/NativeWiringTests.cs.meta b/Tests/NativeWiringTests.cs.meta new file mode 100644 index 0000000..6ad5557 --- /dev/null +++ b/Tests/NativeWiringTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 92ef45fe59147f445bbb2ae4760732b7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/ShrinkAppInstallerComponentTests.cs b/Tests/ShrinkAppInstallerComponentTests.cs new file mode 100644 index 0000000..2e775e1 --- /dev/null +++ b/Tests/ShrinkAppInstallerComponentTests.cs @@ -0,0 +1,179 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; +using NUnit.Framework; +using ShrinkApp; +using ShrinkContext; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace ShrinkContext.AppAdapter.Tests +{ + /// + /// ShrinkApp 安装器适配器: + /// 依赖缺失等待而非抛错(对照 ShrinkAppHost.SortInstallers 的行为差异)、 + /// 依赖就绪顺序保证、重复 ModuleId 退化为供给冲突而非宿主级失败、服务注册可见性。 + /// + public class ShrinkAppInstallerComponentTests + { + private ShrinkContextRuntime _runtime = null!; + private ShrinkAppServices _services = null!; + private ShrinkAppSettings _settings = null!; + private static long _sequence; + + [SetUp] + public void SetUp() + { + _runtime = new ShrinkContextRuntime(); + _services = new ShrinkAppServices(); + _settings = ScriptableObject.CreateInstance(); + } + + [TearDown] + public void TearDown() + { + if (_settings != null) + Object.DestroyImmediate(_settings); + } + + private ShrinkFiber UseInstaller(FakeInstaller installer) + { + return _runtime.Use(new ShrinkAppInstallerComponent(installer, _settings), _services); + } + + [Test] + public void MissingDependency_WaitsInsteadOfThrowing() + { + var dependent = new FakeInstaller("app.b", "b-service") { DependsOn = new[] { "app.a" } }; + var fiber = UseInstaller(dependent); + + Assert.AreEqual(ShrinkFiberState.Inactive, fiber.State, + "依赖模块未就绪时安装器保持非活动(对照:ShrinkAppHost 在排序期直接抛依赖缺失)"); + Assert.AreEqual(0, dependent.RegisterCount); + Assert.AreEqual(0, dependent.InitCount); + } + + [Test] + public void DependencyArrives_DependentActivates_AfterProvider() + { + var provider = new FakeInstaller("app.a", "a-service"); + var dependent = new FakeInstaller("app.b", "b-service") { DependsOn = new[] { "app.a" } }; + + var dependentFiber = UseInstaller(dependent); + var providerFiber = UseInstaller(provider); + + Assert.AreEqual(ShrinkFiberState.Active, providerFiber.State); + Assert.AreEqual(ShrinkFiberState.Active, dependentFiber.State, + "依赖键由提供者发布后,依赖安装器被响应式激活"); + + // 激活次序由依赖关系保证:提供者先完成 Register+Init + Assert.AreEqual(1, provider.InitCount); + Assert.AreEqual(1, dependent.InitCount); + Assert.Less(provider.CompletedAt, dependent.CompletedAt, + "依赖者的初始化必须晚于其依赖的提供者(空间可组合性排序)"); + + Assert.IsTrue(_services.TryGet(out var service)); + Assert.AreEqual("b-service", service!.Value); + } + + [Test] + public void DuplicateModuleId_SecondFailsAsSupplyConflict_FirstStaysActive() + { + var first = new FakeInstaller("app.dup", "first"); + var second = new FakeInstaller("app.dup", "second"); + + var firstFiber = UseInstaller(first); + var secondFiber = UseInstaller(second); + + Assert.AreEqual(ShrinkFiberState.Active, firstFiber.State); + Assert.AreEqual(ShrinkFiberState.Inactive, secondFiber.State); + Assert.IsInstanceOf(secondFiber.LastError, + "重复 ModuleId 退化为该纤程的供给冲突失败,而不是宿主级异常(对照 ShrinkHost 的 Duplicate installer 抛错)"); + Assert.AreEqual(0, second.InitCount); + } + + [Test] + public void RetireProvider_DependentInstallerDeactivates() + { + var provider = new FakeInstaller("app.a", "a-service"); + var dependent = new FakeInstaller("app.b", "b-service") { DependsOn = new[] { "app.a" } }; + + var providerFiber = UseInstaller(provider); + var dependentFiber = UseInstaller(dependent); + Assert.AreEqual(ShrinkFiberState.Active, dependentFiber.State); + + TestAwait.Run(_runtime.RetireAsync(providerFiber)); + + Assert.AreEqual(ShrinkFiberState.Inactive, dependentFiber.State, + "提供者退役后依赖安装器自动停用(服务注销为已知边界,见组件注释)"); + } + [Test] + public void MissingServicesConfig_FailsWithClearError() + { + var installer = new FakeInstaller("app.x", "x"); + var component = new ShrinkAppInstallerComponent(installer, _settings); + var fiber = _runtime.Use(component, config: null!); + + Assert.AreEqual(ShrinkFiberState.Inactive, fiber.State); + Assert.IsInstanceOf(fiber.LastError); + StringAssert.Contains("ShrinkAppServices", fiber.LastError!.Message); + } + + // ---- 测试替身 ---- + + private sealed class FakeInstaller : IShrinkAppModuleInstaller + { + public FakeInstaller(string moduleId, string serviceValue) + { + ModuleId = moduleId; + ServiceValue = serviceValue; + } + + public string ModuleId { get; } + public int Order => 0; + public IReadOnlyList DependsOn { get; set; } = Array.Empty(); + public int RegisterCount { get; private set; } + public int InitCount { get; private set; } + public long CompletedAt { get; private set; } + private string ServiceValue { get; } + + public void RegisterServices(ShrinkAppContext context) + { + RegisterCount++; + context.Services.Register(new FakeService(ServiceValue)); + } + + public UniTask InitializeAsync(ShrinkAppContext context) + { + InitCount++; + CompletedAt = ++_sequence; + return UniTask.CompletedTask; + } + } + + private sealed class FakeService + { + public FakeService(string value) + { + Value = value; + } + + public string Value { get; } + } + } + + /// 跨程序集测试辅助(与 ShrinkContext.Core.Tests.TestAwait 同语义)。 + public static class TestAwait + { + public static void Run(UniTask task) + { + task.GetAwaiter().GetResult(); + } + + public static T Run(UniTask task) + { + return task.GetAwaiter().GetResult(); + } + } +} diff --git a/Tests/ShrinkAppInstallerComponentTests.cs.meta b/Tests/ShrinkAppInstallerComponentTests.cs.meta new file mode 100644 index 0000000..8cb9789 --- /dev/null +++ b/Tests/ShrinkAppInstallerComponentTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4351747efa1ed6242a67b18f60388935 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/ShrinkAppLoaderHostTests.cs b/Tests/ShrinkAppLoaderHostTests.cs new file mode 100644 index 0000000..9a61a5f --- /dev/null +++ b/Tests/ShrinkAppLoaderHostTests.cs @@ -0,0 +1,214 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Cysharp.Threading.Tasks; +using NUnit.Framework; +using ShrinkApp; +using ShrinkContext; +using ShrinkEventBus; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace ShrinkContext.AppAdapter.Tests +{ + /// + /// ShrinkAppLoaderHost 端到端: + /// 启动激活与 Started 事件、运行中 disable/enable 不重启宿主、设置级初始禁用、 + /// 未知模块报错、重复 ModuleId 构造期失败、Shutdown。 + /// + public class ShrinkAppLoaderHostTests + { + [ShrinkEventSubscriber(DefaultBus = "game")] + private sealed class AppStartedProbe + { + public ShrinkAppStartedEvent? Value { get; private set; } + + [ShrinkSubscribe] + private void OnStarted(ShrinkAppStartedEvent value) => Value = value; + } + + private ShrinkAppSettings _settings = null!; + + [SetUp] + public void SetUp() + { + _settings = ScriptableObject.CreateInstance(); + } + + [TearDown] + public void TearDown() + { + if (_settings != null) + Object.DestroyImmediate(_settings); + } + + private ShrinkAppLoaderHost CreateHost(params FakeInstaller[] installers) + { + return new ShrinkAppLoaderHost(_settings, installers); + } + + [Test] + public void StartAsync_ActivatesModules_AndPublishesStartedEvent() + { + var host = CreateHost( + new FakeInstaller("app.a", "a"), + new FakeInstaller("app.b", "b") { DependsOn = new[] { "app.a" } }); + + var probe = new AppStartedProbe(); + using (EventBus.Attach(probe)) + { + TestAwait.Run(host.StartAsync()); + } + + Assert.IsTrue(host.IsRunning); + Assert.IsTrue(host.IsModuleActive("app.a")); + Assert.IsTrue(host.IsModuleActive("app.b"), "依赖模块在提供者激活后自动激活"); + Assert.IsNotNull(probe.Value); + CollectionAssert.AreEquivalent(new[] { "app.a", "app.b" }, probe.Value!.ModuleIds); + } + + [Test] + public void SetModuleDisabled_RetiresModuleAndDependents_EnableReloadsWithoutRestart() + { + var provider = new FakeInstaller("app.a", "a"); + var dependent = new FakeInstaller("app.b", "b") { DependsOn = new[] { "app.a" } }; + var bystander = new FakeInstaller("app.c", "c"); + var host = CreateHost(provider, dependent, bystander); + TestAwait.Run(host.StartAsync()); + Assert.AreEqual(3, host.ActiveModuleIds.Count); + + TestAwait.Run(host.SetModuleDisabledAsync("app.a", true)); + + Assert.IsTrue(host.IsModuleDisabled("app.a")); + Assert.IsFalse(host.IsModuleActive("app.a")); + Assert.IsFalse(host.IsModuleActive("app.b"), "依赖者随提供者停用"); + Assert.IsTrue(host.IsModuleActive("app.c"), "无关模块不受影响,宿主不重启"); + Assert.IsTrue(host.IsRunning); + + TestAwait.Run(host.SetModuleDisabledAsync("app.a", false)); + + Assert.IsTrue(host.IsModuleActive("app.a")); + Assert.IsTrue(host.IsModuleActive("app.b"), "重新启用后依赖链整体复活"); + Assert.AreEqual(2, provider.InitCount, "重启用会重新执行安装器初始化(重建语义)"); + Assert.AreEqual(1, bystander.InitCount, "未受影响的模块不重复初始化"); + } + + [Test] + public void DisabledViaSettings_ExcludedInitially_CanEnableAtRuntime() + { + _settings.disabledModuleIds = new[] { "app.a" }; + var provider = new FakeInstaller("app.a", "a"); + var dependent = new FakeInstaller("app.b", "b") { DependsOn = new[] { "app.a" } }; + var host = CreateHost(provider, dependent); + + TestAwait.Run(host.StartAsync()); + + Assert.IsTrue(host.IsModuleDisabled("app.a")); + Assert.IsFalse(host.TryGetModuleFiber("app.a", out _)); + Assert.IsTrue(host.IsModuleWaiting("app.b"), "依赖被禁用模块的安装器保持等待而非报错"); + CollectionAssert.AreEqual(new[] { "app.b" }, host.WaitingModuleIds); + + TestAwait.Run(host.SetModuleDisabledAsync("app.a", false)); + Assert.IsTrue(host.IsModuleActive("app.a")); + Assert.IsTrue(host.IsModuleActive("app.b")); + } + + [Test] + public void UnknownModuleId_Throws() + { + var host = CreateHost(new FakeInstaller("app.a", "a")); + TestAwait.Run(host.StartAsync()); + + Assert.Throws(() => + TestAwait.Run(host.SetModuleDisabledAsync("app.no-such", true))); + } + + [Test] + public void DuplicateModuleIds_ThrowAtConstruction() + { + Assert.Throws(() => + CreateHost(new FakeInstaller("app.dup", "a"), new FakeInstaller("app.dup", "b"))); + } + + [Test] + public void ShutdownAsync_RetiresEverything() + { + var provider = new FakeInstaller("app.a", "a"); + var host = CreateHost(provider); + TestAwait.Run(host.StartAsync()); + Assert.IsTrue(host.IsModuleActive("app.a")); + + TestAwait.Run(host.ShutdownAsync()); + + Assert.IsFalse(host.IsRunning); + Assert.IsFalse(host.IsModuleActive("app.a")); + } + + [Test] + public void OverrideModuleComponent_ReplacesInstallerWrapper() + { + var installer = new FakeInstaller("app.a", "a"); + var host = CreateHost(installer); + host.OverrideModuleComponent("app.a", () => new NativeReplacementComponent()); + + TestAwait.Run(host.StartAsync()); + + Assert.IsTrue(host.IsModuleActive("app.a")); + Assert.AreEqual(0, installer.InitCount, "原生组件替换后安装器不再被包装执行"); + Assert.IsTrue(host.TryGetModuleFiber("app.a", out var fiber)); + Assert.IsInstanceOf(fiber.Component); + } + + /// 替换安装器的原生测试组件(发布同一模块键)。 + private sealed class NativeReplacementComponent : IShrinkComponent + { + public string Name => "app.a"; + public System.Collections.Generic.IReadOnlyList Inject => System.Array.Empty(); + public System.Collections.Generic.IReadOnlyList Provide => + new[] { ShrinkAppInstallerComponent.ModuleKeyPrefix + "app.a" }; + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + ctx.Set(Provide[0], Name); + return UniTask.CompletedTask; + } + } + + private sealed class FakeInstaller : IShrinkAppModuleInstaller + { + public FakeInstaller(string moduleId, string serviceValue) + { + ModuleId = moduleId; + ServiceValue = serviceValue; + } + + public string ModuleId { get; } + public int Order => 0; + public IReadOnlyList DependsOn { get; set; } = Array.Empty(); + public int InitCount { get; private set; } + private string ServiceValue { get; } + + public void RegisterServices(ShrinkAppContext context) + { + context.Services.Register(new FakeService(ServiceValue)); + } + + public UniTask InitializeAsync(ShrinkAppContext context) + { + InitCount++; + return UniTask.CompletedTask; + } + } + + private sealed class FakeService + { + public FakeService(string value) + { + Value = value; + } + + public string Value { get; } + } + } +} diff --git a/Tests/ShrinkAppLoaderHostTests.cs.meta b/Tests/ShrinkAppLoaderHostTests.cs.meta new file mode 100644 index 0000000..4243e37 --- /dev/null +++ b/Tests/ShrinkAppLoaderHostTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4aa1105e16f405b428d2c5a841521479 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/ShrinkContext.AppAdapter.Tests.asmdef b/Tests/ShrinkContext.AppAdapter.Tests.asmdef new file mode 100644 index 0000000..9ea0043 --- /dev/null +++ b/Tests/ShrinkContext.AppAdapter.Tests.asmdef @@ -0,0 +1,32 @@ +{ + "name": "ShrinkContext.AppAdapter.Tests", + "rootNamespace": "ShrinkContext.AppAdapter.Tests", + "references": [ + "ShrinkContext.AppAdapter.Runtime", + "ShrinkContext.AppAdapter.Editor", + "ShrinkContext.Core.Runtime", + "ShrinkApp.Core.Runtime", + "ShrinkApp.Starter.Basic.Runtime", + "ShrinkEventBus.Runtime", + "ShrinkNetwork.Integration.App", + "ShrinkCommand.Integration.App", + "ShrinkDataSaver.Integration.App", + "ShrinkNetwork.Runtime", + "ShrinkCommand.Runtime", + "UniTask", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/ShrinkContext.AppAdapter.Tests.asmdef.meta b/Tests/ShrinkContext.AppAdapter.Tests.asmdef.meta new file mode 100644 index 0000000..2067dc6 --- /dev/null +++ b/Tests/ShrinkContext.AppAdapter.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 907201a93f40ce44781eaa942ed9dbac +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json new file mode 100644 index 0000000..093157f --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "com.cneicy.shrink-context-app-adapter", + "version": "0.1.0", + "displayName": "ShrinkContext - ShrinkApp Adapter", + "description": "把现有 IShrinkAppModuleInstaller 包装为 ShrinkContext 组件:依赖缺失改为等待而非抛错,模块激活由响应式余效应驱动。", + "unity": "2022.3", + "dependencies": { + "com.cneicy.shrink-context-core": "0.1.0", + "com.cneicy.shrink-app-core": "0.1.1", + "com.cysharp.unitask": "2.5.10" + }, + "keywords": [ + "context", + "cordis", + "shrinkapp", + "adapter", + "installer" + ], + "author": { + "name": "cneicy", + "url": "https://git.crash.work/ShrinkSDK" + }, + "documentationUrl": "https://git.crash.work/ShrinkSDK/ShrinkContext.AppAdapter" +} diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..2c37cce --- /dev/null +++ b/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2a898320ff524ef4d84a3f2021773a8d +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: