Etiquetas

, , ,

No sé si habréis notado, pero si accedéis a Menéame desde un móvil o tableta, se carga muy rápido y están disponibles prácticamente todas las funcionalidades de la versión “normal” de la web. Aunque en realidad no se tratan de dos versiones diferentes, es la misma pero con cambios muy pequeños en la lógica del servidor, y muy poco de Javascript.

El 30% de los accesos a Menéame son desde móviles, antes teníamos una versión más reducida (sigue disponible en m.meneame.net), pero recibíamos quejas por ser muy limitado en cuanto a funcionalidades. Por otro lado, era demasiado trabajo mantener dos versiones diferentes.

También queríamos adaptar la web para los monitores Retina y de alta definición que tienen la mayoría de teléfonos y tabletas de gama media y alta (su densidad de pixels por pulgada es mayor -hasta dobla- a los típicos 96 px/pulgada de los monitores tradicionales), para ello hay que detectarlos y servir en estos casos una imagen del doble de resolución.

Así, hace unas semanas nos pusimos a tope para lograr una única versión integrada, con pocas diferencias en la lógica del backend, y casi ninguna modificación hecha con Javascript en el lado cliente. Aunque el PageSpeed no es una medida cualitativa absoluta, sí que da bastante idea de velocidad y ligereza:

Image

Como se puede deducir, las mejoras de velocidad de carga para móviles también afectó positivamente a la versión normal del web. A continuación describiré cuáles fueron los trucos y técnicas más importantes.

Modificaciones y unificación de CSS

La mayoría de los cambios en la apariencia están determinados por el CSS. El CSS original está diseñado para la versión normal de la web, creamos otro CSS sólo para pantallas más pequeñas que modifica algunas de las clases definidos en el principal. Para evitar que el cliente móvil se cargue dos ficheros diferentes (con el sobrecoste de una nueva conexión), usando los templates Haanga unificamos ambos CSS en uno sólo, incluyendo también el CSS de otros plugins (por ejemplo, el de colorbox, que se usa para la visualización de imágenes, galería, votos, etc.).

El software backend de Menéame detecta si es un dispositivo móvil (la variable $globals[‘mobile’]), que se usa para discriminar en algunas de las partes del código, en ese caso se se usa para:

  1. Si es un móvil, se incluye directamente el contenido del fichero handheld.css.
  2. Si es un navegador de escritorio o tableta, se lo incluye dentro de una regla @media (max-width: 800px) {…}. De esta forma el navegador adaptará la visualización dependiendo del tamaño de la ventana, o si la tableta está en vertical u horizontal. Podéis probarlo reduciendo el ancho de la ventana del navegador (se pondrá igual que el móvil), o poniendo en horizontal la tableta (mostrará igual que la versión de escritorio si el ancho es mayor o igual a 800 pixels).

En el fichero mnm.php que unifica a ambos, también se envían las cabeceras que indican al navegador que guarde en caché el fichero CSS. En caso de modificarlos, cambiamos el url (la parte de “/v_xx”) para forzar la recarga. Esto lo hacemos con una variable de versión en PHP y una regla en el servidor web NGInx para ignorar esa parte:

rewrite /v_\d+/(.+)$ /$1 last;

Cambios en la lógica

Hay muy pocos cambios en la lógica del PHP o de Javascript para la versión móvil o normal. En el PHP se usa la variable  $globals[‘mobile’] en unos pocos sitios, y sobre todo para evitar enviar gran cantidad información que no se visualizará en un dispositivo móvil, por ejemplo:

  1. El contenido de la barra derecha de la versión normal.
  2. Los avatares de autores de comentarios.
  3. Cambiar el número de artículos o comentarios que se muestran por página.
  4. Carga la webfont Roboto sólo para la versión de escritorio (son varios KB, pero los Android ya la tienen, y los dispositivos iOS tienen una Helvética muy similar de alta calidad).

Para todo el resto, la lógica es la misma para cualquier versión.

En Javascript sólo hay un cambio (en la parte var navMenu = new function () {… de main.js). En el móvil están ocultas las cabeceras por el CSS (display:none), salvo la barra negra. En Javascript se coge el contenido del menú de navegación y del formulario de búsquedas

Screenshot from 2013-11-18 17:05:00

y se lo copia al contenedor del menú superior:

Screenshot_2013-11-18-17-05-49

Luego, como vimos que era útil para navegar (incluso en tabletas, con el pulgar izquierdo), el Javascript lo hace para ambas versiones.

Carga de imágenes bajo demanda, y servir imágenes “Retina”

Es importante minimizar la conexión 3G de los móviles, no sólo por el consumo de datos, también por el de la batería (y temperatura que suele alcanzar el chip de radio). Para ello debíamos implementar algo similar al LazyLoad, pero éste es muy grande y “pesado” para móviles. Busqué algo más simple y eficiente. Así encontré el Unveil, éste es suficientemente pequeño para incluirlo en nuestro propio Javascript, pero todavía tenía unos problemas de eficiencia, principalmente en que el código se ejecutaba por cada movimiento del scroll. Esto es un problema de eficiencia, aún más grave para móviles. Para ello programé otro mini plugin de Jquery que implementa el evento “scrollstop” que se dispara sólo una vez después de que el usuario haya dejado de hacer scroll. El código es breve y sencillo (está en main.js):

/* scrollstop plugin for jquery +1.9 */
(function(){
    var latency = 75;
    var handler;
    $.event.special.scrollstop = {
        setup: function() {
            var timer;
            handler = function(evt) {
                var _self = this,
                    _args = arguments;
                if (timer) {
                    clearTimeout(timer);
                }
                timer = setTimeout( function(){
                    timer = null;
                    evt.type = 'scrollstop';
                    $(_self).trigger(evt, [_args]);
                }, latency);
            };
            $(this).on('scroll', handler);
        },
        teardown: function() {
            $(this).off('scroll', handler);
        }
    };
})(jQuery);

La versión modificada de unveil (en (function($) { $.fn.unveil = function(options, callback) {… } de main.js) ahora usa este evento, y además sirve las imágenes de alta resolución si se trata de monitores de alta resolución.

Para ello, originalmente en el src de las imágenes se pone sólo la de una de un pixel (src=”http://mnmstatic.net/v_4/img/g.gif“, se carga una única vez para todas), los datos para la carga de las imágenes que toca van como “data” HTML5, por ejemplo:

<img data-src="/cache/05/cc/379915-1369303151-20.jpg"
data-2x="s:-20.:-40." 
alt="" src="http://mnmstatic.net/v_4/img/g.gif" 
class="tooltip u:379915 avatar lazy"/>

El campo “data-src” indica al unveil que esa es la imagen (de resolución normal) que debe cargar cuando sea visible. El campo data-2x indica cuál es la que debe cargar para pantallas de alta resolución. Aquí se indica sólo una regla [s:-20.:-40.]. Es una extensión que programé al unveil, en vez de especificar el URL alternativo (más trabajo para el servidor, y más texto que enviar), sólo se le indica qué debe reemplazar en el URL normal para coger la de alta resolución. En este caso significa cambiar la subcadena “-20.” por el “-40.”, por lo que quedará como /cache/05/cc/379915-1369303151-40.jpg. Esta regla es para el tamaño de los avatares de usuarios, para las imágenes de miniaturas de noticias es similar:

<img data-2x='s:thumb:thumb_2x:'
data-src='cache/1f/6c/thumb-2059491.jpg'
src="http://mnmstatic.net/v_4/img/g.gif"
alt='' class='thumbnail lazy'/>

En ese caso la regla es remplazar la cadena “thumb” por “thumb_2x”.

Ooops

¿Cómo y cuando se generan las imágenes de mayor resolución para Retina? Aunque desde hace unos días el software ya genera las miniaturas en las dos resoluciones, había que resolver el problema para los artículos anteriores que no tienen la imagen generado. Ya teníamos el programa ooops.php que se llama cada vez que el servidor no encuentra una imagen, analiza el URL, encuentra la original (o la baja del backup en S3 si no está en el disco local) y genera la miniatura en el tamaño solicitado. Sólo tuvimos que modificarlo para que acepte además el “parámetro 2x”, por ejemplo:

case "thumb":
case "thumb_2x": // La nueva regla
case "thumb_medium":
	// Links' thumbnails
	$base = $parts[0];
	if (count($parts) == 2 && $parts[1] > 0) {
		$link = Link::from_db($parts[1]);
		if ($link && ($pathname = $link->try_thumb($base))) {
			header("HTTP/1.0 200 OK");
			header('Content-Type: image/jpeg');
			readfile($pathname);
			$globals['access_log'] = false;
			die;
		}
	}
	$errn = 404;
	break;

Imágenes de fondo en el CSS, sprites y las dos resoluciones

Otra de las cosas importantes que hay que hacer es servir las imágenes de fondo especificadas en el CSS para ambas resoluciones. También incluye modificar el código HTML para que las imágenes estáticas (iconos y logos) incluidas como <img> sean imagen de fondo de un <div>. De esta forma se puede controlar en el CSS de que se cargue la resolución adecuada según la pantalla.

Para minimizar las conexiones y tiempo necesario para bajar los logos e iconos comunes a la versión web y móvil creé un par de imágenes donde incluyen a todas ellas (sprites). Por ejemplo los iconos comunes los incluí en un único SVG (con el Inkscape) llamado icons-common.svg. A partir de este SVG se genera la imagen PNG para la resolución normal (icons-common.png) de 20×300, y otro con el doble de resolución, 40×600 (icons-common-2x.png).

.comment-meta .vote.up {
...
 width: 16px;
 height: 18px;
 background: url(.../img/common/icons-common.png) no-repeat 0 -20px;
 background-size: 20px 300px;
}

@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
 .comment-meta .vote.up {
 background-image:url(../img/common/icons-common-2x.png);
 }
}

(Fijaros que el tamaño especificado en background-size es siempre de la imagen completa en la resolución “normal”. No servimos el SVG directamente porque versiones antiguas de IE no lo entienden, y porque hay que filtrarlos bien después de gaurdarlos con el Inkscape, éste agrega extensiones y el XML no es nada óptimo.)

Retrasar la carga de Javascript

Esta fue una de las partes más interesantes. Se recomienda cargar los javascripts al final del documento HTML. Así, mientras se cargan e interpretan el navegador ya puede mostrar el HTML descargado hasta ese momento.

En nuestro caso era crucial, las jQuery tienen casi 90 KB de tamaño (unos 30 KB comprimidas), y el javascript propio de Menéame casi 50 KB (17 KB comprimidas). En un ordenador de escritorio no se nota, pero en un móvil, para una persona que accede la primera vez y no las tiene en cache, puede tomar varios segundos, con gran probabilidad de que abandone, o que piense que siempre es así y no regrese.

El problema que teníamos que solucionar: en varias páginas de Menéame se llamaban funciones propias y de jQuery, por lo que requería que éstas ya estuviesen cargadas. El truco fue retrasar estas llamadas, gracias a las funciones anónimas y clausura de Javascript fue bastante sencillo.

Junto con cada página de Menéame se genera “inline” un Javascript de inicialización muy pequeño:

var base_key="{{globals.security_key}}",
link_id = {% if globals.link_id %}{{ globals.link_id }}{% else %}0{% endif %},
user_id={{ current_user.user_id }},
user_login='{{ current_user.user_login }}';

var onDocumentLoad = [];
function addPostCode(code) {
 onDocumentLoad.push(code);
}

En el array onDocumentLoad se almacenan las funciones que deben ejecutarse una vez que se cargaron jQuery, las funciones de Menéame y el DOM ya esté completo en el navegador. Para ello, el cargarse todo el DOM se ejecuta lo siguiente (en main.js, que se carga al final):

for (var i= 0; i < onDocumentLoad.length; i++) {
	if (typeof onDocumentLoad[i] == "function") {
		onDocumentLoad[i]();
	}
}

Dentro de las páginas que necesitan ejecutar Javascript, en vez de llamar a la función original de jQuery, simplemente se hace algo como:

<script type="text/javascript">
var do_change = function() {
    ...
};

addPostCode(function() {
	$("#w").change(do_change);
	do_change();
});

(Ni siquiera hace falta definir la función addPostCode, se puede hacer directamente un push de la función anónima, pero así tengo más controlado y puedo cambiar el comportamiento fácilmente en un único sitio.)

Bonus: retrasar la carga de código de Google AdSense

Si usáis AdSense en vustro sitio, el código que os da Google es algo así como:

<script type="text/javascript" src="http://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" async=""></script>
<ins class="adsbygoogle" style="display: inline-block; width: 300px; height: 250px;" data-ad-client="ca-pub-xxxxxxxx" data-ad-slot="xxxxxxx"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>

Por cada anuncio carga y obliga a interpretar el fichero adsbygoogle.js. Si estáis retrasando la carga de todo el Javascript, esto “molesta” porque empieza a cargar antes. Pero podéis quitar esa línea de cada anuncio y ponerla una sola vez al final del documento, justo antes de cerrar el </body> y después de cargar vuestros ficheros Javascript. Funciona perfectamente, así están en la versión móvil del Menéame (en la versión web usamos el DFP de Google, que es más complicado y trasto).

Fin

Esto no es todo lo que hace falta ni lo que hicimos, pero sí lo principal o que más tiempo me hizo pensar y experimentar. Lo demás suele ser lo habitual para servir de forma eficiente: comprimir el HTML/CSS/Javascripot en el servidor con el módulo gzip, poner fechas de caducidad y caché adecuadas, incluir cabeceras “Last-Modified”, etc.

También tuvimos que usar otros trucos para mejorar la usabilidad en algunos sitios con demasiada información, como hacer invisible algunas div en el móvil y mostrar un pequeño icono que al hacer clic muestra la información “oculta”, por ejemplo la de los relojitos para la hora de los comentarios:

Screenshot_2013-11-18-19-18-12

Pero esto ya son detalles, lo principal creo que os he contado. Espero que os sirva y no perdáis tanto tiempo como yo haciendo pruebas y leyendo millones de artículos😉

PS: Nos preguntan siempre ¿y no sacaréis una app móvil? No, al menos a corto plazo. Es muy caro, hay que desarrollar y mantener al menos tres versiones diferentes (Android, iOS, Windows, y quizás hasta Firefox OS), por eso estamos esforzando en una versión muy ligera y rápida.