domingo, 19 de agosto de 2007

Optimización de la carga de imágenes pequeñas

El problema

El tiempo de carga de las páginas es algo muy importante para que los usuarios (nosotros mismos) se sientan cómodos usando las aplicaciones o navegando por la web. En algunas páginas como http://jimmac.musichall.cz/openoffice-icons.php o http://www.google.com/language_tools hay muchísimos iconos. Cada uno de estos iconos está en un fichero separado, por lo que el navegador los tendrá que descargar uno a uno. Este problema también lo podremos observar en cualquier aplicación que tenga muchos iconos.

Mirando http://www.google.com/language_tools con el Firebug, en la pestaña Net, podemos ver algo como esto:

Firebug: Net

Las 158 imágenes de la página se van cargando una detrás de otra. Usando el Full page test de Pingdom vemos un resultado similar de la carga de la página.

Las 157 imágenes que corresponden a las banderas pesan (todas juntas) algo menos de 100KiB. Con una conexión decente no debería llegar a los 2 segundos. Sin embargo está cargando casi 9 segundos. ¿Qué pasa?.

Para empezar, tenemos las cabeceras HTTP. Para cada imagen se lanza una petición como ésta:

GET /images/flags/XX_flag.gif HTTP/1.1
Host: www.google.com
User-Agent: Mozilla/5.0
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: es-es,es;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Cookie: [Cookie de google. No se puede decir que sea corta...]

El tamaño de la petición está por encima de 1KiB. Puede ser insignificante para una sola petición, pero no lo es para 157. La cabecera de la respuesta es bastante más pequeña. Aparte del overhead que añaden las cabeceras hay que tener el de TCP/IP. El uso de keep alive mejora los tiempos, ya que no hace falta volver a abrir una nueva conexión, pero sigue siendo necesario enviar una petición para cada archivo.

La solución

La solución es simplemente combinar todas las imágenes en una sola. Con esto nos ahorramos las cabeceras necesarias para las peticiones HTTP. Además, al comprimir todas las imágenes juntas el fichero resultante tendrá un peso bastante menor.

Esta técnica se ha comentado varias veces en la blogosfera. En A List Apart hay un artículo titulado CSS Sprites: Image Slicing’s Kiss of Death. Incluso hay algunas implementaciones, como el ImageBundle de GWT o este snippet para Django.

No encontré nada para Rails, así que decidí implementar uno.

Funcionamiento

El funcionamiento es muy sencillo

  1. Se empaquetan las imágenes, y se guarda la posición de cada una dentro del paquete.
  2. Se cambia el image_tag para que pueda generar imágenes hacia paquete.

Para generar el paquete hay que tener en cuenta algunos puntos:

  • Primero, es preferible agrupar las imágenes según el formato. Es decir, las imágenes PNG en un paquete guardado como PNG. Las JPEG en otro como JPEG, etc. Cada imagen vendrá en un formato según las ventajas de éste (como que PNG no pierde calidad o que JPEG comprime más, por poner algunos ejemplos), así que al convertirlas a otro formato perderemos ventajas.
  • Segundo, sólo deberían empaquetarse imágenes pequeñas. Las que son muy grandes se suelen usar para fondos o algún logotipo, y el overhead no suele ser un problema. No hay que olvidar que en las pequeñas el peso de las cabeceras puede llegar a ser mayor que la propia imagen.
  • Tercero, las animaciones (generalmente ficheros GIF con más de un fotograma) no pueden ser empaquetadas.

El image_tag tiene que poder detectar si la imagen que se está pidiendo está empaquetada. De ser así, el <img> generado apuntará hacia el paquete.

Implementación

Para poder disponer de algo así fácilmente en cualquier aplicación lo más cómodo es tenerlo como un plugin. El código lo subiré a RubyForge desde que me acepten el proyecto (avisaré por aquí).

El plugin añade una tarea en el rake para actualizar los paquetes generados. De esta manera, tan sólo necesitamos poner

$ rake images:bundle:update

Cuando la tarea empieza comprueba si existen los ficheros config/rails-image-bundle.yml y public/images/fulltransparent.gif. Si no existe alguno de ellos los copia del directorio templates que viene dentro del propio plugin. El primer fichero se usa para cambiar algunos parámetros del plugin. El segundo lo veremos más abajo.

Los parámetros que se pueden configurar del plugin son

  • file-bundle, que indica la base del nombre donde se generarán los paquetes. Inicialmente tiene el valor generated-image-bundle. De esta manera, los paquetes se generarán en public/images/generated-image-bundle.png (o .jpeg, .gif, etc)
  • max-width, que indica el ancho máximo que puede tener una imagen para ser empaquetada.
  • max-height, lo mismo que max-width, pero para el alto.

En la mayoría de los casos no hará falta tocar el fichero.

Para generar los paquetes el plugin buscará todos los ficheros por debajo de public/images de la aplicación. Cada uno los cargará con RMagick y comprobará si:

  • Se cargó correctamente
  • Las dimensiones de la imagen no pasan de max-width y max-height
  • Sólo tiene un fotograma (no es una animación)

Si se cumplen todas las condiciones añadirá al paquete de su formato (public/images/generated-image-bundle.{png,jpeg,...}) y guarda la información necesaria para saber que el fichero está empaquetado. Para esto se necesita guardar:

  • El nombre del fichero que se va a empaquetar (por ejemplo, edit_button.png)
  • La posición (x,y) dentro del paquete
  • El nombre del paquete donde está
  • Las dimensiones de la imagen empaquetada

Después de empaquetar todas las imágenes se vuelca el fichero con toda la información sobre los paquetes en tmp/rails-image-bundle.marshal. Puesto que no es necesario que este fichero sea legible para personas humanas (ni para las personas no humanas) se usa Marshal.

Con esto ya tenemos nuestros paquetes de imágenes generados. Ahora nos queda que sean accesibles al navegador. Cambiaremos el image_tag para detectar cuándo queremos usar alguna imagen que está empaquetada.

La técnica para poder utilizar los paquetes se basa en CSS. Consiste en:

  • Generamos una etiqueta img con una imagen completamente transparente (de ahí el fulltransparent.gif)
  • Ponemos el tamaño exacto de la imagen que queremos mostrar. Con esto además evitamos el efecto (no muy agradable) de que el contenido de la página se mueva mientras se cargan las imágenes
  • Añadimos como fondo de la etiqueta la imagen del paquete, y lo desplazamos para que sólo quede visible la parte que corresponde a la imagen que queremos ver,

Para que se hagan una idea, vean cómo quedó implementado el image_tag

module ImageBundle
    module ViewMethods

        def self.included(base)
            base.alias_method_chain :image_tag, :bundle
        end

        def image_tag_with_bundle(source, options = {})
            @__bundle_information ||= (Marshal.load(File.read(ImageBundle::INFO_FILE)) rescue nil) || {}
            if info = @__bundle_information[source.to_s]
                source = "fulltransparent.gif"
                options[:style] = "background: url(#{image_path info[:bundle]}) -#{info[:x]}px -#{info[:y]}px; #{options[:style]}"
                options[:width] = info[:width]
                options[:height] = info[:height]
            end

            image_tag_without_bundle(source, options)
        end

    end
end

Posibles mejoras

Hay muchas mejoras que se pueden hacer. La implementación actual tiene 142 líneas, escritas en algo más de hora y media. El objetivo principal (optimizar la carga de imágenes pequeñas) está conseguido. ¿Qué podemos hacer ahora?

Una mejora interesante es poder restringir qué archivos son empaquetados. Ahora mismo se cogen todos los que soporta el backend que use RMagick (que será ImageMagick o GraphicsMagick) hasta cierto tamaño. En algún caso nos puede interesar que sólo se empaqueten ficheros PNG y GIF, o que no se empaqueten los que estén bajo public/images/donotuse.

Otra posible mejora (aunque de mucha menos utilidad) es distribuir las imágenes para optimizar el espacio. Ahora mismo se ponen todas en vertical. Habría que hacer pruebas, pero en el caso más general no creo que haya diferencias significativas de tamaño.

Seguro que hay muchas más mejoras, que se irán conociendo con el tiempo.