Almacenamiento en caché de imágenes de Android


141

¿Cómo puedo almacenar en caché las imágenes después de descargarlas de la web?

Respuestas:


177

Y ahora la frase clave: usa el caché del sistema.

URL url = new URL(strUrl);
URLConnection connection = url.openConnection();
connection.setUseCaches(true);
Object response = connection.getContent();
if (response instanceof Bitmap) {
  Bitmap bitmap = (Bitmap)response;
} 

Proporciona memoria y caché flash-rom, compartido con el navegador.

grr. Ojalá alguien me hubiera dicho eso antes de escribir mi propio administrador de caché.


1
Wow, esta fue una forma increíblemente elegante de hacer esto, muchas gracias. De ninguna manera es más lento que mi propio administrador de caché simple, y ahora no necesito hacer tareas domésticas en una carpeta de tarjeta SD.
Kevin leyó el

11
connection.getContent()siempre me devuelve un InputStream, ¿qué estoy haciendo mal?
Tyler Collier

3
Si ahora también pudiera establecer una fecha de vencimiento en el contenido del caché, mi vida sería mucho más fácil :)
Janusz

11
@Scienceprodigy no tengo idea de qué es ese BitmapLoader, ciertamente no está en ninguna biblioteca de Android estándar que conozco, pero al menos me llevó en la dirección correcta. Bitmap response = BitmapFactory.decodeStream((InputStream)connection.getContent());
Stephen Fuhry

66
Asegúrese de ver la respuesta de Joe a continuación sobre los pasos adicionales que debe seguir para que el caché funcione
Keith, el

65

En cuanto a la elegante connection.setUseCachessolución anterior: lamentablemente, no funcionará sin un esfuerzo adicional. Deberá instalar un ResponseCacheuso ResponseCache.setDefault. De lo contrario, HttpURLConnectionignorará silenciosamente el setUseCaches(true)bit.

Vea los comentarios en la parte superior de FileResponseCache.javapara más detalles:

http://libs-for-android.googlecode.com/svn/reference/com/google/android/filecache/FileResponseCache.html

(Publicaría esto en un comentario, pero aparentemente no tengo suficiente SO karma).


Aquí está el archivo
Telémako

2
Cuando usa un HttpResponseCache, es posible que encuentre el HttpResponseCache.getHitCount()0. devuelto No estoy seguro, pero creo que es porque el servidor web que solicita no usa encabezados de almacenamiento en caché en ese caso. Para que el almacenamiento en caché funcione de todos modos, use connection.addRequestProperty("Cache-Control", "max-stale=" + MAX_STALE_CACHE);.
Almer

1
El enlace de búsqueda de código de Google está inactivo (¿de nuevo?), Actualice el enlace.
Felix D.

Además, no estoy seguro de si este comportamiento se ha solucionado o no. Por alguna razón, devolver 304 del servidor colgaría HUC cuando se usa el .getContent()método porque las respuestas 304 no tienen un cuerpo de respuesta asociado según el estándar RFC.
TheRealChx101

27

Conviértalos en mapas de bits y luego guárdelos en una colección (HashMap, List, etc.) o puede escribirlos en la tarjeta SD.

Al almacenarlos en el espacio de la aplicación utilizando el primer enfoque, es posible que desee envolverlos alrededor de una java.lang.ref.SoftReference específicamente si sus números son grandes (de modo que se recolectan basura durante la crisis). Sin embargo, esto podría provocar una recarga.

HashMap<String,SoftReference<Bitmap>> imageCache =
        new HashMap<String,SoftReference<Bitmap>>();

escribirlos en la tarjeta SD no requerirá una recarga; solo un permiso de usuario.


¿Cómo podemos escribir la imagen en la memoria SD o teléfono?
d-man

Para guardar imágenes en la tarjeta SD: Puede guardar los flujos de imágenes leídos desde el servidor remoto en la memoria utilizando operaciones normales de E / S de archivos o si ha convertido sus imágenes en objetos de mapa de bits, puede usar el método Bitmap.compress ().
Samuh

@ d-man Sugeriría escribir primero en el disco y luego obtener una Urireferencia de ruta que pueda pasar ImageViewy otras vistas personalizadas. Porque cada vez que estés compress, estarás perdiendo calidad. Por supuesto, esto es cierto solo para algoritmos con pérdida. Este método también le permitiría incluso almacenar un hash del archivo y usarlo la próxima vez que solicite el archivo desde el servidor If-None-Matchy los ETagencabezados.
TheRealChx101

@ TheRealChx101 podría ayudarme a entender lo que quiere decir la próxima vez que solicite el archivo del servidor a través de encabezados If-None-Match y ETag , básicamente estoy buscando un enfoque de solución donde la imagen debe permanecer para usar el caché local para O bien, si esto no se puede lograr, siempre que el contenido de la URL cambie, debería reflejarse en la aplicación con el último y almacenarse en caché.
Código

@CoDe Visite este enlace por ahora, android.jlelse.eu/…
TheRealChx101

27

Úselo LruCachepara almacenar en caché las imágenes de manera eficiente. Usted puede leer acerca LruCachede Android sitio para desarrolladores

He utilizado la siguiente solución para la descarga de imágenes y el almacenamiento en caché en Android. Puedes seguir los pasos a continuación:

PASO 1: crea la clase con nombre ImagesCache. he usadoSingleton object for this class

import android.graphics.Bitmap;
import android.support.v4.util.LruCache;

public class ImagesCache 
{
    private  LruCache<String, Bitmap> imagesWarehouse;

    private static ImagesCache cache;

    public static ImagesCache getInstance()
    {
        if(cache == null)
        {
            cache = new ImagesCache();
        }

        return cache;
    }

    public void initializeCache()
    {
        final int maxMemory = (int) (Runtime.getRuntime().maxMemory() /1024);

        final int cacheSize = maxMemory / 8;

        System.out.println("cache size = "+cacheSize);

        imagesWarehouse = new LruCache<String, Bitmap>(cacheSize)
                {
                    protected int sizeOf(String key, Bitmap value) 
                    {
                        // The cache size will be measured in kilobytes rather than number of items.

                        int bitmapByteCount = value.getRowBytes() * value.getHeight();

                        return bitmapByteCount / 1024;
                    }
                };
    }

    public void addImageToWarehouse(String key, Bitmap value)
    {       
        if(imagesWarehouse != null && imagesWarehouse.get(key) == null)
        {
            imagesWarehouse.put(key, value);
        }
    }

    public Bitmap getImageFromWarehouse(String key)
    {
        if(key != null)
        {
            return imagesWarehouse.get(key);
        }
        else
        {
            return null;
        }
    }

    public void removeImageFromWarehouse(String key)
    {
        imagesWarehouse.remove(key);
    }

    public void clearCache()
    {
        if(imagesWarehouse != null)
        {
            imagesWarehouse.evictAll();
        }       
    }

}

PASO 2:

cree otra clase llamada DownloadImageTask que se usa si el mapa de bits no está disponible en caché, lo descargará desde aquí:

public class DownloadImageTask extends AsyncTask<String, Void, Bitmap>
{   
    private int inSampleSize = 0;

    private String imageUrl;

    private BaseAdapter adapter;

    private ImagesCache cache;

    private int desiredWidth, desiredHeight;

    private Bitmap image = null;

    private ImageView ivImageView;

    public DownloadImageTask(BaseAdapter adapter, int desiredWidth, int desiredHeight) 
    {
        this.adapter = adapter;

        this.cache = ImagesCache.getInstance();

        this.desiredWidth = desiredWidth;

        this.desiredHeight = desiredHeight;
    }

    public DownloadImageTask(ImagesCache cache, ImageView ivImageView, int desireWidth, int desireHeight)
    {
        this.cache = cache;

        this.ivImageView = ivImageView;

        this.desiredHeight = desireHeight;

        this.desiredWidth = desireWidth;
    }

    @Override
    protected Bitmap doInBackground(String... params) 
    {
        imageUrl = params[0];

        return getImage(imageUrl);
    }

    @Override
    protected void onPostExecute(Bitmap result) 
    {
        super.onPostExecute(result);

        if(result != null)
        {
            cache.addImageToWarehouse(imageUrl, result);

            if(ivImageView != null)
            {
                ivImageView.setImageBitmap(result);
            }
            else if(adapter != null)
            {
                adapter.notifyDataSetChanged();
            }
        }
    }

    private Bitmap getImage(String imageUrl)
    {   
        if(cache.getImageFromWarehouse(imageUrl) == null)
        {
            BitmapFactory.Options options = new BitmapFactory.Options();

            options.inJustDecodeBounds = true;

            options.inSampleSize = inSampleSize;

            try
            {
                URL url = new URL(imageUrl);

                HttpURLConnection connection = (HttpURLConnection)url.openConnection();

                InputStream stream = connection.getInputStream();

                image = BitmapFactory.decodeStream(stream, null, options);

                int imageWidth = options.outWidth;

                int imageHeight = options.outHeight;

                if(imageWidth > desiredWidth || imageHeight > desiredHeight)
                {   
                    System.out.println("imageWidth:"+imageWidth+", imageHeight:"+imageHeight);

                    inSampleSize = inSampleSize + 2;

                    getImage(imageUrl);
                }
                else
                {   
                    options.inJustDecodeBounds = false;

                    connection = (HttpURLConnection)url.openConnection();

                    stream = connection.getInputStream();

                    image = BitmapFactory.decodeStream(stream, null, options);

                    return image;
                }
            }

            catch(Exception e)
            {
                Log.e("getImage", e.toString());
            }
        }

        return image;
    }

PASO 3: Uso de su ActivityoAdapter

Nota: si desea cargar la imagen desde la URL desde la Activityclase. Use el segundo Constructor de DownloadImageTask, pero si desea mostrar la imagen desde el Adapterprimer Constructor de DownloadImageTask(por ejemplo, tiene una imagen ListViewy está configurando la imagen desde 'Adaptador')

USO DE LA ACTIVIDAD:

ImageView imv = (ImageView) findViewById(R.id.imageView);
ImagesCache cache = ImagesCache.getInstance();//Singleton instance handled in ImagesCache class.
cache.initializeCache();

String img = "your_image_url_here";

Bitmap bm = cache.getImageFromWarehouse(img);

if(bm != null)
{
  imv.setImageBitmap(bm);
}
else
{
  imv.setImageBitmap(null);

  DownloadImageTask imgTask = new DownloadImageTask(cache, imv, 300, 300);//Since you are using it from `Activity` call second Constructor.

  imgTask.execute(img);
}

USO DEL ADAPTADOR:

ImageView imv = (ImageView) rowView.findViewById(R.id.imageView);
ImagesCache cache = ImagesCache.getInstance();
cache.initializeCache();

String img = "your_image_url_here";

Bitmap bm = cache.getImageFromWarehouse(img);

if(bm != null)
{
  imv.setImageBitmap(bm);
}
else
{
  imv.setImageBitmap(null);

  DownloadImageTask imgTask = new DownloadImageTask(this, 300, 300);//Since you are using it from `Adapter` call first Constructor.

  imgTask.execute(img);
}

Nota:

cache.initializeCache()puede usar esta declaración en la primera Actividad de su aplicación. Una vez que haya inicializado el caché, nunca necesitará inicializarlo cada vez que use la ImagesCacheinstancia.

Nunca soy bueno para explicar las cosas, pero espero que esto ayude a los principiantes a saber cómo usar el caché LruCachey su uso :)

EDITAR:

Hoy en día hay bibliotecas muy famosas conocidas como Picassoy Glideque se pueden usar para cargar imágenes de manera muy eficiente en la aplicación de Android. Pruebe esta biblioteca muy simple y útil Picasso para Android y Glide para Android . No necesita preocuparse por las imágenes de caché.

Picasso permite la carga de imágenes sin problemas en su aplicación, ¡a menudo en una línea de código!

Glide, al igual que Picasso, puede cargar y mostrar imágenes de muchas fuentes, al tiempo que se encarga del almacenamiento en caché y mantiene un bajo impacto en la memoria al manipular imágenes. Ha sido utilizado por las aplicaciones oficiales de Google (como la aplicación para Google I / O 2015) y es tan popular como Picasso. En esta serie, vamos a explorar las diferencias y ventajas de Glide sobre Picasso.

También puedes visitar el blog para ver la diferencia entre Glide y Picasso


3
Excelente respuesta y explicación! Creo que esta es la mejor solución, ya que funciona sin conexión y utiliza Android LruCache. Descubrí que la solución de Edrowland no funcionaba en modo avión, incluso con la incorporación de Joe, que requería más esfuerzo para integrarse. Por cierto, parece que Android o la red proporciona una cantidad significativa de almacenamiento en caché, incluso si no haces nada extra. (Una cuestión menor: para el uso de muestra getImageFromWareHouse, la 'H' debe estar en minúscula para que coincida). ¡Gracias!
Edwin Evans

1
gran explicación :)
XtreemDeveloper

¿Podría explicar el método getImage (), en particular lo que hace al tamaño de la imagen y cómo sucede. No entiendo, por ejemplo, por qué vuelve a llamar a la función dentro de sí misma y cómo funciona.
Greyshack

1
¡Vota por lo que if(cache == null)resolvió mi problema! :)
MR. García

1
También vea mi respuesta Editada al final. He mencionado sobre bibliotecas famosas utilizadas por la mayoría de los desarrolladores hoy en día. Pruebe esos Picasso: square.github.io/picasso y Glide: futurestud.io/blog/glide-getting-started
Zubair Ahmed

18

Para descargar una imagen y guardarla en la tarjeta de memoria, puede hacerlo así.

//First create a new URL object 
URL url = new URL("http://www.google.co.uk/logos/holiday09_2.gif")

//Next create a file, the example below will save to the SDCARD using JPEG format
File file = new File("/sdcard/example.jpg");

//Next create a Bitmap object and download the image to bitmap
Bitmap bitmap = BitmapFactory.decodeStream(url.openStream());

//Finally compress the bitmap, saving to the file previously created
bitmap.compress(CompressFormat.JPEG, 100, new FileOutputStream(file));

No olvide agregar el permiso de Internet a su manifiesto:

<uses-permission android:name="android.permission.INTERNET" />

10
¿Por qué decodifica el JPEG y luego lo vuelve a codificar? Es mejor que descargue la URL en una matriz de bytes, luego use esa matriz de bytes para crear su mapa de bits y escribir en un archivo. Cada vez que decodifica y vuelve a codificar un JPEG, la calidad de la imagen empeora.
CommonsWare

2
Punto justo, era más por la velocidad que cualquier otra cosa. Sin embargo, si se guarda como una matriz de bytes y el archivo de origen no era un archivo JPEG, ¿no sería necesario convertir el archivo de todos modos? "decodeByteArray" del SDK devuelve "El mapa de bits decodificado, o nulo si los datos de la imagen no se pueden decodificar", así que esto me hace pensar que siempre está decodificando los datos de la imagen, así que ¿no sería necesario volver a codificar?
Ljdawson

Hablando de eficiencia, ¿no sería eficiente si en lugar de pasar FileOutputStream pasamos BufferedOutputStream?
Samuh el

1
No sugiero el almacenamiento en caché de imágenes en su tarjeta SD. Una vez que se desinstala la aplicación, las imágenes no se eliminan, lo que hace que la tarjeta SD se llene de basura inútil. guardar imágenes en el directorio de caché de la aplicación es IMO preferido
james

Con un límite de APK de 50 MB ahora, el almacenamiento en caché en la tarjeta SD puede ser la única forma para los desarrolladores.
Ljdawson

13

Consideraría usar el caché de imágenes de droidfu. Implementa un caché de imágenes en memoria y en disco. También obtiene un WebImageView que aprovecha la biblioteca ImageCache.

Aquí está la descripción completa de droidfu y WebImageView: http://brainflush.wordpress.com/2009/11/23/droid-fu-part-2-webimageview-and-webgalleryadapter/


Ha refactorizado su código desde 2010; aquí está el enlace raíz: github.com/kaeppler/droid-fu
esilver

3
Ese enlace aún no funciona. Escribí una biblioteca similar llamada Android-ImageManager github.com/felipecsl/Android-ImageManager
Felipe Lima

9

Probé SoftReferences, se recuperan de manera demasiado agresiva en Android que sentí que no tenía sentido usarlos.


2
De acuerdo - SoftReferences se recuperan muy rápidamente en los dispositivos que he probado
esilver

3
Google ha confirmado que el GC de Dalvik es muy agresivo en la recolección de SoftReferences. Recomiendan usar su LruCacheen su lugar.
kaka

9

Como sugirió Thunder Rabbit, ImageDownloader es el mejor para el trabajo. También encontré una ligera variación de la clase en:

http://theandroidcoder.com/utilities/android-image-download-and-caching/

La principal diferencia entre los dos es que ImageDownloader utiliza el sistema de almacenamiento en caché de Android, y el modificado utiliza el almacenamiento interno y externo como almacenamiento en caché, manteniendo las imágenes en caché de forma indefinida o hasta que el usuario lo elimine manualmente. El autor también menciona la compatibilidad con Android 2.1.


7

Esta es una buena captura de Joe. El ejemplo de código anterior tiene dos problemas: uno: el objeto de respuesta no es una instancia de Bitmap (cuando mi URL hace referencia a un jpg, como http: \ website.com \ image.jpg, es un

org.apache.harmony.luni.internal.net.www.protocol.http.HttpURLConnectionImpl $ LimitedInputStream).

En segundo lugar, como señala Joe, no se produce el almacenamiento en caché sin configurar una memoria caché de respuesta. Los desarrolladores de Android deben rodar su propio caché. Aquí hay un ejemplo para hacerlo, pero solo se almacena en memoria caché, lo que realmente no es la solución completa.

http://codebycoffee.com/2010/06/29/using-responsecache-in-an-android-app/

La API de almacenamiento en caché de URLConnection se describe aquí:

http://download.oracle.com/javase/6/docs/technotes/guides/net/http-cache.html

Sigo pensando que esta es una buena solución para seguir esta ruta, pero todavía tienes que escribir un caché. Suena divertido, pero prefiero escribir características.


7

Hay una entrada especial en la sección de entrenamiento oficial de Android sobre esto: http://developer.android.com/training/displaying-bitmaps/cache-bitmap.html

La sección es bastante nueva, no estaba allí cuando se hizo la pregunta.

La solución sugerida es usar un LruCache. Esa clase se introdujo en Honeycomb, pero también se incluye en la biblioteca de compatibilidad.

Puede inicializar un LruCache configurando el número máximo de entradas y las clasificará automáticamente y las limpiará menos cuando supere el límite. Aparte de eso, se utiliza como un mapa normal.

El código de muestra de la página oficial:

private LruCache mMemoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    // Get memory class of this device, exceeding this amount will throw an
    // OutOfMemory exception.
    final int memClass = ((ActivityManager) context.getSystemService(
            Context.ACTIVITY_SERVICE)).getMemoryClass();

    // Use 1/8th of the available memory for this memory cache.
    final int cacheSize = 1024 * 1024 * memClass / 8;

    mMemoryCache = new LruCache(cacheSize) {
        @Override
        protected int sizeOf(String key, Bitmap bitmap) {
            // The cache size will be measured in bytes rather than number of items.
            return bitmap.getByteCount();
        }
    };
    ...
}

public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
    if (getBitmapFromMemCache(key) == null) {
        mMemoryCache.put(key, bitmap);
    }
}

public Bitmap getBitmapFromMemCache(String key) {
    return mMemoryCache.get(key);
}

Anteriormente, SoftReferences era una buena alternativa, pero ya no, citando desde la página oficial:

Nota: En el pasado, una implementación de memoria caché popular era una memoria caché de mapa de bits SoftReference o WeakReference, sin embargo, esto no se recomienda. A partir de Android 2.3 (API Nivel 9), el recolector de basura es más agresivo al recopilar referencias suaves / débiles, lo que las hace bastante ineficaces. Además, antes de Android 3.0 (Nivel de API 11), los datos de respaldo de un mapa de bits se almacenaban en la memoria nativa que no se libera de manera predecible, lo que podría causar que una aplicación exceda brevemente sus límites de memoria y se bloquee.


3

Considere usar la biblioteca Universal Image Loader de Sergey Tarasevich . Viene con:

  • Carga de imágenes multiproceso. Le permite definir el tamaño del grupo de subprocesos
  • Almacenamiento en caché de imágenes en la memoria, en el sistema de archivos del dispositivo y en la tarjeta SD.
  • Posibilidad de escuchar el progreso de carga y los eventos de carga

Universal Image Loader permite una gestión de caché detallada para las imágenes descargadas, con las siguientes configuraciones de caché:

  • UsingFreqLimitedMemoryCache: El mapa de bits utilizado con menos frecuencia se elimina cuando se excede el límite de tamaño de caché.
  • LRULimitedMemoryCache: El mapa de bits utilizado menos recientemente se elimina cuando se supera el límite de tamaño de caché.
  • FIFOLimitedMemoryCache: La regla FIFO se usa para eliminación cuando se excede el límite de tamaño de caché.
  • LargestLimitedMemoryCache: El mapa de bits más grande se elimina cuando se excede el límite de tamaño de caché.
  • LimitedAgeMemoryCache: El objeto en caché se elimina cuando su antigüedad excede el valor definido .
  • WeakMemoryCache: Una memoria caché con solo referencias débiles a mapas de bits.

Un ejemplo de uso simple:

ImageView imageView = groupView.findViewById(R.id.imageView);
String imageUrl = "http://site.com/image.png"; 

ImageLoader imageLoader = ImageLoader.getInstance();
imageLoader.init(ImageLoaderConfiguration.createDefault(context));
imageLoader.displayImage(imageUrl, imageView);

Este ejemplo usa el valor predeterminado UsingFreqLimitedMemoryCache.


Cuando se usa intensamente, Universal Image Loader causará muchas pérdidas de memoria. Sospecho que esto sucede porque usa singletons en el código (vea 'getInstance ()' en el ejemplo). Después de cargar muchas imágenes y luego girar mi pantalla un par de veces, mi aplicación se bloqueó todo el tiempo porque OutOfMemoryErrors en UIL. Es una gran biblioteca, pero es un hecho bien conocido que NUNCA debes usar singletons, especialmente no en Android ...
Geert Bellemans

1
¡USE singletons cuando sepa cómo! :)
Renetik

3

Lo que realmente funcionó para mí fue configurar ResponseCache en mi clase Main:

try {
   File httpCacheDir = new File(getApplicationContext().getCacheDir(), "http");
   long httpCacheSize = 10 * 1024 * 1024; // 10 MiB
   HttpResponseCache.install(httpCacheDir, httpCacheSize);
} catch (IOException e) { } 

y

connection.setUseCaches(true);

al descargar bitmap.

http://practicaldroid.blogspot.com/2013/01/utilizing-http-response-cache.html


¿es posible usar lrucache en conjunción con httpresponsecache
iOSAndroidWindowsMobileAppsDev


1

Había estado luchando con esto por algún tiempo; las respuestas que usan SoftReferences perderían sus datos demasiado rápido. Las respuestas que sugieren crear una instancia de RequestCache eran demasiado desordenadas, además, nunca pude encontrar un ejemplo completo.

Pero ImageDownloader.java funciona maravillosamente para mí. Utiliza un HashMap hasta que se alcanza la capacidad o hasta que se produce el tiempo de espera de purga, luego las cosas se mueven a una SoftReference, utilizando así lo mejor de ambos mundos.



0

Incluso la respuesta posterior, pero escribí un Administrador de imágenes de Android que maneja el almacenamiento en caché de forma transparente (memoria y disco). El código está en Github https://github.com/felipecsl/Android-ImageManager


1
Agregué esto a ListView y no parece manejarlo muy bien. ¿Hay alguna implementación especial para ListViews?

0

Respuesta tardía, pero pensé que debería agregar un enlace a mi sitio porque he escrito un tutorial sobre cómo hacer un caché de imágenes para Android: http://squarewolf.nl/2010/11/android-image-cache/ Actualización: el la página se ha desconectado porque la fuente estaba desactualizada. Me uno a @elenasys en su consejo de usar Ignition .

Entonces, a todas las personas que se topan con esta pregunta y no han encontrado una solución: ¡espero que disfruten! = D


0

Respuesta tardía, pero creo que esta biblioteca ayudará mucho con el almacenamiento en caché de imágenes: https://github.com/crypticminds/ColdStorage .

Simplemente anote ImageView con @LoadCache (R.id.id_of_my_image_view, "URL_to_downlaod_image_from) y se encargará de descargar la imagen y cargarla en la vista de imagen. También puede especificar una imagen de marcador de posición y cargar la animación.

La documentación detallada de la anotación está presente aquí: - https://github.com/crypticminds/ColdStorage/wiki/@LoadImage-annotation

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.