Skip to content

Commit

Permalink
fix(img): move img requests out of web workers
Browse files Browse the repository at this point in the history
Due to iOS Cordova WKWebView limitations, ion-img is not able to use
web workers for XMLHttpRequests.
  • Loading branch information
adamdbradley committed Dec 12, 2016
1 parent 8867677 commit 5376318
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 73 deletions.
203 changes: 143 additions & 60 deletions src/components/img/img-loader.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,166 @@
import { Injectable } from '@angular/core';

import { Config } from '../../config/config';
import { removeArrayItem } from '../../util/util';
export class ImgLoader {
private imgs: ImgData[] = [];

load(src: string, useCache: boolean, callback: ImgLoadCallback) {
// see if we already have image data for this src
let img = this.imgs.find(i => i.src === src);

@Injectable()
export class ImgLoader {
private wkr: Worker;
private callbacks: Function[] = [];
private ids = 0;
private url: string;
if (img && img.datauri && useCache) {
// we found image data, and it's cool if we use the cache
// so let's respond with the cached data
callback(200, null, img.datauri);
return;
}

constructor(config: Config) {
this.url = config.get('imgWorkerUrl', IMG_WORKER_URL);
}
// so no cached image data, so we'll
// need to do a new http request

load(src: string, cache: boolean, callback: Function) {
if (src) {
(<any>callback).id = this.ids++;
this.callbacks.push(callback);
this.worker().postMessage(JSON.stringify({
id: (<any>callback).id,
src: src,
cache: cache
}));
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;
}
}

cancelLoad(callback: Function) {
removeArrayItem(this.callbacks, callback);
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) {
if (src) {
this.worker().postMessage(JSON.stringify({
src: src,
type: 'abort'
}));
const img = this.imgs.find(i => i.src === src);
if (img && img.xhr && img.xhr.readyState !== 4) {
// we found the image data and there's an active
// 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) => {
// we got something back from the web worker
// let's emit this out to everyone listening
const msg: ImgResponseMessage = JSON.parse(ev.data);
const callback = this.callbacks.find(cb => (<any>cb).id === msg.id);
if (callback) {
callback(msg);
removeArrayItem(this.callbacks, callback);
}


function onXhrLoad(callback: ImgLoadCallback, ev: any, useCache: boolean, img: ImgData, imgs: ImgData[]) {
if (!callback) {
return;
}

// the http request has been loaded
// create a rsp object to send back to the main thread
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;
};
}
}
}

// fire the callback with what we've learned today
callback(status, null, datauri);
}


function onXhrError(callback: ImgLoadCallback, imgData: ImgData, err: ErrorEvent) {
// darn, we got an error!
callback && callback(0, (err.message || ''), null);
imgData.xhr = null;
}


// return that hard worker
return this.wkr;
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('');
}

const IMG_WORKER_URL = 'build/ion-img-worker.js';
// used by the setData function
const ENCODINGS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';

const CACHE_LIMIT = 1381855 * 20;

export interface ImgResponseMessage {
id: number;
export interface ImgData {
src: string;
status?: number;
data?: string;
msg?: string;
datauri?: string;
len?: number;
xhr?: XMLHttpRequest;
}

export type ImgLoadCallback = {
(status: number, msg: string, datauri: string): void;
}
21 changes: 10 additions & 11 deletions src/components/img/img.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, ElementRef, Input, NgZone, OnDestro

import { Content } from '../content/content';
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 { Platform } from '../../platform/platform';

Expand Down Expand Up @@ -134,7 +134,7 @@ export class Img implements OnDestroy {
/** @internal */
_cache: boolean = true;
/** @internal */
_cb: Function;
_cb: ImgLoadCallback;
/** @internal */
_bounds: any;
/** @internal */
Expand Down Expand Up @@ -235,9 +235,9 @@ export class Img implements OnDestroy {
console.debug(`request ${this._src} ${Date.now()}`);
this._requestingSrc = this._src;

// create a callback for when we get data back from the web worker
this._cb = (msg: ImgResponseMessage) => {
this._loadResponse(msg);
this._cb = (status, msg, datauri) => {
this._loadResponse(status, msg, datauri);
this._cb = null;
};

// post the message to the web worker
Expand All @@ -263,18 +263,18 @@ export class Img implements OnDestroy {
}
}

private _loadResponse(msg: ImgResponseMessage) {
private _loadResponse(status: number, msg: string, datauri: string) {
this._requestingSrc = null;

if (msg.status === 200) {
if (status === 200) {
// success :)
this._tmpDataUri = msg.data;
this._tmpDataUri = datauri;
this.update();

} else {
// error :(
if (msg.status) {
console.error(`img, status: ${msg.status} ${msg.msg}`);
if (status) {
console.error(`img, status: ${status} ${msg}`);
}
this._renderedSrc = this._tmpDataUri = null;
this._dom.write(() => {
Expand Down Expand Up @@ -413,7 +413,6 @@ export class Img implements OnDestroy {
* @private
*/
ngOnDestroy() {
this._ldr.cancelLoad(this._cb);
this._cb = null;
this._content && this._content.removeImg(this);
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/img/test/img.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ElementRef, Renderer } from '@angular/core';
import { Content } from '../../content/content';
import { Img } from '../img';
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';


Expand Down Expand Up @@ -79,7 +79,7 @@ describe('Img', () => {

beforeEach(() => {
content = mockContent();
ldr = new ImgLoader(mockConfig());
ldr = new ImgLoader();
elementRef = mockElementRef();
renderer = mockRenderer();
platform = mockPlatform();
Expand Down

0 comments on commit 5376318

Please sign in to comment.