■ 概要
本記事では、
Unityで画像(Texture2D)を動的に表示させる際に気を付けたい
メモリリークの話と、その解決方法
について紹介したいと思います。
みなさんもUnityでアプリ開発をしていると、
「画像を動的に表示したい。または画像を切り替えたい」
って場面が山ほどあると思います。
特に私の場合実装の都合上、外部の画像ファイルを「StreamingAssets」に格納してそれを動的に切り替えることが多いのですが、そこにメモリリークの罠がありました。
(こんなの知ってて当たり前だよって方もいらっしゃるかと思いますが、個人的には実装に詰まったポイントでもあったので、ここに書き認めておこうかと思います。)
■ 環境
Unityバージョン : Unity2022.3.13f1
プロジェクトテンプレート : 3D
■ 実装
まずは今回のサンプル実装から。
こんな感じでボタンを押したら画像が動的に切り替わります。
画像を切り替えるソースコード。
(こちらは修正後の、解決方法となるソースコードです。)
using System.IO;
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
public class BaseView : MonoBehaviour
{
/// <summary>テクスチャを表示するRawImage</summary>
/// <value></value>
[SerializeField]
private RawImage previewRawImage;
/// <summary>表示するテクスチャ2Dオブジェクト</summary>
/// <value></value>
private Texture2D previewTexture;
/// <summary>プレビューにテクスチャをセットする</summary>
/// <param name="fileName">ファイル名</param>
private void SetPreviewTexture(string fileName)
{
if (!previewTexture)
{
previewTexture = new Texture2D(2, 2, TextureFormat.RGB24, false);
previewRawImage.texture = previewTexture;
}
LoadTextureFromAssets(previewTexture, fileName);
}
/// <summary>StreamingAssetsPathから画像をロードする</summary>
/// <param name="texture">テクスチャーオブジェクト</param>
/// <param name="fileName">ファイル名</param>
private bool LoadTextureFromAssets(Texture2D texture, string fileName)
{
string assetsPath = Path.Combine(Application.streamingAssetsPath, fileName);
var bytes = File.ReadAllBytes(assetsPath);
return texture.LoadImage(bytes);
}
/// <summary>ボタン押下</summary>
/// <param name="index">ボタンのindex</param>
public void ImageButtonPressed(int index)
{
SetPreviewTexture($"img_{index}.png");
}
}
1. メモリリークのポイント
これまでは上記のような実装をしようと思ったら、こんな感じで処理を記述してました。
画像を切り替えようとする際に、その都度Texture2Dを生成しそれをRawImageにセットしていました。
private void SetPreviewTexture(string fileName)
{
// Textureをロード。
var previewTexture = LoadTextureFromAssets(fileName);
// RawImageにセット。
previewRawImage.texture = previewTexture;
}
private Texture2D LoadTextureFromAssets(string fileName)
{
string assetsPath = Path.Combine(Application.streamingAssetsPath, fileName);
// ここで都度Texture2Dを生成。
var texture = new Texture2D(2, 2, TextureFormat.RGB24, true);
var bytes = File.ReadAllBytes(assetsPath);
texture.LoadImage(bytes);
return texture;
}
その結果。
「Profiler」でメモリの推移を確認します。
下の方の「Objects Status」の「Textures」の欄を見てみると…
画面上には画像は1つしか表示されていないのに
「Count」も「Size」もボタンを押すたびに増加し続けています…!
このボタン押下が実行され続ければメモリリークが積み重なり、
いずれアプリはクラッシュすることになります。
2. 原因
Unityはテクスチャのロードやアンロードなどの管理を最適化するために、内部的なテクスチャキャッシュシステムを備えています。しかしこのシステムが適用されるのはアセットバンドル内から生成したテクスチャーだけのようです。
// 例えばこんな感じでテクスチャーを生成する場合は、
// テクスチャキャッシュシステムが適用され、メモリが積み重なり続ける事象が発生しない。
private Texture2D LoadTextureFromResources(string fileName)
{
return Resources.Load(fileName) as Texture2D;
}
「StreamingAssets」のようなアセットバンドル外から生成したテクスチャーは、テクスチャキャッシュシステムの管轄外。生成したテクスチャー毎にメモリが増加します。
private Texture2D LoadTextureFromAssets(string fileName)
{
string assetsPath = Path.Combine(Application.streamingAssetsPath, fileName);
// ここでテスクチャーを生成する毎にProfilerの「Count」と「Size」が増える。
var texture = new Texture2D(2, 2, TextureFormat.RGB24, true);
var bytes = File.ReadAllBytes(assetsPath);
// ここでテクスチャー毎に画像データをロードするので、
// その分メモリが増加しさらに「Size」が増える。
texture.LoadImage(bytes);
return texture;
}
(今回のケースだと、都度Texture2Dを生成してもRawImageのTextureにセットしたタイミングで、以前のセットしてあったTextureは勝手に解放されるものだと勘違いしてました…。)
3. 解決方法
アセットバンドル外から生成するテクスチャーのメモリ増加を防ぐためには、
①使用しなくなったタイミングで明示的に解放
もしくは、
②既存のTexture2Dオブジェクトを再利用し、画像データだけを更新
する必要があります。
①の方法は、Destroy()やResources.UnloadUnusedAssets())で解放が可能なようです。
今回は②の「既存のTexture2Dオブジェクトを再利用し、画像データだけを更新」する方法を実践します。
こちらが修正したソースコード。
Texture2Dを生成するのは、Texture2Dオブジェクトが存在しないはじめのタイミングのみ。それ以降はその生成済みのTexture2Dオブジェクトに対して、画像データをロードし直すような形になってます。
private void SetPreviewTexture(string fileName)
{
if (!previewTexture)
{
// テクスチャーが存在しない時のみ生成し、RawImageにセットする
previewTexture = new Texture2D(2, 2, TextureFormat.RGB24, false);
previewRawImage.texture = previewTexture;
}
// 生成済みのテクスチャに対してデータを書き換える
LoadTextureFromAssets(previewTexture, fileName);
}
private bool LoadTextureFromAssets(Texture2D texture, string fileName)
{
string assetsPath = Path.Combine(Application.streamingAssetsPath, fileName);
var bytes = File.ReadAllBytes(assetsPath);
// 引数から渡ってきたTextureに対して画像データをロードする
return texture.LoadImage(bytes);
}
その結果。
改めてProfilerの「Objects Status」の「Textures」の欄を確認します。
何度ボタンを押しても「Count」と「Size」共に画像1枚分だけ増加し、
画面上に表示されている画像の数と一致しています!
■ 最後に (感想)
最後までご覧いただき、ありがとうございました。