Художественный подход к загрузке изображений

от автора

Как художнице и web-разработчику, у меня со временем появилась необходимость в собственной галерее. Обычно, у галерей две основные функции: показ витрины — всех (или некоторых) картин — и детальный показ одной. Реализация обеих функций есть практически в любой готовой галерее. Но «заношенный» внешний вид готовых галерей и, ставший стандартом, пользовательский интерфейс не годятся для художника :). А нестандартный — требует особой архитектуры и реализации кода, осуществляющего загрузку и показ картин. Сам показ и пользовательский интерфейс я в этой статье опущу. Основное внимание будет отдано загрузке картин с сервера. Об итоговой организации контролируемой загрузки с использованием очередей, асинхронного загрузчика, обработки блоб-объектов, каскадов выполнения обещаний и с возможностью приостановки и пойдет речь.

Примеры кода записаны на coffeeScript

Задачи

  1. Загрузка всех картин витрины требует времени. Мгновенное появление всех — невозможно. А первоочередное появление картин, на которые сразу посмотрит пользователь, возможно.
    Поэтому одной из задач была возможность осуществлять загрузку картин в нужной последовательности. Моя галерея визуально центроориентированная, следовательно порядок загрузки — центробежный, сначала грузятся картинки в центре экрана, а потом расходящимися кругами остальные. Таким образом, маленькие экраны заполняются достаточно быстро, а большие экраны позволяют получить доступ к управлению просмотром в кратчайшие сроки (элементы управления перемещением и переходом к детальному просмотру сосредоточены вокруг центральной картинки).
  2. Другой задачей была возможность приостанавливать загрузку картинок для страницы, с которой уходят, не дождавшись пока абсолютно все на ней загрузится, чтобы сразу начать грузить данные для страницы, на которую приходят. Для этого необходимо сделать паузу в посылке запросов, запомнить какие картины не догрузили, и после возвращения на предыдущую страницу возобновить загрузку.

Для этого была применена трехуровневая архитектура:
приложение -> менеджер загрузок -> асинхронный загрузчик

Уровень приложения

Приложение последовательно получает url картинок, которые надо загрузить и отрисовать на экране. Способ, которым поставляются url’ы не интересен. Для каждой будущей картины приложение создает DOM-узел img или div с фоном.

    imgNode = ($ '<div>')             .addClass('item' + num) 

После чего дает задание менеджеру загрузок, передавая ему url картинки c сервера. Менеджер возвращает обещание (JQuery promise), при выполнении которого мы получим url до экземпляра класса blob с данными загруженной картинки, хранящимися в памяти браузера (url поступит в imgBlobUrl). Это новая возможность, появившаяся в HTML5, позволяющая создавать url’ы до экземпляров классов File или Blob, полученных в данном случае, в результате ajax-запроса.

    loadingIntoLocal = @downloadMan.addTask image.url     # тут нам нужно поставить реакцию на done, но imgNode будет перезаписан очередной картинкой, поэтому для его сохранения используем замыкание     ((imgNode) -> loadingIntoLocal.done (imgBlobUrl) -> imgNode.attr(src: imgBlobUrl)     )(imgNode) 

Уровень менеджера загрузок

Менеджер загрузки управляет очередью заданий ( @queue). Каждое задание указывает: какой url надо загрузить, какое обещание исполним, когда получим результат, и, опционально, номер попытки загрузки для не-с-первого-раза-успешной загрузки. Как только поступило задание, ставим его в очередь, создаем обещание и возвращаем это обещание приложению, чтоб ему было не скучно ждать. Запускаем задания.

    addTask : (url) ->     downloading = new $.Deferred()     task = {         url: url,         promise: downloading         numRetries: 0     }     @queue.push task     @runTasks() 

Чтобы наиболее эффективно использовать канал, будем запускать по несколько XMLHttpRequest’ов одновременно. Браузер позволяет это делать. Поэтому метод @runTasks() должен следить за тем, что бы в каждый момент времени в пути находился не один, а N запросов. В моем случае экспериментально было выбрано 3 «рикши». Если есть свободные «рикши», то даем на выполнение следующее задание из очереди.

    runTasks: ->         if (@curTaskNum < @maxRunningTasks) && !@paused             @runNextTask() 

«Рикша» берет очередное задание и с помощью асинхронного загрузчика подтягивает изображение с сервера, получая url блоба.

    runNextTask: ->         task = @queue.shift()         @curTaskNum++         downloading = @asyncLoader.loadImage task.url 

Как только загрузчик выполнит свое обещание, освобождается один из «рикш», и если еще есть задания в очереди, то метод @runNextTask() запускает следующее. При этом рапортуем наверх, что обещание, данное приложению, выполнено.

        downloading.done (imgBlobUrl) =>             task.promise.resolve imgBlobUrl             @curTaskNum--             if @queue.length != 0 && !@paused                 @runNextTask() 

Код менеджера (упрощенная версия)

        class DownloadManager             constructor: ->                 @queue = []                 @maxRunningTasks = 3                 @curTaskNum = 0                 @paused = false                 @asyncLoader = new AsyncLoader()              addTask : (url) ->                 downloading = new $.Deferred()                 task = {                     url: url,                     promise: downloading                     numRetries: 0                 }                 @queue.push task                 @runTasks()                 downloading              runTasks: ->                 if (@curTaskNum < @maxRunningTasks) && !@paused                     @runNextTask()                              runNextTask: ->                 task = @queue.shift()                 @curTaskNum++                 task.numRetries++                 downloading = @asyncLoader.loadImage task.url                                  downloading.done (imgBlobUrl) =>                     task.promise.resolve imgBlobUrl                     @curTaskNum--                     if @queue.length != 0 && !@paused                         @runNextTask()                  downloading.fail =>                     if task.numRetries < 3                         @addTask task.url              pause: ->                 @paused = true              resume: ->                 @paused = false                 @runTasks()  

Однако при такой реализации паузы через флажок, обозначающий можно ли запускать следующее задание, остановка загрузки работает грубо. Если переход на другую страницу произошел в момент, когда на всех парах в три потока шла загрузка, то прерывания текущих заданий не происходит, просто не запускаются следующие.
Реализация паузы, делающей XMLHttpRequest.abort() заданиям, находящимся на выполнении описано в разделе «Поумневшая пауза».

Уровень асинхронного загрузчика

Асинхронный загрузчик — это самый низкий уровень нашей архитектуры, это тот «вокзал», который осуществляет отправление XMLHttpRequest’ов и прием бинарных данных картинки с последуюим размещением на «складе быстрого доступа».

Снаряжаем «рикшу» в новую поездку и устанавливаем обработчики ее состояний. Отмечаем, что ожидаем получить данные, доступные как объект ArrayBuffer, который содержит raw байты. Отправляем «рикшу» в полет до сервера. И тут же обещаем наверх, что сообщим как только он вернется.

class AsyncLoader     loadImage: (url) ->         xhr = new XMLHttpRequest()          xhr.onprogress = (event) =>             ... # опционально используем для отображения прогресса          xhr.onreadystatechange = =>             ... # вернемся к этому ниже          xhr.responseType = 'arraybuffer'          xhr.open 'GET', url, true         xhr.send()         loadingImgBlob = new $.Deferred()         return loadingImgBlob 

Когда ответ вернулся с данными картинки, создаем из них блоб-объект. Теперь чтобы получить url на этот объект достаточно сделать objectUrl из блоба.

        imgBlobUrl = window.URL.createObjectURL blob 

Получившийся адрес на «локальном складе» возвращаем менеджеру. На этом мы дозагрузили картинку.

        xhr.onreadystatechange = =>             if xhr.readyState == 4                 if (xhr.status >= 200 and xhr.status <= 300) or xhr.status == 304                     contentType = xhr.getResponseHeader 'Content-Type'                     contentType = contentType ? 'application/octet-binary'                      blob = new Blob [xhr.response], type: contentType                     imgBlobUrl = window.URL.createObjectURL blob                     loadingImgBlob .resolve imgBlobUrl 

Поумневшая пауза

Для корректного решения второй поставленной задачи (приостановка планируемой загрузки ради более срочных заданий) поменяем средний уровень нашей архитектуры DownloadManager. Менеджер загрузок помимо основной очереди заданий @queue, в которой лежат еще не отданные на выполнение задания, становится владельцем очереди @enRoute, в которой хранятся задания уже находящиеся в процессе выполнения и которые в случае срабатывания паузы необходимо остановить с тем, чтоб в последствии запустить докачку.

    class DownloadManager         constructor: ->             @queue = []             @enRoute = []             @maxRunningTasks = 3             @curTaskNum = 0             @paused = false             @asyncLoader = new AsyncLoader() 

Таким образом, задания могут поступать двух типов: на первичную закачку и докачку (в случае, если картинка уже попадала в очередь, начинала загружаться, а потом была остановлена). Причем Chrome именно докачивает недостающие данные, а не начинает качать заново. Если мы уже обещали загрузить поступающую в очередь картинку и ее ждут, то мы кладем ее в начало очереди. Если мы еще не начинали загружать ее, запросили первый раз, то — в конец очереди. Определить, была ли уже картинка частично скачана, можно по существованию объекта обещания о ее загрузке в addTask.

        addTask : (url, downloading) ->             add = if !downloading then 'push' else 'unshift'             downloading ?= new $.Deferred()  # если не было передано обещание, что загрузим картинку, то обещаем сейчас, иначе будем выполнять старое обещание             task = {                 xhr: null, # теперь нужно знать с помощью какого XMLHttpRequest'а осуществлялась передача. чтобы иметь возможность ее отменить. Поэтому xhr будет передаваться сюда из метода loadImage в asyncLoader'e                 url: url,                 promise: downloading                 numRetries: 0             }             @queue[add] task             @runTasks()             return downloading 

Стартер @runTasks() каждый раз проверяет есть ли невыполненные задания, есть ли кому их выполнять и не стоим ли мы на паузе. Если все так — работаем.

        runTasks: ->              while (@queue.length != 0) && (@curTaskNum < @maxRunningTasks) && !@paused                 @runNextTask() 

При постановке на паузу все запросы, которые находились в пути ( @enRoute) отменяются (task.xhr.abort()) и заново планируются к доставке в следующий раз. Это время наступит как только resume() перезапустит стартер заданий.

        pause: ->             @paused = true             while @enRoute.length != 0                 task = @enRoute.shift()                 task.xhr.abort()                 @addTask task.url, task.promise # заново используем уже данное обещание                 @curTaskNum--          resume: ->             @paused = false             @runTasks()          runNextTask: ->             task = @queue.shift()             @enRoute.push task              @curTaskNum++             task.numRetries++             { downloading, xhr } = @asyncLoader.loadImage task.url # При запуске задания на исполнение не забываем сохранить xhr, который взялся выполнять задание, чтоб знать кого на паузе останавливать.             task.xhr = xhr                          downloading.done (imgBlobUrl) =>                 i = @enRoute.indexOf task                 @enRoute.splice i, 1                  task.promise.resolve imgBlobUrl                 @curTaskNum--                 @runTasks()              downloading.fail =>                 if task.numRetries < 3                     @addTask task.url 

Я постаралась описать полный цикл контролируемой загрузки. Живой пример работы этой архитектуры можно посмотреть на галерее. Демо, доступное для скачивания и экспериментов — на github’е.
Если при экспериментах с демо вы будете использовать другой сервис, предоставляющий картинки, то на нем необходимо будет настроить совместное использование ресурсов между разными источниками (Cross-origin resource sharing (CORS)), чтобы разрешить браузеру отдавать данные скрипту, загруженного с другого домена. В самом простом случае это означает, что веб-сервер должен возвращать в ответе заголовок Access-Control-Allow-Origin: *. Это будет говорить браузеру, что сервер разрешает скриптам с любых других доменов делать XHR-запросы. Подробнее можно прочитать на MDN.

ссылка на оригинал статьи http://habrahabr.ru/post/273415/


Комментарии

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

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