From 91aa2479b94d63144b6fe885d34b1dbeaa995421 Mon Sep 17 00:00:00 2001 From: tverona1 Date: Sat, 23 Nov 2019 13:09:25 -0800 Subject: [PATCH] Optimize memory utilization for large # of icons Optimize memory utilization for large # of icons by dynaically loading / unloading icons as they are scrolled into view. --- Assets/Scripts/AppEntry.cs | 173 ++++++++++++++++++++++++++----- Assets/Scripts/AppProcessor.cs | 139 ++++++++++++++++++++++--- Assets/Scripts/GridPopulation.cs | 56 ++++------ Assets/Scripts/SkyboxHandler.cs | 95 ++++++----------- 4 files changed, 327 insertions(+), 136 deletions(-) diff --git a/Assets/Scripts/AppEntry.cs b/Assets/Scripts/AppEntry.cs index 8127ba6..9d403a9 100644 --- a/Assets/Scripts/AppEntry.cs +++ b/Assets/Scripts/AppEntry.cs @@ -1,32 +1,157 @@ -using System.Collections; +using System; +using System.Collections; using System.Collections.Generic; +using System.Threading.Tasks; using TMPro; using UnityEngine; +using UnityEngine.UI; -public class AppEntry : MonoBehaviour +namespace QuestAppLauncher { - // Sprite gameobject - public GameObject sprite; - - // TMP text - public TextMeshProUGUI text; - - // App entry contents - public string packageId; - public string appName; - public string externalIconPath; - public int installedApkIndex; - public bool isRenameMode; - - // Start is called before the first frame update - void Start() + public class AppEntry : MonoBehaviour { - - } + // Sprite gameobject + public GameObject sprite = null; + public Image image = null; + public AspectRatioFitter aspectRatioFitter = null; - // Update is called once per frame - void Update() - { - + // TMP text + public TextMeshProUGUI text; + + // Scroll view transform + public RectTransform scrollViewTransform; + + // App entry contents + public string packageId; + public string appName; + public string externalIconPath = null; + public int installedApkIndex = -1; + public bool isRenameMode; + public bool dynamicallyLoadIcon; + + const int MAX_FRAME_DELAYS = 35; + private float lastPos = -1f; + private bool isIconLoaded = false; + private int curFrameDelay = 0; + private int maxFrameDelays = 0; + private float maxDistance = 0f; + private System.Random rand = new System.Random(); + + // Start is called before the first frame update + void Start() + { + // Calculate max distance outside the visible scroll area, beyond which we'll unload the icon to reduce memory + this.maxDistance = 1.5f * this.scrollViewTransform.rect.size.y; + + // Calculate the maximum frame delays when populating this icon; used to stagger loading of icons in the visible scroll area + this.maxFrameDelays = this.rand.Next(MAX_FRAME_DELAYS); + } + + // Update is called once per frame + async void Update() + { + if (!this.dynamicallyLoadIcon || null == this.scrollViewTransform || + (this.externalIconPath == null && this.installedApkIndex == -1)) + { + return; + } + + var localPos = this.scrollViewTransform.InverseTransformPoint(this.transform.position).y; + if (Mathf.Abs(localPos) < this.maxDistance) + { + // Don't bother loading icons while we're scrolling + if (this.lastPos == -1) + { + this.lastPos = localPos; + return; + } + + if (this.lastPos != localPos) + { + this.lastPos = localPos; + return; + + } + + // We're within max distance, so show the icon after some delay (to stagger loading of icons) + if (this.curFrameDelay < this.maxFrameDelays) + { + this.curFrameDelay++; + return; + } + this.maxFrameDelays = this.rand.Next(MAX_FRAME_DELAYS); + this.curFrameDelay = 0; + + if (!this.isIconLoaded) + { + UnloadIcon(); + await LoadIcon(); + this.isIconLoaded = true; + } + } + else if (this.isIconLoaded) + { + // We're outside max distance, so unload the icon + UnloadIcon(); + this.isIconLoaded = false; + } + } + + public async Task LoadIcon() + { + var result = await AppProcessor.GetAppIconAsync(this.externalIconPath, this.installedApkIndex, 1024 * 1024); + var image = result.Item1; + var imageWidth = result.Item2; + var imageHeight = result.Item3; + + Texture2D texture = null; + + // Set the icon image + if (imageWidth == 0 || imageHeight == 0) + { + texture = new Texture2D(2, 2, TextureFormat.RGBA32, false); + texture.filterMode = FilterMode.Trilinear; + texture.anisoLevel = 16; + texture.LoadImage(image); + } + else + { + texture = new Texture2D(imageWidth, imageHeight, TextureFormat.ARGB32, false); + texture.filterMode = FilterMode.Trilinear; + texture.anisoLevel = 16; + texture.LoadRawTextureData(image); + texture.Apply(); + } + + var rect = new Rect(0, 0, texture.width, texture.height); + this.image.sprite = Sprite.Create(texture, rect, new Vector2(0.5f, 0.5f)); + this.image.color = Color.white; + + // Preserve icon's aspect ratio + this.aspectRatioFitter.aspectRatio = (float)texture.width / (float)texture.height; + } + + private void UnloadIcon() + { + // We always manually create the sprite's texture object, + // so we must explicitly destroy it. + if (null != this.image.sprite) + { + if (null != this.image.sprite.texture) + { + DestroyImmediate(image.sprite.texture); + } + + DestroyImmediate(this.image.sprite); + this.image.sprite = null; + this.image.color = Color.clear; + } + } + + private void OnDestroy() + { + Debug.Log("AppEntry.OnDestroy"); + UnloadIcon(); + } } -} +} \ No newline at end of file diff --git a/Assets/Scripts/AppProcessor.cs b/Assets/Scripts/AppProcessor.cs index 1326323..71d83e3 100644 --- a/Assets/Scripts/AppProcessor.cs +++ b/Assets/Scripts/AppProcessor.cs @@ -482,17 +482,114 @@ namespace QuestAppLauncher } } - public static byte[] GetAppIcon(string iconPath, int appIndex) + /// + /// Loads an image from specified path. Scaled down if image is larger than max pixels. + /// + /// Image path + /// Max pixels + /// Image and dimensions + public static async Task<(byte[], int, int)> LoadRawImageAsync(string path, int maxPixels) { - byte[] bytesIcon = null; - bool useApkIcon = true; - if (null != iconPath) + int imageHeight = 0; + int imageWidth = 0; + byte[] image = null; + + await Task.Run(() => { - // Use overridden icon + AndroidJNI.AttachCurrentThread(); + try { - bytesIcon = File.ReadAllBytes(iconPath); - useApkIcon = false; + LoadRawImage(path, maxPixels, out image, out imageWidth, out imageHeight); + } + finally + { + AndroidJNI.DetachCurrentThread(); + } + }); + + return (image, imageWidth, imageHeight); + } + + /// + /// Loads an image from specified path. Scaled down if image is larger than max pixels. + /// + /// Image path + /// Max pixels + /// Output raw image byte array + /// Output width + /// Output height + public static void LoadRawImage(string path, int maxPixels, out byte[] image, out int imageWidth, out int imageHeight) + { + image = null; + imageWidth = 0; + imageHeight = 0; + + try + { + using (AndroidJavaClass unity = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (AndroidJavaObject currentActivity = unity.GetStatic("currentActivity")) + { + // Call Android plugin to load the raw image. + var jo = currentActivity.CallStatic("loadRawImage", path, maxPixels); + if (null == jo) + { + return; + } + + // Get the width, height and raw image data. + imageWidth = jo.Get("width"); + imageHeight = jo.Get("height"); + var rawImage = (byte[])(Array)jo.Get("rawImage"); + + // The image is in ARGB_8888 format (Alpha, Red, Green, Blue - each 1 byte). In addition, (0, 0) coordinates are bottom-left. + // Unity expects RGBA (with Alpha as the last byte) and origin at top-left. So we need to compensate for both. + + // Shift alpha + for (var i = 0; i < rawImage.Length / 4; i++) + { + var tmp = rawImage[i * 4 + 3]; + rawImage[i * 4 + 3] = rawImage[i * 4 + 2]; + rawImage[i * 4 + 2] = rawImage[i * 4 + 1]; + rawImage[i * 4 + 1] = rawImage[i * 4]; + rawImage[i * 4] = tmp; + } + + // Swap rows + var row = new byte[imageWidth * 4]; + for (var i = 0; i < imageHeight / 2; i++) + { + Buffer.BlockCopy(rawImage, i * imageWidth * 4, row, 0, imageWidth * 4); + Buffer.BlockCopy(rawImage, (imageHeight - i - 1) * imageWidth * 4, rawImage, i * imageWidth * 4, imageWidth * 4); + Buffer.BlockCopy(row, 0, rawImage, (imageHeight - i - 1) * imageWidth * 4, imageWidth * 4); + } + + image = rawImage; + } + } + catch (Exception e) + { + // Fall back to using the apk icon + Debug.LogFormat("Error decoding image [{0}]: {1}", path, e.Message); + } + } + + /// + /// Loads an app icon asynchronously, either from specified external icon path or, if path is not provided or fails to load, + /// falls back to loading the icon from the apk itself. + /// + /// External icon path + /// App index (used when falling back to loading icon from APK) + /// Max pixels - image will be scaled down if larger than this size + /// Icon bytes and dimensions + public static async Task<(byte[], int, int)> GetAppIconAsync(string iconPath, int appIndex, int maxPixels) + { + if (null != iconPath) + { + // Load icon from file path + try + { + return await LoadRawImageAsync(iconPath, maxPixels); } catch (Exception e) { @@ -501,17 +598,27 @@ namespace QuestAppLauncher } } - if (useApkIcon) + byte[] bytesIcon = null; + await Task.Run(() => { - // Use built-in icon from the apk - using (AndroidJavaClass unity = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) - using (AndroidJavaObject currentActivity = unity.GetStatic("currentActivity")) - { - bytesIcon = (byte[])(Array)currentActivity.Call("getIcon", appIndex); - } - } + AndroidJNI.AttachCurrentThread(); - return bytesIcon; + try + { + // Use built-in icon from the apk + using (AndroidJavaClass unity = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (AndroidJavaObject currentActivity = unity.GetStatic("currentActivity")) + { + bytesIcon = (byte[])(Array)currentActivity.Call("getIcon", appIndex); + } + } + finally + { + AndroidJNI.DetachCurrentThread(); + } + }); + + return (bytesIcon, 0, 0); } /// diff --git a/Assets/Scripts/GridPopulation.cs b/Assets/Scripts/GridPopulation.cs index 47cd434..30d1cad 100644 --- a/Assets/Scripts/GridPopulation.cs +++ b/Assets/Scripts/GridPopulation.cs @@ -28,6 +28,11 @@ namespace QuestAppLauncher } } + // Max icons to initially populate. If we have more than this number, + // then we fall back into dynamically loading icons as we scroll to conserve memory. + const int MaxIconsOnInitialPopulation = 150; + + // Grid container game object public GameObject panelContainer; @@ -211,29 +216,31 @@ namespace QuestAppLauncher ResizeTabContent(this.rightTabContainer.transform, size, rightTabs.Count, false); } + bool loadIcons = apps.Count <= MaxIconsOnInitialPopulation; + // Populate grid with app information (name & icon) // Sort by custom comparer foreach (var app in apps.OrderBy(key => key.Value, new AppComparer())) { // Add to all tab - await AddCellToGridAsync(app.Value, gridContents[AppProcessor.Tab_All].transform); + await AddCellToGridAsync(app.Value, gridContents[AppProcessor.Tab_All].transform, loadIcons); // Add to auto (built-in) tabs if (gridContents.ContainsKey(app.Value.AutoTabName)) { - await AddCellToGridAsync(app.Value, gridContents[app.Value.AutoTabName].transform); + await AddCellToGridAsync(app.Value, gridContents[app.Value.AutoTabName].transform, loadIcons); } // Add to tab1 if (null != app.Value.Tab1Name && gridContents.ContainsKey(app.Value.Tab1Name)) { - await AddCellToGridAsync(app.Value, gridContents[app.Value.Tab1Name].transform); + await AddCellToGridAsync(app.Value, gridContents[app.Value.Tab1Name].transform, loadIcons); } // Add to tab2 if (null != app.Value.Tab2Name && gridContents.ContainsKey(app.Value.Tab2Name)) { - await AddCellToGridAsync(app.Value, gridContents[app.Value.Tab2Name].transform); + await AddCellToGridAsync(app.Value, gridContents[app.Value.Tab2Name].transform, loadIcons); } } } @@ -252,11 +259,13 @@ namespace QuestAppLauncher // Set panel size SetGridSize(this.renamePanelContainer, gridContent, config.gridSize.rows, config.gridSize.cols); + bool loadIcons = apps.Count <= MaxIconsOnInitialPopulation; + // Populate grid with app information (name & icon) // Sort alphabetically foreach (var app in apps.OrderBy(key => key.Value.AppName, StringComparer.InvariantCultureIgnoreCase)) { - await AddCellToGridAsync(app.Value, gridContent.transform, true); + await AddCellToGridAsync(app.Value, gridContent.transform, loadIcons, true); } } @@ -359,7 +368,7 @@ namespace QuestAppLauncher } } - private async Task AddCellToGridAsync(ProcessedApp app, Transform transform, bool isRenameMode = false) + private async Task AddCellToGridAsync(ProcessedApp app, Transform transform, bool loadIcon, bool isRenameMode = false) { if (app.Index == -1 && string.IsNullOrEmpty(app.IconPath)) { @@ -367,46 +376,25 @@ namespace QuestAppLauncher return; } - // Get app icon in background - var bytesIcon = await Task.Run(() => - { - AndroidJNI.AttachCurrentThread(); - - try - { - return AppProcessor.GetAppIcon(app.IconPath, app.Index); - } - finally - { - AndroidJNI.DetachCurrentThread(); - } - }); - // Create new instances of our app info prefabCell var newObj = (GameObject)Instantiate(this.prefabCell, transform); // Set app entry info var appEntry = newObj.GetComponent(); + appEntry.scrollViewTransform = transform.parent.parent.gameObject.GetComponent(); appEntry.packageId = app.PackageName; appEntry.appName = app.AppName; appEntry.isRenameMode = isRenameMode; appEntry.installedApkIndex = app.Index; appEntry.externalIconPath = app.IconPath; - // Set the icon image - if (null != bytesIcon) - { - var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false); - texture.filterMode = FilterMode.Trilinear; - texture.anisoLevel = 16; - texture.LoadImage(bytesIcon); - var rect = new Rect(0, 0, texture.width, texture.height); - var image = appEntry.sprite.GetComponent(); - image.sprite = Sprite.Create(texture, rect, new Vector2(0.5f, 0.5f)); + // Dynamically load icon if we're not loading the icon now + appEntry.dynamicallyLoadIcon = !loadIcon; - // Preserve icon's aspect ratio - var aspectRatioFitter = appEntry.sprite.GetComponent(); - aspectRatioFitter.aspectRatio = (float)texture.width / (float)texture.height; + // Set the icon image + if (loadIcon) + { + await appEntry.LoadIcon(); } // Set app name in text diff --git a/Assets/Scripts/SkyboxHandler.cs b/Assets/Scripts/SkyboxHandler.cs index d4975b0..f7daa46 100644 --- a/Assets/Scripts/SkyboxHandler.cs +++ b/Assets/Scripts/SkyboxHandler.cs @@ -12,9 +12,8 @@ namespace QuestAppLauncher public class SkyboxHandler : MonoBehaviour { // Max pixles of skybox image for both equirectangular and cubemap images. - // We calculate this to be: 2048px x 2048px x 6 faces (for cubemap). // Anything larger we'll scale down. We restrict it primarily due to memory constraints. - const int MaxPixels = 2048 * 2048 * 6; + const int MaxPixels = 4096 * 4096; // Skybox selected callback public Action OnSkyboxSelected; @@ -142,66 +141,26 @@ namespace QuestAppLauncher return; } - // Read the image - int imageHeight = 0; - int imageWidth = 0; - var image = await Task.Run(() => + // Destroy existing skybox + if (null != RenderSettings.skybox && RenderSettings.skybox != this.defaultSkybox) { - AndroidJNI.AttachCurrentThread(); - - try + // Destroy texture + var oldTexture = RenderSettings.skybox.GetTexture("_Tex"); + if (null != oldTexture) { - using (AndroidJavaClass unity = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) - using (AndroidJavaObject currentActivity = unity.GetStatic("currentActivity")) - { - // Call Android plugin to load the raw image. - var jo = currentActivity.CallStatic("loadRawImage", MakeAbsoluteSkymapPath(skyboxPath), MaxPixels); - if (null == jo) - { - return null; - } - - // Get the width, height and raw image data. - imageWidth = jo.Get("width"); - imageHeight = jo.Get("height"); - var rawImage = (byte[])(Array)jo.Get("rawImage"); - - // The image is in ARGB_8888 format (Alpha, Red, Green, Blue - each 1 byte). In addition, (0, 0) coordinates are bottom-left. - // Unity expects RGBA (with Alpha as the last byte) and origin at top-left. So we need to compensate for both. - - // Shift alpha - for (var i = 0; i < rawImage.Length / 4; i++) - { - var tmp = rawImage[i * 4 + 3]; - rawImage[i * 4 + 3] = rawImage[i * 4 + 2]; - rawImage[i * 4 + 2] = rawImage[i * 4 + 1]; - rawImage[i * 4 + 1] = rawImage[i * 4]; - rawImage[i * 4] = tmp; - } - - // Swap rows - var row = new byte[imageWidth * 4]; - for (var i = 0; i < imageHeight / 2; i++) - { - Buffer.BlockCopy(rawImage, i * imageWidth * 4, row, 0, imageWidth * 4); - Buffer.BlockCopy(rawImage, (imageHeight - i - 1) * imageWidth * 4, rawImage, i * imageWidth * 4, imageWidth * 4); - Buffer.BlockCopy(row, 0, rawImage, (imageHeight - i - 1) * imageWidth * 4, imageWidth * 4); - } - - return rawImage; - } + DestroyImmediate(oldTexture); } - catch (Exception e) - { - // Fall back to using the apk icon - Debug.LogFormat("Error decoding image [{0}]: {1}", skyboxPath, e.Message); - return null; - } - finally - { - AndroidJNI.DetachCurrentThread(); - } - }); + + // Destroy material + DestroyImmediate(RenderSettings.skybox); + RenderSettings.skybox = null; + } + + // Read the image + var result = await AppProcessor.LoadRawImageAsync(MakeAbsoluteSkymapPath(skyboxPath), MaxPixels); + var image = result.Item1; + var imageWidth = result.Item2; + var imageHeight = result.Item3; if (null == image) { @@ -210,23 +169,27 @@ namespace QuestAppLauncher return; } + Texture2D texture = null; + Material material = null; + bool destroyTexture = false; + try { // Load the image into a 2D texture. We decode in background thread (above) in Java and load the raw image here // because Texture2D.LoadImage on the main thread can cause significant freezes since it is not async. - var texture = new Texture2D(imageWidth, imageHeight, TextureFormat.ARGB32, false); + texture = new Texture2D(imageWidth, imageHeight, TextureFormat.ARGB32, false); texture.filterMode = FilterMode.Trilinear; texture.anisoLevel = 16; texture.LoadRawTextureData(image); texture.Apply(); - Material material; if (4 * texture.height == 3 * texture.width) { // Texture is a horizontal cross cube map (4:3 aspect ratio). // Load cubemap shader. Also rotate x-axis by 180 degrees to compensate for platform-specific rendering differences // (see https://docs.unity3d.com/Manual/SL-PlatformDifferences.html). Debug.LogFormat("Setting horizontal-cross cubemap skybox"); + destroyTexture = true; material = new Material(Shader.Find("skybox/cube")); material.SetFloat("_RotationX", 180); material.SetTexture("_Tex", CubemapFromHorizCrossTexture2D(texture)); @@ -237,6 +200,7 @@ namespace QuestAppLauncher // Load cubemap shader. Also rotate x-axis by 180 degrees to compensate for platform-specific rendering differences // (see https://docs.unity3d.com/Manual/SL-PlatformDifferences.html). Debug.LogFormat("Setting horizontal cubemap skybox"); + destroyTexture = true; material = new Material(Shader.Find("skybox/cube")); material.SetFloat("_RotationX", 180); material.SetTexture("_Tex", CubemapFromHorizTexture2D(texture)); @@ -258,6 +222,13 @@ namespace QuestAppLauncher Debug.LogFormat("Exception: {0}", e.Message); SetDefaultSkybox(); } + finally + { + if (destroyTexture && null != texture) + { + DestroyImmediate(texture); + } + } } /// @@ -292,7 +263,7 @@ namespace QuestAppLauncher /// private static Cubemap CubemapFromHorizTexture2D(Texture2D texture) { - int cubedim = texture.width / 4; + int cubedim = texture.height; Cubemap cube = new Cubemap(cubedim, TextureFormat.ARGB32, false); cube.SetPixels(texture.GetPixels(0, 0, cubedim, cubedim), CubemapFace.PositiveX); cube.SetPixels(texture.GetPixels(cubedim, 0, cubedim, cubedim), CubemapFace.NegativeX);