#248 Offline Apps Part 2
- Download:
- source codeProject Files in Zip (120 KB)
- mp4Full Size H.264 Video (27.6 MB)
- m4vSmaller H.264 Video (17.1 MB)
- webmFull Size VP8 Video (43.9 MB)
- ogvFull Size Theora Video (36.1 MB)
Con este episodio concluye esta breve serie que hemos dedicado a la creación de aplicaciones web que funcionan sin conexión. En la primera parte utilizamos la gema rack-offline gem para crear un manifiesto de caché. Cuando se visita un sitio web que dispone de un manifiesto de caché el navegador descargará y cacheará los ficheros que aparecen en el manifiesto, así como aquellos que los referencien, como por ejemplo la portada de la aplicación. Esto significa que una vez que hayamos visitado la aplicación podremos acceder a ella incluso cuando no estemos conectados.
Control de los cambios cuando no hay conexión
Uno de los principales problemas que se nos presentan al desarrollar aplicaciones sin conexión consiste en la manipulación de las páginas que presentan contenido dinámico, como es el caso de nuestra lista de la compra. Si intentamos actualizar la lista añadiendo un nuevo elemento mientras nuestra aplicación se encuentra en producción, el manifiesto de caché no será actualizado y por tanto el navegador ignorará que la página ha cambiado. Vemos que cuando se añade un nuevo elemento sí que aparece en la lista pero esto es sólo porque al añadir un elemento se hace una petición POST al servidor. Si tras añadir el elemento volvemos a cargar la página el navegador mostrará la versión cacheada y por lo tanto el elemento desaparecerá de la lista. Como el navegador ignora que la caché ya no es válida podemos cargar la página todas las veces que queramos que seguirá viéndose mal.
Una posible solución sería actualizar la caché cada vez que se modifique el contenido dinámico. Si se actualiza la suma de control del manifiesto de caché el navegador se verá obligado a descartar su caché. El problema que tiene este enfoque es que hará que el naveagdor descargue todos los ficheros del servidor cosa que que no es muy eficiente (sobre todo si esperamos que el contenido dinámico de la página cambie con cierta frecuencia). Además, con este método la aplicación no funcionará sin conexión. Una idea mejor es quitar el contenido dinámico de los contenidos que están en el manifiesto de caché.
Vamos a modificar la aplicación de forma que la lista de elementos no se cargue directamente desde el servidor. En su lugar, la página de elementos será estática y el listado se rellenará mediante una peticíon AJAX al servidor que se efectuará después de que la página estática haya cargado. Para ello vamos a escribir bastante código JavaScript lo que quiere decir que el resto de este episodio será específico de jQuery.
Hay un excelente plugin para jQuery que se llama jQuery Templates y que hace que sea muy fácil generar HTML dinámico a partir de objetos JSON, lo usaremos para mostrar el listado de recados de la compra Podemos copiar el fichero JavaScript del plugin lanzando la siguiente orden desde el directorio de la aplicación:
$ curl https://github.com/jquery/jquery-tmpl/raw/master/ jquery.tmpl.js > public/javascripts/jquery.tmpl.js
Antes de empezar a implementar esta funcionalidad en la aplicación tenemos que modificar el fichero de configuración en desarrollo. En el último episodio pusimos config.cache_clasess
a true
para así poder simular el modo de producción. Lo cambiaremos a false
para poder continuar el desarrollo de nuestra aplicación.
config.cache_classes = false
Uso de jQuery Template
El primer paso es añadir una referencia al fichero de jQuery Template en el layout de la aplicación.
<%= javascript_include_tag :defaults, "jquery.tmpl" %>
En la acción index
del controlador ItemsController
podemos ver el código que genera la lista de elementos:
<ol id="items" > <% @items.each do |item| %> <li><%= item.name %></li> <% end %> </ol>
En lugar de usar erb en el servidor tenemos que generar este contenido mediante JavaScript en el cliente, y para esto es para lo que escribiremos una plantilla de jQuery con la librería que acabamos de instalar.
Vamos a poner la plantilla en un elemento <script>
de tipo text/html
al que le daremos un id
para poder identificarlo desde JavaScript. En lugar de erb ahora usaremos el lenguaje de plantillas de jQuery Template para determinar dónde va el contenido dinámico, y pondremos el contenido dentro de ${}
, en este caso vamos a mostrar el nombre de los elementos igual que hacíamos con el código erb.
<script type="text/html" id="item_template"> <li>${item.name}</li> </script> <ol id="items"> </ol>
Obsérvese que seguimos dejando el elemento vacío <ol>
en su sitio para poder añadir el contenido dinámico más tarde con jQuery Template desde el código que tenemos en el fichero application.js
. Antes de rellenarlo con datos reales vamos a intentarlo con un elemento de prueba.
$(function () { $(window.applicationCache).bind('error', function () { alert('There was an error when loading the cache manifest.'); }); $("#items").html($("#item_template").tmpl({"item":{"name":"cake"}})); });
En el código anterior obtenemos el elemento <ol>
por su id
y luego llamamos a la función html
de jQuery para cambiar su contenido. Dicho contenido vendrá dado por la plantilla, así que recuperamos también la plantilla por su id
(item_template
) y luego llamamos a tmpl
. Esta función recibe un objeto Javascript (o un array de objetos) como argumento, en este caso le pasamos un objeto literal que tiene definida la propiedad name
.
Si ahora recargamos la página un par de veces veremos la lista de elementos, generada por jQuery Template
Por supuesto lo sigueinte que queremos es que los ítems de la lista sean los que están en la base de datos, por lo que los recuperaremos de verdad usando una petición AJAX al servidor que devolverá los elementos de la base de datos como JSON.
$(function () { $(window.applicationCache).bind('error', function () { alert('There was an error when loading the cache manifest.'); }); $.getJSON("/items.json", function(data) { $("#items").html($("#item_template").tmpl(data)); }); });
Tenemos que modificar la acción index
de ItemsController
para que pueda devolver JSON basándonos en el método respond_to
de Rails.
class ItemsController < ApplicationController respond_to :html, :json def index @items = Item.all respond_with(@items) end def create @item = Item.new(params[:item]) @item.save redirect_to items_path end end
Cuando ahora invoquemos /items.json
el servidor devolverá el listado de elementos en JSON.
Esta página no se encuentra en el manifiesto de caché por lo que no será cacheada, así que no tenemos que preocuparnos de que no se actualicen los resultados. Si volvemos a visitar la página de elementos ahora ya veremos todos los que hay en la tabla items
de la base de datos, rellenados a partir de los datos de la petición JSON.
Almacenamiento local de datos
El siguiente paso es hacer que nuestra aplicación funcione sin conexión. ¿Qué ocurre si paramos el servidor Rails y recargamos la página?
Como el navegador no puede recuperar los datos, la lista aparecerá vacía. Podemos utilizar otra extensión de jQuery para solucionar esto: jQuery-Offline. Con este plugin se pueden guardar los datos JSON utilizando el almacenamiento local del navegador
para que estos estén disponibles cuando no hay conexión. Para usarla tan sólo tenemos que copiar los dos ficheros del directorio lib
del plugin (jquery.offline.js
y json.js
al directorio public/javascripts
de nuestra aplicación. Hagámoslo con curl
en dos pasos.
$ curl https://github.com/wycats/jquery-offline/raw/master/lib jquery.offline.js > public/javascripts/jquery.offline.js
$ curl https://github.com/wycats/jquery-offline/raw/master/lib/ json.js > public/javascripts/json.js
A continuación tenemos que incluir estos ficheros en el fichero layout de la aplicación.
<%= javascript_include_tag :defaults, "jquery.tmpl", "json",
"jquery.offline" %>
Ya podemos cambiar en el fichero application.js
la llamada a getJSON
por retrieveJSON
. La función retrieveJSON
actúa de forma similar a getJSON
pero almacena el JSON recuperado en el objeto localStorage
del navegador.
$(function () { $(window.applicationCache).bind('error', function () { alert('There was an error when loading the cache manifest.'); }); $.retrieveJSON("/items.json", function(data) { $("#items").html($("#item_template").tmpl(data)); }); });
Si arrancamos el servidor y visitamos la página de elementos veremos nuevamente la lista correcta. Si luego paramos el servidor y recargamos la página veremos que el listado permanece porque los datos se encuentran almacenados en localStorage
y retrieveJSON
intentará recuperar los datos del almacenamiento local cuando no hay conexión disponible.
Añadir elementos sin conexión
La aplicación ya puede mostrar la lista incluso cuando no hay conexión a Internet pero no podemos añadir elementos a la lista mientras estemos sin conexión. Si intentamos hacerlo el navegador hará una petición POST normal al servidor que, por supuesto, fallará. Tendremos que hacer cambios de forma que si se añade un elemento mientras la aplicación está desconectada, éste se muestre en la página y se guarde en una lista de pendientes para ser enviados a la base de datos cuando la aplicación vuelva a estar disponible.
Por desgracia jQuery Offline funciona sólo recuperando elementos del servidor, no enviándolos. Tendremos que hacerlo de forma manual, lo que implica vamos a escribir bastante más JavaScript que si hubiese un plugin disponible.
Para guardar los elementos pendientes en el navegador crearemos un nuevo objeto en el almacén localStorage
llamado pendingItems
. Cuando se muestre la lista de elementos queremos ver tanto los que han venido originalmente del servidor como los que están aún pendientes de envío, así que los concatenaremos todos juntos.
$(function () { $(window.applicationCache).bind('error', function () { alert('There was an error when loading the cache manifest.'); }); if (!localStorage["pendingItems"]) { localStorage["pendingItems"] = JSON.stringify([]); } $.retrieveJSON("/items.json", function(data) { var pendingItems = $.parseJSON(localStorage["pendingItems"]); $("#items").html($("#item_template").tmpl(data.concat(pendingItems))); }); });
Cuando alguien envía el formulario para añadir un nuevo elemento queremos capturar el evento en JavaScript para añadir dicho elemento a la lista de envíos pendientes. El código irá justo después de la llamada a $.retrieveJSON
.
$('#new_item').submit(function (e) { var pendingItems = $.parseJSON(localStorage["pendingItems"]); var item = {"data":$(this).serialize(), "item":{"name":$("#item_name").val()}}; $("#item_template").tmpl(item).appendTo("#items"); pendingItems.push(item); localStorage["pendingItems"] = JSON.stringify(pendingItems); $("#item_name").val(""); e.preventDefault(); });
Con este código se captura el evento submit
. Cuando se envíe el formulario la función obtendrá la lista de ítems pendientes del localStorage
para añadirle un nuevo elemento que tendrá dos atributos: el primero llamado data
son los datos del formulario serializados (lo que nos permitirá luego enviárselos al servidor) y el segundo atributo llamado item
tiene su propio atributo name
(igual que los elementos que hemos recuperado de la llamada a /items.json
) por lo que podemos usarlo en nuestra plantilla para mostrar el listado.
Esto lo hacemos en la tercera línea de la función donde obtenemos la plantilla, llamamos a tmpl
(pasándo el elemento recién creado), y concatenamos el resultado en el listado de elementos. Luego añadimos el nuevo ítem a la lista de pendientes (que volvemos a guardar en el localStorage
) y por último limpiamos el campo de texto del formulario e invocamos preventDefault
para evitar que el formulario se envíe al servidor.
Veamos si esto funciona. Tenemos el servidor levantado y recargaremos la página un par de veces para asegurarnos de tener la última versión. Cuando introduzcamos un nuevo elemento en el campo de texto (por ejemplo “cake”) y pulsamos el botón veremos que el elemento aparece en el listado inmediatamente y que se añade también a la lista de pendientes.
Envío de elementos pendientes al servidor
Los elementos que vayamos añadiendo de esta manera irán quedando almacenados en el navegador, por lo que si abrimos la página en otro navegador tan sólo veremos tres elementos. Cuando la aplicación vuelva a estar disponible debemos enviar una petición POST por cada elemento pendiente, lo que haremos con el siguiente fragmento de código:
$(function () { // Other functions omitted. function sendPending() { if (window.navigator.onLine) { var pendingItems = $.parseJSON(localStorage["pendingItems"]); if (pendingItems.length > 0) { var item = pendingItems[0]; $.post("/items", item.data, function (data) { var pendingItems = $.parseJSON(localStorage["pendingItems"]); pendingItems.shift(); localStorage["pendingItems"] = JSON.stringify(pendingItems); setTimeout(sendPending, 100); }); } } } sendPending(); });
La función sendPending
primero comprueba si el navegador dispone de conexión usando navigator.online
. Luego recupera la lista de pendientes del localStorage
y comprueba cuántos hay, cogiendo el primero y enviando la petición POST a /items
con los datos serializados del formulario (que guardamos previamente cuando se añadió el elemento mientras estábamos desconectados). Cuando vuelva la respuesta volvemos a recuperar la lista de pendientes (por si mientras tanto se ha añadido alguno nuevo), eliminamos el primero y volvemos a guardar la lista en localStorage
. Luego esperamos durante una décima de segundo antes de volver a invocar otra vez la función con el siguiente elemento de la lista. Invocamos a nuestra función sendPending
tan pronto como cargue la página.
Con la aplicación levantada podemos refrescar la página un par de veces (para asegurarnos de que se carga el nuevo JavaScript). Si luego abrimos otro navegador veremos que ya aparece el elemento que añadimos mientras estábamos sin conexión.
Algunos ajustes finales
La aplicación ya funciona como queríamos pero hay un par de pequeños cambios que podemos hacer en el JavaScript para mejorarlo. El primero es llamar a sendPending
tan pronto como se añada un elemento a los pendientes para intentar enviarlo al servidor inmediatamente.
$('#new_item').submit(function (e) { var pendingItems = $.parseJSON(localStorage["pendingItems"]); var item = {"data":$(this).serialize(), "item":{"name":$("#item_name").val()}}; $("#item_template").tmpl(item).appendTo("#items"); pendingItems.push(item); localStorage["pendingItems"] = JSON.stringify(pendingItems); $("#item_name").val(""); sendPending(); e.preventDefault(); });
Otro cambio que se puede hacer es vincular la función sendPending
al evento online
porque si estamos en un dispositivo móvil puede ser que estemos constantemente perdiendo y recuperando la conexión. Cuando esto último ocurra se activará este evento, por lo que vinculándonos a él el navegador intentará enviar los mensajes pendientes tan pronto como el dispositivo vuelva a tener una conexión activa.
$(window).bind("online", sendPending);
También podemos cambiar el mensaje de alerta que mostramos cuando hay un error con applicationCache
para que se muestre en la consola.
$(window.applicationCache).bind('error', function () { console.log('There was an error when loading the cache manifest.'); });
El último cambio que haremos al fichero de JavaScript es rodear con un condicional la llamada a localStorage
porque no todos los navegadores la soportan, mostrando un mensaje al usuario para que actualice su navegador si es el caso.
$(function () { if ($.support.localStorage) { // Other code omitted. } else { alert("Time to upgrade your browser.") } });
De vuelta en la plantilla del listado de elementos añadiremos un elemento por defecto en el listado que diga que se están recuperando los elementos del servidor, y así el usuario al menos verá algo si el navegador va un poco lento.
<% title "Grocery List" %> <%= form_for Item.new do |f| %> <%= f.text_field :name %> <%= f.submit "Add Another Item" %> <% end %> <script type="text/html" id="item_template"> <li>${item.name}</li> </script> <ol id="items"> <li><em>Loading items...</em></li> </ol>
Con esto concluimos este episodio, un poco inusual porque se centra sobre todo en JavaScript, pero la posibilidad de crear aplicaciones que pueden funcionar sin conexión es bastante impresionante. Los que tengan un iPhone u otro dispositivo móvil podrán probar la aplicación en modo desconectado cargando la dirección IP con el navegador y luego configurando el teléfono en modo avión.