пятница, 7 июня 2013 г.

Делаем собственный компонент для Android-приложений

Если вы уже не в первый раз делаете приложения под Android, то наверняка вас часто посещает чувство дежавю: решение большинства задач вы уже где-то видели, когда-то что-то делали, а порой вам даже удаётся найти старый код и "вклеить" его в новый проект. Если же вы работаете в команде, то "помню я это где-то делал" уже не работает. Нужно систематизировать наработки, держать актуальный репозиторий библиотек. А когда своих и сторонних библиотек становится много, то поневоле начинаешь перебирать их, оставляя те, что лучше и избавляясь от остальных. Как же понять, что лучше? И как сделать такой компонент, который навсегда займёт почётное место в репозитории вашей команды?
Ниже я предложу своё видение критериев оценки библиотек и в качестве бонуса: код весьма полезного и удобного компонента для асинхронной загрузки, кеширования и отображения картинок из сети.
Какими должны быть библиотеки?
  1. (И главное!) Простыми. Если вы создали великолепную абстракцию, универсальную архитектуру, применили все свои любимые паттерны и надеетесь, что за это другие разработчики будут обязаны потратить пару дней на изучение вашего продукта - вас ждёт разочарование. Программисты используют библиотеки чтобы упростить себе жизнь. И всё. Эстетическими чувствами они не руководствуются. Когда я выбираю себе библиотеку для решения какой-то задачи, я не буду даже рассматривать то, что не заработало легко, интуитивно, без штудирования мануалов. Исключения есть, но это должны быть ОЧЕНЬ хорошие библиотеки или у них не должно быть альтернатив. В нашем деле это редкость.
  2. Монофункциональными. Если вам нужно отобразить простенькую анимацию, и у вас есть под рукой только пятимегабайтный "комбайн", который делает всё на свете - лучше сделайте анимацию сами. Или, в крайнем случае, возьмите этого монстра на первый релиз, но потом, когда появится время, обязательно замените его на что-то что делает только эту анимацию и всё. 
  3. Изолированными. Если чтобы впилить библиотеку вам приходится переписать всю Activity, добавить в проект пару Layout-ов и немного "поправить" бизнес-логику - это плохая библиотека. Идеальная библиотека как коробочка с одним универсальным разъёмом: Включаете просто, легко, в любое место одной-двумя строчками кода. И больше нигде ничего не нужно менять. Всё что ей нужно у неё внутри.  
Вот и всё. Понятно, что библиотека должна быть без багов, гибкая, с хорошим качеством кода, документированная и т.д. и т.п. Мы же о свободном коде говорим верно? Если в простом удобном и идеально вам подходящем компоненте найдётся баг - исправьте его. И документируйте, если нужна документация. 

Последуем своим советам и сделаем что-то полезное

Я заметил как-то что уже дважды менял "любимую" библиотеку для загрузки картинок в ImageView из сети. У одной были проблемы с кешированием, другая была слишком громоздкой. А задача-то тривиальная. Вот и подумалось мне: не пора ли сделать своё, родное?

Требования к компоненту стандартные:
- это должен быть просто наследник ImageView, который без лишних телодвижений можно добавить в xml layout, извлечь через findViewById и вызовом одного метода заставить его работать. 
- асинхронно загружать картинку по её URL и отображать прогресс загрузки.
- кэшировать в память и(или) на sd-карту загруженные изображения и при повторном вызове показывать их из без запросов в сеть. 
- контролировать размер кэша (особенно памяти) и число одновременных потоков загрузки

Получилось вот что:

import android.content.Context;
import android.graphics.*;
import android.os.Handler;
import android.util.AttributeSet;
import android.util.Base64;
import android.util.Log;
import android.widget.ImageView;
 
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URL;
import java.net.URLConnection;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
 
public class NetImageView extends ImageView {
 
    private static final int CONNECT_TIMEOUT = 5000;
    private static final int READ_TIMEOUT = 10000;
    private static final String DISK_CACHE_PATH = "/netimage_cache/";
    private static final boolean USE_DISK_CASHE = true;
    private static final boolean USE_MEMORY_CASHE = true;
    private static final int MEMORY_SIZE_LIMIT = 50;
    private static final int REQUEST_POOL_SIZE_LIMIT = 5;
 
    private String mDiskCachePath;
    private double mCurrPercent = 0.0D;
    private boolean mLoaded = false;
    private boolean mNeedShowProgress = true;
    private Handler mHandler = new Handler();
    private static final String TAG = "netimage";
    private static final ConcurrentHashMap<String, Bitmap> memoryCache = new ConcurrentHashMap<String, Bitmap>();
    private static final ConcurrentLinkedQueue<String> requestPool = new ConcurrentLinkedQueue<String>();
    private static final Object monitor = new Object();
    private Bitmap rezbmp;
 
    public NetImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mDiskCachePath = context.getCacheDir().getAbsolutePath() + DISK_CACHE_PATH;
        File outFile = new File(mDiskCachePath);
        outFile.mkdirs();
    }
 
    public void loadImage(final String url, int staticLoaderImageResource, final int failLoadImageResource, boolean needShowProgress) {
        this.mNeedShowProgress = needShowProgress;
        setImageResource(staticLoaderImageResource);
        new Thread(new Runnable() {
            @Override
            public void run() {
                mLoaded = false;
                final Bitmap bmp;
                try {
                    bmp = cashedLoad(url);
                    mLoaded = true;
                    mHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            if (bmp==null) setImageResource(failLoadImageResource);
                            else setImageBitmap(bmp);
                        }
                    });
                } catch (AlreadyLoadingException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
 
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (!mLoaded && mNeedShowProgress) {
            int delimiter = new BigDecimal((getMeasuredWidth() - 30) * mCurrPercent).intValue();
            if (delimiter<30) delimiter = 30;
            canvas.drawColor(Color.BLACK);
            Paint p = new Paint();
            p.setColor(Color.WHITE);
            canvas.drawText(new BigDecimal(mCurrPercent * 100).setScale(2, RoundingMode.HALF_UP).doubleValue() + "%", 30, getMeasuredHeight() / 2 - 10, p);
            canvas.drawRect(30, getMeasuredHeight() / 2 - 4, delimiter, getMeasuredHeight() / 2 + 4, p);
            p.setColor(Color.GRAY);
            canvas.drawRect(delimiter, getMeasuredHeight() / 2 - 4, getMeasuredWidth() - 30, getMeasuredHeight() / 2 + 4, p);
        }
    }
 
    private Bitmap cashedLoad(String url) throws AlreadyLoadingException {
        rezbmp = null;
        String key;
        try {
            key = new String(Base64.encode(url.getBytes("UTF-8"), Base64.DEFAULT), "UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
            return null;
        }
        final File dcashef = new File(mDiskCachePath + key);
 
        if (USE_MEMORY_CASHE && (rezbmp = memoryCache.get(key))!=null) {
            Log.v(TAG, "restored from memory cashe key: " + key);
            if (USE_DISK_CASHE && !dcashef.exists()) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            rezbmp.compress(Bitmap.CompressFormat.PNG, 100, new FileOutputStream(dcashef));
                            Log.v(TAG, "updated disk cashe file: " + dcashef.getAbsolutePath() + " successfully");
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    }
                }).start();
            }
            return rezbmp;
        }
 
        if (USE_DISK_CASHE && dcashef.exists() && (rezbmp = BitmapFactory.decodeFile(dcashef.getAbsolutePath()))!=null) {
            Log.v(TAG, "restored from cashe file: " + dcashef.getAbsolutePath());
            if (USE_MEMORY_CASHE) {
                if (memoryCache.size()>=MEMORY_SIZE_LIMIT) memoryCache.remove(memoryCache.keySet().iterator().next());
                memoryCache.put(key, rezbmp);
                Log.v(TAG, "updated to memory cashe successfully");
            }
            return rezbmp;
        }
 
        if (requestPool.contains(url)) throw new AlreadyLoadingException();
        while (requestPool.size()>=REQUEST_POOL_SIZE_LIMIT) {
            synchronized (monitor) {
                try {
                    monitor.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
        requestPool.add(url);
 
        try {
            URLConnection conn = new URL(url).openConnection();
            conn.setConnectTimeout(CONNECT_TIMEOUT);
            conn.setReadTimeout(READ_TIMEOUT);
 
            FileOutputStream out = new FileOutputStream(dcashef);
            InputStream in = conn.getInputStream();
            byte[] buf = new byte[128];
            int readed;
            int allread = 0;
            while ((readed = in.read(buf)) > 0) {
                mCurrPercent = (double) allread / conn.getContentLength();
                out.write(buf, 0, readed);
                allread += readed;
                postInvalidate();
            }
            out.flush();
            out.close();
            in.close();
            requestPool.remove(url);
            synchronized (monitor) {
                monitor.notifyAll();
            }
            Log.v(TAG, "stored to file: " + dcashef.getAbsolutePath() + " successfully");
            rezbmp = BitmapFactory.decodeFile(dcashef.getAbsolutePath());
            if (!USE_DISK_CASHE) dcashef.delete();
        } catch (Exception e) {
            e.printStackTrace();
            dcashef.delete();
        }
 
        if (USE_MEMORY_CASHE && rezbmp!=null) {
            if (memoryCache.size()>=MEMORY_SIZE_LIMIT) memoryCache.remove(memoryCache.keySet().iterator().next());
            memoryCache.put(key, rezbmp);
            Log.v(TAG, "stored to memory cashe successfully");
        }
        return rezbmp;
    }
 
    public static void clearCashe() {
        memoryCache.clear();
    }
 
    private class AlreadyLoadingException extends Exception {}
}

Как этим пользоваться? 

Вот так:

netImageView.loadImage(my_cool_image_url, android.R.drawable.ic_popup_sync, android.R.drawable.ic_menu_gallery, true);

...где netImageView - экземпляр вашего view, который вы получили из xml-разметки.
Первым параметром мы передаём URL изображения, вторым - ресурс изображения, которое должно отображаться при загрузке (статический прелоадер), вторым - то изображение что появится в случае если загрузка провалится. Третий параметр определяет, нужно ли вместо статического прелоадера показывать прогресс-бар в процессе загрузки.

Как это настраивать?

В принципе - никак. настройки по умолчанию должны подходить всем. Но если вдруг захотелось - посмотрите на константы в начале листинга:


CONNECT_TIMEOUT - интервал ожидания соединения
READ_TIMEOUT - интервал чтения
DISK_CACHE_PATH - путь к файлам кэша в стандартной директории для кэша приложения
USE_DISK_CASHE - кешировать ли на sd-карту
USE_MEMORY_CASHE - кэшировать ли в память
MEMORY_SIZE_LIMIT максимальное число картинок в кэше памяти
REQUEST_POOL_SIZE_LIMIT - максимальное число потоков загрузки

2 комментария:

  1. У меня есть идея приложения, но написать его может только проф.! Где можно найти таких людей чтоб написали мне приложение?

    ОтветитьУдалить