fix(img): move img requests out of web workers

Due to iOS Cordova WKWebView limitations, ion-img is not able to use
web workers for XMLHttpRequests.
This commit is contained in:
Adam Bradley
2016-12-12 10:02:56 -06:00
parent 8867677742
commit 5376318f3d
3 changed files with 156 additions and 74 deletions

View File

@ -1,83 +1,166 @@
import { Injectable } from '@angular/core';
import { Config } from '../../config/config';
import { removeArrayItem } from '../../util/util';
@Injectable()
export class ImgLoader { export class ImgLoader {
private wkr: Worker; private imgs: ImgData[] = [];
private callbacks: Function[] = [];
private ids = 0;
private url: string;
constructor(config: Config) { load(src: string, useCache: boolean, callback: ImgLoadCallback) {
this.url = config.get('imgWorkerUrl', IMG_WORKER_URL); // see if we already have image data for this src
} let img = this.imgs.find(i => i.src === src);
load(src: string, cache: boolean, callback: Function) { if (img && img.datauri && useCache) {
if (src) { // we found image data, and it's cool if we use the cache
(<any>callback).id = this.ids++; // so let's respond with the cached data
this.callbacks.push(callback); callback(200, null, img.datauri);
this.worker().postMessage(JSON.stringify({ return;
id: (<any>callback).id,
src: src,
cache: cache
}));
} }
}
cancelLoad(callback: Function) { // so no cached image data, so we'll
removeArrayItem(this.callbacks, callback); // need to do a new http request
if (img && img.xhr && img.xhr.readyState !== 4) {
// looks like there's already an active http request going on
// for this same source, so let's just add another listener
img.xhr.addEventListener('load', (xhrEvent) => {
onXhrLoad(callback, xhrEvent, useCache, img, this.imgs);
});
img.xhr.addEventListener('error', (xhrErrorEvent) => {
onXhrError(callback, img, xhrErrorEvent);
});
return;
}
if (!img) {
// no image data yet, so let's create it
img = { src: src };
this.imgs.push(img);
}
// ok, let's do a full request for the image
img.xhr = new XMLHttpRequest();
img.xhr.open('GET', src, true);
img.xhr.responseType = 'arraybuffer';
// add the listeners if it loaded or errored
img.xhr.addEventListener('load', (xhrEvent) => {
onXhrLoad(callback, xhrEvent, useCache, img, this.imgs);
});
img.xhr.addEventListener('error', (xhrErrorEvent) => {
onXhrError(callback, img, xhrErrorEvent);
});
// awesome, let's kick off the request
img.xhr.send();
} }
abort(src: string) { abort(src: string) {
if (src) { const img = this.imgs.find(i => i.src === src);
this.worker().postMessage(JSON.stringify({ if (img && img.xhr && img.xhr.readyState !== 4) {
src: src, // we found the image data and there's an active
type: 'abort' // http request, so let's abort the request
})); img.xhr.abort();
img.xhr = null;
} }
} }
private worker() { }
if (!this.wkr) {
// create the worker
this.wkr = new Worker(this.url);
// create worker onmessage handler
this.wkr.onmessage = (ev: MessageEvent) => { function onXhrLoad(callback: ImgLoadCallback, ev: any, useCache: boolean, img: ImgData, imgs: ImgData[]) {
// we got something back from the web worker if (!callback) {
// let's emit this out to everyone listening return;
const msg: ImgResponseMessage = JSON.parse(ev.data); }
const callback = this.callbacks.find(cb => (<any>cb).id === msg.id);
if (callback) { // the http request has been loaded
callback(msg); // create a rsp object to send back to the main thread
removeArrayItem(this.callbacks, callback); const status: number = ev.target.status;
let datauri: string = null;
if (status === 200) {
// success!!
// now let's convert the response arraybuffer data into a datauri
datauri = getDataUri(ev.target.getResponseHeader('Content-Type'), ev.target.response);
if (useCache) {
// if the image was successfully downloaded
// and this image is allowed to be cached
// then let's add it to our image data for later use
img.datauri = datauri;
img.len = datauri.length;
// let's loop through all our cached data and if we go
// over our limit then let's clean it out a bit
// oldest data should go first
var cacheSize = 0;
for (var i = imgs.length - 1; i >= 0; i--) {
cacheSize += imgs[i].len;
if (cacheSize > CACHE_LIMIT) {
console.debug(`img-loader, clear: ${imgs[i].src}, len: ${imgs[i].len}`);
imgs.splice(i, 1);
} }
}; }
// create worker onerror handler
this.wkr.onerror = (ev: ErrorEvent) => {
console.error(`ImgLoader, worker ${ev.type} ${ev.message ? ev.message : ''}`);
this.callbacks.length = 0;
this.wkr.terminate();
this.wkr = null;
};
} }
// return that hard worker
return this.wkr;
} }
// fire the callback with what we've learned today
callback(status, null, datauri);
} }
const IMG_WORKER_URL = 'build/ion-img-worker.js';
export interface ImgResponseMessage { function onXhrError(callback: ImgLoadCallback, imgData: ImgData, err: ErrorEvent) {
id: number; // darn, we got an error!
callback && callback(0, (err.message || ''), null);
imgData.xhr = null;
}
function getDataUri(contentType, arrayBuffer): string {
// take arraybuffer and content type and turn it into
// a datauri string that can be used by <img>
const rtn: string[] = ['data:' + contentType + ';base64,'];
const bytes = new Uint8Array(arrayBuffer);
const byteLength = bytes.byteLength;
const byteRemainder = byteLength % 3;
const mainLength = byteLength - byteRemainder;
let i, a, b, c, d, chunk;
for (i = 0; i < mainLength; i = i + 3) {
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
a = (chunk & 16515072) >> 18;
b = (chunk & 258048) >> 12;
c = (chunk & 4032) >> 6;
d = chunk & 63;
rtn.push(ENCODINGS[a] + ENCODINGS[b] + ENCODINGS[c] + ENCODINGS[d]);
}
if (byteRemainder === 1) {
chunk = bytes[mainLength];
a = (chunk & 252) >> 2;
b = (chunk & 3) << 4;
rtn.push(ENCODINGS[a] + ENCODINGS[b] + '==');
} else if (byteRemainder === 2) {
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
a = (chunk & 64512) >> 10;
b = (chunk & 1008) >> 4;
c = (chunk & 15) << 2;
rtn.push(ENCODINGS[a] + ENCODINGS[b] + ENCODINGS[c] + '=');
}
return rtn.join('');
}
// used by the setData function
const ENCODINGS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const CACHE_LIMIT = 1381855 * 20;
export interface ImgData {
src: string; src: string;
status?: number; datauri?: string;
data?: string; len?: number;
msg?: string; xhr?: XMLHttpRequest;
}
export type ImgLoadCallback = {
(status: number, msg: string, datauri: string): void;
} }

View File

@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, ElementRef, Input, NgZone, OnDestro
import { Content } from '../content/content'; import { Content } from '../content/content';
import { DomController } from '../../util/dom-controller'; import { DomController } from '../../util/dom-controller';
import { ImgLoader, ImgResponseMessage } from './img-loader'; import { ImgLoader, ImgLoadCallback } from './img-loader';
import { isPresent, isTrueProperty } from '../../util/util'; import { isPresent, isTrueProperty } from '../../util/util';
import { Platform } from '../../platform/platform'; import { Platform } from '../../platform/platform';
@ -134,7 +134,7 @@ export class Img implements OnDestroy {
/** @internal */ /** @internal */
_cache: boolean = true; _cache: boolean = true;
/** @internal */ /** @internal */
_cb: Function; _cb: ImgLoadCallback;
/** @internal */ /** @internal */
_bounds: any; _bounds: any;
/** @internal */ /** @internal */
@ -235,9 +235,9 @@ export class Img implements OnDestroy {
console.debug(`request ${this._src} ${Date.now()}`); console.debug(`request ${this._src} ${Date.now()}`);
this._requestingSrc = this._src; this._requestingSrc = this._src;
// create a callback for when we get data back from the web worker this._cb = (status, msg, datauri) => {
this._cb = (msg: ImgResponseMessage) => { this._loadResponse(status, msg, datauri);
this._loadResponse(msg); this._cb = null;
}; };
// post the message to the web worker // post the message to the web worker
@ -263,18 +263,18 @@ export class Img implements OnDestroy {
} }
} }
private _loadResponse(msg: ImgResponseMessage) { private _loadResponse(status: number, msg: string, datauri: string) {
this._requestingSrc = null; this._requestingSrc = null;
if (msg.status === 200) { if (status === 200) {
// success :) // success :)
this._tmpDataUri = msg.data; this._tmpDataUri = datauri;
this.update(); this.update();
} else { } else {
// error :( // error :(
if (msg.status) { if (status) {
console.error(`img, status: ${msg.status} ${msg.msg}`); console.error(`img, status: ${status} ${msg}`);
} }
this._renderedSrc = this._tmpDataUri = null; this._renderedSrc = this._tmpDataUri = null;
this._dom.write(() => { this._dom.write(() => {
@ -413,7 +413,6 @@ export class Img implements OnDestroy {
* @private * @private
*/ */
ngOnDestroy() { ngOnDestroy() {
this._ldr.cancelLoad(this._cb);
this._cb = null; this._cb = null;
this._content && this._content.removeImg(this); this._content && this._content.removeImg(this);
} }

View File

@ -2,7 +2,7 @@ import { ElementRef, Renderer } from '@angular/core';
import { Content } from '../../content/content'; import { Content } from '../../content/content';
import { Img } from '../img'; import { Img } from '../img';
import { ImgLoader } from '../img-loader'; import { ImgLoader } from '../img-loader';
import { mockConfig, mockContent, MockDomController, mockElementRef, mockPlatform, mockRenderer, mockZone } from '../../../util/mock-providers'; import { mockContent, MockDomController, mockElementRef, mockPlatform, mockRenderer, mockZone } from '../../../util/mock-providers';
import { Platform } from '../../../platform/platform'; import { Platform } from '../../../platform/platform';
@ -79,7 +79,7 @@ describe('Img', () => {
beforeEach(() => { beforeEach(() => {
content = mockContent(); content = mockContent();
ldr = new ImgLoader(mockConfig()); ldr = new ImgLoader();
elementRef = mockElementRef(); elementRef = mockElementRef();
renderer = mockRenderer(); renderer = mockRenderer();
platform = mockPlatform(); platform = mockPlatform();