update logic download to cnd with addressable

This commit is contained in:
CuongNV
2026-05-13 15:33:35 +07:00
parent b242bf46d3
commit 41fae95f46
7 changed files with 553 additions and 0 deletions
@@ -53,6 +53,15 @@ namespace BrewMonster.Scripts
base.Initialize();
_isInitialized = false;
_initializationTcs = new UniTaskCompletionSource();
StartAddressablesInitAfterBootstrapGate().Forget();
}
/// <summary>
/// Waits for <see cref="GameContentBootstrap"/> (version HTTP + optional URL rewrite) when that gate is active.
/// </summary>
async UniTaskVoid StartAddressablesInitAfterBootstrapGate()
{
await GameContentBootstrap.WaitForPreAddressablesSetupIfAnyAsync();
Addressables.InitializeAsync().Completed += OnInitializeComplete;
}
@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using BrewMonster;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.AddressableAssets.ResourceLocators;
using UnityEngine.ResourceManagement.AsyncOperations;
namespace BrewMonster.Scripts
{
/// <summary>
/// Remote catalog / bundle flows: CheckForCatalogUpdates, UpdateCatalogs, optional download size and dependencies.
/// Matches the “catalog new → bundle new / cache” behaviour described in project docs (Addressables overview).
/// </summary>
public static class AddressablesCatalogUpdater
{
/// <summary>
/// Result of checking the remote catalog hash list (may be empty when nothing changed).
/// </summary>
public readonly struct CatalogCheckResult
{
public CatalogCheckResult(bool success, IReadOnlyList<string> catalogsWithUpdates, Exception error)
{
Success = success;
CatalogsWithUpdates = catalogsWithUpdates ?? Array.Empty<string>();
Error = error;
}
public bool Success { get; }
public IReadOnlyList<string> CatalogsWithUpdates { get; }
public bool HasUpdates => CatalogsWithUpdates.Count > 0;
public Exception Error { get; }
}
/// <summary>
/// Result after applying <see cref="Addressables.UpdateCatalogs"/>.
/// </summary>
public readonly struct CatalogApplyResult
{
public CatalogApplyResult(bool success, IReadOnlyList<IResourceLocator> locators, Exception error)
{
Success = success;
Locators = locators ?? Array.Empty<IResourceLocator>();
Error = error;
}
public bool Success { get; }
public IReadOnlyList<IResourceLocator> Locators { get; }
public Exception Error { get; }
}
/// <summary>
/// Ensures Addressables finished initial catalog load before checking remote updates.
/// </summary>
public static async UniTask EnsureInitializedAsync()
{
await Addressables.InitializeAsync().ToUniTask();
}
/// <summary>
/// Calls <see cref="Addressables.CheckForCatalogUpdates"/> — lightweight compared to full bundle downloads.
/// </summary>
/// <param name="autoReleaseHandle">Pass through to Addressables (default true).</param>
public static async UniTask<CatalogCheckResult> CheckForCatalogUpdatesAsync(bool autoReleaseHandle = true)
{
try
{
await EnsureInitializedAsync();
var handle = Addressables.CheckForCatalogUpdates(autoReleaseHandle);
await handle.ToUniTask();
if (handle.Status != AsyncOperationStatus.Succeeded)
{
var err = handle.OperationException;
BMLogger.LogError($"AddressablesCatalogUpdater: CheckForCatalogUpdates failed: {err?.Message}");
return new CatalogCheckResult(false, null, err);
}
var list = handle.Result;
var ids = list != null ? (IReadOnlyList<string>)list : Array.Empty<string>();
return new CatalogCheckResult(true, ids, null);
}
catch (Exception e)
{
BMLogger.LogError($"AddressablesCatalogUpdater: CheckForCatalogUpdates exception: {e.Message}");
return new CatalogCheckResult(false, null, e);
}
}
/// <summary>
/// Downloads new catalog JSON and refreshes locators. Pass <paramref name="catalogIds"/> from
/// <see cref="CatalogCheckResult.CatalogsWithUpdates"/> or null to use Addressables internal list.
/// </summary>
/// <param name="autoCleanBundleCache">When true, removes unreferenced bundles after update (see Addressables docs).</param>
public static async UniTask<CatalogApplyResult> UpdateCatalogsAsync(
IEnumerable<string> catalogIds = null,
bool autoCleanBundleCache = false,
bool autoReleaseHandle = true)
{
try
{
await EnsureInitializedAsync();
AsyncOperationHandle<List<IResourceLocator>> handle;
if (autoCleanBundleCache)
handle = Addressables.UpdateCatalogs(true, catalogIds, autoReleaseHandle);
else
handle = Addressables.UpdateCatalogs(catalogIds, autoReleaseHandle);
await handle.ToUniTask();
if (handle.Status != AsyncOperationStatus.Succeeded)
{
var err = handle.OperationException;
BMLogger.LogError($"AddressablesCatalogUpdater: UpdateCatalogs failed: {err?.Message}");
return new CatalogApplyResult(false, null, err);
}
var locators = handle.Result;
var read = locators != null ? (IReadOnlyList<IResourceLocator>)locators : Array.Empty<IResourceLocator>();
return new CatalogApplyResult(true, read, null);
}
catch (Exception e)
{
BMLogger.LogError($"AddressablesCatalogUpdater: UpdateCatalogs exception: {e.Message}");
return new CatalogApplyResult(false, null, e);
}
}
/// <summary>
/// Check remote catalog, then apply updates if any catalog ids were returned.
/// </summary>
public static async UniTask<CatalogApplyResult> CheckAndUpdateCatalogsIfNeededAsync(bool autoCleanBundleCache = false)
{
var check = await CheckForCatalogUpdatesAsync(true);
if (!check.Success)
return new CatalogApplyResult(false, null, check.Error);
if (!check.HasUpdates)
return new CatalogApplyResult(true, Array.Empty<IResourceLocator>(), null);
return await UpdateCatalogsAsync(check.CatalogsWithUpdates, autoCleanBundleCache, true);
}
/// <summary>
/// Bytes that would be downloaded for <paramref name="key"/> given the current catalog and local cache (0 if fully cached).
/// </summary>
public static async UniTask<long> GetDownloadSizeBytesAsync(object key)
{
try
{
await EnsureInitializedAsync();
var handle = Addressables.GetDownloadSizeAsync(key);
await handle.ToUniTask();
if (handle.Status != AsyncOperationStatus.Succeeded)
{
BMLogger.LogError($"AddressablesCatalogUpdater: GetDownloadSizeAsync failed: {handle.OperationException?.Message}");
return -1;
}
return handle.Result;
}
catch (Exception e)
{
BMLogger.LogError($"AddressablesCatalogUpdater: GetDownloadSizeBytesAsync exception: {e.Message}");
return -1;
}
}
/// <summary>
/// Ensures dependencies for <paramref name="key"/> exist on disk; uses cache when CRC/catalog match (no redundant full download).
/// </summary>
public static async UniTask<bool> DownloadDependenciesAsync(object key, bool autoReleaseHandle = true)
{
try
{
await EnsureInitializedAsync();
var handle = Addressables.DownloadDependenciesAsync(key, autoReleaseHandle);
await handle.ToUniTask();
if (handle.Status != AsyncOperationStatus.Succeeded)
{
BMLogger.LogError($"AddressablesCatalogUpdater: DownloadDependenciesAsync failed: {handle.OperationException?.Message}");
return false;
}
return true;
}
catch (Exception e)
{
BMLogger.LogError($"AddressablesCatalogUpdater: DownloadDependenciesAsync exception: {e.Message}");
return false;
}
}
/// <summary>
/// Removes bundles not referenced by the given catalogs (optional maintenance / “clear old cache”).
/// </summary>
public static async UniTask<bool> CleanBundleCacheAsync(IEnumerable<string> catalogIds = null)
{
try
{
await EnsureInitializedAsync();
var handle = Addressables.CleanBundleCache(catalogIds);
await handle.ToUniTask();
if (handle.Status != AsyncOperationStatus.Succeeded)
{
BMLogger.LogError($"AddressablesCatalogUpdater: CleanBundleCache failed: {handle.OperationException?.Message}");
return false;
}
return handle.Result;
}
catch (Exception e)
{
BMLogger.LogError($"AddressablesCatalogUpdater: CleanBundleCacheAsync exception: {e.Message}");
return false;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8e0ec4bd4b83dbd488a581d5288a5e52
@@ -0,0 +1,80 @@
using System;
using BrewMonster;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.ResourceLocations;
namespace BrewMonster.Scripts
{
/// <summary>
/// Runtime rewrite of bundle/catalog URLs via <see cref="Addressables.InternalIdTransformFunc"/>
/// (e.g. swap CDN host or path prefix). Must run before the first successful remote load — ideally before
/// <see cref="Addressables.InitializeAsync"/> if all remote ids should be rewritten.
/// </summary>
public static class AddressablesRuntimeUrlRewriter
{
static Func<IResourceLocation, string> s_previousTransform;
static string s_fromPrefix;
static string s_toPrefix;
/// <summary>
/// When true, the installed rewriter calls the previous <see cref="Addressables.InternalIdTransformFunc"/> first.
/// </summary>
public static bool ChainPreviousTransform { get; set; } = true;
/// <summary>
/// Replaces the start of each locations <see cref="IResourceLocation.InternalId"/> when it begins with
/// <paramref name="fromPrefix"/> (e.g. build-time profile URL) by <paramref name="toPrefix"/> (runtime CDN).
/// </summary>
public static void InstallPrefixRewrite(string fromPrefix, string toPrefix)
{
if (string.IsNullOrEmpty(fromPrefix))
{
BMLogger.LogError("AddressablesRuntimeUrlRewriter: fromPrefix is null or empty.");
return;
}
s_fromPrefix = fromPrefix;
s_toPrefix = toPrefix ?? string.Empty;
if (ChainPreviousTransform)
s_previousTransform = Addressables.InternalIdTransformFunc;
Addressables.InternalIdTransformFunc = Transform;
BMLogger.Log($"AddressablesRuntimeUrlRewriter: Installed prefix rewrite (from length={fromPrefix.Length}, to length={s_toPrefix.Length}).");
}
/// <summary>
/// Removes this packages rewrite and restores the delegate that was present at install time (if any).
/// </summary>
public static void ClearInstalledRewrite()
{
if (Addressables.InternalIdTransformFunc == Transform)
{
Addressables.InternalIdTransformFunc = s_previousTransform;
s_previousTransform = null;
s_fromPrefix = null;
s_toPrefix = null;
BMLogger.Log("AddressablesRuntimeUrlRewriter: Cleared installed rewrite.");
}
}
static string Transform(IResourceLocation location)
{
string id = location != null ? location.InternalId : null;
if (string.IsNullOrEmpty(id))
return id;
if (ChainPreviousTransform && s_previousTransform != null)
id = s_previousTransform(location);
if (!string.IsNullOrEmpty(s_fromPrefix) &&
id.StartsWith(s_fromPrefix, StringComparison.OrdinalIgnoreCase))
{
return s_toPrefix + id.Substring(s_fromPrefix.Length);
}
return id;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 57d28e0e103bc3743a57b12f74be99ae
@@ -0,0 +1,244 @@
using System;
using BrewMonster;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
namespace BrewMonster.Scripts
{
/// <summary>
/// Bootstrap: gọi server lấy contentVersion + (tuỳ chọn) assetsBaseUrl, gắn URL rewrite trước khi Addressables init,
/// rồi nếu lần đầu hoặc contentVersion khác đã lưu thì cập nhật catalog và tải toàn bộ entry gắn label.
/// First run / version bump: call server for contentVersion + optional assetsBaseUrl, apply URL rewrite before Addressables init,
/// then if first launch or stored version differs, update catalogs and download all entries under the configured label.
/// </summary>
[DefaultExecutionOrder(-2000)]
public class GameContentBootstrap : MonoBehaviour
{
const string PrefsFirstSyncDone = "PW_GameContent_FirstRemoteSyncDone";
const string PrefsLastContentVersion = "PW_GameContent_LastContentVersion";
/// <summary>
/// When non-null, <see cref="AddressableManager"/> waits on this before <see cref="UnityEngine.AddressableAssets.Addressables.InitializeAsync"/>.
/// </summary>
static UniTaskCompletionSource s_addressablesInitGate;
[Header("Version API")]
[Tooltip("GET JSON: { \"contentVersion\": \"12\", \"assetsBaseUrl\": \"https://cdn/.../\" }")]
[SerializeField] string _versionEndpointUrl;
[SerializeField] int _requestTimeoutSeconds = 20;
[Header("Addressables")]
[Tooltip("If true, blocks Addressables init until version HTTP + optional URL rewrite finish (recommended).")]
[SerializeField] bool _holdAddressablesInitUntilVersionChecked = true;
[Tooltip("Prefix baked into remote InternalIds at build (Remote.LoadPath). When server sends assetsBaseUrl, replace this prefix.")]
[SerializeField] string _bakedRemoteUrlPrefixForRewrite;
[Tooltip("Label applied in Addressables Groups to every remote asset that must download on first run / content update.")]
[SerializeField] string _remoteBulkDownloadLabel = "RemoteContent";
[Header("Editor")]
[SerializeField] bool _skipRemoteCallInEditor;
[SerializeField] string _editorFakeContentVersion = "editor";
public event Action<BootstrapResult> Finished;
public static UniTask WaitForPreAddressablesSetupIfAnyAsync()
{
if (s_addressablesInitGate == null)
return UniTask.CompletedTask;
return s_addressablesInitGate.Task;
}
void Awake()
{
if (_holdAddressablesInitUntilVersionChecked)
s_addressablesInitGate ??= new UniTaskCompletionSource();
}
void Start()
{
RunAsync().Forget();
}
void OnDestroy()
{
ReleaseGateIfPending();
}
void OnApplicationQuit()
{
ReleaseGateIfPending();
}
static void ReleaseGateIfPending()
{
if (s_addressablesInitGate != null)
{
s_addressablesInitGate.TrySetResult();
s_addressablesInitGate = null;
}
}
async UniTaskVoid RunAsync()
{
var result = await RunInternalAsync();
Finished?.Invoke(result);
}
public async UniTask<BootstrapResult> RunInternalAsync()
{
try
{
var dto = await FetchVersionDtoAsync();
if (!dto.Ok)
{
AllowAddressablesInit();
return new BootstrapResult(false, false, dto.Error, null);
}
if (!string.IsNullOrWhiteSpace(dto.AssetsBaseUrl) &&
!string.IsNullOrWhiteSpace(_bakedRemoteUrlPrefixForRewrite))
{
AddressablesRuntimeUrlRewriter.InstallPrefixRewrite(
_bakedRemoteUrlPrefixForRewrite,
dto.AssetsBaseUrl);
}
AllowAddressablesInit();
await AddressablesCatalogUpdater.EnsureInitializedAsync();
bool firstRun = PlayerPrefs.GetInt(PrefsFirstSyncDone, 0) == 0;
string localVersion = PlayerPrefs.GetString(PrefsLastContentVersion, string.Empty);
bool needContentWork =
firstRun ||
!string.Equals(localVersion, dto.ContentVersion ?? string.Empty, StringComparison.Ordinal);
if (!needContentWork)
{
BMLogger.Log("[Cuong] GameContentBootstrap: contentVersion unchanged, skip catalog/bulk download.");
return new BootstrapResult(true, false, null, dto.ContentVersion);
}
var catalog = await AddressablesCatalogUpdater.CheckAndUpdateCatalogsIfNeededAsync(false);
if (!catalog.Success)
return new BootstrapResult(false, true, catalog.Error?.Message ?? "Catalog update failed", dto.ContentVersion);
if (string.IsNullOrWhiteSpace(_remoteBulkDownloadLabel))
{
BMLogger.LogError("[Cuong] GameContentBootstrap: _remoteBulkDownloadLabel is empty; skipping bulk download.");
return new BootstrapResult(false, true, "remoteBulkDownloadLabel empty", dto.ContentVersion);
}
bool dl = await AddressablesCatalogUpdater.DownloadDependenciesAsync(_remoteBulkDownloadLabel.Trim(), true);
if (!dl)
return new BootstrapResult(false, true, "DownloadDependencies failed", dto.ContentVersion);
PlayerPrefs.SetString(PrefsLastContentVersion, dto.ContentVersion);
PlayerPrefs.SetInt(PrefsFirstSyncDone, 1);
PlayerPrefs.Save();
BMLogger.Log($"[Cuong] GameContentBootstrap: content sync OK, version={dto.ContentVersion}");
return new BootstrapResult(true, true, null, dto.ContentVersion);
}
catch (Exception e)
{
BMLogger.LogError($"[Cuong] GameContentBootstrap: {e.Message}");
AllowAddressablesInit();
return new BootstrapResult(false, true, e.Message, null);
}
}
static void AllowAddressablesInit()
{
if (s_addressablesInitGate != null)
{
s_addressablesInitGate.TrySetResult();
s_addressablesInitGate = null;
}
}
async UniTask<VersionDtoResult> FetchVersionDtoAsync()
{
#if UNITY_EDITOR
if (_skipRemoteCallInEditor)
{
return new VersionDtoResult(true, _editorFakeContentVersion?.Trim() ?? "editor", null, null);
}
#endif
if (string.IsNullOrWhiteSpace(_versionEndpointUrl))
{
BMLogger.LogWarning("[Cuong] GameContentBootstrap: _versionEndpointUrl empty; using empty contentVersion (no remote check).");
return new VersionDtoResult(true, string.Empty, null, null);
}
using var req = UnityWebRequest.Get(_versionEndpointUrl);
req.timeout = Mathf.Max(5, _requestTimeoutSeconds);
await req.SendWebRequest().ToUniTask();
if (req.result != UnityWebRequest.Result.Success)
return new VersionDtoResult(false, null, null, req.error);
string json = req.downloadHandler?.text;
if (string.IsNullOrWhiteSpace(json))
return new VersionDtoResult(false, null, null, "Empty response body");
try
{
var parsed = JsonUtility.FromJson<VersionResponseJson>(json);
if (parsed == null || string.IsNullOrWhiteSpace(parsed.contentVersion))
return new VersionDtoResult(false, null, null, "JSON missing contentVersion");
return new VersionDtoResult(true, parsed.contentVersion.Trim(), parsed.assetsBaseUrl?.Trim(), null);
}
catch (Exception e)
{
return new VersionDtoResult(false, null, null, $"JSON parse: {e.Message}");
}
}
readonly struct VersionDtoResult
{
public VersionDtoResult(bool ok, string contentVersion, string assetsBaseUrl, string error)
{
Ok = ok;
ContentVersion = contentVersion;
AssetsBaseUrl = assetsBaseUrl;
Error = error;
}
public bool Ok { get; }
public string ContentVersion { get; }
public string AssetsBaseUrl { get; }
public string Error { get; }
}
[Serializable]
class VersionResponseJson
{
public string contentVersion;
public string assetsBaseUrl;
}
public readonly struct BootstrapResult
{
public BootstrapResult(bool success, bool didContentWork, string errorMessage, string serverContentVersion)
{
Success = success;
DidContentWork = didContentWork;
ErrorMessage = errorMessage;
ServerContentVersion = serverContentVersion;
}
public bool Success { get; }
/// <summary>True if catalog/bulk download ran this session.</summary>
public bool DidContentWork { get; }
public string ErrorMessage { get; }
public string ServerContentVersion { get; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2d93de6847f714b43830673032ac76d8