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

Автор: | 17.08.2017

В одном из наших приложений была поставлена задача загружать иконки приложений, которые имеются на устройстве. Поскольку иконок, как и приложений, может быть очень много, для повышения быстродействия эту операцию следовало разделить на параллельные процессы. Затем загруженные записи с приложениями и иконками выводятся пользователям с помощью 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<>();

Для хранения имён пакетов используется безопасная разновидность HashMap - ConcurrentHashMap, принимающая в качестве ключа объект 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 по следующей ссылке.

Делаем правильную множественную загрузку в 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

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

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