Делаем правильную множественную загрузку в Android

В одном из наших приложений была поставлена задача загружать иконки приложений, которые имеются на устройстве. Поскольку иконок, как и приложений, может быть очень много, для повышения быстродействия эту операцию следовало разделить на параллельные процессы. Затем загруженные записи с приложениями и иконками выводятся пользователям с помощью RecyclerView.

Для выполнения этих задач используется класс AsyncTask, принимающий в качестве исходных данных ID ImageView, в который нужно загрузить иконку, и выполняющий процесс загрузки.

Сейчас код, реализующий загрузку иконок, выглядит следующим образом:

public class GetIconTask extends AsyncTask<String, Void, Drawable> {
    private ImageView ivImageView;
    private Context context;

    public GetIconTask(ImageView ivImageView) {
        this.ivImageView = ivImageView;
        this.context = ivImageView.getContext();
    }

    @Override
    protected Drawable doInBackground(String... params) {
        String packageName = params[0];
        Drawable icon = null;
        try {
            icon = context.getPackageManager().getApplicationIcon(packageName);
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
        return icon;
    }

    @Override
    protected void onPostExecute(Drawable result) {
        super.onPostExecute(result);
        if (ivImageView != null) {
            ivImageView.setImageDrawable(result);
        }
    }
}

Однако, как оказалось, здесь имеется существенный недостаток: AsyncTask вместо того, чтобы для загрузки каждой иконки выделять отдельный поток, выполняет все операции в одном потоке, выстраивая их в очередь выполнения. То есть, при создании нового ViewHolder или его перебиндивания вызываемый AsyncTask не запускает новый поток, а ждёт, пока не будет выполнен работающий AsyncTask. Как следствие, загрузка иконок может занять много времени, и также это может вызвать дерганье интерфейса на слабеньких устройствах.

Такая проблема возникает потому, что, начиная с версии Android 3.2 Honeycomb, класс AsyncTask перестал создавать поток для каждого экземпляра, вместо этого создаётся объект Executor, который выполняет всё в одном фоновом потоке. Из-за этого сейчас крайне не рекомендуется его использовать  для операций, выполняющихся не один раз, а на протяжении долгого времени.

Чтобы исправить проблему, попробуем создать собственный фоновый поток для загрузки иконок. В этом нам поможет класс HandlerThread, благодаря которому мы создадим цикл сообщений. Фоновый поток с помощью очередей будет проверять новые сообщения, а главный поток будет извлекать эти сообщения из очереди.

Старый код убираем полностью, теперь наш класс GetIconTask будет наследовать от HandlerThread, а не от AsyncTask.

public class GetIconTask<T> extends HandlerThread {
    private static final String TAG = "GetIconTask";

    public GetIconTask() {
        super(TAG);
    }

    public void queueIcon(T target, String packageName) {
        Log.i(TAG, "Получен packageName: " + packageName);
    }
}

Обобщённый параметр T используется для того, чтобы реализация была более гибкой, не привязанной к какому-то конкретному типу. Метод queueIcon() будет принимать имя пакета, иконку которого мы хотим загрузить в потоке. Он будет вызываться там же, где сейчас запускается AsyncTask, то есть в методе onBindViewHolder() адаптера.

Теперь нужно фоновый поток запускать. Запускаться он будет в момент старта приложения и останавливаться, если оно было закрыто. Поэтому нам нужны методы главной активности onCreate() и onDestroy() соответственно.

private GetIconTask<AppsAdapter.AppViewHolder> mGetIconTask;

...

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    ...
    mGetIconTask = new GetIconTask<>();
    mGetIconTask.start();
    mGetIconTask.getLooper();

    ...
}

@Override
public void onDestroy() {
    super.onDestroy();
    mGetIconTask.quit();
}

Крайне важно уничтожать потоки, если приложение их не использует в данный момент, иначе они останутся работать в системе, что будет создавать лишний мусор. Метод getLooper() вызывается после запуска потока, чтобы исключить состояние гонки (ошибка, при которой работа приложения зависит от того, в каком порядке выполняются части кода), мы должны быть уверены, что поток готов к использованию.

Теперь в методе onBindViewHolder() передаём в метод queueIcon() фонового потока сам холдер и имя пакета. Чтобы получить доступ к экземпляру GetIconTask, нужно будет передать его в адаптер через конструктор.

mGetIconTask.queueIcon(appVH, ai.packageName);

На данном этапе фоновый поток уже должен получать сообщения от адаптера, однако он их пока никак не обрабатывает. Для того, чтобы реализовать обработку, нам понадобится класс Handler, отвечающий за обработку и отправку сообщений (Message). В данной реализации сообщения будут создаваться и отправляться в конец очереди Looper, откуда уже Handler будет его обрабатывать. Для того, чтобы добавить сообщения в очередь, нам также понадобится константа, определяющая сообщение (поле what).

private static final int MESSSAGE_DOWNLOAD = 0;
private Handler mRequestHandler;
private ConcurrentMap<T, String> mRequestMap = new ConcurrentHashMap<>();

Для хранения имён пакетов используется безопасная разновидность HashMapConcurrentHashMap, принимающая в качестве ключа объект T.

Теперь сообщение нужно отправить в очередь, для этого дополним код в методе queueIcon().

public void queueIcon(T target, String packageName) {
    Log.i(TAG, "Получен packageName: " + packageName);
    if (packageName == null) {
        mRequestMap.remove(target);
    } else {
        mRequestMap.put(target, packageName);
        mRequestHandler.obtainMessage(MESSSAGE_DOWNLOAD, target).sendToTarget();
    }
}

Здесь сообщение отправляется на приёмник для дальнейшей обработки. Теперь нашему Handler нужно извлечь сообщение из очереди и загрузить иконку, выполнив необходимые операции. Для этого переопределим метод onLooperPrepared(), в котором Handler будет извлекать сообщения и обрабатывать их в отдельном методе.

@Override
protected void onLooperPrepared() {
    mRequestHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            if (msg.what == MESSSAGE_DOWNLOAD) {
                T target = (T) msg.obj;
                Log.i(TAG, "Получен запрос для пакета: " + mRequestMap.get(target));
                handleRequest(target);
            }
        }
    };
}

private void handleRequest(final T target) {
    final String packageName = mRequestMap.get(target);
    try {
        final Drawable icon = mContext.getPackageManager().getApplicationIcon(packageName);
    } catch (PackageManager.NameNotFoundException e) {
        e.printStackTrace();
    }
}

Фоновый поток принимает и обрабатывает сообщения, однако главный поток об этом ничего не знает. Чтобы передать сообщение в главный поток, нужно объявит в нём экземпляр Handler, который будет передаваться в конструктор фонового потока. В фоновом же потоке нужно добавить интерфейс, через который главный поток будет принимать ViewHolder и иконку приложения.

private Context mContext;
private Handler mResponseHandler;
private GetIconTaskListener<T> mGetIconTaskListener;

public interface GetIconTaskListener<T> {
    void onIconDownloaded(T target, Drawable icon);
}

public void setIconDownloadListener(GetIconTaskListener<T> listener) {
    mGetIconTaskListener = listener;
}

public GetIconTask(Handler responseHandler, Context context) {
    super(TAG);
    mContext = context;
    mResponseHandler = responseHandler;
}

...

Метод onIconDownloaded() в интерфейсе будет вызываться тогда, когда иконка будет загружена и готова к добавлению.

Теперь нам нужно передать Handler из главного потока, для этого нужно его объявить, также необходимо установить слушатель, который будет принимать готовые иконки и обновлять нужные холдеры.

Handler responseHandler = new Handler();
mGetIconTask = new GetIconTask<>(responseHandler, getApplicationContext());
mGetIconTask.setIconDownloadListener(
    new GetIconTask.GetIconTaskListener<AppsAdapter.AppViewHolder>() {
      @Override public void onIconDownloaded(AppsAdapter.AppViewHolder target, Drawable icon) {
        target.bindDrawable(icon);
      }
    });
mGetIconTask.start();
mGetIconTask.getLooper();

Связь между двумя потоками установлена, но никакие данные между ними не пересылаются. Чтобы отправить сообщение из фонового потока в главный, нужно в методе handleRequest() фонового потока добавить код, передающий сообщение в интерфейс и удаляющий его из очереди.

private void handleRequest(final T target) {
    final String packageName = mRequestMap.get(target);
    try {
        final Drawable icon = mContext.getPackageManager().getApplicationIcon(packageName);
        mResponseHandler.post(new Runnable() {
            @Override
            public void run() {
                if (!mRequestMap.get(target).equals(packageName)) {
                    return;
                }
                mRequestMap.remove(target);
                mGetIconTaskListener.onIconDownloaded(target, icon);
            }
        });
    } catch (PackageManager.NameNotFoundException e) {
        e.printStackTrace();
    }
}

Благодаря условию внутри run() мы можем быть уверены, что каждое приложение в списке получит свою иконку.

Чтобы обезопасить себя от возможных ошибок с утечкой памяти или удалением связи между GetIconTask и адаптером, нужно реализовать полную очистку списка. Для этого в классе GetIconTask добавляем новый метод clearQueue().

public void clearQueue() {
    mRequestHandler.removeMessages(MESSSAGE_DOWNLOAD);
}

Затем в методе нашей активности onStop() вызываем очистку списка.

@Override
protected void onStop() {
    super.onStop();
    mGetIconTask.clearQueue();
}

В результате у нас получилась множественная загрузка иконок без каких-либо подтормаживаний и багов. Фоновый поток обрабатывает получаемые сообщения и возвращает их главному потоку, откуда уже происходит обновление записей в RecyclerView. Итоговый код класса GetIconTask выглядит следующим образом:

public class GetIconTask<T> extends HandlerThread {
    private static final String TAG = "GetIconTask";
    private static final int MESSSAGE_DOWNLOAD = 0;
    private Handler mRequestHandler;
    private ConcurrentMap<T, String> mRequestMap = new ConcurrentHashMap<>();
    private Context mContext;
    private Handler mResponseHandler;
    private GetIconTaskListener<T> mGetIconTaskListener;

    public interface GetIconTaskListener<T> {
        void onIconDownloaded(T target, Drawable icon);
    }

    public void setIconDownloadListener(GetIconTaskListener<T> listener) {
        mGetIconTaskListener = listener;
    }

    public GetIconTask(Handler responseHandler, Context context) {
        super(TAG);
        mContext = context;
        mResponseHandler = responseHandler;
    }

    @Override
    protected void onLooperPrepared() {
        mRequestHandler = new Handler() {
            @Override
            public void handleMessage(Message msg) {
                if (msg.what == MESSSAGE_DOWNLOAD) {
                    T target = (T) msg.obj;
                    Log.i(TAG, "Получен запрос для пакета: " + mRequestMap.get(target));
                    handleRequest(target);
                }
            }
        };
    }

    private void handleRequest(final T target) {
        final String packageName = mRequestMap.get(target);
        try {
            final Drawable icon = mContext.getPackageManager().getApplicationIcon(packageName);
            mResponseHandler.post(new Runnable() {
                @Override
                public void run() {
                    if (!mRequestMap.get(target).equals(packageName)) {
                        return;
                    }
                    mRequestMap.remove(target);
                    mGetIconTaskListener.onIconDownloaded(target, icon);
                }
            });
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
    }

    public void queueIcon(T target, String packageName) {
        Log.i(TAG, "Получен packageName: " + packageName);
        if (packageName == null) {
            mRequestMap.remove(target);
        } else {
            mRequestMap.put(target, packageName);
            mRequestHandler.obtainMessage(MESSSAGE_DOWNLOAD, target).sendToTarget();
        }
    }

    public void clearQueue() {
        mRequestHandler.removeMessages(MESSSAGE_DOWNLOAD);
    }
}

Если сравнивать качество и скорость работы с предыдущей версией, то можно наглядно заметить улучшение работы приложения.

Вот так работает загрузка иконок с использованием AsyncTask.

А вот так — после замены AsyncTask на HandlerThread.

Подводя итоги, можно сказать, что использование AsyncTask для часто повторяющихся операций не является эффективным решением, хотя в некоторых ситуациях этот класс крайне полезен, поскольку очень прост в реализации. С помощью HandlerThread же мы добились того, что приложение стало работать быстрее, чем раньше, что должно положительно сказаться на отзывах пользователей.

Скачать обновлённый Менеджер системных приложений вы можете в Google Play по следующей ссылке.

Share on VKShare on Facebook0Share on Google+0Tweet about this on TwitterShare on LinkedIn0Share on Tumblr0Email this to someoneShare on Reddit0
Нашли ошибку в тексте?

Делаем правильную множественную загрузку в Android: 4 комментария

  1. Alexander

    » эту операцию следовало разделить на параллельные процессы. » — вот после этой фразы сразу закрались сомнения..

    Вы просто накидываете в свой Looper сообщения в очередь, по очереди их обрабатываете и отравляете в Main Looper — где тут множетвенная загрузка и распараллеливание?

    Почитайте: https://blog.mindorks.com/android-core-looper-handler-and-handlerthread-bd54d69fe91a

    У вас API > 16? Чем не нравится AsyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) — сам все распараллелит и не надо столько кода впустую писать… Не нравится TREAD_POOL_EXECUTOR? — можно свой написать — и то в разы проще будет.

    Ну если хочется совсем хардкора — можно использовать «родные» способы распараллеливания java, например ExecutorService:

    ExecutorService executionService = Executors.newFixedThreadPool(5);
    executionService.submit(new Runnable(…))

    executionService.shutdown();

    и это, в принципе, 3 сточки которые заменят почти всю вашу реализацию…

    1. Владимир Автор записи

      Спасибо за комментарий. У нас апи 14 минимальный.

  2. Alexander

    Да, и картинки у вас с AsyncTask менялись вовсе не потому, что AsyncTask выполнял все поочередно, а оптому что вы передаете ImageView в каждый таск, RecyclerView — переиспользует ваши вьюхи, и все накиданные ранее таски просто сетили свои картики по завершению во вьюху, вам надо лишь было отменять предыдущий AsyncTask при новом onBind

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *