IPhone Texture Manager

De Codepixel

Si pensamos en desarrollar una aplicación con OpenGL ES para iPhone/iPod Touch una de las primeras tareas con las que nos encontraremos será la administración de las texturas. En este artículo se explica como hacer un sencillo administrador de texturas aprovechándose de las características de ObjectiveC y las restricciones de memoria del iPhone.

En los dispositivos móviles la administración de memoria resulta una tarea crítica, sobre todo en los teléfonos móviles en los que siempre es necesario poder obtener por parte del sistema la memoria necesaria para recibir una llamada o un mensaje. En el iPhone cuando uno de estos eventos ocurre y el sistema no posee la suficiente memoria para tratarlos envía una señal a la aplicación en ejecución solicitándole la liberación de memoria; si este no liberase la suficiente memoria el sistema mataría la aplicación para poder manejar la llamada o el mensaje. Es importante por tanto que nuestra aplicación sea capaz de capturar esta señal y liberar toda la memoria posible para que no sea eliminada por el sistema.

ObjectiveC posee un sistema de gestión de memoria por cuenta de referencias. Esto es, cada vez que se referencia un objeto el contador de este incrementa en uno y cada vez que se elimina una referencia el contador se decrementa; si el contador es cero significa que no existe ninguna referencia a él y por tanto puede ser eliminado. Nos podemos aprovechar de esta capacidad para saber cuantas referencias existen a nuestras texturas y cuando pueden ser liberadas.

Para ello creamos una clase llamada Texture que hereda de NSObject, que es la clase que implementa la funcionalidad de cuenta de referencias. Nuestra clase solo almacena unos cuantos datos básicos: El identificador de la textura para OpenGL, su tamaño y el nombre del archivo. A parte incluimos una variable que nos permite saber si está o no cargada en memoria. El código se puede ver a continuación.

//
//  Texture.h
//
 
#import <OpenGLES/EAGL.h>
#import <OpenGLES/ES1/gl.h>
#import <OpenGLES/ES1/glext.h>
 
@interface Texture : NSObject {
	GLuint textureId;
	int width;
	int height;
	bool isLoaded;
	NSString* fileName;
}
 
- (id) initWithContentsOfFile:(NSString *)path;
 
- (void) dealloc;
 
@property(readonly) int width;
@property(readonly) int height;
@property(readonly) bool isLoaded;
@property(readonly) NSString* fileName;
@property(readonly) GLuint textureId;
 
@end

Todas las propiedades son de solo lectura y se pueden inicializar con un único constructor que carga la textura y rellena los datos. Se sobreescribe el método dealloc que nos permite liberar la textura de OpenGL cuando el objeto vaya a ser eliminado.

La implementación de los métodos se puede ver a continuación:

//
//  Texture.m
//
 
#import "Texture.h"
 
 
@implementation Texture
 
- (id) initWithContentsOfFile:(NSString *)path
{
	self = [super init];
 
	if (self)
	{
		fileName = path;
 
		CGImageRef image = [UIImage imageNamed:fileName].CGImage;
		if (image)
		{
			width = CGImageGetWidth(image);
			height = CGImageGetHeight(image);
			GLubyte	*data = (GLubyte*) malloc(width * height * 4);
			CGContextRef context = CGBitmapContextCreate(data, width, height, 8, width * 4, CGImageGetColorSpace(image), kCGImageAlphaPremultipliedLast);
			CGContextDrawImage(context, CGRectMake(0.0, 0.0, (CGFloat)width, (CGFloat)height), image);
			CGContextRelease(context);
			glGenTextures(1, &textureId);
			glBindTexture(GL_TEXTURE_2D, textureId);
			glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
			free(data);
			glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
			isLoaded = true;
		}
		else
		{
			isLoaded = false;
			width = 1; // Division by zero...
			height = 1;
		}
	}
 
	return self;
}
 
- (void) dealloc
{
	glDeleteTextures(1, &textureId);
	[super dealloc];
}
 
@synthesize width;
@synthesize height;
@synthesize isLoaded;
@synthesize fileName;
@synthesize textureId;
 
@end

El método initWithContentsOfFile carga una textura en OpenGL y rellena los datos del tamaño. El método dealloc se encarga de eliminar la textura de OpenGL cuando el objeto vaya a ser eliminado.

Gracias a la cuenta de referencias que lleva ObjC la textura de OpenGL será liberada de memoria cuando el objeto Texture que la haya cargado vaya a ser eliminado y esto será cuando no exista ninguna referencia al mismo. Esto nos permite trabajar con una textura compartida por varios objetos sin preocuparnos quien debe de ser el encargado de liberarla. En el código siguiente vemos un ejemplo comentado de como funciona la cuenta de referencias.

Texture *texture = [Texture alloc]; // Reservamos memoria
[texture initWithContentsOfFile:@"Texture.png"]; // Cargamos la textura, número de referencias = 1
 
[texture retain]; // Incrementamos el contador de referencias = 2
[texture retain]; // número de referencias = 3;
 
[texture release]; // Decrementamos el contador de referencias = 2
[texture release]; // número de referencias = 1
[texture release]; // número de referencias = 0; se llama a dealloc y se elimina el objeto

Como se puede ver en los comentarios una vez inicializado el objeto el contador de referencias es igual a 1. Con los métodos retain y release incrementamos o decrementamos el contador, por tanto es trabajo del programador programar correctamente las llamadas a estas funciones y una vez el contador llegue a cero el sistema se encarga de llamar al método dealloc (que hemos sobreescrito) y liberar la memoria reservada para el objeto.

Ahora necesitamos una forma de evitar que una textura que vaya a ser utilizada por varios objetos se cargue varias veces en memoria. Ahora mismo si iniciamos dos instancias de Texture con la cadena "Texture.png" el archivo se cargará dos veces. Para ello creamos una clase TextureManager que utilizando el patrón Singleton administra la obtención de texturas.

TextureManager se encarga de mantener un diccionario con todas las Texture cargadas, identificadas por la dirección del archivo. En vez de crear directamente un objeto Texture se solicita a TextureManager. Esta clase comprueba si está ya en el diccionario, en este caso lo retorna, y si no está crea una nueva instancia y la registra en el diccionario. De esta forma una textura nunca se carga dos veces. A continuación está la definición de TextureManager.

//
//  TextureManager.h
//
 
@class Texture;
 
@interface TextureManager : NSObject {
	NSMutableDictionary *textures;
}
 
- (Texture*) getTexture:(NSString*)filename;
 
- (void) freeUnused;
 
// Singleton
+ (TextureManager*) instance;
+ (id) allocWithZone:(NSZone*) zone;
- (id) copyWithZone:(NSZone*) zone;
- (id) retain;
- (void) release;
- (id) autorelease;
 
@end

El método getTexture devuelve una instancia de Texture para el archivo solicitado. El método freeUnused nos permitirá liberar de memoria todas aquellas texturas que no estén siendo utilizadas; y el resto de métodos se utilizan para la implementación del patrón Singleton.

Como explicamos al principio el iPhone tiene un mensaje específico para indicar que el sistema necesita memoria y que el programa debe liberar lo suficiente o se forzará su salida. Para ello queremos liberar la memoria utilizada por las texturas cuando no sean útiles, pero tampoco nos interesa liberarlos en cuanto no estén referenciadas. Por ejemplo: si un objeto que es el único que referencia a una textura es eliminado al decrementar la cuenta de referencias de la textura y quedarse a cero la textura se eliminará, si volvemos a crear inmediatamente otro objeto que use esa textura tendremos que volver a cargarla. Para evitar esto lo que podemos hacer es que TextureManager mantenga una referencia a cada textura y por tanto la cuenta nunca baje de uno. Con freeUnused recorremos todas las texturas mirando aquellas que solo mantienen una referencia (la de TextureManager) para eliminarlas del diccionario, decrementando su cuenta, y así liberarlas de memoria definitivamente. freeUnused solo es necesario llamarla cuando recibimos el mensaje del sistema para liberar memoria y por tanto mantenemos las texturas tanto tiempo como sea posible.

A continuación vemos el código de la implementación de TextureManager, tanto los métodos para el patrón Singleton como los propios de la clase. Existen varios "printf" a modo de debug que nos permiten visualizar las cuentas de referencias de las texturas así como comentarios donde se incrementa o decrementan las cuentas.

//
//  TextureManager.m
//
 
 
#import "TextureManager.h"
#import "Texture.h"
 
static TextureManager *singletonInstance = nil;
 
@implementation TextureManager
 
- init
{
	self = [super init];
 
	if (super)
	{
		textures = [[NSMutableDictionary alloc] init];
	}
 
	return self;
}
 
- (void) dealloc
{
	[textures release];
	[super dealloc];
}
 
- (Texture*) getTexture:(NSString*)filename
{
	Texture *texture = [textures objectForKey:filename];
	if (texture == nil)
	{
		texture = [Texture alloc];
		[texture initWithContentsOfFile:filename];
		printf("RetainCount for %s: %d\n", [[texture fileName] UTF8String], [texture retainCount]);
		[textures setObject:texture forKey:filename];
		[texture release]; // El dueño de la textura es la lista (retainCount = 1)
	}
	printf("RetainCount for %s: %d", [[texture fileName] UTF8String], [texture retainCount]);
	return texture;
}
 
- (void) freeUnused
{	
	NSArray *texturesList = [textures allValues]; // Hace un retain a cada objeto (retainCount = 2 para borrar)
	for (int i = 0; i < [texturesList count]; i++)
	{
		Texture *texture = [texturesList objectAtIndex:i];
		printf("RetainCount for %s: %d\n", [[texture fileName] UTF8String], [texture retainCount]);
		if ([texture retainCount] == 2) [texture release];
	}
}
 
+ (TextureManager*) instance
{
	if (singletonInstance == nil)
		return [[self alloc] init];
	else
		return singletonInstance;
}
 
+ (id) allocWithZone:(NSZone*) zone
{
	@synchronized(self)
	{
		if (singletonInstance == nil)
			return singletonInstance = [super allocWithZone:zone];
	}
	return nil;
}
 
- (id) copyWithZone:(NSZone*) zone
{
	return self;
}
 
- (id) retain
{
	return self; // No modifica el contador;
}
 
- (void) release
{
	// Nada que hacer
}
 
- (id) autorelease
{
	return self;
}
 
@end

Para cargar una textura tenemos que usar el siguiente código:

// Carga de la textura
Texture *texture = [[TextureManager instance] getTexture:@"Texture.png"]; // retainCount = 1 (La mantiene TextureManager, en la próxima llamada a freeUnused será eliminada)
[texture retain]; // retainCount = 2 (Mantenemos una referencia a la textura y por tanto no será eliminada al llamar a freeUnused)
 
// Trabajamos con la textura
 
// Liberamos la textura
[texture release]; // retainCount = 1 (Decrementamos el contador de referencias, se eliminará en la próxima llamada a freeUnused)

Un ejemplo de como funcionaría al utilizar varias veces la misma textura:

Texture *texture = [[TextureManager instance] getTexture:@"Texture.png"]; // retainCount = 1, texturas cargadas = 1
[retain texture]; // retainCount = 2
 
Texture *texture2 = [[TextureManager instance] getTexture:@"Texture.png"]; // retainCount = 2, texturas cargadas = 1
[retain texture2]; // retainCount = 3
 
[[TextureManager instance] freeUnused]; // No se libera la textura
[texture release]; // retainCount = 2
[[TextureManager instance] freeUnused]; // No se libera la textura
[texture release]; // retainCount = 1
[[TextureManager instance] freeUnused]; // Se libera la textura

[editar] Referencias