Optimizar el Canvas de Apps Windows en HTML5/JavaScript


En un artículo anterior sobre el patrón Memento en JavaScript introducía cómo podíamos ir guardando el estado de un elemento canvas para crear un sistema de deshacer/repetir en una app de dibujo. En dicha app había recibido algunas quejas sobre el rendimiento a partir de incluir esta funcionalidad, especialmente al ejecutar la aplicación en tabletas ARM con Windows RT.

Si observáis el código, obtenemos una copia de la imagen dibujada sobre el canvas con la siguiente llamada:

var img = this._ctx.getImageData(x, y, w, h);

En un principio pensé que el problema estaba en capturar toda la pantalla cada vez, pero intentar resolver eso complicaba mucho el desarrollo y tampoco se ganaba en rendimiento, sino todo lo contrario. Ya escribiré sobre ello en otro post.

En realidad el problema está en el rendimiento de los métodos getImageData y putImageData, que si buscáis un poco por internet os recomendarán utilizarlo lo menos posible, o sea, que ni se te ocurra hacerlo en un bucle. El caso es que aparte de algún artículo donde analizan el rendimiento de dicho método y de cómo algunos navegadores lo mejoran, para guardar la imagen de un canvas os dirán que tenéis que utilizar alguno de los siguientes métodos:

  • getImageData o getImageDataHD: copia todos los píxeles a un array, normalmente para poder modificar a nivel de píxel y luego volver a volcar al canvas con un putImageData.
  • toDataURL o toBlob: para guardar los datos, incluso con compresión png, para poder enviarlos a un servidor (aunque esto en Windows8 lo podemos hacer con métodos nativos).

El problema de estos métodos es que tienen un rendimiento bastante pobre, y si tenemos que copiar toda la pantalla cuando el usuario levanta el dedo de la puede que afecte al rendimiento de nuestras rutinas de dibujo, de hecho en mi App estaba afectando y mucho. Debemos recordar que muchos usuarios tendrán una versión RT con un chip ARM que no tiene el rendimiento de un desktop.

Canvas inception

Hay otra forma de guardar el estado de un canvas cuando no necesitamos actuar sobre píxeles individuales o guardar el contenido a un archvivo: pintando dentro de un canvas el contenido del canvas original.
Para guardar el canvas, basta crear uno nuevo de las mismas dimensiones y ejecutar el método drawImage que, además de pintar imágenes, si le damos otro canvas como parámetro pintará el contenido del canvas origen dentro del destino:

var newCanvas=document.createElement("canvas");
var newContext=newCanvas.getContext("2d");

var w=originalCanvas.width;
var h=originalCanvas.height;

newCanvas.width=w;
newCanvas.height=h;

newContext.drawImage(originalCanvas,0,0,w,h,0,0,w,h);

¿Tánto se gana?

Se puede ganar mucho rendimiento con este pequeño cambio. En algunos casos la mejora es tan espectacular que es difícil de creer, pero para que lo comprobéis vosotros mismos aquí os dejo este jsfiddle:
imagecopy

Luego, si queremos recuperar la imagen sólo tenemos que volver a pintar sobre el canvas original el canvas que hemos guardado. Aunque debemos ir con cuidado, no es conveniente ir creando infinitos canvas y esperar que el Garbage Collector vaya recogiendo lo que no necesitamos, aparte de que algunos navegadores no estarán demasiado contentos con eso. Es mucho mejor crear un pool de elementos canvas e ir reutilizándolos. Si queréis ver cómo ha quedado el nuevo patrón memento ahí tenéis el código, aunque por culpa del pool de canvas no está tan desacoplado como me gustaría:

(function mementoNS(global) {
    global.CanvasState = global.CanvasState || {};
})(this);

(function mementoInit(CanvasState) {
    CanvasState.memento = function (state) {
        this._state = state;
    };
    Object.defineProperty(CanvasState.memento.prototype, "state", {
        get: function () { return this._state; },
        set: undefined
    });
})(this.CanvasState);

//this is a buffer pool to avoid possible memory problems
(function bufferPoolInit(CanvasState) {
    CanvasState.bufferPool = {
        _buffer:[],
        addLevels: function (levels) {
            var buffer = CanvasState.bufferPool._buffer;
            for (var i = 0; i < levels; i++) {
                buffer.push([document.createElement("canvas"), false]);
            }
        },
        getBuffer: function () {
            var buffer = CanvasState.bufferPool._buffer;
            for (var i = 0; i < buffer.length; i++) {
                if (!buffer[i][1]) {
                    buffer[i][1] = true;
                    return { id: i, buffer: buffer[i][0] };
                }
            }
        },
        releaseBuffer: function (id) {
            CanvasState.bufferPool._buffer[id][1] = false;
        }
    }

})(this.CanvasState);

(function originatorInit(CanvasState) {
    CanvasState.originator = function (canvas) {
        this._canvas = canvas;
        this._ctx = canvas.getContext("2d");
    };
    CanvasState.originator.prototype.saveToMemento = function (x, y, w, h) {
        if (x === undefined)
            x = 0;
        if (y === undefined)
            y = 0;
        if (w === undefined)
            w = this._canvas.width;
        if (h === undefined)
            h = this._canvas.height;
        var buffer = CanvasState.bufferPool.getBuffer();
        var bufferContext = buffer.buffer.getContext("2d");
        buffer.buffer.width = w;
        buffer.buffer.height = h;
        bufferContext.drawImage(this._canvas, x, y, w, h, 0, 0, w, h);
        var memento = new CanvasState.memento({ image: buffer, x: x, y: y, w: w, h: h });
        return memento;
    };
    CanvasState.originator.prototype.restoreFromMemento = function (memento) {
        var state = memento.state;
        this._ctx.clearRect(state.x,state.y,state.w,state.h);
        this._ctx.drawImage(state.image.buffer, state.x, state.y);
    };
})(this.CanvasState);

(function caretakerInit(CanvasState) {
    CanvasState.caretaker = function (maxLevels) {
        CanvasState.bufferPool.addLevels(maxLevels+1);
        this._undoStates = [];
        this._redoStates = [];
        this._maxLevels = maxLevels;
    };
    CanvasState.caretaker.prototype.addMemento = function (memento) {
        this._undoStates.push(memento);
        this._redoStates.forEach(function (s) {
            CanvasState.bufferPool.releaseBuffer(s._state.image.id);
        });
        this._redoStates = [];
        if (this._undoStates.length > this._maxLevels) {
            CanvasState.bufferPool.releaseBuffer(this._undoStates[0]._state.image.id);
            this._undoStates.splice(0, 1);
        }
    };
    CanvasState.caretaker.prototype.getUndoMemento = function () {
        if (this._undoStates.length > 1) {
            var state = this._undoStates.pop();
            this._redoStates.push(state);
            return this._undoStates[this._undoStates.length-1];
        }
        else
            throw "Undo not allowed, states array empty";
    };
    CanvasState.caretaker.prototype.getRedoMemento = function () {
        if (this._redoStates.length > 0) {
            var state = this._redoStates.pop();
            this._undoStates.push(state);
            return state;
        }
        else
            throw "Redo not allowed, states array empty";
    }
    Object.defineProperty(CanvasState.caretaker.prototype, "canUndo", {
        get: function () { return this._undoStates.length>1; },
        set: undefined 
    });
    Object.defineProperty(CanvasState.caretaker.prototype, "canRedo", {
        get: function () { return this._redoStates.length > 0; },
        set: undefined
    });
})(this.CanvasState);
Anuncios

Un Comentario

  1. kenel

    No entendi nada, podrias poner un ejemplo de un codigo normal y abajo el mismo pero con los agregados que hicieron que mejorara su rendimiento???

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s